diff --git a/.env.example b/.env.example index 1fcd575..839386a 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,14 @@ CHALENDIA_DATABASE_MAX_CONNECTIONS=5 # Log filter. Default: info RUST_LOG=info +# --- Ozalid --- +# Where the journeys' captures are reviewed. Pushed from a developer's machine +# with `just ozalid-push`, before opening a pull request — never from CI, where +# it would already be too late to change what was captured. +# The token is a service token issued by the ozalid project. Never committed. +OZALID_URL=https://server.ozalid.org +OZALID_TOKEN= + # --- Compose --- # Read by docker-compose.yaml, not by the application. diff --git a/docs/delivery-workflow.md b/docs/delivery-workflow.md index f70f132..97ddc08 100644 --- a/docs/delivery-workflow.md +++ b/docs/delivery-workflow.md @@ -142,15 +142,29 @@ In practice: | Step | Command | |---|---| -| Run the journeys and build the report | `just e2e` | -| Open it | `just e2e-open` | -| Record a verdict | edit `tools/e2e-report/reviews.json` | - -An entry is keyed by `::` and carries `status` -(`reviewed` or `to-fix`, the latter with a mandatory `note`), `reviewedAt`, and -`specHash` — the hash of the spec file at review time, which is what sends the -case back to *to review* when the code it judged has changed. The file is -committed; the report itself is not. +| Run the journeys | `just e2e` | +| See what would be pushed | `just ozalid-dry` | +| Push the captures | `just ozalid-push` | +| Review them | the ozalid project, in a browser | + +**Pushed from a developer's machine, before the pull request — never from CI.** +A capture that reaches CI is a capture nobody looked at while there was still +time to change it. + +What changed is not computed here: every capture is hashed, ozalid is asked +which addresses it does not already hold, and what it does not hold is exactly +what changed. A capture ozalid holds is never sent twice, which is what makes a +full visual history affordable. + +The case a journey maps to is committed in `tools/ozalid/cases.json`. ozalid +generates a case's id and the client stores it; matching on the title instead +would open a second case the day somebody rewords a test, silently, leaving the +history on the old one. A rename is fixed by editing one line in that file. + +A journey that runs in one variant only — the narrow-screen one — leaves its +steps without siblings elsewhere. ozalid calls that a **hole**: it stores the +edition and surfaces the gap rather than refusing the evidence the run did +produce. --- diff --git a/docs/git-workflow.md b/docs/git-workflow.md index 68a394b..bd69bab 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -76,9 +76,10 @@ Not yet gated, and deliberately listed so nobody assumes otherwise: |---|---| | Commit message and PR title validation | — | -The journeys also produce the review report the user validates screens in — see +The journeys also produce the captures the user validates screens from — see [`delivery-workflow.md`](delivery-workflow.md) § 6. `just e2e` runs them against -the development stack and builds the report; `just e2e-open` opens it. +the development stack; `just ozalid-push` sends the captures for review, from a +developer's machine and before the pull request. All existing gates must be green before merge; there is no manual skip. diff --git a/justfile b/justfile index 1df5676..fe84977 100644 --- a/justfile +++ b/justfile @@ -293,7 +293,7 @@ api-check: echo "api types: current" # ============================================================================= -# END TO END — the journeys, and the report the user reviews them in +# END TO END — the journeys, and the captures ozalid reviews them from # ============================================================================= # Run the journeys against a shop created from scratch, once per variant @@ -301,11 +301,10 @@ e2e: #!/usr/bin/env bash set -euo pipefail trap 'just dev-stop >/dev/null 2>&1 || true' EXIT - # Stale reports would be merged into this run's, so the previous ones go. + # Stale reports would be pushed alongside this run's, so the previous ones go. rm -rf {{frontend_dir}}/tmp/e2e-report # A failing variant is the most interesting thing to look at, so the run - # goes on and the report is built either way — the exit code carries the - # verdict, not a missing report. + # goes on to the end — the exit code carries the verdict. failed=0 for variant in ${E2E_VARIANTS:-{{e2e_variants}}}; do echo "── ${variant} ───────────────────────────────────────────────" @@ -325,13 +324,12 @@ e2e: E2E_BASE_URL="http://localhost:{{dev_web_port}}" E2E_VARIANT="${variant}" npx playwright test || failed=1 cd {{justfile_directory()}} done - just e2e-report || failed=1 exit "${failed}" -# Run the journeys against the container image, the way CI does and the way an -# operator actually installs the shop — one origin, no dev server. +# Run the journeys against the container image, the way CI and an operator do e2e-image: #!/usr/bin/env bash + # One origin, no dev server — the shape a merchant actually installs. set -euo pipefail trap 'docker compose down -v >/dev/null 2>&1 || true' EXIT rm -rf {{frontend_dir}}/tmp/e2e-report @@ -348,16 +346,25 @@ e2e-image: E2E_BASE_URL="http://localhost:{{dev_api_port}}" E2E_VARIANT="${variant}" npx playwright test || failed=1 cd {{justfile_directory()}} done - just e2e-report || failed=1 exit "${failed}" -# Build the review site from the last run -e2e-report: - node tools/e2e-report/generate.mjs +# Push the last run's captures to ozalid, where they are reviewed +ozalid-push: + #!/usr/bin/env bash + # Local and before the pull request: the visual result is one of the two + # things only the user grants, and a capture reaching CI is a capture + # nobody looked at in time. What changed is not computed here — ozalid is + # asked which content it does not hold, and that is what changed. + set -euo pipefail + set -a; . ./.env; set +a + OZALID_REVISION="$(git rev-parse --short HEAD)" node tools/ozalid/push.mjs "$@" -# Open the review site -e2e-open: - xdg-open reports/e2e/index.html +# Show what a push would send, and write nothing +ozalid-dry: + #!/usr/bin/env bash + set -euo pipefail + set -a; . ./.env; set +a + node tools/ozalid/push.mjs --dry-run # Everything a pull request must pass, in one command check: backend-check backend-test frontend-check frontend-test frontend-build api-check diff --git a/tools/e2e-report/generate.mjs b/tools/e2e-report/generate.mjs deleted file mode 100644 index 1b459da..0000000 --- a/tools/e2e-report/generate.mjs +++ /dev/null @@ -1,389 +0,0 @@ -#!/usr/bin/env node -/** - * Builds the local review site from Playwright's JSON reports. - * - * The report is what the user looks at to decide whether a screen is right — - * assertions prove behaviour, this shows the result. Per case: the video, the - * screenshot of every step, and a review status. - * - * Each journey is replayed once per variant (desktop light, desktop dark, phone) - * and each run leaves its own report, so a case here is the same journey seen - * three ways: one page, one toggle. A case that is missing a variant is a hole - * in the review, so the generator says so and fails. - * - * The status lives in `reviews.json`, committed, keyed by `::`, - * and anchored to a hash of the spec file at review time: when the spec - * changes, the case returns to "to review", because a validation is a statement - * about the code it was given for. - * - * Input: apps/frontend/tmp/e2e-report/report-<variant>.json - * Output: reports/e2e/ (not committed) - */ -import { createHash } from 'node:crypto' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -const HERE = path.dirname(fileURLToPath(import.meta.url)) -const ROOT = path.resolve(HERE, '../..') -const INPUT_DIR = path.join(ROOT, 'apps/frontend/tmp/e2e-report') -const REVIEWS = path.join(HERE, 'reviews.json') -const OUT = path.join(ROOT, 'reports/e2e') -const E2E_DIR = path.join(ROOT, 'apps/frontend/e2e') - -/** Display order, and the label each variant carries in the report. */ -const VARIANTS = { - 'desktop-light': 'desktop · light', - 'desktop-dark': 'desktop · dark', - 'mobile-light': 'mobile', -} - -const reports = (fs.existsSync(INPUT_DIR) ? fs.readdirSync(INPUT_DIR) : []) - .map((file) => ({ file, variant: /^report-(.+)\.json$/.exec(file)?.[1] })) - .filter((entry) => entry.variant) - .sort((a, b) => order(a.variant) - order(b.variant)) - -function order(variant) { - const index = Object.keys(VARIANTS).indexOf(variant) - return index === -1 ? Number.MAX_SAFE_INTEGER : index -} - -if (reports.length === 0) { - console.error(`No report in ${INPUT_DIR} — run the suite first (just e2e).`) - process.exit(1) -} - -const reviews = fs.existsSync(REVIEWS) ? JSON.parse(fs.readFileSync(REVIEWS, 'utf8')) : {} - -// --- collect ----------------------------------------------------------------- - -/** Every case in a run, flattened out of Playwright's nested suites. */ -function* cases(suite, file = suite.file) { - for (const spec of suite.specs ?? []) { - yield { spec, file: spec.file ?? file } - } - for (const child of suite.suites ?? []) { - yield* cases(child, child.file ?? file) - } -} - -const specHashes = new Map() -function hashOf(file) { - if (!specHashes.has(file)) { - const full = path.join(E2E_DIR, file) - const content = fs.existsSync(full) ? fs.readFileSync(full) : Buffer.from(file) - specHashes.set(file, createHash('sha256').update(content).digest('hex').slice(0, 12)) - } - return specHashes.get(file) -} - -/** One entry per journey, holding what each variant saw of it. */ -const collected = new Map() - -for (const { file, variant } of reports) { - const report = JSON.parse(fs.readFileSync(path.join(INPUT_DIR, file), 'utf8')) - - for (const suite of report.suites ?? []) { - for (const { spec, file: specFile } of cases(suite)) { - const result = spec.tests?.[0]?.results?.[0] - const key = `${specFile}::${spec.title}` - - if (!collected.has(key)) { - const review = reviews[key] - const hash = hashOf(specFile) - collected.set(key, { - key, - file: specFile, - // The directory under e2e/ is the category — the layout is the taxonomy. - category: path.dirname(specFile) === '.' ? 'general' : path.dirname(specFile), - title: spec.title, - status: statusOf(review, hash), - note: review?.note, - reviewedAt: review?.reviewedAt, - runs: new Map(), - }) - } - - collected.get(key).runs.set(variant, { - variant, - ok: spec.ok === true, - // A journey that qualifies itself — the phone drawer has no meaning on - // a desktop viewport — is absent from that variant on purpose. - skipped: result?.status === 'skipped', - reason: spec.tests?.[0]?.annotations?.find((a) => a.type === 'skip')?.description, - steps: (result?.attachments ?? []).filter((a) => a.name?.startsWith('step: ')), - video: (result?.attachments ?? []).find((a) => a.contentType === 'video/webm'), - duration: result?.duration ?? 0, - error: result?.error?.message, - }) - } - } -} - -function statusOf(review, hash) { - if (!review) return 'to-review' - if (review.specHash && review.specHash !== hash) return 'stale' - return review.status -} - -const items = [...collected.values()] -const variants = reports.map((entry) => entry.variant) - -for (const item of items) { - item.ok = [...item.runs.values()].every((run) => run.ok) - item.failing = [...item.runs.values()].filter((run) => !run.ok).map((run) => run.variant) -} - -// --- copy artefacts ---------------------------------------------------------- - -fs.rmSync(OUT, { recursive: true, force: true }) -fs.mkdirSync(path.join(OUT, 'assets'), { recursive: true }) - -let asset = 0 - -/** - * Attachments arrive either as a file on disk (video, failure screenshot) or as - * base64 in the report itself (anything attached from a step). Both end up as a - * file the report can point at. - */ -function copy(attachment) { - if (!attachment) return undefined - - const extension = attachment.contentType === 'video/webm' ? '.webm' : '.png' - const name = `${String(++asset).padStart(3, '0')}${extension}` - const target = path.join(OUT, 'assets', name) - - if (attachment.path && fs.existsSync(attachment.path)) { - fs.copyFileSync(attachment.path, target) - } else if (attachment.body) { - fs.writeFileSync(target, Buffer.from(attachment.body, 'base64')) - } else { - asset -= 1 - return undefined - } - - return `assets/${name}` -} - -for (const item of items) { - for (const run of item.runs.values()) { - run.videoSrc = copy(run.video) - run.stepShots = run.steps - .map((step) => ({ name: step.name.replace(/^step: /, ''), src: copy(step) })) - .filter((step) => step.src) - } -} - -// --- the invariant the report exists for ------------------------------------- - -/** - * Every step of every case carries one capture per variant. A silent hole reads - * as "reviewed" once the reviewer clicks through, which is the one outcome this - * report must never produce. - * - * A journey that skipped itself in a variant is not a hole — it says so, with - * its reason. A journey that skipped itself everywhere is one: nobody ever - * sees it. - */ -const holes = [] - -for (const item of items) { - const played = [...item.runs.values()].filter((run) => !run.skipped) - - if (played.length === 0) { - holes.push(`${item.key} — skipped in every variant, so nothing to review`) - continue - } - - const reference = played[0] - - for (const variant of variants) { - const run = item.runs.get(variant) - if (!run) { - holes.push(`${item.key} — no ${variant} run`) - continue - } - if (run.skipped) continue - if (run.stepShots.length === 0 && run.ok) { - holes.push(`${item.key} (${variant}) — no captured step, wrap its actions in reportStep`) - } - if (run !== reference && run.ok && reference.ok && run.stepShots.length !== reference.stepShots.length) { - holes.push( - `${item.key} (${variant}) — ${run.stepShots.length} steps, ${reference.variant} has ${reference.stepShots.length}`, - ) - } - } -} - -// --- render ------------------------------------------------------------------ - -const LABEL = { - 'to-review': 'to review', - reviewed: 'validated', - 'to-fix': 'to fix', - stale: 'code changed since review', -} - -const escape = (value) => - String(value).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) - -const slug = (key) => createHash('sha256').update(key).digest('hex').slice(0, 10) - -const page = (title, body, script = '') => `<!doctype html> -<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> -<title>${escape(title)}
${body}
${script}` - -/** Keeps the chosen variant while the reviewer walks from case to case. */ -const SWITCH_SCRIPT = `` - -fs.mkdirSync(path.join(OUT, 'cases'), { recursive: true }) - -for (const item of items) { - const runs = variants.map((variant) => item.runs.get(variant)).filter(Boolean) - - const toggle = `
-${runs - .map( - (run) => ``, - ) - .join('\n')} -
` - - const sections = runs - .map((run) => { - const steps = run.stepShots - .map( - (step, index) => `
${escape(step.name)} -
${index + 1}. ${escape(step.name)}
`, - ) - .join('\n') - - if (run.skipped) { - return `` - } - - return `` - }) - .join('\n') - - fs.writeFileSync( - path.join(OUT, 'cases', `${slug(item.key)}.html`), - page( - item.title, - `

