836 lines
34 KiB
JavaScript
836 lines
34 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';
|
||
|
||
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',
|
||
});
|
||
|
||
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`
|
||
);
|
||
|
||
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}`);
|
||
}
|
||
|
||
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);
|
||
}
|