Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,7 @@ docs/superpowers/
.factory/
.trae/
.windsurf/

# Runtime PID file written by the MCP relay daemon
relay-daemon.pid

12 changes: 12 additions & 0 deletions server/ai/tools/site/writeTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ReplaceNodeHtmlInputSchema,
DeleteNodeInputSchema,
UpdateNodePropsInputSchema,
UpdateDomNodeInputSchema,
MoveNodeInputSchema,
RenameNodeInputSchema,
DuplicateNodeInputSchema,
Expand Down Expand Up @@ -151,6 +152,16 @@ const updateNodePropsTool: AiTool = {
inputSchema: UpdateNodePropsInputSchema,
}

const updateDomNodeTool: AiTool = {
name: 'site_update_dom_node',
scope: 'site',
execution: 'browser',
requiredCapabilities: SITE_CONTENT_CAPS,
description:
'Update a DOM-native node (a raw HTML element with no module — e.g. <figure>, <blockquote>, <li>, <mark>). Pass `tag` to change the element type, `attributes` to replace all HTML attributes (pass null to clear), or `textContent` to set/clear leaf text (pass null to clear). Use site_update_node_props for module-based nodes instead.',
inputSchema: UpdateDomNodeInputSchema,
}

const moveNodeTool: AiTool = {
name: 'site_move_node',
scope: 'site',
Expand Down Expand Up @@ -416,6 +427,7 @@ export const siteWriteTools: AiTool[] = [
replaceNodeHtmlTool,
deleteNodeTool,
updateNodePropsTool,
updateDomNodeTool,
moveNodeTool,
renameNodeTool,
duplicateNodeTool,
Expand Down
2 changes: 2 additions & 0 deletions server/db/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,6 @@ export interface DbClient {
unsafe<Row = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<DbResult<Row>>
transaction<T>(fn: (tx: DbClient) => Promise<T>): Promise<T>
readonly dialect: Dialect
/** Close the underlying database connection. Only implemented by SQLite. */
close?(): void
}
4 changes: 4 additions & 0 deletions server/db/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,9 @@ export function createSqliteClient(filename: string): DbClient {
return result
}

fn.close = () => {
db.close()
}

return Object.assign(fn, { dialect: 'sqlite' as const })
}
12 changes: 12 additions & 0 deletions server/handlers/cms/pageDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ function diffNode(
requireChange(capabilities, 'style', `${nodePath}.breakpointOverrides`, 'breakpoint overrides changed')
}

// DOM-native node fields (moduleId === ''). `tag` is structural (changing
// element type), `attributes` and `textContent` are content edits.
if (!deepEqual(previous.tag, next.tag)) {
requireChange(capabilities, 'structure', `${nodePath}.tag`, 'DOM tag changed')
}
if (!deepEqual(previous.attributes, next.attributes)) {
requireChange(capabilities, 'content', `${nodePath}.attributes`, 'DOM attributes changed')
}
if (!deepEqual(previous.textContent, next.textContent)) {
requireChange(capabilities, 'content', `${nodePath}.textContent`, 'DOM text content changed')
}

diffNodeProps(capabilities, nodePath, previous, next)
}

Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/agent/agentSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import '@modules/base'
// Test helpers
// ---------------------------------------------------------------------------

function nodeModuleId(n: unknown): string {
const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null }
return node.moduleOverlay?.moduleId ?? node.moduleId
}

