* fix: validate parsed JSON at untrusted boundaries with Valibot Clipboard HTML, library revisions from shared storage, MCP and automation WebSocket messages, the MCP discovery file, sidecar output and AI/MCP tool arguments were JSON.parse'd and cast to their expected types, so a malformed payload reached the document or crashed paste. They now go through v.pipe(v.string(), v.parseJson(), Schema), which reports bad JSON and a wrong shape as the same validation failure. The path_set tool rejects an invalid VectorNetwork and shares its parser with create_vector. The CLI library catalog validates its files and runs revisions through the same size, identity and content-hash checks as the app; reading image bytes as index-keyed records also stops them coming back empty. Hand-rolled typeof readers for plugin data, document metadata, caches and preferences become schemas with their behaviour preserved, and readCacheJSON takes a schema for its payload. open-pencil/no-unvalidated-json-parse rejects type assertions on JSON.parse results other than `as unknown` in src and packages/*/src. * refactor: validate parsed JSON in tests and tooling Extend open-pencil/no-unvalidated-json-parse beyond source: tests, helpers and repo tooling now parse JSON through Valibot schemas instead of asserting a type. The shared fixture reader returns a validated object; its old array annotation never matched the fixtures. * fix: validate clipboard geometry bytes, library images and model catalogs Clipboard geometry blobs and library image bytes must be bytes at contiguous indexes, so out-of-range or gapped values are rejected instead of silently becoming different geometry or images; serialized library nodes must carry source metadata. The models.dev and OpenRouter responses are validated like their cached copies, and activate-tab rejects a CDP frame it cannot read instead of hanging. * refactor: extend the JSON validation lint to .json() results no-unvalidated-json-parse now also rejects type assertions on Response, Bun.file and shell .json() results, the same unchecked parse in another form. MCP server tests read /health through a validated readHealth helper and discovery files through parseDiscoveryInfo; the remaining tooling reads its JSON through schemas. * test: validate the RPC request body in the CLI app export test * test: validate CLI JSON output in the tool and app command tests * test: compare the malformed models.dev fallback with the curated list
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import { strict as assert } from 'node:assert'
|
|
import { readFileSync, renameSync } from 'node:fs'
|
|
|
|
import * as v from 'valibot'
|
|
|
|
import { invokeNative } from '#tests/helpers/tauri/invoke'
|
|
|
|
const HealthJSON = v.pipe(v.string(), v.parseJson(), v.object({ status: v.string() }))
|
|
|
|
const DiscoveryFileJSON = v.pipe(
|
|
v.string(),
|
|
v.parseJson(),
|
|
v.object({ httpPort: v.number(), authToken: v.string() })
|
|
)
|
|
|
|
const NOT_INSTALLED = 'MCP automation is not installed'
|
|
|
|
async function openMCPSettings(): Promise<void> {
|
|
// The platform shortcut is how the existing native settings spec opens Settings.
|
|
await browser.keys([process.platform === 'darwin' ? 'Meta' : 'Control', ','])
|
|
await browser.waitUntil(
|
|
() =>
|
|
browser.execute(() =>
|
|
Boolean(document.querySelector('[data-test-id="app-settings-dialog"]'))
|
|
),
|
|
{ timeout: 10_000, timeoutMsg: 'Settings did not open' }
|
|
)
|
|
await (await $('[data-test-id="settings-section-mcp"]')).click()
|
|
await browser.waitUntil(
|
|
() =>
|
|
browser.execute(() =>
|
|
Boolean(document.querySelector('[data-test-id="settings-mcp-automation-panel"]'))
|
|
),
|
|
{ timeout: 10_000, timeoutMsg: 'MCP settings panel did not render' }
|
|
)
|
|
}
|
|
|
|
function mcpStatus(): Promise<string | null> {
|
|
return browser.execute(
|
|
() =>
|
|
document
|
|
.querySelector<HTMLElement>(
|
|
'[data-test-id="settings-mcp-automation-panel"] [role="status"]'
|
|
)
|
|
?.textContent?.trim() ?? null
|
|
)
|
|
}
|
|
|
|
function mcpAlert(): Promise<string | null> {
|
|
return browser.execute(
|
|
() =>
|
|
document
|
|
.querySelector<HTMLElement>(
|
|
'[data-test-id="settings-mcp-failure"] [data-slot="alert-heading"]'
|
|
)
|
|
?.textContent?.trim() ?? null
|
|
)
|
|
}
|
|
|
|
/** Expand the failure disclosure and read the technical payload. */
|
|
function mcpFailureDetail(): Promise<string | null> {
|
|
return browser.executeAsync((done) => {
|
|
document
|
|
.querySelector<HTMLElement>('[data-test-id="settings-mcp-failure-details"] button')
|
|
?.click()
|
|
requestAnimationFrame(() => {
|
|
done(
|
|
document
|
|
.querySelector<HTMLElement>('[data-test-id="settings-mcp-failure-detail"]')
|
|
?.textContent?.trim() ?? null
|
|
)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Restart is offered by the healthy-state button and by the failure alert, so
|
|
* click whichever one is currently rendered.
|
|
*/
|
|
function clickRestart(): Promise<void> {
|
|
return browser.execute(() => {
|
|
const panel = document.querySelector<HTMLElement>(
|
|
'[data-test-id="settings-mcp-automation-panel"]'
|
|
)
|
|
for (const button of panel?.querySelectorAll<HTMLButtonElement>('button') ?? []) {
|
|
if (button.textContent?.includes('MCP server') && button.offsetParent !== null) {
|
|
button.click()
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async function waitForStatus(expected: string): Promise<void> {
|
|
try {
|
|
await browser.waitUntil(async () => (await mcpStatus()) === expected, { timeout: 30_000 })
|
|
} catch {
|
|
throw new Error(
|
|
`MCP status was "${await mcpStatus()}" instead of "${expected}"; ` +
|
|
`alert "${await mcpAlert()}"; detail "${await mcpFailureDetail()}"`
|
|
)
|
|
}
|
|
}
|
|
|
|
describe('native MCP server lifecycle', () => {
|
|
// Native specs share one app process, which remembers the open Settings
|
|
// section. Restore General before closing so the next spec finds it.
|
|
afterEach(async () => {
|
|
await browser.execute(() => {
|
|
document.querySelector<HTMLElement>('[data-test-id="settings-section-general"]')?.click()
|
|
document.querySelector<HTMLElement>('[data-test-id="app-settings-done"]')?.click()
|
|
})
|
|
await browser.waitUntil(
|
|
() => browser.execute(() => !document.querySelector('[data-test-id="app-settings-dialog"]')),
|
|
{ timeout: 10_000, timeoutMsg: 'Settings did not close after the MCP spec' }
|
|
)
|
|
})
|
|
|
|
it('uses the server it resolves, or reports why it cannot', async () => {
|
|
await browser.waitUntil(
|
|
async () => browser.execute(() => Boolean(window.openPencil?.getStore?.())),
|
|
{ timeout: 30_000, timeoutMsg: 'OpenPencil editor did not initialize' }
|
|
)
|
|
await openMCPSettings()
|
|
|
|
// The app resolves the binary through the PATH it derives from the login
|
|
// shell, so assert what it resolved rather than injecting a fixture.
|
|
const lookup = await invokeNative<{ available: boolean; path: string | null }>('mcp_lookup')
|
|
|
|
if (!lookup.available || !lookup.path) {
|
|
// Without a global install the reason must be the install itself.
|
|
await waitForStatus('Error')
|
|
assert.equal(await mcpAlert(), NOT_INSTALLED)
|
|
return
|
|
}
|
|
|
|
// A resolvable binary either becomes healthy, or is refused with a reason.
|
|
await browser.waitUntil(async () => (await mcpStatus()) !== 'Starting', { timeout: 30_000 })
|
|
if ((await mcpStatus()) === 'Error') {
|
|
const detail = await mcpFailureDetail()
|
|
assert.match(
|
|
detail ?? '',
|
|
/requires @open-pencil\/mcp|major\.minor compatibility/,
|
|
`resolved ${lookup.path} but failed without a version explanation: ${detail}`
|
|
)
|
|
return
|
|
}
|
|
|
|
await waitForStatus('Running')
|
|
const discoveryPath = process.env.OPENPENCIL_MCP_DISCOVERY_PATH
|
|
assert.ok(discoveryPath, 'discovery path missing from the wdio configuration')
|
|
const discovery = v.parse(DiscoveryFileJSON, readFileSync(discoveryPath, 'utf8'))
|
|
assert.ok(discovery.authToken, 'discovery file omitted the auth token')
|
|
|
|
// The spawned server serves the app: real port, real token.
|
|
const health = await fetch(`http://127.0.0.1:${discovery.httpPort}/health`, {
|
|
headers: { authorization: `Bearer ${discovery.authToken}` }
|
|
})
|
|
assert.equal(health.status, 200)
|
|
assert.equal(v.parse(HealthJSON, await health.text()).status, 'ok')
|
|
|
|
// Hiding the resolved binary must report the missing install, and restoring
|
|
// it must recover without relaunching the app.
|
|
const hidden = `${lookup.path}.native-test-hidden`
|
|
renameSync(lookup.path, hidden)
|
|
try {
|
|
await clickRestart()
|
|
await browser.waitUntil(async () => (await mcpAlert()) === NOT_INSTALLED, {
|
|
timeout: 30_000,
|
|
timeoutMsg: `MCP failure alert was: ${await mcpAlert()}`
|
|
})
|
|
} finally {
|
|
renameSync(hidden, lookup.path)
|
|
}
|
|
|
|
await clickRestart()
|
|
await waitForStatus('Running')
|
|
})
|
|
})
|