diff --git a/CHANGELOG.md b/CHANGELOG.md index 40849d9d..dc293bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-09-02 + +### Changes + +- Prima: The three envelope builders (`pw`/`do`/`go`, failures, and `check`/`ask`/`verify`/`research`) + now share one tail builder instead of each repeating the instance/status/artifacts block. Artifact + paths flow back from the write as a return value rather than through a mutable field that had to be + read in the right order, so an envelope can no longer come back missing its Artifacts section. + Per-step file names (`aria.yaml`/`html`/`diff.yaml`) written during a `do` run now have a single + owner shared with the doc line the envelope advertises, so the two can no longer drift apart. + ## 2026-09-01 ### Changes diff --git a/boat/prima/src/cli.ts b/boat/prima/src/cli.ts index bac3fbb6..8243aa0d 100644 --- a/boat/prima/src/cli.ts +++ b/boat/prima/src/cli.ts @@ -129,7 +129,8 @@ function primaFor(options: any): Prima { return new Prima(buildOptions(options)); } -async function runPrima(options: any, command: string, run: (prima: Prima) => Promise): Promise { +async function runPrima(options: any, command: string, run: (prima: Prima) => Promise, opts: { browser?: boolean; record?: boolean } = {}): Promise { + const { browser = true, record = true } = opts; setQuietMode(!isVerboseMode()); trackActivityLine(); const prima = primaFor(options); @@ -137,13 +138,13 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr let envelope: EnvelopeData; try { - await prima.start(); + if (browser) await prima.start(); envelope = await run(prima); } catch (error) { envelope = await prima.toolFailureEnvelope(command, error); } - prima.record(envelope, Date.now() - startedAt); + if (record) prima.record(envelope, Date.now() - startedAt); clearActivityLine(); console.log(renderEnvelope(envelope)); await prima.stop().catch(() => {}); @@ -221,11 +222,7 @@ export function createPrimaCommands(name = 'prima'): Command { addCommonOptions(cmd.command('status ').description('Show the artifacts and page detail recorded for an earlier command')) .addHelpText('after', `\n${statusHelp}`) .action(async (hash, options) => { - setQuietMode(!isVerboseMode()); - const prima = primaFor(options); - const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error)); - console.log(renderEnvelope(envelope)); - process.exit(envelope.ok ? 0 : 1); + await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false }); }); addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report')) diff --git a/boat/prima/src/envelope.ts b/boat/prima/src/envelope.ts index c1e6d477..9250f310 100644 --- a/boat/prima/src/envelope.ts +++ b/boat/prima/src/envelope.ts @@ -5,6 +5,8 @@ export const STATUS_FILE = 'status.json'; const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' }; +export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' }; + const EXPECTATION_LABELS = { passed: 'PASSED ', failed: 'FAILED ', @@ -117,7 +119,7 @@ function renderSteps(data: EnvelopeData): string | null { lines.push(`${index + 1}. ${mark} ${step.label}`); for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`); }); - if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/-.{aria.yaml,html,diff.yaml}`); + if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/-.{${Object.values(STEP_FILES).join(',')}}`); return section('Steps', lines.join('\n')); } diff --git a/boat/prima/src/prima.ts b/boat/prima/src/prima.ts index c5cfdbd4..0bb95a71 100644 --- a/boat/prima/src/prima.ts +++ b/boat/prima/src/prima.ts @@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts'; import { pluralize } from '../../../src/utils/logger.ts'; import { mdq } from '../../../src/utils/markdown-query.ts'; import { safeFilename } from '../../../src/utils/strings.ts'; -import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts'; +import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from './envelope.ts'; import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts'; import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts'; import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts'; @@ -74,7 +74,6 @@ export class Prima { private server: { close: () => Promise } | null = null; private attached: string | null = null; private session: SessionRun | null = null; - private artifacts?: EnvelopeData['artifacts']; constructor(options: PrimaOptions = {}) { this.options = options; @@ -982,16 +981,13 @@ export class Prima { private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise { const changes = await this.pageChanges(result, previousState, used[0]); - const status = await this.saveStatus(result); return { ok: true, command, used, page: this.pageBlock(result, previousState), changes, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } @@ -1000,31 +996,30 @@ export class Prima { const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) }; if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true); - const status = await this.saveStatus(result); return { ok: false, command, page: this.pageBlock(result, previousState), failure, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial): Promise { - const status = await this.saveStatus(result); return { ok: true, command, page: this.pageBlock(result, previousState), ...outcome, - instance: await this.instanceInfo(), - status, - artifacts: this.artifacts, + ...(await this.envelopeTail(result)), }; } + private async envelopeTail(result: ActionResult): Promise> { + const { hash, artifacts } = await this.saveStatus(result); + return { instance: await this.instanceInfo(), status: hash, artifacts }; + } + private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise { const captured = await this.bot .getExplorer() @@ -1087,11 +1082,11 @@ export class Prima { }; } - private async saveStatus(result: ActionResult): Promise { + private async saveStatus(result: ActionResult): Promise<{ hash: string; artifacts: ArtifactPaths }> { const hash = this.statusHash(); - await this.writeSnapshot(result); + const artifacts = await this.writeSnapshot(result); writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8'); - return hash; + return { hash, artifacts }; } private async writeStepFiles(index: number, label: string, diff: string): Promise { @@ -1103,17 +1098,17 @@ export class Prima { const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`); const result = ActionResult.fromState(state); - writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8'); - writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8'); - if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8'); + writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8'); + writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8'); + if (diff) writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8'); } - private async writeSnapshot(result: ActionResult): Promise { - this.artifacts = writeArtifacts(this.statusDir(), { + private async writeSnapshot(result: ActionResult): Promise { + return writeArtifacts(this.statusDir(), { aria: result.ariaSnapshot, html: await result.combinedHtml(), screenshot: result.screenshot, - requests: this.bot.requestStore().getRequests(), + requests: this.bot.requestStore().getMadeRequests(), }); } diff --git a/boat/prima/tests/envelope.test.ts b/boat/prima/tests/envelope.test.ts index c2ae6532..2d400f5a 100644 --- a/boat/prima/tests/envelope.test.ts +++ b/boat/prima/tests/envelope.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { type EnvelopeData, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; +import { type EnvelopeData, STEP_FILES, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts'; const base: EnvelopeData = { ok: true, @@ -180,6 +180,12 @@ describe('renderEnvelope', () => { expect(out).toContain('page after each step: /tmp/x/-.{aria.yaml,html,diff.yaml}'); }); + test('the advertised step-file line names every STEP_FILES extension', () => { + const out = renderEnvelope({ ...base, steps: [{ label: "I.click('Add')", ok: true, proof: '' }], stepFiles: '/tmp/x' }); + const line = out.split('\n').find((entry) => entry.includes('page after each step'))!; + for (const extension of Object.values(STEP_FILES)) expect(line).toContain(extension); + }); + test('open tabs alone are evidence enough for a running browser', () => { const out = renderEnvelope({ ...base, instance: { name: 'default', tabs: 2, others: [] } }); expect(out).toContain('| running'); diff --git a/boat/prima/tests/prima.test.ts b/boat/prima/tests/prima.test.ts index b2087d89..f16f3edd 100644 --- a/boat/prima/tests/prima.test.ts +++ b/boat/prima/tests/prima.test.ts @@ -95,7 +95,7 @@ function fakePrima(options: Record = {}) { }), getCurrentState: () => fakeState(), getConfig: () => ({}), - requestStore: () => ({ getRequests: () => [] }), + requestStore: () => ({ getMadeRequests: () => [] }), getProvider: () => ({ chat: async () => '' }), }; (prima as any).artifactsDir = artifactsRoot;