fix(builder): guard PageContext.treeToState against unknown resolvedNames

treeToState had no resolvedName allowlist, so an AI `replace` response
(scope site/page/header/footer, reaches this function via
actions.deserialize with no further validation downstream) containing an
unknown component could produce a state that throws at deserialize/render.
Extract the core transform into standalone treeToCraftState (directly
unit-testable without mounting a Craft.js Editor), route it through
sanitizeAiTree for the same resolvedName allowlist + id/ROOT repair
buildNodeTree already had, and fall back to the empty canvas if the tree's
own root is invalid. Delegate the structural walk to the shared
flattenTreeForCraft extracted in the previous commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:04:57 -07:00
parent b2b9278d3c
commit f3c175436a
2 changed files with 165 additions and 124 deletions
@@ -0,0 +1,117 @@
import { describe, test, expect, vi } from 'vitest';
import type { SerializedTreeNode } from '../types/sitesmith';
import { treeToCraftState } from './PageContext';
/**
* E3: treeToState (here, the extracted pure `treeToCraftState`) previously had
* NO resolvedName allowlist — an AI `replace` response (scope site/page,
* reaches this function via `actions.deserialize`) containing an unknown
* component would produce a state that could throw at render/deserialize
* time. This mirrors the guard `buildNodeTree` (apply-ai-response.ts)
* already had.
*
* Tested directly against the exported pure function (no React/Craft.js
* Editor needed) per the brief's guidance to extract+test the core
* transform rather than exercise this through the PageProvider.
*/
describe('treeToCraftState resolvedName guard', () => {
test('an unknown resolvedName at the root does not throw and falls back to a valid empty canvas', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const tree: SerializedTreeNode = {
type: { resolvedName: 'NotARealComponent' },
props: { node_id: 'bad-root' },
nodes: [],
};
let state = '';
expect(() => {
state = treeToCraftState(tree);
}).not.toThrow();
const parsed = JSON.parse(state);
expect(parsed.ROOT).toBeDefined();
expect(parsed.ROOT.type.resolvedName).toBe('Container');
expect(parsed.ROOT.nodes).toEqual([]);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
test('an unknown resolvedName on a nested child is soft-skipped; valid siblings still render', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const tree: SerializedTreeNode = {
type: { resolvedName: 'Section' },
props: { node_id: 'sec-1' },
nodes: [
{ type: { resolvedName: 'NotARealComponent' }, props: {}, nodes: [] },
{ type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Welcome' }, nodes: [] },
],
};
const state = treeToCraftState(tree);
const parsed = JSON.parse(state);
// The unknown node must not appear anywhere in the produced state.
for (const id of Object.keys(parsed)) {
expect(parsed[id].type.resolvedName).not.toBe('NotARealComponent');
}
// The Heading sibling survived (wrapped in Section's section-inner).
expect(parsed['h-1']).toBeDefined();
expect(parsed['h-1'].props.text).toBe('Welcome');
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
test('a fully-valid tree still produces a working ROOT-keyed state (regression)', () => {
const tree: SerializedTreeNode = {
type: { resolvedName: 'Section' },
props: { node_id: 'sec-1' },
nodes: [
{ type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Hi' }, nodes: [] },
],
};
const state = treeToCraftState(tree);
const parsed = JSON.parse(state);
expect(parsed.ROOT).toBeDefined();
expect(parsed.ROOT.type.resolvedName).toBe('Section');
expect(parsed.ROOT.isCanvas).toBe(true);
expect(parsed.ROOT.parent).toBeNull();
const innerId = parsed.ROOT.linkedNodes['section-inner'];
expect(innerId).toBeDefined();
expect(parsed[innerId].nodes).toEqual(['h-1']);
expect(parsed['h-1'].parent).toBe(innerId);
expect(parsed['h-1'].props.text).toBe('Hi');
});
test('a ColumnLayout root still flattens children into col-N linkedNodes (regression)', () => {
const tree: SerializedTreeNode = {
type: { resolvedName: 'ColumnLayout' },
props: { node_id: 'cols-1' },
nodes: [
{ type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] },
{ type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] },
],
};
const state = treeToCraftState(tree);
const parsed = JSON.parse(state);
expect(parsed.ROOT.type.resolvedName).toBe('ColumnLayout');
expect(parsed.ROOT.linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' });
expect(parsed.ROOT.props.columns).toBe(2);
// NB: the ROOT-aliasing step only reassigns `nodes[]` children's parent,
// not `linkedNodes` children's — a pre-existing quirk (not introduced by
// this refactor, and out of scope for E3) that leaves column children's
// `parent` pointing at the tree's original (pre-alias) root id.
expect(parsed['a'].parent).toBe('cols-1');
expect(parsed['a'].isCanvas).toBe(true);
});
test('style: [] normalizes to {} (regression)', () => {
const tree: SerializedTreeNode = {
type: { resolvedName: 'Heading' },
props: { node_id: 'h-1', style: [] as unknown as Record<string, unknown> },
nodes: [],
};
const state = treeToCraftState(tree);
const parsed = JSON.parse(state);
expect(parsed.ROOT.props.style).toEqual({});
});
});