From de7c89c59fdbf78cd6421ea0e33309997baa5a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 00:10:12 +0000 Subject: [PATCH] feat(scenarios): gallery-refresh, the gallery refresh in one deterministic step Version check against npm's latest stable @objectstack/cli, run on that release, export into the gallery checkout, commit on export/; never pushes. Driver header names ruling D2; README runbook for the scheduled task. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FEkQ5pZeHgu9hYkeNdBNEA --- .changeset/31121-scenarios-gallery-refresh.md | 13 + packages/qa/scenarios/README.md | 72 ++++ packages/qa/scenarios/package.json | 3 +- packages/qa/scenarios/src/export/cli.ts | 12 +- packages/qa/scenarios/src/export/index.ts | 5 +- .../qa/scenarios/src/export/refresh-cli.ts | 131 +++++++ packages/qa/scenarios/src/export/refresh.ts | 328 +++++++++++++++++ packages/qa/scenarios/src/runner/driver.ts | 11 +- .../qa/scenarios/test/export/refresh.test.ts | 346 ++++++++++++++++++ 9 files changed, 911 insertions(+), 10 deletions(-) create mode 100644 .changeset/31121-scenarios-gallery-refresh.md create mode 100644 packages/qa/scenarios/src/export/refresh-cli.ts create mode 100644 packages/qa/scenarios/src/export/refresh.ts create mode 100644 packages/qa/scenarios/test/export/refresh.test.ts diff --git a/.changeset/31121-scenarios-gallery-refresh.md b/.changeset/31121-scenarios-gallery-refresh.md new file mode 100644 index 00000000000..0c4bf86fa0b --- /dev/null +++ b/.changeset/31121-scenarios-gallery-refresh.md @@ -0,0 +1,13 @@ +--- +'@objectstack/scenarios': patch +--- + +feat(scenarios): `gallery-refresh`, the gallery refresh in one deterministic step + +Clause-②: yes — a new `gallery-refresh` entry point on the published `@objectstack/scenarios` widens its surface; the PR body carries the reading +Metadata-protocol: none + +- **`pnpm --filter @objectstack/scenarios gallery-refresh --gallery DIR [--cards DIR] [--model M] [--debut] [--work DIR] [--chromium PATH]`** (`src/export/refresh-cli.ts`): reads npm's latest stable `@objectstack/cli` and the `framework.version` of every app the gallery checkout's `index.json` lists. When every listed app states that release it prints one `skip: …` line and exits 0, having run nothing; it also skips while a remote-tracking `export/` branch awaits review. Otherwise it runs every card the gallery may show (origin `generic`, tier `starter` or `delivery`; `--debut`: the first of them, only into an empty gallery) with `run-scenarios --modes scratch --packages published --create-version `, exports with screenshots into the checkout, commits `apps/` and `index.json` on a new local branch `export/` and prints the branch, the commit and a ready PR body. It never pushes and never opens a PR. Every refusal (not a clean checkout, an index that does not validate, a prerelease `latest`, an existing local branch, no app exported, an app on another framework version, a change outside `apps/` and `index.json`) exits 1 with the reason. +- **`@objectstack/scenarios/export`** also exports the step as a library: `galleryRefresh`, `runnerArgv`, `readGalleryVersions`, `npmLatestVersion`, `refreshPrBody`, `galleryBranch`, `RefreshRefusal`, `FRAMEWORK_PACKAGE`, `STABLE_VERSION` and their types. +- The live driver's header names ruling D2: a local session or a scheduled cloud Claude Code task on the maintainer's subscription login may run the cards; an API-billed model still never may. The driver's refusal of an API key is unchanged. +- The README gains "Refreshing the gallery — `gallery-refresh`" and the scheduled task's runbook: what it runs, what it needs, what it never does, and the task's prompt verbatim. diff --git a/packages/qa/scenarios/README.md b/packages/qa/scenarios/README.md index 22ffb808d6f..5cb9d520113 100644 --- a/packages/qa/scenarios/README.md +++ b/packages/qa/scenarios/README.md @@ -266,6 +266,78 @@ npx @objectstack/scenarios help # the comma dashboards and nav targets. `test/runner/prompt-trace.test.ts` reads the checks off the scorer itself and holds every card to it. +## Refreshing the gallery — `gallery-refresh` + +```bash +G="$(realpath ../gallery)" # pnpm runs the script in the package directory: pass absolute paths +pnpm --filter @objectstack/scenarios gallery-refresh --gallery "$G" # every exportable card +pnpm --filter @objectstack/scenarios gallery-refresh --gallery "$G" --debut # one card: the gallery's first app +pnpm --filter @objectstack/scenarios gallery-refresh --gallery "$G" --model sonnet # the model every turn asks for +``` + +One step from "is there a new stable release?" to a local commit in a checkout of objectstack-ai/gallery +(`src/export/refresh.ts`, CLI `src/export/refresh-cli.ts`). It reads npm's `latest` `@objectstack/cli` and the +`framework.version` of every app the gallery's `index.json` lists. When the index lists an app and every app states +that version, it prints one `skip: …` line and exits 0, having run nothing. It also skips when a remote-tracking +`export/` branch exists: that refresh was pushed and awaits review. Otherwise it: + +1. runs every card the gallery may show (origin `generic`, tier `starter` or `delivery`; `--debut`: the first of them + by id) with `run-scenarios --modes scratch --packages published --create-version `, so the apps are built + on exactly that release, the turns recorded; +2. exports with screenshots into the checkout (`export-gallery`'s rules: only a run that passed every half); +3. creates the branch `export/`, commits `apps/` and `index.json` under the checkout's own git identity, and + prints the branch, the commit and a ready PR body. + +It refuses, exit 1 with the reason, when: `--gallery` is not the top of a git checkout or has uncommitted changes; +the index does not validate; npm's `latest` is not a plain `MAJOR.MINOR.PATCH`; a local `export/` branch +already exists; `--debut` meets an index that lists an app; no card may be exported; no app exported; an exported app +states another framework version; or anything outside `apps/` and `index.json` changed. The last three leave the +working tree as the export left it, uncommitted. `--work DIR` keeps the records, projects and recording (default: a +temp directory, named on a refusal). ⛔ It never pushes, never opens a PR and never talks to GitHub. + +### The scheduled task's runbook + +Ruling D2 (2026-11-06): a daily scheduled Claude Code task in this project's cloud environment runs the refresh on the +maintainer's subscription login, and the maintainer merges its PR on the gallery. + +- **What it runs**: `gallery-refresh` from a fresh checkout of this repository, into a fresh clone of + objectstack-ai/gallery; then `git push` of the one branch it created, and one PR. +- **What it needs**: both repositories in the environment's resources, with push access to the gallery; npm reachable; + the container's `claude` logged in with the subscription (`claude auth status` reports `oauth_token`) and neither + `ANTHROPIC_API_KEY` nor `ANTHROPIC_AUTH_TOKEN` set (the live driver refuses both); a permission mode that lets the + session launch the headless `claude -p` the runner spawns (Auto mode's classifier refuses it, see + [the cloud session settings](#running-the-cards-in-a-claude-code-cloud-session)); Chromium for the screenshots + (preinstalled in the cloud container). A full refresh runs one `starter` build after another, longer than the + 2-hour cap on a background command, so the task starts it detached and waits in the foreground. +- **What it never does**: push `main` or any branch but `export/`, force-push, merge, edit a gallery file by + hand, pass `--packages workspace`, or run on an API key. A refusal is reported, never retried around. + +The task's prompt, verbatim: + +```text +Refresh the ObjectStack scenario gallery. Run each step in order and stop at the first that fails. + +1. Check the login: `unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN; claude auth status` must report oauth_token. + Anything else is a failure (step 6). +2. In the objectstack checkout: `git fetch origin main && git checkout --detach origin/main`, `pnpm install`, + `pnpm --filter '@objectstack/scenarios^...' build`. +3. Clone the gallery beside it: `git clone https://github.com/objectstack-ai/gallery ../gallery`. + If ../gallery/index.json is missing or lists no app, add `--debut` to the command in step 4. +4. Start the refresh detached and wait for it in the foreground: + `nohup sh -c 'pnpm --filter @objectstack/scenarios gallery-refresh --gallery "$(realpath ../gallery)" --work /tmp/gallery-refresh; echo "gallery-refresh exit $?"' > /tmp/gallery-refresh.log 2>&1 & echo $! > /tmp/gallery-refresh.pid` + then repeat `timeout 590 tail --pid="$(cat /tmp/gallery-refresh.pid)" -f /dev/null` until the process is gone. + The log's last line is `gallery-refresh exit N`; any N but 0 is a failure (step 6). +5. If a line of the log starts with `skip:`, stop: report that line and do nothing else. + Otherwise the log names a `branch: export/VERSION`: push exactly that branch + (`git -C ../gallery push origin export/VERSION`) and open one pull request on objectstack-ai/gallery from it into + main, titled `Gallery refresh for ObjectStack VERSION`, with the text after "open the PR with this body:" as its + body. Never push main, never force-push, never merge, never edit a gallery file by hand. +6. On any failure (a refusal, a non-zero exit, a failed push or PR): search the open issues of + objectstack-ai/objectstack for one titled `Gallery refresh failed`. If one exists, add a comment with today's + date, the step that failed and the last 40 lines of /tmp/gallery-refresh.log; otherwise open one issue with that + title and the same text. Never open a second one. +``` + ## Other agent tools — Codex and the external driver contract The same card can measure another agent tool. Every record (`env.driver`) and every gallery app page (`- Driver:`) diff --git a/packages/qa/scenarios/package.json b/packages/qa/scenarios/package.json index 46d73adff4c..83d20274c03 100644 --- a/packages/qa/scenarios/package.json +++ b/packages/qa/scenarios/package.json @@ -66,7 +66,8 @@ "test": "vitest run", "typecheck": "tsc --noEmit", "run-scenarios": "tsx src/runner/cli.ts", - "export-gallery": "tsx src/export/cli.ts" + "export-gallery": "tsx src/export/cli.ts", + "gallery-refresh": "tsx src/export/refresh-cli.ts" }, "dependencies": { "@objectstack/core": "workspace:*", diff --git a/packages/qa/scenarios/src/export/cli.ts b/packages/qa/scenarios/src/export/cli.ts index eb5f30e2b3b..bad0df5d440 100644 --- a/packages/qa/scenarios/src/export/cli.ts +++ b/packages/qa/scenarios/src/export/cli.ts @@ -85,6 +85,12 @@ export function renderSummary(s: ExportSummary): string[] { return lines; } +/** The real screenshot step: boot the project, sign in as the dev admin, drive Chromium (`--chromium PATH`, or Playwright's own). */ +export function liveScreenshots(chromium?: string): CaptureScreenshots { + const browser = playwrightBrowser(chromium ? { executablePath: chromium } : {}); + return (input) => captureScreenshots(input, { boot: (dir) => bootDev(dir), harness: (url) => new HttpApp({ baseUrl: url, adminEmail: ADMIN.email, adminPassword: ADMIN.password }), browser, admin: ADMIN }); +} + /** Test seam: replace the screenshot step (the CLI's is the real browser). */ export interface ExportCliDeps { screenshots?: CaptureScreenshots; @@ -102,11 +108,7 @@ export async function main(argv: readonly string[], deps: ExportCliDeps = {}): P }); for (const r of refusals) console.error(formatRefusal(r)); const recordings = args.recordings.map((f) => JSON.parse(readFileSync(resolve(f), 'utf8')) as Recording); - const browser = playwrightBrowser(args.chromium ? { executablePath: args.chromium } : {}); - const screenshots: CaptureScreenshots | false = !args.screenshots - ? false - : (deps.screenshots ?? - ((input) => captureScreenshots(input, { boot: (dir) => bootDev(dir), harness: (url) => new HttpApp({ baseUrl: url, adminEmail: ADMIN.email, adminPassword: ADMIN.password }), browser, admin: ADMIN }))); + const screenshots: CaptureScreenshots | false = !args.screenshots ? false : (deps.screenshots ?? liveScreenshots(args.chromium)); const summary = await exportGallery({ runsDir, outDir: resolve(args.out!), diff --git a/packages/qa/scenarios/src/export/index.ts b/packages/qa/scenarios/src/export/index.ts index 341e8ed6f8c..f538cb7db54 100644 --- a/packages/qa/scenarios/src/export/index.ts +++ b/packages/qa/scenarios/src/export/index.ts @@ -4,7 +4,9 @@ * `@objectstack/scenarios/export` — the gallery exporter: verified scenario * apps written to a local directory in the layout of objectstack-ai/gallery. * Private, like the rest of this package. CLI: - * `pnpm --filter @objectstack/scenarios export-gallery --runs DIR --out DIR`. + * `pnpm --filter @objectstack/scenarios export-gallery --runs DIR --out DIR`; + * the whole refresh (version check, run, export, commit) is + * `pnpm --filter @objectstack/scenarios gallery-refresh --gallery DIR`. */ export * from './exporter.js'; @@ -12,6 +14,7 @@ export * from './generic.js'; export * from './manifest.js'; export * from './pass.js'; export * from './record.js'; +export * from './refresh.js'; export * from './screenshots.js'; export * from './source.js'; export * from './transcript.js'; diff --git a/packages/qa/scenarios/src/export/refresh-cli.ts b/packages/qa/scenarios/src/export/refresh-cli.ts new file mode 100644 index 00000000000..b90b42c96bf --- /dev/null +++ b/packages/qa/scenarios/src/export/refresh-cli.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `pnpm --filter @objectstack/scenarios gallery-refresh --gallery DIR [flags]` + * — the gallery refresh in one step (`refresh.ts`): npm's latest stable + * `@objectstack/cli` against the gallery's `index.json`; when they differ, run + * the cards on that release, export into the gallery checkout and commit on + * `export/`. Ruling D2: a daily scheduled Claude Code task on the + * maintainer's subscription login runs it (the package README holds the + * task's runbook). + * + * --gallery DIR the gallery checkout, the top of its git working tree (required) + * --cards DIR the card directory (default: the repo's docs/qa/scenarios); + * any other directory is loaded as the private overlay, + * where a `generic` card is refused (`libraryOf`) + * --model M the model every live turn asks for (`run-scenarios --model`; + * default: the login's own) + * --debut run and export one card only, the first exportable card + * by id: the gallery's first app (refused once the index lists one) + * --work DIR where the run's records, projects and recording go + * (default: a new temp directory) + * --chromium PATH the Chromium executable for the screenshots + * + * Output: `skip: …` (one line, exit 0) when the gallery is already on the + * release; otherwise the branch, the commit and the PR body, exit 0. A refusal + * prints why and exits 1. ⛔ It never pushes and never opens the PR. + */ + +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { formatRefusal, libraryOf, loadScenarios, readCapabilityAreas } from '../loader.js'; +import { findUp, main as runScenarios } from '../runner/cli.js'; +import { liveScreenshots, renderSummary } from './cli.js'; +import { galleryRefresh, RefreshRefusal, runnerArgv, type RefreshDeps, type RefreshOutcome } from './refresh.js'; + +export interface RefreshCliArgs { + gallery: string; + cards?: string; + model?: string; + debut: boolean; + work?: string; + chromium?: string; +} + +export function parseRefreshArgs(argv: readonly string[]): RefreshCliArgs { + const a: Partial & { debut: boolean } = { debut: false }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]!; + const val = () => { + const v = argv[++i]; + if (v === undefined) throw new Error(`${flag} needs a value`); + return v; + }; + if (flag === '--gallery') a.gallery = val(); + else if (flag === '--cards') a.cards = val(); + else if (flag === '--model') { + const raw = val(); + if (raw.trim() === '' || raw.startsWith('-')) throw new Error(`--model takes a model alias or name (sonnet, opus, claude-sonnet-4-5), not \`${raw}\``); + a.model = raw; + } else if (flag === '--debut') a.debut = true; + else if (flag === '--work') a.work = val(); + else if (flag === '--chromium') a.chromium = val(); + else throw new Error(`unknown flag \`${flag}\``); + } + if (!a.gallery) throw new Error('--gallery DIR is required: the gallery checkout the refresh exports into and commits in'); + return a as RefreshCliArgs; +} + +/** What the CLI prints for an outcome, one entry per line. */ +export function renderOutcome(o: RefreshOutcome): string[] { + if (o.kind === 'skip') return [o.line]; + return [ + ...renderSummary(o.summary), + `branch: ${o.branch}`, + `commit: ${o.commit}`, + 'Not pushed. Push the branch and open the PR with this body:', + '', + o.body, + ]; +} + +/** Test seam: replace the steps that reach outside (npm, the model, the browser). */ +export type RefreshCliDeps = Partial; + +export async function main(argv: readonly string[], deps: RefreshCliDeps = {}, io = { out: (l: string) => console.log(l), err: (l: string) => console.error(l) }): Promise { + const args = parseRefreshArgs(argv); + const here = dirname(fileURLToPath(import.meta.url)); + const repo = findUp(here, (d) => existsSync(join(d, 'pnpm-workspace.yaml'))); + const cardsDir = resolve(args.cards ?? join(repo, 'docs/qa/scenarios')); + const { cards, refusals } = loadScenarios(cardsDir, { + areas: readCapabilityAreas(join(repo, 'docs/qa/platform-checklist/areas')), + library: libraryOf(cardsDir, repo), + }); + for (const r of refusals) io.err(formatRefusal(r)); + const work = resolve(args.work ?? mkdtempSync(join(tmpdir(), 'gallery-refresh-'))); + mkdirSync(work, { recursive: true }); + const run: RefreshDeps['run'] = + deps.run ?? + (async (plan) => { + const code = await runScenarios(runnerArgv(plan)); + if (code !== 0) throw new RefreshRefusal(`run-scenarios exited ${code}: nothing is exported`); + }); + try { + const outcome = await galleryRefresh( + { galleryDir: resolve(args.gallery), cards, ...(args.cards ? { cardsDir } : {}), debut: args.debut, ...(args.model ? { model: args.model } : {}), workDir: work }, + { ...deps, run, screenshots: deps.screenshots ?? liveScreenshots(args.chromium) }, + ); + // A skip ran nothing: the temp directory it was given is removed again. + if (outcome.kind === 'skip' && args.work === undefined) rmSync(work, { recursive: true, force: true }); + for (const line of renderOutcome(outcome)) io.out(line); + return 0; + } catch (e) { + if (!(e instanceof RefreshRefusal)) throw e; + io.err(`gallery-refresh: refused — ${e.message}`); + io.err(`work directory: ${work}`); + return 1; + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); + }, + ); +} diff --git a/packages/qa/scenarios/src/export/refresh.ts b/packages/qa/scenarios/src/export/refresh.ts new file mode 100644 index 00000000000..6e6128f3d23 --- /dev/null +++ b/packages/qa/scenarios/src/export/refresh.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The gallery refresh: one deterministic step from "is there a new stable + * release?" to "a local commit on `export/` in a gallery checkout". + * Ruling D2 of the scenario-library epic makes a daily scheduled Claude Code + * task, on the maintainer's subscription login, its caller; the package README + * ("Refreshing the gallery — the scheduled task's runbook") holds the task's + * text. The CLI is `refresh-cli.ts` (`gallery-refresh`). + * + * In order, each step a refusal ({@link RefreshRefusal}, exit non-zero) or a + * quiet skip ({@link RefreshOutcome} `skip`, one line, exit 0): + * + * 1. The gallery directory is the top of a git checkout with a clean working + * tree, and its `index.json` (absent: an empty gallery) is one the + * exporter wrote (`manifest.ts`). + * 2. npm's latest stable `@objectstack/cli` ({@link FRAMEWORK_PACKAGE}) is + * read; a `latest` that is not a plain `MAJOR.MINOR.PATCH` is refused. + * 3. Skip when the gallery is already on it: the index lists at least one + * app and every app's `framework.version` equals it (the index carries + * the version per app — the `@objectstack/spec` the app's project pins, + * which `create-objectstack` pins to its own release, and the fixed + * release group versions both packages together). + * 4. Skip when a remote-tracking `export/` branch already exists: + * a refresh for this version was pushed and awaits review. A LOCAL branch + * of that name is refused (an earlier run's commit, never overwritten). + * 5. The cards: every loaded card the gallery guard admits (`generic.ts`: + * origin `generic`, tier `starter` or `delivery`), or with `debut` the + * first of them by id — only into a gallery that lists no app yet. + * 6. The run (`deps.run`; the CLI's is `run-scenarios` on the published + * packages of exactly that version, scratch mode), then the export into + * the gallery directory (`exporter.ts`). + * 7. Refused when no app exported, when an exported app states another + * framework version, or when anything outside `apps/` and `index.json` + * changed in the checkout. + * 8. `git switch -c export/`, `git add`, `git commit` — under the + * checkout's own git identity. + * + * ⛔ It never pushes, never opens a PR and never talks to GitHub: the caller + * pushes the branch and opens the PR with the body this returns + * ({@link refreshPrBody}). + */ + +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { LoadedScenario } from '../loader.js'; +import type { Recording } from '../runner/driver.js'; +import { exportGallery, type ExportOptions, type ExportSummary } from './exporter.js'; +import { GALLERY_GUARD, type GalleryGuard } from './generic.js'; +import { parseIndex, type GalleryIndex } from './manifest.js'; +import type { CaptureScreenshots } from './screenshots.js'; + +/** The package whose npm `latest` is the framework release the gallery follows. */ +export const FRAMEWORK_PACKAGE = '@objectstack/cli'; + +/** A stable release: `MAJOR.MINOR.PATCH`, no prerelease or build suffix. */ +export const STABLE_VERSION = /^\d+\.\d+\.\d+$/; + +/** The branch a refresh commits on. */ +export const galleryBranch = (version: string): string => `export/${version}`; + +/** A refresh that stops before committing; the CLI prints the message and exits non-zero. */ +export class RefreshRefusal extends Error { + constructor(message: string) { + super(message); + this.name = 'RefreshRefusal'; + } +} + +/** npm's `latest` dist-tag of {@link FRAMEWORK_PACKAGE} (`npm view`, so the caller's npm configuration applies). */ +export function npmLatestVersion(pkg: string = FRAMEWORK_PACKAGE): string { + return execFileSync('npm', ['view', pkg, 'dist-tags.latest'], { encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +/** The gallery's framework version, as its index states it. */ +export interface GalleryVersions { + /** The index, or undefined when the checkout has none yet. */ + index: GalleryIndex | undefined; + /** The distinct `framework.version` of the listed apps, sorted. */ + versions: string[]; +} + +export function readGalleryVersions(galleryDir: string): GalleryVersions { + const file = join(galleryDir, 'index.json'); + if (!existsSync(file)) return { index: undefined, versions: [] }; + let index: GalleryIndex; + try { + index = parseIndex(readFileSync(file, 'utf8')); + } catch (e) { + throw new RefreshRefusal(`${file} is not a gallery index the exporter wrote: ${e instanceof Error ? e.message : String(e)}`); + } + return { index, versions: [...new Set(index.apps.map((a) => a.framework.version))].sort() }; +} + +/** What the run step is asked to do: run these cards on this release, write records to `runsDir` and every turn to `recording`. */ +export interface RunPlan { + /** The framework release: the run scaffolds with `create-objectstack@`, on the published packages. */ + version: string; + /** The card ids to run. */ + cards: string[]; + /** The card directory the CLI loaded, when not the default. */ + cardsDir?: string; + /** Where the run records go (`run-scenarios --out`). */ + runsDir: string; + /** Where the projects are scaffolded (`run-scenarios --work`). */ + workDir: string; + /** Where every live turn is saved (`run-scenarios --record`): the transcript's reply text. */ + recording: string; + /** The model every live turn asks for (`--model`); the login's own when absent. */ + model?: string; +} + +/** + * The `run-scenarios` command line for a plan: the published packages of + * exactly that release, scratch mode only (the gallery ships the scratch + * project, and the exporter's pass rule owes scratch mode's six halves, the + * iterate Run included). + */ +export function runnerArgv(plan: RunPlan): string[] { + return [ + ...(plan.cardsDir ? ['--cards', plan.cardsDir] : []), + '--only', + plan.cards.join(','), + '--modes', + 'scratch', + '--packages', + 'published', + '--create-version', + plan.version, + '--out', + plan.runsDir, + '--work', + plan.workDir, + '--record', + plan.recording, + ...(plan.model ? ['--model', plan.model] : []), + ]; +} + +export interface RefreshOptions { + /** The gallery checkout: the top of its git working tree. */ + galleryDir: string; + /** The loaded cards (`loadScenarios`). */ + cards: readonly LoadedScenario[]; + /** The card directory, when not the default (passed to the run). */ + cardsDir?: string; + /** Export one card only: the gallery's first app. */ + debut?: boolean; + /** The model every live turn asks for. */ + model?: string; + /** Where the run's records, projects and recording go. */ + workDir: string; +} + +export interface RefreshDeps { + /** npm's latest `@objectstack/cli`; default {@link npmLatestVersion}. */ + latestVersion?: () => string | Promise; + /** Run the cards; resolves once the records and the recording are written. */ + run: (plan: RunPlan) => Promise; + /** Take screenshots with this; `false` exports without them. */ + screenshots: CaptureScreenshots | false; + /** Default {@link GALLERY_GUARD}; a test seam, as for the exporter — the CLI offers no flag for another. */ + guard?: GalleryGuard; +} + +export type RefreshOutcome = + | { kind: 'skip'; line: string } + | { kind: 'committed'; version: string; branch: string; commit: string; cards: string[]; summary: ExportSummary; body: string }; + +interface GitAnswer { + status: number; + stdout: string; + stderr: string; +} + +function git(dir: string, args: readonly string[]): GitAnswer { + const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' }); + if (r.error) throw new RefreshRefusal(`git ${args[0]} could not run: ${r.error.message}`); + return { status: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; +} + +function gitOk(dir: string, args: readonly string[]): string { + const r = git(dir, args); + if (r.status !== 0) throw new RefreshRefusal(`git ${args.join(' ')} exited ${r.status} in ${dir}: ${r.stderr.trim() || r.stdout.trim()}`); + return r.stdout; +} + +/** The checkout's changed paths (`git status --porcelain`), untracked files listed one by one. */ +function changedPaths(dir: string): string[] { + return gitOk(dir, ['status', '--porcelain', '--untracked-files=all']) + .split('\n') + .filter((l) => l !== '') + .map((l) => l.slice(3)); +} + +function checkCheckout(galleryDir: string): void { + if (!existsSync(galleryDir) || !statSync(galleryDir).isDirectory()) throw new RefreshRefusal(`--gallery ${galleryDir} is not a directory`); + const top = git(galleryDir, ['rev-parse', '--show-toplevel']); + if (top.status !== 0 || realpathSync(top.stdout.trim()) !== realpathSync(galleryDir)) { + throw new RefreshRefusal(`--gallery ${galleryDir} is not the top of a git checkout: the refresh commits there`); + } + const dirty = changedPaths(galleryDir); + if (dirty.length > 0) { + throw new RefreshRefusal(`the gallery checkout has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ', …' : ''}): a refresh commits only what it exports`); + } +} + +/** A gallery path the exporter writes: `index.json` or anything under `apps/`. */ +const exporterPath = (p: string): boolean => p === 'index.json' || p.startsWith('apps/'); + +export async function galleryRefresh(opts: RefreshOptions, deps: RefreshDeps): Promise { + const dir = opts.galleryDir; + checkCheckout(dir); + const before = readGalleryVersions(dir); + + let latest: string; + try { + latest = (await (deps.latestVersion ?? npmLatestVersion)()).trim(); + } catch (e) { + throw new RefreshRefusal(`npm's latest ${FRAMEWORK_PACKAGE} could not be read: ${e instanceof Error ? e.message : String(e)}`); + } + if (!STABLE_VERSION.test(latest)) { + throw new RefreshRefusal(`npm's latest ${FRAMEWORK_PACKAGE} is \`${latest}\`, not a stable MAJOR.MINOR.PATCH release: nothing is run`); + } + const listed = before.index?.apps.length ?? 0; + if (listed > 0 && before.versions.length === 1 && before.versions[0] === latest) { + return { kind: 'skip', line: `skip: the gallery's index.json framework.version is ${latest}, npm's latest stable ${FRAMEWORK_PACKAGE}` }; + } + const branch = galleryBranch(latest); + if (git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]).status === 0) { + throw new RefreshRefusal(`the gallery checkout already has a local branch ${branch}: an earlier refresh's commit, which is never overwritten — push it or delete it`); + } + const pending = gitOk(dir, ['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${branch}`]).trim(); + if (pending !== '') { + return { kind: 'skip', line: `skip: ${pending.split('\n')[0]} already exists: the refresh for ${latest} awaits review` }; + } + + const guard = deps.guard ?? GALLERY_GUARD; + const eligible = opts.cards + .filter((s) => guard(s).eligible) + .map((s) => s.card.id) + .sort(); + if (eligible.length === 0) throw new RefreshRefusal('no loaded card may be exported (origin `generic`, tier `starter` or `delivery`): nothing to run'); + if (opts.debut && listed > 0) { + throw new RefreshRefusal(`--debut exports the gallery's first app, and its index.json already lists ${listed}: run without --debut`); + } + const cards = opts.debut ? [eligible[0]!] : eligible; + + const plan: RunPlan = { + version: latest, + cards, + ...(opts.cardsDir ? { cardsDir: opts.cardsDir } : {}), + runsDir: join(opts.workDir, 'runs'), + workDir: join(opts.workDir, 'work'), + recording: join(opts.workDir, 'recording.json'), + ...(opts.model ? { model: opts.model } : {}), + }; + await deps.run(plan); + if (!existsSync(plan.runsDir)) throw new RefreshRefusal(`the run wrote no record (${plan.runsDir} does not exist): nothing is exported`); + + const recordings: Recording[] = existsSync(plan.recording) ? [JSON.parse(readFileSync(plan.recording, 'utf8')) as Recording] : []; + const exportOptions: ExportOptions = { runsDir: plan.runsDir, outDir: dir, cards: opts.cards, recordings, guard, screenshots: deps.screenshots }; + const summary = await exportGallery(exportOptions); + if (summary.exported.length === 0) { + const why = summary.refused.map((r) => `${r.id}: ${r.reasons.join('; ')}`); + throw new RefreshRefusal(`no app exported for ${latest}, so nothing is committed${why.length ? ` — ${why.join(' · ')}` : ''}. The gallery checkout's working tree holds what the export changed`); + } + const after = readGalleryVersions(dir); + const stale = (after.index?.apps ?? []).filter((a) => a.framework.version !== latest); + if (stale.length > 0) { + throw new RefreshRefusal( + `${stale.map((a) => `${a.id} states framework ${a.framework.version}`).join(', ')}, not ${latest}: the run did not install that release, so nothing is committed`, + ); + } + const stray = changedPaths(dir).filter((p) => !exporterPath(p)); + if (stray.length > 0) throw new RefreshRefusal(`the checkout changed outside apps/ and index.json (${stray.join(', ')}): nothing is committed`); + + gitOk(dir, ['switch', '--quiet', '-c', branch]); + gitOk(dir, ['add', '--all', '--', 'apps', 'index.json']); + if (git(dir, ['diff', '--cached', '--quiet']).status === 0) throw new RefreshRefusal(`the export changed nothing in the checkout: nothing is committed on ${branch}`); + const subject = `export: the gallery on ${FRAMEWORK_PACKAGE} ${latest}${opts.debut ? ' (debut)' : ''}`; + gitOk(dir, ['commit', '--quiet', '-m', subject, '-m', `Generated by gallery-refresh (@objectstack/scenarios): ${summary.exported.map((e) => e.id).join(', ')}.`]); + const commit = gitOk(dir, ['rev-parse', 'HEAD']).trim(); + const body = refreshPrBody({ version: latest, branch, commit, previous: before, cards, debut: opts.debut === true, ...(opts.model ? { model: opts.model } : {}), summary }); + return { kind: 'committed', version: latest, branch, commit, cards, summary, body }; +} + +export interface PrBodyInput { + version: string; + branch: string; + commit: string; + previous: GalleryVersions; + cards: string[]; + debut: boolean; + model?: string; + summary: ExportSummary; +} + +/** The PR body the caller opens the gallery PR with: what was run, what was exported, and what was not and why. */ +export function refreshPrBody(p: PrBodyInput): string { + const prev = p.previous.index && p.previous.index.apps.length > 0 ? `${p.previous.versions.join(', ')} (${p.previous.index.apps.length} app(s))` : 'none (the gallery listed no app)'; + const s = p.summary; + const lines = [ + `Gallery refresh for ObjectStack ${p.version}, npm's latest stable \`${FRAMEWORK_PACKAGE}\`.`, + '', + 'Generated by `gallery-refresh` (`@objectstack/scenarios`): every file under `apps/` and `index.json` is the exporter\'s output. Do not edit it by hand; fix the card, the platform or the authoring skills, and the next refresh corrects it.', + '', + `- Branch: \`${p.branch}\`, one commit \`${p.commit.slice(0, 10)}\``, + `- Previous framework version in index.json: ${prev}`, + `- Cards run: ${p.debut ? 'the debut, one card' : 'every generic starter or delivery card'}: ${p.cards.map((c) => `\`${c}\``).join(', ')}`, + `- Run: scratch mode, published packages \`create-objectstack@${p.version}\`, model ${p.model ? `\`${p.model}\`` : 'the login\'s default'}`, + '', + `## Exported (${s.exported.length})`, + '', + ...s.exported.map((e) => `- \`${e.id}\`: ${e.screenshots.length} screenshot(s)${e.skippedShots.map((k) => `; no ${k.kind}: ${k.reason}`).join('')}`), + ]; + const section = (title: string, rows: string[]) => (rows.length ? ['', `## ${title} (${rows.length})`, '', ...rows] : []); + lines.push( + ...section('Refused', s.refused.map((r) => `- \`${r.id}\`: ${r.reasons.join('; ')}`)), + ...section('Removed from the gallery', s.removed.map((r) => `- \`${r.id}\`: ${r.why}`)), + ...section('Kept as the previous export left them', s.kept.map((k) => `- \`${k.id}\`: ${k.why}`)), + ...section('Not the exporter\'s, left alone', s.unowned.map((u) => `- \`apps/${u}\``)), + '', + ); + return lines.join('\n'); +} diff --git a/packages/qa/scenarios/src/runner/driver.ts b/packages/qa/scenarios/src/runner/driver.ts index 0e7e46502d5..163eb282a76 100644 --- a/packages/qa/scenarios/src/runner/driver.ts +++ b/packages/qa/scenarios/src/runner/driver.ts @@ -4,9 +4,14 @@ * The agent driver: the one seam between the runner and the model. * * The cost ruling on cloud#3600 (「目前阶段尽量不要用云端的token,api的更贵」) - * fixes what may sit behind it: a LOCAL Claude Code session on the developer's - * own login, never an API-billed model and never a cloud agent. No driver - * calls a model API itself: + * fixes what may sit behind it: a Claude Code session on a subscription + * login, never an API-billed model. Ruling D2 of the scenario-library epic + * (2026-11-06, the gallery write path) names the two callers that qualify: a + * LOCAL session on the developer's own login, and a scheduled Claude Code + * task in the project's cloud environment on the maintainer's subscription + * login (the gallery refresh, `export/refresh.ts`). An API-billed model is + * still never allowed, wherever the session runs. No driver calls a model + * API itself: * * - {@link ClaudeCodeDriver} spawns the `claude` CLI in headless mode * (`claude -p --output-format stream-json`, the prompt on stdin). It REFUSES diff --git a/packages/qa/scenarios/test/export/refresh.test.ts b/packages/qa/scenarios/test/export/refresh.test.ts new file mode 100644 index 00000000000..c8c329f352c --- /dev/null +++ b/packages/qa/scenarios/test/export/refresh.test.ts @@ -0,0 +1,346 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The gallery refresh (`refresh.ts`, CLI `refresh-cli.ts`) on a fixture + * gallery checkout: a real git repository with a bare `origin`, a stubbed npm + * version reader, and a run step that runs the real crm card through + * `runCard` with the fixture's scripted model inside the runner's own + * `RecordingDriver` (`fixture.ts`), writing the record and the recording + * where `run-scenarios` would. No network, no model, no browser. + */ + +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { GalleryGuard } from '../../src/export/generic.js'; +import { parseIndex, serializeIndex, type GalleryApp } from '../../src/export/manifest.js'; +import { main, parseRefreshArgs, renderOutcome } from '../../src/export/refresh-cli.js'; +import { galleryBranch, galleryRefresh, RefreshRefusal, runnerArgv, STABLE_VERSION, type RefreshDeps, type RefreshOptions, type RunPlan } from '../../src/export/refresh.js'; +import { parseArgs } from '../../src/runner/cli.js'; +import { CARD_ID, loadAllCards, loadCrm, runCrm, tempDir, writeRecord } from './fixture.js'; + +/** The crm card is a `control` card the real guard refuses: this double lets it through, as the exporter's tests do. */ +const ELIGIBLE_DOUBLE: GalleryGuard = () => ({ eligible: true, reasons: [] }); + +/** The version the fixture's scaffold pins (`@objectstack/spec` 18.3.0 in its package.json). */ +const PINNED = '18.3.0'; + +function sh(dir: string, ...args: string[]): string { + const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); +} + +/** A listed app, as the exporter writes it, at this framework version. */ +function app(id: string, version: string): GalleryApp { + return { + id, + code: 'crm', + namespace: 'democrm', + path: `apps/${id}`, + function: 'sales', + title: { en: 'CRM', zh: '客户' }, + summary: {}, + capabilities: [], + screenshots: [], + qa: { questions: 1, turns: 1, personas: 1 }, + operations: 0, + framework: { version, runner: 'abc1234' }, + runDate: '2026-11-01', + run: `2026-11-01-scenario-${id}`, + }; +} + +/** A gallery checkout: `main` with a README (and an index when `apps` is given), pushed to a bare `origin`. */ +function gallery(apps?: GalleryApp[]): { dir: string; remote: string } { + const root = tempDir('refresh'); + const remote = join(root, 'origin.git'); + const dir = join(root, 'gallery'); + mkdirSync(dir); + spawnSync('git', ['init', '--quiet', '--bare', '-b', 'main', remote]); + sh(dir, 'init', '--quiet', '-b', 'main'); + sh(dir, 'config', 'user.name', 'Gallery Maintainer'); + sh(dir, 'config', 'user.email', 'maintainer@example.test'); + sh(dir, 'config', 'commit.gpgsign', 'false'); + writeFileSync(join(dir, 'README.md'), '# gallery\n'); + if (apps) { + writeFileSync(join(dir, 'index.json'), serializeIndex(apps)); + for (const a of apps) { + mkdirSync(join(dir, a.path), { recursive: true }); + writeFileSync(join(dir, a.path, 'README.md'), `# ${a.id}\n`); + } + } + sh(dir, 'add', '--all'); + sh(dir, 'commit', '--quiet', '-m', 'init'); + sh(dir, 'remote', 'add', 'origin', remote); + sh(dir, 'push', '--quiet', 'origin', 'main'); + sh(dir, 'fetch', '--quiet', 'origin'); + return { dir, remote }; +} + +/** Every ref of the bare remote: a refresh must leave it as it was. */ +const remoteRefs = (remote: string) => sh(remote, 'for-each-ref', '--format=%(refname) %(objectname)'); + +interface Harness { + plans: RunPlan[]; + deps: RefreshDeps; +} + +/** The deps a test refresh runs with: npm answers `latest`, the run is the fixture's crm run (scratch mode). */ +function harness(latest: string | (() => string), over: { iterateRun?: boolean; also?: (plan: RunPlan) => void } = {}): Harness { + const plans: RunPlan[] = []; + return { + plans, + deps: { + latestVersion: typeof latest === 'string' ? () => latest : latest, + guard: ELIGIBLE_DOUBLE, + screenshots: false, + run: async (plan) => { + plans.push(plan); + const fx = await runCrm({ work: plan.workDir, modes: ['scratch'], ...(over.iterateRun === false ? { iterateRun: false } : {}) }); + writeRecord(plan.runsDir, fx.record); + writeFileSync(plan.recording, JSON.stringify(fx.recording)); + over.also?.(plan); + }, + }, + }; +} + +const options = (dir: string, over: Partial = {}): RefreshOptions => ({ galleryDir: dir, cards: [loadCrm()], workDir: tempDir('work'), ...over }); + +async function refused(p: Promise): Promise { + const e = await p.then( + () => undefined, + (err: unknown) => err, + ); + expect(e).toBeInstanceOf(RefreshRefusal); + return (e as Error).message; +} + +describe('galleryRefresh — the quiet skip', () => { + it('skips with one `skip:` line, running nothing, when every listed app states npm’s latest stable version', async () => { + const { dir, remote } = gallery([app('crm_account_with_contacts', PINNED), app('order_to_cash', PINNED)]); + const head = sh(dir, 'rev-parse', 'HEAD'); + const refs = remoteRefs(remote); + const h = harness(PINNED); + const out = await galleryRefresh(options(dir), h.deps); + expect(out.kind).toBe('skip'); + expect(out.kind === 'skip' && out.line).toMatch(/^skip: .*18\.3\.0/); + expect(h.plans).toEqual([]); + expect(sh(dir, 'rev-parse', 'HEAD')).toBe(head); + expect(sh(dir, 'status', '--porcelain')).toBe(''); + expect(remoteRefs(remote)).toBe(refs); + }); + + it('does not skip when one listed app is on an older version, or when the index lists no app', async () => { + for (const apps of [[app('crm_account_with_contacts', PINNED), app('order_to_cash', '18.2.0')], [], undefined]) { + const { dir } = gallery(apps); + const h = harness(PINNED); + const out = await galleryRefresh(options(dir), h.deps); + expect(out.kind).toBe('committed'); + expect(h.plans).toHaveLength(1); + } + }); + + it('skips when a remote-tracking export/ branch exists: that refresh awaits review', async () => { + const { dir, remote } = gallery(); + sh(dir, 'push', '--quiet', 'origin', `main:refs/heads/${galleryBranch(PINNED)}`); + sh(dir, 'fetch', '--quiet', 'origin'); + const refs = remoteRefs(remote); + const h = harness(PINNED); + const out = await galleryRefresh(options(dir), h.deps); + expect(out).toEqual({ kind: 'skip', line: `skip: origin/export/${PINNED} already exists: the refresh for ${PINNED} awaits review` }); + expect(h.plans).toEqual([]); + expect(remoteRefs(remote)).toBe(refs); + }); +}); + +describe('galleryRefresh — run, export, commit on export/', () => { + it('commits apps/ and index.json on export/ under the checkout’s identity, never pushes, and returns the PR body', async () => { + const { dir, remote } = gallery(); + const base = sh(dir, 'rev-parse', 'HEAD'); + const refs = remoteRefs(remote); + const h = harness(PINNED); + const out = await galleryRefresh(options(dir, { model: 'sonnet' }), h.deps); + if (out.kind !== 'committed') throw new Error('expected a commit'); + + expect(out.branch).toBe('export/18.3.0'); + expect(sh(dir, 'branch', '--show-current')).toBe('export/18.3.0'); + expect(sh(dir, 'rev-parse', 'HEAD')).toBe(out.commit); + expect(sh(dir, 'rev-parse', 'HEAD^')).toBe(base); + expect(sh(dir, 'status', '--porcelain')).toBe(''); + expect(sh(dir, 'log', '-1', '--format=%an <%ae>')).toBe('Gallery Maintainer '); + const files = sh(dir, 'diff', '--name-only', 'HEAD^', 'HEAD').split('\n'); + expect(files).toContain('index.json'); + expect(files.every((f) => f === 'index.json' || f.startsWith(`apps/${CARD_ID}/`))).toBe(true); + expect(parseIndex(readFileSync(join(dir, 'index.json'), 'utf8')).apps.map((a) => [a.id, a.framework.version])).toEqual([[CARD_ID, PINNED]]); + // ⛔ never pushes: the remote holds exactly what it held. + expect(remoteRefs(remote)).toBe(refs); + + expect(h.plans).toHaveLength(1); + expect(h.plans[0]).toMatchObject({ version: PINNED, cards: [CARD_ID], model: 'sonnet' }); + expect(out.body).toContain('`export/18.3.0`'); + expect(out.body).toContain(`\`${CARD_ID}\``); + expect(out.body).toContain('`sonnet`'); + expect(out.body).toContain('none (the gallery listed no app)'); + + // The next day: the index now states the release, so the refresh skips. + const again = harness(PINNED); + expect((await galleryRefresh(options(dir), again.deps)).kind).toBe('skip'); + expect(again.plans).toEqual([]); + }); + + it('asks run-scenarios for exactly that release, published packages, scratch mode, the cards and the model', () => { + const plan: RunPlan = { version: PINNED, cards: ['leave_balances', 'service_desk'], cardsDir: '/c', runsDir: '/w/runs', workDir: '/w/work', recording: '/w/rec.json', model: 'haiku' }; + const args = parseArgs(runnerArgv(plan)); + expect(args).toMatchObject({ + cards: '/c', + only: ['leave_balances', 'service_desk'], + modes: ['scratch'], + packages: 'published', + createVersion: PINNED, + out: '/w/runs', + work: '/w/work', + record: '/w/rec.json', + model: 'haiku', + driver: 'claude', + iterateRun: true, + }); + const { model: _model, cardsDir: _cards, ...bare } = plan; + expect(parseArgs(runnerArgv(bare))).not.toHaveProperty('model'); + }); + + it('runs every card the real gallery guard admits, and with --debut only the first of them by id', async () => { + const cards = loadAllCards(); + const plansOf = async (debut: boolean) => { + const { dir } = gallery(); + const plans: RunPlan[] = []; + // The run writes nothing, so the refresh stops after it: only the plan is read here. + const msg = await refused(galleryRefresh(options(dir, { cards, debut }), { latestVersion: () => PINNED, screenshots: false, run: async (p) => void plans.push(p) })); + expect(msg).toMatch(/wrote no record/); + return plans[0]!.cards; + }; + const starters = cards.filter((s) => s.card.tier !== 'control').map((s) => s.card.id).sort(); + expect(starters.length).toBeGreaterThan(1); + expect(await plansOf(false)).toEqual(starters); + expect(await plansOf(true)).toEqual([starters[0]]); + }); +}); + +describe('galleryRefresh — refusals (exit non-zero, nothing committed)', () => { + it('refuses a latest that is not a stable MAJOR.MINOR.PATCH, and an npm read that fails, before running anything', async () => { + for (const v of ['18.4.0-rc.1', '18.4', '', 'v18.4.0']) { + expect(STABLE_VERSION.test(v)).toBe(false); + const h = harness(v); + expect(await refused(galleryRefresh(options(gallery().dir), h.deps))).toMatch(/not a stable/); + expect(h.plans).toEqual([]); + } + const h = harness(() => { + throw new Error('npm ERR! code E404'); + }); + expect(await refused(galleryRefresh(options(gallery().dir), h.deps))).toMatch(/could not be read: npm ERR! code E404/); + expect(h.plans).toEqual([]); + }); + + it('refuses a directory that is not the top of a clean git checkout, or whose index.json does not validate', async () => { + const h = harness(PINNED); + expect(await refused(galleryRefresh(options(tempDir('plain')), h.deps))).toMatch(/not the top of a git checkout/); + const sub = join(gallery().dir, 'apps'); + mkdirSync(sub); + expect(await refused(galleryRefresh(options(sub), h.deps))).toMatch(/not the top of a git checkout/); + const dirty = gallery().dir; + writeFileSync(join(dirty, 'notes.txt'), 'hand edit\n'); + expect(await refused(galleryRefresh(options(dirty), h.deps))).toMatch(/uncommitted changes \(notes\.txt\)/); + const broken = gallery().dir; + writeFileSync(join(broken, 'index.json'), '{"apps": []}\n'); + sh(broken, 'add', 'index.json'); + sh(broken, 'commit', '--quiet', '-m', 'broken index'); + expect(await refused(galleryRefresh(options(broken), h.deps))).toMatch(/not a gallery index the exporter wrote/); + expect(h.plans).toEqual([]); + }); + + it('refuses a local export/ branch: an earlier refresh’s commit is never overwritten', async () => { + const { dir } = gallery(); + sh(dir, 'branch', galleryBranch(PINNED)); + const h = harness(PINNED); + expect(await refused(galleryRefresh(options(dir), h.deps))).toMatch(/already has a local branch export\/18\.3\.0/); + expect(h.plans).toEqual([]); + }); + + it('refuses --debut into a gallery that already lists an app', async () => { + const { dir } = gallery([app('order_to_cash', '18.2.0')]); + const h = harness(PINNED); + expect(await refused(galleryRefresh(options(dir, { debut: true }), h.deps))).toMatch(/--debut .* already lists 1/); + expect(h.plans).toEqual([]); + }); + + it('refuses to commit an app that states another framework version than the release it was asked for', async () => { + const { dir } = gallery(); + const base = sh(dir, 'rev-parse', 'HEAD'); + const msg = await refused(galleryRefresh(options(dir), harness('18.4.0').deps)); + expect(msg).toContain(`${CARD_ID} states framework ${PINNED}, not 18.4.0`); + expect(sh(dir, 'rev-parse', 'HEAD')).toBe(base); + expect(sh(dir, 'branch', '--list', 'export/*')).toBe(''); + }); + + it('refuses when no app exported (a run that did not pass every half), naming why', async () => { + const { dir } = gallery(); + const base = sh(dir, 'rev-parse', 'HEAD'); + const msg = await refused(galleryRefresh(options(dir), harness(PINNED, { iterateRun: false }).deps)); + expect(msg).toMatch(new RegExp(`no app exported for 18\\.3\\.0.*${CARD_ID}: .*did not pass every half`)); + expect(sh(dir, 'rev-parse', 'HEAD')).toBe(base); + expect(sh(dir, 'branch', '--list', 'export/*')).toBe(''); + }); + + it('refuses when anything outside apps/ and index.json changed in the checkout', async () => { + const { dir } = gallery(); + const msg = await refused(galleryRefresh(options(dir), harness(PINNED, { also: () => writeFileSync(join(dir, '.env'), 'X=1\n') }).deps)); + expect(msg).toMatch(/changed outside apps\/ and index\.json \(\.env\)/); + expect(sh(dir, 'branch', '--list', 'export/*')).toBe(''); + }); +}); + +describe('gallery-refresh CLI', () => { + it('parses its flags, and refuses a missing --gallery, a bad --model and an unknown flag', () => { + expect(parseRefreshArgs(['--gallery', 'g', '--cards', 'c', '--model', 'opus', '--debut', '--work', 'w', '--chromium', 'x'])).toEqual({ gallery: 'g', cards: 'c', model: 'opus', debut: true, work: 'w', chromium: 'x' }); + expect(parseRefreshArgs(['--gallery', 'g'])).toEqual({ gallery: 'g', debut: false }); + expect(() => parseRefreshArgs([])).toThrow(/--gallery DIR is required/); + for (const bad of ['', ' ', '--debut']) expect(() => parseRefreshArgs(['--gallery', 'g', '--model', bad])).toThrow(/--model takes a model alias/); + expect(() => parseRefreshArgs(['--gallery', 'g', '--push'])).toThrow(/unknown flag `--push`/); + }); + + it('prints exactly one `skip:` line and exits 0 when the gallery is on the release', async () => { + const { dir } = gallery([app(CARD_ID, PINNED)]); + const out: string[] = []; + const err: string[] = []; + const code = await main(['--gallery', dir], { latestVersion: () => PINNED, screenshots: false }, { out: (l) => out.push(l), err: (l) => err.push(l) }); + expect(code).toBe(0); + expect(out).toHaveLength(1); + expect(out[0]).toMatch(/^skip: /); + expect(err).toEqual([]); + }); + + it('on the repo’s own cards: runs, exports, commits, and prints the branch and the PR body; a refusal exits 1 with why', async () => { + const { dir, remote } = gallery(); + const refs = remoteRefs(remote); + const h = harness(PINNED); + const out: string[] = []; + const io = { out: (l: string) => out.push(l), err: () => {} }; + expect(await main(['--gallery', dir], h.deps, io)).toBe(0); + expect(h.plans[0]!.cards).toEqual(loadAllCards().map((s) => s.card.id).sort()); + expect(out).toContain('branch: export/18.3.0'); + expect(out.some((l) => l.startsWith('Gallery refresh for ObjectStack 18.3.0'))).toBe(true); + expect(remoteRefs(remote)).toBe(refs); + + const err: string[] = []; + expect(await main(['--gallery', gallery().dir], harness('18.4.0-rc.1').deps, { out: () => {}, err: (l) => err.push(l) })).toBe(1); + expect(err[0]).toMatch(/^gallery-refresh: refused — npm's latest @objectstack\/cli is `18\.4\.0-rc\.1`/); + }); + + it('renders a skip as its one line', () => { + expect(renderOutcome({ kind: 'skip', line: 'skip: x' })).toEqual(['skip: x']); + }); +});