openpencil/scripts/smoke-v1-pipeline.ts
Kayshen-X a4b7f62e9a Merge feat/rust-ification into v0.8.0 (Step 0 Rust workspace bootstrap)
Step 0 of OP Rust-ification (per kickoff spec v7 FROZEN):
- Cargo workspace at root (members = ["crates/*"], glob)
- 9 skeleton crates: openpencil-app, openpencil-shell-{core,web,native},
  pen-{types,core,engine,codegen,figma}
- rust-toolchain.toml pinned 1.85 (forced from 1.80 → 1.82 → 1.85
  due to crates.io ecosystem edition2024 requirements)
- deny.toml with kickoff §1.2 wasm32 ban invariant
- 2 GitHub Actions: rust-check.yml (3-platform native + cargo-deny)
  and wasm-bundle-check.yml (wasm32 forward + reverse cargo-deny bans)
- vendor/agent submodule → github.com/ZSeven-W/agent-rs
- Bun script wrappers (cargo:check / :test / :wasm-check / :deny)
- README "Rust subsystem" section + Phase boundary note

§1.2 invariants live:
- Forward wasm32 check: shell-web + 5 bucket A crates compile
- Reverse cargo-deny check bans: native + wasm32 both clean
- compile_error guard: shell-native fails wasm32 build with explicit
  message, validated by canary

Step 1+ owns real implementation; Phase 0 docs (snapshot / plan
patches / IPC inventory / parley-taffy matrix / cargo-deny validation)
in openpencil-docs.
2026-05-04 21:00:00 +08:00

559 lines
25 KiB
TypeScript