← every case

-

${escape(item.title)}

-

${escape(item.file)} · -${item.ok ? 'passing' : 'failing'} -${LABEL[item.status]}

-${item.note ? `

${escape(item.note)}

` : ''} -${toggle} -${sections}`, - SWITCH_SCRIPT, - ), - ) -} - -const byCategory = new Map() -for (const item of items) { - byCategory.set(item.category, [...(byCategory.get(item.category) ?? []), item]) -} - -const count = (status) => items.filter((item) => item.status === status).length -const toReview = count('to-review') + count('stale') - -const index = [...byCategory.entries()] - .map( - ([category, cases]) => `

${escape(category)}

` + - cases - .map( - (item) => `
-${item.ok ? 'passing' : `failing: ${escape(item.failing.join(', '))}`} -${escape(item.title)} -${LABEL[item.status]}
`, - ) - .join('\n'), - ) - .join('\n') - -fs.writeFileSync( - path.join(OUT, 'index.html'), - page( - 'End-to-end journeys', - `

End-to-end journeys

-

Generated from the last run, ${variants - .map((variant) => escape(VARIANTS[variant] ?? variant)) - .join(' · ')}. Statuses live in tools/e2e-report/reviews.json.

-
-${items.length} cases -${variants.length} variants each -${items.filter((item) => item.ok).length} passing -${toReview} waiting for review -${count('to-fix')} to fix -
-${index}`, - ), -) - -console.log( - `report: ${items.length} cases × ${variants.length} variants, ${toReview} waiting for review → reports/e2e/index.html`, -) - -if (holes.length > 0) { - console.error(`\nThe report has holes — a case nobody can see is a case nobody reviewed:`) - for (const hole of holes) console.error(` ${hole}`) - process.exit(1) -} diff --git a/tools/e2e-report/reviews.json b/tools/e2e-report/reviews.json deleted file mode 100644 index 27b835d..0000000 --- a/tools/e2e-report/reviews.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "_comment": "Review statuses, keyed by ::. status: reviewed | to-fix (note required). A case with no entry is implicitly to review — which is the honest default, because only the user validates. specHash anchors a review to the spec file at review time: when it changes, the case returns to to-review." -} diff --git a/tools/ozalid/cases.json b/tools/ozalid/cases.json new file mode 100644 index 0000000..a68eb15 --- /dev/null +++ b/tools/ozalid/cases.json @@ -0,0 +1,7 @@ +{ + "an operator configures the shop and lands in the back office": "d9598e06b3b0", + "setup refuses to run twice": "5266661f6249", + "a merchant describes a product and finds it in the list": "8c21baa382ea", + "a staff member signs in and lands on the page they asked for": "51610259902d", + "its sections and account actions live in a drawer": "d05a243ec3cf" +} diff --git a/tools/ozalid/push.mjs b/tools/ozalid/push.mjs new file mode 100644 index 0000000..8e7e9d0 --- /dev/null +++ b/tools/ozalid/push.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +/* + * Pushes the journeys' captures to ozalid, where they are reviewed. + * + * Run locally, after `just e2e`, and **before** opening a pull request: the + * visual result is one of the two things this project says cannot be + * self-granted (CLAUDE.md), and a capture reaching CI is a capture nobody + * looked at in time. + * + * The evidence comes from Playwright's own reports rather than from the + * artefact directory: the reports carry the case titles and the step names in + * words, where the directory carries truncated slugs. + * + * What changed is not computed here. Every capture is hashed, ozalid is asked + * which addresses it does not hold, and what it does not hold is exactly what + * changed — content addressing answers the question for us. + */ + +import { createHash } from 'node:crypto' +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' + +const ROOT = new URL('../..', import.meta.url).pathname +const REPORTS = join(ROOT, 'apps/frontend/tmp/e2e-report') +/* + * Which ozalid case each journey is, committed to the repository. + * + * ozalid generates a case's id and says the client stores it and never invents + * one. Matching on the title instead would open a second case the day somebody + * rewords a test — silently, and with the history left behind on the old one. + * Keeping the map in git makes a rename visible in review, where it is fixed by + * editing one line rather than by losing a case. + */ +const CASES = join(ROOT, 'tools/ozalid/cases.json') + +/** Shows what would be sent, and writes nothing anywhere. */ +const DRY = process.argv.includes('--dry-run') + +const BASE = process.env.OZALID_URL +const TOKEN = process.env.OZALID_TOKEN +const PROJECT = process.env.OZALID_PROJECT ?? 'chalendia' + +if (!BASE || !TOKEN) { + console.error('OZALID_URL and OZALID_TOKEN are required — see .env.example') + process.exit(1) +} + +/** + * A variant, as the axes this project renders on. + * + * ozalid ships no list of axes and never invents one: they are created by + * first use, and their order is what a variant's label reads from. + */ +function axesOf(variant) { + const [viewport, theme] = variant.split('-') + return { viewport, theme } +} + +async function call(path, options = {}) { + const response = await fetch(`${BASE}/api${path}`, { + ...options, + headers: { + Authorization: `Bearer ${TOKEN}`, + ...(options.body && !options.raw ? { 'Content-Type': 'application/json' } : {}), + ...options.headers, + }, + }) + return response +} + +/** Walks a Playwright report and yields one entry per captured step. */ +function stepsOf(report) { + const found = [] + + const walk = (suites) => { + for (const suite of suites ?? []) { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const result of test.results ?? []) { + for (const attachment of result.attachments ?? []) { + if (attachment.contentType !== 'image/png' || !attachment.body) continue + found.push({ + case: spec.title, + // Playwright prefixes a named step; the bare `screenshot` is + // the end-of-test one, which is a moment like any other. + step: attachment.name.replace(/^step: /, '') || 'end of journey', + bytes: Buffer.from(attachment.body, 'base64'), + }) + } + } + } + } + walk(suite.suites) + } + } + + walk(report.suites) + return found +} + +const files = (await readdir(REPORTS)).filter((name) => /^report-.+\.json$/.test(name)) +if (files.length === 0) { + console.error(`no report in ${REPORTS} — run \`just e2e\` first`) + process.exit(1) +} + +// case title → step name → variant → bytes +const book = new Map() +const blobs = new Map() + +for (const file of files) { + const variant = file.replace(/^report-|\.json$/g, '') + const report = JSON.parse(await readFile(join(REPORTS, file), 'utf8')) + + for (const entry of stepsOf(report)) { + const hash = `sha256:${createHash('sha256').update(entry.bytes).digest('hex')}` + blobs.set(hash, entry.bytes) + + const steps = book.get(entry.case) ?? new Map() + const captures = steps.get(entry.step) ?? [] + captures.push({ variant: axesOf(variant), hash }) + steps.set(entry.step, captures) + book.set(entry.case, steps) + } +} + +console.log(`${book.size} cases, ${blobs.size} distinct captures across ${files.length} variants`) + +// ── The cases, by the map this repository keeps ─────────────────────────── +const stored = await readFile(CASES, 'utf8').then(JSON.parse).catch(() => ({})) +const byTitle = new Map(Object.entries(stored)) + +const missing = [...book.keys()].filter((title) => !byTitle.has(title)) +const unused = [...byTitle.keys()].filter((title) => !book.has(title)) + +if (unused.length > 0) { + // Not an error: a journey may be temporarily skipped. Said out loud, because + // the other reading is that somebody reworded a test and is about to open a + // duplicate case. + console.warn(`\n${unused.length} case(s) in the map that this run did not produce:`) + for (const title of unused) console.warn(` ${byTitle.get(title)} ${title}`) + console.warn('If one was renamed, edit tools/ozalid/cases.json rather than opening a second case.\n') +} + +if (DRY) { + console.log(`\nwould open ${missing.length} case(s):`) + for (const title of missing) console.log(` ${title}`) + let absent = 0 + for (const hash of blobs.keys()) { + const held = await call(`/projects/${PROJECT}/blobs/${hash}`, { method: 'HEAD' }) + if (!held.ok) absent += 1 + } + console.log(`\nwould send ${absent} of ${blobs.size} captures — the rest is already held`) + console.log(`would push an edition of ${book.size} cases`) + process.exit(0) +} + +const categories = await (await call(`/projects/${PROJECT}/categories`)).json() +let categoryId = categories[0]?.id + +if (!categoryId) { + const made = await call(`/projects/${PROJECT}/categories`, { + method: 'POST', + body: JSON.stringify({ name: 'Journeys' }), + }) + if (!made.ok) { + console.error(`cannot open a category: ${made.status} ${await made.text()}`) + process.exit(1) + } + categoryId = (await made.json()).id + console.log(`opened category "Journeys"`) +} + +for (const title of missing) { + + const made = await call(`/projects/${PROJECT}/cases`, { + method: 'POST', + body: JSON.stringify({ title, categoryId }), + }) + if (!made.ok) { + console.error(`cannot open case "${title}": ${made.status} ${await made.text()}`) + process.exit(1) + } + const opened = await made.json() + byTitle.set(title, opened.id) + console.log(`opened case ${opened.id} ${title}`) +} + +// Written before the edition: a case opened and forgotten is a case that gets +// opened twice on the next run. +await writeFile(CASES, `${JSON.stringify(Object.fromEntries(byTitle), undefined, 2)}\n`) + +// ── Upload only what the store does not hold ────────────────────────────── +let sent = 0 +for (const [hash, bytes] of blobs) { + const held = await call(`/projects/${PROJECT}/blobs/${hash}`, { method: 'HEAD' }) + if (held.ok) continue + + const put = await call(`/projects/${PROJECT}/blobs/${hash}`, { + method: 'PUT', + body: bytes, + raw: true, + headers: { 'Content-Type': 'image/png' }, + }) + if (!put.ok) { + console.error(`cannot send ${hash}: ${put.status} ${await put.text()}`) + process.exit(1) + } + sent += 1 +} + +console.log( + sent === 0 + ? 'nothing changed: the store already holds every capture' + : `${sent} capture(s) changed and were sent`, +) + +// ── The manifest ────────────────────────────────────────────────────────── +const manifest = { + revision: process.env.OZALID_REVISION ?? 'working tree', + cases: [...book].map(([title, steps]) => ({ + id: byTitle.get(title), + steps: [...steps].map(([name, captures]) => ({ name, captures })), + })), +} + +const pushed = await call(`/projects/${PROJECT}/editions`, { + method: 'POST', + body: JSON.stringify(manifest), +}) + +if (!pushed.ok) { + console.error(`intake refused: ${pushed.status}`) + console.error(await pushed.text()) + process.exit(1) +} + +const accepted = await pushed.json() +console.log( + `edition ${accepted.editionId}: ${accepted.cases} cases, ${accepted.captures} captures`, +) +console.log(`review it at ${BASE}/projects/${PROJECT}`)