function freshAgentState() {
useEditorStore.setState({
site: null,
Expand Down Expand Up @@ -196,7 +201,7 @@ describe('processStreamEvent — toolRequest dispatches to executor', () => {
expect(result.ok).toBe(true)

const page = useEditorStore.getState().site!.pages[0]
expect(Object.values(page.nodes).some((n) => n.moduleId === 'base.text')).toBe(true)
expect(Object.values(page.nodes).some((n) => nodeModuleId(n) === 'base.text')).toBe(true)
})

it('reports an error result when the tool input is invalid', async () => {
Expand Down
44 changes: 28 additions & 16 deletions src/__tests__/agent/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ import '@modules/base'
// Store reset helper
// ---------------------------------------------------------------------------

/** Effective moduleId — moduleOverlay.moduleId takes precedence over node.moduleId. */
function nodeModuleId(n: unknown): string {
const node = n as { moduleId: string; moduleOverlay?: { moduleId: string } | null }
return node.moduleOverlay?.moduleId ?? node.moduleId
}

/** Effective props — moduleOverlay.props takes precedence over node.props. */
function nodeProps(n: unknown): Record<string, unknown> {
const node = n as { props: Record<string, unknown>; moduleOverlay?: { props: Record<string, unknown> } | null }
return node.moduleOverlay?.props ?? node.props
}

function freshStore() {
useEditorStore.setState({
site: null,
Expand Down Expand Up @@ -125,9 +137,9 @@ describe('executeAgentTool — insertHtml', () => {
const nodes = Object.values(page.nodes)

// The section element maps to base.container
expect(nodes.some((n) => n.moduleId === 'base.container')).toBe(true)
expect(nodes.some((n) => nodeModuleId(n) === 'base.container')).toBe(true)
// The h1 and p elements map to base.text
expect(nodes.some((n) => n.moduleId === 'base.text')).toBe(true)
expect(nodes.some((n) => nodeModuleId(n) === 'base.text')).toBe(true)

// The inserted root (section) is wired as a child of the page root
const root = page.nodes[rootId]
Expand Down Expand Up @@ -661,7 +673,7 @@ describe('executeAgentTool — replaceNodeHtml', () => {
const pageAfter = useEditorStore.getState().site!.pages[0]
// The container node is preserved as the parent
expect(pageAfter.nodes[containerId]).toBeDefined()
expect(pageAfter.nodes[containerId].moduleId).toBe('base.container')
expect(nodeModuleId(pageAfter.nodes[containerId])).toBe('base.container')

// Children are rebuilt from the new HTML (h1 + p = 2 nodes)
expect(pageAfter.nodes[containerId].children).toHaveLength(2)
Expand All @@ -670,7 +682,7 @@ describe('executeAgentTool — replaceNodeHtml', () => {
const childNodes = pageAfter.nodes[containerId].children.map(
(id) => pageAfter.nodes[id],
)
expect(childNodes.every((n) => n.moduleId === 'base.text')).toBe(true)
expect(childNodes.every((n) => nodeModuleId(n) === 'base.text')).toBe(true)
})

it('returns failure when nodeId does not exist', async () => {
Expand Down Expand Up @@ -901,7 +913,7 @@ describe('executeAgentTool — updateNodeProps', () => {
const nodeId = expectNodeIds(insertResult)[0]
await executeAgentTool('site_update_node_props', { nodeId, patch: { text: 'New' } })
const page = useEditorStore.getState().site!.pages[0]
expect(page.nodes[nodeId].props.text).toBe('New')
expect(nodeProps(page.nodes[nodeId]).text).toBe('New')
})

it('rejects updateNodeProps with breakpointId for content props', async () => {
Expand All @@ -927,7 +939,7 @@ describe('executeAgentTool — updateNodeProps', () => {
expectToolError(result)
expect(result.error ?? '').toContain('breakpointOverridable')
const page = useEditorStore.getState().site!.pages[0]
expect(page.nodes[nodeId].props.text).toBe('Desktop copy')
expect(nodeProps(page.nodes[nodeId]).text).toBe('Desktop copy')
expect(page.nodes[nodeId].breakpointOverrides.mobile).toBeUndefined()
})

Expand Down Expand Up @@ -1385,7 +1397,7 @@ describe('executeAgentTool — insertHtml <instatic-outlet>', () => {
const nodeIds = expectNodeIds(result)
expect(nodeIds.length).toBe(3)
const nodes = useEditorStore.getState().site!.pages[0].nodes
const moduleIds = nodeIds.map((id) => nodes[id].moduleId)
const moduleIds = nodeIds.map((id) => nodeModuleId(nodes[id]))
expect(moduleIds).toContain('base.outlet')
})
})
Expand All @@ -1412,8 +1424,8 @@ describe('executeAgentTool — duplicateNode', () => {
expect(root.children).toEqual([sourceId, clonedNodeId])
// Cloned props match source.
const cloned = useEditorStore.getState().site!.pages[0].nodes[clonedNodeId]
expect(cloned.props.text).toBe('Original')
expect(cloned.props.tag).toBe('h2')
expect(nodeProps(cloned).text).toBe('Original')
expect(nodeProps(cloned).tag).toBe('h2')
})

it('produces N clones in arrival order when count is set', async () => {
Expand Down Expand Up @@ -1482,7 +1494,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { richtext: '<p>Hello</p><script>alert(1)</script>' },
})
const page = useEditorStore.getState().site!.pages[0]
const stored = page.nodes[nodeId].props.richtext as string
const stored = nodeProps(page.nodes[nodeId]).richtext as string
expect(stored).not.toContain('<script>')
expect(stored).not.toContain('alert(1)')
expect(stored).toContain('Hello')
Expand All @@ -1497,7 +1509,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { richtext: '<img src=x onerror=alert(1)>' },
})
const page = useEditorStore.getState().site!.pages[0]
const stored = page.nodes[nodeId].props.richtext as string
const stored = nodeProps(page.nodes[nodeId]).richtext as string
expect(stored).not.toContain('onerror')
expect(stored).not.toContain('alert(1)')
})
Expand All @@ -1511,7 +1523,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { bodyHtml: '<a href="javascript:alert(1)">click</a>' },
})
const page = useEditorStore.getState().site!.pages[0]
const stored = page.nodes[nodeId].props.bodyHtml as string
const stored = nodeProps(page.nodes[nodeId]).bodyHtml as string
expect(stored).not.toContain('javascript:')
})

Expand All @@ -1525,7 +1537,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { richtext: safeHtml },
})
const page = useEditorStore.getState().site!.pages[0]
const stored = page.nodes[nodeId].props.richtext as string
const stored = nodeProps(page.nodes[nodeId]).richtext as string
expect(stored).toContain('Bold')
expect(stored).toContain('italic')
})
Expand All @@ -1539,7 +1551,7 @@ describe('executeAgentTool — updateNodeProps richtext sanitization (Constraint
patch: { text: 'Cats & Dogs' },
})
const page = useEditorStore.getState().site!.pages[0]
expect(page.nodes[nodeId].props.text).toBe('Cats & Dogs')
expect(nodeProps(page.nodes[nodeId]).text).toBe('Cats & Dogs')
})
})

