// shared test-run registration for both runners (Maestro / Detox). posts a // `sootRun` row to /api/sootsim/test-runs so the org // dashboard and PR comments get a pass/fail list with git provenance and a // link into the hosted replay (the uploaded preview share). // // registration policy: a run registers when it produced a hosted share // (--preview) or when it runs in CI (github-token / actions env) — local // iteration without --preview stays local. registration failures warn and // never change the run's exit code; the test result is the playback result. import { execSync } from 'child_process' import { authHeaderValue, resolveCliAuth, type CliAuth } from './auth' import type { SootSimFlowTraceStep } from './bridge-flow-runner' export type RunKind = 'maestro' | 'detox' export type RunProvenance = { owner: string | null repo: string | null branch: string | null commitSha: string | null githubUsername: string | null pullRequestNumber: number | null pullRequestTitle: string | null githubRunId: string | null source: 'cli' | 'ci' } function gitOutput(command: string): string | null { try { const out = execSync(command, { stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, encoding: 'utf8', }).trim() return out || null } catch { return null } } function parsePositiveInt(value: string | undefined): number | null { const trimmed = value?.trim() if (!trimmed || !/^\d+$/.test(trimmed)) return null return Number(trimmed) } // owner/repo from the checkout's github remote, for local runs with no // CONTRAST_REPO/GITHUB_REPOSITORY env. runs are repo-scoped on the dashboard // (/org///runs), so a repo-less registration has no home — the // server 400s it. handles ssh (git@github.com:o/r.git) and https // (https://github.com/o/r(.git)) remote shapes. function ownerRepoFromGitRemote(): { owner: string; repo: string } | null { const url = gitOutput('git remote get-url origin') if (!url) return null const match = url.match(/github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i) if (!match) return null return { owner: match[1], repo: match[2] } } // git provenance for the current run. in GitHub Actions the CONTRAST_*/GITHUB_* // env is authoritative (the checkout can be a detached merge ref); locally // we ask git itself. export function resolveRunProvenance(): RunProvenance { const isActions = process.env.GITHUB_ACTIONS === 'true' const contrastBranch = process.env.CONTRAST_BRANCH?.trim() || null const contrastSha = process.env.CONTRAST_SHA?.trim() || null const pullRequestTitle = process.env.CONTRAST_PR_TITLE?.trim() || null const repoSlug = ( process.env.CONTRAST_REPO || process.env.GITHUB_REPOSITORY || '' ).trim() const [envOwner, envRepo] = repoSlug.includes('/') ? repoSlug.split('/', 2) : [null, null] if (isActions) { // pull_request events: GITHUB_HEAD_REF is the PR head branch and // GITHUB_REF looks like refs/pull//merge. push events: GITHUB_REF_NAME. const prFromRef = process.env.GITHUB_REF?.match(/^refs\/pull\/(\d+)\//)?.[1] return { owner: envOwner, repo: envRepo, branch: contrastBranch || process.env.GITHUB_HEAD_REF?.trim() || process.env.GITHUB_REF_NAME?.trim() || null, commitSha: contrastSha || process.env.GITHUB_SHA?.trim() || null, githubUsername: process.env.GITHUB_ACTOR?.trim() || null, pullRequestNumber: parsePositiveInt(process.env.CONTRAST_PR_NUMBER) ?? parsePositiveInt(prFromRef), pullRequestTitle, githubRunId: process.env.GITHUB_RUN_ID?.trim() || null, source: 'ci', } } const branch = contrastBranch ?? gitOutput('git rev-parse --abbrev-ref HEAD') // env slug wins when present; otherwise infer from the github remote so a // a plain local `rnx maestro test --preview` still lands on its repo's dashboard. const remote = envOwner ? null : ownerRepoFromGitRemote() return { owner: envOwner ?? remote?.owner ?? null, repo: envRepo ?? remote?.repo ?? null, branch: branch === 'HEAD' ? null : branch, commitSha: contrastSha ?? gitOutput('git rev-parse HEAD'), githubUsername: null, pullRequestNumber: parsePositiveInt(process.env.CONTRAST_PR_NUMBER), pullRequestTitle, githubRunId: null, source: 'cli', } } export function stepSummaryFromTrace(steps: SootSimFlowTraceStep[]): { stepCount: number | null failedStepIndex: number | null } { if (steps.length === 0) return { stepCount: null, failedStepIndex: null } const failed = steps.find((step) => step.status === 'failure') return { stepCount: steps.length, failedStepIndex: failed ? failed.stepIndex : null, } } // whether this run should register at all — see the policy note at the top. export function shouldRegisterRun(args: { uploadedShare: boolean auth: CliAuth | null }): boolean { if (!args.auth) return false if (args.uploadedShare) return true return args.auth.kind === 'github' || process.env.GITHUB_ACTIONS === 'true' } export type RegisterRunArgs = { origin: string kind: RunKind status: 'passed' | 'failed' name?: string | null prompt?: string | null summary?: string | null failureMessage?: string | null previewShareId?: string | null // explicit --owner/--repo flags override env/git detection owner?: string | null repo?: string | null durationMs?: number | null flowYamlSizeBytes?: number | null stepCount?: number | null failedStepIndex?: number | null auth?: CliAuth | null } export type RegisteredRun = { id: string previewUrl: string | null traceUrl: string | null } export async function registerRun(args: RegisterRunArgs): Promise { const auth = args.auth ?? resolveCliAuth() if (!auth) return null const provenance = resolveRunProvenance() const owner = args.owner ?? (auth.kind === 'github' ? auth.owner : null) ?? provenance.owner const repo = args.repo ?? (auth.kind === 'github' ? auth.repo : null) ?? provenance.repo try { const res = await fetch(`${args.origin.replace(/\/$/, '')}/api/sootsim/test-runs`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: authHeaderValue(auth), }, body: JSON.stringify({ kind: args.kind, source: provenance.source, name: args.name ?? undefined, prompt: args.prompt ?? undefined, summary: args.summary ?? undefined, status: args.status, failureMessage: args.failureMessage ?? undefined, previewShareId: args.previewShareId ?? undefined, owner: owner ?? undefined, repo: repo ?? undefined, branch: provenance.branch ?? undefined, commitSha: provenance.commitSha ?? undefined, githubUsername: provenance.githubUsername ?? undefined, pullRequestNumber: provenance.pullRequestNumber ?? undefined, githubRunId: provenance.githubRunId ?? undefined, stepCount: args.stepCount ?? undefined, failedStepIndex: args.failedStepIndex ?? undefined, durationMs: args.durationMs ?? undefined, flowYamlSizeBytes: args.flowYamlSizeBytes ?? undefined, }), }) if (!res.ok) { const text = await res.text().catch(() => '') console.warn( ` warn: run registration failed: /api/sootsim/test-runs ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}`, ) return null } const body = (await res.json()) as { run?: { id?: string; traceUrl?: string | null; previewUrl?: string | null } } if (!body.run?.id) { console.warn(' warn: run registration returned no id') return null } return { id: body.run.id, previewUrl: body.run.previewUrl ?? null, traceUrl: body.run.traceUrl ?? null, } } catch (err) { console.warn( ` warn: run registration failed: ${err instanceof Error ? err.message : String(err)}`, ) return null } }