/**
* Smoke test: v1 system mode + design system ripple
*
* Validates the full pipeline:
* createEmptyDocument
* → applySemanticPalette (seeds 56 tokens + Light/Dark theme axis)
* → buildHeadingV1/buildSettingRowV1/etc. with theme:'system' (emit $X refs)
* → resolveNodeForCanvas(doc, { Mode: 'Light' }) (resolve to light hex)
* → resolveNodeForCanvas(doc, { Mode: 'Dark' }) (resolve to dark hex)
*
* Run: bun scripts/smoke-v1-pipeline.ts
*
* Known gaps surfaced by this test (do NOT fix here — report to controller):
* GAP-1: resolveNodeForCanvas does not resolve `fontWeight` on text nodes
* (only fontSize/lineHeight/letterSpacing are in the key list at resolve.ts:281)
* GAP-2: resolveNodeForCanvas early-exits when `variables === {}` (line 217),
* so DEFAULT_PALETTE_FALLBACK is unreachable for un-seeded documents
*/
import {
createEmptyDocument,
applySemanticPalette,
resolveNodeForCanvas,
buildHeadingV1,
buildBodyTextV1,
buildSectionHeaderV1,
buildSettingRowV1,
} from '@zseven-w/pen-core';
import type { PenNode } from '@zseven-w/pen-types';
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
type Result = 'PASS' | 'FAIL' | 'KNOWN_GAP';
interface Assertion {
name: string;
result: Result;
detail?: string;
}
const assertions: Assertion[] = [];
function assert(name: string, condition: boolean, detail?: string): void {
assertions.push({ name, result: condition ? 'PASS' : 'FAIL', detail });
}
/** Document a known gap without failing the test. */
function knownGap(name: string, detail: string): void {
assertions.push({ name, result: 'KNOWN_GAP', detail });
}
/** Get all fill entries from the top-level `fill` array of a node (not recursive). */
function getTopFills(node: Record<string, unknown>): Array<Record<string, unknown>> {
const fills = node.fill as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(fills)) return [];
return fills;
}
/** Recursively find a named child by role. */
function findByRole(
node: Record<string, unknown>,
role: string,
): Record<string, unknown> | undefined {
if (node.role === role) return node;
const children = node.children as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(children)) return undefined;
for (const child of children) {
const found = findByRole(child, role);
if (found) return found;
}
return undefined;
}
/** Walk all string values in a node tree; return any that start with '$'. */
function collectRefs(node: Record<string, unknown>): string[] {
const refs: string[] = [];
for (const val of Object.values(node)) {
if (typeof val === 'string' && val.startsWith('$')) refs.push(val);
if (Array.isArray(val)) {
for (const item of val) {
if (item && typeof item === 'object')
refs.push(...collectRefs(item as Record<string, unknown>));
if (typeof item === 'string' && item.startsWith('$')) refs.push(item);
}
}
if (val && typeof val === 'object' && !Array.isArray(val)) {
refs.push(...collectRefs(val as Record<string, unknown>));
}
}
return refs;
}
function hasNoRefs(node: Record<string, unknown>): boolean {
return collectRefs(node).length === 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Build v1 system-mode nodes
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════════════════════════════');
console.log(' STEP 1: Build v1 nodes with theme:"system"');
console.log('══════════════════════════════════════════════════════');
const sectionHeader = buildSectionHeaderV1({
title: 'Notifications',
theme: 'system',
}) as unknown as Record<string, unknown>;
const heading = buildHeadingV1({
content: 'Account',
level: 'h2',
theme: 'system',
}) as unknown as Record<string, unknown>;
const bodyText = buildBodyTextV1({
content: 'Manage your account preferences',
theme: 'system',
}) as unknown as Record<string, unknown>;
// settingRow with switch (on:true) — no leading_icon so switch is the first child with a fill
const settingRow1 = buildSettingRowV1({
title: 'Email',
trailing: { kind: 'switch', on: true },
theme: 'system',
}) as unknown as Record<string, unknown>;
// settingRow with value trailing
const settingRow3 = buildSettingRowV1({
title: 'SMS',
trailing: { kind: 'value', value: 'Off' },
theme: 'system',
}) as unknown as Record<string, unknown>;
// settingRow with leading_icon (for breadth of testing)
const settingRow4 = buildSettingRowV1({
title: 'Marketing',
leading_icon: 'mail',
trailing: { kind: 'chevron' },
theme: 'system',
}) as unknown as Record<string, unknown>;
const settingRow2 = buildSettingRowV1({
title: 'Push Notifications',
trailing: { kind: 'switch', on: false },
theme: 'system',
}) as unknown as Record<string, unknown>;
const allV1Nodes = [
sectionHeader,
settingRow1,
settingRow2,
settingRow3,
settingRow4,
heading,
bodyText,
];
// Inspect raw v1 output using role-based search (precise, not depth-first first-fill)
const headingFill = getTopFills(heading);
const switchNode = findByRole(settingRow1, 'setting-row-switch');
const switchFill = switchNode ? getTopFills(switchNode) : [];
const valueFill = findByRole(settingRow3, 'setting-row-value');
console.log('\nRaw heading node:');
console.log(` fontSize: ${String(heading.fontSize)}`);
console.log(` fontWeight: ${String(heading.fontWeight)}`);
console.log(` lineHeight: ${String(heading.lineHeight)}`);
console.log(` fill: ${JSON.stringify(headingFill)}`);
console.log('\nRaw settingRow1 switch fill (role=setting-row-switch):');
console.log(` fill: ${JSON.stringify(switchFill)}`);
console.log('\nRaw settingRow3 trailing value fill (role=setting-row-value):');
console.log(` fill: ${JSON.stringify(getTopFills(valueFill ?? {}))}`);
// Collect all $refs across v1 nodes
const allRefs = allV1Nodes.flatMap((n) => collectRefs(n));
const uniqueRefs = [...new Set(allRefs)].sort();
console.log(`\nAll unique $refs found across v1 nodes (${uniqueRefs.length}):`);
for (const ref of uniqueRefs) console.log(` ${ref}`);
// ── Assertions: Step 1 ──────────────────────────────────────────────────────
assert(
'heading.fontSize is $type-h2-size ref (system mode)',
heading.fontSize === '$type-h2-size',
`got: ${String(heading.fontSize)}`,
);
assert(
'heading.fontWeight is $type-h2-weight ref (system mode)',
heading.fontWeight === '$type-h2-weight',
`got: ${String(heading.fontWeight)}`,
);
assert(
'heading.lineHeight is $type-h2-line-height ref (system mode)',
heading.lineHeight === '$type-h2-line-height',
`got: ${String(heading.lineHeight)}`,
);
const headingFillColor = headingFill[0]?.color as string | undefined;
assert(
'heading fill[0].color is $color-text-primary ref',
headingFillColor === '$color-text-primary',
`got: ${String(headingFillColor)}`,
);
const switchFillColor = switchFill[0]?.color as string | undefined;
assert(
'settingRow1 switch fill[0].color is $color-accent ref (on:true)',
switchFillColor === '$color-accent',
`got: ${String(switchFillColor)}`,
);
const valueFillColor = valueFill
? (getTopFills(valueFill)[0]?.color as string | undefined)
: undefined;
assert(
'settingRow3 trailing value fill[0].color is $color-text-muted ref',
valueFillColor === '$color-text-muted',
`got: ${String(valueFillColor)}`,
);
assert(
'v1 nodes contain multiple $refs (system mode active)',
allRefs.length > 5,
`found ${allRefs.length} total refs`,
);
assert(
'bodyText.fontSize is literal number 16 (body not yet using system typography tokens)',
typeof bodyText.fontSize === 'number' && bodyText.fontSize === 16,
`got: ${typeof bodyText.fontSize} ${String(bodyText.fontSize)}`,
);
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: Mount into doc + seed palette
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════════════════════════════');
console.log(' STEP 2: Mount nodes into doc + applySemanticPalette');
console.log('══════════════════════════════════════════════════════');
const rawDoc = createEmptyDocument();
const seededDoc = applySemanticPalette(rawDoc);
// Inject nodes into the active page's root frame children
const activePage = seededDoc.pages?.[0];
if (!activePage) throw new Error('No active page in document');
const rootFrame = activePage.children[0] as Record<string, unknown>;
rootFrame.children = allV1Nodes;
console.log(`\nDoc themes after applySemanticPalette: ${JSON.stringify(seededDoc.themes)}`);
console.log(`Doc variables count: ${Object.keys(seededDoc.variables ?? {}).length}`);
assert(
'doc has Mode theme axis after applySemanticPalette',
Array.isArray(seededDoc.themes?.Mode) &&
(seededDoc.themes?.Mode as string[]).includes('Light') &&
(seededDoc.themes?.Mode as string[]).includes('Dark'),
`got: ${JSON.stringify(seededDoc.themes)}`,
);
assert(
'doc has 56 semantic palette variables',
Object.keys(seededDoc.variables ?? {}).length === 56,
`got: ${Object.keys(seededDoc.variables ?? {}).length}`,
);
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: Resolve in Light theme
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════════════════════════════');
console.log(' STEP 3: Resolve in Light theme');
console.log('══════════════════════════════════════════════════════');
const vars = seededDoc.variables ?? {};
const lightTheme = { Mode: 'Light' };
const darkTheme = { Mode: 'Dark' };
const lightHeading = resolveNodeForCanvas(
heading as unknown as PenNode,
vars,
lightTheme,
) as unknown as Record<string, unknown>;
const lightRow1 = resolveNodeForCanvas(
settingRow1 as unknown as PenNode,
vars,
lightTheme,
) as unknown as Record<string, unknown>;
const lightHeadingFontSize = lightHeading.fontSize;
const lightHeadingFill = getTopFills(lightHeading)[0]?.color as string | undefined;
const lightSwitchNode = findByRole(lightRow1, 'setting-row-switch');
const lightSwitchFill = lightSwitchNode
? (getTopFills(lightSwitchNode)[0]?.color as string | undefined)
: undefined;
console.log(`\nLight-resolved heading.fontSize: ${String(lightHeadingFontSize)}`);
console.log(`Light-resolved heading.fontWeight: ${String(lightHeading.fontWeight)}`);
console.log(`Light-resolved heading.lineHeight: ${String(lightHeading.lineHeight)}`);
console.log(`Light-resolved heading fill[0].color: ${String(lightHeadingFill)}`);
console.log(`Light-resolved switch fill[0].color: ${String(lightSwitchFill)}`);
assert(
'Light: heading.fontSize resolves to number 20 ($type-h2-size)',
typeof lightHeadingFontSize === 'number' && lightHeadingFontSize === 20,
`got: ${typeof lightHeadingFontSize} ${String(lightHeadingFontSize)}`,
);
assert(
'Light: heading.lineHeight resolves to number 1.25 ($type-h2-line-height)',
typeof lightHeading.lineHeight === 'number' && lightHeading.lineHeight === 1.25,
`got: ${typeof lightHeading.lineHeight} ${String(lightHeading.lineHeight)}`,
);
assert(
'Light: heading fill[0].color resolves to #0F172A (light text-primary)',
lightHeadingFill === '#0F172A',
`got: ${String(lightHeadingFill)}`,
);
assert(
'Light: switch fill[0].color resolves to #2563EB (light accent)',
lightSwitchFill === '#2563EB',
`got: ${String(lightSwitchFill)}`,
);
// GAP-1 fixed (commit 52f9549b): fontWeight now in resolve.ts text-key list.
// Verify it stays fixed.
const fontWeightAfterLight = lightHeading.fontWeight;
assert(
'GAP-1 fixed: heading.fontWeight resolves to number (was $ref before 52f9549b)',
typeof fontWeightAfterLight === 'number' && fontWeightAfterLight === 600,
`got: ${String(fontWeightAfterLight)} (expected 600 for h2)`,
);
// The heading still has $type-h2-weight unresolved → refs remain
const lightHeadingRefs = collectRefs(lightHeading);
console.log(`\nRemaining $refs in light-resolved heading: ${JSON.stringify(lightHeadingRefs)}`);
assert(
'Light: fill and fontSize refs resolved (no $color-* or $type-h*-size refs remain)',
!lightHeadingRefs.some((r) => r.startsWith('$color-') || r === '$type-h2-size'),
`remaining: ${JSON.stringify(lightHeadingRefs)}`,
);
// ─────────────────────────────────────────────────────────────────────────────
// Step 4: Resolve in Dark theme
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════════════════════════════');
console.log(' STEP 4: Resolve in Dark theme');
console.log('══════════════════════════════════════════════════════');
const darkHeading = resolveNodeForCanvas(
heading as unknown as PenNode,
vars,
darkTheme,
) as unknown as Record<string, unknown>;
const darkRow1 = resolveNodeForCanvas(
settingRow1 as unknown as PenNode,
vars,
darkTheme,
) as unknown as Record<string, unknown>;
const darkHeadingFontSize = darkHeading.fontSize;
const darkHeadingFill = getTopFills(darkHeading)[0]?.color as string | undefined;
const darkSwitchNode = findByRole(darkRow1, 'setting-row-switch');
const darkSwitchFill = darkSwitchNode
? (getTopFills(darkSwitchNode)[0]?.color as string | undefined)
: undefined;
console.log(`\nDark-resolved heading.fontSize: ${String(darkHeadingFontSize)}`);
console.log(`Dark-resolved heading fill[0].color: ${String(darkHeadingFill)}`);
console.log(`Dark-resolved switch fill[0].color: ${String(darkSwitchFill)}`);
// Side-by-side comparison table
console.log('\n┌─────────────────────────────┬──────────────────┬──────────────────┐');
console.log('│ Field │ Light │ Dark │');
console.log('├─────────────────────────────┼──────────────────┼──────────────────┤');
console.log(
`│ heading.fontSize │ ${String(lightHeadingFontSize).padEnd(16)}${String(darkHeadingFontSize).padEnd(16)}`,
);
console.log(
`│ heading fill (text-primary) │ ${String(lightHeadingFill).padEnd(16)}${String(darkHeadingFill).padEnd(16)}`,
);
console.log(
`│ switch fill (accent) │ ${String(lightSwitchFill).padEnd(16)}${String(darkSwitchFill).padEnd(16)}`,
);
console.log('└─────────────────────────────┴──────────────────┴──────────────────┘');
assert(
'Dark: heading.fontSize resolves to number 20 (typography is theme-agnostic)',
typeof darkHeadingFontSize === 'number' && darkHeadingFontSize === 20,
`got: ${typeof darkHeadingFontSize} ${String(darkHeadingFontSize)}`,
);
assert(
'Dark: heading fill[0].color resolves to #F1F5F9 (dark text-primary)',
darkHeadingFill === '#F1F5F9',
`got: ${String(darkHeadingFill)}`,
);
assert(
'Dark: switch fill[0].color resolves to #60A5FA (dark accent)',
darkSwitchFill === '#60A5FA',
`got: ${String(darkSwitchFill)}`,
);
assert(
'Light vs Dark heading fill.color are DIFFERENT (color theme ripple works)',
lightHeadingFill !== darkHeadingFill,
`light=${String(lightHeadingFill)}, dark=${String(darkHeadingFill)}`,
);
assert(
'Light vs Dark heading.fontSize are SAME (numeric is theme-agnostic)',
lightHeadingFontSize === darkHeadingFontSize,
`light=${String(lightHeadingFontSize)}, dark=${String(darkHeadingFontSize)}`,
);
assert(
'Light vs Dark switch fill.color are DIFFERENT (accent ripple works)',
lightSwitchFill !== darkSwitchFill,
`light=${String(lightSwitchFill)}, dark=${String(darkSwitchFill)}`,
);
// ─────────────────────────────────────────────────────────────────────────────
// Step 5: Fallback path (un-seeded doc)
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════════════════════════════');
console.log(' STEP 5: Fallback path — un-seeded doc (no palette)');
console.log('══════════════════════════════════════════════════════');
// GAP-2: resolveNodeForCanvas exits early when variables === {} (line 217)
// The DEFAULT_PALETTE_FALLBACK is never reached. We verify this is the actual
// behavior and document it as a known gap.
const unseededDoc = createEmptyDocument();
const unseededVars = unseededDoc.variables ?? {};
const fallbackLightHeading = resolveNodeForCanvas(heading as unknown as PenNode, unseededVars, {
Mode: 'Light',
}) as unknown as Record<string, unknown>;
const fallbackLightFill = getTopFills(fallbackLightHeading)[0]?.color as string | undefined;
const fallbackLightSize = fallbackLightHeading.fontSize;
console.log(`\nUn-seeded vars count: ${Object.keys(unseededVars).length}`);
console.log(`Fallback Light heading fill[0].color: ${String(fallbackLightFill)}`);
console.log(`Fallback Light heading.fontSize: ${String(fallbackLightSize)}`);
console.log(`(Post-52f9549b: empty-vars doc reaches DEFAULT_PALETTE_FALLBACK)`);
// GAP-2 fixed (commit 52f9549b): empty vars no longer early-exits;
// DEFAULT_PALETTE_FALLBACK fires per spec §5.3 equivalence contract.
assert(
'GAP-2 fixed: empty-vars doc resolves color ref via DEFAULT_PALETTE_FALLBACK',
fallbackLightFill === '#0F172A',
`fill=${String(fallbackLightFill)} (expected #0F172A for $color-text-primary light)`,
);
assert(
'GAP-2 fixed: empty-vars doc resolves typography ref via DEFAULT_PALETTE_FALLBACK',
typeof fallbackLightSize === 'number' && fallbackLightSize === 20,
`fontSize=${String(fallbackLightSize)} (expected 20 for h2)`,
);
// Demonstrate that fallback DOES work when vars has at least one non-palette entry
// (proving the code path itself works — the guard is the only issue)
const artificialVars = { dummy: { type: 'number' as const, value: 1 } };
const artificialFallbackHeading = resolveNodeForCanvas(
heading as unknown as PenNode,
artificialVars,
{ Mode: 'Light' },
) as unknown as Record<string, unknown>;
const artificialFallbackFill = getTopFills(artificialFallbackHeading)[0]?.color as
| string
| undefined;
const artificialFallbackSize = artificialFallbackHeading.fontSize;
console.log(`\nArtificial non-empty vars (triggers fallback path):`);
console.log(` heading fill[0].color: ${String(artificialFallbackFill)}`);
console.log(` heading.fontSize: ${String(artificialFallbackSize)}`);
assert(
'DEFAULT_PALETTE_FALLBACK works when vars is non-empty (fill → #0F172A)',
artificialFallbackFill === '#0F172A',
`got: ${String(artificialFallbackFill)}`,
);
assert(
'DEFAULT_PALETTE_FALLBACK works when vars is non-empty (fontSize → 20)',
typeof artificialFallbackSize === 'number' && artificialFallbackSize === 20,
`got: ${typeof artificialFallbackSize} ${String(artificialFallbackSize)}`,
);
// Fallback comparison table
console.log('\n┌─────────────────────────────┬──────────────────┬──────────────────┐');
console.log('│ Field │ Seeded │ Artificial-FB │');
console.log('├─────────────────────────────┼──────────────────┼──────────────────┤');
console.log(
`│ Light heading fill.color │ ${String(lightHeadingFill).padEnd(16)}${String(artificialFallbackFill).padEnd(16)}`,
);
console.log(
`│ Light heading.fontSize │ ${String(lightHeadingFontSize).padEnd(16)}${String(artificialFallbackSize).padEnd(16)}`,
);
console.log('└─────────────────────────────┴──────────────────┴──────────────────┘');
assert(
'Seeded == Fallback for Light fill (fallback byte-equiv to seeded)',
lightHeadingFill === artificialFallbackFill,
`seeded=${String(lightHeadingFill)}, fallback=${String(artificialFallbackFill)}`,
);
assert(
'Seeded == Fallback for Light fontSize (fallback byte-equiv to seeded)',
lightHeadingFontSize === artificialFallbackSize,
`seeded=${String(lightHeadingFontSize)}, fallback=${String(artificialFallbackSize)}`,
);
// ─────────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n══════════════════════════════════════════════════════');
console.log(' ASSERTION SUMMARY');
console.log('══════════════════════════════════════════════════════');
let passed = 0;
let failed = 0;
let gaps = 0;
for (const a of assertions) {
let icon: string;
if (a.result === 'PASS') {
icon = '✓';
passed++;
} else if (a.result === 'FAIL') {
icon = '✗';
failed++;
} else {
icon = '⚠';
gaps++;
}
const detail = a.detail ? ` (${a.detail})` : '';
console.log(`${icon} [${a.result}] ${a.name}${detail}`);
}
console.log(
`\n${passed}/${assertions.length - gaps} assertions passed, ${failed} failed, ${gaps} known gap(s) documented`,
);
if (failed > 0) {
console.error('\n[SMOKE TEST FAILED] — see FAIL assertions above');
process.exit(1);
} else {
console.log('\n[SMOKE TEST PASSED] Full pipeline OK (known gaps documented above)');
}