Skip to content

Commit 639677d

Browse files
committed
wip(scenarios): gallery exporter module and CLI
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ihL2ebbZkr2QAEG1x7UC8
1 parent 6ff27b3 commit 639677d

14 files changed

Lines changed: 1325 additions & 37 deletions

File tree

‎packages/qa/scenarios/package.json‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,21 @@
88
"exports": {
99
".": "./src/index.ts",
1010
"./score": "./src/score/index.ts",
11-
"./runner": "./src/runner/index.ts"
11+
"./runner": "./src/runner/index.ts",
12+
"./export": "./src/export/index.ts"
1213
},
1314
"scripts": {
1415
"test": "vitest run",
1516
"typecheck": "tsc --noEmit",
16-
"run-scenarios": "tsx src/runner/cli.ts"
17+
"run-scenarios": "tsx src/runner/cli.ts",
18+
"export-gallery": "tsx src/export/cli.ts"
1719
},
1820
"dependencies": {
1921
"@objectstack/core": "workspace:*",
2022
"@objectstack/objectql": "workspace:*",
2123
"@objectstack/plugin-sharing": "workspace:*",
2224
"@objectstack/spec": "workspace:*",
25+
"playwright-core": "1.63.0",
2326
"zod": "^4.6.6"
2427
},
2528
"devDependencies": {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `pnpm --filter @objectstack/scenarios export-gallery --runs DIR --out DIR [flags]`
5+
* — write every verified scenario app the runner's records prove into a
6+
* local directory, in the layout of objectstack-ai/gallery (`exporter.ts`).
7+
*
8+
* --runs DIR the run records (`run-scenarios --out`; default docs/qa/platform-checklist/runs)
9+
* --out DIR where `apps/` and `index.json` go (required)
10+
* --recording F a run's recording (`run-scenarios --record F`, or the
11+
* file a `--driver recorded:F` run replayed); repeatable
12+
* --cards DIR the card directory (default: the repo's docs/qa/scenarios)
13+
* --work DIR read the projects here, not at each record's `evidenceDir`
14+
* --no-screenshots export without screenshots (no app is booted)
15+
* --chromium PATH the Chromium executable for the screenshots
16+
*
17+
* It writes only under `--out`; it pushes nothing anywhere. ⛔ It exports only
18+
* cards marked generic, and the card schema has no generic marker yet, so
19+
* today it refuses every card (`generic.ts`) — and offers no flag to do
20+
* otherwise.
21+
*/
22+
23+
import { existsSync, readFileSync } from 'node:fs';
24+
import { dirname, join, resolve } from 'node:path';
25+
import { fileURLToPath } from 'node:url';
26+
27+
import { formatRefusal, loadScenarios, readCapabilityAreas } from '../loader.js';
28+
import { HttpApp } from '../runner/app.js';
29+
import { ADMIN, findUp } from '../runner/cli.js';
30+
import type { Recording } from '../runner/driver.js';
31+
import { bootDev } from '../runner/project.js';
32+
import { exportGallery, type ExportSummary } from './exporter.js';
33+
import { captureScreenshots, playwrightBrowser, type CaptureScreenshots } from './screenshots.js';
34+
35+
export interface ExportCliArgs {
36+
runs?: string;
37+
out?: string;
38+
recordings: string[];
39+
cards?: string;
40+
work?: string;
41+
screenshots: boolean;
42+
chromium?: string;
43+
}
44+
45+
export function parseExportArgs(argv: readonly string[]): ExportCliArgs {
46+
const a: ExportCliArgs = { recordings: [], screenshots: true };
47+
for (let i = 0; i < argv.length; i++) {
48+
const flag = argv[i]!;
49+
const val = () => {
50+
const v = argv[++i];
51+
if (v === undefined) throw new Error(`${flag} needs a value`);
52+
return v;
53+
};
54+
if (flag === '--runs') a.runs = val();
55+
else if (flag === '--out') a.out = val();
56+
else if (flag === '--recording') a.recordings.push(val());
57+
else if (flag === '--cards') a.cards = val();
58+
else if (flag === '--work') a.work = val();
59+
else if (flag === '--no-screenshots') a.screenshots = false;
60+
else if (flag === '--chromium') a.chromium = val();
61+
else throw new Error(`unknown flag \`${flag}\``);
62+
}
63+
if (!a.out) throw new Error('--out DIR is required: the directory `apps/` and `index.json` are written into');
64+
return a;
65+
}
66+
67+
/** The summary, one line per app, as the CLI prints it. */
68+
export function renderSummary(s: ExportSummary): string[] {
69+
const lines: string[] = [];
70+
for (const e of s.exported) lines.push(`exported ${e.id} · ${e.screenshots.length} screenshot(s)${e.skippedShots.map((k) => ` · no ${k.kind}: ${k.reason}`).join('')}`);
71+
for (const r of s.refused) lines.push(`refused ${r.id} · ${r.reasons.join(' · ')}`);
72+
for (const r of s.removed) lines.push(`removed ${r.id} · ${r.why}`);
73+
for (const u of s.unowned) lines.push(`left apps/${u} · not in the previous index, so not the exporter's`);
74+
for (const f of s.ignoredFiles) lines.push(`ignored ${f} · not a scenario-runner record`);
75+
lines.push(`index.json ${s.indexChanged ? 'written' : 'unchanged'} · ${s.exported.length} exported, ${s.refused.length} refused, ${s.removed.length} removed`);
76+
return lines;
77+
}
78+
79+
/** Test seam: replace the screenshot step (the CLI's is the real browser). */
80+
export interface ExportCliDeps {
81+
screenshots?: CaptureScreenshots;
82+
}
83+
84+
export async function main(argv: readonly string[], deps: ExportCliDeps = {}): Promise<number> {
85+
const args = parseExportArgs(argv);
86+
const here = dirname(fileURLToPath(import.meta.url));
87+
const repo = findUp(here, (d) => existsSync(join(d, 'pnpm-workspace.yaml')));
88+
const cardsDir = resolve(args.cards ?? join(repo, 'docs/qa/scenarios'));
89+
const runsDir = resolve(args.runs ?? join(repo, 'docs/qa/platform-checklist/runs'));
90+
const { cards, refusals } = loadScenarios(cardsDir, { areas: readCapabilityAreas(join(repo, 'docs/qa/platform-checklist/areas')) });
91+
for (const r of refusals) console.error(formatRefusal(r));
92+
const recordings = args.recordings.map((f) => JSON.parse(readFileSync(resolve(f), 'utf8')) as Recording);
93+
const browser = playwrightBrowser(args.chromium ? { executablePath: args.chromium } : {});
94+
const screenshots: CaptureScreenshots | false = !args.screenshots
95+
? false
96+
: (deps.screenshots ??
97+
((input) => captureScreenshots(input, { boot: (dir) => bootDev(dir), harness: (url) => new HttpApp({ baseUrl: url, adminEmail: ADMIN.email, adminPassword: ADMIN.password }), browser, admin: ADMIN })));
98+
const summary = await exportGallery({
99+
runsDir,
100+
outDir: resolve(args.out!),
101+
cards,
102+
recordings,
103+
...(args.work ? { workDir: resolve(args.work) } : {}),
104+
screenshots,
105+
});
106+
for (const line of renderSummary(summary)) (line.startsWith('refused') || line.startsWith('removed') ? console.error : console.log)(line);
107+
return 0;
108+
}
109+
110+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
111+
main(process.argv.slice(2)).then(
112+
(code) => process.exit(code),
113+
(e) => {
114+
console.error(e instanceof Error ? e.message : e);
115+
process.exit(1);
116+
},
117+
);
118+
}

0 commit comments

Comments
 (0)