diff --git a/packages/cli/src/commands/test/__tests__/dryRunTranscripts.test.ts b/packages/cli/src/commands/test/__tests__/dryRunTranscripts.test.ts index 8a5fb0c5..a8a0aa64 100644 --- a/packages/cli/src/commands/test/__tests__/dryRunTranscripts.test.ts +++ b/packages/cli/src/commands/test/__tests__/dryRunTranscripts.test.ts @@ -27,6 +27,7 @@ import chalk from 'chalk'; import { DRY_RUN_TRANSCRIPTS, DRY_RUN_TRANSCRIPT_IDS, + FIXTURE_ROOTS, type TranscriptScenario, } from '../dryRun.transcripts'; import { renderScenarioTranscript, transcriptForCapture } from '../renderTranscript'; @@ -74,6 +75,105 @@ describe('the dry-run transcript catalog', () => { expect(unstated).toEqual([]); }); + it("every scenario answers for a fixture under one of the catalog's declared roots", () => { + // A fixture path is resolved by joining it onto the sherlo-tester checkout, + // so a path under ANY tree in that repository resolves. What this case is + // for is the other direction: a root nobody declared is how a typo, or a + // tree that quietly moved, reaches a consumer as a missing file rather than + // as a review finding. + const declaredRoots = Object.values(FIXTURE_ROOTS); + const strays = Object.entries(DRY_RUN_TRANSCRIPTS) + .filter( + ([, scenario]) => !declaredRoots.some((root) => scenario.fixture.startsWith(`${root}/`)) + ) + .map(([id, scenario]) => `${id} -> ${scenario.fixture}`); + + expect( + strays, + 'a scenario names a fixture root this catalog does not declare - add it to FIXTURE_ROOTS, ' + + 'saying what lives there, or fix the path' + ).toEqual([]); + }); + + it('the six LEGACY fixture paths are UNCHANGED - the legacy suites still consume them', () => { + // Pinned as literals, not derived from FIXTURE_ROOTS: a case that composed + // the expected paths the same way the catalog does would agree with any + // move of the root, which is exactly the change it exists to catch. These + // six are consumed by suites that are still green, so moving one is a red + // capture night, not a refactor. + // + // It reads the six BY ID rather than comparing the whole catalog, because + // those are two different claims: "these six have not moved" is a law, and + // "there are exactly six" was never one - the catalog is meant to grow. A + // missing id still reds here (it reads back as undefined), so a deletion is + // caught as loudly as a move. + const legacyFixtures = { + 'dry-run-single-platform-nothing-to-capture': + 'e2e/suites/snapshots/test-bundled/06-single-platform.spec.ts-snapshots/u4-single-platform-cli-Test-Bundled-darwin.txt', + 'dry-run-cold-start': + 'e2e/suites/snapshots/test-bundled/09-cold-start.spec.ts-snapshots/c3-cold-start-cli-Test-Bundled-darwin.txt', + 'dry-run-provenance-guard-skip': + 'e2e/suites/snapshots/test-bundled/10-provenance-guard-skip.spec.ts-snapshots/c4b-guard-skip-cli-Test-Bundled-darwin.txt', + 'dry-run-api-unreachable': + 'e2e/suites/snapshots/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f4-api-unreachable-cli-Test-Bundled-Checks-darwin.txt', + 'dry-run-detached-head': + 'e2e/suites/snapshots/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-detached-head-cli-Test-Bundled-Checks-darwin.txt', + 'dry-run-no-git': + 'e2e/suites/snapshots/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-no-git-cli-Test-Bundled-Checks-darwin.txt', + }; + + const declared = Object.fromEntries( + Object.keys(legacyFixtures).map((id) => [id, DRY_RUN_TRANSCRIPTS[id]?.fixture]) + ); + + expect(declared).toEqual(legacyFixtures); + }); + + it('every declared root is a path RELATIVE TO THE CHECKOUT ROOT', () => { + // The one claim about a root that holds whether or not the tree behind it + // exists yet, and the one both consumers depend on: a root is joined onto a + // repository root, so an absolute path or a `..` escape would resolve + // somewhere neither the ratchet nor `expected-render` means. This runs with + // no checkout, which is what lets a root be declared BEFORE the tree it + // names is created - `e2e-beats/suites` is such a root today. + const malformed = Object.entries(FIXTURE_ROOTS) + .filter( + ([, root]) => root.startsWith('/') || root.endsWith('/') || root.split('/').includes('..') + ) + .map(([name, root]) => `${name} -> ${root}`); + + expect( + malformed, + 'a fixture root must be relative to the sherlo-tester repo root, with no trailing slash' + ).toEqual([]); + }); + + it.runIf(TESTER_AVAILABLE)( + 'every root a SCENARIO ANSWERS UNDER is a real tree in the checkout', + () => { + // Scoped to the roots in use, because that is the strongest form of this + // claim that can be true: a root may be declared before its tree exists + // (the beats suites are not in the checkout yet), and a case that demanded + // otherwise would red for a tree nobody has authored rather than for a + // mistake. The moment a scenario answers under a root, the root has to be + // there - and the per-scenario `fixtureExists` case below then proves the + // whole path, not just its head. + const rootsInUse = new Set( + Object.values(DRY_RUN_TRANSCRIPTS).map( + (scenario) => + Object.values(FIXTURE_ROOTS).find((root) => scenario.fixture.startsWith(`${root}/`)) ?? + '' + ) + ); + + const unreachable = [...rootsInUse].filter((root) => !fixtureExists(root)); + + expect(unreachable, 'a fixture root a scenario answers under is not in the checkout').toEqual( + [] + ); + } + ); + it('every scenario scripts a bundle for every platform it puts under test', () => { // A missing bundle is a refusal at render time; catching it here names every // offender at once instead of one per run. @@ -88,13 +188,34 @@ describe('the dry-run transcript catalog', () => { it.runIf(TESTER_AVAILABLE)('every scenario names a fixture that exists', () => { const missing = Object.entries(DRY_RUN_TRANSCRIPTS) - .filter(([, s]) => !fixtureExists(s.fixture)) + .filter(([, s]) => !s.fixtureNotMintedYet && !fixtureExists(s.fixture)) .map(([id, s]) => `${id} -> ${s.fixture}`); expect(missing, 'a scenario answers for a fixture that is not in the tree').toEqual([]); }); + it.runIf(TESTER_AVAILABLE)('a NOT-MINTED-YET marker cannot outlive the mint', () => { + // The whole risk of that marker is that it becomes a permanent opt-out of + // the byte case. It cannot: the moment `yarn tester expected-render` writes + // the bytes, this case reds until the marker comes off - which is what turns + // the scenario back into a ratcheted one. A marker is a statement about the + // tree, and this is what keeps it true. + const stale = Object.entries(DRY_RUN_TRANSCRIPTS) + .filter(([, s]) => s.fixtureNotMintedYet && fixtureExists(s.fixture)) + .map(([id, s]) => `${id} -> ${s.fixture}`); + + expect( + stale, + 'this fixture HAS been minted, so the scenario is ratchetable now: drop ' + + '`fixtureNotMintedYet` and let the byte case judge it' + ).toEqual([]); + }); + for (const id of DRY_RUN_TRANSCRIPT_IDS) { - it.runIf(TESTER_AVAILABLE)( + // A scenario whose fixture has never been minted has nothing to be identical + // TO - it is the render that will create those bytes. Every other case in + // this file still covers it, the determinism case below included, and the + // staleness case above turns this one back on the moment the mint lands. + it.runIf(TESTER_AVAILABLE && !DRY_RUN_TRANSCRIPTS[id].fixtureNotMintedYet)( `${id}: renders byte-identically to its committed fixture`, async () => { const scenario = DRY_RUN_TRANSCRIPTS[id]; diff --git a/packages/cli/src/commands/test/dryRun.transcripts.ts b/packages/cli/src/commands/test/dryRun.transcripts.ts index b23fdb3b..ed8cb92a 100644 --- a/packages/cli/src/commands/test/dryRun.transcripts.ts +++ b/packages/cli/src/commands/test/dryRun.transcripts.ts @@ -99,8 +99,31 @@ export type TranscriptScenario = { * though it was printed in the middle of the run. */ capture: 'stdout' | 'stdout+stderr'; - /** The committed fixture this scenario must render byte-identically. */ + /** + * The committed fixture this scenario must render byte-identically, as a path + * relative to the sherlo-tester repo ROOT - not to any one tree inside it. See + * {@link FIXTURE_ROOTS} for the roots this catalog draws from. + */ fixture: string; + /** + * Set ONLY while {@link fixture} names a path whose bytes DO NOT EXIST YET, + * because this scenario is what mints them. + * + * The suite-snapshot scenarios run the other way round: a real device run + * wrote the fixture, a person reviewed it into git, and the ratchet proves the + * CLI still reproduces it. A beats chapter has no such run to wait for - its + * expected transcript is minted THROUGH this catalog (`yarn tester + * expected-render` renders the scenario and writes the file), so the path must + * be named here before anything is there to compare against. Without this + * marker that ordering is simply a red ratchet: the byte case would read a + * file that cannot exist until the byte case passes. + * + * It is not a way to opt out of the ratchet, and it cannot linger: the ratchet + * REFUSES a scenario that carries this marker once its fixture is in the tree + * (`dryRunTranscripts.test.ts`), so the mint that creates the bytes is also + * what forces the marker off and the byte case on. + */ + fixtureNotMintedYet?: true; state: DryRunTranscriptState; }; @@ -144,7 +167,45 @@ function firstBuildEverywhere(platforms: Platform[]): ComputeDiffScopeDryRunResu const BOTH_PLATFORMS: Platform[] = ['android', 'ios']; -const SNAPSHOTS = 'e2e/suites/snapshots'; +/** + * THE FIXTURE ROOTS this catalog draws from, each a path relative to the + * SHERLO-TESTER REPO ROOT - the base both consumers resolve a `fixture` against: + * the ratchet's `committedFixture` joins it onto the checkout, and + * `yarn tester expected-render --check` joins it onto that repository's root. + * + * Neither consumer has ever cared which tree inside the checkout a path lands + * in; what this catalog lacked was a place to SAY which trees it draws from, so + * that a fixture outside the snapshot trees reads as a decision rather than as a + * typo nobody caught. + */ +export const FIXTURE_ROOTS = { + /** + * The suite snapshot trees: a fixture a Playwright spec captured from a real + * device run against the real backend, and a person reviewed into git. Every + * scenario below answers under this root. + */ + suiteSnapshots: 'e2e/suites/snapshots', + /** + * The BEATS suites tree. A beats chapter keeps its kept-output fixtures BESIDE + * THE CHAPTER, in Playwright's default snapshot sidecar, so a transcript a + * beat shows is named: + * + * `//.spec.ts-snapshots/--.txt` + * + * e.g. `diff-scope/closure/01-.spec.ts-snapshots/-Diff-Scope-Closure-darwin.txt`. + * That file name is not an author's choice: it carries the Playwright PROJECT + * NAME with its spaces hyphenated, and the platform, because that is + * Playwright's default `snapshotPathTemplate` and no beats project pins its + * own - so the path a scenario names here follows from the project its chapter + * runs under. + * + * A beat is read before any run of the behaviour exists - that is what an + * expected report is - so a scenario answering under this root has no captured + * snapshot behind it, and its `groundedBy` is `captured-run` or `enumerated`: + * `derived` names an upstream fixture, and a beat has none. + */ + beatsSnapshots: 'e2e-beats/suites', +} as const; export const DRY_RUN_TRANSCRIPTS: Record = { 'dry-run-single-platform-nothing-to-capture': { @@ -154,11 +215,11 @@ export const DRY_RUN_TRANSCRIPTS: Record = { 'capture" and the one the inversion hazard exists to protect.', groundedBy: { kind: 'derived', - fromFixture: `${SNAPSHOTS}/test-bundled/06-single-platform.spec.ts-snapshots/u4-single-platform-cli-Test-Bundled-darwin.txt`, + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/06-single-platform.spec.ts-snapshots/u4-single-platform-cli-Test-Bundled-darwin.txt`, }, ambient: { skipIntro: false }, capture: 'stdout', - fixture: `${SNAPSHOTS}/test-bundled/06-single-platform.spec.ts-snapshots/u4-single-platform-cli-Test-Bundled-darwin.txt`, + fixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/06-single-platform.spec.ts-snapshots/u4-single-platform-cli-Test-Bundled-darwin.txt`, state: { platformsToTest: ['android'], bundles: { android: bundleFor('android') }, @@ -186,11 +247,11 @@ export const DRY_RUN_TRANSCRIPTS: Record = { 'verbatim why - never the "couldn\'t compute" degrade.', groundedBy: { kind: 'derived', - fromFixture: `${SNAPSHOTS}/test-bundled/09-cold-start.spec.ts-snapshots/c3-cold-start-cli-Test-Bundled-darwin.txt`, + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/09-cold-start.spec.ts-snapshots/c3-cold-start-cli-Test-Bundled-darwin.txt`, }, ambient: { skipIntro: false }, capture: 'stdout', - fixture: `${SNAPSHOTS}/test-bundled/09-cold-start.spec.ts-snapshots/c3-cold-start-cli-Test-Bundled-darwin.txt`, + fixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/09-cold-start.spec.ts-snapshots/c3-cold-start-cli-Test-Bundled-darwin.txt`, state: { platformsToTest: BOTH_PLATFORMS, bundles: { android: bundleFor('android'), ios: bundleFor('ios') }, @@ -207,11 +268,11 @@ export const DRY_RUN_TRANSCRIPTS: Record = { 'for the guard-skip route.', groundedBy: { kind: 'derived', - fromFixture: `${SNAPSHOTS}/test-bundled/10-provenance-guard-skip.spec.ts-snapshots/c4b-guard-skip-cli-Test-Bundled-darwin.txt`, + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/10-provenance-guard-skip.spec.ts-snapshots/c4b-guard-skip-cli-Test-Bundled-darwin.txt`, }, ambient: { skipIntro: false }, capture: 'stdout', - fixture: `${SNAPSHOTS}/test-bundled/10-provenance-guard-skip.spec.ts-snapshots/c4b-guard-skip-cli-Test-Bundled-darwin.txt`, + fixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/10-provenance-guard-skip.spec.ts-snapshots/c4b-guard-skip-cli-Test-Bundled-darwin.txt`, state: { platformsToTest: BOTH_PLATFORMS, bundles: { android: bundleFor('android'), ios: bundleFor('ios') }, @@ -228,11 +289,11 @@ export const DRY_RUN_TRANSCRIPTS: Record = { "a bail-open carries no reason and no denominator - and no error text on the user's line.", groundedBy: { kind: 'derived', - fromFixture: `${SNAPSHOTS}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f4-api-unreachable-cli-Test-Bundled-Checks-darwin.txt`, + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f4-api-unreachable-cli-Test-Bundled-Checks-darwin.txt`, }, ambient: { skipIntro: false }, capture: 'stdout', - fixture: `${SNAPSHOTS}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f4-api-unreachable-cli-Test-Bundled-Checks-darwin.txt`, + fixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f4-api-unreachable-cli-Test-Bundled-Checks-darwin.txt`, state: { platformsToTest: BOTH_PLATFORMS, bundles: { android: bundleFor('android'), ios: bundleFor('ios') }, @@ -251,11 +312,11 @@ export const DRY_RUN_TRANSCRIPTS: Record = { 'detached-head case degrades nothing a user sees.', groundedBy: { kind: 'derived', - fromFixture: `${SNAPSHOTS}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-detached-head-cli-Test-Bundled-Checks-darwin.txt`, + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-detached-head-cli-Test-Bundled-Checks-darwin.txt`, }, ambient: { skipIntro: false }, capture: 'stdout+stderr', - fixture: `${SNAPSHOTS}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-detached-head-cli-Test-Bundled-Checks-darwin.txt`, + fixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-detached-head-cli-Test-Bundled-Checks-darwin.txt`, state: { platformsToTest: BOTH_PLATFORMS, bundles: { android: bundleFor('android'), ios: bundleFor('ios') }, @@ -271,11 +332,11 @@ export const DRY_RUN_TRANSCRIPTS: Record = { 'fixture, because the fixture is stdout followed by stderr.', groundedBy: { kind: 'derived', - fromFixture: `${SNAPSHOTS}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-no-git-cli-Test-Bundled-Checks-darwin.txt`, + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-no-git-cli-Test-Bundled-Checks-darwin.txt`, }, ambient: { skipIntro: false }, capture: 'stdout+stderr', - fixture: `${SNAPSHOTS}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-no-git-cli-Test-Bundled-Checks-darwin.txt`, + fixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled-checks/02-failure-modes.spec.ts-snapshots/f6-no-git-cli-Test-Bundled-Checks-darwin.txt`, state: { platformsToTest: BOTH_PLATFORMS, bundles: { android: bundleFor('android'), ios: bundleFor('ios') }, @@ -283,6 +344,56 @@ export const DRY_RUN_TRANSCRIPTS: Record = { decision: { outcome: 'answered', result: firstBuildEverywhere(BOTH_PLATFORMS) }, }, }, + + /** + * THE FIRST SCENARIO THAT ANSWERS FOR A BEAT rather than for a suite snapshot, + * and the one the Diff Scope Closure chapter is waiting on. Everything about + * it was read off that chapter rather than assumed: + * + * - the fixture path is the slot's own sidecar. The chapter is + * `e2e-beats/suites/diff-scope/closure/01-asking-costs-nothing.spec.ts`, + * its Playwright project is `Diff Scope Closure`, and its kept-output slot + * is `first-dry-run-capture-plan` - so Playwright's default template spells + * `-Diff-Scope-Closure-darwin.txt` beside the chapter, which is also + * the shape the beats report's own `committedSnapshot` looks for; + * - BOTH platforms, because the chapter runs a BARE `sherlo test --dry-run` + * and the `test-bundled` variant's config lists a Pixel and an iPhone; + * - a confident FULL capture on each, because the chapter mints its project + * in `beforeAll` and asks immediately: there is no ancestor to compare + * against, which is the same rung the cold start answers on; + * - `skipIntro: false`, because the beats dry-run action passes no `env` to + * `runSherloTest`, so the wordmark prints - and the chapter keeps stdout + * only, which is what its beat attaches. + * + * WHY THE STATE IS THE COLD START'S, DELIBERATELY. This renders the same bytes + * as `dry-run-cold-start`, and that is the strongest thing that could be true + * of a fixture nothing has captured yet: those bytes are already proven, by a + * committed fixture a real device run against the real backend produced, to be + * what this CLI prints for this state on this app. The denominator is not + * invented either - the `test-bundled` variant's narrowed Storybook glob fixes + * the manifest at seven stories, which is why the plan reads "all 7". + */ + 'diff-scope-closure-first-dry-run': { + description: + 'THE DIFF SCOPE CLOSURE SKELETON, asking what would be photographed before anything is ' + + 'pushed. A freshly minted project on the `test-bundled` variant has no ancestor to compare ' + + 'against, so both platforms answer with a confident full capture and the plan reads "would ' + + 'capture all 7 stories" - the dry run\'s own verb, never the live "capturing".', + groundedBy: { + kind: 'derived', + fromFixture: `${FIXTURE_ROOTS.suiteSnapshots}/test-bundled/09-cold-start.spec.ts-snapshots/c3-cold-start-cli-Test-Bundled-darwin.txt`, + }, + ambient: { skipIntro: false }, + capture: 'stdout', + fixture: `${FIXTURE_ROOTS.beatsSnapshots}/diff-scope/closure/01-asking-costs-nothing.spec.ts-snapshots/first-dry-run-capture-plan-Diff-Scope-Closure-darwin.txt`, + fixtureNotMintedYet: true, + state: { + platformsToTest: BOTH_PLATFORMS, + bundles: { android: bundleFor('android'), ios: bundleFor('ios') }, + gitInfoAvailable: true, + decision: { outcome: 'answered', result: firstBuildEverywhere(BOTH_PLATFORMS) }, + }, + }, }; export const DRY_RUN_TRANSCRIPT_IDS = Object.keys(DRY_RUN_TRANSCRIPTS); diff --git a/packages/cli/src/commands/test/renderTranscript.ts b/packages/cli/src/commands/test/renderTranscript.ts index 9a2113ed..4d22544e 100644 --- a/packages/cli/src/commands/test/renderTranscript.ts +++ b/packages/cli/src/commands/test/renderTranscript.ts @@ -134,6 +134,21 @@ function groundingFor(entry: CatalogEntry): string { return entry.scenario.groundedBy.kind; } +/** + * Whether the scenario's fixture is a path this render is about to CREATE rather + * than one it must match. + * + * Published because the consumer cannot work it out and would otherwise get it + * wrong in the worst direction: `expected-render --check` reads a missing file + * as a DIVERGENCE, so a scenario naming a beats sidecar nothing has minted yet + * would report as "the CLI changed what a user sees" - the one message that + * road must never say untruthfully. `false` for every family but the dry-run + * one, which is the only family whose fixtures are minted through this road. + */ +function fixtureNotMintedYetFor(entry: CatalogEntry): boolean { + return entry.family === 'dry-run' && entry.scenario.fixtureNotMintedYet === true; +} + /** The git info a scenario that CAN read git reports. Fixed, never a wall-clock read. */ const SCRIPTED_GIT_INFO: GitInfo = { commitName: 'the commit this scenario was grounded on', @@ -162,6 +177,8 @@ type TranscriptEnvelope = { * a consumer must not read such a transcript as the DEFAULT experience. */ grounded: string; + /** `true` -> {@link fixture} is this render's mint TARGET, not a baseline it must match. */ + fixtureNotMintedYet: boolean; command: string; exitCode: number; capture: TranscriptScenario['capture']; @@ -213,6 +230,7 @@ export async function runRenderTranscript(scenarioId: string): Promise { family, fixture: fixtureFor(entry), grounded: groundingFor(entry), + fixtureNotMintedYet: fixtureNotMintedYetFor(entry), command: `sherlo test --dry-run --render-transcript ${scenarioId}`, // Neither a dry run nor a scripted wait creates anything or routes // anything; both always complete. @@ -366,6 +384,8 @@ type CatalogIndexEntry = { capture: string; /** `gated-shipped` -> the shipped path emits these, but only when opted in. */ grounded: string; + /** `true` -> the fixture is a mint TARGET; a `--check` must not read its absence as a divergence. */ + fixtureNotMintedYet: boolean; }; function transcriptCatalogIndex(): Record { @@ -376,6 +396,7 @@ function transcriptCatalogIndex(): Record { fixture: fixtureFor(entry), capture: entry.scenario.capture, grounded: groundingFor(entry), + fixtureNotMintedYet: fixtureNotMintedYetFor(entry), }; } return index; @@ -409,7 +430,9 @@ function formatTranscriptCatalog(): string { const provenance = fixture === null ? ` fixture: none - ${WHY_NO_FIXTURE[grounding] ?? grounding}` - : ` fixture: ${fixture}`; + : ` fixture: ${fixture}${ + fixtureNotMintedYetFor(entry) ? ' (NOT MINTED YET - this render creates it)' : '' + }`; return ( ` ${id} (${entry.family})\n ${entry.scenario.description}\n${provenance}\n` +