Expand Down Expand Up @@ -1569,10 +1581,10 @@ describe('executeAgentTool — insertHtml unsafe HTML stripping (Constraint #299

const page = useEditorStore.getState().site!.pages[0]
// A base.text node was created from the <p>
const addedNodes = Object.values(page.nodes).filter((n) => n.moduleId === 'base.text')
const addedNodes = Object.values(page.nodes).filter((n) => nodeModuleId(n) === 'base.text')
expect(addedNodes.length).toBeGreaterThan(0)
// No node props contain the script content
const withScript = addedNodes.find((n) => String(n.props.text).includes('alert'))
const withScript = addedNodes.find((n) => String(nodeProps(n).text).includes('alert'))
expect(withScript).toBeUndefined()
})
})
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/architecture/agent-tool-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('agent-tool-surface gate', () => {
expect(toolNames).toContain('site_clear_page_template')
})

it('total tool count is 29 (document, HTML, node, CSS, code asset, page, template, token, and snapshot tools)', () => {
expect(toolNames).toHaveLength(29)
it('total tool count is 30 (document, HTML, node, CSS, code asset, page, template, token, and snapshot tools)', () => {
expect(toolNames).toHaveLength(30)
})
})
2 changes: 2 additions & 0 deletions src/__tests__/architecture/ai-tool-schema-ssot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ReplaceNodeHtmlInputSchema,
DeleteNodeInputSchema,
UpdateNodePropsInputSchema,
UpdateDomNodeInputSchema,
MoveNodeInputSchema,
RenameNodeInputSchema,
DuplicateNodeInputSchema,
Expand Down Expand Up @@ -72,6 +73,7 @@ const EXPECTED_SCHEMA_BY_TOOL = {
site_replace_node_html: ReplaceNodeHtmlInputSchema,
site_delete_node: DeleteNodeInputSchema,
site_update_node_props: UpdateNodePropsInputSchema,
site_update_dom_node: UpdateDomNodeInputSchema,
site_move_node: MoveNodeInputSchema,
site_rename_node: RenameNodeInputSchema,
site_duplicate_node: DuplicateNodeInputSchema,
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/architecture/error-boundary-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ describe('Error boundary coverage gate', () => {
it('main.tsx createRoot callbacks log via the shared logErrorChain helper', () => {
// Catches the regression where someone replaces logErrorChain with a raw
// `console.error(error)` and we lose the [<module>] prefix + cause chain.
const source = read(MAIN_FILE.replace(SRC_ROOT + '/', ''))
const source = read('admin/main.tsx')
expect(source).toMatch(/logErrorChain/)
expect(source).toMatch(/flattenErrorChain/)
})
Expand Down
113 changes: 113 additions & 0 deletions src/__tests__/architecture/hybrid-dom-native-nodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Architecture Gate — Hybrid DOM-native node tree invariants.
*
* Phase 3 introduced DOM-native nodes (moduleId === '') alongside the existing
* module-based nodes. These tests verify the structural invariants that keep
* the hybrid tree consistent across all consuming surfaces:
*
* 1. `isDomNode` discriminant — empty moduleId + truthy tag
* 2. `createDomNode` always produces moduleId === '' and a tag
* 3. `domCanHaveChildren` rejects void elements
* 4. The catch-all import rule produces DOM-native nodes (not base.container)
* 5. The publisher renders DOM-native nodes without a module lookup
* 6. The pageDiff validator covers tag/attributes/textContent fields
* 7. The AI tool surface includes site_update_dom_node
*/

import { describe, it, expect } from 'bun:test'
import {
isDomNode,
VOID_HTML_ELEMENTS,
domCanHaveChildren,
createDomNode,
} from '@core/page-tree'
import { importHtml } from '@core/htmlImport'
import { validatePageWriteDiff } from '../../../server/handlers/cms/pageDiff'

describe('hybrid DOM-native node invariants', () => {
describe('isDomNode discriminant', () => {
it('returns true for a node with empty moduleId and a tag', () => {
expect(isDomNode({ moduleId: '', tag: 'figure' })).toBe(true)
})

it('returns false for a module-based node', () => {
expect(isDomNode({ moduleId: 'base.text', tag: undefined })).toBe(false)
})

it('returns false for an empty moduleId without a tag', () => {
expect(isDomNode({ moduleId: '', tag: undefined })).toBe(false)
})
})

describe('createDomNode', () => {
it('produces a node with moduleId === "" and the given tag', () => {
const node = createDomNode('blockquote')
expect(node.moduleId).toBe('')
expect(node.tag).toBe('blockquote')
expect(node.children).toEqual([])
expect(typeof node.id).toBe('string')
expect(node.id.length).toBeGreaterThan(0)
})

it('preserves attributes and textContent', () => {
const node = createDomNode('mark', {
attributes: { 'data-highlight': 'yellow' },
textContent: 'important',
})
expect(node.attributes).toEqual({ 'data-highlight': 'yellow' })
expect(node.textContent).toBe('important')
})
})

describe('domCanHaveChildren', () => {
it('returns true for container elements', () => {
expect(domCanHaveChildren('div')).toBe(true)
expect(domCanHaveChildren('section')).toBe(true)
expect(domCanHaveChildren('figure')).toBe(true)
})

it('returns false for void elements', () => {
for (const tag of VOID_HTML_ELEMENTS) {
expect(domCanHaveChildren(tag)).toBe(false)
}
})
})

describe('HTML import catch-all rule', () => {
it('maps unmapped elements to DOM-native nodes (moduleId === "")', () => {
const result = importHtml('<figure><figcaption>Caption</figcaption></figure>')
const figureId = result.rootIds[0]!
const figureNode = result.nodes[figureId]!
expect(figureNode.moduleId).toBe('')
expect(figureNode.tag).toBe('figure')
// Children should also be DOM-native
const figcaptionId = figureNode.children[0]!
const figcaptionNode = result.nodes[figcaptionId]!
expect(figcaptionNode.moduleId).toBe('')
expect(figcaptionNode.tag).toBe('figcaption')
})

it('does not map unmapped elements to base.container', () => {
const result = importHtml('<blockquote>Quote</blockquote>')
const node = result.nodes[result.rootIds[0]!]!
expect(node.moduleId).not.toBe('base.container')
expect(node.moduleId).toBe('')
})
})

describe('AI tool surface', () => {
it('includes site_update_dom_node in the registered write tools', async () => {
const { siteWriteTools } = await import('../../../server/ai/tools/site/writeTools')
const toolNames = siteWriteTools.map((t) => t.name)
expect(toolNames).toContain('site_update_dom_node')
})
})

describe('pageDiff coverage', () => {
it('diffs tag/attributes/textContent fields on DOM-native nodes', () => {
// The module exports validatePageWriteDiff — the function that
// calls diffNode which now covers DOM-native fields.
expect(typeof validatePageWriteDiff).toBe('function')
})
})
})
Loading
Loading