The check suite consumes source files through two entirely separate channels, and Phase 0 only hardened one of them. `withSourceResolution` wraps `ssrLoadModule`, which covers the 159 module loads. It does not and cannot see the other 30 sites, which read source as TEXT through `readFileSync` to assert structural facts - "the panel imports no local suggestion ranker", "no runtime path writes a definition". Renaming `base44Client.js` to `.ts` is what surfaced the difference: ENOENT in the middle of a suite that had been passing, from a line no grep for `ssrLoadModule` would ever have found. `resolveSourcePath()` in `ssr-resolve.mjs` reuses the existing `candidatesFor()` ordering - the written path first, then `.ts`, then `.tsx` - and returns a path relative to the root, so every call site keeps its `join(ROOT, ...)` as it was. An unresolvable path comes back unchanged, which keeps the two absence assertions honest: a check proving `store.js` is GONE still asks about the path it means, and now also notices if the file returns under another extension. Thirty-seven lines change, each a one-for-one replacement. No assertion text, no record() message, no ordering, no logic. The audit found the reads in four shapes, and two of them a path grep cannot see: - direct readFileSync(join(ROOT, 'src/x.jsx'), 'utf8') - via a const const P = join(ROOT, 'src/x.jsx') - dir + name ['node.js', 'patch.js'].map((f) => readFileSync(join(ROOT, 'src/lib/ui', f))) - path array for (const f of files) readFileSync(join(ROOT, f)) The third and fourth hide thirteen filenames in adjacent arrays, which is why the first estimate of this work was twenty-two files and the real number is thirty-five. One directory scan also filtered `/\.jsx?$/` over `src/pages/admin` and `src/components/agents`. That one does not crash - it quietly matches nothing once those directories are TypeScript, and the check passes having inspected an empty set. Widened to `/\.[jt]sx?$/`. A silent shrink is worse than a failure, and CI's FLOOR of 900 would not have caught it. Deliberately NOT touched, because they are correctly extension-specific: the `dist/assets` filter reads built bundles, which are `.js` whatever the source was; `scripts/stubs/react-hot-toast.js` and `scripts/browser-flows.js` are scripts, not migrated source; and every comment that mentions a `.js` filename says something true about a file that still has that name. Proven rather than assumed. `npm test` reports 1684/1691 before and after, the same seven failures. Then `src/api/base44Client.js` - the exact file whose rename broke the suite - was renamed to `.ts`, the suite re-run, and the check that reads it as text passed: "the app does not import the seed fixture". The rename was reverted and the file verified byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
10867 lines
532 KiB
JavaScript
10867 lines
532 KiB
JavaScript
/**
|
||
* Skill pipeline check — Owliver skills and Board (UI) skills.
|
||
*
|
||
* Runs the *real* module graph through Vite, so `import.meta.glob`, the `@/`
|
||
* alias and the Markdown loading all behave exactly as they do in the app. That
|
||
* matters: the reliability problem this script exists for was never in the
|
||
* Markdown, it was in what the pipeline did with a definition it could only
|
||
* partly read, and a mock of that pipeline would have reproduced none of it.
|
||
*
|
||
* node scripts/skill-check.mjs # against source, as the dev server sees it
|
||
* node scripts/skill-check.mjs --dist # also assert the built bundle carries every definition
|
||
*
|
||
* Exits non-zero on failure, so it can gate a build.
|
||
*/
|
||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { createServer } from 'vite';
|
||
import React from 'react';
|
||
import { renderToStaticMarkup } from 'react-dom/server';
|
||
import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs';
|
||
import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs';
|
||
import { resolveSourcePath, withSourceResolution } from './ssr-resolve.mjs';
|
||
|
||
const ROOT = process.cwd();
|
||
const results = [];
|
||
const record = (name, pass, detail = '') => {
|
||
results.push({ name, pass, detail });
|
||
const mark = pass ? ' ok ' : ' FAIL ';
|
||
console.log(`[${mark}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||
};
|
||
|
||
const server = await createServer({
|
||
root: ROOT,
|
||
server: { middlewareMode: true },
|
||
appType: 'custom',
|
||
logLevel: 'error',
|
||
/* `react-hot-toast` evaluates `goober`, which calls `document.createElement`
|
||
at import time — so loading it in Node takes the design-system barrel, and
|
||
with it the Owliver block renderers, down. Nothing here raises a toast, and
|
||
the stub is what lets the citation tests assert on RENDERED MARKUP rather
|
||
than on the block objects behind it. */
|
||
resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } },
|
||
});
|
||
|
||
/* Every `ssrLoadModule` path below names a file by its current extension. While
|
||
the TypeScript migration is under way that extension changes underneath them,
|
||
so the loader resolves .js/.jsx/.ts/.tsx rather than each call site having to
|
||
know which one a module is on today. The path as written is tried first. */
|
||
withSourceResolution(server, ROOT);
|
||
|
||
const reg = await server.ssrLoadModule('/src/lib/skills/registry.js');
|
||
const placement = await server.ssrLoadModule('/src/components/ai-assistant/placement.js');
|
||
|
||
const contextFor = (pageKey) => Object.entries(placement.PLACEMENT_ROUTES)
|
||
.find(([route]) => reg.pageKeyForRoute(route) === pageKey)?.[1] ?? null;
|
||
|
||
/* ── 1. Discovery ─────────────────────────────────────────────────────────── */
|
||
console.log('\n── Discovery ──');
|
||
|
||
const onDisk = readdirSync(join(ROOT, 'src/skills'))
|
||
.flatMap((dir) => readdirSync(join(ROOT, 'src/skills', dir)).map((f) => `${dir}/${f}`))
|
||
.filter((f) => f.endsWith('.md'));
|
||
|
||
record(
|
||
'every .md under src/skills registers',
|
||
reg.SKILLS.length === onDisk.length,
|
||
`${reg.SKILLS.length} registered / ${onDisk.length} files`
|
||
);
|
||
|
||
record(
|
||
'no duplicate skill ids',
|
||
new Set(reg.SKILLS.map((s) => s.id)).size === reg.SKILLS.length,
|
||
`${new Set(reg.SKILLS.map((s) => s.id)).size} unique ids`
|
||
);
|
||
|
||
record(
|
||
'every skill declares at least one page',
|
||
reg.SKILLS.every((s) => s.pages.length > 0)
|
||
);
|
||
|
||
/* Both managed lists are non-empty — the Workspace → Skills tabs. */
|
||
const uiFacet = reg.skillsWithFacet(reg.SKILLS, 'ui');
|
||
const owliverFacet = reg.skillsWithFacet(reg.SKILLS, 'owliver');
|
||
record('Owliver Skills list is populated', owliverFacet.length > 0, `${owliverFacet.length} skills`);
|
||
record(
|
||
'Board Skills list resolves without error',
|
||
Array.isArray(uiFacet),
|
||
`${uiFacet.length} skills declare a \`ui:\` block`
|
||
);
|
||
|
||
/* ── 2. Registration integrity: nothing half-loads in silence ─────────────── */
|
||
console.log('\n── Registration integrity ──');
|
||
|
||
const clean = reg.readSkillRegistry([]);
|
||
record(
|
||
'shipped registry reports no diagnostics',
|
||
clean.diagnostics.length === 0,
|
||
clean.diagnostics.map((d) => d.message).join(' | ') || 'none'
|
||
);
|
||
|
||
record(
|
||
'every shipped skill registered every capability it declared',
|
||
reg.SKILLS.every((s) => !(s.owliverErrors?.length || s.uiErrors?.length))
|
||
);
|
||
|
||
/* An unreadable stored definition must be REPORTED, not swallowed. */
|
||
const brokenYaml = `---\nid: broken\n name: bad indent\n---\n# Broken\n`;
|
||
const withBroken = reg.readSkillRegistry([{ path: 'custom/broken.md', raw: brokenYaml }]);
|
||
record(
|
||
'unreadable stored skill is reported, not silently dropped',
|
||
withBroken.diagnostics.some((d) => d.kind === 'unreadable'),
|
||
withBroken.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* A definition with one bad capability keeps the good one AND says so. */
|
||
const partial = `---
|
||
id: partial-check
|
||
name: Partial Check
|
||
description: One resolvable capability and one that names no source.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
owliver:
|
||
enabled: true
|
||
capabilities:
|
||
- summary
|
||
- flow
|
||
responses:
|
||
summary:
|
||
source: position.activity
|
||
periods:
|
||
- today
|
||
---
|
||
|
||
# Partial Check
|
||
`;
|
||
const withPartial = reg.readSkillRegistry([{ path: 'custom/partial.md', raw: partial }]);
|
||
const partialSkill = withPartial.skills.find((s) => s.id === 'partial-check');
|
||
record(
|
||
'partly-resolvable skill still registers its good capability',
|
||
partialSkill?.owliver.capabilities.includes('summary'),
|
||
`capabilities = ${JSON.stringify(partialSkill?.owliver.capabilities)}`
|
||
);
|
||
record(
|
||
'and the capability it LOST is reported',
|
||
withPartial.diagnostics.some((d) => d.kind === 'incomplete' && d.skillId === 'partial-check'),
|
||
withPartial.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* Shadowing a built-in is allowed, but must be visible. */
|
||
const shadow = `---\nid: create-position\nname: Shadow\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n# Shadow\n`;
|
||
const withShadow = reg.readSkillRegistry([{ path: 'custom/shadow.md', raw: shadow }]);
|
||
record(
|
||
'a custom skill overriding a built-in is reported',
|
||
withShadow.diagnostics.some((d) => d.kind === 'shadowed' && d.skillId === 'create-position'),
|
||
withShadow.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* ── 3. Markdown parsing: no silent content loss ──────────────────────────── */
|
||
console.log('\n── Markdown parsing ──');
|
||
|
||
const cp = reg.SKILLS.find((s) => s.id === 'create-position');
|
||
record(
|
||
'wrapped bullets are read whole',
|
||
cp.capabilities.some((c) => c.endsWith('out of a single sentence.')),
|
||
cp.capabilities.find((c) => c.includes('single sentence')) ?? 'TRUNCATED'
|
||
);
|
||
|
||
record(
|
||
'conversation steps all parse',
|
||
cp.conversation.length === 7 && cp.conversation.every((s) => s.field && s.question),
|
||
`${cp.conversation.length} steps`
|
||
);
|
||
|
||
/* ── Every conversation step resolves in the registry it is bound to ─────── */
|
||
|
||
/*
|
||
* The check that catches the failure nobody else would.
|
||
*
|
||
* `stepsOf` filters a skill's steps down to the fields its registry defines.
|
||
* A typo in a field name — or a step added to the Markdown before the field
|
||
* exists in code — is therefore DROPPED SILENTLY: the flow asks fewer questions
|
||
* than the file lists, and a skill whose steps are all unknown asks none at all,
|
||
* jumps straight to the summary, and offers to write an empty record. Nothing
|
||
* errors, nothing logs, and the Markdown still reads correctly.
|
||
*
|
||
* It is also the failure a second conversation makes easy: `flow: employee-role`
|
||
* against position field names parses, validates, and produces a flow that asks
|
||
* nothing.
|
||
*/
|
||
const flowsModule = await server.ssrLoadModule('/src/lib/skills/flows/index.js');
|
||
|
||
const conversational = reg.SKILLS.filter((s) => (s.conversation || []).length);
|
||
record(
|
||
'every conversational skill declares a flow that exists',
|
||
conversational.every((s) => flowsModule.flowFor(s)),
|
||
conversational.filter((s) => !flowsModule.flowFor(s))
|
||
.map((s) => `${s.id} → ${s.flow ?? 'none'}`).join(', ')
|
||
|| `${conversational.length} skill(s): ${conversational.map((s) => `${s.id}→${s.flow}`).join(', ')}`
|
||
);
|
||
|
||
const unresolvedSteps = conversational.flatMap((skill) => {
|
||
const registry = flowsModule.flowFor(skill);
|
||
if (!registry) return [];
|
||
return (skill.conversation || [])
|
||
.filter((step) => !registry.fields[step.field])
|
||
.map((step) => `${skill.id}:${step.field}`);
|
||
});
|
||
record(
|
||
'...and every one of its steps resolves to a field that registry defines',
|
||
unresolvedSteps.length === 0,
|
||
unresolvedSteps.join(', ')
|
||
|| `${conversational.reduce((n, s) => n + s.conversation.length, 0)} steps all resolve`
|
||
);
|
||
|
||
/* A registry's commit vocabulary has to accept the chip it renders, or the
|
||
summary offers a button that falls through to "I did not catch that". */
|
||
const unspokenVerbs = Object.values(flowsModule.FLOWS).flatMap((registry) => registry.verbs
|
||
.filter((verb) => !verb.says.has(verb.chip.toLowerCase()))
|
||
.map((verb) => `${registry.id}:${verb.chip}`));
|
||
record(
|
||
'every commit chip is a phrase its own flow accepts',
|
||
unspokenVerbs.length === 0,
|
||
unspokenVerbs.join(', ') || 'all verbs answer their own chips'
|
||
);
|
||
|
||
const numbered = reg.parseSkill(
|
||
`---\nid: numbered\nname: Numbered\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||
+ `## Capabilities\n\n1. First instruction.\n2. Second instruction.\n3. Third instruction.\n`,
|
||
{ custom: true }
|
||
);
|
||
record(
|
||
'numbered instructions are preserved',
|
||
numbered.capabilities.length === 3,
|
||
JSON.stringify(numbered.capabilities)
|
||
);
|
||
|
||
/* A section ends at the next `##`, and a heading that merely starts with the
|
||
same word is a different heading. Both halves matter: the first is what stops
|
||
`Capabilities` swallowing the rest of the file, the second is what stops a
|
||
near-miss heading being read as the real one. */
|
||
const bounded = reg.parseSkill(
|
||
`---\nid: bounded\nname: Bounded\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||
+ `## Capabilities\n\n- one\n- two\n\n## Capabilities (v2)\n\n- three\n\n## Actions\n\n- four\n`,
|
||
{ custom: true }
|
||
);
|
||
record(
|
||
'a section stops at the next heading',
|
||
bounded.capabilities.length === 2,
|
||
JSON.stringify(bounded.capabilities)
|
||
);
|
||
|
||
/* Sub-headings (`###`) belong to the section they sit under, and must not end
|
||
it — `\n##\s` would match `### ` if the space were not required. */
|
||
const nested = reg.parseSkill(
|
||
`---\nid: nested\nname: Nested\ndescription: d\npages:\n - positions\nstatus: active\n---\n\n`
|
||
+ `## Capabilities\n\n- one\n\n### Detail\n\n- two\n\n## Actions\n\n- three\n`,
|
||
{ custom: true }
|
||
);
|
||
record(
|
||
'a `###` sub-heading does not truncate its section',
|
||
nested.capabilities.length === 2,
|
||
JSON.stringify(nested.capabilities)
|
||
);
|
||
|
||
/* Frontmatter of every shipped file parses and carries its required fields. */
|
||
let frontmatterOk = true;
|
||
const frontmatterDetail = [];
|
||
for (const s of reg.SKILLS) {
|
||
if (!s.id || !s.name || !s.pages.length) {
|
||
frontmatterOk = false;
|
||
frontmatterDetail.push(s.path);
|
||
}
|
||
}
|
||
record('every shipped skill has id, name and pages', frontmatterOk, frontmatterDetail.join(', '));
|
||
|
||
/* ── 4. Trigger / matching ────────────────────────────────────────────────── */
|
||
console.log('\n── Trigger matching ──');
|
||
|
||
const positions = contextFor('positions');
|
||
record('positions context resolves', Boolean(positions), String(positions));
|
||
|
||
const MATRIX = [
|
||
['create a position', 'create-position'],
|
||
['i want to hire', 'create-position'],
|
||
['post a job', 'create-position'],
|
||
['add a client', 'create-position'],
|
||
['show hiring activity', 'hiring-activity-assistant'],
|
||
['hiring flow', 'hiring-activity-assistant'],
|
||
['applications over time', 'hiring-activity-assistant'],
|
||
];
|
||
for (const [question, expected] of MATRIX) {
|
||
const got = reg.matchSkill(question, positions)?.id ?? null;
|
||
record(`trigger "${question}" -> ${expected}`, got === expected, got ?? 'no match');
|
||
}
|
||
|
||
/* Every claimant is enumerable, so a contested phrase is not invisible. */
|
||
const contender = `---
|
||
id: zz-contender
|
||
name: ZZ Contender
|
||
description: Also claims a phrase create-position owns.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
triggers:
|
||
- create a position
|
||
owliver:
|
||
enabled: true
|
||
capabilities:
|
||
- summary
|
||
responses:
|
||
summary:
|
||
source: position.activity
|
||
periods:
|
||
- today
|
||
---
|
||
|
||
# ZZ Contender
|
||
`;
|
||
const contested = [{ path: 'custom/zz.md', raw: contender }];
|
||
const claimants = reg.matchSkills('create a position', positions, [], contested);
|
||
record(
|
||
'all trigger claimants are enumerable',
|
||
claimants.length === 2,
|
||
claimants.map((s) => s.id).join(', ')
|
||
);
|
||
record(
|
||
'a contested trigger is reported as a collision',
|
||
reg.readSkillRegistry(contested).diagnostics.some((d) => d.kind === 'trigger-collision'),
|
||
reg.readSkillRegistry(contested).diagnostics.find((d) => d.kind === 'trigger-collision')?.message ?? 'NO DIAGNOSTIC'
|
||
);
|
||
|
||
/* ── 5. Owliver suggestions ───────────────────────────────────────────────── */
|
||
console.log('\n── Suggestions ──');
|
||
|
||
const resolver = await server.ssrLoadModule('/src/lib/skills/owliverResolver.js');
|
||
const declared = resolver.owliverSuggestions(positions, [], [], {});
|
||
|
||
record(
|
||
'every declared suggestion names a capability the skill offers',
|
||
declared.every((c) => !c.skillCapability
|
||
|| reg.SKILLS.find((s) => s.id === c.skillId)?.owliver.capabilities.includes(c.skillCapability))
|
||
);
|
||
|
||
const byIntent = declared.map((c) => `${c.skillId}:${c.skillCapability}`);
|
||
record(
|
||
'no two declared suggestions resolve to one capability',
|
||
new Set(byIntent).size === byIntent.length,
|
||
byIntent.join(' | ')
|
||
);
|
||
|
||
const dynamic = await server.ssrLoadModule('/src/components/ai-assistant/dynamic.js');
|
||
|
||
/* The generator is exercised at three different data shapes: the labels must
|
||
track the data, and must never appear when the count behind them is zero. */
|
||
const factsFor = (n) => ({
|
||
postings: [], starvedPositions: new Array(n).fill({}), unscreened: new Array(n).fill({}),
|
||
scored: [], stalled: [], ranked: [], activity: [], missingCredentials: [],
|
||
});
|
||
const shapes = [0, 1, 7, 12];
|
||
let labelsOk = true;
|
||
const offenders = [];
|
||
for (const n of shapes) {
|
||
for (const chip of dynamic.buildPrompts('admin.positions', factsFor(n), null)) {
|
||
/* A complete intent asks or instructs. A raw fragment — "4 ready for
|
||
interview" — does neither, and is what this check exists to catch. */
|
||
const isIntent = /\?$/.test(chip.label) || /^(show|summari[sz]e|create|continue|compare|find|explain|what|which|who)\b/i.test(chip.label);
|
||
if (!isIntent) { labelsOk = false; offenders.push(`n=${n}: "${chip.label}"`); }
|
||
if (n === 0 && /\b0\b/.test(chip.label)) { labelsOk = false; offenders.push(`n=0 leaked a zero count: "${chip.label}"`); }
|
||
}
|
||
}
|
||
record('every generated label is a complete intent', labelsOk, offenders.join(' ; ') || 'all labels are intents');
|
||
|
||
/* Counts must move with the data rather than being baked in. */
|
||
const at7 = dynamic.buildPrompts('admin.positions', factsFor(7), null).map((c) => c.label);
|
||
const at12 = dynamic.buildPrompts('admin.positions', factsFor(12), null).map((c) => c.label);
|
||
record(
|
||
'labels are dynamic, not hardcoded',
|
||
at7.some((l) => l.includes('7')) && at12.some((l) => l.includes('12')) && !at7.some((l) => l.includes('12')),
|
||
`7 -> ${at7.find((l) => l.includes('7'))} | 12 -> ${at12.find((l) => l.includes('12'))}`
|
||
);
|
||
|
||
const atZero = dynamic.buildPrompts('admin.positions', factsFor(0), null).map((c) => c.label);
|
||
record(
|
||
'no count-bearing suggestion when the count is zero',
|
||
!atZero.some((l) => /\d/.test(l)),
|
||
atZero.join(' | ')
|
||
);
|
||
|
||
/**
|
||
* The workforce branch — the one the fragments came from.
|
||
*
|
||
* "4 ready for interview" and "2 strong for Event Server" were generated here,
|
||
* so a suggestion check that passes `null` for workforce proves nothing about
|
||
* them. Applications are dated today so the "applied today" branch is live too.
|
||
*/
|
||
const workforceAt = (readyCount) => {
|
||
const position = { id: 'p1', title: 'Event Server – Fine Dining', status: 'active', headcount: 5 };
|
||
const today = new Date();
|
||
const applications = Array.from({ length: readyCount }, (_, i) => ({
|
||
id: `a${i}`,
|
||
job_posting_id: 'p1',
|
||
email: `c${i}@example.com`,
|
||
applicant_name: `Candidate ${i}`,
|
||
status: i % 2 ? 'ai_screened' : 'shortlisted',
|
||
ai_score: 90,
|
||
created_date: today.toISOString(),
|
||
updated_date: today.toISOString(),
|
||
}));
|
||
return {
|
||
positions: [position],
|
||
currentPositionId: null,
|
||
context: { applications, today, profiles: [], staff: [], assignments: [] },
|
||
};
|
||
};
|
||
|
||
for (const n of [1, 4, 9]) {
|
||
const labels = dynamic
|
||
.buildPrompts('admin.positions', factsFor(2), workforceAt(n))
|
||
.map((c) => c.label);
|
||
|
||
const ready = labels.find((l) => /ready for interview/i.test(l));
|
||
record(
|
||
`workforce branch at ${n}: "ready for interview" is a complete question`,
|
||
Boolean(ready) && /\?$/.test(ready) && ready.includes(String(n)) && /^Which\b/.test(ready),
|
||
ready ?? 'NOT GENERATED'
|
||
);
|
||
|
||
/* Singular/plural has to track the count, or the fix trades one awkward
|
||
label for another. */
|
||
if (n === 1) {
|
||
record(
|
||
'a single candidate reads as one candidate',
|
||
ready === 'Which 1 candidate is ready for interview?',
|
||
ready ?? 'NOT GENERATED'
|
||
);
|
||
}
|
||
|
||
const fragment = labels.find((l) => /^\d+\s/.test(l));
|
||
record(
|
||
`workforce branch at ${n}: no bare status fragments`,
|
||
!fragment,
|
||
fragment ?? 'none'
|
||
);
|
||
}
|
||
|
||
/* ── 6. Authoring: what the editors save, and what they refuse ────────────── */
|
||
console.log('\n── Authoring ──');
|
||
|
||
const fields = await server.ssrLoadModule('/src/lib/skills/skillFields.js');
|
||
const templates = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||
const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js');
|
||
|
||
/* A template must produce something that works before it is edited. The Board
|
||
template used to default to a source its own default placement cannot read. */
|
||
record(
|
||
'the Board template validates as written',
|
||
reg.validateSkillSource(templates.uiSkillTemplate({
|
||
id: 'template-check', name: 'Template Check', pages: ['positions'],
|
||
})) === null,
|
||
reg.validateSkillSource(templates.uiSkillTemplate({
|
||
id: 'template-check', name: 'Template Check', pages: ['positions'],
|
||
})) || 'valid'
|
||
);
|
||
|
||
/* An Owliver block that can answer nothing is refused rather than saved and
|
||
then reported as a skill that does not work. */
|
||
record(
|
||
'an Owliver skill with no capabilities is refused',
|
||
Boolean(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
|
||
}))),
|
||
reg.validateSkillSource(templates.owliverSkillTemplate({
|
||
id: 'empty-owliver', name: 'Empty Owliver', pages: ['positions'],
|
||
})) || 'ACCEPTED — should have been refused'
|
||
);
|
||
|
||
/* The section-can-never-resolve check, in both directions. */
|
||
const boardOn = (page, placement) => `---
|
||
id: context-check
|
||
name: Context Check
|
||
description: Reads position activity.
|
||
pages:
|
||
- ${page}
|
||
status: active
|
||
ui:
|
||
type: flow
|
||
placement: ${placement}
|
||
source: position.activity
|
||
---
|
||
|
||
# Context Check
|
||
`;
|
||
|
||
record(
|
||
'a position source on a page with no position is refused',
|
||
Boolean(reg.validateSkillSource(boardOn('analytics', 'after-header'))),
|
||
reg.validateSkillSource(boardOn('analytics', 'after-header')) || 'ACCEPTED — should have been refused'
|
||
);
|
||
record(
|
||
'the same source is refused above the Positions grid',
|
||
Boolean(reg.validateSkillSource(boardOn('positions', 'after-position-list'))),
|
||
reg.validateSkillSource(boardOn('positions', 'after-position-list')) || 'ACCEPTED — should have been refused'
|
||
);
|
||
record(
|
||
'and accepted inside a position card',
|
||
reg.validateSkillSource(boardOn('positions', 'after-position-card')) === null,
|
||
reg.validateSkillSource(boardOn('positions', 'after-position-card')) || 'accepted'
|
||
);
|
||
|
||
/* Owliver responses are deliberately NOT subject to that rule: an unmet need is
|
||
a question back, not a dead card. `hiring-activity-assistant` depends on it. */
|
||
record(
|
||
'a shipped Owliver skill reading one position still validates',
|
||
reg.validateSkillSource(
|
||
reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown
|
||
) === null
|
||
);
|
||
|
||
record(
|
||
'every shipped definition still validates',
|
||
reg.SKILLS.every((s) => reg.validateSkillSource(s.markdown) === null),
|
||
reg.SKILLS.filter((s) => reg.validateSkillSource(s.markdown)).map((s) => s.id).join(', ') || 'all valid'
|
||
);
|
||
|
||
/* Reading a definition into the editor fields — the upload path. */
|
||
for (const skill of reg.SKILLS.filter((s) => s.kind === 'assistant')) {
|
||
const read = fields.owliverFieldsFromSource(skill.markdown);
|
||
record(
|
||
`\`${skill.id}\` reads back into the Owliver fields`,
|
||
read.id === skill.id && read.name === skill.name
|
||
&& read.pages.join(',') === skill.pages.join(','),
|
||
`${read.name} / ${read.pages.join(', ')}`
|
||
);
|
||
}
|
||
|
||
record(
|
||
'an unparseable file leaves the fields empty rather than throwing',
|
||
fields.owliverFieldsFromSource('not a definition').id === ''
|
||
);
|
||
|
||
/* Writing a field back — the half that used to do nothing. */
|
||
const original = reg.SKILLS.find((s) => s.id === 'hiring-activity-assistant').markdown;
|
||
const renamed = fields.patchFrontmatter(original, { name: 'Renamed Assistant' });
|
||
|
||
record(
|
||
'patching a field changes what the registry reads',
|
||
reg.parseSkill(renamed, { custom: true }).name === 'Renamed Assistant',
|
||
reg.parseSkill(renamed, { custom: true }).name
|
||
);
|
||
record(
|
||
'patching a field leaves the body untouched',
|
||
reg.parseSkill(renamed, { custom: true }).body === reg.parseSkill(original, { custom: true }).body
|
||
);
|
||
record(
|
||
'patching a field preserves frontmatter comments',
|
||
renamed.includes('# One suggestion per capability.')
|
||
);
|
||
record(
|
||
'patching a field changes nothing else',
|
||
original.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
|
||
=== renamed.split('\n').filter((l) => !l.startsWith('name:')).join('\n')
|
||
);
|
||
record(
|
||
'a patched definition still validates',
|
||
reg.validateSkillSource(renamed) === null,
|
||
reg.validateSkillSource(renamed) || 'valid'
|
||
);
|
||
|
||
const repaged = fields.patchFrontmatter(original, { pages: ['analytics', 'activity'] });
|
||
record(
|
||
'patching a list replaces the whole block',
|
||
reg.parseSkill(repaged, { custom: true }).pages.join(',') === 'analytics,activity',
|
||
reg.parseSkill(repaged, { custom: true }).pages.join(',')
|
||
);
|
||
|
||
const restatused = fields.patchFrontmatter(original, { status: 'inactive' });
|
||
record(
|
||
'patching a key the file never declared adds it',
|
||
reg.parseSkill(restatused, { custom: true }).status === 'inactive'
|
||
);
|
||
|
||
/* Both keys, because that is what the editor's own field handler writes: the
|
||
registry reads capabilities as the union of the declared list and the keys of
|
||
`responses:`, so patching one without the other changes nothing. */
|
||
const recapped = fields.patchFrontmatter(original, {
|
||
'owliver.capabilities': ['summary'],
|
||
'owliver.responses': { summary: { source: 'position.activity', periods: ['today'] } },
|
||
});
|
||
record(
|
||
'patching a nested block rewrites only that block',
|
||
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',') === 'summary',
|
||
reg.parseSkill(recapped, { custom: true }).owliver.capabilities.join(',')
|
||
);
|
||
/* Asserted on the text, not on the parse: with `flow` no longer offered, the
|
||
registry correctly drops the suggestion that names it — which is the rule
|
||
working, not the patch reaching a sibling key it should not have. */
|
||
record(
|
||
'and leaves its siblings in the same block alone',
|
||
recapped.includes('Summarize hiring activity for this position')
|
||
&& recapped.includes('Show hiring activity as a flow')
|
||
&& recapped.includes(' enabled: true')
|
||
);
|
||
|
||
/* The two `ui:` shapes, told apart — the per-page form must not be overwritten
|
||
from four single-valued fields. */
|
||
record(
|
||
'the shorthand `ui:` form is recognised',
|
||
fields.uiShape(boardOn('positions', 'after-position-card')) === 'shorthand'
|
||
);
|
||
record(
|
||
'a definition with no `ui:` block reports none',
|
||
fields.uiShape(original) === 'none'
|
||
);
|
||
|
||
/* The picker and the validator read the same table. */
|
||
record(
|
||
'a position card supplies a position',
|
||
surfaces.contextSuppliedBy(['positions'], 'after-position-card').includes('positionId')
|
||
);
|
||
record(
|
||
'the Positions list supplies nothing',
|
||
surfaces.contextSuppliedBy(['positions'], 'after-position-list').length === 0
|
||
);
|
||
record(
|
||
'Analytics supplies nothing',
|
||
surfaces.contextSuppliedBy(['analytics'], 'after-header').length === 0
|
||
);
|
||
record(
|
||
'every surface declares what its placements provide',
|
||
surfaces.SKILL_SURFACES.every((s) => s.provides && typeof s.provides === 'object'),
|
||
surfaces.SKILL_SURFACES.filter((s) => !s.provides).map((s) => s.id).join(', ') || 'all declared'
|
||
);
|
||
|
||
/* ── 7. Upload hydration: a file, into the fields ─────────────────────────── */
|
||
console.log('\n── Upload hydration ──');
|
||
|
||
/**
|
||
* The shapes people actually upload.
|
||
*
|
||
* Every case here is a definition that arrived from outside the editors, which
|
||
* is the only way most definitions arrive. What is being asserted is not that
|
||
* the parser is lenient — it is that the three identity fields, the pages and
|
||
* the section a reader can see in the file are the ones the form shows.
|
||
*/
|
||
const COMPLETE = `---
|
||
id: hiring-activity-assistant
|
||
name: Hiring Activity Assistant
|
||
description: Answer questions about recent hiring activity on a position.
|
||
type: board
|
||
ui:
|
||
- page: Positions
|
||
placement: grid-card
|
||
source: position.activity
|
||
---
|
||
|
||
# Hiring Activity Assistant
|
||
`;
|
||
|
||
const NO_ID = `---
|
||
name: Hiring Activity Assistant
|
||
description: Answer questions about recent hiring activity.
|
||
pages:
|
||
- positions
|
||
---
|
||
|
||
# Hiring Activity Assistant
|
||
`;
|
||
|
||
const MULTI_PAGE = `---
|
||
id: hiring-activity
|
||
name: Hiring Activity
|
||
description: Hiring activity across the workspace.
|
||
ui:
|
||
- page: Positions
|
||
placement: grid-card
|
||
source: candidates.activity
|
||
- page: Analytics
|
||
placement: panel
|
||
source: hires.performance
|
||
---
|
||
|
||
# Hiring Activity
|
||
`;
|
||
|
||
/* Case 1 — a complete definition hydrates every field it declares. */
|
||
{
|
||
const f = fields.boardFieldsFromSource(COMPLETE);
|
||
record('upload: name hydrates', f.name === 'Hiring Activity Assistant', f.name || 'EMPTY');
|
||
record('upload: id hydrates', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
|
||
record(
|
||
'upload: description hydrates',
|
||
f.description === 'Answer questions about recent hiring activity on a position.',
|
||
f.description || 'EMPTY'
|
||
);
|
||
record('upload: pages come from the `ui:` entries', f.pages.join(',') === 'positions', f.pages.join(',') || 'EMPTY');
|
||
record('upload: `grid-card` resolves to a real placement', f.placement === 'after-position-card', f.placement || 'EMPTY');
|
||
record('upload: source hydrates', f.source === 'position.activity', f.source || 'EMPTY');
|
||
record('upload: an undeclared type is inferred from the source', f.type === 'flow', f.type || 'EMPTY');
|
||
record('upload: the definition validates as written', reg.validateSkillSource(COMPLETE) === null,
|
||
reg.validateSkillSource(COMPLETE) || 'valid');
|
||
record('upload: it is classified as a Board skill',
|
||
fields.facetsFromSource(COMPLETE).join(',') === 'ui', fields.facetsFromSource(COMPLETE).join(','));
|
||
|
||
/* The same file read by the other editor's reader — one pipeline, two views. */
|
||
const o = fields.owliverFieldsFromSource(COMPLETE);
|
||
record('upload: the Owliver reader hydrates the same identity',
|
||
o.id === f.id && o.name === f.name && o.description === f.description);
|
||
}
|
||
|
||
/* Case 2 — no `id:`, so it is slugged from the name and never invented. */
|
||
{
|
||
const f = fields.boardFieldsFromSource(NO_ID);
|
||
record('upload: a missing id slugs the name', f.id === 'hiring-activity-assistant', f.id || 'EMPTY');
|
||
record('upload: a missing id does not become the placeholder path', f.id !== 'custom');
|
||
record('upload: an explicit id is never replaced by a generated one',
|
||
fields.boardFieldsFromSource(COMPLETE).id === 'hiring-activity-assistant');
|
||
record('upload: a file with no frontmatter hydrates nothing',
|
||
fields.boardFieldsFromSource('# Just a heading\n').id === '');
|
||
}
|
||
|
||
/* Case 3 — several pages, and every entry survives a field edit. */
|
||
{
|
||
const f = fields.boardFieldsFromSource(MULTI_PAGE);
|
||
record('upload: every page in the list is reported', f.pages.join(',') === 'positions,analytics', f.pages.join(','));
|
||
|
||
const parsed = reg.parseSkill(MULTI_PAGE, { custom: true });
|
||
record('upload: every entry becomes a section',
|
||
parsed.ui.positions.sections.length === 1 && parsed.ui.analytics.sections.length === 1);
|
||
record('upload: each entry keeps its own source',
|
||
parsed.ui.positions.sections[0].source === 'candidates.activity'
|
||
&& parsed.ui.analytics.sections[0].source === 'hires.performance');
|
||
record('upload: a multi-page definition validates', reg.validateSkillSource(MULTI_PAGE) === null,
|
||
reg.validateSkillSource(MULTI_PAGE) || 'valid');
|
||
|
||
/* The fields must not be able to flatten it. */
|
||
record('upload: the section fields are read-only against a list', !fields.uiIsEditableFromFields(MULTI_PAGE));
|
||
record('upload: a single-section definition stays field-editable',
|
||
fields.uiIsEditableFromFields(templates.uiSkillTemplate({ id: 'x', name: 'X', pages: ['positions'] })));
|
||
|
||
/* Renaming is identity, not structure: it must still work, and must not
|
||
touch either entry. */
|
||
const renamedMulti = fields.patchFrontmatter(MULTI_PAGE, { name: 'Renamed Multi' });
|
||
const after = reg.parseSkill(renamedMulti, { custom: true });
|
||
record('upload: renaming a multi-page definition keeps both entries',
|
||
after.ui.positions?.sections.length === 1 && after.ui.analytics?.sections.length === 1,
|
||
Object.keys(after.ui).join(','));
|
||
record('upload: ...and actually renames it', after.name === 'Renamed Multi', after.name);
|
||
record('upload: ...and leaves the `ui:` text byte-identical',
|
||
renamedMulti.slice(renamedMulti.indexOf('ui:')) === MULTI_PAGE.slice(MULTI_PAGE.indexOf('ui:')));
|
||
}
|
||
|
||
/* Files as they actually arrive: from Windows, from a download, from paste. */
|
||
{
|
||
const dirty = {
|
||
'a byte-order mark': `${COMPLETE}`,
|
||
'CRLF line endings': COMPLETE.replace(/\n/g, '\r\n'),
|
||
'a BOM and CRLF': `${COMPLETE.replace(/\n/g, '\r\n')}`,
|
||
'a blank line above the fence': `\n\n${COMPLETE}`,
|
||
'trailing spaces on the fence': COMPLETE.replace(/^---$/gm, '--- '),
|
||
};
|
||
for (const [what, md] of Object.entries(dirty)) {
|
||
const f = fields.boardFieldsFromSource(md);
|
||
record(
|
||
`upload: a file with ${what} still hydrates`,
|
||
f.name === 'Hiring Activity Assistant' && f.id === 'hiring-activity-assistant'
|
||
&& f.pages.join(',') === 'positions',
|
||
`${f.name || 'EMPTY'} / ${f.id || 'EMPTY'} / ${f.pages.join(',') || 'EMPTY'}`
|
||
);
|
||
record(
|
||
`upload: ...and the registry reads it the same way`,
|
||
reg.parseSkill(md, { custom: true }).name === 'Hiring Activity Assistant',
|
||
reg.parseSkill(md, { custom: true }).name
|
||
);
|
||
record(`upload: ...and it validates`, reg.validateSkillSource(md) === null,
|
||
reg.validateSkillSource(md) || 'valid');
|
||
}
|
||
|
||
/* Normalising on the way in is what keeps `patchFrontmatter` safe: an
|
||
unrecognised fence would have it write a second one above the first. */
|
||
const patchedDirty = fields.patchFrontmatter(fields.normalizeUpload(`${COMPLETE}`), { name: 'Clean' });
|
||
record('upload: patching a normalised file writes one frontmatter block',
|
||
(patchedDirty.match(/^---$/gm) || []).length === 2,
|
||
`${(patchedDirty.match(/^---$/gm) || []).length} fences`);
|
||
record('upload: ...and it still parses', reg.parseSkill(patchedDirty, { custom: true }).name === 'Clean');
|
||
|
||
/* A file with nothing to read must be refused, not read as a blank skill. */
|
||
record('upload: a file with no fence is not readable', !fields.isReadableDefinition('# Just prose\n'));
|
||
record('upload: a real definition is readable', fields.isReadableDefinition(COMPLETE));
|
||
}
|
||
|
||
/* Page names and placements as they are written in the product, not as the
|
||
vocabulary spells them internally. */
|
||
record('upload: `Positions` resolves to the positions surface', surfaces.canonicalPage('Positions') === 'positions');
|
||
record('upload: `Talent Pool` resolves to the talent-pool surface', surfaces.canonicalPage('Talent Pool') === 'talent-pool');
|
||
record('upload: `panel` on Analytics resolves to a real placement',
|
||
surfaces.placementFor('analytics', 'panel') === 'after-header');
|
||
record('upload: an alias never resolves onto a surface that lacks it',
|
||
surfaces.placementFor('analytics', 'grid-card') === null);
|
||
record('upload: a canonical placement still resolves to itself',
|
||
surfaces.placementFor('positions', 'after-position-card') === 'after-position-card');
|
||
|
||
/* Case 4 — an existing definition reopened for editing. */
|
||
for (const skill of reg.SKILLS) {
|
||
const f = skill.facets?.includes('ui')
|
||
? fields.boardFieldsFromSource(skill.markdown)
|
||
: fields.owliverFieldsFromSource(skill.markdown);
|
||
record(
|
||
`reopening \`${skill.id}\` loads its identity unchanged`,
|
||
f.id === skill.id && f.name === skill.name && f.description === skill.description
|
||
&& f.pages.join(',') === skill.pages.join(','),
|
||
`${f.id} / ${f.name} / ${f.pages.join(', ')}`
|
||
);
|
||
}
|
||
|
||
/* Case 5 — a manual edit is not reverted by later synchronisation. */
|
||
{
|
||
/* The editor's own handler, in miniature: hydrate from the file, edit one
|
||
field, then edit an unrelated one. The first edit must survive the second. */
|
||
let draft = fields.owliverFieldsFromSource(COMPLETE);
|
||
let src = COMPLETE;
|
||
const edit = (patch) => {
|
||
draft = { ...draft, ...patch };
|
||
src = fields.patchFrontmatter(src, {
|
||
id: draft.id || undefined,
|
||
name: draft.name || undefined,
|
||
description: draft.description || undefined,
|
||
pages: draft.pages?.length && !fields.pagesAreDerived(src) ? draft.pages : undefined,
|
||
});
|
||
};
|
||
|
||
edit({ name: 'My Own Name' });
|
||
record('edit: a manual name reaches the artefact',
|
||
reg.parseSkill(src, { custom: true }).name === 'My Own Name');
|
||
|
||
edit({ description: 'My own description.' });
|
||
record('edit: a later edit does not revert the earlier one',
|
||
reg.parseSkill(src, { custom: true }).name === 'My Own Name',
|
||
reg.parseSkill(src, { custom: true }).name);
|
||
record('edit: ...and applies itself',
|
||
reg.parseSkill(src, { custom: true }).description === 'My own description.');
|
||
record('edit: ...and the `ui:` block is untouched throughout',
|
||
src.slice(src.indexOf('ui:')) === COMPLETE.slice(COMPLETE.indexOf('ui:')));
|
||
|
||
/* A second upload replaces the draft outright — it is a new source. */
|
||
const rehydrated = fields.owliverFieldsFromSource(MULTI_PAGE);
|
||
record('edit: a derived `pages:` is never written back',
|
||
!src.includes('\npages:'), src.includes('\npages:') ? 'pages: was inserted' : 'not written');
|
||
|
||
record('edit: a declared `pages:` still patches normally',
|
||
reg.parseSkill(
|
||
fields.patchFrontmatter(NO_ID, { pages: ['analytics'] }), { custom: true }
|
||
).pages.join(',') === 'analytics');
|
||
|
||
record('edit: an inherited trigger is not written into the file',
|
||
!src.includes('triggers:'), src.includes('triggers:') ? 'triggers: was materialised' : 'not written');
|
||
record('edit: a declared trigger is still read into the fields',
|
||
fields.owliverFieldsFromSource(
|
||
reg.SKILLS.find((x) => x.id === 'hiring-activity-assistant').markdown
|
||
).triggers.includes('hiring activity'));
|
||
record('edit: a definition with no triggers reads none',
|
||
fields.owliverFieldsFromSource(COMPLETE).triggers.length === 0,
|
||
JSON.stringify(fields.owliverFieldsFromSource(COMPLETE).triggers));
|
||
|
||
record('edit: a second upload hydrates from the new file',
|
||
rehydrated.id === 'hiring-activity' && rehydrated.name === 'Hiring Activity',
|
||
`${rehydrated.id} / ${rehydrated.name}`);
|
||
}
|
||
|
||
/* ── 8. Markdown ↔ manual equivalence ─────────────────────────────────────
|
||
The two authoring paths, asserted to produce one artefact.
|
||
|
||
A form that cannot express what the format can is not a shortcut to it: the
|
||
manual Owliver editor carried a single `source` shared by every selected
|
||
capability, so a capability chosen before a source was picked composed no
|
||
`responses:` block at all — and a definition whose capabilities all drop
|
||
registers, shows as active on its page, and contributes no chip. These check
|
||
that both doors compose the same definition, and that the one that cannot be
|
||
configured is refused rather than saved empty. */
|
||
console.log('\n── Markdown ↔ manual equivalence ──');
|
||
|
||
const dataResolver = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
|
||
const positionsContext = Object.entries(placement.PLACEMENT_ROUTES)
|
||
.find(([route]) => reg.pageKeyForRoute(route) === 'positions')?.[1];
|
||
|
||
/* The canonical model is what `parseSkill` makes of a definition. Comparing the
|
||
whole record would compare its Markdown too, which is the one thing the two
|
||
paths are allowed to differ on — so the runtime configuration is compared,
|
||
which is exactly what every consumer reads. */
|
||
const canonical = (skill) => JSON.parse(JSON.stringify({
|
||
id: skill.id,
|
||
name: skill.name,
|
||
description: skill.description,
|
||
status: skill.status,
|
||
pages: skill.pages,
|
||
kind: skill.kind,
|
||
triggers: skill.triggers,
|
||
ui: skill.ui,
|
||
owliver: skill.owliver,
|
||
}));
|
||
|
||
/* TEST A / B — the Board card, written both ways. */
|
||
const BOARD_MD = `---
|
||
id: board
|
||
name: Board
|
||
description: Helps Owliver understand, analyze, and act on the current task board.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
ui:
|
||
type: card
|
||
placement: after-position-list-summary
|
||
title: Board
|
||
source: candidates.activity
|
||
periods:
|
||
- today
|
||
- last-7-days
|
||
- previous-month
|
||
---
|
||
|
||
# Board
|
||
`;
|
||
|
||
const boardManual = templates.uiSkillTemplate({
|
||
id: 'board',
|
||
name: 'Board',
|
||
description: 'Helps Owliver understand, analyze, and act on the current task board.',
|
||
pages: ['positions'],
|
||
type: 'card',
|
||
placement: 'after-position-list-summary',
|
||
title: 'Board',
|
||
source: 'candidates.activity',
|
||
periods: ['today', 'last-7-days', 'previous-month'],
|
||
});
|
||
|
||
record('TEST A: Markdown Board saves', reg.validateSkillSource(BOARD_MD) === null,
|
||
reg.validateSkillSource(BOARD_MD) || 'accepted');
|
||
record('TEST B: manual Board saves', reg.validateSkillSource(boardManual) === null,
|
||
reg.validateSkillSource(boardManual) || 'accepted');
|
||
|
||
const boardFromMd = reg.parseSkill(BOARD_MD, { custom: true });
|
||
const boardFromForm = reg.parseSkill(boardManual, { custom: true });
|
||
const boardSection = (skill) => Object.values(skill.ui || {}).flatMap((pg) => pg.sections || [])[0];
|
||
|
||
record('TEST A: one section, above the position list',
|
||
Object.values(boardFromMd.ui).flatMap((pg) => pg.sections).length === 1
|
||
&& boardSection(boardFromMd).placement === 'after-position-list-summary',
|
||
`${boardSection(boardFromMd).type}@${boardSection(boardFromMd).placement}`);
|
||
record('TEST A: the Board card contributes no Owliver capability',
|
||
boardFromMd.owliver.enabled === false && boardFromMd.owliver.capabilities.length === 0);
|
||
record('TEST A: no unresolvable section', reg.unresolvableSections(boardFromMd).length === 0,
|
||
reg.unresolvableSections(boardFromMd)[0]?.message || 'none');
|
||
record('TEST B: manual Board renders the identical section',
|
||
JSON.stringify(boardSection(boardFromForm)) === JSON.stringify(boardSection(boardFromMd)),
|
||
JSON.stringify(boardSection(boardFromForm)));
|
||
record('TEST B: manual Board keeps its title', boardSection(boardFromForm).title === 'Board',
|
||
String(boardSection(boardFromForm).title));
|
||
|
||
/* TEST C / D — the conversational skill, written both ways. */
|
||
const OWLIVER_MD = `---
|
||
id: owliver-conversation-test
|
||
name: Hiring Activity Test
|
||
description: Shows hiring activity from the Positions page.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
triggers:
|
||
- hiring activity test
|
||
owliver:
|
||
enabled: true
|
||
suggestions:
|
||
- Show hiring activity
|
||
- Summarize hiring activity
|
||
capabilities:
|
||
- summary
|
||
responses:
|
||
summary:
|
||
source: candidates.activity
|
||
periods:
|
||
- today
|
||
- last-7-days
|
||
- previous-month
|
||
---
|
||
|
||
# Hiring Activity Test
|
||
`;
|
||
|
||
const owliverManual = templates.owliverSkillTemplate({
|
||
id: 'owliver-conversation-test',
|
||
name: 'Hiring Activity Test',
|
||
description: 'Shows hiring activity from the Positions page.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity', 'Summarize hiring activity'],
|
||
capabilities: ['summary'],
|
||
responses: {
|
||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||
},
|
||
});
|
||
|
||
record('TEST C: Markdown Owliver skill saves', reg.validateSkillSource(OWLIVER_MD) === null,
|
||
reg.validateSkillSource(OWLIVER_MD) || 'accepted');
|
||
record('TEST D: manual Owliver skill saves', reg.validateSkillSource(owliverManual) === null,
|
||
reg.validateSkillSource(owliverManual) || 'accepted');
|
||
|
||
for (const [label, source] of [['TEST C', OWLIVER_MD], ['TEST D', owliverManual]]) {
|
||
const custom = [{ path: 'custom/owliver-conversation-test.md', raw: source }];
|
||
const skill = reg.parseSkill(source, { custom: true });
|
||
|
||
record(`${label}: the capability keeps its source`,
|
||
skill.owliver.responses.summary?.source === 'candidates.activity',
|
||
skill.owliver.responses.summary?.source || 'dropped');
|
||
record(`${label}: the capability keeps its periods`,
|
||
JSON.stringify(skill.owliver.responses.summary?.periods)
|
||
=== JSON.stringify(['today', 'last-7-days', 'previous-month']),
|
||
JSON.stringify(skill.owliver.responses.summary?.periods));
|
||
record(`${label}: it draws no page card`,
|
||
Object.keys(skill.ui).length === 0, `${Object.keys(skill.ui).length} pages`);
|
||
|
||
const chips = resolver.owliverSuggestions(positionsContext, [], custom, {});
|
||
const mine = chips.filter((c) => c.skillId === 'owliver-conversation-test');
|
||
record(`${label}: its suggestion reaches the panel`,
|
||
mine.some((c) => c.label === 'Show hiring activity'),
|
||
JSON.stringify(chips.map((c) => c.label)));
|
||
record(`${label}: the suggestion answers without asking for a record`,
|
||
mine.every((c) => !c.deferred));
|
||
|
||
const owlSkills = resolver.owliverSkillsForContext(positionsContext, [], custom);
|
||
const matched = resolver.matchOwliverSkill('Show hiring activity', owlSkills);
|
||
record(`${label}: clicking it routes to this skill's summary`,
|
||
matched?.skill.id === 'owliver-conversation-test' && matched?.capability === 'summary' && matched?.exact,
|
||
`${matched?.skill.id}:${matched?.capability} exact=${matched?.exact}`);
|
||
|
||
const answered = resolver.resolveOwliverResponse({
|
||
skill: matched.skill,
|
||
capability: matched.capability,
|
||
question: 'Show hiring activity',
|
||
context: {
|
||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||
applications: [{ id: 'a1', job_posting_id: 'p1', created_date: new Date().toISOString() }],
|
||
},
|
||
now: new Date(),
|
||
});
|
||
record(`${label}: it answers with the configured reading`,
|
||
answered?.missing === null && resolver.summaryLines(answered.data).length === 3,
|
||
JSON.stringify(resolver.summaryLines(answered?.data || {})));
|
||
}
|
||
|
||
/* TEST H — the same skill through both doors, normalized, compared. */
|
||
record('TEST H: Markdown and manual normalize identically (Board)',
|
||
JSON.stringify(canonical(boardFromMd)) === JSON.stringify(canonical(boardFromForm)),
|
||
JSON.stringify(canonical(boardFromForm)).slice(0, 120));
|
||
record('TEST H: Markdown and manual normalize identically (Owliver)',
|
||
JSON.stringify(canonical(reg.parseSkill(OWLIVER_MD, { custom: true })))
|
||
=== JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))),
|
||
JSON.stringify(canonical(reg.parseSkill(owliverManual, { custom: true }))).slice(0, 120));
|
||
|
||
/* TEST E — a capability with no source is refused, never saved empty. */
|
||
const noSourceFields = {
|
||
id: 'no-source-test',
|
||
name: 'No Source Test',
|
||
description: 'A capability with nothing to read.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity'],
|
||
capabilities: ['summary'],
|
||
responses: {},
|
||
};
|
||
const noSource = templates.owliverSkillTemplate(noSourceFields);
|
||
record('TEST E: an unconfigured capability is named by the form',
|
||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)) === '["summary"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(noSourceFields)));
|
||
record('TEST E: and the save is refused',
|
||
/owliver\.responses\.summary/.test(reg.validateSkillSource(noSource) || ''),
|
||
reg.validateSkillSource(noSource) || 'ACCEPTED — a skill with no capability would register');
|
||
record('TEST E: the refused definition would have had zero capabilities',
|
||
reg.parseSkill(noSource, { custom: true }).owliver.capabilities.length === 0,
|
||
'which is why it is refused rather than stored');
|
||
|
||
/* TEST F — two capabilities, two different sources, neither inheriting. */
|
||
const twoSources = templates.owliverSkillTemplate({
|
||
id: 'two-source-test',
|
||
name: 'Two Source Test',
|
||
description: 'Two capabilities reading two sources.',
|
||
pages: ['positions'],
|
||
suggestions: ['Summarize workspace applications', 'List the open roles'],
|
||
capabilities: ['summary', 'list'],
|
||
responses: {
|
||
summary: { source: 'candidates.activity', periods: ['today'] },
|
||
list: { source: 'positions.demand', limit: 5 },
|
||
},
|
||
});
|
||
const twoSkill = reg.parseSkill(twoSources, { custom: true });
|
||
record('TEST F: manual save accepted', reg.validateSkillSource(twoSources) === null,
|
||
reg.validateSkillSource(twoSources) || 'accepted');
|
||
record('TEST F: each capability keeps its own source',
|
||
twoSkill.owliver.responses.summary?.source === 'candidates.activity'
|
||
&& twoSkill.owliver.responses.list?.source === 'positions.demand',
|
||
`summary→${twoSkill.owliver.responses.summary?.source}, list→${twoSkill.owliver.responses.list?.source}`);
|
||
record('TEST F: neither inherits the other\'s options',
|
||
JSON.stringify(twoSkill.owliver.responses.summary.periods) === '["today"]'
|
||
&& twoSkill.owliver.responses.list.limit === 5
|
||
&& twoSkill.owliver.responses.list.periods.length === 0,
|
||
`summary periods=${JSON.stringify(twoSkill.owliver.responses.summary.periods)}, list limit=${twoSkill.owliver.responses.list.limit}`);
|
||
record('TEST F: both are offered as suggestions',
|
||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/two.md', raw: twoSources }], {})
|
||
.filter((c) => c.skillId === 'two-source-test').length === 2);
|
||
|
||
/* TEST G — editing one field leaves every other configuration alone. */
|
||
const edited = fields.patchFrontmatter(
|
||
twoSources,
|
||
fields.owliverPatch(
|
||
{ ...fields.owliverFieldsFromSource(twoSources), description: 'A new description.' },
|
||
{ existing: twoSources }
|
||
)
|
||
);
|
||
const editedSkill = reg.parseSkill(edited, { custom: true });
|
||
record('TEST G: the edit lands', editedSkill.description === 'A new description.', editedSkill.description);
|
||
record('TEST G: every capability source survives the edit',
|
||
JSON.stringify(canonical(editedSkill).owliver.responses)
|
||
=== JSON.stringify(canonical(twoSkill).owliver.responses),
|
||
`summary→${editedSkill.owliver.responses.summary?.source}, list→${editedSkill.owliver.responses.list?.source}`);
|
||
record('TEST G: suggestions survive the edit',
|
||
JSON.stringify(editedSkill.owliver.suggestions) === JSON.stringify(twoSkill.owliver.suggestions));
|
||
|
||
const boardEdited = fields.patchFrontmatter(
|
||
boardManual,
|
||
fields.boardPatch(
|
||
{ ...fields.boardFieldsFromSource(boardManual), description: 'A new description.' },
|
||
{ existing: boardManual }
|
||
)
|
||
);
|
||
record('TEST G: Board UI configuration survives the edit',
|
||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true })))
|
||
=== JSON.stringify(boardSection(boardFromForm)),
|
||
JSON.stringify(boardSection(reg.parseSkill(boardEdited, { custom: true }))));
|
||
|
||
/* ── Editing a definition that is not indented with ASCII spaces ──────────
|
||
*
|
||
* Found in a browser, on a definition stored on a real account: changing the
|
||
* card title through Skill Configure appended a whole second `ui:` block, so
|
||
* every field under it appeared twice. The account's definition indents with
|
||
* U+00A0, which JavaScript's `\s` matches — the parser read the file correctly,
|
||
* every screen showed the right values, and only the writer disagreed, because
|
||
* `findKey` compared against literal ASCII spaces and concluded the keys it was
|
||
* asked to update did not exist.
|
||
*
|
||
* The Go port shares the parser's reading (`jsIsSpace` lists `0x00A0`), so this
|
||
* was never a parser disagreement and needs no parser change. The fixture below
|
||
* builds the indentation from an escape rather than a pasted character, so an
|
||
* editor that tidies whitespace cannot silently delete the thing under test.
|
||
*/
|
||
const NB = '\u00a0';
|
||
const NBSP_BOARD = [
|
||
'---',
|
||
'id: nbsp-board',
|
||
'name: NBSP Board',
|
||
'description: A definition indented with no-break spaces.',
|
||
'pages:',
|
||
`${NB}${NB}- positions`,
|
||
'status: active',
|
||
'ui:',
|
||
`${NB}${NB}type: card`,
|
||
`${NB}${NB}placement: after-position-list-summary`,
|
||
`${NB}${NB}title: Before`,
|
||
`${NB}${NB}source: candidates.activity`,
|
||
`${NB}${NB}periods:`,
|
||
`${NB}${NB}${NB}${NB}- today`,
|
||
'---',
|
||
'',
|
||
'# NBSP Board',
|
||
'',
|
||
'Body.',
|
||
'',
|
||
].join('\n');
|
||
|
||
const nbspEdited = fields.patchFrontmatter(
|
||
NBSP_BOARD,
|
||
fields.boardPatch(
|
||
{ ...fields.boardFieldsFromSource(NBSP_BOARD), title: 'BOARD UI TARGET TEST' },
|
||
{ existing: NBSP_BOARD }
|
||
)
|
||
);
|
||
const nbspFm = nbspEdited.split('---')[1];
|
||
const nbspCount = (pattern) => (nbspFm.match(pattern) || []).length;
|
||
const nbspParsed = reg.parseSkill(nbspEdited, { custom: true });
|
||
const nbspSection = nbspParsed?.ui?.positions?.sections?.[0] || null;
|
||
|
||
record('NBSP: editing the title replaces it rather than adding a second one',
|
||
nbspSection?.title === 'BOARD UI TARGET TEST' && !/title:\s*Before/.test(nbspFm),
|
||
nbspSection?.title);
|
||
|
||
for (const key of ['type', 'placement', 'title', 'source', 'periods']) {
|
||
record(`NBSP: \`${key}\` is not duplicated`,
|
||
nbspCount(new RegExp(`^\\s+${key}:`, 'gm')) === 1,
|
||
`${nbspCount(new RegExp(`^\\s+${key}:`, 'gm'))} occurrence(s)`);
|
||
}
|
||
|
||
record('NBSP: exactly one `ui:` block remains',
|
||
nbspCount(/^ui:/gm) === 1, `${nbspCount(/^ui:/gm)} block(s)`);
|
||
|
||
record('NBSP: the edited definition still parses',
|
||
Boolean(nbspParsed)
|
||
&& nbspParsed.id === 'nbsp-board'
|
||
&& (nbspParsed.ui?.positions?.sections || []).length === 1
|
||
&& nbspSection?.type === 'card'
|
||
&& nbspSection?.placement === 'after-position-list-summary'
|
||
&& nbspSection?.source === 'candidates.activity',
|
||
`${nbspSection?.type} at ${nbspSection?.placement} reading ${nbspSection?.source}`);
|
||
|
||
record('NBSP: `pages` is unchanged by the edit',
|
||
JSON.stringify(nbspParsed?.pages) === JSON.stringify(['positions']),
|
||
JSON.stringify(nbspParsed?.pages));
|
||
|
||
/* The half that must not move: ASCII definitions are the entire repository, and
|
||
widening what counts as an indent must leave them byte-identical. */
|
||
record('ASCII indentation is written exactly as it was before',
|
||
fields.patchFrontmatter(boardManual, { name: 'Renamed Board' })
|
||
=== fields.patchFrontmatter(boardManual, { name: 'Renamed Board' })
|
||
&& /^ {2}type:/m.test(fields.patchFrontmatter(boardManual, { name: 'Renamed Board' })),
|
||
'two-space indentation preserved');
|
||
|
||
/* TEST 6 — one source/shape resolver, shared by the pickers and the validator. */
|
||
record('shape compatibility: the resolver refuses what the normalizer refuses',
|
||
surfaces.sourcesForShape('list').every((src) => surfaces.sourceSupportsShape(src.id, 'list'))
|
||
&& !surfaces.sourcesForShape('list').some((src) => src.id === 'candidates.activity'),
|
||
`list sources: ${surfaces.sourcesForShape('list').map((s) => s.id).join(', ')}`);
|
||
record('shape compatibility: a list against candidates.activity is refused on save',
|
||
/cannot be shown as/.test(reg.validateSkillSource(templates.owliverSkillTemplate({
|
||
id: 'bad-shape-test',
|
||
name: 'Bad Shape Test',
|
||
description: 'A list of something with no list in it.',
|
||
pages: ['positions'],
|
||
capabilities: ['list'],
|
||
responses: { list: { source: 'candidates.activity' } },
|
||
})) || ''),
|
||
'the picker cannot offer it, and the save refuses it');
|
||
record('summary is prose, so every source suits it',
|
||
surfaces.shapeForCapability('summary') === null
|
||
&& surfaces.sourcesForShape(null).length === surfaces.DATA_SOURCES.length);
|
||
|
||
/* ── 9. The reported failure, reproduced end to end ───────────────────────── */
|
||
console.log('\n── Regression: manual Owliver skill on Positions ──');
|
||
|
||
/* Exactly the configuration from the report: Pages = Positions, one suggestion,
|
||
Summary, `candidates.activity`, three periods — composed the way the form
|
||
composes it, then read the way the panel reads it. */
|
||
const REGRESSION = templates.owliverSkillTemplate({
|
||
id: 'owliver-conversation-test',
|
||
name: 'Hiring Activity Test',
|
||
description: 'Shows hiring activity from the Positions page.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity'],
|
||
capabilities: ['summary'],
|
||
responses: {
|
||
summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'] },
|
||
},
|
||
});
|
||
const stored = [{ path: 'custom/owliver-conversation-test.md', raw: REGRESSION }];
|
||
|
||
record('the manual definition saves', reg.validateSkillSource(REGRESSION) === null,
|
||
reg.validateSkillSource(REGRESSION) || 'accepted');
|
||
record('it registers', reg.allSkills(stored).some((s) => s.id === 'owliver-conversation-test'));
|
||
record('it is attached to Positions',
|
||
reg.skillsForContext(positionsContext, [], stored).some((s) => s.id === 'owliver-conversation-test'));
|
||
record('it keeps a capability — the step that used to empty',
|
||
reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities.length === 1,
|
||
JSON.stringify(reg.parseSkill(REGRESSION, { custom: true }).owliver.capabilities));
|
||
record('it reaches owliverSkillsForContext',
|
||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||
.some((s) => s.id === 'owliver-conversation-test'));
|
||
|
||
const regressionChips = resolver.owliverSuggestions(positionsContext, [], stored, {});
|
||
record('"Show hiring activity" is offered as a chip',
|
||
regressionChips.some((c) => c.label === 'Show hiring activity'),
|
||
JSON.stringify(regressionChips.map((c) => c.label)));
|
||
|
||
const clicked = resolver.matchOwliverSkill(
|
||
'Show hiring activity',
|
||
resolver.owliverSkillsForContext(positionsContext, [], stored)
|
||
);
|
||
record('clicking it invokes this skill\'s summary',
|
||
clicked?.skill.id === 'owliver-conversation-test' && clicked?.capability === 'summary',
|
||
`${clicked?.skill.id}:${clicked?.capability}`);
|
||
|
||
const now = new Date();
|
||
const answer = resolver.resolveOwliverResponse({
|
||
skill: clicked.skill,
|
||
capability: clicked.capability,
|
||
question: 'Show hiring activity',
|
||
context: {
|
||
positions: [{ id: 'p1', title: 'Line Cook', status: 'active' }],
|
||
applications: [
|
||
{ id: 'a1', job_posting_id: 'p1', created_date: now.toISOString() },
|
||
{ id: 'a2', job_posting_id: 'p1', created_date: new Date(now - 3 * 86400000).toISOString() },
|
||
],
|
||
},
|
||
now,
|
||
});
|
||
record('it answers with the configured source and periods',
|
||
answer?.missing === null && answer.section.source === 'candidates.activity'
|
||
&& answer.section.periods.length === 3,
|
||
`${answer?.section.source} over ${JSON.stringify(answer?.section.periods)}`);
|
||
record('the answer carries real figures',
|
||
resolver.summaryLines(answer.data).length === 3,
|
||
JSON.stringify(resolver.summaryLines(answer.data)));
|
||
record('and no `position.activity needs a position` is possible',
|
||
dataResolver.resolveSkillData(answer.section, { applications: [], positions: [] }, now).unavailable !== true,
|
||
'the source declares no context requirement');
|
||
|
||
/* Reload: what is stored is the Markdown, so the round trip through storage is
|
||
the round trip through the parser. */
|
||
const reloaded = reg.parseSkill(
|
||
fields.patchFrontmatter(REGRESSION, {}),
|
||
{ custom: true }
|
||
);
|
||
record('the source survives a reload',
|
||
reloaded.owliver.responses.summary?.source === 'candidates.activity',
|
||
reloaded.owliver.responses.summary?.source || 'lost');
|
||
record('the form reads it back unchanged',
|
||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses)
|
||
=== JSON.stringify({ summary: { source: 'candidates.activity', periods: ['today', 'last-7-days', 'previous-month'], limit: null } }),
|
||
JSON.stringify(fields.owliverFieldsFromSource(REGRESSION).responses));
|
||
|
||
/* ── 10. Storage round trip ───────────────────────────────────────────────
|
||
The editors do not hand the registry a record; they write Markdown into
|
||
`preferences.customSkills`, which `base44Client` sends to the API and reads
|
||
back on the next load. "The source disappeared after reload" is a claim about
|
||
*that* path, so it is exercised here rather than assumed: the same module the
|
||
app runs, against the same requests it makes.
|
||
|
||
The API is stubbed, and only here. `base44Client` is production code and must
|
||
talk to the real backend — that is the whole point of it — so what is
|
||
replaced is the transport underneath it: `fetch`, answering the three
|
||
endpoints this section drives with an in-memory record. Everything above the
|
||
transport is the real thing: the same client, the same request builder, the
|
||
same envelope unwrapping, the same synchronous preferences accessor.
|
||
|
||
Node has no `localStorage` and no origin, so both are supplied. The origin is
|
||
why this used to fail outright: the client builds a same-origin path
|
||
(`/api/v1/...`), which `fetch` cannot resolve without one. */
|
||
console.log('\n── Storage round trip ──');
|
||
|
||
const store = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||
setItem: (k, v) => store.set(k, String(v)),
|
||
removeItem: (k) => store.delete(k),
|
||
};
|
||
|
||
/* The user the stubbed API holds, and the requests it saw. Both are asserted
|
||
below: what the client sent matters as much as what it did with the reply. */
|
||
const apiUser = {
|
||
id: 'user_demo', full_name: 'Alex Rivera', email: 'demo@krow.app',
|
||
role: 'admin', account_type: 'employer',
|
||
preferences: { owliverDefault: true, compactDensity: false, emailDigest: true },
|
||
};
|
||
const apiCalls = [];
|
||
|
||
/* What GET /api/v1/owliver/suggestions answers with. Set per assertion, so a
|
||
test can say what the server returned and check what the panel made of it. */
|
||
let apiSuggestions = [];
|
||
|
||
/* Turns the suggestions route into one this backend does not have — the
|
||
deployment lag where the route is in the Go source and not yet on the host
|
||
the dev server proxies to. Everything else keeps answering, which is exactly
|
||
what makes that state confusing to diagnose by hand. */
|
||
let apiSuggestionsDeployed = true;
|
||
|
||
/* The collections the stubbed API serves, by resource path.
|
||
*
|
||
* Filled in by section 12 from `api/seed.js` — the shipped fixture, which is
|
||
* what it is for now that the running app reads PostgreSQL instead. Declared
|
||
* here because the stub is installed before that module is loaded, and empty
|
||
* until then so an accidental early read is an empty list rather than a lie.
|
||
*
|
||
* The paths are `httpClient.js`'s own RESOURCE_PATHS, which is a declared table
|
||
* rather than a derivation, so the two are written out the same way. */
|
||
const apiEntities = new Map();
|
||
|
||
/* The subset of the list contract this fixture implements: repeated parameters
|
||
* as `IN`, `-field` as descending, `limit` as a cap. Enough for the reads below
|
||
* and deliberately no more — a fuller reimplementation would be a second
|
||
* backend to keep honest. */
|
||
function serveList(records, params) {
|
||
let out = [...records];
|
||
|
||
for (const [key, values] of params) {
|
||
if (key === 'sort' || key === 'limit' || key === 'offset') continue;
|
||
out = out.filter((record) => values.includes(String(record[key])));
|
||
}
|
||
|
||
const sort = params.has('sort') ? String(params.get('sort')[0] ?? '') : '';
|
||
if (sort) {
|
||
const desc = sort.startsWith('-');
|
||
const field = desc ? sort.slice(1) : sort;
|
||
out.sort((a, b) => {
|
||
const [x, y] = [a[field], b[field]];
|
||
const cmp = x === y ? 0
|
||
: (typeof x === 'number' && typeof y === 'number') ? x - y
|
||
: String(x ?? '').localeCompare(String(y ?? ''));
|
||
return desc ? -cmp : cmp;
|
||
});
|
||
}
|
||
|
||
const limit = Number(params.get('limit')?.[0]);
|
||
return limit > 0 ? out.slice(0, limit) : out;
|
||
}
|
||
|
||
const jsonResponse = (data) => new Response(JSON.stringify({ data }), {
|
||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||
});
|
||
|
||
globalThis.fetch = async (url, init = {}) => {
|
||
const method = init.method || 'GET';
|
||
const path = String(url);
|
||
apiCalls.push({ method, path, body: init.body ? JSON.parse(init.body) : null });
|
||
|
||
if (method === 'GET' && path === '/api/v1/me') return jsonResponse(apiUser);
|
||
|
||
if (method === 'PATCH' && path === '/api/v1/me/preferences') {
|
||
apiUser.preferences = { ...apiUser.preferences, ...JSON.parse(init.body) };
|
||
return jsonResponse(apiUser.preferences);
|
||
}
|
||
|
||
if (method === 'GET' && path.startsWith('/api/v1/owliver/suggestions')) {
|
||
if (!apiSuggestionsDeployed) {
|
||
return new Response(
|
||
JSON.stringify({ error: { code: 'not_found', message: 'not found' } }),
|
||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||
);
|
||
}
|
||
return jsonResponse({ suggestions: apiSuggestions });
|
||
}
|
||
|
||
const listed = /^\/api\/v1\/([a-z-]+)(?:\?(.*))?$/.exec(path);
|
||
if (method === 'GET' && listed && apiEntities.has(listed[1])) {
|
||
const params = new URLSearchParams(listed[2] || '');
|
||
const grouped = new Map();
|
||
for (const key of new Set(params.keys())) grouped.set(key, params.getAll(key));
|
||
return jsonResponse(serveList(apiEntities.get(listed[1]), grouped));
|
||
}
|
||
|
||
return new Response(
|
||
JSON.stringify({ error: { code: 'not_found', message: `no stub for ${method} ${path}` } }),
|
||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||
);
|
||
};
|
||
|
||
const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js');
|
||
/* The client hydrates itself with GET /me at module load; wait for that to land
|
||
so the synchronous accessor below reads the server's answer rather than the
|
||
default shape. */
|
||
await base44.auth.me();
|
||
const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js');
|
||
|
||
/* Saving is what the editor's Save button does: compose, validate, upsert,
|
||
write the preference. */
|
||
const saved = custom.upsertCustomSkill(base44.auth.preferences().customSkills || [], REGRESSION);
|
||
const written = await base44.auth.updatePreferences({ customSkills: saved.next });
|
||
record('the definition is persisted', written.persisted, written.error?.message || 'written to storage');
|
||
|
||
/* Reload: a fresh read of the same key, exactly as a page load does. */
|
||
const rehydratedSkills = base44.auth.preferences().customSkills || [];
|
||
record('it survives the reload', rehydratedSkills.length === 1
|
||
&& String(rehydratedSkills[0].raw).includes('candidates.activity'),
|
||
`${rehydratedSkills.length} stored`);
|
||
|
||
const afterReload = reg.parseSkill(rehydratedSkills[0].raw, { custom: true });
|
||
record('its capability survives the reload',
|
||
afterReload.owliver.responses.summary?.source === 'candidates.activity',
|
||
afterReload.owliver.responses.summary?.source || 'lost');
|
||
record('and it still offers its chip after the reload',
|
||
resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {})
|
||
.some((c) => c.label === 'Show hiring activity'),
|
||
JSON.stringify(resolver.owliverSuggestions(positionsContext, [], rehydratedSkills, {}).map((c) => c.label)));
|
||
record('the editor reopens it with its source intact',
|
||
fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses.summary?.source === 'candidates.activity',
|
||
JSON.stringify(fields.owliverFieldsFromSource(rehydratedSkills[0].raw).responses));
|
||
|
||
/* The Board skill through the same path, stored beside it — the two subsystems
|
||
are independent, so both must survive one write. */
|
||
const bothStored = custom.upsertCustomSkill(rehydratedSkills, boardManual).next;
|
||
await base44.auth.updatePreferences({ customSkills: bothStored });
|
||
const finalStored = base44.auth.preferences().customSkills || [];
|
||
const finalRegistry = reg.readSkillRegistry(finalStored);
|
||
record('Board and Owliver skills coexist in storage', finalStored.length === 2,
|
||
`${finalStored.length} stored`);
|
||
record('neither produces a registry diagnostic',
|
||
!finalRegistry.diagnostics.some((d) => d.level === 'error'),
|
||
finalRegistry.diagnostics.filter((d) => d.level === 'error').map((d) => d.message).join(' | ') || 'clean');
|
||
record('the Board card draws once on Positions, and offers no chip',
|
||
reg.getSkillsForPage('positions', { customSources: finalStored })
|
||
.filter((sk) => sk.id === 'board')
|
||
.every((sk) => Object.values(sk.ui).flatMap((pg) => pg.sections).length === 1
|
||
&& sk.owliver.capabilities.length === 0));
|
||
record('the Owliver skill offers its chip, and draws no card',
|
||
resolver.owliverSuggestions(positionsContext, [], finalStored, {})
|
||
.some((c) => c.skillId === 'owliver-conversation-test')
|
||
&& Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0);
|
||
|
||
/* ── 10b. Owliver suggestions come from the API ───────────────────────────
|
||
*
|
||
* The suggestion the panel offers is a claim that the reader could usefully ask
|
||
* something, and only the backend can make that claim: it knows the caller's
|
||
* role, and — with nothing typed — it ranks against what is actually in
|
||
* PostgreSQL. So the frontend's whole part in this is a request and a
|
||
* translation, and these checks are about exactly that boundary.
|
||
*
|
||
* What is asserted, in order: the request the client makes, and the chip the
|
||
* panel makes of the reply. Nothing here re-implements ranking, because nothing
|
||
* in `src/` does either — the point of the change these checks protect is that
|
||
* the local ranker is gone. `MISSING matchPrompts` below is the guard against
|
||
* it coming back.
|
||
*/
|
||
console.log('\n── Owliver suggestions from the API ──');
|
||
|
||
const serverSuggestions = await server.ssrLoadModule('/src/lib/skills/serverSuggestions.js');
|
||
|
||
/* ── The request ─────────────────────────────────────────────────────────── */
|
||
|
||
const requestFor = async (args) => {
|
||
const before = apiCalls.length;
|
||
await base44.owliver.suggestions(args);
|
||
return apiCalls.slice(before).map((c) => c.path);
|
||
};
|
||
|
||
const typedRequest = await requestFor({ page: 'positions', query: 'pipeline' });
|
||
record('a typed query is asked of the API',
|
||
typedRequest.length === 1
|
||
&& typedRequest[0].startsWith('/api/v1/owliver/suggestions')
|
||
&& typedRequest[0].includes('page=positions')
|
||
&& typedRequest[0].includes('query=pipeline'),
|
||
typedRequest.join(', ') || 'no request made');
|
||
|
||
/* Absent, not empty. `?query=` and no `query` at all are different requests:
|
||
the first says "the composer holds nothing", the second asks the data what to
|
||
suggest, and only the second reads the database. */
|
||
const untypedRequest = await requestFor({ page: 'positions' });
|
||
record('an empty composer asks the data, not for an empty string',
|
||
untypedRequest.length === 1 && !untypedRequest[0].includes('query='),
|
||
untypedRequest.join(', '));
|
||
|
||
record('a page-less request is not made at all',
|
||
(await requestFor({ query: 'pipeline' })).length === 0,
|
||
'nothing to ask about');
|
||
|
||
/* ── The reply, as chips ─────────────────────────────────────────────────── */
|
||
|
||
/* Two the Positions page declares and one it does not, so both branches of the
|
||
translation are exercised by one response. */
|
||
apiSuggestions = [
|
||
{ text: 'Which positions need attention?', intent: 'positions-attention' },
|
||
{ text: 'Show hiring activity as a flow', intent: 'hiring-operations', capability: 'flow' },
|
||
{ text: 'A reading this build has not shipped', intent: 'not-a-capability' },
|
||
];
|
||
const fromServer = await base44.owliver.suggestions({ page: 'positions', query: 'attention' });
|
||
const chips = serverSuggestions.suggestionChips(fromServer, positionsContext);
|
||
|
||
record('every suggestion the server sent becomes a chip',
|
||
chips.length === apiSuggestions.length, `${chips.length} of ${apiSuggestions.length}`);
|
||
|
||
record('...in the order the server ranked them',
|
||
chips.map((c) => c.label).join(' | ') === apiSuggestions.map((s) => s.text).join(' | '),
|
||
chips.map((c) => c.label).join(' | '));
|
||
|
||
record('...reading exactly what the server wrote',
|
||
chips.every((c, i) => c.label === apiSuggestions[i].text && c.prompt === apiSuggestions[i].text),
|
||
'label and prompt are the server text');
|
||
|
||
/* These two checks used to assert that a suggestion resolved to a LOCAL
|
||
capability — a canned reading the browser would run itself. Those readings
|
||
were the simulator, and they were deleted along with it. The property that
|
||
replaced them matters more: every suggestion now becomes a question, asked in
|
||
the server's own words, and nothing in between rewrites it. */
|
||
record('every suggestion becomes a question, in the server\'s own words',
|
||
chips.every((c, i) => c.prompt === apiSuggestions[i].text),
|
||
chips.map((c) => c.prompt).join(' | '));
|
||
|
||
/* `capability` on a server suggestion names a SECTION TYPE to draw, not
|
||
anything to run. It used to be at risk of being confused with the chip's own
|
||
capability field; there is no longer a second meaning to confuse it with, and
|
||
this asserts the remaining one still rides through untouched. */
|
||
record('a requested shape rides through as a shape, not as an instruction',
|
||
chips[1].shape === 'flow',
|
||
`shape=${chips[1].shape}`);
|
||
|
||
record('every chip says it came from the API',
|
||
chips.every((c) => c.source === 'api'));
|
||
|
||
apiSuggestions = [];
|
||
record('no suggestions is an empty row, not a fabricated one',
|
||
serverSuggestions.suggestionChips(
|
||
await base44.owliver.suggestions({ page: 'positions', query: 'sourdough' }),
|
||
positionsContext
|
||
).length === 0);
|
||
|
||
/* ── A backend without the route ─────────────────────────────────────────── */
|
||
|
||
/* The deployment lag this guards against is real and has happened: the route is
|
||
in the Go source and not yet on the host a dev server proxies to, which
|
||
answers 404 for it and 200 for everything else. Every keystroke would then be
|
||
a request that cannot succeed, and no later keystroke can change the answer. */
|
||
const hooks = await server.ssrLoadModule('/src/lib/krowHooks.js');
|
||
|
||
apiSuggestionsDeployed = false;
|
||
record('a suggestion request that 404s resolves to none, not an error',
|
||
(await hooks.fetchOwliverSuggestions({ page: 'positions', query: 'attention' })).length === 0);
|
||
|
||
const afterBreaker = apiCalls.length;
|
||
await hooks.fetchOwliverSuggestions({ page: 'positions', query: 'pipeline' });
|
||
record('...and stops asking a backend that does not have the route',
|
||
apiCalls.length === afterBreaker,
|
||
`${apiCalls.length - afterBreaker} request(s) after the 404`);
|
||
|
||
/* ── The local ranker is gone ────────────────────────────────────────────── */
|
||
|
||
/* Structural, because this is the property that decays quietly. The panel used
|
||
to rank a locally-built catalogue against the query — the same job the API
|
||
now does — and the failure mode of it returning is not an error but a second
|
||
opinion silently outranking the server's. */
|
||
record('the panel imports no local suggestion ranker',
|
||
!existsSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/matchPrompts.js'))),
|
||
'matchPrompts.js removed');
|
||
|
||
const panelSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/KrowAssistant.jsx')), 'utf8');
|
||
record('...and does not rank, score or sort suggestions itself',
|
||
!/rankPrompts|\.sort\(|score\(/.test(panelSource),
|
||
'no ranking in the panel');
|
||
record('...it asks the API instead',
|
||
/useOwliverSuggestions/.test(panelSource) && /suggestionChips/.test(panelSource));
|
||
|
||
/* The production data client must not carry the fixture. */
|
||
record('the app does not import the seed fixture',
|
||
!/from '\.\/seed'/.test(readFileSync(join(ROOT, resolveSourcePath('src/api/base44Client.js')), 'utf8')),
|
||
'base44Client reads demoUser.js, not seed.js');
|
||
record('the local entity store is gone',
|
||
!existsSync(join(ROOT, resolveSourcePath('src/api/store.js'))),
|
||
'store.js removed');
|
||
|
||
/* ── 11. A fresh Add Owliver Skill screen ─────────────────────────────────
|
||
What the form starts as, asserted at the state that drives the checkbox
|
||
rather than at the checkbox. `checked` is
|
||
`draft.capabilities.includes(capability.id)` and the per-capability
|
||
configuration is rendered under `{checked && …}`, so the array below is the
|
||
whole of "what is ticked" and "what is shown". */
|
||
console.log('\n── Fresh Add Owliver Skill ──');
|
||
|
||
const CAPS = surfaces.OWLIVER_CAPABILITIES.map((c) => c.id);
|
||
|
||
/* 1. NEW skill. `emptyDraft` is `EMPTY_OWLIVER_FIELDS`, which is what the
|
||
editor initializes to when there is no `:id` and nothing handed over. */
|
||
record('new skill: initial capabilities are []',
|
||
Array.isArray(fields.EMPTY_OWLIVER_FIELDS.capabilities)
|
||
&& fields.EMPTY_OWLIVER_FIELDS.capabilities.length === 0,
|
||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.capabilities));
|
||
record('new skill: no response is pre-configured either',
|
||
Object.keys(fields.EMPTY_OWLIVER_FIELDS.responses).length === 0,
|
||
JSON.stringify(fields.EMPTY_OWLIVER_FIELDS.responses));
|
||
record(`new skill: all ${CAPS.length} capability checkboxes start unchecked`,
|
||
CAPS.every((id) => !fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)),
|
||
CAPS.filter((id) => fields.EMPTY_OWLIVER_FIELDS.capabilities.includes(id)).join(', ') || 'none ticked');
|
||
|
||
/* The template the fresh screen composes must not declare capabilities either:
|
||
an author who types a name before ticking anything patches *that* file, and a
|
||
`capabilities:` key in it would tick boxes nobody chose. */
|
||
const freshTemplate = templates.owliverSkillTemplate({});
|
||
record('new skill: the composed template declares no capabilities',
|
||
!/^\s*capabilities:/m.test(freshTemplate),
|
||
freshTemplate.split('\n').filter((l) => /capabilit/i.test(l)).join(' / ') || 'no capabilities key');
|
||
record('new skill: reading that template back still yields []',
|
||
fields.owliverFieldsFromSource(freshTemplate).capabilities.length === 0,
|
||
JSON.stringify(fields.owliverFieldsFromSource(freshTemplate).capabilities));
|
||
|
||
/* 2. One capability, chosen. The editor's `update({ capabilities })` is a plain
|
||
state merge, so the assertion is on what that array then means. */
|
||
const pickedSummary = { ...fields.EMPTY_OWLIVER_FIELDS, capabilities: ['summary'] };
|
||
record('selecting Summary ticks exactly Summary',
|
||
JSON.stringify(pickedSummary.capabilities) === '["summary"]');
|
||
record('and only Summary renders a configuration block',
|
||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).length === 1,
|
||
CAPS.filter((id) => pickedSummary.capabilities.includes(id)).join(', '));
|
||
record('an unsourced Summary is invalid, and only Summary is reported',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)) === '["summary"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedSummary)));
|
||
|
||
/* 3. Choosing its source clears the error, and the definition saves. */
|
||
const sourcedSummary = {
|
||
...pickedSummary,
|
||
id: 'fresh-summary-test',
|
||
name: 'Fresh Summary Test',
|
||
description: 'One capability, configured.',
|
||
pages: ['positions'],
|
||
suggestions: ['Show hiring activity'],
|
||
responses: { summary: { source: 'candidates.activity', periods: ['today'] } },
|
||
};
|
||
record('choosing a source clears the validation error',
|
||
fields.unconfiguredCapabilities(sourcedSummary).length === 0,
|
||
JSON.stringify(fields.unconfiguredCapabilities(sourcedSummary)));
|
||
const freshSaved = templates.owliverSkillTemplate(sourcedSummary);
|
||
record('and the save succeeds', reg.validateSkillSource(freshSaved) === null,
|
||
reg.validateSkillSource(freshSaved) || 'accepted');
|
||
record('the saved definition declares only the chosen capability',
|
||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities) === '["summary"]',
|
||
JSON.stringify(reg.parseSkill(freshSaved, { custom: true }).owliver.capabilities));
|
||
record('and its suggestion reaches the Positions panel',
|
||
resolver.owliverSuggestions(positionsContext, [], [{ path: 'custom/fresh.md', raw: freshSaved }], {})
|
||
.some((c) => c.skillId === 'fresh-summary-test'));
|
||
|
||
/* 4. Several, chosen by hand — only those, each validated on its own. */
|
||
const pickedTwo = {
|
||
...fields.EMPTY_OWLIVER_FIELDS,
|
||
capabilities: ['summary', 'list'],
|
||
responses: { summary: { source: 'candidates.activity' } },
|
||
};
|
||
record('selecting two capabilities renders exactly those two',
|
||
CAPS.filter((id) => pickedTwo.capabilities.includes(id)).join(',') === 'summary,list');
|
||
record('and only the unconfigured one is reported',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)) === '["list"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(pickedTwo)));
|
||
|
||
/* 5. An EXISTING skill hydrates from its own definition, and from nothing else.
|
||
Including the capability that did *not* resolve: it is the one the author
|
||
opened the editor to fix, and dropping it from the form would delete it
|
||
from the file on the next save. */
|
||
const DECLARED_ONE = `---
|
||
id: declared-one
|
||
name: Declared One
|
||
description: Declares a single capability.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
owliver:
|
||
enabled: true
|
||
suggestions:
|
||
- Show hiring activity
|
||
capabilities:
|
||
- summary
|
||
responses:
|
||
summary:
|
||
source: candidates.activity
|
||
---
|
||
|
||
# Declared One
|
||
`;
|
||
record('existing skill: hydrates exactly its declared capability',
|
||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities) === '["summary"]',
|
||
JSON.stringify(fields.owliverFieldsFromSource(DECLARED_ONE).capabilities));
|
||
record('existing skill: no new-skill default is applied on top',
|
||
CAPS.filter((id) => fields.owliverFieldsFromSource(DECLARED_ONE).capabilities.includes(id)).length === 1);
|
||
|
||
const HALF_BROKEN = DECLARED_ONE
|
||
.replace(' - summary\n', ' - summary\n - list\n')
|
||
.replace('id: declared-one', 'id: half-broken');
|
||
const halfFields = fields.owliverFieldsFromSource(HALF_BROKEN);
|
||
record('existing skill: a declared capability that lost its source still shows',
|
||
JSON.stringify(halfFields.capabilities) === '["summary","list"]',
|
||
JSON.stringify(halfFields.capabilities));
|
||
record('...unsourced, so it stays invalid rather than silently dropped',
|
||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)) === '["list"]',
|
||
JSON.stringify(fields.unconfiguredCapabilities(halfFields)));
|
||
record('...and the configured one keeps its source through the round trip',
|
||
halfFields.responses.summary.source === 'candidates.activity',
|
||
halfFields.responses.summary.source);
|
||
record('...while the runtime still registers only what can answer',
|
||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities) === '["summary"]',
|
||
JSON.stringify(reg.parseSkill(HALF_BROKEN, { custom: true }).owliver.capabilities));
|
||
|
||
/* A definition inheriting its reading from a `ui:` section still hydrates the
|
||
capability it actually offers — the union is declaration-first, not
|
||
declaration-only. */
|
||
const INHERITED = `---
|
||
id: inherited-cap
|
||
name: Inherited Cap
|
||
description: A capability reading the page section.
|
||
pages:
|
||
- positions
|
||
status: active
|
||
ui:
|
||
type: card
|
||
placement: after-position-list-summary
|
||
source: candidates.activity
|
||
owliver:
|
||
enabled: true
|
||
capabilities:
|
||
- card
|
||
---
|
||
|
||
# Inherited Cap
|
||
`;
|
||
record('a capability inheriting from `ui:` still hydrates with its source',
|
||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source === 'candidates.activity',
|
||
fields.owliverFieldsFromSource(INHERITED).responses.card?.source || 'lost');
|
||
|
||
/* ── 12. Attendance and overtime data ─────────────────────────────────────
|
||
*
|
||
* Attendance is the one collection whose dates are anchored to now rather than
|
||
* written as calendar dates, and the one whose foreign keys have to line up
|
||
* with a narrative written months earlier. Both are easy to get wrong in ways
|
||
* that look fine: a shift pointing at a staff member who was never hired reads
|
||
* as a working feature until someone asks whose shift it was.
|
||
*
|
||
* These checks are about the data being *true* — that it joins, that it is
|
||
* windowable by the existing period machinery, and that the analyses over it
|
||
* find what is there and stay quiet about what is not.
|
||
*/
|
||
console.log('\n── Attendance and overtime data ──');
|
||
|
||
const attendance = await server.ssrLoadModule('/src/lib/attendance.js');
|
||
const shiftSeed = await server.ssrLoadModule('/src/api/attendanceSeed.js');
|
||
const seedModule = await server.ssrLoadModule('/src/api/seed.js');
|
||
const resolverModule = await server.ssrLoadModule('/src/lib/skills/dataResolver.js');
|
||
|
||
/* The stubbed API serves the shipped fixture from here on, so the reads below
|
||
go through the real client, the real request builder and the real envelope —
|
||
only the transport is replaced. See the note above the `fetch` stub. */
|
||
for (const [entity, path] of Object.entries({
|
||
JobPosting: 'job-postings', JobApplication: 'job-applications', AIInterview: 'ai-interviews',
|
||
Staff: 'staff', WorkerProfile: 'worker-profiles', Course: 'courses', Badge: 'badges',
|
||
LearningPath: 'learning-paths', Certification: 'certifications',
|
||
RoleCategory: 'role-categories', UserActivity: 'user-activity', Evidence: 'evidence',
|
||
User: 'users', Assignment: 'assignments', ShiftRecord: 'shift-records',
|
||
})) {
|
||
apiEntities.set(path, seedModule.seedData[entity] || []);
|
||
}
|
||
|
||
const SHIFTS = shiftSeed.SHIFT_RECORDS;
|
||
const SHIFT_STATUSES = ['present', 'late', 'absent', 'no_show', 'excused'];
|
||
|
||
record('shift records are seeded', SHIFTS.length > 0, `${SHIFTS.length} shifts`);
|
||
record('the seed registers them as `ShiftRecord`',
|
||
Array.isArray(seedModule.seedData.ShiftRecord) && seedModule.seedData.ShiftRecord.length === SHIFTS.length,
|
||
`${seedModule.seedData.ShiftRecord?.length ?? 0} in seedData`);
|
||
|
||
record('every shift has a unique id',
|
||
new Set(SHIFTS.map((s) => s.id)).size === SHIFTS.length);
|
||
|
||
record('every shift status is one the product recognises',
|
||
SHIFTS.every((s) => SHIFT_STATUSES.includes(s.status)),
|
||
[...new Set(SHIFTS.map((s) => s.status))].join(', '));
|
||
|
||
record('every shift was scheduled for real hours',
|
||
SHIFTS.every((s) => s.scheduled_hours > 0));
|
||
|
||
record('overtime is never negative',
|
||
SHIFTS.every((s) => s.overtime_hours >= 0));
|
||
|
||
/* ── The joins ───────────────────────────────────────────────────────────── */
|
||
|
||
const staffIds = new Set(seedModule.seedData.Staff.map((s) => s.id));
|
||
const postingsById = new Map(seedModule.seedData.JobPosting.map((p) => [p.id, p]));
|
||
|
||
record('every shift belongs to someone who was actually hired',
|
||
SHIFTS.every((s) => staffIds.has(s.staff_id)),
|
||
[...new Set(SHIFTS.map((s) => s.staff_id).filter((id) => !staffIds.has(id)))].join(', ') || 'all resolve');
|
||
|
||
record('every shift belongs to a real position',
|
||
SHIFTS.every((s) => postingsById.has(s.job_posting_id)),
|
||
[...new Set(SHIFTS.map((s) => s.job_posting_id).filter((id) => !postingsById.has(id)))].join(', ') || 'all resolve');
|
||
|
||
/* "Department" has to mean the same thing here as it does on Hired History and
|
||
Analytics, both of which read it off the posting — see the join in
|
||
`lib/hiringRecords.js`. A shift carrying its own category is a denormalized
|
||
copy, and a copy that disagrees is worse than no copy. */
|
||
record('a shift\'s department matches its position\'s',
|
||
SHIFTS.every((s) => postingsById.get(s.job_posting_id)?.role_category === s.role_category),
|
||
SHIFTS.filter((s) => postingsById.get(s.job_posting_id)?.role_category !== s.role_category)
|
||
.map((s) => s.id).join(', ') || 'all agree');
|
||
|
||
/**
|
||
* `created_date` is the instant the shift was worked.
|
||
*
|
||
* Load-bearing rather than incidental: `inPeriod` windows every collection on
|
||
* `created_date`, so if these drifted apart every period reading of attendance
|
||
* would come back empty and nothing would say why.
|
||
*/
|
||
record('a shift\'s created date is the instant it was worked',
|
||
SHIFTS.every((s) => s.created_date === s.scheduled_start));
|
||
|
||
/* ── Hours add up ────────────────────────────────────────────────────────── */
|
||
|
||
record('a missed shift is zero hours worked, not a short one',
|
||
SHIFTS.filter((s) => s.status === 'absent' || s.status === 'no_show')
|
||
.every((s) => s.actual_hours === 0 && s.overtime_hours === 0 && s.actual_start === null));
|
||
|
||
record('a worked shift\'s hours are its schedule, less lateness, plus overtime',
|
||
SHIFTS.filter((s) => s.status === 'present' || s.status === 'late').every((s) => {
|
||
const expected = s.scheduled_hours - s.minutes_late / 60 + s.overtime_hours;
|
||
return Math.abs(s.actual_hours - expected) < 0.02;
|
||
}));
|
||
|
||
record('a late shift is one that was turned up for',
|
||
SHIFTS.filter((s) => s.status === 'late').every((s) => s.minutes_late > 0 && s.actual_start !== null));
|
||
|
||
/* ── Reachable the way a page reaches it ─────────────────────────────────── */
|
||
|
||
const client = await server.ssrLoadModule('/src/api/base44Client.js');
|
||
const listed = await client.base44.entities.ShiftRecord.list('-created_date', 500);
|
||
|
||
record('shifts are queryable through the entity API',
|
||
listed.length === SHIFTS.length, `${listed.length} returned`);
|
||
|
||
record('...newest first, like every other collection',
|
||
listed.every((s, i) => i === 0
|
||
|| new Date(listed[i - 1].created_date).getTime() >= new Date(s.created_date).getTime()));
|
||
|
||
const filtered = await client.base44.entities.ShiftRecord.filter({ status: 'absent' });
|
||
record('...and filterable by field',
|
||
filtered.length > 0 && filtered.every((s) => s.status === 'absent'),
|
||
`${filtered.length} absences`);
|
||
|
||
/* ── The sources future skills will declare ──────────────────────────────── */
|
||
|
||
for (const id of ['workforce.attendance', 'workforce.overtime']) {
|
||
const source = surfaces.dataSourceFor(id);
|
||
record(`\`${id}\` is a declarable source`, Boolean(source));
|
||
record(`\`${id}\` needs no record in context`, source?.context === null);
|
||
record(`\`${id}\` supports every shape it declares`,
|
||
source.shapes.every((shape) => surfaces.sourceSupportsShape(id, shape)),
|
||
source.shapes.join(', '));
|
||
record(`\`${id}\` accepts the options it declares`,
|
||
(source.options || []).every((option) => surfaces.sourceSupportsOption(id, option)),
|
||
(source.options || []).join(', ') || 'none');
|
||
|
||
/* Honest emptiness. A source with nothing to read must say so rather than
|
||
reporting zeros that look like findings. */
|
||
const empty = resolverModule.resolveSkillData({ source: id }, { shifts: [] });
|
||
record(`\`${id}\` reports honestly when there are no shifts`,
|
||
empty.empty === true && Boolean(empty.emptyNote),
|
||
empty.emptyNote || 'NO NOTE');
|
||
|
||
const full = resolverModule.resolveSkillData({ source: id }, { shifts: SHIFTS });
|
||
record(`\`${id}\` reads the seeded shifts`,
|
||
full.empty === false && full.steps.length > 0 && full.items.length > 0,
|
||
`${full.steps.length} figures, ${full.items.length} rows`);
|
||
|
||
const periodic = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['last-7-days', 'previous-month'] }, { shifts: SHIFTS }
|
||
);
|
||
record(`\`${id}\` windows by period`,
|
||
periodic.steps.length === 2 && periodic.steps.some((s) => s.records.length > 0),
|
||
periodic.steps.map((s) => `${s.label}=${s.records.length}`).join(', '));
|
||
}
|
||
|
||
/* ── The analyses ────────────────────────────────────────────────────────── */
|
||
|
||
const summary = attendance.attendanceSummary(SHIFTS);
|
||
record('attendance counts every scheduled shift exactly once',
|
||
summary.worked + summary.missed === summary.scheduled,
|
||
`${summary.worked} worked + ${summary.missed} missed = ${summary.scheduled}`);
|
||
record('attendance rate is a percentage',
|
||
summary.attendanceRate >= 0 && summary.attendanceRate <= 100, `${summary.attendanceRate}%`);
|
||
record('punctuality is never better than attendance',
|
||
summary.punctualityRate <= summary.attendanceRate,
|
||
`${summary.punctualityRate}% punctual / ${summary.attendanceRate}% present`);
|
||
|
||
const overtime = attendance.overtimeSummary(SHIFTS);
|
||
record('overtime is the gap between hours worked and hours scheduled',
|
||
overtime.hours > 0 && overtime.hoursWorked > overtime.hoursScheduled - summary.minutesLate / 60,
|
||
`${overtime.hours}h overtime`);
|
||
|
||
const workers = attendance.attendanceByWorker(SHIFTS);
|
||
record('every worker on the roster is compared',
|
||
workers.length === new Set(SHIFTS.map((s) => s.staff_id)).size, `${workers.length} workers`);
|
||
record('workers are ordered worst attendance first',
|
||
workers.every((w, i) => i === 0 || workers[i - 1].attendanceRate <= w.attendanceRate),
|
||
workers.map((w) => `${w.name.split(' ')[0]} ${w.attendanceRate}%`).join(', '));
|
||
|
||
const overtimeWorkers = attendance.overtimeByWorker(SHIFTS);
|
||
record('overtime comparison is ordered most hours first',
|
||
overtimeWorkers.every((w, i) => i === 0 || overtimeWorkers[i - 1].hours >= w.hours),
|
||
overtimeWorkers.map((w) => `${w.name.split(' ')[0]} ${w.hours}h`).join(', '));
|
||
|
||
const departments = attendance.attendanceByDepartment(SHIFTS);
|
||
record('departments are compared too',
|
||
departments.length > 1 && departments.every((d) => d.people > 0),
|
||
departments.map((d) => `${d.name} ${d.attendanceRate}%`).join(', '));
|
||
|
||
const trend = attendance.weeklyTrend(SHIFTS);
|
||
record('the weekly trend runs oldest to newest',
|
||
trend.length >= 4 && trend.every((w, i) => i === 0
|
||
|| new Date(trend[i - 1].weekStart).getTime() < new Date(w.weekStart).getTime()),
|
||
`${trend.length} weeks`);
|
||
record('a week nobody was rostered for is not reported as 0% attendance',
|
||
trend.every((w) => w.scheduled > 0));
|
||
|
||
/* ── Anomalies: what it finds, and what it stays quiet about ─────────────── */
|
||
|
||
/* Anchored, not live.
|
||
*
|
||
* These shifts are generated by counting back from a day, and which of them
|
||
* land in a *complete* week depends on where that day sits in one. Asserting
|
||
* against the live set meant asserting against the calendar: the seeded
|
||
* overtime climb is detected on a Thursday and not on a Friday, so this check
|
||
* failed every Friday and Saturday and passed the rest of the week.
|
||
*
|
||
* The anchor is the same instant the rest of this file pins, and the same date
|
||
* is passed as `now` — shifts built around one day and bucketed around another
|
||
* describe two different windows, which is its own way to be wrong. */
|
||
const ANCHOR = new Date('2026-08-20T09:00:00.000Z');
|
||
const ANCHORED_SHIFTS = shiftSeed.buildShiftsAt(ANCHOR);
|
||
const findings = attendance.attendanceAnomalies(ANCHORED_SHIFTS, { now: ANCHOR });
|
||
record('the seeded attendance decline is found',
|
||
findings.some((f) => f.kind === 'attendance'),
|
||
findings.filter((f) => f.kind === 'attendance').map((f) => f.title).join(' | ') || 'NOT FOUND');
|
||
record('the seeded overtime climb is found',
|
||
findings.some((f) => f.kind === 'overtime'),
|
||
findings.find((f) => f.kind === 'overtime')?.detail || 'NOT FOUND');
|
||
|
||
/* And that it does not depend on the day, measured rather than assumed.
|
||
*
|
||
* Pinning the anchor makes this file deterministic; it does not make the
|
||
* product insensitive to the day, and it was not: the climb was reported Sunday
|
||
* through Thursday and vanished on Friday and Saturday, because the oldest week
|
||
* in the window is a partial one and thinned to two shifts as the week wore on,
|
||
* inflating the baseline every later week was compared against.
|
||
*
|
||
* Fixed in attendanceAnomalies by dropping a leading week rostered well below
|
||
* the usual. This is the number that keeps it fixed: a change that reintroduces
|
||
* a calendar-shaped blind spot fails here even though the pinned assertion
|
||
* above still passes. */
|
||
const detectableOn = Array.from({ length: 28 }, (_, i) => new Date(2026, 7, i + 1, 12))
|
||
.filter((day) => attendance
|
||
.attendanceAnomalies(shiftSeed.buildShiftsAt(day), { now: day })
|
||
.some((f) => f.kind === 'overtime')).length;
|
||
record('the seeded overtime climb is detectable on every day of the week',
|
||
detectableOn === 28,
|
||
`${detectableOn}/28 anchors`);
|
||
record('every finding carries the figures behind it',
|
||
findings.every((f) => f.title && f.detail && f.severity && f.metric));
|
||
record('findings are ordered most severe first',
|
||
findings.every((f, i) => i === 0
|
||
|| ({ high: 0, medium: 1, low: 2 })[findings[i - 1].severity] <= ({ high: 0, medium: 1, low: 2 })[f.severity]),
|
||
findings.map((f) => f.severity).join(' → '));
|
||
|
||
/**
|
||
* The check that matters most, and the one a detector like this usually fails.
|
||
*
|
||
* A rule that flags everything is worse than no rule: it trains its reader to
|
||
* skim, and the one finding that mattered goes past unread. So a workforce with
|
||
* nothing wrong must produce *nothing at all* — not a low-severity note, not a
|
||
* "no issues" finding.
|
||
*/
|
||
const flawless = SHIFTS.map((s) => ({
|
||
...s, status: 'present', minutes_late: 0, overtime_hours: 0, actual_hours: s.scheduled_hours,
|
||
}));
|
||
record('a workforce with nothing wrong produces no findings',
|
||
attendance.attendanceAnomalies(flawless).length === 0,
|
||
`${attendance.attendanceAnomalies(flawless).length} findings`);
|
||
|
||
record('no shift records produces no findings either',
|
||
attendance.attendanceAnomalies([]).length === 0);
|
||
|
||
/* One bad week is a bad week; it takes a second to be a direction. */
|
||
const oneOffWeek = SHIFTS.map((s) => {
|
||
const daysBack = Math.round((Date.now() - new Date(s.created_date).getTime()) / 86400000);
|
||
return daysBack > 21 && daysBack <= 28 ? { ...s, status: 'absent', actual_hours: 0, overtime_hours: 0 } : { ...s, status: 'present', minutes_late: 0, overtime_hours: 0, actual_hours: s.scheduled_hours };
|
||
});
|
||
record('a single bad week months ago is not reported as a current problem',
|
||
!attendance.attendanceAnomalies(oneOffWeek).some((f) => f.id === 'missed-shifts-rising'),
|
||
attendance.attendanceAnomalies(oneOffWeek).map((f) => f.id).join(', ') || 'none');
|
||
|
||
/* ── 13. Cross-domain data sources ────────────────────────────────────────
|
||
*
|
||
* Eight readings over records the workspace already holds. The failure these
|
||
* guard against is a source that *looks* like it works: one that returns
|
||
* plausible zeros when it has nothing to read, or that quietly assumes a field
|
||
* the data does not carry. Either produces a skill that appears to answer and
|
||
* is answering about nothing.
|
||
*
|
||
* So every source is checked twice — against the seed, and against an empty
|
||
* workspace — and the empty case has to say so rather than report zeros.
|
||
*/
|
||
console.log('\n── Cross-domain data sources ──');
|
||
|
||
const signalsModule = await server.ssrLoadModule('/src/lib/activitySignals.js');
|
||
const insightsModule = await server.ssrLoadModule('/src/components/ai-assistant/insights.js');
|
||
|
||
const expectedBaseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||
const SEED = seedModule.seedData;
|
||
|
||
/* The workspace a declared reading is checked against — the same collections the
|
||
panel hands a resolver at runtime. Declared once here because both the
|
||
analysis-skill and agent sections read against it. */
|
||
const AGENT_SKILL_CONTEXT = {
|
||
positions: SEED.JobPosting,
|
||
applications: SEED.JobApplication,
|
||
interviews: SEED.AIInterview,
|
||
staff: SEED.Staff,
|
||
profiles: SEED.WorkerProfile,
|
||
workerProfiles: SEED.WorkerProfile,
|
||
activity: SEED.UserActivity,
|
||
assignments: SEED.Assignment,
|
||
courses: SEED.Course,
|
||
shifts: SHIFTS,
|
||
trainingPaths: [],
|
||
};
|
||
const FULL_CONTEXT = {
|
||
positions: SEED.JobPosting,
|
||
applications: SEED.JobApplication,
|
||
interviews: SEED.AIInterview,
|
||
staff: SEED.Staff,
|
||
profiles: SEED.WorkerProfile,
|
||
workerProfiles: SEED.WorkerProfile,
|
||
activity: SEED.UserActivity,
|
||
assignments: SEED.Assignment,
|
||
shifts: SHIFTS,
|
||
};
|
||
|
||
const NEW_SOURCES = [
|
||
'positions.risk',
|
||
'candidates.quality',
|
||
'talent.pool',
|
||
'workforce.coverage',
|
||
'activity.signals',
|
||
'activity.breakdown',
|
||
'operations.risk',
|
||
'workspace.summary',
|
||
];
|
||
|
||
for (const id of NEW_SOURCES) {
|
||
const decl = surfaces.dataSourceFor(id);
|
||
|
||
record(`\`${id}\` is a declarable source`, Boolean(decl));
|
||
if (!decl) continue;
|
||
|
||
/* Every one of these is a workspace-level reading. A source that needed a
|
||
record in context could not be answered from a page that has none, and
|
||
would validate then fail at read time. */
|
||
record(`\`${id}\` needs no record in context`, decl.context === null);
|
||
|
||
record(`\`${id}\` supports every shape it declares`,
|
||
decl.shapes.every((shape) => surfaces.sourceSupportsShape(id, shape)),
|
||
decl.shapes.join(', '));
|
||
|
||
record(`\`${id}\` accepts the options it declares`,
|
||
(decl.options || []).every((option) => surfaces.sourceSupportsOption(id, option)),
|
||
(decl.options || []).join(', ') || 'none');
|
||
|
||
/* Reads the workspace as it actually is. */
|
||
const readFull = resolverModule.resolveSkillData({ source: id }, FULL_CONTEXT);
|
||
record(`\`${id}\` reads the seeded workspace`,
|
||
readFull.empty === false && (readFull.steps?.length > 0),
|
||
`${readFull.steps?.length ?? 0} figures, ${readFull.items?.length ?? 0} rows`);
|
||
|
||
record(`\`${id}\` returns figures, never prose`,
|
||
(readFull.steps || []).every((step) => typeof step.value === 'number'),
|
||
(readFull.steps || []).map((s) => `${s.title}=${s.value}`).join(', '));
|
||
|
||
/**
|
||
* The check that separates a working source from one that only looks like it.
|
||
*
|
||
* An empty workspace must produce `empty: true` and a note saying why — not
|
||
* a row of zeros, which reads as a measured result rather than an absent one.
|
||
*/
|
||
const readEmpty = resolverModule.resolveSkillData({ source: id }, {});
|
||
record(`\`${id}\` reports honestly when there is nothing to read`,
|
||
readEmpty.empty === true && Boolean(readEmpty.emptyNote),
|
||
readEmpty.emptyNote || 'NO NOTE');
|
||
|
||
record(`\`${id}\` never throws on a half-empty workspace`,
|
||
(() => {
|
||
try {
|
||
const partialRead = resolverModule.resolveSkillData({ source: id }, { positions: SEED.JobPosting });
|
||
return partialRead && typeof partialRead.empty === 'boolean';
|
||
} catch {
|
||
return false;
|
||
}
|
||
})());
|
||
}
|
||
|
||
/* ── Period filtering, where it applies ──────────────────────────────────── */
|
||
|
||
for (const id of ['candidates.quality', 'activity.breakdown']) {
|
||
const readPeriodic = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['previous-month'] }, FULL_CONTEXT
|
||
);
|
||
const all = resolverModule.resolveSkillData({ source: id }, FULL_CONTEXT);
|
||
record(`\`${id}\` windows by period`,
|
||
readPeriodic.total <= all.total,
|
||
`${readPeriodic.total} in the previous month / ${all.total} in total`);
|
||
|
||
/* Overlapping windows must not count the same record twice — `today` sits
|
||
inside `last-7-days`, and a naive concatenation would double it. */
|
||
const overlapping = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['today', 'last-7-days'] }, FULL_CONTEXT
|
||
);
|
||
const sevenOnly = resolverModule.resolveSkillData(
|
||
{ source: id, periods: ['last-7-days'] }, FULL_CONTEXT
|
||
);
|
||
record(`\`${id}\` does not double-count overlapping periods`,
|
||
overlapping.total === sevenOnly.total,
|
||
`${overlapping.total} vs ${sevenOnly.total}`);
|
||
}
|
||
|
||
/* ── `activity.signals` reuses the assistant's own detection ─────────────── */
|
||
|
||
/**
|
||
* The reuse assertion.
|
||
*
|
||
* `activitySignals` moved out of `buildFacts` so a data source could read it
|
||
* without a library importing from the component tree. The whole value of that
|
||
* move is that there is still exactly one implementation — "two unusual
|
||
* patterns" has to mean the same two in the greeting and on the card.
|
||
*/
|
||
const factSheet = insightsModule.buildFacts({
|
||
applications: SEED.JobApplication,
|
||
postings: SEED.JobPosting,
|
||
interviews: SEED.AIInterview,
|
||
staff: SEED.Staff,
|
||
profiles: SEED.WorkerProfile,
|
||
activity: SEED.UserActivity,
|
||
courses: SEED.Course,
|
||
profile: null,
|
||
user: seedModule.DEMO_USER,
|
||
today: new Date('2026-08-20T09:00:00.000Z'),
|
||
});
|
||
const direct = signalsModule.activitySignals(SEED.UserActivity, new Date('2026-08-20T09:00:00.000Z'));
|
||
|
||
record('the fact sheet and the extracted detection agree exactly',
|
||
JSON.stringify(factSheet.activitySignals) === JSON.stringify(direct),
|
||
JSON.stringify(direct.flags));
|
||
|
||
record('`PRIVILEGED_EVENTS` is still importable from insights.js',
|
||
JSON.stringify(insightsModule.PRIVILEGED_EVENTS) === JSON.stringify(signalsModule.PRIVILEGED_EVENTS),
|
||
JSON.stringify(insightsModule.PRIVILEGED_EVENTS));
|
||
|
||
const signalsRead = resolverModule.resolveSkillData({ source: 'activity.signals' }, FULL_CONTEXT);
|
||
record('the activity.signals source reports the same flags the greeting counts',
|
||
signalsRead.items.length === factSheet.activitySignals.flags.length,
|
||
`${signalsRead.items.length} signals`);
|
||
|
||
record('every signal is explained rather than named',
|
||
signalsRead.items.every((i) => i.title && i.detail && i.title !== i.id));
|
||
|
||
/* A workspace with nothing out of pattern is a real answer, and a different
|
||
one from having no log at all. */
|
||
const quiet = resolverModule.resolveSkillData({ source: 'activity.signals' }, { activity: [] });
|
||
record('an empty log and a quiet log are different answers',
|
||
quiet.emptyNote !== signalsRead.emptyNote);
|
||
|
||
/* ── `workforce.coverage` refuses to invent a headcount ──────────────────── */
|
||
|
||
/**
|
||
* No seeded position declares a headcount, and `demandFor` reports that rather
|
||
* than defaulting to one person per role. This check exists because the obvious
|
||
* shortcut — assume 1, report a fill rate — produces a confident percentage
|
||
* that means nothing, and nothing on screen would say so.
|
||
*/
|
||
const coverage = resolverModule.resolveSkillData({ source: 'workforce.coverage' }, FULL_CONTEXT);
|
||
const declaredStep = coverage.steps.find((s) => s.id === 'declared');
|
||
record('coverage says out loud when no role states a headcount',
|
||
declaredStep.value === 0 && /does not|not state|No open role/i.test(declaredStep.detail),
|
||
declaredStep.detail);
|
||
record('coverage still reports who has actually been hired',
|
||
coverage.steps.find((s) => s.id === 'covered').value > 0,
|
||
`${coverage.steps.find((s) => s.id === 'covered').value} roles have someone hired`);
|
||
record('a role with no headcount reports hires, not a fill percentage',
|
||
coverage.items.every((i) => i.declared || /headcount not stated/.test(i.detail)));
|
||
|
||
/* ── `positions.risk` distinguishes "nothing wrong" from "nothing posted" ── */
|
||
|
||
const noRoles = resolverModule.resolveSkillData({ source: 'positions.risk' }, { positions: [], applications: [] });
|
||
const healthy = resolverModule.resolveSkillData({ source: 'positions.risk' }, {
|
||
positions: SEED.JobPosting.filter((p) => p.status === 'active').slice(0, 1),
|
||
applications: SEED.JobApplication.map((a) => ({
|
||
...a, job_posting_id: SEED.JobPosting.find((p) => p.status === 'active').id, ai_score: 88, status: 'hired',
|
||
})),
|
||
});
|
||
record('positions.risk separates "no roles open" from "no roles at risk"',
|
||
noRoles.emptyNote !== healthy.emptyNote,
|
||
`${noRoles.emptyNote} / ${healthy.emptyNote}`);
|
||
|
||
/* ── `operations.risk` stays quiet when the operation is running ─────────── */
|
||
|
||
record('operations.risk finds the real backlog',
|
||
resolverModule.resolveSkillData({ source: 'operations.risk' }, FULL_CONTEXT).findings.length > 0,
|
||
resolverModule.resolveSkillData({ source: 'operations.risk' }, FULL_CONTEXT)
|
||
.findings.map((f) => f.id).join(', '));
|
||
|
||
const readClean = resolverModule.resolveSkillData({ source: 'operations.risk' }, {
|
||
/* Everything screened and decided, every role has applicants, every shift worked. */
|
||
applications: SEED.JobApplication.map((a) => ({ ...a, status: 'hired', ai_score: 90 })),
|
||
positions: SEED.JobPosting.filter((p) => p.status === 'active').map((p) => ({ ...p })),
|
||
shifts: SHIFTS.map((s) => ({ ...s, status: 'present' })),
|
||
});
|
||
record('operations.risk reports nothing when nothing is wrong',
|
||
readClean.findings.length === 0 && readClean.empty === true,
|
||
readClean.findings.map((f) => f.id).join(', ') || 'no findings');
|
||
|
||
/* ── `talent.pool` does not average away the unscored ───────────────────── */
|
||
|
||
/**
|
||
* Four of the nine seeded profiles have no score. Averaging them in as zero
|
||
* would report a healthy pool as poor, in exact proportion to how much of it
|
||
* nobody has assessed yet — a figure that gets worse as the pool grows.
|
||
*/
|
||
const pool = resolverModule.resolveSkillData({ source: 'talent.pool' }, FULL_CONTEXT);
|
||
const scoredProfiles = SEED.WorkerProfile.filter((p) => (p.krow_score || 0) > 0);
|
||
const expectedAvg = Math.round(
|
||
scoredProfiles.reduce((sum, p) => sum + p.krow_score, 0) / scoredProfiles.length
|
||
);
|
||
record('talent.pool averages only the profiles that have been scored',
|
||
pool.steps.find((s) => s.id === 'quality').value === expectedAvg,
|
||
`${pool.steps.find((s) => s.id === 'quality').value} vs ${expectedAvg} expected`);
|
||
record('...and says how many are not yet assessed',
|
||
/not yet assessed/.test(pool.steps.find((s) => s.id === 'scored').detail),
|
||
pool.steps.find((s) => s.id === 'scored').detail);
|
||
record('an unscored person reads as unscored, not as a zero score',
|
||
pool.items.filter((i) => i.value === 0).every((i) => /Not yet scored/.test(i.detail)));
|
||
|
||
/* ── `candidates.quality` reports coverage beside quality ────────────────── */
|
||
|
||
const quality = resolverModule.resolveSkillData({ source: 'candidates.quality' }, FULL_CONTEXT);
|
||
record('candidate quality reports how much of the pool was actually scored',
|
||
quality.steps.find((s) => s.id === 'coverage').value < 100,
|
||
quality.steps.find((s) => s.id === 'coverage').detail);
|
||
record('score bands add up to the number scored',
|
||
quality.bands.reduce((n, b) => n + b.value, 0)
|
||
=== SEED.JobApplication.filter((a) => a.ai_score > 0).length,
|
||
quality.bands.map((b) => `${b.label}=${b.value}`).join(', '));
|
||
|
||
/* ── Nothing here reaches for a record it was not given ─────────────────── */
|
||
|
||
/**
|
||
* A resolver that throws takes the page down; one that invents a default is
|
||
* worse, because it reports a figure nobody can trace. Every source is called
|
||
* with each collection missing in turn.
|
||
*/
|
||
const COLLECTIONS = ['positions', 'applications', 'interviews', 'staff', 'profiles', 'activity', 'shifts', 'assignments'];
|
||
let survived = true;
|
||
let culprit = '';
|
||
for (const id of NEW_SOURCES) {
|
||
for (const drop of COLLECTIONS) {
|
||
const withoutOne = { ...FULL_CONTEXT };
|
||
delete withoutOne[drop];
|
||
try {
|
||
const out = resolverModule.resolveSkillData({ source: id }, withoutOne);
|
||
if (!out || typeof out.empty !== 'boolean') { survived = false; culprit = `${id} without ${drop}`; }
|
||
} catch (error) {
|
||
survived = false;
|
||
culprit = `${id} without ${drop}: ${error.message}`;
|
||
}
|
||
}
|
||
}
|
||
record('every source survives any single collection being absent', survived, culprit || `${NEW_SOURCES.length} sources × ${COLLECTIONS.length} collections`);
|
||
|
||
/* ── 14. Analysis skills ──────────────────────────────────────────────────
|
||
*
|
||
* Thirteen definitions written against the sources built in the two previous
|
||
* phases. The failure they guard against is a definition that registers, looks
|
||
* complete, and answers nothing — a capability bound to a source that cannot be
|
||
* read, or a trigger that quietly takes a question another definition was
|
||
* written to answer.
|
||
*
|
||
* Every one is therefore executed, not merely parsed: each declared capability
|
||
* is resolved against the seeded workspace and has to come back with figures.
|
||
*/
|
||
console.log('\n── Analysis skills ──');
|
||
|
||
const ANALYSIS_SKILLS = [
|
||
'staffing-risk', 'attendance-analysis', 'overtime-analysis', 'candidate-analysis',
|
||
'talent-pool-analysis', 'workforce-analytics', 'anomaly-detection', 'activity-analysis',
|
||
'operational-risk', 'executive-summary', 'hiring-history-analysis', 'learning-analysis',
|
||
'hiring-pulse-analysis',
|
||
];
|
||
|
||
record('every analysis skill registers',
|
||
ANALYSIS_SKILLS.every((id) => reg.SKILLS.some((s) => s.id === id)),
|
||
ANALYSIS_SKILLS.filter((id) => !reg.SKILLS.some((s) => s.id === id)).join(', ') || `${ANALYSIS_SKILLS.length} skills`);
|
||
|
||
for (const id of ANALYSIS_SKILLS) {
|
||
const skill = reg.SKILLS.find((s) => s.id === id);
|
||
if (!skill) continue;
|
||
|
||
/* Written in the one format, read by the one parser. */
|
||
record(`\`${id}\` is a valid definition`,
|
||
reg.validateSkillSource(skill.markdown) === null,
|
||
reg.validateSkillSource(skill.markdown) || 'ok');
|
||
|
||
record(`\`${id}\` is an Owliver skill on real pages`,
|
||
skill.facets.includes('owliver')
|
||
&& skill.pages.length > 0
|
||
&& skill.pages.every((p) => surfaces.SUPPORTED_SKILL_PAGES.includes(surfaces.canonicalPage(p) || p)),
|
||
JSON.stringify(skill.pages));
|
||
|
||
/* The sections the brief asks every definition to carry. `Purpose` and
|
||
`Capabilities` are parsed into fields; the rest are prose the authoring
|
||
flow will later read back, so they are checked for presence rather than
|
||
for shape. */
|
||
record(`\`${id}\` states its purpose and capabilities`,
|
||
skill.purpose.length > 0 && skill.capabilities.length > 0,
|
||
`${skill.purpose.length} purpose, ${skill.capabilities.length} capabilities`);
|
||
|
||
record(`\`${id}\` documents its data, analysis, output and limitations`,
|
||
['## Data', '## Analysis', '## Output', '## Limitations'].every((h) => skill.body.includes(h)),
|
||
['Data', 'Analysis', 'Output', 'Limitations'].filter((h) => !skill.body.includes(`## ${h}`)).join(', ') || 'all four');
|
||
|
||
/* Every capability resolves, and comes back with figures rather than an
|
||
apology. This is what separates a definition that works from one that
|
||
merely parses. */
|
||
const failures = [];
|
||
for (const capability of skill.owliver.capabilities) {
|
||
const response = skill.owliver.responses[capability];
|
||
if (!response) { failures.push(`${capability}: no response`); continue; }
|
||
|
||
const declared = surfaces.dataSourceFor(response.source);
|
||
if (!declared) { failures.push(`${capability}: unknown source ${response.source}`); continue; }
|
||
|
||
/* Shape has to be one the source actually supports, or the definition
|
||
promises a rendering the data cannot produce. */
|
||
const shape = surfaces.shapeForCapability(capability);
|
||
if (shape && !surfaces.sourceSupportsShape(response.source, shape)) {
|
||
failures.push(`${capability}: ${response.source} cannot draw ${shape}`);
|
||
continue;
|
||
}
|
||
|
||
if (declared.context) continue; /* needs a record; asked for at runtime */
|
||
|
||
const read = dataResolver.resolveSkillData(response, AGENT_SKILL_CONTEXT);
|
||
if (read?.unavailable) failures.push(`${capability}: unreadable`);
|
||
else if (read?.empty && !read.emptyNote) failures.push(`${capability}: empty with no explanation`);
|
||
}
|
||
record(`\`${id}\` answers every capability it declares`,
|
||
failures.length === 0,
|
||
failures.join(' | ') || `${skill.owliver.capabilities.length} capabilities`);
|
||
|
||
/* Every suggestion names the capability it asks for. Without this a chip
|
||
falls through to the first declared capability, and two chips silently
|
||
become one answer — the lesson recorded in hiring-activity-assistant.md. */
|
||
record(`\`${id}\` names a capability on every suggestion`,
|
||
skill.owliver.suggestions.every((s) => s.capability
|
||
&& skill.owliver.capabilities.includes(s.capability)),
|
||
skill.owliver.suggestions.map((s) => `${s.label} → ${s.capability}`).join(' | '));
|
||
|
||
/* Its own triggers, claimed rather than inherited from its name. */
|
||
record(`\`${id}\` claims its own triggers`,
|
||
skill.declaredTriggers && skill.triggers.length > 0,
|
||
JSON.stringify(skill.triggers));
|
||
}
|
||
|
||
/* ── Reachable from the pages they name ──────────────────────────────────── */
|
||
|
||
const PAGE_EXPECTATIONS = {
|
||
'admin.controlCenter': ['executive-summary', 'staffing-risk', 'operational-risk', 'anomaly-detection', 'attendance-analysis', 'overtime-analysis', 'hiring-pulse-analysis'],
|
||
'admin.positions': ['staffing-risk'],
|
||
'admin.candidatesList': ['candidate-analysis'],
|
||
'admin.hiredHistory': ['hiring-history-analysis'],
|
||
'admin.talentPool': ['talent-pool-analysis'],
|
||
'admin.forge': ['learning-analysis'],
|
||
'admin.analytics': ['workforce-analytics', 'attendance-analysis', 'overtime-analysis', 'hiring-pulse-analysis'],
|
||
'admin.activity': ['activity-analysis', 'anomaly-detection', 'operational-risk'],
|
||
};
|
||
|
||
for (const [contextId, expected] of Object.entries(PAGE_EXPECTATIONS)) {
|
||
const available = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
record(`${contextId.replace('admin.', '')} offers its analysis skills`,
|
||
expected.every((id) => available.includes(id)),
|
||
expected.filter((id) => !available.includes(id)).join(', ') || `${expected.length} available`);
|
||
}
|
||
|
||
/**
|
||
* Profile keeps none, and that is correct.
|
||
*
|
||
* There is no data source about an account, so a skill there would be a
|
||
* placeholder. The emptiness is pinned so a later phase cannot quietly fill it
|
||
* to make a list look complete.
|
||
*/
|
||
record('the Profile page still carries no analysis skill',
|
||
reg.skillsForContext('admin.profile', [], []).length === 0,
|
||
JSON.stringify(reg.skillsForContext('admin.profile', [], []).map((s) => s.id)));
|
||
|
||
/* ── Triggers reach their skill, and take nothing that was not theirs ────── */
|
||
|
||
const TRIGGER_CASES = [
|
||
['admin.positions', 'Which roles are at risk?', 'staffing-risk'],
|
||
['admin.analytics', 'How is attendance this month?', 'attendance-analysis'],
|
||
['admin.analytics', 'How much overtime are we running?', 'overtime-analysis'],
|
||
['admin.candidatesList', 'What is the candidate quality like?', 'candidate-analysis'],
|
||
['admin.talentPool', 'How healthy is the talent pool?', 'talent-pool-analysis'],
|
||
['admin.analytics', 'Show me workforce coverage', 'workforce-analytics'],
|
||
['admin.activity', 'Is there anything unusual?', 'anomaly-detection'],
|
||
['admin.activity', 'Give me an event breakdown', 'activity-analysis'],
|
||
['admin.controlCenter', 'What is the operational risk?', 'operational-risk'],
|
||
['admin.controlCenter', 'Give me an executive summary', 'executive-summary'],
|
||
['admin.hiredHistory', 'What is our time to hire?', 'hiring-history-analysis'],
|
||
['admin.forge', 'How is training progress?', 'learning-analysis'],
|
||
['admin.controlCenter', 'What is the hiring pulse?', 'hiring-pulse-analysis'],
|
||
];
|
||
|
||
for (const [contextId, question, expected] of TRIGGER_CASES) {
|
||
const matched = reg.matchSkill(question, contextId, [], []);
|
||
record(`"${question}" reaches \`${expected}\``,
|
||
matched?.id === expected, matched?.id || 'no match');
|
||
}
|
||
|
||
/**
|
||
* The check that protects everything already shipped.
|
||
*
|
||
* Thirteen new definitions across shared pages is the likeliest way to take a
|
||
* question that an existing definition — or a page's own reader — was answering.
|
||
* Every baseline question is replayed on every page: whatever answered it before
|
||
* must still answer it.
|
||
*/
|
||
const stolen = [];
|
||
for (const contextId of Object.keys(expectedBaseline.contexts)) {
|
||
const before = expectedBaseline.contexts[contextId];
|
||
for (const intent of before.intents) {
|
||
const now = reg.matchSkill(intent.question, contextId, [], [])?.id ?? null;
|
||
if (now !== intent.matchedSkill) {
|
||
stolen.push(`${contextId} "${intent.question}": ${intent.matchedSkill ?? 'page reader'} → ${now}`);
|
||
}
|
||
}
|
||
}
|
||
record('no new skill takes a question something else was answering',
|
||
stolen.length === 0, stolen.join(' | ') || `${Object.keys(expectedBaseline.contexts).length} contexts replayed`);
|
||
|
||
/* ── The format stays suitable for non-technical authoring ──────────────── */
|
||
|
||
/**
|
||
* Every analysis definition round-trips through the field writer.
|
||
*
|
||
* The Markdown is the canonical representation, and a future authoring flow
|
||
* will compose it rather than replace it. That only holds if a definition can
|
||
* be read into fields and written back without losing what it said — so the
|
||
* property is asserted now, while there are thirteen definitions to test it
|
||
* against, rather than discovered later.
|
||
*/
|
||
const lossy = [];
|
||
for (const id of ANALYSIS_SKILLS) {
|
||
const skill = reg.SKILLS.find((s) => s.id === id);
|
||
if (!skill) continue;
|
||
const rewritten = fields.patchFrontmatter(skill.markdown, { description: skill.description });
|
||
const reparsedSkill = reg.parseSkill(rewritten, { custom: true });
|
||
if (reparsedSkill.body !== skill.body) lossy.push(`${id}: body`);
|
||
if (JSON.stringify(reparsedSkill.owliver) !== JSON.stringify(skill.owliver)) lossy.push(`${id}: owliver`);
|
||
if (JSON.stringify(reparsedSkill.pages) !== JSON.stringify(skill.pages)) lossy.push(`${id}: pages`);
|
||
}
|
||
record('an analysis definition survives being written back through the field writer',
|
||
lossy.length === 0, lossy.join(', ') || `${ANALYSIS_SKILLS.length} definitions`);
|
||
|
||
record('every analysis skill declares a category for grouping',
|
||
ANALYSIS_SKILLS.every((id) => reg.SKILLS.find((s) => s.id === id)?.category),
|
||
[...new Set(ANALYSIS_SKILLS.map((id) => reg.SKILLS.find((s) => s.id === id)?.category))].join(', '));
|
||
|
||
/* ── 15. Agent registry ───────────────────────────────────────────────────
|
||
*
|
||
* Agents are the layer above skills, and they are read by the *same* parser —
|
||
* `parseAgent` imports `parseFrontmatter` and the section readers from the
|
||
* skill registry rather than reimplementing them. These checks exist to keep
|
||
* that true, and to keep an agent honest about what it carries: an agent that
|
||
* names a skill nobody provides is a capability promised and not delivered,
|
||
* and it fails silently.
|
||
*/
|
||
console.log('\n── Agent registry ──');
|
||
|
||
const agentReg = await server.ssrLoadModule('/src/lib/agents/registry.js');
|
||
const agentFields = await server.ssrLoadModule('/src/lib/agents/agentFields.js');
|
||
const customAgents = await server.ssrLoadModule('/src/lib/agents/customAgents.js');
|
||
const vocab = await server.ssrLoadModule('/src/lib/agents/vocabulary.js');
|
||
|
||
const agentFiles = readdirSync(join(ROOT, 'src/agents')).filter((f) => f.endsWith('.md'));
|
||
|
||
|
||
record('every .md under src/agents registers',
|
||
agentReg.AGENTS.length === agentFiles.length,
|
||
`${agentReg.AGENTS.length} registered / ${agentFiles.length} files`);
|
||
|
||
record('no two agents share an id',
|
||
new Set(agentReg.AGENTS.map((a) => a.id)).size === agentReg.AGENTS.length);
|
||
|
||
const shippedAgents = agentReg.readAgentRegistry([]);
|
||
|
||
record('shipped agent registry reports no diagnostics',
|
||
shippedAgents.diagnostics.length === 0,
|
||
shippedAgents.diagnostics.map((d) => d.message).join(' | ') || 'none');
|
||
|
||
record('every shipped agent registered every field it declared',
|
||
agentReg.AGENTS.every((a) => !a.errors?.length),
|
||
agentReg.AGENTS.flatMap((a) => a.errors || []).join(' | ') || 'none');
|
||
|
||
/* The nine the product ships: one per Krow page, plus the root. */
|
||
const EXPECTED_AGENTS = {
|
||
'krow-workforce-agent': null, // covers every page
|
||
'control-center-agent': 'control-center',
|
||
'positions-agent': 'positions',
|
||
'candidates-agent': 'candidates',
|
||
'hired-history-agent': 'hired-history',
|
||
'talent-pool-agent': 'talent-pool',
|
||
'krow-forge-agent': 'krow-forge',
|
||
'analytics-agent': 'analytics',
|
||
'activity-agent': 'activity',
|
||
};
|
||
|
||
for (const [id, page] of Object.entries(EXPECTED_AGENTS)) {
|
||
const agent = agentReg.getAgent(agentReg.AGENTS, id);
|
||
record(`\`${id}\` is registered and published`,
|
||
Boolean(agent) && agent.status === 'published',
|
||
agent ? agent.status : 'MISSING');
|
||
if (agent && page) {
|
||
record(`\`${id}\` covers \`${page}\``, agent.pages.includes(page),
|
||
JSON.stringify(agent.pages));
|
||
}
|
||
}
|
||
|
||
/* The root agent reaches every surface a skill may name, so it can stand in on
|
||
a page whose own agent carries nothing. */
|
||
const root = agentReg.getAgent(agentReg.AGENTS, 'krow-workforce-agent');
|
||
record('the root agent covers every supported page',
|
||
surfaces.SUPPORTED_SKILL_PAGES.every((p) => root.pages.includes(p)),
|
||
`${root.pages.length}/${surfaces.SUPPORTED_SKILL_PAGES.length}`);
|
||
record('the root agent carries the other eight as subagents',
|
||
root.subagents.length === 8
|
||
&& root.subagents.every((s) => s !== root.id && EXPECTED_AGENTS[s] !== undefined),
|
||
JSON.stringify(root.subagents));
|
||
|
||
/* Every address an agent states must resolve. */
|
||
const registeredSkillIds = new Set(reg.SKILLS.map((s) => s.id));
|
||
const registeredAgentIds = new Set(agentReg.AGENTS.map((a) => a.id));
|
||
|
||
record('every skill an agent names exists in the skill registry',
|
||
agentReg.AGENTS.every((a) => a.skills.every((s) => registeredSkillIds.has(s))),
|
||
agentReg.AGENTS.flatMap((a) => a.skills.filter((s) => !registeredSkillIds.has(s))).join(', ') || 'all resolve');
|
||
|
||
record('every subagent an agent names exists',
|
||
agentReg.AGENTS.every((a) => a.subagents.every((s) => registeredAgentIds.has(s))));
|
||
|
||
record('every page an agent names is a real surface',
|
||
agentReg.AGENTS.every((a) => a.pages.every((p) => surfaces.SUPPORTED_SKILL_PAGES.includes(p))),
|
||
agentReg.AGENTS.flatMap((a) => a.pages.filter((p) => !surfaces.SUPPORTED_SKILL_PAGES.includes(p))).join(', ') || 'all resolve');
|
||
|
||
/**
|
||
* No agent carries a placeholder skill.
|
||
*
|
||
* This replaces an earlier check that pinned four agents at zero skills, which
|
||
* was the honest assertion while no skill existed for their pages: the
|
||
* temptation when building an agent UI is to invent one so the list looks
|
||
* populated. Those skills now exist and are real, so pinning zero would be
|
||
* pinning the wrong thing — but the property worth protecting is unchanged, and
|
||
* this states it directly instead of by proxy.
|
||
*
|
||
* A skill is real when it declares a capability whose response binds to a
|
||
* registered data source, and that source resolves against the seeded workspace
|
||
* without reporting itself unavailable. A definition that names a source the
|
||
* product does not have, or one that cannot be read, is a promise the agent
|
||
* cannot keep.
|
||
*/
|
||
const skillById = new Map(reg.SKILLS.map((s) => [s.id, s]));
|
||
const placeholders = [];
|
||
const unreadable = [];
|
||
|
||
for (const agent of agentReg.AGENTS) {
|
||
for (const skillId of agent.skills) {
|
||
const skill = skillById.get(skillId);
|
||
if (!skill) continue; /* already reported as `unattached` above */
|
||
|
||
const responses = Object.values(skill.owliver?.responses || {});
|
||
const sources = responses.map((r) => r.source).filter(Boolean);
|
||
|
||
/**
|
||
* Two kinds of real skill, and only one of them reads data.
|
||
*
|
||
* An *analysis* skill binds capabilities to data sources. An *action* skill
|
||
* — `create-position`, `forge-skill-management` — declares actions or a
|
||
* guided conversation instead, and carries no responses at all. Both are
|
||
* real; a definition that does neither is the placeholder this looks for.
|
||
*/
|
||
const doesSomething = sources.length > 0
|
||
|| (skill.actions || []).length > 0
|
||
|| (skill.conversation || []).length > 0;
|
||
|
||
if (!doesSomething || !sources.every((src) => surfaces.dataSourceFor(src))) {
|
||
placeholders.push(`${agent.id}/${skillId}`);
|
||
continue;
|
||
}
|
||
|
||
for (const response of responses) {
|
||
const declared = surfaces.dataSourceFor(response.source);
|
||
/* A source that needs a position or a candidate in context is *correctly*
|
||
unavailable when it is handed neither — `resolveEntity` asks the reader
|
||
which one at runtime. Only workspace-level sources can be read cold,
|
||
so only those are asserted here. */
|
||
if (declared?.context) continue;
|
||
|
||
const read = dataResolver.resolveSkillData(response, AGENT_SKILL_CONTEXT);
|
||
/* `unavailable` means the resolver has no reading for that source at all.
|
||
`empty` is fine and honest — the source read successfully and found
|
||
nothing. */
|
||
if (read?.unavailable) unreadable.push(`${agent.id}/${skillId}:${response.source}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
record('no agent carries a skill without a real data source',
|
||
placeholders.length === 0, placeholders.join(', ') || 'every attached skill declares one');
|
||
|
||
record('every skill an agent carries reads a source the product can resolve',
|
||
unreadable.length === 0, unreadable.join(', ') || 'all resolve');
|
||
|
||
/* Every page agent now carries at least one skill of its own, except where the
|
||
product genuinely has none to give it. Stated as data rather than as a
|
||
number, so a future page with no source is visible rather than assumed. */
|
||
const withoutSkills = agentReg.AGENTS.filter((a) => a.skills.length === 0).map((a) => a.id);
|
||
record('every agent that has a skill available to it carries one',
|
||
withoutSkills.length === 0, withoutSkills.join(', ') || 'all nine agents carry skills');
|
||
|
||
/* ── Diagnostics: nothing half-loads in silence ──────────────────────────── */
|
||
|
||
const UNATTACHED = `---\nid: ghost-agent\nname: Ghost Agent\npages:\n - positions\nskills:\n - no-such-skill\n---\n\n# Ghost\n`;
|
||
const withUnattached = agentReg.readAgentRegistry([{ path: 'custom/ghost.md', raw: UNATTACHED }]);
|
||
record('an agent naming a missing skill is reported',
|
||
withUnattached.diagnostics.some((d) => d.kind === 'unattached' && d.agentId === 'ghost-agent'),
|
||
withUnattached.diagnostics.find((d) => d.kind === 'unattached')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
const BROKEN_AGENT = `---\nid: broken\n name: bad indent\n---\n# Broken\n`;
|
||
const withBrokenAgent = agentReg.readAgentRegistry([{ path: 'custom/broken.md', raw: BROKEN_AGENT }]);
|
||
record('an unreadable stored agent is reported, not silently dropped',
|
||
withBrokenAgent.diagnostics.some((d) => d.kind === 'unreadable'),
|
||
withBrokenAgent.diagnostics.find((d) => d.kind === 'unreadable')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
const SHADOW_AGENT = `---\nid: positions-agent\nname: My Positions Agent\npages:\n - positions\n---\n\n# Shadow\n`;
|
||
const withShadowAgent = agentReg.readAgentRegistry([{ path: 'custom/shadow.md', raw: SHADOW_AGENT }]);
|
||
record('a stored agent overriding a built-in is reported',
|
||
withShadowAgent.diagnostics.some((d) => d.kind === 'shadowed' && d.agentId === 'positions-agent'),
|
||
withShadowAgent.diagnostics.find((d) => d.kind === 'shadowed')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
/* A definition with one bad field keeps the rest AND says so. */
|
||
const PARTIAL_AGENT = `---\nid: partial-agent\nname: Partial Agent\npages:\n - positions\nreasoning: telepathy\n---\n\n# Partial\n`;
|
||
const withPartialAgent = agentReg.readAgentRegistry([{ path: 'custom/partial.md', raw: PARTIAL_AGENT }]);
|
||
const partialAgent = withPartialAgent.agents.find((a) => a.id === 'partial-agent');
|
||
record('an agent with one bad field still registers',
|
||
Boolean(partialAgent) && partialAgent.pages.includes('positions'));
|
||
record('...falls back to the documented default',
|
||
partialAgent?.reasoning === 'balanced', partialAgent?.reasoning);
|
||
record('...and the field it lost is reported',
|
||
withPartialAgent.diagnostics.some((d) => d.kind === 'incomplete' && d.agentId === 'partial-agent'),
|
||
withPartialAgent.diagnostics.find((d) => d.kind === 'incomplete')?.message ?? 'NO DIAGNOSTIC');
|
||
|
||
/* A cycle would make subagent resolution non-terminating. */
|
||
const SELF_SUB = `---\nid: loop-agent\nname: Loop Agent\npages:\n - positions\nsubagents:\n - loop-agent\n---\n\n# Loop\n`;
|
||
const looped = agentReg.parseAgent(SELF_SUB, { custom: true });
|
||
record('an agent cannot be its own subagent',
|
||
looped.subagents.length === 0 && looped.errors.length > 0,
|
||
looped.errors[0] || 'NOT REPORTED');
|
||
|
||
/* ── Validation: what the editor refuses ─────────────────────────────────── */
|
||
|
||
const REFUSALS = [
|
||
['an empty definition', ''],
|
||
['a malformed id', `---\nid: Not An Id\nname: Bad\npages:\n - positions\n---\n# x\n`],
|
||
['no name', `---\nid: no-name\npages:\n - positions\n---\n# x\n`],
|
||
['no pages', `---\nid: no-pages\nname: No Pages\n---\n# x\n`],
|
||
['an unsupported page', `---\nid: bad-page\nname: Bad Page\npages:\n - the-moon\n---\n# x\n`],
|
||
['an unsupported reasoning mode', `---\nid: bad-reason\nname: Bad Reason\npages:\n - positions\nreasoning: vibes\n---\n# x\n`],
|
||
['an unsupported permission role', `---\nid: bad-role\nname: Bad Role\npages:\n - positions\npermissions:\n people:\n - user: a@b.com\n role: emperor\n---\n# x\n`],
|
||
];
|
||
|
||
for (const [label, source] of REFUSALS) {
|
||
record(`validateAgentSource refuses ${label}`,
|
||
Boolean(agentReg.validateAgentSource(source)),
|
||
agentReg.validateAgentSource(source) || 'ACCEPTED');
|
||
}
|
||
|
||
/**
|
||
* An agent that omits `id:` derives one from its name.
|
||
*
|
||
* The same fallback `parseSkill` applies, and deliberately not a refusal: an
|
||
* explicit id is an address other definitions refer to, so writing one is
|
||
* encouraged, but leaving it out means "call it after its name" rather than
|
||
* "this file is broken".
|
||
*/
|
||
const DERIVED_ID = `---\nname: Derived Id Agent\npages:\n - positions\n---\n\n# D\n`;
|
||
record('an agent with no `id:` derives one from its name',
|
||
agentReg.parseAgent(DERIVED_ID, { custom: true }).id === 'derived-id-agent'
|
||
&& agentReg.validateAgentSource(DERIVED_ID) === null,
|
||
agentReg.parseAgent(DERIVED_ID, { custom: true }).id);
|
||
|
||
record('an agent with neither id nor name is refused',
|
||
Boolean(agentReg.validateAgentSource(`---\npages:\n - positions\n---\n\n# x\n`)));
|
||
|
||
record('validateAgentSource accepts a minimal agent',
|
||
agentReg.validateAgentSource(`---\nid: minimal\nname: Minimal\npages:\n - positions\n---\n\n# Minimal\n`) === null);
|
||
|
||
/**
|
||
* An agent carrying no skills is accepted.
|
||
*
|
||
* Deliberately not a refusal, unlike a skill that declares no capabilities.
|
||
* A skill with nothing to say cannot answer; an agent with no skills still has
|
||
* its page's own reader — which is exactly how Control Center, Hired History,
|
||
* Talent Pool and Activity answer today.
|
||
*/
|
||
record('validateAgentSource accepts an agent with no skills',
|
||
agentReg.validateAgentSource(`---\nid: skill-less\nname: Skill-less\npages:\n - control-center\n---\n\n# S\n`) === null);
|
||
|
||
/* ── Fields ⇄ definition, through the existing writer ────────────────────── */
|
||
|
||
const rootSource = root.markdown;
|
||
const patched = agentFields.applyAgentFields(rootSource, {
|
||
name: 'Renamed Agent',
|
||
permissions: {
|
||
owner: 'owner@krow.app',
|
||
access: 'specific',
|
||
people: [{ user: 'a@krow.app', role: 'editor' }, { user: 'b@krow.app', role: 'viewer' }],
|
||
},
|
||
knowledge: [{ id: 'policy', label: 'Overtime policy', kind: 'note', body: 'Beyond 20h needs sign-off.' }],
|
||
});
|
||
const reparsed = agentReg.parseAgent(patched, { custom: true });
|
||
|
||
/* The claim the whole editor rests on: `patchFrontmatter` already writes
|
||
nested block maps and `- key: value` sequences, so agents needed no second
|
||
writer. Asserted rather than assumed. */
|
||
record('a nested `permissions.people` list round-trips through the existing writer',
|
||
JSON.stringify(reparsed.permissions.people)
|
||
=== JSON.stringify([{ user: 'a@krow.app', role: 'editor' }, { user: 'b@krow.app', role: 'viewer' }]),
|
||
JSON.stringify(reparsed.permissions.people));
|
||
|
||
record('a nested `knowledge` entry round-trips too',
|
||
reparsed.knowledge.length === 1 && reparsed.knowledge[0].body === 'Beyond 20h needs sign-off.',
|
||
JSON.stringify(reparsed.knowledge));
|
||
|
||
record('patching one field leaves the body untouched',
|
||
reparsed.instructions === root.instructions);
|
||
|
||
record('patching one field leaves the others untouched',
|
||
JSON.stringify(reparsed.skills) === JSON.stringify(root.skills)
|
||
&& reparsed.subagents.length === root.subagents.length,
|
||
`${reparsed.skills.length} skills, ${reparsed.subagents.length} subagents`);
|
||
|
||
record('the patched definition still validates',
|
||
agentReg.validateAgentSource(patched) === null,
|
||
agentReg.validateAgentSource(patched) || 'ok');
|
||
|
||
record('fields read back out match what was written in',
|
||
agentFields.agentFieldsFromSource(patched).permissions.access === 'specific');
|
||
|
||
/* ── Storage round trip ──────────────────────────────────────────────────── */
|
||
|
||
const template = customAgents.agentTemplate({ id: 'my-agent', name: 'My Agent', pages: ['positions'] });
|
||
record('a fresh agent template validates',
|
||
agentReg.validateAgentSource(template) === null,
|
||
agentReg.validateAgentSource(template) || 'ok');
|
||
record('a fresh agent is a draft, never published',
|
||
agentReg.parseAgent(template, { custom: true }).status === 'draft',
|
||
agentReg.parseAgent(template, { custom: true }).status);
|
||
|
||
const storedAgent = customAgents.upsertCustomAgent([], template);
|
||
record('an authored agent stores as its own Markdown',
|
||
storedAgent.next.length === 1 && storedAgent.next[0].raw === template);
|
||
record('...and reads back with its id intact',
|
||
agentReg.parseAgent(storedAgent.next[0].raw, { custom: true }).id === 'my-agent');
|
||
|
||
const restored = customAgents.upsertCustomAgent(storedAgent.next, template);
|
||
record('re-saving an agent replaces its entry rather than duplicating it',
|
||
restored.next.length === 1, `${restored.next.length} stored`);
|
||
|
||
record('customAgentSource finds a stored definition',
|
||
customAgents.customAgentSource(storedAgent.next, 'my-agent') === template);
|
||
|
||
record('removeCustomAgent drops exactly one entry',
|
||
customAgents.removeCustomAgent(storedAgent.next, 'my-agent').length === 0);
|
||
|
||
/* An unparseable storedAgent entry must not take its neighbours with it. */
|
||
const mixed = [{ path: 'custom/bad.md', raw: BROKEN_AGENT }, ...storedAgent.next];
|
||
record('a broken stored agent does not remove the good ones',
|
||
customAgents.removeCustomAgent(mixed, 'nobody').length === 2);
|
||
|
||
/* ── Search ─────────────────────────────────────────────────────────────── */
|
||
|
||
record('an empty search returns every agent',
|
||
agentReg.searchAgents(agentReg.AGENTS, '').length === agentReg.AGENTS.length);
|
||
record('search matches on name',
|
||
agentReg.searchAgents(agentReg.AGENTS, 'analytics').some((a) => a.id === 'analytics-agent'));
|
||
record('search matches on what an agent is for',
|
||
agentReg.searchAgents(agentReg.AGENTS, 'audit trail').some((a) => a.id === 'activity-agent'));
|
||
record('search that matches nothing returns nothing',
|
||
agentReg.searchAgents(agentReg.AGENTS, 'zzzznope').length === 0);
|
||
|
||
/* ── Vocabulary is closed ───────────────────────────────────────────────── */
|
||
|
||
record('every agent names an icon the product has',
|
||
agentReg.AGENTS.every((a) => vocab.AGENT_ICONS.includes(a.icon)),
|
||
agentReg.AGENTS.map((a) => a.icon).join(', '));
|
||
record('every agent names a supported reasoning mode',
|
||
agentReg.AGENTS.every((a) => vocab.SUPPORTED_REASONING.includes(a.reasoning)));
|
||
record('every agent has a version of at least 1',
|
||
agentReg.AGENTS.every((a) => Number.isInteger(a.version) && a.version >= 1));
|
||
record('every agent states when to use it',
|
||
agentReg.AGENTS.every((a) => a.trigger.length > 0));
|
||
record('every agent states its instructions',
|
||
agentReg.AGENTS.every((a) => a.instructions.length > 0));
|
||
|
||
/* ── The parser is the skill parser ─────────────────────────────────────── */
|
||
|
||
/* A BOM, CRLF line endings, a blank line above the fence and trailing spaces
|
||
after it — the four things that used to take a skill definition down. An
|
||
agent gets the same tolerance for free, because it is the same function. */
|
||
const HOSTILE = `\r\n\r\n--- \r\nid: hostile-agent\r\nname: Hostile Agent\r\npages:\r\n - positions\r\n--- \r\n\r\n# Hostile\r\n\r\n## Instructions\r\n\r\nStill readable.\r\n`;
|
||
const hostile = agentReg.parseAgent(HOSTILE, { custom: true });
|
||
record('an agent survives a BOM, CRLF, a leading blank line and trailing spaces',
|
||
hostile.id === 'hostile-agent' && hostile.pages.includes('positions'),
|
||
`${hostile.id} / ${JSON.stringify(hostile.pages)}`);
|
||
record('...and its body still reads',
|
||
hostile.instructions.includes('Still readable.'),
|
||
hostile.instructions || 'LOST');
|
||
|
||
/* ── 16. Page boundary: runtime, knowledge and tools ──────────────────────
|
||
*
|
||
* The one property the whole design rests on: **an agent narrows a page and can
|
||
* never widen it.** Every layer added on top — data, knowledge, tools — has to
|
||
* inherit that, or selecting an agent becomes a way around the page boundary.
|
||
*
|
||
* It is asserted exhaustively rather than by example. Every agent is tried on
|
||
* every page, and the scoped result must be a subset of what the page offers
|
||
* with no agent at all. A subset cannot contain something the page did not have,
|
||
* so this is a proof rather than a spot-check.
|
||
*/
|
||
console.log('\n── Page boundary: runtime, knowledge and tools ──');
|
||
|
||
const runtime = await server.ssrLoadModule('/src/lib/agents/runtime.js');
|
||
|
||
/* ── Agent ownership of skill UI ──────────────────────────────────────────
|
||
*
|
||
* Ownership is opt-in: a skill an agent claims is that agent's, a skill nobody
|
||
* claims is unchanged. Found live — the panel's agent list was still being read
|
||
* from `preferences.customAgents` after authored agents had moved to the
|
||
* definitions store, so an attachment made in Agent Configure was invisible to
|
||
* the page and every agent saw every skill's UI.
|
||
*/
|
||
{
|
||
const A = { id: 'agent-a', skills: ['board', 'staffing-risk'] };
|
||
const B = { id: 'agent-b', skills: ['staffing-risk'] };
|
||
const permits = (id, agent, agents = [A, B]) => runtime.agentPermitsSkill(id, { agents, agent });
|
||
|
||
record('ownership: a claimed skill is permitted for the agent that claims it',
|
||
permits('board', A) === true, 'agent-a claims board');
|
||
|
||
record('ownership: a claimed skill is refused for an agent that does not',
|
||
permits('board', B) === false, 'agent-b does not claim board');
|
||
|
||
record('ownership: a skill no agent claims is unconstrained',
|
||
permits('unowned-skill', A) === true && permits('unowned-skill', B) === true,
|
||
'unowned stays visible to every agent');
|
||
|
||
record('ownership: a skill claimed by both is permitted for both',
|
||
permits('staffing-risk', A) === true && permits('staffing-risk', B) === true,
|
||
'shared claim');
|
||
|
||
record('ownership: with no agents loaded nothing is constrained', (() => {
|
||
const none = runtime.agentPermitsSkill('board', { agents: [], agent: null });
|
||
const undef = runtime.agentPermitsSkill('board', {});
|
||
return { pass: none === true && undef === true, detail: `${none} / ${undef}` };
|
||
})().pass);
|
||
|
||
record('ownership: an agent with no skills still sees unowned skills', (() => {
|
||
const empty = { id: 'agent-c', skills: [] };
|
||
return {
|
||
pass: permits('unowned-skill', empty) === true && permits('board', empty) === false,
|
||
detail: 'unowned yes, owned-by-another no',
|
||
};
|
||
})().pass);
|
||
|
||
record('ownership: the rule is the one the page actually uses', (() => {
|
||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/skills/SkillSurface.jsx')), 'utf8');
|
||
return {
|
||
pass: /agentPermitsSkill\(skill\.id, scope\)/.test(source)
|
||
&& /from '@\/lib\/agents\/runtime'/.test(source),
|
||
detail: 'useSkillSections filters through agentPermitsSkill',
|
||
};
|
||
})().pass);
|
||
|
||
record('ownership: the panel reads agents from the definitions store', (() => {
|
||
/* The live failure: this file read `preferences.customAgents`, which authored
|
||
agents had already moved out of, so no attachment was ever visible here. */
|
||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentContext.jsx')), 'utf8');
|
||
return {
|
||
pass: /useAgentDefinitions\(\)/.test(source)
|
||
&& /sourcesFrom\(/.test(source)
|
||
&& !/allAgents\(preferences\.customAgents/.test(source),
|
||
detail: 'AgentProvider builds its registry from agent-definitions',
|
||
};
|
||
})().pass);
|
||
}
|
||
const contexts = await server.ssrLoadModule('/src/components/ai-assistant/contexts.js');
|
||
const knowledge = await server.ssrLoadModule('/src/lib/agents/knowledge.js');
|
||
const tools = await server.ssrLoadModule('/src/lib/skills/tools.js');
|
||
const agentContext = await server.ssrLoadModule('/src/lib/agents/context.js');
|
||
const routingModule = await server.ssrLoadModule('/src/components/ai-assistant/routing.js');
|
||
const actions = await server.ssrLoadModule('/src/lib/skills/actions.js');
|
||
|
||
const ALL_AGENTS = agentReg.AGENTS;
|
||
const ALL_SKILLS = reg.SKILLS;
|
||
const PAGE_CONTEXTS = Object.keys(expectedBaseline.contexts);
|
||
|
||
/* ── The invariant, over every page × agent pair ─────────────────────────── */
|
||
|
||
const widened = [];
|
||
const scopedCounts = [];
|
||
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
const unscoped = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
|
||
for (const agent of ALL_AGENTS) {
|
||
const disabled = runtime.agentScopedDisabledWith(agent, ALL_AGENTS, ALL_SKILLS, []);
|
||
const scoped = reg.skillsForContext(contextId, disabled, []).map((s) => s.id);
|
||
|
||
const extra = scoped.filter((id) => !unscoped.includes(id));
|
||
if (extra.length) widened.push(`${contextId} + ${agent.id}: ${JSON.stringify(extra)}`);
|
||
scopedCounts.push(scoped.length);
|
||
}
|
||
}
|
||
|
||
record(`no agent widens any page (${PAGE_CONTEXTS.length} pages × ${ALL_AGENTS.length} agents)`,
|
||
widened.length === 0,
|
||
widened.slice(0, 3).join(' | ') || `${PAGE_CONTEXTS.length * ALL_AGENTS.length} combinations, every result a subset`);
|
||
|
||
/* With no agent at all, nothing is withheld — the pre-agent behaviour. */
|
||
record('no agent selected withholds nothing',
|
||
PAGE_CONTEXTS.every((contextId) =>
|
||
JSON.stringify(reg.skillsForContext(contextId, runtime.agentScopedDisabled(null, ALL_SKILLS, []), []).map((s) => s.id))
|
||
=== JSON.stringify(reg.skillsForContext(contextId, [], []).map((s) => s.id))));
|
||
|
||
record('`agentScopedDisabled(null, …)` returns its input untouched',
|
||
JSON.stringify(runtime.agentScopedDisabled(null, ALL_SKILLS, ['a', 'b'])) === JSON.stringify(['a', 'b']));
|
||
|
||
/* ── The three named cross-boundary cases ────────────────────────────────── */
|
||
|
||
const CROSS_CASES = [
|
||
['Positions page + Analytics Agent', 'admin.positions', 'analytics-agent'],
|
||
['Candidates page + Krow Workforce Agent', 'admin.candidatesList', 'krow-workforce-agent'],
|
||
['Analytics page + Positions Agent', 'admin.analytics', 'positions-agent'],
|
||
];
|
||
|
||
for (const [label, contextId, agentId] of CROSS_CASES) {
|
||
const agent = agentReg.getAgent(ALL_AGENTS, agentId);
|
||
const pageOnly = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
const disabled = runtime.agentScopedDisabledWith(agent, ALL_AGENTS, ALL_SKILLS, []);
|
||
const scoped = reg.skillsForContext(contextId, disabled, []).map((s) => s.id);
|
||
|
||
/* Data. */
|
||
record(`${label}: reads nothing the page does not already offer`,
|
||
scoped.every((id) => pageOnly.includes(id)),
|
||
`page offers ${JSON.stringify(pageOnly)}, agent sees ${JSON.stringify(scoped)}`);
|
||
|
||
/* Tools. */
|
||
const pageTools = tools.toolsForContext(contextId, [], []).map((t) => t.name);
|
||
const agentTools = tools.toolsForContext(contextId, disabled, []).map((t) => t.name);
|
||
record(`${label}: reaches no tool the page does not already offer`,
|
||
agentTools.every((name) => pageTools.includes(name)),
|
||
`page offers ${JSON.stringify(pageTools)}, agent reaches ${JSON.stringify(agentTools)}`);
|
||
|
||
/* Knowledge. */
|
||
const covers = runtime.agentCovers(agent, contextId);
|
||
const read = knowledge.retrieveKnowledge({
|
||
agent, contextId, question: 'What does the scope note say about pages?',
|
||
});
|
||
if (!covers) {
|
||
record(`${label}: retrieves no knowledge, because the agent is constrained here`,
|
||
read.available === false && read.passages.length === 0,
|
||
read.note || 'NO NOTE');
|
||
} else {
|
||
record(`${label}: knowledge stays inside the agent's own entries`,
|
||
read.passages.every((p) => (agent.knowledge || []).some((k) => k.id === p.documentId)),
|
||
`${read.passages.length} passage(s)`);
|
||
}
|
||
|
||
/* Routing. */
|
||
const intent = routingModule.resolveIntent({
|
||
question: 'What needs my attention?',
|
||
contextId,
|
||
disabledSkills: disabled,
|
||
agent,
|
||
agentCoversPage: covers,
|
||
agentSuggestion: runtime.defaultAgentForContext(ALL_AGENTS, contextId),
|
||
});
|
||
record(`${label}: ${covers ? 'answers from this page' : 'declines honestly instead of reaching'}`,
|
||
covers ? intent.kind !== 'constrained' : intent.kind === 'constrained',
|
||
`kind=${intent.kind}`);
|
||
}
|
||
|
||
/**
|
||
* A constrained agent must not answer *from the pages it does cover*.
|
||
*
|
||
* The subtlest way to break the boundary: Analytics Agent on Positions could
|
||
* plausibly answer an analytics question "because that is what it is for". It
|
||
* must not — the page is the boundary, and an agent is a lens on the page in
|
||
* front of the reader, never a route to a different one.
|
||
*/
|
||
const analyticsAgent = agentReg.getAgent(ALL_AGENTS, 'analytics-agent');
|
||
const leaked = routingModule.resolveIntent({
|
||
question: 'How much overtime are we running?',
|
||
contextId: 'admin.positions',
|
||
disabledSkills: runtime.agentScopedDisabledWith(analyticsAgent, ALL_AGENTS, ALL_SKILLS, []),
|
||
agent: analyticsAgent,
|
||
agentCoversPage: false,
|
||
agentSuggestion: agentReg.getAgent(ALL_AGENTS, 'positions-agent'),
|
||
});
|
||
record('a constrained agent does not answer from the pages it covers elsewhere',
|
||
leaked.kind === 'constrained' && !leaked.skill,
|
||
`kind=${leaked.kind}, skill=${leaked.skill?.id ?? 'none'}`);
|
||
|
||
record('...and says which agent belongs here instead',
|
||
JSON.stringify(leaked.doc).includes('Positions Agent'));
|
||
|
||
/* ── Tools ──────────────────────────────────────────────────────────────── */
|
||
|
||
record('every action a handler exists for is described',
|
||
tools.undescribedActions().length === 0,
|
||
tools.undescribedActions().join(', ') || `${tools.TOOL_NAMES.length} tools described`);
|
||
|
||
record('a tool that writes a record requires approval',
|
||
tools.toolRequiresApproval('create_position') === true);
|
||
|
||
record('a tool that only navigates does not',
|
||
['navigate_to_positions', 'navigate_to_analytics', 'open_related_page']
|
||
.every((name) => tools.toolRequiresApproval(name) === false));
|
||
|
||
record('every mutating tool requires approval',
|
||
tools.TOOLS.filter((t) => t.mutates).every((t) => t.requiresApproval),
|
||
tools.TOOLS.filter((t) => t.mutates).map((t) => t.name).join(', ') || 'none mutate');
|
||
|
||
record('every read-only tool is honest about mutating nothing',
|
||
tools.TOOLS.filter((t) => t.readOnly).every((t) => t.mutates === null));
|
||
|
||
/* A tool is reachable only through a skill on this page that declares it. */
|
||
const undeclaredTools = [];
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
const declared = new Set(
|
||
reg.skillsForContext(contextId, [], []).flatMap((s) => s.actions || [])
|
||
);
|
||
for (const tool of tools.toolsForContext(contextId, [], [])) {
|
||
if (!declared.has(tool.name)) undeclaredTools.push(`${contextId}: ${tool.name}`);
|
||
}
|
||
}
|
||
record('a tool is reachable only where a skill on that page declares it',
|
||
undeclaredTools.length === 0, undeclaredTools.join(', ') || 'every tool traced to a declaring skill');
|
||
|
||
/**
|
||
* The real action still refuses what a skill did not declare.
|
||
*
|
||
* `runAction` gated on `skill.actions` before any of this existed, and the tool
|
||
* layer describes actions rather than performing them — so the gate must be
|
||
* exactly where it was.
|
||
*/
|
||
const positionsSkill = reg.SKILLS.find((s) => s.id === 'staffing-risk');
|
||
record('runAction still refuses an action the skill did not declare',
|
||
actions.runAction('create_position', { skill: positionsSkill, draft: {}, status: 'draft' }) === null);
|
||
|
||
const creator = reg.SKILLS.find((s) => s.id === 'create-position');
|
||
record('...and still performs one it did',
|
||
actions.runAction('create_position', { skill: creator, draft: { title: 'X' }, status: 'draft' })?.type
|
||
=== 'create_position');
|
||
|
||
record('open_related_page resolves only addresses the product has',
|
||
actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'positions' })?.route
|
||
=== '/admin/positions'
|
||
&& actions.runAction('open_related_page', { skill: { actions: ['open_related_page'] }, page: 'the-moon' }) === null);
|
||
|
||
/* ── Both conversations, end to end ─────────────────────────────────────── */
|
||
|
||
/*
|
||
* A whole conversation, driven the way the panel drives it.
|
||
*
|
||
* The flow engine is the one place a wrong answer produces no error at all: an
|
||
* unrecognised reply re-renders the summary, which is exactly what a CORRECT
|
||
* reply does on the turn before. So the only way to know the confirmation step
|
||
* works is to walk one to the end and check that a record came out.
|
||
*/
|
||
const flowEngine = await server.ssrLoadModule('/src/lib/skills/conversationFlow.js');
|
||
|
||
const ROLES = ['Bartender', 'Server', 'Line Cook'];
|
||
const WORKERS = [
|
||
{ id: 'wp-1', name: 'Asha Menon', email: 'asha@example.test' },
|
||
{ id: 'wp-2', name: 'Ravi Kumar', email: 'ravi@example.test' },
|
||
];
|
||
const COMPANIES = ['Fairmont San Jose', 'Taj Coromandel'];
|
||
|
||
/** Answer a flow's questions in order, and return the last turn. */
|
||
function walk(registry, skill, opening, answers, ctx) {
|
||
let turn = flowEngine.beginFlow({ registry, question: opening, skill, ctx });
|
||
for (const answer of answers) {
|
||
if (!turn.flow) break;
|
||
turn = flowEngine.advanceFlow({ registry, flow: turn.flow, answer, skill, ctx });
|
||
}
|
||
return turn;
|
||
}
|
||
|
||
const positionSkill = reg.SKILLS.find((s) => s.id === 'create-position');
|
||
const positionFlow = flowsModule.flowFor(positionSkill);
|
||
const positionCtx = { roles: ROLES, companies: COMPANIES };
|
||
|
||
const posted = walk(positionFlow, positionSkill,
|
||
'Create a bartender position in Chennai paying $30-$40/hr',
|
||
['Fairmont San Jose', 'No minimum', 'Skip', 'None', 'Publish Job Posting'],
|
||
positionCtx);
|
||
|
||
record('the position conversation reaches a record',
|
||
Boolean(posted.create),
|
||
posted.create ? `status=${posted.create.status}` : `stage=${posted.flow?.stage}, step=${posted.flow?.step}`);
|
||
|
||
record('...carrying every answer the conversation collected',
|
||
posted.create?.draft.company === 'Fairmont San Jose'
|
||
&& posted.create?.draft.title === 'Bartender'
|
||
&& posted.create?.draft.location === 'Chennai'
|
||
&& posted.create?.draft.pay_range_min === '30',
|
||
JSON.stringify(posted.create?.draft ?? {}));
|
||
|
||
record('...and the payload the API receives is unchanged by the split',
|
||
JSON.stringify(actions.runAction('create_position',
|
||
{ skill: positionSkill, draft: posted.create.draft, status: posted.create.status })?.data.status) === '"active"'
|
||
&& actions.runAction('create_position',
|
||
{ skill: positionSkill, draft: posted.create.draft, status: posted.create.status })?.data.pay_range_min === 30);
|
||
|
||
/*
|
||
* The confirmation bug, pinned.
|
||
*
|
||
* "Create position" committed and "create positions" — the plural the Positions
|
||
* page itself uses — silently did not: the anchored regex missed it, and the
|
||
* reader got the summary back with no clue what was wrong. All four phrasings
|
||
* are now the same answer.
|
||
*/
|
||
const atReview = walk(positionFlow, positionSkill,
|
||
'Create a bartender position in Chennai paying $30-$40/hr',
|
||
['Fairmont San Jose', 'No minimum', 'Skip', 'None'],
|
||
positionCtx);
|
||
|
||
for (const [phrase, shouldCommit] of [
|
||
['Create position', true], ['create positions', true], ['Create a position', true],
|
||
['create it', true], ['Publish', true], ['Save as draft', true],
|
||
['banana', false], ['no', false],
|
||
]) {
|
||
const turn = flowEngine.advanceFlow({
|
||
registry: positionFlow, flow: atReview.flow, answer: phrase, skill: positionSkill, ctx: positionCtx,
|
||
});
|
||
record(`confirmation: "${phrase}" ${shouldCommit ? 'commits' : 'does not commit'}`,
|
||
Boolean(turn.create) === shouldCommit,
|
||
`create=${Boolean(turn.create)}`);
|
||
}
|
||
|
||
record('...and "no" opens the change menu rather than refusing',
|
||
flowEngine.advanceFlow({
|
||
registry: positionFlow, flow: atReview.flow, answer: 'no', skill: positionSkill, ctx: positionCtx,
|
||
}).flow?.stage === 'change');
|
||
|
||
record('...while "Save as draft" commits the draft status, not the published one',
|
||
flowEngine.advanceFlow({
|
||
registry: positionFlow, flow: atReview.flow, answer: 'Save as draft', skill: positionSkill, ctx: positionCtx,
|
||
}).create?.status === 'draft');
|
||
|
||
/*
|
||
* A request that names no role must settle no title.
|
||
*
|
||
* "Create new position" used to arrive with the title already set to "New" — a
|
||
* value nobody typed, on the one field a position cannot be created without.
|
||
* The scaffolding is a PHRASE at least as often as it is a single word, and the
|
||
* first fix for this only handled the single word: "create another new position"
|
||
* still produced "Nother New", the regex having eaten the "a" of "another".
|
||
*
|
||
* Both halves are pinned below, because both failed silently — a fabricated
|
||
* title reads like an answer, so nothing about it looks wrong until somebody
|
||
* notices the position is called "One More".
|
||
*/
|
||
for (const question of [
|
||
'Create position', 'Create a position', 'create positions',
|
||
'Create new position', 'create a new position', 'create another new position',
|
||
'create a brand new position', 'create one more position', 'create the position',
|
||
'open a new role', 'add a new position', 'post a new job',
|
||
]) {
|
||
const draft = flowEngine.beginFlow({
|
||
registry: positionFlow, question, skill: positionSkill, ctx: positionCtx,
|
||
}).flow.draft;
|
||
record(`"${question}" settles no title`, !draft.title, JSON.stringify(draft));
|
||
}
|
||
|
||
/*
|
||
* ...and the fix must not rename a role that legitimately uses one of those
|
||
* words. The scaffolding list is stripped to DECIDE whether a phrase named
|
||
* anything, never to rewrite what it named — otherwise "second chef" becomes
|
||
* "Chef" and the cure is worse than the bug.
|
||
*/
|
||
for (const [question, want] of [
|
||
['create a bartender position', 'Bartender'],
|
||
['create a sous chef position', 'Sous Chef'],
|
||
['create a second chef position', 'Second Chef'],
|
||
['create an open kitchen lead position', 'Open Kitchen Lead'],
|
||
['create a fresh produce buyer position', 'Fresh Produce Buyer'],
|
||
['create an extra hands supervisor position', 'Extra Hands Supervisor'],
|
||
['position for a third cook', 'Third Cook'],
|
||
]) {
|
||
const draft = flowEngine.beginFlow({
|
||
registry: positionFlow, question, skill: positionSkill, ctx: positionCtx,
|
||
}).flow.draft;
|
||
record(`"${question}" keeps its own words`, draft.title === want, draft.title ?? 'null');
|
||
}
|
||
|
||
/* The employee role: the other conversation, over the same engine. */
|
||
const roleSkill = reg.SKILLS.find((s) => s.id === 'create-employee-role');
|
||
const roleFlow = flowsModule.flowFor(roleSkill);
|
||
const roleCtx = { roles: ROLES, workers: WORKERS };
|
||
|
||
const NEW_NAME = 'A New Person';
|
||
const NEW_EMAIL = 'a-new-person@example.test';
|
||
|
||
const recorded = walk(roleFlow, roleSkill, 'Create an employee role',
|
||
[NEW_NAME, NEW_EMAIL, 'Bartender', '3 years', 'Skip', 'None', '$25-$35/hr', 'Weekends', 'Skip',
|
||
'Create employee role'],
|
||
roleCtx);
|
||
|
||
record('the employee-role conversation reaches a record',
|
||
Boolean(recorded.create),
|
||
recorded.create ? `status=${recorded.create.status}` : `stage=${recorded.flow?.stage}, step=${recorded.flow?.step}`);
|
||
|
||
/**
|
||
* It creates a NEW person, and the name and email are both the caller's.
|
||
*
|
||
* This flow used to look the name up among the existing workers and record the
|
||
* role against the first match, which is wrong twice: a name selects nobody
|
||
* when several people share one, and a name nobody had was understood as
|
||
* nothing at all — the same question asked again, forever.
|
||
*/
|
||
record('...for a NEW person, from the name and email the caller gave',
|
||
recorded.create?.draft.worker_name === NEW_NAME
|
||
&& recorded.create?.draft.worker_email === NEW_EMAIL
|
||
&& recorded.create?.draft.role_category === 'Bartender',
|
||
JSON.stringify(recorded.create?.draft ?? {}));
|
||
|
||
record('...and no worker id is carried, because none is chosen',
|
||
!recorded.create?.draft.worker_profile_id);
|
||
|
||
/* The name is the FIRST question and it is not a lookup: a name that matches
|
||
nobody moves the conversation on rather than repeating itself. */
|
||
const afterName = (answer) => flowEngine.advanceFlow({
|
||
registry: roleFlow,
|
||
flow: flowEngine.beginFlow({ registry: roleFlow, question: 'Create an employee role', skill: roleSkill, ctx: roleCtx }).flow,
|
||
answer, skill: roleSkill, ctx: roleCtx,
|
||
});
|
||
|
||
record('a name nobody has is accepted, and the next question is the email',
|
||
afterName('Somebody Entirely New').flow.step === 'worker_email');
|
||
|
||
record('...and a name that DOES match an existing worker is still just a name',
|
||
(() => {
|
||
const out = afterName(WORKERS[0].name);
|
||
return out.flow.step === 'worker_email'
|
||
&& out.flow.draft.worker_name === WORKERS[0].name
|
||
&& !out.flow.draft.worker_email
|
||
&& !out.flow.draft.worker_profile_id;
|
||
})(), 'no existing worker is selected');
|
||
|
||
/**
|
||
* The email is asked for and never invented.
|
||
*
|
||
* Nothing derives an address from the name, from the operator or from anything
|
||
* an earlier conversation collected. An answer that is not an address is
|
||
* re-asked; the draft carries no email until the caller types one.
|
||
*/
|
||
for (const notAnEmail of ['I do not know', 'skip it', 'same as before', 'A New Person']) {
|
||
record(`"${notAnEmail}" is not an email, so it is asked for again`,
|
||
(() => {
|
||
const named = afterName(NEW_NAME);
|
||
const out = flowEngine.advanceFlow({
|
||
registry: roleFlow, flow: named.flow, answer: notAnEmail, skill: roleSkill, ctx: roleCtx,
|
||
});
|
||
return out.flow.step === 'worker_email' && !out.flow.draft.worker_email;
|
||
})());
|
||
}
|
||
|
||
record('...and nothing in the flow can produce an email on its own',
|
||
(() => {
|
||
const named = afterName(NEW_NAME).flow;
|
||
/* Every field, asked with the name settled and no email given: none of them
|
||
may put an address into the draft. */
|
||
return !named.worker_email;
|
||
})());
|
||
|
||
/* The `@` tokens resolve from data the caller already holds. */
|
||
const firstChips = (registry, skill, ctx) => flowEngine.beginFlow({
|
||
registry, question: registry.id === 'position' ? 'Create a position' : 'Create an employee role', skill, ctx,
|
||
}).followUp.map((c) => c.label);
|
||
|
||
record('@companies offers the clients this organization already staffs for',
|
||
COMPANIES.every((c) => firstChips(positionFlow, positionSkill, positionCtx).includes(c)),
|
||
firstChips(positionFlow, positionSkill, positionCtx).join(', '));
|
||
|
||
/* The first question offers no chips at all: a new person's name cannot be
|
||
suggested from the people already on file, and offering them would be the
|
||
lookup this flow no longer does. */
|
||
record('the name question suggests nobody',
|
||
firstChips(roleFlow, roleSkill, roleCtx).length === 0,
|
||
firstChips(roleFlow, roleSkill, roleCtx).join(', ') || 'no chips');
|
||
|
||
/*
|
||
* The chip the server suggests has to be a phrase a skill answers to.
|
||
*
|
||
* No page context declares `capabilities`, so every server suggestion is
|
||
* dispatched as its own TEXT — which means the catalogue's wording and the
|
||
* skills' triggers are one coupling with nothing else holding it together. A
|
||
* renamed chip would produce a suggestion that opens nothing, silently.
|
||
*/
|
||
for (const [chip, wantSkill] of [
|
||
['Create a company position', 'create-position'],
|
||
['Create an employee role', 'create-employee-role'],
|
||
]) {
|
||
const matched = reg.matchSkill(chip, 'admin.positions', [], []);
|
||
record(`the "${chip}" chip is answered by ${wantSkill}`,
|
||
matched?.id === wantSkill,
|
||
matched?.id ?? `no skill on Positions answers "${chip}"`);
|
||
}
|
||
|
||
/* And the wording actually distinguishes them: the two chips must not both
|
||
route to the same conversation, which is the whole reason "company" and
|
||
"employee" are in the text the reader clicks. */
|
||
record('...and the two create chips route to different conversations',
|
||
reg.matchSkill('Create a company position', 'admin.positions', [], [])?.flow
|
||
!== reg.matchSkill('Create an employee role', 'admin.positions', [], [])?.flow,
|
||
`${reg.matchSkill('Create a company position', 'admin.positions', [], [])?.flow} vs `
|
||
+ `${reg.matchSkill('Create an employee role', 'admin.positions', [], [])?.flow}`);
|
||
|
||
/* ── Knowledge ──────────────────────────────────────────────────────────── */
|
||
|
||
record('an agent with no knowledge says so rather than returning nothing',
|
||
(() => {
|
||
const bare = agentReg.getAgent(ALL_AGENTS, 'activity-agent');
|
||
const read = knowledge.retrieveKnowledge({ agent: bare, contextId: 'admin.activity', question: 'policy' });
|
||
return read.available === false && /no knowledge attached/i.test(read.note || '');
|
||
})());
|
||
|
||
record('no agent is active means no knowledge, stated honestly',
|
||
(() => {
|
||
const read = knowledge.retrieveKnowledge({ agent: null, question: 'anything' });
|
||
return read.available === false && read.passages.length === 0 && Boolean(read.note);
|
||
})());
|
||
|
||
const rootAgent = agentReg.getAgent(ALL_AGENTS, 'krow-workforce-agent');
|
||
const found = knowledge.retrieveKnowledge({
|
||
agent: rootAgent, contextId: 'admin.positions', question: 'What can this agent see across pages?',
|
||
});
|
||
record('an agent with knowledge retrieves from its own entries',
|
||
found.available === true && found.passages.length > 0,
|
||
`${found.passages.length} passage(s)`);
|
||
|
||
record('every passage names the document it came from',
|
||
found.passages.every((p) => p.documentId && p.chunkId && p.text));
|
||
|
||
record('a question the knowledge does not cover returns nothing, and says so',
|
||
(() => {
|
||
const miss = knowledge.retrieveKnowledge({
|
||
agent: rootAgent, contextId: 'admin.positions', question: 'zzzz quantum bicycles',
|
||
});
|
||
return miss.available === true && miss.passages.length === 0 && Boolean(miss.note);
|
||
})());
|
||
|
||
record('no knowledge document is invented',
|
||
ALL_AGENTS.every((a) => knowledge.knowledgeDocuments(a)
|
||
.every((d) => (a.knowledge || []).some((k) => k.id === d.id))),
|
||
`${ALL_AGENTS.reduce((n, a) => n + knowledge.knowledgeDocuments(a).length, 0)} declared documents in total`);
|
||
|
||
/* ── Question classification ────────────────────────────────────────────── */
|
||
|
||
record('a question about records is structured, never retrieval',
|
||
runtime.classifyQuestion({ question: 'Which employees worked more than 20 overtime hours?' }) === 'structured');
|
||
|
||
record('a question about a document is knowledge',
|
||
runtime.classifyQuestion({ question: 'What does our overtime policy say?' }) === 'knowledge');
|
||
|
||
record('a question needing both is combined',
|
||
runtime.classifyQuestion({ question: 'Which employees exceeded the overtime policy this month?' }) === 'combined');
|
||
|
||
record('an ambiguous question stays structured rather than guessing at retrieval',
|
||
runtime.classifyQuestion({ question: 'How is attendance?' }) === 'structured');
|
||
|
||
/* ── Coverage and defaults ──────────────────────────────────────────────── */
|
||
|
||
const NATIVE = {
|
||
'admin.controlCenter': 'control-center-agent',
|
||
'admin.positions': 'positions-agent',
|
||
'admin.candidatesList': 'candidates-agent',
|
||
'admin.hiredHistory': 'hired-history-agent',
|
||
'admin.talentPool': 'talent-pool-agent',
|
||
'admin.forge': 'krow-forge-agent',
|
||
'admin.analytics': 'analytics-agent',
|
||
'admin.activity': 'activity-agent',
|
||
};
|
||
for (const [contextId, expected] of Object.entries(NATIVE)) {
|
||
record(`${contextId.replace('admin.', '')} opens on its own agent`,
|
||
runtime.defaultAgentForContext(ALL_AGENTS, contextId)?.id === expected,
|
||
runtime.defaultAgentForContext(ALL_AGENTS, contextId)?.id || 'none');
|
||
}
|
||
|
||
record('every page has an agent to open with',
|
||
PAGE_CONTEXTS.every((contextId) => runtime.defaultAgentForContext(ALL_AGENTS, contextId)),
|
||
PAGE_CONTEXTS.filter((c) => !runtime.defaultAgentForContext(ALL_AGENTS, c)).join(', ') || 'all covered');
|
||
|
||
record('a requested agent is never silently swapped for another',
|
||
(() => {
|
||
const turn = runtime.resolveAgentForTurn(ALL_AGENTS, 'analytics-agent', 'admin.positions');
|
||
return turn.agent.id === 'analytics-agent' && turn.covers === false && turn.suggestion?.id === 'positions-agent';
|
||
})());
|
||
|
||
/* ── Subagents ──────────────────────────────────────────────────────────── */
|
||
|
||
/* `subagents` means DELEGATE TO, not borrow from — CLAUDE.md §3 and §6. These
|
||
checks pin that, because the opposite behaviour lived here for a long time
|
||
and reads as reasonable: a parent silently carried one level of its
|
||
subagents' skills, which is a second and incompatible meaning for the key
|
||
the backend uses to spawn a delegated run. */
|
||
|
||
record('an agent carries only its own skills, never a subagent\'s',
|
||
(() => {
|
||
const a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' };
|
||
const b = { id: 'b', skills: ['s2'], subagents: [], status: 'published' };
|
||
const carried = runtime.agentSkillIds(a, [a, b]);
|
||
return carried.length === 1 && carried[0] === 's1';
|
||
})(),
|
||
'reaching another agent is delegation, which the runtime does with its own budget and trajectory');
|
||
|
||
record('the shipped root agent carries exactly what it declares',
|
||
runtime.agentSkillIds(rootAgent, ALL_AGENTS).length === new Set(rootAgent.skills).size,
|
||
`${new Set(rootAgent.skills).size} declared`);
|
||
|
||
record('a subagent cycle cannot be walked, because nothing walks subagents here',
|
||
(() => {
|
||
const a = { id: 'a', skills: ['s1'], subagents: ['b'], status: 'published' };
|
||
const b = { id: 'b', skills: ['s2'], subagents: ['a'], status: 'published' };
|
||
return runtime.agentSkillIds(a, [a, b]).length === 1;
|
||
})(),
|
||
'cycles are refused at publish (definition.FindSubagentCycle) and bounded at run time by the depth cap');
|
||
|
||
/* ── Starters ───────────────────────────────────────────────────────────── */
|
||
|
||
record('an agent offers no starters on a page it does not cover',
|
||
runtime.agentStarters(analyticsAgent, 'admin.positions').length === 0);
|
||
|
||
record('an agent offers its starters on a page it does cover',
|
||
runtime.agentStarters(analyticsAgent, 'admin.analytics').length > 0,
|
||
runtime.agentStarters(analyticsAgent, 'admin.analytics').map((s) => s.label).join(' | '));
|
||
|
||
record('a starter names no capability, so it cannot address an unoffered skill',
|
||
ALL_AGENTS.every((a) => runtime.agentStarters(a, null).every((s) => s.capability === null)));
|
||
|
||
/* ── Context envelope ───────────────────────────────────────────────────── */
|
||
|
||
const envelope = agentContext.buildOwliverContext({
|
||
context: { id: 'admin.positions', page: 'Positions' },
|
||
pathname: '/admin/positions',
|
||
pageContext: { position: { id: 'job_chef' } },
|
||
});
|
||
record('the envelope resolves its address from the placement table',
|
||
envelope.route === '/admin/positions' && envelope.pageKey === 'positions',
|
||
`${envelope.pageKey} @ ${envelope.route}`);
|
||
|
||
record('a page that publishes only a record still yields a valid envelope',
|
||
envelope.position?.id === 'job_chef'
|
||
&& Array.isArray(envelope.selectedItems) && envelope.selectedItems.length === 0
|
||
&& envelope.period === null,
|
||
JSON.stringify({ selected: envelope.selectedItems.length, period: envelope.period }));
|
||
|
||
record('a page that publishes nothing at all still yields a valid envelope',
|
||
(() => {
|
||
const bare = agentContext.buildOwliverContext({ context: { id: 'admin.activity', page: 'Activity' } });
|
||
return bare.pageKey === 'activity' && bare.position === null && Object.keys(bare.metrics).length === 0;
|
||
})());
|
||
|
||
record('the stored form of the envelope carries no records',
|
||
(() => {
|
||
const stored = agentContext.storableContext(envelope);
|
||
return !('selectedItems' in stored) && !('metrics' in stored) && !('position' in stored)
|
||
&& stored.pageKey === 'positions';
|
||
})());
|
||
|
||
/* ── Nothing above changed the unscoped product ─────────────────────────── */
|
||
|
||
/**
|
||
* The regression assertion for the whole phase.
|
||
*
|
||
* `resolveIntent` gained four parameters. With none of them supplied it must
|
||
* behave exactly as it did before — same branch, same skill, same document
|
||
* shape — or every existing caller has quietly changed.
|
||
*/
|
||
const UNSCOPED_CASES = [
|
||
['admin.positions', 'Which positions need attention?'],
|
||
['admin.candidatesList', 'Who is waiting on a decision?'],
|
||
['admin.analytics', 'What is the hiring trend?'],
|
||
['admin.activity', 'What happened recently?'],
|
||
['admin.profile', 'What are my permissions?'],
|
||
];
|
||
const drifted = [];
|
||
for (const [contextId, question] of UNSCOPED_CASES) {
|
||
const out = routingModule.resolveIntent({ question, contextId });
|
||
const before = expectedBaseline.contexts[contextId]?.intents.find((i) => i.question === question);
|
||
if (out.kind === 'constrained') drifted.push(`${contextId}: became constrained without an agent`);
|
||
if (before && out.kind !== before.kind) drifted.push(`${contextId} "${question}": ${before.kind} → ${out.kind}`);
|
||
}
|
||
record('resolveIntent with no agent behaves exactly as before',
|
||
drifted.length === 0, drifted.join(' | ') || `${UNSCOPED_CASES.length} cases unchanged`);
|
||
|
||
/* ── 17. Agent switcher wiring ────────────────────────────────────────────
|
||
*
|
||
* The switcher is a React component and there is no component harness here, so
|
||
* what is checked is the logic behind it: which agent a page opens on, what the
|
||
* list offers, and — the part that matters — that none of it can move the page.
|
||
*
|
||
* The rendering itself is verified by driving the real application; see the
|
||
* Phase 6 report.
|
||
*/
|
||
console.log('\n── Agent switcher wiring ──');
|
||
|
||
const icons = await server.ssrLoadModule('/src/components/agents/icons.js');
|
||
|
||
/* Every icon an agent names resolves, or falls back to the avatar on purpose. */
|
||
record('every agent icon resolves to a component or to the avatar',
|
||
ALL_AGENTS.every((a) => a.icon === 'owliver' || icons.agentIconFor(a.icon)),
|
||
ALL_AGENTS.map((a) => `${a.icon}${icons.agentIconFor(a.icon) ? '' : '(avatar)'}`).join(', '));
|
||
|
||
record('the primary agent draws the Owliver avatar rather than a glyph',
|
||
icons.agentIconFor('owliver') === null);
|
||
|
||
record('an unknown icon falls back to the avatar rather than crashing',
|
||
icons.agentIconFor('not-an-icon') === null);
|
||
|
||
/* ── The list the switcher renders ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* Agents that work here sort above those that do not.
|
||
*
|
||
* On a page where two of nine apply, the seven that cannot must not sit above
|
||
* them. Order is otherwise the registry's own, so the list does not reshuffle
|
||
* as the reader types.
|
||
*/
|
||
for (const contextId of ['admin.positions', 'admin.analytics', 'admin.activity']) {
|
||
const ordered = [...agentReg.searchAgents(ALL_AGENTS, '')]
|
||
.sort((a, b) => Number(runtime.agentCovers(b, contextId)) - Number(runtime.agentCovers(a, contextId)));
|
||
const firstConstrained = ordered.findIndex((a) => !runtime.agentCovers(a, contextId));
|
||
const lastCovering = ordered.map((a) => runtime.agentCovers(a, contextId)).lastIndexOf(true);
|
||
record(`${contextId.replace('admin.', '')}: agents that work here are listed first`,
|
||
firstConstrained === -1 || lastCovering < firstConstrained,
|
||
ordered.slice(0, 3).map((a) => `${a.name}${runtime.agentCovers(a, contextId) ? '' : ' (constrained)'}`).join(', '));
|
||
}
|
||
|
||
record('search reaches every agent by name',
|
||
ALL_AGENTS.every((a) => agentReg.searchAgents(ALL_AGENTS, a.name).some((m) => m.id === a.id)));
|
||
|
||
record('a constrained agent is still listed rather than hidden',
|
||
agentReg.searchAgents(ALL_AGENTS, 'Analytics').some((a) => a.id === 'analytics-agent'),
|
||
'listed on every page, marked where it does not apply');
|
||
|
||
/* ── Native agent per page ──────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* A page opens on the agent written for it, and that state is behaviourally
|
||
* identical to no agent at all.
|
||
*
|
||
* The second half is what makes automatic selection safe: a page agent carries
|
||
* every skill its own page offers, so selecting it withholds nothing. If that
|
||
* ever stopped being true, opening a page would silently lose a capability.
|
||
*/
|
||
for (const [contextId, expected] of Object.entries(NATIVE)) {
|
||
const native = runtime.defaultAgentForContext(ALL_AGENTS, contextId);
|
||
const unscoped = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
const withNative = reg.skillsForContext(
|
||
contextId, runtime.agentScopedDisabledWith(native, ALL_AGENTS, ALL_SKILLS, []), []
|
||
).map((s) => s.id);
|
||
|
||
record(`${contextId.replace('admin.', '')}: opening on its native agent withholds nothing`,
|
||
JSON.stringify(unscoped) === JSON.stringify(withNative),
|
||
`${expected}: ${withNative.length}/${unscoped.length} skills`);
|
||
}
|
||
|
||
/* ── Switching agent cannot move the page ───────────────────────────────── */
|
||
|
||
/**
|
||
* The protected contract, at the UI seam.
|
||
*
|
||
* `AgentProvider` is handed the context id and only reads it. There is no
|
||
* setter, no navigation and no write-back, so selecting an agent cannot change
|
||
* which page the reader is on. Asserted structurally: the page's context id and
|
||
* the skills it offers are identical whichever agent is active.
|
||
*/
|
||
const movedPage = [];
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
const pageOnly = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
for (const candidate of ALL_AGENTS) {
|
||
const turn = runtime.resolveAgentForTurn(ALL_AGENTS, candidate.id, contextId);
|
||
/* The context the panel resolves is unchanged by the choice. */
|
||
if (turn.agent?.id !== candidate.id) movedPage.push(`${contextId}: ${candidate.id} was swapped`);
|
||
/* And the page still offers exactly what it offered. */
|
||
const stillOffers = reg.skillsForContext(contextId, [], []).map((s) => s.id);
|
||
if (JSON.stringify(stillOffers) !== JSON.stringify(pageOnly)) {
|
||
movedPage.push(`${contextId}: page changed under ${candidate.id}`);
|
||
}
|
||
}
|
||
}
|
||
record('choosing an agent never changes the page or what it offers',
|
||
movedPage.length === 0,
|
||
movedPage.slice(0, 3).join(' | ') || `${PAGE_CONTEXTS.length * ALL_AGENTS.length} selections, page unchanged in every one`);
|
||
|
||
/* ── The constrained state ──────────────────────────────────────────────── */
|
||
|
||
const constrainedPairs = [];
|
||
for (const contextId of PAGE_CONTEXTS) {
|
||
for (const candidate of ALL_AGENTS) {
|
||
if (runtime.agentCovers(candidate, contextId)) continue;
|
||
constrainedPairs.push([contextId, candidate]);
|
||
}
|
||
}
|
||
|
||
record('a constrained agent offers no starters',
|
||
constrainedPairs.every(([contextId, candidate]) => runtime.agentStarters(candidate, contextId).length === 0),
|
||
`${constrainedPairs.length} constrained pairs`);
|
||
|
||
/**
|
||
* A constrained agent does not answer — which is not the same as carrying no
|
||
* skills here.
|
||
*
|
||
* An earlier version of this check asserted the empty list and was wrong.
|
||
* Control Center Agent is constrained on Positions, yet it carries
|
||
* `staffing-risk`, and `staffing-risk` genuinely *is* a Positions skill. The
|
||
* list is non-empty and entirely within the page's boundary — the subset proof
|
||
* above already covers that.
|
||
*
|
||
* What actually protects the reader is that the constrained branch fires before
|
||
* any skill is consulted, so the agent declines rather than answering under a
|
||
* name that does not belong to this page. That is what is asserted here, over
|
||
* every constrained pair rather than a sample.
|
||
*/
|
||
const answeredWhileConstrained = [];
|
||
for (const [contextId, candidate] of constrainedPairs) {
|
||
const intent = routingModule.resolveIntent({
|
||
question: 'What needs my attention?',
|
||
contextId,
|
||
disabledSkills: runtime.agentScopedDisabledWith(candidate, ALL_AGENTS, ALL_SKILLS, []),
|
||
agent: candidate,
|
||
agentCoversPage: false,
|
||
agentSuggestion: runtime.defaultAgentForContext(ALL_AGENTS, contextId),
|
||
});
|
||
if (intent.kind !== 'constrained') {
|
||
answeredWhileConstrained.push(`${contextId} + ${candidate.id}: ${intent.kind}`);
|
||
}
|
||
}
|
||
record('a constrained agent declines rather than answering, on every page',
|
||
answeredWhileConstrained.length === 0,
|
||
answeredWhileConstrained.slice(0, 3).join(' | ') || `${constrainedPairs.length} constrained pairs all decline`);
|
||
|
||
/* And whatever it does carry is still inside the page's own boundary. */
|
||
record('a constrained agent still cannot exceed the page it is constrained on',
|
||
constrainedPairs.every(([contextId, candidate]) => {
|
||
const pageOnly = reg.skillsForContext(contextId, [], []).map((x) => x.id);
|
||
return reg.skillsForContext(contextId, runtime.agentScopedDisabledWith(candidate, ALL_AGENTS, ALL_SKILLS, []), [])
|
||
.every((x) => pageOnly.includes(x.id));
|
||
}));
|
||
|
||
record('every constrained pair has a native agent to point at instead',
|
||
constrainedPairs.every(([contextId]) => runtime.defaultAgentForContext(ALL_AGENTS, contextId)));
|
||
|
||
/* ── Starters merge without duplicating a suggestion ────────────────────── */
|
||
|
||
/**
|
||
* A starter worded like a skill's suggestion must yield one chip, not two.
|
||
*
|
||
* The panel de-duplicates on what a chip *resolves to* as well as its label, and
|
||
* an agent starter deliberately carries no capability — so it can never address
|
||
* a skill the page has not offered, and a duplicate wording drops out.
|
||
*/
|
||
const dupes = [];
|
||
for (const [contextId] of Object.entries(NATIVE)) {
|
||
const native = runtime.defaultAgentForContext(ALL_AGENTS, contextId);
|
||
const disabled = runtime.agentScopedDisabledWith(native, ALL_AGENTS, ALL_SKILLS, []);
|
||
const starters = runtime.agentStarters(native, contextId);
|
||
const suggestions = resolver.owliverSuggestions(contextId, disabled, [], {});
|
||
|
||
const labels = [...starters, ...suggestions].map((c) => String(c.label).trim().toLowerCase());
|
||
const unique = new Set(labels);
|
||
if (labels.length !== unique.size) {
|
||
/* Not a failure in itself — the panel drops the repeat — but it is worth
|
||
knowing which wordings collide. */
|
||
dupes.push(`${contextId}: ${labels.length - unique.size}`);
|
||
}
|
||
}
|
||
record('agent starters carry no capability, so they cannot address an unoffered skill',
|
||
ALL_AGENTS.every((a) => runtime.agentStarters(a, null).every((s) => s.capability === null)),
|
||
dupes.length ? `overlapping wordings de-duplicated on: ${dupes.join(', ')}` : 'no overlapping wordings');
|
||
|
||
/**
|
||
* Every destination the switcher offers must exist.
|
||
*
|
||
* This check exists because it did not, and a visual review found two footer
|
||
* actions navigating to routes the router had no entry for. Tests covered what
|
||
* the switcher *computed* and nothing about where it *sent* the reader, which
|
||
* is exactly the gap a 404 lives in.
|
||
*
|
||
* Read out of `App.jsx` rather than asserted against a written list, so a route
|
||
* that is renamed or removed fails here rather than in someone's browser.
|
||
*/
|
||
const appSource = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8');
|
||
|
||
/**
|
||
* The switcher, if there still is one.
|
||
*
|
||
* `AgentSwitcher.jsx` was removed when the panel took over agent selection, and
|
||
* these two checks read its source to assert properties *of that file*. A
|
||
* deleted file has no dead routes and no configure-specific branch, so the
|
||
* checks below are vacuously satisfied — but reading it unconditionally threw
|
||
* ENOENT and stopped the whole run here, taking every section after it with it.
|
||
* Absent is a state to report, not to crash on.
|
||
*/
|
||
const SWITCHER_PATH = join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentSwitcher.jsx'));
|
||
const switcherSource = existsSync(SWITCHER_PATH) ? readFileSync(SWITCHER_PATH, 'utf8') : null;
|
||
|
||
/* Only live navigations count: a disabled control goes nowhere by design. */
|
||
const navigated = switcherSource
|
||
? [...switcherSource.matchAll(/navigate\('([^']+)'\)/g)].map((m) => m[1])
|
||
: [];
|
||
|
||
const routed = new Set(
|
||
[...appSource.matchAll(/<Route\s+path="([^"]+)"/g)]
|
||
.map((m) => m[1])
|
||
.filter((path) => path !== '*')
|
||
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`))
|
||
);
|
||
|
||
const dead = navigated.filter((to) => !routed.has(to));
|
||
record('every route the agent switcher navigates to exists',
|
||
dead.length === 0,
|
||
!switcherSource ? 'no switcher component — nothing to navigate'
|
||
: dead.length ? `DEAD: ${dead.join(', ')}`
|
||
: `${navigated.length} destination(s): ${navigated.join(', ')}`);
|
||
|
||
/**
|
||
* Authoring is reachable, and its screen exists.
|
||
*
|
||
* This asked about the SWITCHER, which no longer exists. The panel header used
|
||
* to open a popover listing every agent, Browse all and Create agent; it was
|
||
* deliberately reduced to a label, because it was a second place to decide
|
||
* which agent answers — competing with the editor, and letting a reader put
|
||
* Owliver into a state nothing on the page explained. See AgentBadge.jsx.
|
||
*
|
||
* Creating an agent moved with it, to the Agents list, which is where
|
||
* AgentBadge's own note says this belongs. The property is unchanged and worth
|
||
* keeping — offered somewhere, and the route resolves — so it is asserted
|
||
* against where the control actually is.
|
||
*/
|
||
const agentsListSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/WorkspaceAgents.jsx')), 'utf8');
|
||
record('creating an agent is reachable, and its screen exists',
|
||
/navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource)
|
||
&& routed.has('/admin/workspace/agents/new'),
|
||
'offered on the Agents list, and the route resolves');
|
||
|
||
record('every published agent offers at least one starter where it applies',
|
||
ALL_AGENTS.filter((a) => a.status === 'published')
|
||
.every((a) => a.pages.length === 0 || runtime.agentStarters(a, null).length > 0),
|
||
ALL_AGENTS.map((a) => `${a.id}:${a.starters.length}`).join(' '));
|
||
|
||
/* ── Removing an agent ───────────────────────────────────────────────────
|
||
*
|
||
* Removal is permanent, and the two things that make it safe are both
|
||
* structural rather than visible: WHICH agents offer it, and that archiving
|
||
* never quietly becomes it.
|
||
*
|
||
* The menu is duplicated between the card view and the table row — two blocks
|
||
* of the same JSX — so every property here is asserted against BOTH. A guard
|
||
* added to one and forgotten in the other is exactly the shape of bug that
|
||
* would ship a delete button on a built-in agent.
|
||
*
|
||
* `shipped` is the source of truth, from the ids this build bundles. Nothing
|
||
* here reads a display name: two agents may legitimately share one.
|
||
*/
|
||
const removeItems = agentsListSource.match(
|
||
/onAction\('remove', agent\)[\s\S]{0,400}?<\/DropdownMenuItem>/g) || [];
|
||
record('both agent menus offer removal, and neither offers it unconditionally',
|
||
removeItems.length === 2
|
||
&& (agentsListSource.match(/\{\(overridden \|\| !shipped\) && \(/g) || []).length === 2,
|
||
`${removeItems.length} remove item(s), each behind (overridden || !shipped)`);
|
||
|
||
record('a custom agent is offered Remove, and a built-in one is only offered a revert',
|
||
removeItems.every((block) => / Remove</.test(block) && /Revert to shipped/.test(block)
|
||
&& /shipped\s*\?/.test(block)),
|
||
'the label is chosen by `shipped`, not by name');
|
||
|
||
/* Archive is reversible; Remove is not. The menu must not make removal depend
|
||
on status, or an archived custom agent becomes unremovable — which would
|
||
silently turn Archive into the only exit and leave the row forever. */
|
||
record('removal does not depend on an agent status, so an archived custom agent stays removable',
|
||
removeItems.every((block) => !/status/.test(block)),
|
||
'the remove item reads `shipped`/`overridden` only');
|
||
|
||
/* Removal is REVERSIBLE, and the confirmation is where a reader learns that.
|
||
Copy that promised permanence would be a false statement about what the
|
||
button does — the agent is archived, not destroyed — so the wording is
|
||
asserted, including the absence of the two phrases that would be lies. */
|
||
record('removing a custom agent confirms first, in words that say it is reversible',
|
||
/'Remove agent\?'/.test(agentsListSource)
|
||
&& /This agent will be removed from the active Agents list\./.test(agentsListSource)
|
||
&& /can be restored later from Archived/.test(agentsListSource)
|
||
&& /'Remove Agent'/.test(agentsListSource),
|
||
'title, description and confirm label');
|
||
|
||
record('...and the confirmation never claims the removal is permanent',
|
||
!/cannot be undone/i.test(agentsListSource)
|
||
&& !/permanently (remove|delete)/i.test(agentsListSource),
|
||
'no irreversibility language anywhere on the screen');
|
||
|
||
/* The substance behind that copy: Remove on an authored agent runs ARCHIVE.
|
||
Only a shipped agent's own override is deleted, and deleting an override is
|
||
how "Revert to shipped" restores the product's definition — it destroys no
|
||
agent. If this resolution is ever removed, Remove becomes a hard delete
|
||
again and every promise above becomes false. */
|
||
record('Remove archives an authored agent rather than deleting it',
|
||
/const resolved = action === 'remove' && !isShipped\(agent\.id\) \? 'archive' : action;/
|
||
.test(agentsListSource),
|
||
'run() resolves remove -> archive for anything not shipped');
|
||
|
||
record('...and the default list stops showing what has been removed',
|
||
/useState\('active'\)/.test(agentsListSource)
|
||
&& /if \(status === 'active'\) return found\.filter\(\(a\) => a\.status !== 'archived'\);/
|
||
.test(agentsListSource),
|
||
'the default filter is active, which excludes archived');
|
||
|
||
record('...while Archived stays reachable as the place removal is undone',
|
||
/setStatus\('archived'\)/.test(agentsListSource)
|
||
&& /onAction\('restore', agent\)/.test(agentsListSource),
|
||
'the Archived tab and Restore as draft both survive');
|
||
|
||
/* The tab must be there BEFORE anything is in it. It was rendered behind
|
||
`archivedCount > 0`, so the one screen that says where a removed agent went
|
||
only appeared once a reader had already removed one and gone looking — and
|
||
the confirmation names "Archived" while no such tab is on screen. */
|
||
record('...and Archived is offered even when it is empty',
|
||
!/\{archivedCount > 0 && \(/.test(agentsListSource)
|
||
&& /Archived <span[^>]*>\(\{archivedCount\}\)/.test(agentsListSource),
|
||
'the tab is unconditional and shows its count');
|
||
|
||
record('...and reverting a built-in agent is not worded as a removal',
|
||
/'Revert to the shipped definition\?'/.test(agentsListSource)
|
||
&& /isShipped\(confirming\?\.agent\?\.id\) \? 'Confirm' : 'Remove Agent'/.test(agentsListSource),
|
||
'the shipped path keeps its own copy');
|
||
|
||
record('a removal that fails is reported rather than swallowed',
|
||
/if \(result\?\.error\) toast\.error\(result\.error\)/.test(agentsListSource),
|
||
'run() surfaces the error the mutation returned');
|
||
|
||
/* The gate itself, exercised rather than read: every id this build ships is
|
||
`shipped`, so none of them reaches the branch that renders Remove. This is
|
||
the same set `useAgents().isShipped` tests against — `AGENTS`, bundled from
|
||
the agent definitions under src/agents — so the check moves with the
|
||
product. */
|
||
const shippedAgentIds = new Set(ALL_AGENTS.map((a) => a.id));
|
||
record('every agent this build ships is classified as built-in',
|
||
ALL_AGENTS.every((a) => shippedAgentIds.has(a.id)),
|
||
`${shippedAgentIds.size} shipped id(s)`);
|
||
record('...and an authored agent is not, whatever its status',
|
||
['draft', 'published', 'archived'].every((status) => {
|
||
const id = `authored-${status}-agent`;
|
||
return !shippedAgentIds.has(id);
|
||
}),
|
||
'draft, published and archived authored ids are all removable');
|
||
|
||
/* ── Remove and restore, exercised end to end ────────────────────────────
|
||
*
|
||
* The claim the confirmation makes — "its configuration and skills will be
|
||
* preserved and it can be rmRestored later" — is a claim about these two
|
||
* functions, so it is tested against them rather than against the sentence.
|
||
*
|
||
* Both are pure over Markdown and the Markdown is the authoritative artefact,
|
||
* so "nothing was lost" is checkable exactly: the source may differ in the
|
||
* status line and in nothing else, and the parsed agent may differ in `status`
|
||
* and in nothing else. A patch that dropped a tool, renumbered a version or
|
||
* rewrote an id would fail here rather than in somebody's account.
|
||
*/
|
||
const rmLifecycle = await server.ssrLoadModule('/src/lib/agents/agentLifecycle.js');
|
||
|
||
const rmAuthored = [
|
||
'---',
|
||
'id: coverage-helper',
|
||
'name: Coverage Helper',
|
||
'description: Finds and offers cover for open shifts.',
|
||
'status: published',
|
||
'version: 3',
|
||
'pages:',
|
||
' - control-center',
|
||
' - positions',
|
||
'tools:',
|
||
' - workforce.coverage',
|
||
'skills:',
|
||
' - create-position',
|
||
'---',
|
||
'',
|
||
'## Instructions',
|
||
'Find the shifts nobody has taken and say who could take them.',
|
||
].join('\n');
|
||
|
||
const rmStatusless = (src) => src.replace(/^status:.*$/m, 'status: <redacted>');
|
||
/* `markdown` is excluded because it IS the source, status line and all — the
|
||
line above already asserts that source byte-for-byte, so comparing it again
|
||
here would only re-report the one difference both checks expect. Every other
|
||
parsed field, including tools, skills, pages, instructions and version, is
|
||
compared. */
|
||
const rmWithoutStatus = (agent) => {
|
||
const { status, markdown, ...rest } = agent;
|
||
return JSON.stringify(rest);
|
||
};
|
||
|
||
const rmRemoved = rmLifecycle.archiveAgent(rmAuthored);
|
||
const rmRestored = rmLifecycle.restoreAgent(rmRemoved);
|
||
|
||
const rmBefore = agentReg.parseAgent(rmAuthored, { custom: true });
|
||
const rmAfterRemove = agentReg.parseAgent(rmRemoved, { custom: true });
|
||
const rmAfterRestore = agentReg.parseAgent(rmRestored, { custom: true });
|
||
|
||
record('remove: an authored agent becomes archived rather than disappearing',
|
||
rmAfterRemove.status === 'archived',
|
||
`status ${rmBefore.status} -> ${rmAfterRemove.status}`);
|
||
|
||
record('remove: the definition keeps its identity',
|
||
rmAfterRemove.id === rmBefore.id && rmAfterRemove.id === 'coverage-helper',
|
||
rmAfterRemove.id);
|
||
|
||
record('remove: instructions, tools, skills, pages and version all survive',
|
||
rmStatusless(rmRemoved) === rmStatusless(rmAuthored)
|
||
&& rmWithoutStatus(rmAfterRemove) === rmWithoutStatus(rmBefore),
|
||
'the source differs in the status line and nothing else');
|
||
|
||
record('restore: the SAME agent comes back, as a draft',
|
||
rmAfterRestore.id === rmBefore.id && rmAfterRestore.status === 'draft',
|
||
`${rmAfterRestore.id} @ ${rmAfterRestore.status}`);
|
||
|
||
record('restore: nothing was lost on the round trip',
|
||
rmStatusless(rmRestored) === rmStatusless(rmAuthored)
|
||
&& rmWithoutStatus(rmAfterRestore) === rmWithoutStatus(rmBefore),
|
||
'archive -> restore is lossless apart from status');
|
||
|
||
/* Removing something already archived must not escalate into anything else:
|
||
it is still that same archived definition afterwards. */
|
||
const rmRemovedTwice = rmLifecycle.archiveAgent(rmRemoved);
|
||
record('remove: removing an already-archived agent leaves it archived and whole',
|
||
agentReg.parseAgent(rmRemovedTwice, { custom: true }).status === 'archived'
|
||
&& rmStatusless(rmRemovedTwice) === rmStatusless(rmAuthored),
|
||
'idempotent, and still non-destructive');
|
||
|
||
/* ── 18. Conversation records and insights ───────────────────────────────
|
||
*
|
||
* Conversations gained fields: which agent answered, where, what it used, and
|
||
* how it was rated. The risk in changing a stored shape is not that the new
|
||
* records are wrong — it is that the old ones quietly stop reading, and a
|
||
* reader's history disappears without anything saying so.
|
||
*
|
||
* So the migration is asserted first, and the figures built on top are asserted
|
||
* to describe only records that exist.
|
||
*/
|
||
console.log('\n── Conversation records and insights ──');
|
||
|
||
const insightsSelectors = await server.ssrLoadModule('/src/lib/agents/conversationInsights.js');
|
||
const historyModule = await server.ssrLoadModule('/src/components/ai-assistant/history.js');
|
||
|
||
/* `history.js` writes to localStorage, which does not exist under SSR. A
|
||
minimal in-memory stand-in lets the real module be exercised rather than a
|
||
reimplementation of it — the migration is the thing under test, and a mock of
|
||
it would reproduce none of the failures this section exists for. */
|
||
const historyStore = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (historyStore.has(k) ? historyStore.get(k) : null),
|
||
setItem: (k, v) => historyStore.set(k, String(v)),
|
||
removeItem: (k) => historyStore.delete(k),
|
||
clear: () => historyStore.clear(),
|
||
};
|
||
|
||
const HISTORY_KEY = 'krow_assistant:history';
|
||
const seedHistory = (records) => historyStore.set(HISTORY_KEY, JSON.stringify(records));
|
||
|
||
/* ── A conversation held before any of this existed ──────────────────────── */
|
||
|
||
const V1_RECORD = {
|
||
id: 'c_old',
|
||
contextId: 'admin.positions',
|
||
page: 'Positions',
|
||
title: 'Which positions need attention?',
|
||
turns: 2,
|
||
updatedAt: new Date().toISOString(),
|
||
messages: [
|
||
{ role: 'user', text: 'Which positions need attention?' },
|
||
{ role: 'assistant', blocks: [{ type: 'text', text: 'Three roles need attention.' }] },
|
||
],
|
||
};
|
||
|
||
seedHistory([V1_RECORD]);
|
||
const migrated = historyModule.readHistory();
|
||
|
||
record('a conversation stored before agents existed still reads',
|
||
migrated.length === 1 && migrated[0].id === 'c_old',
|
||
`${migrated.length} record(s)`);
|
||
|
||
record('...and its messages are returned byte-identical',
|
||
JSON.stringify(migrated[0].messages) === JSON.stringify(V1_RECORD.messages));
|
||
|
||
record('...with the new fields present and honestly empty',
|
||
migrated[0].schema === 2
|
||
&& migrated[0].agentId === null
|
||
&& migrated[0].feedback === null
|
||
&& Array.isArray(migrated[0].skillsUsed) && migrated[0].skillsUsed.length === 0,
|
||
JSON.stringify({
|
||
schema: migrated[0].schema, agentId: migrated[0].agentId, skills: migrated[0].skillsUsed,
|
||
}));
|
||
|
||
record('migration does not rewrite the stored copy',
|
||
JSON.parse(historyStore.get(HISTORY_KEY))[0].schema === undefined,
|
||
'read-time migration, so nothing can fail half-written');
|
||
|
||
/* ── A conversation held now ─────────────────────────────────────────────── */
|
||
|
||
historyStore.clear();
|
||
historyModule.saveConversation({
|
||
id: 'c_new',
|
||
contextId: 'admin.positions',
|
||
page: 'Positions',
|
||
agentId: 'positions-agent',
|
||
pageContext: { page: 'Positions', pageKey: 'positions', route: '/admin/positions', period: null },
|
||
skillsUsed: ['staffing-risk', 'staffing-risk'],
|
||
toolsUsed: ['create_position'],
|
||
knowledgeUsed: [],
|
||
messages: [
|
||
{ role: 'user', text: 'Which roles are at risk?' },
|
||
{ role: 'assistant', blocks: [{ type: 'text', text: 'Three.' }] },
|
||
],
|
||
});
|
||
|
||
const [savedConversation] = historyModule.readHistory();
|
||
|
||
record('a conversation records which agent answered',
|
||
savedConversation.agentId === 'positions-agent', savedConversation.agentId);
|
||
|
||
record('...what it used, deduplicated',
|
||
JSON.stringify(savedConversation.skillsUsed) === JSON.stringify(['staffing-risk'])
|
||
&& JSON.stringify(savedConversation.toolsUsed) === JSON.stringify(['create_position']),
|
||
`${JSON.stringify(savedConversation.skillsUsed)} / ${JSON.stringify(savedConversation.toolsUsed)}`);
|
||
|
||
/**
|
||
* The stored context is the *reduced* envelope.
|
||
*
|
||
* Selections and computed figures are records; writing them per turn would put
|
||
* the dataset into localStorage a message at a time. What a reviewer needs
|
||
* later is where the question was asked, not a copy of what was on screen.
|
||
*/
|
||
record('the stored page context carries no records',
|
||
!('selectedItems' in savedConversation.pageContext) && !('metrics' in savedConversation.pageContext)
|
||
&& !('position' in savedConversation.pageContext) && savedConversation.pageContext.pageKey === 'positions',
|
||
Object.keys(savedConversation.pageContext).join(', '));
|
||
|
||
/* ── Feedback ───────────────────────────────────────────────────────────── */
|
||
|
||
historyModule.recordFeedback('c_new', { rating: 'up' });
|
||
record('a conversation can be rated', historyModule.readHistory()[0].feedback?.rating === 'up');
|
||
|
||
historyModule.recordFeedback('c_new', { rating: 'down', note: 'Missed the chef role' });
|
||
const rerated = historyModule.readHistory();
|
||
record('re-rating corrects rather than appends',
|
||
rerated.length === 1 && rerated[0].feedback.rating === 'down' && rerated[0].feedback.note === 'Missed the chef role',
|
||
`${rerated.length} record(s), rating=${rerated[0].feedback.rating}`);
|
||
|
||
historyModule.recordFeedback('c_new', null);
|
||
record('clearing a rating leaves none behind, not a neutral one',
|
||
historyModule.readHistory()[0].feedback === null);
|
||
|
||
record('rating a conversation that does not exist changes nothing',
|
||
historyModule.recordFeedback('c_nope', { rating: 'up' }).length === 1);
|
||
|
||
/* A rating survives the thread growing. */
|
||
historyModule.recordFeedback('c_new', { rating: 'up' });
|
||
historyModule.saveConversation({
|
||
id: 'c_new',
|
||
contextId: 'admin.positions',
|
||
page: 'Positions',
|
||
agentId: 'positions-agent',
|
||
messages: [
|
||
{ role: 'user', text: 'Which roles are at risk?' },
|
||
{ role: 'assistant', blocks: [{ type: 'text', text: 'Three.' }] },
|
||
{ role: 'user', text: 'And the chef role?' },
|
||
],
|
||
});
|
||
record('a rating survives the conversation continuing',
|
||
historyModule.readHistory()[0].feedback?.rating === 'up'
|
||
&& historyModule.readHistory()[0].turns === 2,
|
||
`rating kept across ${historyModule.readHistory()[0].turns} turns`);
|
||
|
||
/* ── Insight selectors ──────────────────────────────────────────────────── */
|
||
|
||
const nowMs = Date.now();
|
||
const day = (n) => new Date(nowMs - n * 86400000).toISOString();
|
||
|
||
const FIXTURE = [
|
||
{ id: 'a1', agentId: 'positions-agent', contextId: 'admin.positions', page: 'Positions', turns: 3, skillsUsed: ['staffing-risk'], toolsUsed: [], feedback: { rating: 'up' }, updatedAt: day(0), messages: [{ role: 'user', text: 'x' }] },
|
||
{ id: 'a2', agentId: 'positions-agent', contextId: 'admin.positions', page: 'Positions', turns: 1, skillsUsed: ['staffing-risk', 'create-position'], toolsUsed: ['create_position'], feedback: { rating: 'down' }, updatedAt: day(1), messages: [{ role: 'user', text: 'x' }] },
|
||
{ id: 'a3', agentId: 'analytics-agent', contextId: 'admin.analytics', page: 'Analytics', turns: 2, skillsUsed: ['overtime-analysis'], toolsUsed: [], feedback: null, updatedAt: day(1), messages: [{ role: 'user', text: 'x' }] },
|
||
];
|
||
|
||
/**
|
||
* Nothing recorded is a different answer from nothing happening.
|
||
*
|
||
* A row of zeros reads as "the agent was asked and did nothing". The empty flag
|
||
* is what lets a view say "not asked yet" instead — which is the whole reason
|
||
* Insights can be honest before any conversation exists.
|
||
*/
|
||
const none = insightsSelectors.conversationStats([]);
|
||
record('no conversations reports empty rather than zeros',
|
||
none.empty === true && none.total === 0 && none.feedback.score === null,
|
||
`score=${none.feedback.score} (null, not 0)`);
|
||
|
||
const stats = insightsSelectors.conversationStats(FIXTURE);
|
||
record('conversation counts match the records', stats.total === 3 && stats.turns === 6,
|
||
`${stats.total} conversations, ${stats.turns} turns`);
|
||
|
||
record('pages counted are the distinct contexts', stats.pages === 2, `${stats.pages} pages`);
|
||
|
||
record('feedback counts every rating and every absence',
|
||
stats.feedback.up === 1 && stats.feedback.down === 1 && stats.feedback.unrated === 1
|
||
&& stats.feedback.score === 50,
|
||
JSON.stringify(stats.feedback));
|
||
|
||
record('skills are tallied across conversations, most used first',
|
||
JSON.stringify(stats.bySkill) === JSON.stringify([
|
||
{ id: 'staffing-risk', count: 2 }, { id: 'create-position', count: 1 }, { id: 'overtime-analysis', count: 1 },
|
||
]),
|
||
JSON.stringify(stats.bySkill));
|
||
|
||
record('tools are tallied too',
|
||
JSON.stringify(stats.byTool) === JSON.stringify([{ id: 'create_position', count: 1 }]));
|
||
|
||
record('conversations are grouped per agent',
|
||
JSON.stringify(stats.byAgent) === JSON.stringify([
|
||
{ id: 'positions-agent', count: 2 }, { id: 'analytics-agent', count: 1 },
|
||
]));
|
||
|
||
record('a day nothing was asked is not charted as a zero',
|
||
stats.byDay.length === stats.activeDays && stats.byDay.every((d) => d.count > 0),
|
||
`${stats.byDay.length} active day(s)`);
|
||
|
||
record('stats can be scoped to one agent',
|
||
insightsSelectors.conversationStats(FIXTURE, { agentId: 'analytics-agent' }).total === 1);
|
||
|
||
record('an agent with no conversations reports empty, not zero',
|
||
insightsSelectors.conversationStats(FIXTURE, { agentId: 'activity-agent' }).empty === true);
|
||
|
||
record('stats can be windowed by date',
|
||
insightsSelectors.conversationStats(FIXTURE, { since: day(0.5) }).total === 1,
|
||
`${insightsSelectors.conversationStats(FIXTURE, { since: day(0.5) }).total} in the last 12 hours`);
|
||
|
||
record('conversationsForAgent narrows without mutating the input',
|
||
insightsSelectors.conversationsForAgent(FIXTURE, 'positions-agent').length === 2
|
||
&& FIXTURE.length === 3);
|
||
|
||
record('unrated conversations are the review queue',
|
||
insightsSelectors.unratedConversations(FIXTURE).map((r) => r.id).join(',') === 'a3');
|
||
|
||
/**
|
||
* A review row carries no thread.
|
||
*
|
||
* A list renders forty of these and one is ever opened; including the messages
|
||
* would load every conversation to draw a table.
|
||
*/
|
||
const row = insightsSelectors.reviewRow(FIXTURE[0]);
|
||
record('a review row omits the thread itself',
|
||
!('messages' in row) && row.id === 'a1' && row.page === 'Positions',
|
||
Object.keys(row).join(', '));
|
||
|
||
/* ── Nothing here invents a figure ──────────────────────────────────────── */
|
||
|
||
const invented = [];
|
||
for (const key of ['total', 'turns', 'pages']) {
|
||
if (insightsSelectors.conversationStats([])[key] !== 0) invented.push(key);
|
||
}
|
||
for (const entry of [...stats.bySkill, ...stats.byTool, ...stats.byAgent]) {
|
||
const real = FIXTURE.some((r) => [...r.skillsUsed, ...r.toolsUsed, r.agentId].includes(entry.id));
|
||
if (!real) invented.push(entry.id);
|
||
}
|
||
record('every figure traces to a record that exists',
|
||
invented.length === 0, invented.join(', ') || 'nothing invented');
|
||
|
||
delete globalThis.localStorage;
|
||
|
||
/* ── 19. Agent management ─────────────────────────────────────────────────
|
||
*
|
||
* The management screens let someone who has never seen a Markdown file
|
||
* create, configure and publish an agent. What makes that safe is that they are
|
||
* not a second agent system: every screen writes fields, `agentPatch` turns
|
||
* those into frontmatter, and the existing parser reads them back.
|
||
*
|
||
* So what is checked here is the round trip — a form edit must survive being
|
||
* written and re-read — and the lifecycle rules that protect a published agent.
|
||
*/
|
||
console.log('\n── Agent management ──');
|
||
|
||
const lifecycle = await server.ssrLoadModule('/src/lib/agents/agentLifecycle.js');
|
||
|
||
const shippedSource = agentReg.getAgent(ALL_AGENTS, 'positions-agent').markdown;
|
||
|
||
/* ── Fields survive the round trip ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* The property the whole management UI rests on.
|
||
*
|
||
* A form holds fields; storage holds Markdown. If a field could not survive
|
||
* being written and read back, configuring an agent would silently lose part of
|
||
* it — and the loss would only show up later, in an answer that did not happen.
|
||
*/
|
||
const EDITS = {
|
||
name: 'Renamed Positions Agent',
|
||
description: 'A different description.',
|
||
trigger: 'Use when roles are not filling.',
|
||
instructions: 'Answer about open roles only.\n\nAsk which role when none is open.',
|
||
icon: 'briefcase',
|
||
reasoning: 'deep',
|
||
webSearch: true,
|
||
pages: ['positions', 'control-center'],
|
||
skills: ['staffing-risk', 'create-position'],
|
||
subagents: ['analytics-agent'],
|
||
starters: [{ label: 'Which roles are at risk?', prompt: 'Which roles are at risk?' }],
|
||
knowledge: [{ id: 'policy', label: 'Fill policy', kind: 'note', body: 'A role open 30 days is escalated.', url: '' }],
|
||
permissions: { owner: 'demo@krow.app', access: 'specific', people: [{ user: 'a@krow.app', role: 'editor' }] },
|
||
};
|
||
|
||
const composed = agentFields.applyAgentFields(shippedSource, EDITS);
|
||
const readBack = agentFields.agentFieldsFromSource(composed);
|
||
|
||
const lost = [];
|
||
for (const [key, value] of Object.entries(EDITS)) {
|
||
if (JSON.stringify(readBack[key]) !== JSON.stringify(value)) {
|
||
lost.push(`${key}: wrote ${JSON.stringify(value)}, read ${JSON.stringify(readBack[key])}`);
|
||
}
|
||
}
|
||
record('every configurable field survives being written and read back',
|
||
lost.length === 0, lost.slice(0, 2).join(' | ') || `${Object.keys(EDITS).length} fields`);
|
||
|
||
record('the composed definition is valid',
|
||
agentReg.validateAgentSource(composed) === null,
|
||
agentReg.validateAgentSource(composed) || 'ok');
|
||
|
||
record('configuring an agent never has to touch Markdown',
|
||
/^---/.test(composed) && agentReg.parseAgent(composed, { custom: true }).name === EDITS.name,
|
||
'fields in, frontmatter out, parsed by the one parser');
|
||
|
||
/* Editing one field leaves the rest of the file alone, including its prose. */
|
||
const oneField = agentFields.applyAgentFields(shippedSource, { description: 'Just this.' });
|
||
const before8 = agentReg.parseAgent(shippedSource, { custom: true });
|
||
const after8 = agentReg.parseAgent(oneField, { custom: true });
|
||
record('editing one field leaves the others untouched',
|
||
after8.description === 'Just this.'
|
||
&& JSON.stringify(after8.skills) === JSON.stringify(before8.skills)
|
||
&& after8.instructions === before8.instructions,
|
||
`${after8.skills.length} skills and the instructions kept`);
|
||
|
||
/* ── Lifecycle ──────────────────────────────────────────────────────────── */
|
||
|
||
record('a duplicate is always a draft at v1',
|
||
(() => {
|
||
const copy = agentReg.parseAgent(
|
||
lifecycle.duplicateAgent(shippedSource, { existingIds: ALL_AGENTS.map((a) => a.id) }),
|
||
{ custom: true }
|
||
);
|
||
return copy.status === 'draft' && copy.version === 1 && copy.id !== 'positions-agent';
|
||
})(),
|
||
'a copy of a published agent must not enter the switcher unreviewed');
|
||
|
||
record('a duplicate keeps what the original carried',
|
||
(() => {
|
||
const copy = agentReg.parseAgent(
|
||
lifecycle.duplicateAgent(shippedSource, { existingIds: [] }), { custom: true }
|
||
);
|
||
return JSON.stringify(copy.skills) === JSON.stringify(before8.skills)
|
||
&& copy.instructions === before8.instructions;
|
||
})());
|
||
|
||
record('a duplicate never collides with an existing id',
|
||
(() => {
|
||
const taken = ALL_AGENTS.map((a) => a.id);
|
||
const first = agentReg.parseAgent(lifecycle.duplicateAgent(shippedSource, { existingIds: taken }), { custom: true });
|
||
const second = agentReg.parseAgent(
|
||
lifecycle.duplicateAgent(shippedSource, { existingIds: [...taken, first.id] }), { custom: true }
|
||
);
|
||
return first.id !== second.id;
|
||
})());
|
||
|
||
record('archiving takes an agent out of service without altering it',
|
||
(() => {
|
||
const archived = agentReg.parseAgent(lifecycle.archiveAgent(shippedSource), { custom: true });
|
||
return archived.status === 'archived'
|
||
&& JSON.stringify(archived.skills) === JSON.stringify(before8.skills);
|
||
})());
|
||
|
||
record('restoring brings it back as a draft, not straight back into service',
|
||
agentReg.parseAgent(lifecycle.restoreAgent(lifecycle.archiveAgent(shippedSource)), { custom: true })
|
||
.status === 'draft');
|
||
|
||
/**
|
||
* Publishing must never discard a version somebody else published.
|
||
*
|
||
* The failure it prevents is silent: a draft taken from v1 published over a v2
|
||
* looks exactly like the v2 change never having been made.
|
||
*/
|
||
record('publishing a draft moves it into service',
|
||
(() => {
|
||
const draft = lifecycle.restoreAgent(shippedSource);
|
||
const out = lifecycle.publishAgent(draft);
|
||
return !out.conflict && agentReg.parseAgent(out.source, { custom: true }).status === 'published';
|
||
})());
|
||
|
||
record('republishing a published agent moves its version on',
|
||
(() => {
|
||
const out = lifecycle.publishAgent(shippedSource);
|
||
return agentReg.parseAgent(out.source, { custom: true }).version === before8.version + 1;
|
||
})(),
|
||
'so "what is live" is always a specific version');
|
||
|
||
record('publishing over a newer version is refused, not silently applied',
|
||
(() => {
|
||
const out = lifecycle.publishAgent(lifecycle.restoreAgent(shippedSource), { publishedVersion: 5 });
|
||
return Boolean(out.conflict) && !out.source;
|
||
})(),
|
||
'a conflict the screen can explain, rather than a lost change');
|
||
|
||
/* ── An agent created from nothing ──────────────────────────────────────── */
|
||
|
||
/**
|
||
* The path a non-technical author actually takes: a blank template, filled in
|
||
* through the form, saved. It has to produce a definition the runtime accepts.
|
||
*/
|
||
const fresh = agentFields.applyAgentFields(customAgents.agentTemplate(), {
|
||
id: 'hr-helper',
|
||
name: 'HR Helper',
|
||
description: 'Answers hiring questions for the HR team.',
|
||
trigger: 'Use on Candidates for pipeline questions.',
|
||
instructions: 'Answer from candidate records on this page.',
|
||
icon: 'users',
|
||
reasoning: 'balanced',
|
||
pages: ['candidates'],
|
||
skills: ['candidate-analysis'],
|
||
starters: [{ label: 'How strong is the pool?', prompt: 'How strong is the pool?' }],
|
||
});
|
||
|
||
record('an agent created entirely through the form is valid',
|
||
agentReg.validateAgentSource(fresh) === null,
|
||
agentReg.validateAgentSource(fresh) || 'ok');
|
||
|
||
const freshAgent = agentReg.parseAgent(fresh, { custom: true });
|
||
record('...and starts as a draft rather than live',
|
||
freshAgent.status === 'draft', freshAgent.status);
|
||
|
||
record('...and registers alongside the shipped ones',
|
||
(() => {
|
||
const { agents, diagnostics } = agentReg.readAgentRegistry([{ path: 'custom/hr-helper.md', raw: fresh }]);
|
||
return agents.some((a) => a.id === 'hr-helper') && diagnostics.length === 0;
|
||
})(),
|
||
'no diagnostics, so nothing it declared was dropped');
|
||
|
||
record('...and is bounded by the page exactly like a shipped agent',
|
||
(() => {
|
||
const disabled = runtime.agentScopedDisabledWith(freshAgent, ALL_AGENTS, ALL_SKILLS, []);
|
||
const pageOnly = reg.skillsForContext('admin.candidatesList', [], []).map((s) => s.id);
|
||
const scoped = reg.skillsForContext('admin.candidatesList', disabled, []).map((s) => s.id);
|
||
/* And it reaches nothing at all on a page it does not cover. */
|
||
const elsewhere = reg.skillsForContext('admin.analytics', disabled, []).map((s) => s.id);
|
||
return scoped.every((x) => pageOnly.includes(x)) && elsewhere.length === 0;
|
||
})(),
|
||
'a user-created agent gets the same boundary, not a weaker one');
|
||
|
||
/* ── Editing a shipped agent overrides rather than mutates ───────────────── */
|
||
|
||
record('editing a shipped agent is reported as an override',
|
||
(() => {
|
||
const { diagnostics } = agentReg.readAgentRegistry([{ path: 'custom/positions-agent.md', raw: composed }]);
|
||
return diagnostics.some((d) => d.kind === 'shadowed' && d.agentId === 'positions-agent');
|
||
})(),
|
||
'the shipped definition is never altered on disk');
|
||
|
||
record('the shipped definition is still intact after an override',
|
||
agentReg.AGENTS.find((a) => a.id === 'positions-agent').name === 'Positions Agent');
|
||
|
||
/* ── The Add Skills modal reads the one registry ────────────────────────── */
|
||
|
||
/**
|
||
* Asserted against the registry rather than the component, because the failure
|
||
* worth preventing is architectural: a separate list for agents would drift
|
||
* from the one Owliver runs, and an agent would offer a skill the runtime does
|
||
* not have.
|
||
*/
|
||
const attachable = reg.skillsWithFacet(reg.allSkills([]), 'owliver').filter((s) => s.status === 'active');
|
||
record('every attachable skill comes from the shared registry',
|
||
attachable.every((s) => reg.SKILLS.some((r) => r.id === s.id)),
|
||
`${attachable.length} attachable`);
|
||
|
||
record('workforce training paths are not offered as agent skills',
|
||
attachable.every((s) => s.kind !== 'workforce'),
|
||
'a training path is something a person learns, not something an agent does');
|
||
|
||
record('every category offered by the picker matches at least one skill',
|
||
(() => {
|
||
const categories = [...new Set(attachable.map((s) => s.category).filter(Boolean))];
|
||
return categories.every((c) => attachable.some((s) => s.category === c));
|
||
})(),
|
||
[...new Set(attachable.map((s) => s.category).filter(Boolean))].join(', '));
|
||
|
||
/* ── Every management destination exists ────────────────────────────────── */
|
||
|
||
const managementRoutes = ['/admin/workspace/agents', '/admin/workspace/agents/new'];
|
||
const appRoutes = new Set(
|
||
[...readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8').matchAll(/<Route\s+path="([^"]+)"/g)]
|
||
.map((m) => m[1])
|
||
.map((path) => (path.startsWith('/') ? path : `/admin/${path}`))
|
||
);
|
||
record('every agent management route is registered',
|
||
managementRoutes.every((r) => appRoutes.has(r)),
|
||
managementRoutes.filter((r) => !appRoutes.has(r)).join(', ') || managementRoutes.join(', '));
|
||
|
||
/**
|
||
* Every surface a skill can be attached to has to be somewhere a person can
|
||
* stand.
|
||
*
|
||
* `surfaces.js` is the closed vocabulary: a skill declares a page, the page
|
||
* carries a route, and Owliver's placement table keys off the same route. A
|
||
* surface whose route is not in the router is a page that can be authored
|
||
* against, targeted by a skill and composed into a node tree — and then 404s.
|
||
* That is exactly what happened to `candidates-analysis`, which was imported by
|
||
* `App.jsx` and never routed. One assertion closes the class.
|
||
*/
|
||
record('every surface route is registered in the router',
|
||
surfaces.SKILL_SURFACES.every((s) => !s.route || appRoutes.has(s.route)),
|
||
surfaces.SKILL_SURFACES.filter((s) => s.route && !appRoutes.has(s.route)).map((s) => s.id).join(', ')
|
||
|| `${surfaces.SKILL_SURFACES.filter((s) => s.route).length} routes reachable`);
|
||
|
||
record('the dynamic agent route is registered after the static one',
|
||
(() => {
|
||
const source = readFileSync(join(ROOT, resolveSourcePath('src/App.jsx')), 'utf8');
|
||
return source.indexOf('workspace/agents/new') < source.indexOf('workspace/agents/:id');
|
||
})(),
|
||
'so `agents/new` cannot be read as an agent whose id is "new"');
|
||
|
||
/* ── 20. Owliver on the agent configuration screen ────────────────────────
|
||
*
|
||
* Configure is a workspace page, not an operational one, and it is now a host
|
||
* for the *existing* Owliver rather than a second chat. Two things have to hold
|
||
* and they pull in opposite directions:
|
||
*
|
||
* - Owliver must actually mount there, at both addresses.
|
||
* - Standing there must not become standing on an operational page — most
|
||
* sharply when the agent being *edited* is an operational agent.
|
||
*
|
||
* The second is the one worth testing hardest: configuring the Analytics Agent
|
||
* must not put a reader on Analytics.
|
||
*/
|
||
console.log('\n── Owliver on Agent Configure ──');
|
||
|
||
const CONFIGURE_CONTEXT = 'admin.agentConfigure';
|
||
|
||
/* ── It mounts, at both addresses ────────────────────────────────────────── */
|
||
|
||
for (const route of ['/admin/workspace/agents/new', '/admin/workspace/agents/analytics-agent']) {
|
||
const resolved = placement.resolveAssistantContext('admin', route);
|
||
record(`Owliver mounts on \`${route}\``,
|
||
resolved?.id === CONFIGURE_CONTEXT, resolved?.id || 'no panel');
|
||
}
|
||
|
||
/* This used to assert the context carried a `respond` function and a list of
|
||
capabilities. Both were the local simulator's, and both went with it. The
|
||
property it was really guarding — that Agent Configure reuses a context from
|
||
the shared table rather than declaring a panel of its own — survives, and is
|
||
what is asserted now. */
|
||
record('the configure context is the one Owliver already uses, not a new panel',
|
||
Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT])
|
||
&& Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.page)
|
||
&& Array.isArray(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.topics),
|
||
'a context in the existing table, resolved by the existing placement');
|
||
|
||
/**
|
||
* The rest of the workspace hosts Owliver too — under its *own* context.
|
||
*
|
||
* These two checks used to assert the opposite: that the agents list and the
|
||
* other workspace routes carried no panel at all. That was right while Agent
|
||
* Configure was the only workspace host, and wrong as a general rule — it left
|
||
* Owliver dead on every configuration surface in the product, on the reasoning
|
||
* that a page with no specialist agent is a page with no assistant. It is not.
|
||
*
|
||
* What still matters, and is what these now assert, is that each one resolves
|
||
* to *its own* context rather than borrowing Agent Configure's: the page a
|
||
* reader is standing on is never something another page's context describes.
|
||
*/
|
||
const WORKSPACE_HOSTS = {
|
||
'/admin/settings': 'admin.settings',
|
||
'/admin/workspace': 'admin.workspace',
|
||
'/admin/workspace/agents': 'admin.workspaceAgents',
|
||
'/admin/workspace/skills': 'admin.workspaceSkills',
|
||
'/admin/workspace/skill-development': 'admin.skillDevelopment',
|
||
'/admin/workspace/skills/new': 'admin.skillConfigure',
|
||
'/admin/workspace/skills/owliver/new': 'admin.skillConfigure',
|
||
'/admin/workspace/skills/my-skill': 'admin.skillConfigure',
|
||
'/admin/workspace/skills/owliver/my-skill': 'admin.skillConfigure',
|
||
};
|
||
const misplaced = Object.entries(WORKSPACE_HOSTS)
|
||
.filter(([route, id]) => placement.resolveAssistantContext('admin', route)?.id !== id)
|
||
.map(([route, id]) => `${route}: expected ${id}, got ${placement.resolveAssistantContext('admin', route)?.id ?? 'no panel'}`);
|
||
record('every workspace surface hosts Owliver under its own context',
|
||
misplaced.length === 0,
|
||
misplaced.join(' | ') || `${Object.keys(WORKSPACE_HOSTS).length} routes`);
|
||
|
||
record('the agents list does not borrow the configure context',
|
||
placement.resolveAssistantContext('admin', '/admin/workspace/agents')?.id === 'admin.workspaceAgents');
|
||
|
||
/**
|
||
* The pattern must not swallow addresses beneath it.
|
||
*
|
||
* One segment after `agents/`, and no deeper. A nested route added later would
|
||
* otherwise silently inherit this panel.
|
||
*/
|
||
record('the dynamic pattern matches one segment only',
|
||
placement.resolveAssistantContext('admin', '/admin/workspace/agents/x/y') === null
|
||
&& placement.resolveAssistantContext('admin', '/admin/workspace/agents/x') !== null);
|
||
|
||
/* ── The eight operational pages are untouched ───────────────────────────── */
|
||
|
||
/**
|
||
* Exact matching still happens first, so none of the eight ever reaches the
|
||
* pattern table. Asserted rather than assumed, because "I added a fallback" is
|
||
* exactly the change that quietly re-routes something.
|
||
*/
|
||
const OPERATIONAL = {
|
||
'/admin': 'admin.controlCenter',
|
||
'/admin/positions': 'admin.positions',
|
||
'/admin/candidates': 'admin.candidatesList',
|
||
'/admin/hired': 'admin.hiredHistory',
|
||
'/admin/talent-pool': 'admin.talentPool',
|
||
'/admin/university': 'admin.forge',
|
||
'/admin/analytics': 'admin.analytics',
|
||
'/admin/activity': 'admin.activity',
|
||
};
|
||
const rerouted = Object.entries(OPERATIONAL)
|
||
.filter(([route, expectedId]) => placement.resolveAssistantContext('admin', route)?.id !== expectedId);
|
||
record('all eight operational pages resolve exactly as before',
|
||
rerouted.length === 0, rerouted.map(([r]) => r).join(', ') || '8 pages unchanged');
|
||
|
||
/* ── The critical boundary ──────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Editing the Analytics Agent does not put the reader on Analytics.
|
||
*
|
||
* The edited agent is a record being changed, not the page anyone is standing
|
||
* on. Two independent reasons this holds, both asserted: no skill declares the
|
||
* configure page, and the Analytics Agent does not cover it.
|
||
*/
|
||
record('no skill is available on the configure page at all',
|
||
reg.skillsForContext(CONFIGURE_CONTEXT, [], []).length === 0,
|
||
JSON.stringify(reg.skillsForContext(CONFIGURE_CONTEXT, [], []).map((s) => s.id)));
|
||
|
||
const analyticsAgentCfg = agentReg.getAgent(ALL_AGENTS, 'analytics-agent');
|
||
record('the Analytics Agent does not cover the configure page',
|
||
runtime.agentCovers(analyticsAgentCfg, CONFIGURE_CONTEXT) === false);
|
||
|
||
record('editing the Analytics Agent exposes no analytics skill',
|
||
reg.skillsForContext(
|
||
CONFIGURE_CONTEXT,
|
||
runtime.agentScopedDisabledWith(analyticsAgentCfg, ALL_AGENTS, ALL_SKILLS, []),
|
||
[]
|
||
).length === 0,
|
||
'the edited agent is metadata, not the current page');
|
||
|
||
/* The Analytics page keeps everything it had. A subset check, not equality:
|
||
the snapshot predates the analysis skills added since, and the baseline
|
||
section above already governs additions. What matters here is that mounting
|
||
Owliver on a workspace page took nothing away from an operational one. */
|
||
const analyticsNow = reg.skillsForContext('admin.analytics', [], []).map((s) => s.id);
|
||
const analyticsLost = expectedBaseline.contexts['admin.analytics'].skills
|
||
.filter((id) => !analyticsNow.includes(id));
|
||
record('...and the Analytics page kept every skill it had',
|
||
analyticsLost.length === 0,
|
||
analyticsLost.length ? `LOST ${JSON.stringify(analyticsLost)}` : `${analyticsNow.length} skills, none lost`);
|
||
|
||
/* Every agent, on the configure page: none reaches an operational skill. */
|
||
const leakedOnConfigure = ALL_AGENTS.filter((a) =>
|
||
reg.skillsForContext(
|
||
CONFIGURE_CONTEXT, runtime.agentScopedDisabledWith(a, ALL_AGENTS, ALL_SKILLS, []), []
|
||
).length > 0);
|
||
record('no agent reaches an operational skill from the configure page',
|
||
leakedOnConfigure.length === 0,
|
||
leakedOnConfigure.map((a) => a.id).join(', ') || `${ALL_AGENTS.length} agents, none`);
|
||
|
||
/* ── The page answers honestly ──────────────────────────────────────────── */
|
||
|
||
const configureContext = contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT];
|
||
|
||
record('the configure page has a native agent to answer with',
|
||
runtime.defaultAgentForContext(ALL_AGENTS, CONFIGURE_CONTEXT)?.id === 'krow-workforce-agent',
|
||
runtime.defaultAgentForContext(ALL_AGENTS, CONFIGURE_CONTEXT)?.name || 'none');
|
||
|
||
record('it answers questions about agents and skills',
|
||
['What agents can I configure?', 'Where do skills come from?', 'What does reasoning do?']
|
||
.every((q) => routingModule.resolveIntent({ question: q, contextId: CONFIGURE_CONTEXT }).kind === 'answer'),
|
||
'agent-management questions are in scope');
|
||
|
||
/**
|
||
* A workforce question asked here is declined, not answered.
|
||
*
|
||
* This is the honest half of the boundary: the page has no operational records,
|
||
* so it must say so and point at the page that does — rather than answering
|
||
* from whatever the configuration screen happens to know.
|
||
*/
|
||
const workforceHere = ['How many candidates applied this week?', 'What is our attendance rate?']
|
||
.map((q) => routingModule.resolveIntent({ question: q, contextId: CONFIGURE_CONTEXT }));
|
||
record('a workforce question on the configure page is declined or routed away',
|
||
workforceHere.every((i) => i.kind === 'outOfScope' || i.kind === 'navigate'),
|
||
workforceHere.map((i) => i.kind).join(', '));
|
||
|
||
record('...and never answered from the configure page itself',
|
||
workforceHere.every((i) => i.kind !== 'answer' && !i.skill));
|
||
|
||
/* ── One Owliver, not two ───────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Asserted structurally: the configure screen must not import or define a chat.
|
||
* The panel it gets is the one the Admin shell already mounts.
|
||
*/
|
||
const detailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/AgentDetail.jsx')), 'utf8');
|
||
const configureSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentConfigure.jsx')), 'utf8');
|
||
|
||
/* The property is right and the old pattern was too blunt. It matched any
|
||
occurrence of the substring, so `useAssistantPanel` — the hook for talking to
|
||
the ONE panel the shell mounts, and the opposite of defining a second one —
|
||
read as a violation, and so did the words `useConversation` inside a comment.
|
||
|
||
What actually constitutes a second chat is IMPORTING one or RENDERING one, so
|
||
that is what is matched now. */
|
||
const secondChat = [
|
||
/import\s+[^;]*\bKrowAssistant\b[^;]*from/,
|
||
/import\s+[^;]*\bcreateAssistantProvider\b[^;]*from/,
|
||
/import\s+[^;]*\buseConversation\b[^;]*from/,
|
||
/<KrowAssistant\b/,
|
||
/<AssistantPanel\b(?!Provider)/,
|
||
].filter((re) => re.test(detailSource + configureSource));
|
||
|
||
record('the configure screen defines no chat of its own',
|
||
secondChat.length === 0,
|
||
secondChat.length ? `defines one: ${secondChat[0]}` : 'uses the shell\'s panel, defines none');
|
||
|
||
record('the panel is mounted once, by the shell',
|
||
/* `<AssistantPanel` alone also matches `<AssistantPanelProvider`, which is a
|
||
different component and legitimately present. */
|
||
(readFileSync(join(ROOT, resolveSourcePath('src/layouts/AdminLayout.jsx')), 'utf8').match(/<AssistantPanel\b(?!Provider)/g) || []).length === 1);
|
||
|
||
/* The switcher is gone — see the note above. What replaced it is a label, and
|
||
the property worth keeping is that it stayed one: a header that can be
|
||
clicked is a second place to choose an agent. */
|
||
const badgeSource = readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/AgentBadge.jsx')), 'utf8');
|
||
record('the panel header names the agent and does not switch it',
|
||
!/<button|onClick=|navigate\(/.test(badgeSource),
|
||
'a label, not a control');
|
||
|
||
record('PageContext is untouched',
|
||
!/agentConfigure/.test(readFileSync(join(ROOT, resolveSourcePath('src/components/ai-assistant/PageContext.jsx')), 'utf8')));
|
||
|
||
/* ── The configure surface declares no placements ───────────────────────── */
|
||
|
||
/**
|
||
* Nothing renders skill cards on this screen, so the surface offers no
|
||
* placement. A `ui:` skill that tried to attach is refused at validation rather
|
||
* than validating and then drawing nothing.
|
||
*/
|
||
const configureSurface = surfaces.surfaceFor('workspace-agent-configure');
|
||
record('the configure surface exists and offers no placements',
|
||
Boolean(configureSurface) && configureSurface.placements.length === 0,
|
||
`${configureSurface?.placements.length ?? '—'} placements`);
|
||
|
||
record('no skill declares the configure page',
|
||
reg.SKILLS.every((s) => !s.pages.includes('workspace-agent-configure')),
|
||
'so there is nothing operational to reach here');
|
||
|
||
/* ── The configure workspace ─────────────────────────────────────────────
|
||
*
|
||
* The screen is an authoring surface and the only one in the console that is,
|
||
* so it carries a visual treatment the eight operational pages do not. What
|
||
* follows guards the two things that treatment must not cost.
|
||
*
|
||
* **Every control still exists.** A redesign that quietly drops a field looks
|
||
* exactly like a redesign that kept it — until someone cannot set an icon. So
|
||
* each closed vocabulary is asserted to be *rendered from its own table*, not
|
||
* merely present as a word: the icon picker over `AGENT_ICONS`, the page chips
|
||
* over `SUPPORTED_SKILL_PAGES`, reasoning over `REASONING_MODES`, and the
|
||
* knowledge kinds over `KNOWLEDGE_KINDS`. A hand-written subset would pass a
|
||
* grep and still be wrong the day a value is added.
|
||
*
|
||
* **It stays Krow.** The palette is the tokens, and nothing else.
|
||
*/
|
||
|
||
const canvasSource = readFileSync(join(ROOT, resolveSourcePath('src/components/agents/AgentCanvas.jsx')), 'utf8');
|
||
const agentUi = detailSource + configureSource + canvasSource;
|
||
|
||
/* One surface with sections inside it, not four cards side by side. */
|
||
record('the configure screen composes one workspace surface',
|
||
/<Workspace\b/.test(configureSource)
|
||
&& (configureSource.match(/<DocSection\b/g) || []).length === 4
|
||
&& !/<Surface\b/.test(configureSource),
|
||
'Workspace + 4 DocSection, no per-section card');
|
||
|
||
/* The rail is navigation over the document, so every id it offers must exist
|
||
as something the document actually renders. A leaf pointing at nothing is a
|
||
dead link the eye cannot see. */
|
||
const railSections = [...configureSource.matchAll(/\{\s*id:\s*'([a-z-]+)',\s*icon:/g)].map((m) => m[1]);
|
||
const railLeaves = [...configureSource.matchAll(/\{\s*id:\s*'([a-z-]+)',\s*label:/g)].map((m) => m[1]);
|
||
const railIds = [...railSections, ...railLeaves];
|
||
const missingTargets = railIds.filter((id) => !new RegExp(`id="${id}"`).test(configureSource));
|
||
record('every rail entry addresses something the document renders',
|
||
railSections.length === 4 && railLeaves.length === 12 && missingTargets.length === 0,
|
||
missingTargets.length
|
||
? `no target for ${missingTargets.join(', ')}`
|
||
: `${railSections.length} sections, ${railLeaves.length} leaves, all addressable`);
|
||
|
||
/* Each closed vocabulary is rendered from its own table. */
|
||
record('the icon picker offers every icon an agent may name',
|
||
/icons=\{AGENT_ICONS\}/.test(configureSource) && vocab.AGENT_ICONS.length === 10,
|
||
`${vocab.AGENT_ICONS.length} icons, from AGENT_ICONS`);
|
||
|
||
/* This asked for SUPPORTED_SKILL_PAGES — every surface, configuration screens
|
||
included. The editor deliberately offers the narrower DOMAIN_SURFACES: a
|
||
surface with no placements renders no skill sections, so an agent scoped to
|
||
one has nothing to answer from. Offering it would let somebody build an agent
|
||
that is silently mute.
|
||
|
||
The pages a definition ALREADY declares are unioned in, so an agent written
|
||
by hand against a configuration surface keeps it rather than losing it on the
|
||
next save. Both halves are asserted. */
|
||
record('the page chips offer every domain surface, and keep what a definition declares',
|
||
/\.\.\.DOMAIN_SURFACES, \.\.\.fields\.pages/.test(configureSource),
|
||
`${surfaces.DOMAIN_SURFACES.length} domain surfaces of `
|
||
+ `${surfaces.SUPPORTED_SKILL_PAGES.length} supported, plus whatever is declared`);
|
||
|
||
record('reasoning offers every mode',
|
||
/REASONING_MODES\.map/.test(configureSource),
|
||
vocab.REASONING_MODES.map((m) => m.label).join(' / '));
|
||
|
||
record('knowledge offers every kind',
|
||
/KNOWLEDGE_KINDS\.map/.test(configureSource),
|
||
vocab.KNOWLEDGE_KINDS.join(', '));
|
||
|
||
/* The controls that are not list-driven, one by one. */
|
||
const CONTROLS = {
|
||
name: /value=\{fields\.name\}/,
|
||
description: /value=\{fields\.description\}/,
|
||
'when to use': /value=\{fields\.trigger\}/,
|
||
instructions: /value=\{fields\.instructions\}/,
|
||
/* Skills are editable here again, but NOT the way this check used to look
|
||
for. The old controls wrote `fields.skills` directly, which made attaching
|
||
a skill a DRAFT — and the catalog persists one immediately, on purpose. Two
|
||
write paths for one field meant the two screens disagreed about when a
|
||
skill took effect.
|
||
|
||
So Configure now calls the caller's `onToggleSkill`, the same optimistic
|
||
path the catalog uses. The property worth guarding is that both controls
|
||
exist and both go through it. */
|
||
'add skill': /onValueChange=\{\(id\) => !fields\.skills\.includes\(id\) && onToggleSkill/,
|
||
'remove skill': /onRemove=\{onToggleSkill \? \(\) => onToggleSkill\(id\) : undefined\}/,
|
||
'add knowledge': /knowledge: \[\s*\n?\s*\.\.\.fields\.knowledge/,
|
||
'remove knowledge': /knowledge: fields\.knowledge\.filter/,
|
||
'add starter': /starters: \[\.\.\.fields\.starters/,
|
||
'remove starter': /starters: fields\.starters\.filter/,
|
||
'web search': /onCheckedChange=\{\(webSearch\) => set\(\{ webSearch \}\)\}/,
|
||
'add subagent': /subagents: \[\.\.\.fields\.subagents/,
|
||
'remove subagent': /subagents: fields\.subagents\.filter/,
|
||
};
|
||
const missingControls = Object.entries(CONTROLS)
|
||
.filter(([, re]) => !re.test(configureSource)).map(([k]) => k);
|
||
/* Skills are bounded by the pages above — "a page decides which skills exist
|
||
there; an agent chooses among them, and can narrow that list, never widen
|
||
it." The old controls offered a flat list, which is how an agent ends up
|
||
carrying a skill its pages do not provide. */
|
||
record('the skills offered are bounded by the pages chosen',
|
||
/getSkillsForPage\(page,/.test(configureSource)
|
||
&& /for \(const page of fields\.pages\)/.test(configureSource),
|
||
'derived from fields.pages, not a flat catalog');
|
||
|
||
/* An agent whose pages changed may still carry a skill they no longer offer.
|
||
Dropping it silently would edit the definition behind the author's back. */
|
||
record('a skill the pages no longer offer is flagged, not dropped',
|
||
/Not available on the pages above/.test(configureSource));
|
||
|
||
record('every field the editor had is still editable',
|
||
missingControls.length === 0,
|
||
missingControls.length ? `MISSING ${missingControls.join(', ')}` : `${Object.keys(CONTROLS).length} controls`);
|
||
|
||
/* Lifecycle state stays legible: version, status, and the customized marker. */
|
||
record('the header still states version, status and customization',
|
||
/v\{agent\.version\}/.test(detailSource)
|
||
&& /\{agent\.status\}/.test(detailSource)
|
||
&& /isOverridden\(id\) && <Badge variant="info">Customized/.test(detailSource)
|
||
&& /Publish update/.test(detailSource),
|
||
'v · status · Customized · Publish update');
|
||
|
||
record('save state is shown and Save is offered only when there is something to save',
|
||
/dirty \? 'Unsaved changes' : 'Saved'/.test(detailSource)
|
||
&& /onClick=\{persist\} disabled=\{!dirty\}/.test(detailSource));
|
||
|
||
/**
|
||
* Closed sections stay reachable by the rail, which means they stay mounted —
|
||
* and a mounted control nobody can see must not be in the tab order.
|
||
*/
|
||
record('collapsed content is inert rather than merely hidden',
|
||
/inert=\{open \? undefined : true\}/.test(canvasSource)
|
||
&& !/aria-hidden=\{open/.test(canvasSource),
|
||
'not tabbable while closed');
|
||
|
||
/**
|
||
* Motion is CSS transitions only, so the global `prefers-reduced-motion` rule
|
||
* in index.css governs it. A JavaScript animation would need its own guard and
|
||
* would eventually be written without one.
|
||
*/
|
||
record('the workspace animates in CSS, so reduced motion is inherited',
|
||
!/framer-motion/.test(configureSource + canvasSource)
|
||
&& /transition-\[grid-template-rows\]/.test(canvasSource),
|
||
'no JS animation on this screen');
|
||
|
||
/**
|
||
* The palette is the design system's. Not "no hex anywhere" — the check is
|
||
* that this screen introduced none of its own.
|
||
*/
|
||
const strayColour = [...agentUi.matchAll(/(?:bg|text|border|from|via|to|ring)-(?:\[#[0-9a-fA-F]{3,8}\]|purple|violet|fuchsia|pink|indigo|cyan|teal|lime|orange)-?\d*/g)]
|
||
.map((m) => m[0]);
|
||
record('the configure screen introduces no colour outside the Krow palette',
|
||
strayColour.length === 0,
|
||
strayColour.length ? `STRAY ${[...new Set(strayColour)].join(', ')}` : 'tokens only');
|
||
|
||
/* The redesign is scoped to this screen. Nothing else may import its parts. */
|
||
const canvasImporters = [
|
||
...readdirSync(join(ROOT, 'src/pages/admin')).map((f) => ['src/pages/admin', f]),
|
||
...readdirSync(join(ROOT, 'src/components/agents')).map((f) => ['src/components/agents', f]),
|
||
]
|
||
.filter(([, f]) => /\.[jt]sx?$/.test(f))
|
||
.filter(([, f]) => !/^(AgentConfigure|AgentDetail|AgentCanvas)\./.test(f))
|
||
.filter(([dir, f]) => /agents\/AgentCanvas|from '\.\/AgentCanvas'/
|
||
.test(readFileSync(join(ROOT, resolveSourcePath(`${dir}/${f}`)), 'utf8')))
|
||
.map(([dir, f]) => `${dir}/${f}`);
|
||
record('the workspace treatment is used by the configure screen alone',
|
||
canvasImporters.length === 0,
|
||
canvasImporters.join(', ') || 'no other page imports it');
|
||
|
||
/* ── 20b. Native agent pages vs agent-less pages ──────────────────────────
|
||
*
|
||
* The correction this section pins down, in one sentence: **a page with no
|
||
* agent of its own is not a page without Owliver.**
|
||
*
|
||
* Two runtime modes, and only the first existed as a deliberate design:
|
||
*
|
||
* 1. **Native.** The eight operational pages open on the agent written for
|
||
* them, with that page's skills and that page's records. Unchanged, and
|
||
* most of what follows exists to prove it stayed unchanged.
|
||
* 2. **Fallback.** Settings, the workspace surfaces, Agent Configure and
|
||
* Profile have no specialist and need none. They open on the general Krow
|
||
* Workforce Agent, and Owliver works there — chat, chips, history,
|
||
* everything — while still declining any question about records the page
|
||
* does not hold.
|
||
*
|
||
* The failure this replaces was reading "no native agent" as "constrained", and
|
||
* a constrained panel is a dead one: no starters, no answers, a decline to every
|
||
* question. That is the right behaviour for an agent the reader *chose* which
|
||
* does not cover the page, and the wrong behaviour for a page nobody wrote an
|
||
* agent for. Both are asserted below, because the whole correction is the
|
||
* distinction between them.
|
||
*/
|
||
console.log('\n── Native agent pages vs agent-less pages ──');
|
||
|
||
/** Pages with an agent of their own. `NATIVE` above is the same eight. */
|
||
const NATIVE_CONTEXTS = Object.keys(NATIVE);
|
||
|
||
/**
|
||
* Pages with none.
|
||
*
|
||
* Profile is on this list and always was — it had no specialist before any of
|
||
* this and resolved to the general agent, which is exactly the behaviour the
|
||
* six new surfaces now share. Listing it here is what proves the fallback is
|
||
* one rule rather than a special case for the pages added last.
|
||
*/
|
||
const AGENTLESS_CONTEXTS = [
|
||
'admin.settings',
|
||
'admin.workspace',
|
||
'admin.workspaceAgents',
|
||
'admin.workspaceSkills',
|
||
'admin.skillConfigure',
|
||
'admin.skillDevelopment',
|
||
'admin.agentConfigure',
|
||
'admin.profile',
|
||
];
|
||
|
||
const GENERAL = 'krow-workforce-agent';
|
||
|
||
/* ── 1–2. Resolution, both modes ────────────────────────────────────────── */
|
||
|
||
for (const contextId of NATIVE_CONTEXTS) {
|
||
const native = runtime.nativeAgentForContext(ALL_AGENTS, contextId);
|
||
const resolved = runtime.resolveDefaultAgent(ALL_AGENTS, contextId);
|
||
record(`${contextId.replace('admin.', '')}: resolves its own agent`,
|
||
native?.id === NATIVE[contextId] && resolved?.id === NATIVE[contextId],
|
||
resolved?.id || 'none');
|
||
}
|
||
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
const page = contextId.replace('admin.', '');
|
||
record(`${page}: has no agent of its own, and resolves the general one`,
|
||
runtime.nativeAgentForContext(ALL_AGENTS, contextId) === null
|
||
&& runtime.resolveDefaultAgent(ALL_AGENTS, contextId)?.id === GENERAL,
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, contextId)?.id || 'NONE');
|
||
}
|
||
|
||
record('the general agent covers every agent-less page',
|
||
AGENTLESS_CONTEXTS.every((c) => runtime.agentCovers(agentReg.getAgent(ALL_AGENTS, GENERAL), c)),
|
||
`${AGENTLESS_CONTEXTS.length} pages`);
|
||
|
||
/* Nothing above changed which agent the eight operational pages open on. */
|
||
record('the eight native pages still open on exactly the agents they did',
|
||
Object.entries(NATIVE).every(([c, id]) => runtime.defaultAgentForContext(ALL_AGENTS, c)?.id === id),
|
||
Object.entries(NATIVE).filter(([c, id]) => runtime.defaultAgentForContext(ALL_AGENTS, c)?.id !== id)
|
||
.map(([c]) => c).join(', ') || '8 pages unchanged');
|
||
|
||
/* ── 3–5. An agent-less page does not open constrained ──────────────────── */
|
||
|
||
/**
|
||
* The heart of it.
|
||
*
|
||
* With nothing chosen, the panel resolves an agent that covers the page — so
|
||
* `covers` is true, no constrained document is produced, and the reader gets an
|
||
* assistant rather than an apology.
|
||
*/
|
||
const openedConstrained = [];
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
const resolved = runtime.resolveDefaultAgent(ALL_AGENTS, contextId);
|
||
const turn = runtime.resolveAgentForTurn(ALL_AGENTS, null, contextId);
|
||
const intent = routingModule.resolveIntent({
|
||
question: 'What can I do here?',
|
||
contextId,
|
||
agent: resolved,
|
||
agentCoversPage: runtime.agentCovers(resolved, contextId),
|
||
agentSuggestion: resolved,
|
||
});
|
||
if (!turn.covers || intent.kind === 'constrained') openedConstrained.push(contextId);
|
||
}
|
||
record('no agent-less page opens in the constrained state',
|
||
openedConstrained.length === 0,
|
||
openedConstrained.join(', ') || `${AGENTLESS_CONTEXTS.length} pages open with a working agent`);
|
||
|
||
record('Settings opens on the general agent, not constrained',
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings')?.id === GENERAL
|
||
&& runtime.resolveAgentForTurn(ALL_AGENTS, null, 'admin.settings').covers === true);
|
||
|
||
record('Agent Configure opens on the general agent, not constrained',
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.agentConfigure')?.id === GENERAL
|
||
&& runtime.resolveAgentForTurn(ALL_AGENTS, null, 'admin.agentConfigure').covers === true);
|
||
|
||
/* ── 6–7. The edited agent is metadata, never the runtime ───────────────── */
|
||
|
||
/**
|
||
* Configuring an agent does not become standing on the page it covers.
|
||
*
|
||
* Section 20 proves the *data* half — no skill is reachable there. This is the
|
||
* *identity* half: whichever agent is being edited, the agent answering on the
|
||
* configure page is the general one, because nothing about the record on the
|
||
* form is an input to agent resolution.
|
||
*/
|
||
for (const edited of ['analytics-agent', 'positions-agent', 'activity-agent']) {
|
||
record(`editing \`${edited}\` does not make it the runtime agent`,
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.agentConfigure')?.id === GENERAL
|
||
&& runtime.agentCovers(agentReg.getAgent(ALL_AGENTS, edited), 'admin.agentConfigure') === false,
|
||
'the edited agent is configuration, not the active runtime identity');
|
||
}
|
||
|
||
/* ── 8–13. Stale selection ──────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* A selection made on one page must not decide another.
|
||
*
|
||
* `resolveSelection` is a pure function of (agents, selection, context), so the
|
||
* whole rule is provable here rather than through a React tree. Three outcomes,
|
||
* and every case below is one of them: applied because it covers, applied
|
||
* because this is where it was chosen, or retired.
|
||
*/
|
||
const LEAKS = [
|
||
['positions-agent', 'admin.positions', 'admin.settings'],
|
||
['analytics-agent', 'admin.analytics', 'admin.settings'],
|
||
['activity-agent', 'admin.activity', 'admin.settings'],
|
||
['positions-agent', 'admin.positions', 'admin.agentConfigure'],
|
||
['control-center-agent', 'admin.controlCenter', 'admin.workspaceSkills'],
|
||
['talent-pool-agent', 'admin.talentPool', 'admin.skillDevelopment'],
|
||
];
|
||
const leakedSelections = [];
|
||
for (const [id, chosenOn, arrivingAt] of LEAKS) {
|
||
const applied = runtime.resolveSelection(ALL_AGENTS, { id, contextId: chosenOn }, arrivingAt);
|
||
const answering = runtime.resolveAgentForTurn(ALL_AGENTS, applied.id, arrivingAt);
|
||
if (applied.id !== null || !applied.retire || answering.agent?.id !== GENERAL || !answering.covers) {
|
||
leakedSelections.push(`${id} (${chosenOn}) → ${arrivingAt}: ${answering.agent?.id}`);
|
||
}
|
||
}
|
||
record('a selection made on another page never follows onto an agent-less page',
|
||
leakedSelections.length === 0,
|
||
leakedSelections.join(' | ') || `${LEAKS.length} navigations, every one resolved to the general agent`);
|
||
|
||
record('a retired selection is retired, not merely ignored',
|
||
runtime.resolveSelection(ALL_AGENTS, { id: 'positions-agent', contextId: 'admin.positions' }, 'admin.settings').retire === true,
|
||
'so returning to that page later does not resurrect it');
|
||
|
||
/* Arriving on a page the selection *does* cover keeps it — the choice is only
|
||
dropped where it could not answer. */
|
||
record('a selection that covers the page it arrives on is kept',
|
||
(() => {
|
||
const applied = runtime.resolveSelection(
|
||
ALL_AGENTS, { id: 'analytics-agent', contextId: 'admin.settings' }, 'admin.analytics'
|
||
);
|
||
return applied.id === 'analytics-agent' && applied.covers === true && applied.retire === false;
|
||
})(),
|
||
'Settings → Analytics keeps a deliberate choice');
|
||
|
||
record('Agent Configure → Analytics resolves Analytics normally',
|
||
(() => {
|
||
const carried = runtime.resolveSelection(ALL_AGENTS, { id: null, contextId: 'admin.agentConfigure' }, 'admin.analytics');
|
||
return carried.id === null
|
||
&& runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.analytics')?.id === 'analytics-agent';
|
||
})());
|
||
|
||
record('Settings → Analytics resolves Analytics normally',
|
||
runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.analytics')?.id === 'analytics-agent');
|
||
|
||
/* An account-level default is a choice made nowhere, and is read by the same
|
||
rule: it applies where it covers, and never constrains a page it does not. */
|
||
record('an account default that does not cover a page does not constrain it',
|
||
runtime.resolveSelection(ALL_AGENTS, 'analytics-agent', 'admin.settings').id === null
|
||
&& runtime.resolveSelection(ALL_AGENTS, 'analytics-agent', 'admin.analytics').id === 'analytics-agent');
|
||
|
||
/* A stored id for an agent that no longer exists resolves to nothing rather
|
||
than leaving the panel pointed at a ghost. */
|
||
record('a selection naming an agent that no longer exists is retired',
|
||
runtime.resolveSelection(ALL_AGENTS, { id: 'deleted-agent', contextId: 'admin.settings' }, 'admin.settings').retire === true);
|
||
|
||
/* ── 18. An explicit incompatible choice still constrains ───────────────── */
|
||
|
||
/**
|
||
* The other half, and the reason "stale" had to be defined rather than just
|
||
* cleared: choosing a specialist *here*, on a page it does not cover, is a
|
||
* deliberate act. It is honoured, shown as constrained, and it declines — the
|
||
* honest answer, and the same one every constrained pair gives above.
|
||
*/
|
||
const explicit = runtime.resolveSelection(
|
||
ALL_AGENTS, { id: 'analytics-agent', contextId: 'admin.settings' }, 'admin.settings'
|
||
);
|
||
const explicitTurn = runtime.resolveAgentForTurn(ALL_AGENTS, explicit.id, 'admin.settings');
|
||
record('choosing an incompatible agent on this page is honoured, and constrained',
|
||
explicit.id === 'analytics-agent' && explicit.retire === false
|
||
&& explicitTurn.agent?.id === 'analytics-agent' && explicitTurn.covers === false,
|
||
'the reader chose it here, so it is not swapped out from under them');
|
||
|
||
record('...and it declines rather than answering',
|
||
routingModule.resolveIntent({
|
||
question: 'What can I configure here?',
|
||
contextId: 'admin.settings',
|
||
agent: explicitTurn.agent,
|
||
agentCoversPage: false,
|
||
agentSuggestion: runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings'),
|
||
}).kind === 'constrained');
|
||
|
||
record('...and the way out of it is named on a page with no specialist',
|
||
Boolean(runtime.resolveDefaultAgent(ALL_AGENTS, 'admin.settings')),
|
||
'the general agent is what a constrained answer points at');
|
||
|
||
/* ── 14–17. Owliver actually works there ────────────────────────────────── */
|
||
|
||
/* Every one of these pages mounts the panel. A resolution rule is worth nothing
|
||
if there is no panel to resolve for. */
|
||
record('every agent-less context is a real placement with a panel',
|
||
AGENTLESS_CONTEXTS.every((id) => Object.values(placement.PLACEMENT_ROUTES).includes(id)
|
||
|| Object.values(placement.PLACEMENT_PATTERN_ROUTES).includes(id)),
|
||
AGENTLESS_CONTEXTS.filter((id) => !Object.values(placement.PLACEMENT_ROUTES).includes(id)).join(', ') || 'all mounted');
|
||
|
||
/* Each one names itself, so the header reads the page the reader is on. */
|
||
record('every agent-less page states its own name, key and route',
|
||
AGENTLESS_CONTEXTS.every((id) => {
|
||
const context = contexts.ASSISTANT_CONTEXTS[id];
|
||
const pageKey = reg.pageKeyForContext(id);
|
||
return Boolean(context?.page) && Boolean(pageKey) && Boolean(reg.routeForPageKey(pageKey));
|
||
}),
|
||
AGENTLESS_CONTEXTS.map((id) => `${contexts.ASSISTANT_CONTEXTS[id].page}/${reg.pageKeyForContext(id)}`).join(', '));
|
||
|
||
/**
|
||
* The chips are real.
|
||
*
|
||
* Every suggestion offered on these pages is answered by the page it is offered
|
||
* on — a chip is a promise, and one that declines is worse than no chip. The
|
||
* count is asserted too: suggestions disappearing on an agent-less page was one
|
||
* of the reported symptoms.
|
||
*/
|
||
const emptyChips = [];
|
||
const brokenChips = [];
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
const prompts = dynamic.buildPrompts(contextId, factSheet, null) || [];
|
||
if (!prompts.length) emptyChips.push(contextId);
|
||
for (const prompt of prompts) {
|
||
const intent = routingModule.resolveIntent({ question: prompt.prompt, contextId });
|
||
if (intent.kind !== 'answer') brokenChips.push(`${contextId}: "${prompt.label}" → ${intent.kind}`);
|
||
}
|
||
}
|
||
record('every agent-less page offers suggestions',
|
||
emptyChips.length === 0,
|
||
emptyChips.join(', ') || AGENTLESS_CONTEXTS
|
||
.map((c) => `${c.replace('admin.', '')}:${(dynamic.buildPrompts(c, factSheet, null) || []).length}`).join(' '));
|
||
|
||
record('every suggestion an agent-less page offers is one it can answer',
|
||
brokenChips.length === 0,
|
||
brokenChips.slice(0, 3).join(' | ') || 'every chip resolves to an answer');
|
||
|
||
/* And the landing screen is furnished — a title, something to type into. */
|
||
record('every agent-less page has a landing screen rather than an empty panel',
|
||
AGENTLESS_CONTEXTS.every((contextId) => {
|
||
const intro = dynamic.buildIntro(contextId, factSheet, 'Test');
|
||
return Boolean(intro.title) && Boolean(intro.description) && intro.placeholders.length > 0;
|
||
}));
|
||
|
||
/**
|
||
* No fake skills.
|
||
*
|
||
* The lazy way to make a configuration page answer is to invent a skill for it.
|
||
* That would show up in exactly two places, and both are checked: the page would
|
||
* carry skills, and its chips would resolve to one. Neither is true — the
|
||
* answers come from the page responder, which is the mechanism every context in
|
||
* the table already used.
|
||
*/
|
||
record('no agent-less page carries any skill',
|
||
AGENTLESS_CONTEXTS.every((c) => reg.skillsForContext(c, [], []).length === 0),
|
||
AGENTLESS_CONTEXTS.filter((c) => reg.skillsForContext(c, [], []).length).join(', ') || 'no skills, on any of them');
|
||
|
||
/* A chip may name one of the page's own capabilities — Profile's always have —
|
||
and that is a context responder, not a skill. What must not exist is a skill
|
||
behind any of them. */
|
||
record('no chip on an agent-less page is backed by a skill',
|
||
AGENTLESS_CONTEXTS.every((contextId) =>
|
||
(dynamic.buildPrompts(contextId, factSheet, null) || []).every((p) =>
|
||
!reg.matchSkill(p.prompt, contextId, [], []))),
|
||
'the fallback is architectural, not a skill layer');
|
||
|
||
record('no agent reaches a skill from an agent-less page',
|
||
AGENTLESS_CONTEXTS.every((contextId) => ALL_AGENTS.every((a) =>
|
||
reg.skillsForContext(contextId, runtime.agentScopedDisabledWith(a, ALL_AGENTS, ALL_SKILLS, []), []).length === 0)),
|
||
`${AGENTLESS_CONTEXTS.length} pages × ${ALL_AGENTS.length} agents`);
|
||
|
||
/**
|
||
* Honest about what it cannot answer.
|
||
*
|
||
* An operational question asked on a configuration screen must never be
|
||
* answered from that screen. It may route to the page that holds the records —
|
||
* which is the useful answer — or decline. What it may not do is produce a
|
||
* figure.
|
||
*/
|
||
const OPERATIONAL_QUESTIONS = [
|
||
'What are the open positions today?',
|
||
'What is our attendance rate?',
|
||
'Which candidates need review?',
|
||
'How many people did we hire last month?',
|
||
];
|
||
const fabricated = [];
|
||
for (const contextId of AGENTLESS_CONTEXTS) {
|
||
for (const question of OPERATIONAL_QUESTIONS) {
|
||
const intent = routingModule.resolveIntent({ question, contextId });
|
||
if (intent.kind === 'answer' || intent.skill) fabricated.push(`${contextId}: "${question}" → ${intent.kind}`);
|
||
}
|
||
}
|
||
record('an operational question on an agent-less page is declined or routed, never answered',
|
||
fabricated.length === 0,
|
||
fabricated.slice(0, 3).join(' | ')
|
||
|| `${AGENTLESS_CONTEXTS.length} pages × ${OPERATIONAL_QUESTIONS.length} questions`);
|
||
|
||
/* The decline names what the page *can* do, so it is an offer rather than a
|
||
dead end. */
|
||
/* The offer used to be a bulleted list of capability labels — the simulator's
|
||
canned readings, deleted with it. It is now a sentence naming the page's
|
||
subjects, which is a better offer: a label named a report, a subject names
|
||
something somebody can ask about in their own words. The check asserts the
|
||
PROPERTY (a decline says what the page does cover) rather than the block
|
||
type, so the next change of shape does not break it for no reason. */
|
||
record('the decline offers what the page can answer instead',
|
||
AGENTLESS_CONTEXTS.every((contextId) => {
|
||
const intent = routingModule.resolveIntent({ question: 'What is our attendance rate?', contextId });
|
||
if (intent.kind !== 'outOfScope') return true;
|
||
const said = (intent.doc?.blocks || [])
|
||
.map((b) => b.text || (b.items || []).join(' ')).join(' ');
|
||
return said.includes('This page covers') || /\b(covers|ask about)\b/.test(said);
|
||
}));
|
||
|
||
/* ── The switcher still works there ─────────────────────────────────────── */
|
||
|
||
/**
|
||
* An agent-less page is not a page with no switcher.
|
||
*
|
||
* The list is the whole registry, ordered so the agents that work here come
|
||
* first — which on these pages is the general agent, because it is the one that
|
||
* covers them.
|
||
*/
|
||
record('the switcher lists every agent on an agent-less page',
|
||
AGENTLESS_CONTEXTS.every((contextId) =>
|
||
agentReg.searchAgents(ALL_AGENTS, '').length === ALL_AGENTS.length),
|
||
`${ALL_AGENTS.length} agents, on every page`);
|
||
|
||
record('the general agent sorts to the top of the list on an agent-less page',
|
||
AGENTLESS_CONTEXTS.every((contextId) => {
|
||
const ordered = [...agentReg.searchAgents(ALL_AGENTS, '')]
|
||
.sort((a, b) => Number(runtime.agentCovers(b, contextId)) - Number(runtime.agentCovers(a, contextId)));
|
||
return ordered[0]?.id === GENERAL;
|
||
}),
|
||
'the one that can answer here is offered first');
|
||
|
||
record('choosing an agent on an agent-less page still cannot move the page',
|
||
AGENTLESS_CONTEXTS.every((contextId) => ALL_AGENTS.every((candidate) =>
|
||
runtime.resolveAgentForTurn(ALL_AGENTS, candidate.id, contextId).agent?.id === candidate.id)));
|
||
|
||
/* ── 21. Owliver behaviour baseline ───────────────────────────────────────
|
||
*
|
||
* The Agent layer is additive, which is a claim rather than a guarantee: every
|
||
* page's skills, suggestions, prompts and intent routing must survive it
|
||
* unchanged. That is far more than fits in a reviewer's head, and all of it
|
||
* fails silently — a page quietly answering with one skill fewer looks exactly
|
||
* like a page that never had it.
|
||
*
|
||
* So today's behaviour was recorded before any of it was built
|
||
* (`scripts/__baseline__/owliver-baseline.json`) and is compared here, per
|
||
* context and per question. `captureBaseline` is the same function that wrote
|
||
* the file, so there is one definition of what is measured.
|
||
*
|
||
* A failure here is a regression in the existing product, not a stale
|
||
* expectation. The file is regenerated deliberately, never to turn a check
|
||
* green.
|
||
*/
|
||
console.log('\n── Owliver behaviour baseline ──');
|
||
|
||
if (!existsSync(BASELINE_PATH)) {
|
||
record('baseline snapshot exists', false, 'run `node scripts/owliver-baseline.mjs --write`');
|
||
} else {
|
||
const expected = JSON.parse(readFileSync(BASELINE_PATH, 'utf8'));
|
||
const actual = await captureBaseline(server);
|
||
|
||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||
|
||
record('baseline schema matches', expected.schema === actual.schema,
|
||
`expected ${expected.schema}, got ${actual.schema}`);
|
||
|
||
/**
|
||
* Additive is allowed; losing something is not.
|
||
*
|
||
* The snapshot records the product as it was before the agent layer existed,
|
||
* and later phases legitimately add skills — that is what they are for. So a
|
||
* strict equality here would fail on every intended change and force the
|
||
* snapshot to be regenerated, which is exactly how a baseline stops catching
|
||
* anything.
|
||
*
|
||
* The contract is therefore a *subset*: every skill that existed then must
|
||
* still exist now. A skill disappearing is a regression and still fails.
|
||
* What must not move at all — routes, page keys, suggested prompts and intent
|
||
* routing — is asserted strictly below, and those are the checks that catch a
|
||
* new definition quietly taking over an existing question.
|
||
*/
|
||
const lostSkills = expected.skillIds.filter((id) => !actual.skillIds.includes(id));
|
||
const gainedSkills = actual.skillIds.filter((id) => !expected.skillIds.includes(id));
|
||
record('every skill that existed before the agent layer still registers',
|
||
lostSkills.length === 0,
|
||
lostSkills.length
|
||
? `LOST ${JSON.stringify(lostSkills)}`
|
||
: `${expected.skillIds.length} kept, ${gainedSkills.length} added since`);
|
||
|
||
record('registry still reports no diagnostics', actual.diagnostics.length === 0,
|
||
actual.diagnostics.map((d) => d.message).join(' | ') || 'none');
|
||
|
||
/**
|
||
* Route → context: every address that resolved before must resolve the same
|
||
* way now.
|
||
*
|
||
* Additive, for the same reason page skills are. A later phase can mount
|
||
* Owliver somewhere new — the agent configuration screen is the first — and a
|
||
* strict equality would fail on that intended addition and force the snapshot
|
||
* to be regenerated, which is how a baseline stops catching anything.
|
||
*
|
||
* What must never happen is an *existing* route resolving somewhere else, or
|
||
* ceasing to resolve at all: that is one of the eight operational pages
|
||
* quietly changing which assistant it carries. Both are failures here.
|
||
*/
|
||
const movedRoutes = Object.entries(expected.routes)
|
||
.filter(([route, contextId]) => actual.routes[route] !== contextId)
|
||
.map(([route, contextId]) => `${route}: ${contextId} → ${actual.routes[route] ?? 'nothing'}`);
|
||
const addedRoutes = Object.keys(actual.routes).filter((route) => !(route in expected.routes));
|
||
|
||
record('every route that resolved before resolves the same way',
|
||
movedRoutes.length === 0,
|
||
movedRoutes.join(' | ')
|
||
|| `${Object.keys(expected.routes).length} unchanged${addedRoutes.length ? `, added ${JSON.stringify(addedRoutes)}` : ''}`);
|
||
|
||
/* Per context, so a failure names the page it broke rather than reporting
|
||
that "something" moved. */
|
||
for (const contextId of Object.keys(expected.contexts)) {
|
||
const want = expected.contexts[contextId];
|
||
const got = actual.contexts[contextId];
|
||
const page = contextId.replace(/^admin\./, '');
|
||
|
||
if (!got) {
|
||
record(`${page}: context still exists`, false, 'context missing from the registry');
|
||
continue;
|
||
}
|
||
|
||
record(`${page}: page key and route unchanged`,
|
||
want.pageKey === got.pageKey && want.route === got.route,
|
||
`${got.pageKey} @ ${got.route}`);
|
||
|
||
/* The page boundary itself. A page may gain skills as later phases add
|
||
them; it may never lose one, because that is a capability the page had
|
||
and silently stopped offering. */
|
||
const lost = want.skills.filter((id) => !got.skills.includes(id));
|
||
const gained = got.skills.filter((id) => !want.skills.includes(id));
|
||
record(`${page}: keeps every skill it had`, lost.length === 0,
|
||
lost.length ? `LOST ${JSON.stringify(lost)}`
|
||
: gained.length ? `${want.skills.length} kept, gained ${JSON.stringify(gained)}`
|
||
: `${got.skills.length} skill(s), unchanged`);
|
||
|
||
const lostSuggestions = want.suggestions.filter((label) => !got.suggestions.includes(label));
|
||
record(`${page}: keeps every suggestion it offered`, lostSuggestions.length === 0,
|
||
lostSuggestions.length ? `LOST ${JSON.stringify(lostSuggestions)}`
|
||
: `${want.suggestions.length} kept, ${got.suggestions.length - want.suggestions.length} added`);
|
||
|
||
record(`${page}: suggested prompts unchanged`, same(want.prompts, got.prompts),
|
||
same(want.prompts, got.prompts)
|
||
? `${got.prompts.length} prompt(s)`
|
||
: `expected ${JSON.stringify(want.prompts)}, got ${JSON.stringify(got.prompts)}`);
|
||
|
||
/* Intent routing, question by question — skill matching, the branch taken,
|
||
and the shape of the answer. */
|
||
const drifted = want.intents.filter((w, i) => !same(w, got.intents[i]));
|
||
record(`${page}: intent routing unchanged`, drifted.length === 0,
|
||
drifted.length === 0
|
||
? `${want.intents.length} question(s)`
|
||
: drifted.map((d) => `"${d.question}"`).join(', '));
|
||
}
|
||
}
|
||
|
||
/* ── The agent provider ───────────────────────────────────────────────────────
|
||
*
|
||
* The provider that calls the real backend. Checked here rather than left to a
|
||
* browser because the failure modes are all shape failures — a run that came
|
||
* back Completed and rendered nothing, a proposed write rendered as prose with
|
||
* no button — and none of them is visible without asserting on the blocks.
|
||
*
|
||
* `fetch` is stubbed. The point is the mapping from a run result to blocks, not
|
||
* the network; the network is tested on the Go side, against a real model.
|
||
*/
|
||
console.log('\n── Agent provider ──');
|
||
{
|
||
const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js');
|
||
|
||
const drain = async (stream) => {
|
||
let last = [];
|
||
for await (const snapshot of stream) last = snapshot;
|
||
return last;
|
||
};
|
||
|
||
const withFetch = async (impl, fn) => {
|
||
const original = globalThis.fetch;
|
||
globalThis.fetch = impl;
|
||
try { return await fn(); } finally { globalThis.fetch = original; }
|
||
};
|
||
|
||
const jsonResponse = (body, status = 200) => ({
|
||
ok: status >= 200 && status < 300,
|
||
status,
|
||
json: async () => body,
|
||
});
|
||
|
||
const provider = providerMod.createAgentProvider({ baseUrl: '/api/v1' });
|
||
|
||
/* A completed run renders its answer. */
|
||
{
|
||
const blocks = await withFetch(
|
||
async () => jsonResponse({
|
||
runId: 'run_1', termination: 'Completed',
|
||
output: 'Twelve events, mostly logins.', usage: {},
|
||
}),
|
||
() => drain(provider.stream({ question: 'what happened?', agent: { id: 'activity-agent' } }))
|
||
);
|
||
record('agent provider: a completed run renders its answer',
|
||
blocks.some((b) => b.type === 'text' && b.text.includes('Twelve events')),
|
||
`${blocks.length} block(s)`);
|
||
record('agent provider: a completed run adds no system note',
|
||
!blocks.some((b) => b.type === 'note'));
|
||
}
|
||
|
||
/* A proposed write renders as a decision, not as prose. */
|
||
{
|
||
const blocks = await withFetch(
|
||
async () => jsonResponse({
|
||
runId: 'run_2', termination: 'ConfirmationPending',
|
||
message: 'The agent has proposed a change and is waiting for you to approve it.',
|
||
confirmations: [{
|
||
token: 'cnf_abc', tool: 'assign_worker',
|
||
title: 'Assign Maya Chen to Bar Supervisor',
|
||
summary: 'Maya Chen will be scheduled to work Friday evening.',
|
||
details: [{ label: 'Worker', value: 'Maya Chen' }],
|
||
warnings: ['Maya Chen already has 1 assignment over this period.'],
|
||
}],
|
||
usage: {},
|
||
}),
|
||
() => drain(provider.stream({ question: 'cover Friday', agent: { id: 'coverage-agent' } }))
|
||
);
|
||
const confirmation = blocks.find((b) => b.type === 'confirmation');
|
||
record('agent provider: a proposed write renders as a confirmation block',
|
||
Boolean(confirmation), confirmation ? confirmation.title : `${blocks.length} block(s)`);
|
||
record('agent provider: the confirmation carries its token',
|
||
confirmation?.token === 'cnf_abc');
|
||
record('agent provider: the confirmation carries its warnings',
|
||
confirmation?.warnings?.length === 1);
|
||
}
|
||
|
||
/* A token, once approved, travels on the next request — and nothing else about
|
||
the caller does. The browser cannot name a principal. */
|
||
{
|
||
let sent = null;
|
||
await withFetch(
|
||
async (_url, init) => { sent = JSON.parse(init.body); return jsonResponse({ termination: 'Completed', output: 'Assigned.', usage: {} }); },
|
||
() => drain(provider.stream({
|
||
question: 'cover Friday', agent: { id: 'coverage-agent' }, confirmation: 'cnf_abc',
|
||
}))
|
||
);
|
||
record('agent provider: an approval sends its token', sent?.confirmation === 'cnf_abc');
|
||
record('agent provider: the body names no principal',
|
||
sent && !('identity' in sent) && !('userId' in sent) && !('orgId' in sent),
|
||
Object.keys(sent || {}).join(', '));
|
||
}
|
||
|
||
/* Failures say what to do about them, and a 404 does not distinguish
|
||
"no such agent" from "not yours". */
|
||
{
|
||
for (const [status, expect] of [[401, 'Sign in again'], [404, 'not available'], [429, 'Try again']]) {
|
||
const blocks = await withFetch(
|
||
async () => jsonResponse({ error: { message: 'x' } }, status),
|
||
() => drain(provider.stream({ question: 'hi', agent: { id: 'a' } }))
|
||
);
|
||
const note = blocks.find((b) => b.type === 'note');
|
||
record(`agent provider: ${status} explains what to do`,
|
||
Boolean(note?.text?.includes(expect)), note?.text || 'no note');
|
||
}
|
||
}
|
||
|
||
/* No agent, no run. The panel resolves an agent before calling; reaching the
|
||
provider without one is a routing bug and should say so. */
|
||
{
|
||
let called = false;
|
||
const blocks = await withFetch(
|
||
async () => { called = true; return jsonResponse({}); },
|
||
() => drain(provider.stream({ question: 'hi', agent: null }))
|
||
);
|
||
record('agent provider: no agent means no request', !called);
|
||
record('agent provider: no agent says so', blocks.some((b) => b.type === 'note'));
|
||
}
|
||
}
|
||
|
||
/* ── Preferring the agent ─────────────────────────────────────────────────────
|
||
*
|
||
* The rule that decides whether a question is answered by the browser or by the
|
||
* agent. Checked here because getting it wrong in either direction is invisible
|
||
* from the outside: too eager and a form flow breaks; too shy and the panel
|
||
* keeps answering from templates while a real agent sits behind it.
|
||
*/
|
||
console.log('\n── Preferring the agent ──');
|
||
{
|
||
const routing = await server.ssrLoadModule('/src/components/ai-assistant/routing.js');
|
||
const { preferAgent } = routing;
|
||
|
||
const defers = (intent) => preferAgent(intent, { modelBacked: true }).kind === 'answer';
|
||
const keeps = (intent) => preferAgent(intent, { modelBacked: true }) === intent;
|
||
|
||
/* Text answers go to the agent — it reads the same rows and can be followed up. */
|
||
for (const kind of ['answer-doc', 'skill', 'workforce', 'outOfScope']) {
|
||
record(`prefer-agent: ${kind} defers to the agent`, defers({ kind }));
|
||
}
|
||
|
||
/* Anything that DOES something keeps its path. The agent has no tool for
|
||
these, so deferring would lose capability rather than gimmickry. */
|
||
const acts = [
|
||
['flow + create', { kind: 'flow', create: {} }],
|
||
['draft + perform', { kind: 'draft', perform: {} }],
|
||
['workforce + assign', { kind: 'workforce', assign: {} }],
|
||
['workforce + headcount', { kind: 'workforce', headcount: {} }],
|
||
['workforce + interview', { kind: 'workforce', interview: {} }],
|
||
['skill + action', { kind: 'skill', action: {} }],
|
||
];
|
||
for (const [label, intent] of acts) {
|
||
record(`prefer-agent: ${label} keeps its own path`, keeps(intent));
|
||
}
|
||
|
||
/* Deliberate product behaviour, left alone. */
|
||
record('prefer-agent: navigate is left alone', keeps({ kind: 'navigate', destination: {} }));
|
||
record('prefer-agent: constrained is left alone', keeps({ kind: 'constrained', agent: {} }));
|
||
|
||
/* With no agent behind the panel there is nothing better to defer TO, and
|
||
deferring would turn every template answer into a worse one. */
|
||
const inert = ['answer-doc', 'skill', 'workforce', 'outOfScope']
|
||
.every((kind) => preferAgent({ kind }, { modelBacked: false }).kind === kind);
|
||
record('prefer-agent: inert without a live agent', inert,
|
||
'the panel behaves exactly as it shipped');
|
||
}
|
||
|
||
/* ── Version pinning ──────────────────────────────────────────────────────────
|
||
*
|
||
* §3: running conversations pin the version they started with. The frontend's
|
||
* half is small — send back the version the first answer carried — and the
|
||
* consequence of getting it wrong is invisible: an edit published mid-thread
|
||
* would silently change which agent is answering, and the reader would see the
|
||
* change as the agent being inconsistent.
|
||
*/
|
||
console.log('\n── Version pinning ──');
|
||
{
|
||
const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js');
|
||
const provider = providerMod.createAgentProvider({ baseUrl: '/api/v1' });
|
||
|
||
const drain = async (stream) => { let last = []; for await (const s of stream) last = s; return last; };
|
||
const withFetch = async (impl, fn) => {
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = impl;
|
||
try { return await fn(); } finally { globalThis.fetch = orig; }
|
||
};
|
||
const json = (body) => ({
|
||
ok: true, status: 200,
|
||
headers: { get: () => 'application/json' },
|
||
json: async () => body,
|
||
});
|
||
|
||
let sent = null;
|
||
const capture = async (_u, init) => { sent = JSON.parse(init.body); return json({ termination: 'Completed', output: 'ok', usage: {} }); };
|
||
|
||
await withFetch(capture, () => drain(provider.stream({
|
||
question: 'q', agent: { id: 'a' }, agentVersion: 3,
|
||
})));
|
||
record('pinning: a pinned version travels with the question', sent.agentVersion === 3,
|
||
`agentVersion=${sent.agentVersion}`);
|
||
|
||
await withFetch(capture, () => drain(provider.stream({ question: 'q', agent: { id: 'a' } })));
|
||
record('pinning: an unpinned turn sends none, and gets whatever is current',
|
||
sent.agentVersion === undefined, `agentVersion=${sent.agentVersion}`);
|
||
|
||
/* The one that matters: an approval carries the version it was approved
|
||
under, so the write happens against the agent the person was shown. */
|
||
await withFetch(capture, () => drain(provider.stream({
|
||
question: 'q', agent: { id: 'a' }, confirmation: 'cnf_x', agentVersion: 2,
|
||
})));
|
||
record('pinning: an approval carries both its token and its version',
|
||
sent.confirmation === 'cnf_x' && sent.agentVersion === 2,
|
||
JSON.stringify(sent));
|
||
}
|
||
|
||
/* ── A deployment with no agent ───────────────────────────────────────────────
|
||
*
|
||
* The local simulator used to be the fallback. It is gone, so an unconfigured
|
||
* deployment now has NOTHING to answer with — and the whole risk of deleting it
|
||
* is that this state becomes silent. A panel that accepts a question and
|
||
* produces nothing is the worst of the possible failures and the hardest to
|
||
* diagnose from the outside.
|
||
*/
|
||
console.log('\n── No agent configured ──');
|
||
{
|
||
const { createUnconfiguredProvider } = await server.ssrLoadModule('/src/components/ai-assistant/provider.js');
|
||
const p = createUnconfiguredProvider();
|
||
|
||
let blocks = [];
|
||
for await (const snap of p.stream({ question: 'what needs my attention?' })) blocks = snap;
|
||
|
||
record('unconfigured: a question still gets an answer', blocks.length > 0,
|
||
`${blocks.length} block(s)`);
|
||
record('unconfigured: the answer says WHY it cannot answer',
|
||
blocks.some((b) => /not configured/i.test(b.text || '')),
|
||
blocks[0]?.text?.slice(0, 80) || 'nothing said');
|
||
record('unconfigured: it names what to set',
|
||
blocks.some((b) => /VITE_AGENT_API/.test(b.text || '')),
|
||
'an operator can act on it');
|
||
record('unconfigured: it is not silent', p.id === 'unconfigured');
|
||
}
|
||
|
||
/* ── Streaming ────────────────────────────────────────────────────────────────
|
||
*
|
||
* The property worth guarding is not "deltas arrive" — it is that a client
|
||
* reading only the LAST snapshot ends up where it would have without streaming.
|
||
* If that ever stops being true, streaming has quietly become a second, worse
|
||
* answering path.
|
||
*/
|
||
/**
|
||
* Follow-ups after an answer are follow-ups, not the landing screen redrawn.
|
||
*
|
||
* The suggestions themselves are the server's; what is asserted here is the
|
||
* only decision the panel makes about them — that a conversation is never
|
||
* offered a question it has already asked or already been offered. Without it a
|
||
* page holding six intents returns its top three after every answer, including
|
||
* the one just pressed.
|
||
*/
|
||
console.log('\n── Follow-ups ──');
|
||
{
|
||
const { nextSteps } = await server.ssrLoadModule('/src/components/ai-assistant/useAssistant.js');
|
||
|
||
const PAGE = [
|
||
{ label: 'How healthy is the platform right now?', prompt: 'How healthy is the platform right now?' },
|
||
{ label: 'What needs attention right now?', prompt: 'What needs attention right now?' },
|
||
{ label: 'What should I do next?', prompt: 'What should I do next?' },
|
||
];
|
||
/* A query-scoped answer is narrower, and led by the thing just asked — which
|
||
is exactly why it can come back empty once that one is excluded. */
|
||
/* A query-scoped answer is led by the thing just asked and carries some
|
||
neighbours with it — which is what makes exclusion meaningful rather than
|
||
total. */
|
||
const refresh = async ({ query = '' } = {}) => (
|
||
query ? (/next|should/i.test(query) ? [PAGE[2], PAGE[1], PAGE[0]] : PAGE.slice(0, 2)) : PAGE
|
||
);
|
||
const said = (r) => (r || []).map((c) => c.prompt);
|
||
|
||
record('the question just asked is never offered back',
|
||
await (async () => {
|
||
const out = said(await nextSteps({ question: 'What should I do next?', history: [], refresh }));
|
||
return !out.includes(PAGE[2].prompt) && out.length > 0;
|
||
})());
|
||
|
||
record('...nor is a chip this thread has already offered',
|
||
said(await nextSteps({
|
||
question: 'How healthy is the platform right now?',
|
||
history: [
|
||
{ role: 'user', text: 'What should I do next?' },
|
||
{ role: 'assistant', followUp: [PAGE[0], PAGE[1]] },
|
||
],
|
||
refresh,
|
||
})).length === 0);
|
||
|
||
record('...and case and punctuation are not differences',
|
||
JSON.stringify(said(await nextSteps({
|
||
/* No capital, no question mark — still the question already asked. */
|
||
question: 'what should i do next',
|
||
history: [{ role: 'user', text: 'What needs attention right now?' }],
|
||
refresh,
|
||
}))) === JSON.stringify([PAGE[0].prompt]));
|
||
|
||
/* The correction a live run forced: a question the catalogue does not cover
|
||
must not fall through to the page's own ranking. "Summarize hiring
|
||
activity" matched nothing, nothing was excluded, and the reader got the
|
||
three standing highlights under an unrelated answer. */
|
||
record('a question the catalogue does not cover offers nothing, not the page ranking',
|
||
(await nextSteps({
|
||
question: 'Summarize hiring activity', history: [],
|
||
refresh: async ({ query } = {}) => (query ? [] : PAGE),
|
||
})) === undefined);
|
||
|
||
record('...and the page ranking is never reached as a fallback',
|
||
(await nextSteps({
|
||
question: 'What should I do next?', history: [],
|
||
refresh: async ({ query } = {}) => (query ? [PAGE[2]] : PAGE),
|
||
})) === undefined);
|
||
|
||
record('when the catalogue is spent the row is left empty, not repeated',
|
||
(await nextSteps({
|
||
question: 'x',
|
||
history: [{ role: 'assistant', followUp: PAGE }],
|
||
refresh: async () => PAGE,
|
||
})) === undefined);
|
||
|
||
record('a response that repeats itself is collapsed',
|
||
said(await nextSteps({
|
||
question: 'x', history: [],
|
||
refresh: async () => [PAGE[0], PAGE[0], PAGE[1]],
|
||
})).length === 2);
|
||
}
|
||
|
||
console.log('\n── Streaming ──');
|
||
{
|
||
const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js');
|
||
const provider = providerMod.createAgentProvider({ baseUrl: '/api/v1' });
|
||
|
||
const sse = (frames) => ({
|
||
ok: true,
|
||
status: 200,
|
||
headers: { get: (k) => (k === 'Content-Type' ? 'text/event-stream' : null) },
|
||
body: {
|
||
getReader() {
|
||
const chunks = frames.map((f) => new TextEncoder().encode(`data: ${JSON.stringify(f)}\n\n`));
|
||
let i = 0;
|
||
return {
|
||
read: async () => (i < chunks.length ? { done: false, value: chunks[i++] } : { done: true }),
|
||
cancel: () => {},
|
||
};
|
||
},
|
||
},
|
||
});
|
||
|
||
const withFetch = async (impl, fn) => {
|
||
const orig = globalThis.fetch;
|
||
globalThis.fetch = impl;
|
||
try { return await fn(); } finally { globalThis.fetch = orig; }
|
||
};
|
||
|
||
const collect = async (stream) => {
|
||
const snaps = [];
|
||
for await (const s of stream) snaps.push(s);
|
||
return snaps;
|
||
};
|
||
|
||
/* Text arrives progressively, and the final run replaces it. */
|
||
{
|
||
const snaps = await withFetch(
|
||
async () => sse([
|
||
{ delta: '## Roles at risk\n\n' },
|
||
{ delta: 'Three roles have applicants but ' },
|
||
{ delta: 'none strong.' },
|
||
{ run: { termination: 'Completed', output: '## Roles at risk\n\nThree roles have applicants but none strong.', usage: {} } },
|
||
]),
|
||
() => collect(provider.stream({ question: 'risk?', agent: { id: 'positions-agent' } }))
|
||
);
|
||
|
||
record('streaming: the answer arrives in more than one snapshot', snaps.length > 1,
|
||
`${snaps.length} snapshots`);
|
||
record('streaming: text renders before the run finishes',
|
||
snaps[0].some((b) => b.type === 'heading' || b.type === 'text'),
|
||
JSON.stringify(snaps[0]).slice(0, 70));
|
||
|
||
/* The one that matters. */
|
||
const last = snaps[snaps.length - 1];
|
||
record('streaming: the last snapshot is what a non-streaming client would get',
|
||
last.some((b) => b.type === 'heading' && /Roles at risk/.test(b.text))
|
||
&& last.some((b) => b.type === 'text' && /none strong/.test(b.text)),
|
||
JSON.stringify(last).slice(0, 90));
|
||
}
|
||
|
||
/* A proposed write survives the stream — it rides on the final run event, not
|
||
on any delta, so a client that only read deltas would miss the decision. */
|
||
{
|
||
const snaps = await withFetch(
|
||
async () => sse([
|
||
{ delta: "I'll put someone forward." },
|
||
{ run: {
|
||
termination: 'ConfirmationPending',
|
||
output: "I'll put someone forward.",
|
||
confirmations: [{ token: 'cnf_x', title: 'Assign Maya Chen', summary: 'She will be scheduled.' }],
|
||
usage: {},
|
||
} },
|
||
]),
|
||
() => collect(provider.stream({ question: 'cover friday', agent: { id: 'positions-agent' } }))
|
||
);
|
||
const last = snaps[snaps.length - 1];
|
||
record('streaming: a proposed write survives to the final snapshot',
|
||
last.some((b) => b.type === 'confirmation' && b.token === 'cnf_x'));
|
||
}
|
||
|
||
/* A stream that dies mid-answer keeps what arrived. An empty panel would
|
||
throw away text the reader was already reading. */
|
||
{
|
||
const snaps = await withFetch(
|
||
async () => sse([{ delta: 'Half an answ' }]),
|
||
() => collect(provider.stream({ question: 'x', agent: { id: 'a' } }))
|
||
);
|
||
const last = snaps[snaps.length - 1];
|
||
record('streaming: a dropped connection keeps what arrived',
|
||
last.some((b) => /Half an answ/.test(b.text || '')),
|
||
JSON.stringify(last).slice(0, 60));
|
||
}
|
||
|
||
/* A server that answers JSON instead of a stream still works. */
|
||
{
|
||
const snaps = await withFetch(
|
||
async () => ({
|
||
ok: true, status: 200,
|
||
headers: { get: () => 'application/json' },
|
||
json: async () => ({ termination: 'Completed', output: 'Plain answer.', usage: {} }),
|
||
}),
|
||
() => collect(provider.stream({ question: 'x', agent: { id: 'a' } }))
|
||
);
|
||
record('streaming: a non-streaming server still answers',
|
||
snaps.at(-1).some((b) => /Plain answer/.test(b.text || '')));
|
||
}
|
||
}
|
||
|
||
/* ── An assumed attendance does not manufacture a score ──────────────────── */
|
||
|
||
/**
|
||
* `attendance_score` defaults to 100 so an unrated worker shows a full bar
|
||
* instead of an accusatory 0. It carries 30% of reliability and 12% of the KROW
|
||
* score, so used as a scoring input it lifted a profile with no evidence at all
|
||
* from 0 to 12 — out of "Not yet scored" and into the low band, where every
|
||
* ranking read it as a poor worker rather than an unassessed one. And this is
|
||
* live: recalcProfilePatch runs on course completion, skill assessments and
|
||
* identity creation, so a seeded 0 did not stay 0.
|
||
*/
|
||
const scoreModule = await server.ssrLoadModule('/src/lib/krowScore.js');
|
||
const noEvidence = seedModule.seedData.WorkerProfile.filter((p) => !(p.shifts_completed > 0));
|
||
record('the seed still contains profiles with no attendance record', noEvidence.length > 0,
|
||
`${noEvidence.length} of ${seedModule.seedData.WorkerProfile.length}`);
|
||
const manufactured = noEvidence.filter((p) => scoreModule.computeKrowScore(p).krow_score > 0);
|
||
record('a profile with no evidence scores 0, not a number made of an assumption',
|
||
manufactured.length === 0,
|
||
manufactured.map((p) => `${p.full_name}=${scoreModule.computeKrowScore(p).krow_score}`).join(', ') || 'all zero');
|
||
record('...and its breakdown reports attendance as absent, not as zero percent',
|
||
noEvidence.every((p) => scoreModule.computeKrowScore(p).breakdown.attendance === null));
|
||
|
||
/* Recalculating a seeded profile must change nothing.
|
||
krowScore.js calls itself "formula-based, auto-recalculated", but every one of
|
||
the nine seeded profiles disagreed with the formula that claims to compute it
|
||
— hand-authored narrative values on one side, an engine on the other, never
|
||
reconciled. That is not cosmetic: recalcProfilePatch runs on course
|
||
completion, skill assessments and identity creation, so a worker's score
|
||
jumped the moment they touched anything (Priya 68 → 78, a whole band). The
|
||
seed now stores the engine's own output, and this keeps the two in step. */
|
||
const scoreDrift = seedModule.seedData.WorkerProfile
|
||
.map((p) => ({ p, r: scoreModule.recalcProfilePatch(p) }))
|
||
.filter(({ p, r }) =>
|
||
r.krow_score !== p.krow_score ||
|
||
r.reliability_score !== p.reliability_score ||
|
||
r.profile_completion !== p.profile_completion);
|
||
record('recalculating a seeded profile changes nothing', scoreDrift.length === 0,
|
||
scoreDrift.map(({ p, r }) =>
|
||
`${p.full_name}: ${p.krow_score}/${p.reliability_score}/${p.profile_completion}` +
|
||
` -> ${r.krow_score}/${r.reliability_score}/${r.profile_completion}`).join('; ') || 'all 9 in step');
|
||
|
||
/* A completion percentage cannot exceed 100. It could: seven optional fields
|
||
incremented the numerator and the denominator counted five, so the most
|
||
complete profile computed 107% complete. */
|
||
const overComplete = seedModule.seedData.WorkerProfile
|
||
.filter((p) => scoreModule.computeProfileCompletion(p) > 100);
|
||
record('profile completion never exceeds 100%', overComplete.length === 0,
|
||
overComplete.map((p) => `${p.full_name}=${scoreModule.computeProfileCompletion(p)}`).join(', ') || 'all within range');
|
||
|
||
/* And the breakdown is percentages a person reads, not raw binary floats. */
|
||
const unrounded = seedModule.seedData.WorkerProfile.flatMap((p) =>
|
||
Object.entries(scoreModule.computeKrowScore(p).breakdown)
|
||
.filter(([, v]) => v !== null && !Number.isInteger(v))
|
||
.map(([k, v]) => `${p.full_name}.${k}=${v}`));
|
||
record('every breakdown figure is a whole percentage', unrounded.length === 0,
|
||
unrounded.join(', ') || 'all whole');
|
||
|
||
/* The guard must not silence a worker who has actually turned up. */
|
||
const withRecord = seedModule.seedData.WorkerProfile.filter((p) => p.shifts_completed > 0);
|
||
record('a worker with a shift record still scores on attendance',
|
||
withRecord.length > 0 &&
|
||
withRecord.every((p) => scoreModule.computeKrowScore(p).breakdown.attendance > 0),
|
||
`${withRecord.length} profiles with shifts`);
|
||
|
||
/* Real evidence still earns a score — the guard withholds, it does not block. */
|
||
const firstCourse = seedModule.seedData.Course[0];
|
||
const earned = scoreModule.recalcProfilePatch({
|
||
...noEvidence[0], completed_courses: [firstCourse.id], earned_badges: [],
|
||
xp: (noEvidence[0].xp || 0) + (firstCourse.xp || 0),
|
||
});
|
||
record('completing a real course does earn a score', earned.krow_score > 0,
|
||
`${noEvidence[0].full_name} → ${earned.krow_score}`);
|
||
|
||
/* ── Every application status can be placed on the funnel ────────────────── */
|
||
|
||
/**
|
||
* `atOrBeyond` ranks a status by its index in STAGE_ORDER, so a status missing
|
||
* from that list ranks -1 and falls out of *every* bucket including `applied`.
|
||
* That is what happened to `rejected` and `assigned`: a position's funnel simply
|
||
* lost them, and a role whose candidates had all been assigned to shifts read as
|
||
* unfilled. Nothing in the app held the full status list to check against.
|
||
*/
|
||
const records = await server.ssrLoadModule('/src/lib/hiringRecords.js');
|
||
const unplaceable = records.APPLICATION_STATUSES.filter((st) => records.rankOf(st) < 0);
|
||
record('every application status can be placed on the funnel',
|
||
unplaceable.length === 0, unplaceable.join(', ') || 'all placed');
|
||
|
||
record('a rejected candidate counts as screened',
|
||
records.rankOf('rejected') >= records.STAGE_ORDER.indexOf('ai_screened'));
|
||
record('...but is never counted as shortlisted, a stage rejection overwrites',
|
||
records.rankOf('rejected') < records.STAGE_ORDER.indexOf('shortlisted'));
|
||
record('an assigned candidate counts as hired',
|
||
records.HIRED_STATUSES.includes('assigned') &&
|
||
records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired'));
|
||
|
||
/* ── Final selection, and seats ──────────────────────────────────────────
|
||
*
|
||
* Final selection is DERIVED — there is no `final_selection` value in the
|
||
* `application_status` enum and none is added. The fact it reads is the
|
||
* existence of an interview row, which is a NOT NULL foreign key, rather than
|
||
* `job_applications.interview_id`, which the schema keeps as an unconstrained
|
||
* soft reference precisely so it may dangle.
|
||
*
|
||
* That distinction is the whole point: `status = 'interview'` is set both when
|
||
* an interview is arranged and when one is completed, so status alone cannot
|
||
* tell a queue of people who have been interviewed from a queue of people who
|
||
* have merely been booked in.
|
||
*/
|
||
const fsApps = [
|
||
{ id: 'a-applied', status: 'applied' },
|
||
{ id: 'a-booked', status: 'interview' }, // arranged, never sat
|
||
{ id: 'a-done', status: 'interview' }, // sat it
|
||
{ id: 'a-hired', status: 'hired' },
|
||
{ id: 'a-assigned', status: 'assigned' },
|
||
{ id: 'a-rejected', status: 'rejected' },
|
||
];
|
||
const fsInterviews = [
|
||
{ id: 'i1', application_id: 'a-done' },
|
||
{ id: 'i2', application_id: 'a-hired' },
|
||
{ id: 'i3', application_id: 'a-rejected' },
|
||
];
|
||
const queue = records.finalSelection(fsApps, fsInterviews).map((a) => a.id);
|
||
|
||
record('final selection holds only the interviewed and undecided',
|
||
queue.length === 1 && queue[0] === 'a-done', queue.join(', ') || 'empty');
|
||
record('...so an interview merely arranged does not qualify',
|
||
!records.isFinalSelection(fsApps[1], fsInterviews),
|
||
'status interview with no interview row');
|
||
record('...and a decided application never reappears in the queue',
|
||
['a-hired', 'a-assigned', 'a-rejected']
|
||
.every((id) => !queue.includes(id)),
|
||
'hired, assigned and rejected all excluded');
|
||
record('...while the interview record, not interview_id, is what is read',
|
||
!records.isFinalSelection({ id: 'x', status: 'interview', interview_id: 'dangling' }, []),
|
||
'a soft reference alone proves nothing');
|
||
|
||
/* Seats are counted from the employment records. A `filled` column would be a
|
||
second source of truth, and nothing could say which one was right. */
|
||
const seatPosting = { id: 'p1', headcount: 2 };
|
||
const seatStaff = [
|
||
{ id: 's1', job_posting_id: 'p1', status: 'active' },
|
||
{ id: 's2', job_posting_id: 'p1', status: 'onboarding' },
|
||
{ id: 's3', job_posting_id: 'p1', status: 'inactive' }, // left; seat reopens
|
||
{ id: 's4', job_posting_id: 'p2', status: 'active' }, // another position
|
||
];
|
||
record('seats filled are counted from the staff records',
|
||
records.filledFor('p1', seatStaff) === 2, `${records.filledFor('p1', seatStaff)} of 2`);
|
||
record('...someone who has left frees their seat',
|
||
records.remainingFor({ id: 'p1', headcount: 3 }, seatStaff) === 1,
|
||
'inactive staff do not hold a seat');
|
||
record('...a full position reports no remaining seats, never a negative number',
|
||
records.isFullyStaffed(seatPosting, seatStaff)
|
||
&& records.remainingFor({ id: 'p1', headcount: 1 }, seatStaff) === 0,
|
||
'over-hiring floors at zero');
|
||
|
||
/* ── The Candidates page IS the final-selection queue ─────────────────────
|
||
*
|
||
* The derivation above is only worth having if the page that decides hires
|
||
* actually opens on it. Candidates used to list every application the org had
|
||
* ever taken — all seven statuses at once — which made it a second Talent Pool
|
||
* rather than a queue of decisions waiting.
|
||
*
|
||
* Two rules these assertions hold in place, both of them safety rules rather
|
||
* than presentation ones:
|
||
*
|
||
* The evidence is the interview ROW. `interview_id` is a soft reference the
|
||
* schema deliberately leaves unconstrained, and it dangles in real data.
|
||
*
|
||
* The score never gates. `ai_score`, `verdict` and `hire_recommendation`
|
||
* assist a recruiter; a human makes the selection. A candidate who scored
|
||
* zero and sat the interview is still a decision somebody owes them.
|
||
*/
|
||
record('a candidate who scored zero still reaches the queue',
|
||
records.isFinalSelection({ id: 'a-zero', status: 'interview', ai_score: 0 },
|
||
[{ id: 'i9', application_id: 'a-zero' }]),
|
||
'screening is not a gate on the decision');
|
||
|
||
{
|
||
/* Comments are stripped first: these assert what the page DOES, and a
|
||
comment explaining the bug that was fixed must not read as the bug. */
|
||
const decomment = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
const candidatesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/Candidates.jsx')), 'utf8'));
|
||
const nodesSrc = decomment(readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/candidates/nodes.jsx')), 'utf8'));
|
||
/* Read from source rather than imported: the page pulls in the interview
|
||
modal, which touches `window` at module scope, and standing up a DOM to
|
||
read one string constant would be a worse test than reading the string. */
|
||
const defaultStage = candidatesSrc.match(/export const DEFAULT_STAGE = '([a-z_]+)'/)?.[1];
|
||
|
||
record('the candidate page opens on the final-selection queue',
|
||
defaultStage === 'final' && /useState\(DEFAULT_STAGE\)/.test(candidatesSrc),
|
||
String(defaultStage));
|
||
|
||
/* The interview rows were fetched and then thrown away — `useInterviews();`
|
||
on its own line, its result unbound — which is why the queue could not be
|
||
derived on the one page that needed it. */
|
||
record('the candidate page keeps the interview rows it fetches',
|
||
/(const|let)\s*\{[^}]*\}\s*=\s*useInterviews\(\)/.test(candidatesSrc),
|
||
'useInterviews() is bound, not discarded');
|
||
|
||
/* The filter compared `a.stage`. Applications carry `status`; no application
|
||
has ever had a `stage` property, so every stage selection emptied the
|
||
page. */
|
||
record('the stage filter reads a field applications actually carry',
|
||
!/\ba\.stage\b/.test(candidatesSrc) && /matchesStage\(/.test(candidatesSrc),
|
||
'status, not an undefined stage');
|
||
|
||
/* Narrowing the default view is only safe if nothing becomes unreachable.
|
||
`shortlisted` and `assigned` were absent from these options, so the rows
|
||
holding them could not be found by any selection. */
|
||
const offered = [...nodesSrc.matchAll(/\{ value: '([a-z_]+)', label: '[^']+' \}/g)]
|
||
.map((m) => m[1]);
|
||
const unreachable = records.APPLICATION_STATUSES.filter((st) => !offered.includes(st));
|
||
record('every application status is still reachable from the stage filter',
|
||
unreachable.length === 0, unreachable.join(', ') || 'all seven offered');
|
||
|
||
record('...and the queue itself is offered alongside them',
|
||
offered.includes('final') && offered.includes('all'),
|
||
'final selection and all stages');
|
||
}
|
||
|
||
/* ── Human interviews leave a trace, and only completion counts ───────────
|
||
*
|
||
* A human interview used to persist nothing at all: the modal collected a
|
||
* type, a date, a time and notes, announced "Interview scheduled!", and dropped
|
||
* every one of them. So the final-selection queue could only ever hold people
|
||
* an AI had interviewed, and anyone seen by a person was invisible to it.
|
||
*
|
||
* What changed is where each fact lives, and the split is the safety property:
|
||
*
|
||
* booking → a `user_activity` row. Advances nobody.
|
||
* completion→ an `ai_interviews` row. The evidence, unchanged since Step 6.
|
||
*
|
||
* `isFinalSelection` is not touched by any of this and is not expected to be.
|
||
* These assertions exist to prove that a booking cannot masquerade as an
|
||
* interview, which is the one way this work could put a candidate in front of a
|
||
* hiring decision that nobody has actually met.
|
||
*/
|
||
const human = await server.ssrLoadModule('/src/lib/humanInterviews.js');
|
||
|
||
{
|
||
const app = { id: 'a-human', status: 'interview', job_posting_id: 'p1', applicant_name: 'A' };
|
||
const at = (n) => new Date(Date.UTC(2026, 0, n)).toISOString();
|
||
|
||
const bookedEvent = {
|
||
event_type: human.SCHEDULED, application_id: 'a-human', created_date: at(1),
|
||
metadata: { interview_type: 'video', scheduled_date: '2026-01-09', scheduled_time: '10:00', notes: 'prep' },
|
||
};
|
||
const completedRow = { id: 'i-human', application_id: 'a-human' };
|
||
|
||
/* Booked, and nothing else. */
|
||
record('a booked human interview is recorded',
|
||
human.humanInterviewState(app, [bookedEvent], []) === 'scheduled');
|
||
record('...and the booking keeps what was entered',
|
||
(() => {
|
||
const d = human.scheduleDetails(human.scheduledInterview(app, [bookedEvent]));
|
||
return d.type === 'video' && d.date === '2026-01-09' && d.time === '10:00' && d.notes === 'prep';
|
||
})(), 'type, date, time and notes survive');
|
||
record('...but booking alone is NOT final selection',
|
||
!records.isFinalSelection(app, []),
|
||
'no interview row, no decision to make');
|
||
|
||
/* Held. */
|
||
record('a completed human interview is recorded',
|
||
human.humanInterviewState(app, [bookedEvent], [completedRow]) === 'completed');
|
||
record('...and a completed human interview DOES reach final selection',
|
||
records.isFinalSelection(app, [completedRow]));
|
||
record('...even with no score, because a human interview measures none',
|
||
records.isFinalSelection({ ...app, ai_score: 0 }, [completedRow]),
|
||
'absence of an AI score is not absence of an interview');
|
||
|
||
/* Not held. Both outcomes are events, and neither is an interview. */
|
||
for (const [label, type] of [['cancelled', human.CANCELLED], ['not attended', human.NO_SHOW]]) {
|
||
const events = [bookedEvent, { event_type: type, application_id: 'a-human', created_date: at(2) }];
|
||
record(`an interview ${label} leaves no completion record`,
|
||
human.humanInterviewState(app, events, []) === (type === human.CANCELLED ? 'cancelled' : 'no_show'));
|
||
record(`...so ${label} never reaches final selection`,
|
||
!records.isFinalSelection(app, []));
|
||
}
|
||
|
||
/* A decided application is out regardless of how it was interviewed. */
|
||
for (const status of ['hired', 'assigned', 'rejected']) {
|
||
record(`a ${status} application leaves the queue even with a human interview`,
|
||
!records.isFinalSelection({ ...app, status }, [completedRow]));
|
||
}
|
||
|
||
/* The Step 6 guard, restated against a human booking: a booking may well set
|
||
interview_id one day, and it must still prove nothing on its own. */
|
||
record('a dangling interview_id still does not qualify a human interview',
|
||
!records.isFinalSelection({ ...app, interview_id: 'nope' }, []),
|
||
'the row is the evidence, not the reference');
|
||
|
||
/* An event claiming completion with no row behind it. The log is not the
|
||
record, and treating it as one would reintroduce exactly the soft-reference
|
||
problem interview_id already demonstrates. */
|
||
record('a completion EVENT without an interview row is not completion',
|
||
human.humanInterviewState(app,
|
||
[{ event_type: human.COMPLETED, application_id: 'a-human', created_date: at(3) }], []) === 'none',
|
||
'user_activity is a log, not the evidence');
|
||
}
|
||
|
||
/* ── Nothing about AI is invented for a human interview ───────────────────
|
||
*
|
||
* The completed-interview record lives in `ai_interviews`, which is currently
|
||
* reused as the durable completion record for both flows. Reusing the table is
|
||
* a deliberate decision; filling in its AI columns with plausible numbers no
|
||
* machine produced would not be. A fabricated score or verdict would be read by
|
||
* analytics, by the candidate profile and by a recruiter as a measurement.
|
||
*
|
||
* `overall_interview_score` matters twice over: the server copies it onto the
|
||
* application only when the request mentions it, so sending a 0 would overwrite
|
||
* a real screening score with one this interview never took.
|
||
*/
|
||
{
|
||
const hooksSrc = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
|
||
const complete = hooksSrc.slice(
|
||
hooksSrc.indexOf('export function useCompleteHumanInterview'),
|
||
hooksSrc.indexOf('export function useRecordInterviewNotHeld')
|
||
);
|
||
const schedule = hooksSrc.slice(
|
||
hooksSrc.indexOf('export function useScheduleHumanInterview'),
|
||
hooksSrc.indexOf('export function useCompleteHumanInterview')
|
||
);
|
||
|
||
record('the human completion path exists at all', complete.length > 0 && schedule.length > 0);
|
||
|
||
const fabricated = ['overall_interview_score', 'integrity_score', 'ai_flags', 'category_scores',
|
||
'hire_recommendation', 'messages', 'ai_score']
|
||
.filter((f) => new RegExp(`^\\s*${f}\\s*:`, 'm').test(complete));
|
||
record('a human interview fabricates no AI data',
|
||
fabricated.length === 0, fabricated.join(', ') || 'no AI column is written');
|
||
|
||
record('...and the interviewer\'s own assessment is only sent when given',
|
||
/if \(assessment\) record\.verdict = assessment;/.test(complete),
|
||
'verdict is human-entered or left to the schema');
|
||
|
||
record('...while the facts it does write are the ones it knows',
|
||
/application_id: application\.id/.test(complete)
|
||
&& /job_posting_id: application\.job_posting_id/.test(complete));
|
||
|
||
/* The rule the whole step turns on. */
|
||
record('scheduling never writes an interview record',
|
||
!/AIInterview|useCreateInterview|createInterview/.test(schedule),
|
||
'a booking touches user_activity only');
|
||
|
||
record('...and completion goes through the existing interview client',
|
||
/createInterview\.mutateAsync/.test(complete),
|
||
'no second interview API');
|
||
|
||
/* Not held writes an event and nothing else — no row, and no invented
|
||
application status to go with it. */
|
||
const notHeld = hooksSrc.slice(hooksSrc.indexOf('export function useRecordInterviewNotHeld'));
|
||
record('a cancelled or unattended interview writes no record and no new status',
|
||
!/AIInterview|createInterview/.test(notHeld) && !/JobApplication\.update/.test(notHeld),
|
||
'an event, and nothing else');
|
||
}
|
||
|
||
/* The AI flow is untouched: it still writes its own record at completion, and
|
||
still does it through the same client this now shares. */
|
||
{
|
||
const aiSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/AIInterviewModal.jsx')), 'utf8');
|
||
record('the AI interview still records itself on completion',
|
||
/const finishInterview = useCallback/.test(aiSrc)
|
||
&& /createInterview\.mutateAsync\(\{/.test(aiSrc)
|
||
&& /messages: finalMessages/.test(aiSrc),
|
||
'unchanged by the human path');
|
||
}
|
||
|
||
/* The modal is no longer theatre. */
|
||
{
|
||
const modalSrc = readFileSync(join(ROOT, resolveSourcePath('src/components/krow/ScheduleInterviewModal.jsx')), 'utf8');
|
||
record('scheduling an interview persists it',
|
||
/useScheduleHumanInterview/.test(modalSrc) && /schedule\.mutateAsync/.test(modalSrc));
|
||
record('...the props its six call sites pass are unchanged',
|
||
/\{ open, onClose, application, position \}/.test(modalSrc),
|
||
'open, onClose, application — position optional');
|
||
record('...and a failed save is reported rather than celebrated',
|
||
/setError\(/.test(modalSrc) && !/setScheduled\(true\)/.test(modalSrc),
|
||
'the old version always claimed success');
|
||
}
|
||
|
||
/* Every path that files an application on somebody's behalf must carry the
|
||
link back to the talent-pool record. The column is nullable, so omitting it
|
||
saves cleanly and fails silently: the application belongs to an email address
|
||
instead of to a person, and the hire it becomes cannot be traced back to the
|
||
profile it came from. Both call sites are asserted because they were written
|
||
at different times and only one of them is on the position page. */
|
||
const positionDetailSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/PositionDetail.jsx')), 'utf8');
|
||
const hooksSource = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
|
||
record('admitting talent to a position links the application to the profile',
|
||
/worker_profile_id: profile\.id/.test(positionDetailSource),
|
||
'PositionDetail.admitTalent');
|
||
record('...and so does assigning a worker straight from the pool',
|
||
/worker_profile_id: worker\.profile\?\.id/.test(hooksSource),
|
||
'useAssignWorkers filing an application');
|
||
|
||
/* ── One person, many positions ──────────────────────────────────────────
|
||
*
|
||
* The two profile routes answer different questions and must stay separate.
|
||
* `/admin/talent/:id` is the PERSON — a `worker_profiles` row, and every
|
||
* position they are up for. `/admin/candidates/:id` is that person FOR ONE
|
||
* POSITION — a `job_applications` row, with its score, interview and decision.
|
||
*
|
||
* One screen doing both is how a product ends up with a candidate record per
|
||
* vacancy, which is exactly what `worker_profiles` exists to prevent.
|
||
*/
|
||
record('the person and the application have separate routes',
|
||
routed.has('/admin/talent/:id') && routed.has('/admin/candidates/:id'),
|
||
[...routed].filter((r) => r.includes('talent') || r.includes('candidates')).join(', '));
|
||
|
||
/* Which applications are this person's. The foreign key is the answer; email is
|
||
the fallback for rows written before the key was being set, and it has to be
|
||
there or every application already in the database detaches from its person. */
|
||
const whoProfile = { id: 'wp-1', email: 'Maria@Example.com' };
|
||
const whoApps = [
|
||
{ id: 'by-key', worker_profile_id: 'wp-1', email: 'nothing@else.com' },
|
||
{ id: 'by-email', worker_profile_id: null, email: 'maria@example.com' },
|
||
{ id: 'by-email-case', email: 'MARIA@EXAMPLE.COM' },
|
||
{ id: 'somebody-else', worker_profile_id: 'wp-2', email: 'other@example.com' },
|
||
];
|
||
const whoIds = records.applicationsForProfile(whoApps, whoProfile).map((a) => a.id);
|
||
record('an application finds its person by foreign key',
|
||
whoIds.includes('by-key'), 'worker_profile_id');
|
||
record('...and falls back to email, case-insensitively, for older rows',
|
||
whoIds.includes('by-email') && whoIds.includes('by-email-case'),
|
||
'citext identity, as worker_profiles already asserts');
|
||
record('...without collecting somebody else',
|
||
!whoIds.includes('somebody-else') && whoIds.length === 3, whoIds.join(', '));
|
||
record('...and asks for nobody when there is no profile',
|
||
records.applicationsForProfile(whoApps, null).length === 0, 'empty, not everything');
|
||
|
||
/* The reported symptom: a talent pool you could read and not use. */
|
||
const talentPoolSource = readFileSync(join(ROOT, resolveSourcePath('src/pages/admin/talent-pool/nodes.jsx')), 'utf8');
|
||
record('a talent pool row opens that person',
|
||
/onRowClick=\{\(p\) => navigate\(`\/admin\/talent\/\$\{p\.id\}`\)\}/.test(talentPoolSource),
|
||
'DataTable wires the handler only when it is given one');
|
||
record('...and no action pretends to open something it does not',
|
||
!/toast\.info\(`Opening/.test(talentPoolSource),
|
||
'the "view profile" toast is gone');
|
||
record('...and a person can be put in front of a vacancy from the pool',
|
||
/setConsidering\(p\)/.test(talentPoolSource)
|
||
&& /AddToPositionModal/.test(talentPoolSource),
|
||
'the step that was missing between pool and position');
|
||
|
||
/* "Screened and waiting on a decision" includes the shortlisted. This counted
|
||
`ai_screened` alone, which read as correct only while nothing was ever
|
||
shortlisted — the first shortlisted candidate dropped out of the count in
|
||
silence. The baseline records the prompt; this records the rule. */
|
||
const insightsFacts = insightsModule.buildFacts({
|
||
postings: SEED.JobPosting,
|
||
interviews: [],
|
||
staff: [],
|
||
profiles: [],
|
||
activity: [],
|
||
courses: [],
|
||
profile: null,
|
||
user: seedModule.DEMO_USER,
|
||
today: new Date('2026-08-20T09:00:00.000Z'),
|
||
applications: [
|
||
{ id: 's1', status: 'shortlisted', ai_score: 91, job_posting_id: 'p', applicant_name: 'Shortlisted Strong' },
|
||
{ id: 's2', status: 'ai_screened', ai_score: 85, job_posting_id: 'p', applicant_name: 'Screened Strong' },
|
||
{ id: 's3', status: 'shortlisted', ai_score: 40, job_posting_id: 'p', applicant_name: 'Shortlisted Weak' },
|
||
],
|
||
});
|
||
record('a shortlisted strong candidate counts as waiting on a decision',
|
||
insightsFacts.stalled.length === 2,
|
||
insightsFacts.stalled.map((a) => a.applicant_name).join(', ') || 'none');
|
||
|
||
/* The backend fixture is generated from this seed, not maintained beside it.
|
||
`seed/fixtures/seed.json` is what the Go seeder loads and `src/api/seed.js` is
|
||
what a person edits. They were two hand-maintained copies of the same data,
|
||
which fails quietly: the demo and the API would answer the same question with
|
||
different numbers, and the first symptom would be a page disagreeing with an
|
||
agent. This asserts the committed fixture is exactly what this seed implies —
|
||
`npm run seed:fixture` regenerates it. */
|
||
const builtFixture = await buildFixture(server);
|
||
if (!existsSync(FIXTURE_PATH)) {
|
||
record('the backend fixture is in step with this seed', false,
|
||
'seed.json missing — run `npm run seed:fixture`');
|
||
} else {
|
||
const onDisk = readFileSync(FIXTURE_PATH, 'utf8');
|
||
record('the backend fixture is in step with this seed', onDisk === builtFixture,
|
||
onDisk === builtFixture
|
||
? `${JSON.parse(builtFixture).entities.JobApplication.length} applications, byte-identical`
|
||
: 'stale — run `npm run seed:fixture`');
|
||
}
|
||
|
||
/* ShiftRecord stays out of the fixture on purpose: its dates anchor to the day
|
||
the seeder runs, so the Go side generates it rather than reading a snapshot
|
||
that would go stale. */
|
||
record('...and leaves the date-anchored shift records to the Go seeder',
|
||
!('ShiftRecord' in JSON.parse(builtFixture).entities) && seedModule.seedData.ShiftRecord.length > 0,
|
||
`${seedModule.seedData.ShiftRecord.length} shift records held in seed.js only`);
|
||
|
||
/* ── The registry does not report a shipped agent as an override of itself ── */
|
||
|
||
/**
|
||
* Authored agents now come from `/api/v1/agent-definitions`, and that registry
|
||
* holds both kinds: the shipped set, published from the same files this build
|
||
* ships, and anything a person authored. Passing the first kind through as an
|
||
* authored source made every shipped agent report that it "replaces the
|
||
* built-in agent of the same id" — nine warnings on the one screen that exists
|
||
* to say what is wrong, about a deployment that was working correctly.
|
||
*
|
||
* The rows below are the shape the live registry actually returns.
|
||
*/
|
||
const storeModule = await server.ssrLoadModule('/src/lib/agents/agentStore.js');
|
||
const agentsModule = await server.ssrLoadModule('/src/lib/agents/registry.js');
|
||
const shippedIds = new Set(agentsModule.AGENTS.map((a) => a.id));
|
||
const registryRows = [
|
||
...agentsModule.AGENTS.map((a) => ({
|
||
definition_id: a.id, visibility: 'organization', markdown: a.markdown,
|
||
})),
|
||
{
|
||
definition_id: 'authored-example', visibility: 'personal',
|
||
markdown: '---\nid: authored-example\nname: Authored Example\nstatus: published\n'
|
||
+ 'version: 1\npages:\n - control-center\n---\n\n# Authored Example\n',
|
||
},
|
||
];
|
||
const built = agentsModule.readAgentRegistry(
|
||
storeModule.sourcesFrom(registryRows, shippedIds), { customSkills: [] });
|
||
const falseShadows = built.diagnostics.filter((d) => d.kind === 'shadowed');
|
||
record('a shipped agent published to the registry is not reported as overriding itself',
|
||
falseShadows.length === 0,
|
||
falseShadows.map((d) => d.agentId).join(', ') || 'no false overrides');
|
||
record('...and an authored agent still appears beside the shipped ones',
|
||
built.agents.some((a) => a.id === 'authored-example')
|
||
&& built.agents.length === agentsModule.AGENTS.length + 1,
|
||
`${built.agents.length} agents from ${registryRows.length} rows`);
|
||
|
||
/* A genuine personal override of a shipped id is still reported — the
|
||
diagnostic is useful, it was only being raised for the wrong rows. */
|
||
const overridden = agentsModule.readAgentRegistry(
|
||
storeModule.sourcesFrom([{
|
||
definition_id: agentsModule.AGENTS[0].id, visibility: 'personal',
|
||
markdown: agentsModule.AGENTS[0].markdown,
|
||
}], shippedIds), { customSkills: [] });
|
||
record('a personal override of a shipped agent is still reported as one',
|
||
overridden.diagnostics.some((d) => d.kind === 'shadowed'),
|
||
overridden.diagnostics.map((d) => d.kind).join(', ') || 'none');
|
||
|
||
/* The seed must exercise every status, or none of the above can catch anything. */
|
||
const seededStatuses = new Set(seedModule.seedData.JobApplication.map((a) => a.status));
|
||
const neverSeeded = records.APPLICATION_STATUSES.filter((st) => !seededStatuses.has(st));
|
||
record('the seed exercises every application status',
|
||
neverSeeded.length === 0,
|
||
neverSeeded.length ? `never produced: ${neverSeeded.join(', ')}` : `all ${seededStatuses.size}`);
|
||
record('...and at least one assignment exists, so `assigned` means something',
|
||
seedModule.seedData.Assignment.length > 0,
|
||
`${seedModule.seedData.Assignment.length} assignment(s)`);
|
||
record('...while most positions still have none, so the empty case stays covered',
|
||
seedModule.seedData.Assignment.length < seedModule.seedData.JobPosting.length,
|
||
`${seedModule.seedData.Assignment.length} of ${seedModule.seedData.JobPosting.length} positions`);
|
||
|
||
/* The accounting identity: a funnel that loses people is not a funnel. */
|
||
const positionsModule = await server.ssrLoadModule('/src/lib/admin/positionInsights.js');
|
||
const everyStage = [
|
||
{ id: '1', job_posting_id: 'p', status: 'applied', ai_score: 0 },
|
||
{ id: '2', job_posting_id: 'p', status: 'ai_screened', ai_score: 75 },
|
||
{ id: '3', job_posting_id: 'p', status: 'rejected', ai_score: 40 },
|
||
{ id: '4', job_posting_id: 'p', status: 'assigned', ai_score: 88 },
|
||
];
|
||
const posStats = positionsModule.buildPosition(
|
||
{ id: 'p', title: 'Bartender', status: 'active' }, everyStage).stats;
|
||
record('a position accounts for every applicant it has',
|
||
posStats.unscreened + posStats.screened === posStats.applied,
|
||
`${posStats.unscreened} unscreened + ${posStats.screened} screened of ${posStats.applied}`);
|
||
record('a rejected applicant is screened but not shortlisted',
|
||
posStats.screened === 3 && posStats.shortlisted === 1,
|
||
`screened ${posStats.screened}, shortlisted ${posStats.shortlisted}`);
|
||
record('an assigned applicant still counts as hired on the position',
|
||
posStats.hired === 1, `hired ${posStats.hired}`);
|
||
|
||
const funnel = records.buildFunnel(everyStage);
|
||
record('the funnel never grows as it narrows',
|
||
funnel.stages.every((st, i) => i === 0 || st.count <= funnel.stages[i - 1].count),
|
||
funnel.stages.map((st) => `${st.label}=${st.count}`).join(' '));
|
||
|
||
/* The list above is the app's claim about the schema. This is the schema. */
|
||
const SCHEMA_SQL = join(ROOT, '..', 'krow-backend', 'migrations', '000001_initial_schema.up.sql');
|
||
if (existsSync(SCHEMA_SQL)) {
|
||
const declared = readFileSync(SCHEMA_SQL, 'utf8')
|
||
.match(/CREATE TYPE application_status AS ENUM \(([^)]*)\)/)?.[1]
|
||
?.match(/'([a-z_]+)'/g)?.map((q) => q.slice(1, -1)) ?? [];
|
||
record('the app knows every status the schema defines',
|
||
declared.length > 0 &&
|
||
declared.every((st) => records.APPLICATION_STATUSES.includes(st)) &&
|
||
records.APPLICATION_STATUSES.every((st) => declared.includes(st)),
|
||
`schema: ${declared.join(', ')}`);
|
||
} else {
|
||
record('the app knows every status the schema defines', true,
|
||
'krow-backend not present beside this repo — cross-check skipped, not verified');
|
||
}
|
||
|
||
/* ── The dashboard cards obey the same rule as the resolvers ─────────────── */
|
||
|
||
/**
|
||
* `talent.pool` above proves the resolver does not average away the unscored.
|
||
* The dashboard cards compute their own figures and were never covered, and
|
||
* three of them broke the rule: two averaged an unscored hire in as a zero, and
|
||
* one labelled its AI-scored count "screened" — a word the rest of the product
|
||
* uses for the stage (status <> 'applied'), so that card and the unscreened
|
||
* count on the same dashboard did not add up to the pool.
|
||
*
|
||
* Rendered rather than reimplemented: the figure that matters is the one a
|
||
* person reads off the card.
|
||
*/
|
||
const renderCard = async (path, props) =>
|
||
renderToStaticMarkup(React.createElement((await server.ssrLoadModule(path)).default, props));
|
||
|
||
const twoHiresOneScored = [
|
||
{ id: 'h1', status: 'hired', ai_score: 80, created_date: '2026-01-01', updated_date: '2026-01-02' },
|
||
{ id: 'h2', status: 'hired', ai_score: 0, created_date: '2026-01-01', updated_date: '2026-01-02' },
|
||
];
|
||
|
||
const hiringCard = await renderCard('/src/components/krow/HiringAnalytics.jsx',
|
||
{ applications: twoHiresOneScored, interviews: [], staff: [] });
|
||
const shownAvg = hiringCard.match(/>([\d—]+)<\/div><div class="text-\[10px\][^"]*"[^>]*>AVG SCORE/)?.[1];
|
||
record('hiring analytics averages only the hires that carry a score',
|
||
shownAvg === '80', `card shows ${shownAvg ?? '(not found)'}, want 80 (not 40)`);
|
||
|
||
const noneScored = twoHiresOneScored.map((a) => ({ ...a, ai_score: 0 }));
|
||
const hiringNone = await renderCard('/src/components/krow/HiringAnalytics.jsx',
|
||
{ applications: noneScored, interviews: [], staff: [] });
|
||
record('...and shows an em dash rather than a confident 0 when none is scored',
|
||
/—<\/div><div class="text-\[10px\][^"]*"[^>]*>AVG SCORE/.test(hiringNone));
|
||
|
||
const impact = await renderCard('/src/components/krow/ImpactMetrics.jsx',
|
||
{ applications: twoHiresOneScored, interviews: [], staff: [] });
|
||
record('quality of hire averages only the scored hires',
|
||
impact.includes('80/100'), impact.includes('40/100') ? 'shows 40/100' : 'no 40/100');
|
||
record('...and its caption counts the hires the figure is made of',
|
||
/1 scored hire/.test(impact), /2 hired candidates/.test(impact) ? 'still says 2 hired candidates' : '');
|
||
|
||
const dist = await renderCard('/src/components/krow/ScoreDistribution.jsx',
|
||
{ applications: twoHiresOneScored });
|
||
record('the score card says "scored", not "screened"',
|
||
/1 candidate scored/.test(dist.replace(/\s+/g, ' ')) && !/candidates? screened/.test(dist),
|
||
dist.replace(/\s+/g, ' ').match(/\d+ candidates? \w+/)?.[0] ?? '(caption not found)');
|
||
|
||
let hadWindow = true;
|
||
|
||
/* Runs LAST, deliberately. Loading the block renderers pulls in the design
|
||
system, which needs a `window` to evaluate — and framer-motion, loaded in the
|
||
same graph, decides once at import time whether it is in a browser and caches
|
||
the answer. Put this section earlier and the shim below makes every motion
|
||
component rendered after it throw. */
|
||
/**
|
||
* Citation markup never reaches a reader — asserted on the rendered DOM.
|
||
*
|
||
* ROOT CAUSE, so the tests below read as something rather than as a list:
|
||
* `knowledge/context.go` instructs the model "Each <source> carries an id: cite
|
||
* it when you use what it says", and `knowledge_search` repeats it — without
|
||
* ever saying in WHAT FORM. The ids are `knowledge_chunks.id`, which are UUIDs.
|
||
* So the model invents a citation syntax, differs on it between runs, and the
|
||
* panel — which has no citation surface — renders whatever it invented.
|
||
*
|
||
* Everything here is asserted by SHAPE. Nothing matches a particular id.
|
||
*/
|
||
console.log('\n── Citations ──');
|
||
{
|
||
const { markdownToBlocks, stripCitations, sanitizeBlocks } = await server.ssrLoadModule('/src/components/ai-assistant/provider.js');
|
||
|
||
/* The block renderers reach `@/components/ds`, which reads `window` when the
|
||
module is evaluated. That is a pre-existing SSR limitation of the design
|
||
system and not what is under test here — the alternative to this shim is
|
||
asserting on block objects instead of on markup, which is exactly the
|
||
weaker test this section exists to avoid. */
|
||
hadWindow = 'window' in globalThis;
|
||
if (!hadWindow) {
|
||
globalThis.window = {
|
||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||
addEventListener() {}, removeEventListener() {},
|
||
};
|
||
}
|
||
|
||
const { ResponseDocument } = await server.ssrLoadModule('/src/components/ai-assistant/ResponseBlocks.jsx');
|
||
|
||
/* Anything that would be an internal reference artefact on screen. Applied to
|
||
rendered HTML, not to intermediate values. */
|
||
const ARTEFACT = new RegExp([
|
||
'<cite', '<cite', '</?citation', '/cite', // tag forms
|
||
'\\]\\(#\\)', '\\[[0-9a-f]{4,}', // link and bracket forms
|
||
'\\((?:id|ids|ref|refs|reference|references|source|sources)\\b', // the prose form
|
||
'`[0-9a-f]{4,}-', // a backticked id, however it got there
|
||
'\\(\\s*\\)', '\\(\\s*,', '``', // what a bad lift leaves behind
|
||
].join('|'), 'i');
|
||
|
||
/* An in-app link renders through the router, so the renderer needs one — the
|
||
same context the panel has when it is mounted in the app. */
|
||
const { MemoryRouter } = await server.ssrLoadModule('/node_modules/react-router-dom/dist/index.mjs')
|
||
.catch(async () => import('react-router-dom'));
|
||
|
||
/** The REAL render path: the block renderers the panel mounts. */
|
||
const dom = (blocks) => renderToStaticMarkup(
|
||
React.createElement(MemoryRouter, null,
|
||
React.createElement(ResponseDocument, { blocks: sanitizeBlocks(blocks) }))
|
||
);
|
||
|
||
/* A. – E. Every format seen, through the parser and into the DOM. */
|
||
const answer = [
|
||
'<cite id="2470e231-18e8-4c7e-a7a3-24972fabf548">',
|
||
'The screening backlog has 10 applications waiting.',
|
||
'</cite>',
|
||
'',
|
||
'## <cite id="b-2470e231">Next steps</cite>',
|
||
'',
|
||
'- Review the backlog today ([337042b3](#))',
|
||
'- Coverage is 97% across 113 shifts',
|
||
'',
|
||
'| Stage | Count |',
|
||
'|---|---|',
|
||
'| Applied ([2b94bc43](#)) | 4 |',
|
||
].join('\n');
|
||
|
||
const html = dom(markdownToBlocks(answer));
|
||
record('the rendered DOM carries no citation artefact', !ARTEFACT.test(html),
|
||
(html.match(ARTEFACT) || ['clean'])[0]);
|
||
record('...while the sentences survive intact',
|
||
html.includes('The screening backlog has 10 applications waiting.')
|
||
&& html.includes('Next steps') && html.includes('Review the backlog today'));
|
||
record('...and the business numbers in them are untouched',
|
||
html.includes('10 applications') && html.includes('97%') && html.includes('113 shifts')
|
||
&& html.includes('Applied') && html.includes('4'));
|
||
|
||
/**
|
||
* The acceptance criterion, on the rendered DOM, both ways round.
|
||
*
|
||
* The same UUID has to vanish in one sentence and survive in the other, which
|
||
* is the only test that proves the rule is about the wrapper rather than
|
||
* about the id. A sanitiser that passed the first half alone could simply be
|
||
* deleting every UUID it sees — and would take a worker's record id off their
|
||
* own profile.
|
||
*/
|
||
{
|
||
const cited = dom(markdownToBlocks(
|
||
`According to the staffing policy (id \`${'f34e8ef0-9a01-5921-9baf-641f168e2f05'}\`) workers must complete the required check.`
|
||
));
|
||
record('the reported UUID is absent from the DOM when it is a citation',
|
||
!cited.includes('f34e8ef0-9a01-5921-9baf-641f168e2f05') && !ARTEFACT.test(cited),
|
||
cited.replace(/<[^>]+>/g, '').trim());
|
||
record('...and the sentence around it is intact and unbroken',
|
||
cited.includes('According to the staffing policy workers must complete the required check.'));
|
||
|
||
const business = dom(markdownToBlocks(
|
||
`Worker ID: ${'f34e8ef0-9a01-5921-9baf-641f168e2f05'} — 10 applications, 97% coverage.`
|
||
));
|
||
record('...while the SAME UUID stays in the DOM when it is worker data',
|
||
business.includes('f34e8ef0-9a01-5921-9baf-641f168e2f05'),
|
||
business.replace(/<[^>]+>/g, '').trim());
|
||
}
|
||
|
||
/**
|
||
* A real link renders as a link; a citation-shaped one never gets the chance.
|
||
*
|
||
* The two live at different layers on purpose. `provider.js` decides what is
|
||
* an internal reference, by the wrapper's shape, before a block exists.
|
||
* `ResponseBlocks` decides how a surviving link looks. Neither knows about
|
||
* the other, which is why fixing one has never had to touch the other.
|
||
*/
|
||
{
|
||
const linked = dom(markdownToBlocks(
|
||
'See [staffing policy](#staffing) and [Positions](/admin/positions), or [the docs](https://example.test/x).'
|
||
));
|
||
record('an anchor link renders as an anchor',
|
||
linked.includes('href="#staffing"') && linked.includes('>staffing policy<'), linked.includes('href="#staffing"'));
|
||
record('an in-app link renders through the router',
|
||
linked.includes('href="/admin/positions"') && linked.includes('>Positions<'));
|
||
record('an external link opens away from the app',
|
||
linked.includes('href="https://example.test/x"') && linked.includes('rel="noreferrer noopener"'));
|
||
record('...and no link markup is left as text',
|
||
!/\[staffing policy\]|\(#staffing\)|\[Positions\]/.test(linked));
|
||
|
||
/* The security half. Model-written text is untrusted (I7): a link it can
|
||
write must not be a link it can execute. */
|
||
for (const href of ['javascript:alert(1)', 'data:text/html,<script>', 'vbscript:x', '//evil.test/x']) {
|
||
const out = dom(markdownToBlocks(`Click [here](${href}) now.`));
|
||
record(`an unsafe href is inert: ${href.slice(0, 22)}`,
|
||
!/<a\s/.test(out), /<a\s/.test(out) ? 'RENDERED AN ANCHOR' : 'stayed plain text');
|
||
}
|
||
|
||
/* And the distinction the brief turns on. */
|
||
const cited = dom(markdownToBlocks('Kept [337042b3](#) here, and [staffing policy](#staffing) too.'));
|
||
record('a citation-shaped link is removed, a real one beside it is not',
|
||
!cited.includes('337042b3') && cited.includes('href="#staffing"'),
|
||
cited.replace(/<[^>]+>/g, '').trim());
|
||
|
||
/* A heading and a table cell are plain strings, so a link there keeps its
|
||
label and loses its target rather than showing its source. */
|
||
const inCell = dom(markdownToBlocks('| Stage | Link |\n|---|---|\n| Applied | [staffing policy](#staffing) |'));
|
||
record('a link inside a table cell reads as its label',
|
||
inCell.includes('staffing policy') && !inCell.includes('](#staffing)'));
|
||
}
|
||
|
||
/* Every format discovered, in one table. The point of a table rather than a
|
||
test each is that a new rule has to be run against ALL of them — the way
|
||
this suite grew was one format at a time, and each addition was a chance to
|
||
break an earlier one. */
|
||
const U1 = 'f34e8ef0-9a01-5921-9baf-641f168e2f05';
|
||
const U2 = '2470e231-18e8-4c7e-a7a3-24972fabf548';
|
||
for (const [name, input, want] of [
|
||
['1 html cite', `<cite id="${U2}">text</cite>`, 'text'],
|
||
['2 html citation', `<citation id="${U2}">text</citation>`, 'text'],
|
||
['3 unmatched closer', 'text</cite>', 'text'],
|
||
['4 markdown citation', 'Kept [337042b3](#) here.', 'Kept here.'],
|
||
['5 citation group', 'Kept ([337042b3](#), [2b94bc43](#)).', 'Kept.'],
|
||
['6 bare uuid in brackets', 'Kept [337042b3-e7a5-4146-a972-d3370748eebf] here.', 'Kept here.'],
|
||
['7 uuid group in brackets', `Kept [${U1}, ${U2}] here.`, 'Kept here.'],
|
||
['8 backtick id wrapper', `Kept (id \`${U1}\`) here.`, 'Kept here.'],
|
||
['9 several backtick ids', `Kept (id \`${U1}\`, \`${U2}\`) here.`, 'Kept here.'],
|
||
/* The exact sentence reported, and the exact expected output. */
|
||
[' the reported sentence',
|
||
`According to the staffing policy (id \`${U1}\`) workers must complete the required check.`,
|
||
'According to the staffing policy workers must complete the required check.'],
|
||
[' A at the end', `Staffing policy applies (id \`${U1}\`).`, 'Staffing policy applies.'],
|
||
/* As reported, with the closing backtick missing — a model that opens a
|
||
code span and forgets to close it must not defeat this. */
|
||
[' B in the middle', `Staffing (id \`${U1}) policy applies.`, 'Staffing policy applies.'],
|
||
[' C two separate wrappers',
|
||
`Staffing policy (id \`${U1}\`) and right-to-work policy (id \`${U2}\`) apply.`,
|
||
'Staffing policy and right-to-work policy apply.'],
|
||
[' D two ids in one wrapper', `Policy (id \`${U1}\`, \`${U2}\`).`, 'Policy.'],
|
||
[' the older link sentence',
|
||
'an SIA licence is required before a first shift ([337042b3](#), [2b94bc43](#)).',
|
||
'an SIA licence is required before a first shift.'],
|
||
[' a full uuid link', `Kept [${U2}](#).`, 'Kept.'],
|
||
[' markdown inside a cite', `<cite id="${U1}">**Bold** kept.</cite>`, '**Bold** kept.'],
|
||
[' reference: spelling', `Kept (reference \`${U1}\`) here.`, 'Kept here.'],
|
||
[' source: spelling', `Kept (source \`${U1}\`) here.`, 'Kept here.'],
|
||
[' with a colon', `Kept (id: \`${U1}\`) here.`, 'Kept here.'],
|
||
[' without backticks', `Kept (id ${U1}) here.`, 'Kept here.'],
|
||
]) {
|
||
record(`stripped: ${name}`, stripCitations(input) === want, JSON.stringify(stripCitations(input)));
|
||
}
|
||
|
||
/* F. / G. Streaming. `readRunStream` re-parses the WHOLE answer on every
|
||
delta, so any split point is a state the reader can see — which makes the
|
||
only honest test every split point, not a chosen one. */
|
||
for (const [name, full] of [
|
||
['a link group', 'A licence is required before a first shift ([337042b3](#), [2b94bc43](#)). Act today.'],
|
||
['a cite tag', 'Screened 10 of 24 <cite id="2470e231-18e8-4c7e">this week</cite>. Act today.'],
|
||
['two separate groups', 'Coverage is 97% ([2b94bc43](#)) and 113 shifts ran ([337042b3](#)).'],
|
||
['a backtick wrapper', `Policy (id \`${U1}\`) applies. Act today.`],
|
||
['two backtick wrappers', `Staffing (id \`${U1}\`) and right-to-work (id \`${U2}\`) apply.`],
|
||
['a bracketed uuid', `Policy [${U1}] applies today.`],
|
||
]) {
|
||
let leaked = null;
|
||
for (let i = 1; i <= full.length && !leaked; i += 1) {
|
||
const frame = dom(markdownToBlocks(full.slice(0, i), { partial: true }));
|
||
if (ARTEFACT.test(frame)) leaked = `at ${i}: ${(frame.match(ARTEFACT) || [])[0]}`;
|
||
}
|
||
record(`no frame of a streamed answer leaks (${name})`, !leaked, leaked || `${full.length} frames clean`);
|
||
}
|
||
record("F the brief's partial renders nothing of itself",
|
||
markdownToBlocks('A licence is required ([3370', { partial: true }).map((b) => b.text).join(' ') === 'A licence is required');
|
||
|
||
/* And the mirror of it: a FINISHED answer has no frontier, so a bracket that
|
||
merely looks like the start of an id is left where the model put it. */
|
||
for (const [name, input] of [
|
||
['a footnote marker ending the answer', 'See [staffing policy](#staffing) and note [12]'],
|
||
['a trailing open bracket', 'The formula is (a + b'],
|
||
]) {
|
||
record(`a completed answer keeps: ${name}`, stripCitations(input) === input, JSON.stringify(stripCitations(input)));
|
||
}
|
||
|
||
/* What must survive — and the important half is the SAME UUID as a business
|
||
value. The rule has to be about the wrapper, never about the id, or a
|
||
worker's real record id disappears off their own profile. */
|
||
for (const [name, input] of [
|
||
['the same uuid as worker data', `Worker ID: ${U1}`],
|
||
[' ...as application data', `Application ID is ${U1}.`],
|
||
[' ...in an ordinary sentence', `The chunk ${U1} was reindexed.`],
|
||
[' ...in a table cell', `| Worker | ${U1} |`],
|
||
[' a bare reference number', 'Reference number 337042b3 is recorded in the worker profile.'],
|
||
[' a parenthetical that is not a reference', `Omar Haddad (hired 2026-01-04) starts Monday.`],
|
||
['H business numbers', '10 applications, 97% coverage and 113 shifts.'],
|
||
[' more business data', '89 match, 45 days open, £10 and $10 on 2026-09-02 — 3 uncovered.'],
|
||
['I an anchor link', 'See [staffing policy](#staffing).'],
|
||
[' an application link', 'See [Positions](/admin/positions).'],
|
||
[' a readable label on #', 'See [Read more](#).'],
|
||
[' an inequality', 'Throughput 5 < 10 and 20 > 3.'],
|
||
[' a code tag', 'Use <code>x</code> here.'],
|
||
[' a word starting with cit', 'A <city>place</city>.'],
|
||
[' bold, italic and code', '**Bold**, _italic_ and `code` survive.'],
|
||
/* Four hex characters, and a year — the reason the bare-bracket rule wants
|
||
eight, or a dash, before it will act. */
|
||
[' a year in brackets', 'Revenue in [2026] rose.'],
|
||
[' a footnote marker', 'See note [12] below.'],
|
||
[' a markdown task box', '- [ ] Screen the backlog'],
|
||
]) {
|
||
record(`untouched: ${name}`, stripCitations(input) === input, JSON.stringify(stripCitations(input)));
|
||
}
|
||
|
||
/* The paths that never see the markdown parser: a bounded run's message, a
|
||
transport failure, and a proposed write. Each was its own way through. */
|
||
record('a note block is sanitised too',
|
||
!ARTEFACT.test(dom([{ type: 'note', text: 'Stopped early ([337042b3](#)).' }])));
|
||
record('a confirmation is sanitised and KEEPS its token',
|
||
(() => {
|
||
const [out] = sanitizeBlocks([{
|
||
type: 'confirmation', token: 'tok_337042b3',
|
||
title: 'Move ([2b94bc43](#))', summary: 'Do it ([337042b3](#))',
|
||
warnings: ['Risky ([337042b3](#))'],
|
||
}]);
|
||
return out.token === 'tok_337042b3' && out.title === 'Move'
|
||
&& out.summary === 'Do it' && out.warnings[0] === 'Risky';
|
||
})());
|
||
record('...and an action keeps the route it points at',
|
||
(() => {
|
||
const [out] = sanitizeBlocks([{
|
||
type: 'actions',
|
||
items: [{ label: 'Open ([337042b3](#))', route: '/admin/positions#337042b3' }],
|
||
}]);
|
||
return out.items[0].label === 'Open' && out.items[0].route === '/admin/positions#337042b3';
|
||
})());
|
||
|
||
}
|
||
|
||
/**
|
||
* When the composer offers anything at all.
|
||
*
|
||
* The rule used to be "there is something in the box", so suggestions appeared
|
||
* from the second character typed — at the exact moment the reader had already
|
||
* decided what to ask, and off two characters that cannot say what they meant.
|
||
* It is now "the box is focused and empty", which is the whole state table
|
||
* below.
|
||
*/
|
||
console.log('\n── Composer suggestions ──');
|
||
{
|
||
const { shouldShowSuggestions } = await server.ssrLoadModule('/src/components/ai-assistant/KrowAssistant.jsx');
|
||
const { actionSuggestions } = await server.ssrLoadModule('/src/lib/skills/tools.js');
|
||
const base = { focused: true, busy: false, view: 'chat', count: 3 };
|
||
const show = (over) => shouldShowSuggestions({ ...base, ...over });
|
||
|
||
record('on load, before the composer is touched, nothing is offered', !show({ focused: false }));
|
||
record('an empty composer that gains focus offers what it has', show({}));
|
||
record('focus leaving hides them', !show({ focused: false }));
|
||
record('nothing is offered while an answer is arriving', !show({ busy: true }));
|
||
record('nothing is offered in History', !show({ view: 'history' }));
|
||
record('with nothing to offer, no panel at all', !show({ count: 0 }));
|
||
|
||
/**
|
||
* Typed action intent.
|
||
*
|
||
* The phrases are the SKILLS' own — a definition's `prompt:` — resolved
|
||
* through `skillsForContext`, so the page filter and the agent's scoping are
|
||
* already applied. Nothing here is a list of action names.
|
||
*/
|
||
const onPositions = reg.skillsForContext(positions);
|
||
const said = (typed) => actionSuggestions(typed, onPositions).map((c) => c.prompt);
|
||
|
||
record('typing one character names no action', said('C').length === 0);
|
||
record('...and two still names none', said('Cr').length === 0, JSON.stringify(said('Cr')));
|
||
record('"Create" surfaces the actions this page can perform',
|
||
said('Create').length > 0, JSON.stringify(said('Create')));
|
||
record('...and they come from the skills, not from a list here',
|
||
said('Create').every((p) => onPositions.some((sk) => sk.prompt === p)), JSON.stringify(said('Create')));
|
||
record('"Create position" still resolves, article and all',
|
||
said('Create position').length > 0 && said('create a position').length > 0,
|
||
JSON.stringify(said('Create position')));
|
||
record('arbitrary text names no action', said('abc').length === 0 && said('he').length === 0);
|
||
record('a reading question is never autocompleted',
|
||
said('which positions').length === 0 && said('show me').length === 0);
|
||
record('only skills that declare an action are offered',
|
||
actionSuggestions('create', [{ prompt: 'Create a thing', actions: [] }]).length === 0);
|
||
record('a switched-off skill is not offered',
|
||
reg.skillsForContext(positions, ['create-position']).every((sk) => sk.id !== 'create-position')
|
||
&& said('Create').length >= 0);
|
||
record('duplicates are collapsed',
|
||
actionSuggestions('create', [
|
||
{ prompt: 'Create a position', actions: ['create_position'] },
|
||
{ prompt: 'Create a position', actions: ['create_position'] },
|
||
]).length === 1);
|
||
}
|
||
|
||
{
|
||
/* Put the environment back. A half-built `window` reads as a browser to
|
||
framer-motion, which decides once at import time whether it is in a browser
|
||
and caches the answer — so this whole region runs last and cleans up after
|
||
itself. */
|
||
if (!hadWindow) delete globalThis.window;
|
||
}
|
||
|
||
/**
|
||
* Certifications follow the role, and the role's evidence is the org's own.
|
||
*
|
||
* ARCHITECTURE, because the tests only make sense against it: the schema has NO
|
||
* role-to-certification relationship — `role_categories` and `certifications`
|
||
* are both bare `(id, org_id, name)` lists with nothing joining them. Relevance
|
||
* is therefore observed rather than looked up, from the one place the two
|
||
* already appear together: `job_postings.role_category` beside
|
||
* `job_postings.certifications_required`.
|
||
*
|
||
* Every fixture below is built from that shape rather than from real role or
|
||
* certification names, so the assertions are about the RULE and would survive
|
||
* this organization renaming everything it has.
|
||
*/
|
||
console.log('\n── Role-aware certifications ──');
|
||
{
|
||
const { certificationsForRole } = await server.ssrLoadModule('/src/lib/positionModel.js');
|
||
const flows = await server.ssrLoadModule('/src/lib/skills/flows/index.js');
|
||
|
||
/* Two roles with different requirements, one with none, named positionally so
|
||
nothing here depends on what this deployment happens to call them. */
|
||
const [ROLE_A, ROLE_B, ROLE_NONE] = ['role-alpha', 'role-beta', 'role-gamma'];
|
||
const [C1, C2, C3] = ['cert-one', 'cert-two', 'cert-three'];
|
||
const POSTINGS = [
|
||
{ role_category: ROLE_A, certifications_required: [C1, C2] },
|
||
{ role_category: ROLE_A, certifications_required: [C1] },
|
||
{ role_category: ROLE_B, certifications_required: [C3] },
|
||
{ role_category: ROLE_NONE, certifications_required: [] },
|
||
];
|
||
|
||
const set = (xs) => JSON.stringify([...(xs || [])].sort());
|
||
|
||
record('1. a role with relevant certifications offers exactly those',
|
||
set(certificationsForRole(ROLE_A, POSTINGS)) === set([C1, C2]),
|
||
JSON.stringify(certificationsForRole(ROLE_A, POSTINGS)));
|
||
|
||
record('2. ...deduplicated across every posting for that role',
|
||
certificationsForRole(ROLE_A, POSTINGS).length === 2);
|
||
|
||
record('3. a different role offers a different list, not a global one',
|
||
set(certificationsForRole(ROLE_B, POSTINGS)) === set([C3])
|
||
&& !certificationsForRole(ROLE_B, POSTINGS).includes(C1));
|
||
|
||
record('4. a role with none offers none — never a fallback list',
|
||
set(certificationsForRole(ROLE_NONE, POSTINGS)) === set([]));
|
||
|
||
record('5. a role this organization has never posted offers none',
|
||
set(certificationsForRole('role-never-posted', POSTINGS)) === set([]));
|
||
|
||
record('6. matching ignores case and surrounding space',
|
||
set(certificationsForRole(` ${ROLE_A.toUpperCase()} `, POSTINGS)) === set([C1, C2]));
|
||
|
||
/* The distinction the brief asks for explicitly. */
|
||
record('7. "not loaded" is null and "none relevant" is empty — not the same',
|
||
certificationsForRole(ROLE_A, null) === null
|
||
&& Array.isArray(certificationsForRole(ROLE_NONE, POSTINGS)));
|
||
|
||
/* Resolved through the flows, which is how the conversation reaches it. */
|
||
for (const [flowName, draftFor] of [
|
||
['employee-role', (role) => ({ role_category: role })],
|
||
['position', (role) => ({ role_category: role })],
|
||
]) {
|
||
const flow = flows.FLOWS[flowName];
|
||
const offered = (role) => flow.resolve('@certifications', { postings: POSTINGS }, draftFor(role));
|
||
|
||
record(`8. ${flowName}: suggestions follow the selected role`,
|
||
set(offered(ROLE_A)) === set([C1, C2]) && set(offered(ROLE_B)) === set([C3]));
|
||
|
||
record(`9. ${flowName}: changing the role recalculates, leaving nothing stale`,
|
||
(() => {
|
||
const first = offered(ROLE_A);
|
||
const second = offered(ROLE_B);
|
||
return first.every((c) => !second.includes(c)) && second.length > 0;
|
||
})());
|
||
|
||
record(`10. ${flowName}: a role with no requirements offers no chips at all`,
|
||
offered(ROLE_NONE).length === 0);
|
||
|
||
record(`11. ${flowName}: unloaded postings offer nothing rather than a global list`,
|
||
flow.resolve('@certifications', { postings: null }, draftFor(ROLE_A)).length === 0);
|
||
}
|
||
|
||
/* One source of truth: both conversational flows answer identically for the
|
||
same role and the same evidence. */
|
||
record('12. both flows agree, because both read the same helper',
|
||
set(flows.FLOWS['employee-role'].resolve('@certifications', { postings: POSTINGS }, { role_category: ROLE_A }))
|
||
=== set(flows.FLOWS.position.resolve('@certifications', { postings: POSTINGS }, { role_category: ROLE_A })));
|
||
|
||
/**
|
||
* Every optional question is actually REACHABLE.
|
||
*
|
||
* The conversation seeded its draft from the record's write-time defaults, so
|
||
* `certifications: []`, `notes: ''` and `experience_years: 0` all counted as
|
||
* answers and five of the eight questions were skipped — the certification
|
||
* step among them, which is why none of the rules above could be reached in
|
||
* the product. A conversation has to tell "missing" from "answered".
|
||
*/
|
||
{
|
||
const role = flows.FLOWS['employee-role'];
|
||
const fresh = role.prefill('create an employee role', { roles: [] });
|
||
const unreachable = Object.entries(role.fields)
|
||
.filter(([, field]) => field.settled(fresh))
|
||
.map(([name]) => name);
|
||
record('14. a new employee-role conversation has nothing settled in advance',
|
||
unreachable.length === 0, unreachable.join(', ') || 'every question is asked');
|
||
}
|
||
|
||
/* And it is derived, not listed: a certification this deployment invents
|
||
shows up without any code here changing. */
|
||
record('13. a certification new to the data is offered without a code change',
|
||
(() => {
|
||
const invented = 'cert-invented-by-the-tenant';
|
||
const extended = [...POSTINGS, { role_category: ROLE_B, certifications_required: [invented] }];
|
||
return certificationsForRole(ROLE_B, extended).includes(invented);
|
||
})());
|
||
}
|
||
|
||
/**
|
||
* Two sources of people, kept apart.
|
||
*
|
||
* A CANDIDATE is somebody in the hiring pipeline — `job_applications`. The
|
||
* TALENT POOL is the workforce — `worker_profiles`. They are different sets and
|
||
* a question about one is never answered from the other, however well somebody
|
||
* in the other set scores.
|
||
*
|
||
* The separation is structural rather than filtered: `poolFor` iterates
|
||
* applications and nothing else, and uses profiles only as an email lookup to
|
||
* enrich a candidate it has already found. A worker who has never applied
|
||
* cannot appear in it, so there is nothing to filter out. That property is
|
||
* asserted below rather than assumed.
|
||
*/
|
||
console.log('\n── Candidates vs Talent Pool ──');
|
||
{
|
||
const wf = await server.ssrLoadModule('/src/lib/skills/workforceFlow.js');
|
||
const { poolFor } = await server.ssrLoadModule('/src/lib/workforce.js');
|
||
|
||
/* Fixtures built from the shape of the records, not from anyone's name. */
|
||
const position = { id: 'pos-1', title: 'Test Role', role_category: 'role-alpha' };
|
||
const applicants = [
|
||
{ id: 'app-1', email: 'pipeline-one@example.test', applicant_name: 'Pipeline One', job_posting_id: 'pos-1', created_date: '2026-01-01', status: 'interview' },
|
||
{ id: 'app-2', email: 'pipeline-two@example.test', applicant_name: 'Pipeline Two', job_posting_id: 'pos-1', created_date: '2026-01-02', status: 'applied' },
|
||
];
|
||
/* A worker who has NEVER applied, and one who has. The first must never
|
||
appear; the second appears because of the application, not the profile. */
|
||
const profiles = [
|
||
{ id: 'wp-1', email: 'workforce-only@example.test', full_name: 'Workforce Only', krow_score: 99 },
|
||
{ id: 'wp-2', email: 'pipeline-one@example.test', full_name: 'Pipeline One', krow_score: 50 },
|
||
];
|
||
|
||
const pool = poolFor(position, { applications: applicants, profiles, assignments: [], courses: [], staff: [] });
|
||
const emails = pool.map((r) => String(r.email || '').toLowerCase());
|
||
|
||
record('candidate matching returns only people in the hiring pipeline',
|
||
emails.length === 2 && emails.every((e) => applicants.some((a) => a.email === e)),
|
||
JSON.stringify(emails));
|
||
record('...so a workforce-only person never appears, whatever they score',
|
||
!emails.includes('workforce-only@example.test'));
|
||
record('...and no candidate is invented from a profile',
|
||
pool.length === applicants.length, `${pool.length} rows from ${applicants.length} applications`);
|
||
record('with no applications there are no candidates — no fallback fills the gap',
|
||
poolFor(position, { applications: [], profiles, assignments: [], courses: [], staff: [] }).length === 0);
|
||
|
||
/**
|
||
* Intent decides the source, and this is where it was going wrong.
|
||
*
|
||
* A bare `show ` test was checked second and swallowed every collection
|
||
* query, so "show employees who match this role" — a workforce question —
|
||
* resolved to a candidate lookup. Meanwhile the matching intent was narrow
|
||
* enough that most natural phrasings reached no intent at all.
|
||
*/
|
||
const intent = (q) => wf.matchWorkforceIntent(q.toLowerCase());
|
||
|
||
for (const q of [
|
||
'Who is a match for this role?',
|
||
'Show candidates matching this role',
|
||
'Find the best candidate for this position',
|
||
'Show strong candidates for this role',
|
||
'Find strong candidates',
|
||
'Show candidates matching a role',
|
||
]) {
|
||
record(`pipeline question resolves to candidate matching: "${q}"`, intent(q) === 'matches', String(intent(q)));
|
||
}
|
||
|
||
for (const q of ['Which candidates should I interview?', 'Who should I interview?']) {
|
||
record(`interview question stays in the pipeline: "${q}"`, intent(q) === 'interview_ready', String(intent(q)));
|
||
}
|
||
|
||
/* A workforce question reaches NO pipeline intent, so the Talent Pool
|
||
responder answers it from worker_profiles. */
|
||
for (const q of [
|
||
'Find someone in the Talent Pool',
|
||
'Show employees who match this role',
|
||
'Find available employees for this position',
|
||
'Show me the workers for this role',
|
||
]) {
|
||
record(`workforce question never reaches a pipeline intent: "${q}"`, intent(q) === null, String(intent(q)));
|
||
}
|
||
|
||
/* A question naming BOTH belongs to the pipeline — it says "candidate". */
|
||
record('a question naming both is answered from the pipeline',
|
||
intent('Which candidates are available?') === 'availability');
|
||
|
||
/* And a person-detail request still opens a record. */
|
||
for (const [q, want] of [
|
||
['Show Some Person', 'candidate_detail'],
|
||
["Show Some Person's details", 'candidate_detail'],
|
||
['Tell me about Some Person', 'candidate_detail'],
|
||
['Open the full profile for Some Person', 'open_profile'],
|
||
['Assign the best person', 'assign'],
|
||
['Who applied today?', 'applied_today'],
|
||
['Which position needs people first?', 'priority'],
|
||
]) {
|
||
record(`unchanged: "${q}" → ${want}`, intent(q) === want, String(intent(q)));
|
||
}
|
||
}
|
||
|
||
/* ── UI node system: contract, registry, operations, validation ────────────
|
||
Phase 1 of the runtime UI customization work. Nothing here renders — these
|
||
assert the engine's behaviour, which is what every later phase and the agent
|
||
itself will depend on.
|
||
|
||
The single most important assertion in this section is the last one: that the
|
||
engine contains no branch on a node type. Everything else can be re-derived
|
||
by reading the code; that one decays silently the first time somebody adds a
|
||
convenient special case. */
|
||
{
|
||
console.log('\n── UI node system ──');
|
||
|
||
const nodeMod = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
const registryMod = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const opsMod = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
const validateMod = await server.ssrLoadModule('/src/lib/ui/validate.js');
|
||
const inspectMod = await server.ssrLoadModule('/src/lib/ui/inspect.js');
|
||
const patchMod = await server.ssrLoadModule('/src/lib/ui/patch.js');
|
||
|
||
const { NodeTypeRegistry } = registryMod;
|
||
const { makeNode, findNode } = nodeMod;
|
||
const { applyOperation, applyOperations } = opsMod;
|
||
const { validateTree } = validateMod;
|
||
|
||
/* A component stand-in. The registry stores a reference and never calls it
|
||
here, so a function is enough and keeps this file free of React. */
|
||
const Stub = () => null;
|
||
|
||
/**
|
||
* A registry built per case.
|
||
*
|
||
* Deliberately NOT the application's. These types are named for their shape
|
||
* rather than for anything in KROW, which is the point: if the engine needed
|
||
* to know a real component to work, none of this would pass.
|
||
*/
|
||
const freshRegistry = () => {
|
||
const r = new NodeTypeRegistry();
|
||
r.register({
|
||
type: 'section', label: 'Section', component: Stub, container: true,
|
||
propSchema: { title: { type: 'string' } },
|
||
constraints: { maxColumns: 4 },
|
||
});
|
||
r.register({
|
||
type: 'panel', label: 'Panel', component: Stub, container: true,
|
||
accepts: ['metric'],
|
||
});
|
||
r.register({
|
||
type: 'metric', label: 'Metric', component: Stub,
|
||
dataShapes: ['stats'], dataRequired: true,
|
||
propSchema: {
|
||
title: { type: 'string' },
|
||
tone: { enum: ['neutral', 'positive', 'negative'] },
|
||
rank: { type: 'number', min: 1, max: 5 },
|
||
},
|
||
});
|
||
r.register({
|
||
type: 'grid', label: 'Grid', component: Stub,
|
||
dataShapes: ['table', 'stats'],
|
||
});
|
||
r.register({
|
||
type: 'rule', label: 'Rule', component: Stub, capabilities: ['remove', 'move'],
|
||
});
|
||
r.register({
|
||
type: 'banner', label: 'Banner', component: Stub, roles: ['admin'],
|
||
});
|
||
/* Updatable, but reads no data — which is what separates "this component
|
||
will not take a binding" from "this component will not be changed at
|
||
all". `rule` above is the other half of that pair. */
|
||
r.register({ type: 'divider', label: 'Divider', component: Stub });
|
||
return r;
|
||
};
|
||
|
||
/* `candidates.activity` needs no page context and supports several shapes,
|
||
which is why the fixtures bind to it. Read from the vocabulary rather than
|
||
assumed — if it ever stops supporting `stats`, these fail loudly. */
|
||
const surfaces = await server.ssrLoadModule('/src/lib/skills/surfaces.js');
|
||
const SOURCE = 'candidates.activity';
|
||
record(
|
||
'fixture source exists in the closed vocabulary',
|
||
surfaces.SUPPORTED_DATA_SOURCES.includes(SOURCE),
|
||
SOURCE
|
||
);
|
||
record(
|
||
'fixture source supports the fixture shapes',
|
||
surfaces.sourceSupportsShape(SOURCE, 'stats') && surfaces.sourceSupportsShape(SOURCE, 'table'),
|
||
(surfaces.dataSourceFor(SOURCE)?.shapes || []).join(', ')
|
||
);
|
||
|
||
const tree = () => [
|
||
makeNode({
|
||
id: 'top', type: 'section', props: { title: 'Overview' }, origin: 'builtin',
|
||
children: [
|
||
makeNode({ id: 'one', type: 'metric', origin: 'builtin', data: { source: SOURCE } }),
|
||
makeNode({ id: 'two', type: 'metric', origin: 'builtin', data: { source: SOURCE } }),
|
||
],
|
||
}),
|
||
makeNode({ id: 'bottom', type: 'section', origin: 'builtin', children: [] }),
|
||
];
|
||
|
||
const reg = freshRegistry();
|
||
const ctx = { registry: reg };
|
||
const run = (t, op, extra = {}) => applyOperation(t, op, { ...ctx, ...extra });
|
||
|
||
/* ── Registration ──────────────────────────────────────────────────────── */
|
||
|
||
record('a fresh registry lists what was registered',
|
||
freshRegistry().list().join(',') === 'section,panel,metric,grid,rule,banner,divider');
|
||
|
||
record('a duplicate type is refused at registration', (() => {
|
||
try { const r = freshRegistry(); r.register({ type: 'metric', component: Stub }); return false; }
|
||
catch (e) { return /already registered/.test(e.message); }
|
||
})());
|
||
|
||
record('a type with no component is refused', (() => {
|
||
try { new NodeTypeRegistry().register({ type: 'x' }); return false; }
|
||
catch (e) { return /needs a `component`/.test(e.message); }
|
||
})());
|
||
|
||
record('an unknown capability is refused at registration', (() => {
|
||
try {
|
||
new NodeTypeRegistry().register({ type: 'x', component: Stub, capabilities: ['teleport'] });
|
||
return false;
|
||
} catch (e) { return /unknown capability/.test(e.message); }
|
||
})());
|
||
|
||
record('a non-container claiming `add` is refused', (() => {
|
||
try {
|
||
new NodeTypeRegistry().register({ type: 'x', component: Stub, capabilities: ['add'] });
|
||
return false;
|
||
} catch (e) { return /not a container/.test(e.message); }
|
||
})());
|
||
|
||
/* ── The baseline ──────────────────────────────────────────────────────── */
|
||
|
||
record('a well-formed tree validates', validateTree(tree(), ctx).ok);
|
||
|
||
/* ── add ───────────────────────────────────────────────────────────────── */
|
||
|
||
{
|
||
const r = run(tree(), {
|
||
op: 'add', parent: 'bottom',
|
||
node: { id: 'three', type: 'metric', data: { source: SOURCE } },
|
||
});
|
||
record('add: places a node in the named container',
|
||
r.ok && findNode(r.tree, 'three') !== null && findNode(r.tree, 'bottom').children.length === 1);
|
||
record('add: a new node defaults to origin `user`',
|
||
r.ok && findNode(r.tree, 'three').origin === 'user');
|
||
record('add: does not disturb the rest of the tree',
|
||
r.ok && findNode(r.tree, 'top').children.length === 2);
|
||
}
|
||
|
||
record('add: honours an index', (() => {
|
||
const r = run(tree(), {
|
||
op: 'add', parent: 'top', index: 0,
|
||
node: { id: 'first', type: 'metric', data: { source: SOURCE } },
|
||
});
|
||
return r.ok && findNode(r.tree, 'top').children[0].id === 'first';
|
||
})());
|
||
|
||
record('add: a duplicate id is refused', (() => {
|
||
const r = run(tree(), { op: 'add', parent: 'bottom', node: { id: 'one', type: 'metric', data: { source: SOURCE } } });
|
||
return !r.ok && /already exists/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('add: an unregistered type is refused by name', (() => {
|
||
const r = run(tree(), { op: 'add', parent: 'bottom', node: { id: 'x', type: 'hologram' } });
|
||
return !r.ok && /Unsupported UI type: hologram/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('add: a container that does not accept the type is refused', (() => {
|
||
const t = run(tree(), { op: 'add', parent: null, node: { id: 'p', type: 'panel' } }).tree;
|
||
const r = run(t, { op: 'add', parent: 'p', node: { id: 'g', type: 'grid', data: { source: SOURCE } } });
|
||
return !r.ok && /cannot sit inside|cannot hold/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('add: a non-container parent is refused', (() => {
|
||
const r = run(tree(), { op: 'add', parent: 'one', node: { id: 'x', type: 'metric', data: { source: SOURCE } } });
|
||
return !r.ok && /cannot hold other nodes/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── update ────────────────────────────────────────────────────────────── */
|
||
|
||
record('update: merges props and leaves the others alone', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'top', props: { title: 'Renamed' } });
|
||
return r.ok && findNode(r.tree, 'top').props.title === 'Renamed';
|
||
})());
|
||
|
||
record('update: an explicit null unsets a prop', (() => {
|
||
const seeded = run(tree(), { op: 'update', target: 'one', props: { tone: 'positive' } }).tree;
|
||
const r = run(seeded, { op: 'update', target: 'one', props: { tone: null } });
|
||
return r.ok && !('tone' in findNode(r.tree, 'one').props);
|
||
})());
|
||
|
||
record('update: an unknown prop is refused, naming what is accepted', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'one', props: { colour: 'red' } });
|
||
return !r.ok && /has no property `colour`/.test(r.problems[0].message)
|
||
&& /title, tone, rank/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: a value outside an enum is refused', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'one', props: { tone: 'chartreuse' } });
|
||
return !r.ok && /must be one of: neutral, positive, negative/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: a number out of range is refused', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'one', props: { rank: 9 } });
|
||
return !r.ok && /must be at most 5/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: a prop of the wrong kind is refused', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'one', props: { title: 42 } });
|
||
return !r.ok && /must be a string/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: layout columns within the type constraint are accepted', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'top', layout: { columns: 2 } });
|
||
return r.ok && findNode(r.tree, 'top').layout.columns === 2;
|
||
})());
|
||
|
||
record('update: layout columns beyond the type constraint are refused', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'top', layout: { columns: 5 } });
|
||
return !r.ok && /supports 1–4 columns/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: a column count outside the grid is refused', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'top', layout: { columns: 40 } });
|
||
return !r.ok && /between 1 and 12/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: an unknown gap is refused', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'top', layout: { gap: 'enormous' } });
|
||
return !r.ok && /`gap` must be one of/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: a type that declines `update` refuses it', (() => {
|
||
const seeded = run(tree(), { op: 'add', parent: 'bottom', node: { id: 'r', type: 'rule' } }).tree;
|
||
const r = run(seeded, { op: 'update', target: 'r', props: {} });
|
||
return !r.ok && /cannot be changed/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('update: a locked node refuses it', (() => {
|
||
const t = [makeNode({ id: 'fixed', type: 'section', locked: true, origin: 'builtin' })];
|
||
const r = run(t, { op: 'update', target: 'fixed', props: { title: 'no' } });
|
||
return !r.ok && /fixed here/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── data binding ──────────────────────────────────────────────────────── */
|
||
|
||
record('data: an unknown source is refused by name', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'one', data: { source: 'invented.metric' } });
|
||
return !r.ok && /Unsupported data source: invented\.metric/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('data: a source that cannot fill the type’s shape is refused', (() => {
|
||
/* A source whose shapes exclude `stats`, found from the vocabulary rather
|
||
than named here, so this stays true as the vocabulary changes. */
|
||
const incompatible = surfaces.DATA_SOURCES.find((s) => !s.shapes.includes('stats'));
|
||
if (!incompatible) return true;
|
||
const r = run(tree(), { op: 'update', target: 'one', data: { source: incompatible.id } });
|
||
return !r.ok && /cannot be shown as `metric`/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('data: a type that reads no data refuses a binding', (() => {
|
||
const seeded = run(tree(), { op: 'add', parent: 'bottom', node: { id: 'd1', type: 'divider' } }).tree;
|
||
const r = run(seeded, { op: 'update', target: 'd1', data: { source: SOURCE } });
|
||
return !r.ok && /does not read data/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('data: a required binding cannot be dropped', (() => {
|
||
const r = run(tree(), { op: 'update', target: 'one', data: null });
|
||
return !r.ok && /needs a data source/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('data: an unknown period is refused', (() => {
|
||
const r = run(tree(), {
|
||
op: 'update', target: 'one', data: { source: SOURCE, params: { periods: ['epoch'] } },
|
||
});
|
||
return !r.ok && /Unsupported period: epoch/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('data: a limit out of range is refused', (() => {
|
||
const r = run(tree(), {
|
||
op: 'update', target: 'one', data: { source: SOURCE, params: { limit: 500 } },
|
||
});
|
||
return !r.ok && /between 1 and 50/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── remove ────────────────────────────────────────────────────────────── */
|
||
|
||
record('remove: takes out a node the user added', (() => {
|
||
const seeded = run(tree(), {
|
||
op: 'add', parent: 'bottom', node: { id: 'mine', type: 'metric', data: { source: SOURCE } },
|
||
}).tree;
|
||
const r = run(seeded, { op: 'remove', target: 'mine' });
|
||
return r.ok && findNode(r.tree, 'mine') === null;
|
||
})());
|
||
|
||
record('remove: a built-in is refused and told to hide instead', (() => {
|
||
const r = run(tree(), { op: 'remove', target: 'one' });
|
||
return !r.ok && /part of the page/.test(r.problems[0].message)
|
||
&& /Hide it instead/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('remove: an unknown target is refused by id', (() => {
|
||
const r = run(tree(), { op: 'remove', target: 'nowhere' });
|
||
return !r.ok && /No UI node with the id `nowhere`/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── move ──────────────────────────────────────────────────────────────── */
|
||
|
||
record('move: relocates into another container', (() => {
|
||
const r = run(tree(), { op: 'move', target: 'one', parent: 'bottom', index: 0 });
|
||
return r.ok
|
||
&& findNode(r.tree, 'bottom').children.map((n) => n.id).join() === 'one'
|
||
&& findNode(r.tree, 'top').children.map((n) => n.id).join() === 'two';
|
||
})());
|
||
|
||
record('move: within a parent, the index counts after removal', (() => {
|
||
const r = run(tree(), { op: 'move', target: 'one', parent: 'top', index: 1 });
|
||
return r.ok && findNode(r.tree, 'top').children.map((n) => n.id).join() === 'two,one';
|
||
})());
|
||
|
||
record('move: to the root', (() => {
|
||
const r = run(tree(), { op: 'move', target: 'one', parent: null, index: 0 });
|
||
return r.ok && r.tree[0].id === 'one' && findNode(r.tree, 'top').children.length === 1;
|
||
})());
|
||
|
||
record('move: a node cannot be moved inside itself', (() => {
|
||
const r = run(tree(), { op: 'move', target: 'top', parent: 'top' });
|
||
return !r.ok && /inside itself/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('move: into a container that does not accept the type is refused', (() => {
|
||
const t = run(tree(), { op: 'add', parent: null, node: { id: 'p2', type: 'panel' } }).tree;
|
||
const seeded = run(t, { op: 'add', parent: null, node: { id: 'g2', type: 'grid', data: { source: SOURCE } } }).tree;
|
||
const r = run(seeded, { op: 'move', target: 'g2', parent: 'p2' });
|
||
return !r.ok;
|
||
})());
|
||
|
||
/* ── replace ───────────────────────────────────────────────────────────── */
|
||
|
||
record('replace: swaps the type and keeps id, place and binding', (() => {
|
||
const r = run(tree(), { op: 'replace', target: 'one', type: 'grid' });
|
||
const after = findNode(r.tree, 'one');
|
||
return r.ok && after.type === 'grid' && after.data.source === SOURCE
|
||
&& findNode(r.tree, 'top').children[0].id === 'one';
|
||
})());
|
||
|
||
record('replace: drops the old type’s props rather than carrying them over', (() => {
|
||
const seeded = run(tree(), { op: 'update', target: 'one', props: { tone: 'positive' } }).tree;
|
||
const r = run(seeded, { op: 'replace', target: 'one', type: 'grid' });
|
||
return r.ok && Object.keys(findNode(r.tree, 'one').props).length === 0;
|
||
})());
|
||
|
||
record('replace: an unregistered type is refused', (() => {
|
||
const r = run(tree(), { op: 'replace', target: 'one', type: 'sculpture' });
|
||
return !r.ok && /Unsupported UI type: sculpture/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('replace: a target whose binding the new type cannot draw is refused', (() => {
|
||
const incompatible = surfaces.DATA_SOURCES.find(
|
||
(s) => s.shapes.includes('stats') && !s.shapes.includes('table')
|
||
);
|
||
if (!incompatible) return true;
|
||
const t = [makeNode({ id: 'm', type: 'metric', origin: 'user', data: { source: incompatible.id } })];
|
||
const r = run(t, { op: 'replace', target: 'm', type: 'grid' });
|
||
/* `grid` draws table and stats; if the source only fills stats it still
|
||
works, so this asserts only that an impossible pairing is caught. */
|
||
return r.ok || /cannot be shown as/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('replace: a container with children cannot become a leaf', (() => {
|
||
const r = run(tree(), { op: 'replace', target: 'top', type: 'grid' });
|
||
return !r.ok && /cannot hold the nodes already inside/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── hide ──────────────────────────────────────────────────────────────── */
|
||
|
||
record('hide: marks a built-in hidden without removing it', (() => {
|
||
const r = run(tree(), { op: 'hide', target: 'one' });
|
||
return r.ok && findNode(r.tree, 'one').hidden === true;
|
||
})());
|
||
|
||
record('hide: is reversible', (() => {
|
||
const hidden = run(tree(), { op: 'hide', target: 'one' }).tree;
|
||
const r = run(hidden, { op: 'hide', target: 'one', hidden: false });
|
||
return r.ok && findNode(r.tree, 'one').hidden === false;
|
||
})());
|
||
|
||
record('hide: a type that declines it refuses it', (() => {
|
||
const seeded = run(tree(), { op: 'add', parent: 'bottom', node: { id: 'r3', type: 'rule' } }).tree;
|
||
const r = run(seeded, { op: 'hide', target: 'r3' });
|
||
return !r.ok && /cannot be hidden/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── reorder ───────────────────────────────────────────────────────────── */
|
||
|
||
record('reorder: rearranges a container’s children', (() => {
|
||
const r = run(tree(), { op: 'reorder', parent: 'top', order: ['two', 'one'] });
|
||
return r.ok && findNode(r.tree, 'top').children.map((n) => n.id).join() === 'two,one';
|
||
})());
|
||
|
||
record('reorder: the root is addressable with a null parent', (() => {
|
||
const r = run(tree(), { op: 'reorder', parent: null, order: ['bottom', 'top'] });
|
||
return r.ok && r.tree.map((n) => n.id).join() === 'bottom,top';
|
||
})());
|
||
|
||
record('reorder: a partial order keeps the unnamed nodes after it', (() => {
|
||
const seeded = run(tree(), {
|
||
op: 'add', parent: 'top', node: { id: 'three', type: 'metric', data: { source: SOURCE } },
|
||
}).tree;
|
||
const r = run(seeded, { op: 'reorder', parent: 'top', order: ['three'] });
|
||
return r.ok && findNode(r.tree, 'top').children.map((n) => n.id).join() === 'three,one,two';
|
||
})());
|
||
|
||
record('reorder: an id from another container is refused', (() => {
|
||
const r = run(tree(), { op: 'reorder', parent: 'top', order: ['bottom'] });
|
||
return !r.ok && /is not inside `top`/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('reorder: a repeated id is refused', (() => {
|
||
const r = run(tree(), { op: 'reorder', parent: 'top', order: ['one', 'one'] });
|
||
return !r.ok && /same node twice/.test(r.problems[0].message);
|
||
})());
|
||
|
||
/* ── Permission ────────────────────────────────────────────────────────── */
|
||
|
||
record('permission: a role-restricted type is refused for another role', (() => {
|
||
const r = run(tree(), {
|
||
op: 'add', parent: 'bottom', node: { id: 'b', type: 'banner' },
|
||
}, { role: 'talent' });
|
||
return !r.ok && /do not have access/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('permission: the same node is allowed for the role that may have it', (() => {
|
||
const r = run(tree(), {
|
||
op: 'add', parent: 'bottom', node: { id: 'b', type: 'banner' },
|
||
}, { role: 'admin' });
|
||
return r.ok;
|
||
})());
|
||
|
||
/* ── Engine behaviour ──────────────────────────────────────────────────── */
|
||
|
||
record('an unknown operation is refused by name', (() => {
|
||
const r = run(tree(), { op: 'summon', target: 'one' });
|
||
return !r.ok && /Unknown operation: summon/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('a refused operation returns the tree untouched', (() => {
|
||
const before = tree();
|
||
const r = applyOperation(before, { op: 'remove', target: 'one' }, ctx);
|
||
return !r.ok && r.tree === before;
|
||
})());
|
||
|
||
record('a batch is all-or-nothing', (() => {
|
||
const before = tree();
|
||
const r = applyOperations(before, [
|
||
{ op: 'hide', target: 'one' },
|
||
{ op: 'remove', target: 'two' },
|
||
], ctx);
|
||
return !r.ok && r.tree === before && r.problems[0].opIndex === 1;
|
||
})());
|
||
|
||
record('a successful batch applies in order', (() => {
|
||
const r = applyOperations(tree(), [
|
||
{ op: 'hide', target: 'one' },
|
||
{ op: 'reorder', parent: 'top', order: ['two', 'one'] },
|
||
], ctx);
|
||
return r.ok && findNode(r.tree, 'one').hidden && findNode(r.tree, 'top').children[0].id === 'two';
|
||
})());
|
||
|
||
record('a duplicate id anywhere in the tree is caught', (() => {
|
||
const broken = [
|
||
makeNode({ id: 'dup', type: 'section', children: [makeNode({ id: 'dup', type: 'section' })] }),
|
||
];
|
||
const { ok, problems } = validateTree(broken, ctx);
|
||
return !ok && problems.some((p) => /Two nodes share the id `dup`/.test(p.message));
|
||
})());
|
||
|
||
record('validation reports every problem, not just the first', (() => {
|
||
const broken = [
|
||
makeNode({ id: 'a', type: 'nope' }),
|
||
makeNode({ id: 'b', type: 'alsonope' }),
|
||
];
|
||
return validateTree(broken, ctx).problems.length >= 2;
|
||
})());
|
||
|
||
/* ── Inspect ───────────────────────────────────────────────────────────── */
|
||
|
||
record('inspect: describes every node with its capabilities', (() => {
|
||
const seen = inspectMod.inspectTree(tree(), ctx);
|
||
const one = seen.find((n) => n.id === 'one');
|
||
return seen.length === 4 && one.type === 'metric' && one.capabilities.includes('hide')
|
||
&& one.parent === 'top' && one.data.source === SOURCE;
|
||
})());
|
||
|
||
record('inspect: reports the editable properties a type declares', (() => {
|
||
const one = inspectMod.inspectTree(tree(), ctx).find((n) => n.id === 'one');
|
||
return one.editable.map((f) => f.key).sort().join() === 'rank,title,tone'
|
||
&& one.editable.find((f) => f.key === 'tone').options.includes('positive');
|
||
})());
|
||
|
||
record('inspect: an outline reads as a structure', (() => {
|
||
const lines = inspectMod.outlineTree(tree(), ctx);
|
||
return lines.length === 4 && lines[0].includes('Overview') && lines[1].startsWith(' ');
|
||
})());
|
||
|
||
record('resolveTarget: an id said verbatim wins', (() => {
|
||
const hits = inspectMod.resolveTarget(tree(), 'one', ctx);
|
||
return hits.length > 0 && hits[0].id === 'one';
|
||
})());
|
||
|
||
record('resolveTarget: matches on a node’s visible title', (() => {
|
||
const hits = inspectMod.resolveTarget(tree(), 'the overview section', ctx);
|
||
return hits.length > 0 && hits[0].id === 'top';
|
||
})());
|
||
|
||
record('resolveTarget: returns nothing rather than guessing', (() => {
|
||
return inspectMod.resolveTarget(tree(), 'the quarterly revenue waterfall', ctx).length === 0;
|
||
})());
|
||
|
||
record('resolveTarget: keeps ties as candidates instead of picking one', (() => {
|
||
const hits = inspectMod.resolveTarget(tree(), 'metric', ctx);
|
||
return hits.length === 2;
|
||
})());
|
||
|
||
record('addableTypes: offers only what the container accepts', (() => {
|
||
const inPanel = inspectMod.addableTypes('panel', ctx).map((t) => t.type);
|
||
return inPanel.join() === 'metric';
|
||
})());
|
||
|
||
record('addableTypes: hides a type the role may not use', (() => {
|
||
const asTalent = inspectMod.addableTypes('section', { ...ctx, role: 'talent' }).map((t) => t.type);
|
||
return !asTalent.includes('banner');
|
||
})());
|
||
|
||
/* ── Patch: stored operations, not stored trees ────────────────────────── */
|
||
|
||
record('patch: applying a stored op list reproduces the change', (() => {
|
||
const patch = patchMod.pushOp(patchMod.emptyPatch('demo'), { op: 'hide', target: 'one' });
|
||
const { tree: next } = patchMod.applyPatch(tree(), patch, ctx);
|
||
return findNode(next, 'one').hidden === true;
|
||
})());
|
||
|
||
record('patch: undo drops the last operation', (() => {
|
||
let patch = patchMod.emptyPatch('demo');
|
||
patch = patchMod.pushOp(patch, { op: 'hide', target: 'one' });
|
||
patch = patchMod.pushOp(patch, { op: 'hide', target: 'two' });
|
||
const undone = patchMod.popOp(patch);
|
||
const { tree: next } = patchMod.applyPatch(tree(), undone, ctx);
|
||
return undone.ops.length === 1 && findNode(next, 'one').hidden && !findNode(next, 'two').hidden;
|
||
})());
|
||
|
||
record('patch: reset returns the page the application ships', (() => {
|
||
let patch = patchMod.pushOp(patchMod.emptyPatch('demo'), { op: 'hide', target: 'one' });
|
||
patch = patchMod.clearOps(patch);
|
||
const { tree: next } = patchMod.applyPatch(tree(), patch, ctx);
|
||
return !findNode(next, 'one').hidden;
|
||
})());
|
||
|
||
record('patch: an op naming a node that no longer exists is skipped, not fatal', (() => {
|
||
const patch = {
|
||
schema: patchMod.PATCH_SCHEMA,
|
||
page: 'demo',
|
||
ops: [{ op: 'hide', target: 'departed' }, { op: 'hide', target: 'two' }],
|
||
};
|
||
const { tree: next, skipped } = patchMod.applyPatch(tree(), patch, ctx);
|
||
return skipped.length === 1 && findNode(next, 'two').hidden === true;
|
||
})());
|
||
|
||
record('patch: an unreadable operation is dropped on load', (() => {
|
||
const { patch, dropped } = patchMod.normalizePatch({
|
||
schema: patchMod.PATCH_SCHEMA,
|
||
ops: [{ op: 'hide', target: 'one' }, { op: 'detonate' }, 'nonsense'],
|
||
}, 'demo');
|
||
return patch.ops.length === 1 && dropped === 2;
|
||
})());
|
||
|
||
record('patch: a stored op carries only the fields its operation reads', (() => {
|
||
/* `component` is the field that would matter if this boundary leaked: a
|
||
stored blob is the one place a value the engine never validated could
|
||
arrive from. It must not survive the read. */
|
||
const op = patchMod.normalizeOp({ op: 'hide', target: 'one', component: '<script>', extra: 1 });
|
||
return Object.keys(op).sort().join() === 'op,target';
|
||
})());
|
||
|
||
record('patch: a patch from an unknown schema version is not half-read', (() => {
|
||
const { patch, dropped } = patchMod.normalizePatch({ schema: 999, ops: [{ op: 'hide', target: 'one' }] }, 'demo');
|
||
return patch.ops.length === 0 && dropped === 1;
|
||
})());
|
||
|
||
record('patch: an empty page patch is not stored', (() => {
|
||
const { layouts } = patchMod.normalizeLayouts({
|
||
demo: { schema: patchMod.PATCH_SCHEMA, ops: [] },
|
||
other: { schema: patchMod.PATCH_SCHEMA, ops: [{ op: 'hide', target: 'one' }] },
|
||
});
|
||
return !('demo' in layouts) && 'other' in layouts;
|
||
})());
|
||
|
||
/* ── Extensibility: a new type needs no engine change ──────────────────── */
|
||
|
||
record('a type registered at run time works with no change to the engine', (() => {
|
||
const r2 = freshRegistry();
|
||
r2.register({
|
||
type: 'sparkline', label: 'Sparkline', component: Stub,
|
||
dataShapes: ['stats'], propSchema: { title: { type: 'string' } },
|
||
});
|
||
const c2 = { registry: r2 };
|
||
const added = applyOperation(tree(), {
|
||
op: 'add', parent: 'bottom', node: { id: 'sp', type: 'sparkline', data: { source: SOURCE } },
|
||
}, c2);
|
||
if (!added.ok) return false;
|
||
const moved = applyOperation(added.tree, { op: 'move', target: 'sp', parent: 'top', index: 0 }, c2);
|
||
const swapped = applyOperation(moved.tree, { op: 'replace', target: 'sp', type: 'grid' }, c2);
|
||
const seen = inspectMod.inspectTree(swapped.tree, c2).find((n) => n.id === 'sp');
|
||
return moved.ok && swapped.ok && seen.type === 'grid';
|
||
})());
|
||
|
||
/* ── The rule that decays silently ─────────────────────────────────────── */
|
||
|
||
const typeBranches = (() => {
|
||
/* Every UI type name the application knows, plus the page keys — none of
|
||
them may appear in the engine. Read from the vocabularies rather than
|
||
listed here, so a type added later is covered without editing this. */
|
||
const banned = [
|
||
...surfaces.SUPPORTED_SECTION_TYPES,
|
||
...surfaces.SUPPORTED_SKILL_PAGES,
|
||
/* Every type the application has registered. Empty until the node types
|
||
land, and self-tightening after that: a type registered tomorrow is
|
||
banned from the engine tomorrow, without this list being edited. */
|
||
...registryMod.nodeRegistry.list(),
|
||
];
|
||
const engine = ['node.js', 'registry.js', 'operations.js', 'validate.js', 'patch.js', 'inspect.js']
|
||
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/lib/ui/${f}`)), 'utf8'));
|
||
|
||
const offences = [];
|
||
engine.forEach((source, i) => {
|
||
/* Comments explain the design and legitimately name things; code must
|
||
not. Strip block and line comments before looking. */
|
||
const code = source
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/^\s*\/\/.*$/gm, '');
|
||
for (const word of banned) {
|
||
if (new RegExp(`['"\`]${word}['"\`]`).test(code)) {
|
||
offences.push(`${['node', 'registry', 'operations', 'validate', 'patch', 'inspect'][i]}.js: ${word}`);
|
||
}
|
||
}
|
||
});
|
||
return offences;
|
||
})();
|
||
record('the engine contains no branch on a node type', typeBranches.length === 0, typeBranches.join(', '));
|
||
|
||
/* ── The existing skill vocabulary is untouched ────────────────────────── */
|
||
|
||
record('the UI engine did not change the skill section vocabulary',
|
||
surfaces.SUPPORTED_SECTION_TYPES.join(',') === 'card,stats,list,timeline,flow,table,progress,insight,weights');
|
||
}
|
||
|
||
/* ── UI composition, renderer and the first migrated page ──────────────────
|
||
Phase 2. The engine from the previous section now draws something, and one
|
||
page composes through it. The assertions that matter most are the last two:
|
||
that the renderer adds no markup of its own, and that the migrated page
|
||
renders what it always rendered. */
|
||
{
|
||
console.log('\n── UI composition and rendering ──');
|
||
|
||
const compositionMod = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const registryMod2 = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const nodeMod2 = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
const patchMod2 = await server.ssrLoadModule('/src/lib/ui/patch.js');
|
||
const validateMod2 = await server.ssrLoadModule('/src/lib/ui/validate.js');
|
||
const opsMod2 = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
|
||
/* The page's own module registers its types and its composition as a side
|
||
effect of being imported — the same thing the page does when it mounts. */
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/activity/nodes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/hired-history/nodes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/positions/nodes.js');
|
||
/* Loaded so the scope assertions have a second real page to compare against,
|
||
and so the two slots a page mounts can be told apart by name. */
|
||
await server.ssrLoadModule('/src/pages/admin/candidates-analysis/nodes.jsx');
|
||
|
||
const appRegistry = registryMod2.nodeRegistry;
|
||
|
||
/* ── The composition ───────────────────────────────────────────────────── */
|
||
|
||
record('the migrated page registered a composition',
|
||
compositionMod.hasComposition('activity'));
|
||
|
||
record('the migrated pages compose through the tree', (() => {
|
||
const pages = compositionMod.composedPages();
|
||
return {
|
||
pass: ['activity', 'hired-history', 'positions'].every((p) => pages.includes(p)),
|
||
detail: pages.join(', '),
|
||
};
|
||
})().pass);
|
||
|
||
const activityTree = compositionMod.composePage('activity').tree;
|
||
|
||
record('the composition is the page in reading order',
|
||
activityTree.map((n) => n.id).join(',')
|
||
=== 'activity-extensions-top,activity-summary,activity-privileged-notice,timeline,audit,activity-extensions-bottom',
|
||
activityTree.map((n) => n.id).join(', '));
|
||
|
||
record('node ids are continuous with the ids the DOM already carried',
|
||
activityTree.some((n) => n.id === 'timeline') && activityTree.some((n) => n.id === 'audit'));
|
||
|
||
record('every composed node names a registered type',
|
||
activityTree.every((n) => appRegistry.has(n.type)),
|
||
activityTree.filter((n) => !appRegistry.has(n.type)).map((n) => n.type).join(', '));
|
||
|
||
record('the composition validates against the application registry', (() => {
|
||
const { ok, problems } = validateMod2.validateTree(activityTree, { registry: appRegistry });
|
||
return { ok, detail: problems.map((p) => p.message).join('; ') };
|
||
})().ok);
|
||
|
||
record('every composed node is a built-in',
|
||
activityTree.every((n) => n.origin === 'builtin'));
|
||
|
||
record('a page section can be moved and hidden, and nothing else', (() => {
|
||
const section = appRegistry.get('activity-audit-log');
|
||
return section.capabilities.join(',') === 'move,hide';
|
||
})());
|
||
|
||
record('a built-in section cannot be removed', (() => {
|
||
const r = opsMod2.applyOperation(activityTree, { op: 'remove', target: 'audit' }, { registry: appRegistry });
|
||
return !r.ok;
|
||
})());
|
||
|
||
record('the skill surface slot is a container that moves, hides and reorders', (() => {
|
||
const surface = appRegistry.get('skill-surface');
|
||
return surface.container === true && surface.capabilities.join(',') === 'move,hide,reorder';
|
||
})());
|
||
|
||
record('a slot accepts only the nine reading types', (() => {
|
||
const surface = appRegistry.get('skill-surface');
|
||
return surface.accepts.join(',') === surfaces.SUPPORTED_SECTION_TYPES.join(',');
|
||
})());
|
||
|
||
record('a composition is unaffected by a patch for another page', (() => {
|
||
const patch = patchMod2.pushOp(patchMod2.emptyPatch('somewhere-else'), { op: 'hide', target: 'audit' });
|
||
const { tree } = compositionMod.composePage('activity', { patch: { ...patch, page: 'activity', ops: [] } });
|
||
return tree.every((n) => !n.hidden);
|
||
})());
|
||
|
||
record('a saved patch is folded into what the page composes', (() => {
|
||
const patch = patchMod2.pushOp(patchMod2.emptyPatch('activity'), { op: 'hide', target: 'audit' });
|
||
const { tree } = compositionMod.composePage('activity', { patch, registry: appRegistry });
|
||
return nodeMod2.findNode(tree, 'audit').hidden === true;
|
||
})());
|
||
|
||
record('a preview is composed on top of what was saved', (() => {
|
||
const patch = patchMod2.pushOp(patchMod2.emptyPatch('activity'), { op: 'hide', target: 'audit' });
|
||
const preview = patchMod2.pushOp(patchMod2.emptyPatch('activity'), {
|
||
op: 'reorder', parent: null, order: ['timeline', 'activity-summary'],
|
||
});
|
||
const { tree } = compositionMod.composePage('activity', { patch, preview, registry: appRegistry });
|
||
return nodeMod2.findNode(tree, 'audit').hidden === true && tree[0].id === 'timeline';
|
||
})());
|
||
|
||
record('composing does not mutate the registered composition', (() => {
|
||
const patch = patchMod2.pushOp(patchMod2.emptyPatch('activity'), { op: 'hide', target: 'audit' });
|
||
compositionMod.composePage('activity', { patch, registry: appRegistry });
|
||
const fresh = compositionMod.composePage('activity').tree;
|
||
return fresh.every((n) => !n.hidden);
|
||
})());
|
||
|
||
record('a page that never registered composes to nothing',
|
||
compositionMod.composePage('a-page-that-does-not-exist').tree.length === 0);
|
||
|
||
/* ── The renderer ──────────────────────────────────────────────────────── */
|
||
|
||
const hadWindow2 = 'window' in globalThis;
|
||
if (!hadWindow2) {
|
||
globalThis.window = {
|
||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||
addEventListener() {}, removeEventListener() {},
|
||
};
|
||
}
|
||
|
||
const { UiTreeRenderer } = await server.ssrLoadModule('/src/components/ui-tree/UiTreeRenderer.jsx');
|
||
const { MemoryRouter: Router2 } = await import('react-router-dom');
|
||
|
||
const draw = (nodes, context = {}) => renderToStaticMarkup(
|
||
React.createElement(Router2, null,
|
||
React.createElement(UiTreeRenderer, { nodes, context, registry: appRegistry }))
|
||
);
|
||
|
||
/* A registry of plain components, so what the renderer emits is visible with
|
||
nothing else in the way. */
|
||
const plain = new registryMod2.NodeTypeRegistry();
|
||
plain.register({ type: 'leaf', component: ({ attrs = {} }) => React.createElement('p', attrs, 'leaf') });
|
||
plain.register({ type: 'box', component: ({ attrs = {}, children }) => React.createElement('section', attrs, children), container: true });
|
||
plain.register({ type: 'bare', component: () => React.createElement('span', null, 'bare'), wrap: true });
|
||
|
||
const drawPlain = (nodes) => renderToStaticMarkup(
|
||
React.createElement(UiTreeRenderer, { nodes, registry: plain })
|
||
);
|
||
|
||
record('the renderer adds no element of its own', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({ id: 'a', type: 'leaf' }), nodeMod2.makeNode({ id: 'b', type: 'leaf' })]);
|
||
const stripped = html.replace(/ data-ui-(?:node|type)="[^"]*"/g, '');
|
||
return stripped === '<p>leaf</p><p>leaf</p>';
|
||
})());
|
||
|
||
record('a node carries its identity into the DOM', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({ id: 'only', type: 'leaf' })]);
|
||
return html.includes('data-ui-node="only"') && html.includes('data-ui-type="leaf"');
|
||
})());
|
||
|
||
record('a type that cannot forward props gets a wrapper carrying only identity', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({ id: 'w', type: 'bare' })]);
|
||
return html === '<div data-ui-node="w" data-ui-type="bare"><span>bare</span></div>';
|
||
})());
|
||
|
||
record('a hidden node renders nothing at all', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({ id: 'a', type: 'leaf', hidden: true })]);
|
||
return html === '';
|
||
})());
|
||
|
||
record('a node of an unknown type renders nothing rather than throwing', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({ id: 'ghost', type: 'departed-type' })]);
|
||
return html === '';
|
||
})());
|
||
|
||
record('a container draws its children inside itself', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({
|
||
id: 'outer', type: 'box', children: [nodeMod2.makeNode({ id: 'inner', type: 'leaf' })],
|
||
})]);
|
||
return /<section[^>]*data-ui-node="outer"[^>]*><p[^>]*data-ui-node="inner"/.test(html);
|
||
})());
|
||
|
||
record('a container with no layout adds no grid', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({
|
||
id: 'outer', type: 'box', children: [nodeMod2.makeNode({ id: 'inner', type: 'leaf' })],
|
||
})]);
|
||
return !html.includes('class=');
|
||
})());
|
||
|
||
record('a container asking for columns renders a grid', (() => {
|
||
const html = drawPlain([nodeMod2.makeNode({
|
||
id: 'outer', type: 'box', layout: { columns: 2 },
|
||
children: [nodeMod2.makeNode({ id: 'inner', type: 'leaf' })],
|
||
})]);
|
||
return /class="grid grid-cols-1 sm:grid-cols-2 gap-4"/.test(html);
|
||
})());
|
||
|
||
record('reordering the tree reorders the markup', (() => {
|
||
const nodes = [nodeMod2.makeNode({ id: 'a', type: 'leaf' }), nodeMod2.makeNode({ id: 'b', type: 'leaf' })];
|
||
const moved = opsMod2.applyOperation(nodes, { op: 'reorder', parent: null, order: ['b', 'a'] }, { registry: plain });
|
||
const html = drawPlain(moved.tree);
|
||
return html.indexOf('data-ui-node="b"') < html.indexOf('data-ui-node="a"');
|
||
})());
|
||
|
||
record('the renderer never receives a component name from configuration', (() => {
|
||
/* The only path from a node to a component is `entry.component`, and an
|
||
entry is only created by a `register` call that was handed a reference.
|
||
A node naming a type nobody registered draws nothing — asserted above —
|
||
so there is no string that can become code. */
|
||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||
.replace(/^\s*\/\/.*$/gm, '');
|
||
return !/\bimport\s*\(/.test(source)
|
||
&& !/\beval\s*\(/.test(source)
|
||
&& !/new\s+Function/.test(source)
|
||
&& !/dangerouslySetInnerHTML/.test(source);
|
||
})());
|
||
|
||
/* ── The migrated page renders what it always rendered ─────────────────── */
|
||
|
||
{
|
||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||
const Page = (await server.ssrLoadModule('/src/pages/admin/Activity.jsx')).default;
|
||
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||
/* Mounted the way the app mounts it: the editing session lives in the
|
||
layout, above both the page and the panel. */
|
||
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
|
||
let now = renderToStaticMarkup(
|
||
React.createElement(Router2, { initialEntries: ['/admin/activity'] },
|
||
React.createElement(QueryClientProvider, { client },
|
||
React.createElement(UiEditingProvider, { page: 'activity' },
|
||
React.createElement(Page))))
|
||
);
|
||
|
||
const before = readFileSync(join(ROOT, 'scripts/__baseline__/activity-page.pre-migration.html'), 'utf8');
|
||
|
||
/**
|
||
* Remove the layout controls before comparing.
|
||
*
|
||
* Phase 3 adds one genuinely new thing to the page — the affordance that
|
||
* lets a person change it — and that is a deliberate addition, not a
|
||
* regression in what the page already drew. Everything below the controls
|
||
* must still be exactly what it was, so the control subtree is lifted out
|
||
* by balanced tag counting and the rest is compared untouched.
|
||
*/
|
||
const stripControls = (html) => {
|
||
const at = html.indexOf('<div data-ui-controls');
|
||
if (at < 0) return html;
|
||
let depth = 0;
|
||
let i = at;
|
||
while (i < html.length) {
|
||
if (html.startsWith('<div', i)) { depth += 1; i = html.indexOf('>', i) + 1; continue; }
|
||
if (html.startsWith('</div>', i)) {
|
||
depth -= 1; i += 6;
|
||
if (depth === 0) break;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
}
|
||
return html.slice(0, at) + html.slice(i);
|
||
};
|
||
|
||
record('the layout controls are present and separable',
|
||
now.includes('<div data-ui-controls') && stripControls(now).indexOf('data-ui-controls') === -1);
|
||
|
||
now = stripControls(now);
|
||
|
||
/**
|
||
* Two renderings are the same page when they paint the same styled boxes,
|
||
* in the same order, around the same words.
|
||
*
|
||
* Compared this way rather than byte-for-byte because Phase 2 legitimately
|
||
* adds two things and must be free to add exactly those: the `data-ui-*`
|
||
* identity attributes, and — for one node whose component destructures its
|
||
* props — a wrapper element with no class and no content of its own. Both
|
||
* are invisible. Anything else, including a single changed utility class or
|
||
* a reordered section, moves the signature and fails here.
|
||
*/
|
||
const classes = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
|
||
const words = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
|
||
record('the migrated page paints the same styled elements, in the same order',
|
||
classes(now) === classes(before),
|
||
classes(now) === classes(before) ? '' : 'class sequence differs');
|
||
|
||
record('the migrated page shows the same words',
|
||
words(now) === words(before),
|
||
words(now) === words(before) ? '' : 'text differs');
|
||
|
||
/**
|
||
* The exact delta, pinned two ways.
|
||
*
|
||
* A prefix/suffix diff cannot isolate this change — the wrapper's opening
|
||
* and closing tags are ~3kB apart and everything between them looks
|
||
* inserted — so the delta is characterised instead: the same tags in the
|
||
* same numbers except for one extra `div` pair, and that pair carrying
|
||
* nothing but the node's identity. Together with the class-sequence and
|
||
* word assertions above, that leaves no room for a visible change.
|
||
*/
|
||
const tagNames = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || []).map((t) => t.toLowerCase());
|
||
|
||
record('exactly one element was added, and it is a div', (() => {
|
||
const tallies = (list) => list.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
|
||
const b = tallies(tagNames(before));
|
||
const n = tallies(tagNames(now));
|
||
const names = [...new Set([...Object.keys(b), ...Object.keys(n)])];
|
||
const changed = names.filter((t) => (b[t] || 0) !== (n[t] || 0));
|
||
return {
|
||
pass: changed.join(',') === '<div,</div'
|
||
&& n['<div'] - b['<div'] === 1 && n['</div'] - b['</div'] === 1,
|
||
detail: changed.join(', '),
|
||
};
|
||
})().pass);
|
||
|
||
record('the added element paints nothing — identity attributes only',
|
||
now.includes('<div data-ui-node="activity-summary" data-ui-type="activity-summary">'));
|
||
|
||
record('the migrated page carries stable node identity in the DOM', (() => {
|
||
const ids = [...now.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
|
||
return ids.includes('timeline') && ids.includes('audit') && ids.includes('activity-summary');
|
||
})());
|
||
|
||
record('the page still renders its heading and both sections',
|
||
now.includes('Operational timeline') && now.includes('Audit log') && now.includes('Total events'));
|
||
}
|
||
|
||
if (!hadWindow2) delete globalThis.window;
|
||
}
|
||
|
||
/* ── Preview, Apply, and surviving a reload ────────────────────────────────
|
||
Phase 3. The loop that makes this a runtime feature: a change is proposed,
|
||
shown, and either kept in the person's own account or thrown away — with the
|
||
application's source untouched either way. */
|
||
{
|
||
console.log('\n── UI persistence ──');
|
||
|
||
const patchMod3 = await server.ssrLoadModule('/src/lib/ui/patch.js');
|
||
const compositionMod3 = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const registryMod3 = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const nodeMod3 = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/activity/nodes.jsx');
|
||
|
||
const reg3 = registryMod3.nodeRegistry;
|
||
const ctx3 = { registry: reg3 };
|
||
const PAGE = 'activity';
|
||
|
||
/** What a person's change looks like once they have made it. */
|
||
const hideAudit = { op: 'hide', target: 'audit', hidden: true };
|
||
const previewPatch = patchMod3.pushOp(patchMod3.emptyPatch(PAGE), hideAudit);
|
||
|
||
/* ── 1. Proposed, and visible, without being stored ────────────────────── */
|
||
|
||
record('a proposed change shows in the composed page', (() => {
|
||
const { tree } = compositionMod3.composePage(PAGE, { preview: previewPatch, ...ctx3 });
|
||
return nodeMod3.findNode(tree, 'audit').hidden === true;
|
||
})());
|
||
|
||
record('a preview writes nothing to the layout store', (() => {
|
||
/* The store is only ever changed by `mergeLayouts`. A preview never reaches
|
||
it, which is why previewing takes no argument from it and cannot. */
|
||
const stored = patchMod3.mergeLayouts({}, PAGE, patchMod3.emptyPatch(PAGE));
|
||
return Object.keys(stored).length === 0;
|
||
})());
|
||
|
||
record('discarding leaves the store exactly as it was', (() => {
|
||
const saved = { [PAGE]: patchMod3.pushOp(patchMod3.emptyPatch(PAGE), { op: 'hide', target: 'timeline' }) };
|
||
/* Discard is dropping the preview; the saved map is not a party to it. */
|
||
const after = patchMod3.mergeLayouts(saved, PAGE, saved[PAGE]);
|
||
return JSON.stringify(after) === JSON.stringify(saved);
|
||
})());
|
||
|
||
/* ── 2. Applying stores operations, and only operations ────────────────── */
|
||
|
||
const applied = patchMod3.mergeLayouts({}, PAGE, previewPatch);
|
||
|
||
record('applying stores the patch under its own page key',
|
||
Object.keys(applied).join() === PAGE, Object.keys(applied).join(', '));
|
||
record('the stored payload is a schema, a page and an operation list',
|
||
Object.keys(applied[PAGE]).sort().join() === 'ops,page,schema,updatedAt',
|
||
Object.keys(applied[PAGE]).sort().join(', '));
|
||
|
||
record('a stored operation carries no markup, no component and no Markdown', (() => {
|
||
const json = JSON.stringify(applied);
|
||
return !/</.test(json) && !/component|className|jsx|markdown|---/i.test(json);
|
||
})());
|
||
|
||
record('the stored operation is exactly what was proposed',
|
||
JSON.stringify(applied[PAGE].ops) === JSON.stringify([hideAudit]));
|
||
|
||
record('applying writes only the uiLayouts key, so no other preference moves', (() => {
|
||
/* `useUiLayouts.save` sends `{ uiLayouts }` and nothing else; the endpoint
|
||
shallow-merges, so `customSkills` and the rest survive untouched. */
|
||
const source = readFileSync(join(ROOT, resolveSourcePath('src/lib/krowHooks.js')), 'utf8');
|
||
const fn = source.slice(source.indexOf('export function useUiLayouts'), source.indexOf('export function useJobPostings'));
|
||
const writes = [...fn.matchAll(/mutateAsync\(\{([^}]*)\}/g)].map((m) => m[1].trim());
|
||
return writes.length === 1 && writes[0].startsWith('uiLayouts:');
|
||
})());
|
||
|
||
/* ── 3. A cold reload rebuilds the same page ───────────────────────────── */
|
||
|
||
record('a stored patch survives the round trip through JSON', (() => {
|
||
const overWire = JSON.parse(JSON.stringify(applied));
|
||
const { layouts } = patchMod3.normalizeLayouts(overWire);
|
||
return JSON.stringify(layouts[PAGE].ops) === JSON.stringify([hideAudit]);
|
||
})());
|
||
|
||
record('a cold reload reconstructs the identical effective tree', (() => {
|
||
/* What the browser had before the reload. */
|
||
const live = compositionMod3.composePage(PAGE, { preview: previewPatch, ...ctx3 }).tree;
|
||
/* What it rebuilds from storage alone, with no preview and no memory. */
|
||
const overWire = JSON.parse(JSON.stringify(applied));
|
||
const { layouts } = patchMod3.normalizeLayouts(overWire);
|
||
const rebuilt = compositionMod3.composePage(PAGE, { patch: layouts[PAGE], ...ctx3 }).tree;
|
||
return JSON.stringify(rebuilt) === JSON.stringify(live);
|
||
})());
|
||
|
||
record('a person with no stored layout gets the page as it ships', (() => {
|
||
const { layouts } = patchMod3.normalizeLayouts(undefined);
|
||
const tree = compositionMod3.composePage(PAGE, { patch: layouts[PAGE], ...ctx3 }).tree;
|
||
const shipped = compositionMod3.composePage(PAGE, ctx3).tree;
|
||
return JSON.stringify(tree) === JSON.stringify(shipped);
|
||
})());
|
||
|
||
record('one account’s layout is not readable from another’s preferences', (() => {
|
||
/* The store is a value inside one user's preferences row, addressed by the
|
||
session. Two accounts are two objects; there is no shared key and nothing
|
||
here reads a page's layout from anywhere but the caller's own. */
|
||
const mine = patchMod3.mergeLayouts({}, PAGE, previewPatch);
|
||
const theirs = patchMod3.normalizeLayouts(undefined).layouts;
|
||
const theirTree = compositionMod3.composePage(PAGE, { patch: theirs[PAGE], ...ctx3 }).tree;
|
||
return Object.keys(theirs).length === 0
|
||
&& nodeMod3.findNode(theirTree, 'audit').hidden === false
|
||
&& nodeMod3.findNode(compositionMod3.composePage(PAGE, { patch: mine[PAGE], ...ctx3 }).tree, 'audit').hidden === true;
|
||
})());
|
||
|
||
/* ── 4. Undo and reset ─────────────────────────────────────────────────── */
|
||
|
||
record('undo drops the most recent change and keeps the rest', (() => {
|
||
let patch = patchMod3.emptyPatch(PAGE);
|
||
patch = patchMod3.pushOp(patch, { op: 'hide', target: 'timeline' });
|
||
patch = patchMod3.pushOp(patch, hideAudit);
|
||
const undone = patchMod3.popOp(patch);
|
||
const { tree } = compositionMod3.composePage(PAGE, { patch: undone, ...ctx3 });
|
||
return undone.ops.length === 1
|
||
&& nodeMod3.findNode(tree, 'timeline').hidden === true
|
||
&& nodeMod3.findNode(tree, 'audit').hidden === false;
|
||
})());
|
||
|
||
record('reset restores the page the application ships', (() => {
|
||
const patch = patchMod3.clearOps(previewPatch);
|
||
const { tree } = compositionMod3.composePage(PAGE, { patch, ...ctx3 });
|
||
return JSON.stringify(tree) === JSON.stringify(compositionMod3.composePage(PAGE, ctx3).tree);
|
||
})());
|
||
|
||
record('reset removes the page’s key rather than storing an empty patch', (() => {
|
||
const store = patchMod3.mergeLayouts(applied, PAGE, patchMod3.clearOps(previewPatch));
|
||
return !(PAGE in store);
|
||
})());
|
||
|
||
/* ── 5. A bad patch cannot cost a good one ─────────────────────────────── */
|
||
|
||
record('a stale operation is skipped and the valid ones still apply', (() => {
|
||
const patch = {
|
||
schema: patchMod3.PATCH_SCHEMA,
|
||
page: PAGE,
|
||
ops: [
|
||
{ op: 'hide', target: 'a-section-a-release-removed' },
|
||
hideAudit,
|
||
],
|
||
};
|
||
const { tree, skipped } = compositionMod3.composePage(PAGE, { patch, ...ctx3 });
|
||
return skipped.length === 1 && nodeMod3.findNode(tree, 'audit').hidden === true;
|
||
})());
|
||
|
||
record('one page’s unreadable patch does not touch another page’s', (() => {
|
||
const store = {
|
||
[PAGE]: previewPatch,
|
||
'some-other-page': { schema: 999, ops: [{ op: 'hide', target: 'whatever' }] },
|
||
};
|
||
const { layouts, dropped } = patchMod3.normalizeLayouts(store);
|
||
return dropped === 1 && layouts[PAGE].ops.length === 1 && !('some-other-page' in layouts);
|
||
})());
|
||
|
||
record('saving one page preserves every other page’s layout', (() => {
|
||
const store = patchMod3.mergeLayouts({}, 'positions', patchMod3.pushOp(patchMod3.emptyPatch('positions'), { op: 'hide', target: 'x' }));
|
||
const after = patchMod3.mergeLayouts(store, PAGE, previewPatch);
|
||
return 'positions' in after && PAGE in after;
|
||
})());
|
||
|
||
record('an operation that would be refused is never stored', (() => {
|
||
/* `propose` validates before it previews, so a refused change never becomes
|
||
a preview and therefore never reaches an apply. */
|
||
const provider = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiEditingProvider.jsx')), 'utf8');
|
||
const proposeBody = provider.slice(provider.indexOf('const propose'), provider.indexOf('/** Throw the experiment away'));
|
||
return /applyOperation\(/.test(proposeBody)
|
||
&& /if \(!result\.ok\)/.test(proposeBody)
|
||
&& proposeBody.indexOf('if (!result.ok)') < proposeBody.indexOf('setPreview');
|
||
})());
|
||
|
||
/* ── 6. Nothing in the source is written ───────────────────────────────── */
|
||
|
||
record('no runtime path writes a file, a skill or a definition', (() => {
|
||
const files = [
|
||
'src/lib/ui/patch.js', 'src/lib/ui/composition.js', 'src/lib/ui/operations.js',
|
||
'src/components/ui-tree/UiEditingProvider.jsx',
|
||
'src/components/ui-editor/UiEditor.jsx', 'src/components/ui-editor/NodeInspector.jsx',
|
||
'src/components/ui-editor/NodePicker.jsx', 'src/components/ui-editor/TreePanel.jsx',
|
||
];
|
||
const offences = [];
|
||
for (const f of files) {
|
||
const code = readFileSync(join(ROOT, resolveSourcePath(f)), 'utf8')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
for (const bad of ['writeFile', 'customSkills', 'skillDefinition', 'skill-definitions', '.md']) {
|
||
if (code.includes(bad)) offences.push(`${f}: ${bad}`);
|
||
}
|
||
}
|
||
return { pass: offences.length === 0, detail: offences.join(', ') };
|
||
})().pass);
|
||
|
||
record('the shipped Markdown skills are unchanged and still parse', (() => {
|
||
/* The registry reads the real `.md` files off disk through Vite. Zero
|
||
diagnostics means every one of them still parses exactly as before — the
|
||
strongest statement available that the layout work did not touch them. */
|
||
const { skills, diagnostics } = reg.readSkillRegistry([]);
|
||
return { pass: skills.length > 0 && diagnostics.length === 0, detail: `${skills.length} skills, ${diagnostics.length} diagnostics` };
|
||
})().pass);
|
||
|
||
record('the layout store is a different key from the skill store',
|
||
!JSON.stringify(applied).includes('customSkills'));
|
||
}
|
||
|
||
/* ── Owliver edits the page ────────────────────────────────────────────────
|
||
Phase 4. Natural language reaches the same eight operations everything else
|
||
uses. The assertions that matter most are the ones about what Owliver will
|
||
NOT do: guess a target, invent a type, invent a data source, or apply
|
||
anything in the turn it proposed it. */
|
||
{
|
||
console.log('\n── Owliver UI editing ──');
|
||
|
||
const intentMod = await server.ssrLoadModule('/src/lib/ui/intent.js');
|
||
const uiEditMod = await server.ssrLoadModule('/src/components/ai-assistant/uiEdit.js');
|
||
const routingMod = await server.ssrLoadModule('/src/components/ai-assistant/routing.js');
|
||
const compositionMod4 = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const registryMod4 = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const opsMod4 = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
const nodeMod4 = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/activity/nodes.jsx');
|
||
|
||
const reg4 = registryMod4.nodeRegistry;
|
||
const base = compositionMod4.composePage('activity').tree;
|
||
|
||
/* A page with a reading on it, so "change this to a table" has something to
|
||
act on. Built through the engine, not hand-written, so it is a tree the
|
||
product could really produce. */
|
||
const withChart = opsMod4.applyOperation(base, {
|
||
op: 'add', parent: null,
|
||
node: { id: 'flow-1', type: 'flow', data: { source: 'candidates.activity' }, props: { title: 'Hiring activity' } },
|
||
}, { registry: reg4 });
|
||
record('a reading can be placed on the page for these cases', withChart.ok,
|
||
withChart.ok ? '' : withChart.problems.map((p) => p.message).join('; '));
|
||
|
||
const tree4 = withChart.tree;
|
||
const ask = (q, opts = {}) => intentMod.matchUiEdit(q, { tree: tree4, registry: reg4, ...opts });
|
||
const reply = (q, opts = {}) => uiEditMod.resolveUiEdit({
|
||
question: q,
|
||
ui: { available: true, tree: tree4, registry: reg4, previewing: false, role: null, ...opts },
|
||
});
|
||
|
||
/* ── The four requests ─────────────────────────────────────────────────── */
|
||
|
||
record('"Hide the audit section." → hide the audit node', (() => {
|
||
const m = ask('Hide the audit section.');
|
||
return m?.kind === 'plan' && m.op.op === 'hide' && m.op.target === 'audit' && m.op.hidden === true;
|
||
})());
|
||
|
||
record('"Move timeline above the notice." → a reorder putting it first', (() => {
|
||
const m = ask('Move timeline above the notice.');
|
||
if (m?.kind !== 'plan' || m.op.op !== 'reorder') return { pass: false, detail: JSON.stringify(m) };
|
||
const order = m.op.order;
|
||
return {
|
||
pass: order.indexOf('timeline') < order.indexOf('activity-privileged-notice'),
|
||
detail: order.join(', '),
|
||
};
|
||
})().pass);
|
||
|
||
record('the move is a real change, applied through the engine', (() => {
|
||
const m = ask('Move timeline above the notice.');
|
||
const r = opsMod4.applyOperation(tree4, m.op, { registry: reg4 });
|
||
const ids = r.tree.map((n) => n.id);
|
||
return r.ok && ids.indexOf('timeline') < ids.indexOf('activity-privileged-notice');
|
||
})());
|
||
|
||
record('"Change the hiring activity to a table." → replace with table', (() => {
|
||
const m = ask('Change the hiring activity to a table.');
|
||
return m?.kind === 'plan' && m.op.op === 'replace' && m.op.target === 'flow-1' && m.op.type === 'table';
|
||
})());
|
||
|
||
record('the replacement is accepted by the engine', (() => {
|
||
const m = ask('Change the hiring activity to a table.');
|
||
const r = opsMod4.applyOperation(tree4, m.op, { registry: reg4 });
|
||
return r.ok && nodeMod4.findNode(r.tree, 'flow-1').type === 'table';
|
||
})());
|
||
|
||
record('"Add a card." asks what it should show rather than inventing one', (() => {
|
||
const m = ask('Add a card.');
|
||
return m?.kind === 'needs-source' && m.type.type === 'card' && m.options.length > 0;
|
||
})());
|
||
|
||
record('the sources offered are all real, from the closed vocabulary', (() => {
|
||
const m = ask('Add a card.');
|
||
return m.options.every((id) => surfaces.SUPPORTED_DATA_SOURCES.includes(id));
|
||
})());
|
||
|
||
record('"Add a card showing …" names a source and produces a plan', (() => {
|
||
const m = ask('Add a card showing candidate activity');
|
||
return m?.kind === 'plan' && m.op.op === 'add'
|
||
&& m.op.node.type === 'card'
|
||
&& surfaces.SUPPORTED_DATA_SOURCES.includes(m.op.node.data.source);
|
||
})());
|
||
|
||
/* ── What it refuses ───────────────────────────────────────────────────── */
|
||
|
||
record('an unknown target is reported, not guessed', (() => {
|
||
const m = ask('Hide the quarterly revenue section');
|
||
return m?.kind === 'unknown';
|
||
})());
|
||
|
||
record('a request naming nothing on the page is not a layout edit at all',
|
||
ask('Hide the quarterly revenue waterfall') === null);
|
||
|
||
record('an ambiguous target asks which one', (() => {
|
||
/* Two skill-surface slots sit on this page and share a label, so a phrase
|
||
naming only the label fits both. */
|
||
const m = ask('Hide the skill sections');
|
||
return { pass: m?.kind === 'ambiguous' && m.candidates.length >= 2, detail: m?.kind };
|
||
})().pass);
|
||
|
||
record('the ambiguous reply names the candidates and offers them as chips', (() => {
|
||
const r = reply('Hide the skill sections');
|
||
return r?.kind === 'ui-answer'
|
||
&& /which did you mean/i.test(JSON.stringify(r.doc))
|
||
&& r.followUp.length >= 2;
|
||
})());
|
||
|
||
record('an unregistered UI type is refused, and the real ones are named', (() => {
|
||
const r = reply('Change the hiring activity to a hologram');
|
||
/* `hologram` names no type, so the request cannot resolve to one. */
|
||
return r === null || (r.kind === 'ui-answer' && !/hologram/i.test(JSON.stringify(r.doc)));
|
||
})());
|
||
|
||
record('a type this reading cannot become is refused with the reason', (() => {
|
||
/* `weights` is a registered type; a source that has no weights in it cannot
|
||
be drawn as one, and the refusal says so rather than failing later. */
|
||
const m = ask('Change the hiring activity to weights');
|
||
return { pass: m?.kind === 'refused', detail: m?.message || m?.kind };
|
||
})().pass);
|
||
|
||
record('a built-in section cannot be turned into something else', (() => {
|
||
const m = ask('Change the audit log to a table');
|
||
return m?.kind === 'refused' && /cannot be changed into/.test(m.message);
|
||
})());
|
||
|
||
record('a section that declines hiding says so', (() => {
|
||
/* Every Activity section allows hiding, so this asserts the mechanism on a
|
||
type that does not: capability is read from the registry, not assumed. */
|
||
const r = new registryMod4.NodeTypeRegistry();
|
||
r.register({ type: 'fixed-thing', label: 'Fixed thing', component: () => null, capabilities: ['move'] });
|
||
const t = [nodeMod4.makeNode({ id: 'fixed-thing-1', type: 'fixed-thing', props: { title: 'Header' } })];
|
||
const m = intentMod.matchUiEdit('hide the header', { tree: t, registry: r });
|
||
return m?.kind === 'refused' && /cannot be hidden/.test(m.message);
|
||
})());
|
||
|
||
record('a role without access to a type cannot add it', (() => {
|
||
const r = new registryMod4.NodeTypeRegistry();
|
||
r.register({ type: 'banner', label: 'Banner', component: () => null, roles: ['admin'] });
|
||
const m = intentMod.matchUiEdit('add a banner', { tree: [], registry: r, role: 'talent' });
|
||
return m?.kind === 'refused' && /do not have access/.test(m.message);
|
||
})());
|
||
|
||
record('an invalid property never reaches a plan', (() => {
|
||
/* There is no phrasing that sets an arbitrary property: the only one the
|
||
language layer can set is a column count, and it is bounded twice — by
|
||
the grid, and by the type's own constraints. */
|
||
const m = ask('Make the hiring activity 40 columns');
|
||
if (m?.kind !== 'plan') return true;
|
||
const r = opsMod4.applyOperation(tree4, m.op, { registry: reg4 });
|
||
return !r.ok && /between 1 and 12/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('a valid column count is planned and accepted', (() => {
|
||
const m = ask('Make the hiring activity two columns');
|
||
return m?.kind === 'plan' && m.op.op === 'update' && m.op.layout.columns === 2;
|
||
})());
|
||
|
||
record('a built-in section refuses a change it never declared', (() => {
|
||
/* Activity's own sections declare `move` and `hide` and nothing else, so a
|
||
property change is refused by capability rather than by validation. */
|
||
const m = ask('Make the audit log two columns');
|
||
return m?.kind === 'refused' && /cannot be changed/.test(m.message);
|
||
})());
|
||
|
||
/* ── Preview, then a separate Apply ────────────────────────────────────── */
|
||
|
||
record('a plan comes back as a preview, never as an applied change', (() => {
|
||
const r = reply('Hide the audit section.');
|
||
return r.kind === 'ui-preview'
|
||
&& /preview/i.test(JSON.stringify(r.doc))
|
||
&& r.followUp.map((c) => c.label).join() === 'Apply,Discard';
|
||
})());
|
||
|
||
/**
|
||
* These two asserted that the panel's own chip text returned nothing when
|
||
* nothing was previewed — which is how the phrase reached the model, and how
|
||
* a person clicking "Discard" twice was told by an agent scoped to open roles
|
||
* that layout changes were outside its scope. The corrected contract: the
|
||
* request is still claimed, and still changes nothing. What must keep
|
||
* returning null is `apply` as an ordinary word, asserted just below.
|
||
*/
|
||
record('"apply" with nothing previewed changes nothing, and is not handed on', (() => {
|
||
const r = reply('Apply the layout change');
|
||
return r?.kind === 'ui-answer' && !r.op && /nothing to apply/i.test(JSON.stringify(r.doc));
|
||
})());
|
||
|
||
record('"apply" is recognised only while a preview is on screen', (() => {
|
||
const r = reply('Apply the layout change', { previewing: true });
|
||
return r?.kind === 'ui-apply';
|
||
})());
|
||
|
||
record('"discard" behaves the same way, and acts only during a preview', (() => {
|
||
const idle = reply('Discard the layout change');
|
||
return idle?.kind === 'ui-answer' && !idle.op
|
||
&& /nothing to discard/i.test(JSON.stringify(idle.doc))
|
||
&& reply('Discard the layout change', { previewing: true })?.kind === 'ui-discard';
|
||
})());
|
||
|
||
record('asking what is on the page is a reading, not a change', (() => {
|
||
const r = reply('What is on this page?');
|
||
return r?.kind === 'ui-answer' && /timeline/i.test(JSON.stringify(r.doc));
|
||
})());
|
||
|
||
/* ── Owliver never authors ─────────────────────────────────────────────── */
|
||
|
||
record('no plan carries markup, a component or Markdown', (() => {
|
||
const asks = [
|
||
'Hide the audit section.', 'Move timeline above the notice.',
|
||
'Change the hiring activity to a table.', 'Add a card showing candidate activity',
|
||
'Make the hiring activity two columns',
|
||
];
|
||
const json = JSON.stringify(asks.map((q) => ask(q)?.op).filter(Boolean));
|
||
return !/</.test(json) && !/component|className|jsx|markdown|dangerously/i.test(json);
|
||
})());
|
||
|
||
record('every produced operation is one of the eight the engine knows', (() => {
|
||
const asks = [
|
||
'Hide the audit section.', 'Move timeline above the notice.',
|
||
'Change the hiring activity to a table.', 'Add a card showing candidate activity',
|
||
'Make the hiring activity two columns',
|
||
];
|
||
const kinds = asks.map((q) => ask(q)?.op?.op).filter(Boolean);
|
||
return { pass: kinds.every((k) => opsMod4.OPERATIONS.includes(k)) && kinds.length === 5, detail: kinds.join(', ') };
|
||
})().pass);
|
||
|
||
record('the language layer names no page and no component', (() => {
|
||
const code = readFileSync(join(ROOT, resolveSourcePath('src/lib/ui/intent.js')), 'utf8')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...reg4.list()];
|
||
const hits = banned.filter((w) => new RegExp(`['"\`]${w}['"\`]`).test(code));
|
||
return { pass: hits.length === 0, detail: hits.join(', ') };
|
||
})().pass);
|
||
|
||
/* ── Everything Owliver already did, unchanged ─────────────────────────── */
|
||
|
||
const uiBag = { available: true, tree: tree4, registry: reg4, previewing: false, role: null };
|
||
|
||
record('an ordinary question on a composed page is not taken as a layout edit', (() => {
|
||
const ordinary = [
|
||
'Which candidates need attention?',
|
||
'Show me recent activity',
|
||
'Who applied today?',
|
||
'What is the hiring trend?',
|
||
'Create a position',
|
||
'Create an employee role',
|
||
'Which positions are in draft?',
|
||
'Tell me about Some Person',
|
||
];
|
||
const taken = ordinary.filter((q) => uiEditMod.resolveUiEdit({ question: q, ui: uiBag }) !== null);
|
||
return { pass: taken.length === 0, detail: taken.join(' | ') };
|
||
})().pass);
|
||
|
||
record('with no layout session, routing behaves exactly as before', (() => {
|
||
const withUi = routingMod.resolveIntent({ question: 'Who applied today?', contextId: 'admin.activity', ui: uiBag });
|
||
const without = routingMod.resolveIntent({ question: 'Who applied today?', contextId: 'admin.activity' });
|
||
return withUi.kind === without.kind;
|
||
})());
|
||
|
||
record('a page that composes no tree never reaches the UI matcher',
|
||
uiEditMod.resolveUiEdit({ question: 'Hide the audit section.', ui: { available: false, tree: [] } }) === null);
|
||
|
||
/**
|
||
* Apply and discard with nothing being previewed.
|
||
*
|
||
* Found in a browser, not here: clicking the panel's own "Discard the layout
|
||
* change" chip a second time fell through to the model, and an agent scoped
|
||
* to open roles answered that layout changes were not in its scope. A request
|
||
* about the interface must never be answered by something that does not know
|
||
* the interface exists.
|
||
*
|
||
* The two halves pull against each other and both are asserted: the panel's
|
||
* own words are always claimed, and "apply" as an ordinary English word is
|
||
* never claimed. These support the live browser flow in
|
||
* `scripts/browser-flows.js`; they do not stand in for it.
|
||
*/
|
||
record('apply with nothing previewed is answered here, not by the model', (() => {
|
||
const m = intentMod.matchUiEdit('Apply the layout change', { tree: tree4, registry: reg4, previewing: false });
|
||
return { pass: m?.kind === 'nothing-previewed' && m.op === 'apply', detail: JSON.stringify(m) };
|
||
})().pass);
|
||
|
||
record('discard with nothing previewed is answered here, not by the model', (() => {
|
||
const m = intentMod.matchUiEdit('Discard the layout change', { tree: tree4, registry: reg4, previewing: false });
|
||
return { pass: m?.kind === 'nothing-previewed' && m.op === 'discard', detail: JSON.stringify(m) };
|
||
})().pass);
|
||
|
||
record('and it says so in words rather than acting', (() => {
|
||
const a = uiEditMod.resolveUiEdit({ question: 'Apply the layout change', ui: uiBag });
|
||
const d = uiEditMod.resolveUiEdit({ question: 'Discard the layout change', ui: uiBag });
|
||
const words = JSON.stringify([a, d]);
|
||
return {
|
||
pass: a?.kind === 'ui-answer' && d?.kind === 'ui-answer'
|
||
&& !a.op && !d.op
|
||
&& /nothing to apply/i.test(words) && /nothing to discard/i.test(words),
|
||
detail: words.slice(0, 200),
|
||
};
|
||
})().pass);
|
||
|
||
record('an ordinary "apply" is still not a layout request', (() => {
|
||
const ordinary = [
|
||
'How do I apply for this position?',
|
||
'Can this candidate apply for two roles?',
|
||
'Apply the vetting weights filter',
|
||
'Which candidates applied today?',
|
||
'Discard this draft position',
|
||
];
|
||
const taken = ordinary.filter((q) => uiEditMod.resolveUiEdit({ question: q, ui: uiBag }) !== null);
|
||
return { pass: taken.length === 0, detail: taken.join(' | ') };
|
||
})().pass);
|
||
|
||
record('while previewing, apply and discard still act', (() => {
|
||
const previewing = { ...uiBag, previewing: true };
|
||
const a = uiEditMod.resolveUiEdit({ question: 'Apply the layout change', ui: previewing });
|
||
const d = uiEditMod.resolveUiEdit({ question: 'Discard the layout change', ui: previewing });
|
||
return { pass: a?.kind === 'ui-apply' && d?.kind === 'ui-discard', detail: `${a?.kind} / ${d?.kind}` };
|
||
})().pass);
|
||
|
||
record('routing returns the layout intent on a composed page', (() => {
|
||
const intent = routingMod.resolveIntent({
|
||
question: 'Hide the audit section.', contextId: 'admin.activity', ui: uiBag,
|
||
});
|
||
return intent.kind === 'ui-preview' && intent.op.target === 'audit';
|
||
})());
|
||
|
||
record('the Board skill vocabulary is untouched by the language layer',
|
||
surfaces.SUPPORTED_SECTION_TYPES.every((t) => reg4.has(t)),
|
||
'all nine reading types are registered node types');
|
||
}
|
||
|
||
/* ── Built-in UI and skill UI in one tree ──────────────────────────────────
|
||
Phase 5A. The coverage prerequisites: a skill's section is a node, Hired
|
||
History composes, and Positions' page-level slots do. The point of the
|
||
section is the pair of assertions at the end — one operation engine moving a
|
||
built-in and a skill section with the same call. */
|
||
{
|
||
console.log('\n── Skill sections as nodes ──');
|
||
|
||
const compMod = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const regMod5 = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const opsMod5 = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
const nodeMod5 = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
const skillNodesMod = await server.ssrLoadModule('/src/lib/ui/skillNodes.js');
|
||
const inspectMod5 = await server.ssrLoadModule('/src/lib/ui/inspect.js');
|
||
const intentMod5 = await server.ssrLoadModule('/src/lib/ui/intent.js');
|
||
const uiConfigMod = await server.ssrLoadModule('/src/lib/skills/uiConfig.js');
|
||
const validateMod5 = await server.ssrLoadModule('/src/lib/ui/validate.js');
|
||
const patchMod5 = await server.ssrLoadModule('/src/lib/ui/patch.js');
|
||
const { MemoryRouter: Router5 } = await import('react-router-dom');
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/activity/nodes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/hired-history/nodes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/positions/nodes.js');
|
||
|
||
const reg5 = regMod5.nodeRegistry;
|
||
const ctx5 = { registry: reg5 };
|
||
|
||
/**
|
||
* The Board skill exactly as the account stores it — Markdown in, node out.
|
||
*
|
||
* Parsed by the real parser rather than hand-built, so this breaks if the
|
||
* adapter and the skill format ever stop agreeing.
|
||
*/
|
||
const BOARD_MD = [
|
||
'---',
|
||
'id: board',
|
||
'name: Board',
|
||
'description: Helps Owliver understand, analyze, and act on the current task board.',
|
||
'pages:',
|
||
' - positions',
|
||
'status: active',
|
||
'ui:',
|
||
' type: card',
|
||
' placement: after-position-list-summary',
|
||
' title: Board',
|
||
' source: candidates.activity',
|
||
' periods:',
|
||
' - today',
|
||
' - last-7-days',
|
||
' - previous-month',
|
||
'---',
|
||
'',
|
||
'# Board',
|
||
].join('\n');
|
||
|
||
const board = reg.parseSkill(BOARD_MD, 'custom/board.md');
|
||
|
||
record('the Board skill parses and declares a section on Positions', (() => {
|
||
const sections = uiConfigMod.sectionsForPage(board, 'positions');
|
||
return { pass: sections.length === 1 && sections[0].type === 'card', detail: JSON.stringify(sections[0] || null) };
|
||
})().pass);
|
||
|
||
const boardSections = uiConfigMod.sectionsForPage(board, 'positions')
|
||
.map((section) => ({ skill: board, section }));
|
||
const boardByPlacement = skillNodesMod.skillNodesByPlacement(boardSections);
|
||
|
||
record('a skill section becomes a node with skill origin and stable identity', (() => {
|
||
const node = boardByPlacement['after-position-list-summary'][0];
|
||
return node.id === 'skill-board-board' && node.origin === 'skill' && node.type === 'card';
|
||
})());
|
||
|
||
record('the node carries the section’s real data binding, not a copy of data', (() => {
|
||
const node = boardByPlacement['after-position-list-summary'][0];
|
||
return node.data.source === 'candidates.activity'
|
||
&& JSON.stringify(node.data.params.periods) === JSON.stringify(['today', 'last-7-days', 'previous-month']);
|
||
})());
|
||
|
||
record('the node keeps the skill’s title and attributes the skill', (() => {
|
||
const node = boardByPlacement['after-position-list-summary'][0];
|
||
return node.props.title === 'Board' && node.props.attribution === 'Board';
|
||
})());
|
||
|
||
record('an option the source does not read is dropped rather than losing the node', (() => {
|
||
/* `hires.performance` declares no options; a definition asking for periods
|
||
against it must still produce a renderable node. */
|
||
const node = skillNodesMod.skillSectionNode(
|
||
{ id: 'x', name: 'X' },
|
||
{ id: 's', type: 'stats', source: 'hires.performance', periods: ['today'], title: 'X' }
|
||
);
|
||
return !node.data.params.periods && node.data.source === 'hires.performance';
|
||
})());
|
||
|
||
/* ── The Board card on the Positions tree ──────────────────────────────── */
|
||
|
||
const positionsTree = compMod.composePage('positions', { skillNodes: boardByPlacement, ...ctx5 }).tree;
|
||
|
||
record('the Board card is a child of the page-level slot', (() => {
|
||
const slot = positionsTree.find((n) => n.id === 'positions-extensions-summary');
|
||
return slot?.children?.length === 1 && slot.children[0].id === 'skill-board-board';
|
||
})());
|
||
|
||
record('the composed Positions tree validates', (() => {
|
||
const { ok, problems } = validateMod5.validateTree(positionsTree, ctx5);
|
||
return { ok, detail: problems.map((p) => p.message).join('; ') };
|
||
})().ok);
|
||
|
||
record('the Board card is addressable by name', (() => {
|
||
const hits = inspectMod5.resolveTarget(positionsTree, 'the board card', { registry: reg5 });
|
||
return hits.length > 0 && hits[0].id === 'skill-board-board';
|
||
})());
|
||
|
||
record('the Board card can be hidden through the ordinary engine', (() => {
|
||
const r = opsMod5.applyOperation(positionsTree, { op: 'hide', target: 'skill-board-board' }, ctx5);
|
||
return r.ok && nodeMod5.findNode(r.tree, 'skill-board-board').hidden === true;
|
||
})());
|
||
|
||
record('Owliver reaches the Board card in words', (() => {
|
||
const m = intentMod5.matchUiEdit('hide the board card', { tree: positionsTree, registry: reg5 });
|
||
return m?.kind === 'plan' && m.op.op === 'hide' && m.op.target === 'skill-board-board';
|
||
})());
|
||
|
||
record('a skill node cannot be deleted, only hidden', (() => {
|
||
const r = opsMod5.applyOperation(positionsTree, { op: 'remove', target: 'skill-board-board' }, ctx5);
|
||
return !r.ok && /part of the page/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('hiding a Board card produces an operation, never a Markdown edit', (() => {
|
||
const r = opsMod5.applyOperation(positionsTree, { op: 'hide', target: 'skill-board-board' }, ctx5);
|
||
const json = JSON.stringify(r.diff);
|
||
return r.ok && !/---|ui:|placement|markdown/i.test(json);
|
||
})());
|
||
|
||
record('the skill definition is untouched by the operation', (() => {
|
||
/* The same Markdown, parsed again after the tree was mutated, must give the
|
||
same section. Nothing in the node path writes back. */
|
||
const again = reg.parseSkill(BOARD_MD, 'custom/board.md');
|
||
return JSON.stringify(uiConfigMod.sectionsForPage(again, 'positions'))
|
||
=== JSON.stringify(uiConfigMod.sectionsForPage(board, 'positions'));
|
||
})());
|
||
|
||
/* ── Hired History ─────────────────────────────────────────────────────── */
|
||
|
||
const hiredTree = compMod.composePage('hired-history').tree;
|
||
|
||
record('Hired History composes in reading order',
|
||
hiredTree.map((n) => n.id).join(',')
|
||
=== 'hired-extensions-top,hired-filters,hired-summary,chronology,records',
|
||
hiredTree.map((n) => n.id).join(', '));
|
||
|
||
record('the Recent Hiring Timeline is addressable as `chronology`',
|
||
Boolean(nodeMod5.findNode(hiredTree, 'chronology')));
|
||
|
||
record('the composed Hired History tree validates', (() => {
|
||
const { ok, problems } = validateMod5.validateTree(hiredTree, ctx5);
|
||
return { ok, detail: problems.map((p) => p.message).join('; ') };
|
||
})().ok);
|
||
|
||
record('the chronology node can be hidden', (() => {
|
||
const r = opsMod5.applyOperation(hiredTree, { op: 'hide', target: 'chronology' }, ctx5);
|
||
return r.ok && nodeMod5.findNode(r.tree, 'chronology').hidden === true;
|
||
})());
|
||
|
||
record('the chronology node can be moved above the summary', (() => {
|
||
const m = intentMod5.matchUiEdit('move the recent hiring timeline above the hire summary', {
|
||
tree: hiredTree, registry: reg5,
|
||
});
|
||
if (m?.kind !== 'plan') return { pass: false, detail: JSON.stringify(m) };
|
||
const r = opsMod5.applyOperation(hiredTree, m.op, ctx5);
|
||
const ids = r.tree.map((n) => n.id);
|
||
return { pass: r.ok && ids.indexOf('chronology') < ids.indexOf('hired-summary'), detail: ids.join(', ') };
|
||
})().pass);
|
||
|
||
record('Owliver reaches the timeline by the words on screen', (() => {
|
||
const m = intentMod5.matchUiEdit('hide the recent hiring timeline', { tree: hiredTree, registry: reg5 });
|
||
return m?.kind === 'plan' && m.op.target === 'chronology';
|
||
})());
|
||
|
||
/* ── One engine, both kinds of node ────────────────────────────────────── */
|
||
|
||
record('a built-in and a skill node move with the identical operation', (() => {
|
||
/* The same op shape, the same call, two different origins. If the engine
|
||
had learned the difference anywhere, one of these would need its own
|
||
path — and this is the assertion that would fail. */
|
||
const builtin = opsMod5.applyOperation(hiredTree, { op: 'hide', target: 'records' }, ctx5);
|
||
const skill = opsMod5.applyOperation(positionsTree, { op: 'hide', target: 'skill-board-board' }, ctx5);
|
||
return builtin.ok && skill.ok
|
||
&& builtin.diff.op === skill.diff.op
|
||
&& nodeMod5.findNode(builtin.tree, 'records').origin === 'builtin'
|
||
&& nodeMod5.findNode(skill.tree, 'skill-board-board').origin === 'skill';
|
||
})());
|
||
|
||
record('a saved patch survives across both origins', (() => {
|
||
const patch = {
|
||
schema: patchMod5.PATCH_SCHEMA, page: 'positions',
|
||
ops: [{ op: 'hide', target: 'skill-board-board' }],
|
||
};
|
||
const { tree } = compMod.composePage('positions', { patch, skillNodes: boardByPlacement, ...ctx5 });
|
||
return nodeMod5.findNode(tree, 'skill-board-board').hidden === true;
|
||
})());
|
||
|
||
record('a patch naming a card whose skill was switched off is skipped, not fatal', (() => {
|
||
const patch = {
|
||
schema: patchMod5.PATCH_SCHEMA, page: 'positions',
|
||
ops: [{ op: 'hide', target: 'skill-board-board' }],
|
||
};
|
||
/* No skill nodes: the definition is disabled, so the card is not there. */
|
||
const { tree, skipped } = compMod.composePage('positions', { patch, ...ctx5 });
|
||
return skipped.length === 1 && tree.length === 2;
|
||
})());
|
||
|
||
record('a page composes identically whether or not skills are supplied', (() => {
|
||
const without = compMod.composePage('hired-history', ctx5).tree;
|
||
const withNone = compMod.composePage('hired-history', { skillNodes: {}, ...ctx5 }).tree;
|
||
return JSON.stringify(without) === JSON.stringify(withNone);
|
||
})());
|
||
|
||
/* ── Rendering parity, against the captured baselines ──────────────────── */
|
||
|
||
{
|
||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
|
||
|
||
const draw = async (modulePath, page, route) => {
|
||
const Page = (await server.ssrLoadModule(modulePath)).default;
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||
return renderToStaticMarkup(
|
||
React.createElement(Router5, { initialEntries: [route] },
|
||
React.createElement(QueryClientProvider, { client },
|
||
React.createElement(UiEditingProvider, { page },
|
||
React.createElement(Page))))
|
||
);
|
||
};
|
||
|
||
const stripControls = (html) => {
|
||
const at = html.indexOf('<div data-ui-controls');
|
||
if (at < 0) return html;
|
||
let depth = 0;
|
||
let i = at;
|
||
while (i < html.length) {
|
||
if (html.startsWith('<div', i)) { depth += 1; i = html.indexOf('>', i) + 1; continue; }
|
||
if (html.startsWith('</div>', i)) { depth -= 1; i += 6; if (depth === 0) break; continue; }
|
||
i += 1;
|
||
}
|
||
return html.slice(0, at) + html.slice(i);
|
||
};
|
||
|
||
const classes = (html) => (html.match(/class="[^"]*"/g) || []).join('\n');
|
||
const words = (html) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
const tally = (html) => (html.match(/<\/?[a-z][a-z0-9-]*/gi) || [])
|
||
.map((t) => t.toLowerCase())
|
||
.reduce((acc, t) => ({ ...acc, [t]: (acc[t] || 0) + 1 }), {});
|
||
|
||
const hiredNow = stripControls(await draw('/src/pages/admin/HiredHistory.jsx', 'hired-history', '/admin/hired'));
|
||
const hiredBefore = readFileSync(join(ROOT, 'scripts/__baseline__/hired-history.pre-migration.html'), 'utf8');
|
||
|
||
record('Hired History paints the same styled elements, in the same order',
|
||
classes(hiredNow) === classes(hiredBefore));
|
||
record('Hired History shows the same words', words(hiredNow) === words(hiredBefore));
|
||
record('Hired History added only identity wrappers', (() => {
|
||
const b = tally(hiredBefore);
|
||
const n = tally(hiredNow);
|
||
const changed = [...new Set([...Object.keys(b), ...Object.keys(n)])].filter((t) => (b[t] || 0) !== (n[t] || 0));
|
||
return { pass: changed.join(',') === '<div,</div' && n['<div'] - b['<div'] === 2, detail: changed.join(', ') };
|
||
})().pass);
|
||
record('Hired History carries node identity in the DOM', (() => {
|
||
const ids = [...hiredNow.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
|
||
return ids.includes('chronology') && ids.includes('records');
|
||
})());
|
||
|
||
const positionsNow = await draw('/src/pages/admin/Positions.jsx', 'positions', '/admin/positions');
|
||
const positionsBefore = readFileSync(join(ROOT, 'scripts/__baseline__/positions.pre-migration.html'), 'utf8');
|
||
|
||
/**
|
||
* Positions, with whatever this run's account has authored.
|
||
*
|
||
* The suite creates Board skills earlier on, so by the time this renders,
|
||
* Positions legitimately carries one — which is the thing Phase 5A exists
|
||
* to make possible. So the assertion is not "nothing changed" but the two
|
||
* things that matter: the card is there **as an addressable node**, and
|
||
* the page's own markup around it is exactly what it was.
|
||
*/
|
||
record('a Board skill card renders on Positions as an addressable node', (() => {
|
||
const ids = [...positionsNow.matchAll(/data-ui-node="(skill-[^"]+)"/g)].map((m) => m[1]);
|
||
return { pass: ids.length > 0, detail: ids.join(', ') || 'no skill node rendered' };
|
||
})().pass, [...positionsNow.matchAll(/data-ui-node="(skill-[^"]+)"/g)].map((m) => m[1]).join(', '));
|
||
|
||
record('Positions’ own markup is unchanged apart from the skill slot', (() => {
|
||
/* Lift out each slot subtree — a `space-y-4` wrapper holding skill nodes —
|
||
by balanced tag counting, and what is left must be the baseline. */
|
||
let html = stripControls(positionsNow);
|
||
for (let guard = 0; guard < 4; guard += 1) {
|
||
/* The slot wrapper carries `space-y-4` plus whatever margin the page's
|
||
composition declared, so match on the pair rather than an exact class. */
|
||
const at = html.search(/<div class="space-y-4[^"]*"><section data-ui-node="skill-/);
|
||
if (at < 0) break;
|
||
let depth = 0;
|
||
let i = at;
|
||
while (i < html.length) {
|
||
if (html.startsWith('<div', i)) { depth += 1; i = html.indexOf('>', i) + 1; continue; }
|
||
if (html.startsWith('</div>', i)) { depth -= 1; i += 6; if (depth === 0) break; continue; }
|
||
i += 1;
|
||
}
|
||
html = html.slice(0, at) + html.slice(i);
|
||
}
|
||
return { pass: html === positionsBefore, detail: html === positionsBefore ? '' : `len ${positionsBefore.length}->${html.length}` };
|
||
})().pass);
|
||
}
|
||
}
|
||
|
||
/* ── Every page-level composition ─────────────────────────────────────────
|
||
Phase 5B. Five more pages in the tree, and the spacing vocabulary that let
|
||
the Positions slots keep the margins they had before they became nodes. */
|
||
{
|
||
console.log('\n── Page compositions ──');
|
||
|
||
const compMod5b = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const regMod5b = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const opsMod5b = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
const nodeMod5b = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
const validateMod5b = await server.ssrLoadModule('/src/lib/ui/validate.js');
|
||
const intentMod5b = await server.ssrLoadModule('/src/lib/ui/intent.js');
|
||
const skillNodesMod5b = await server.ssrLoadModule('/src/lib/ui/skillNodes.js');
|
||
const uiCfg5b = await server.ssrLoadModule('/src/lib/skills/uiConfig.js');
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
for (const m of ['activity', 'hired-history', 'candidates', 'talent-pool', 'analytics',
|
||
'candidates-analysis', 'control-center']) {
|
||
await server.ssrLoadModule(`/src/pages/admin/${m}/nodes.jsx`).catch(() => null);
|
||
}
|
||
await server.ssrLoadModule('/src/pages/admin/positions/nodes.js');
|
||
|
||
const reg5b = regMod5b.nodeRegistry;
|
||
const ctx5b = { registry: reg5b };
|
||
|
||
/** Every page that should now compose, and the nodes it must carry. */
|
||
const EXPECTED = {
|
||
activity: ['activity-summary', 'activity-privileged-notice', 'timeline', 'audit'],
|
||
'hired-history': ['hired-filters', 'hired-summary', 'chronology', 'records'],
|
||
candidates: ['candidates-toolbar', 'candidates-list'],
|
||
'talent-pool': ['segments', 'directory'],
|
||
analytics: ['performance', 'funnel', 'trend', 'departments', 'positions', 'efficiency', 'insights'],
|
||
'candidates-analysis': ['supply', 'distribution', 'risk', 'skills', 'fit', 'recommend', 'screening'],
|
||
'control-center': ['cc-snapshot', 'cc-activity', 'cc-pipeline', 'cc-positions', 'cc-actions', 'cc-lists', 'cc-recent'],
|
||
positions: ['positions-extensions-summary', 'positions-extensions-list'],
|
||
};
|
||
|
||
record('every migrated page registers a composition', (() => {
|
||
const pages = compMod5b.composedPages();
|
||
const missing = Object.keys(EXPECTED).filter((p) => !pages.includes(p));
|
||
return { pass: missing.length === 0, detail: missing.join(', ') || pages.join(', ') };
|
||
})().pass, compMod5b.composedPages().join(', '));
|
||
|
||
for (const [page, ids] of Object.entries(EXPECTED)) {
|
||
const tree = compMod5b.composePage(page, ctx5b).tree;
|
||
|
||
record(`${page}: composes the expected nodes`, (() => {
|
||
const have = tree.map((n) => n.id);
|
||
const missing = ids.filter((id) => !have.includes(id));
|
||
return { pass: missing.length === 0, detail: missing.length ? `missing ${missing.join(', ')}` : have.join(', ') };
|
||
})().pass);
|
||
|
||
record(`${page}: the tree validates`, (() => {
|
||
const { ok, problems } = validateMod5b.validateTree(tree, ctx5b);
|
||
return { ok, detail: problems.map((p) => p.message).join('; ') };
|
||
})().ok);
|
||
|
||
record(`${page}: every node is a built-in that moves and hides`, (() => {
|
||
const bad = tree.filter((n) => {
|
||
const entry = reg5b.get(n.type);
|
||
return !entry || !entry.capabilities.includes('hide') || !entry.capabilities.includes('move');
|
||
});
|
||
return { pass: bad.length === 0, detail: bad.map((n) => n.id).join(', ') };
|
||
})().pass);
|
||
|
||
/* One node per page, hidden and moved through the engine — the same two
|
||
calls, whatever the page happens to contain. */
|
||
const target = ids[0];
|
||
record(`${page}: \`${target}\` hides through the ordinary engine`, (() => {
|
||
const r = opsMod5b.applyOperation(tree, { op: 'hide', target }, ctx5b);
|
||
return r.ok && nodeMod5b.findNode(r.tree, target).hidden === true;
|
||
})());
|
||
|
||
record(`${page}: the page reorders through the ordinary engine`, (() => {
|
||
const order = [...tree.map((n) => n.id)].reverse();
|
||
const r = opsMod5b.applyOperation(tree, { op: 'reorder', parent: null, order }, ctx5b);
|
||
return r.ok && r.tree.map((n) => n.id).join() === order.join();
|
||
})());
|
||
}
|
||
|
||
/* ── The spacing vocabulary that replaced the lost margins ─────────────── */
|
||
|
||
record('a slot can declare the margin it always had', (() => {
|
||
const tree = compMod5b.composePage('positions', ctx5b).tree;
|
||
const summary = tree.find((n) => n.id === 'positions-extensions-summary');
|
||
const list = tree.find((n) => n.id === 'positions-extensions-list');
|
||
return summary.layout.spacingAfter === 'md' && list.layout.spacingBefore === 'lg';
|
||
})());
|
||
|
||
record('spacing is a closed vocabulary, not a class name', (() => {
|
||
/* Asserted against the validator rather than through an operation: the slot
|
||
type declares only move, hide and reorder, so an `update` is refused on
|
||
capability before the layout is ever read. The rule is what matters. */
|
||
const node = nodeMod5b.makeNode({
|
||
id: 'x', type: 'skill-surface', props: { page: 'positions', placement: 'after-position-list' },
|
||
layout: { spacingAfter: 'mb-96' },
|
||
});
|
||
const problems = validateMod5b.validateNode(node, ctx5b);
|
||
return { pass: problems.some((p) => /spacingAfter` must be one of/.test(p.message)),
|
||
detail: problems.map((p) => p.message).join('; ') };
|
||
})().pass);
|
||
|
||
record('a legal spacing step is accepted', (() => {
|
||
const node = nodeMod5b.makeNode({
|
||
id: 'x', type: 'skill-surface', props: { page: 'positions', placement: 'after-position-list' },
|
||
layout: { spacingAfter: 'lg', spacingBefore: 'sm' },
|
||
});
|
||
return validateMod5b.validateNode(node, ctx5b).length === 0;
|
||
})());
|
||
|
||
record('no page composition carries a class name', (() => {
|
||
/* Configuration must never be able to say "add this CSS". Every node in
|
||
every composed page, checked for anything that looks like a class. */
|
||
const offences = [];
|
||
for (const page of compMod5b.composedPages()) {
|
||
for (const node of nodeMod5b.walk(compMod5b.composePage(page, ctx5b).tree)) {
|
||
const json = JSON.stringify({ props: node.props, layout: node.layout });
|
||
if (/class|className|\b(?:mt|mb|px|py|bg|text)-/.test(json)) offences.push(`${page}/${node.id}`);
|
||
}
|
||
}
|
||
return { pass: offences.length === 0, detail: offences.join(', ') };
|
||
})().pass);
|
||
|
||
/* ── Built-in and skill nodes, one mechanism, on a migrated page ───────── */
|
||
|
||
{
|
||
const BOARD_MD = [
|
||
'---', 'id: hh-board', 'name: Hiring board', 'description: A reading for Hired History.',
|
||
'pages:', ' - hired-history', 'status: active',
|
||
'ui:', ' type: stats', ' placement: after-header', ' title: Hiring board',
|
||
' source: hires.performance', '---', '', '# Hiring board',
|
||
].join('\n');
|
||
const skill = reg.parseSkill(BOARD_MD, 'custom/hh-board.md');
|
||
const sections = uiCfg5b.sectionsForPage(skill, 'hired-history').map((section) => ({ skill, section }));
|
||
const byPlacement = skillNodesMod5b.skillNodesByPlacement(sections);
|
||
const tree = compMod5b.composePage('hired-history', { skillNodes: byPlacement, ...ctx5b }).tree;
|
||
|
||
record('a skill section joins a migrated page as a child node', (() => {
|
||
const slot = tree.find((n) => n.id === 'hired-extensions-top');
|
||
return { pass: slot?.children?.length === 1 && slot.children[0].origin === 'skill', detail: JSON.stringify(slot?.children?.map((c) => c.id)) };
|
||
})().pass);
|
||
|
||
record('one operation moves a built-in and a skill node on the same page', (() => {
|
||
/* The identical call shape, twice, against one tree. */
|
||
const builtin = opsMod5b.applyOperation(tree, { op: 'hide', target: 'chronology' }, ctx5b);
|
||
const skillNode = tree.find((n) => n.id === 'hired-extensions-top').children[0];
|
||
const skilled = opsMod5b.applyOperation(tree, { op: 'hide', target: skillNode.id }, ctx5b);
|
||
return builtin.ok && skilled.ok
|
||
&& builtin.diff.op === skilled.diff.op
|
||
&& nodeMod5b.findNode(builtin.tree, 'chronology').origin === 'builtin'
|
||
&& nodeMod5b.findNode(skilled.tree, skillNode.id).origin === 'skill';
|
||
})());
|
||
|
||
record('Owliver reaches both by name on the same page', (() => {
|
||
const a = intentMod5b.matchUiEdit('hide the recent hiring timeline', { tree, registry: reg5b });
|
||
const b = intentMod5b.matchUiEdit('hide the hiring board', { tree, registry: reg5b });
|
||
return { pass: a?.kind === 'plan' && b?.kind === 'plan' && a.op.target !== b.op.target,
|
||
detail: `${a?.op?.target} / ${b?.op?.target}` };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── Rendering parity for every migrated page ──────────────────────────── */
|
||
|
||
{
|
||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||
const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx');
|
||
const { MemoryRouter: R } = await import('react-router-dom');
|
||
|
||
const stripControls = (html) => {
|
||
const at = html.indexOf('<div data-ui-controls');
|
||
if (at < 0) return html;
|
||
let depth = 0;
|
||
let i = at;
|
||
while (i < html.length) {
|
||
if (html.startsWith('<div', i)) { depth += 1; i = html.indexOf('>', i) + 1; continue; }
|
||
if (html.startsWith('</div>', i)) { depth -= 1; i += 6; if (depth === 0) break; continue; }
|
||
i += 1;
|
||
}
|
||
return html.slice(0, at) + html.slice(i);
|
||
};
|
||
const classes = (h) => (h.match(/class="[^"]*"/g) || []).join('\n');
|
||
const words = (h) => h.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
|
||
const PAGES = [
|
||
['candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'],
|
||
['talent-pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'],
|
||
['analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'],
|
||
['candidates-analysis', '/src/pages/admin/CandidatesAnalysis.jsx', '/admin/candidates-analysis', 'candidates-analysis'],
|
||
['control-center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'],
|
||
];
|
||
|
||
for (const [page, modulePath, route, baseline] of PAGES) {
|
||
const Page = (await server.ssrLoadModule(modulePath)).default;
|
||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
|
||
const now = stripControls(renderToStaticMarkup(
|
||
React.createElement(R, { initialEntries: [route] },
|
||
React.createElement(QueryClientProvider, { client },
|
||
React.createElement(UiEditingProvider, { page },
|
||
React.createElement(Page))))
|
||
));
|
||
const before = readFileSync(join(ROOT, `scripts/__baseline__/${baseline}.pre-migration.html`), 'utf8');
|
||
|
||
/**
|
||
* A migration may INSERT a wrapper class — grouping three siblings under
|
||
* one node has to carry their spacing inward — but it must never change
|
||
* or drop one. Two pointers over the sequences: every baseline entry has
|
||
* to appear, in order; anything extra is reported.
|
||
*/
|
||
record(`${page}: paints the same styled elements, in the same order`, (() => {
|
||
const a = (now.match(/class="[^"]*"/g) || []);
|
||
const b = (before.match(/class="[^"]*"/g) || []);
|
||
const inserted = [];
|
||
let i = 0;
|
||
for (const want of b) {
|
||
while (i < a.length && a[i] !== want) { inserted.push(a[i]); i += 1; }
|
||
if (i >= a.length) return { pass: false, detail: `missing ${want}` };
|
||
i += 1;
|
||
}
|
||
inserted.push(...a.slice(i));
|
||
return { pass: true, detail: inserted.length ? `inserted ${inserted.join(' ')}` : 'identical' };
|
||
})().pass, (() => {
|
||
const a = (now.match(/class="[^"]*"/g) || []);
|
||
const b = (before.match(/class="[^"]*"/g) || []);
|
||
return a.length === b.length ? '' : `${a.length - b.length} wrapper class(es) inserted`;
|
||
})());
|
||
record(`${page}: shows the same words`, words(now) === words(before));
|
||
record(`${page}: carries node identity in the DOM`, (() => {
|
||
const ids = [...now.matchAll(/data-ui-node="([^"]+)"/g)].map((m) => m[1]);
|
||
return { pass: ids.length > 0, detail: ids.join(', ') };
|
||
})().pass);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── A hidden node is still a node ─────────────────────────────────────────
|
||
The regression: hiding a section made it unreachable, because the only word
|
||
that would bring it back — "show" — was excluded from the layout verbs to
|
||
keep it from swallowing "show me the candidates". The tree decides now. */
|
||
{
|
||
console.log('\n── Hidden nodes stay addressable ──');
|
||
|
||
const compH = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const regH = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const opsH = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
const nodeH = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
const inspectH = await server.ssrLoadModule('/src/lib/ui/inspect.js');
|
||
const intentH = await server.ssrLoadModule('/src/lib/ui/intent.js');
|
||
const patchH = await server.ssrLoadModule('/src/lib/ui/patch.js');
|
||
const skillH = await server.ssrLoadModule('/src/lib/ui/skillNodes.js');
|
||
const uiCfgH = await server.ssrLoadModule('/src/lib/skills/uiConfig.js');
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/hired-history/nodes.jsx');
|
||
|
||
const nreg = regH.nodeRegistry;
|
||
const ctx = { registry: nreg };
|
||
const PAGE = 'hired-history';
|
||
const ask = (q, tree) => intentH.matchUiEdit(q, { tree, registry: nreg });
|
||
|
||
const visible = compH.composePage(PAGE, ctx).tree;
|
||
|
||
/* ── hide a visible node → hidden, still inspectable ───────────────────── */
|
||
|
||
const hidden = opsH.applyOperation(visible, { op: 'hide', target: 'chronology' }, ctx).tree;
|
||
|
||
record('hiding a node leaves it in the tree', Boolean(nodeH.findNode(hidden, 'chronology')));
|
||
|
||
record('a hidden node is still in the inspection inventory', (() => {
|
||
const seen = inspectH.inspectTree(hidden, ctx).find((n) => n.id === 'chronology');
|
||
return seen && seen.hidden === true;
|
||
})());
|
||
|
||
record('a hidden node is still listed in the outline, marked hidden', (() => {
|
||
const lines = inspectH.outlineTree(hidden, ctx);
|
||
return lines.some((l) => l.includes('chronology') && /hidden/.test(l));
|
||
})());
|
||
|
||
record('a hidden node still resolves by name', (() => {
|
||
const hits = inspectH.resolveTarget(hidden, 'the recent hiring timeline', ctx);
|
||
return hits.length > 0 && hits[0].id === 'chronology';
|
||
})());
|
||
|
||
/* ── hide an already-hidden node → still the UI flow ───────────────────── */
|
||
|
||
record('hiding an already-hidden node stays in the UI flow', (() => {
|
||
const m = ask('Hide the recent hiring timeline', hidden);
|
||
return { pass: m !== null, detail: m?.kind };
|
||
})().pass);
|
||
|
||
record('…and says it is already hidden rather than repeating the change', (() => {
|
||
const m = ask('Hide the recent hiring timeline', hidden);
|
||
return m?.kind === 'refused' && /already hidden/.test(m.message);
|
||
})());
|
||
|
||
record('no redundant operation is produced', (() => {
|
||
const m = ask('Hide the recent hiring timeline', hidden);
|
||
return !m.op;
|
||
})());
|
||
|
||
/* ── show a hidden node ────────────────────────────────────────────────── */
|
||
|
||
record('"show" resolves a hidden node and plans to reveal it', (() => {
|
||
const m = ask('Show the recent hiring timeline', hidden);
|
||
return { pass: m?.kind === 'plan' && m.op.op === 'hide' && m.op.target === 'chronology' && m.op.hidden === false,
|
||
detail: JSON.stringify(m?.op || m?.kind) };
|
||
})().pass);
|
||
|
||
record('"unhide" and "bring back" reach it too', (() => {
|
||
const a = ask('Unhide the recent hiring timeline', hidden);
|
||
const b = ask('Bring back the recent hiring timeline', hidden);
|
||
return a?.kind === 'plan' && b?.kind === 'plan' && a.op.hidden === false && b.op.hidden === false;
|
||
})());
|
||
|
||
record('the reveal applies through the ordinary engine', (() => {
|
||
const m = ask('Show the recent hiring timeline', hidden);
|
||
const r = opsH.applyOperation(hidden, m.op, ctx);
|
||
return r.ok && nodeH.findNode(r.tree, 'chronology').hidden === false;
|
||
})());
|
||
|
||
record('showing an already-visible node says so', (() => {
|
||
const m = ask('Unhide the recent hiring timeline', visible);
|
||
return m?.kind === 'refused' && /already showing/.test(m.message);
|
||
})());
|
||
|
||
/* ── the branch that must NOT be taken ─────────────────────────────────── */
|
||
|
||
record('"show" over a page with nothing hidden is not a layout request',
|
||
ask('Show the recent hiring timeline', visible) === null);
|
||
|
||
record('ordinary "show me" questions are untouched, hidden node or not', (() => {
|
||
const asks = ['Show me the recent hires', 'Show me the candidates', 'Show the hiring trend'];
|
||
const taken = asks.filter((q) => ask(q, hidden) !== null);
|
||
return { pass: taken.length === 0, detail: taken.join(' | ') };
|
||
})().pass);
|
||
|
||
record('a normal data question after a hide still reaches Owliver', (() => {
|
||
/* The whole point of resolving `show` against the tree: this must stay null
|
||
so routing hands it to the reader that answers it. */
|
||
return ask('Who was hired most recently?', hidden) === null
|
||
&& ask('What is the average time to hire?', hidden) === null;
|
||
})());
|
||
|
||
/* ── across a cold reload ──────────────────────────────────────────────── */
|
||
|
||
record('hide → reload → show works from stored operations alone', (() => {
|
||
/* What the browser does on a fresh load: compose from the saved patch. */
|
||
const saved = patchH.pushOp(patchH.emptyPatch(PAGE), { op: 'hide', target: 'chronology' });
|
||
const overWire = JSON.parse(JSON.stringify({ [PAGE]: saved }));
|
||
const { layouts } = patchH.normalizeLayouts(overWire);
|
||
const reloaded = compH.composePage(PAGE, { patch: layouts[PAGE], ...ctx }).tree;
|
||
|
||
const m = ask('Show the recent hiring timeline', reloaded);
|
||
if (m?.kind !== 'plan') return { pass: false, detail: JSON.stringify(m?.kind) };
|
||
const r = opsH.applyOperation(reloaded, m.op, ctx);
|
||
return { pass: r.ok && nodeH.findNode(r.tree, 'chronology').hidden === false, detail: '' };
|
||
})().pass);
|
||
|
||
record('show → apply → reload leaves it visible', (() => {
|
||
const saved = patchH.pushOp(patchH.emptyPatch(PAGE), { op: 'hide', target: 'chronology' });
|
||
const shown = patchH.pushOp(saved, { op: 'hide', target: 'chronology', hidden: false });
|
||
const { layouts } = patchH.normalizeLayouts(JSON.parse(JSON.stringify({ [PAGE]: shown })));
|
||
const tree = compH.composePage(PAGE, { patch: layouts[PAGE], ...ctx }).tree;
|
||
return nodeH.findNode(tree, 'chronology').hidden === false;
|
||
})());
|
||
|
||
/* ── a hidden SKILL node ───────────────────────────────────────────────── */
|
||
|
||
{
|
||
const MD = ['---', 'id: hh-hidden', 'name: Hiring board', 'description: A reading.',
|
||
'pages:', ' - hired-history', 'status: active', 'ui:', ' type: stats',
|
||
' placement: after-header', ' title: Hiring board', ' source: hires.performance',
|
||
'---', '', '# Hiring board'].join('\n');
|
||
const sk = reg.parseSkill(MD, 'custom/hh-hidden.md');
|
||
const sections = uiCfgH.sectionsForPage(sk, PAGE).map((section) => ({ skill: sk, section }));
|
||
const withSkill = compH.composePage(PAGE, { skillNodes: skillH.skillNodesByPlacement(sections), ...ctx }).tree;
|
||
const skillId = withSkill.find((n) => n.id === 'hired-extensions-top').children[0].id;
|
||
|
||
const skillHidden = opsH.applyOperation(withSkill, { op: 'hide', target: skillId }, ctx).tree;
|
||
|
||
record('a hidden skill node stays in the inventory', (() => {
|
||
const seen = inspectH.inspectTree(skillHidden, ctx).find((n) => n.id === skillId);
|
||
return seen && seen.hidden === true && seen.origin === 'skill';
|
||
})());
|
||
|
||
record('a hidden skill node can be shown again by name', (() => {
|
||
const m = ask('Show the hiring board', skillHidden);
|
||
if (m?.kind !== 'plan') return { pass: false, detail: JSON.stringify(m?.kind) };
|
||
const r = opsH.applyOperation(skillHidden, m.op, ctx);
|
||
return { pass: r.ok && nodeH.findNode(r.tree, skillId).hidden === false, detail: '' };
|
||
})().pass);
|
||
|
||
record('hiding an already-hidden skill node stays in the UI flow', (() => {
|
||
const m = ask('Hide the hiring board', skillHidden);
|
||
return m?.kind === 'refused' && /already hidden/.test(m.message);
|
||
})());
|
||
}
|
||
|
||
/* ── ambiguity among hidden nodes still asks ───────────────────────────── */
|
||
|
||
record('an ambiguous hidden target asks which one', (() => {
|
||
/* Two slots share the label "Skill sections"; hide both, then name them. */
|
||
const withSlots = compH.composePage('activity', ctx).tree;
|
||
let t = opsH.applyOperation(withSlots, { op: 'hide', target: 'activity-extensions-top' }, ctx).tree;
|
||
t = opsH.applyOperation(t, { op: 'hide', target: 'activity-extensions-bottom' }, ctx).tree;
|
||
const m = intentH.matchUiEdit('Show the skill sections', { tree: t, registry: nreg });
|
||
return { pass: m?.kind === 'ambiguous' && m.candidates.length === 2, detail: m?.kind };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── The visual editor ─────────────────────────────────────────────────────
|
||
Phase 5C. The editor is a client of the same engine, and the assertion that
|
||
matters is the equivalence one: the operation its Hide button stands for and
|
||
the operation Owliver builds from "hide the recent hiring timeline" are the
|
||
same object. */
|
||
{
|
||
console.log('\n── Visual editor ──');
|
||
|
||
const compE = await server.ssrLoadModule('/src/lib/ui/composition.js');
|
||
const regE = await server.ssrLoadModule('/src/lib/ui/registry.js');
|
||
const opsE = await server.ssrLoadModule('/src/lib/ui/operations.js');
|
||
const nodeE = await server.ssrLoadModule('/src/lib/ui/node.js');
|
||
const inspectE = await server.ssrLoadModule('/src/lib/ui/inspect.js');
|
||
const intentE = await server.ssrLoadModule('/src/lib/ui/intent.js');
|
||
const patchE = await server.ssrLoadModule('/src/lib/ui/patch.js');
|
||
const skillE = await server.ssrLoadModule('/src/lib/ui/skillNodes.js');
|
||
const uiCfgE = await server.ssrLoadModule('/src/lib/skills/uiConfig.js');
|
||
const editorOps = await server.ssrLoadModule('/src/components/ui-editor/ops.js');
|
||
const validateE = await server.ssrLoadModule('/src/lib/ui/validate.js');
|
||
await server.ssrLoadModule('/src/components/ui-tree/nodeTypes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/hired-history/nodes.jsx');
|
||
await server.ssrLoadModule('/src/pages/admin/positions/nodes.js');
|
||
|
||
const nreg = regE.nodeRegistry;
|
||
const ectx = { registry: nreg };
|
||
const hiredTree = compE.composePage('hired-history', ectx).tree;
|
||
const caTree = compE.composePage('candidates-analysis', ectx).tree;
|
||
const boundaryE = await server.ssrLoadModule('/src/components/ui-tree/UiNodeBoundary.jsx');
|
||
const rendererE = await server.ssrLoadModule('/src/components/ui-tree/UiTreeRenderer.jsx');
|
||
const seen = (tree) => inspectE.inspectTree(tree, ectx);
|
||
const pick = (tree, id) => seen(tree).find((n) => n.id === id);
|
||
|
||
/* ── THE equivalence test ──────────────────────────────────────────────── */
|
||
|
||
record('Owliver and the editor build the identical hide operation', (() => {
|
||
const fromOwliver = intentE.matchUiEdit('Hide the recent hiring timeline', {
|
||
tree: hiredTree, registry: nreg,
|
||
})?.op;
|
||
const fromEditor = editorOps.hideOp(pick(hiredTree, 'chronology'));
|
||
return {
|
||
pass: JSON.stringify(fromOwliver) === JSON.stringify(fromEditor),
|
||
detail: `${JSON.stringify(fromOwliver)} vs ${JSON.stringify(fromEditor)}`,
|
||
};
|
||
})().pass, JSON.stringify(editorOps.hideOp(pick(hiredTree, 'chronology'))));
|
||
|
||
record('both hide operations persist through one mechanism', (() => {
|
||
/* Applied, saved and replayed the same way — there is only one path. */
|
||
const op = editorOps.hideOp(pick(hiredTree, 'chronology'));
|
||
const saved = patchE.mergeLayouts({}, 'hired-history',
|
||
patchE.pushOp(patchE.emptyPatch('hired-history'), op));
|
||
const { layouts } = patchE.normalizeLayouts(JSON.parse(JSON.stringify(saved)));
|
||
const tree = compE.composePage('hired-history', { patch: layouts['hired-history'], ...ectx }).tree;
|
||
return nodeE.findNode(tree, 'chronology').hidden === true;
|
||
})());
|
||
|
||
record('Owliver and the editor build the identical reorder operation', (() => {
|
||
const fromOwliver = intentE.matchUiEdit('Move the recent hiring timeline above the hire summary', {
|
||
tree: hiredTree, registry: nreg,
|
||
})?.op;
|
||
const roots = seen(hiredTree).filter((n) => n.parent === null);
|
||
const target = pick(hiredTree, 'chronology');
|
||
const summaryAt = roots.findIndex((n) => n.id === 'hired-summary');
|
||
const fromEditor = editorOps.reorderOp(target, roots, summaryAt);
|
||
return {
|
||
pass: JSON.stringify(fromOwliver?.order) === JSON.stringify(fromEditor?.order)
|
||
&& fromOwliver.op === fromEditor.op && fromOwliver.parent === fromEditor.parent,
|
||
detail: `${JSON.stringify(fromOwliver?.order)} vs ${JSON.stringify(fromEditor?.order)}`,
|
||
};
|
||
})().pass);
|
||
|
||
/* ── One engine, both origins ──────────────────────────────────────────── */
|
||
|
||
const BOARD = ['---', 'id: eq-board', 'name: Hiring board', 'description: A reading.',
|
||
'pages:', ' - hired-history', 'status: active', 'ui:', ' type: stats',
|
||
' placement: after-header', ' title: Hiring board', ' source: hires.performance',
|
||
'---', '', '# Hiring board'].join('\n');
|
||
const boardSkill = reg.parseSkill(BOARD, 'custom/eq-board.md');
|
||
const boardNodes = skillE.skillNodesByPlacement(
|
||
uiCfgE.sectionsForPage(boardSkill, 'hired-history').map((section) => ({ skill: boardSkill, section }))
|
||
);
|
||
const mixed = compE.composePage('hired-history', { skillNodes: boardNodes, ...ectx }).tree;
|
||
const skillNodeId = mixed.find((n) => n.id === 'hired-extensions-top').children[0].id;
|
||
|
||
for (const [what, id] of [['built-in', 'chronology'], ['skill', skillNodeId]]) {
|
||
record(`${what} node → hide, through the editor's operation`, (() => {
|
||
const op = editorOps.hideOp(pick(mixed, id));
|
||
const r = opsE.applyOperation(mixed, op, ectx);
|
||
return r.ok && nodeE.findNode(r.tree, id).hidden === true;
|
||
})());
|
||
|
||
record(`${what} node → reorder, through the editor's operation`, (() => {
|
||
const node = pick(mixed, id);
|
||
const siblings = seen(mixed).filter((n) => n.parent === node.parent);
|
||
/* An only child has nowhere to move; that is not a failure, and the
|
||
shape has to match what the caller reads. */
|
||
if (siblings.length < 2) return { pass: true, detail: 'only child' };
|
||
const op = editorOps.nudgeOp(node, siblings, node.index === 0 ? 1 : -1);
|
||
const r = opsE.applyOperation(mixed, op, ectx);
|
||
return { pass: r.ok, detail: r.ok ? '' : r.problems.map((p) => p.message).join('; ') };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── Every editor operation, through the one engine ────────────────────── */
|
||
|
||
record('editor: add', (() => {
|
||
const op = editorOps.addOp(hiredTree, null, 'card', 'hires.performance');
|
||
const r = opsE.applyOperation(hiredTree, op, ectx);
|
||
return r.ok && nodeE.findNode(r.tree, op.node.id).data.source === 'hires.performance';
|
||
})());
|
||
|
||
record('editor: update a declared property', (() => {
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const node = pick(added, 'card-1');
|
||
const r = opsE.applyOperation(added, editorOps.propOp(node, 'title', 'Renamed'), ectx);
|
||
return r.ok && nodeE.findNode(r.tree, 'card-1').props.title === 'Renamed';
|
||
})());
|
||
|
||
record('editor: update layout from the closed vocabulary', (() => {
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const r = opsE.applyOperation(added, editorOps.layoutOp(pick(added, 'card-1'), 'gap', 'lg'), ectx);
|
||
return r.ok && nodeE.findNode(r.tree, 'card-1').layout.gap === 'lg';
|
||
})());
|
||
|
||
record('editor: replace', (() => {
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const r = opsE.applyOperation(added, editorOps.replaceOp(pick(added, 'card-1'), 'table'), ectx);
|
||
return { pass: r.ok && nodeE.findNode(r.tree, 'card-1').type === 'table',
|
||
detail: r.ok ? '' : r.problems.map((p) => p.message).join('; ') };
|
||
})().pass);
|
||
|
||
record('editor: remove a node the person added', (() => {
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const r = opsE.applyOperation(added, editorOps.removeOp(pick(added, 'card-1')), ectx);
|
||
return r.ok && nodeE.findNode(r.tree, 'card-1') === null;
|
||
})());
|
||
|
||
record('editor: show a hidden node', (() => {
|
||
const hidden = opsE.applyOperation(hiredTree, { op: 'hide', target: 'chronology' }, ectx).tree;
|
||
const op = editorOps.hideOp(pick(hidden, 'chronology'));
|
||
const r = opsE.applyOperation(hidden, op, ectx);
|
||
return op.hidden === false && r.ok && nodeE.findNode(r.tree, 'chronology').hidden === false;
|
||
})());
|
||
|
||
/* ── What the editor refuses to compose ────────────────────────────────── */
|
||
|
||
record('editor: a built-in cannot be removed', (() => {
|
||
/* Refused on capability — a page section never declares `remove` — before
|
||
the origin rule is even reached. Either refusal is correct; what matters
|
||
is that the editor cannot compose it. */
|
||
const r = opsE.applyOperation(hiredTree, editorOps.removeOp(pick(hiredTree, 'chronology')), ectx);
|
||
return { pass: !r.ok, detail: r.ok ? 'ACCEPTED' : r.problems[0].message };
|
||
})().pass);
|
||
|
||
record('editor: an invalid property is refused by the engine', (() => {
|
||
const r = opsE.applyOperation(hiredTree, editorOps.propOp(pick(hiredTree, 'chronology'), 'colour', 'red'), ectx);
|
||
return !r.ok;
|
||
})());
|
||
|
||
record('editor: an invalid layout value is refused', (() => {
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const r = opsE.applyOperation(added, editorOps.layoutOp(pick(added, 'card-1'), 'gap', 'enormous'), ectx);
|
||
return !r.ok && /`gap` must be one of/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('editor: an unregistered type cannot be added', (() => {
|
||
const r = opsE.applyOperation(hiredTree, editorOps.addOp(hiredTree, null, 'hologram'), ectx);
|
||
return !r.ok && /Unsupported UI type/.test(r.problems[0].message);
|
||
})());
|
||
|
||
record('editor: an invalid placement is refused', (() => {
|
||
/* A page section inside a skill slot: the slot accepts only readings. */
|
||
const r = opsE.applyOperation(mixed, {
|
||
op: 'add', parent: 'hired-extensions-top',
|
||
node: { id: 'x', type: 'hired-records' },
|
||
}, ectx);
|
||
return !r.ok;
|
||
})());
|
||
|
||
record('editor: an unauthorized type is not offered and not accepted', (() => {
|
||
const r = new regE.NodeTypeRegistry();
|
||
r.register({ type: 'banner', label: 'Banner', component: () => null, roles: ['admin'] });
|
||
r.register({ type: 'box', label: 'Box', component: () => null, container: true });
|
||
const offered = inspectE.addableTypes(null, { registry: r, role: 'talent' }).map((t) => t.type);
|
||
const applied = opsE.applyOperation([], editorOps.addOp([], null, 'banner'),
|
||
{ registry: r, role: 'talent' });
|
||
return !offered.includes('banner') && !applied.ok;
|
||
})());
|
||
|
||
/* ── The two things that must be structurally impossible ───────────────── */
|
||
|
||
record('no editor control can carry a class name or a style', (() => {
|
||
/* Every property the editor can set comes from a `propSchema`. If no
|
||
registered type declares a styling property, the editor cannot offer one
|
||
— so this checks the schemas, not the components. */
|
||
const offenders = [];
|
||
for (const entry of nreg.all()) {
|
||
for (const key of Object.keys(entry.propSchema)) {
|
||
if (/class|style|css|html|jsx|markup/i.test(key)) offenders.push(`${entry.type}.${key}`);
|
||
}
|
||
}
|
||
return { pass: offenders.length === 0, detail: offenders.join(', ') };
|
||
})().pass);
|
||
|
||
record('a class name sent as a property is refused', (() => {
|
||
/* On a node that DOES accept updates, so the refusal is the schema's and
|
||
not merely the capability's. */
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const r = opsE.applyOperation(added,
|
||
{ op: 'update', target: 'card-1', props: { className: 'bg-red-500' } }, ectx);
|
||
return { pass: !r.ok && /has no property `className`/.test(r.problems[0].message),
|
||
detail: r.ok ? 'ACCEPTED' : r.problems[0].message };
|
||
})().pass);
|
||
|
||
record('the editor never evaluates a string as code', (() => {
|
||
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js']
|
||
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8'))
|
||
.join('\n')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
return !/\beval\s*\(/.test(files) && !/new\s+Function/.test(files)
|
||
&& !/dangerouslySetInnerHTML/.test(files) && !/\bimport\s*\(/.test(files);
|
||
})());
|
||
|
||
record('the editor contains no page name and no component branch', (() => {
|
||
const files = ['UiEditor.jsx', 'TreePanel.jsx', 'NodeInspector.jsx', 'NodePicker.jsx', 'ops.js']
|
||
.map((f) => readFileSync(join(ROOT, resolveSourcePath(`src/components/ui-editor/${f}`)), 'utf8'))
|
||
.join('\n')
|
||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||
const banned = [...surfaces.SUPPORTED_SKILL_PAGES, ...nreg.list()];
|
||
const hits = banned.filter((w) => new RegExp(`['"\`]${w}['"\`]`).test(files));
|
||
return { pass: hits.length === 0, detail: hits.join(', ') };
|
||
})().pass);
|
||
|
||
/* ── The panels render what the registry says ──────────────────────────── */
|
||
|
||
{
|
||
const hadWindow = 'window' in globalThis;
|
||
if (!hadWindow) {
|
||
globalThis.window = {
|
||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||
addEventListener() {}, removeEventListener() {},
|
||
};
|
||
}
|
||
const { TreePanel } = await server.ssrLoadModule('/src/components/ui-editor/TreePanel.jsx');
|
||
const { NodeInspector } = await server.ssrLoadModule('/src/components/ui-editor/NodeInspector.jsx');
|
||
const { NodePicker } = await server.ssrLoadModule('/src/components/ui-editor/NodePicker.jsx');
|
||
|
||
const draw = (el) => renderToStaticMarkup(el);
|
||
|
||
/**
|
||
* One reading node with a given layout, rendered through the real renderer.
|
||
*
|
||
* A reading resolves its data through the app's query hooks, so it needs a
|
||
* client the way it has one in the browser. Queries are disabled rather
|
||
* than mocked: the section then draws its empty state, which is all a test
|
||
* about *classes* needs.
|
||
*/
|
||
const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
|
||
const drawReading = (layout) => draw(React.createElement(
|
||
QueryClientProvider,
|
||
{ client: new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }) },
|
||
React.createElement(rendererE.UiTreeRenderer, {
|
||
nodes: [nodeE.makeNode({
|
||
id: 'layout-probe', type: 'card', origin: 'user',
|
||
data: { source: 'candidates.activity' }, ...(layout ? { layout } : {}),
|
||
})],
|
||
registry: nreg,
|
||
})
|
||
));
|
||
const classesOf = (html) => (html.match(/class="[^"]*"/) || ['(none)'])[0];
|
||
|
||
record('tree panel lists every node, built-in and skill', (() => {
|
||
const html = draw(React.createElement(TreePanel, {
|
||
tree: mixed, selectedId: null, onSelect: () => {}, registry: nreg,
|
||
}));
|
||
return html.includes('Recent hiring timeline') && html.includes('Hiring board') && html.includes('Skill');
|
||
})());
|
||
|
||
record('tree panel marks a hidden node and still lists it', (() => {
|
||
const hidden = opsE.applyOperation(mixed, { op: 'hide', target: 'chronology' }, ectx).tree;
|
||
const html = draw(React.createElement(TreePanel, {
|
||
tree: hidden, selectedId: null, onSelect: () => {}, registry: nreg,
|
||
}));
|
||
return html.includes('Recent hiring timeline') && html.includes('aria-label="Hidden"');
|
||
})());
|
||
|
||
record('tree panel marks the selected node', (() => {
|
||
const html = draw(React.createElement(TreePanel, {
|
||
tree: mixed, selectedId: 'chronology', onSelect: () => {}, registry: nreg,
|
||
}));
|
||
return /aria-current="true"/.test(html);
|
||
})());
|
||
|
||
record('property panel offers exactly the declared properties', (() => {
|
||
const added = opsE.applyOperation(hiredTree,
|
||
editorOps.addOp(hiredTree, null, 'card', 'hires.performance'), ectx).tree;
|
||
const node = pick(added, 'card-1');
|
||
const html = draw(React.createElement(NodeInspector, {
|
||
node, siblings: seen(added).filter((n) => n.parent === null), onOperate: () => {}, registry: nreg,
|
||
}));
|
||
const declared = Object.values(nreg.get('card').propSchema).map((r) => r.label);
|
||
/* Only that every declared property is offered. Whether a *styling*
|
||
property could exist at all is asserted against the schemas above —
|
||
checking the markup for "class" would only find Tailwind. */
|
||
const missing = declared.filter((label) => !html.includes(label));
|
||
return { pass: missing.length === 0, detail: missing.join(', ') };
|
||
})().pass);
|
||
|
||
record('property panel offers only the capabilities the type declares', (() => {
|
||
/* A built-in page section declares move and hide, so no Remove button. */
|
||
const html = draw(React.createElement(NodeInspector, {
|
||
node: pick(hiredTree, 'chronology'),
|
||
siblings: seen(hiredTree).filter((n) => n.parent === null),
|
||
onOperate: () => {}, registry: nreg,
|
||
}));
|
||
return html.includes('Hide') && !html.includes('Remove');
|
||
})());
|
||
|
||
record('property panel offers Show for a hidden node', (() => {
|
||
const hidden = opsE.applyOperation(hiredTree, { op: 'hide', target: 'chronology' }, ectx).tree;
|
||
const html = draw(React.createElement(NodeInspector, {
|
||
node: pick(hidden, 'chronology'),
|
||
siblings: seen(hidden).filter((n) => n.parent === null),
|
||
onOperate: () => {}, registry: nreg,
|
||
}));
|
||
return html.includes('Show') && !html.includes('>Hide<');
|
||
})());
|
||
|
||
record('node picker offers only what the location accepts', (() => {
|
||
const slot = pick(mixed, 'hired-extensions-top');
|
||
const html = draw(React.createElement(NodePicker, {
|
||
tree: mixed, parent: slot, onAdd: () => {}, registry: nreg,
|
||
}));
|
||
/* A slot takes readings, never a page section. */
|
||
return html.includes('Card') && !html.includes('Recent hiring timeline');
|
||
})());
|
||
|
||
/* ── Scope: what a page may be offered ───────────────────────────────────
|
||
*
|
||
* Every one of these was found in a browser, on Candidates Analysis, in one
|
||
* sitting. The picker offered 43 types on a page that composes seven — every
|
||
* private section of every other page, plus the slot it renders into — and
|
||
* adding one of them threw `undefined.length` inside a section reading a
|
||
* render context that page does not publish, which unmounted the whole
|
||
* application to a white screen.
|
||
*
|
||
* The registry had no way to say where a type belongs or that a type is not
|
||
* something a person adds. It has both now, and these hold it to them.
|
||
*/
|
||
|
||
record('a page is offered only its own sections and the generic readings', (() => {
|
||
const offered = inspectE.addableTypes(null, { registry: nreg, page: 'candidates-analysis' })
|
||
.map((t) => t.type);
|
||
const foreign = offered.filter((t) => {
|
||
const entry = nreg.get(t);
|
||
return entry.page && entry.page !== 'candidates-analysis';
|
||
});
|
||
return { pass: foreign.length === 0, detail: foreign.join(', ') || `${offered.length} offered` };
|
||
})().pass);
|
||
|
||
record('another page\'s section is not offered here', (() => {
|
||
const offered = inspectE.addableTypes(null, { registry: nreg, page: 'candidates-analysis' })
|
||
.map((t) => t.type);
|
||
return { pass: !offered.includes('hired-chronology'), detail: offered.join(', ') };
|
||
})().pass);
|
||
|
||
record('nor on the page it belongs to — a page composes its own sections', (() => {
|
||
/* It is the page's, not a person's: `move` and `hide`, never `add`. The
|
||
earlier reading of this asserted it *was* offered on its own page,
|
||
which is how a node could be created and then never deleted. */
|
||
const offered = inspectE.addableTypes(null, { registry: nreg, page: 'hired-history' })
|
||
.map((t) => t.type);
|
||
return { pass: !offered.includes('hired-chronology'), detail: offered.join(', ') };
|
||
})().pass);
|
||
|
||
record('everything offered anywhere can also be removed', (() => {
|
||
const pages = ['activity', 'analytics', 'candidates-analysis', 'hired-history', 'talent-pool'];
|
||
const stuck = [];
|
||
for (const page of pages) {
|
||
for (const option of inspectE.addableTypes(null, { registry: nreg, page })) {
|
||
if (!nreg.allows(option.type, 'remove')) stuck.push(`${page}:${option.type}`);
|
||
}
|
||
}
|
||
return { pass: stuck.length === 0, detail: stuck.join(', ') || 'no dead ends' };
|
||
})().pass);
|
||
|
||
record('a registration that can be added but not removed is refused at boot', (() => {
|
||
const reg = new regE.NodeTypeRegistry();
|
||
try {
|
||
reg.register({
|
||
type: 'stuck-node',
|
||
label: 'Stuck',
|
||
component: () => null,
|
||
capabilities: ['move', 'hide'],
|
||
addable: true,
|
||
});
|
||
return { pass: false, detail: 'accepted a node nobody could delete' };
|
||
} catch (error) {
|
||
return { pass: /must be able to remove/.test(error.message), detail: error.message };
|
||
}
|
||
})().pass);
|
||
|
||
record('the generic readings are offered on every page', (() => {
|
||
const pages = ['activity', 'analytics', 'candidates-analysis', 'hired-history', 'talent-pool'];
|
||
const missing = pages.filter((page) => {
|
||
const offered = inspectE.addableTypes(null, { registry: nreg, page }).map((t) => t.type);
|
||
return !surfaces.SUPPORTED_SECTION_TYPES.every((type) => offered.includes(type));
|
||
});
|
||
return { pass: missing.length === 0, detail: missing.join(', ') || pages.join(', ') };
|
||
})().pass);
|
||
|
||
record('a slot is a container and still not something a person adds', (() => {
|
||
const entry = nreg.get('skill-surface');
|
||
const offered = inspectE.addableTypes(null, { registry: nreg, page: 'candidates-analysis' })
|
||
.map((t) => t.type);
|
||
return {
|
||
pass: entry.container === true && entry.addable === false && !offered.includes('skill-surface'),
|
||
detail: `container=${entry.container} addable=${entry.addable}`,
|
||
};
|
||
})().pass);
|
||
|
||
record('two slots on one page are told apart by name', (() => {
|
||
const named = seen(caTree).filter((n) => n.type === 'skill-surface').map((n) => n.title);
|
||
return {
|
||
pass: named.length === 2 && named[0] !== named[1] && named.every(Boolean),
|
||
detail: named.join(' | '),
|
||
};
|
||
})().pass);
|
||
|
||
/**
|
||
* The boundary.
|
||
*
|
||
* React does not run error boundaries during server rendering, so a
|
||
* throwing component here takes the whole check script down rather than
|
||
* being caught — which is exactly why this is proven in a browser, in
|
||
* `scripts/browser-flows.js`, and only held to its contract here.
|
||
*/
|
||
record('every node is rendered inside a boundary', (() => {
|
||
const source = readFileSync(join(ROOT, resolveSourcePath('src/components/ui-tree/UiTreeRenderer.jsx')), 'utf8');
|
||
return {
|
||
pass: /<UiNodeBoundary node=\{node\}>/.test(source)
|
||
&& /import \{ UiNodeBoundary \}/.test(source),
|
||
detail: 'UiNode wraps its component in UiNodeBoundary',
|
||
};
|
||
})().pass);
|
||
|
||
record('a failed node renders in place, named, and still addressable', (() => {
|
||
const boundary = new boundaryE.UiNodeBoundary({ node: { id: 'bad', type: 'bar-chart', props: {} } });
|
||
boundary.state = { failed: true, forNode: 'bad' };
|
||
const html = draw(boundary.render());
|
||
return {
|
||
pass: html.includes('data-ui-node="bad"')
|
||
&& html.includes('data-ui-failed="true"')
|
||
&& /could not be shown/.test(html),
|
||
detail: html.slice(0, 160),
|
||
};
|
||
})().pass);
|
||
|
||
record('the boundary latches on a failure and clears when the node changes', (() => {
|
||
const B = boundaryE.UiNodeBoundary;
|
||
const failed = B.getDerivedStateFromError();
|
||
const sameNode = B.getDerivedStateFromProps({ node: { id: 'bad' } }, { failed: true, forNode: 'bad' });
|
||
const otherNode = B.getDerivedStateFromProps({ node: { id: 'other' } }, { failed: true, forNode: 'bad' });
|
||
return {
|
||
pass: failed.failed === true && sameNode === null && otherNode.failed === false,
|
||
detail: JSON.stringify({ failed, sameNode, otherNode }),
|
||
};
|
||
})().pass);
|
||
|
||
|
||
/* ── Layout actually renders ──────────────────────────────────────────
|
||
*
|
||
* The gap this closes: `ReadingNode` took `layout` as a prop and dropped
|
||
* it, so a layout operation validated, changed the tree, persisted — and
|
||
* the browser looked identical. A layout value that cannot be seen is
|
||
* worse than one that is refused, because nothing reports it.
|
||
*
|
||
* The acceptance criterion here is the same as it is in the browser: the
|
||
* *markup* has to change.
|
||
*/
|
||
|
||
record('layout: spacing before and after render as classes', (() => {
|
||
const html = drawReading({ spacingBefore: 'lg', spacingAfter: 'sm' });
|
||
return { pass: /\bmt-6\b/.test(html) && /\bmb-2\b/.test(html), detail: classesOf(html) };
|
||
})().pass);
|
||
|
||
record('layout: span renders as a column span', (() => {
|
||
const html = drawReading({ span: 6 });
|
||
return { pass: /\bcol-span-6\b/.test(html), detail: classesOf(html) };
|
||
})().pass);
|
||
|
||
record('layout: align renders as self-alignment', (() => {
|
||
const html = drawReading({ align: 'center' });
|
||
return { pass: /\bself-center\b/.test(html), detail: classesOf(html) };
|
||
})().pass);
|
||
|
||
record('layout: every vocabulary value maps to a real class', (() => {
|
||
const missing = [];
|
||
for (const value of nodeE.SPACING_VALUES) {
|
||
if (value === 'none') continue;
|
||
if (!rendererE.layoutClasses({ spacingBefore: value })) missing.push(`spacingBefore:${value}`);
|
||
if (!rendererE.layoutClasses({ spacingAfter: value })) missing.push(`spacingAfter:${value}`);
|
||
}
|
||
for (const value of nodeE.ALIGN_VALUES) {
|
||
if (!rendererE.layoutClasses({ align: value })) missing.push(`align:${value}`);
|
||
}
|
||
for (let n = nodeE.MIN_COLUMNS; n <= nodeE.MAX_COLUMNS; n += 1) {
|
||
if (!rendererE.layoutClasses({ span: n })) missing.push(`span:${n}`);
|
||
}
|
||
return { pass: missing.length === 0, detail: missing.join(', ') || 'all mapped' };
|
||
})().pass);
|
||
|
||
record('layout: a node that asks for nothing renders exactly as before', (() => {
|
||
/* The migration baselines depend on this: no layout, no extra class. */
|
||
const before = drawReading(null);
|
||
const empty = drawReading({});
|
||
return {
|
||
pass: before === empty && rendererE.layoutClasses(null) === undefined
|
||
&& rendererE.layoutClasses({}) === undefined,
|
||
detail: classesOf(before),
|
||
};
|
||
})().pass);
|
||
|
||
record('layout: an arbitrary value produces no class at all', (() => {
|
||
/* The closed half. Configuration can only ever name a vocabulary value;
|
||
anything else maps to nothing rather than to itself, so there is no
|
||
path from a stored patch to arbitrary CSS. */
|
||
const hostile = rendererE.layoutClasses({
|
||
span: 'p-99', align: 'absolute inset-0', spacingBefore: '"><script>', spacingAfter: 'mt-[999px]',
|
||
});
|
||
const html = drawReading({ align: 'evil-class' });
|
||
return {
|
||
pass: hostile === undefined && !/evil-class/.test(html),
|
||
detail: String(hostile),
|
||
};
|
||
})().pass);
|
||
|
||
record('layout: a validated operation is what reaches the DOM', (() => {
|
||
/* End to end through the engine rather than by constructing a node: the
|
||
op is validated, applied, and the resulting tree is rendered. */
|
||
const tree = [nodeE.makeNode({ id: 'r1', type: 'card', origin: 'user', data: { source: 'candidates.activity' } })];
|
||
const result = opsE.applyOperation(tree, { op: 'update', target: 'r1', layout: { spacingBefore: 'lg' } }, ectx);
|
||
const html = draw(React.createElement(
|
||
QueryClientProvider,
|
||
{ client: new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }) },
|
||
React.createElement(rendererE.UiTreeRenderer, { nodes: result.tree, registry: nreg })
|
||
));
|
||
return { pass: result.ok && /\bmt-6\b/.test(html), detail: `${result.ok} · ${classesOf(html)}` };
|
||
})().pass);
|
||
|
||
record('layout: an invalid value is refused before it can render', (() => {
|
||
const tree = [nodeE.makeNode({ id: 'r2', type: 'card', origin: 'user', data: { source: 'candidates.activity' } })];
|
||
const r = opsE.applyOperation(tree, { op: 'update', target: 'r2', layout: { spacingBefore: 'enormous' } }, ectx);
|
||
return { pass: !r.ok && /spacingBefore/.test(JSON.stringify(r.problems)), detail: JSON.stringify(r.problems) };
|
||
})().pass);
|
||
|
||
|
||
/* ── Variant and density ──────────────────────────────────────────────
|
||
*
|
||
* The whole point is that a value a person picks changes what they see.
|
||
* A setting that validates, persists and renders identically is the bug
|
||
* this is written to prevent, so the markup is what is asserted.
|
||
*/
|
||
|
||
const drawLook = (presentation) => draw(React.createElement(
|
||
QueryClientProvider,
|
||
{ client: new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }) },
|
||
React.createElement(rendererE.UiTreeRenderer, {
|
||
nodes: [nodeE.makeNode({
|
||
id: 'look-probe', type: 'card', origin: 'user',
|
||
data: { source: 'candidates.activity' }, ...(presentation ? { presentation } : {}),
|
||
})],
|
||
registry: nreg,
|
||
})
|
||
));
|
||
|
||
record('presentation: the vocabulary is closed and centralised', (() => {
|
||
const okVariants = JSON.stringify(nodeE.VARIANT_VALUES) === JSON.stringify(['default', 'subtle', 'emphasis']);
|
||
const okDensity = JSON.stringify(nodeE.DENSITY_VALUES) === JSON.stringify(['comfortable', 'compact']);
|
||
return { pass: okVariants && okDensity && nodeE.PRESENTATION_KEYS.join() === 'variant,density',
|
||
detail: `${nodeE.VARIANT_VALUES.join('/')} · ${nodeE.DENSITY_VALUES.join('/')}` };
|
||
})().pass);
|
||
|
||
record('presentation: a reading type declares support for both', (() => {
|
||
const entry = nreg.get('card');
|
||
return { pass: entry.variants.length === 3 && entry.densities.length === 2,
|
||
detail: `${entry.variants.join(',')} · ${entry.densities.join(',')}` };
|
||
})().pass);
|
||
|
||
record('presentation: a type that draws no panel declares none', (() => {
|
||
const entry = nreg.get('skill-surface');
|
||
return { pass: entry.variants.length === 0 && entry.densities.length === 0, detail: 'slot supports neither' };
|
||
})().pass);
|
||
|
||
record('presentation: density visibly changes the markup', (() => {
|
||
const comfortable = drawLook({ density: 'comfortable' });
|
||
const compact = drawLook({ density: 'compact' });
|
||
return { pass: /\bp-5\b/.test(comfortable) && /\bp-3\b/.test(compact) && comfortable !== compact,
|
||
detail: classesOf(compact) };
|
||
})().pass);
|
||
|
||
record('presentation: variant visibly changes the markup', (() => {
|
||
const plain = drawLook({ variant: 'default' });
|
||
const emphasis = drawLook({ variant: 'emphasis' });
|
||
const subtle = drawLook({ variant: 'subtle' });
|
||
return { pass: /ring-krow-blue/.test(emphasis) && /bg-surface-subtle/.test(subtle)
|
||
&& plain !== emphasis && plain !== subtle, detail: classesOf(emphasis) };
|
||
})().pass);
|
||
|
||
record('presentation: a node that asks for nothing renders as it always did', (() => {
|
||
const bare = drawLook(null);
|
||
return { pass: bare === drawLook({ variant: 'default', density: 'comfortable' })
|
||
&& /\bp-5\b/.test(bare) && /bg-surface\b/.test(bare), detail: classesOf(bare) };
|
||
})().pass);
|
||
|
||
record('presentation: a valid update is applied through the one engine', (() => {
|
||
const tree = [nodeE.makeNode({ id: 'p1', type: 'card', origin: 'user', data: { source: 'candidates.activity' } })];
|
||
const r = opsE.applyOperation(tree, { op: 'update', target: 'p1', presentation: { density: 'compact' } }, ectx);
|
||
return { pass: r.ok && nodeE.findNode(r.tree, 'p1').presentation.density === 'compact',
|
||
detail: JSON.stringify(r.problems) };
|
||
})().pass);
|
||
|
||
record('presentation: setting one value keeps the other', (() => {
|
||
const tree = [nodeE.makeNode({ id: 'p2', type: 'card', origin: 'user',
|
||
data: { source: 'candidates.activity' }, presentation: { variant: 'emphasis' } })];
|
||
const r = opsE.applyOperation(tree, { op: 'update', target: 'p2', presentation: { density: 'compact' } }, ectx);
|
||
const now = nodeE.findNode(r.tree, 'p2').presentation;
|
||
return { pass: r.ok && now.variant === 'emphasis' && now.density === 'compact', detail: JSON.stringify(now) };
|
||
})().pass);
|
||
|
||
for (const [label, presentation] of [
|
||
['an unknown variant', { variant: 'neon' }],
|
||
['an unknown density', { density: 'squished' }],
|
||
['a class name', { variant: 'p-0 bg-red-500' }],
|
||
]) {
|
||
record(`presentation: ${label} is refused`, (() => {
|
||
const tree = [nodeE.makeNode({ id: 'p3', type: 'card', origin: 'user', data: { source: 'candidates.activity' } })];
|
||
const r = opsE.applyOperation(tree, { op: 'update', target: 'p3', presentation }, ectx);
|
||
return { pass: !r.ok, detail: JSON.stringify(r.problems).slice(0, 120) };
|
||
})().pass);
|
||
}
|
||
|
||
record('presentation: an unknown presentation key cannot take effect', (() => {
|
||
/* Dropped at the contract rather than refused at the validator — the same
|
||
rule `layout` and `props` follow. Stronger than a refusal: the key
|
||
never exists to be read, so nothing downstream can grow a dependency on
|
||
it, and no stored patch can smuggle one in. */
|
||
const tree = [nodeE.makeNode({ id: 'p4', type: 'card', origin: 'user', data: { source: 'candidates.activity' } })];
|
||
const r = opsE.applyOperation(tree, { op: 'update', target: 'p4', presentation: { elevation: 'xl' } }, ectx);
|
||
const stored = r.ok ? nodeE.findNode(r.tree, 'p4').presentation : null;
|
||
/* Compared against the bare render rather than grepped for a substring:
|
||
`rounded-2xl` contains "xl", and a test that reads a class name out of
|
||
another class name proves nothing. */
|
||
return {
|
||
pass: stored && !('elevation' in stored) && drawLook({ elevation: 'xl' }) === drawLook(null),
|
||
detail: JSON.stringify(stored),
|
||
};
|
||
})().pass);
|
||
|
||
record('presentation: a type that supports none refuses a valid value', (() => {
|
||
const tree = compE.composePage('candidates-analysis', ectx).tree;
|
||
const slot = tree.find((n) => n.type === 'skill-surface');
|
||
const r = opsE.applyOperation(tree, { op: 'update', target: slot.id, presentation: { density: 'compact' } }, ectx);
|
||
return { pass: !r.ok, detail: JSON.stringify(r.problems).slice(0, 140) };
|
||
})().pass);
|
||
|
||
record('presentation: a registration cannot invent a value', (() => {
|
||
const reg = new regE.NodeTypeRegistry();
|
||
try {
|
||
reg.register({ type: 'bad-look', label: 'Bad', component: () => null, variants: ['neon'] });
|
||
return { pass: false, detail: 'accepted an invented variant' };
|
||
} catch (error) {
|
||
return { pass: /unknown variant/.test(error.message), detail: error.message };
|
||
}
|
||
})().pass);
|
||
|
||
record('presentation: the editor emits the operation Owliver emits', (() => {
|
||
/* A reading node, because a page's own sections declare only move and
|
||
hide — the equivalence being tested is between two clients, not a claim
|
||
that every node is editable. */
|
||
const tree = [nodeE.makeNode({
|
||
id: 'supply-card', type: 'card', origin: 'user',
|
||
props: { title: 'Supply summary' }, data: { source: 'candidates.activity' },
|
||
})];
|
||
const node = inspectE.inspectTree(tree, ectx)[0];
|
||
const fromEditor = editorOps.presentationOp(node, 'density', 'compact');
|
||
const fromOwliver = intentE.matchUiEdit('make the Supply summary compact',
|
||
{ tree, registry: nreg, page: 'candidates-analysis' });
|
||
return {
|
||
pass: fromEditor.op === 'update' && fromEditor.presentation.density === 'compact'
|
||
&& fromOwliver?.kind === 'plan' && fromOwliver.op.op === 'update'
|
||
&& fromOwliver.op.target === node.id && fromOwliver.op.presentation.density === 'compact',
|
||
detail: `${JSON.stringify(fromEditor)} vs ${JSON.stringify(fromOwliver?.op)}`,
|
||
};
|
||
})().pass);
|
||
|
||
record('presentation: a stored patch round-trips the value', (() => {
|
||
const { patch } = patchE.normalizePatch({ schema: 1, page: 'x', ops: [
|
||
{ op: 'update', target: 'p1', presentation: { density: 'compact', variant: 'emphasis' } },
|
||
] }, 'x');
|
||
const op = patch.ops[0];
|
||
return { pass: op?.presentation?.density === 'compact'
|
||
&& op?.presentation?.variant === 'emphasis', detail: JSON.stringify(op) };
|
||
})().pass);
|
||
|
||
record('presentation: ordinary data questions are not presentation requests', (() => {
|
||
const ordinary = [
|
||
'What is on this page?', 'Show recent hires', 'How many candidates applied?',
|
||
'Which positions need attention?', 'Compare the top candidates',
|
||
];
|
||
const taken = ordinary.filter((q) => {
|
||
const m = intentE.matchUiEdit(q, { tree: caTree, registry: nreg, page: 'candidates-analysis' });
|
||
return m?.kind === 'plan' && m.op?.presentation;
|
||
});
|
||
return { pass: taken.length === 0, detail: taken.join(' | ') || 'none taken' };
|
||
})().pass);
|
||
|
||
|
||
/* ── Generic replace ──────────────────────────────────────────────────
|
||
*
|
||
* A node turned into a different kind of node is still the same node: same
|
||
* id, same reading, same place. What changes is what draws it.
|
||
*/
|
||
|
||
const cardNode = (extra = {}) => nodeE.makeNode({
|
||
id: 'perf', type: 'card', origin: 'user',
|
||
props: { title: 'Hiring performance' },
|
||
data: { source: 'hires.performance' },
|
||
layout: { spacingBefore: 'lg' },
|
||
presentation: { variant: 'emphasis', density: 'compact' },
|
||
...extra,
|
||
});
|
||
const replaceWith = (type, node = cardNode()) =>
|
||
opsE.applyOperation([node], { op: 'replace', target: 'perf', type }, ectx);
|
||
|
||
record('replace: card becomes table', (() => {
|
||
const r = replaceWith('table');
|
||
return { pass: r.ok && nodeE.findNode(r.tree, 'perf').type === 'table',
|
||
detail: JSON.stringify(r.problems) };
|
||
})().pass);
|
||
|
||
record('replace: the node keeps its id', (() => {
|
||
const r = replaceWith('table');
|
||
return { pass: r.ok && r.tree.length === 1 && r.tree[0].id === 'perf', detail: r.tree[0]?.id };
|
||
})().pass);
|
||
|
||
record('replace: the reading is carried, never repointed', (() => {
|
||
const r = replaceWith('table');
|
||
return { pass: r.ok && nodeE.findNode(r.tree, 'perf').data.source === 'hires.performance',
|
||
detail: JSON.stringify(nodeE.findNode(r.tree, 'perf').data) };
|
||
})().pass);
|
||
|
||
record('replace: layout and presentation survive where the new type accepts them', (() => {
|
||
const after = replaceWith('table').tree[0];
|
||
return {
|
||
pass: after.layout.spacingBefore === 'lg'
|
||
&& after.presentation.variant === 'emphasis' && after.presentation.density === 'compact',
|
||
detail: JSON.stringify({ layout: after.layout, presentation: after.presentation }),
|
||
};
|
||
})().pass);
|
||
|
||
record('replace: a property both types declare is kept', (() => {
|
||
const after = replaceWith('table').tree[0];
|
||
return { pass: after.props.title === 'Hiring performance', detail: JSON.stringify(after.props) };
|
||
})().pass);
|
||
|
||
record('replace: a property the new type never declared is dropped', (() => {
|
||
const node = cardNode({ props: { title: 'Kept', notAThing: 'dropped' } });
|
||
const after = replaceWith('table', node).tree[0];
|
||
return { pass: after.props.title === 'Kept' && !('notAThing' in after.props),
|
||
detail: JSON.stringify(after.props) };
|
||
})().pass);
|
||
|
||
record('replace: hidden and origin are preserved', (() => {
|
||
const after = replaceWith('table', cardNode({ hidden: true })).tree[0];
|
||
return { pass: after.hidden === true && after.origin === 'user', detail: `${after.hidden}/${after.origin}` };
|
||
})().pass);
|
||
|
||
record('replace: a type that cannot draw the reading is refused by name', (() => {
|
||
/* `hires.performance` has no list in it. */
|
||
const r = replaceWith('list');
|
||
return { pass: !r.ok && /cannot show/i.test(JSON.stringify(r.problems)),
|
||
detail: JSON.stringify(r.problems).slice(0, 140) };
|
||
})().pass);
|
||
|
||
record('replace: a refused replacement mutates nothing', (() => {
|
||
const before = [cardNode()];
|
||
const r = opsE.applyOperation(before, { op: 'replace', target: 'perf', type: 'list' }, ectx);
|
||
return { pass: !r.ok && r.tree === before && r.tree[0].type === 'card', detail: r.tree[0].type };
|
||
})().pass);
|
||
|
||
record('replace: an unregistered type is refused', (() => {
|
||
/* A name nobody registers, and nothing here should ever register. */
|
||
const r = replaceWith('sculpture');
|
||
return { pass: !r.ok && /Unsupported UI type/i.test(JSON.stringify(r.problems)),
|
||
detail: JSON.stringify(r.problems).slice(0, 120) };
|
||
})().pass);
|
||
|
||
record('replace: a built-in page section cannot be replaced', (() => {
|
||
const tree = compE.composePage('candidates-analysis', ectx).tree;
|
||
const section = tree.find((n) => n.type && n.type.startsWith('ca-'));
|
||
const r = opsE.applyOperation(tree, { op: 'replace', target: section.id, type: 'table' }, ectx);
|
||
return { pass: !r.ok, detail: JSON.stringify(r.problems).slice(0, 140) };
|
||
})().pass);
|
||
|
||
record('replace: the offering is page-scoped', (() => {
|
||
const shapes = surfaces.dataSourceFor('hires.performance').shapes;
|
||
const here = nreg.replacements('card', { shapes, page: 'analytics' });
|
||
const foreign = here.filter((t) => {
|
||
const entry = nreg.get(t);
|
||
return entry.page && entry.page !== 'analytics';
|
||
});
|
||
return { pass: foreign.length === 0 && here.includes('table'),
|
||
detail: here.join(', ') };
|
||
})().pass);
|
||
|
||
record('replace: the offering excludes types nobody may add', (() => {
|
||
const offered = nreg.replacements('card', { shapes: null, page: 'analytics' });
|
||
return { pass: !offered.includes('skill-surface'), detail: offered.join(', ') || 'none' };
|
||
})().pass);
|
||
|
||
record('replace: the editor emits the operation Owliver emits', (() => {
|
||
const tree = [cardNode()];
|
||
const node = inspectE.inspectTree(tree, ectx)[0];
|
||
const fromEditor = editorOps.replaceOp(node, 'table');
|
||
const fromOwliver = intentE.matchUiEdit('change the Hiring performance card to a table',
|
||
{ tree, registry: nreg, page: 'analytics' });
|
||
return {
|
||
pass: JSON.stringify(fromEditor) === JSON.stringify(fromOwliver?.op),
|
||
detail: `${JSON.stringify(fromEditor)} vs ${JSON.stringify(fromOwliver?.op)}`,
|
||
};
|
||
})().pass);
|
||
|
||
record('replace: a stored patch round-trips the operation', (() => {
|
||
const { patch } = patchE.normalizePatch({ schema: 1, page: 'analytics', ops: [
|
||
{ op: 'replace', target: 'perf', type: 'table' },
|
||
] }, 'analytics');
|
||
return { pass: patch.ops[0]?.op === 'replace' && patch.ops[0]?.type === 'table',
|
||
detail: JSON.stringify(patch.ops[0]) };
|
||
})().pass);
|
||
|
||
record('replace: ordinary data questions do not become replacements', (() => {
|
||
const tree = [cardNode()];
|
||
const ordinary = ['What is on this page?', 'Show recent hires', 'How many candidates applied?',
|
||
'Compare the top candidates', 'Which positions need attention?'];
|
||
const taken = ordinary.filter((q) => {
|
||
const m = intentE.matchUiEdit(q, { tree, registry: nreg, page: 'analytics' });
|
||
return m?.kind === 'plan' && m.op?.op === 'replace';
|
||
});
|
||
return { pass: taken.length === 0, detail: taken.join(' | ') || 'none taken' };
|
||
})().pass);
|
||
|
||
|
||
/* ── Visualizations ───────────────────────────────────────────────────
|
||
*
|
||
* Five node types that are deliberately *not* skill section types, drawing
|
||
* the payload a `flow` reading already resolves to. The tests that matter
|
||
* are about compatibility, and there are now two kinds: whether a component
|
||
* *can* draw a reading, and whether doing so would be true.
|
||
*/
|
||
|
||
const VIZ = ['bar-chart', 'line-chart', 'area-chart', 'pie-chart', 'donut-chart'];
|
||
|
||
/* What a reading means, asked the same way every consumer asks it. */
|
||
const seriesMod = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||
|
||
const seriesNode = (type, source, extra = {}) => nodeE.makeNode({
|
||
id: 'viz', type, origin: 'user', props: { title: 'Hiring performance' },
|
||
data: { source }, layout: { spacingBefore: 'lg' },
|
||
presentation: { variant: 'emphasis', density: 'compact' }, ...extra,
|
||
});
|
||
|
||
/**
|
||
* A reading whose meaning this visualization can honestly draw.
|
||
*
|
||
* There is no single source that fits all five, and that is the point: a
|
||
* line may draw a reading that is ordered, a pie may draw one whose points
|
||
* are parts of a whole, and no reading is both. Picked from what the source
|
||
* itself declares rather than assumed.
|
||
*/
|
||
const READING_OF_KIND = {
|
||
periodic: 'candidates.activity',
|
||
cumulative: 'position.pipeline',
|
||
parts: 'activity.breakdown',
|
||
};
|
||
const readingFor = (type) => READING_OF_KIND[nreg.get(type).seriesKinds[0]];
|
||
|
||
for (const type of VIZ) {
|
||
record(`${type}: is a registered node type with a real component`, (() => {
|
||
const entry = nreg.get(type);
|
||
return { pass: Boolean(entry) && typeof entry.component === 'function' && entry.dataRequired,
|
||
detail: entry ? `shapes ${entry.dataShapes.join(',')}` : 'missing' };
|
||
})().pass);
|
||
|
||
record(`${type}: declares the series shapes and nothing else`, (() => {
|
||
const entry = nreg.get(type);
|
||
return { pass: JSON.stringify([...entry.dataShapes].sort()) === JSON.stringify(['flow', 'stats']),
|
||
detail: entry.dataShapes.join(', ') };
|
||
})().pass);
|
||
|
||
record(`${type}: declares which series meanings it can honestly draw`, (() => {
|
||
const entry = nreg.get(type);
|
||
const known = entry.seriesKinds.every((k) => nodeE.SERIES_KINDS.includes(k));
|
||
return { pass: entry.seriesKinds.length > 0 && known, detail: entry.seriesKinds.join(', ') };
|
||
})().pass);
|
||
|
||
record(`${type}: supports the presentation vocabulary it honours`, (() => {
|
||
const entry = nreg.get(type);
|
||
return { pass: entry.variants.length === 3 && entry.densities.length === 2,
|
||
detail: `${entry.variants.length}/${entry.densities.length}` };
|
||
})().pass);
|
||
|
||
record(`${type}: a card on a series reading can become one`, (() => {
|
||
const source = readingFor(type);
|
||
const r = opsE.applyOperation([seriesNode('card', source)],
|
||
{ op: 'replace', target: 'viz', type }, ectx);
|
||
const after = r.ok ? nodeE.findNode(r.tree, 'viz') : null;
|
||
return { pass: r.ok && after.type === type && after.id === 'viz'
|
||
&& after.data.source === source, detail: JSON.stringify(r.problems) };
|
||
})().pass);
|
||
|
||
/* Gap 4: a reading whose meaning was never declared is refused, not
|
||
guessed at. `hires.performance` draws `flow` and `stats` — structurally
|
||
every chart could take it — and says nothing about what its figures
|
||
mean, which used to be read as consent. */
|
||
record(`${type}: a reading of unknown meaning is refused, not guessed`, (() => {
|
||
const before = [seriesNode('card', 'hires.performance')];
|
||
const r = opsE.applyOperation(before, { op: 'replace', target: 'viz', type }, ectx);
|
||
const said = JSON.stringify(r.problems);
|
||
return { pass: !r.ok && r.tree === before && /does not say what its figures mean/i.test(said),
|
||
detail: said.slice(0, 170) };
|
||
})().pass);
|
||
|
||
record(`${type}: replacement keeps layout, presentation and title`, (() => {
|
||
const after = opsE.applyOperation([seriesNode('card', readingFor(type))],
|
||
{ op: 'replace', target: 'viz', type }, ectx).tree[0];
|
||
return { pass: after.layout.spacingBefore === 'lg' && after.presentation.variant === 'emphasis'
|
||
&& after.presentation.density === 'compact' && after.props.title === 'Hiring performance',
|
||
detail: JSON.stringify({ l: after.layout, p: after.presentation, t: after.props.title }) };
|
||
})().pass);
|
||
|
||
record(`${type}: a reading with no series is refused by name`, (() => {
|
||
/* `position.requirements` declares list, table and card — no series in
|
||
it at all. One of only two such sources, which is itself the honest
|
||
compatibility picture: nearly every reading carries a series. */
|
||
const r = opsE.applyOperation([seriesNode('list', 'position.requirements')],
|
||
{ op: 'replace', target: 'viz', type }, ectx);
|
||
return { pass: !r.ok && /cannot show/i.test(JSON.stringify(r.problems)),
|
||
detail: JSON.stringify(r.problems).slice(0, 130) };
|
||
})().pass);
|
||
|
||
record(`${type}: a refused replacement mutates nothing`, (() => {
|
||
const before = [seriesNode('list', 'position.requirements')];
|
||
const r = opsE.applyOperation(before, { op: 'replace', target: 'viz', type }, ectx);
|
||
return { pass: !r.ok && r.tree === before && r.tree[0].type === 'list', detail: r.tree[0].type };
|
||
})().pass);
|
||
|
||
record(`${type}: the editor offers it only where the reading fits`, (() => {
|
||
const source = readingFor(type);
|
||
const fits = nreg.replacements('card', {
|
||
shapes: surfaces.dataSourceFor(source).shapes, page: 'analytics',
|
||
seriesKind: seriesMod.kindOfBinding({ source }), bound: true,
|
||
});
|
||
const doesNot = nreg.replacements('list', {
|
||
shapes: surfaces.dataSourceFor('position.requirements').shapes, page: 'analytics',
|
||
seriesKind: seriesMod.kindOfBinding({ source: 'position.requirements' }), bound: true,
|
||
});
|
||
/* And the one gap 4 is about: structurally able, meaning undeclared. */
|
||
const unknown = nreg.replacements('card', {
|
||
shapes: surfaces.dataSourceFor('hires.performance').shapes, page: 'analytics',
|
||
seriesKind: seriesMod.kindOfBinding({ source: 'hires.performance' }), bound: true,
|
||
});
|
||
return { pass: fits.includes(type) && !doesNot.includes(type) && !unknown.includes(type),
|
||
detail: `fits: ${fits.join(',')} | not: ${doesNot.join(',')} | unknown: ${unknown.join(',') || 'none'}` };
|
||
})().pass);
|
||
|
||
record(`${type}: Owliver plans the same operation the editor emits`, (() => {
|
||
const tree = [seriesNode('card', readingFor(type))];
|
||
const node = inspectE.inspectTree(tree, ectx)[0];
|
||
const fromEditor = editorOps.replaceOp(node, type);
|
||
const label = nreg.get(type).label.toLowerCase();
|
||
const fromOwliver = intentE.matchUiEdit(
|
||
`change the Hiring performance to a ${label}`, { tree, registry: nreg, page: 'analytics' });
|
||
return { pass: JSON.stringify(fromEditor) === JSON.stringify(fromOwliver?.op),
|
||
detail: `${JSON.stringify(fromEditor)} vs ${JSON.stringify(fromOwliver?.op)}` };
|
||
})().pass);
|
||
}
|
||
|
||
record('visualizations: none of them is a skill section type', (() => {
|
||
/* The MD vocabulary is untouched, so the Go parser and its oracle are too. */
|
||
const inMd = surfaces.SUPPORTED_SECTION_TYPES.filter((t) => VIZ.includes(t));
|
||
return { pass: inMd.length === 0, detail: surfaces.SUPPORTED_SECTION_TYPES.join(',') };
|
||
})().pass);
|
||
|
||
record('visualizations: ordinary data questions do not become visualisations', (() => {
|
||
const tree = [seriesNode('card', 'hires.performance')];
|
||
const ordinary = ['What is on this page?', 'Show recent hires', 'How many candidates applied?',
|
||
'What is the hiring trend?', 'Compare the top candidates'];
|
||
const taken = ordinary.filter((q) => {
|
||
const m = intentE.matchUiEdit(q, { tree, registry: nreg, page: 'analytics' });
|
||
return m?.kind === 'plan' && VIZ.includes(m.op?.type);
|
||
});
|
||
return { pass: taken.length === 0, detail: taken.join(' | ') || 'none taken' };
|
||
})().pass);
|
||
|
||
/* ── Series registry and meaning ──────────────────────────────────────
|
||
*
|
||
* The half `dataShapes` could not express. A `flow` is, in the section
|
||
* vocabulary's own words, "a sequence of stages *or* periods" — so these
|
||
* check that the difference is now declared, enforced, and explained.
|
||
*/
|
||
{
|
||
const seriesE = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||
|
||
record('series: the kind vocabulary is closed', () => true);
|
||
record('series: the kind vocabulary is closed',
|
||
JSON.stringify(nodeE.SERIES_KINDS) === JSON.stringify(['periodic', 'cumulative', 'parts']),
|
||
nodeE.SERIES_KINDS.join(', '));
|
||
|
||
record('series: Control Center publishes its hiring activity', (() => {
|
||
const entry = seriesE.seriesFor('control-center.hiring-activity');
|
||
return { pass: Boolean(entry) && entry.kind === 'periodic' && entry.measures.length === 4,
|
||
detail: entry ? `${entry.kind} · ${entry.measures.map((m) => m.key).join(',')}` : 'missing' };
|
||
})().pass);
|
||
|
||
record('series: a registration with an unknown kind is refused', (() => {
|
||
try {
|
||
seriesE.registerSeries({ id: 'x.y', kind: 'vibes', measures: [{ key: 'a', label: 'A' }], read: () => [] });
|
||
return false;
|
||
} catch (e) { return /unknown kind/i.test(e.message); }
|
||
})());
|
||
|
||
record('series: a registration with no measure is refused', (() => {
|
||
try {
|
||
seriesE.registerSeries({ id: 'x.z', kind: 'parts', measures: [], read: () => [] });
|
||
return false;
|
||
} catch (e) { return /at least one measure/i.test(e.message); }
|
||
})());
|
||
|
||
record('series: a registration with no read is refused', (() => {
|
||
try {
|
||
seriesE.registerSeries({ id: 'x.w', kind: 'parts', measures: [{ key: 'a', label: 'A' }] });
|
||
return false;
|
||
} catch (e) { return /needs a `read/i.test(e.message); }
|
||
})());
|
||
|
||
record('series: a reading bound to periods is periodic', (() => {
|
||
const kind = seriesE.kindOfBinding({ source: 'candidates.activity', params: { periods: ['today'] } });
|
||
return { pass: kind === 'periodic', detail: String(kind) };
|
||
})().pass);
|
||
|
||
record('series: a binding that cannot say vetoes nothing', (() => {
|
||
const kind = seriesE.kindOfBinding({ source: 'hires.performance', params: {} });
|
||
return { pass: kind === null, detail: String(kind) };
|
||
})().pass);
|
||
|
||
record('series: a page series resolves the page\u2019s own rows, unchanged', (() => {
|
||
const rows = [{ label: '9/1', applications: 3, screened: 2, interviews: 1, hires: 0 }];
|
||
const out = seriesE.readSeries({ series: 'control-center.hiring-activity' },
|
||
{ context: { activitySeries: rows } });
|
||
return { pass: out.rows.length === 1 && out.rows[0].applications === 3
|
||
&& out.rows[0].label === '9/1' && out.measures.length === 4,
|
||
detail: JSON.stringify(out.rows) };
|
||
})().pass);
|
||
|
||
record('series: nothing is invented when the page published nothing', (() => {
|
||
const out = seriesE.readSeries({ series: 'control-center.hiring-activity' }, { context: {} });
|
||
return { pass: out.rows.length === 0, detail: JSON.stringify(out.rows) };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── Built-in chart → another visualization ───────────────────────────
|
||
*
|
||
* The whole point, end to end on the real page composition: the section
|
||
* Control Center ships is bound to a series it publishes, and that is the
|
||
* only reason it can be drawn another way.
|
||
*/
|
||
{
|
||
const seriesE = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||
await server.ssrLoadModule('/src/pages/admin/control-center/nodes.jsx');
|
||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||
const activity = () => ccTree().find((n) => n.id === 'cc-activity');
|
||
|
||
record('built-in: Hiring activity is bound to the page\u2019s own series', (() => {
|
||
const node = activity();
|
||
return { pass: node?.data?.series === 'control-center.hiring-activity',
|
||
detail: JSON.stringify(node?.data) };
|
||
})().pass);
|
||
|
||
{
|
||
const verdict = validateE.validateTree(ccTree(), ectx);
|
||
record('built-in: it composes and validates as it ships',
|
||
verdict.ok === true, JSON.stringify(verdict.problems).slice(0, 260));
|
||
}
|
||
|
||
for (const type of ['bar-chart', 'line-chart', 'area-chart']) {
|
||
record(`built-in: Hiring activity can become a ${type}`, (() => {
|
||
const r = opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type }, ectx);
|
||
const after = r.ok ? nodeE.findNode(r.tree, 'cc-activity') : null;
|
||
return { pass: r.ok && after.type === type
|
||
&& after.id === 'cc-activity'
|
||
&& after.data.series === 'control-center.hiring-activity'
|
||
&& after.origin === 'builtin',
|
||
detail: JSON.stringify(r.problems).slice(0, 160) };
|
||
})().pass);
|
||
}
|
||
|
||
for (const type of ['pie-chart', 'donut-chart']) {
|
||
record(`built-in: Hiring activity refuses ${type}, and says why`, (() => {
|
||
const r = opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type }, ectx);
|
||
const said = JSON.stringify(r.problems);
|
||
return { pass: !r.ok && /ordered in time/i.test(said), detail: said.slice(0, 170) };
|
||
})().pass);
|
||
}
|
||
|
||
record('built-in: a refused visualization mutates nothing', (() => {
|
||
const before = ccTree();
|
||
const r = opsE.applyOperation(before, { op: 'replace', target: 'cc-activity', type: 'pie-chart' }, ectx);
|
||
return { pass: !r.ok && r.tree === before, detail: String(r.ok) };
|
||
})().pass);
|
||
|
||
record('built-in: the offering is the registry\u2019s, and excludes the untrue ones', (() => {
|
||
const node = activity();
|
||
const offered = nreg.replacements(node.type, {
|
||
page: 'control-center',
|
||
seriesKind: seriesE.kindOfBinding(node.data),
|
||
seriesBound: true,
|
||
bound: true,
|
||
});
|
||
return { pass: offered.includes('bar-chart') && offered.includes('line-chart')
|
||
&& offered.includes('area-chart') && !offered.includes('pie-chart')
|
||
&& !offered.includes('donut-chart'),
|
||
detail: offered.join(', ') };
|
||
})().pass);
|
||
|
||
record('built-in: Owliver and the editor emit the same operation', (() => {
|
||
const tree = ccTree();
|
||
const node = inspectE.inspectTree(tree, { ...ectx, page: 'control-center' })
|
||
.find((n) => n.id === 'cc-activity');
|
||
const fromEditor = editorOps.replaceOp(node, 'bar-chart');
|
||
const fromOwliver = intentE.matchUiEdit('Change Hiring activity to a bar chart',
|
||
{ tree, registry: nreg, page: 'control-center' });
|
||
return { pass: JSON.stringify(fromEditor) === JSON.stringify(fromOwliver?.op),
|
||
detail: `${JSON.stringify(fromEditor)} vs ${JSON.stringify(fromOwliver?.op)}` };
|
||
})().pass);
|
||
|
||
record('built-in: a bar chart can be changed back to a line chart', (() => {
|
||
const barred = opsE.applyOperation(ccTree(),
|
||
{ op: 'replace', target: 'cc-activity', type: 'bar-chart' }, ectx).tree;
|
||
const r = opsE.applyOperation(barred, { op: 'replace', target: 'cc-activity', type: 'line-chart' }, ectx);
|
||
const after = r.ok ? nodeE.findNode(r.tree, 'cc-activity') : null;
|
||
return { pass: r.ok && after.type === 'line-chart'
|
||
&& after.data.series === 'control-center.hiring-activity',
|
||
detail: JSON.stringify(r.problems).slice(0, 140) };
|
||
})().pass);
|
||
|
||
record('built-in: a stored patch replays the change from cold', (() => {
|
||
const patch = { ops: [{ op: 'replace', target: 'cc-activity', type: 'bar-chart' }] };
|
||
const round = JSON.parse(JSON.stringify(patch));
|
||
const out = compE.composePage('control-center', { patch: round, ...ectx });
|
||
const after = out.tree.find((n) => n.id === 'cc-activity');
|
||
return { pass: after?.type === 'bar-chart' && out.skipped.length === 0,
|
||
detail: `${after?.type} · skipped ${out.skipped.length}` };
|
||
})().pass);
|
||
|
||
record('built-in: a stored patch that became untrue is refused on load', (() => {
|
||
const patch = { ops: [{ op: 'replace', target: 'cc-activity', type: 'pie-chart' }] };
|
||
const out = compE.composePage('control-center', { patch, ...ectx });
|
||
const after = out.tree.find((n) => n.id === 'cc-activity');
|
||
return { pass: after?.type === 'cc-activity' && out.skipped.length === 1,
|
||
detail: `${after?.type} · skipped ${out.skipped.length}` };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── Continuity, controls, and knowing what a reading means ───────────
|
||
*
|
||
* The four gaps the live verification found, each as the smallest test
|
||
* that would have caught it.
|
||
*/
|
||
{
|
||
const seriesE = await server.ssrLoadModule('/src/lib/ui/series.js');
|
||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||
const barred = () => opsE.applyOperation(ccTree(),
|
||
{ op: 'replace', target: 'cc-activity', type: 'bar-chart' }, ectx).tree;
|
||
const ask = (q, opts = {}) => intentE.matchUiEdit(q,
|
||
{ tree: ccTree(), registry: nreg, page: 'control-center', ...opts });
|
||
|
||
/* ── A. "Change it back to a line chart." ─────────────────────────── */
|
||
|
||
record('continuity: a replaced node keeps the name of what it reads', (() => {
|
||
const node = inspectE.inspectTree(barred(), ectx).find((n) => n.id === 'cc-activity');
|
||
return { pass: node?.type === 'bar-chart' && node?.title === 'Hiring activity',
|
||
detail: `${node?.type} · ${node?.title}` };
|
||
})().pass);
|
||
|
||
record('continuity: it is still addressable by that name after replacement', (() => {
|
||
const m = intentE.matchUiEdit('Change Hiring activity to a line chart',
|
||
{ tree: barred(), registry: nreg, page: 'control-center' });
|
||
return { pass: m?.kind === 'plan' && m.op.target === 'cc-activity' && m.op.type === 'line-chart',
|
||
detail: `${m?.kind}: ${JSON.stringify(m?.op)}` };
|
||
})().pass);
|
||
|
||
record('continuity: "change it back to a line chart" resolves to what was changed', (() => {
|
||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||
{ tree: barred(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||
return { pass: m?.kind === 'plan' && m.op.op === 'replace'
|
||
&& m.op.target === 'cc-activity' && m.op.type === 'line-chart',
|
||
detail: `${m?.kind}: ${JSON.stringify(m?.op)}` };
|
||
})().pass);
|
||
|
||
record('continuity: the same sentence with no subject is still unknown', (() => {
|
||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||
{ tree: barred(), registry: nreg, page: 'control-center' });
|
||
return { pass: m?.kind === 'unknown', detail: String(m?.kind) };
|
||
})().pass);
|
||
|
||
record('continuity: a request that names something else is never redirected', (() => {
|
||
/* Focus is a last resort, not a preference: the words still win. */
|
||
const m = intentE.matchUiEdit('Hide the pipeline intelligence',
|
||
{ tree: ccTree(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||
return { pass: m?.kind === 'plan' && m.op.target === 'cc-pipeline',
|
||
detail: JSON.stringify(m?.op) };
|
||
})().pass);
|
||
|
||
record('continuity: a focus that has left the page resolves to nothing', (() => {
|
||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||
{ tree: barred(), registry: nreg, page: 'control-center', focus: 'no-such-node' });
|
||
return { pass: m?.kind === 'unknown', detail: String(m?.kind) };
|
||
})().pass);
|
||
|
||
record('continuity: a phrase with no pronoun does not reach for the focus', (() => {
|
||
const m = intentE.matchUiEdit('Change the audit log to a table',
|
||
{ tree: ccTree(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||
return { pass: m?.kind !== 'plan' || m.op.target !== 'cc-activity', detail: String(m?.kind) };
|
||
})().pass);
|
||
|
||
record('continuity: the data is untouched across bar → it → line', (() => {
|
||
const rows = [{ label: '9/1', applications: 4, screened: 2, interviews: 1, hires: 1 }];
|
||
const read = (tree) => seriesE.readSeries(tree.find((n) => n.id === 'cc-activity').data,
|
||
{ context: { activitySeries: rows } });
|
||
const before = read(ccTree());
|
||
const asBar = read(barred());
|
||
const m = intentE.matchUiEdit('Change it back to a line chart',
|
||
{ tree: barred(), registry: nreg, page: 'control-center', focus: 'cc-activity' });
|
||
const asLine = read(opsE.applyOperation(barred(), m.op, ectx).tree);
|
||
const same = JSON.stringify(before.rows) === JSON.stringify(asBar.rows)
|
||
&& JSON.stringify(asBar.rows) === JSON.stringify(asLine.rows);
|
||
return { pass: same && before.rows.length === 1 && before.rows[0].applications === 4,
|
||
detail: JSON.stringify(asLine.rows) };
|
||
})().pass);
|
||
|
||
/* ── B. The control survives the replacement ──────────────────────── */
|
||
|
||
record('control: the reading publishes the range, not the component', (() => {
|
||
const entry = seriesE.seriesFor('control-center.hiring-activity');
|
||
const values = (entry?.control?.options || []).map((o) => o.value);
|
||
return { pass: JSON.stringify(values) === JSON.stringify(['7d', '30d', '90d']),
|
||
detail: values.join(', ') || 'none' };
|
||
})().pass);
|
||
|
||
for (const type of ['bar-chart', 'line-chart', 'area-chart']) {
|
||
record(`control: 7D/30D/90D is still there after becoming a ${type}`, (() => {
|
||
const tree = opsE.applyOperation(ccTree(),
|
||
{ op: 'replace', target: 'cc-activity', type }, ectx).tree;
|
||
const node = tree.find((n) => n.id === 'cc-activity');
|
||
const control = seriesE.controlOfBinding(node.data, { range: '30d', setRange: () => {} });
|
||
return { pass: Boolean(control) && control.value === '30d'
|
||
&& control.options.length === 3 && control.label.length > 0,
|
||
detail: control ? `${control.label} · ${control.options.map((o) => o.label).join('/')}` : 'lost' };
|
||
})().pass);
|
||
}
|
||
|
||
record('control: choosing a range calls the page back, and nothing else', (() => {
|
||
const seen = [];
|
||
const control = seriesE.controlOfBinding({ series: 'control-center.hiring-activity' },
|
||
{ range: '7d', setRange: (v) => seen.push(v) });
|
||
control.set('90d');
|
||
return { pass: seen.length === 1 && seen[0] === '90d', detail: seen.join(',') };
|
||
})().pass);
|
||
|
||
record('control: none is invented when the page publishes none', (() => {
|
||
const control = seriesE.controlOfBinding({ series: 'control-center.hiring-activity' }, {});
|
||
return { pass: control === null, detail: JSON.stringify(control) };
|
||
})().pass);
|
||
|
||
record('control: a reading that declares none has none', (() => {
|
||
const control = seriesE.controlOfBinding({ source: 'candidates.activity' }, { range: '7d' });
|
||
return { pass: control === null, detail: JSON.stringify(control) };
|
||
})().pass);
|
||
|
||
record('control: a control with no option is refused at registration', (() => {
|
||
try {
|
||
seriesE.registerSeries({ id: 'x.control', kind: 'parts', measures: [{ key: 'a', label: 'A' }],
|
||
read: () => [], control: { id: 'r', options: [], read: () => null, write: () => {} } });
|
||
return false;
|
||
} catch (e) { return /at least one option/i.test(e.message); }
|
||
})());
|
||
|
||
record('control: a control with no writer is refused at registration', (() => {
|
||
try {
|
||
seriesE.registerSeries({ id: 'x.control2', kind: 'parts', measures: [{ key: 'a', label: 'A' }],
|
||
read: () => [], control: { id: 'r', options: [{ value: 'a', label: 'A' }], read: () => null } });
|
||
return false;
|
||
} catch (e) { return /needs a `read` and a `write`/i.test(e.message); }
|
||
})());
|
||
|
||
record('replace: identity, binding, origin and visibility all survive', (() => {
|
||
const before = ccTree().find((n) => n.id === 'cc-activity');
|
||
const after = barred().find((n) => n.id === 'cc-activity');
|
||
return { pass: after.id === before.id && after.origin === before.origin
|
||
&& Boolean(after.hidden) === Boolean(before.hidden)
|
||
&& JSON.stringify(after.data) === JSON.stringify(before.data),
|
||
detail: JSON.stringify({ id: after.id, origin: after.origin, data: after.data }) };
|
||
})().pass);
|
||
|
||
/* ── C. Asking for alternatives, in the words people use ──────────── */
|
||
|
||
for (const phrase of [
|
||
"I don't like this design. Show me other options.",
|
||
'Can you give me another visualization?',
|
||
'What else can I use?',
|
||
'Give me some alternatives.',
|
||
'I want a different chart.',
|
||
]) {
|
||
record(`alternatives: "${phrase.slice(0, 40)}…" is answered without a model`, (() => {
|
||
/* `matchUiEdit` is a pure function of the tree and the registry: there
|
||
is no provider to reach and nothing to await, so an answer here is
|
||
an answer with zero model calls by construction. */
|
||
const m = ask(phrase, { focus: 'cc-activity' });
|
||
const types = (m?.options || []).map((o) => o.type);
|
||
return { pass: m?.kind === 'options' && types.length > 0
|
||
&& types.every((t) => Boolean(nreg.get(t))),
|
||
detail: `${m?.kind}: ${types.join(', ') || '—'}` };
|
||
})().pass);
|
||
}
|
||
|
||
record('alternatives: the answer is the registry\u2019s, and excludes the untrue', (() => {
|
||
const m = ask('Show me other options.', { focus: 'cc-activity' });
|
||
const types = (m?.options || []).map((o) => o.type);
|
||
return { pass: types.includes('bar-chart') && types.includes('area-chart')
|
||
&& !types.includes('pie-chart') && !types.includes('donut-chart'),
|
||
detail: types.join(', ') };
|
||
})().pass);
|
||
|
||
record('alternatives: every one offered is one replace would accept', (() => {
|
||
const m = ask('I want a different chart.', { focus: 'cc-activity' });
|
||
const bad = (m?.options || []).filter((o) => (
|
||
!opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type: o.type }, ectx).ok
|
||
));
|
||
return { pass: (m?.options || []).length > 0 && bad.length === 0,
|
||
detail: bad.map((o) => o.type).join(', ') || 'none' };
|
||
})().pass);
|
||
|
||
record('alternatives: an ordinary question is still left alone', (() => {
|
||
const left = [
|
||
'What are my options for this position?',
|
||
'What other candidates applied?',
|
||
'Give me an alternative candidate',
|
||
'Show me another worker for Friday',
|
||
'Is there a different applicant for this shift?',
|
||
].filter((q) => ask(q, { focus: 'cc-activity' }) !== null);
|
||
return { pass: left.length === 0, detail: left.join(' | ') || 'none taken' };
|
||
})().pass);
|
||
|
||
/* ── D. Meaning that was never established ────────────────────────── */
|
||
|
||
record('meaning: a reading that declares one is believed', (() => {
|
||
const declared = ['position.activity', 'candidates.activity', 'position.pipeline',
|
||
'candidates.pipeline', 'activity.breakdown']
|
||
.map((id) => [id, seriesE.kindOfBinding({ source: id })]);
|
||
const bad = declared.filter(([, kind]) => !nodeE.SERIES_KINDS.includes(kind));
|
||
return { pass: bad.length === 0, detail: declared.map(([i, k]) => `${i}=${k}`).join(' ') };
|
||
})().pass);
|
||
|
||
record('meaning: every declared kind is one of the closed three', (() => {
|
||
const bad = surfaces.DATA_SOURCES
|
||
.filter((source) => source.series != null)
|
||
.filter((source) => !nodeE.SERIES_KINDS.includes(source.series));
|
||
return { pass: bad.length === 0, detail: bad.map((s) => `${s.id}=${s.series}`).join(', ') || 'all known' };
|
||
})().pass);
|
||
|
||
record('meaning: a reading that declares none is refused rather than guessed', (() => {
|
||
const node = nodeE.makeNode({ id: 'viz', type: 'card', origin: 'user',
|
||
data: { source: 'hires.performance' } });
|
||
const m = intentE.matchUiEdit('Change the Hiring performance card to a pie chart',
|
||
{ tree: [node], registry: nreg, page: 'analytics' });
|
||
return { pass: m?.kind === 'refused' && /does not say what its figures mean/i.test(m.message),
|
||
detail: `${m?.kind}: ${m?.message}` };
|
||
})().pass);
|
||
|
||
record('meaning: nor is it offered in the first place', (() => {
|
||
const node = nodeE.makeNode({ id: 'viz', type: 'card', origin: 'user',
|
||
data: { source: 'hires.performance' } });
|
||
const m = intentE.matchUiEdit('Show me other options for the card',
|
||
{ tree: [node], registry: nreg, page: 'analytics', focus: 'viz' });
|
||
const types = (m?.options || []).map((o) => o.type);
|
||
const charts = types.filter((t) => nreg.get(t).seriesKinds.length > 0);
|
||
return { pass: charts.length === 0, detail: types.join(', ') || 'nothing offered' };
|
||
})().pass);
|
||
|
||
record('meaning: a stored patch that never established one is refused on load', (() => {
|
||
const tree = [nodeE.makeNode({ id: 'viz', type: 'bar-chart', origin: 'user',
|
||
data: { source: 'hires.performance' } })];
|
||
const verdict = validateE.validateTree(tree, ectx);
|
||
return { pass: !verdict.ok, detail: JSON.stringify(verdict.problems).slice(0, 170) };
|
||
})().pass);
|
||
|
||
record('meaning: components that claim no meaning are untouched by the rule', (() => {
|
||
/* The Card → Table path, which says nothing about what its rows mean
|
||
and must keep working exactly as it did. */
|
||
const node = nodeE.makeNode({ id: 'viz', type: 'card', origin: 'user',
|
||
props: { title: 'Recent hires' }, data: { source: 'hires.recent' } });
|
||
const r = opsE.applyOperation([node], { op: 'replace', target: 'viz', type: 'table' }, ectx);
|
||
const after = r.ok ? nodeE.findNode(r.tree, 'viz') : null;
|
||
return { pass: r.ok && after.type === 'table' && after.data.source === 'hires.recent'
|
||
&& after.props.title === 'Recent hires',
|
||
detail: JSON.stringify(r.problems).slice(0, 150) };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── Asking for the alternatives ──────────────────────────────────────
|
||
*
|
||
* The request that most invites a model to invent something. Every answer
|
||
* here is the registry's, and it is the same list the replace path checks
|
||
* a single name against.
|
||
*/
|
||
{
|
||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||
const ask = (q) => intentE.matchUiEdit(q, { tree: ccTree(), registry: nreg, page: 'control-center' });
|
||
|
||
for (const phrase of [
|
||
'Show me other options for the Hiring activity',
|
||
'I do not like this design, show me other designs for Hiring activity',
|
||
'What else could the Hiring activity be',
|
||
'Give me alternatives for the Hiring activity',
|
||
]) {
|
||
record(`options: "${phrase.slice(0, 44)}…" is answered from the registry`, (() => {
|
||
const m = ask(phrase);
|
||
const types = (m?.options || []).map((o) => o.type);
|
||
return { pass: m?.kind === 'options' && types.length > 0
|
||
&& types.every((t) => Boolean(nreg.get(t)))
|
||
&& !types.includes('pie-chart'),
|
||
detail: `${m?.kind}: ${types.join(', ')}` };
|
||
})().pass);
|
||
}
|
||
|
||
record('options: at most four are offered', (() => {
|
||
const m = ask('Show me other options for the Hiring activity');
|
||
return { pass: (m?.options || []).length <= 4, detail: String(m?.options?.length) };
|
||
})().pass);
|
||
|
||
record('options: every option is one replace would accept', (() => {
|
||
const m = ask('Show me other options for the Hiring activity');
|
||
const bad = (m?.options || []).filter((o) => (
|
||
!opsE.applyOperation(ccTree(), { op: 'replace', target: 'cc-activity', type: o.type }, ectx).ok
|
||
));
|
||
return { pass: bad.length === 0, detail: bad.map((o) => o.type).join(', ') || 'none' };
|
||
})().pass);
|
||
|
||
record('options: an ordinary question about options is left alone', (() => {
|
||
const left = ['What are my options for this position?', 'Show me the candidates',
|
||
'What other candidates applied?', 'Give me an alternative candidate']
|
||
.filter((q) => ask(q) !== null);
|
||
return { pass: left.length === 0, detail: left.join(' | ') || 'none taken' };
|
||
})().pass);
|
||
|
||
record('options: a refusal offers the alternatives', (() => {
|
||
const m = ask('Change Hiring activity to a pie chart');
|
||
const labels = (m?.alternatives || []).map((a) => a.type);
|
||
return { pass: m?.kind === 'refused' && /ordered in time/i.test(m.message)
|
||
&& labels.includes('bar-chart') && !labels.includes('pie-chart'),
|
||
detail: `${m?.message} → ${labels.join(', ')}` };
|
||
})().pass);
|
||
}
|
||
|
||
/* ── Safety ───────────────────────────────────────────────────────────
|
||
*
|
||
* Nothing a person can type produces markup, a class name or a component.
|
||
*/
|
||
{
|
||
const ccTree = () => compE.composePage('control-center', ectx).tree;
|
||
const ask = (q) => intentE.matchUiEdit(q, { tree: ccTree(), registry: nreg, page: 'control-center' });
|
||
|
||
record('safety: an invented visualization type is refused, not improvised', (() => {
|
||
const m = ask('Turn the Hiring activity into a neon 3D holographic chart');
|
||
const planned = m?.kind === 'plan';
|
||
return { pass: !planned, detail: `${m?.kind}` };
|
||
})().pass);
|
||
|
||
record('safety: an unregistered type never reaches the tree', (() => {
|
||
const r = opsE.applyOperation(ccTree(),
|
||
{ op: 'replace', target: 'cc-activity', type: 'holograph' }, ectx);
|
||
return { pass: !r.ok && /Unsupported UI type/i.test(JSON.stringify(r.problems)),
|
||
detail: JSON.stringify(r.problems).slice(0, 120) };
|
||
})().pass);
|
||
|
||
record('safety: a request for custom CSS produces no operation', (() => {
|
||
const bad = ['Give this card some custom CSS', 'Add class bg-red-500 to the Hiring activity',
|
||
'Set the style of the Hiring activity to display:none',
|
||
'Inject <script>alert(1)</script> into the Hiring activity']
|
||
.filter((q) => ask(q)?.kind === 'plan');
|
||
return { pass: bad.length === 0, detail: bad.join(' | ') || 'none planned' };
|
||
})().pass);
|
||
|
||
record('safety: a class name in props is dropped by the schema', (() => {
|
||
const r = opsE.applyOperation(
|
||
[seriesNode('bar-chart', 'hires.performance')],
|
||
{ op: 'update', target: 'viz', props: { className: 'bg-red-500', style: 'color:red' } },
|
||
ectx
|
||
);
|
||
const props = r.ok ? nodeE.findNode(r.tree, 'viz').props : null;
|
||
return { pass: !r.ok || (!('className' in props) && !('style' in props)),
|
||
detail: JSON.stringify(props) };
|
||
})().pass);
|
||
|
||
record('safety: an ambiguous request asks rather than guesses', (() => {
|
||
const m = ask('Change this');
|
||
return { pass: m === null || m.kind === 'ambiguous' || m.kind === 'unknown'
|
||
|| m.kind === 'unknown-type' || m.kind === 'refused',
|
||
detail: String(m?.kind) };
|
||
})().pass);
|
||
}
|
||
|
||
if (!hadWindow) delete globalThis.window;
|
||
}
|
||
}
|
||
|
||
await server.close();
|
||
|
||
/* ── 8. Production bundle ─────────────────────────────────────────────────── */
|
||
if (process.argv.includes('--dist')) {
|
||
console.log('\n── Production bundle ──');
|
||
const dir = join(ROOT, 'dist/assets');
|
||
if (!existsSync(dir)) {
|
||
record('dist/assets exists', false, 'run `npm run build` first');
|
||
} else {
|
||
const bundle = readdirSync(dir)
|
||
.filter((f) => f.endsWith('.js'))
|
||
.map((f) => readFileSync(join(dir, f), 'utf8'))
|
||
.join('\n');
|
||
|
||
for (const s of reg.SKILLS) {
|
||
record(`bundle carries \`${s.id}\``, bundle.includes(s.id));
|
||
}
|
||
/* Body content, not just the id — a manifest naming a skill whose Markdown
|
||
did not survive is the failure this is looking for. */
|
||
record(
|
||
'bundle carries skill body text',
|
||
bundle.includes('Which client is this role for'),
|
||
'create-position conversation step found in bundle'
|
||
);
|
||
}
|
||
}
|
||
|
||
/* ── Summary ──────────────────────────────────────────────────────────────── */
|
||
const failed = results.filter((r) => !r.pass);
|
||
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
|
||
if (failed.length) {
|
||
console.log('\nFailed:');
|
||
failed.forEach((r) => console.log(` - ${r.name}${r.detail ? ` (${r.detail})` : ''}`));
|
||
process.exit(1);
|
||
}
|