import { describe, it, expect } from 'vitest'; import { buildMermaid } from '@beehexa/hexasync-template-report-render'; import { reconcile } from '@beehexa/hexasync-template-compose'; import { renderReport } from '@beehexa/hexasync-template-report-render'; import { renderReportHtml } from '@beehexa/hexasync-template-report-render'; import { ComponentFlow, TracerEvent } from '@beehexa/hexasync-template-compose'; import { withSource } from './withSource'; describe('buildMermaid — puller/pusher flow charts', () => { it('renders a linear pusher chain via string `next`', () => { const flow: ComponentFlow = { kind: 'pusher', phases: [ { name: 'pushSteps', steps: [ { key: 'A', label: 'Get details', next: 'B', rootStep: true }, { key: 'B', label: 'Save', next: 'C', rootStep: false }, { key: 'C', label: 'Save detail', next: null, rootStep: false }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); expect(mmd.startsWith('flowchart LR')).toBe(true); /** * A LONE stage IS boxed, and named (Story 4.6 review, H1). * * ⛔ This asserted `not.toContain('subgraph')`, justified as *"the shared renderer's rule, and the retired one's * too — one stage is not worth a box around it"*. The retired emitter had no such rule: it boxed every phase * unconditionally, and the assertion this one replaced was `toContain('subgraph sg_pushSteps["pushSteps"]')` on * this very fixture. The quote is `frontend-flow`'s, about a frontend WORKFLOW. * * **1,293 of the corpus's 2,065 composed pullers/pushers declare exactly one non-empty stage**, so the omission * dropped the box and the stage's name from 63% of the report's charts — under a caption reading "1 phase(s)" that * never says which. Story 4.5 declined `multiStage: true` for the EDITOR, correctly: its retired renderer did not * box a lone stage. Two surfaces, two baselines, one renderer taking it as an argument. */ expect(mmd).toContain('subgraph pushers__id_FIXTURE__pushSteps["Push"]'); /** * The full AD-33 address, spelled out: `:id_::key_`, rendered with `__` for * every `:`. The retired emitter's id was `pushSteps_A` — a name unique only within the one component it was * drawing, which is why the report and the editor could not state a correspondence between their nodes. */ /** * ⚠️ The label is COMPOSED, not the step's name alone (upgraded to `2608.20.32`, 2026-08-20). * `composeNodeLabel` in `template-model` now draws `name · KEY · TYPE`, so a reader beside the YAML can * tell which `key:` a box is — and `next:` / `outputs:` reference steps BY KEY, which the name never gave them. * One composer serves this report and the extension's diagram, so the two cannot label differently. */ expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A["Get details · A"]', ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A --> pushers__id_FIXTURE__pushSteps__key_B', ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_B --> pushers__id_FIXTURE__pushSteps__key_C', ); // Terminal step (next null) has no outgoing edge. expect(mmd).not.toContain('pushers__id_FIXTURE__pushSteps__key_C -->'); }); it('chains phases entry-to-entry (before → pull → after)', () => { const flow: ComponentFlow = { kind: 'puller', phases: [ { name: 'beforePullSteps', steps: [{ key: 'PREP', label: 'Prep', next: null, rootStep: true }], }, { name: 'pullSteps', steps: [{ key: 'PULL', label: 'Pull', next: null, rootStep: true }], }, { name: 'afterPullSteps', steps: [{ key: 'DONE', label: 'Done', next: null, rootStep: true }], }, ], }; const mmd = buildMermaid(withSource(flow)); // Stage edges join the STEPS, terminal to entry (verdict `stage-edges-node-not-subgraph`, ADOPT) — the retired // emitter chained entry-to-entry, which is the same pair here because each stage holds one step. expect(mmd).toMatch(/beforePullSteps__key_PREP --> \S*pullSteps__key_PULL/); expect(mmd).toMatch(/pullSteps__key_PULL --> \S*afterPullSteps__key_DONE/); // …and each stage is its own subgraph, titled by the stage's PROSE label rather than its raw key. expect(mmd).toContain('subgraph'); expect(mmd).toContain('["Before pull"]'); }); it('renders an if/then/else `next` object as a YES/NO diamond', () => { const flow: ComponentFlow = { kind: 'pusher', phases: [ { name: 'pushSteps', steps: [ { key: 'A', label: 'Check', next: { if: 'x > 1', then: 'B', else: 'C' }, rootStep: true, }, { key: 'B', label: 'Yes path', next: null, rootStep: false }, { key: 'C', label: 'No path', next: null, rootStep: false }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); /** * Two things are pinned on one line, and neither is cosmetic. * * **`>`, not `>`.** The shipped report's `escapeLabel` escaped only `"` and newlines, so mermaid read a `<` or * `>` in a label as HTML and swallowed it: a step named `Sync AD` rendered as `Sync AD` (Story 4.4 review, * HIGH-3). This assertion pinned the pre-fix behaviour, which is why nothing caught it. Adopted under the * `escaping-widened-amp-lt-gt` verdict, and now there is one escaper rather than two agreeing by hand — they had * diverged on exactly the difference the golden suite cannot see, because it renders nothing this file emits. * * **A STEP IS NEVER ITSELF THE DIAMOND.** The decision gets its own node, addressed by the property that carries * it — `…key_A:next:if` — where the retired emitter counted them (`D_1`, `D_2`, …). That counter is the positional * identity AD-33 exists to remove: inserting a branch above renumbered every diamond below it. */ expect(mmd).toMatch( /pushers__id_FIXTURE__pushSteps__key_A__next__if\{"x > 1"\}/, ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A --> pushers__id_FIXTURE__pushSteps__key_A__next__if', ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A__next__if -->|"YES"| pushers__id_FIXTURE__pushSteps__key_B', ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A__next__if -->|"NO"| pushers__id_FIXTURE__pushSteps__key_C', ); }); it('routes a Scriban expression `next` through an EXPR diamond to quoted keys', () => { const flow: ComponentFlow = { kind: 'pusher', phases: [ { name: 'pushSteps', steps: [ { key: 'A', label: 'Branch', next: '{{ if cond "B" else "C" end }}', rootStep: true, }, { key: 'B', label: 'B', next: null, rootStep: false }, { key: 'C', label: 'C', next: null, rootStep: false }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); // Same rule as the IF diamond, and the junction is named by WHAT it is rather than counted: `next:expression`. // Its label carries the expression itself, so a reader can see which route is a run-time decision. expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A__next__expression{"{{ if cond "B" else "C" end }}"}', ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A__next__expression --> pushers__id_FIXTURE__pushSteps__key_B', ); expect(mmd).toContain( 'pushers__id_FIXTURE__pushSteps__key_A__next__expression --> pushers__id_FIXTURE__pushSteps__key_C', ); }); it('escapes quotes/newlines in labels and sanitizes ids', () => { const flow: ComponentFlow = { kind: 'puller', phases: [ { name: 'pullSteps', steps: [ { key: 'PULL-DATA', label: 'say "hi"\nnow', next: null, rootStep: true, }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); expect(mmd).toContain( '__pullSteps__key_PULL_DATA["say "hi" now · PULL-DATA"]', ); }); /** * The placeholder, on each route that can actually reach it (Story 4.6 review, M4). * * ⛔ There was ONE case here — `{ kind: 'puller', phases: [] }` — and after the swap it passed solely because the * fixture has no `source`, so it would have passed with any `phases` at all. The branches production reaches * (`nodes.length === 0`, no drawable workflow, an unknown collection) had no test between them. */ describe('the "No steps" placeholder', () => { const source = (over: Record) => ({ kind: 'puller', phases: [], ...over }) as unknown as ComponentFlow; it('a flow with NO source draws nothing — there is nothing to draw it from', () => { // The honest outcome, not a fallback: the legacy emitter that could work from `phases` alone is gone. expect(buildMermaid({ kind: 'puller', phases: [] })).toContain( 'No steps', ); }); it('a worker component whose stages hold no drawable step', () => { // Not the same as "no source": the model ran and produced zero nodes. A `!`-removed key is dropped by the // address algebra (AD-33 rule 6), so this is a reachable production shape. const mmd = buildMermaid( source({ source: { runtime: 'worker', collection: 'pullers', componentId: 'P', component: { pullSteps: [{ key: '!GONE' }] }, }, }), ); expect(mmd).toContain('No steps'); }); it('a collection the worker stage table does not describe', () => { // `WORKER_STAGES` has no list for `webhooks`, and guessing one would chart a component the model cannot describe. expect( buildMermaid( source({ source: { runtime: 'worker', collection: 'webhooks', component: { pullSteps: [{ key: 'A' }] }, }, }), ), ).toContain('No steps'); }); it('a frontend document declaring no creationSteps', () => { const mmd = buildMermaid( source({ kind: 'creation', source: { runtime: 'frontend', component: { creationSteps: [] } }, }), ); expect(mmd).toContain('No steps'); }); it('and the placeholder is never mistaken for a chart', () => { // Non-vacuity for all four: every case above would also "contain" the word inside a real diagram. expect(buildMermaid({ kind: 'puller', phases: [] })).toBe( 'flowchart LR\n empty["No steps"]', ); }); }); }); describe('reconcile — flow attached only for final puller/pusher components', () => { const ev = ( parentKey: string, idOrKey: string, generation: number, ): TracerEvent => ({ idOrKey, parentKey, operation: 'added', depth: 0, sourceFile: 'f.yaml', project: generation === 0 ? 'THIS' : 'anc', generation, weight: generation === 0 ? NaN : 1, }); const finalOutput = { pushers: [ { id: 'P1', name: 'Pusher One', pushSteps: [{ key: 'A', name: 'Step A', next: null, rootStep: true }], }, ], connectors: [{ id: 'C1', name: 'Conn' }], }; it('attaches a flow to a pusher but not to a connector', () => { const model = reconcile( [ev('pushers', 'P1', 0), ev('connectors', 'C1', 0)], [], finalOutput, [{ project: 'THIS', generation: 0, weight: NaN }], 'demo', ); const pusher = model.components.find((c) => c.idOrKey === 'P1'); const connector = model.components.find((c) => c.idOrKey === 'C1'); expect(pusher?.flow?.kind).toBe('pusher'); expect(pusher?.flow?.phases[0].name).toBe('pushSteps'); expect(connector?.flow).toBeUndefined(); }); it('renders the flow chart row (mermaid) in both MD and HTML reports', () => { const model = reconcile( [ev('pushers', 'P1', 0)], [], finalOutput, [{ project: 'THIS', generation: 0, weight: NaN }], 'demo', ); const md = renderReport(model); expect(md).toContain('```mermaid'); expect(md).toContain('__pushSteps__key_A["Step A · A"]'); const html = renderReportHtml(model); expect(html).toContain('id="mmd-0"'); expect(html).toContain('mermaid.esm.min.mjs'); expect(html).toContain('__pushSteps__key_A'); }); }); describe('provenance is grouped by type (collapsible; changed types open)', () => { const ev2 = ( parentKey: string, idOrKey: string, operation: 'added' | 'customized', generation: number, ): TracerEvent => ({ idOrKey, parentKey, operation, depth: 0, sourceFile: 'f.yaml', project: generation === 0 ? 'THIS' : 'anc', generation, weight: generation === 0 ? NaN : 1, }); // pullers has a real customization (change); webhooks is purely inherited. const snapshots = new Map(); snapshots.set(JSON.stringify(['pullers', 'PL']), { id: 'PL', name: 'old' }); const events: TracerEvent[] = [ ev2('pullers', 'PL', 'added', 1), ev2('pullers', 'PL', 'customized', 0), ev2('webhooks', 'WH', 'added', 1), ]; const finalOutput = { pullers: [{ id: 'PL', name: 'new' }], webhooks: [{ id: 'WH' }], }; const projects = [ { project: 'anc', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ]; it('MD: a changed type opens, an all-inherited type stays collapsed', () => { const md = renderReport( reconcile(events, [], finalOutput, projects, 'demo', { snapshots }), ); // pullers changed → open; webhooks inherited-only → collapsed. expect(md).toContain('
pullers'); expect(md).toContain('
webhooks'); // Type column no longer repeated inside the table (header dropped it). expect(md).not.toContain('Type'); }); it('HTML: groups render as
with per-type counts', () => { const html = renderReportHtml( reconcile(events, [], finalOutput, projects, 'demo', { snapshots }), ); expect(html).toContain( '
pullers', ); expect(html).toContain( '
webhooks', ); }); }); describe('buildMermaid — creationSteps workflow (flowchart TD)', () => { it('renders START → root, linear next, IF YES/NO, and terminal END', () => { const flow: ComponentFlow = { kind: 'creation', phases: [ { name: 'creationSteps', steps: [ { key: 'A', label: 'Start', next: 'B', rootStep: true, stepType: 'API', }, { key: 'B', label: 'Check', rootStep: false, stepType: 'IF', metadata: { then: 'C', else: 'D' }, }, { key: 'C', label: 'Yes path', next: null, rootStep: false }, { key: 'D', label: 'No path', next: null, rootStep: false }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); // `TD`, still — a creation workflow's branch tree reads top-down, and the report keeps the direction it shipped. expect(mmd.startsWith('flowchart TD')).toBe(true); /** * The START stadium is `creationSteps:start` — the workflow's own entry marker, not a step called `START`, which is * what the retired emitter's `nodeId('START')` implied. Its label is the workflow's registered title, from the * shared `FRONTEND_WORKFLOW_TITLES`, so the report and the editor now name it identically. * * No collection segment and no document id: `creationSteps` sits at the root of the composed output, so the * address starts at the workflow. */ expect(mmd).toContain('creationSteps__start(["Profile Creation"])'); expect(mmd).toContain('creationSteps__start --> creationSteps__key_A'); expect(mmd).toContain('creationSteps__key_A --> creationSteps__key_B'); /** * Here the STEP is the rhombus, where a worker IF step gets a separate diamond. Not an inconsistency: AD-21 keeps * the vocabularies apart because the runtimes differ — a frontend `IF` step's `metadata` holds its own branches, so * the step IS the decision, while a worker step's route is a property of it. */ // The rhombus keeps its shape; only the label gained the key and the type — see the note above. expect(mmd).toContain('creationSteps__key_B{"Check · ◆ B · IF"}'); expect(mmd).toContain( 'creationSteps__key_B -->|"YES"| creationSteps__key_C', ); expect(mmd).toContain( 'creationSteps__key_B -->|"NO"| creationSteps__key_D', ); /** * An END terminus PER terminal step, addressed by the route that ends there — where the retired emitter drew one * shared `END` node that every terminal pointed at. The shared model's shape is the honest one: two steps ending * the workflow are two endings, and collapsing them drew an edge into a node neither step names. */ expect(mmd).toContain('creationSteps__key_C__next__end(["END"])'); expect(mmd).toContain('creationSteps__key_D__next__end(["END"])'); expect(mmd).toContain( 'creationSteps__key_C --> creationSteps__key_C__next__end', ); }); it('renders SWITCH cases + default, object next, and onCancelled', () => { const flow: ComponentFlow = { kind: 'creation', phases: [ { name: 'creationSteps', steps: [ { key: 'FORM', label: 'Pick', rootStep: true, stepType: 'FORM', next: 'SW', onCancelled: 'CANCEL', }, { key: 'SW', label: 'Route', rootStep: false, stepType: 'SWITCH', metadata: { cases: { Completed: 'DONE', Failed: 'ERR' }, default: 'WAIT', }, }, { key: 'WAIT', label: 'Loop?', rootStep: false, next: { if: 'x', then: 'SW', else: 'DONE' }, }, { key: 'DONE', label: 'Done', next: null, rootStep: false }, { key: 'ERR', label: 'Error', next: null, rootStep: false }, { key: 'CANCEL', label: 'Cancelled', next: null, rootStep: false }, ], }, ], }; const mmd = buildMermaid(withSource(flow)); // A SWITCH step is the rhombus and its `cases`/`default` are its edges, each labelled by the branch key. expect(mmd).toContain( 'creationSteps__key_SW -->|"Completed"| creationSteps__key_DONE', ); expect(mmd).toContain( 'creationSteps__key_SW -->|"Failed"| creationSteps__key_ERR', ); expect(mmd).toContain( 'creationSteps__key_SW -->|"default"| creationSteps__key_WAIT', ); /** * An object `next` on a NON-decision step routes through its own `next:if` diamond — `WAIT` is a plain step whose * route happens to branch, so the branch is a property of the route rather than of the step. The distinction the * assertion above pins (`SW` itself is the rhombus) is about the step's TYPE, not about branching. */ expect(mmd).toContain('creationSteps__key_WAIT__next__if{"x"}'); expect(mmd).toContain( 'creationSteps__key_WAIT__next__if -->|"YES"| creationSteps__key_SW', ); expect(mmd).toContain( 'creationSteps__key_WAIT__next__if -->|"NO"| creationSteps__key_DONE', ); // onCancelled → Cancel edge expect(mmd).toContain( 'creationSteps__key_FORM -->|"Cancel"| creationSteps__key_CANCEL', ); }); }); describe('reconcile — creationSteps is ONE aggregated workflow component', () => { const cev = ( idOrKey: string, operation: 'added' | 'customized', generation: number, ): TracerEvent => ({ idOrKey, parentKey: 'creationSteps', operation, depth: 0, sourceFile: 'f.yaml', project: generation === 0 ? 'THIS' : 'anc', generation, weight: generation === 0 ? NaN : 1, }); it('collapses N step-events into a single (workflow) row with a creation chart', () => { const finalOutput = { creationSteps: [ { key: 'A', name: 'Start', root: true, next: 'B', type: 'API' }, { key: 'B', name: 'End', type: 'SHOW_INFO', next: null }, ], }; const model = reconcile( [cev('A', 'added', 0), cev('B', 'added', 0)], [], finalOutput, [{ project: 'THIS', generation: 0, weight: NaN }], 'demo', ); const cs = model.components.filter((c) => c.parentKey === 'creationSteps'); expect(cs).toHaveLength(1); // one aggregate, not one-per-step expect(cs[0].idOrKey).toBe('(workflow)'); expect(cs[0].status).toBe('added'); // no ancestor → entirely new in THIS expect(cs[0].flow?.kind).toBe('creation'); const md = renderReport(model); expect(md).toContain('creation workflow —'); expect(md).toContain('flowchart TD'); }); it('is "customized" with a whole-content diff when THIS changes an inherited workflow', () => { const finalOutput = { creationSteps: [ { key: 'A', name: 'Start NEW', next: 'B' }, { key: 'B', name: 'Added by THIS' }, ], }; const model = reconcile( [cev('A', 'added', 1), cev('A', 'customized', 0), cev('B', 'added', 0)], [], finalOutput, [ { project: 'anc', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { projectCheckpoints: [ { label: 'anc', output: { creationSteps: [{ key: 'A', name: 'Start OLD' }] }, }, { label: 'THIS', output: finalOutput }, ], }, ); const cs = model.components.find((c) => c.parentKey === 'creationSteps')!; expect(cs.status).toBe('customized'); expect(cs.diffText).toMatch(/^- .*name: Start OLD/m); // removed line expect(cs.diffText).toMatch(/^\+ .*name: Start NEW/m); // added line expect(cs.diffText).toMatch(/^\+ .*key: B/m); // step B added by THIS (whole-array diff) }); }); describe('reconcile — per-project overwrite-history phases (D→C→B→A)', () => { const pev = ( operation: 'added' | 'customized', generation: number, project: string, ): TracerEvent => ({ idOrKey: 'X', parentKey: 'pushers', operation, depth: 0, sourceFile: 'f.yaml', project, generation, weight: generation === 0 ? NaN : 1, }); // X is overwritten at every project down the chain D→C→B→THIS. const events: TracerEvent[] = [ pev('added', 3, 'D'), pev('customized', 2, 'C'), pev('customized', 1, 'B'), pev('customized', 0, 'THIS'), ]; const snapshots = new Map([ [JSON.stringify(['pushers', 'X']), { id: 'X', name: 'v3' }], ]); const finalOutput = { pushers: [{ id: 'X', name: 'v0' }] }; // Per-project checkpoints, ascending precedence (lowest → THIS). const projectCheckpoints = [ { label: 'D', output: { pushers: [{ id: 'X', name: 'v3' }] } }, { label: 'C', output: { pushers: [{ id: 'X', name: 'v2' }] } }, { label: 'B', output: { pushers: [{ id: 'X', name: 'v1' }] } }, { label: 'THIS', output: finalOutput }, ]; const projects = [ { project: 'D', generation: 3, weight: 1 }, { project: 'C', generation: 2, weight: 1 }, { project: 'B', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ]; it('builds one phase per project hop (lowest precedence → THIS)', () => { const model = reconcile(events, [], finalOutput, projects, 'demo', { snapshots, projectCheckpoints, }); const x = model.components.find((c) => c.idOrKey === 'X')!; expect(x.status).toBe('customized'); expect(x.diffPhases?.map((ph) => `${ph.fromLabel}->${ph.toLabel}`)).toEqual( ['D->C', 'C->B', 'B->THIS'], ); const md = renderReport(model); expect(md).toContain('Overwrite history —'); expect(md).toContain('D → C'); }); it('HTML renders switchable tabs (last = THIS override active)', () => { const html = renderReportHtml( reconcile(events, [], finalOutput, projects, 'demo', { snapshots, projectCheckpoints, }), ); expect(html).toContain('class="tabset"'); expect(html).toContain('data-tab="0"'); // three tab buttons; the last (B → THIS) is active by default expect(html).toContain('class="tab-btn active" data-tab="0" data-i="2"'); expect(html).toContain('class="tab-panel active" data-tab="0" data-i="2"'); expect(html).toContain('.tab-btn'); // css present expect(html).toContain("querySelectorAll('.tab-btn')"); // switch script present }); it('two SAME-generation projects overwriting each other produce a hop', () => { // A→B are both generation 1 (siblings, no generation difference); the merge // order (B after A) still yields an A→B overwrite phase — the reported bug. const model = reconcile( [pev('added', 1, 'connA'), pev('customized', 1, 'connB')], [], finalOutput, [ { project: 'connA', generation: 1, weight: 1 }, { project: 'connB', generation: 1, weight: 1 }, ], 'demo', { snapshots: new Map([ [JSON.stringify(['pushers', 'X']), { id: 'X', name: 'vA' }], ]), projectCheckpoints: [ { label: 'connA', output: { pushers: [{ id: 'X', name: 'vA' }] } }, { label: 'connB', output: finalOutput }, ], }, ); const x = model.components.find((c) => c.idOrKey === 'X')!; expect(x.status).toBe('customized'); // overwritten between two gen-1 siblings expect(x.diffText).toMatch(/name: vA/); expect(x.diffText).toMatch(/name: v0/); }); it('single hop renders as one labeled block, not a tabset', () => { const model = reconcile( [pev('added', 1, 'B'), pev('customized', 0, 'THIS')], [], finalOutput, [ { project: 'B', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { snapshots: new Map([ [JSON.stringify(['pushers', 'X']), { id: 'X', name: 'v1' }], ]), projectCheckpoints: [ { label: 'B', output: { pushers: [{ id: 'X', name: 'v1' }] } }, { label: 'THIS', output: finalOutput }, ], }, ); const x = model.components.find((c) => c.idOrKey === 'X')!; expect(x.diffPhases?.map((ph) => `${ph.fromLabel}->${ph.toLabel}`)).toEqual( ['B->THIS'], ); // MD shows the single labeled overwrite; HTML shows a labeled block, no tabset. expect(renderReport(model)).toContain('Overwritten: B → THIS'); const html = renderReportHtml(model); expect(html).toContain('overwritten: B → THIS'); expect(html).not.toContain('class="tabset"'); }); });