diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a2047e9..be7eea74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: build on: push: - branches: [main] + branches: [main, 'ci/**'] pull_request: branches: [main] @@ -20,6 +20,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -31,6 +33,12 @@ jobs: cache: 'pnpm' cache-dependency-path: pnpm-lock.yaml + - name: Check agent plugin version + if: matrix.node == '24.x' + env: + PLUGIN_VERSION_BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || startsWith(github.ref, 'refs/heads/ci/') && 'origin/main' || github.event.before }} + run: node scripts/check-agent-plugin-version.mjs + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -38,6 +46,7 @@ jobs: run: pnpm --filter @tempad-dev/extension test:setup - name: Check agent plugins + if: matrix.node == '24.x' run: >- pnpm agent-plugin:build && test -z "$(git status --porcelain --untracked-files=all -- @@ -77,7 +86,39 @@ jobs: run: pnpm format:check - name: Check bridge compatibility - run: pnpm mcp:check-bridge + run: node packages/mcp-server/scripts/check-bridge-compatibility.mjs + + - name: Check runtime profile isolation + run: node packages/mcp-server/scripts/check-runtime-profiles.mjs + + - name: Check CLI host context isolation + run: node packages/mcp-server/scripts/check-host-context.mjs + + - name: Check isolated runtime supervisor + run: pnpm mcp:check-runtime-prototype + + - name: Check local runtime switching and recovery + run: pnpm mcp:check-runtime-switch + + - name: Prepare dependency metadata for offline release packaging + run: pnpm mcp:prepare-release-dependencies + + - name: Check verified release downloads and explicit activation + env: + npm_config_logs_dir: ${{ runner.temp }}/tempad-npm-logs + run: pnpm mcp:check-runtime-updates + + - name: Preserve failure evidence + if: failure() + uses: actions/upload-artifact@v7 + with: + name: validation-failures-node-${{ matrix.node }} + path: | + packages/*/.vitest/attachments/** + ${{ runner.temp }}/tempad-npm-logs + include-hidden-files: true + if-no-files-found: ignore + retention-days: 14 typescript-compatibility: runs-on: ubuntu-latest diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index 9746448c..7494dbc3 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -15,16 +15,11 @@ on: permissions: contents: read - id-token: write jobs: - publish: - name: Publish @tempad-dev/mcp + guard: + name: Validate release branch runs-on: ubuntu-latest - concurrency: - group: publish-mcp - cancel-in-progress: true - steps: - name: Require main branch env: @@ -35,6 +30,22 @@ jobs: exit 1 fi + windows-runtime: + name: Validate Windows runtime + needs: guard + uses: ./.github/workflows/windows-runtime.yml + + publish: + name: Publish @tempad-dev/mcp + needs: [guard, windows-runtime] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + concurrency: + group: publish-mcp + cancel-in-progress: false + steps: - name: Checkout repository uses: actions/checkout@v7 @@ -55,16 +66,39 @@ jobs: - name: Build package run: pnpm build:mcp + - name: Prepare dependency metadata for offline release packaging + run: pnpm mcp:prepare-release-dependencies + - name: Validate package run: | pnpm --filter @tempad-dev/mcp... typecheck pnpm --filter @tempad-dev/mcp... test:run pnpm mcp:check-bridge pnpm --filter @tempad-dev/mcp check:logging + pnpm mcp:check-runtime-updates + pnpm mcp:check-runtime-switch + + - name: Prepare and verify the immutable release + run: pnpm mcp:prepare-release --output "$RUNNER_TEMP/tempad-mcp.tgz" --verify-baseline + + - name: Check the exact release archive + run: | + release_check_dir=$(mktemp -d "$RUNNER_TEMP/tempad-mcp-installed.XXXXXX") + trap 'rm -rf "$release_check_dir"' EXIT + tar -xzf "$RUNNER_TEMP/tempad-mcp.tgz" -C "$release_check_dir" --strip-components=1 + node packages/mcp-server/scripts/check-bridge-compatibility.mjs "$release_check_dir" + node packages/mcp-server/scripts/check-log-transport.mjs "$release_check_dir" + + - name: Archive the reviewed release and manifest + uses: actions/upload-artifact@v7 + with: + name: tempad-mcp-release + path: | + ${{ runner.temp }}/tempad-mcp.tgz + ${{ runner.temp }}/tempad-mcp.tgz.json - name: Publish - working-directory: packages/mcp-server - run: npm publish --access public --tag "${{ inputs.tag }}" + run: npm publish "$RUNNER_TEMP/tempad-mcp.tgz" --ignore-scripts --access public --tag "${{ inputs.tag }}" - name: Verify published version working-directory: packages/mcp-server @@ -72,10 +106,12 @@ jobs: NPM_DIST_TAG: ${{ inputs.tag }} run: | version=$(node -p "require('./package.json').version") + expected=$(node -p "require(process.env.RUNNER_TEMP + '/tempad-mcp.tgz.json').integrity") for attempt in {1..20}; do published=$(npm view "@tempad-dev/mcp@$version" version --prefer-online 2>/dev/null || true) tagged=$(npm view "@tempad-dev/mcp@$NPM_DIST_TAG" version --prefer-online 2>/dev/null || true) - if [[ "$published" == "$version" && "$tagged" == "$version" ]]; then + integrity=$(npm view "@tempad-dev/mcp@$version" dist.integrity --prefer-online 2>/dev/null || true) + if [[ "$published" == "$version" && "$tagged" == "$version" && "$integrity" == "$expected" ]]; then echo "Verified @tempad-dev/mcp@$version on $NPM_DIST_TAG" exit 0 fi diff --git a/.github/workflows/publish-plugins.yml b/.github/workflows/publish-plugins.yml index c3bb5d89..d5788317 100644 --- a/.github/workflows/publish-plugins.yml +++ b/.github/workflows/publish-plugins.yml @@ -5,16 +5,11 @@ on: permissions: contents: read - id-token: write jobs: - publish: - name: Publish @tempad-dev/plugins + guard: + name: Validate release branch runs-on: ubuntu-latest - concurrency: - group: publish-plugins - cancel-in-progress: true - steps: - name: Require main branch env: @@ -25,6 +20,17 @@ jobs: exit 1 fi + publish: + name: Publish @tempad-dev/plugins + needs: guard + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + concurrency: + group: publish-plugins + cancel-in-progress: false + steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/.github/workflows/windows-runtime.yml b/.github/workflows/windows-runtime.yml new file mode 100644 index 00000000..bb0c2c5a --- /dev/null +++ b/.github/workflows/windows-runtime.yml @@ -0,0 +1,127 @@ +name: Windows runtime + +on: + push: + branches: [main, 'ci/**'] + pull_request: + branches: [main] + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + runtime: + runs-on: windows-latest + timeout-minutes: 60 + strategy: + fail-fast: false + max-parallel: 4 + matrix: + node: ['22.x', '24.x'] + suite: [contracts, prototype, switch, updates] + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + TEMPAD_RUNTIME_EVIDENCE_DIR: ${{ github.workspace }}/runtime-ci-logs/fixtures + steps: + - name: Preserve reviewed source bytes + run: git config --global core.autocrlf false + + - name: Share dependency metadata across install and offline packaging + run: '"pnpm_config_cache_dir=$env:RUNNER_TEMP/tempad-pnpm-cache" >> $env:GITHUB_ENV' + + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare dependency metadata for offline release packaging + if: matrix.suite == 'updates' + run: pnpm mcp:prepare-release-dependencies + + - name: Record runtime environment + env: + EXPECTED_NODE: ${{ matrix.node }} + run: | + New-Item -ItemType Directory runtime-ci-logs | Out-Null + node -p 'JSON.stringify({platform:process.platform,arch:process.arch,node:process.version,execPath:process.execPath})' | Tee-Object runtime-ci-logs/environment.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm --version | Tee-Object -Append runtime-ci-logs/environment.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $runtimePlatform = pnpm exec node -p process.platform + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $runtimeVersion = pnpm exec node -p process.versions.node + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "pnpm runtime: $runtimePlatform $runtimeVersion" | Tee-Object -Append runtime-ci-logs/environment.log + if ($runtimePlatform -ne 'win32' -or $runtimeVersion.Split('.')[0] -ne $env:EXPECTED_NODE.Split('.')[0]) { + throw 'Unexpected test runtime' + } + $metadataCache = pnpm config get cache-dir + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + "pnpm metadata cache: $metadataCache" | Tee-Object -Append runtime-ci-logs/environment.log + $metadataFiles = @(Get-ChildItem -LiteralPath $metadataCache -Recurse -File -Filter '*.jsonl' | Where-Object { $_.DirectoryName -match '[\\/]metadata-full[\\/]' }) + "Full metadata files: $($metadataFiles.Count)" | Tee-Object -Append runtime-ci-logs/environment.log + $metadataFiles | Where-Object { $_.Name -in @('body-parser.jsonl', 'es-object-atoms.jsonl') } | ForEach-Object { $_.FullName } | Tee-Object -Append runtime-ci-logs/environment.log + + - name: Build runtime packages + run: | + pnpm --filter @tempad-dev/shared build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm build:mcp + + - name: Test runtime contracts and platform boundaries + if: matrix.suite == 'contracts' + shell: cmd + run: | + call pnpm --filter @tempad-dev/mcp exec vitest run --config vitest.config.ts tests/runtime- tests/stable-edge.test.ts --maxWorkers=2 --testTimeout=30000 --hookTimeout=30000 > runtime-ci-logs\unit.log 2>&1 + set "test_exit=%errorlevel%" + type runtime-ci-logs\unit.log + exit /b %test_exit% + + - name: Check supervisor recovery and concurrent edges + if: matrix.suite == 'prototype' + shell: cmd + run: | + call pnpm mcp:check-runtime-prototype > runtime-ci-logs\prototype.log 2>&1 + set "test_exit=%errorlevel%" + type runtime-ci-logs\prototype.log + exit /b %test_exit% + + - name: Check switching, in-flight requests, crashes and rollback + if: matrix.suite == 'switch' + shell: cmd + run: | + call pnpm mcp:check-runtime-switch > runtime-ci-logs\switch.log 2>&1 + set "test_exit=%errorlevel%" + type runtime-ci-logs\switch.log + exit /b %test_exit% + + - name: Check verified downloads and automatic activation + if: matrix.suite == 'updates' + shell: cmd + run: | + call pnpm mcp:check-runtime-updates > runtime-ci-logs\updates.log 2>&1 + set "test_exit=%errorlevel%" + type runtime-ci-logs\updates.log + exit /b %test_exit% + + - name: Preserve runtime evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: windows-runtime-node-${{ matrix.node }}-${{ matrix.suite }} + path: runtime-ci-logs + retention-days: 14 + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index f52463d0..c161e126 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,10 @@ default authority unless a routed document says otherwise. generated output, and include it in the change. `agent-plugin/targets/` is marked as generated for review; read `agent-plugin/src/` instead. Ordinary `pnpm build` must not modify agent-plugin artifacts. +- Installed payload changes (including skills, hooks, manifests, and assets) require a stable + version increase in `agent-plugin/src/plugin.json` and regenerated targets. Package-root + README and changelog edits alone are exempt. Before pushing, check committed changes with + `pnpm agent-plugin:check-version origin/main` against the intended release baseline. - Keep Codex and Claude development support equivalent. Both manifests must launch the same working-tree MCP runtime. - Release MCP configuration must use `@tempad-dev/mcp@latest`, never an alpha @@ -148,6 +152,7 @@ check is sufficient. | ------------------------------------------------------- | --------------------------------------------------------------- | | Test selection, required checks, or troubleshooting | `TESTING.md` | | Test runtime or coverage architecture | `docs/testing/architecture.md` | +| Release preparation or compatibility retirement | `docs/releasing.md` | | Codex desktop IPC discovery, lifecycle, or feedback | `docs/engineering/codex-desktop-ipc.md` | | End-to-end authoring evolution or live agent evaluation | `docs/testing/agent-authoring-evolution.md` | | Extension implementation or MCP behavior | `packages/extension/AGENTS.md`, then its routed design document | diff --git a/README.md b/README.md index 4392d079..8acbda53 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Both workflows use the same MCP connection to Figma. Compatible clients can inst ### Setup guide -1. Install Node.js 22.x, 24.x, or 26+ with `npx`. Keep TemPad Dev open in the Figma tab you want the agent to inspect, then enable **Preferences → Agent integration → MCP access**. When prompted, allow the loopback connection to `127.0.0.1`. Canvas authoring is available while MCP access is enabled and the current Figma Design file is editable. +1. Install Node.js 22.x, 24.x, or 26+ and make sure `npx` is available. Keep TemPad Dev open in the Figma tab you want the agent to inspect, then enable **Preferences → Agent integration → MCP access**. When prompted, allow the loopback connection to `127.0.0.1`. Canvas authoring is available while MCP access is enabled and the current Figma Design file is editable. 2. Select **Set up agents**, choose your client, and follow the displayed path. Dedicated entries include Qwen Code, Qoder, ZCode, Kimi Code, Pi, and Hermes Agent alongside the existing IDE and CLI integrations. Use **Other agents** for another compatible client. The choice only changes the instructions shown; it does not bind or activate an agent. 3. The setup flow uses native marketplace installation for Codex and Claude Code, and the portable Agent Plugin for Cursor and VS Code. Codex App integration uses native IPC without lifecycle hooks. Other entries provide the client's MCP flow plus the two standalone skills, with configuration destinations and documentation links where needed. Pi requires version 0.99 or later with built-in MCP enabled. Every command or config is shown in full for review and copying. These setup paths do not imply native comment delivery or host interruption support for every client. @@ -104,10 +104,13 @@ For native Codex and Claude marketplace commands and portable Cursor and VS Code see the [Agent Plugin guide](./agent-plugin/src/README.md). All plugin and direct `npx`-based setup paths use `@tempad-dev/mcp@latest`. +Verified MCP releases from 1.0.0 enable background downloads and compatible Hub updates during +idle MCP sessions. Agent Plugin 1.0.0 and the setup commands select this policy explicitly. +Existing clients take it up after restarting onto the new release. See +[runtime update controls](./packages/mcp-server/README.md#runtime-updates-100) for limits and opt-out. -For this release, use extension **0.23.4**, MCP server **0.11.0**, and Agent -Plugin **0.5.1** together. See the [upgrade guide](./agent-plugin/src/README.md#upgrading) -when updating an existing installation. +See the [upgrade guide](./agent-plugin/src/README.md#upgrading) for minimum versions +and instructions for updating an existing installation. Keep TemPad Dev open with MCP enabled while using it. If multiple Figma files are connected, click the MCP badge in the panel for the file you want the agent to inspect; that file becomes the active context. An agent can also list connected sessions and pass an exact `sessionId` to an independent read without changing the active badge. @@ -123,7 +126,7 @@ When the MCP server is enabled, a badge appears in the TemPad Dev panel title ba MCP status badge showing Unavailable. -- **Inactive**: TemPad Dev is connected to a local MCP server, but this tab is not currently active because multiple Figma tabs are open. Click the badge to activate MCP for this tab (this deactivates MCP in other tabs). +- **Inactive**: TemPad Dev is connected to the local MCP server, but this tab is not the default file. Click its badge to make it the default. Other connected files remain available for reads addressed to their exact session. @@ -174,7 +177,7 @@ for exact continuation. A single `nodeId` remains supported. page, or one or more visible nodes in the current selection. - `upload_asset`: Stores a generated PNG/JPEG/GIF in the local Hub and returns an `assetHash` for canvas authoring. -- Binary assets are returned as metadata + HTTP download URLs (`asset.url`) in tool responses. Asset MCP resources are not exposed. +- Binary assets are returned as metadata with temporary HTTP download URLs (`asset.url`) and, when available, local file paths (`asset.localPath`). Asset MCP resources are not exposed. --- @@ -341,15 +344,15 @@ Current available plugins: | Plugin name | Description | Author | Repository | | -- | -- | -- | -- | -| `@kong` | Kong Design System | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | -| `@kong/advanced` | Kong Design System (Advanced) | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | -| `@fubukicss/unocss` | UnoCSS by FubukiCSS | [@zouhangwithsweet](https://github.com/@zouhangwithsweet) | GitHub [GitHub](https://github.com/zouhangwithsweet/fubukicss-tool) | -| `@nuxt` | Nuxt UI | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | -| `@nuxt/pro` | Nuxt UI Pro | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | -| `@baidu-health/wz-style` | Custom style for Baidu Health wz-style | [@KangXinzhi](https://github.com/@KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-wz-style) | -| `@baidu-health/med-style` | Custom style for Baidu Health med-style | [@KangXinzhi](https://github.com/@KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-med-style) | -| `@tailwind` | CSS to Tailwind CSS | [@haydenull](https://github.com/@haydenull) | GitHub [GitHub](https://github.com/haydenull/tempad-dev-plugin-tailwind) | -| `@react-native` | CSS to React Native StyleSheet | [@CANntyield](https://github.com/@CANntyield) | GitHub [GitHub](https://github.com/CANntyield/tempad-dev-plugin-react-native) | +| `@kong` | Kong Design System | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | +| `@kong/advanced` | Kong Design System (Advanced) | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | +| `@fubukicss/unocss` | UnoCSS by FubukiCSS | [@zouhangwithsweet](https://github.com/zouhangwithsweet) | GitHub [GitHub](https://github.com/zouhangwithsweet/fubukicss-tool) | +| `@nuxt` | Nuxt UI | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | +| `@nuxt/pro` | Nuxt UI Pro | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | +| `@baidu-health/wz-style` | Custom style for Baidu Health wz-style | [@KangXinzhi](https://github.com/KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-wz-style) | +| `@baidu-health/med-style` | Custom style for Baidu Health med-style | [@KangXinzhi](https://github.com/KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-med-style) | +| `@tailwind` | CSS to Tailwind CSS | [@haydenull](https://github.com/haydenull) | GitHub [GitHub](https://github.com/haydenull/tempad-dev-plugin-tailwind) | +| `@react-native` | CSS to React Native StyleSheet | [@CANntyield](https://github.com/CANntyield) | GitHub [GitHub](https://github.com/CANntyield/tempad-dev-plugin-react-native) | @@ -367,37 +370,10 @@ If there are components generated by the TemPad Figma plugin on the canvas, TemP

Quirks mode

-> [!CAUTION] -> Quirks mode is no longer usable as of 2025.04.01. Figma removed the `window.DebuggingHelpers.logSelected` API, which was used to extract style data. - -> [!NOTE] -> New in v0.1.0 - -New in TemPad Dev v0.1.0, Quirks Mode lets you use the tool even when `window.figma` is unavailable. This mode extracts style data through Figma's debug logs, allowing for basic style generation, albeit with some limitations. - -Known missing features generating style codes include: - -- Styles added through Effects, corresponding to CSS properties like `box-shadow`, `filter: blur()`, and `backdrop-filter: blur()`. -- Gradient fill styles. TemPad Dev can only detect the existence of a gradient and outputs it as `linear-gradient()`. -- Fill styles' blend mode, corresponding to the `background-blend-mode` CSS property. -- `font-family` of text nodes, which is obtained heuristically and may be inaccurate. -- Advanced OpenType configurations for text nodes, other than numeric styles, which are generally not used. -- The ["Scroll Selection into View"](#scroll-selection-into-view) feature is not available in this mode. - -Except for the above-mentioned features, others are mostly consistent with the standard mode. If Quirks mode is sufficient for your scenarios, it can eliminate the tedious operation of duplicating to drafts and be used directly in view-only mode. Note that this mode also relies on Figma's globally exposed debug interface and cannot guarantee long-term validity. If Figma removes the related interface again, this mode will also become unavailable. - -
-

Compatibility Updates

- -**2025.04.01**: Figma removed the `window.DebuggingHelpers.logSelected` API, which was used to extract style data. As a result, Quirks mode is no longer usable. - -**2024.11.04**: TemPad Dev now managed to bring back the `window.figma` API under view-only mode. But we still cannot guarantee the long-term validity of this feature. If Figma removes the related interface again, this mode will also become unavailable. - -**2024.04.08**: TemPad Dev successfully retrieved most style information using currently unblocked debug interfaces, providing a new [Quirks Mode](#quirks-mode). This mode does not rely on `window.figma` but instead parses debug logs to generate style code, with slight differences from the standard mode. - -**2024.03.20**: After we posted complaints on the Figma Community Forum, the Figma team stated that they would reinstate the `window.figma` interface in view-only mode in the coming weeks. You can track the progress of this issue on this [thread](https://forum.figma.com/t/figma-removed-window-figma-on-view-only-pages-today/67292). - -
+Quirks mode was a fallback that extracted styles from Figma debug logs. It stopped +working when Figma removed the required API and was removed in TemPad Dev 0.12.0. +It is unavailable in current releases. See the [extension changelog](./packages/extension/CHANGELOG.md) +for its history and [Inspect designs](#inspect-designs) for current features.
diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 50c0efaf..d51104aa 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -101,9 +101,11 @@ Codex 和 Claude 的原生 marketplace 命令,以及 Cursor 和 VS Code 的可 [Agent Plugin 指南](./agent-plugin/src/README.zh-Hans.md)。 所有 plugin 和直接使用 `npx` 的配置路径都使用 `@tempad-dev/mcp@latest`。 +从 MCP 1.0.0 起,经过校验的正式包默认启用后台下载,并在 MCP 会话空闲时自动更新兼容的 Hub。 +Agent Plugin 1.0.0 和安装命令会明确选择此策略;既有客户端需重启到新版本后生效。 +适用范围和关闭方式见[运行时更新控制](./packages/mcp-server/README.zh-Hans.md#运行时更新100)。 -本次版本应配套使用扩展 **0.23.4**、MCP server **0.11.0** 和 Agent Plugin -**0.5.1**。更新既有安装时,请参阅 [升级指南](./agent-plugin/src/README.zh-Hans.md#升级)。 +最低版本要求和既有安装的更新步骤请参阅[升级指南](./agent-plugin/src/README.zh-Hans.md#升级)。 使用期间请保持 TemPad Dev 打开并启用 MCP。如果连接了多个 Figma 文件,请点击目标文件面板中的 MCP 徽标;该文件会成为 agent 当前访问的上下文。Agent 也可以列出已连接的会话,在独立只读调用中传入精确的 `sessionId`,无需切换激活徽标。 @@ -119,7 +121,7 @@ Codex 和 Claude 的原生 marketplace 命令,以及 Cursor 和 VS Code 的可 MCP 状态徽标,显示为 Unavailable。
-- **Inactive**:TemPad Dev 已连接到本地 MCP 服务器,但由于打开了多个 Figma 标签页,此标签页当前未激活。点击徽标即可为当前标签页激活 MCP(同时会停用其他标签页的 MCP)。 +- **Inactive**:TemPad Dev 已连接到本地 MCP 服务器,但此标签页不是默认文件。点击徽标可将其设为默认文件;其他已连接文件仍可通过精确会话读取。 @@ -164,7 +166,7 @@ Codex 和 Claude 的原生 marketplace 命令,以及 Cursor 和 VS Code 的可 - `get_screenshot`:为每个请求的节点返回有大小限制的渲染 PNG,用于按需视觉验证。 - `get_structure`:精确节点、精确托管页面或当前选中的一个或多个可见节点的结构信息(id、类型、几何数据)。 - `upload_asset`:将生成的 PNG/JPEG/GIF 存入本地 Hub,并返回供画布创作使用的 `assetHash`。 -- 二进制资源会通过工具响应中的元数据 + HTTP 下载地址(`asset.url`)提供;MCP 不再暴露 asset 资源模板。 +- 二进制资源通过工具响应中的元数据提供,包含临时 HTTP 下载地址(`asset.url`),可用时也附带本地文件路径(`asset.localPath`)。MCP 不再暴露 asset 资源模板。 --- @@ -328,15 +330,15 @@ sandboxed extension page 内启动一个全新的 Worker,并在完成或五秒 | 插件名称 | 描述 | 作者 | 仓库 | | -- | -- | -- | -- | -| `@kong` | Kong Design System | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | -| `@kong/advanced` | Kong Design System(高级版) | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | -| `@fubukicss/unocss` | FubukiCSS 的 UnoCSS | [@zouhangwithsweet](https://github.com/@zouhangwithsweet) | GitHub [GitHub](https://github.com/zouhangwithsweet/fubukicss-tool) | -| `@nuxt` | Nuxt UI | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | -| `@nuxt/pro` | Nuxt UI Pro | [@Justineo](https://github.com/@Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | -| `@baidu-health/wz-style` | 百度健康 wz-style 自定义样式 | [@KangXinzhi](https://github.com/@KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-wz-style) | -| `@baidu-health/med-style` | 百度健康 med-style 自定义样式 | [@KangXinzhi](https://github.com/@KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-med-style) | -| `@tailwind` | CSS 转 Tailwind CSS | [@haydenull](https://github.com/@haydenull) | GitHub [GitHub](https://github.com/haydenull/tempad-dev-plugin-tailwind) | -| `@react-native` | CSS 转 React Native StyleSheet | [@CANntyield](https://github.com/@CANntyield) | GitHub [GitHub](https://github.com/CANntyield/tempad-dev-plugin-react-native) | +| `@kong` | Kong Design System | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | +| `@kong/advanced` | Kong Design System(高级版) | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-kong) | +| `@fubukicss/unocss` | FubukiCSS 的 UnoCSS | [@zouhangwithsweet](https://github.com/zouhangwithsweet) | GitHub [GitHub](https://github.com/zouhangwithsweet/fubukicss-tool) | +| `@nuxt` | Nuxt UI | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | +| `@nuxt/pro` | Nuxt UI Pro | [@Justineo](https://github.com/Justineo) | GitHub [GitHub](https://github.com/Justineo/tempad-dev-plugin-nuxt-ui) | +| `@baidu-health/wz-style` | 百度健康 wz-style 自定义样式 | [@KangXinzhi](https://github.com/KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-wz-style) | +| `@baidu-health/med-style` | 百度健康 med-style 自定义样式 | [@KangXinzhi](https://github.com/KangXinzhi) | GitHub [GitHub](https://github.com/KangXinzhi/tempad-dev-plugin-med-style) | +| `@tailwind` | CSS 转 Tailwind CSS | [@haydenull](https://github.com/haydenull) | GitHub [GitHub](https://github.com/haydenull/tempad-dev-plugin-tailwind) | +| `@react-native` | CSS 转 React Native StyleSheet | [@CANntyield](https://github.com/CANntyield) | GitHub [GitHub](https://github.com/CANntyield/tempad-dev-plugin-react-native) | @@ -354,37 +356,9 @@ sandboxed extension page 内启动一个全新的 Worker,并在完成或五秒

Quirks 模式

-> [!CAUTION] -> 自 2025.04.01 起,Quirks 模式已不可用。Figma 移除了用于提取样式数据的 `window.DebuggingHelpers.logSelected` API。 - -> [!NOTE] -> TemPad Dev v0.1.0 新增 - -Quirks 模式是在 TemPad Dev v0.1.0 中引入的,当 `window.figma` 不可用时,你仍然可以使用该工具。该模式通过解析 Figma 的调试日志来提取样式数据,从而生成基础样式代码,但会存在一定限制。 - -当前已知无法生成或存在差异的功能包括: - -- Effects 中添加的样式,对应的 CSS 属性如 `box-shadow`、`filter: blur()`、`backdrop-filter: blur()`。 -- 渐变填充样式。TemPad Dev 只能检测到渐变的存在,并输出为 `linear-gradient()`。 -- 填充样式的混合模式,对应 CSS 的 `background-blend-mode`。 -- 文本节点的 `font-family`,该值通过启发式方式获取,可能不准确。 -- 除数字样式之外的高级 OpenType 配置,通常在实际中较少使用。 -- [“将选中项滚动到视图中”](#将选中项滚动到视图中) 功能在该模式下不可用。 - -除上述限制外,其余功能与标准模式基本一致。如果 Quirks 模式能够满足你的使用场景,就可以避免复制到草稿文件等繁琐操作,直接在只读视图中使用。但需要注意的是,该模式同样依赖于 Figma 全局暴露的调试接口,无法保证长期可用性。如果 Figma 再次移除相关接口,该模式也会随之失效。 - -
-

兼容性更新

- -**2025.04.01**:Figma 移除了用于提取样式数据的 `window.DebuggingHelpers.logSelected` API,Quirks 模式因此不可用。 - -**2024.11.04**:TemPad Dev 成功在只读模式下重新获取了 `window.figma` API,但仍无法保证该能力的长期有效性。如果 Figma 再次移除相关接口,该模式也将不可用。 - -**2024.04.08**:TemPad Dev 通过当前尚未被封禁的调试接口,成功获取了大部分样式信息,并引入了新的 [Quirks 模式](#quirks-mode)。该模式不依赖 `window.figma`,而是通过解析调试日志生成样式代码,与标准模式存在少量差异。 - -**2024.03.20**:在我们向 Figma 社区论坛提交反馈后,Figma 团队表示将在未来几周内恢复只读模式下的 `window.figma` 接口。你可以在这个 [讨论帖](https://forum.figma.com/t/figma-removed-window-figma-on-view-only-pages-today/67292) 中跟踪该问题的进展。 - -
+Quirks 模式曾通过 Figma 调试日志提取样式。所需 API 被 Figma 移除后,该模式失效, +并在 TemPad Dev 0.12.0 中被删除,当前版本已不可用。历史记录见 +[扩展更新日志](./packages/extension/CHANGELOG.md),当前功能见[检查设计](#检查设计)。
diff --git a/TESTING.md b/TESTING.md index de4a73fc..0de9275d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,6 +29,10 @@ Then run: Use this sequence as the default pre-PR verification. Also run `pnpm test:coverage` when changing pure utilities or coverage configuration, as detailed below. CI enforces the coverage floor. +Push a validation branch under `ci/` to run the full Linux build and Windows runtime matrices +without publishing `main`. Validation branches compare plugin payload versions against +`origin/main`; ordinary main pushes retain their previous-commit baseline. + ## Command reference Root: @@ -42,6 +46,70 @@ Root: Hub-to-extension, extension-to-Hub, and formatted MCP fixture responses. - `pnpm agent-plugin:check-installer` (networked discovery smoke test with `plugins@1.3.4`; run after `pnpm agent-plugin:build`, without installing into a host) +- `pnpm mcp:check-profiles` (builds and launches isolated real CLI/Hub processes; checks + shared release ownership, default and overridden directories, actual release/checkout WebSocket + ports, and independent shutdown). CI runs the same harness beside the bridge check. + Windows process-level isolation is unverified: the harness refuses Windows and Unix hook + fixtures are skipped there. Unit tests cover pipe naming by user and checkout and the unchanged + release pipe; runtime-directory overrides cannot isolate two Windows Hubs for the same checkout. +- `pnpm mcp:check-host-context` (builds and exercises real CLI processes against a private fixture + Hub; checks both host launch orders, sanitized child environments, registration after reconnect, + and continued bridging after the original launcher exits). A real MCP SDK fixture also checks + that the pre-context Hub contract accepts new CLIs through initialization, identity, and tools. + CI runs it alongside profile isolation. + It refuses Windows for the same fixed-pipe constraint; it does not connect to installed hosts. + Native adapter and real Hub socket tests separately cover routing and persisted host contexts. +- `pnpm mcp:check-runtime-prototype` builds a bundled-only prototype in a private runtime root, + using ephemeral WebSocket/HTTP ports and a synthetic extension. It checks two initialized + edges, supervisor crash recovery without replacing the Hub or replaying a write, admission + fencing, Stop, host-context isolation, legacy hook routing, and last-client shutdown. It + reports startup/recovery timings and process RSS. The installed CLI and live runtimes are + unchanged; this harness does not download or switch versions. Windows uses named pipes and + checks actual directory and pipe ACLs rather than Unix permission bits. +- `pnpm mcp:check-runtime-switch` builds trusted local A/B fixtures from source in an ignored + directory, then exercises manual switching with two initialized SDK clients and a synthetic + extension. It checks draining, fixed asset endpoints, a disconnected write and late receipt, + crashes at all four journal phases, port conflicts, rollback retaining a later Stop, and + pending Claude Stop notices surviving both switching and rollback with durable hook retries. + New edges also attach during a delayed handoff and after real stale writer leases expire; + the fixture's initialization deadline exceeds the edge's 90-second recovery bound. + `TEMPAD_SWITCH_CASES` selects comma-separated cases for diagnosis; `TEMPAD_KEEP_PROTOTYPE=1` + retains isolated evidence. It never downloads or updates installed runtimes. CI runs the full + matrix on Linux and Windows with Node 22 and 24; native host behavior remains outside this harness. +- Before runtime archive checks, run `pnpm mcp:prepare-release-dependencies` with network access. + It verifies and caches the exact frozen production dependency graph without package scripts; + use the same pnpm cache for the subsequent offline packaging step. A workspace install alone + may not cache all verification evidence required by the reduced production lockfile. +- `pnpm mcp:check-runtime-updates` builds complete release archives from the frozen local store, + serves them from an isolated loopback registry, and exercises default release enrollment, staging and explicit + activation through real CLI/Hub processes. It checks mixed policies sharing one Hub, exact + pins, newest-compatible selection below the stable tag, tag rollback without downgrade, + exact-version requests without substitution, frozen presentation, incompatible epochs, + offline operation, corrupt caches, and zero + registry requests from checkout builds. A real metadata write failure must leave the supervisor + and MCP service alive without starting acquisition. After all owners exit, concurrent installed-release + launches upgrade both control and Hub across an epoch boundary without registry access or + allowing an older CLI to downgrade them. A separate simulated Codex namespace checks idle + automatic activation, generic MCP clients, verified Claude hooks, unverified Claude/off-policy deferral, continuous tool work, bounded request + waiting and measured admission pause. This is process evidence, not new native acceptance. CI runs this on + Node 22/24 on Linux and Windows; native host acceptance remains outside this fixture. No installed + runtime or public registry is used by the acquisition test. +- `pnpm mcp:prepare-release --output --verify-baseline` builds and verifies + the publishable dependency closure and checks the current stable npm baseline. See + [release policy](docs/releasing.md#runtime-release-policy) before refreshing the review hash. +- `pnpm mcp:measure-runtime --output --runs 5 [--lockfile ] [...]` + compares trusted, explicitly supplied local release artifacts in the given version order. It + performs MCP initialization/catalog reads through fresh CLI/Hub processes and records artifact + hashes, structural comparisons across release windows, startup/attach timing and RSS. It never + downloads packages or connects to installed hosts. TCP listeners are remapped to ephemeral + loopback ports and IPC listeners are confined to a temporary root; Windows is refused. + Preserve the resolved dependency lock alongside the report. Structural equality does not prove + guidance or behavior compatibility, and warm attachment is not uninterrupted session recovery. +- `pnpm mcp:measure-hooks --hook --output --runs 30` measures an inspected + hook against an isolated synthetic receiver, including slow commit, lost reply, failed commit, + disconnect and exit before request arrival. It does not modify installed hooks or contact the + user's Hub. File/directory fsync and injected delay are not full-device flush, disk-pressure, + or installed-host adoption evidence. Keep measurement reports outside version control. - `pnpm --filter @tempad-dev/extension test:setup` (install extension browser runtime) - `pnpm --filter @tempad-dev/extension test:node` (extension node tests only) - `pnpm --filter @tempad-dev/extension test:browser` (extension browser tests only) @@ -54,6 +122,64 @@ Root: - `pnpm agent-eval:log ` (retain the small amount of provenance needed to trust a live authoring run) +### Windows runtime CI + +The [Windows runtime workflow](.github/workflows/windows-runtime.yml) runs the runtime unit +tests and all three supervisor/switch/acquisition process harnesses on Node 22 and 24. It +preserves LF source bytes for the reviewed manifest hash, asserts the selected Node version, +and uploads per-step logs. Push a validation branch under `ci/` to run it without publishing +`main`; it also runs for `main`, pull requests and manual dispatch. + +All Windows matrix jobs must pass on the candidate before release. These checks exercise real +named pipes, ACLs, child processes, downloads, concurrent edges, in-flight requests, automatic +activation, crashes and rollback. They do not establish installed Codex/Claude desktop behavior, +and do not change the Windows limitations of the separate profile/host-context/bridge harnesses. + +### Native runtime switch acceptance + +Use the [authoring runbook](docs/testing/agent-authoring-evolution.md) for installed-host +replacement, preflight and native calls. The following opt-in fixture tests the packaged +release path without fetching or publishing a release. It requires macOS/Linux, a short new +private directory, and unused release discovery ports; it never stops another port owner. + +```sh +pnpm mcp:prepare-release-dependencies +node packages/mcp-server/scripts/native-runtime-fixture.mjs --root /tmp/tp-native-example --prepare +node packages/mcp-server/scripts/native-runtime-fixture.mjs --root /tmp/tp-native-example --serve --hold-candidate +# In another terminal, use the canonical descriptor path printed by --prepare: +pnpm agent-plugin:build --runtime-fixture /private/tmp/tp-native-example/runtime-fixture.json +pnpm build:ext +``` + +Preparation deploys frozen dependencies offline and verifies complete immutable A/B archives. +The fixture changes only package identity and the compiled registry origin to a fixed loopback +server; normal discovery, bootstrap, staging, consent and handoff logic remain in use. Both +packages are private and point publication at the fixture registry. Never publish these archives. +The ignored dev plugin routes Codex, Claude and its hooks to the same private release-test +namespace. It does not change any generated release target or the default checkout configuration. +Use a release-profile extension for this fixture, not a checkout-profile build. + +After replacing the plugin and reloading the extension, require preflight and a native MCP read. +`--hold-candidate` keeps the registry on the initial version while this baseline is established. +Send `SIGUSR2` to the registry PID printed by `--serve` to expose the already-verified candidate; +this does not restart either the host or registry. Invoke `--check-runtime-update` with the +descriptor's environment to discover it immediately instead of waiting for the scheduled check. +An off-policy observer can hold the candidate staged for inspection; close it before testing +activation. Enrolled, initialized, wait-capable MCP clients can activate after five idle seconds. +Record stable host/edge identities, changed Hub identity, native feedback/lifecycle/Stop and +subsequent canvas work. Claude requires a verified session and receipt/retry-capable hooks; +unverified sessions and legacy hooks must remain staged. The explicit update command remains +available, but cannot override an attached off-policy observer or other compatibility blockers. +The registry request log records only local acquisition. This establishes installed-host behavior +for the local candidate; official-registry provenance and cross-version release compatibility +remain separate checks. Do not count synthetic SDK clients as native-host acceptance. + +When finished, uninstall the fixture plugin and confirm its CLI/Hub/supervisor processes have +stopped before shutting down the registry. Regenerate the ordinary plugin with +`pnpm agent-plugin:build`, rebuild the extension with `TEMPAD_MCP_DEV_CHECKOUT="$PWD"`, and follow +the same installation/preflight workflow to restore checkout development. Retain evidence outside +version control. Never reuse a fixture root for different source bytes. + Per package: - `pnpm --filter @tempad-dev/extension test:run` (includes Worker dependency checks and the real diff --git a/agent-plugin/src/CHANGELOG.md b/agent-plugin/src/CHANGELOG.md index 433117c6..f144f70a 100644 --- a/agent-plugin/src/CHANGELOG.md +++ b/agent-plugin/src/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0 + +- Enabled verified runtime downloads and compatible Hub updates between operations on macOS, + Linux and Windows with MCP 1.0.0. Claude requires a verified session and current lifecycle + hooks; development checkouts keep their bundled runtime. +- Retried interrupted Claude lifecycle messages without repeating their effects, including + during runtime switches. Preserved Unicode text and tolerated malformed Hub replies. +- Kept development Claude hooks connected to their own checkout while a release runtime is running. +- Clarified recovery after failed or uncertain canvas writes, native text binding limits, + supported text entities, and browser compatibility for token-based CSS. Unsupported entity + warnings require extension 1.0.0 or later. +- Corrected component sizing, asset storage, shadow inspection, and Chinese setup guidance. + ## 0.5.1 - Fixed Claude session binding so Figma Stop reaches the correct conversation. diff --git a/agent-plugin/src/README.md b/agent-plugin/src/README.md index b6703c10..031c96e0 100644 --- a/agent-plugin/src/README.md +++ b/agent-plugin/src/README.md @@ -76,7 +76,8 @@ skill installations should also remove the old skill after installing the new on The standalone package retains the former name as a generated alias for one extension store release cycle so setup commands in older installed extensions continue to work. -Use TemPad Dev extension **0.23.4 or later** and MCP server **0.11.0 or later**. +For the complete 1.0 release, update the TemPad Dev extension, MCP server, and +Agent Plugin (or its standalone skills) to **1.0.0 or later**. Node.js **22.x, 24.x, or 26+** is required for the MCP server. Multi-node reads and consumer-aware token resolution require matching protocol 15 Hub and extension builds. MCP 0.11.0 also supports extension 0.22.0 through protocol 13, preserving @@ -84,13 +85,17 @@ existing reads, sessions, tasks, and canvas writes during the store rollout. New prompt an extension upgrade; results identify older selection and token semantics. MCP 0.10.0 and later use `manage_design_task` in place of the older lifecycle tools. -1. Update the browser extension to 0.23.4 or later and reload the Figma tab. Version 0.23.4 includes packaged rewrite rules and SVG placement fixes; multi-node reads arrived in 0.23.0. +1. Update the browser extension to 1.0.0 or later and reload the Figma tab. 2. Update the installed plugin through the client or installer used originally. With standalone setup, update both `figma-design-to-code` and `design-in-figma`. 3. Keep the release MCP configuration on `@tempad-dev/mcp@latest`; replace any previous - `@alpha` or fixed alpha version. Reconnect the MCP client and start a new task so it loads the - updated tools and skills. If a stale Hub is reported, close tasks using the old MCP server - before reconnecting. + `@alpha` or fixed alpha version. Plugin 1.0.0 enables verified runtime downloads with MCP + 1.0.0 or later on macOS, Linux and Windows. Compatible Hub updates switch automatically during idle MCP + sessions with waiting support; Claude also requires a verified session and receipt/retry-capable hooks. + When upgrading from an unmanaged Hub, disconnect all TemPad MCP + clients once, then restart their MCP servers. Edge processes, installed plugins and incompatible + runtime changes still require their normal update/restart. See the + [runtime update controls](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.md#runtime-updates-100). 4. Open TemPad Dev, enable **MCP access**, and click the MCP badge in the intended Figma tab when a session choice is needed. The badge selects the default file; agents can also discover connected sessions and target independent reads by exact session ID. diff --git a/agent-plugin/src/README.zh-Hans.md b/agent-plugin/src/README.zh-Hans.md index 95a300f7..b8ddafe9 100644 --- a/agent-plugin/src/README.zh-Hans.md +++ b/agent-plugin/src/README.zh-Hans.md @@ -66,6 +66,9 @@ claude plugin install tempad-dev@tempad-dev 设计任务的状态栏显示 agent 状态、Stop/Done 和评论入口。Stop 会立即阻止当前任务继续写入, 在执行中的操作结束后永久取消该任务;重新连接也不会恢复它。后续设计工作需要明确开始新任务。 +正常配置只需完成 TemPad Dev 扩展设置并安装本插件,无需另行配置控制地址、 +环境变量或辅助服务。 + 评论目前仅支持通过兼容 Codex App 的原生会话通道发送。Queue 将整批评论交给宿主的原生队列, 等待会话可以执行时再处理。宿主确认接收后,TemPad Dev 会清空本批评论和标记、停止发送指示, 并允许继续输入下一批;这表示已接收,不表示 agent 已完成修改。如果原生入队不可用, @@ -89,7 +92,10 @@ Codex App 的任务绑定和状态同步使用 MCP 元数据及原生 IPC,不 宿主不可用时,本地取消仍然生效。Claude 保留生命周期和 Stop 通知 hooks;Claude、Codex CLI 等尚未接入原生投递的客户端提供任务状态和 Stop/Done,但不显示评论入口,已有草稿不会删除。 Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂停可用新的 lease epoch 恢复, -恢复后必须先重新读取画布才能写入;取消的任务不能恢复。详见 +恢复后必须先重新读取画布才能写入;取消的任务不能恢复。Agent 会遵守 Stop,不会自动创建 +替代任务;必要或用户要求的后续设计工作可以显式开始新任务,无需另行在 Figma 中解锁。 +只有实际持有目标会话的宿主确认支持时,才会启用 Codex 原生投递,无需手动配置连接。 +当前验证范围详见 [任务与客户端设计](https://github.com/ecomfe/tempad-dev/blob/main/docs/extension/mcp-design-tasks.md)。 原生适配器在 macOS/Linux 上使用 Unix socket,在 Windows 上使用 Codex 的本机 Named Pipe。 @@ -97,6 +103,9 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 自动执行和完整的已安装插件/Figma UI 流程仍待实测;Windows/Linux 的证据限于源码检查和 自动化测试,尚不能据此宣称完整宿主支持。 +Agent 会在对话中报告结果并附上 Figma 链接,用户可以继续提出后续要求。 +任务工具返回文本和结构化数据。 + ## 升级 设计 skill 已更名为 `design-in-figma`,与展示名 **Design in Figma** 一致。 @@ -104,19 +113,23 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 安装新版后还应移除旧名称的 skill。 独立 skill 包会保留旧名称的生成兼容入口一个扩展商店发布周期,让已安装旧扩展中的配置命令继续有效。 -请使用 TemPad Dev 扩展 **0.23.4 或更高版本**和 MCP server **0.11.0 或更高版本**。 +如需使用完整的 1.0 发布,请将 TemPad Dev 扩展、MCP server、Agent Plugin(或其独立 skill) +均更新到 **1.0.0 或更高版本**。 MCP server 要求 Node.js **22.x、24.x 或 26+**。 多节点读取和按消费节点解析 token 要求 Hub 与扩展均使用匹配的 protocol 15 构建。 MCP 0.11.0 同时通过 protocol 13 兼容扩展 0.22.0,在商店更新期间保留既有读取、会话、任务和画布写入。 新版读取选项会提示升级扩展,响应会明确标注旧版选区和 token 语义。 MCP 0.10.0 及之后版本使用 `manage_design_task` 代替旧的生命周期工具。 -1. 将浏览器扩展更新到 0.23.4 或更高版本并重新加载 Figma 标签页。0.23.4 包含内置 rewrite 规则和 SVG 定位修复;多节点读取从 0.23.0 开始提供。 +1. 将浏览器扩展更新到 1.0.0 或更高版本并重新加载 Figma 标签页。 2. 通过原先使用的客户端或安装器更新 plugin。独立配置时,请同时更新 `figma-design-to-code` 和 `design-in-figma`。 3. 正式版 MCP 配置使用 `@tempad-dev/mcp@latest`;请替换旧的 `@alpha` 或固定 alpha 版本。 - 重新连接 MCP client 并新建任务,以加载更新后的工具和 skill。若提示 Hub 过期,请先关闭 - 使用旧 MCP server 的任务,再重新连接。 + Plugin 1.0.0 配合 MCP 1.0.0 或更高版本,会在 macOS、Linux 和 Windows 上启用经过校验的后台下载, + 在支持切换等待的 MCP 会话空闲时自动切换兼容的 Hub。Claude 还要求已验证的会话和支持回执与重试的 hook。 + 从未启用更新的 Hub 升级时,需先断开所有 TemPad MCP client, + 再重启其 MCP server,一次即可。入口进程、已安装插件和不兼容的运行时改动仍需按原方式更新或重启。 + 详见[运行时更新控制](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.zh-Hans.md#运行时更新100)。 4. 打开 TemPad Dev 并启用 **MCP access**;需要选择会话时,点击目标 Figma 标签页内的 MCP badge。该 badge 选择默认文件;agent 也可以发现已连接会话,并通过精确 session ID 指定独立读取的目标。 diff --git a/agent-plugin/src/clients/claude/hooks.json b/agent-plugin/src/clients/claude/hooks.json index 173f8eb1..6571c919 100644 --- a/agent-plugin/src/clients/claude/hooks.json +++ b/agent-plugin/src/clients/claude/hooks.json @@ -1,5 +1,5 @@ { - "description": "Bind Figma tasks, collect user feedback at host lifecycle boundaries, and honor task Stop.", + "description": "Bind Figma tasks, report host lifecycle, and deliver task Stop notices.", "hooks": { "Stop": [ { diff --git a/agent-plugin/src/clients/shared/lifecycle.mjs b/agent-plugin/src/clients/shared/lifecycle.mjs index 6327b2f9..c5af9ed9 100644 --- a/agent-plugin/src/clients/shared/lifecycle.mjs +++ b/agent-plugin/src/clients/shared/lifecycle.mjs @@ -1,19 +1,28 @@ // Installed hooks exchange lifecycle identity and Stop notices with the existing Hub. They never // launch an agent, poll a model, change host trust, or unlock a stopped Figma file. import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' import { connect } from 'node:net' -import { join } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import process from 'node:process' import { clearTimeout, setTimeout } from 'node:timers' +import { pathToFileURL } from 'node:url' import { assertTrustedSocket, resolveDefaultRoot } from './runtime-paths.mjs' +import { + checkoutProfile, + resolveRuntimeProfile, + runtimeDirectories, + runtimeSocketPath +} from './runtime-profile.mjs' let socket const deadline = setTimeout(() => process.exit(0), 750) try { let text = '' + process.stdin.setEncoding('utf8') for await (const chunk of process.stdin) { - text += String(chunk) + text += chunk if (text.length > 1_000_000) process.exit(0) } const input = JSON.parse(text) @@ -54,60 +63,128 @@ try { kind: process.argv[2] === 'claude' ? 'claude' : 'codex', sessionId: input.session_id } - const runtimeDir = process.env.TEMPAD_MCP_RUNTIME_DIR || join(resolveDefaultRoot(), 'run') - const path = - process.platform === 'win32' ? '\\\\.\\pipe\\tempad-mcp' : join(runtimeDir, 'mcp.sock') - assertTrustedSocket(path) - socket = connect(path) - const pending = new Map() - let sequence = 0 - let buffer = '' - socket.on('data', (data) => { - buffer += data.toString() - if (buffer.length > 1_000_000) { - socket.destroy() - return - } - let index - while ((index = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, index) - buffer = buffer.slice(index + 1) - let message - try { - message = JSON.parse(line) - } catch { - continue - } - const entry = pending.get(message.id) - if (!entry) continue - pending.delete(message.id) - if (message.error) entry.reject(new Error('The Hub does not support this hook request.')) - else entry.resolve(message.result) + let profile = { kind: 'release' } + let path + if (process.argv[3] === '--runtime-profile') { + const descriptorPath = process.argv[4] + const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')) + if (typeof descriptor.serverName !== 'string' || !descriptor.serverName) + throw new Error('Missing checkout MCP server name.') + // Read the same generated configuration as MCP/preflight so directory overrides cannot drift. + const pluginRoot = resolve(dirname(descriptorPath), '../..') + const config = JSON.parse(readFileSync(join(pluginRoot, '.mcp.json'), 'utf8')).mcpServers?.[ + descriptor.serverName + ] + if ( + typeof config?.env?.TEMPAD_MCP_DEV_CHECKOUT !== 'string' || + typeof config?.args?.[0] !== 'string' + ) + throw new Error('Missing checkout MCP configuration.') + const runtimeDir = config.env.TEMPAD_MCP_RUNTIME_DIR + if ( + runtimeDir !== undefined && + (typeof runtimeDir !== 'string' || (runtimeDir && !isAbsolute(runtimeDir))) + ) + throw new Error('Checkout runtime directory must be an absolute path.') + const env = { + ...process.env, + TEMPAD_MCP_DEV_CHECKOUT: config.env.TEMPAD_MCP_DEV_CHECKOUT, + ...(runtimeDir !== undefined ? { TEMPAD_MCP_RUNTIME_DIR: runtimeDir } : {}) } - }) - const disconnected = () => { - for (const entry of pending.values()) entry.reject(new Error('The Hub hook connection closed.')) - pending.clear() + profile = resolveRuntimeProfile(env, pathToFileURL(resolve(pluginRoot, config.args[0]))) + if ( + profile.kind !== 'checkout' || + profile.id !== descriptor.id || + checkoutProfile(descriptor.checkout).id !== profile.id + ) + throw new Error('Invalid checkout hook profile.') + path = runtimeSocketPath(runtimeDirectories(env, profile).runtime, profile) + } else { + const runtimeDir = process.env.TEMPAD_MCP_RUNTIME_DIR || join(resolveDefaultRoot(), 'run') + path = process.platform === 'win32' ? '\\\\.\\pipe\\tempad-mcp' : join(runtimeDir, 'mcp.sock') } - socket.on('error', disconnected) - socket.on('close', disconnected) - await new Promise((resolve, reject) => { - socket.once('connect', resolve) - socket.once('error', reject) - }) - const request = (method, params) => - new Promise((resolve, reject) => { - const id = ++sequence - pending.set(id, { resolve, reject }) - socket.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') - }) - const result = await request('tempad/client-hook', { + assertTrustedSocket(path) + const lifecycle = { ...identity, event, invocationId: randomUUID(), ...(taskId ? { taskId } : {}), ...(typeof input.turn_id === 'string' ? { turnId: input.turn_id } : {}) - }) + } + const params = { ...lifecycle, version: 2, profile, issuedAt: Date.now() } + let legacy = false + async function request() { + socket = connect(path).setEncoding('utf8') + const connection = socket + let timer + try { + return await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('Hook reply timed out.')), 200) + let buffer = '' + socket.on('data', (data) => { + buffer += data + if (buffer.length > 1_000_000) { + socket.destroy() + return + } + let index + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + let message + try { + message = JSON.parse(line) + } catch { + continue + } + if (message?.id !== 1) continue + if (message.error) + reject( + Object.assign(new Error('The Hub does not support this hook request.'), { + code: message.error.code + }) + ) + else resolve(message.result) + return + } + }) + socket.on('error', reject) + socket.once('close', () => reject(new Error('The Hub hook connection closed.'))) + socket.once('connect', () => { + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/client-hook', + params: legacy ? lifecycle : params + }) + '\n' + ) + }) + }) + } finally { + clearTimeout(timer) + connection.destroy() + } + } + let result + for (let attempt = 0; attempt < 4; attempt++) { + try { + result = await request() + if (legacy) break + const receipt = result?.receipt + if (receipt?.version !== 2 || receipt.invocationId !== params.invocationId) + throw new Error('Invalid lifecycle receipt.') + if (receipt.state === 'applied') break + if (receipt.state !== 'accepted') throw new Error('Invalid lifecycle receipt state.') + result = undefined + } catch (error) { + result = undefined + // Only a definite old-schema rejection permits fallback. A lost reply + // retries the same v2 invocation, never a second lifecycle effect. + if (error?.code === -32602 && !legacy) legacy = true + } + await new Promise((resolve) => setTimeout(resolve, 30)) + } // Old Hubs may still offer comment batches; never emit or acknowledge those. if ( !result?.receiptId && diff --git a/agent-plugin/src/clients/shared/runtime-profile.d.mts b/agent-plugin/src/clients/shared/runtime-profile.d.mts new file mode 100644 index 00000000..e732bf4e --- /dev/null +++ b/agent-plugin/src/clients/shared/runtime-profile.d.mts @@ -0,0 +1,17 @@ +type RuntimeProfile = { kind: 'release' } | { kind: 'checkout'; id: string } +export function checkoutProfile(checkout: string): Extract +export function resolveRuntimeProfile( + env?: NodeJS.ProcessEnv, + entryUrl?: string | URL +): RuntimeProfile +export function runtimeDirectories( + env?: NodeJS.ProcessEnv, + profile?: RuntimeProfile, + options?: { tmpdir?: string; homedir?: string; uid?: number; prepare?: boolean } +): { root: string | undefined; runtime: string; log: string; assets: string; state: string } +export function runtimeSocketPath( + runtime: string, + profile?: RuntimeProfile, + platform?: NodeJS.Platform, + home?: string +): string diff --git a/agent-plugin/src/clients/shared/runtime-profile.mjs b/agent-plugin/src/clients/shared/runtime-profile.mjs new file mode 100644 index 00000000..c966707f --- /dev/null +++ b/agent-plugin/src/clients/shared/runtime-profile.mjs @@ -0,0 +1,104 @@ +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import { realpathSync, statSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { prepareDefaultRoot, resolveDefaultRoot } from './runtime-paths.mjs' + +function hash(value) { + return createHash('sha256').update(value).digest('hex').slice(0, 16) +} + +export function checkoutProfile(checkout) { + if (!isAbsolute(checkout)) throw new Error('TEMPAD_MCP_DEV_CHECKOUT must be an absolute path.') + const root = realpathSync(checkout) + if (!statSync(root).isDirectory()) throw new Error('The checkout must be a directory.') + return { kind: 'checkout', id: hash(root) } +} + +export function resolveRuntimeProfile(env = process.env, entryUrl) { + if (env.TEMPAD_MCP_DEV_CHECKOUT === undefined) return { kind: 'release' } + const profile = checkoutProfile(env.TEMPAD_MCP_DEV_CHECKOUT) + if (entryUrl) { + const root = realpathSync(env.TEMPAD_MCP_DEV_CHECKOUT) + const dist = join(root, 'packages/mcp-server/dist') + for (const name of ['cli.mjs', 'hub.mjs']) { + const path = join(dist, name) + try { + if (realpathSync(path) === path && statSync(path).isFile()) continue + } catch (cause) { + throw new Error(`Missing checkout runtime: ${path}. Run pnpm build:mcp in the checkout.`, { + cause + }) + } + throw new Error(`Invalid checkout runtime: ${path}. Build the checkout before starting MCP.`) + } + if (dirname(realpathSync(fileURLToPath(entryUrl))) !== dist) + throw new Error('The MCP entry point does not belong to TEMPAD_MCP_DEV_CHECKOUT.') + } + return profile +} + +export function runtimeDirectories( + env = process.env, + profile = resolveRuntimeProfile(env), + options = {} +) { + const temporary = options.tmpdir ?? tmpdir() + const home = options.homedir ?? homedir() + const user = options.uid ?? process.getuid?.() ?? hash(home) + const checkout = profile.kind === 'checkout' + const needsRoot = + !env.TEMPAD_MCP_RUNTIME_DIR || !env.TEMPAD_MCP_LOG_DIR || !env.TEMPAD_MCP_ASSET_DIR + let root + if (needsRoot) { + root = checkout + ? join(temporary, `tpd-${user}-${profile.id}`) + : (options.prepare ? prepareDefaultRoot : resolveDefaultRoot)(temporary) + } + // Explicit directories remain usable for isolated tests, but never merge checkout and release. + const directory = (override, name) => { + if (!override) return join(root, name) + return checkout ? join(override, `checkout-${profile.id}`) : override + } + const runtime = directory(env.TEMPAD_MCP_RUNTIME_DIR, 'run') + let state + if (!checkout && env.TEMPAD_MCP_STATE_DIR !== undefined) { + state = env.TEMPAD_MCP_STATE_DIR + } else if (env.TEMPAD_MCP_STATE_DIR) { + state = directory(env.TEMPAD_MCP_STATE_DIR, 'state') + } else if (env.TEMPAD_MCP_RUNTIME_DIR) { + state = join(runtime, 'state') + } else { + state = checkout + ? join(home, '.tempad-dev', 'checkouts', profile.id, 'state') + : join(home, '.tempad-dev', 'state') + } + return { + root, + runtime, + log: directory(env.TEMPAD_MCP_LOG_DIR, 'log'), + assets: directory(env.TEMPAD_MCP_ASSET_DIR, 'assets'), + state + } +} + +export function runtimeSocketPath( + runtime, + profile = { kind: 'release' }, + platform = process.platform, + home = homedir() +) { + if (platform !== 'win32') { + const socket = join(runtime, 'mcp.sock') + if (profile.kind === 'checkout' && Buffer.byteLength(socket) > 103) + throw new Error('Checkout socket path is too long. Choose a shorter TEMPAD_MCP_RUNTIME_DIR.') + return socket + } + return profile.kind === 'checkout' + ? `\\\\.\\pipe\\tempad-mcp-${hash(home)}-${profile.id}` + : '\\\\.\\pipe\\tempad-mcp' +} diff --git a/agent-plugin/src/mcp.json b/agent-plugin/src/mcp.json index 4326590e..63a42b75 100644 --- a/agent-plugin/src/mcp.json +++ b/agent-plugin/src/mcp.json @@ -4,7 +4,7 @@ "tempad-dev": { "type": "stdio", "command": "npx", - "args": ["-y", "@tempad-dev/mcp@latest"] + "args": ["-y", "@tempad-dev/mcp@latest", "--runtime-updates=stable"] } } } diff --git a/agent-plugin/src/plugin.json b/agent-plugin/src/plugin.json index 98ec9e1a..20bca1fa 100644 --- a/agent-plugin/src/plugin.json +++ b/agent-plugin/src/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "tempad-dev", - "version": "0.5.1", + "version": "1.0.0", "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "author": { "name": "TemPad Dev" diff --git a/agent-plugin/src/skills/design-in-figma/SKILL.md b/agent-plugin/src/skills/design-in-figma/SKILL.md index 9278d7b3..28d6ac55 100644 --- a/agent-plugin/src/skills/design-in-figma/SKILL.md +++ b/agent-plugin/src/skills/design-in-figma/SKILL.md @@ -134,7 +134,9 @@ screen or section boundaries and carry shared roles coherently across them. Inspect every `apply_canvas` result, including warnings. Repair each observed unintended defect or disclose why it remains. A local validation failure calls for a local payload correction; it does not justify discarding a working root -or simplifying away the intended content. Open pixels again after the final +or simplifying away the intended content. If a write times out, loses its result, +or cannot verify rollback, follow [local recovery](references/editing.md#recover-locally) +before another write. Open pixels again after the final material write, covering every materially distinct screen. Verify native facts with `get_structure` when identity, placement, editability, or representation matters. Opened pixels prove visual access, not good judgment; a structural pass diff --git a/agent-plugin/src/skills/design-in-figma/references/canvas-html.md b/agent-plugin/src/skills/design-in-figma/references/canvas-html.md index ef11c260..a8428ac3 100644 --- a/agent-plugin/src/skills/design-in-figma/references/canvas-html.md +++ b/agent-plugin/src/skills/design-in-figma/references/canvas-html.md @@ -68,10 +68,14 @@ reveals issues one at a time. unlinks that field. - A `span` contains only text and `
` or `
` line breaks. Use `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is - literal unless it forms a semicolon-terminated entity; supported entities - decode. Canvas typography does not inherit from a parent `div`: put font and - other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, - borders, corners, and box shadows on a parent `div`. + literal unless it forms a semicolon-terminated entity. Numeric decimal/hex + entities and these named entities decode: `amp`, `apos`, `bull`, `copy`, `gt`, + `hellip`, `ldquo`, `lsquo`, `lt`, `mdash`, `middot`, `nbsp`, `ndash`, `quot`, + `rdquo`, `reg`, `rsquo`, `trade`. Other names remain literal; extension 1.0.0+ + also reports an `unknown-html-entity` warning. Use the Unicode character or a + numeric entity instead. Canvas typography does not inherit from + a parent `div`: put font and other text utilities on each `span`/TEXT node. + Put flex/grid, gaps, padding, borders, corners, and box shadows on a parent `div`. - A component tag is childless, includes its returned `data-ref`, and accepts returned props plus the shared class, identity, variable, and style attributes. diff --git a/agent-plugin/src/skills/design-in-figma/references/component-authoring.md b/agent-plugin/src/skills/design-in-figma/references/component-authoring.md index 99573887..b747048e 100644 --- a/agent-plugin/src/skills/design-in-figma/references/component-authoring.md +++ b/agent-plugin/src/skills/design-in-figma/references/component-authoring.md @@ -241,7 +241,7 @@ the call returns the set as `rootNodeId`, this creates Default and Hover: ```json { "mode": "create", - "markup": "
", + "markup": "
", "native": { "screen/default": { "component": { "id": "ComponentSetID:created-button-set" } diff --git a/agent-plugin/src/skills/design-in-figma/references/editing.md b/agent-plugin/src/skills/design-in-figma/references/editing.md index f8653bd5..f020d91e 100644 --- a/agent-plugin/src/skills/design-in-figma/references/editing.md +++ b/agent-plugin/src/skills/design-in-figma/references/editing.md @@ -32,10 +32,30 @@ native references needed for the intended change. ## Recover locally Read the entire mutation result. For a rejected payload, correct all reported -issues together without changing the design to fit the error. A verification -failure is rolled back by TemPad; do not assume a partial successful edit. -For an unknown transport outcome, read the exact target before retrying a create -or removal so an uncertain response does not become a duplicate mutation. +issues together without changing the design to fit the error. An execution failure +requires inspecting its reported outcome before retrying. TemPad attempts rollback +after a failed write. Only an explicit no-write outcome (`CANVAS_APPLY_ABORTED` or +`EXTENSION_DELIVERY_FAILED`) skips file readback. Other diagnostics can originate +inside a mutation, especially with older extensions. Follow the returned readback +requirement: rollback checks cover only part of native state. +Automatic Undo can be skipped when this attempt has no confirmed remaining change. +Do not suggest manual Undo in that case: it could remove an earlier successful edit. + +A timeout, lost result, or unverified rollback leaves the canvas outcome uncertain. +Do not replay the write. Wait for a pending operation to settle; reload a stuck +runtime only when the tool calls for it. Then read the affected surviving node with +`get_structure` or `get_code`, carrying the current task and epoch. If the node was +removed, use `get_structure` with its exact page ID. An empty page snapshot counts; +empty selection, failed-node-only results, screenshots, and resource catalogs do not +satisfy readback. Inspect the returned state before deciding on a repair or retry. +If the failed request also changed local variables or styles, inspect those affected +resources with `get_design_system`; a canvas read alone does not verify their state. +If it cannot establish a safe next change, ask the user to inspect the canvas. + +Readback is also required after task resume or reconnection. A file's unknown-write +requirement survives Hub restarts, Stop, and a fresh task; none of those replaces the +read. A notice that readback is still required means the last read did not establish +usable canvas state. Stop cancellation remains in force independently of readback. Repair warnings where they occur. Replace a whole root only when an observed structural defect requires it and the complete intended content can be diff --git a/agent-plugin/src/skills/design-in-figma/references/paints-effects.md b/agent-plugin/src/skills/design-in-figma/references/paints-effects.md index c32b82f2..2ce41f9d 100644 --- a/agent-plugin/src/skills/design-in-figma/references/paints-effects.md +++ b/agent-plugin/src/skills/design-in-figma/references/paints-effects.md @@ -35,9 +35,11 @@ Named scales such as `shadow-md` are theme references, not portable geometry: Never assume Tailwind defaults or create a token only to resolve a named class. `shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. -Treat an outer shadow's rendered halo as part of the composition. Inspect the -final PNG beyond the root edges; visible granular or noisy fringe, or a halo -that dominates the captured bounds, is a defect even when the frame itself is +Treat an outer shadow's rendered halo as part of the composition. Exact-node +screenshots may crop to the node's bounds; inspect an enclosing composition with +enough surrounding space to judge the halo. Its absence from a cropped PNG is +not evidence that it is absent on canvas. Visible granular or noisy fringe, or a +halo that dominates the composition, is a defect even when the frame itself is intact. Preserve intended depth by tightening blur, spread, or opacity or using smaller layered shadows, then recheck. Do not flatten established material treatment merely to hide the defect. diff --git a/agent-plugin/src/skills/design-in-figma/references/rich-text.md b/agent-plugin/src/skills/design-in-figma/references/rich-text.md index 3e26cc97..3ac85e5e 100644 --- a/agent-plugin/src/skills/design-in-figma/references/rich-text.md +++ b/agent-plugin/src/skills/design-in-figma/references/rich-text.md @@ -38,6 +38,22 @@ Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a range variable. `null` unlinks supported styles or hyperlinks; omission preserves. +Numeric font size, line height, and letter spacing can also affect a `\n` +immediately after a range. Give adjacent ranges explicit, consistent ownership. +If an error identifies a retained Text style at that boundary, keep the earlier +range unchanged or explicitly replace the affected style only when that is the +intended design; expanding a range can overwrite the next range's formatting. +Do not retry an unchanged request that reports this native limitation. + +Variable `null` is verified after application. Paragraph indent and spacing apply +to the affected paragraphs, even when the requested range covers only part of +their text. Figma may retain a paragraph alias after unlinking; TemPad tries to +preserve its current literal value without changing the next paragraph. If no +safe fallback exists, including on an empty paragraph, it reports failure rather +than claiming the binding was cleared. Follow the returned readback requirement +and inspect affected text and resources before another write. Then preserve the +binding or explicitly replace the affected paragraph as the intended design requires. + Hyperlinks support URLs and node targets. For a same-result target: ```json diff --git a/agent-plugin/src/skills/figma-design-to-code/references/assets-and-tokens.md b/agent-plugin/src/skills/figma-design-to-code/references/assets-and-tokens.md index c42d134b..9df81ff8 100644 --- a/agent-plugin/src/skills/figma-design-to-code/references/assets-and-tokens.md +++ b/agent-plugin/src/skills/figma-design-to-code/references/assets-and-tokens.md @@ -8,11 +8,13 @@ code, including references whose token metadata is missing. Follow the project's established asset and icon policy before TemPad delivery details. -- Download bytes only from a TemPad-provided `asset.url`. Never substitute a - public internet asset. +- Use the returned `asset.localPath` when accessible; otherwise download + `asset.url`. These are temporary Hub delivery locations: copy needed files + into the project's normal asset location. Never substitute a public internet + asset. - Treat assets as files to store or reference, not text evidence to parse. - If project policy forbids storing them, reference TemPad URLs only when the - user accepts the local-server dependency, and report it. + user accepts the temporary local-server dependency, and report it. - Treat emitted `` markup as design truth for structure, size, and instance color. Refactor delivery only through an existing project SVG path. @@ -61,8 +63,12 @@ Definitions and a consumer's rendered value need not have the same literal representation. Figma opacity variables store percentages (`50` means `0.5` opacity). Code output normalizes only the opacity consumer: resolved output uses the fraction, while unresolved output retains a token reference in `calc()` with -the matching number or length denominator. Preserve that expression; do not divide -the shared token definition, which may also serve spacing or other properties. +the matching number or length denominator. Length division in opacity or font-weight +expressions requires CSS typed arithmetic; preserve it only when the project's CSS +targets support it. For portable output, follow the compatibility warning by +rereading with `resolveTokens: true` and verifying the affected consumer literals. +Unitless division does not require this compatibility fallback. Do not divide the +shared token definition, which may also serve spacing or other properties. If definitions still contradict consumer output after accounting for aliases and effective modes, narrow the design evidence or ask which state is intended. For unavailable or ambiguous facts, follow the token warnings in diff --git a/agent-plugin/src/skills/figma-design-to-code/references/recovery.md b/agent-plugin/src/skills/figma-design-to-code/references/recovery.md index 6464e5ca..14cc8370 100644 --- a/agent-plugin/src/skills/figma-design-to-code/references/recovery.md +++ b/agent-plugin/src/skills/figma-design-to-code/references/recovery.md @@ -29,9 +29,13 @@ affected root, preserve any trustworthy parent composition: the reported condition before retrying. - **`depth-cap`**: keep the returned top-level composition, then use returned `data-hint-id` values for targeted child `get_code` calls. -- **`token-resolution`**: some references remain in code even with - `resolveTokens: true`. Treat those consumer values as unresolved; do not - replace them with a collection default or claim all values were inlined. +- **`token-resolution`**: read the message to distinguish unresolved consumer + values or units from a CSS typed-arithmetic compatibility warning. The latter + can occur with `resolveTokens: false` for length-valued opacity or font-weight + tokens. If the project needs portable literals, reread the exact target with + `resolveTokens: true` and verify the affected values became literals. References + that remain unresolved must not be replaced with collection defaults or reported + as successfully inlined. - **`token-definition`**: definitions or mode labels are missing or ambiguous. Pause decisions that depend on those facts; successfully resolved consumer literals remain usable under the [token fallback guidance](assets-and-tokens.md#tokens). diff --git a/agent-plugin/targets/claude/.claude-plugin/plugin.json b/agent-plugin/targets/claude/.claude-plugin/plugin.json index d8530323..52ab68e6 100644 --- a/agent-plugin/targets/claude/.claude-plugin/plugin.json +++ b/agent-plugin/targets/claude/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tempad-dev", - "version": "0.5.1", + "version": "1.0.0", "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "author": { "name": "TemPad Dev" diff --git a/agent-plugin/targets/claude/.mcp.json b/agent-plugin/targets/claude/.mcp.json index 3789c234..97b654af 100644 --- a/agent-plugin/targets/claude/.mcp.json +++ b/agent-plugin/targets/claude/.mcp.json @@ -4,7 +4,8 @@ "command": "npx", "args": [ "-y", - "@tempad-dev/mcp@latest" + "@tempad-dev/mcp@latest", + "--runtime-updates=stable" ] } } diff --git a/agent-plugin/targets/claude/CHANGELOG.md b/agent-plugin/targets/claude/CHANGELOG.md index 433117c6..f144f70a 100644 --- a/agent-plugin/targets/claude/CHANGELOG.md +++ b/agent-plugin/targets/claude/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0 + +- Enabled verified runtime downloads and compatible Hub updates between operations on macOS, + Linux and Windows with MCP 1.0.0. Claude requires a verified session and current lifecycle + hooks; development checkouts keep their bundled runtime. +- Retried interrupted Claude lifecycle messages without repeating their effects, including + during runtime switches. Preserved Unicode text and tolerated malformed Hub replies. +- Kept development Claude hooks connected to their own checkout while a release runtime is running. +- Clarified recovery after failed or uncertain canvas writes, native text binding limits, + supported text entities, and browser compatibility for token-based CSS. Unsupported entity + warnings require extension 1.0.0 or later. +- Corrected component sizing, asset storage, shadow inspection, and Chinese setup guidance. + ## 0.5.1 - Fixed Claude session binding so Figma Stop reaches the correct conversation. diff --git a/agent-plugin/targets/claude/README.md b/agent-plugin/targets/claude/README.md index b6703c10..031c96e0 100644 --- a/agent-plugin/targets/claude/README.md +++ b/agent-plugin/targets/claude/README.md @@ -76,7 +76,8 @@ skill installations should also remove the old skill after installing the new on The standalone package retains the former name as a generated alias for one extension store release cycle so setup commands in older installed extensions continue to work. -Use TemPad Dev extension **0.23.4 or later** and MCP server **0.11.0 or later**. +For the complete 1.0 release, update the TemPad Dev extension, MCP server, and +Agent Plugin (or its standalone skills) to **1.0.0 or later**. Node.js **22.x, 24.x, or 26+** is required for the MCP server. Multi-node reads and consumer-aware token resolution require matching protocol 15 Hub and extension builds. MCP 0.11.0 also supports extension 0.22.0 through protocol 13, preserving @@ -84,13 +85,17 @@ existing reads, sessions, tasks, and canvas writes during the store rollout. New prompt an extension upgrade; results identify older selection and token semantics. MCP 0.10.0 and later use `manage_design_task` in place of the older lifecycle tools. -1. Update the browser extension to 0.23.4 or later and reload the Figma tab. Version 0.23.4 includes packaged rewrite rules and SVG placement fixes; multi-node reads arrived in 0.23.0. +1. Update the browser extension to 1.0.0 or later and reload the Figma tab. 2. Update the installed plugin through the client or installer used originally. With standalone setup, update both `figma-design-to-code` and `design-in-figma`. 3. Keep the release MCP configuration on `@tempad-dev/mcp@latest`; replace any previous - `@alpha` or fixed alpha version. Reconnect the MCP client and start a new task so it loads the - updated tools and skills. If a stale Hub is reported, close tasks using the old MCP server - before reconnecting. + `@alpha` or fixed alpha version. Plugin 1.0.0 enables verified runtime downloads with MCP + 1.0.0 or later on macOS, Linux and Windows. Compatible Hub updates switch automatically during idle MCP + sessions with waiting support; Claude also requires a verified session and receipt/retry-capable hooks. + When upgrading from an unmanaged Hub, disconnect all TemPad MCP + clients once, then restart their MCP servers. Edge processes, installed plugins and incompatible + runtime changes still require their normal update/restart. See the + [runtime update controls](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.md#runtime-updates-100). 4. Open TemPad Dev, enable **MCP access**, and click the MCP badge in the intended Figma tab when a session choice is needed. The badge selects the default file; agents can also discover connected sessions and target independent reads by exact session ID. diff --git a/agent-plugin/targets/claude/README.zh-Hans.md b/agent-plugin/targets/claude/README.zh-Hans.md index 95a300f7..b8ddafe9 100644 --- a/agent-plugin/targets/claude/README.zh-Hans.md +++ b/agent-plugin/targets/claude/README.zh-Hans.md @@ -66,6 +66,9 @@ claude plugin install tempad-dev@tempad-dev 设计任务的状态栏显示 agent 状态、Stop/Done 和评论入口。Stop 会立即阻止当前任务继续写入, 在执行中的操作结束后永久取消该任务;重新连接也不会恢复它。后续设计工作需要明确开始新任务。 +正常配置只需完成 TemPad Dev 扩展设置并安装本插件,无需另行配置控制地址、 +环境变量或辅助服务。 + 评论目前仅支持通过兼容 Codex App 的原生会话通道发送。Queue 将整批评论交给宿主的原生队列, 等待会话可以执行时再处理。宿主确认接收后,TemPad Dev 会清空本批评论和标记、停止发送指示, 并允许继续输入下一批;这表示已接收,不表示 agent 已完成修改。如果原生入队不可用, @@ -89,7 +92,10 @@ Codex App 的任务绑定和状态同步使用 MCP 元数据及原生 IPC,不 宿主不可用时,本地取消仍然生效。Claude 保留生命周期和 Stop 通知 hooks;Claude、Codex CLI 等尚未接入原生投递的客户端提供任务状态和 Stop/Done,但不显示评论入口,已有草稿不会删除。 Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂停可用新的 lease epoch 恢复, -恢复后必须先重新读取画布才能写入;取消的任务不能恢复。详见 +恢复后必须先重新读取画布才能写入;取消的任务不能恢复。Agent 会遵守 Stop,不会自动创建 +替代任务;必要或用户要求的后续设计工作可以显式开始新任务,无需另行在 Figma 中解锁。 +只有实际持有目标会话的宿主确认支持时,才会启用 Codex 原生投递,无需手动配置连接。 +当前验证范围详见 [任务与客户端设计](https://github.com/ecomfe/tempad-dev/blob/main/docs/extension/mcp-design-tasks.md)。 原生适配器在 macOS/Linux 上使用 Unix socket,在 Windows 上使用 Codex 的本机 Named Pipe。 @@ -97,6 +103,9 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 自动执行和完整的已安装插件/Figma UI 流程仍待实测;Windows/Linux 的证据限于源码检查和 自动化测试,尚不能据此宣称完整宿主支持。 +Agent 会在对话中报告结果并附上 Figma 链接,用户可以继续提出后续要求。 +任务工具返回文本和结构化数据。 + ## 升级 设计 skill 已更名为 `design-in-figma`,与展示名 **Design in Figma** 一致。 @@ -104,19 +113,23 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 安装新版后还应移除旧名称的 skill。 独立 skill 包会保留旧名称的生成兼容入口一个扩展商店发布周期,让已安装旧扩展中的配置命令继续有效。 -请使用 TemPad Dev 扩展 **0.23.4 或更高版本**和 MCP server **0.11.0 或更高版本**。 +如需使用完整的 1.0 发布,请将 TemPad Dev 扩展、MCP server、Agent Plugin(或其独立 skill) +均更新到 **1.0.0 或更高版本**。 MCP server 要求 Node.js **22.x、24.x 或 26+**。 多节点读取和按消费节点解析 token 要求 Hub 与扩展均使用匹配的 protocol 15 构建。 MCP 0.11.0 同时通过 protocol 13 兼容扩展 0.22.0,在商店更新期间保留既有读取、会话、任务和画布写入。 新版读取选项会提示升级扩展,响应会明确标注旧版选区和 token 语义。 MCP 0.10.0 及之后版本使用 `manage_design_task` 代替旧的生命周期工具。 -1. 将浏览器扩展更新到 0.23.4 或更高版本并重新加载 Figma 标签页。0.23.4 包含内置 rewrite 规则和 SVG 定位修复;多节点读取从 0.23.0 开始提供。 +1. 将浏览器扩展更新到 1.0.0 或更高版本并重新加载 Figma 标签页。 2. 通过原先使用的客户端或安装器更新 plugin。独立配置时,请同时更新 `figma-design-to-code` 和 `design-in-figma`。 3. 正式版 MCP 配置使用 `@tempad-dev/mcp@latest`;请替换旧的 `@alpha` 或固定 alpha 版本。 - 重新连接 MCP client 并新建任务,以加载更新后的工具和 skill。若提示 Hub 过期,请先关闭 - 使用旧 MCP server 的任务,再重新连接。 + Plugin 1.0.0 配合 MCP 1.0.0 或更高版本,会在 macOS、Linux 和 Windows 上启用经过校验的后台下载, + 在支持切换等待的 MCP 会话空闲时自动切换兼容的 Hub。Claude 还要求已验证的会话和支持回执与重试的 hook。 + 从未启用更新的 Hub 升级时,需先断开所有 TemPad MCP client, + 再重启其 MCP server,一次即可。入口进程、已安装插件和不兼容的运行时改动仍需按原方式更新或重启。 + 详见[运行时更新控制](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.zh-Hans.md#运行时更新100)。 4. 打开 TemPad Dev 并启用 **MCP access**;需要选择会话时,点击目标 Figma 标签页内的 MCP badge。该 badge 选择默认文件;agent 也可以发现已连接会话,并通过精确 session ID 指定独立读取的目标。 diff --git a/agent-plugin/targets/claude/clients/claude/hooks.json b/agent-plugin/targets/claude/clients/claude/hooks.json index 173f8eb1..6571c919 100644 --- a/agent-plugin/targets/claude/clients/claude/hooks.json +++ b/agent-plugin/targets/claude/clients/claude/hooks.json @@ -1,5 +1,5 @@ { - "description": "Bind Figma tasks, collect user feedback at host lifecycle boundaries, and honor task Stop.", + "description": "Bind Figma tasks, report host lifecycle, and deliver task Stop notices.", "hooks": { "Stop": [ { diff --git a/agent-plugin/targets/claude/clients/shared/lifecycle.mjs b/agent-plugin/targets/claude/clients/shared/lifecycle.mjs index 6327b2f9..c5af9ed9 100644 --- a/agent-plugin/targets/claude/clients/shared/lifecycle.mjs +++ b/agent-plugin/targets/claude/clients/shared/lifecycle.mjs @@ -1,19 +1,28 @@ // Installed hooks exchange lifecycle identity and Stop notices with the existing Hub. They never // launch an agent, poll a model, change host trust, or unlock a stopped Figma file. import { randomUUID } from 'node:crypto' +import { readFileSync } from 'node:fs' import { connect } from 'node:net' -import { join } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import process from 'node:process' import { clearTimeout, setTimeout } from 'node:timers' +import { pathToFileURL } from 'node:url' import { assertTrustedSocket, resolveDefaultRoot } from './runtime-paths.mjs' +import { + checkoutProfile, + resolveRuntimeProfile, + runtimeDirectories, + runtimeSocketPath +} from './runtime-profile.mjs' let socket const deadline = setTimeout(() => process.exit(0), 750) try { let text = '' + process.stdin.setEncoding('utf8') for await (const chunk of process.stdin) { - text += String(chunk) + text += chunk if (text.length > 1_000_000) process.exit(0) } const input = JSON.parse(text) @@ -54,60 +63,128 @@ try { kind: process.argv[2] === 'claude' ? 'claude' : 'codex', sessionId: input.session_id } - const runtimeDir = process.env.TEMPAD_MCP_RUNTIME_DIR || join(resolveDefaultRoot(), 'run') - const path = - process.platform === 'win32' ? '\\\\.\\pipe\\tempad-mcp' : join(runtimeDir, 'mcp.sock') - assertTrustedSocket(path) - socket = connect(path) - const pending = new Map() - let sequence = 0 - let buffer = '' - socket.on('data', (data) => { - buffer += data.toString() - if (buffer.length > 1_000_000) { - socket.destroy() - return - } - let index - while ((index = buffer.indexOf('\n')) >= 0) { - const line = buffer.slice(0, index) - buffer = buffer.slice(index + 1) - let message - try { - message = JSON.parse(line) - } catch { - continue - } - const entry = pending.get(message.id) - if (!entry) continue - pending.delete(message.id) - if (message.error) entry.reject(new Error('The Hub does not support this hook request.')) - else entry.resolve(message.result) + let profile = { kind: 'release' } + let path + if (process.argv[3] === '--runtime-profile') { + const descriptorPath = process.argv[4] + const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')) + if (typeof descriptor.serverName !== 'string' || !descriptor.serverName) + throw new Error('Missing checkout MCP server name.') + // Read the same generated configuration as MCP/preflight so directory overrides cannot drift. + const pluginRoot = resolve(dirname(descriptorPath), '../..') + const config = JSON.parse(readFileSync(join(pluginRoot, '.mcp.json'), 'utf8')).mcpServers?.[ + descriptor.serverName + ] + if ( + typeof config?.env?.TEMPAD_MCP_DEV_CHECKOUT !== 'string' || + typeof config?.args?.[0] !== 'string' + ) + throw new Error('Missing checkout MCP configuration.') + const runtimeDir = config.env.TEMPAD_MCP_RUNTIME_DIR + if ( + runtimeDir !== undefined && + (typeof runtimeDir !== 'string' || (runtimeDir && !isAbsolute(runtimeDir))) + ) + throw new Error('Checkout runtime directory must be an absolute path.') + const env = { + ...process.env, + TEMPAD_MCP_DEV_CHECKOUT: config.env.TEMPAD_MCP_DEV_CHECKOUT, + ...(runtimeDir !== undefined ? { TEMPAD_MCP_RUNTIME_DIR: runtimeDir } : {}) } - }) - const disconnected = () => { - for (const entry of pending.values()) entry.reject(new Error('The Hub hook connection closed.')) - pending.clear() + profile = resolveRuntimeProfile(env, pathToFileURL(resolve(pluginRoot, config.args[0]))) + if ( + profile.kind !== 'checkout' || + profile.id !== descriptor.id || + checkoutProfile(descriptor.checkout).id !== profile.id + ) + throw new Error('Invalid checkout hook profile.') + path = runtimeSocketPath(runtimeDirectories(env, profile).runtime, profile) + } else { + const runtimeDir = process.env.TEMPAD_MCP_RUNTIME_DIR || join(resolveDefaultRoot(), 'run') + path = process.platform === 'win32' ? '\\\\.\\pipe\\tempad-mcp' : join(runtimeDir, 'mcp.sock') } - socket.on('error', disconnected) - socket.on('close', disconnected) - await new Promise((resolve, reject) => { - socket.once('connect', resolve) - socket.once('error', reject) - }) - const request = (method, params) => - new Promise((resolve, reject) => { - const id = ++sequence - pending.set(id, { resolve, reject }) - socket.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') - }) - const result = await request('tempad/client-hook', { + assertTrustedSocket(path) + const lifecycle = { ...identity, event, invocationId: randomUUID(), ...(taskId ? { taskId } : {}), ...(typeof input.turn_id === 'string' ? { turnId: input.turn_id } : {}) - }) + } + const params = { ...lifecycle, version: 2, profile, issuedAt: Date.now() } + let legacy = false + async function request() { + socket = connect(path).setEncoding('utf8') + const connection = socket + let timer + try { + return await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('Hook reply timed out.')), 200) + let buffer = '' + socket.on('data', (data) => { + buffer += data + if (buffer.length > 1_000_000) { + socket.destroy() + return + } + let index + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + let message + try { + message = JSON.parse(line) + } catch { + continue + } + if (message?.id !== 1) continue + if (message.error) + reject( + Object.assign(new Error('The Hub does not support this hook request.'), { + code: message.error.code + }) + ) + else resolve(message.result) + return + } + }) + socket.on('error', reject) + socket.once('close', () => reject(new Error('The Hub hook connection closed.'))) + socket.once('connect', () => { + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/client-hook', + params: legacy ? lifecycle : params + }) + '\n' + ) + }) + }) + } finally { + clearTimeout(timer) + connection.destroy() + } + } + let result + for (let attempt = 0; attempt < 4; attempt++) { + try { + result = await request() + if (legacy) break + const receipt = result?.receipt + if (receipt?.version !== 2 || receipt.invocationId !== params.invocationId) + throw new Error('Invalid lifecycle receipt.') + if (receipt.state === 'applied') break + if (receipt.state !== 'accepted') throw new Error('Invalid lifecycle receipt state.') + result = undefined + } catch (error) { + result = undefined + // Only a definite old-schema rejection permits fallback. A lost reply + // retries the same v2 invocation, never a second lifecycle effect. + if (error?.code === -32602 && !legacy) legacy = true + } + await new Promise((resolve) => setTimeout(resolve, 30)) + } // Old Hubs may still offer comment batches; never emit or acknowledge those. if ( !result?.receiptId && diff --git a/agent-plugin/targets/claude/clients/shared/runtime-profile.d.mts b/agent-plugin/targets/claude/clients/shared/runtime-profile.d.mts new file mode 100644 index 00000000..e732bf4e --- /dev/null +++ b/agent-plugin/targets/claude/clients/shared/runtime-profile.d.mts @@ -0,0 +1,17 @@ +type RuntimeProfile = { kind: 'release' } | { kind: 'checkout'; id: string } +export function checkoutProfile(checkout: string): Extract +export function resolveRuntimeProfile( + env?: NodeJS.ProcessEnv, + entryUrl?: string | URL +): RuntimeProfile +export function runtimeDirectories( + env?: NodeJS.ProcessEnv, + profile?: RuntimeProfile, + options?: { tmpdir?: string; homedir?: string; uid?: number; prepare?: boolean } +): { root: string | undefined; runtime: string; log: string; assets: string; state: string } +export function runtimeSocketPath( + runtime: string, + profile?: RuntimeProfile, + platform?: NodeJS.Platform, + home?: string +): string diff --git a/agent-plugin/targets/claude/clients/shared/runtime-profile.mjs b/agent-plugin/targets/claude/clients/shared/runtime-profile.mjs new file mode 100644 index 00000000..c966707f --- /dev/null +++ b/agent-plugin/targets/claude/clients/shared/runtime-profile.mjs @@ -0,0 +1,104 @@ +import { Buffer } from 'node:buffer' +import { createHash } from 'node:crypto' +import { realpathSync, statSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { prepareDefaultRoot, resolveDefaultRoot } from './runtime-paths.mjs' + +function hash(value) { + return createHash('sha256').update(value).digest('hex').slice(0, 16) +} + +export function checkoutProfile(checkout) { + if (!isAbsolute(checkout)) throw new Error('TEMPAD_MCP_DEV_CHECKOUT must be an absolute path.') + const root = realpathSync(checkout) + if (!statSync(root).isDirectory()) throw new Error('The checkout must be a directory.') + return { kind: 'checkout', id: hash(root) } +} + +export function resolveRuntimeProfile(env = process.env, entryUrl) { + if (env.TEMPAD_MCP_DEV_CHECKOUT === undefined) return { kind: 'release' } + const profile = checkoutProfile(env.TEMPAD_MCP_DEV_CHECKOUT) + if (entryUrl) { + const root = realpathSync(env.TEMPAD_MCP_DEV_CHECKOUT) + const dist = join(root, 'packages/mcp-server/dist') + for (const name of ['cli.mjs', 'hub.mjs']) { + const path = join(dist, name) + try { + if (realpathSync(path) === path && statSync(path).isFile()) continue + } catch (cause) { + throw new Error(`Missing checkout runtime: ${path}. Run pnpm build:mcp in the checkout.`, { + cause + }) + } + throw new Error(`Invalid checkout runtime: ${path}. Build the checkout before starting MCP.`) + } + if (dirname(realpathSync(fileURLToPath(entryUrl))) !== dist) + throw new Error('The MCP entry point does not belong to TEMPAD_MCP_DEV_CHECKOUT.') + } + return profile +} + +export function runtimeDirectories( + env = process.env, + profile = resolveRuntimeProfile(env), + options = {} +) { + const temporary = options.tmpdir ?? tmpdir() + const home = options.homedir ?? homedir() + const user = options.uid ?? process.getuid?.() ?? hash(home) + const checkout = profile.kind === 'checkout' + const needsRoot = + !env.TEMPAD_MCP_RUNTIME_DIR || !env.TEMPAD_MCP_LOG_DIR || !env.TEMPAD_MCP_ASSET_DIR + let root + if (needsRoot) { + root = checkout + ? join(temporary, `tpd-${user}-${profile.id}`) + : (options.prepare ? prepareDefaultRoot : resolveDefaultRoot)(temporary) + } + // Explicit directories remain usable for isolated tests, but never merge checkout and release. + const directory = (override, name) => { + if (!override) return join(root, name) + return checkout ? join(override, `checkout-${profile.id}`) : override + } + const runtime = directory(env.TEMPAD_MCP_RUNTIME_DIR, 'run') + let state + if (!checkout && env.TEMPAD_MCP_STATE_DIR !== undefined) { + state = env.TEMPAD_MCP_STATE_DIR + } else if (env.TEMPAD_MCP_STATE_DIR) { + state = directory(env.TEMPAD_MCP_STATE_DIR, 'state') + } else if (env.TEMPAD_MCP_RUNTIME_DIR) { + state = join(runtime, 'state') + } else { + state = checkout + ? join(home, '.tempad-dev', 'checkouts', profile.id, 'state') + : join(home, '.tempad-dev', 'state') + } + return { + root, + runtime, + log: directory(env.TEMPAD_MCP_LOG_DIR, 'log'), + assets: directory(env.TEMPAD_MCP_ASSET_DIR, 'assets'), + state + } +} + +export function runtimeSocketPath( + runtime, + profile = { kind: 'release' }, + platform = process.platform, + home = homedir() +) { + if (platform !== 'win32') { + const socket = join(runtime, 'mcp.sock') + if (profile.kind === 'checkout' && Buffer.byteLength(socket) > 103) + throw new Error('Checkout socket path is too long. Choose a shorter TEMPAD_MCP_RUNTIME_DIR.') + return socket + } + return profile.kind === 'checkout' + ? `\\\\.\\pipe\\tempad-mcp-${hash(home)}-${profile.id}` + : '\\\\.\\pipe\\tempad-mcp' +} diff --git a/agent-plugin/targets/claude/skills/design-in-figma/SKILL.md b/agent-plugin/targets/claude/skills/design-in-figma/SKILL.md index 9278d7b3..28d6ac55 100644 --- a/agent-plugin/targets/claude/skills/design-in-figma/SKILL.md +++ b/agent-plugin/targets/claude/skills/design-in-figma/SKILL.md @@ -134,7 +134,9 @@ screen or section boundaries and carry shared roles coherently across them. Inspect every `apply_canvas` result, including warnings. Repair each observed unintended defect or disclose why it remains. A local validation failure calls for a local payload correction; it does not justify discarding a working root -or simplifying away the intended content. Open pixels again after the final +or simplifying away the intended content. If a write times out, loses its result, +or cannot verify rollback, follow [local recovery](references/editing.md#recover-locally) +before another write. Open pixels again after the final material write, covering every materially distinct screen. Verify native facts with `get_structure` when identity, placement, editability, or representation matters. Opened pixels prove visual access, not good judgment; a structural pass diff --git a/agent-plugin/targets/claude/skills/design-in-figma/references/canvas-html.md b/agent-plugin/targets/claude/skills/design-in-figma/references/canvas-html.md index ef11c260..a8428ac3 100644 --- a/agent-plugin/targets/claude/skills/design-in-figma/references/canvas-html.md +++ b/agent-plugin/targets/claude/skills/design-in-figma/references/canvas-html.md @@ -68,10 +68,14 @@ reveals issues one at a time. unlinks that field. - A `span` contains only text and `
` or `
` line breaks. Use `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is - literal unless it forms a semicolon-terminated entity; supported entities - decode. Canvas typography does not inherit from a parent `div`: put font and - other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, - borders, corners, and box shadows on a parent `div`. + literal unless it forms a semicolon-terminated entity. Numeric decimal/hex + entities and these named entities decode: `amp`, `apos`, `bull`, `copy`, `gt`, + `hellip`, `ldquo`, `lsquo`, `lt`, `mdash`, `middot`, `nbsp`, `ndash`, `quot`, + `rdquo`, `reg`, `rsquo`, `trade`. Other names remain literal; extension 1.0.0+ + also reports an `unknown-html-entity` warning. Use the Unicode character or a + numeric entity instead. Canvas typography does not inherit from + a parent `div`: put font and other text utilities on each `span`/TEXT node. + Put flex/grid, gaps, padding, borders, corners, and box shadows on a parent `div`. - A component tag is childless, includes its returned `data-ref`, and accepts returned props plus the shared class, identity, variable, and style attributes. diff --git a/agent-plugin/targets/claude/skills/design-in-figma/references/component-authoring.md b/agent-plugin/targets/claude/skills/design-in-figma/references/component-authoring.md index 99573887..b747048e 100644 --- a/agent-plugin/targets/claude/skills/design-in-figma/references/component-authoring.md +++ b/agent-plugin/targets/claude/skills/design-in-figma/references/component-authoring.md @@ -241,7 +241,7 @@ the call returns the set as `rootNodeId`, this creates Default and Hover: ```json { "mode": "create", - "markup": "
", + "markup": "
", "native": { "screen/default": { "component": { "id": "ComponentSetID:created-button-set" } diff --git a/agent-plugin/targets/claude/skills/design-in-figma/references/editing.md b/agent-plugin/targets/claude/skills/design-in-figma/references/editing.md index f8653bd5..f020d91e 100644 --- a/agent-plugin/targets/claude/skills/design-in-figma/references/editing.md +++ b/agent-plugin/targets/claude/skills/design-in-figma/references/editing.md @@ -32,10 +32,30 @@ native references needed for the intended change. ## Recover locally Read the entire mutation result. For a rejected payload, correct all reported -issues together without changing the design to fit the error. A verification -failure is rolled back by TemPad; do not assume a partial successful edit. -For an unknown transport outcome, read the exact target before retrying a create -or removal so an uncertain response does not become a duplicate mutation. +issues together without changing the design to fit the error. An execution failure +requires inspecting its reported outcome before retrying. TemPad attempts rollback +after a failed write. Only an explicit no-write outcome (`CANVAS_APPLY_ABORTED` or +`EXTENSION_DELIVERY_FAILED`) skips file readback. Other diagnostics can originate +inside a mutation, especially with older extensions. Follow the returned readback +requirement: rollback checks cover only part of native state. +Automatic Undo can be skipped when this attempt has no confirmed remaining change. +Do not suggest manual Undo in that case: it could remove an earlier successful edit. + +A timeout, lost result, or unverified rollback leaves the canvas outcome uncertain. +Do not replay the write. Wait for a pending operation to settle; reload a stuck +runtime only when the tool calls for it. Then read the affected surviving node with +`get_structure` or `get_code`, carrying the current task and epoch. If the node was +removed, use `get_structure` with its exact page ID. An empty page snapshot counts; +empty selection, failed-node-only results, screenshots, and resource catalogs do not +satisfy readback. Inspect the returned state before deciding on a repair or retry. +If the failed request also changed local variables or styles, inspect those affected +resources with `get_design_system`; a canvas read alone does not verify their state. +If it cannot establish a safe next change, ask the user to inspect the canvas. + +Readback is also required after task resume or reconnection. A file's unknown-write +requirement survives Hub restarts, Stop, and a fresh task; none of those replaces the +read. A notice that readback is still required means the last read did not establish +usable canvas state. Stop cancellation remains in force independently of readback. Repair warnings where they occur. Replace a whole root only when an observed structural defect requires it and the complete intended content can be diff --git a/agent-plugin/targets/claude/skills/design-in-figma/references/paints-effects.md b/agent-plugin/targets/claude/skills/design-in-figma/references/paints-effects.md index c32b82f2..2ce41f9d 100644 --- a/agent-plugin/targets/claude/skills/design-in-figma/references/paints-effects.md +++ b/agent-plugin/targets/claude/skills/design-in-figma/references/paints-effects.md @@ -35,9 +35,11 @@ Named scales such as `shadow-md` are theme references, not portable geometry: Never assume Tailwind defaults or create a token only to resolve a named class. `shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. -Treat an outer shadow's rendered halo as part of the composition. Inspect the -final PNG beyond the root edges; visible granular or noisy fringe, or a halo -that dominates the captured bounds, is a defect even when the frame itself is +Treat an outer shadow's rendered halo as part of the composition. Exact-node +screenshots may crop to the node's bounds; inspect an enclosing composition with +enough surrounding space to judge the halo. Its absence from a cropped PNG is +not evidence that it is absent on canvas. Visible granular or noisy fringe, or a +halo that dominates the composition, is a defect even when the frame itself is intact. Preserve intended depth by tightening blur, spread, or opacity or using smaller layered shadows, then recheck. Do not flatten established material treatment merely to hide the defect. diff --git a/agent-plugin/targets/claude/skills/design-in-figma/references/rich-text.md b/agent-plugin/targets/claude/skills/design-in-figma/references/rich-text.md index 3e26cc97..3ac85e5e 100644 --- a/agent-plugin/targets/claude/skills/design-in-figma/references/rich-text.md +++ b/agent-plugin/targets/claude/skills/design-in-figma/references/rich-text.md @@ -38,6 +38,22 @@ Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a range variable. `null` unlinks supported styles or hyperlinks; omission preserves. +Numeric font size, line height, and letter spacing can also affect a `\n` +immediately after a range. Give adjacent ranges explicit, consistent ownership. +If an error identifies a retained Text style at that boundary, keep the earlier +range unchanged or explicitly replace the affected style only when that is the +intended design; expanding a range can overwrite the next range's formatting. +Do not retry an unchanged request that reports this native limitation. + +Variable `null` is verified after application. Paragraph indent and spacing apply +to the affected paragraphs, even when the requested range covers only part of +their text. Figma may retain a paragraph alias after unlinking; TemPad tries to +preserve its current literal value without changing the next paragraph. If no +safe fallback exists, including on an empty paragraph, it reports failure rather +than claiming the binding was cleared. Follow the returned readback requirement +and inspect affected text and resources before another write. Then preserve the +binding or explicitly replace the affected paragraph as the intended design requires. + Hyperlinks support URLs and node targets. For a same-result target: ```json diff --git a/agent-plugin/targets/claude/skills/figma-design-to-code/references/assets-and-tokens.md b/agent-plugin/targets/claude/skills/figma-design-to-code/references/assets-and-tokens.md index c42d134b..9df81ff8 100644 --- a/agent-plugin/targets/claude/skills/figma-design-to-code/references/assets-and-tokens.md +++ b/agent-plugin/targets/claude/skills/figma-design-to-code/references/assets-and-tokens.md @@ -8,11 +8,13 @@ code, including references whose token metadata is missing. Follow the project's established asset and icon policy before TemPad delivery details. -- Download bytes only from a TemPad-provided `asset.url`. Never substitute a - public internet asset. +- Use the returned `asset.localPath` when accessible; otherwise download + `asset.url`. These are temporary Hub delivery locations: copy needed files + into the project's normal asset location. Never substitute a public internet + asset. - Treat assets as files to store or reference, not text evidence to parse. - If project policy forbids storing them, reference TemPad URLs only when the - user accepts the local-server dependency, and report it. + user accepts the temporary local-server dependency, and report it. - Treat emitted `` markup as design truth for structure, size, and instance color. Refactor delivery only through an existing project SVG path. @@ -61,8 +63,12 @@ Definitions and a consumer's rendered value need not have the same literal representation. Figma opacity variables store percentages (`50` means `0.5` opacity). Code output normalizes only the opacity consumer: resolved output uses the fraction, while unresolved output retains a token reference in `calc()` with -the matching number or length denominator. Preserve that expression; do not divide -the shared token definition, which may also serve spacing or other properties. +the matching number or length denominator. Length division in opacity or font-weight +expressions requires CSS typed arithmetic; preserve it only when the project's CSS +targets support it. For portable output, follow the compatibility warning by +rereading with `resolveTokens: true` and verifying the affected consumer literals. +Unitless division does not require this compatibility fallback. Do not divide the +shared token definition, which may also serve spacing or other properties. If definitions still contradict consumer output after accounting for aliases and effective modes, narrow the design evidence or ask which state is intended. For unavailable or ambiguous facts, follow the token warnings in diff --git a/agent-plugin/targets/claude/skills/figma-design-to-code/references/recovery.md b/agent-plugin/targets/claude/skills/figma-design-to-code/references/recovery.md index 6464e5ca..14cc8370 100644 --- a/agent-plugin/targets/claude/skills/figma-design-to-code/references/recovery.md +++ b/agent-plugin/targets/claude/skills/figma-design-to-code/references/recovery.md @@ -29,9 +29,13 @@ affected root, preserve any trustworthy parent composition: the reported condition before retrying. - **`depth-cap`**: keep the returned top-level composition, then use returned `data-hint-id` values for targeted child `get_code` calls. -- **`token-resolution`**: some references remain in code even with - `resolveTokens: true`. Treat those consumer values as unresolved; do not - replace them with a collection default or claim all values were inlined. +- **`token-resolution`**: read the message to distinguish unresolved consumer + values or units from a CSS typed-arithmetic compatibility warning. The latter + can occur with `resolveTokens: false` for length-valued opacity or font-weight + tokens. If the project needs portable literals, reread the exact target with + `resolveTokens: true` and verify the affected values became literals. References + that remain unresolved must not be replaced with collection defaults or reported + as successfully inlined. - **`token-definition`**: definitions or mode labels are missing or ambiguous. Pause decisions that depend on those facts; successfully resolved consumer literals remain usable under the [token fallback guidance](assets-and-tokens.md#tokens). diff --git a/agent-plugin/targets/codex/.codex-plugin/plugin.json b/agent-plugin/targets/codex/.codex-plugin/plugin.json index 74f1d552..99ed8faf 100644 --- a/agent-plugin/targets/codex/.codex-plugin/plugin.json +++ b/agent-plugin/targets/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tempad-dev", - "version": "0.5.1", + "version": "1.0.0", "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "author": { "name": "TemPad Dev" diff --git a/agent-plugin/targets/codex/.mcp.json b/agent-plugin/targets/codex/.mcp.json index 49508b10..4b7a5d8f 100644 --- a/agent-plugin/targets/codex/.mcp.json +++ b/agent-plugin/targets/codex/.mcp.json @@ -4,7 +4,8 @@ "command": "npx", "args": [ "-y", - "@tempad-dev/mcp@latest" + "@tempad-dev/mcp@latest", + "--runtime-updates=stable" ], "tool_timeout_sec": 300 } diff --git a/agent-plugin/targets/codex/CHANGELOG.md b/agent-plugin/targets/codex/CHANGELOG.md index 433117c6..f144f70a 100644 --- a/agent-plugin/targets/codex/CHANGELOG.md +++ b/agent-plugin/targets/codex/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0 + +- Enabled verified runtime downloads and compatible Hub updates between operations on macOS, + Linux and Windows with MCP 1.0.0. Claude requires a verified session and current lifecycle + hooks; development checkouts keep their bundled runtime. +- Retried interrupted Claude lifecycle messages without repeating their effects, including + during runtime switches. Preserved Unicode text and tolerated malformed Hub replies. +- Kept development Claude hooks connected to their own checkout while a release runtime is running. +- Clarified recovery after failed or uncertain canvas writes, native text binding limits, + supported text entities, and browser compatibility for token-based CSS. Unsupported entity + warnings require extension 1.0.0 or later. +- Corrected component sizing, asset storage, shadow inspection, and Chinese setup guidance. + ## 0.5.1 - Fixed Claude session binding so Figma Stop reaches the correct conversation. diff --git a/agent-plugin/targets/codex/README.md b/agent-plugin/targets/codex/README.md index b6703c10..031c96e0 100644 --- a/agent-plugin/targets/codex/README.md +++ b/agent-plugin/targets/codex/README.md @@ -76,7 +76,8 @@ skill installations should also remove the old skill after installing the new on The standalone package retains the former name as a generated alias for one extension store release cycle so setup commands in older installed extensions continue to work. -Use TemPad Dev extension **0.23.4 or later** and MCP server **0.11.0 or later**. +For the complete 1.0 release, update the TemPad Dev extension, MCP server, and +Agent Plugin (or its standalone skills) to **1.0.0 or later**. Node.js **22.x, 24.x, or 26+** is required for the MCP server. Multi-node reads and consumer-aware token resolution require matching protocol 15 Hub and extension builds. MCP 0.11.0 also supports extension 0.22.0 through protocol 13, preserving @@ -84,13 +85,17 @@ existing reads, sessions, tasks, and canvas writes during the store rollout. New prompt an extension upgrade; results identify older selection and token semantics. MCP 0.10.0 and later use `manage_design_task` in place of the older lifecycle tools. -1. Update the browser extension to 0.23.4 or later and reload the Figma tab. Version 0.23.4 includes packaged rewrite rules and SVG placement fixes; multi-node reads arrived in 0.23.0. +1. Update the browser extension to 1.0.0 or later and reload the Figma tab. 2. Update the installed plugin through the client or installer used originally. With standalone setup, update both `figma-design-to-code` and `design-in-figma`. 3. Keep the release MCP configuration on `@tempad-dev/mcp@latest`; replace any previous - `@alpha` or fixed alpha version. Reconnect the MCP client and start a new task so it loads the - updated tools and skills. If a stale Hub is reported, close tasks using the old MCP server - before reconnecting. + `@alpha` or fixed alpha version. Plugin 1.0.0 enables verified runtime downloads with MCP + 1.0.0 or later on macOS, Linux and Windows. Compatible Hub updates switch automatically during idle MCP + sessions with waiting support; Claude also requires a verified session and receipt/retry-capable hooks. + When upgrading from an unmanaged Hub, disconnect all TemPad MCP + clients once, then restart their MCP servers. Edge processes, installed plugins and incompatible + runtime changes still require their normal update/restart. See the + [runtime update controls](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.md#runtime-updates-100). 4. Open TemPad Dev, enable **MCP access**, and click the MCP badge in the intended Figma tab when a session choice is needed. The badge selects the default file; agents can also discover connected sessions and target independent reads by exact session ID. diff --git a/agent-plugin/targets/codex/README.zh-Hans.md b/agent-plugin/targets/codex/README.zh-Hans.md index 95a300f7..b8ddafe9 100644 --- a/agent-plugin/targets/codex/README.zh-Hans.md +++ b/agent-plugin/targets/codex/README.zh-Hans.md @@ -66,6 +66,9 @@ claude plugin install tempad-dev@tempad-dev 设计任务的状态栏显示 agent 状态、Stop/Done 和评论入口。Stop 会立即阻止当前任务继续写入, 在执行中的操作结束后永久取消该任务;重新连接也不会恢复它。后续设计工作需要明确开始新任务。 +正常配置只需完成 TemPad Dev 扩展设置并安装本插件,无需另行配置控制地址、 +环境变量或辅助服务。 + 评论目前仅支持通过兼容 Codex App 的原生会话通道发送。Queue 将整批评论交给宿主的原生队列, 等待会话可以执行时再处理。宿主确认接收后,TemPad Dev 会清空本批评论和标记、停止发送指示, 并允许继续输入下一批;这表示已接收,不表示 agent 已完成修改。如果原生入队不可用, @@ -89,7 +92,10 @@ Codex App 的任务绑定和状态同步使用 MCP 元数据及原生 IPC,不 宿主不可用时,本地取消仍然生效。Claude 保留生命周期和 Stop 通知 hooks;Claude、Codex CLI 等尚未接入原生投递的客户端提供任务状态和 Stop/Done,但不显示评论入口,已有草稿不会删除。 Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂停可用新的 lease epoch 恢复, -恢复后必须先重新读取画布才能写入;取消的任务不能恢复。详见 +恢复后必须先重新读取画布才能写入;取消的任务不能恢复。Agent 会遵守 Stop,不会自动创建 +替代任务;必要或用户要求的后续设计工作可以显式开始新任务,无需另行在 Figma 中解锁。 +只有实际持有目标会话的宿主确认支持时,才会启用 Codex 原生投递,无需手动配置连接。 +当前验证范围详见 [任务与客户端设计](https://github.com/ecomfe/tempad-dev/blob/main/docs/extension/mcp-design-tasks.md)。 原生适配器在 macOS/Linux 上使用 Unix socket,在 Windows 上使用 Codex 的本机 Named Pipe。 @@ -97,6 +103,9 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 自动执行和完整的已安装插件/Figma UI 流程仍待实测;Windows/Linux 的证据限于源码检查和 自动化测试,尚不能据此宣称完整宿主支持。 +Agent 会在对话中报告结果并附上 Figma 链接,用户可以继续提出后续要求。 +任务工具返回文本和结构化数据。 + ## 升级 设计 skill 已更名为 `design-in-figma`,与展示名 **Design in Figma** 一致。 @@ -104,19 +113,23 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 安装新版后还应移除旧名称的 skill。 独立 skill 包会保留旧名称的生成兼容入口一个扩展商店发布周期,让已安装旧扩展中的配置命令继续有效。 -请使用 TemPad Dev 扩展 **0.23.4 或更高版本**和 MCP server **0.11.0 或更高版本**。 +如需使用完整的 1.0 发布,请将 TemPad Dev 扩展、MCP server、Agent Plugin(或其独立 skill) +均更新到 **1.0.0 或更高版本**。 MCP server 要求 Node.js **22.x、24.x 或 26+**。 多节点读取和按消费节点解析 token 要求 Hub 与扩展均使用匹配的 protocol 15 构建。 MCP 0.11.0 同时通过 protocol 13 兼容扩展 0.22.0,在商店更新期间保留既有读取、会话、任务和画布写入。 新版读取选项会提示升级扩展,响应会明确标注旧版选区和 token 语义。 MCP 0.10.0 及之后版本使用 `manage_design_task` 代替旧的生命周期工具。 -1. 将浏览器扩展更新到 0.23.4 或更高版本并重新加载 Figma 标签页。0.23.4 包含内置 rewrite 规则和 SVG 定位修复;多节点读取从 0.23.0 开始提供。 +1. 将浏览器扩展更新到 1.0.0 或更高版本并重新加载 Figma 标签页。 2. 通过原先使用的客户端或安装器更新 plugin。独立配置时,请同时更新 `figma-design-to-code` 和 `design-in-figma`。 3. 正式版 MCP 配置使用 `@tempad-dev/mcp@latest`;请替换旧的 `@alpha` 或固定 alpha 版本。 - 重新连接 MCP client 并新建任务,以加载更新后的工具和 skill。若提示 Hub 过期,请先关闭 - 使用旧 MCP server 的任务,再重新连接。 + Plugin 1.0.0 配合 MCP 1.0.0 或更高版本,会在 macOS、Linux 和 Windows 上启用经过校验的后台下载, + 在支持切换等待的 MCP 会话空闲时自动切换兼容的 Hub。Claude 还要求已验证的会话和支持回执与重试的 hook。 + 从未启用更新的 Hub 升级时,需先断开所有 TemPad MCP client, + 再重启其 MCP server,一次即可。入口进程、已安装插件和不兼容的运行时改动仍需按原方式更新或重启。 + 详见[运行时更新控制](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.zh-Hans.md#运行时更新100)。 4. 打开 TemPad Dev 并启用 **MCP access**;需要选择会话时,点击目标 Figma 标签页内的 MCP badge。该 badge 选择默认文件;agent 也可以发现已连接会话,并通过精确 session ID 指定独立读取的目标。 diff --git a/agent-plugin/targets/codex/skills/design-in-figma/SKILL.md b/agent-plugin/targets/codex/skills/design-in-figma/SKILL.md index 9278d7b3..28d6ac55 100644 --- a/agent-plugin/targets/codex/skills/design-in-figma/SKILL.md +++ b/agent-plugin/targets/codex/skills/design-in-figma/SKILL.md @@ -134,7 +134,9 @@ screen or section boundaries and carry shared roles coherently across them. Inspect every `apply_canvas` result, including warnings. Repair each observed unintended defect or disclose why it remains. A local validation failure calls for a local payload correction; it does not justify discarding a working root -or simplifying away the intended content. Open pixels again after the final +or simplifying away the intended content. If a write times out, loses its result, +or cannot verify rollback, follow [local recovery](references/editing.md#recover-locally) +before another write. Open pixels again after the final material write, covering every materially distinct screen. Verify native facts with `get_structure` when identity, placement, editability, or representation matters. Opened pixels prove visual access, not good judgment; a structural pass diff --git a/agent-plugin/targets/codex/skills/design-in-figma/references/canvas-html.md b/agent-plugin/targets/codex/skills/design-in-figma/references/canvas-html.md index ef11c260..a8428ac3 100644 --- a/agent-plugin/targets/codex/skills/design-in-figma/references/canvas-html.md +++ b/agent-plugin/targets/codex/skills/design-in-figma/references/canvas-html.md @@ -68,10 +68,14 @@ reveals issues one at a time. unlinks that field. - A `span` contains only text and `
` or `
` line breaks. Use `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is - literal unless it forms a semicolon-terminated entity; supported entities - decode. Canvas typography does not inherit from a parent `div`: put font and - other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, - borders, corners, and box shadows on a parent `div`. + literal unless it forms a semicolon-terminated entity. Numeric decimal/hex + entities and these named entities decode: `amp`, `apos`, `bull`, `copy`, `gt`, + `hellip`, `ldquo`, `lsquo`, `lt`, `mdash`, `middot`, `nbsp`, `ndash`, `quot`, + `rdquo`, `reg`, `rsquo`, `trade`. Other names remain literal; extension 1.0.0+ + also reports an `unknown-html-entity` warning. Use the Unicode character or a + numeric entity instead. Canvas typography does not inherit from + a parent `div`: put font and other text utilities on each `span`/TEXT node. + Put flex/grid, gaps, padding, borders, corners, and box shadows on a parent `div`. - A component tag is childless, includes its returned `data-ref`, and accepts returned props plus the shared class, identity, variable, and style attributes. diff --git a/agent-plugin/targets/codex/skills/design-in-figma/references/component-authoring.md b/agent-plugin/targets/codex/skills/design-in-figma/references/component-authoring.md index 99573887..b747048e 100644 --- a/agent-plugin/targets/codex/skills/design-in-figma/references/component-authoring.md +++ b/agent-plugin/targets/codex/skills/design-in-figma/references/component-authoring.md @@ -241,7 +241,7 @@ the call returns the set as `rootNodeId`, this creates Default and Hover: ```json { "mode": "create", - "markup": "
", + "markup": "
", "native": { "screen/default": { "component": { "id": "ComponentSetID:created-button-set" } diff --git a/agent-plugin/targets/codex/skills/design-in-figma/references/editing.md b/agent-plugin/targets/codex/skills/design-in-figma/references/editing.md index f8653bd5..f020d91e 100644 --- a/agent-plugin/targets/codex/skills/design-in-figma/references/editing.md +++ b/agent-plugin/targets/codex/skills/design-in-figma/references/editing.md @@ -32,10 +32,30 @@ native references needed for the intended change. ## Recover locally Read the entire mutation result. For a rejected payload, correct all reported -issues together without changing the design to fit the error. A verification -failure is rolled back by TemPad; do not assume a partial successful edit. -For an unknown transport outcome, read the exact target before retrying a create -or removal so an uncertain response does not become a duplicate mutation. +issues together without changing the design to fit the error. An execution failure +requires inspecting its reported outcome before retrying. TemPad attempts rollback +after a failed write. Only an explicit no-write outcome (`CANVAS_APPLY_ABORTED` or +`EXTENSION_DELIVERY_FAILED`) skips file readback. Other diagnostics can originate +inside a mutation, especially with older extensions. Follow the returned readback +requirement: rollback checks cover only part of native state. +Automatic Undo can be skipped when this attempt has no confirmed remaining change. +Do not suggest manual Undo in that case: it could remove an earlier successful edit. + +A timeout, lost result, or unverified rollback leaves the canvas outcome uncertain. +Do not replay the write. Wait for a pending operation to settle; reload a stuck +runtime only when the tool calls for it. Then read the affected surviving node with +`get_structure` or `get_code`, carrying the current task and epoch. If the node was +removed, use `get_structure` with its exact page ID. An empty page snapshot counts; +empty selection, failed-node-only results, screenshots, and resource catalogs do not +satisfy readback. Inspect the returned state before deciding on a repair or retry. +If the failed request also changed local variables or styles, inspect those affected +resources with `get_design_system`; a canvas read alone does not verify their state. +If it cannot establish a safe next change, ask the user to inspect the canvas. + +Readback is also required after task resume or reconnection. A file's unknown-write +requirement survives Hub restarts, Stop, and a fresh task; none of those replaces the +read. A notice that readback is still required means the last read did not establish +usable canvas state. Stop cancellation remains in force independently of readback. Repair warnings where they occur. Replace a whole root only when an observed structural defect requires it and the complete intended content can be diff --git a/agent-plugin/targets/codex/skills/design-in-figma/references/paints-effects.md b/agent-plugin/targets/codex/skills/design-in-figma/references/paints-effects.md index c32b82f2..2ce41f9d 100644 --- a/agent-plugin/targets/codex/skills/design-in-figma/references/paints-effects.md +++ b/agent-plugin/targets/codex/skills/design-in-figma/references/paints-effects.md @@ -35,9 +35,11 @@ Named scales such as `shadow-md` are theme references, not portable geometry: Never assume Tailwind defaults or create a token only to resolve a named class. `shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. -Treat an outer shadow's rendered halo as part of the composition. Inspect the -final PNG beyond the root edges; visible granular or noisy fringe, or a halo -that dominates the captured bounds, is a defect even when the frame itself is +Treat an outer shadow's rendered halo as part of the composition. Exact-node +screenshots may crop to the node's bounds; inspect an enclosing composition with +enough surrounding space to judge the halo. Its absence from a cropped PNG is +not evidence that it is absent on canvas. Visible granular or noisy fringe, or a +halo that dominates the composition, is a defect even when the frame itself is intact. Preserve intended depth by tightening blur, spread, or opacity or using smaller layered shadows, then recheck. Do not flatten established material treatment merely to hide the defect. diff --git a/agent-plugin/targets/codex/skills/design-in-figma/references/rich-text.md b/agent-plugin/targets/codex/skills/design-in-figma/references/rich-text.md index 3e26cc97..3ac85e5e 100644 --- a/agent-plugin/targets/codex/skills/design-in-figma/references/rich-text.md +++ b/agent-plugin/targets/codex/skills/design-in-figma/references/rich-text.md @@ -38,6 +38,22 @@ Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a range variable. `null` unlinks supported styles or hyperlinks; omission preserves. +Numeric font size, line height, and letter spacing can also affect a `\n` +immediately after a range. Give adjacent ranges explicit, consistent ownership. +If an error identifies a retained Text style at that boundary, keep the earlier +range unchanged or explicitly replace the affected style only when that is the +intended design; expanding a range can overwrite the next range's formatting. +Do not retry an unchanged request that reports this native limitation. + +Variable `null` is verified after application. Paragraph indent and spacing apply +to the affected paragraphs, even when the requested range covers only part of +their text. Figma may retain a paragraph alias after unlinking; TemPad tries to +preserve its current literal value without changing the next paragraph. If no +safe fallback exists, including on an empty paragraph, it reports failure rather +than claiming the binding was cleared. Follow the returned readback requirement +and inspect affected text and resources before another write. Then preserve the +binding or explicitly replace the affected paragraph as the intended design requires. + Hyperlinks support URLs and node targets. For a same-result target: ```json diff --git a/agent-plugin/targets/codex/skills/figma-design-to-code/references/assets-and-tokens.md b/agent-plugin/targets/codex/skills/figma-design-to-code/references/assets-and-tokens.md index c42d134b..9df81ff8 100644 --- a/agent-plugin/targets/codex/skills/figma-design-to-code/references/assets-and-tokens.md +++ b/agent-plugin/targets/codex/skills/figma-design-to-code/references/assets-and-tokens.md @@ -8,11 +8,13 @@ code, including references whose token metadata is missing. Follow the project's established asset and icon policy before TemPad delivery details. -- Download bytes only from a TemPad-provided `asset.url`. Never substitute a - public internet asset. +- Use the returned `asset.localPath` when accessible; otherwise download + `asset.url`. These are temporary Hub delivery locations: copy needed files + into the project's normal asset location. Never substitute a public internet + asset. - Treat assets as files to store or reference, not text evidence to parse. - If project policy forbids storing them, reference TemPad URLs only when the - user accepts the local-server dependency, and report it. + user accepts the temporary local-server dependency, and report it. - Treat emitted `` markup as design truth for structure, size, and instance color. Refactor delivery only through an existing project SVG path. @@ -61,8 +63,12 @@ Definitions and a consumer's rendered value need not have the same literal representation. Figma opacity variables store percentages (`50` means `0.5` opacity). Code output normalizes only the opacity consumer: resolved output uses the fraction, while unresolved output retains a token reference in `calc()` with -the matching number or length denominator. Preserve that expression; do not divide -the shared token definition, which may also serve spacing or other properties. +the matching number or length denominator. Length division in opacity or font-weight +expressions requires CSS typed arithmetic; preserve it only when the project's CSS +targets support it. For portable output, follow the compatibility warning by +rereading with `resolveTokens: true` and verifying the affected consumer literals. +Unitless division does not require this compatibility fallback. Do not divide the +shared token definition, which may also serve spacing or other properties. If definitions still contradict consumer output after accounting for aliases and effective modes, narrow the design evidence or ask which state is intended. For unavailable or ambiguous facts, follow the token warnings in diff --git a/agent-plugin/targets/codex/skills/figma-design-to-code/references/recovery.md b/agent-plugin/targets/codex/skills/figma-design-to-code/references/recovery.md index 6464e5ca..14cc8370 100644 --- a/agent-plugin/targets/codex/skills/figma-design-to-code/references/recovery.md +++ b/agent-plugin/targets/codex/skills/figma-design-to-code/references/recovery.md @@ -29,9 +29,13 @@ affected root, preserve any trustworthy parent composition: the reported condition before retrying. - **`depth-cap`**: keep the returned top-level composition, then use returned `data-hint-id` values for targeted child `get_code` calls. -- **`token-resolution`**: some references remain in code even with - `resolveTokens: true`. Treat those consumer values as unresolved; do not - replace them with a collection default or claim all values were inlined. +- **`token-resolution`**: read the message to distinguish unresolved consumer + values or units from a CSS typed-arithmetic compatibility warning. The latter + can occur with `resolveTokens: false` for length-valued opacity or font-weight + tokens. If the project needs portable literals, reread the exact target with + `resolveTokens: true` and verify the affected values became literals. References + that remain unresolved must not be replaced with collection defaults or reported + as successfully inlined. - **`token-definition`**: definitions or mode labels are missing or ambiguous. Pause decisions that depend on those facts; successfully resolved consumer literals remain usable under the [token fallback guidance](assets-and-tokens.md#tokens). diff --git a/agent-plugin/targets/plugins-cli/.mcp.json b/agent-plugin/targets/plugins-cli/.mcp.json index 3789c234..97b654af 100644 --- a/agent-plugin/targets/plugins-cli/.mcp.json +++ b/agent-plugin/targets/plugins-cli/.mcp.json @@ -4,7 +4,8 @@ "command": "npx", "args": [ "-y", - "@tempad-dev/mcp@latest" + "@tempad-dev/mcp@latest", + "--runtime-updates=stable" ] } } diff --git a/agent-plugin/targets/plugins-cli/.plugin/plugin.json b/agent-plugin/targets/plugins-cli/.plugin/plugin.json index 4e887d36..84ac761d 100644 --- a/agent-plugin/targets/plugins-cli/.plugin/plugin.json +++ b/agent-plugin/targets/plugins-cli/.plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tempad-dev", - "version": "0.5.1", + "version": "1.0.0", "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "author": { "name": "TemPad Dev" diff --git a/agent-plugin/targets/plugins-cli/CHANGELOG.md b/agent-plugin/targets/plugins-cli/CHANGELOG.md index 433117c6..f144f70a 100644 --- a/agent-plugin/targets/plugins-cli/CHANGELOG.md +++ b/agent-plugin/targets/plugins-cli/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0 + +- Enabled verified runtime downloads and compatible Hub updates between operations on macOS, + Linux and Windows with MCP 1.0.0. Claude requires a verified session and current lifecycle + hooks; development checkouts keep their bundled runtime. +- Retried interrupted Claude lifecycle messages without repeating their effects, including + during runtime switches. Preserved Unicode text and tolerated malformed Hub replies. +- Kept development Claude hooks connected to their own checkout while a release runtime is running. +- Clarified recovery after failed or uncertain canvas writes, native text binding limits, + supported text entities, and browser compatibility for token-based CSS. Unsupported entity + warnings require extension 1.0.0 or later. +- Corrected component sizing, asset storage, shadow inspection, and Chinese setup guidance. + ## 0.5.1 - Fixed Claude session binding so Figma Stop reaches the correct conversation. diff --git a/agent-plugin/targets/plugins-cli/README.md b/agent-plugin/targets/plugins-cli/README.md index b6703c10..031c96e0 100644 --- a/agent-plugin/targets/plugins-cli/README.md +++ b/agent-plugin/targets/plugins-cli/README.md @@ -76,7 +76,8 @@ skill installations should also remove the old skill after installing the new on The standalone package retains the former name as a generated alias for one extension store release cycle so setup commands in older installed extensions continue to work. -Use TemPad Dev extension **0.23.4 or later** and MCP server **0.11.0 or later**. +For the complete 1.0 release, update the TemPad Dev extension, MCP server, and +Agent Plugin (or its standalone skills) to **1.0.0 or later**. Node.js **22.x, 24.x, or 26+** is required for the MCP server. Multi-node reads and consumer-aware token resolution require matching protocol 15 Hub and extension builds. MCP 0.11.0 also supports extension 0.22.0 through protocol 13, preserving @@ -84,13 +85,17 @@ existing reads, sessions, tasks, and canvas writes during the store rollout. New prompt an extension upgrade; results identify older selection and token semantics. MCP 0.10.0 and later use `manage_design_task` in place of the older lifecycle tools. -1. Update the browser extension to 0.23.4 or later and reload the Figma tab. Version 0.23.4 includes packaged rewrite rules and SVG placement fixes; multi-node reads arrived in 0.23.0. +1. Update the browser extension to 1.0.0 or later and reload the Figma tab. 2. Update the installed plugin through the client or installer used originally. With standalone setup, update both `figma-design-to-code` and `design-in-figma`. 3. Keep the release MCP configuration on `@tempad-dev/mcp@latest`; replace any previous - `@alpha` or fixed alpha version. Reconnect the MCP client and start a new task so it loads the - updated tools and skills. If a stale Hub is reported, close tasks using the old MCP server - before reconnecting. + `@alpha` or fixed alpha version. Plugin 1.0.0 enables verified runtime downloads with MCP + 1.0.0 or later on macOS, Linux and Windows. Compatible Hub updates switch automatically during idle MCP + sessions with waiting support; Claude also requires a verified session and receipt/retry-capable hooks. + When upgrading from an unmanaged Hub, disconnect all TemPad MCP + clients once, then restart their MCP servers. Edge processes, installed plugins and incompatible + runtime changes still require their normal update/restart. See the + [runtime update controls](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.md#runtime-updates-100). 4. Open TemPad Dev, enable **MCP access**, and click the MCP badge in the intended Figma tab when a session choice is needed. The badge selects the default file; agents can also discover connected sessions and target independent reads by exact session ID. diff --git a/agent-plugin/targets/plugins-cli/README.zh-Hans.md b/agent-plugin/targets/plugins-cli/README.zh-Hans.md index 95a300f7..b8ddafe9 100644 --- a/agent-plugin/targets/plugins-cli/README.zh-Hans.md +++ b/agent-plugin/targets/plugins-cli/README.zh-Hans.md @@ -66,6 +66,9 @@ claude plugin install tempad-dev@tempad-dev 设计任务的状态栏显示 agent 状态、Stop/Done 和评论入口。Stop 会立即阻止当前任务继续写入, 在执行中的操作结束后永久取消该任务;重新连接也不会恢复它。后续设计工作需要明确开始新任务。 +正常配置只需完成 TemPad Dev 扩展设置并安装本插件,无需另行配置控制地址、 +环境变量或辅助服务。 + 评论目前仅支持通过兼容 Codex App 的原生会话通道发送。Queue 将整批评论交给宿主的原生队列, 等待会话可以执行时再处理。宿主确认接收后,TemPad Dev 会清空本批评论和标记、停止发送指示, 并允许继续输入下一批;这表示已接收,不表示 agent 已完成修改。如果原生入队不可用, @@ -89,7 +92,10 @@ Codex App 的任务绑定和状态同步使用 MCP 元数据及原生 IPC,不 宿主不可用时,本地取消仍然生效。Claude 保留生命周期和 Stop 通知 hooks;Claude、Codex CLI 等尚未接入原生投递的客户端提供任务状态和 Stop/Done,但不显示评论入口,已有草稿不会删除。 Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂停可用新的 lease epoch 恢复, -恢复后必须先重新读取画布才能写入;取消的任务不能恢复。详见 +恢复后必须先重新读取画布才能写入;取消的任务不能恢复。Agent 会遵守 Stop,不会自动创建 +替代任务;必要或用户要求的后续设计工作可以显式开始新任务,无需另行在 Figma 中解锁。 +只有实际持有目标会话的宿主确认支持时,才会启用 Codex 原生投递,无需手动配置连接。 +当前验证范围详见 [任务与客户端设计](https://github.com/ecomfe/tempad-dev/blob/main/docs/extension/mcp-design-tasks.md)。 原生适配器在 macOS/Linux 上使用 Unix socket,在 Windows 上使用 Codex 的本机 Named Pipe。 @@ -97,6 +103,9 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 自动执行和完整的已安装插件/Figma UI 流程仍待实测;Windows/Linux 的证据限于源码检查和 自动化测试,尚不能据此宣称完整宿主支持。 +Agent 会在对话中报告结果并附上 Figma 链接,用户可以继续提出后续要求。 +任务工具返回文本和结构化数据。 + ## 升级 设计 skill 已更名为 `design-in-figma`,与展示名 **Design in Figma** 一致。 @@ -104,19 +113,23 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 安装新版后还应移除旧名称的 skill。 独立 skill 包会保留旧名称的生成兼容入口一个扩展商店发布周期,让已安装旧扩展中的配置命令继续有效。 -请使用 TemPad Dev 扩展 **0.23.4 或更高版本**和 MCP server **0.11.0 或更高版本**。 +如需使用完整的 1.0 发布,请将 TemPad Dev 扩展、MCP server、Agent Plugin(或其独立 skill) +均更新到 **1.0.0 或更高版本**。 MCP server 要求 Node.js **22.x、24.x 或 26+**。 多节点读取和按消费节点解析 token 要求 Hub 与扩展均使用匹配的 protocol 15 构建。 MCP 0.11.0 同时通过 protocol 13 兼容扩展 0.22.0,在商店更新期间保留既有读取、会话、任务和画布写入。 新版读取选项会提示升级扩展,响应会明确标注旧版选区和 token 语义。 MCP 0.10.0 及之后版本使用 `manage_design_task` 代替旧的生命周期工具。 -1. 将浏览器扩展更新到 0.23.4 或更高版本并重新加载 Figma 标签页。0.23.4 包含内置 rewrite 规则和 SVG 定位修复;多节点读取从 0.23.0 开始提供。 +1. 将浏览器扩展更新到 1.0.0 或更高版本并重新加载 Figma 标签页。 2. 通过原先使用的客户端或安装器更新 plugin。独立配置时,请同时更新 `figma-design-to-code` 和 `design-in-figma`。 3. 正式版 MCP 配置使用 `@tempad-dev/mcp@latest`;请替换旧的 `@alpha` 或固定 alpha 版本。 - 重新连接 MCP client 并新建任务,以加载更新后的工具和 skill。若提示 Hub 过期,请先关闭 - 使用旧 MCP server 的任务,再重新连接。 + Plugin 1.0.0 配合 MCP 1.0.0 或更高版本,会在 macOS、Linux 和 Windows 上启用经过校验的后台下载, + 在支持切换等待的 MCP 会话空闲时自动切换兼容的 Hub。Claude 还要求已验证的会话和支持回执与重试的 hook。 + 从未启用更新的 Hub 升级时,需先断开所有 TemPad MCP client, + 再重启其 MCP server,一次即可。入口进程、已安装插件和不兼容的运行时改动仍需按原方式更新或重启。 + 详见[运行时更新控制](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.zh-Hans.md#运行时更新100)。 4. 打开 TemPad Dev 并启用 **MCP access**;需要选择会话时,点击目标 Figma 标签页内的 MCP badge。该 badge 选择默认文件;agent 也可以发现已连接会话,并通过精确 session ID 指定独立读取的目标。 diff --git a/agent-plugin/targets/plugins-cli/skills/design-in-figma/SKILL.md b/agent-plugin/targets/plugins-cli/skills/design-in-figma/SKILL.md index 9278d7b3..28d6ac55 100644 --- a/agent-plugin/targets/plugins-cli/skills/design-in-figma/SKILL.md +++ b/agent-plugin/targets/plugins-cli/skills/design-in-figma/SKILL.md @@ -134,7 +134,9 @@ screen or section boundaries and carry shared roles coherently across them. Inspect every `apply_canvas` result, including warnings. Repair each observed unintended defect or disclose why it remains. A local validation failure calls for a local payload correction; it does not justify discarding a working root -or simplifying away the intended content. Open pixels again after the final +or simplifying away the intended content. If a write times out, loses its result, +or cannot verify rollback, follow [local recovery](references/editing.md#recover-locally) +before another write. Open pixels again after the final material write, covering every materially distinct screen. Verify native facts with `get_structure` when identity, placement, editability, or representation matters. Opened pixels prove visual access, not good judgment; a structural pass diff --git a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/canvas-html.md b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/canvas-html.md index ef11c260..a8428ac3 100644 --- a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/canvas-html.md +++ b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/canvas-html.md @@ -68,10 +68,14 @@ reveals issues one at a time. unlinks that field. - A `span` contains only text and `
` or `
` line breaks. Use `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is - literal unless it forms a semicolon-terminated entity; supported entities - decode. Canvas typography does not inherit from a parent `div`: put font and - other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, - borders, corners, and box shadows on a parent `div`. + literal unless it forms a semicolon-terminated entity. Numeric decimal/hex + entities and these named entities decode: `amp`, `apos`, `bull`, `copy`, `gt`, + `hellip`, `ldquo`, `lsquo`, `lt`, `mdash`, `middot`, `nbsp`, `ndash`, `quot`, + `rdquo`, `reg`, `rsquo`, `trade`. Other names remain literal; extension 1.0.0+ + also reports an `unknown-html-entity` warning. Use the Unicode character or a + numeric entity instead. Canvas typography does not inherit from + a parent `div`: put font and other text utilities on each `span`/TEXT node. + Put flex/grid, gaps, padding, borders, corners, and box shadows on a parent `div`. - A component tag is childless, includes its returned `data-ref`, and accepts returned props plus the shared class, identity, variable, and style attributes. diff --git a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/component-authoring.md b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/component-authoring.md index 99573887..b747048e 100644 --- a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/component-authoring.md +++ b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/component-authoring.md @@ -241,7 +241,7 @@ the call returns the set as `rootNodeId`, this creates Default and Hover: ```json { "mode": "create", - "markup": "
", + "markup": "
", "native": { "screen/default": { "component": { "id": "ComponentSetID:created-button-set" } diff --git a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/editing.md b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/editing.md index f8653bd5..f020d91e 100644 --- a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/editing.md +++ b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/editing.md @@ -32,10 +32,30 @@ native references needed for the intended change. ## Recover locally Read the entire mutation result. For a rejected payload, correct all reported -issues together without changing the design to fit the error. A verification -failure is rolled back by TemPad; do not assume a partial successful edit. -For an unknown transport outcome, read the exact target before retrying a create -or removal so an uncertain response does not become a duplicate mutation. +issues together without changing the design to fit the error. An execution failure +requires inspecting its reported outcome before retrying. TemPad attempts rollback +after a failed write. Only an explicit no-write outcome (`CANVAS_APPLY_ABORTED` or +`EXTENSION_DELIVERY_FAILED`) skips file readback. Other diagnostics can originate +inside a mutation, especially with older extensions. Follow the returned readback +requirement: rollback checks cover only part of native state. +Automatic Undo can be skipped when this attempt has no confirmed remaining change. +Do not suggest manual Undo in that case: it could remove an earlier successful edit. + +A timeout, lost result, or unverified rollback leaves the canvas outcome uncertain. +Do not replay the write. Wait for a pending operation to settle; reload a stuck +runtime only when the tool calls for it. Then read the affected surviving node with +`get_structure` or `get_code`, carrying the current task and epoch. If the node was +removed, use `get_structure` with its exact page ID. An empty page snapshot counts; +empty selection, failed-node-only results, screenshots, and resource catalogs do not +satisfy readback. Inspect the returned state before deciding on a repair or retry. +If the failed request also changed local variables or styles, inspect those affected +resources with `get_design_system`; a canvas read alone does not verify their state. +If it cannot establish a safe next change, ask the user to inspect the canvas. + +Readback is also required after task resume or reconnection. A file's unknown-write +requirement survives Hub restarts, Stop, and a fresh task; none of those replaces the +read. A notice that readback is still required means the last read did not establish +usable canvas state. Stop cancellation remains in force independently of readback. Repair warnings where they occur. Replace a whole root only when an observed structural defect requires it and the complete intended content can be diff --git a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/paints-effects.md b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/paints-effects.md index c32b82f2..2ce41f9d 100644 --- a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/paints-effects.md +++ b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/paints-effects.md @@ -35,9 +35,11 @@ Named scales such as `shadow-md` are theme references, not portable geometry: Never assume Tailwind defaults or create a token only to resolve a named class. `shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. -Treat an outer shadow's rendered halo as part of the composition. Inspect the -final PNG beyond the root edges; visible granular or noisy fringe, or a halo -that dominates the captured bounds, is a defect even when the frame itself is +Treat an outer shadow's rendered halo as part of the composition. Exact-node +screenshots may crop to the node's bounds; inspect an enclosing composition with +enough surrounding space to judge the halo. Its absence from a cropped PNG is +not evidence that it is absent on canvas. Visible granular or noisy fringe, or a +halo that dominates the composition, is a defect even when the frame itself is intact. Preserve intended depth by tightening blur, spread, or opacity or using smaller layered shadows, then recheck. Do not flatten established material treatment merely to hide the defect. diff --git a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/rich-text.md b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/rich-text.md index 3e26cc97..3ac85e5e 100644 --- a/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/rich-text.md +++ b/agent-plugin/targets/plugins-cli/skills/design-in-figma/references/rich-text.md @@ -38,6 +38,22 @@ Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a range variable. `null` unlinks supported styles or hyperlinks; omission preserves. +Numeric font size, line height, and letter spacing can also affect a `\n` +immediately after a range. Give adjacent ranges explicit, consistent ownership. +If an error identifies a retained Text style at that boundary, keep the earlier +range unchanged or explicitly replace the affected style only when that is the +intended design; expanding a range can overwrite the next range's formatting. +Do not retry an unchanged request that reports this native limitation. + +Variable `null` is verified after application. Paragraph indent and spacing apply +to the affected paragraphs, even when the requested range covers only part of +their text. Figma may retain a paragraph alias after unlinking; TemPad tries to +preserve its current literal value without changing the next paragraph. If no +safe fallback exists, including on an empty paragraph, it reports failure rather +than claiming the binding was cleared. Follow the returned readback requirement +and inspect affected text and resources before another write. Then preserve the +binding or explicitly replace the affected paragraph as the intended design requires. + Hyperlinks support URLs and node targets. For a same-result target: ```json diff --git a/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/assets-and-tokens.md b/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/assets-and-tokens.md index c42d134b..9df81ff8 100644 --- a/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/assets-and-tokens.md +++ b/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/assets-and-tokens.md @@ -8,11 +8,13 @@ code, including references whose token metadata is missing. Follow the project's established asset and icon policy before TemPad delivery details. -- Download bytes only from a TemPad-provided `asset.url`. Never substitute a - public internet asset. +- Use the returned `asset.localPath` when accessible; otherwise download + `asset.url`. These are temporary Hub delivery locations: copy needed files + into the project's normal asset location. Never substitute a public internet + asset. - Treat assets as files to store or reference, not text evidence to parse. - If project policy forbids storing them, reference TemPad URLs only when the - user accepts the local-server dependency, and report it. + user accepts the temporary local-server dependency, and report it. - Treat emitted `` markup as design truth for structure, size, and instance color. Refactor delivery only through an existing project SVG path. @@ -61,8 +63,12 @@ Definitions and a consumer's rendered value need not have the same literal representation. Figma opacity variables store percentages (`50` means `0.5` opacity). Code output normalizes only the opacity consumer: resolved output uses the fraction, while unresolved output retains a token reference in `calc()` with -the matching number or length denominator. Preserve that expression; do not divide -the shared token definition, which may also serve spacing or other properties. +the matching number or length denominator. Length division in opacity or font-weight +expressions requires CSS typed arithmetic; preserve it only when the project's CSS +targets support it. For portable output, follow the compatibility warning by +rereading with `resolveTokens: true` and verifying the affected consumer literals. +Unitless division does not require this compatibility fallback. Do not divide the +shared token definition, which may also serve spacing or other properties. If definitions still contradict consumer output after accounting for aliases and effective modes, narrow the design evidence or ask which state is intended. For unavailable or ambiguous facts, follow the token warnings in diff --git a/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/recovery.md b/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/recovery.md index 6464e5ca..14cc8370 100644 --- a/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/recovery.md +++ b/agent-plugin/targets/plugins-cli/skills/figma-design-to-code/references/recovery.md @@ -29,9 +29,13 @@ affected root, preserve any trustworthy parent composition: the reported condition before retrying. - **`depth-cap`**: keep the returned top-level composition, then use returned `data-hint-id` values for targeted child `get_code` calls. -- **`token-resolution`**: some references remain in code even with - `resolveTokens: true`. Treat those consumer values as unresolved; do not - replace them with a collection default or claim all values were inlined. +- **`token-resolution`**: read the message to distinguish unresolved consumer + values or units from a CSS typed-arithmetic compatibility warning. The latter + can occur with `resolveTokens: false` for length-valued opacity or font-weight + tokens. If the project needs portable literals, reread the exact target with + `resolveTokens: true` and verify the affected values became literals. References + that remain unresolved must not be replaced with collection defaults or reported + as successfully inlined. - **`token-definition`**: definitions or mode labels are missing or ambiguous. Pause decisions that depend on those facts; successfully resolved consumer literals remain usable under the [token fallback guidance](assets-and-tokens.md#tokens). diff --git a/agent-plugin/targets/standard/CHANGELOG.md b/agent-plugin/targets/standard/CHANGELOG.md index 433117c6..f144f70a 100644 --- a/agent-plugin/targets/standard/CHANGELOG.md +++ b/agent-plugin/targets/standard/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0 + +- Enabled verified runtime downloads and compatible Hub updates between operations on macOS, + Linux and Windows with MCP 1.0.0. Claude requires a verified session and current lifecycle + hooks; development checkouts keep their bundled runtime. +- Retried interrupted Claude lifecycle messages without repeating their effects, including + during runtime switches. Preserved Unicode text and tolerated malformed Hub replies. +- Kept development Claude hooks connected to their own checkout while a release runtime is running. +- Clarified recovery after failed or uncertain canvas writes, native text binding limits, + supported text entities, and browser compatibility for token-based CSS. Unsupported entity + warnings require extension 1.0.0 or later. +- Corrected component sizing, asset storage, shadow inspection, and Chinese setup guidance. + ## 0.5.1 - Fixed Claude session binding so Figma Stop reaches the correct conversation. diff --git a/agent-plugin/targets/standard/README.md b/agent-plugin/targets/standard/README.md index b6703c10..031c96e0 100644 --- a/agent-plugin/targets/standard/README.md +++ b/agent-plugin/targets/standard/README.md @@ -76,7 +76,8 @@ skill installations should also remove the old skill after installing the new on The standalone package retains the former name as a generated alias for one extension store release cycle so setup commands in older installed extensions continue to work. -Use TemPad Dev extension **0.23.4 or later** and MCP server **0.11.0 or later**. +For the complete 1.0 release, update the TemPad Dev extension, MCP server, and +Agent Plugin (or its standalone skills) to **1.0.0 or later**. Node.js **22.x, 24.x, or 26+** is required for the MCP server. Multi-node reads and consumer-aware token resolution require matching protocol 15 Hub and extension builds. MCP 0.11.0 also supports extension 0.22.0 through protocol 13, preserving @@ -84,13 +85,17 @@ existing reads, sessions, tasks, and canvas writes during the store rollout. New prompt an extension upgrade; results identify older selection and token semantics. MCP 0.10.0 and later use `manage_design_task` in place of the older lifecycle tools. -1. Update the browser extension to 0.23.4 or later and reload the Figma tab. Version 0.23.4 includes packaged rewrite rules and SVG placement fixes; multi-node reads arrived in 0.23.0. +1. Update the browser extension to 1.0.0 or later and reload the Figma tab. 2. Update the installed plugin through the client or installer used originally. With standalone setup, update both `figma-design-to-code` and `design-in-figma`. 3. Keep the release MCP configuration on `@tempad-dev/mcp@latest`; replace any previous - `@alpha` or fixed alpha version. Reconnect the MCP client and start a new task so it loads the - updated tools and skills. If a stale Hub is reported, close tasks using the old MCP server - before reconnecting. + `@alpha` or fixed alpha version. Plugin 1.0.0 enables verified runtime downloads with MCP + 1.0.0 or later on macOS, Linux and Windows. Compatible Hub updates switch automatically during idle MCP + sessions with waiting support; Claude also requires a verified session and receipt/retry-capable hooks. + When upgrading from an unmanaged Hub, disconnect all TemPad MCP + clients once, then restart their MCP servers. Edge processes, installed plugins and incompatible + runtime changes still require their normal update/restart. See the + [runtime update controls](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.md#runtime-updates-100). 4. Open TemPad Dev, enable **MCP access**, and click the MCP badge in the intended Figma tab when a session choice is needed. The badge selects the default file; agents can also discover connected sessions and target independent reads by exact session ID. diff --git a/agent-plugin/targets/standard/README.zh-Hans.md b/agent-plugin/targets/standard/README.zh-Hans.md index 95a300f7..b8ddafe9 100644 --- a/agent-plugin/targets/standard/README.zh-Hans.md +++ b/agent-plugin/targets/standard/README.zh-Hans.md @@ -66,6 +66,9 @@ claude plugin install tempad-dev@tempad-dev 设计任务的状态栏显示 agent 状态、Stop/Done 和评论入口。Stop 会立即阻止当前任务继续写入, 在执行中的操作结束后永久取消该任务;重新连接也不会恢复它。后续设计工作需要明确开始新任务。 +正常配置只需完成 TemPad Dev 扩展设置并安装本插件,无需另行配置控制地址、 +环境变量或辅助服务。 + 评论目前仅支持通过兼容 Codex App 的原生会话通道发送。Queue 将整批评论交给宿主的原生队列, 等待会话可以执行时再处理。宿主确认接收后,TemPad Dev 会清空本批评论和标记、停止发送指示, 并允许继续输入下一批;这表示已接收,不表示 agent 已完成修改。如果原生入队不可用, @@ -89,7 +92,10 @@ Codex App 的任务绑定和状态同步使用 MCP 元数据及原生 IPC,不 宿主不可用时,本地取消仍然生效。Claude 保留生命周期和 Stop 通知 hooks;Claude、Codex CLI 等尚未接入原生投递的客户端提供任务状态和 Stop/Done,但不显示评论入口,已有草稿不会删除。 Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂停可用新的 lease epoch 恢复, -恢复后必须先重新读取画布才能写入;取消的任务不能恢复。详见 +恢复后必须先重新读取画布才能写入;取消的任务不能恢复。Agent 会遵守 Stop,不会自动创建 +替代任务;必要或用户要求的后续设计工作可以显式开始新任务,无需另行在 Figma 中解锁。 +只有实际持有目标会话的宿主确认支持时,才会启用 Codex 原生投递,无需手动配置连接。 +当前验证范围详见 [任务与客户端设计](https://github.com/ecomfe/tempad-dev/blob/main/docs/extension/mcp-design-tasks.md)。 原生适配器在 macOS/Linux 上使用 Unix socket,在 Windows 上使用 Codex 的本机 Named Pipe。 @@ -97,6 +103,9 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 自动执行和完整的已安装插件/Figma UI 流程仍待实测;Windows/Linux 的证据限于源码检查和 自动化测试,尚不能据此宣称完整宿主支持。 +Agent 会在对话中报告结果并附上 Figma 链接,用户可以继续提出后续要求。 +任务工具返回文本和结构化数据。 + ## 升级 设计 skill 已更名为 `design-in-figma`,与展示名 **Design in Figma** 一致。 @@ -104,19 +113,23 @@ Claude 会在下一个启用 hook 的工具边界收到 Stop 通知。任务暂 安装新版后还应移除旧名称的 skill。 独立 skill 包会保留旧名称的生成兼容入口一个扩展商店发布周期,让已安装旧扩展中的配置命令继续有效。 -请使用 TemPad Dev 扩展 **0.23.4 或更高版本**和 MCP server **0.11.0 或更高版本**。 +如需使用完整的 1.0 发布,请将 TemPad Dev 扩展、MCP server、Agent Plugin(或其独立 skill) +均更新到 **1.0.0 或更高版本**。 MCP server 要求 Node.js **22.x、24.x 或 26+**。 多节点读取和按消费节点解析 token 要求 Hub 与扩展均使用匹配的 protocol 15 构建。 MCP 0.11.0 同时通过 protocol 13 兼容扩展 0.22.0,在商店更新期间保留既有读取、会话、任务和画布写入。 新版读取选项会提示升级扩展,响应会明确标注旧版选区和 token 语义。 MCP 0.10.0 及之后版本使用 `manage_design_task` 代替旧的生命周期工具。 -1. 将浏览器扩展更新到 0.23.4 或更高版本并重新加载 Figma 标签页。0.23.4 包含内置 rewrite 规则和 SVG 定位修复;多节点读取从 0.23.0 开始提供。 +1. 将浏览器扩展更新到 1.0.0 或更高版本并重新加载 Figma 标签页。 2. 通过原先使用的客户端或安装器更新 plugin。独立配置时,请同时更新 `figma-design-to-code` 和 `design-in-figma`。 3. 正式版 MCP 配置使用 `@tempad-dev/mcp@latest`;请替换旧的 `@alpha` 或固定 alpha 版本。 - 重新连接 MCP client 并新建任务,以加载更新后的工具和 skill。若提示 Hub 过期,请先关闭 - 使用旧 MCP server 的任务,再重新连接。 + Plugin 1.0.0 配合 MCP 1.0.0 或更高版本,会在 macOS、Linux 和 Windows 上启用经过校验的后台下载, + 在支持切换等待的 MCP 会话空闲时自动切换兼容的 Hub。Claude 还要求已验证的会话和支持回执与重试的 hook。 + 从未启用更新的 Hub 升级时,需先断开所有 TemPad MCP client, + 再重启其 MCP server,一次即可。入口进程、已安装插件和不兼容的运行时改动仍需按原方式更新或重启。 + 详见[运行时更新控制](https://github.com/ecomfe/tempad-dev/blob/main/packages/mcp-server/README.zh-Hans.md#运行时更新100)。 4. 打开 TemPad Dev 并启用 **MCP access**;需要选择会话时,点击目标 Figma 标签页内的 MCP badge。该 badge 选择默认文件;agent 也可以发现已连接会话,并通过精确 session ID 指定独立读取的目标。 diff --git a/agent-plugin/targets/standard/mcp.json b/agent-plugin/targets/standard/mcp.json index 56c29e0f..3da39523 100644 --- a/agent-plugin/targets/standard/mcp.json +++ b/agent-plugin/targets/standard/mcp.json @@ -6,7 +6,8 @@ "command": "npx", "args": [ "-y", - "@tempad-dev/mcp@latest" + "@tempad-dev/mcp@latest", + "--runtime-updates=stable" ] } } diff --git a/agent-plugin/targets/standard/plugin.json b/agent-plugin/targets/standard/plugin.json index 98ec9e1a..20bca1fa 100644 --- a/agent-plugin/targets/standard/plugin.json +++ b/agent-plugin/targets/standard/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "tempad-dev", - "version": "0.5.1", + "version": "1.0.0", "description": "Connect your coding agent to Figma. Create and edit native designs, inspect existing designs, and implement UI in your codebase.", "author": { "name": "TemPad Dev" diff --git a/agent-plugin/targets/standard/skills/design-in-figma/SKILL.md b/agent-plugin/targets/standard/skills/design-in-figma/SKILL.md index 9278d7b3..28d6ac55 100644 --- a/agent-plugin/targets/standard/skills/design-in-figma/SKILL.md +++ b/agent-plugin/targets/standard/skills/design-in-figma/SKILL.md @@ -134,7 +134,9 @@ screen or section boundaries and carry shared roles coherently across them. Inspect every `apply_canvas` result, including warnings. Repair each observed unintended defect or disclose why it remains. A local validation failure calls for a local payload correction; it does not justify discarding a working root -or simplifying away the intended content. Open pixels again after the final +or simplifying away the intended content. If a write times out, loses its result, +or cannot verify rollback, follow [local recovery](references/editing.md#recover-locally) +before another write. Open pixels again after the final material write, covering every materially distinct screen. Verify native facts with `get_structure` when identity, placement, editability, or representation matters. Opened pixels prove visual access, not good judgment; a structural pass diff --git a/agent-plugin/targets/standard/skills/design-in-figma/references/canvas-html.md b/agent-plugin/targets/standard/skills/design-in-figma/references/canvas-html.md index ef11c260..a8428ac3 100644 --- a/agent-plugin/targets/standard/skills/design-in-figma/references/canvas-html.md +++ b/agent-plugin/targets/standard/skills/design-in-figma/references/canvas-html.md @@ -68,10 +68,14 @@ reveals issues one at a time. unlinks that field. - A `span` contains only text and `
` or `
` line breaks. Use `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is - literal unless it forms a semicolon-terminated entity; supported entities - decode. Canvas typography does not inherit from a parent `div`: put font and - other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, - borders, corners, and box shadows on a parent `div`. + literal unless it forms a semicolon-terminated entity. Numeric decimal/hex + entities and these named entities decode: `amp`, `apos`, `bull`, `copy`, `gt`, + `hellip`, `ldquo`, `lsquo`, `lt`, `mdash`, `middot`, `nbsp`, `ndash`, `quot`, + `rdquo`, `reg`, `rsquo`, `trade`. Other names remain literal; extension 1.0.0+ + also reports an `unknown-html-entity` warning. Use the Unicode character or a + numeric entity instead. Canvas typography does not inherit from + a parent `div`: put font and other text utilities on each `span`/TEXT node. + Put flex/grid, gaps, padding, borders, corners, and box shadows on a parent `div`. - A component tag is childless, includes its returned `data-ref`, and accepts returned props plus the shared class, identity, variable, and style attributes. diff --git a/agent-plugin/targets/standard/skills/design-in-figma/references/component-authoring.md b/agent-plugin/targets/standard/skills/design-in-figma/references/component-authoring.md index 99573887..b747048e 100644 --- a/agent-plugin/targets/standard/skills/design-in-figma/references/component-authoring.md +++ b/agent-plugin/targets/standard/skills/design-in-figma/references/component-authoring.md @@ -241,7 +241,7 @@ the call returns the set as `rootNodeId`, this creates Default and Hover: ```json { "mode": "create", - "markup": "
", + "markup": "
", "native": { "screen/default": { "component": { "id": "ComponentSetID:created-button-set" } diff --git a/agent-plugin/targets/standard/skills/design-in-figma/references/editing.md b/agent-plugin/targets/standard/skills/design-in-figma/references/editing.md index f8653bd5..f020d91e 100644 --- a/agent-plugin/targets/standard/skills/design-in-figma/references/editing.md +++ b/agent-plugin/targets/standard/skills/design-in-figma/references/editing.md @@ -32,10 +32,30 @@ native references needed for the intended change. ## Recover locally Read the entire mutation result. For a rejected payload, correct all reported -issues together without changing the design to fit the error. A verification -failure is rolled back by TemPad; do not assume a partial successful edit. -For an unknown transport outcome, read the exact target before retrying a create -or removal so an uncertain response does not become a duplicate mutation. +issues together without changing the design to fit the error. An execution failure +requires inspecting its reported outcome before retrying. TemPad attempts rollback +after a failed write. Only an explicit no-write outcome (`CANVAS_APPLY_ABORTED` or +`EXTENSION_DELIVERY_FAILED`) skips file readback. Other diagnostics can originate +inside a mutation, especially with older extensions. Follow the returned readback +requirement: rollback checks cover only part of native state. +Automatic Undo can be skipped when this attempt has no confirmed remaining change. +Do not suggest manual Undo in that case: it could remove an earlier successful edit. + +A timeout, lost result, or unverified rollback leaves the canvas outcome uncertain. +Do not replay the write. Wait for a pending operation to settle; reload a stuck +runtime only when the tool calls for it. Then read the affected surviving node with +`get_structure` or `get_code`, carrying the current task and epoch. If the node was +removed, use `get_structure` with its exact page ID. An empty page snapshot counts; +empty selection, failed-node-only results, screenshots, and resource catalogs do not +satisfy readback. Inspect the returned state before deciding on a repair or retry. +If the failed request also changed local variables or styles, inspect those affected +resources with `get_design_system`; a canvas read alone does not verify their state. +If it cannot establish a safe next change, ask the user to inspect the canvas. + +Readback is also required after task resume or reconnection. A file's unknown-write +requirement survives Hub restarts, Stop, and a fresh task; none of those replaces the +read. A notice that readback is still required means the last read did not establish +usable canvas state. Stop cancellation remains in force independently of readback. Repair warnings where they occur. Replace a whole root only when an observed structural defect requires it and the complete intended content can be diff --git a/agent-plugin/targets/standard/skills/design-in-figma/references/paints-effects.md b/agent-plugin/targets/standard/skills/design-in-figma/references/paints-effects.md index c32b82f2..2ce41f9d 100644 --- a/agent-plugin/targets/standard/skills/design-in-figma/references/paints-effects.md +++ b/agent-plugin/targets/standard/skills/design-in-figma/references/paints-effects.md @@ -35,9 +35,11 @@ Named scales such as `shadow-md` are theme references, not portable geometry: Never assume Tailwind defaults or create a token only to resolve a named class. `shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. -Treat an outer shadow's rendered halo as part of the composition. Inspect the -final PNG beyond the root edges; visible granular or noisy fringe, or a halo -that dominates the captured bounds, is a defect even when the frame itself is +Treat an outer shadow's rendered halo as part of the composition. Exact-node +screenshots may crop to the node's bounds; inspect an enclosing composition with +enough surrounding space to judge the halo. Its absence from a cropped PNG is +not evidence that it is absent on canvas. Visible granular or noisy fringe, or a +halo that dominates the composition, is a defect even when the frame itself is intact. Preserve intended depth by tightening blur, spread, or opacity or using smaller layered shadows, then recheck. Do not flatten established material treatment merely to hide the defect. diff --git a/agent-plugin/targets/standard/skills/design-in-figma/references/rich-text.md b/agent-plugin/targets/standard/skills/design-in-figma/references/rich-text.md index 3e26cc97..3ac85e5e 100644 --- a/agent-plugin/targets/standard/skills/design-in-figma/references/rich-text.md +++ b/agent-plugin/targets/standard/skills/design-in-figma/references/rich-text.md @@ -38,6 +38,22 @@ Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a range variable. `null` unlinks supported styles or hyperlinks; omission preserves. +Numeric font size, line height, and letter spacing can also affect a `\n` +immediately after a range. Give adjacent ranges explicit, consistent ownership. +If an error identifies a retained Text style at that boundary, keep the earlier +range unchanged or explicitly replace the affected style only when that is the +intended design; expanding a range can overwrite the next range's formatting. +Do not retry an unchanged request that reports this native limitation. + +Variable `null` is verified after application. Paragraph indent and spacing apply +to the affected paragraphs, even when the requested range covers only part of +their text. Figma may retain a paragraph alias after unlinking; TemPad tries to +preserve its current literal value without changing the next paragraph. If no +safe fallback exists, including on an empty paragraph, it reports failure rather +than claiming the binding was cleared. Follow the returned readback requirement +and inspect affected text and resources before another write. Then preserve the +binding or explicitly replace the affected paragraph as the intended design requires. + Hyperlinks support URLs and node targets. For a same-result target: ```json diff --git a/agent-plugin/targets/standard/skills/figma-canvas-authoring/SKILL.md b/agent-plugin/targets/standard/skills/figma-canvas-authoring/SKILL.md index 818581b9..6f16b1fd 100644 --- a/agent-plugin/targets/standard/skills/figma-canvas-authoring/SKILL.md +++ b/agent-plugin/targets/standard/skills/figma-canvas-authoring/SKILL.md @@ -135,7 +135,9 @@ screen or section boundaries and carry shared roles coherently across them. Inspect every `apply_canvas` result, including warnings. Repair each observed unintended defect or disclose why it remains. A local validation failure calls for a local payload correction; it does not justify discarding a working root -or simplifying away the intended content. Open pixels again after the final +or simplifying away the intended content. If a write times out, loses its result, +or cannot verify rollback, follow [local recovery](references/editing.md#recover-locally) +before another write. Open pixels again after the final material write, covering every materially distinct screen. Verify native facts with `get_structure` when identity, placement, editability, or representation matters. Opened pixels prove visual access, not good judgment; a structural pass diff --git a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/canvas-html.md b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/canvas-html.md index ef11c260..a8428ac3 100644 --- a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/canvas-html.md +++ b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/canvas-html.md @@ -68,10 +68,14 @@ reveals issues one at a time. unlinks that field. - A `span` contains only text and `
` or `
` line breaks. Use `whitespace-pre-wrap` for literal newlines or repeated spaces. A plain `&` is - literal unless it forms a semicolon-terminated entity; supported entities - decode. Canvas typography does not inherit from a parent `div`: put font and - other text utilities on each `span`/TEXT node. Put flex/grid, gaps, padding, - borders, corners, and box shadows on a parent `div`. + literal unless it forms a semicolon-terminated entity. Numeric decimal/hex + entities and these named entities decode: `amp`, `apos`, `bull`, `copy`, `gt`, + `hellip`, `ldquo`, `lsquo`, `lt`, `mdash`, `middot`, `nbsp`, `ndash`, `quot`, + `rdquo`, `reg`, `rsquo`, `trade`. Other names remain literal; extension 1.0.0+ + also reports an `unknown-html-entity` warning. Use the Unicode character or a + numeric entity instead. Canvas typography does not inherit from + a parent `div`: put font and other text utilities on each `span`/TEXT node. + Put flex/grid, gaps, padding, borders, corners, and box shadows on a parent `div`. - A component tag is childless, includes its returned `data-ref`, and accepts returned props plus the shared class, identity, variable, and style attributes. diff --git a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/component-authoring.md b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/component-authoring.md index 99573887..b747048e 100644 --- a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/component-authoring.md +++ b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/component-authoring.md @@ -241,7 +241,7 @@ the call returns the set as `rootNodeId`, this creates Default and Hover: ```json { "mode": "create", - "markup": "
", + "markup": "
", "native": { "screen/default": { "component": { "id": "ComponentSetID:created-button-set" } diff --git a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/editing.md b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/editing.md index f8653bd5..f020d91e 100644 --- a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/editing.md +++ b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/editing.md @@ -32,10 +32,30 @@ native references needed for the intended change. ## Recover locally Read the entire mutation result. For a rejected payload, correct all reported -issues together without changing the design to fit the error. A verification -failure is rolled back by TemPad; do not assume a partial successful edit. -For an unknown transport outcome, read the exact target before retrying a create -or removal so an uncertain response does not become a duplicate mutation. +issues together without changing the design to fit the error. An execution failure +requires inspecting its reported outcome before retrying. TemPad attempts rollback +after a failed write. Only an explicit no-write outcome (`CANVAS_APPLY_ABORTED` or +`EXTENSION_DELIVERY_FAILED`) skips file readback. Other diagnostics can originate +inside a mutation, especially with older extensions. Follow the returned readback +requirement: rollback checks cover only part of native state. +Automatic Undo can be skipped when this attempt has no confirmed remaining change. +Do not suggest manual Undo in that case: it could remove an earlier successful edit. + +A timeout, lost result, or unverified rollback leaves the canvas outcome uncertain. +Do not replay the write. Wait for a pending operation to settle; reload a stuck +runtime only when the tool calls for it. Then read the affected surviving node with +`get_structure` or `get_code`, carrying the current task and epoch. If the node was +removed, use `get_structure` with its exact page ID. An empty page snapshot counts; +empty selection, failed-node-only results, screenshots, and resource catalogs do not +satisfy readback. Inspect the returned state before deciding on a repair or retry. +If the failed request also changed local variables or styles, inspect those affected +resources with `get_design_system`; a canvas read alone does not verify their state. +If it cannot establish a safe next change, ask the user to inspect the canvas. + +Readback is also required after task resume or reconnection. A file's unknown-write +requirement survives Hub restarts, Stop, and a fresh task; none of those replaces the +read. A notice that readback is still required means the last read did not establish +usable canvas state. Stop cancellation remains in force independently of readback. Repair warnings where they occur. Replace a whole root only when an observed structural defect requires it and the complete intended content can be diff --git a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/paints-effects.md b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/paints-effects.md index c32b82f2..2ce41f9d 100644 --- a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/paints-effects.md +++ b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/paints-effects.md @@ -35,9 +35,11 @@ Named scales such as `shadow-md` are theme references, not portable geometry: Never assume Tailwind defaults or create a token only to resolve a named class. `shadow-none`, `inset-shadow-none`, and `text-shadow-none` explicitly clear. -Treat an outer shadow's rendered halo as part of the composition. Inspect the -final PNG beyond the root edges; visible granular or noisy fringe, or a halo -that dominates the captured bounds, is a defect even when the frame itself is +Treat an outer shadow's rendered halo as part of the composition. Exact-node +screenshots may crop to the node's bounds; inspect an enclosing composition with +enough surrounding space to judge the halo. Its absence from a cropped PNG is +not evidence that it is absent on canvas. Visible granular or noisy fringe, or a +halo that dominates the composition, is a defect even when the frame itself is intact. Preserve intended depth by tightening blur, spread, or opacity or using smaller layered shadows, then recheck. Do not flatten established material treatment merely to hide the defect. diff --git a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/rich-text.md b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/rich-text.md index 3e26cc97..3ac85e5e 100644 --- a/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/rich-text.md +++ b/agent-plugin/targets/standard/skills/figma-canvas-authoring/references/rich-text.md @@ -38,6 +38,22 @@ Use `{ "ref": "s1" }` for a catalog range style and `{ "ref": "v1" }` for a range variable. `null` unlinks supported styles or hyperlinks; omission preserves. +Numeric font size, line height, and letter spacing can also affect a `\n` +immediately after a range. Give adjacent ranges explicit, consistent ownership. +If an error identifies a retained Text style at that boundary, keep the earlier +range unchanged or explicitly replace the affected style only when that is the +intended design; expanding a range can overwrite the next range's formatting. +Do not retry an unchanged request that reports this native limitation. + +Variable `null` is verified after application. Paragraph indent and spacing apply +to the affected paragraphs, even when the requested range covers only part of +their text. Figma may retain a paragraph alias after unlinking; TemPad tries to +preserve its current literal value without changing the next paragraph. If no +safe fallback exists, including on an empty paragraph, it reports failure rather +than claiming the binding was cleared. Follow the returned readback requirement +and inspect affected text and resources before another write. Then preserve the +binding or explicitly replace the affected paragraph as the intended design requires. + Hyperlinks support URLs and node targets. For a same-result target: ```json diff --git a/agent-plugin/targets/standard/skills/figma-design-to-code/references/assets-and-tokens.md b/agent-plugin/targets/standard/skills/figma-design-to-code/references/assets-and-tokens.md index c42d134b..9df81ff8 100644 --- a/agent-plugin/targets/standard/skills/figma-design-to-code/references/assets-and-tokens.md +++ b/agent-plugin/targets/standard/skills/figma-design-to-code/references/assets-and-tokens.md @@ -8,11 +8,13 @@ code, including references whose token metadata is missing. Follow the project's established asset and icon policy before TemPad delivery details. -- Download bytes only from a TemPad-provided `asset.url`. Never substitute a - public internet asset. +- Use the returned `asset.localPath` when accessible; otherwise download + `asset.url`. These are temporary Hub delivery locations: copy needed files + into the project's normal asset location. Never substitute a public internet + asset. - Treat assets as files to store or reference, not text evidence to parse. - If project policy forbids storing them, reference TemPad URLs only when the - user accepts the local-server dependency, and report it. + user accepts the temporary local-server dependency, and report it. - Treat emitted `` markup as design truth for structure, size, and instance color. Refactor delivery only through an existing project SVG path. @@ -61,8 +63,12 @@ Definitions and a consumer's rendered value need not have the same literal representation. Figma opacity variables store percentages (`50` means `0.5` opacity). Code output normalizes only the opacity consumer: resolved output uses the fraction, while unresolved output retains a token reference in `calc()` with -the matching number or length denominator. Preserve that expression; do not divide -the shared token definition, which may also serve spacing or other properties. +the matching number or length denominator. Length division in opacity or font-weight +expressions requires CSS typed arithmetic; preserve it only when the project's CSS +targets support it. For portable output, follow the compatibility warning by +rereading with `resolveTokens: true` and verifying the affected consumer literals. +Unitless division does not require this compatibility fallback. Do not divide the +shared token definition, which may also serve spacing or other properties. If definitions still contradict consumer output after accounting for aliases and effective modes, narrow the design evidence or ask which state is intended. For unavailable or ambiguous facts, follow the token warnings in diff --git a/agent-plugin/targets/standard/skills/figma-design-to-code/references/recovery.md b/agent-plugin/targets/standard/skills/figma-design-to-code/references/recovery.md index 6464e5ca..14cc8370 100644 --- a/agent-plugin/targets/standard/skills/figma-design-to-code/references/recovery.md +++ b/agent-plugin/targets/standard/skills/figma-design-to-code/references/recovery.md @@ -29,9 +29,13 @@ affected root, preserve any trustworthy parent composition: the reported condition before retrying. - **`depth-cap`**: keep the returned top-level composition, then use returned `data-hint-id` values for targeted child `get_code` calls. -- **`token-resolution`**: some references remain in code even with - `resolveTokens: true`. Treat those consumer values as unresolved; do not - replace them with a collection default or claim all values were inlined. +- **`token-resolution`**: read the message to distinguish unresolved consumer + values or units from a CSS typed-arithmetic compatibility warning. The latter + can occur with `resolveTokens: false` for length-valued opacity or font-weight + tokens. If the project needs portable literals, reread the exact target with + `resolveTokens: true` and verify the affected values became literals. References + that remain unresolved must not be replaced with collection defaults or reported + as successfully inlined. - **`token-definition`**: definitions or mode labels are missing or ambiguous. Pause decisions that depend on those facts; successfully resolved consumer literals remain usable under the [token fallback guidance](assets-and-tokens.md#tokens). diff --git a/docs/engineering/codex-desktop-ipc.md b/docs/engineering/codex-desktop-ipc.md index 47db190e..fa648c0a 100644 --- a/docs/engineering/codex-desktop-ipc.md +++ b/docs/engineering/codex-desktop-ipc.md @@ -8,6 +8,9 @@ Read this report when changing TemPad's Codex conversation routing, lifecycle, comments, Queue, Steer, or Stop. It describes the installed desktop application's private protocol, not a stable public API. The implementation and product rules remain in [Design tasks, client integration, and feedback](../extension/mcp-design-tasks.md). +The [integration assessment](#tempad-integration-assessment) summarizes the current +adapter. Dated probes below retain their original scope; later rechecks resolve +some of the early findings without establishing support for newer host builds. ## Release-blocking installed-host findings @@ -27,6 +30,7 @@ This corrects the earlier implication that the server queue cannot be read at al its persisted local state is accessible, although the existing follower IPC does not expose it. Both stores were empty for the inspected task, so that probe alone does not validate nonempty payload decoding or establish its feature-gate value. +The current TemPad reader also honors `CODEX_SQLITE_HOME` as a directory override. The adapter now retains a populated legacy queue and uses native server admission when a server database exists and the legacy queue is empty. It lazily discovers @@ -160,10 +164,10 @@ The 2026-09-17 installed-plugin acceptance exposed a queue-backend conflict. Aft the user queued an independent message in Codex, admitting a Figma comment through the legacy follower endpoint made that independent message disappear from the displayed queue. The user reported that it did not reappear after Figma Stop. -Source inspection explains the queue selection: `R1t` selects the server queue only +Source inspection explained the queue selection: `R1t` selects the server queue only when enabled **and** the legacy queue is empty, while `acceptFromFollower` always writes legacy storage. This establishes a visibility/coexistence failure, not proof -that TemPad deleted the server-side submission. The adapter does not call +that TemPad deleted the server-side submission. That adapter did not yet call `thread/queue/delete`. Basic Queue admission, consecutive submission, draft clearing, queued execution, @@ -208,12 +212,11 @@ regression and successful refreshed-host acceptance are recorded above. ## Findings -1. **Desktop IPC has a native queue interface.** +1. **Native queue admission is separate from execution.** The legacy `thread-follower-set-queued-follow-ups-state` persists a conversation's local follow-up queue and acknowledges the write without waiting for execution. - TemPad now reads the committed local queue and submits a merged list through - this interface. An unavailable local store retains the bounded Hub waiting path. -2. **The native queue interface replaces a whole conversation queue.** It is not + The app-server's `thread/queue/add` similarly acknowledges a stored submission. +2. **The legacy interface replaces a whole conversation queue.** It is not an atomic append operation. Its request has no expected revision, and its acknowledgement has no message ID or queue revision. Finding this method does not establish that an independent producer can safely append alongside the @@ -234,9 +237,12 @@ regression and successful refreshed-host acceptance are recorded above. The optional source is a message ID and text range; there is no Figma node source in that contract. -The working-tree integration uses best-effort read/merge/set, with conversation-level +TemPad selects server admission when the server database exists and the legacy queue +is empty; otherwise it retains legacy admission. Both paths use conversation-level serialization, stable message IDs, durable receipts, and targeted cancellation. -The endpoint still has no compare-and-swap protection against simultaneous composer edits. +Only the legacy path uses best-effort read/merge/set, without compare-and-swap +protection against simultaneous composer edits. The bounded Start fallback requires +both queues to be readable and empty; an unreadable store is never treated as empty. ## Contents @@ -313,9 +319,11 @@ only that message through IPC. The queue was empty before and after the probe. A including the original-conversation snapshot, took 782.96 ms in this single observation. The probe did not execute the diagnostic or measure the read-to-write conflict window. Unit tests cover preservation of existing messages, multiple batches, uncertain admission, -restart recovery, and targeted removal. Automatic queued execution, simultaneous native -composer edits, and the full installed-plugin/Figma UI flow still need live verification. -Windows and Linux observations remain limited to source and deterministic tests. +restart recovery, and targeted removal. This probe did not verify automatic execution, +simultaneous native composer edits, or the installed-plugin/Figma UI flow. The +September 20 follow-up separately records execution, coexistence, Steer and Stop +checks; it does not eliminate the legacy replacement race. Windows and Linux +observations remain limited to source and deterministic tests. ## Protocol boundaries @@ -328,8 +336,15 @@ flowchart LR O -->|app-server RPC| A[Codex app-server] Q --> L[Local follow-up storage] Q -->|when enabled| A + H -->|queue list/add/delete| C[Managed queue-only companion] + C --> S[Native server queue storage] + A --> S ``` +The companion shares native queue storage with the original app-server. It never +loads or executes the conversation; lifecycle, Start, Steer and interruption remain +with the desktop owner. + Three surfaces must not be conflated: | Surface | Wire/API shape | Role | @@ -695,8 +710,10 @@ TemPad reads `queued-follow-ups` from the host's `.codex-global-state.json` with modifying that file. S2 uses `updateAndPersist`; S6 writes and commits the complete file before updating the in-memory store and resolving the native write. Each admission reads again after durable receipt preparation, immediately before dispatch. -A missing or unreadable store uses the existing Hub waiting path; incompatible queue -contents fail without replacement. This is a best-effort integration, not atomic append. +A missing or unreadable store prevents replacement. The bounded Hub waiting path +can submit directly only after both queues can be checked and are empty; +incompatible queue contents fail without replacement. This is a best-effort +integration, not atomic append. Even obtaining an initial list is insufficient to exclude intervening composer edits. Never submit only TemPad's new batch as the replacement list when other queued messages may exist. Likewise, retrying an acknowledged or uncertain replacement can restore messages that the @@ -748,7 +765,9 @@ binary version alone does not establish the feature gate's value. `QueuedSubmission` contains `id`, `clientUserMessageId`, and `input`. The `thread/queue/changed` notification contains `threadId`; the desktop refetches the list. These schemas were generated from the installed binary with experimental -fields enabled; no app-server queue mutations were performed. +fields enabled. The initial schema probe performed no mutations; the later +[companion experiment](#queue-only-companion-process-verified-feasibility) tested +admission, deletion and original-owner execution separately. The local coordinator selects the server queue only when it is enabled **and the legacy queue is empty**. Adding a legacy entry can change which queue is selected. @@ -758,9 +777,10 @@ stores require explicit verification. The app-server API's atomic add and targeted delete are a better semantic fit for multiple producers, but no generic app-server forwarding method was found in the -desktop follower registry. Whether TemPad can reach these operations through an -existing, supported owner connection remains unresolved. Starting another server -or editing Codex's global-state file would not satisfy the normal installation path. +desktop follower registry. TemPad therefore uses the automatically managed queue-only +companion described above. This is not a second conversation executor or a manually +launched helper, and it does not write the host's global-state file or database +directly. A direct owner queue route remains undiscovered in the inspected builds. ## Lifecycle subscriptions and interruption @@ -875,7 +895,7 @@ It is not a claim about a published release. | Windows | Fixed local pipe, OS URL handler, deterministic tests | Native Windows host verification is outstanding | | Reconnection | Durable identities and reconciliation; never restore absent uncertain messages | Absence is not evidence of execution | -A final `delivered` receipt means native input admission, either into the local queue +A final `delivered` receipt means native input admission, either into a native queue or into a turn. It does not mean execution or completion. The UI clears only submitted revisions and permits the next batch after native admission. A final receipt arriving before the initial Hub response unlocks the editor immediately; the late response cannot @@ -939,6 +959,9 @@ process. - [IPC transport and discovery](../../packages/mcp-server/src/agent-clients/codex-ipc.ts) - [Feedback delivery and receipts](../../packages/mcp-server/src/agent-clients/codex-feedback.ts) +- [Queue backend selection and legacy admission](../../packages/mcp-server/src/agent-clients/codex-queue.ts) +- [Read-only server queue detection](../../packages/mcp-server/src/agent-clients/codex-server-queue.ts) +- [Managed queue-only RPC](../../packages/mcp-server/src/agent-clients/codex-queue-rpc.ts) - [Lifecycle subscription and interruption](../../packages/mcp-server/src/agent-clients/codex-lifecycle.ts) - [Turn-state projection](../../packages/mcp-server/src/agent-clients/codex-turn-state.ts) - [Per-request host identity](../../packages/mcp-server/src/agent-clients/identity.ts) diff --git a/docs/engineering/mcp-runtime-updates.md b/docs/engineering/mcp-runtime-updates.md new file mode 100644 index 00000000..f5ba3283 --- /dev/null +++ b/docs/engineering/mcp-runtime-updates.md @@ -0,0 +1,1083 @@ +# Updating a running MCP runtime + +Status: Phases 0–4 are implemented. Codex and Claude native switching are verified. Compatible automatic activation requires enrolled MCP sessions with waiting support; Claude also requires a verified session and receipt/retry-capable hooks. +Verified releases default to stable updates; checkout and unverified local builds keep the bundled +bridge without release acquisition. Incompatible or breaking-boundary updates require an explicit +decision or restart. Design baseline: `74ed70c9`. + +This design is for maintainers changing runtime updates and preparing compatible +releases. It records the process boundary, update transaction, and acceptance +criteria. [Releasing](../releasing.md) owns the implemented artifact and publication policy. + +## Problem and intended experience + +A user should receive compatible Hub fixes without closing an otherwise idle agent +or restarting its MCP connection. Connected operations drain before switching; +disconnected executions require the durable exclusion described below. +Network failures must not stop the installed runtime, and a development checkout +must never query, download, activate, or fall back to a production release. + +This is a process replacement mechanism, not JavaScript module hot reload. The first +implementation preserves the host-facing MCP session but may briefly reconnect the +extension and require an existing design task to resume and read its canvas. It does +not promise uninterrupted WebSockets, HTTP transfers across a restart, or live task +lease migration. + +### Baseline behavior (`74ed70c9`) + +| Owner | Current behavior | Consequence | +| ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| [`cli.ts`](../../packages/mcp-server/src/cli.ts) | Bridges host stdio to the shared Hub; starts the `hub.mjs` beside itself when necessary. | Reconnection neither installs a release nor prevents an older CLI from starting its old Hub. | +| [`hub-runtime.ts`](../../packages/mcp-server/src/hub-runtime.ts) | Owns MCP sessions, tool handlers, WebSockets, asset HTTP, and host adapters. Shuts down after the last MCP consumer disconnects. | An idle consumer can keep old code running indefinitely; restarting the Hub also discards MCP session state. | +| [`design-tasks.ts`](../../packages/mcp-server/src/design-tasks.ts) | Restores active tasks as paused, advances epochs, and requires read-back. | Reconnecting a socket is not enough to resume editing safely. | +| [`asset-http-server.ts`](../../packages/mcp-server/src/asset-http-server.ts) | Allocates a new port and capabilities at startup. | Previously returned HTTP URLs can stop working even when their files remain. | +| [`terminal-receipts.ts`](../../packages/mcp-server/src/terminal-receipts.ts) | Tracks execution completion in process-local state. | A host timeout does not prove the Figma operation has stopped. | +| [`shared.ts`](../../packages/mcp-server/src/shared.ts) and [development packaging](../../scripts/build-agent-plugin.mjs) | Release and checkout launches share the default runtime paths and discovery ports. | A development CLI can attach to the running release Hub instead of its own build. | + +At this baseline, publishing npm `latest` only affected subsequent package resolution. +There was no scheduled version check, compatible-version activation, or coordinated handoff. + +## Alternatives and feasibility + +| Approach | Assessment | +| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Detect updates and ask the host to restart MCP | Lowest complexity; retain this fallback. Automatic host reconnection and tool refresh are not established across supported hosts. It does not fulfill the no-host-restart goal by itself. | +| Retire an old Hub for new connections; run new connections on a new version | Does not update existing long-lived clients. Concurrent Hubs also need independent extension routing and exclusive state/asset ownership, rather than just separate ports. | +| Periodically restart today's byte-bridge Hub | Loses MCP initialization state and permits old CLIs to respawn old code. A timer alone is insufficient. | +| Stable edges plus Hub-managed replacement or an elected edge coordinator | Saves one persistent process, but still needs an owner-independent lock, activation journal, crash recovery, and a hook endpoint during replacement. Reconsider if measured supervisor cost outweighs that simpler ownership model. | +| Stable edges plus a supervisor | Recommended candidate: explicit lifecycle ownership and a surviving control endpoint. Adds a process and a recovery protocol; this cost requires measurement before full implementation. | + +With one host, the proposed steady state has three processes rather than today's two. +An optional installation worker is temporary, not a fourth permanent service. With N +host MCP connections sharing a profile, the count changes from N + 1 to N + 2. No additional +installer process is required when the candidate is already available locally. + +Phase 0 must measure actual emitted contracts for known release artifacts: identical +contracts, presentation-only differences safe for old sessions, incompatible wire or +behavior changes, and cumulative eligibility for a session spanning several releases. +Also measure idle memory, cold startup, and reconnect overhead. Source-file churn is +only a screening signal, not an activation rate. If representative correctness fixes cannot +reach long-lived sessions without carrying multiple behavioral APIs, retain detection +and restart guidance instead of completing this architecture merely because it is designed. + +## Proposed architecture: stable MCP edge, shared supervisor, replaceable Hub + +```text +Host A <-- MCP stdio --> CLI edge A --+ + +-- data --> Hub generation N +Host B <-- MCP stdio --> CLI edge B --+ |-- Figma WebSocket server + |-- asset HTTP server + |-- tools and host adapters + +-- task and asset stores +Edges <-- control --> Supervisor -- lease/lifecycle --> Hub +``` + +| Component | Owns | Does not own | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| CLI edge, one per MCP connection | MCP initialization, negotiated capabilities, request IDs, cancellation/progress, host metadata, and the session's advertised contract. | Canvas logic, native conversation verification, npm polling, or choosing its own Hub version. | +| Supervisor, one per user and runtime profile | Runtime selection, installation worker, admission gate, generation changes, activation journal, and Hub process lifecycle. | MCP tool implementations, Figma operations, native feedback delivery, or task-store writes. | +| Hub, one active generation per profile | Tool validation/execution, WebSocket and HTTP services, native host integrations, and exclusive task/asset-store ownership. | The lifetime of host MCP sessions or independent update selection. | + +Keep the existing npm entry point. A release contains the edge, supervisor, and a +complete runtime payload; an update activates only the runtime payload. The supervisor +may delegate package acquisition to a short-lived worker so downloads do not block +routing. There is no new system daemon, privileged installer, or separate user setup. + +The edge is small but is a real MCP server, not a byte pipe. Use the SDK's protocol +handling and forward generic method/argument envelopes to the Hub. Keep tool schemas +and behavior in the runtime; do not compile a second per-tool implementation into +the edge. The edge freezes the validated tool catalog at initialization. Request +metadata, client identity, cancellation, progress, and structured errors must survive +the internal hop unchanged in meaning. + +The internal protocol needs explicit version negotiation and bounded frames. Each +data connection binds an edge-session ID; each call carries its request ID, supervisor generation, original metadata, +and remaining deadline. Generation changes must not reset deadlines, confuse equal +request IDs from different clients, or authorize a conversation from an arbitrary +claimed ID. The Hub still verifies native identity through the existing host adapters. +Reattaching an edge to a new Hub must not emit a second MCP `initialize` to the host. +Preserve both directions of protocol traffic and backpressure; server requests cannot +share the client's request-ID space. When the host closes stdio, detach that edge. +When the last edge disconnects and no accepted work remains, stop the Hub and supervisor; +the updater must not turn today's on-demand service into an always-running background app. + +The MCP initialization version describes the stable edge. Diagnostics separately +report the active runtime version; they must not imply the initialization reply +changes after an update. + +### Per-edge host context and process environment + +At the design baseline, [CLI startup](../../packages/mcp-server/src/cli.ts) inherited the first launcher's +environment into the Hub. Identity hints are read in the CLI, but the Hub's Codex +[queue reader](../../packages/mcp-server/src/agent-clients/codex-queue.ts), +[SQLite reader](../../packages/mcp-server/src/agent-clients/codex-server-queue.ts), and +[IPC discovery](../../packages/mcp-server/src/agent-clients/codex-ipc.ts) also read host +paths from process environment. Phase 0b removes that dependency; a supervisor must preserve +the same isolation after the original edge disconnects. + +Each edge registers a typed, bounded host context: client kind, session hints, relevant +host discovery inputs, and resolved host-home paths such as `CODEX_HOME` and +`CODEX_SQLITE_HOME`. Resolve defaults in the edge. Capture identity hints from variables +such as `CODEX_APP_TOOLS_PIPE_PATH`, `CODEX_THREAD_ID`, `CLAUDECODE`, and +`CLAUDE_CODE_SESSION_ID` there, never in the supervisor or Hub. Do not forward the entire +environment, credentials, or arbitrary executable paths. These inputs locate a host; +they do not prove conversation ownership. Native verification and request-scoped metadata +still govern binding, including multiple conversations sharing one edge and retired +Claude session hints. + +Launch both supervisor and Hub with an explicit environment allowlist: platform necessities +and validated profile configuration, without host identity/home overrides or unrelated +inherited secrets. Host adapters receive the originating edge's context as an argument; +they must not read or mutate global `process.env` for host settings. Adapter caches and +helper-process environments are scoped to that context, so two host homes cannot share +queue readers or connections accidentally. Helpers receive only the required validated +host fields on top of the clean base environment. + +Re-registration after a Hub switch or supervisor recovery includes the context and its +revision, not just pending request IDs. Revalidate endpoints and native identity before +delivery or writes; missing context cannot fall back to the process that started the Hub. +Retire a context once its edge and outstanding verified work no longer reference it. + +## What can update automatically + +Automatic activation requires all of the following, checked again at commit time: + +- The candidate comes from the configured stable release channel and is newer than + the active release. Crossing a breaking-release boundary requires an explicit update + decision: a minor change within 0.x, transition to 1.0, or a major change thereafter. +- Its internal protocol supports every connected edge and the running supervisor. +- Its executable MCP contract matches every initialized session: methods, capabilities, + tool names, annotations, input schemas, and output schemas. Compare a canonical wire + contract hash, not just a package version or tool count. +- Each session may keep its original instructions and tool descriptions only when the + release explicitly declares that guidance still correct for the candidate's behavior. + Fixing a typo need not force a restart; changing required workflow or safety guidance does. +- Its extension protocol adapters support the promised released-extension range, + including connected peers. An offline older extension must not lose a promised adapter. +- Every host session is initialized and each edge acknowledges bounded pre-dispatch waiting. + Claude requires a verified native session and the receipt/retry requirement in + [Appendix B](#appendix-b-installed-hooks-and-delivery-evidence); legacy or unknown + capability defers automatic switching, without disabling ordinary MCP use. +- It runs on the current Node executable and platform, and preserves the current + configuration's meaning. Updating Node itself is outside this mechanism. +- Both candidate and previous runtime can read and write the state format used after + activation, including cancellation fences and unknown-result markers. + +Each release payload therefore needs a static manifest containing its artifact identity, +Node/platform requirements, supported internal protocol range, wire contract hash, +presentation hash, guidance-compatibility epoch, extension protocol range, and state +read/write compatibility family. Compare this with +every live participant before activation; a release label such as "patch" is insufficient. + +Hashes are gates, not proof of semantic compatibility. Releases must declare compatible +behavior and test the retained guidance. An unchanged guidance epoch is an explicit +reviewed promise; a missing declaration blocks activation. Its source, cumulative meaning, +review responsibility, and publication gates are specified in the +[release policy](#appendix-c-release-policy). +Each edge serves its frozen +presentation snapshot, including after `tools/list`; a new session gets the active +runtime's presentation. No description is silently replaced mid-session. Wire changes +or incompatible guidance stay pending until all affected sessions can be restarted. +The first delivery does not maintain multiple schema/behavior implementations or rely +on every host refreshing its tools after a list-change notification. + +Changes to the edge, supervisor, Node requirement, extension page code, installed +skills, or hooks retain their own update lifecycle. In particular, a Hub update cannot +replace Figma-side `apply_canvas` or code-export implementations. The first delivery +does not hot-replace the supervisor or edge themselves. + +## Runtime provenance and development isolation + +Separate endpoint identity from update policy. There is one release namespace per user, +one per real checkout, and explicitly isolated release-test namespaces. Enrollment never +creates a second release Hub, port pool, or store. A verified release package started by +local path uses the release namespace too; an unclassifiable build fails closed. + +| Namespace | Resolution and update policy | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Release | All plugin/manual release launches share one Hub. Each edge registers its update policy; automatic activation requires every attached edge to opt in. | +| Checkout | `TEMPAD_MCP_DEV_CHECKOUT` pins the real checkout path. No registry requests, downloads, production cache lookup, or production fallback. | +| Release test | Explicit isolated endpoints and stores for updater tests, never production or checkout state. | + +A checkout declaration always wins over a release-update flag; contradictory inputs +must fail closed. Verified releases default to the stable policy; generated release plugin +configuration and manual installation snippets also select `--runtime-updates=stable` explicitly. +No plugin is required to enroll. Existing no-argument configurations take up this policy after +restarting onto the verified release. An `off` edge, or an older edge without enrollment, joins +the same release Hub but defers automatic activation while attached; enrolled edges may still +stage a candidate. +Recheck unanimous consent when closing admission and before commit, including new arrivals. +An explicit `--runtime-updates=pinned` edge requires its launch package's version and manifest; +absence of enrollment is not proof of an npm version pin. Do not switch a pinned edge to a different active artifact: report the +conflict and required reconnect without starting a competing release Hub. +This policy controls runtime acquisition; an external `npx` bootstrap +may still fetch its launch package. Checkout launches use the local Node entry point. +Build origin, real path, and launch policy are validated together rather than guessing +from semver, the current directory, or whether `.git` happens to exist. + +MCP 1.0.0 defaults to stable updates only when a verified release manifest is present. +Unverified local builds retain the bundled path with no registry requests; explicit release +enrollment fails rather than falling back online. macOS, Linux and Windows share the managed +update policy. `--runtime-updates=off` opts out, while +`pinned` requires the launch artifact's exact version. +Agent Plugin 1.0.0 and the shared manual setup snippets specify the same stable policy. +Keep both root and MCP README languages aligned; publish the compatible runtime before these +plugin targets and instructions. First enrollment still needs one coordinated MCP restart. +Until the existing legacy Hub exits, ordinary launches defer enrollment and keep its byte bridge +so updating an installation does not interrupt its active work. +Automatic Hub switching requires initialized MCP sessions and negotiated waiting support; +Claude lifecycle acceptance remains a gate, and checkout builds never enroll. + +Namespace IPC endpoints, locks, state, assets, activation records, and caches by user +and profile. Checkout profiles also include a stable hash of the real checkout path. +The [release Windows pipe](../../packages/mcp-server/src/shared.ts) retains its existing name +and ignores runtime-directory overrides; the Phase 0a checkout pipe includes user and checkout +hashes. Managed Windows control/data pipe names also incorporate the canonical runtime directory +and user, so test and explicit runtime overrides cannot collide. Their private storage and pipe +ACLs are checked through the Windows security APIs; Unix ownership/mode checks are not used as +Windows evidence. Untrusted paths and endpoints fail closed. The compatibility pipe retains its +released name. Naming alone does not establish cross-user access control. + +### Checkout discovery and installed hooks + +Keep the release discovery pool at the current `6220`, `7431`, and `8127` ports. Checkout +Hubs and extensions use a separate bounded pool that never borrows release ports, even +when every checkout port is occupied. Release-test endpoints are also explicitly isolated. +Maintain the pool definitions in shared code and bind the selected pool to the runtime +profile; port separation alone does not authenticate a peer. + +The extension's build-time profile descriptor identifies the real checkout hash or a +release build. Use it for both port selection and the handshake. `import.meta.env.DEV` +alone is insufficient: a checkout candidate can be built in production mode for native +acceptance. Development serving and explicitly prepared checkout candidates must carry +the same checkout identity; published archives must contain no checkout identity. +Distinct checkouts may contend within the dev pool but must reject each other's identity +and never fall back to release. A checkout Hub rejects identity-less old extensions +before accepting sessions or tools; rejection must let their discovery loop try the next +candidate. Verify this with frozen released peers. Unambiguous legacy release connections +retain their compatibility adapter; a conflicting profile never wins by answering first. + +Claude hooks are launched independently of the MCP server and cannot depend on its +configured environment. [Development packaging](../../scripts/build-agent-plugin.mjs) +must generate dev hook commands pointing explicitly to a packaged profile descriptor +containing the same real checkout identity as the MCP launch. A missing or conflicting +descriptor fails closed, without trying the default release socket. Generate this input +from authored sources; do not patch installed or generated hooks by hand. Keep release +hook discovery unchanged in this isolation step; the later versioned hook migration is +specified in [Appendix B](#appendix-b-installed-hooks-and-delivery-evidence). + +### npm launches and version skew + +The existing `npx -y @tempad-dev/mcp@latest` remains the bootstrap distribution path; +its resolved package can vary with registry/cache availability. Runtime polling is a +second acquisition trigger, not a second activation authority. Both feed the supervisor. + +| Incoming edge/package | Required behavior | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bundled runtime older than active | Use the activated runtime if the edge protocol is supported; never downgrade or spawn the adjacent Hub. | +| Bundled runtime newer than active | Offer the complete payload through the versioned bootstrap protocol as a local staged candidate. Verify provenance, integrity, and compatibility normally; do not download it again if already verified. | +| Edge/package prefers a newer supervisor protocol | Negotiate the highest mutually supported version and use the active runtime's contract. Disable only optional unsupported update features. Refuse the new attachment only when no safe common version exists. | +| New runtime needs a newer edge or supervisor | Keep it staged; identify which sessions/control component require replacement. No force activation. | + +Edges and supervisors must negotiate downward throughout the control-protocol support +window in the [release policy](#appendix-c-release-policy). +The fixed bootstrap discovery response advertises ranges before negotiation. Prefer +optional capabilities to a new protocol generation. This is a minimum support window, +not a guarantee that an indefinitely old supervisor can accept every edge. + +If no compatible protocol remains, respond to the host's `initialize` request with an +explicit JSON-RPC server error identifying `MCP_RUNTIME_RESTART_REQUIRED`, the installed +and supported versions, and the required restart action. Emit one stderr explanation, +flush the response, and exit unsuccessfully. Do not initialize successfully with an empty +tool list, advertise unusable tools, or launch another supervisor. Hosts may present this +as a failed MCP connection; their exact UI is an acceptance check, not a protocol guarantee. + +The first implementation explicitly accepts that edge/supervisor code can remain old +for the lifetime of open host connections. It guarantees opportunities for Hub updates, +not a maximum age for every process. Report control-component updates immediately when +detected, retaining a restart-required status; no expiry timer forcibly disconnects users. +After all edges disconnect, a subsequent launch may select the newer compatible control +component. Persist control-component identity separately from the active Hub, so an old +edge cannot accidentally respawn an obsolete supervisor during crash recovery. + +## Discovery and staging + +Recommended initial defaults, subject to isolated measurement: + +- Check in the background after successful startup, then every six hours with jitter. + Persist the last check so reconnects and multiple edges do not multiply requests. +- Only the supervisor checks. A network check never blocks MCP initialization or + a tool call. Offline operation uses the current verified runtime. +- Bound metadata requests to ten seconds, downloads to two minutes, and the complete acquisition worker to four minutes; + bound response, archive, and extracted sizes. Failure leaves the active runtime intact. +- Retry failed checks with backoff. Quarantine a failed candidate by version and + integrity hash rather than attempting the same failing switch in a loop. + +Read the official release catalog and search stable versions newer than the active Hub, in +descending order up to the current `latest` tag. Skip deprecated, quarantined and incompatible +candidates; compare compatibility against both the active Hub and the retained control artifact. +Each pass examines at most 16 releases and downloads at most one archive, yielding between +candidates after 30 seconds. Persist the continuation cursor and resume after 30 seconds; catalog, +active/control manifest or quarantine changes invalidate that cursor. A successful check also +revokes stale staging when the stable tag moves back, without downgrading the running Hub. +Exact-version commands inspect that version only, never substitute a different candidate. + +Resolve the selected version to its exact integrity and stage that immutable artifact in a +private directory outside the npm/npx cache. Validate +the tarball integrity, package name/version, runtime manifest, required files, archive +paths, and supported engine before executing candidate code. Never use a URL supplied +by a Figma document, extension message, or tool argument as an update source. + +The release payload must include a reproducible runtime dependency closure, including +logging workers, so activation needs no network access or floating dependency install. +Building that payload and checking it outside the workspace is a release prerequisite. +Do not run package lifecycle scripts during acquisition, rewrite the running npm cache, +or install into the user's project. Registry integrity checks protect artifact bytes; +they do not independently prove publisher authenticity. Retain the existing trusted +registry/publisher boundary and verify release provenance in CI. + +Perform an isolated candidate self-test using temporary stores and endpoints, with no +user Figma discovery or native host delivery. Check startup, contract hashes, configuration, +state-reader compatibility, and clean shutdown. Candidate execution is allowed only +after artifact validation. Keep the active and previous known-good payloads until +rollback and process-reference checks permit cleanup. + +## Update transaction + +```text +running -> checking -> staged -> draining -> switching -> verifying -> running + | | | | | + running deferred deferred recovery rollback +``` + +1. **Stage while serving.** Download and self-test without disturbing the current Hub. +2. **Close admission globally.** Acquire the update lock, pin the connected-client + compatibility set, and fence new work in the supervisor and old Hub. Each edge first + pauses local dispatch and acknowledges completion of requests already sent; closing + the Hub gate alone can lose a pre-dispatch rejection when its data socket exits. + Edges without this handshake defer switching. Newly connecting edges join the gate, + and an aborted switch resumes the original edges. MCP ping and cached tool listing remain + available. New business + calls wait in a bounded edge buffer for at most ten seconds, within the original request + deadline. Only explicit pre-dispatch rejection can be retried; unknown outcomes and socket + failures are not replayed. Host cancellation and closure discard waiting requests. Each edge + retains at most 64 requests and the SDK byte limit. Exhaustion returns an unavailable result + stating that no call was dispatched; the frozen tool catalog remains available. + The Hub also gates direct HTTP uploads/downloads and browser feedback actions; + gating only the CLI path would leave an admission race. Keep registration, completion + receipts, cancellation, and Stop available. +3. **Drain actual work.** Wait for dispatched calls, native feedback admission/removal, + uploads, downloads, task bindings, and state flushes. Count Figma terminal receipts, + not merely expired host requests. Idle stdio connections, WebSocket heartbeats, and + open reviews do not block an update. Known read-back fences can be carried forward; + detached operations require the stronger execution-fence rules below. +4. **Defer rather than kill.** Derive the drain deadline from the latest recorded remaining + deadline of admitted work plus a bounded flush allowance, subject to the maximum + admission-pause policy below. If work is still active, reopen old admission and retry + later. Do not cancel a canvas transaction or feedback submission merely to update. + Automatic activation first requires five continuous idle seconds, tracked across tools, + transfers, bindings, hooks and native actions, including work between inspection polls. + It rechecks immediately after closing admission and at shutdown: newly arrived work reopens + the original Hub instead of entering the long manual drain. The existing lease heartbeat + schedules checks only with an eligible staged candidate; failed attempts back off for a minute. +5. **Prepare durable handoff.** Flush current task, fence, feedback, and asset state. + Save the update phase, active/candidate artifact identities, state revision, endpoint + configuration, and next generation in the supervisor's activation journal. Journal + writes use atomic replacement and the platform's durability primitives. No task or + asset store is written by both processes. +6. **Stop the old Hub.** Use a dedicated acknowledged drain/shutdown path; the current + best-effort `shutdown()` is insufficient. Confirm process exit and store-lock release + before starting the candidate with production state. Losing a control socket is not + proof that the process exited. An unresponsive old process blocks the planned update. + Its shutdown acknowledgment includes the last durably applied control sequence and + store revision; controls accepted after the earlier flush must not be omitted. +7. **Start and verify the candidate.** Acquire the same store locks, restore state, bind + the reserved endpoints, reattach edges, and allow extension registration and lifecycle + controls. Keep normal operations fenced until stores, listeners, and routing are ready. + Use a bounded readiness deadline; no connected Figma page is required just to start. +8. **Commit and reopen.** Atomically commit the activated artifact and generation before + releasing admission. Each edge routes subsequent calls to the new generation without + replacing its host stdio connection. Revalidate task and browser identity before any + write. Suppress routine success messages in model-facing tool output. + +Cancellation and Stop remain admissible during draining. Existing browser-local Stop +fences and saved cancellation snapshots remain authoritative during the brief socket +gap. The new Hub reconciles them before permitting writes. Ordinary control requests +must not report completed cancellation merely because they were buffered. Legacy hooks +have the separate durable-admission contract below. If control delivery or conversation +identity is uncertain, leave tasks paused and require fresh verification. +Never replay Queue/Steer delivery to compensate for an unknown admission result. + +### Drain deadlines and disconnected execution + +The default canvas timeout is 120 seconds; Codex's 300-second tool timeout is a host +wait limit, not proof that Figma has stopped. Runtime settings may override the defaults. +For each admitted operation, +record its effective absolute deadline and actual terminal state. The drain target is +the latest remaining deadline plus a proposed ten-second flush allowance, capped by a +proposed 330-second admission pause. A longer override may cause deferral; expiry never +authorizes killing the operation. Closing admission prevents a growing new-work queue. +Use monotonic time for a live drain and conservatively reconcile after sleep/restart. + +Execution with a confirmed end needs only read-back protection. An execution that may +still be running needs durable file exclusion, preserved across updates **and cold starts**. +This extends the existing in-process exclusion; it intentionally strengthens restart behavior. +It cannot expire just because every edge disconnected. Provide explicit operator recovery +for an abandoned executor, followed by read-back, rather than requiring its original browser +profile to return forever. [Appendix A](#appendix-a-disconnected-execution-and-operator-recovery) +defines the evidence, recovery entry points, and limits. + +### Task continuity + +Use normal restore semantics in the first implementation: active tasks become paused, +their epochs advance, and subsequent writes require explicit resume and successful +read-back. MCP tools explain the recovery, so an agent can continue through the same +connection without a user restarting it. Cancelled tasks remain cancelled; update +handling must never begin a replacement task automatically. Disconnected owners do not +regain authority simply because their edge process survived. + +Prefer a quiet operation boundary, but do not wait for every design task to complete: +that would recreate the indefinite-update problem. No-op MCP heartbeats do not count +as work. The tradeoff is a possible resume/read step during a long conversation. +Transparent live-lease transfer would need a separate fenced handoff protocol and is +deliberately excluded from the first delivery. + +### WebSocket and asset continuity + +- Restart the WebSocket and HTTP services together with the Hub generation. Keep the + previous WebSocket port when available; reconnect through existing discovery. Never + run two writer Hubs on different candidate ports to hide the transition. +- Drain accepted HTTP transfers before stopping. Persist the asset listener port and + current/legacy capability secrets privately and supply them to the successor. The + asset server currently needs new constructor support for these inputs. Preserve the + asset directory and filenames, so existing HTTP URLs and `localPath` values retain + their ordinary lifetime after a compatible update. +- If the required HTTP port cannot be rebound, keep admission closed and attempt safe + recovery. Do not claim a transparent update while silently invalidating returned URLs. + Capability rotation or endpoint changes require an explicit nontransparent transition. +- After every prior edge and owner has exited, a cold start may replace occupied + endpoint ports under the startup lock. It reserves available ports (using the release + discovery candidates for WebSocket) and durably changes only those allocations, + preserving both capability secrets and the existing metadata format. A later port + collision fails closed and can be retried at the next cold start; an incomplete hot + handoff never reallocates endpoints. +- A reconnected extension must validate its new handshake and asset endpoint normally. + Do not weaken Origin checks or send a changed endpoint on an already-open connection. + A stopped task's snapshot must win over restored active state. + +## Failure and rollback rules + +| Failure | Required behavior | +| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Offline, invalid artifact, incompatible contract, failed isolated self-test | Keep serving the current runtime; record a pending/failed check without interrupting tools. | +| Drain budget expires | Reopen old admission, preserving operations and cancellation; retry later. | +| Persisting the update journal fails | Do not stop the old Hub. | +| Persisting update scheduling metadata fails | Keep serving tools; expose the error and disable checks until owner restart. Download only after a durable attempt; activate only committed candidates. | +| Old Hub cannot confirm shutdown | Do not start a second writer or forcibly kill it for an automatic update. | +| Candidate fails before admission reopens | Stop and confirm candidate exit, then restart the previous runtime against current state. | +| Candidate fails after handling work | Preserve current state and unknown-result fences; recover with the previous binary only when state compatibility permits. Never replay a write or restore an old database snapshot. | +| Supervisor crashes during ordinary operation | Keep edge MCP sessions open; elect one replacement supervisor and reattach to the existing Hub before considering a new Hub. | +| Supervisor crashes during a switch | Close new admission; reconcile journal, live process identity, and store locks before choosing a generation. An ambiguous transition stays blocked. | +| State requires a one-way migration | Stage only; require a separate coordinated upgrade. Automatic binary rollback is not safe. | +| New runtime needs a newer edge, supervisor, or Node | Explain the required MCP restart/runtime upgrade; do not bypass compatibility checks. | + +The journal tracks `preparing`, `old-stopped`, `candidate-ready`, and `committed` with +unique transaction/generation IDs. Resume from observed process and lock ownership, +not PID alone. A successor never assumes a restarted supervisor means an old Hub is +dead. Until a new supervisor handshake is established, the Hub rejects new business +work but lets already-dispatched operations settle and persists their outcomes. This +applies during ordinary operation as well as an update. + +Edges retain their initialized MCP session and enter bounded reconnect/backoff after +losing the supervisor. One edge wins the profile's startup lock and starts the recorded +compatible supervisor; other edges wait. The winner must also acquire the supervisor's +lifetime lock before publishing its control endpoint. A broken socket alone cannot +replace a still-live supervisor. The successor checks the activation journal, process +identity, the Hub control lease defined below, and store locks, then attaches to the live Hub or recovers a +dead one. Edges re-register their session, versioned host context, and pending request IDs +without asking the host to initialize again. Every routed frame carries the control +generation, fencing stale owners. The independent edge/Hub data connection can deliver +accepted results during control recovery without supervisor buffering. If that connection +is also lost, unrecoverable write results become unknown outcomes, never retries; durable +execution ownership and result recovery require Phase 2. Exhausted recovery +reports unavailability while preserving the host session; it does not create a second writer. + +The **Hub control lease** is issued and checked by the Hub that holds the store locks. +It grants one supervisor instance control of that Hub, scoped to the profile, Hub instance, +supervisor instance, and monotonically increasing control generation. Proposed defaults +are renewal every two seconds and expiry after ten seconds without renewal, measured on +the Hub's monotonic clock. Each admission checks expiry; a delayed timer is not authority. +Expiry or control-channel loss stops new business admission, while existing operations may +settle and Stop remains available. It neither releases store locks nor proves a process +has exited. A replacement supervisor must acquire the profile lifetime lock and complete +the validated control handshake before receiving a new lease. Frames from an older +generation cannot renew or use it; wake from sleep requires revalidation before admission. + +Rollback means switching executable code while preserving the latest durable state. +Restoring a pre-update snapshot could resurrect a stopped task or resend feedback and +is prohibited. The rollback guarantee must cover state written after activation, not +just whether a candidate can read the old snapshot. + +## Status, controls, and rollout + +Expose current edge/runtime versions, source profile, last check, staged version, +phase, and the reason activation is deferred. Secrets and full capability URLs never +appear in status. Add proposed CLI actions `--runtime-status`, `--check-runtime-update`, +and `--update-runtime`; they contact the supervisor and cannot bypass drain or profile +rules. A profile-level updates-off setting prevents background checks. A manual check +on a checkout reports that it is pinned without contacting the registry. + +Runtime status reports `controlComponents` separately from the active Hub: the retained +supervisor version and each attached edge's reported package version. A newer active or +staged version remains pending with `restartRequired`; switching the Hub does not apply +that control update. Unknown edge versions and their restart requirement remain `null`, +and quarantined candidates do not recommend a restart. No host context or session tokens +are included in this projection. + +After every MCP edge and runtime owner has exited, launching a newer installed release +advances both the retained control component and the Hub using those local verified bytes. +The startup lock serializes migration and edge registration; live edge records prevent an +owner crash from being mistaken for a cold restart. An interrupted metadata migration +resumes its recorded decision without restoring task or receipt snapshots. Cold replacement +may cross a wire/guidance boundary, but still requires state/extension compatibility and a +common control protocol for binary rollback. It never downgrades, retries quarantined bytes, +or activates a downloaded candidate merely because it is staged. + +The extension's integration status may display a concise pending-update or recovery +message through a negotiated status capability. Older extensions keep the existing +wire shape. Do not inject recurring upgrade reminders into model prompts or tool results. + +Do not expose these proposed actions as MCP tools that let document content trigger +installation. The supervisor owns policy; model-visible tools report normal availability +and recovery rather than choosing versions or bypassing an update gate. + +Existing raw-bridge CLIs cannot acquire these capabilities by updating the Hub beneath +them. The initial transition requires one coordinated restart of the old MCP connections. +Do not kill an existing legacy Hub or start another writer while old clients still own +it. The supervisor's public IPC endpoint must reject legacy byte-bridge attachment with +actionable guidance; legacy CLI processes cannot participate in hot activation. This +rejection does not apply to installed lifecycle hooks, whose compatibility is described +below. Thereafter, updates outside the compatible-runtime boundary still need a restart. + +Migrate existing stores to a profile only with the legacy Hub stopped and its store locks +held. Preserve all task fences, unknown records, feedback receipts, and asset paths; +record an idempotent migration result before activation. Existing asset files may stay +in their owned directory to preserve returned paths. The old default IPC path needs a +compatibility guard so a leftover legacy CLI receives restart guidance instead of +silently recreating an old Hub. If a conflicting legacy process or ambiguous store owner +is detected, defer enrollment. This cannot prevent someone deliberately launching an +unmodified old binary against a separate custom configuration; do not claim that guarantee. + +### Installed hook compatibility + +Keep the legacy hook listener; rejecting old MCP byte bridges must not reject valid +lifecycle hooks. Its 750 ms deadline cannot guarantee durable admission under all disk +conditions. Automatic switching with attached Claude clients therefore requires verified +receipt/retry-capable hooks; legacy or unknown capability defers automatic switching. +Manual migration retains the compatibility listener and fresh lifecycle validation. +[Appendix B](#appendix-b-installed-hooks-and-delivery-evidence) defines routing, +timing measurements, and recovery when acknowledgment is missing. + +## Delivery phases and acceptance gates + +Use isolated processes, fake registry artifacts, real sockets, and fault injection. +Each phase has an exit criterion; none implies permission to publish or replace live services. + +### Phase 0a — independent release/checkout isolation + +Keep one release namespace and its existing paths, pipe, and ports. Isolate checkout roots, +locks, state, assets, logs, discovery ports, and hook routing by real checkout identity. +This phase does not implement enrollment, a supervisor, an updater, or host-context changes. +Exit: isolated real CLI/Hub processes coexist without cross-routing or shared files; +missing/conflicting checkout builds fail closed; dev hooks work without MCP environment; +production-mode checkout extensions and released peers obey the profile handshake. + +The implementation uses `TEMPAD_MCP_DEV_CHECKOUT` for Node launches and production-mode checkout +extension builds; `pnpm dev` selects the checkout automatically. Explicit directory overrides +remain bases and receive a checkout suffix. Empty checkout overrides use defaults. Runtime +socket paths exceeding the Unix limit fail before connection retries. Installed hosts are +not switched by the isolation checks; see `pnpm mcp:check-profiles` in [Testing](../../TESTING.md). +Release ZIP packaging rejects checkout builds. Checkout discovery uses the build profile even +when runtime identity is unavailable, and refuses to connect without that identity. Checkout +Hubs close peers that do not complete the profile handshake within five seconds. Generated +dev hooks read the sibling MCP configuration for directory overrides and reject missing or +conflicting checkout identity instead of falling back to release. Configured runtime directory +overrides must be absolute: hooks and MCP hosts need not have the same working directory. + +Windows process-level isolation is unverified. Unit tests cover distinct users/checkouts and +the unchanged release pipe name; process and Unix hook checks do not run on Windows. Directory +overrides do not change a checkout pipe name, so two test Hubs for the same Windows user and +checkout cannot be isolated that way. Phase 0a is not claimed as verified on every platform. + +### Phase 0b — independent per-edge host context + +Extend today's CLI identity registration channel without waiting for a stable MCP edge or +supervisor. Remove first-launcher environment dependence from host adapters and helpers. +Exit: Codex and Claude launch in either order, two Codex homes remain isolated, and losing +the first launcher cannot change another session's identity, queue path, or helper environment. +Deliver and review this behavior change separately from 0a. + +The CLI now sends a versioned `notifications/tempad/host-context` registration before the +unchanged client identity notification on every connection. Unknown notifications leave +older Hubs' identity handling intact. The Hub accepts one immutable context per connection; +request metadata and native ownership checks still select the conversation. Queue readers, +helper environments, availability caches and lifecycle subscriptions use that context. +Hub launch uses an environment allowlist rather than inheriting host identities or credentials. +Codex task ownership and delivery serialization compare only the Codex home and SQLite home. +`home` and `tempDir` remain discovery inputs: a verified reconnect updates them without losing +its task or delivery identity. Caches still distinguish complete discovery contexts. Claude +task reconnection does not depend on Codex paths. Rejected storage mismatches are logged. + +Contexts remain private to the Hub: saved tasks and feedback receipts retain their routing +context through disconnect and restart, without exposing it to MCP results or the extension. +Older clients and stored records without context keep canvas access and local Stop; native +controls require updating the CLI and restarting its MCP server in the host; reconnecting an old +process cannot supply missing context. They never use +the first launcher's configuration as a fallback. Contexts do not authorize executable paths. +Legacy queue receipts without recoverable context retain their deduplication/cancellation evidence +instead of expiring without checking the native queue, as detailed in the +[feedback retention rules](../extension/mcp-design-tasks.md#codex-feedback-native-queue-and-steer). + +`pnpm mcp:check-host-context` verifies real CLI launch/reconnect behavior with a private fixture +Hub. Real Hub socket tests cover registration and task persistence; adapter tests cover two +Codex homes, scoped queue helpers and restored receipts. These checks do not establish live +Codex/Claude UI behavior or Windows process-level isolation. No supervisor is introduced. +Codex App native Queue, Steer, Stop and lifecycle acceptance is required before shipping 0b; +deterministic checks alone do not establish compatibility of the sanitized helper environment. + +Native acceptance passed on macOS with Codex App 26.1021.22027 and bundled CLI +0.164.0-alpha.11.1: a comment queued from Figma reached the next native turn, Steer +reached an already-running turn, turn completion paused the design task, and Figma Stop +persisted cancellation and interrupted the owning turn. This used the installed checkout +plugin and runtime-managed queue helper; it does not establish Windows or other host-version +compatibility. + +### Phase 0c — feasibility measurements and hook inventory + +Compare actual release-artifact contracts/guidance and measure process cost and legacy hook +latencies. Inventory capable, legacy, and unknown hooks with the observed denominator in +installed-host tests, without background telemetry or population-wide adoption claims. +Current shipped hooks are legacy. Exit: delivery evidence after timeout is understood and +the measured benefit justifies the supervisor; otherwise retain update notices. This research +does not block the independent 0a/0b fixes or establish that automatic updates are implemented. + +The opt-in measurement commands in [Testing](../../TESTING.md) now inspect supplied local +packages without downloading releases or touching installed runtimes. They record emitted +catalogs, artifact hashes, resolved-dependency lock provenance, process timings/RSS, and +structural comparisons for both adjacent and skipped releases. Schema descriptions are +separated from executable fields without erasing properties literally named `description`. +Unknown schema keywords remain significant. Neither hash grants activation eligibility. + +The initial macOS/Node 24 sample covered all 12 stable npm releases from 0.5.0 through 0.11.0, +with five fresh Hub runs per artifact and two CLI attachments per run. Of 11 adjacent transitions, five had identical +advertisements, two changed presentation only, and four changed the wire contract. The +0.5.0 → 0.5.1 presentation change actually changes vector/follow-up guidance and must not be +treated as a safe text correction. The 0.7.0 → 0.7.1 change only renames/titles the server. +All four transitions from 0.7.1 onwards change wire contracts. These counts are not hot-update +coverage: 0.x minor boundaries also need approval, dependencies were resolved at measurement +time, and no historical release supplies a reviewed guidance epoch. + +This supports a bounded bundled-only Phase 1 prototype: the 0.6.1 Hub startup-race fix is a +concrete correction whose advertised contract stayed unchanged. It does not justify enabling +automatic updates or carrying multiple schema implementations. Measure the prototype's added +process and recovery cost against this baseline before accepting the supervisor architecture. +Warm CLI attachment is only a baseline; it does not prove uninterrupted MCP reconnection. + +The inspected installed Claude payload was 0.4.0: one legacy payload, zero receipt/retry-capable +payloads. No live Claude binding was validated by this inventory. Both that payload and the +current hook were exercised against a synthetic receiver. Slow persistence and lost replies +can leave a committed event; failed persistence, early disconnect and exit before arrival +leave none. All five cases exit successfully without usable acknowledgment. Therefore the +receiver's retained record, followed by fresh binding evidence, must decide recovery; the +hook exit code cannot. File/directory fsync samples and an injected 900 ms delay do not measure +full-device flush or platform-wide load behavior. Those measurements and installed-host +receipt/retry acceptance remain gates before automatic activation, not claims from this probe. + +### Phase 1 — stable edge and supervisor, bundled runtime only + +The isolated `runtime-prototype.mjs` entry implements this topology with control protocol 2. +Protocol 1 was an unpublished prototype without hook admission provenance; its isolated +fixtures require a new runtime root. Incompatible control components fail during negotiation, +before handoff or rollback, rather than interpreting historical hooks as fresh evidence. +It requires a private runtime root, pins the built `.mjs` files, and allocates ephemeral ports; +the normal CLI does not select it. `pnpm mcp:check-runtime-prototype` exercises it with real +processes and a synthetic extension, without touching installed hosts or downloading packages. + +The prototype keeps edge/Hub data sockets separate from supervisor control. Existing responses +and cancellation can settle while the supervisor is unavailable; new business calls, asset +transfers, and feedback admission require the Hub's current lease. Stop, review closure, and +lifecycle hooks remain available. A successor re-registers each edge and its host context against the same Hub. +This avoids replay buffers in the supervisor and preserves the SDK sessions on both ends. +A lost Hub or data socket is not retried as a write: the host session retains ping and its frozen +catalog. Phase 2 adds backend replacement and durable recovery below; Phase 1's process-cost +measurements describe the earlier supervisor-only implementation, not the switching path. +Neither harness is installed-host acceptance. +Its separate build under `dist/prototype/` leaves the normal Hub entry and preflight +fingerprint intact; ordinary builds and npm publication do not include that prototype output. +Dependencies still resolve from the local installation; this is not Phase 3's immutable release payload. + +Five fresh-process trials per path on macOS arm64 with Node 24.22.1 gave these local medians: + +| Measurement | Existing bridge | Prototype | +| ---------------------------------- | --------------- | --------- | +| Attach a second initialized client | 134 ms | 173 ms | +| Two-client process RSS sum | 316 MiB | 433 MiB | +| Supervisor crash recovery | Not applicable | 5.44 s | + +Prototype cold initialization took 589 ms (585–603 ms), including election and catalog freezing. +The existing harness starts its Hub separately, so its cold timings are not directly comparable. +Recovery included the five-second stale-lock window; all five trials retained the same Hub and +delivered the admitted write once. These are isolated synthetic checks, not latency guarantees +or native-host acceptance. RSS sums include shared pages and are not physical-memory deltas. +The extra process and roughly 116 MiB RSS cost need to be considered before production enablement. + +- Two clients initialize and use the same bundled Hub; tools, frozen metadata, progress, + cancellation, responses, and native identity retain their existing semantics. +- Kill the supervisor during normal operation. Exactly one replacement attaches to the + existing Hub; clients keep their MCP initialization and no canvas write is replayed. + Re-register each edge's host context and verify isolation after the original launcher exits. + Distinguish a lost supervisor connection from the host closing its edge session. +- Exercise newer/older edge negotiation, response backpressure, host disconnect, and last-client + shutdown. The public legacy hook endpoint works; raw legacy MCP attachment gets guidance. +- Verify a new edge negotiates to each supported old supervisor and keeps usable tools. + No common version produces an explicit initialization error, never a successful empty catalog. +- No online checks or runtime version switching are enabled in this phase. + +### Phase 2 — manual local-artifact switch and recovery + +The isolated prototype implements local switching. The installed CLI still uses its normal +bridge; no release or checkout is enrolled, and no online version lookup or download runs. +Build the prototype separately as in Phase 1. Keep the initial entry available for supervisor +recovery and issue operator commands against that entry and the same private, canonical root: + +```sh +node /runtime-prototype.mjs --root --status +node /runtime-prototype.mjs --root --recover-runtime +node /runtime-prototype.mjs --root --switch /runtime-prototype.mjs +node /runtime-prototype.mjs --root --runtime-status +node /runtime-prototype.mjs --root --resolve-runtime-block +``` + +`--switch` is an explicit decision for that exact newer stable version. Both local artifacts +are probed in disposable private runtimes, checked for handoff support and identical frozen MCP +metadata, and fingerprinted before execution. Local dependency resolution is still trusted; +this is not Phase 3's verified npm acquisition or immutable dependency closure. Rejected contracts +require a host restart; failed activation bundles are quarantined in this isolated root. + +The coordinator gates new work, drains admitted calls/native feedback/transfers, flushes state, +records the handoff, acknowledges shutdown, and verifies process exit and writer-lock release +before starting the replacement. Known request deadlines include a ten-second flush margin; +work without a shared absolute deadline uses the 330-second outer bound. Expiry defers the switch, +never kills admitted work. Fixed WebSocket/HTTP ports and both asset capabilities are persisted. +The edge checks the replacement's full contract again before acknowledging readiness. Recovery +and rollback reuse current state, including post-activation Stop fences; no old snapshot is restored. +Following a crash, the existing writer-lock expiry can delay recovery by about a minute. +Failed fallback startup retries for up to 90 seconds only when no process still owns the +runtime. A persistent port conflict or uncertain owner keeps admission closed; after fixing +that condition, `--recover-runtime` continues the journal without clearing task or execution fences. +First attachment also waits up to 90 seconds for explicit transient admission failures; +permanent compatibility errors fail immediately. This does not extend a host's shorter +initialization deadline and never replays MCP initialization or business requests. + +The supervisor owns the legacy hook socket while the Hub uses a private data socket. Received +events enter a durable inbox; task effects and invocation receipts commit together before reply. +Failed or unconfirmed delivery fences admission. Lost replies can be retried without reapplying +effects. Pending inbox events and uncertain Hub receipts each have a 4,096-event limit; +completed history does not consume pending capacity or fence otherwise healthy admission. +The v2 ledger holds at most 4,096 pending events and completed responses in total. Completed +responses expire after up to 24 hours, or sooner under result-cache pressure, with a persisted rejection cutoff so +expired invocations cannot replay even after clock rollback. An expired pending v2 event +is retired by the Hub with its session/task invalidation durably recorded; newer lifecycle +evidence, Stop notices and execution fences survive. The inbox records a terminal expired +outcome rather than an applied acknowledgment, allowing subsequent events and recovery to +proceed. Cache pressure cannot advance the cutoff past unresolved events. Unconfirmed retirement +still fences admission. The separate legacy ledger retains at most 4,096 identities: result bodies +expire after 24 hours, but v1 has no sender timestamp, so its identity tombstones cannot be discarded +without allowing an old retry to reauthorize a session. Exhaustion rejects new v1 identities with +an explicit plugin-upgrade error. Together the two ledgers retain at most 8,192 identities; +unresolved events still share the 4,096-event admission limit. Before reopening general MCP admission, the authenticated Hub +retires only v1-only sessions and durably pauses their tasks, preserving Stop and execution fences; +v2 sessions remain usable, and a fresh v2 hook can establish current evidence. Known v1 identities +remain deduplicated. Old snapshots without a legacy completion time are compacted on startup; +pending and uncertain effects are preserved. Clock rollback never lowers a persisted rejection cutoff. +Live legacy Claude +bindings defer even manual switching in this prototype. A legacy hook that exits before its request +arrives is still unobservable; this implementation does not claim to preserve that event. + +Persistent execution exclusion and interactive recovery also apply to the normal Hub. A cold +restart does not unlock a possibly running write. Recovery requires stopping its original execution +environment, confirming the exact block interactively, and then reading the canvas; cancelled tasks +remain cancelled. No extension recovery dialog is exposed in this phase. + +`pnpm mcp:check-runtime-switch` owns the repeatable process/fault matrix; unit tests cover durable +commit failures, stale confirmations, lifecycle deduplication and admission boundaries. The +Windows CI job runs this matrix against real named pipes and child processes; installed +Codex/Claude behavior remains a separate acceptance requirement. + +- In isolated release-test profiles, switch compatible A to B with two clients still + initialized; idle clients do not block. The operator offers a newer local build; + lower or equal versions are rejected. Verified npm acquisition remains Phase 3 work. +- A live canvas operation, native feedback delivery, and asset transfers drain. Verify + custom deadlines, deferral, and calls arriving after the gate as definitely undispatched. +- A disconnected remote execution transfers only with the durable exclusion contract. + Read/resume cannot unlock it; late verified completion or document replacement can. + Unsupported peers defer; a new runtime never mistakes a missing receipt for completion. +- Full cold restart preserves execution exclusion. A new browser profile can use explicit + operator recovery without the old profile returning; test stale confirmations, failure to + persist, idempotence, retained Stop fences, and the subsequent mandatory read-back. +- Ship persistent exclusion and its tested interactive CLI recovery in the same MCP version, + even when automatic updates are off; document the changed restart behavior in its changelog. + Never release exclusion alone. If the extension dialog is offered, verify its privileged + origin/user-gesture path and rejection of page, plugin, forged, and replayed recovery messages. +- Exercise Stop, `/clear`, and hook events at every phase boundary, including the legacy listener. + Inject delays beyond 750 ms and losses before arrival and after commit. Do not claim an + unreceived event survived; unavailable fresh evidence keeps writes paused. Legacy/unknown + hook capability defers automatic activation, and capable retries never duplicate effects. +- Asset URLs and local paths survive switching. Test port conflicts, Origin checks, + capabilities, journal crash points, disk-full errors, and failure after admission reopens. + Rollback retains all post-activation Stop and execution fences. + +### Phase 3 — verified release acquisition and explicit controls + +Implemented in `src/runtime-update/`, with the Phase 2 transaction shared by the managed release +entry and isolated local harness. The coordinator retains its original edge/supervisor payload, +current/previous Hub, staged candidate, and interrupted-transaction artifacts. New CLI launches +use the same release namespace; non-opt-in edges defer switching. Checks run only while enrolled +edges exist, with persisted backoff and one acquisition worker. Status alone never enrolls a runtime. +The worker verifies official npm integrity, safe extraction, complete dependency closure, and a +self-probe before atomic cache publication is eligible for activation. Failed checks leave the +active Hub available. The release policy is now maintained in [Releasing](../releasing.md#runtime-release-policy). + +`mcp:check-runtime-updates` covers real downloads from an isolated local registry, shared ownership, +explicit activation, preserved session presentation, incompatible epochs, offline operation, +corrupt caches, and checkout refusal. These process tests do not establish native host acceptance. +Windows runs the same acquisition and switch transaction with private named pipes, ACL-checked +storage and platform-specific child-process cleanup. The [Windows runtime CI](../../TESTING.md#windows-runtime-ci) +matrix must pass for the candidate before release; adding a platform to the manifest alone is +not acceptance. This does not establish Windows desktop-host behavior. +Publication must still satisfy these gates; they are not additional implementation phases: + +- Publish the backward-compatible hook receiver, then a versioned plugin + with profile-aware receipt/retry hooks. Allow an installed-plugin upgrade period and verify + actual capable bindings; an elapsed period or plugin version alone does not establish adoption. + Follow shared contract → MCP receiver → agent-plugin inputs/generated payloads, and the + extension consumer when affected. The plugin payload change requires a stable version bump. +- Validate staged artifact integrity, reproducible dependencies, engine and protocol ranges, + wire hashes, guidance epochs, state compatibility, and failed-candidate quarantine. +- Show exact reasons for pending updates. Presentation-only safe updates can use frozen + session text; incompatible guidance/schema changes remain blocked. +- Maintain the authored policy, generated manifest, cumulative guidance review, and CI + gates in [Releasing](../releasing.md#runtime-release-policy). Test skipped releases and epoch + changes even when later presentation text reverts. +- Publish the compatible runtime before updated plugin launch arguments and + installation guidance; keep the release package specifier at `@tempad-dev/mcp@latest`. +- Offline starts, sleep/wake, and reconnect storms neither multiply checks nor block tools. + Checkout runtimes make zero requests even with missing builds or corrupt caches. A release + Hub with no enrolled edge makes no background requests; a mixed-policy release Hub may + stage for an enrolled edge but cannot activate while a non-opt-in edge remains attached. + +### Phase 4 — native acceptance and compatible automatic activation + +Automatic activation requires enrolled, initialized, wait-capable MCP clients. Use the isolated +[native fixture](../../TESTING.md#native-runtime-switch-acceptance) to exercise local packaged +candidates through installed hosts; changing the registry origin for this probe does not +establish official-registry provenance or compatibility with every host version. +The installed-host checks below ran on macOS; Windows CI validates the service processes, +not Windows desktop-host interaction. + +Codex App acceptance covers Queue/Steer before and after an explicit Hub switch, resumed +canvas reads/writes and screenshots, native Stop interruption, and cancelled-task read-back +after both switching and crash rollback. The existing MCP edge processes remain alive. +Native automatic activation also passed with the private 0.12.0 → 0.12.1 fixture: exposing +the candidate and requesting a check, without an activation command, replaced the Hub while +preserving the App's CLI processes and the Figma session. Native reads succeeded before and +after the switch. +Claude Code acceptance uses the installed plugin and Haiku: model-driven canvas writes/read-back, +`/clear` rebinding, and a Figma Stop held across both explicit and automatic Hub switches. +The same host and MCP edge survive each switch; the next native tool hook delivers the persisted +Stop notice, and the cancelled task stays fenced. Automatic activation requires the exact +connection's verified Claude session and receipt/retry-capable hooks. Unbound sessions, cached +session hints and legacy hooks keep the candidate staged; ordinary MCP tools remain usable. +The process fixture additionally checks idle automatic switching and requests arriving during +handoff. It measures the actual pause without treating it as a zero-downtime guarantee: a lost +reply at socket closure can still surface an error because uncertain writes cannot be retried. +The updater searches for the newest compatible stable candidate at or below npm's `latest` tag. +An incompatible newest release does not hide a compatible patch in the retained release line; +status preserves the latest-version blocker and bounded search progress. Explicit exact-version +commands remain available, with the same activation compatibility checks. + +- Run the full preceding matrix and frozen released-extension compatibility checks against + the packaged candidate. Measure switch latency and deferral rates before promising a target. +- Complete native acceptance through the normal installed Codex and Claude paths under the + [authoring runbook](../testing/agent-authoring-evolution.md): `/clear`, legacy/current hooks, + feedback, Stop, runtime update, and resumed canvas work without MCP reinitialization. +- Confirm pending supervisor/edge updates remain visible rather than falsely reported as + applied. No zero-downtime or host-support claim follows from isolated tests alone. + +Protocol references: [MCP lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) +and [tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools). + +## Appendix A: Disconnected execution and operator recovery + +Distinguish two conditions: + +- **Execution ended, result unknown:** persist the normal read-back fence. It can cross + generations and no longer blocks drain; a qualifying later read can clear it. +- **Execution may still be running:** a WebSocket close or request timeout is insufficient + to release exclusion. The current operation/receipt maps and ordinary read-back marker + do not durably represent this condition across a generation change. + +To stop a disconnected peer from pinning the whole runtime forever, Phase 2 must add a +durable unresolved-execution record: file, task/epoch, browser/tab/document identity, +request ID, and receipt generation/token. After persistence is acknowledged and old +Hub-local work has settled, this exclusion may move to the new Hub instead of blocking +its upgrade. It blocks **all new writes to that file**, across tasks and clients; resume, +an idle snapshot, a successful read, or lease expiry cannot clear it. Reads during this +period must not be presented as final verification of the pending operation. + +Automatic release requires a verified terminal receipt or authoritative evidence that +the executing document closed/was replaced; unknown outcomes still require read-back. +Other files and runtime updates can proceed while one file remains blocked. The recorded +old receipt generation must remain verifiable after the switch; today's broker discards +pending receipts on generation change, so this needs a coordinated extension contract. +Peers without that contract retain conservative update deferral. Ordinary read-back +markers alone are never a substitute. All automatic rollback candidates must enforce +the same durable exclusion, including records created after activation. + +### Cold starts and operator-assisted recovery + +This record persists the file exclusion already enforced by `hasPendingOperation` in +[design-tasks.ts](../../packages/mcp-server/src/design-tasks.ts). Its lifetime is deliberately +stronger: unlike today's process-local operations, it survives all edges disconnecting, +a full Hub restart, lease expiry, and a different browser profile connecting. Closing the +agent or restarting the Hub does not stop JavaScript already executing in a Figma page. +Keep unresolved records until resolution, outside ordinary retired-task retention. + +`--runtime-status` remains read-only and reports a block ID/revision, file, originating +browser/document, recorded time, and available recovery evidence without receipt secrets. +Provide a separate proposed `--resolve-runtime-block ` interactive action and +an equivalent user-operated extension recovery dialog. These are not model-callable MCP +tools, automatic retries, or part of `--recover-task-storage`. + +The extension confirmation must run in a packaged extension-owned page, not the injected +Figma UI or a document/plugin frame. It is not web-accessible or embeddable by Figma. +Require a trusted confirmation gesture there; the background worker checks browser-supplied +sender identity and the allowed extension page before admitting a dedicated control request. +Neither page bridges nor content scripts may request or relay this recovery capability. +The Hub verifies the registered extension connection, Origin/profile, and a short-lived, +single-use challenge bound to the exact block revision. Keep the challenge in privileged +extension contexts; invalidate it on reconnect, expiry, or revision change. A caller-provided +`userGesture: true` is never evidence. The Hub trusts the verified broker to attest the UI +gesture within the existing local trust boundary; it cannot observe the click itself. +If this route is not ready, expose only the interactive CLI recovery, not a page-world bypass. + +Persistent exclusion and working operator recovery are one release unit. The CLI route +must be available in the first MCP version that preserves these records across cold starts, +including with automatic updates disabled. Explain the new persistence and recovery in +that release's changelog; rollback candidates must retain an equivalent recovery path. + +1. Prefer automatic evidence: reconnect/reload the original executor and verify terminal + completion or document replacement. A different browser ID alone is not that proof. +2. If the original profile cannot return, the operator must confirm that the original + execution environment has been stopped (for example, its Figma page/browser was closed). + Show the exact file and executor and explain that the action cannot terminate remote + code. An unreachable machine or an elapsed timeout is not proof. This is an explicit + operator attestation when machine-verifiable evidence is unavailable, not a claim that + the software has verified termination. Do not expose a silent noninteractive reset. +3. Under the file gate, compare the selected block revision, persist an abandonment + tombstone for that execution and retire its old authority, then replace its execution + exclusion with a required-read marker. Do not clear other blocks or any Stop fence. + Durability failure leaves the block in place; a stale confirmation cannot clear a + newer operation. Record the resolution basis and make repeating the same action idempotent. +4. Keep the task inactive. Rebind/resume with a fresh epoch and read the affected canvas + before writing. Reject late old-generation results as current authority; tombstones + must survive rollback and subsequent task-state updates. + +If the old executor might still be running, closing the original environment is necessary: +invalidating a lease cannot undo an already-dispatched native call. The recovery dialog must +state that limit rather than promising that a button can safely stop unknown remote code. +This path avoids requiring the old browser identity forever without making cold restart +an implicit override. + +## Appendix B: Installed hooks and delivery evidence + +[`lifecycle.mjs`](../../agent-plugin/src/clients/shared/lifecycle.mjs) is shipped inside +the installed plugin, has a 750 ms total deadline, and uses the old socket or fixed Windows +pipe. Updating the Hub does not update this code. Keep a compatibility listener at the +released path for valid `tempad/client-hook` requests, even while rejecting raw legacy +MCP attachment. Reuse [runtime-paths.mjs](../../agent-plugin/src/clients/shared/runtime-paths.mjs) +for private-root checks, atomic claiming of the legacy root, and ambiguity detection. +Do not redirect or take over a live legacy Hub's endpoint. + +The release namespace owns the legacy listener regardless of enrollment, through its +supervisor. Route only to the relevant verified Claude binding in that namespace; never +broadcast an event or choose the foreground/default Figma file. Legacy hooks cannot +disambiguate parallel checkouts; generated checkout hooks use explicit profile descriptors. +Other namespaces use their own endpoints; do not add a cross-profile broadcast router +or let a second supervisor compete for the legacy endpoint. +New hooks carry profile identity and use the new discovery path. Codex remains native-IPC +only; this is not a Codex hook fallback. + +During a switch, received lifecycle events enter a bounded durable inbox keyed by +profile and invocation ID with an ordered sequence. Acknowledge only after durable +commit, using the peer's response shape; do not assume commit will fit its deadline. +Once a complete request is admitted for persistence, finish that persistence even if +the hook closes its socket. Acknowledgment loss must not delete the event or trigger +feedback replay. New hooks retry within their host budget with the same invocation ID +and distinguish durable receipt from application; legacy hooks cannot do either reliably. +The Hub deduplicates and applies the inbox before reopening task writes. + +The old 750 ms timer covers input reading, connection, persistence, and reply handling; +it is not a server-side disk budget. Measure full invocation-to-reply and durable-commit +latencies (p50/p95/p99 and maximum) under cold start, disk contention, and memory pressure +on each supported platform. Inject slow flush, crash-before-commit, commit-with-lost-ack, +and exit-before-request-arrival. Do not weaken durability to meet a percentile target. +A passing latency sample is not a proof that every legacy invocation can succeed. + +Delivery evidence and fallback: + +| Observation | Required behavior | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Durable inbox record exists, reply was lost | Consume that record once by invocation ID. Return the same admission receipt to a capable retrying hook; do not repeat effects. | +| Request received but commit fails or remains unconfirmed | Keep the profile fenced and expose the delivery fault. Receipt-capable hooks retry with the same ID; a timeout is not success. | +| Hook exits before the listener receives a complete request | The server cannot reconstruct or even reliably detect the missing event. Fresh authoritative lifecycle/binding evidence is required before further writes; a socket timer or cached task state is not evidence. | +| Required fresh evidence remains unavailable | Leave the affected task/profile paused and explain recovery. Do not infer the active conversation from focus, inherited environment, or the previous session. | + +A Figma-local Stop fence protects a known Figma Stop. It cannot replace a missing Claude +`Stop`, `/clear`, or session-binding event. After a switch, reconcile the inbox and require +fresh validated client/session/turn evidence through the existing host-specific mechanism +before resuming writes. Stale pre-switch hooks cannot satisfy that check. A fresh event +cannot prove a missing historical event was delivered; it can only establish a safe current +binding while existing task cancellation and read-back protections remain in force. + +The inbox records each invocation's first admitting Hub, plus a durable freshness boundary +when that Hub is replaced. Reattaching the same Hub preserves both; the first Hub can accept +hooks issued while it was starting. Historical delivery may reconcile cancellation, but cannot +activate a session, bind a task, or consume a pending Stop notice. Delivery faults fence admission +until the entire accepted backlog commits or retires, including events received during recovery. + +Automatic switching while Claude clients are attached is eligible only after each binding +demonstrates the new receipt/retry contract and its recovery tests pass. Unknown or legacy +hook capability keeps the candidate staged; manual migration remains available with paused +tasks and revalidation. Merely recognizing an installed plugin version is insufficient. +Keep this limit visible rather than claiming guaranteed delivery through the old listener. + +Cached Stop notices may be returned only from an authoritative matching binding; do not +fabricate prompts or feedback receipts. The listener preserves existing responses during +normal operation, and only lifecycle events enter its inbox. Routing ambiguity or inbox +exhaustion is a visible failure, not permission to broadcast. Test ordering across `/clear`, +Stop, a later turn, and supervisor recovery; Codex remains native-IPC only. + +A pending Claude Figma Stop notice is stored with its cancelled task and consumed in the +same transaction as the hook receipt. A new user turn or ended session retires the old notice; +restoring it never restores session liveness or hook capability. Cancellation remains readable +by older Hubs, but binaries predating this notice field cannot preserve the pending message. +Claude does not support Queue or Steer; its native acceptance covers canvas tools, lifecycle +binding and tool-boundary Stop notices, not Codex-only comment delivery. + +## Appendix C: Release policy + +The implemented publication gates, cumulative guidance review, release boundaries, and hook +rollout are maintained in [Releasing](../releasing.md#runtime-release-policy). Automatic activation +also requires initialized clients, edge waiting support, unanimous enrollment and an idle Hub; +passing artifact checks alone does not enable it. diff --git a/docs/extension/get-code-cache-layer-design.md b/docs/extension/get-code-cache-layer-design.md index 51e61c40..ca69b8f3 100644 --- a/docs/extension/get-code-cache-layer-design.md +++ b/docs/extension/get-code-cache-layer-design.md @@ -26,8 +26,9 @@ Those reads used to be spread across asset planning, extension-owned style resol ### Request scope only -The cache exists only for one `handleGetCode()` execution. -Nothing is persisted across requests. +The cache exists only for one public `get_code` request. The runtime shares it +across roots in a batch; single-root calls create their own context. A shell retry +reuses the same context. Nothing is persisted across requests. ### Package boundary @@ -37,40 +38,25 @@ Nothing is persisted across requests. ### Cache context -The request creates one `GetCodeCacheContext` containing: +[`GetCodeCacheContext`](../../packages/extension/mcp/tools/code/cache/types.ts) +and its [factory](../../packages/extension/mcp/tools/code/cache/context.ts) own the +cache fields. The context holds raw CSS promises, plugin responses, image bytes +and dimensions, lookup readers, node semantics, overflow and vector analysis. +It also captures the token configuration and plugin for consistent output across +the request's roots. -```ts -type GetCodeCacheContext = { - readers: FigmaLookupReaders - variables: Map - styles: Map - paintStyles: Map - nodeSemantics: Map - vectorAnalysis: Map - metrics?: CacheMetrics -} -``` - -It is created in `handleGetCode()` and threaded through: - -- `buildVariableMappings()` -- `planAssets()` -- `collectNodeData()` -- `prepareStyles()` -- `exportVectorAssets()` +The [runtime](../../packages/extension/mcp/runtime.ts) supplies the shared context +to the [code pipeline](../../packages/extension/mcp/tools/code/index.ts). Variable +discovery, plugin preparation, asset planning, collection, style preparation and +vector export reuse it. Processed styles remain specific to each root: collection +copies raw CSS before applying layout and token transformations. ### Lookup readers -`packages/extension/utils/figma-style/types.ts` exports: - -```ts -type FigmaLookupReaders = { - getStyleById(id: string): BaseStyle | null - getVariableById(id: string): Variable | null -} -``` - -The extension-backed reader implementation dedupes request-local `figma.getStyleById()` and `figma.variables.getVariableById()` calls. +[`FigmaLookupReaders`](../../packages/extension/utils/figma-style/types.ts) lets +style resolution use either direct or cached lookups. The request-backed readers +deduplicate style and variable IDs, including missing results. Text-range bindings +are cached by node, start/end and field; direct readers may omit that optional hook. ### Node semantics @@ -96,7 +82,6 @@ The cache records what Figma returned; it does not synthesize alternate semantic - visible paint count - single visible paint - single visible solid paint -- single visible solid color It intentionally does not cache gradient strings by `styleId` alone. Gradients still depend on the current node size and are resolved at the call site from cached raw paints plus the current width/height. @@ -113,35 +98,11 @@ Per-root vector analysis results are cached in `ctx.vectorAnalysis`. ## Metrics and tracing -The cache layer exposes optional request metrics: - -```ts -type CacheMetrics = { - nodeSemanticHits: number - nodeSemanticMisses: number - styleHits: number - styleMisses: number - paintStyleHits: number - paintStyleMisses: number - variableHits: number - variableMisses: number - vectorAnalysisHits: number - vectorAnalysisMisses: number - vectorExportCandidates: number - vectorExportSkippedMissing: number - vectorExportSkippedZeroBounds: number - vectorExportNull: number - vectorExportUploaded: number - vectorExportThemeableInline: number - vectorExportRawInline: number -} -``` - -In dev builds, `get_code` trace output logs: - -- stage timings -- node/style/paint-style/variable/vector-analysis cache hit rates -- vector export result breakdown +[`CacheMetrics`](../../packages/extension/mcp/tools/code/cache/types.ts) defines +optional request counters. In dev builds, `get_code` logs stage timings. When the +context is created with `metrics: true`, traces also include node, style, +paint-style, variable, text-range and vector-analysis hit/miss counts and the +vector export result breakdown. This keeps performance investigation architectural instead of adding throwaway trace code to each pass. @@ -152,10 +113,12 @@ This keeps performance investigation architectural instead of adding throwaway t - No caching of `getCSSAsync()` across requests. - No per-pass one-off caches that duplicate the central request context. -Within one request, `getCSSAsync()` is still called once per collected node. -The cache layer reduces the expensive reads around that call, not the safety boundary of the call itself. -Regression fixtures enforce that count, assert zero CSS reads for skipped descendants, and verify -that repeated layout-semantic access produces one cache miss followed by hits per node. +Within one request, raw `getCSSAsync()` promises are shared by node ID, including +plugin preparation, overlapping roots and shell retries. Collection still checks +cancellation before and after awaiting them; it does not cache processed styles. +Regression fixtures enforce one raw read per collected node, zero CSS reads for +skipped descendants, isolated processed styles and fresh reads on later requests. +Repeated layout-semantic access produces one cache miss followed by hits per node. ## Current limitations diff --git a/docs/extension/mcp-browser-gateway-design.md b/docs/extension/mcp-browser-gateway-design.md index b6c40ede..7725c61f 100644 --- a/docs/extension/mcp-browser-gateway-design.md +++ b/docs/extension/mcp-browser-gateway-design.md @@ -16,7 +16,8 @@ use a separate narrow runtime message validated by the background worker. Explicit design tasks add file leases, fixed session routing, page-side fencing, and DOM feedback. See [design tasks](mcp-design-tasks.md) for lifecycle, takeover, -expiry, and recovery semantics. Taskless reads still use the active route. +expiry, and recovery semantics. Taskless reads use the active route unless they specify an exact +`sessionId` from `list_design_sessions`. ## Connection lifecycle @@ -28,6 +29,14 @@ expiry, and recovery semantics. Taskless reads still use the active route. 3. The broker starts one WebSocket client for all Figma tabs in the extension context. 4. The client probes the known ports and requests the `tempad-mcp` WebSocket subprotocol before receiving any frames. It accepts a candidate only after receiving both `registered` and `state`. + Release and checkout builds use disjoint port pools from shared constants. A checkout Hub + announces its real-path profile hash in `registered`; the broker must match it and send + `runtimeHello` before the Hub registers the connection or exposes state and asset access. + Missing profile fields mean release. Checkout peers reject missing or different identities + and never fall back to release ports. Release peers preserve the existing handshake. + Discovery uses the build profile independently of the runtime fingerprint. A checkout build + without a valid runtime identity makes no connection attempt. Checkout Hubs close connections + with code 1008 if the runtime handshake has not completed within five seconds. Registration announces `protocolVersion` and the versions the Hub still serves. A Hub that does not serve this extension is rejected with instructions to restart the agent's MCP connection using `@tempad-dev/mcp@latest`, including other agents keeping an old Hub alive. Reloading Figma @@ -43,6 +52,11 @@ expiry, and recovery semantics. Taskless reads still use the active route. A draft request that cannot be forwarded receives an error response instead of being silently dropped. A waiting request never carries over into a replacement enable. +Granting loopback access in Chrome settings also triggers a fresh permission check. The broker +notifies Figma content scripts through extension messaging so an enabled page whose initial +check was denied can register without a reload. The notification grants no authority: each +page rechecks permission, disabled pages stay disabled, and a newer revocation wins. + The bridge protocol version covers the shared tool contract as well as transport messages. Bump it whenever a Hub and extension built from different revisions must not exchange tool calls. @@ -77,7 +91,8 @@ agent decides whether to reuse or refresh the Hub under the [evolution guide](../testing/agent-authoring-evolution.md#3-establish-a-trustworthy-runtime). Compatible extension updates can reconnect to the same Hub. Runtime handshakes, protocol validation, connection ownership, and stale-request checks remain enforced. -Exact checkout matching belongs to preflight and the frozen run record. +Checkout identity must match during the handshake. Exact source/build matching belongs to +preflight and the frozen run record. ### Released unversioned extensions @@ -146,15 +161,75 @@ still synchronizes after a failed dispatch or reconnect, including after page re restored Stop attempts native interruption before cancellation; absent native turn identity cannot guarantee interruption, but never removes the canvas fence. Hiding a stopped review does not discard its cancellation. The page and broker retain the live -Stop fence for reconnect when storage fails; unpersisted state cannot survive a full browser restart. +Stop fence when storage fails. The content bridge also retains the latest matching review and +marks local Stop before forwarding it, so a replacement service worker receives cancellation in +the replayed enable even if storage is still unavailable. A stale active update cannot undo that +Stop. Another tab's live review does not become this page's claim, while cancellation and Done +fences can synchronize from any tab in the same file. Changing file/session or disabling the +bridge clears its cache; if all surviving page and worker state is lost before persistence or +Hub delivery, the unpersisted Stop cannot be recovered. +Even with a surviving page, a failed `storage.local.get` during worker replacement can lose the +saved browser identity. The replacement then connects with a new browser ID, so the Hub rejects +reviews bound to the previous identity. The page still enforces its local Stop fence; its cached +cancellation alone cannot establish the old browser identity. Closing that review still requires a durable receipt, and reconnect processes Stop before Done. Mutating tool waits warn at their configured timeout and stop waiting after twice that interval. An unknown outcome keeps the file operation fenced until a definitive result or session recovery; -the error explicitly forbids replaying the write. Incompatible saved task records retain a durable +the error explicitly forbids replaying the write. When a dispatched write's caller stops waiting +because of cancellation, timeout, or disconnection, that file also requires a successful +`get_structure` or `get_code` read before further writes. A late result releases the execution +fence but does not clear this read requirement, which survives Stop and beginning a fresh task. +A completed write whose result cannot be formatted or fit the inline budget also requires readback. +`EXTENSION_RESULT_LOST` also covers page-to-extension result transfer failure after execution. +`CANVAS_APPLY_FAILED` remains an uncertain outcome, including failures from older extensions. +Current extensions return `CANVAS_APPLY_ABORTED` only at gates that prove authoring execution has +not started, including broker/page rejection, editor availability, schema/theme validation, and +markup parsing. The diagnostic retains the original error code. These failures do not require a +file readback. Reconciliation preflight can import library resources before the Undo boundary, +so its failures return `CANVAS_APPLY_FAILED` even when the original cause is a validation error. +Undo-boundary failures retain the requirement even if the +protected snapshots match: those checks do not prove that every native resource field or +concurrent user edit was restored. Uncoded or unknown error codes from dispatched writes also +retain readback, including result-transfer failures from older peers. Only the positive +`CANVAS_APPLY_ABORTED` and `EXTENSION_DELIVERY_FAILED` outcomes release a failed dispatched +write's marker. Other diagnostics can originate inside a mutation on supported older peers, +so they retain readback even when a particular rejection happened before writing. +The Hub persists a file read marker before dispatch and retires it only after a definitive usable +outcome or a qualifying read. Thus a Hub restart, including while the write is still running, +retains the requirement. A failed marker save prevents dispatch; a failed clear retains the fence. +A qualifying read returns at least one structure root or code result, or an exact page snapshot +(which can confirm an empty page). Empty selections and batches containing only errors do not +acknowledge it. This recovery gate is file-scoped: a qualifying read by any caller clears it for +that file; it does not prove that the original caller inspected every affected node. Task-specific +resume read requirements remain bound to their task and owner. +A failed initial page-port delivery reports +`EXTENSION_DELIVERY_FAILED`, which releases the operation because execution never started; +`EXTENSION_DISCONNECTED` remains an unknown outcome after delivery. Incompatible saved task records retain a durable file fence while valid records recover independently. An unreadable snapshot with no identifiable file boundary preserves its evidence and blocks all writes, allowing read tools to remain available. +Peers may negotiate terminal receipts through an optional registration offer and runtime hello; +released peers retain their existing messages. A surviving worker can resend only a completed +call's receipt after reconnecting. The optional `durableTerminalReceipts` offer/hello allows +receipts from a previous Hub generation; it is sent only when the receiver offers it, preserving +released strict hello schemas. The Hub checks the persisted original request, +receipt identity, browser and extension Origin, and exact Figma session, file, tab and document +before releasing that operation. It never replays the write. If the original result was lost, +`EXTENSION_RESULT_LOST` requires reading the original target; recovered writes require a +successful `get_structure` or `get_code` read of that file before another write, including calls +without a task. A disconnect reported before the receipt arrives also warns that the write outcome +is unknown and must not be replayed. Unfinished receipt records are bounded by rejecting new calls +before dispatch, and completed receipts are acknowledged idempotently while retained. Unresolved +writes persist in task storage before dispatch, including for peers without receipts; their +files stay blocked across cold restarts. Reads cannot clear this exclusion. Original-browser +tab closure or document replacement can release it; a different browser ID and ordinary idle +snapshots cannot. A lost worker cannot reconstruct receipts. Operator recovery then requires +the interactive CLI described in the [MCP README](../../packages/mcp-server/README.md#task-storage-recovery), +preserves Stop and records abandonment plus mandatory readback atomically. No page bridge exposes +that control. Peers without durable receipts cannot transfer unresolved writes during a planned +runtime switch. + ## Assets For outbound assets, the page computes hashes and descriptors and sends at most diff --git a/docs/extension/mcp-canvas-assets-design.md b/docs/extension/mcp-canvas-assets-design.md index c7f26b78..14d2f59b 100644 --- a/docs/extension/mcp-canvas-assets-design.md +++ b/docs/extension/mcp-canvas-assets-design.md @@ -24,15 +24,10 @@ model-authored prose or later Canvas arguments. The dedicated upload argument ca once; subsequent Canvas calls see only a content hash. Do not put raster bytes or large SVG documents in `apply_canvas`. -Image generation may run in an isolated subagent when the host supports it and the Design in Figma -delegation gate passes. The main agent fixes the art brief, remains the only Canvas writer, and owns -placement and final judgment. This is an optional agent-orchestration optimization, not part of the -TemPad protocol. - -Asset-medium selection remains an evidence decision. Creative latitude, Canvas editability, and -delivery convenience do not establish a geometric or vector language. When several assets represent -distinct content, the chosen existing, licensed, generated, or vector route must preserve the -distinctions the composition depends on instead of substituting one reusable placeholder motif. +This document owns asset transport, validation, and native ownership. The +[Design in Figma skill](../../agent-plugin/src/skills/design-in-figma/SKILL.md) owns asset selection, +provenance, delegation, and visual acceptance through its conditional references. Keep those +task policies in the skill rather than duplicating them in the protocol design. This extends the existing declarative language rather than creating a second asset dialect. @@ -106,13 +101,6 @@ type CanvasAssets = Record< Asset keys are call-scoped aliases. They deduplicate one source used by several nodes, but do not create a Figma design-system resource and do not need to remain stable across calls. -The asset manifest is a delivery contract, not a medium selector. Before declaring a material -asset, the agent records which user requirement, inspected evidence, or explicit brief decision -establishes its subject and medium. A content-image role uses a sourced, generated, supplied, or -current-file asset; availability of inline SVG does not justify replacing it with primitives or -newly invented vector artwork. Agent-authored vectors require an independently established -illustration, diagram, pattern, or decorative-geometry role. - Allow at most 32 declarations and 64 KiB of inline SVG across one call. Every declaration must be referenced, every reference must exist and match the required type, and page-only or remove operations cannot carry assets. These rules prevent an asset manifest from becoming hidden @@ -255,52 +243,35 @@ Figma page requests assetHash The page never uses the Hub capability URL for inbound fetches. The broker accepts only an exact content hash, builds the URL from its validated Hub state, and cannot be used as an arbitrary URL -proxy. Binary encoding exists only inside the extension bridge; it never enters an MCP tool call or -result. The page still receives the existing capability URL solely to describe outbound assets -already uploaded by `get_code` and `get_screenshot`. +proxy. On this reverse path, binary encoding stays inside the extension bridge; `apply_canvas` +carries only the manifest's hash. The dedicated `upload_asset` call is the bounded client-to-Hub +data-URL path described below. The page still receives the existing capability URL solely to +describe outbound assets already uploaded by `get_code` and `get_screenshot`. -Cache resolved bytes and imported Figma image hashes by content hash for the active session. +The page caches downloaded bytes by content hash until its bridge disconnects or is disabled; +failed downloads are evicted. Imported Figma image hashes use a separate bounded cache and are +checked with `getImageByHash` before reuse. A bridge reconnect does not invalidate native file +handles. See [asset transport](../../packages/extension/mcp/assets.ts) and +[media preparation](../../packages/extension/mcp/tools/canvas/media.ts) for cache ownership. ### Agent-generated images -When custom focal imagery is appropriate and a generation capability is -available, generation runs before layout instead of substituting hand-built -Canvas geometry. - -Support three factual routes: - -1. A rights-established public HTTPS PNG/JPEG/GIF URL: use `imageUrl`. -2. The generator returns a PNG/JPEG/GIF `data:` URL: compose its result directly into - `upload_asset`, then use the returned `assetHash`. -3. Neither path exists: omit optional imagery or disclose the required gap; never synthesize an - image-role illustration from Figma primitives. - `upload_asset` is Hub-only, content-addressed, idempotent, and bounded by the existing per-asset and aggregate quotas. It accepts no local path, remote URL, headers, credentials, SVG, or arbitrary MIME type. It validates base64 canonically, recomputes SHA-256, and stores through the existing loopback asset server. Its response contains only `assetHash`, MIME type, and size. -When the host supports subagents and generation is separable, importable, verifiable, and worth its -coordination cost, delegate nontrivial image generation: - -1. The main design agent sends a compact brief: layout role, subject, aspect ratio, palette/style, - important empty space, and negative constraints. -2. The image subagent generates and iterates independently. The main agent programmatically uploads - its selected `data:` URL through `upload_asset`; a rights-established public result URL remains a - valid direct route. -3. It returns only an importable `assetHash` or `imageUrl` plus MIME type, dimensions, and a short - description. It does not return bytes, candidate history, or its generation transcript. -4. The main agent owns placement and crop, and verifies the final composition when pixels can - change the decision. It does not need to inspect intermediate candidates. - -Do not delegate exact project assets, icon-library SVGs, existing Figma images, direct URL imports, -crop/placement decisions, or final acceptance. Do not spawn an image subagent when it cannot return -an importable reference. Clients without subagents follow the same asset contract directly; TemPad -neither exposes a subagent tool nor assumes one exists. +Hosts compose generation and upload programmatically, passing the generated `data:` URL directly +to `upload_asset` and using its returned hash in later Canvas calls. Hosts without that composition +path can use a public image URL. Acquisition decisions and recovery belong to the skill's +[images reference](../../agent-plugin/src/skills/design-in-figma/references/images.md). +Optional delegation follows its +[delegation reference](../../agent-plugin/src/skills/design-in-figma/references/delegation.md); +TemPad does not expose or orchestrate subagents. Do not accept: -- base64 or data URLs in `apply_canvas`, prose, or a copied/manual tool argument; +- base64 or data URLs in `apply_canvas`; - arbitrary local file paths; - credentials, headers, cookies, or signed-request recipes; - a server-side “fetch any URL” endpoint. @@ -339,14 +310,16 @@ SVG is code-like input even when Figma turns it into design layers. Validate bef frontend icon libraries without requiring a CSS parser; - no unresolved `currentColor`. -Use the extension's existing XML parser (`svgson`) for structural checks and deterministic -serialization. Do not build an SVG renderer or path parser. Figma remains the rendering and import -authority. +Use the [XML wrapper](../../packages/extension/mcp/tools/canvas/svg-xml.ts) around `svgson` +to preserve entity, CDATA, whitespace, and attribute semantics through parsing and serialization. +The sanitizer resolves namespaces and checks decoded CSS escapes for external references. +Changing the import policy invalidates previous SVG digests. Do not build an SVG renderer or path +parser. Figma remains the rendering and import authority. After `createNodeFromSvg`, reject and roll back if Figma produces: - zero or non-finite geometry; -- more than 500 imported descendants; +- more than 500 imported nodes, including the root; - a node outside the expected managed wrapper; - an import error. @@ -361,10 +334,9 @@ SVG root. Store only bounded metadata on the wrapper: - source SHA-256; - resolved `currentColor`, if any; -- import-policy version; -- imported-child ownership marker. +- import-policy version. -The source itself is not stored in plugin data. +The imported root carries its own ownership marker. The source itself is not stored in plugin data. Reconcile as follows: @@ -420,10 +392,8 @@ Extend mandatory structural verification: - all existing paint fields still match. Do not export SVG or image bytes into the result. Return only normal mutation counts and bounded -warnings. A composition containing a consequential visual asset checks one representative usage -before propagation, then checks the final board and any materially distinct crop or treatment where -a defect could hide. Mechanical text, token, prop, and hierarchy updates skip screenshots; -corrections recheck only affected compositions. +warnings. These structural checks do not prove visual fidelity; the authoring skill owns the +required native and screenshot observations for the requested change. ## Context budget @@ -434,30 +404,17 @@ The feature adds almost no always-on context: - one sentence in the tool description only if needed; - exact syntax stays in the Design in Figma skill's progressive visual-assets reference; - asset bytes move through the local binary bridge; -- optional image generation and iteration can stay in an isolated task, with only a compact brief - and final asset descriptor reaching the main design context; - full validation errors are bounded and source-free; - apply results never repeat sources or previews. -Subagents reduce generation-specific reasoning and visual exploration in the main task. The asset -server separately keeps binary transport out of every model context. Neither mechanism eliminates -the main agent's responsibility to specify the image's role or judge the final composition. - -Inline SVG is reserved for small frontend icons. A 32 KiB schema ceiling is a safety limit, not a -recommended prompt budget; the skill should recommend Hub assets for anything beyond a compact icon -or logo. +The 32 KiB inline SVG ceiling is a safety limit, not a recommended prompt budget. Larger sources +use Hub-backed assets; the authoring skill owns medium and source selection. ## Provenance and privacy TemPad imports exact caller-provided content; it does not search for assets, determine copyright -status, or grant usage rights. The authoring workflow preserves enough source and rights evidence -for the intended use. It requires item-level attribution or license records only when source terms, -user or project policy, or the requested handoff makes them material. The skill should prefer -project-approved, user-supplied, or permissively licensed sources and keep one icon family -coherent. In a clean context it derives an -icon profile from the product and composition, inspects fitting candidates from official sources, -and compares materially distinct options only when the decision is consequential or the first fit -rests on familiarity. It intentionally ships no named fallback list. +status, or grant usage rights. The authoring skill owns source and rights evidence under the +applicable user and project policy. Do not log SVG source, raster bytes, remote URL queries, capability URLs, or upload tokens. Logs may contain only asset key, content hash, MIME type, byte count, dimensions, and bounded failure code. diff --git a/docs/extension/mcp-canvas-authoring-coverage.md b/docs/extension/mcp-canvas-authoring-coverage.md index 0b0d3a7d..13032b23 100644 --- a/docs/extension/mcp-canvas-authoring-coverage.md +++ b/docs/extension/mcp-canvas-authoring-coverage.md @@ -1,7 +1,10 @@ # Canvas authoring coverage Status: implemented surface and gap ledger -Baseline: `@figma/plugin-typings@1.130.0`, 2026-07-28 + +API baseline: the repository's `@figma/plugin-typings` version resolved in +[`pnpm-lock.yaml`](../../pnpm-lock.yaml). The [shared canvas schema](../../packages/shared/src/mcp/canvas.ts) +owns the accepted result fields. ## Scope @@ -9,8 +12,8 @@ This ledger covers persistent, authorable state in the Figma Design document mod that state with the current `apply_canvas` contract; it does not assign a percentage because Figma does not define a canonical set of equally weighted capabilities. -The inventory is based on the Plugin API types pinned by this repository and cross-checked against -Figma's current [Plugin API reference](https://developers.figma.com/docs/plugins/api/api-reference/), +The inventory is based on that Plugin API and cross-checked against Figma's +[Plugin API reference](https://developers.figma.com/docs/plugins/api/api-reference/), [shared node properties](https://developers.figma.com/docs/plugins/api/node-properties/), and [node types](https://developers.figma.com/docs/plugins/api/nodes/). @@ -43,6 +46,11 @@ Pattern Paint is included: although it launched alongside Draw, Plugin API [update 110](https://developers.figma.com/docs/plugins/updates/2025/05/07/version-1-update-110/) explicitly states that the beta paint type can be used in both Figma Design and Figma Draw. +Native preconditions and the [reconciliation limits](./mcp-canvas-authoring-design.md#reconciliation) +apply to every row. Supported declarations can still require rejecting an edit when native setters +cannot produce and verify the requested state. This inventory measures native authoring, not CSS +export fidelity. + Status terms: - **Supported**: the result can describe and reconcile the complete listed state. @@ -224,6 +232,10 @@ native Auto Layout Grid. `data-style-grid` instead links an existing frame layou | Multiple effects and effect order | ordered `Effect[]` | **Supported** on frames, authored components, component sets, slots, groups, Boolean operations, text, instances, and basic shapes; omission preserves the live stack, `[]` clears it, and a supplied direct stack replaces any linked Effect style | | Effect variables | shadow color/radius/spread/offsets and blur radius | **Supported** on direct shadow and blur effects for every field in the pinned `VariableBindableEffectField` union | +Noise and Texture retain independent vector dimensions: only `noiseSizeVector.x` must equal +`noiseSize`, following [Figma's Effect contract](https://developers.figma.com/docs/plugins/api/Effect/). +Omitting the vector uses the scalar for both axes. + Media bytes are a transport concern, not persistent Figma Design state. `apply_canvas` accepts an existing current-file hash, imports an HTTP(S) image URL or downloaded HTTPS video through Figma's native APIs, or resolves an exact Hub image hash through the extension's bounded local bridge. It @@ -245,7 +257,7 @@ intentionally does not place inline binary payloads in model-visible MCP JSON. | Auto resize | none, width-and-height, height, deprecated truncate | **Supported** for all current modes; the deprecated `TRUNCATE` value is deliberately not emitted | | Truncation | disabled or ending ellipsis, with nullable `number` maximum lines | **Supported**: both modes and null are supported; non-null maximum lines use the native API's required integer range starting at one | | Text case | original, upper, lower, title, small caps, forced small caps | **Supported** on the whole node and ranges | -| Paragraph formatting | paragraph indent, paragraph spacing, and list spacing | **Supported** on the whole node and ranges; indent and paragraph spacing accept whole-node and range FLOAT variables | +| Paragraph formatting | paragraph indent, paragraph spacing, and list spacing | **Supported** on whole nodes and affected paragraphs, with FLOAT bindings for indent/spacing. Empty paragraphs and native unlink limits can reject edits; see [reconciliation limits](./mcp-canvas-authoring-design.md#reconciliation). | | Lists | ordered/unordered list options, indentation, list spacing, and hanging-list state | **Supported**: range patches carry every list type, indentation, and spacing; a full-range patch expresses whole-node list state | | Hanging punctuation | `hangingPunctuation` | **Supported** on the whole node | | Decoration | none/underline/strikethrough plus underline style, offset, thickness, color, and skip ink | **Supported**: basic values are available whole-node; ranges carry the complete writable decoration state, including variable-bound solid decoration color | @@ -254,7 +266,10 @@ intentionally does not place inline binary payloads in model-visible MCP JSON. | Auto rename | derive the layer name from changed characters | **Supported** through `figma.text.autoRename`; fixed `figma.name` plus enabled auto-renaming is rejected as contradictory | | Rich text ranges | per-range font, case, spacing, line height, fill, styles, lists, decoration, links, and variable bindings | **Supported** for every non-deprecated range setter for formatting, lists, links, styles, and variables in the pinned typings; declarative `characters` replaces procedural insertion/deletion; patches are ordered, non-overlapping UTF-16 intervals and omitted fields preserve live state | | Text and fill styles | whole-node or range `textStyleId` and `fillStyleId` | **Supported** by local ID, published key, or stable local `styleKey`, including same-result authored styles and explicit `null` unlinking at whole-node or range scope | -| OpenType features | `openTypeFeatures` | Plugin API 1.130 exposes this state read-only; `apply_canvas` cannot author it without a Figma API addition | +| OpenType features | `openTypeFeatures` | The pinned Plugin API exposes this state read-only; `apply_canvas` cannot author it without a Figma API addition | + +Text ranges remain subject to the native binding and TextStyle +[reconciliation limits](./mcp-canvas-authoring-design.md#reconciliation). ## Components and instances diff --git a/docs/extension/mcp-canvas-authoring-design.md b/docs/extension/mcp-canvas-authoring-design.md index 3b92742f..648d1d1d 100644 --- a/docs/extension/mcp-canvas-authoring-design.md +++ b/docs/extension/mcp-canvas-authoring-design.md @@ -379,8 +379,9 @@ complete private native schema after short refs are expanded. `bg-(--surface)`, `gap-(--content-gap)`, `font-(family-name:--body-family)`, and `text-(length:--body-size)` use Tailwind custom-property shorthand; bracketed `var(...)` forms are equivalent. The bounded subset covers fills/strokes, dimensions, gaps, padding, corner radii, -stroke width, opacity, and typography metrics. FLOAT measurements use native pixel units; opacity -uses 0–1 and weight uses 1–1000. No expression evaluation, `calc()`, CSS fallback, cascade, or +stroke width, opacity, and typography metrics. FLOAT measurements use native pixel units; bound +opacity variables use 0–100 (literal CSS opacity uses 0–1), and weight uses 1–1000. +No expression evaluation, `calc()`, CSS fallback, cascade, or project stylesheet execution is introduced. Ordinary scale utilities remain literals. Catalog aliases use valid WEB custom-property syntax when present, otherwise a normalized name. @@ -418,11 +419,11 @@ supported utility table. Other native fields and explicit unlinks retain inline/ One markup tree is bounded to 160 elements and 12 levels. The element ceiling covers currently observed complete screen sections up to 129 elements with bounded headroom; it is not a target for -packing unrelated work. The independent 200 KiB markup, 32-asset, and 4 MiB transport limits remain -in force. These limits are part of the public tool description and Canvas HTML reference so an -agent can split a larger composition before calling the tool. The syntax parser rejects an -oversized tree before utility, layout, or native-state compilation, so the global split requirement -is reported before repairable node-local errors and before any mutation. +packing unrelated work. The independent markup limit of 200,000 Unicode code points, 32-asset limit, +and 4 MiB transport limit remain in force. These limits are part of the public tool description and +Canvas HTML reference so an agent can split a larger composition before calling the tool. The syntax +parser rejects an oversized tree before utility, layout, or native-state compilation, so the global +split requirement is reported before repairable node-local errors and before any mutation. Local collection, variable, and style authoring keys persist as file-wide identities. They must be rooted in one collision-resistant prefix for the independent system rather than a generic product @@ -626,7 +627,7 @@ subject to stronger constraints: 1. correct native result; 2. scope and ownership safety; 3. dependency-safe ordering; -4. one Undo boundary and rollback on failure; +4. one Undo boundary and guarded rollback on failure; 5. no-op convergence; 6. only then, fewer Plugin API calls. @@ -648,6 +649,14 @@ path. ## Reconciliation +`mcp/tools/canvas/reconcile.ts` owns public dispatch and tree-update orchestration. Its internal +`reconcile/` modules group node identity and request state, Undo protection, resource preparation, +node-property application, tree and page operations, removals, and result checks. `tree.ts` keeps +recursive container staging together and orders the layout, appearance, text, binding, and component +setters. `native-update.ts` owns updates without markup. Strict verification and advisory warnings +remain separate. Internal modules do not import the public entrypoint; shared request-state types +use type-only imports so these responsibilities have no runtime import cycles. + The local deterministic pipeline is: 1. parse and validate the public input; @@ -661,7 +670,7 @@ The local deterministic pipeline is: 9. apply late references and stabilize deterministic geometry after derived-layout setters; 10. remove explicitly absent owned state; 11. verify the live result; -12. commit one Undo boundary, or undo the whole attempt on failure. +12. commit one Undo boundary, or attempt rollback when retained changes justify Undo. Preflight is staged: identity, vector paths, known text-range bounds, incompatible local or same-call variable types in nodes, styles, paints, effects, grids, and component property definitions, missing @@ -683,8 +692,10 @@ the Undo boundary, including when the same call declares resources. Existing Tex fonts also preload for their current consumers when the call declares no variable collections and does not import font variables. The full resource preflight still rechecks those consumers. Both preflight paths share the same font resolution and request-local load cache. Empty resource declarations use the complete -preflight before that boundary. Key-only library references retain their later import and validation -path. Existing result pages and pages needed for declared variable, mode, or collection removals are +preflight before that boundary. Key-only component references also import before that boundary, +including when resources are declared; the full preflight reuses their request-local identities. +Other key-only library references retain their later import and validation path. +Existing result pages and pages needed for declared variable, mode, or collection removals are loaded before opening the Undo boundary. Node removal uses `loadAllPagesAsync()` once per document when other pages exist; Figma also [automatically loads pages added later by other clients](https://developers.figma.com/docs/plugins/migrating-to-dynamic-loading/#accessing-all-pages). Variable removal loads pages individually and caches successful loads only within the request, @@ -697,9 +708,45 @@ creation, and library/media preparation is not a guarantee of native transaction Typography variable setters apply one field at a time. Preload the font faces reached by each binding in its actual application order, including intermediate family/style combinations, for Text nodes, text ranges, and Text styles. Reuse the request-local font cache across those paths. +Rich-text style references with explicit typography overrides compare the current style defaults, +literal overrides, and final variable bindings in the consumer's mode. Figma can detach the style +link after an override, so repeated requests compare effective typography rather than restoring +the link on every call. Pure style references still require the native style identity. Native +semantic overrides are detected before mutation; those ranges and variable-weight compositions +retain their existing application path until their interaction with explicit typography has +native acceptance. +Numeric character metrics (font size, line height, and letter spacing) can extend to the immediately +following `\n` in Figma's native setter. Reconciliation distinguishes that physical write extent +from the range's final ownership when a later declared range starts at the newline. An unchanged +range does not repair a later owner's newline. A necessary write that would damage a retained +TextStyle without a proven safe restoration is rejected with the conflicting range and style. +Explicit numeric range bindings are checked after all ranges have been applied. A range starting +on `\n` can leave that separator's alias unchanged even when the visible text acquires the +requested binding; this was observed with font size. Such a mismatch fails rather than reporting +success or repeatedly rebinding. Preserve the separator's existing formatting, or start the +range at the following content only when that matches the intended design. +U+2028 and CRLF setter extension is not inferred from the observed `\n` behavior. + +Paragraph indent, paragraph spacing, and list spacing use paragraph-wide comparison scopes but +literal setters target each affected paragraph's content, excluding its terminal newline to avoid +changing the next paragraph. Native spill was confirmed for indent and paragraph spacing; +list spacing uses the same conservative bounds without assuming identical native behavior. +Explicit variable null is read back. If native paragraph unlinking +retains an alias, a content-only literal fallback preserves the current value and must verify +that the alias disappeared. Empty paragraphs without a safe setter/fallback reject required +changes rather than silently skipping them or writing into another paragraph. Identical supported +requests still converge without writes. These checks do not imply support for conflicting values +assigned to the same native paragraph. +Clearing a character-metric range alias does not guarantee independence from later node-level +typography changes. Native font size can still inherit the node's value when the range alias reads +back as absent. For a fixed local value, author the intended literal and verify its independence +from retained node bindings; a successful `null` unlink or equal-valued readback alone is insufficient. Mode changes prepare fonts only for affected typography, text-content, or size bindings, including aliases and extended collections. Unbound text and descendant mode overrides do not require unrelated fonts to load. Affected text metrics still require the current fonts even without a font-family binding. +Unavailable native bindings in unrelated collections do not block a mode change. Alias traversal +still finds affected dependencies across collections; an unavailable value reached by the changed +mode fails before mutation as an existing canvas dependency error. Updates to existing pages preload fonts for the first changed variable mode before opening the Undo boundary or changing page properties, including calls with markup but no declared resources. Each mode setter still rechecks the live font requirements; later transitions may depend on @@ -749,24 +796,43 @@ belongs to the same set and the requested variant property values or bindings re Omitted instance sizing axes preserve their native modes, including modes produced by variant selection. Explicit size or grow utilities override only the axes they describe. +Slot property identities are stored on their slot nodes, separately from authored component-property +keys. Adopting a slot records that identity even when its metadata already matches. Older owner-map +identities remain readable; preflight retains the resolved slot name for the current apply so a normal +property using the same stable key cannot redirect the slot update. Slot creation and metadata edits +protect their definition owner before mutation, including a component set outside the update root. + Figma may normalize Text auto-resize while truncation and maximum-line state changes. Reconciliation therefore applies truncation state before the declared auto-resize mode, so fixed text boxes remain fixed while still supporting native ending truncation. -After a failed mutation, reconciliation commits the partial attempt as the newest history entry -before triggering Undo. This keeps rollback scoped to that attempt in a long-lived plugin session -instead of consuming the preceding successful apply. Rollback verifies that the update root remains +After a failed mutation, reconciliation attempts Undo only when a created node, page, or resource +still exists, a known resource was removed, or a touched pre-existing node/resource has an observable +retained change. Resource IDs are recorded immediately after creation, before ownership claims or +later preparation can fail. Counted setters alone do not establish +a history entry: no-op setters and changes that cancel each other out can otherwise make Undo consume +the preceding successful apply. Unrelated protected roots do not justify Undo. Unreadable state or no +observable change skips automatic Undo and retains the uncertain-failure readback requirement. +An unavailable read does not negate independent confirmed changes. Partial snapshots cannot certify +that uncaptured state stayed unchanged. When a change +is observed, reconciliation commits the partial attempt before triggering Undo. This is a conservative +guard, not a guarantee of isolation from concurrent edits. Rollback verifies that the update root remains resolvable. Exact pre-existing node/component references and unrelated top-level roots on the result page also retain their type, parent, canvas key, geometry, direct child identities, and common -content fields (including paints, effects, and whole-node text content/style), local transforms, -clipping, supported layout settings, and size constraints. These content snapshots copy native values +content fields (including paints, effects, binding identities, stroke/corner geometry, component +properties, and text metrics), local transforms, clipping, supported layout settings, and size +constraints. Mutable text nodes also snapshot native styled segments; unrelated text references do +not require a full range read. Selected existing local resources snapshot their authored fields, +and rollback checks that newly created resources no longer exist. These snapshots copy native values so in-place changes cannot mutate the expected state. Existing pages also retain their name, background, guides, explicit variable modes, authoring key, page order, and direct child identities. Page-only updates snapshot unrelated roots after font preloading, so edits made during preparation are not mistaken for rollback corruption. Losing or changing one is reported as rollback failure instead of masking partial corruption with the original validation error. This detects selected rollback corruption; it does not prove restoration of every native field, -mixed text run, resource, unrelated descendant, or concurrent user edit. +unreadable text range, imported library resource, unrelated descendant, or concurrent user edit. +If Undo is skipped or restoration is unconfirmed, inspect affected canvas and resource state before +repairing. Do not advise manual Undo without confirming which history entry it would revert. The result budget is checked inside this rollback boundary, reserving 2 KiB of the shared 64 KiB limit for runtime evidence. The Hub checks that evidence against the reservation before dispatch @@ -796,6 +862,14 @@ Structural verification is mandatory. It checks: - direct layout grids and frame guides; - mask and managed SVG state. +Paint, effect, and layout-grid comparisons preserve the exact declared variable aliases. +They do not compare fallback literals for fields owned by those bindings: Figma may return +the resolved variable value instead. This includes SOLID color and opacity, bound gradient +stop colors, shadow color/radius/spread and each bound offset axis, blur radius, and bound +grid measurements. Unbound fields, paint/stop counts and geometry, effect types, visibility, +blend modes, and grid patterns remain checked. Reconciliation and verification use the same +comparators; a missing or different alias is still a mismatch. + `apply_canvas` returns counts and factual warnings: ```ts @@ -812,6 +886,10 @@ type Verification = { } ``` +A named HTML entity outside the supported set remains literal and produces one +`unknown-html-entity` warning per request. Numeric references and explicitly escaped entity +syntax keep their existing behavior; use Unicode or a numeric reference for an intended glyph. + A variable or style newly created by the call but not referenced by the same desired result produces an `unbound-created-variable` or `unbound-created-style` warning. This is intentionally non-fatal because a multi-call design-system workflow may stage definitions, but the authoring skill requires @@ -826,6 +904,11 @@ state without rejecting legitimate multi-mode fallbacks, because matching any au sufficient. Font-weight comparison retains the explicit utility's numeric value, including non-hundred weights; it does not infer a weight from native style names or default fonts. +A requested literal that an existing variable or style binding prevents from taking effect +produces `literal-ignored-by-binding`, identifying the affected node key and fields. Preserve +the binding when it owns the intended value. To replace it with a literal, explicitly clear +the corresponding supported variable or style link with `null`; omission retains the link. + A visible component-property reference that can alter Auto Layout flow produces `layout-affecting-visibility-property`. It is also non-fatal because optional content may be designed to reflow. The authoring workflow must either preserve geometry with a fixed slot, diff --git a/docs/extension/mcp-context-strategy.md b/docs/extension/mcp-context-strategy.md index ab49ec3d..c148c131 100644 --- a/docs/extension/mcp-context-strategy.md +++ b/docs/extension/mcp-context-strategy.md @@ -83,6 +83,9 @@ This document records the current context-control strategy for TemPad Dev MCP ou - Resource discovery returns a deterministic immutable catalog only when existing-resource reuse is permitted and relevant; direct or new local resource authoring does not require it. - Normal discovery targets 16 KiB and uses short catalog-scoped refs. + A single verbose entry may exceed that target but must fit the final 64 KiB response. + Component option/property summaries are reduced with omission counts before rejecting + an oversized entry; the immutable catalog retains the complete property definitions. - It reads definitions only and performs no canvas-usage, text, semantic, or relevance retrieval. - Components, variables, and styles are interleaved before advanced collections and shaders. - Component discovery uses optimized type-filtered queries on already-accessible pages and never diff --git a/docs/extension/mcp-design-tasks.md b/docs/extension/mcp-design-tasks.md index a6bea83a..a4e2cacf 100644 --- a/docs/extension/mcp-design-tasks.md +++ b/docs/extension/mcp-design-tasks.md @@ -76,8 +76,9 @@ readable to preserve drafts; current delivery uses Queue or Steer. higher epoch, or taskless write cannot revive it. Applied changes remain. - **Done** closes the review, persists its closure, and clears this round's saved comments and unsaved buffers before dismissing controls. It waits for pending - storage/delivery work; a failed clear remains retryable. Neither elapsed time, - refresh, disconnection, nor viewing the result counts as Done. + storage/delivery work; a failed clear remains retryable. For a completed review + with unsent content, it asks for confirmation before discarding that content. + Neither elapsed time, refresh, disconnection, nor viewing the result counts as Done. Respect Stop without automatically replacing its task. Further requested or necessary work may explicitly begin a fresh task after the running operation drains; no separate @@ -96,10 +97,10 @@ not human editing, other devices, or unrelated services. ## Persistence and recovery -The Hub atomically stores task identity, current-file ownership, results, and Stop/Done -fences under `~/.tempad-dev/state/`. Current, open reviews have no idle eviction. Closed, -cancelled, and replaced task details are retained for 30 days from retirement; running -operations must settle before their records can be removed. Existing records without a +The Hub atomically stores task identity, current-file ownership, results, Stop/Done +fences, and per-file readback markers under `~/.tempad-dev/state/`. Current, open reviews +have no idle eviction. Closed, cancelled, and replaced task details are retained for 30 days +from retirement; running operations must settle before their records can be removed. Existing records without a retirement timestamp start their retention window when first loaded by a retention-aware Hub. Lease expiry is not a history timestamp. @@ -107,6 +108,17 @@ Begin and resume publish a new lease only after its snapshot is saved. If a resu be saved, retain the previous status, epoch, result, and readiness; storage recovery can then retry the same resume without having exposed an active lease that would disappear on restart. +Before dispatching a canvas write, the Hub persists a generation-scoped readback marker. +If saving fails, no write is dispatched and the previous marker is retained. A definitive +success, rejected dispatch, or confirmed pre-write failure clears only that operation's +generation. A lost result or failed mutation attempt retains the marker across Hub restarts, +Stop, and new tasks. Once no operation remains pending, a fresh `get_structure` or +`get_code` result with a surviving node, or an exact `get_structure` page snapshot, clears +the file marker. An empty page snapshot is valid; empty selection, failed-node-only +results, screenshots, and resource catalogs are not. Task resume/reconnection separately +requires a usable read by the current task owner; another task's read cannot satisfy that +lease's `needsRead` requirement. + Expired task IDs cannot be queried or resumed. The Hub retains a small fence per affected file so an old tab cannot recreate a removed task or bypass a previous Stop with taskless writes. A fresh task can still begin normally. Same-owner retries of a removed begin request @@ -136,7 +148,10 @@ Done persists a closure fence before clearing drafts. It propagates through reco and worker restarts, and notifies other tabs locally even without a Hub. Late messages, autosaves, and delivery receipts cannot reopen or clear a later round. Stop clears unsent comments and dismisses cancelled controls after pending work and local saves -settle. This is local persistence, not cross-device history. +settle. If cleanup fails, cancellation remains in effect and Done retries cleanup +explicitly, without confirmation; task refreshes do not retry it automatically. +The panel retains that recovery action even without a usable anchor. This is local +persistence, not cross-device history. ## Host integration @@ -146,10 +161,11 @@ environment edits, helper processes, and agent-driven connection setup do not es host support. Protocol tests verify mechanics; live claims require the installed plugin against the actual host. -`pnpm agent-plugin:build` generates the portable, native, and local development packages from -`agent-plugin/src/`. The native and development packages carry each host's marketplace layout -and omit the portable manifests, so neither host can resolve a conflicting manifest. Codex -registers no hooks. Claude retains its lifecycle hook definitions. +`pnpm agent-plugin:build` derives each installation channel from `agent-plugin/src/`. +Published targets carry only their own installer layout; the ignored local development +package carries both native host layouts and launches this checkout's MCP runtime. Only +Claude registers lifecycle hooks. The [agent plugin invariants](../../AGENTS.md#agent-plugin-invariants) +own the channel-to-target mapping and generated-payload rules. ### Claude lifecycle hooks @@ -211,6 +227,17 @@ dispatched interruptions, acknowledgements, and failures. There is no hook fallb ### Codex feedback: native Queue and Steer +Each CLI registers its resolved host paths before forwarding MCP requests. The Hub scopes +queue readers, helper environments, availability caches and lifecycle subscriptions to that +context, independently of which client started it. Saved Hub tasks and delivery receipts retain +the context across reconnects and restarts; these paths are never included in public task data. +Task reconnection and delivery deduplication use the Codex home and SQLite home, not `HOME` or +temporary discovery paths. A verified reconnect refreshes those discovery inputs. Claude task +binding does not compare Codex paths. An older CLI must be updated and its MCP server restarted +in the host before native feedback is available; reconnecting the same old process is insufficient. +Canvas access and local Stop remain available. Context paths are +discovery inputs, not conversation ownership evidence; native verification is still required. + Where legacy admission is available, Queue admits comments into Codex's local native queue and settles after the host acknowledges storage. The Hub serializes submissions per conversation, reads the latest committed `queued-follow-ups` snapshot from Codex home without modifying @@ -244,7 +271,7 @@ Discovery failures occur before comment dispatch and must not be reported as unc After opening, short repeated discovery queries observe the owner as it loads; a query begun before loading can otherwise wait for the router's entire window without noticing the new owner. -Queue uses `thread-follower-set-queued-follow-ups-state` v1. The message ID is the +Legacy Queue uses `thread-follower-set-queued-follow-ups-state` v1. The message ID is the stable feedback ID; composer context holds the review prompt and empty attachment lists. The original conversation snapshot supplies its working directory. The task ID remains untrusted `writingBlockAdditionalContext`; the owner derives workspace roots, model, and @@ -313,6 +340,11 @@ message. Cleanup rechecks the receipt inside the conversation's existing seriali An unreadable queue is never treated as empty. Uncertain submissions and cancellation tombstones that could still receive a late commit remain until that uncertainty is resolved; an empty queue alone does not establish that a disconnected writer has finished. +Pre-context queue receipts with no recoverable task context intentionally have no age-only +cleanup: their native queue cannot be checked safely. Retaining this bounded legacy set preserves +delivery deduplication and late cancellation evidence. Reconnecting the original conversation +through an updated CLI can recover context while its task still exists, after which normal +verified cleanup applies. Never infer a missing context from the Hub's environment. Feedback batches older than 30 days, or dated more than five minutes in the future, cannot be dispatched. A failed expiry check keeps the draft and delivery identity. The user must @@ -322,14 +354,13 @@ Ordinary transport disconnects do not remove admitted messages. Already consumed input cannot be recalled. Acknowledgement confirms admission, not execution or completion of design changes. -The private protocol was inspected against Codex App 26.908.70816, and the busy-owner -Steer path and paused native queue admission/removal were verified against that macOS host. -Automatic queued execution and the refreshed plugin/Figma UI flow remain live verification -items. Windows endpoint selection, handshake, -URL loading, and rejection of unexpected pipe names have regression coverage; native -Windows host verification is still required. Incompatible or unavailable hosts keep drafts -until native delivery becomes available. Fixtures alone -cannot establish support across host versions or platforms. +The [IPC report](../engineering/codex-desktop-ipc.md) records the inspected host +versions and subsequent native admission, ordered execution, coexistence, Steer and +Stop checks. These dated macOS observations do not establish support across host +versions or platforms. Windows endpoint selection, handshake, URL loading and +rejection of unexpected pipe names have regression coverage, without native Windows +host acceptance. Incompatible or unavailable hosts keep drafts until native delivery +becomes available; fixtures alone cannot establish host support. ## Canvas controls and comments @@ -354,7 +385,11 @@ remain separate from drag, and cancellation/blur releases capture. Locate frames anchor and bar while preserving the offset. Pan clips the bar at the canvas edge; page changes hide it. No canvas action implicitly changes the user's page, viewport, or selection. -All overlays share one animation-frame snapshot of canvas, viewport, page, and selection. +All overlays share one snapshot of canvas, viewport, page, and selection per update. +Captured interaction events keep animation-frame updates active for 500 ms; otherwise, +the scheduler polls every 100 ms and pauses while the document is hidden. Tooltip +membership is cached until relevant DOM changes, while visibility and geometry are +measured on each update. Native geometry determines projection; control sizes remain in screen pixels. Transparent wrappers and pointer-travel regions are passive. Feedback popovers sit above the main panel; the panel stays above canvas task controls. Markers stay below native floating layers, diff --git a/docs/extension/mcp-get-code-design.md b/docs/extension/mcp-get-code-design.md index 7c25e283..efe3a504 100644 --- a/docs/extension/mcp-get-code-design.md +++ b/docs/extension/mcp-get-code-design.md @@ -23,7 +23,9 @@ For splittable roots, the extension limits tree construction and full generation shell with a separate 5-second limit. These extension limits are fixed; a shorter Hub override can still expire first. The fallback shares lookup and CSS-promise caches with the original attempt, and abort checks fence late results and prevent subsequent collection, export, and plugin batches. -Synchronous checkpoints cover depth selection, tree traversal (including hidden siblings), +A completed full or shell result wins if synchronous work finishes before its overdue timer runs; +the deadline wrapper does not recheck elapsed time after success or discard completed output. +Synchronous checkpoints cover depth selection, tree traversal (including hidden siblings), descendant-text budget preflight, candidate-variable scans, and collection between nodes and after text, CSS, style, and media reads. They also run within physical-child overflow scans, per-field text-variable reads, and nested binding/paint scans, including overflow work during semantic-cache population. Group flow and @@ -34,6 +36,11 @@ Vector export checks the same deadline between candidates, after color analysis, export batch. Cached or immediately resolved reads cannot defer cancellation until collection or export finishes. Media and vector helpers also check after native byte reads or exports and before starting uploads, fallback exports, or the next fill; their fallback handlers preserve cancellation. +Optional Tile dimensions share one second of dimension-wait time across each node's image fills; +required byte reads and uploads do not consume that budget. Each shared dimension read has its +own one-second timeout. A shorter node wait does not cache a premature failure for later roots +or shell retries. An exhausted node wait budget still reuses settled cache entries without waiting +for pending ones or starting new dimension reads. Stalled reads retain native CSS dimensions. An incomplete tree fails without a shell; a completed tree can use the fallback even when its last getter exhausts the full-generation budget. Checkpoints cannot interrupt a single native getter or cancel in-flight Figma API calls. Actual leaf and vector roots retain their full-only @@ -60,7 +67,7 @@ A stalled root or unusable shell still fails. 3. **Create request-scoped lookup context** - Create a `GetCodeCacheContext` near the existing variable cache. - - Build variable mappings only from nodes in the bounded visible tree (the root alone for early + - Collect variable IDs per node in the bounded visible tree (the root alone for early shells), using shared lookup readers without descending into the original subtrees. - Pass the same readers through text-segment rendering so bound-variable and style lookups discovered during scanning are reused rather than read again. @@ -75,13 +82,13 @@ A stalled root or unusable shell still fails. - `getCSSAsync()` once per node (skipped nodes are excluded). - `getStyledTextSegments()` for text nodes. - Resolve fill/stroke style-backed CSS through shared lookup readers instead of issuing new ad hoc style/variable lookups per pass. + - Project supported variable-backed properties to canonical CSS references using that node's + bindings before preprocessing or asset URL replacement. - Preprocess styles (clean background, expand shorthands, merge inferred layout, infer resizing, apply overflow). - Apply positioning (auto layout absolute, constraints). - Replace image fills with uploaded assets. -6. **Canonicalize MCP variable output and sanitize styles** - - Rewrite supported variable-backed output into canonical CSS variable IR. - - Capture variable usage candidates for token detection and resolution. +6. **Sanitize projected styles** - Patch known layout issues (negative gap). - Ensure layout parents are `position: relative` when children are absolute. @@ -102,12 +109,21 @@ A stalled root or unusable shell still fails. 10. **Token pipeline** -- Detect token references in output code. +- Detect token references in the rendered component tree's styling values before serialization. - Apply plugin transforms to token names. -- Rewrite code with transformed token names. +- Rewrite class, style, and SVG presentation values with transformed token names. Preserve literal + children, labels, other non-style props, and quoted CSS strings and URLs. Native inline SVG uses + XML attributes as its styling boundary, preserving text and whitespace; literal XML text and CDATA + are escaped for the target template syntax. Explicit plugin `raw()` + content retains its authored code contract and is processed as code. - Build a single `tokens` definition map from exact used variable IDs and alias dependencies, preserving all modes and aliases regardless of `resolveTokens`. - When `resolveTokens` is true, resolve code through native `Variable.resolveForConsumer(node)` before final render. Pass the actual node for styles, text runs, and SVG presentation. - The resolve rerender applies to both collected node styles and themeable vector-placeholder root presentation styles so emitted vector color evidence stays in sync with token resolution. +- The first render records its text-run style inputs after inheritance pruning and default resets. + Token resolution reuses those inputs without new native reads; unchanged node, vector, and text + styles retain the first rendered output, including failed-resolution diagnostics. +- Serialize the transformed component tree. Apply fallback removal and resolved color simplification + at the same styling boundary so CSS-looking design text remains unchanged. 11. **Build unresolved-literal diagnostics** @@ -178,18 +194,23 @@ A stalled root or unusable shell still fails. - `mergeInferredAutoLayout`: - Inject inferred flex/padding/gap where applicable. - `inferResizingStyles`: - - Translate Figma layout sizing into `align-self` and size deletions. + - Translate fill/stretch into `align-self` and hug dimensions into `max-content`. + Deleting a hug dimension would let CSS block/flex auto sizing stretch it to the containing box. - `applyOverflowStyles`: - Add overflow rules based on clips/scroll. ### Sanitize (tree-level) - `patchNegativeGapStyles` + - Replace proven negative gap axes with flow-child margin/padding compensation. + Absolute/fixed children keep their offsets and do not count toward flow compensation. + Preserve other axis values and leave unknown shorthand arity intact. - `canonicalizeAutoLayoutStyles` - Only runs on proven single-child auto-layout containers. - - Resolves each axis as fixed/hug/fill/absolute/unknown. - - Removes only redundant size or padding expressions without changing the emitted flow model. + - Removes redundant symmetric padding only on fixed axes, keeping hug/fill dimensions and + the emitted flow model intact. Intrinsic sizing belongs to preprocessing. - `ensureRelativeForAbsoluteChildren` + - Make an unpositioned or explicitly static layout parent relative, preserving other positioning modes. ### Tailwind output @@ -199,6 +220,18 @@ remain explicit where configured. Expressions and arbitrary properties retain their bracket syntax. Internal style values remain CSS `var(...)` expressions; token collection, renaming, and resolution use the same variable identities. +Use the same arbitrary-property fallback for ordinary styles and generated +pseudo-elements. A missing named utility must not silently discard a retained +declaration, including the transform and origin that position normalized SVG +assets. Test that boundary through serialized markup and browser rendering, not +only the intermediate style map. + +Keep unexpanded background shorthands and their longhand overrides in one inline +style object, with the shorthand first. This preserves quoted image URLs and +override order without depending on Tailwind's source scanner or utility order. +The shared utility converter uses `[background:...]` for retained shorthands, +rather than the `bg-[...]` image/color utilities. + ## Variable output semantics ### Product boundary @@ -247,7 +280,10 @@ token collection, renaming, and resolution use the same variable identities. - `packages/extension/utils/variable-output.ts` is the explicit output boundary: - UI codegen formats supported variable-backed properties with exact `WEB codeSyntax` when present. - MCP `get_code` formats the same supported properties as canonical CSS variable references. -- String-based token rewrites remain a compatibility/backstop pass for legacy CSS output, alias bridging, and downstream transforms. +- Per-node projection owns native CSS alias bridging; duplicate `codeSyntax` aliases cannot choose + a variable, while explicit field bindings retain their exact identity. Do not repeat this step + with candidates from the whole subtree: an unrelated literal can equal another node's syntax. + Downstream token transforms operate on CSS references, preserving strings and asset URLs. ## Request-scoped cache layer @@ -264,6 +300,11 @@ token collection, renaming, and resolution use the same variable identities. ### Cached data +- `rawCss`: native CSS promises shared by plugin preparation and MCP collection, including failed + reads. Each formatting path works on a copy and retains its own variable-output policy. +- `pluginResponses`: completed raw plugin responses keyed by instance ID, including empty + responses. Overlapping roots and timeout shells reuse them within the call; each root selects + its language independently. Aborted or expired attempts cannot publish late responses into the cache. - `variables`: request-scoped `Variable | null` lookup map. - `tokens`: request-scoped collection/name lookups and raw native values keyed by consumer node ID and variable ID. Code reads traverse exact IDs and aliases without indexing all local variables. The names-only `get_token_defs` request creates its own fresh context and lazy full index. - `styles`: request-scoped `BaseStyle | null` lookup map. @@ -349,9 +390,12 @@ The request context is threaded through: - Native video fills share one composited PNG preview per node and record their ordered unique current-file identities as `figmaVideoHashes`. Figma exposes the hash on each `VideoPaint` but no API for reading the original video bytes, so the descriptor does not present the preview as the - source video. + source video. When different nodes produce identical preview bytes, deduplication merges their + native image/video identities in first-seen order. - Vector-like nodes may be exported to SVG. - Vector containers can be converted to a single SVG when their subtree is vector-like and the container itself does not carry wrapper semantics such as its own fill/stroke/effects/clipping or design-component hints. + Component boundaries and hints inside that subtree also prevent ancestor promotion, keeping + the component structure available in the output. - Themeable vectors are single-color vectors that can safely use one contextual color channel. Full-bounds SVG exports are mapped from Figma's baked absolute coordinates into local coordinates using the inverse linear transform. Node geometry replaces integer-rounded export dimensions, @@ -367,19 +411,22 @@ The request context is threaded through: - Placeholder SVG nodes receive external layout styles plus presentation color on the root markup when the vector is single-channel. - Themeable-vector eligibility and single-channel color detection reuse shared paint/effect semantics instead of maintaining separate vector-only interpretations. - Token paint channels compare variable IDs. A descendant token is promoted to the SVG root only when their effective mode maps match; otherwise preserve the native fixed-color asset, since cross-collection aliases can depend on any differing mode. + If fixed and themeable instances share identical SVG bytes, their shared descriptor omits + `themeable`; eligible instances still retain their own root presentation color. - The tool emits `` placeholders by default for the current response. If asset upload fails after export, it falls back to inline SVG so the response still carries vector structure. Host apps may refactor to their own SVG policy, such as repo icon primitives, bundler/dev-server SVG transforms, inline SVG, or asset-backed SVG usage, as long as themeable vectors remain single-channel and fixed-color vectors keep their palette. - Placeholder SVG is emitted when export fails or is unavailable, using node width/height. - Vector-root descendants are not rendered; their CSS is not collected. ## Token pipeline (detailed) -1. Build source index from variable IDs and codeSyntax. +1. Build the source index from collected variable IDs and CSS variable aliases. Raw `codeSyntax` + literals are not token names: they can coincide with unrelated colors, lengths or classes. 2. Strip fallbacks, then extract raw token names from code (boundary-aware). Include font-face bindings collected from the text actually emitted by this render. 3. Apply plugin transforms to names. 4. Rewrite code with transformed names (only if any rename occurs). 5. Build a single definition map from the transform's final name-to-ID mapping (direct + alias-chain tokens), always preserving modes and aliases. -6. Resolve code for each consuming node when `resolveTokens` is true. Native Figma resolution owns inherited modes and cross-collection alias traversal; known length properties override token-name heuristics, and serialized literals stay in native pixels until rendering applies the request's units and scale. Divide native opacity percentages by 100 only for that property. Unresolved opacity, font-weight and known length properties retain canonical references in dimensionally compatible `calc()` expressions, using final definition dimensions across modes and aliases. Font-weight length conversion divides by 100px then multiplies by 100; the reverse conversion for unitless definitions in length properties multiplies by 100px then divides by 100. Both avoid rounding a 1px factor during rem conversion. Preserve length units on zero definitions; mixed or unknown dimensions retain the reference with a consumer-specific `token-resolution` warning. +6. Resolve code for each consuming node when `resolveTokens` is true. Native Figma resolution owns inherited modes and cross-collection alias traversal; known length properties override token-name heuristics, and serialized literals stay in native pixels until rendering applies the request's units and scale. Divide native opacity percentages by 100 only for that property. Unresolved opacity, font-weight and known length properties retain canonical references in dimensionally compatible `calc()` expressions, using final definition dimensions across modes and aliases. Font-weight length conversion divides by 100px then multiplies by 100; the reverse conversion for unitless definitions in length properties multiplies by 100px then divides by 100. Both avoid rounding a 1px factor during rem conversion. Length division emits a bounded `token-resolution` warning because it requires CSS typed arithmetic support; `resolveTokens:true` produces portable literals. Preserve length units on zero definitions; mixed or unknown dimensions retain the reference with a consumer-specific `token-resolution` warning. ### Pipeline guards @@ -411,6 +458,10 @@ The request context is threaded through: lookups, full-range text bindings, and vector analysis across scanning, collection, and render. - Text-run token resolution reads only typography fields consumed by the rendered run; list and paragraph geometry continue to use the segment's native values. +- Paragraph indentation and spacing use separate paragraph boxes when needed; spacing applies + between blocks, and soft line breaks stay inside their paragraph or list item. Indented ordinary + paragraphs repeat indentation after soft breaks without indenting automatic wrapped lines. + Ordinary text with zero paragraph geometry retains compact output. - Asset export is planned and executed once. - A descendant-text preflight can bypass descendant collection and all asset work when UTF-8 text alone proves the response cannot fit. Tests assert that it short-circuits before scanning the rest diff --git a/docs/extension/mcp-get-code-requirements.md b/docs/extension/mcp-get-code-requirements.md index 89a6650a..388181c3 100644 --- a/docs/extension/mcp-get-code-requirements.md +++ b/docs/extension/mcp-get-code-requirements.md @@ -85,8 +85,10 @@ This document records the requirements and hard constraints for the MCP `get_cod preflight is deliberately a proof, not an output-size guess, so it must not discard work for trees that may still fit. - Only throw a user-facing budget error when a usable shell cannot be generated. -- For splittable roots, a full read that takes 20 seconds falls back to the same shell contract. +- For splittable roots, an in-progress full read that reaches 20 seconds falls back to the same shell contract. Allow up to 5 seconds for the shell so it can return before the Hub's default 30-second timeout. + Preserve a completed full or shell result if it finishes before an overdue timer runs; do not + discard it or schedule another attempt solely because the elapsed budget has passed. Reuse request-scoped CSS reads and stop scheduling descendant work after the deadline. Actual leaf and vector roots and unbounded debug reads retain full reads. Containers planned as a single vector asset must not fall back to a shell, which could break masks or compositing across children. @@ -199,10 +201,17 @@ nearest non-GROUP/BOOLEAN ancestor. Rebase offsets by the boxed group's native o ## Token handling -- Token detection starts from the emitted markup; names may be transformed/re-written, and final used names are derived from the rewrite map (no second scan). +- Token detection starts from styling values in the rendered component tree, including classes, + inline styles, and SVG presentation attributes. Names may be transformed/re-written, and final + used names are derived from the rewrite map (no second scan). - Font-face variable bindings also contribute definitions for text actually rendered. Omitted descendants and text replaced by plugin output must not contribute these bindings. -- Token detection always strips `var(..., fallback)` before matching to avoid false positives. +- Token detection strips `var(..., fallback)` from these styling values before matching to avoid + false positives. Literal text, labels, and other non-style props must not be detected or rewritten + as tokens. Preserve quoted CSS strings and URLs, including Tailwind content literals. Native inline + SVG must preserve XML text, entities, and whitespace without interpreting text or CDATA as template + expressions. Explicit plugin `raw()` output is authored + code and retains code-level processing. - `get_code` code output intentionally does not use inline `var(..., fallback)` values as the token value source; values, aliases, and modes belong in the `tokens` payload. - `get_code` emits canonical CSS variable IR for supported variable-backed properties when `resolveTokens` is `false`. - Canonical token identity is derived from variable identity and must stay stable across property families such as paint-derived channels, typography/text output, and future supported layout/effect properties. @@ -239,6 +248,9 @@ nearest non-GROUP/BOOLEAN ancestor. Rebase offsets by the boxed group's native o cross-axis bindings take precedence; equal numeric gaps alone do not establish synchronization. - If an opacity or font-weight token's modes or aliases have mixed or unknown dimensions, preserve the reference and emit a `token-resolution` warning identifying the incomplete normalization. +- Length-valued opacity and font-weight tokens require CSS typed arithmetic when left unresolved. + Emit a bounded `token-resolution` compatibility warning and suggest `resolveTokens:true` for + portable literal output. Unitless division does not need this warning. - Failed consumer resolution preserves the reference and emits a bounded `token-resolution` warning. Never choose a collection default as a substitute for failed native resolution. - Missing definitions, ambiguous names, and unavailable mode labels emit bounded `token-definition` warnings. Do not fabricate empty values or pick one variable from a name collision. - Collection and mode IDs carry identity. Duplicate collection names make definition labels ambiguous and emit a warning; mode hints use collection IDs in that case. diff --git a/docs/extension/multi-fill-background-design.md b/docs/extension/multi-fill-background-design.md index db3bde96..1108b1a5 100644 --- a/docs/extension/multi-fill-background-design.md +++ b/docs/extension/multi-fill-background-design.md @@ -1,727 +1,149 @@ -# Multi-fill Background Resolution Design - -This document records the paint-order contract and background resolution design for issue `#101` when a Figma node has multiple fill paints. - -The goal of this design is to preserve same-node fill intent directly. If a user explicitly configured multiple fills on one Figma node, the generated CSS should preserve that stack whenever those fills can be represented as CSS background layers. - -## Summary - -The extension style resolver and MCP fallback preserve representable same-node fill stacks: - -- a single solid uses `background-color` -- a single gradient uses `background` -- multiple solid or gradient paints use ordered CSS background layers - -Scalar channels such as text color and strokes use the frontmost renderable paint only when it -can represent the stack. They retain native CSS for translucent or blended stacks instead of -substituting a lower paint. - -However, issue `#101` is not sufficient evidence that every visual involving "multiple layers" should be solved by emitting multiple CSS background layers: - -- the screenshot attached to the issue shows two fill entries on the same node, both named `bg/secondary` -- the screenshot does not show separate child geometry -- the issue does not prove that the visual comes from separate child geometry rather than same-node fills - -The recommended direction is: - -1. treat same-node multi-fill support as an authoring-intent feature -2. preserve the ordered fill stack whenever it is representable as CSS background layers -3. avoid heuristic collapse based on opacity or inferred visual equivalence - -In practical terms, this means the implementation should distinguish between: - -- author-authored Figma fills, which should be preserved -- API-introduced fallback tokens from `getCSSAsync()`, which should be normalized away before stack preservation - -## Research Findings - -### 1. CSS constraints - -Official references: - -- [MDN: `background-color`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-color) -- [MDN: Using multiple backgrounds](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_backgrounds_and_borders/Using_multiple_backgrounds) - -Relevant constraints: - -- `background-color` accepts a single `` value. -- Multiple background layers are expressed through comma-separated background image layers. -- In a multi-background stack, the first listed layer is painted on top and the last listed layer is at the back. -- Only the last background may include a `background-color`. - -Implications for this repo: - -- `background-color: color1, color2` is invalid CSS. -- When multiple pure-color layers must be represented as independent background layers, a solid color must be encoded as an image-like layer, for example `linear-gradient(color, color)`. -- Position keywords such as `top` and `bottom` do not create independent geometric regions by themselves. Without explicit size and repeat control, they do not reproduce a top-half / bottom-half split. -- Existing `background` / `background-image` values that contain image layers must not be merged blindly with inferred fill layers unless the ordering semantics are explicitly defined. - -### 2. Figma constraints - -Official reference: - -- [Figma plugin docs: `fills`](https://developers.figma.com/docs/plugins/api/properties/nodes-fills/) - -Relevant constraints: - -- `fills` is `ReadonlyArray | figma.mixed`. -- Fills are paints applied to the shape area of a node. -- Fills can differ by visibility, opacity, gradient stops, and blend mode. -- The `fills` property itself does not provide independent rectangle geometry for separate top/bottom regions on a single node. -- The repo already relies on Figma paint visibility semantics in multiple places; multi-fill support should reuse those semantics instead of introducing a second interpretation. -- Native fills are ordered back-to-front: the last paint is visually on top. CSS background layers - run front-to-back. The native probe on page `2772:2` confirmed this with reversed opaque-blue / - half-opacity-red pairs. Convert order once at the paint-to-CSS boundary, retaining native indices - for variable bindings; existing CSS layers and radial geometry already use CSS order. - -Implications for this repo: - -- Multiple solid fills on one node are full-node layers, not independently positioned rectangles. -- If the visual in a Figma design is created by separate child layers, the fix is a tree/rendering change, not a background-layer change. -- If the visual comes from same-node fills, CSS background layering can only preserve the subset that is representable as full-node background layers. - -### 3. Diagnosis of issue #101 - -Issue `#101` currently contains an ambiguous problem statement: - -- The body says the object has two colors on separate layers. -- The attached screenshot shows two fill entries on one node, both named `bg/secondary`. -- The expected CSS in the issue is invalid because it places gradients in `background-color`. - -Based on the available evidence, the safest interpretation is: - -- the issue is about multiple fills on the same node, not about multiple child layers -- the expected CSS in the issue body is invalid, but the product request itself is reasonable - -Therefore, `#101` should be addressed as "same-node multi-fill stacks should survive into CSS when representable", not as "try to infer whether duplicate fills matter visually". - -## Current Repository Behavior - -### Extension style resolver - -The UI/codegen path uses: - -- `packages/extension/utils/codegen.ts` -- `packages/extension/utils/figma-style/style-resolver.ts` - -- `resolveBackgroundFillFromPaints()` preserves all renderable solid/gradient layers in CSS order. -- Single-value helpers inspect the frontmost renderable paint, without searching through a - covering paint of another type. Hidden and zero-opacity paints do not cover lower paints. -- `ResolvedPaintValue` remains scalar for `color`, `fill`, and stroke channels. An opaque normal - front paint can replace these values; a translucent or blended multi-paint stack retains native - CSS. Indexed variable bindings keep the original native paint index after reversing paint order. - -### Extension MCP cleanup path - -The MCP `get_code` path uses: - -- `packages/extension/mcp/tools/code/collect.ts` -- `packages/extension/mcp/tools/code/styles/background.ts` -- `packages/extension/mcp/tools/code/styles/normalize.ts` - -The MCP fallback injects representable layered backgrounds from live or style-backed paints -when native CSS omits them. It shares renderability and paint-order helpers with the style -resolver while retaining MCP-specific token formatting. Existing real image shorthand is not -merged with inferred color/gradient layers. - -### `getCSSAsync()` fallback gray behavior - -The repo already contains a cleanup rule for Figma's default gray image-fill fallback: - -- style-resolution path: `packages/extension/utils/figma-style/style-resolver.ts` -- MCP path: `packages/extension/mcp/tools/code/styles/background.ts` - -Current behavior: - -- when `getCSSAsync()` produces `background: url(...) lightgray ...` -- the synthetic `lightgray` token is removed -- real fill data is used to recover the intended background color when possible - -This behavior must be preserved. - -Why: - -- the gray token is not author intent -- it is API-generated fallback noise from `getCSSAsync()` -- multi-fill preservation must not accidentally treat that gray token as a real background layer - -### Plugin component data path - -The plugin component serializer in `packages/extension/utils/component.ts` already preserves multiple solid fills for vector children through `VectorNode.fills`. - -This means the problem is not "the repository cannot represent multiple fills anywhere". The problem is specifically background-style generation for node CSS. - -## Product Decision - -The repository needs an explicit decision on what it is trying to preserve. - -### Recommended decision - -Preserve same-node fill intent, not only inferred rendered equivalence. - -That means: - -- If a node has multiple visible fills and those fills are representable as CSS full-box background layers, preserve them as separate layers. -- Do not try to collapse layers by inspecting opacity or guessing visual redundancy. -- If the effect depends on geometry outside a single full-node fill stack, do not force it into background CSS. -- Normalize API-generated fallback tokens before applying any of the above rules. - -### Why this is the right default - -- It matches the user's explicit configuration in Figma. -- It avoids heuristic logic that can become fragile and surprising. -- It keeps the implementation easier to reason about and test. -- It still leaves room for later optimization if the project decides to canonicalize redundant fill stacks. - -## Proposed Scope - -### In scope for v1 - -- Same-node fill stacks containing supported paint types: - - `SOLID` - - `GRADIENT_LINEAR` - - `GRADIENT_RADIAL` - - `GRADIENT_DIAMOND` - - `GRADIENT_ANGULAR` -- Preservation of ordered same-node fill stacks. -- Shared resolver behavior used by both UI/codegen and MCP collection. -- MCP cleanup fallback when background channels are missing. - -### Explicitly out of scope for v1 - -- Flattening sibling layers into one background. -- Deriving top/bottom/left/right subregions from same-node fills. -- Supporting image/video/pattern fill mixing in the first pass. -- Full `background-blend-mode` support for non-`NORMAL` fill blend modes. -- Changing MCP schemas or public tool contracts. - -## Design Principles - -1. Keep single-fill output unchanged. -2. Preserve explicit multi-fill intent when the stack is representable in CSS. -3. Do not add heuristic occlusion analysis in the first implementation. -4. Do not change public shared types unless there is a clear downstream need. -5. Treat fill order as a verified runtime behavior, not an assumption hidden in code. -6. Treat `getCSSAsync()` gray fallback cleanup as a normalization pass, not as fill intent. - -## Precedence Rules - -The repository already has precedence rules for resolving paints: - -1. style-backed paints from `fillStyleId`, when present and resolvable -2. direct node `fills`, when style lookup is absent or unusable - -The multi-fill design should preserve this precedence. - -That means: - -- if `fillStyleId` resolves to a paint style, the stack is built from the style paints -- otherwise the stack is built from direct node paints -- the stack is never composed by partially mixing a resolved paint style with unrelated direct fills - -This keeps multi-fill behavior aligned with the current single-fill resolver semantics. - -## Normalization Rules - -Before preserving a fill stack, the implementation should normalize raw CSS and Figma-derived signals in a fixed order. - -### Rule 1: Remove API-generated fallback gray - -If `getCSSAsync()` produces `background: url(...) lightgray ...`, that `lightgray` token must be removed before multi-fill processing. - -This remains true even after multi-fill support lands. - -### Rule 2: Do not reinterpret fallback gray as a fill layer - -The removed gray token must not become: - -- a `background-color` -- a synthetic `linear-gradient(lightgray, lightgray)` layer -- part of a multi-layer `background` - -Only real Figma fills may contribute background layers. - -### Rule 3: Preserve existing image shorthand cleanup - -When the input contains image shorthand from `getCSSAsync()`, existing shorthand cleanup must remain intact: - -- preserve `background-image` -- preserve `background-size` -- preserve `background-repeat` -- preserve `background-position` - -The multi-fill implementation must layer on top of this behavior rather than replacing it. - -### Rule 4: Keep single-fill simplification - -If normalization leaves exactly one representable fill layer: - -- a single solid fill still becomes `background-color` -- a single gradient still becomes `background` - -The repository should not wrap a single solid layer into `linear-gradient(color, color)` just for consistency with the multi-layer case. - -## Proposed Output Rules - -For same-node visible fill stacks: - -1. Filter to visible paints using the existing semantics: - - `visible !== false` - - paint opacity must be greater than zero - - gradient paints with all stop alpha zero are treated as invisible - -2. Preserve the ordered visible stack: - - keep every visible representable paint - - do not collapse duplicate colors - - do not collapse opaque-over-opaque stacks - - exclude API-generated fallback gray from the stack entirely - -3. Resolve the preserved stack: - - zero preserved layers: no fill-derived background output - - one preserved opaque solid layer: emit `background-color` - - one preserved gradient layer: emit `background` - - multiple preserved representable layers: emit comma-separated `background` - -4. Encode solid layers in multi-layer output as: - -```css -linear-gradient(color, color) -``` - -This is not used because the layer is visually a gradient. It is used because CSS background layering requires image-like layers, and a solid `linear-gradient()` is the most direct full-box representation. - -## Supported and Unsupported Stack Shapes - -### Supported in v1 - -- solid + solid -- solid + gradient -- gradient + gradient -- any number of visible representable solid/gradient fills on the same node - -### Unsupported in v1 - -- image + solid stacks expressed as one merged CSS stack -- image + gradient stacks expressed as one merged CSS stack -- non-`NORMAL` blend mode composition -- geometry-dependent composition that would require child elements instead of one background stack - -### Fallback behavior for unsupported stacks - -When the visible stack is unsupported, the implementation should degrade predictably: - -- preserve the current image-fill cleanup behavior -- preserve current single-layer fill resolution behavior where applicable -- do not emit partially merged multi-layer CSS with ambiguous ordering -- log or test the unsupported branch explicitly so behavior stays intentional - -## Why the issue's proposed CSS should not be implemented - -The expected output proposed in `#101` should not be used as the design target. - -Problems with the proposal: - -- it uses `background-color` with gradient values, which is invalid CSS -- it assumes same-node fills can be independently positioned as `top` and `bottom` -- it omits the size/repeat controls that would be required even if independent positioning were valid - -If the intended design really contains independently positioned color bands, the source is almost certainly separate child geometry rather than multiple fills on one node. - -## Implementation Plan - -### Phase 1: Add layered background resolution without changing public contracts - -Do not change MCP result schemas. Keep the single-result resolver type internal. - -Instead, add new internal helpers for background stacks while keeping existing single-result helpers for stroke and simple fill consumers. - -Recommended internal abstraction: - -```ts -type ResolvedBackgroundStack = - | { kind: 'none' } - | { kind: 'color'; color: string } - | { kind: 'layers'; layers: string[] } -``` - -This type should remain internal to background resolution logic. - -Suggested internal invariants: - -- `kind: 'none'` means no usable fill-derived background signal after normalization -- `kind: 'color'` means exactly one preserved solid fill -- `kind: 'layers'` means one or more image-like layers suitable for CSS background stacking - -In practice, `kind: 'layers'` should be used for: - -- one preserved gradient -- multiple preserved solid/gradient layers - -The caller may still simplify a single gradient to `background` output for readability. - -### Phase 2: Refactor extension-owned paint helpers into per-paint formatters - -In `packages/extension/utils/figma-style/gradient.ts`: - -- keep the existing single-result helpers for simple fill and stroke consumers -- extract per-paint helpers: - - `resolveGradientPaint()` - - `resolveSolidPaint()` -- add a new helper that resolves an ordered background stack from multiple paints - -This keeps the helper layer small while enabling stack-aware background logic. - -### Phase 3: Use stack-aware logic in the extension style resolver - -In `packages/extension/utils/figma-style/style-resolver.ts`: - -- replace the current fill-to-single-value rewrite in `resolveStylesFromNodeData()` -- when fill-derived background channels are being resolved, use the new stack helper instead of `ResolvedPaintValue` -- use conservative frontmost-paint resolution for scalar channels: - - `color` - - `fill` - - stroke handling -- keep the current `url(...) lightgray` cleanup ahead of stack resolution - -Important behavior requirement: - -- if a background contains a real image layer from `getCSSAsync()`, the style resolver must not silently interleave inferred fill layers into that image stack in v1 -- mixed image/fill stack preservation is a follow-up feature, not part of the first implementation - -Expected behavior: - -- UI codegen immediately benefits through `resolveStylesFromNode()` -- MCP collection also benefits because `collectNodeData()` already calls `resolveStylesFromNodeData()` - -### Phase 4: Use stack-aware logic in extension fallback cleanup - -In `packages/extension/mcp/tools/code/styles/background.ts`: - -- add local stack-aware fallback/injection logic for cases where: - - `getCSSAsync()` omits background channels - - MCP-specific variable formatting still needs to be derived from live paints or style-backed paints -- keep the current special handling for: - - gradient opacity rewriting - - `url(...) lightgray` cleanup - -Important behavior requirement: - -- fallback cleanup must not recreate a removed gray token as a new background layer -- fallback cleanup should only ever synthesize layers from real Figma paints - -Important note: - -The extension-side background cleanup helper does not need to share the exact string formatter with the extension style resolver, because the MCP pipeline currently uses different variable formatting rules in this stage. What must be shared is the decision logic: - -- visibility filtering -- supported paint-type rules -- layer ordering rules - -### Phase 5: Preserve existing image asset behavior - -Do not expand scope to mixed image + color fill stacks in the first pass. - -For v1: - -- leave current image-fill behavior unchanged -- only generate layered backgrounds when the preserved fill stack is composed entirely of representable solid/gradient paints - -This keeps the first implementation focused and avoids reworking `replaceMediaUrlsWithAssets()` and shorthand merging at the same time. - -### Phase 6: Optional follow-up for mixed image/fill stacks - -Only after the solid/gradient-only implementation is stable: - -- define ordering rules for real image layers from `getCSSAsync()` -- define how fill-derived layers should merge with `background-image` -- update asset replacement tests accordingly - -## Implementation Checklist - -This checklist translates the design into a concrete execution sequence for the repo. - -### 1. Extension-owned paint helpers - -Target files: - -- `packages/extension/utils/figma-style/gradient.ts` -- `packages/extension/utils/figma-style/types.ts` if and only if an internal helper type cannot remain file-local - -Tasks: - -- add per-paint formatting helpers for: - - solid paint -> CSS color expression - - gradient paint -> CSS gradient expression -- add a stack-aware helper that resolves an ordered background stack from a paint list -- keep single-result helpers available for the style resolver's non-background channels -- do not change MCP schemas or shared public contracts for the first pass - -Definition of done: - -- extension-owned helpers can resolve one or more representable solid/gradient fills in order -- hidden paints and zero-opacity paints are excluded -- fallback gray from `getCSSAsync()` is not modeled here as a paint layer - -### 2. Extension style resolver integration - -Target file: - -- `packages/extension/utils/figma-style/style-resolver.ts` - -Tasks: - -- keep `url(...) lightgray` cleanup before any fill-stack resolution -- introduce a helper that applies a resolved background stack onto CSS channels -- preserve the existing precedence: - - style-backed fill paints first - - direct node fills only as fallback -- keep single-fill output compact: - - single solid -> `background-color` - - single gradient -> `background` -- preserve native CSS when a composited stack cannot be represented by one value in: - - `color` - - `fill` - - stroke channels -- do not merge real image shorthand output with inferred fill layers in v1 - -Definition of done: - -- UI codegen path preserves same-node multi-fill stacks for supported solid/gradient shapes -- existing image shorthand cleanup still works -- unsupported mixed image/fill stacks stay on the current behavior path - -### 3. Extension MCP cleanup integration - -Target file: - -- `packages/extension/mcp/tools/code/styles/background.ts` - -Tasks: - -- add stack-aware fallback logic for live paints and style-backed paints -- preserve current handling for: - - gradient opacity rewriting - - `url(...) lightgray` normalization - - shorthand splitting to `background-image`, `background-size`, `background-repeat`, and `background-position` -- ensure fallback cleanup only synthesizes layers from real Figma paints -- ensure removed fallback gray is never recreated as: - - `background-color` - - a synthetic solid gradient layer - - part of a multi-layer `background` - -Definition of done: - -- MCP `get_code` fallback behavior matches the extension style resolver's multi-fill rules -- background injection when `getCSSAsync()` omits channels can now produce layered output for supported stacks -- fallback gray never leaks into final output - -### 4. Cache and data-shape review - -Target files: - -- `packages/extension/mcp/tools/code/cache/context.ts` -- `packages/extension/mcp/tools/code/cache/types.ts` - -Tasks: - -- verify whether the existing cached raw `paints` field is sufficient for stack-aware resolution -- avoid expanding cache summaries unless a concrete performance need appears -- if a cache change is needed, keep it extension-local and avoid public contract changes - -Definition of done: - -- stack-aware resolution can reuse cached style paints where appropriate -- no unnecessary shared contract churn is introduced - -### 5. Extension style resolver tests - -Target files: - -- `packages/extension/tests/utils/figma-style/gradient.test.ts` -- `packages/extension/tests/utils/figma-style/style-resolver.test.ts` - -Add tests for: - -- two visible solid fills emit layered background output -- two visible identical solid fills still emit layered output -- solid + gradient emits layered output -- hidden and zero-opacity fills are excluded -- `url(...) lightgray` is stripped before fill-stack preservation -- unsupported mixed image/fill cases follow the intended fallback path - -Definition of done: - -- the extension style resolver behavior is locked in with explicit multi-fill regression tests - -### 6. Extension tests - -Target files: - -- `packages/extension/tests/mcp/tools/code/styles/background.test.ts` -- optionally `packages/extension/tests/mcp/tools/code/cache/context.test.ts` -- optionally `packages/extension/tests/mcp/tools/code/assets/media.test.ts` if the normalization path needs extra coverage - -Add tests for: - -- multi-fill fallback injection when no usable background channel exists -- style-backed paint stacks resolved through cached paints -- removed fallback gray is not reintroduced as a layer -- existing single-fill behavior remains unchanged -- image shorthand cleanup still produces the expected split properties - -Definition of done: - -- MCP-side behavior is aligned with extension style resolver behavior -- the gray fallback branch has explicit regression coverage - -### 7. Manual verification - -Use a live Figma fixture to verify: - -1. the visual order of `node.fills` -2. a same-node multi-fill example with at least: - - solid + solid - - solid + gradient -3. an image-fill case where `getCSSAsync()` emits `url(...) lightgray ...` - -Definition of done: - -- fill order is confirmed once and recorded by tests -- gray fallback does not appear in final generated CSS - -### 8. Repository verification - -Run the repo checks that match the touched files: - -- `pnpm typecheck` -- `pnpm lint` -- `pnpm test:run` - -If only a subset is practical while iterating, the minimum useful sequence is: - -- targeted style-resolver tests -- targeted extension tests -- final full `pnpm test:run` before merge - -## Affected Files - -Primary implementation targets: - -- `packages/extension/utils/figma-style/gradient.ts` -- `packages/extension/utils/figma-style/style-resolver.ts` -- `packages/extension/tests/utils/figma-style/gradient.test.ts` -- `packages/extension/tests/utils/figma-style/style-resolver.test.ts` -- `packages/extension/mcp/tools/code/styles/background.ts` -- `packages/extension/tests/mcp/tools/code/styles/background.test.ts` - -Files that should not need contract changes: - -- `packages/extension/utils/figma-style/types.ts` -- `packages/extension/mcp/tools/code/cache/types.ts` -- MCP tool schemas in `packages/shared/src/mcp/tools.ts` - -## Test Plan - -Add regression coverage for both extension paths. - -### Extension style resolver tests - -Add cases for: - -- two visible solid fills emit layered `background` -- two visible identical solid fills still emit layered `background` -- solid + gradient emits layered `background` -- hidden or zero-opacity fills are excluded -- `url(...) lightgray` is removed before any fill stack is built -- unsupported mixed stacks fall back to current behavior - -### Extension MCP tests - -Add cases for: - -- missing background channels with multi-fill stacks -- style-backed fill stacks resolved through cached paints -- MCP fallback path preserving layered output -- removed gray fallback is not reintroduced as a synthetic layer -- existing single-fill cases remaining unchanged - -### Manual verification - -Before merging implementation: - -1. Create a live Figma fixture with ordered fills. -2. Confirm the order of `node.fills` against Figma's visible stacking order. -3. Lock that order in regression tests. -4. Validate a fixture where `getCSSAsync()` emits `url(...) lightgray ...` and confirm the gray token never appears in final layered output. - -The native pair above establishes the ordering contract. Future changes to paint mapping should -retain those regressions and verify mixed media or geometry behavior independently. - -## Risks - -### 1. Order mismatch - -If Figma's fill array order is interpreted incorrectly, layered output will invert foreground and background paints. - -Mitigation: - -- verify once with a live fixture -- document the chosen order in tests - -### 2. Output inflation - -Always preserving multi-fill stacks will produce more verbose CSS for redundant cases. - -Mitigation: - -- accept this as the explicit tradeoff for preserving author intent -- keep `background-color` for single-fill nodes - -### 3. Shared/extension drift - -If the extension style resolver and the extension cleanup path adopt different multi-fill rules, UI codegen and MCP output will diverge. - -Mitigation: - -- document the common decision rules in tests -- keep the extension helper behaviorally aligned even if the string formatters differ - -### 4. Gray fallback regression - -If the implementation treats `getCSSAsync()` fallback gray as a real fill layer, output will contain spurious backgrounds that were never authored in Figma. - -Mitigation: - -- keep gray cleanup as a normalization step ahead of stack preservation -- add explicit regression tests for image shorthand plus fill stacks - -## Open Questions - -1. Should the product preserve redundant fill-list structure even when rendered output is unchanged? - -Recommended answer: yes, for same-node multi-fill stacks. - -2. Do we want a second-phase design for image + translucent solid fill stacks? - -Recommended answer: yes, but only after the solid/gradient-only path is stable. - -3. Should non-`NORMAL` blend modes eventually map to `background-blend-mode`? - -Recommended answer: yes, but that is a separate feature and should not block the first pass. - -## Recommendation - -Implement issue `#101` as "same-node multi-fill stacks become multiple CSS layers whenever they are representable as CSS full-box background layers". - -That means: - -- the fills must be on the same node -- the fills must be representable as supported CSS background layers -- all visible representable fills should be preserved in order - -This gives the repository a simple and predictable rule: do not outsmart the document. If the user configured multiple fills on one node, preserve that stack instead of collapsing it based on inferred visual equivalence. - -At the same time, do not preserve `getCSSAsync()` fallback artifacts as if they were authored fills. The implementation should be conservative about author intent in the right direction: - -- preserve real fill stacks -- normalize away API-generated gray fallback +# Multi-fill background resolution + +This document owns the paint-order and fallback rules for converting same-node Figma fills into +CSS backgrounds. The original motivation was issue #101: multiple fills on one node should +survive export when CSS can represent them. This is an export contract, separate from the +[native canvas authoring surface](./mcp-canvas-authoring-coverage.md). + +## Representation boundary + +Preserve explicit fill layers rather than removing duplicates or guessing that an opaque layer +makes another redundant. Same-node fills cover the node's shape; they do not describe independent +child geometry. Do not infer top/bottom bands or flatten sibling nodes into one background. + +CSS paints background images front-to-back. Only the last background layer may include a color; +`background-color` cannot contain a comma-separated list of colors or gradients. A solid paint +inside a layered stack therefore uses `linear-gradient(color, color)`. See +[CSS multiple backgrounds](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_backgrounds_and_borders/Using_multiple_backgrounds) +and [`background-color`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-color). + +The implementation preserves solid/gradient stacks and existing image/gradient/color shorthand. +It does not promise a lossless CSS equivalent for every Figma paint: + +- Non-`NORMAL` paint blending has no complete `background-blend-mode` mapping here. +- Pattern and shader paints have no general background reconstruction path. +- Gradient synthesis alone cannot reproduce arbitrary rotated/skewed radial geometry or a + diamond gradient. Preserve matching native CSS geometry when available; the fallback formatter's + radial/conic approximations are not a claim of pixel equivalence. +- Text color, SVG fill/stroke, and borders are scalar or separate output channels. They do not + gain full multi-fill support from this background resolver. Gradient-border composition has its + own CSS constraints and is not a general Figma stroke model. + +## Paint source and order + +[The style resolver](../../packages/extension/utils/figma-style/style-resolver.ts) selects a +resolvable Paint style before direct node paints. Missing, invalid, or unavailable styles fall +back to the node's paints. Never combine part of a style stack with unrelated direct fills. + +[Paint semantics](../../packages/extension/utils/figma-paint.ts) are shared by both codegen paths: + +- A paint is renderable when it is visible, has positive paint opacity, and, for gradients, at + least one stop has positive alpha. +- Native paints are back-to-front; CSS background layers are front-to-back. Reverse once when + resolving paints, retaining the native index for variable bindings. +- Existing CSS layers are already in CSS order and must not be reversed again. +- A recovered `background-color` must come from the bottom renderable solid paint. A translucent + or blended paint is not a safe substitute for that base color. + +The earlier native probe on page `2772:2` established the order with reversed opaque-blue and +half-opacity-red pairs. The regression preserves that observation; it does not establish unrelated +media or gradient geometry behavior. + +## Solid and gradient backgrounds + +[The background resolver](../../packages/extension/utils/figma-style/gradient.ts) returns a color, +ordered layers, a request to retain native CSS, or no usable fill-derived value: + +- One solid paint stays `background-color`, including its supported alpha representation. +- One gradient uses `background`. +- Multiple solid/gradient paints use ordered `background` layers, with solids represented as + constant gradients. Duplicate and opaque-over-opaque layers remain separate. +- Hidden and zero-opacity paints do not contribute synthesized layers. + +Prefer native gradient geometry over re-deriving it. Complete CSS stacks are paired with native +paints before changing colors. Matching accounts for omitted transparent paints, retained +provably transparent layers, and the four positioned linear-gradient quadrants Figma emits for +a diamond. If ownership is ambiguous, preserve native channels rather than assigning a neighbor's +geometry or clearing a separate base color. + +For a matched gradient, replace stop colors while retaining its function, angle/center, stop +positions, and trailing position/size/repeat syntax. Figma's angular-gradient closure stops retain +their native colors; only matching authored stops are replaced. Variable-bound stop alpha already +belongs to the variable, so independent paint opacity is applied once through `color-mix`. +Literal stops combine their own alpha with paint opacity. + +Paint-style caches contain raw paints and size-independent facts. Resolve gradient strings using +the current node dimensions, not a string cached for a differently sized consumer. + +## Image and mixed backgrounds + +Image mixing is implemented by preserving Figma's existing CSS stack, not by synthesizing image +geometry from the solid/gradient resolver. A value containing `url(...)` must not be classified as +a scalar color just because another layer contains `var(...)`. + +Normalize Figma's synthetic `lightgray` fallback only as a standalone token in a URL layer. Do not +remove it from a URL, quoted content, or an unrelated color layer, and do not turn it into an +authored fill. Recover a safe bottom paint separately when available. A single cleaned image +shorthand can be split into image, size, repeat and position; a multi-layer shorthand retains +its coupled geometry. Crop positions may be negative, and sizes may contain two values. + +The MCP [background cleanup](../../packages/extension/mcp/tools/code/styles/background.ts) +repairs matching gradient opacity within mixed stacks without dropping image layers or changing +their positions. The [media path](../../packages/extension/mcp/tools/code/assets/media.ts) then +replaces URLs with exported assets: + +- Retain each visible media paint's slot, including a failed image export, so later images do not + shift into the wrong layer. +- Match complete media stacks, or complete image-only CSS when Figma omits video URLs. Repeated + URLs may reuse a single paint. Do not infer the geometry of a partial multi-paint stack. +- A partial stack may reuse an asset only when every candidate identifies the same native image + and exported bytes. This does not justify reusing a candidate's Tile dimensions. +- Native image bytes retain their Figma hash. Unavailable bytes and videos use a composited node + preview with the corresponding native identities; failed delivery falls back to a placeholder. + A preview is not the original video or an isolated copy of each paint. +- Exact Tile periods use native image dimensions multiplied by that paint's scale when ownership + is known. Optional dimension reads are bounded and cached; unavailable dimensions retain native + CSS. See the [request budget](./mcp-get-code-design.md) for their lifecycle and the + [asset strategy](./mcp-get-code-design.md#svg-and-asset-strategy) for identity. + +Unexpanded shorthands and their longhand overrides stay together through serialization, preserving +order and quoted URLs. See [Tailwind output](./mcp-get-code-design.md#tailwind-output); CSS-valid +intermediate values alone are insufficient if the emitted utility is invalid or reorders overrides. + +## Shared rules and caller policy + +UI codegen and MCP share paint source, visibility, ordering and geometry helpers. They deliberately +retain different variable-output contracts; see +[variable output semantics](./mcp-get-code-design.md#variable-output-semantics). A style-name variable +may replace only a safely scalar style, not an entire layered background or size-sensitive gradient. + +The UI resolver repairs existing background channels. MCP additionally injects missing representable +backgrounds for non-text nodes through its cleanup path. Neither path should synthesize a partially +merged image stack when CSS does not identify its geometry. + +Scalar channels inspect the frontmost renderable paint without looking through a covering paint of +another type. An opaque normal front paint can supply a scalar value; a translucent or blended +multi-paint stack retains the native scalar CSS instead. This rule is separate from preserving all +layers of a background. + +## Verification ownership + +The owning regressions cover the boundaries above: + +- [Gradient helpers](../../packages/extension/tests/utils/figma-style/gradient.test.ts) and + [style resolution](../../packages/extension/tests/utils/figma-style/style-resolver.test.ts): + source precedence, native order/binding indices, compact single fills, retained native channels, + transparent paints and scalar-channel behavior. +- [Background cleanup](../../packages/extension/tests/mcp/tools/code/styles/background.test.ts), + [variable backgrounds](../../packages/extension/tests/mcp/tools/code/background-variables-pipeline.test.ts) + and [gradient pipeline](../../packages/extension/tests/mcp/tools/code/gradient-background-pipeline.test.ts): + fallback-gray removal, variable base/overlay colors, opacity and native gradient geometry through + final code generation. +- [Media export](../../packages/extension/tests/mcp/tools/code/assets/media.test.ts) and + [background pipeline](../../packages/extension/tests/mcp/tools/code/background-pipeline.test.ts): + asset ordering and fallback, Crop/Tile geometry, bounded optional reads and serialized output. + +Follow [TESTING.md](../../TESTING.md) for required checks. Browser rendering and utility compilation +prove the fixtures' CSS behavior; they do not prove new native Figma behavior. When a change needs +new native evidence, follow the [authoring runbook](../testing/agent-authoring-evolution.md) rather +than inferring it from the fake model or old paint-order probe. diff --git a/docs/marketing-screenshots.md b/docs/marketing-screenshots.md index 987d6ebe..c725d225 100644 --- a/docs/marketing-screenshots.md +++ b/docs/marketing-screenshots.md @@ -136,6 +136,10 @@ Capture, compare, and promote accept the same `--group` or `--only` selection an The capture checks the selected agent, compares displayed commands against `AGENT_INTEGRATIONS` from the shared package, and verifies the declared purpose text. It also checks that the crop contains the complete dialog. Changing package versions, installation paths, skills, or dialog copy requires refreshing these images; do not rely on PNG dimensions alone to establish freshness. +Canvas-painted details cannot be verified by DOM assertions. The manifest's +`visualReview` requirements appear in every relevant comparison row; confirm those +pixels before promotion, including all four measurement labels in the measure scene. + Setup captures use `screenshots/dialog-capture.mjs` to isolate the real dialog on a transparent browser surface. The capture temporarily hides surrounding page content and the modal backdrop, omits the outside shadow, and restores those overrides in `finally`. It preserves the dialog’s native geometry, colors, text, and antialiased rounded corners. Keep the resulting RGBA PNG: do not flatten it against the gray overlay or a theme-specific background. Review all four corners on both light and dark backgrounds before promotion. This is capture-only styling; do not change the shipped dialog to fix screenshot composition. If setup content needs scrolling, declare separate views in the manifest with exact scroll targets and visible assertions. Do not expand the dialog using injected styles or silently crop out a skill command. Keep screenshots faithful to the shipped interface. diff --git a/docs/releasing.md b/docs/releasing.md index 14c8d7fc..f0db9089 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,15 +24,27 @@ were prepared before their source changes were committed. `.agents/plugins/marketplace.json`, and `.claude-plugin/marketplace.json`. Run `pnpm agent-plugin:check-installer` to verify marketplace routing and MCP discovery with the actual installer. Release MCP configuration must use `@tempad-dev/mcp@latest`. + CI runs `pnpm agent-plugin:check-version ` against committed source and target payloads, + requiring a stable `x.y.z` increase; package-root README and changelog edits alone are exempt. 3. Run the checks in [TESTING.md](../TESTING.md), then `pnpm format:check`, `pnpm build`, and `pnpm zip`. Ordinary build must not change tracked agent-plugin files. The extension archive is written to `packages/extension/.output/` with the version from the extension package. -4. Remove `packages/mcp-server/dist` and rebuild before packing. A working tree that has been - built repeatedly has been seen holding a stale hashed shared chunk even though `clean` is - configured, and packing from it ships both copies. Then run `npm pack` in `packages/mcp-server` - and inspect the tarball: the CLI, Hub, logging worker, exactly one bundled shared chunk, package metadata, and - both README languages must be present. Install it outside the - workspace and verify MCP initialization and tool discovery on a supported Node.js runtime. + ZIP packaging rejects checkout profiles; unset `TEMPAD_MCP_DEV_CHECKOUT` before packaging. + The build log prints the selected profile. +4. Run `pnpm mcp:prepare-release-dependencies` with network access to cache and verify the exact + frozen production dependency graph without running package scripts. A workspace install alone + may leave its offline verification metadata incomplete. Use the same pnpm cache for preparation + and packaging; the temporary deployment is removed and no release artifact is created. + Prepare the reviewed MCP policy below, then run + `pnpm mcp:prepare-release --output --verify-baseline`. + It uses an offline, script-free hoisted deploy from the frozen workspace lock, verifies the + complete dependency closure, and probes the packaged runtime outside the workspace. + Plain workspace `npm pack` can include pnpm store paths outside the package and is not a + publishable runtime. Inspect the verified tarball and its `.tgz.json` manifest/integrity receipt. + Check MCP initialization and tool discovery on supported Node.js versions. + Run `node packages/mcp-server/scripts/check-bridge-compatibility.mjs ` + against the unpacked archive to verify the frozen extension contracts using the exact shipped + Hub and CLI; the publication workflow repeats this check before publishing. Run `node packages/mcp-server/scripts/check-log-transport.mjs ` from the checkout to check the shipped logging worker with its installed dependencies. Use separate runtime, state, log, and asset directories for the check so an active local Hub is @@ -56,10 +68,22 @@ were prepared before their source changes were committed. ## Bridge protocol and release order -Every setup resolves `npx -y @tempad-dev/mcp@latest` on each launch, so moving the npm `latest` -tag upgrades the MCP server of every existing installation, while the extension waits for Chrome -Web Store review and staged rollout. Store publication does not establish that existing users -have installed the update or reloaded their running extension and Figma tab. +Release setup launches `npx -y @tempad-dev/mcp@latest`. Moving the npm `latest` tag affects +subsequent launches. Before MCP 1.0.0, running CLI and Hub processes do not update themselves; +a newly launched CLI reuses an existing Hub, and an older CLI can restart its own packaged Hub. +Migrating that runtime requires disconnecting all TemPad MCP clients once before restarting +their MCP servers. New clients defer enrollment while that legacy Hub remains alive and keep +using its byte bridge; ordinary MCP startup must not fail merely because older work is ongoing. +The extension separately waits for Chrome Web Store review and staged rollout. Neither npm nor +store publication establishes that users are running the update or have reloaded Figma. + +Verified MCP 1.0.0 releases default to stable runtime updates on macOS, Linux and Windows. Agent Plugin +1.0.0 and shared installation snippets also select `--runtime-updates=stable` explicitly. +Publish the verified MCP release before shipping these plugin targets or setup instructions. +Checkout and unverified builds never acquire release updates. Compatible Hub updates activate for initialized MCP sessions +with negotiated waiting support at an idle boundary. Claude additionally requires a verified +session with receipt/retry-capable hooks; `off` and +`pinned` policies remain available. See the [runtime design](engineering/mcp-runtime-updates.md). | Hub change | Release requirement | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | @@ -130,6 +154,17 @@ change after verifying the supported old-extension setup and runtime paths. The Agent Plugin follows `main` and launches the npm `latest` tag. Make the required stable MCP version available before exposing the new plugin on `main`. +For the initial MCP 1.0.0 rollout, the reviewed branch already contains the dependent plugin. +Keep its release PR in draft while the candidate passes the build, rewrite, Windows runtime, +and installed-archive checks. A release maintainer can then publish the exact verified MCP +tarball from an authenticated environment with +`npm publish --ignore-scripts --access public --tag latest`. +Verify the registry version, dist-tag, and integrity against its `.tgz.json` receipt before merging +the candidate to `main`. This one-time bootstrap preserves the reviewed commit history and avoids +exposing the plugin early; it does not relax any artifact or compatibility check. Subsequent +releases use the main-only publication workflow below, with dependent plugin changes held back +until their runtime is available. + 1. Prepare the Chrome Web Store submission using the archive from the checked candidate. Retain its SHA-256 digest and the exact source revision. 2. Resolve the bridge migration requirement above and the installed-host feedback acceptance @@ -138,10 +173,11 @@ version available before exposing the new plugin on `main`. sufficient. The current Codex findings are tracked in the [IPC report](engineering/codex-desktop-ipc.md#original-installed-host-findings), including the later acceptance evidence preceding the original findings. -3. Merge the checked MCP runtime change to `main` before dispatching `publish-mcp.yml` +3. For later runtime releases, merge the checked MCP change to `main` before dispatching `publish-mcp.yml` with `tag=latest`; publication workflows reject other branches. Keep plugin changes that - require that runtime in a separate follow-up until publication is verified. Its `prepublishOnly` hook rebuilds the - package before npm publication. Confirm that both the exact package version and + require that runtime in a separate follow-up until publication is verified. The workflow publishes + only the verified tarball with scripts disabled; directory publication is rejected by + `prepublishOnly`. Confirm the published integrity, exact package version, and `npm view @tempad-dev/mcp@latest version` match the checked package manifest. A `next` publication may be used for isolated candidate testing; it neither completes the migration nor changes release plugin configurations away from `latest`. @@ -156,3 +192,55 @@ version available before exposing the new plugin on `main`. Use the [plugin upgrade guide](../agent-plugin/src/README.md#upgrading) for existing installations. It covers the Node.js requirement, extension reload, plugin/skill update, stable MCP configuration, client reconnection, and target-tab selection. + +## Runtime release policy + +The release maintainer owns `packages/mcp-server/runtime-policy.json`. PRs identify guidance +impact in review; release preparation aggregates it into one decision, rationale, candidate +version, and current stable baseline version/integrity. Review the cumulative set of shipped +instructions and descriptions sharing the epoch, including behavior changes with unchanged +text. Keep the epoch only if that guidance remains valid; otherwise increase it. Never reuse +an earlier epoch after reverting text. Hash equality cannot establish semantic compatibility. + +After review and final source formatting, obtain the candidate hash with +`node packages/mcp-server/scripts/prepare-runtime-release.mjs --source-hash` and record it in +`guidanceReview.sourceHash`. Any covered source/contract/lock change invalidates the review. +The packager derives wire/presentation and file hashes from the built runtime, checks compiled +control/extension protocol declarations, validates the complete dependency closure and Node +requirements, and verifies the baseline against npm `latest`. Missing historical manifests are +allowed only for the first epoch; that release requires a coordinated restart. CI archives the +exact tarball and manifest receipt, then confirms npm serves that same integrity. + +Each same-epoch release promises compatibility with all earlier presentations in that epoch, +including skipped releases. Session wire hashes and epochs must match, state must remain readable +in both directions, and the released extension adapters must remain available. The current control +protocol is 2; unpublished protocol 1 fixtures must restart in a new isolated runtime root. +Stable 0.11.0 predates managed control, so this does not retire a released protocol. Retirement +of a released protocol is blocked by the packager and needs a separate migration +review: retain the current and previous stable generations and at least 90 days after a successor +becomes stable. Breaking boundaries are 0.x minor changes, 1.0, and later major changes. + +Explicit switches require interactive `--update-runtime ` confirmation of that +artifact's version and integrity; approval never bypasses compatibility or attached-client policy. +Enrolled, initialized MCP sessions may automatically activate a compatible stable candidate within the +same breaking-release boundary after five idle seconds. All edges must acknowledge bounded +pre-dispatch waiting; old edges and unverified or legacy Claude hooks keep candidates staged. Automatic +shutdown rechecks readiness and defers immediately if work arrives, without waiting through a +long drain or killing work. Verified releases default to this stable policy; generated plugin +arguments and shared installation snippets make it explicit with `--runtime-updates=stable`. +Use `--runtime-updates=off` to disable acquisition and automatic switching, or `pinned` to enforce +the installed artifact. Checkout configurations must never carry release enrollment. Publish +MCP 1.0.0 before Agent Plugin 1.0.0 and the updated installation guidance. An old cached CLI +may ignore the flag; that does not prove enrollment, which requires the runtime's status evidence. + +Publish the compatible hook receiver before Agent Plugin 1.0.0's receipt/retry payload. Current +bindings must demonstrate v2 capability; plugin version or elapsed rollout time is not evidence. +Legacy/unknown Claude bindings defer switching. Retain the legacy 750 ms hook path and downgrade +only on an explicit unsupported-version reply. The acquisition fixture and Phase 2 fault matrix +are publication checks, but do not replace Phase 4 installed Codex/Claude acceptance. + +The publish workflow requires the [Windows runtime workflow](../.github/workflows/windows-runtime.yml) +to pass all Node 22 and 24 suites at the same commit before publishing a manifest that includes +`win32`. It exercises real ACL checks, named pipes and processes across acquisition, switching and +recovery; it does not establish Windows desktop-host acceptance. Preserve its logs with the release +evidence. diff --git a/docs/security/local-mcp-threat-model.md b/docs/security/local-mcp-threat-model.md index 4447e78a..da2921f6 100644 --- a/docs/security/local-mcp-threat-model.md +++ b/docs/security/local-mcp-threat-model.md @@ -137,8 +137,9 @@ authentication are outside the current protocol; Origin checks must not be descr `sandbox="allow-scripts"` iframe, verifies the exact frame window and opaque `null` Origin, and then transfers a private `MessageChannel`. - The broker accepts only two fixed packaged Worker kinds. Requests and responses are limited to - plain structured data, 4 MiB, depth 64, and 100,000 entries. It caps four active Workers and 32 - queued requests and normalizes error messages to 2,048 characters. + plain structured data, 4 MiB, depth 64, and 100,000 entries. These budgets include enumerable + array properties and count shared references along every traversal path. It caps four active + Workers and 32 queued requests and normalizes error messages to 2,048 characters. - Codegen and variable transforms use a fresh Worker for every broker request. MCP component generation may put up to 32 same-plugin jobs in one request to amortize startup; module state is shared only within that batch. Each Worker is terminated after success, failure, unreadable diff --git a/docs/testing/agent-authoring-evolution.md b/docs/testing/agent-authoring-evolution.md index 63ee798c..af1f04b5 100644 --- a/docs/testing/agent-authoring-evolution.md +++ b/docs/testing/agent-authoring-evolution.md @@ -14,26 +14,31 @@ replace judgment and must not grow into a second product-quality rubric. ## Operational tools -Run the live evolution workflow through CLI commands, native task-management -tools, and TemPad MCP. The development-plugin replacement script below is the -sole CDP exception: it operates Codex App’s plugin installation UI. Do not use -computer-use or browser-use tools, CDP, accessibility automation, or browser-driving -scripts for Figma authoring, feedback acceptance, or other live app operations. +Use CLI commands, native task-management tools, and TemPad MCP for runtime +inspection and authoring. When authorized, invoke the repository's detached +CDP installer directly for Codex plugin replacement; this does not require +acquiring Codex as a computer-use app. Use computer use for Chrome extension +reload, Figma reload, and MCP activation. Prefer native Chrome controls: browser +instrumentation has interfered with the code-output plugin's HTML sandbox. - Build, install, inspect processes/logs, and run preflight through the CLI. - Create, inspect, and wait for native evaluation tasks through the host's task-management tools. - Inspect and change Figma through TemPad MCP; use `get_screenshot` and open the returned image for visual review. +- Observe or exercise feedback controls through supported native tools or + authorized UI automation. Record which path was used; MCP state alone does + not prove that a visual interaction worked. - Research through web/image search, direct source retrieval, and supplied references. Preserve the distinction between retrieved text and visual evidence; do not claim to have experienced an interface from text alone. -If a required extension reload, tab reload, or task reconnection has no -supported CLI or native API, request that specific manual action and resume -with preflight and a native MCP read. Do not substitute UI automation or -dispatch against an unverified runtime. This boundary applies to live app -operation; isolated repository browser regression tests still follow `TESTING.md`. +After any refresh, require preflight and a native MCP read before dispatch. +A broken UI codegen sandbox does not establish that independent MCP tools are +broken, or vice versa: +report the affected lane separately. Codegen-plugin acceptance needs a clean +page without the interfering instrumentation. Isolated browser regression +tests still follow `TESTING.md`. ## Why this process exists @@ -283,14 +288,31 @@ remains readable but is not required. Before every live dispatch: +For native runtime-switch acceptance only, the explicit +[local release-test fixture](../../TESTING.md#native-runtime-switch-acceptance) replaces the +usual checkout launch below. The generated dev plugin records its private descriptor; +preflight verifies that configuration and the active immutable Hub rather than assuming +`dist/hub.mjs`. Keep the same native installation and read-back requirements, and restore the +ordinary checkout plugin and extension after the probe. This fixture never fetches online releases. + 1. Build the affected packages when the change needs them and generate the development plugin when a generator input changed: ```sh - pnpm build + TEMPAD_MCP_DEV_CHECKOUT="$PWD" pnpm build pnpm agent-plugin:build ``` + The explicit checkout identity is required for a production-mode extension candidate. + Without it, `pnpm build` produces a release extension, which connects only to the release + Hub. `pnpm dev` selects the checkout automatically. Both use the same checkout hash as + the generated development plugin; checkout discovery never falls back to release ports. + + When upgrading from the shared development runtime, restart the old dev Hub once to release + its release discovery port. Existing dev task state and assets remain in the release stores; + they are not migrated. Checkout state now defaults to `~/.tempad-dev/checkouts//state`, + and previously returned dev asset references no longer resolve through the new checkout Hub. + 2. When generated plugin content changed, replace `tempad-dev-dev` **in the running Codex App** using the CDP script: @@ -299,9 +321,12 @@ Before every live dispatch: ``` The script validates that the configured local marketplace points at this checkout, - selects one Codex App page, uninstalls the development plugin in its UI, waits for - this checkout's old MCP processes to stop, then installs the generated version in - the App. It checks both the visible installed state and the enabled version on disk. + selects one Codex App page, uninstalls the development plugin in its UI, stops + this checkout's CLI/Hub processes and requires an empty process list for + 1.5 seconds before installing the generated version in the App. It checks both + the visible installed state and + the enabled version on disk. Uninstall is also checked on disk; a respawning + checkout process aborts installation instead of being killed repeatedly. CLI `plugin add` alone changes installation on disk; it does **not** establish that the running App refreshed its plugin state, skills, tools, or MCP connections. The script uses the bundled CLI only for read-only source/version checks. @@ -314,15 +339,29 @@ Before every live dispatch: When CDP is unavailable, explicitly authorize App restart and use: ```sh - pnpm agent-plugin:reinstall --restart-codex --cdp-url http://127.0.0.1:9222 + pnpm agent-plugin:reinstall --restart-codex \ + --resume-thread "$CODEX_THREAD_ID" --cdp-url http://127.0.0.1:9222 ``` - Restart can interrupt other Codex tasks. The macOS script runs the recovery in - a detached launchctl job, quits only the configured App, relaunches it with a - loopback debugging port, and continues UI installation. It prints a log path - under `.dev/`; inspect the final log after reconnecting. Scheduling the job is - not installation success. Do not start a second reinstall while it is running. - `--resume-after-restart` is an internal continuation flag, not a normal command. + Restart can interrupt other Codex tasks. Capture the original task UUID before + quitting; the restart flag defaults to `CODEX_THREAD_ID`, while an external + terminal must pass the captured UUID explicitly. The script saves that ID, + checkout, expected version, phase, and log path under `.dev/` before scheduling + an independent launchctl job. That job quits only the configured App, + relaunches it with loopback CDP, uninstalls, verifies runtime shutdown, and + reinstalls without needing another agent tool call. It then loads the saved + local task route and sends one visible continuation through its composer. + It preserves existing drafts and does not send into an already-running task. + + Inspect the saved report and log after reconnecting. Scheduling is not + success, and isolated Chromium fixtures do not prove compatibility with the + installed App. A report left at `resume-dispatching`, or a later failure, + can mean the message was sent without acknowledgement: inspect the task + before retrying. Saved reports cannot be replayed. Do not start a second job + while one is running. `--resume-after-restart` and `--report-path` are internal + continuation arguments, not normal commands. If installation fails after + attaching to the App, recovery attempts to return to the original task with + the failure information rather than claiming success. App installation success still does not prove that the current task transport is ready. Use a new task when the host cannot refresh its existing tools or connection; @@ -330,9 +369,20 @@ Before every live dispatch: successful native MCP read. A fresh task alone may reuse an old Hub. Do not launch another app-server or helper MCP client to stand in for the App under evaluation. -3. When extension runtime code changed, reload it and the intended Figma tab through - a supported native interface, or request the manual reload under the - operational-tools rule above. Keep one intended live Figma tab and file active. +3. Reload the extension and intended Figma tab when their runtime changed: + - In Chrome's existing extension manager, open **TemPad Dev → Details** and + verify its ID and **Loaded from** directory against the candidate output. + A production-mode checkout commonly uses `.output/chrome-mv3`; do not + assume `.output/chrome-mv3-dev` is the installed directory. + - Click that extension's **Reload** and confirm Chrome reports **Reloaded**. + Switch to the existing fixture tab and reload the full page, then wait for + Figma and the TemPad panel to load. Do not duplicate the file in another tab. + - Activate the **MCP** button in the **TemPad Dev** panel, not Figma's own MCP + setup control. The active badge selects the target; browser focus does not. + - Verify the source fingerprint and connected file below. An enabled extension, + a successful click, or a cached page is not evidence of a current connection. + Use computer use for this sequence when permitted, or request only the + unavailable manual action. Keep one intended live Figma tab and file active. 4. Confirm the extension is connected to the same checkout's Hub, then make a read-only native MCP call such as `list_design_sessions` through the evaluator task's own connection. A connected extension badge or another @@ -346,7 +396,7 @@ Before every live dispatch: --log /absolute/path/to/authoring-runs.jsonl ``` -The start command rejects stale or partial checkout processes, multiple Hubs, +The start command rejects an absent or partial checkout runtime, multiple Hubs, an inactive or mismatched extension, and an installed development-plugin version different from the generated cachebuster. It stops before page creation on failure. diff --git a/docs/testing/architecture.md b/docs/testing/architecture.md index e03404d7..7c233fcc 100644 --- a/docs/testing/architecture.md +++ b/docs/testing/architecture.md @@ -29,9 +29,9 @@ For contributor workflow and commands, see `TESTING.md`. - Coverage provider: `istanbul`. - Build artifacts are excluded from coverage (`**/dist/**`, `**/.output/**`). -- Shared schemas and the plugin SDK retain their package-owned 100% thresholds. Shared installation - and response helpers are also reported; they participate in the root aggregate baseline rather - than being omitted from the shared report. +- Shared schemas and the plugin SDK enforce the same per-file 100% thresholds in package and + workspace coverage runs. Shared installation and response helpers are also reported but only + participate in the root aggregate baseline. - Root, extension, and MCP report all TypeScript runtime modules in their node source scope, including untested modules. The shared agent/Hub runtime-path JavaScript helper also participates in the root and MCP reports and baseline. Their existing baseline groups retain the thresholds in @@ -40,9 +40,9 @@ For contributor workflow and commands, see `TESTING.md`. behavioral coverage; do not remove baseline files or lower its floor to pass a change. - Every extension MCP module and Hub runtime module also has a per-file floor of 50% for lines/statements/functions and 40% for branches. Broad source globs include new modules - automatically, so aggregate coverage cannot hide a completely untested core file. The CLI and - Hub launchers remain reported but are excluded from this floor because their integration tests - run in child processes outside coverage instrumentation. These minimums supplement the existing + automatically, so aggregate coverage cannot hide a completely untested core file. The CLI, + Hub, isolated/managed runtime, and acquisition worker entry launchers remain reported but are excluded from this floor + because their integration tests run in child processes outside coverage instrumentation. These minimums supplement the existing baseline; meeting either gate is not evidence that every behavior is correct. - Browser behavior is verified separately in the browser suite. Browser-facing TypeScript still appears in the node coverage report; that report does not measure Vue templates or native host behavior. diff --git a/package.json b/package.json index aeb748e8..2587970e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,15 @@ "build:plugins": "pnpm --filter @tempad-dev/plugins build", "build:mcp": "pnpm --filter @tempad-dev/mcp build", "mcp:check-bridge": "pnpm --filter @tempad-dev/shared build && pnpm --filter @tempad-dev/mcp check:bridge-compatibility", + "mcp:check-profiles": "pnpm --filter @tempad-dev/shared build && pnpm build:mcp && node packages/mcp-server/scripts/check-runtime-profiles.mjs", + "mcp:check-host-context": "pnpm --filter @tempad-dev/shared build && pnpm build:mcp && node packages/mcp-server/scripts/check-host-context.mjs", + "mcp:check-runtime-prototype": "pnpm --filter @tempad-dev/shared build && pnpm --filter @tempad-dev/mcp exec tsdown --config tsdown.prototype.config.ts && node packages/mcp-server/scripts/check-runtime-prototype.mjs", + "mcp:check-runtime-switch": "pnpm --filter @tempad-dev/shared build && node packages/mcp-server/scripts/check-runtime-switch.mjs", + "mcp:prepare-release-dependencies": "node packages/mcp-server/scripts/prepare-runtime-dependencies.mjs", + "mcp:prepare-release": "pnpm --filter @tempad-dev/shared build && pnpm build:mcp && node packages/mcp-server/scripts/prepare-runtime-release.mjs", + "mcp:check-runtime-updates": "pnpm --filter @tempad-dev/shared build && pnpm build:mcp && node packages/mcp-server/scripts/check-runtime-updates.mjs", + "mcp:measure-runtime": "node packages/mcp-server/scripts/measure-runtime.mjs", + "mcp:measure-hooks": "node packages/mcp-server/scripts/measure-hooks.mjs", "screenshots": "pnpm --filter @tempad-dev/extension screenshots", "test": "pnpm -r --parallel --if-present test", "test:run": "pnpm -r --if-present test:run", @@ -22,6 +31,7 @@ "lint:root:fix": "eslint . --fix --ignore-pattern \"packages/**\"", "agent-plugin:build": "node scripts/build-agent-plugin.mjs", "agent-plugin:check-installer": "node scripts/check-agent-plugin-installer.mjs", + "agent-plugin:check-version": "node scripts/check-agent-plugin-version.mjs", "agent-plugin:reinstall": "pnpm --filter @tempad-dev/extension codex-plugin:reinstall", "agent-eval:authoring": "pnpm --filter @tempad-dev/extension agent-eval:authoring", "agent-eval:log": "pnpm --filter @tempad-dev/extension agent-eval:log", diff --git a/packages/extension/CHANGELOG.md b/packages/extension/CHANGELOG.md index 65f1ebd1..f5572ce4 100644 --- a/packages/extension/CHANGELOG.md +++ b/packages/extension/CHANGELOG.md @@ -1,35 +1,70 @@ # Changelog -## Unreleased - -- Improved CSS exports for layered fills, stacking and blending, mixed fonts, variable opacity, - and SVG sizing and placement. -- Fixed canvas updates involving components, grids, fonts, and variable modes. Invalid resource - changes are rejected earlier, with clearer errors when failed edits cannot be fully restored. -- Improved responsiveness when reading large designs and exporting screenshots. -- Fixed blank images with the extension enabled and improved Figma script loading and recovery. -- Kept the selected Figma file active across reconnects and extension background restarts. +## 1.0.0 (unreleased) + +### Code export + +- Fixed layered image backgrounds with variable colors, gradient opacity and stop positions, + Crop positioning, Tile sizing, and background repeat utilities. +- Fixed cropped or misplaced SVGs and rotated vectors, stretched content-sized frames, and + absolute children positioned outside their parent. Preserved stacking, blending, and CSS + properties without a dedicated Tailwind utility. +- Preserved literal JSX/Vue text, HTML/SVG attributes, token names, URLs, and font names during + escaping, unit conversion, and token processing. +- Fixed mixed-font output, paragraph spacing and indentation, nested lists, soft line breaks, + and trailing empty or whitespace-only paragraphs. +- Fixed token resolution for different layers and variable modes, including near-opaque colors. + Added compatibility warnings for token-based font weight and opacity. + +### Canvas editing + +- Fixed partial text updates clearing neighboring variable bindings or skipping unbound paragraphs. + Corrected interactions between native text styles, line breaks, and explicit binding removal. +- Fixed component and Slot updates, grid settings, variable modes, SVG imports, and independent + Noise/Texture dimensions. Preserved valid resource and node keys during validation. +- Rejected invalid resources and ownership conflicts before editing. Detected silently ignored + writes and reported incomplete rollback or unsupported text bindings instead of claiming success. +- Preserved supplied text when decoding entities; unsupported entity names now produce warnings. + +### Tasks and interface + - Stop now cancels immediately and discards unsent comments, including during disconnection. -- Preserved feedback drafts after failed delivery and added a reload action for conflicting - comments saved in another tab. -- Fixed stale selection code, incorrect unit conversions, and misleading copy or plugin-install - success messages. Added code-generation progress and Retry. Improved keyboard access. -- Preserved independent preferences across tabs and allowed plugin deletion when storage is full. -- Added Refresh links and documentation links for component sets. + Fixed repeated cleanup errors after cancellation. +- Preserved feedback drafts after failed delivery and added a reload action for comments changed + in another tab. Clicking outside a nonempty comment editor first keeps it open with a shake. +- Preserved preferences and plugin changes across tabs, added recovery for malformed saved plugins, + and avoided clearing code when an unrelated plugin changes. +- Added code-generation progress and Retry, plus refresh and documentation links for component sets. + Copy and plugin installation no longer report success after failure; copy controls support keyboard use. +- Restored selection and measurement tools after switching windows with keys held, and preserved + panel resizing when pointer capture is lost. + +### Performance and reliability + +- Kept development MCP connections on separate ports from installed releases. +- Shared node CSS reads between plugin preparation and MCP export, reducing two reads to one on + that path. Reused variable and plugin results within each request and skipped unrelated text ranges. +- Fixed traversal failures on very wide designs, with regression coverage for 150,000 sibling nodes. + Stopped further work at export deadlines while preserving completed results. +- Prevented optional image-size lookups from holding up an export; fall back to Figma's CSS dimensions. +- Fixed blank images with the extension enabled and improved Figma script loading and failure recovery. +- Kept the active Figma file across reconnects and background restarts; reconnected after Chrome + local-host permission changes and rejected stale connections without disturbing replacements. +- Hardened plugin isolation and payload limits, and kept asset access credentials out of export logs. ## 0.23.4 - Simplified the dynamic script interceptor to use packaged matching rules directly, preserving Rspack chunk loading and existing failure recovery. -## 0.23.3 +## 0.23.3 (not released) - Removed rewrite hot updates and the remote JSON endpoint. Use packaged rules and `figma.js` for both static scripts and dynamic chunks, and clear persisted rules from older versions. - Preserve clipped SVG placement and fractional viewport dimensions, including Boolean assets, rotated vectors, and inline fallbacks. Keep freeform clipping frames out of inferred flex layout. -## 0.23.2 +## 0.23.2 (not released) - Fixed radial gradient centers and radii in generated styles, preserving native Figma geometry. - Fixed SVG export bounds to match generated node dimensions and preserved SVG attribute casing diff --git a/packages/extension/build/plugin-sandbox-workers.ts b/packages/extension/build/plugin-sandbox-workers.ts index 70175797..610f72ee 100644 --- a/packages/extension/build/plugin-sandbox-workers.ts +++ b/packages/extension/build/plugin-sandbox-workers.ts @@ -4,6 +4,8 @@ import { build } from 'esbuild' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { workspaceSources } from './workspace-sources' + const EXTENSION_ROOT = fileURLToPath(new URL('../', import.meta.url)) const SANDBOX_WORKER_QUERY = '?sandbox-worker' const VIRTUAL_WORKER_PREFIX = '\0tempad-plugin-sandbox-worker:' @@ -13,6 +15,7 @@ export async function bundleSandboxWorker( ): Promise<{ code: string; inputs: string[] }> { const result = await build({ absWorkingDir: EXTENSION_ROOT, + alias: workspaceSources, bundle: true, define: { __DEV__: 'true' }, entryPoints: [entry], diff --git a/packages/extension/build/readme.ts b/packages/extension/build/readme.ts index 3715f15a..5c64f2a0 100644 --- a/packages/extension/build/readme.ts +++ b/packages/extension/build/readme.ts @@ -76,7 +76,7 @@ ${plugins : `${vendor.name} [${vendor.name}](${repo})` : '' - return `| \`@${name}\` | ${description} | [${author}](https://github.com/${author}) | ${link} |` + return `| \`@${name}\` | ${description} | [${author}](https://github.com/${author.replace(/^@/, '')}) | ${link} |` }) .join('\n')}` } diff --git a/packages/extension/build/workspace-sources.ts b/packages/extension/build/workspace-sources.ts new file mode 100644 index 00000000..fb74ce50 --- /dev/null +++ b/packages/extension/build/workspace-sources.ts @@ -0,0 +1,6 @@ +import { fileURLToPath } from 'node:url' + +// Internal builds and tests consume current SDK source without changing its public package exports. +export const workspaceSources = { + '@tempad-dev/plugins': fileURLToPath(new URL('../../plugins/src/index.ts', import.meta.url)) +} diff --git a/packages/extension/components/Button.vue b/packages/extension/components/Button.vue index 4b8900c2..85018984 100644 --- a/packages/extension/components/Button.vue +++ b/packages/extension/components/Button.vue @@ -70,13 +70,13 @@ withDefaults( outline-color: var(--color-border-onbrand-strong); } -.tp-action-button-secondary:disabled { +.tp-action-button-secondary:is(:disabled, [aria-disabled='true']) { background: transparent; color: var(--color-text-disabled); outline-color: var(--color-border-disabled); } -.tp-action-button-primary:disabled { +.tp-action-button-primary:is(:disabled, [aria-disabled='true']) { background: var(--color-bg-disabled); color: var(--color-text-ondisabled); } diff --git a/packages/extension/components/Code.vue b/packages/extension/components/Code.vue index 0e7842c5..8064b9b9 100644 --- a/packages/extension/components/Code.vue +++ b/packages/extension/components/Code.vue @@ -1,6 +1,6 @@ + diff --git a/packages/extension/components/sections/CodeSection.vue b/packages/extension/components/sections/CodeSection.vue index efa9420f..463eeb21 100644 --- a/packages/extension/components/sections/CodeSection.vue +++ b/packages/extension/components/sections/CodeSection.vue @@ -9,7 +9,14 @@ import Code from '@/components/Code.vue' import IconButton from '@/components/IconButton.vue' import Preview from '@/components/icons/Preview.vue' import Section from '@/components/Section.vue' -import { selection, selectedNode, options, selectedTemPadComponent, activePlugin } from '@/ui/state' +import { + selection, + selectionRevision, + selectedNode, + options, + selectedTemPadComponent, + activePlugin +} from '@/ui/state' import { generateCodeBlocksForNode } from '@/utils/codegen' const componentCode = shallowRef('') @@ -17,9 +24,23 @@ const componentLink = shallowRef('') const codeBlocks = shallowRef([]) const codeError = shallowRef('') const loading = shallowRef(false) +const showLoading = shallowRef(false) const content = shallowRef(null) const loadingHeight = shallowRef(0) let generation = 0 +let loadingTimer: ReturnType | undefined +const pluginCode = computed(() => activePlugin.value?.code || undefined) +const generationInput = computed(() => ({ + node: selectedNode.value, + config: { + cssUnit: options.value.cssUnit, + rootFontSize: options.value.rootFontSize, + scale: options.value.scale + }, + pluginCode: pluginCode.value, + variableDisplay: options.value.variableDisplay +})) +let contentInput: typeof generationInput.value | null = null const playButtonTitle = computed(() => componentLink.value @@ -29,54 +50,72 @@ const playButtonTitle = computed(() => async function updateCode() { const current = ++generation - const node = selectedNode.value + const input = generationInput.value + const node = input.node + const retainCode = node !== null && input === contentInput && selection.value.length === 1 + clearTimeout(loadingTimer) + showLoading.value = false loadingHeight.value = content.value?.getBoundingClientRect().height ?? 0 loading.value = false - codeBlocks.value = [] - componentCode.value = '' - componentLink.value = '' + if (!retainCode) { + contentInput = null + codeBlocks.value = [] + componentCode.value = '' + componentLink.value = '' + } codeError.value = '' if (node == null || selection.value.length > 1) return - loading.value = true + if (!retainCode) { + loading.value = true + loadingTimer = setTimeout(() => (showLoading.value = true), 150) + } const tempadComponent = selectedTemPadComponent.value componentCode.value = tempadComponent?.code || '' componentLink.value = tempadComponent?.link || '' try { - const result = await generateCodeBlocksForNode( - node, - { - cssUnit: options.value.cssUnit, - rootFontSize: options.value.rootFontSize, - scale: options.value.scale - }, - activePlugin.value?.code || undefined, - { variableDisplay: options.value.variableDisplay } - ) - if (current === generation) codeBlocks.value = result.codeBlocks + const result = await generateCodeBlocksForNode(node, input.config, input.pluginCode, { + variableDisplay: input.variableDisplay + }) + if (current === generation) { + if ( + codeBlocks.value.length !== result.codeBlocks.length || + result.codeBlocks.some((block, index) => { + const previous = codeBlocks.value[index]! + return ( + block.name !== previous.name || + block.title !== previous.title || + block.lang !== previous.lang || + block.code !== previous.code + ) + }) + ) + codeBlocks.value = result.codeBlocks + contentInput = input + } } catch { - if (current === generation) codeError.value = 'Code generation failed.' + if (current === generation) { + contentInput = null + codeBlocks.value = [] + componentCode.value = '' + componentLink.value = '' + codeError.value = 'Code generation failed.' + } } finally { - if (current === generation) loading.value = false + if (current === generation) { + clearTimeout(loadingTimer) + showLoading.value = false + loading.value = false + } } } -watch( - [ - selection, - activePlugin, - () => options.value.cssUnit, - () => options.value.rootFontSize, - () => options.value.scale, - () => options.value.variableDisplay - ], - updateCode, - { immediate: true } -) +watch([selection, selectionRevision, generationInput], updateCode, { immediate: true }) onBeforeUnmount(() => { generation++ + clearTimeout(loadingTimer) }) function open() { @@ -98,45 +137,50 @@ function open() {
-

Generating code…

-

- {{ codeError }} -

- - - - +

{{ showLoading ? 'Generating code…' : '' }}

+
+

+ {{ codeError }} +

+ + + + +
diff --git a/packages/extension/composables/key-lock.ts b/packages/extension/composables/key-lock.ts index f925ab2c..65f211cd 100644 --- a/packages/extension/composables/key-lock.ts +++ b/packages/extension/composables/key-lock.ts @@ -1,11 +1,12 @@ import { useEventListener, useMutationObserver } from '@vueuse/core' -import { shallowRef, watch } from 'vue' +import { onScopeDispose, shallowRef, watch } from 'vue' import { layoutReady, options } from '@/ui/state' import { getCanvas, setLockAltKey, setLockMetaKey } from '@/utils' let spacePressed = false let altPressed = false +let windowActive = true let duplicateClass: string | null = null let classSnapshot = new Set() const cursorHost = shallowRef(null) @@ -30,14 +31,11 @@ function pause() { setLockAltKey(false) } -function isCanvasHovered() { - return !!canvas.value?.matches(':hover') +function canLockCanvas() { + return layoutReady.value && windowActive && !spacePressed && !!canvas.value?.matches(':hover') } function resume() { - if (spacePressed || !isCanvasHovered()) { - return - } syncMetaLock() syncAltLock() } @@ -51,7 +49,21 @@ function pauseDeepSelect() { } let resuming: ReturnType | null = null +let pointerPause: ReturnType | null = null + +function resetInteraction() { + spacePressed = false + altPressed = false + if (resuming != null) clearTimeout(resuming) + if (pointerPause != null) clearTimeout(pointerPause) + resuming = null + pointerPause = null + pause() + if (cursorHost.value) clearCursorCover(cursorHost.value) +} + function pauseMetaThenResume() { + if (!windowActive || !layoutReady.value) return if (resuming != null) { clearTimeout(resuming) } @@ -60,14 +72,12 @@ function pauseMetaThenResume() { resuming = setTimeout(() => { resuming = null - if (!spacePressed) { - syncMetaLock() - } + syncMetaLock() }, 200) } function keydown(e: KeyboardEvent) { - if (!layoutReady.value) { + if (!layoutReady.value || !windowActive) { return } if (!options.value.measureOn && e.key === 'Alt') { @@ -81,6 +91,7 @@ function keydown(e: KeyboardEvent) { if (!spacePressed && e.key === ' ') { spacePressed = true pause() + reconcileCursor() } } @@ -88,9 +99,6 @@ function keyup(e: KeyboardEvent) { if (!layoutReady.value) { return } - if (!options.value.measureOn && e.key === 'Alt') { - return - } if (altPressed && e.key === 'Alt') { altPressed = false syncAltLock() @@ -99,17 +107,16 @@ function keyup(e: KeyboardEvent) { if (spacePressed && e.key === ' ') { spacePressed = false resume() + reconcileCursor() } } function syncAltLock() { - const hovered = isCanvasHovered() - setLockAltKey(hovered && !altPressed && options.value.measureOn) + setLockAltKey(canLockCanvas() && !altPressed && options.value.measureOn) } function syncMetaLock() { - const hovered = isCanvasHovered() - setLockMetaKey(hovered && options.value.deepSelectOn) + setLockMetaKey(canLockCanvas() && options.value.deepSelectOn) } function isDuplicateCursor(host: HTMLElement) { @@ -140,7 +147,13 @@ function clearCursorCover(host: HTMLElement) { function reconcileCursor(host?: HTMLElement | null) { const target = host ?? cursorHost.value if (!target) return - if (!options.value.measureOn || altPressed) { + if ( + !layoutReady.value || + !windowActive || + !options.value.measureOn || + altPressed || + spacePressed + ) { clearCursorCover(target) classSnapshot = new Set(target.classList) return @@ -155,6 +168,32 @@ function reconcileCursor(host?: HTMLElement | null) { } export function useKeyLock() { + windowActive = document.visibilityState !== 'hidden' && document.hasFocus() + resetInteraction() + + function deactivate() { + windowActive = false + resetInteraction() + } + + function syncWindowActivity() { + windowActive = document.visibilityState !== 'hidden' && document.hasFocus() + if (!windowActive) { + resetInteraction() + return + } + resume() + reconcileCursor() + } + + function clearTargets() { + resetInteraction() + canvas.value = null + cursorHost.value = null + duplicateClass = null + classSnapshot = new Set() + } + function syncTargets() { canvas.value = getCanvas() cursorHost.value = canvas.value?.parentElement?.parentElement as HTMLElement | null @@ -176,12 +215,7 @@ export function useKeyLock() { reconcileCursor(cursorHost.value) return } - setLockMetaKey(false) - setLockAltKey(false) - canvas.value = null - cursorHost.value = null - duplicateClass = null - classSnapshot = new Set() + clearTargets() }, { immediate: true } ) @@ -190,12 +224,20 @@ export function useKeyLock() { useEventListener(canvas, 'mouseenter', resume) useEventListener(canvas, 'pointerdown', pauseMeasure, { capture: true }) useEventListener(canvas, 'pointerdown', () => { - setTimeout(pauseDeepSelect, 0) + if (pointerPause != null) clearTimeout(pointerPause) + pointerPause = setTimeout(() => { + pointerPause = null + pauseDeepSelect() + }, 0) }) useEventListener('pointerup', resume, { capture: true }) useEventListener(canvas, 'wheel', pauseMetaThenResume) useEventListener('keydown', keydown) useEventListener('keyup', keyup) + useEventListener('blur', deactivate) + useEventListener('focus', syncWindowActivity) + useEventListener(document, 'visibilitychange', syncWindowActivity) + onScopeDispose(clearTargets) useMutationObserver( cursorHost, @@ -214,6 +256,7 @@ export function useKeyLock() { watch( () => options.value.deepSelectOn, () => { + if (!options.value.deepSelectOn && !options.value.measureOn) resetInteraction() if (!layoutReady.value) return syncMetaLock() } @@ -222,6 +265,8 @@ export function useKeyLock() { watch( () => options.value.measureOn, () => { + if (!options.value.measureOn) altPressed = false + if (!options.value.deepSelectOn && !options.value.measureOn) resetInteraction() if (!layoutReady.value) return syncAltLock() reconcileCursor(cursorHost.value) diff --git a/packages/extension/composables/mcp.ts b/packages/extension/composables/mcp.ts index cced719d..325f1f73 100644 --- a/packages/extension/composables/mcp.ts +++ b/packages/extension/composables/mcp.ts @@ -37,7 +37,7 @@ import { import { getFeedbackDraftScope } from '@/mcp/design-feedback' import { PageDesignTasks } from '@/mcp/design-task' import { bytesToBase64 } from '@/mcp/encoding' -import { coerceToolErrorPayload, createCodedError } from '@/mcp/errors' +import { canvasAbortedError, coerceToolErrorPayload, createCodedError } from '@/mcp/errors' import { readFigmaSession } from '@/mcp/figma-session' import { getContainingPage, getNodeById } from '@/mcp/local-resources' import { MCP_LOCAL_HOST_PERMISSION_ERROR, MCP_PERMISSION_REQUEST_EVENT } from '@/mcp/permissions' @@ -87,7 +87,10 @@ export const useMcp = createSharedComposable(() => { string, PendingRequest & { local: boolean } >() - const pendingFeedbackDrafts = new Map>() + const pendingFeedbackDrafts = new Map< + string, + PendingRequest & { action: string } + >() const sentDesignActions = new Set() const designAnchor = shallowRef(null) let anchorRevision = 0 @@ -196,7 +199,11 @@ export const useMcp = createSharedComposable(() => { }) } rejectPending(pendingDesignActions, 'Agent disconnected.') - rejectPending(pendingFeedbackDrafts, 'Disconnected while saving.') + for (const [requestId, { action }] of pendingFeedbackDrafts) { + takePending(pendingFeedbackDrafts, requestId)?.reject( + new Error(`Disconnected while ${action.toLowerCase()}.`) + ) + } sentDesignActions.clear() rejectPending(pendingAssetUploads, 'MCP disabled before asset upload completed.') rejectPending(pendingAssetDownloads, 'MCP disabled before asset download completed.') @@ -329,6 +336,7 @@ export const useMcp = createSharedComposable(() => { let stopFeedback: (() => void) | undefined let result: unknown let error: ReturnType | undefined + let handlerStarted = false try { if (name === '__begin_design') { if (window.INITIAL_OPTIONS?.editor_type !== 'design') { @@ -359,13 +367,16 @@ export const useMcp = createSharedComposable(() => { taskGuard.task ? { ...input, page: { ...page, id: taskGuard.task.target.pageId } } : args + handlerStarted = true result = await runMcpTool(name, pinnedArgs) if (name === 'apply_canvas' && route?.taskId && input?.mode === 'create') await recordCreatedAnchor(result, route.taskId) } } } catch (caught: unknown) { - error = coerceToolErrorPayload(caught) + error = coerceToolErrorPayload( + name === 'apply_canvas' && !handlerStarted ? canvasAbortedError(caught) : caught + ) } finally { stopFeedback?.() finish?.() @@ -382,6 +393,7 @@ export const useMcp = createSharedComposable(() => { ...pageMessageBase, callId, error: { + code: TEMPAD_MCP_ERROR_CODES.EXTENSION_RESULT_LOST, message: 'The tool result could not be transferred. Inspect the canvas before retrying a write.' }, @@ -637,7 +649,7 @@ export const useMcp = createSharedComposable(() => { status: 'failed', message: action.action === 'stop' - ? 'Stop confirmation timed out.' + ? 'Stop acknowledgement timed out.' : action.action === 'done' ? 'Finishing the review timed out.' : 'Sending comments timed out.' @@ -699,13 +711,20 @@ export const useMcp = createSharedComposable(() => { const session = readFigmaSession(sessionId, taskGuard.busy) if (!enabled || session?.fileKey !== request.scope.fileKey) return Promise.reject(new Error('Comments are unavailable in this file.')) + const action = { + load: 'Loading comments', + clear: 'Clearing comments', + comment: 'Saving comments', + save: 'Saving comments', + remove: 'Removing comments' + }[request.operation] return new Promise((resolve, reject) => { const requestId = crypto.randomUUID() const timer = setTimeout(() => { pendingFeedbackDrafts.delete(requestId) - reject(new Error('Saving timed out.')) + reject(new Error(`${action} timed out.`)) }, 8000) - pendingFeedbackDrafts.set(requestId, { resolve, reject, timer }) + pendingFeedbackDrafts.set(requestId, { resolve, reject, timer, action }) try { postPageMessage({ ...pageMessageBase, type: 'mcp.feedbackDrafts', requestId, request }) } catch (error) { diff --git a/packages/extension/composables/plugin.ts b/packages/extension/composables/plugin.ts index 922ad1d2..c57fb515 100644 --- a/packages/extension/composables/plugin.ts +++ b/packages/extension/composables/plugin.ts @@ -1,4 +1,4 @@ -import { shallowRef } from 'vue' +import { onScopeDispose, shallowRef } from 'vue' import { readBoundedResponseBytes } from '@/mcp/bounded-response' import SNAPSHOT_PLUGINS from '@/plugins/available-plugins.json' @@ -78,6 +78,8 @@ export function usePluginInstall() { installing.value = false } + onScopeDispose(cancel, true) + async function install(src: string) { if (installing.value) { return null diff --git a/packages/extension/composables/scrollbar.ts b/packages/extension/composables/scrollbar.ts index e36a2d21..acc3adcb 100644 --- a/packages/extension/composables/scrollbar.ts +++ b/packages/extension/composables/scrollbar.ts @@ -24,8 +24,6 @@ export function useScrollbar( container: MaybeRefOrGetter, scrollOptions: Parameters[1] ) { - let os: OverlayScrollbars | null = null - watchEffect( (onCleanup) => { const el = toValue(container) @@ -33,10 +31,10 @@ export function useScrollbar( return } - os = OverlayScrollbars(el, scrollOptions) + const os = OverlayScrollbars(el, scrollOptions) onCleanup(() => { - os?.destroy() + os.destroy() }) }, { flush: 'post' } diff --git a/packages/extension/composables/selection.ts b/packages/extension/composables/selection.ts index 5ba150b8..1a3b1ad2 100644 --- a/packages/extension/composables/selection.ts +++ b/packages/extension/composables/selection.ts @@ -1,8 +1,8 @@ import { useDocumentVisibility, useEventListener, useWindowFocus } from '@vueuse/core' -import { computed, onMounted, shallowRef, watch } from 'vue' +import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue' import { readFigmaFileKey } from '@/mcp/figma-session' -import { layoutReady, selection, runtimeMode } from '@/ui/state' +import { layoutReady, selection, selectionRevision, runtimeMode } from '@/ui/state' import { getCanvas, getLeftPanel } from '@/utils' let selectionFileKey: string | null = null @@ -15,9 +15,19 @@ function isSameSelection(next: readonly SceneNode[], current: readonly SceneNode return true } -export function syncSelection() { +function readPageSelection(api: PluginAPI | undefined) { + try { + const page = api?.currentPage + return page ? { page, nodes: page.selection } : undefined + } catch { + return undefined + } +} + +export function syncSelection(refreshContent = true) { const api = window.figma - if (!api?.currentPage) { + const snapshot = readPageSelection(api) + if (!snapshot) { selectionFileKey = null if (selection.value.length) { selection.value = [] @@ -25,25 +35,15 @@ export function syncSelection() { return } const fileKey = readFigmaFileKey(api) - const next = api.currentPage.selection + const next = snapshot.nodes // Node IDs are file-local. Keep same-file wrappers stable, but never retain a // previous document's node (and its resources) when the selected IDs coincide. - if (selectionFileKey !== fileKey || !isSameSelection(next, selection.value)) { + const changed = selectionFileKey !== fileKey || !isSameSelection(next, selection.value) + if (changed) { selection.value = next } selectionFileKey = fileKey -} - -function handleClick() { - syncSelection() -} - -function handleKeyDown(e: KeyboardEvent) { - const target = e.target as Element | null - if (target?.classList.contains('focus-target')) { - // command + A or other shortcut that changes selection - syncSelection() - } + if (changed || refreshContent) selectionRevision.value++ } export function useSelection() { @@ -54,21 +54,251 @@ export function useSelection() { const isWindowActive = computed(() => documentVisibility.value === 'visible' && focused.value) const options = { capture: true } + let disposed = false + let refreshTimer: ReturnType | undefined + let contentChanged = false + let nativeSelection = false + let nativeChanges = false + let observedApi: PluginAPI | undefined + let observedPage: PageNode | undefined + const nativeListeners: Array<() => void> = [] + const pageListeners = new Map void>() + const relevantNodes = new Set() + const relevantStyles = new Set() + const subtreeRoots = new Set() + + function trackSelection() { + relevantNodes.clear() + relevantStyles.clear() + subtreeRoots.clear() + const pages = new Set(observedPage ? [observedPage] : []) + // Code generation has no output for multiple selections. Avoid traversing + // a whole page just to keep a hidden panel's dependencies up to date. + if (!observedApi || selection.value.length !== 1) { + syncPageListeners(pages) + return + } + const pending: BaseNode[] = [...selection.value] + const visited = new Set() + const ancestors = new Set() + + function addDefinition(node: BaseNode) { + if (node.type !== 'INSTANCE') return + const component = node.mainComponent + if (!component) return + subtreeRoots.add(component.id) + pending.push(component) + } + + function trackAncestors(node: BaseNode | null) { + while (node && !ancestors.has(node.id)) { + ancestors.add(node.id) + relevantNodes.add(node.id) + if (node.type === 'PAGE') pages.add(node) + // A selected sublayer inherits content from its containing instance. + addDefinition(node) + node = node.parent + } + } + + const selected = selection.value[0]! + subtreeRoots.add(selected.id) + try { + trackAncestors(selected.parent) + } catch { + // Removed selections are cleared by the next selection synchronization. + } + while (pending.length) { + const node = pending.pop()! + if (visited.has(node.id)) continue + visited.add(node.id) + relevantNodes.add(node.id) + try { + if ('children' in node) pending.push(...node.children) + for (const field of [ + 'fillStyleId', + 'strokeStyleId', + 'effectStyleId', + 'gridStyleId', + 'textStyleId' + ] as const) { + const styleId: unknown = Reflect.get(node, field) + if (typeof styleId === 'string') relevantStyles.add(styleId) + } + if (node.type === 'TEXT') { + const fields = (['textStyleId', 'fillStyleId'] as const).filter( + (field) => typeof node[field] === 'symbol' + ) + if (fields.length) { + for (const segment of node.getStyledTextSegments(fields)) { + for (const field of fields) relevantStyles.add(segment[field]) + } + } + } + addDefinition(node) + // Component-set properties and accessible definition pages are also + // dependencies; inherited instance edits need not emit their own event. + if (node.type === 'COMPONENT') trackAncestors(node.parent) + } catch { + // A native node can disappear while its change notification is pending. + } + } + syncPageListeners(pages) + } + + function syncPageListeners(pages: Set) { + for (const [page, remove] of pageListeners) { + if (pages.has(page)) continue + removeListener(remove) + pageListeners.delete(page) + } + for (const page of pages) { + if ( + pageListeners.has(page) || + typeof page.on !== 'function' || + typeof page.off !== 'function' + ) + continue + // A distinct callback keeps removal isolated even if subscriptions are indexed by callback. + const onNodeChange = (event?: NodeChangeEvent) => handleNodeChange(event) + try { + page.on('nodechange', onNodeChange) + pageListeners.set(page, () => page.off('nodechange', onNodeChange)) + } catch { + // Unavailable definition pages remain covered by local edits/focus. + } + } + nativeChanges = !!observedPage && pageListeners.has(observedPage) + } + + function changedNodeAffectsSelection(change: NodeChange): boolean { + if (relevantNodes.has(change.id)) return true + if (change.node.removed) return false + try { + // New or reparented descendants were not present in the last snapshot. + let parent = change.node.parent + while (parent) { + if (subtreeRoots.has(parent.id)) return true + parent = parent.parent + } + } catch { + // The retained identities above still recognize deleted selected content. + } + return false + } + + function handleNodeChange(event?: NodeChangeEvent) { + if (!event || event.nodeChanges.some(changedNodeAffectsSelection)) scheduleRefresh(true) + } + + function handleStyleChange(event?: StyleChangeEvent) { + if (!event || event.styleChanges.some(({ id }) => relevantStyles.has(id))) scheduleRefresh(true) + } + + function handleSelectionChange() { + scheduleRefresh() + } function syncTargets() { canvas.value = getCanvas() objectsPanel.value = getLeftPanel() } - onMounted(() => { - if (layoutReady.value) { - syncTargets() - syncSelection() + function removeListener(remove: () => void) { + try { + remove() + } catch { + // A previous document's native API may already be unavailable. } + } - useEventListener(canvas, 'click', handleClick, options) - useEventListener(objectsPanel, 'click', handleClick, options) - useEventListener(window, 'keydown', handleKeyDown, options) + function removeNativeListeners() { + for (const remove of nativeListeners.splice(0)) removeListener(remove) + for (const remove of pageListeners.values()) removeListener(remove) + pageListeners.clear() + observedApi = undefined + observedPage = undefined + nativeSelection = false + nativeChanges = false + } + + function subscribe(add: () => void, remove: () => void) { + try { + add() + nativeListeners.push(remove) + return true + } catch { + // Runtime builds without native event support use the DOM/focus fallback. + return false + } + } + + function syncNativeListeners() { + const api = layoutReady.value && runtimeMode.value === 'standard' ? window.figma : undefined + const page = readPageSelection(api)?.page + if (api === observedApi && page === observedPage) return false + removeNativeListeners() + observedApi = api + observedPage = page + if (api && typeof api.on === 'function' && typeof api.off === 'function') { + nativeSelection = subscribe( + () => api.on('selectionchange', handleSelectionChange), + () => api.off('selectionchange', handleSelectionChange) + ) + subscribe( + () => api.on('currentpagechange', handleSelectionChange), + () => api.off('currentpagechange', handleSelectionChange) + ) + subscribe( + () => api.on('stylechange', handleStyleChange), + () => api.off('stylechange', handleStyleChange) + ) + } + syncPageListeners(new Set(page ? [page] : [])) + return true + } + + function scheduleRefresh(refreshContent = false) { + clearTimeout(refreshTimer) + if (disposed || !layoutReady.value || runtimeMode.value !== 'standard' || !isWindowActive.value) + return + contentChanged ||= refreshContent + // Read after Figma's event handlers and coalesce drag, typing and native batches. + refreshTimer = setTimeout(() => { + const changedPage = syncNativeListeners() + syncSelection(contentChanged || changedPage) + contentChanged = false + }, 100) + } + + function handleEdit(event: Event) { + const target = event.target as Element | null + if (target?.closest?.('tempad')) return + if ('key' in event && /^(Alt|Control|Meta|Shift| )$/.test(String(event.key))) return + const changedPage = syncNativeListeners() + // Variable edits do not have a dedicated native event. Keep actual editor + // input as a fallback without regenerating on ordinary canvas clicks. + const edited = + event.type === 'input' || + event.type === 'change' || + (event.type === 'keyup' && + event instanceof KeyboardEvent && + event.key === 'Enter' && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !target?.closest?.('.focus-target') && + !!target?.closest?.('input, textarea, [contenteditable="true"]')) + if (edited || changedPage || !nativeSelection || !nativeChanges) + scheduleRefresh(edited || !nativeChanges) + } + + onMounted(() => { + useEventListener(canvas, 'click', handleEdit, options) + useEventListener(objectsPanel, 'click', handleEdit, options) + for (const event of ['pointerup', 'keyup', 'input', 'change'] as const) { + useEventListener(window, event, handleEdit, options) + } }) watch( @@ -76,8 +306,13 @@ export function useSelection() { (ready) => { if (ready) { syncTargets() + syncNativeListeners() + if (runtimeMode.value === 'standard') syncSelection() return } + clearTimeout(refreshTimer) + contentChanged = false + removeNativeListeners() canvas.value = null objectsPanel.value = null if (selection.value.length) { @@ -88,8 +323,19 @@ export function useSelection() { ) watch([runtimeMode, isWindowActive], ([mode, active]) => { + clearTimeout(refreshTimer) + contentChanged = false + syncNativeListeners() if (mode !== 'standard') return if (!layoutReady.value) return if (active) syncSelection() }) + + watch([selection, selectionRevision], trackSelection, { immediate: true }) + + onBeforeUnmount(() => { + disposed = true + clearTimeout(refreshTimer) + removeNativeListeners() + }) } diff --git a/packages/extension/entrypoints/loader.ts b/packages/extension/entrypoints/loader.ts index 6bb8f9bb..e7d0843d 100644 --- a/packages/extension/entrypoints/loader.ts +++ b/packages/extension/entrypoints/loader.ts @@ -26,10 +26,7 @@ async function loadUi(): Promise { script.textContent = `${bootstrap}${content}\n//# sourceURL=${location}\n` document.body.appendChild(script) - - script.onload = () => { - script.remove() - } + script.remove() } export default defineUnlistedScript(() => { diff --git a/packages/extension/entrypoints/rewrite.content.ts b/packages/extension/entrypoints/rewrite.content.ts index 0e483ca8..922167c5 100644 --- a/packages/extension/entrypoints/rewrite.content.ts +++ b/packages/extension/entrypoints/rewrite.content.ts @@ -22,36 +22,11 @@ function preserveWindowFigma(): void { } function createBlobCache(): (src: string) => Promise { - const cache = new Map() - - function releaseBlobUrl(src: string, usedUrl: string): void { - const entry = cache.get(src) - if (!entry || entry.url !== usedUrl) { - return - } - - entry.ref -= 1 - if (entry.ref > 0) { - return - } - - try { - URL.revokeObjectURL(entry.url) - } catch { - // noop - } - - cache.delete(src) - } - - async function acquireBlobUrl(src: string): Promise { - const existing = cache.get(src) - if (existing) { - existing.ref += 1 - return { url: existing.url, release: () => releaseBlobUrl(src, existing.url) } - } + const cache = new Map>() + async function createEntry(src: string): Promise { const response = await fetch(src, { credentials: 'include', cache: 'force-cache' }) + if (!response.ok) throw new Error(`Script request failed with HTTP ${response.status}: ${src}`) const originalText = await response.text() const { content, changed } = applyGroups(originalText, GROUPS) @@ -60,9 +35,39 @@ function createBlobCache(): (src: string) => Promise { } const blob = new Blob([content], { type: 'application/javascript; charset=utf-8' }) - const url = URL.createObjectURL(blob) - cache.set(src, { url, ref: 1 }) - return { url, release: () => releaseBlobUrl(src, url) } + return { url: URL.createObjectURL(blob), ref: 0 } + } + + async function acquireBlobUrl(src: string): Promise { + let pending = cache.get(src) + if (!pending) { + pending = createEntry(src) + cache.set(src, pending) + } + let entry: CacheEntry + try { + entry = await pending + } catch (error) { + if (cache.get(src) === pending) cache.delete(src) + throw error + } + entry.ref += 1 + let released = false + return { + url: entry.url, + release: () => { + if (released) return + released = true + entry.ref -= 1 + if (entry.ref > 0) return + try { + URL.revokeObjectURL(entry.url) + } catch { + // noop + } + if (cache.get(src) === pending) cache.delete(src) + } + } } return acquireBlobUrl diff --git a/packages/extension/entrypoints/ui/App.vue b/packages/extension/entrypoints/ui/App.vue index 4e45773a..03e3786f 100644 --- a/packages/extension/entrypoints/ui/App.vue +++ b/packages/extension/entrypoints/ui/App.vue @@ -89,7 +89,8 @@ const mcpBadgeTooltip = computed(() => { return `Active${fileLabel}` } - return `Inactive${fileLabel}\nClick to activate` + const reason = errorMessage.value ? `\n${errorMessage.value}` : '' + return `Inactive${fileLabel}${reason}\nClick to activate` }) const mcpBadgeStatusClass = computed(() => `tp-mcp-badge-${status.value}`) diff --git a/packages/extension/entrypoints/ui/global.d.ts b/packages/extension/entrypoints/ui/global.d.ts index 57115aba..9565607a 100644 --- a/packages/extension/entrypoints/ui/global.d.ts +++ b/packages/extension/entrypoints/ui/global.d.ts @@ -3,10 +3,6 @@ interface Window { readonly INITIAL_OPTIONS?: { readonly editor_type?: string } - DebuggingHelpers: { - logSelected?: () => string - logNode?: (id: string) => string - } Prism?: typeof import('prismjs') } diff --git a/packages/extension/mcp/assets.ts b/packages/extension/mcp/assets.ts index 8ea82974..d5785c1a 100644 --- a/packages/extension/mcp/assets.ts +++ b/packages/extension/mcp/assets.ts @@ -109,7 +109,7 @@ export async function ensureAssetUploaded( const promise = uploadAsset({ bytes, hash, metadata, mimeType }) .then(() => { if (generation === assetCacheGeneration) uploadedAssets.add(uploadKey) - logger.log(`Uploaded asset ${hash.slice(0, 8)} (${mimeType}, ${size} bytes) to ${url}`) + logger.log(`Uploaded asset ${hash.slice(0, 8)} (${mimeType}, ${size} bytes)`) }) .finally(() => { if (inflightUploads.get(uploadKey) === promise) inflightUploads.delete(uploadKey) @@ -131,7 +131,8 @@ async function uploadAsset(request: AssetUploadRequest): Promise { try { await assetUploader(request) } catch (error) { - logger.error('Failed to upload asset via MCP bridge.', error) + // Transport errors can contain the asset capability URL. + logger.error('Failed to upload asset via MCP bridge.') throw error instanceof Error ? error : new Error('Failed to upload asset via MCP bridge.') } } diff --git a/packages/extension/mcp/bridge/content.ts b/packages/extension/mcp/bridge/content.ts index 1302b27e..23cdde97 100644 --- a/packages/extension/mcp/bridge/content.ts +++ b/packages/extension/mcp/bridge/content.ts @@ -1,4 +1,4 @@ -import type { PageToBridgeMessage } from '@tempad-dev/shared' +import type { DesignTask, PageToBridgeMessage } from '@tempad-dev/shared' import { TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, @@ -11,6 +11,7 @@ import { import { MCP_LOCAL_HOST_PERMISSION_ERROR, + MCP_PERMISSION_CHANGE_MESSAGE, MCP_PERMISSION_REQUEST_EVENT, type McpPermissionMessageType, createMcpPermissionMessage, @@ -23,7 +24,9 @@ type EnableMessage = Extract export function startMcpContentBridge(): () => void { let invalidated = false let enableMessage: EnableMessage | null = null + let latestTask: DesignTask | undefined let enableReady: Promise | null = null + let enableRevision = 0 let port: ReturnType | null = null let permissionGranted = false let permissionRequest: Promise | null = null @@ -35,6 +38,26 @@ export function startMcpContentBridge(): () => void { reconnectTimer = null } + function rememberTask(task: DesignTask): void { + if (!enableMessage || task.target.fileKey !== enableMessage.document?.fileKey) return + const previous = latestTask?.taskId === task.taskId ? latestTask : undefined + latestTask = { + ...task, + ...(previous?.status === 'cancelled' ? { status: 'cancelled', operation: null } : {}), + ...(previous?.reviewClosed ? { reviewClosed: true } : {}) + } + // Another tab's live review cannot become this page's claim. Stop and Done + // fences, however, must survive worker loss from any tab showing this file. + if ( + task.target.sessionId === enableMessage.sessionId || + enableMessage.reviewTask?.taskId === task.taskId || + latestTask.status === 'cancelled' || + latestTask.reviewClosed + ) + enableMessage.reviewTask = latestTask + else delete enableMessage.reviewTask + } + function scheduleReconnect(): void { if (invalidated || !enableMessage || !permissionGranted || reconnectTimer) return reconnectTimer = setTimeout(() => { @@ -57,9 +80,16 @@ export function startMcpContentBridge(): () => void { } nextPort.onMessage.addListener((raw) => { - if (invalidated) return + if (invalidated || port !== nextPort) return const message = parseBridgeToPageMessage(raw) if (!message) return + if (message.type === 'mcp.designTaskState') rememberTask(message.task) + if ( + message.type === 'mcp.designReviewClosed' && + message.taskId === latestTask?.taskId && + message.fileKey === latestTask.target.fileKey + ) + rememberTask({ ...latestTask, reviewClosed: true }) window.postMessage(message, TEMPAD_MCP_FIGMA_ORIGIN) }) @@ -94,8 +124,9 @@ export function startMcpContentBridge(): () => void { } async function enable(message: EnableMessage): Promise { + const revision = ++enableRevision const granted = await ensureLocalHostPermission() - if (invalidated || enableMessage !== message) return + if (invalidated || enableMessage !== message || revision !== enableRevision) return if (!granted) { postMissingLocalHostPermission(message.sessionId) return @@ -110,7 +141,14 @@ export function startMcpContentBridge(): () => void { if (!message) return if (message.type === 'mcp.enable') { + if ( + enableMessage?.sessionId !== message.sessionId || + enableMessage.document?.fileKey !== message.document?.fileKey + ) + latestTask = undefined enableMessage = message + const review = message.reviewTask ?? latestTask + if (review) rememberTask(review) permissionGranted = false enableReady = enable(message) return enableReady @@ -136,10 +174,22 @@ export function startMcpContentBridge(): () => void { return } if (message.type === 'mcp.sessionInfo') { + if (enableMessage.document?.fileKey !== message.document.fileKey) { + latestTask = undefined + delete enableMessage.reviewTask + } enableMessage.document = message.document } + if ( + message.type === 'mcp.designAction' && + message.action.action === 'stop' && + message.action.taskId === latestTask?.taskId + ) + // Cache before reconnect can replay enable or failed storage can lose Stop. + rememberTask({ ...latestTask, status: 'cancelled', operation: null }) if (message.type === 'mcp.disable') { enableMessage = null + latestTask = undefined permissionGranted = false clearReconnectTimer() } @@ -205,8 +255,26 @@ export function startMcpContentBridge(): () => void { void requestLocalHostPermission() } + const runtimeMessages = browser.runtime.onMessage + const handlePermissionChange: Parameters[0] = ( + message, + sender + ) => { + if ( + invalidated || + !enableMessage || + permissionGranted || + sender.id !== browser.runtime?.id || + message?.source !== MCP_PERMISSION_CHANGE_MESSAGE.source || + message?.type !== MCP_PERMISSION_CHANGE_MESSAGE.type + ) + return + enableReady = enable(enableMessage) + } + window.addEventListener('message', handlePageMessage) window.addEventListener(MCP_PERMISSION_REQUEST_EVENT, handlePermissionRequest) + runtimeMessages.addListener(handlePermissionChange) function postDisconnected(message: string): void { if (!enableMessage) return @@ -229,7 +297,7 @@ export function startMcpContentBridge(): () => void { function invalidateIfNeeded(error?: unknown): boolean { if ( - !browser.runtime.id || + !browser.runtime?.id || (error instanceof Error && error.message.includes('Extension context invalidated')) ) { invalidate() @@ -246,6 +314,11 @@ export function startMcpContentBridge(): () => void { window.removeEventListener(MCP_PERMISSION_REQUEST_EVENT, handlePermissionRequest) const previousPort = port port = null + try { + runtimeMessages.removeListener(handlePermissionChange) + } catch { + // A stale listener must not prevent disconnecting its port. + } try { previousPort?.disconnect() } catch { diff --git a/packages/extension/mcp/broker/design-reviews.ts b/packages/extension/mcp/broker/design-reviews.ts index a931c33f..24f25791 100644 --- a/packages/extension/mcp/broker/design-reviews.ts +++ b/packages/extension/mcp/broker/design-reviews.ts @@ -1,10 +1,22 @@ import type { DesignTask } from '@tempad-dev/shared' -import { DesignTaskSchema } from '@tempad-dev/shared' +import { + AgentCapabilitiesSchema, + AgentClientSchema, + DesignTaskSchema, + DesignTaskTargetSchema +} from '@tempad-dev/shared' import { createSerialQueue } from './serial' const KEY = 'tempad.design-reviews.v1' +// Storage may contain additive fields from a newer version. Wire schemas stay strict. +const StoredTaskSchema = DesignTaskSchema.strip().extend({ + target: DesignTaskTargetSchema.strip(), + client: AgentClientSchema.strip().optional(), + capabilities: AgentCapabilitiesSchema.strip().optional() +}) +type StoredReview = { value: unknown; task?: DesignTask } type Storage = { get(key: string): Promise> set(values: Record): Promise @@ -16,6 +28,7 @@ export class DesignReviews { private loaded = false private pendingStop = false private readonly tasks = new Map() + private stored: StoredReview[] = [] constructor(private readonly storage: Storage) {} @@ -23,6 +36,14 @@ export class DesignReviews { return this.tasks.get(fileKey) } + preserveFences(task: DesignTask): DesignTask { + const previous = this.tasks.get(task.target.fileKey) + if (previous?.taskId !== task.taskId) return task + if (previous.reviewClosed) task = { ...task, reviewClosed: true } + if (previous.status === 'cancelled') task = { ...task, status: 'cancelled', operation: null } + return task + } + ready(): Promise { return this.serial(() => this.load()) } @@ -48,12 +69,13 @@ export class DesignReviews { return this.serial(async () => { await this.load(task) const previous = this.tasks.get(task.target.fileKey) - if (previous?.taskId === task.taskId) { - if (previous.reviewClosed) task = { ...task, reviewClosed: true } - if (previous.status === 'cancelled') - task = { ...task, status: 'cancelled', operation: null } - if (!this.pendingStop && JSON.stringify(previous) === JSON.stringify(task)) return previous - } + task = this.preserveFences(task) + if ( + previous?.taskId === task.taskId && + !this.pendingStop && + JSON.stringify(previous) === JSON.stringify(task) + ) + return previous return this.commit(task) }) } @@ -80,7 +102,18 @@ export class DesignReviews { // Stop remains fenced in this worker even if storage fails. Done still needs a durable receipt. this.rememberStop(task) const next = new Map(this.tasks).set(task.target.fileKey, task) - await persist({ [KEY]: [...next.values()] }) + const written = new Set() + const stored = this.stored.flatMap((entry): StoredReview[] => { + if (!entry.task) return [entry] + const updated = next.get(entry.task.target.fileKey)! + if (updated.taskId !== entry.task.taskId) return [] + written.add(updated.target.fileKey) + return [{ task: updated, value: mergeStoredTask(updated, entry.value) }] + }) + for (const updated of next.values()) + if (!written.has(updated.target.fileKey)) stored.push({ task: updated, value: updated }) + await persist({ [KEY]: stored.map(({ value }) => value) }) + this.stored = stored this.tasks.set(task.target.fileKey, task) this.pendingStop = false return task @@ -109,7 +142,8 @@ export class DesignReviews { } if (Array.isArray(values)) for (const value of values) { - const parsed = DesignTaskSchema.strip().safeParse(value) + const parsed = StoredTaskSchema.safeParse(value) + this.stored.push({ value, ...(parsed.success ? { task: parsed.data } : {}) }) if (!parsed.success) continue const task = parsed.data const local = this.tasks.get(task.target.fileKey) @@ -123,3 +157,28 @@ export class DesignReviews { this.loaded = true } } + +function extraFields(value: unknown, known: object): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return Object.fromEntries(Object.entries(value).filter(([key]) => !Object.hasOwn(known, key))) +} + +function mergeStoredTask(task: DesignTask, raw: unknown): unknown { + const previous = raw as Record + return { + ...extraFields(previous, DesignTaskSchema.shape), + ...task, + target: { ...extraFields(previous.target, DesignTaskTargetSchema.shape), ...task.target }, + ...(task.client + ? { client: { ...extraFields(previous.client, AgentClientSchema.shape), ...task.client } } + : {}), + ...(task.capabilities + ? { + capabilities: { + ...extraFields(previous.capabilities, AgentCapabilitiesSchema.shape), + ...task.capabilities + } + } + : {}) + } +} diff --git a/packages/extension/mcp/broker/hub-client.ts b/packages/extension/mcp/broker/hub-client.ts index 039cfcb4..de18cf24 100644 --- a/packages/extension/mcp/broker/hub-client.ts +++ b/packages/extension/mcp/broker/hub-client.ts @@ -8,11 +8,14 @@ import type { RuntimeHelloMessage, StateMessage, ToolCallMessage, - ToolResultMessage + ToolResultMessage, + ToolCompletionMessage, + ToolCompletionAckMessage } from '@tempad-dev/shared' import { - MCP_PORT_CANDIDATES, + runtimePortCandidates, + sameRuntimeProfile, TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, TEMPAD_MCP_BRIDGE_SUBPROTOCOL, parseMessageToExtension @@ -28,6 +31,8 @@ const LOCAL_HUB_UNREACHABLE_MESSAGE = type HubConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error' type HubClientSnapshot = { + terminalReceiptGeneration?: string + durableTerminalReceipts?: true activeId: string | null assetServerUrl: string | null errorMessage: string | null @@ -36,6 +41,8 @@ type HubClientSnapshot = { } type McpHubClientEvents = { + onToolCompletionAck?: (message: ToolCompletionAckMessage) => void + onKeepalive?: () => void onSnapshot?: (snapshot: HubClientSnapshot) => void onToolCall?: (message: ToolCallMessage) => void onDesignActionResult?: (message: DesignActionResultMessage) => void @@ -53,6 +60,7 @@ type HubConnection = { class McpBridgeProtocolMismatchError extends Error {} export class McpHubClient { + private readonly runtimeProfile = __TEMPAD_RUNTIME_PROFILE__ private activeId: string | null = null private assetServerUrl: string | null = null private candidateSocket: WebSocket | null = null @@ -66,6 +74,8 @@ export class McpHubClient { private registeredId: string | null = null private status: HubConnectionStatus = 'idle' private ws: WebSocket | null = null + private terminalReceiptGeneration: string | undefined + private durableTerminalReceipts = false constructor( private readonly events: McpHubClientEvents = {}, @@ -76,6 +86,10 @@ export class McpHubClient { getSnapshot(): HubClientSnapshot { return { + ...(this.terminalReceiptGeneration + ? { terminalReceiptGeneration: this.terminalReceiptGeneration } + : {}), + ...(this.durableTerminalReceipts ? { durableTerminalReceipts: true as const } : {}), activeId: this.activeId, assetServerUrl: this.assetServerUrl, errorMessage: this.errorMessage, @@ -122,8 +136,22 @@ export class McpHubClient { this.sendJson({ type: 'activate' }) } - sendToolResult(message: ToolResultMessage): void { - this.sendJson(message) + sendToolResult(message: ToolResultMessage): boolean { + return this.trySendJson(message) + } + + sendToolCompletion(message: ToolCompletionMessage): boolean { + if (!this.durableTerminalReceipts && message.generation !== this.terminalReceiptGeneration) + return false + return this.trySendJson(message) + } + + private trySendJson(message: unknown): boolean { + try { + return this.sendJson(message) + } catch { + return false + } } sendSessions(message: FigmaSessionsMessage): void { @@ -136,6 +164,13 @@ export class McpHubClient { private async connect(epoch: number): Promise { this.clearReconnectTimer() + if (!sameRuntimeProfile(this.runtimeProfile, this.runtimeIdentity?.runtimeProfile)) { + this.status = 'error' + this.errorMessage = + 'MCP runtime identity is missing or inconsistent. Rebuild and reload the extension.' + this.emitSnapshot() + return + } this.status = 'connecting' this.errorMessage = null this.emitSnapshot() @@ -155,7 +190,12 @@ export class McpHubClient { } this.attachSocket(connection.ws) this.handleHubMessage(connection.registered) - if (this.runtimeIdentity) this.sendJson(this.runtimeIdentity) + if (this.runtimeIdentity && this.runtimeProfile.kind === 'release') + this.sendJson({ + ...this.runtimeIdentity, + ...(this.terminalReceiptGeneration ? { terminalReceipts: true } : {}), + ...(this.durableTerminalReceipts ? { durableTerminalReceipts: true } : {}) + }) this.handleHubMessage(connection.state) if (!this.isCurrentConnection(epoch) || this.ws !== connection.ws) return this.lastSuccessfulPort = candidatePort @@ -182,13 +222,14 @@ export class McpHubClient { } private getPortCandidates(): number[] { - if (this.lastSuccessfulPort && MCP_PORT_CANDIDATES.includes(this.lastSuccessfulPort)) { + const candidates = runtimePortCandidates(this.runtimeProfile) + if (this.lastSuccessfulPort && candidates.includes(this.lastSuccessfulPort)) { return [ this.lastSuccessfulPort, - ...MCP_PORT_CANDIDATES.filter((port) => port !== this.lastSuccessfulPort) + ...candidates.filter((port) => port !== this.lastSuccessfulPort) ] } - return [...MCP_PORT_CANDIDATES] + return [...candidates] } private openHubConnection(port: number): Promise { @@ -236,6 +277,14 @@ export class McpHubClient { return } if (message.type === 'registered') { + if (!sameRuntimeProfile(this.runtimeProfile, message.runtimeProfile)) { + fail( + new McpBridgeProtocolMismatchError( + 'MCP runtime profile does not match this extension build.' + ) + ) + return + } if (!servesThisExtension(message)) { fail(createProtocolMismatchError(message)) return @@ -245,6 +294,21 @@ export class McpHubClient { return } registered = message + // Checkout Hubs withhold routes and asset access until identity is verified. + if (this.runtimeProfile.kind === 'checkout') { + try { + ws.send( + JSON.stringify({ + ...this.runtimeIdentity, + ...(message.terminalReceiptGeneration ? { terminalReceipts: true } : {}), + ...(message.durableTerminalReceipts ? { durableTerminalReceipts: true } : {}) + }) + ) + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))) + return + } + } } else if (message.type === 'state') { if (state) { fail(new Error('Received duplicate MCP server state')) @@ -287,7 +351,7 @@ export class McpHubClient { if (this.ws !== ws) return const message = parseHubMessage(event) if (!message) { - this.rejectConnectedMessage( + this.disconnectSocket( ws, isRegistrationFrame(event) ? createProtocolMismatchError().message @@ -296,7 +360,7 @@ export class McpHubClient { return } if (message.type === 'registered') { - this.rejectConnectedMessage( + this.disconnectSocket( ws, servesThisExtension(message) ? 'Received duplicate registration from MCP server' @@ -305,7 +369,7 @@ export class McpHubClient { return } if (message.type === 'state' && !isAllowedAssetServerUrl(message.assetServerUrl)) { - this.rejectConnectedMessage(ws, 'MCP server advertised a non-loopback asset URL') + this.disconnectSocket(ws, 'MCP server advertised a non-loopback asset URL') return } if ( @@ -313,24 +377,36 @@ export class McpHubClient { this.assetServerUrl !== null && message.assetServerUrl !== this.assetServerUrl ) { - this.rejectConnectedMessage(ws, 'MCP server changed its asset server URL') + this.disconnectSocket(ws, 'MCP server changed its asset server URL') return } this.handleHubMessage(message) } - private rejectConnectedMessage(ws: WebSocket, message: string): void { + private disconnectSocket(ws: WebSocket, errorMessage: string | null): void { if (this.ws !== ws) return - this.errorMessage = message + // Revoke the route before close(), whose handshake can outlive this connection. + this.cleanupSocket() + this.activeId = null + this.assetServerUrl = null + this.registeredId = null this.status = 'connecting' + this.errorMessage = errorMessage this.emitSnapshot() - closeWebSocket(ws) + this.scheduleReconnect() } private handleHubMessage(message: MessageToExtension): void { switch (message.type) { case 'registered': this.registeredId = message.id + this.durableTerminalReceipts = !!this.runtimeIdentity && !!message.durableTerminalReceipts + this.terminalReceiptGeneration = this.runtimeIdentity + ? message.terminalReceiptGeneration + : undefined + break + case 'toolCompletionAck': + this.events.onToolCompletionAck?.(message) break case 'state': this.activeId = message.activeId @@ -352,15 +428,7 @@ export class McpHubClient { } private handleClose(ws: WebSocket, event: CloseEvent): void { - if (this.ws !== ws) return - this.cleanupSocket() - this.activeId = null - this.assetServerUrl = null - this.registeredId = null - this.status = 'connecting' - this.errorMessage = event.wasClean ? null : 'MCP connection closed unexpectedly' - this.emitSnapshot() - this.scheduleReconnect() + this.disconnectSocket(ws, event.wasClean ? null : 'MCP connection closed unexpectedly') } private handleError(ws: WebSocket, event: Event): void { @@ -386,7 +454,8 @@ export class McpHubClient { private startKeepalive(): void { this.clearKeepaliveTimer() this.keepaliveTimer = setInterval(() => { - this.sendJson({ type: 'ping' }) + this.trySendJson({ type: 'ping' }) + this.events.onKeepalive?.() }, KEEPALIVE_INTERVAL_MS) } diff --git a/packages/extension/mcp/broker/service-worker.ts b/packages/extension/mcp/broker/service-worker.ts index 7a9cd9f6..feeaeb2d 100644 --- a/packages/extension/mcp/broker/service-worker.ts +++ b/packages/extension/mcp/broker/service-worker.ts @@ -8,7 +8,9 @@ import type { PageToBridgeMessage, RuntimeHelloMessage, TempadMcpErrorCode, - ToolCallMessage + ToolCallMessage, + TerminalReceipt, + ToolCompletionAckMessage } from '@tempad-dev/shared' import { @@ -27,10 +29,11 @@ import type { McpBrokerPort } from './sessions' import { readBoundedResponseBytes } from '../bounded-response' import { getFeedbackDraftScope } from '../design-feedback' import { base64ToBytes, bytesToBase64, digestMatchesAssetHash, sha256Hex } from '../encoding' -import { coerceToolErrorPayload, createCodedError } from '../errors' +import { canvasAbortedError, coerceToolErrorPayload, createCodedError } from '../errors' import { MCP_LOCAL_HOST_ORIGIN, MCP_LOCAL_HOST_PERMISSION_ERROR, + MCP_PERMISSION_CHANGE_MESSAGE, type McpPermissionMessageType, type McpPermissionResponse, isMcpPermissionMessage @@ -55,6 +58,7 @@ export type McpBrokerHubClient = Pick< | 'getSnapshot' | 'sendActivate' | 'sendToolResult' + | 'sendToolCompletion' | 'sendSessions' | 'sendDesignAction' | 'start' @@ -74,7 +78,15 @@ export class McpServiceWorkerBroker { private permissionRevision = 0 private permissionChangeRevision = 0 private readonly hubClient: McpBrokerHubClient - private readonly pendingToolCalls = new Map() + private readonly pendingToolCalls = new Map< + string, + { + sessionId: string + port: McpBrokerPort + receipt?: TerminalReceipt + completed?: true + } + >() private readonly portSessions = new WeakMap() private readonly sessions = new McpSessionRegistry() private readonly serializeTaskState = createSerialQueue() @@ -90,6 +102,8 @@ export class McpServiceWorkerBroker { { onSnapshot: (snapshot) => this.handleHubSnapshot(snapshot), onToolCall: (message) => this.routeToolCall(message), + onToolCompletionAck: (message) => this.handleToolCompletionAck(message), + onKeepalive: () => this.flushToolCompletions(), onDesignTask: (message) => { void this.routeDesignTask(message).catch(() => { /* Retry on the next session update. */ @@ -110,6 +124,12 @@ export class McpServiceWorkerBroker { } start(): void { + browser.permissions?.onAdded?.addListener((permissions) => { + if (!permissions.origins?.includes(MCP_LOCAL_HOST_ORIGIN)) return + void this.restorePermission().catch(() => { + // A tab may navigate or close before the permission notification arrives. + }) + }) browser.permissions?.onRemoved?.addListener((permissions) => { if (!permissions.origins?.includes(MCP_LOCAL_HOST_ORIGIN)) return this.permissionRevision++ @@ -375,6 +395,21 @@ export class McpServiceWorkerBroker { } } + private async restorePermission(): Promise { + const { granted } = await this.handlePermissionMessage('mcp.permissions.contains') + if (!granted) return + // Initially denied pages have no registered port. Wake only Figma content scripts; + // each enabled script rechecks permission before opening its own port. + const tabs = await browser.tabs.query({ url: `${TEMPAD_MCP_FIGMA_ORIGIN}/*` }) + await Promise.allSettled( + tabs.flatMap((tab) => + tab.id === undefined + ? [] + : [browser.tabs.sendMessage(tab.id, MCP_PERMISSION_CHANGE_MESSAGE)] + ) + ) + } + private async handlePermissionMessage( type: McpPermissionMessageType ): Promise { @@ -494,16 +529,57 @@ export class McpServiceWorkerBroker { message: Extract ): void { if (this.portSessions.get(port) !== message.sessionId) return - const pendingSessionId = this.pendingToolCalls.get(message.callId) - if (pendingSessionId !== message.sessionId) { + const pending = this.pendingToolCalls.get(message.callId) + if ( + !pending || + pending.sessionId !== message.sessionId || + pending.port !== port || + pending.completed + ) { return } - this.pendingToolCalls.delete(message.callId) + // Only a page's actual terminal response proves execution ended. A synthetic + // port-disconnection error must never become a completion receipt. + if (pending.receipt && message.error?.code !== TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED) { + pending.completed = true + } else { + this.pendingToolCalls.delete(message.callId) + } this.hubClient.sendToolResult( message.error !== undefined ? { error: message.error, id: message.callId, type: 'toolResult' } : { id: message.callId, payload: message.payload, type: 'toolResult' } ) + this.flushToolCompletions() + } + + private handleToolCompletionAck(message: ToolCompletionAckMessage): void { + const pending = this.pendingToolCalls.get(message.id) + if ( + pending?.completed && + pending.receipt?.generation === message.generation && + pending.receipt.receiptId === message.receiptId + ) + this.pendingToolCalls.delete(message.id) + } + + private flushToolCompletions(): void { + const snapshot = this.hubClient.getSnapshot() + if (snapshot.status !== 'connected') return + for (const [id, pending] of this.pendingToolCalls) { + if ( + !pending.completed || + !pending.receipt || + (!snapshot.durableTerminalReceipts && + pending.receipt.generation !== snapshot.terminalReceiptGeneration) + ) + continue + if (this.sessions.get(pending.sessionId)?.port !== pending.port) { + this.pendingToolCalls.delete(id) + continue + } + this.hubClient.sendToolCompletion({ type: 'toolCompletion', id, ...pending.receipt }) + } } private async uploadAsset(port: McpBrokerPort, message: AssetUploadMessage): Promise { @@ -558,7 +634,14 @@ export class McpServiceWorkerBroker { private handleHubSnapshot(snapshot: ReturnType): void { if (snapshot.registeredId !== this.connectedHubId) { - this.pendingToolCalls.clear() + for (const [id, pending] of this.pendingToolCalls) { + if ( + !pending.receipt || + (!snapshot.durableTerminalReceipts && + pending.receipt.generation !== snapshot.terminalReceiptGeneration) + ) + this.pendingToolCalls.delete(id) + } if (snapshot.status === 'connected') void this.refreshOpenTabs() } if (snapshot.registeredId && snapshot.registeredId !== this.connectedHubId) { @@ -566,9 +649,25 @@ export class McpServiceWorkerBroker { } this.connectedHubId = snapshot.registeredId this.broadcastState() + // broadcastState publishes the current inventory first, on the same socket. + this.flushToolCompletions() } private routeToolCall(message: ToolCallMessage): void { + if (this.pendingToolCalls.has(message.id)) return + const rejectBeforeDelivery = (code: TempadMcpErrorCode, messageText: string): void => { + const error = createCodedError(code, messageText) + const failure = message.payload.name === 'apply_canvas' ? canvasAbortedError(error) : error + this.sendToolError(message.id, failure.code, failure.message) + } + if (this.pendingToolCalls.size >= 128) { + this.sendToolError( + message.id, + TEMPAD_MCP_ERROR_CODES.EXTENSION_DELIVERY_FAILED, + 'Too many calls await completion acknowledgment. This call was not delivered to Figma.' + ) + return + } const activeSession = message.route ? this.sessions.get(message.route.sessionId) : this.sessions.getActive() @@ -577,16 +676,14 @@ export class McpServiceWorkerBroker { (message.route.gatewayId !== this.connectedHubId || activeSession?.document?.fileKey !== message.route.fileKey) ) { - this.sendToolError( - message.id, + rejectBeforeDelivery( TEMPAD_MCP_ERROR_CODES.DESIGN_TARGET_CHANGED, 'The requested Figma runtime is no longer connected. No other tab was selected.' ) return } if (!activeSession) { - this.sendToolError( - message.id, + rejectBeforeDelivery( TEMPAD_MCP_ERROR_CODES.NO_ACTIVE_EXTENSION, 'No active TemPad Dev Figma session available.' ) @@ -604,7 +701,14 @@ export class McpServiceWorkerBroker { try { activeSession.port.postMessage(bridgeMessage) - this.pendingToolCalls.set(message.id, activeSession.sessionId) + this.pendingToolCalls.set(message.id, { + sessionId: activeSession.sessionId, + port: activeSession.port, + ...(message.terminalReceipt?.generation === + this.hubClient.getSnapshot().terminalReceiptGeneration + ? { receipt: message.terminalReceipt } + : {}) + }) } catch { this.unregisterSession( activeSession.sessionId, @@ -612,7 +716,7 @@ export class McpServiceWorkerBroker { ) this.sendToolError( message.id, - TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED, + TEMPAD_MCP_ERROR_CODES.EXTENSION_DELIVERY_FAILED, 'Figma session disconnected before receiving a tool call.' ) this.stopHubIfIdle() @@ -635,9 +739,10 @@ export class McpServiceWorkerBroker { } private rejectPendingForSession(sessionId: string, message: string): void { - for (const [callId, pendingSessionId] of this.pendingToolCalls) { - if (pendingSessionId !== sessionId) continue + for (const [callId, pending] of this.pendingToolCalls) { + if (pending.sessionId !== sessionId) continue this.pendingToolCalls.delete(callId) + if (pending.completed) continue this.sendToolError(callId, TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED, message) } } @@ -658,6 +763,7 @@ export class McpServiceWorkerBroker { request: AssetUploadMessage, error?: { code?: TempadMcpErrorCode; message: string } ): void { + if (this.portSessions.get(port) !== request.sessionId) return const message: BridgeToPageMessage = { ...(error ? { error } : {}), requestId: request.requestId, @@ -684,6 +790,7 @@ export class McpServiceWorkerBroker { payload?: AssetDownloadResultPayload, error?: { code?: TempadMcpErrorCode; message: string } ): void { + if (this.portSessions.get(port) !== request.sessionId) return const message: BridgeToPageMessage = { ...(error ? { error } : { payload: payload! }), requestId: request.requestId, @@ -714,13 +821,16 @@ export class McpServiceWorkerBroker { const snapshot = this.hubClient.getSnapshot() const brokerIsActive = snapshot.registeredId !== null && snapshot.activeId === snapshot.registeredId + const selectedTabUnavailable = + this.selection != null && !this.sessions.get(this.selection.sessionId) const commonState = { activeSessionId: brokerIsActive ? this.sessions.getActiveId() : null, gatewayId: snapshot.registeredId, assetServerUrl: snapshot.assetServerUrl, errorMessage: this.permissionMissing ? MCP_LOCAL_HOST_PERMISSION_ERROR - : snapshot.errorMessage, + : (snapshot.errorMessage ?? + (selectedTabUnavailable ? 'The selected Figma tab is unavailable.' : null)), sessionCount: this.sessions.size, status: this.permissionMissing ? 'connecting' @@ -889,7 +999,8 @@ export class McpServiceWorkerBroker { else await browser.storage.local.set({ [key]: this.browserId }) await this.reviews().ready() } catch { - // The current connection can still work; recovery will require live session evidence. + // Connection can still work, but without the saved browser ID the Hub cannot + // authenticate reviews bound to the previous identity. } } @@ -945,7 +1056,7 @@ export class McpServiceWorkerBroker { if (previous && message.task.revision < previous.revision) return const task = await this.reviews() .save(message.task) - .catch(() => message.task) + .catch(() => this.reviews().preserveFences(message.task)) if (gatewayId !== this.connectedHubId) return this.designTasks.set(fileKey, task) for (const session of this.sessions.list()) { @@ -979,6 +1090,10 @@ function extensionRuntimeIdentity(): RuntimeHelloMessage | null { type: 'runtimeHello', protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, extensionVersion: manifest.version, + ...(typeof __TEMPAD_RUNTIME_PROFILE__ !== 'undefined' && + __TEMPAD_RUNTIME_PROFILE__.kind === 'checkout' + ? { runtimeProfile: __TEMPAD_RUNTIME_PROFILE__ } + : {}), extensionRuntimeFingerprint: runtimeFingerprint } } diff --git a/packages/extension/mcp/canvas-overlay.ts b/packages/extension/mcp/canvas-overlay.ts index 6206e1c2..ba7c388a 100644 --- a/packages/extension/mcp/canvas-overlay.ts +++ b/packages/extension/mcp/canvas-overlay.ts @@ -123,7 +123,7 @@ export function observeCanvasOverlay( }) activeUntil = performance.now() + 500 for (const event of ACTIVITY_EVENTS) - window.addEventListener(event, markActivity, { passive: true }) + window.addEventListener(event, markActivity, { capture: true, passive: true }) document.addEventListener('visibilitychange', onVisibilityChange) } observers.add(observer) @@ -143,7 +143,7 @@ export function observeCanvasOverlay( tooltipObserver?.disconnect() tooltipObserver = undefined tooltipElements = undefined - for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, markActivity) + for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, markActivity, true) document.removeEventListener('visibilitychange', onVisibilityChange) } } diff --git a/packages/extension/mcp/errors.ts b/packages/extension/mcp/errors.ts index fe1558b0..cbab731f 100644 --- a/packages/extension/mcp/errors.ts +++ b/packages/extension/mcp/errors.ts @@ -26,6 +26,22 @@ export function createCodedError( return Object.assign(new Error(message), { code }) } +/** Use only at a boundary that proves no native authoring work has started. */ +export function canvasAbortedError(error: unknown): Error & { code: TempadMcpErrorCode } { + const payload = coerceToolErrorPayload(error) + const diagnostic = + payload.code && payload.code !== TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED + ? ` [${payload.code}]` + : '' + return Object.assign( + createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + `${payload.message}${diagnostic}` + ), + { cause: error } + ) +} + export function coerceToolErrorPayload(error: unknown): ToolErrorPayload { if (error instanceof Error) { const message = error.message || 'Unknown error' diff --git a/packages/extension/mcp/permissions.ts b/packages/extension/mcp/permissions.ts index 33dcb777..a605b329 100644 --- a/packages/extension/mcp/permissions.ts +++ b/packages/extension/mcp/permissions.ts @@ -1,6 +1,10 @@ export const MCP_LOCAL_HOST_ORIGIN = 'http://127.0.0.1/*' export const MCP_PERMISSION_REQUEST_EVENT = 'tempad:mcp-request-local-host-permission' export const MCP_PERMISSION_MESSAGE_SOURCE = 'tempad-dev:mcp-permissions' +export const MCP_PERMISSION_CHANGE_MESSAGE = { + source: MCP_PERMISSION_MESSAGE_SOURCE, + type: 'mcp.permissions.changed' +} as const export const MCP_LOCAL_HOST_PERMISSION_ERROR = 'Local host permission is required before MCP can connect.' diff --git a/packages/extension/mcp/runtime.ts b/packages/extension/mcp/runtime.ts index 76944d7f..68567260 100644 --- a/packages/extension/mcp/runtime.ts +++ b/packages/extension/mcp/runtime.ts @@ -23,7 +23,7 @@ import { import type { GetCodeRuntimeOptions } from './tools/code' -import { coerceToolErrorPayload, createCodedError } from './errors' +import { canvasAbortedError, coerceToolErrorPayload, createCodedError } from './errors' import { readNodeBatch, resolveReadTargets, singleReadTarget } from './node-reads' import { handleApplyCanvas } from './tools/canvas' import { pageById, pageSnapshot, pagesByKey } from './tools/canvas/identity' @@ -173,7 +173,13 @@ export async function runMcpTool(name: string, args: unknown): Promise throw new Error(`No handler registered for tool "${name}".`) } const handler = MCP_TOOL_HANDLERS[name] as (args?: unknown) => Promise - return handler(MCP_TOOL_SCHEMAS[name].parse(args ?? {})) + let parsed: unknown + try { + parsed = MCP_TOOL_SCHEMAS[name].parse(args ?? {}) + } catch (error) { + throw name === 'apply_canvas' ? canvasAbortedError(error) : error + } + return handler(parsed) } function exposeToolsOnWindow(): void { diff --git a/packages/extension/mcp/tools/canvas/assets.ts b/packages/extension/mcp/tools/canvas/assets.ts index 6c7b1519..4c762665 100644 --- a/packages/extension/mcp/tools/canvas/assets.ts +++ b/packages/extension/mcp/tools/canvas/assets.ts @@ -1,19 +1,21 @@ import type { CanvasAssets, TempadMcpErrorCode } from '@tempad-dev/shared' +import type { INode } from 'svgson' import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' -import { parseSync, stringify, type INode } from 'svgson' import { downloadAsset } from '@/mcp/assets' import { sha256Hex } from '@/mcp/encoding' import { detectImageMime } from '@/mcp/media' import { createCodedError } from '../../errors' +import { parseSvgXml, stringifySvgXml } from './svg-xml' const INLINE_SVG_BYTES = 32 * 1024 const HUB_SVG_BYTES = 1024 * 1024 const MAX_SVG_ELEMENTS = 500 const MAX_SVG_DEPTH = 32 -export const SVG_POLICY_VERSION = '3' +export const SVG_POLICY_VERSION = '4' +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg' const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink' const BANNED_ELEMENTS = new Set([ @@ -150,7 +152,7 @@ async function sanitizeSvg( let root: INode try { - root = parseSync(source) + root = parseSvgXml(source) } catch { assetError(TEMPAD_MCP_ERROR_CODES.SVG_INVALID, key, 'SVG XML is malformed.') } @@ -172,26 +174,41 @@ async function sanitizeSvg( `SVG may contain at most ${MAX_SVG_ELEMENTS} elements and ${MAX_SVG_DEPTH} levels.` ) } - const name = node.name.toLowerCase() + const namespaces = new Map(inheritedNamespaces) + for (const [attribute, value] of Object.entries(node.attributes)) { + if (attribute === 'xmlns') namespaces.set('', value) + else if (attribute.startsWith('xmlns:')) namespaces.set(attribute.slice(6), value) + } + const parts = node.name.split(':') + const localName = parts.at(-1)! + const prefix = parts.length === 1 ? '' : parts[0]! + if (parts.length > 2 || namespaces.get(prefix) !== SVG_NAMESPACE) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_INVALID, + key, + 'SVG elements must belong to the SVG namespace.' + ) + } + const elementName = localName.toLowerCase() if (node.attributes.id) { if (ids.has(node.attributes.id)) { assetError(TEMPAD_MCP_ERROR_CODES.SVG_INVALID, key, 'SVG ids must be unique.') } ids.set(node.attributes.id, node) } - if (BANNED_ELEMENTS.has(name)) { - assetError(TEMPAD_MCP_ERROR_CODES.SVG_INVALID, key, `SVG element <${name}> is not supported.`) - } - const namespaces = new Map(inheritedNamespaces) - for (const [attribute, rawValue] of Object.entries(node.attributes)) { - const separator = attribute.indexOf(':') - if (separator > 0 && attribute.slice(0, separator).toLowerCase() === 'xmlns') { - namespaces.set(attribute.slice(separator + 1), rawValue.trim()) - } + if (BANNED_ELEMENTS.has(elementName)) { + assetError( + TEMPAD_MCP_ERROR_CODES.SVG_INVALID, + key, + `SVG element <${elementName}> is not supported.` + ) } for (const [attribute, rawValue] of Object.entries(node.attributes)) { const name = attribute.toLowerCase() - if (name === 'style' || name.startsWith('on') || name === 'src') { + const separator = attribute.indexOf(':') + const namespacePrefix = separator > 0 ? attribute.slice(0, separator) : undefined + const localName = separator > 0 ? attribute.slice(separator + 1).toLowerCase() : name + if (localName === 'style' || localName.startsWith('on') || localName === 'src') { assetError( TEMPAD_MCP_ERROR_CODES.SVG_INVALID, key, @@ -199,9 +216,6 @@ async function sanitizeSvg( ) } const value = rawValue.trim() - const separator = attribute.indexOf(':') - const namespacePrefix = separator > 0 ? attribute.slice(0, separator) : undefined - const localName = separator > 0 ? attribute.slice(separator + 1).toLowerCase() : name const isLink = name === 'href' || name === 'xlink:href' || @@ -216,7 +230,7 @@ async function sanitizeSvg( 'SVG links must reference a local #id.' ) } - if (node.name.toLowerCase() === 'use') { + if (elementName === 'use') { const previous = uses.get(node) if (previous && previous !== value.slice(1)) { assetError( @@ -228,14 +242,15 @@ async function sanitizeSvg( uses.set(node, value.slice(1)) } } - if (/@import/i.test(value) || hasExternalUrl(value)) { + const cssValue = decodeCssEscapes(value) + if (/@import/i.test(cssValue) || hasExternalUrl(cssValue)) { assetError( TEMPAD_MCP_ERROR_CODES.SVG_EXTERNAL_REFERENCE, key, 'SVG cannot load external content.' ) } - if (COLOR_ATTRIBUTES.has(name) && /^currentcolor$/i.test(value)) { + if (COLOR_ATTRIBUTES.has(name) && /^currentcolor$/i.test(cssValue)) { if (!normalizedColor) { assetError( TEMPAD_MCP_ERROR_CODES.SVG_INVALID, @@ -251,7 +266,7 @@ async function sanitizeSvg( ) for (const child of node.children) visit(child, depth + 1, namespaces) } - visit(root, 1, new Map()) + visit(root, 1, new Map([['', SVG_NAMESPACE]])) const memo = new Map() const active = new Set() @@ -305,7 +320,7 @@ async function sanitizeSvg( error instanceof Error ? error.message : 'SVG viewport is invalid.' ) } - const sanitized = stringify(root) + const sanitized = stringifySvgXml(root) return { type: 'SVG', digest: await sha256Hex( @@ -343,6 +358,19 @@ function hasExternalUrl(value: string): boolean { return /url\s*\(/i.test(withoutLocalRefs) } +function decodeCssEscapes(value: string): string { + return value.replace( + /\\(?:([\da-f]{1,6})(?:\r\n|[ \t\r\n\f])?|(\r\n|[\r\n\f])|(.))/gi, + (_escape, hex: string | undefined, continuation: string | undefined, character: string) => { + if (!hex) return continuation ? '' : character + const point = Number.parseInt(hex, 16) + return String.fromCodePoint( + point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff) ? 0xfffd : point + ) + } + ) +} + function validateImageMime( key: string, bytes: Uint8Array, diff --git a/packages/extension/mcp/tools/canvas/html.ts b/packages/extension/mcp/tools/canvas/html.ts index 072f6875..b6258436 100644 --- a/packages/extension/mcp/tools/canvas/html.ts +++ b/packages/extension/mcp/tools/canvas/html.ts @@ -4,6 +4,7 @@ import { specError as htmlError } from './errors' export type CanvasMarkupElement = { sourceClasses?: ReadonlyMap + hasUnknownEntities?: boolean attributes: Record lineBreakOffsets: number[] children: CanvasMarkupElement[] @@ -37,19 +38,18 @@ function normalizeTag(tag: string): string { return normalized === 'div' || normalized === 'span' ? normalized : tag } -function decodeEntities(value: string): string { +function decodeEntities(value: string, onUnknownEntity: () => void): string { const chunks: string[] = [] let copyStart = 0 let entityStart = -1 for (let index = 0; index < value.length; index += 1) { const character = value[index]! - if (entityStart < 0) { - if (character === '&') { - chunks.push(value.slice(copyStart, index)) - entityStart = index - } + if (character === '&') { + chunks.push(value.slice(entityStart < 0 ? copyStart : entityStart, index)) + entityStart = index continue } + if (entityStart < 0) continue if (/\s/.test(character)) { chunks.push(value.slice(entityStart, index)) copyStart = index @@ -63,6 +63,7 @@ function decodeEntities(value: string): string { chunks.push(HTML_ENTITIES[entity]!) } else if (!entity.startsWith('#')) { chunks.push(`&${entity};`) + if (/^[A-Za-z][A-Za-z0-9]*$/.test(entity)) onUnknownEntity() } else { const hex = entity.startsWith('#x') || entity.startsWith('#X') const digits = hex ? entity.slice(2) : entity.startsWith('#') ? entity.slice(1) : '' @@ -90,6 +91,10 @@ function decodeEntities(value: string): string { class CanvasHtmlParser { private elementCount = 0 private index = 0 + private hasUnknownEntities = false + private readonly onUnknownEntity = () => { + this.hasUnknownEntities = true + } constructor(private readonly source: string) {} @@ -103,7 +108,7 @@ class CanvasHtmlParser { 'Canvas markup must contain exactly one root element. For a partial update, include the target or a bounded ancestor as that root; omitted siblings are preserved.' ) } - return root + return this.hasUnknownEntities ? { ...root, hasUnknownEntities: true } : root } private parseElement(depth: number): CanvasMarkupElement { @@ -154,7 +159,7 @@ class CanvasHtmlParser { this.index += 1 const end = this.source.indexOf(quote, this.index) if (end < 0) htmlError(`Attribute "${name}" has an unterminated value.`) - attributes[name] = decodeEntities(this.source.slice(this.index, end)) + attributes[name] = decodeEntities(this.source.slice(this.index, end), this.onUnknownEntity) this.index = end + 1 } if (selfClosing) return { attributes, children: [], lineBreakOffsets: [], tag, text: '' } @@ -182,7 +187,7 @@ class CanvasHtmlParser { } else { const end = this.source.indexOf('<', this.index) const textEnd = end < 0 ? this.source.length : end - text += decodeEntities(this.source.slice(this.index, textEnd)) + text += decodeEntities(this.source.slice(this.index, textEnd), this.onUnknownEntity) this.index = textEnd } } diff --git a/packages/extension/mcp/tools/canvas/identity.ts b/packages/extension/mcp/tools/canvas/identity.ts index 20092056..b15023a3 100644 --- a/packages/extension/mcp/tools/canvas/identity.ts +++ b/packages/extension/mcp/tools/canvas/identity.ts @@ -81,9 +81,7 @@ export function claimNodeKey(node: SceneNode, key: string): boolean { } const current = readOwnedNodeKey(node) const owner = readAuthoringKey(node, CANVAS_NODE_OWNER_NAME) - if (current && current !== key) { - specError(`Node "${node.id}" is already owned by canvas key "${current}".`) - } + assertNodeKey(node.id, key, current) const keyChanged = current !== key const ownerChanged = owner !== node.id @@ -94,6 +92,12 @@ export function claimNodeKey(node: SceneNode, key: string): boolean { return keyChanged || ownerChanged } +export function assertNodeKey(id: string, key: string, current: string | undefined): void { + if (current && current !== key) { + specError(`Node "${id}" is already owned by canvas key "${current}".`) + } +} + export function assertAuthoringKey( id: string, key: string, diff --git a/packages/extension/mcp/tools/canvas/index.ts b/packages/extension/mcp/tools/canvas/index.ts index 3e6d24ff..5d4c5670 100644 --- a/packages/extension/mcp/tools/canvas/index.ts +++ b/packages/extension/mcp/tools/canvas/index.ts @@ -8,7 +8,7 @@ import { ApplyCanvasParametersSchema, TEMPAD_MCP_ERROR_CODES } from '@tempad-dev import type { DesignSystemCatalog } from '../design-system-catalog' -import { createCodedError } from '../../errors' +import { canvasAbortedError, coerceToolErrorPayload, createCodedError } from '../../errors' import { errorMessage, formatSchemaError, specError } from './errors' import { parseCanvasMarkup } from './markup' import { collectUpdateNodeTypeHints, reconcileCanvas } from './reconcile' @@ -46,53 +46,67 @@ export async function applyResolvedCanvas( ): Promise { applyInProgress = true try { - let preparedTheme + let parsedInput try { - preparedTheme = await prepareCanvasTheme(input, catalog) - } catch (error) { - if (error instanceof Error && 'code' in error) throw error - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, - errorMessage(error, 'Canvas theme could not be prepared.') - ) - } - const existingNodeTypes = - input.mode === 'update' && input.markup !== undefined - ? await collectUpdateNodeTypeHints(input.targetNodeId!) - : undefined - const parsedInput = parseSpec(() => { - if (input.mode === 'remove' && input.targetNodeId) { - return { mode: 'remove' as const, targetNodeId: input.targetNodeId, root: null } - } - if (input.markup === undefined) { - if (input.mode === 'update' && input.targetNodeId) { + const preparedTheme = await prepareCanvasTheme(input, catalog) + const existingNodeTypes = + input.mode === 'update' && input.markup !== undefined + ? await collectUpdateNodeTypeHints(input.targetNodeId!) + : undefined + parsedInput = parseSpec(() => { + if (input.mode === 'remove' && input.targetNodeId) { + return { mode: 'remove' as const, targetNodeId: input.targetNodeId, root: null } + } + if (input.markup === undefined) { + if (input.mode === 'update' && input.targetNodeId) { + return { + mode: 'update' as const, + targetNodeId: input.targetNodeId, + bindings: input.bindings!, + ...(input.assets === undefined ? {} : { assets: input.assets }), + ...(input.styles === undefined ? {} : { styles: input.styles }), + ...(input.variableCollections === undefined + ? {} + : { variableCollections: input.variableCollections }) + } + } + if (!input.page) throw new Error('A page identity is required for this operation.') return { - mode: 'update' as const, - targetNodeId: input.targetNodeId, - bindings: input.bindings!, - ...(input.assets === undefined ? {} : { assets: input.assets }), - ...(input.styles === undefined ? {} : { styles: input.styles }), - ...(input.variableCollections === undefined - ? {} - : { variableCollections: input.variableCollections }) + mode: input.mode, + page: input.page, + ...(input.selection === undefined ? {} : { selection: input.selection }) } } - if (!input.page) throw new Error('A page identity is required for this operation.') - return { - mode: input.mode, - page: input.page, - ...(input.selection === undefined ? {} : { selection: input.selection }) - } - } - return parseCanvasMarkup(input, catalog, existingNodeTypes, preparedTheme) - }, 'Canvas markup is invalid.') - return await reconcileCanvas(parsedInput) - } catch (error) { - if (error instanceof Error && 'code' in error) throw error - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, - errorMessage(error, 'Canvas apply failed.') - ) + return parseCanvasMarkup(input, catalog, existingNodeTypes, preparedTheme) + }, 'Canvas markup is invalid.') + } catch (error) { + // Theme hydration and markup parsing only read existing local resources. + throw canvasAbortedError(error) + } + try { + return await reconcileCanvas(parsedInput) + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED + ) + throw error + // Resource preflight can import library resources before the Undo boundary. + // Even a later aborted preparation step cannot prove that nothing changed. + const payload = coerceToolErrorPayload(error) + const diagnostic = + payload.code && payload.code !== TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED + ? ` [${payload.code}]` + : '' + throw Object.assign( + createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + `${payload.message}${diagnostic}` + ), + { cause: error } + ) + } } finally { applyInProgress = false } @@ -101,9 +115,14 @@ export async function applyResolvedCanvas( export async function handleApplyCanvas( args?: ApplyCanvasParametersInput ): Promise { - assertCanvasAvailable() - const parsed = ApplyCanvasParametersSchema.safeParse(args) - if (!parsed.success) specError(formatSchemaError(parsed.error)) - const resolved = parseSpec(() => resolveCanvasInput(parsed.data)) + let resolved: ReturnType + try { + assertCanvasAvailable() + const parsed = ApplyCanvasParametersSchema.safeParse(args) + if (!parsed.success) specError(formatSchemaError(parsed.error)) + resolved = parseSpec(() => resolveCanvasInput(parsed.data)) + } catch (error) { + throw canvasAbortedError(error) + } return applyResolvedCanvas(resolved.input, resolved.catalog) } diff --git a/packages/extension/mcp/tools/canvas/markup.ts b/packages/extension/mcp/tools/canvas/markup.ts index f5742443..e12bb490 100644 --- a/packages/extension/mcp/tools/canvas/markup.ts +++ b/packages/extension/mcp/tools/canvas/markup.ts @@ -453,7 +453,7 @@ function assertStaticMarkupLegality( function textContent(value: string, preserve: boolean, lineBreakOffsets: number[]): string { const normalize = preserve ? (segment: string) => segment - : (segment: string) => segment.replace(/[\t\n\f\r ]+/g, ' ').trim() + : (segment: string) => segment.replace(/[\t\n\f\r ]+/g, ' ').replace(/^ | $/g, '') if (!lineBreakOffsets.length) return normalize(value) let start = 0 @@ -1078,6 +1078,8 @@ function fillStrokeAppearance( } } +const errorsWithSourceClasses = new WeakSet() + function compileElement( element: CanvasMarkupElement, state: CompileState, @@ -1089,8 +1091,10 @@ function compileElement( try { return compileElementUnchecked(element, state, depth, parent, gridPlacement, insideComponent) } catch (error) { - if (error instanceof Error) + if (error instanceof Error && !errorsWithSourceClasses.has(error)) { error.message = sourceClassMessage(element.sourceClasses, error.message) + errorsWithSourceClasses.add(error) + } throw error } } @@ -1870,7 +1874,9 @@ function compileElementUnchecked( insideComponent || type === 'COMPONENT' || type === 'COMPONENT_SET' || type === 'SLOT' if (placement && liveGrid) { const childKeys = new Set(element.children.map((child) => child.attributes['data-key'])) - const childIds = new Set(element.children.map((child) => child.attributes['data-node-id'])) + const childIds = new Set( + element.children.map((child) => child.attributes['data-node-id']?.trim()) + ) for (const child of liveGrid.children) { if ( (child.key && (childKeys.has(child.key) || state.removedKeys.has(child.key))) || @@ -1953,6 +1959,18 @@ export function parseCanvasMarkup( : { variableCollections: input.variableCollections }), ...(input.styles === undefined ? {} : { styles: input.styles }), ...(input.assets === undefined ? {} : { assets: input.assets }), + ...(parsedElement.hasUnknownEntities + ? { + warnings: [ + { + code: 'unknown-html-entity', + message: + 'Unknown named HTML entities were kept as literal text. Use Unicode characters or numeric character references for the intended glyphs.', + key: root.key + } + ] + } + : {}), root } } diff --git a/packages/extension/mcp/tools/canvas/media.ts b/packages/extension/mcp/tools/canvas/media.ts index d01ee1ba..89955e9e 100644 --- a/packages/extension/mcp/tools/canvas/media.ts +++ b/packages/extension/mcp/tools/canvas/media.ts @@ -123,7 +123,7 @@ async function resolveVideoUrls(state: CanvasMediaState): Promise { bytes = await readVideoBytes(response) } catch { throw createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, 'Video download failed. Use a directly accessible public HTTPS video up to 100MB; redirects, HTTP errors, oversized responses, and timeouts are rejected.' ) } diff --git a/packages/extension/mcp/tools/canvas/model.ts b/packages/extension/mcp/tools/canvas/model.ts index f917a216..c4f23a8c 100644 --- a/packages/extension/mcp/tools/canvas/model.ts +++ b/packages/extension/mcp/tools/canvas/model.ts @@ -1,4 +1,5 @@ import type { + ApplyCanvasResult, CanvasAssets, CanvasBinding, CanvasComponentPropertyValue, @@ -214,6 +215,7 @@ type ParsedCanvasCommon = { export type ParsedCanvasTreeInput = ParsedCanvasCommon & { root: CanvasNodeSpec + warnings?: ApplyCanvasResult['verification']['warnings'] } export type ParsedCanvasNativeUpdateInput = { diff --git a/packages/extension/mcp/tools/canvas/reconcile.ts b/packages/extension/mcp/tools/canvas/reconcile.ts index a3630493..f8adb563 100644 --- a/packages/extension/mcp/tools/canvas/reconcile.ts +++ b/packages/extension/mcp/tools/canvas/reconcile.ts @@ -1,8192 +1,66 @@ -import { - type ApplyCanvasResult, - type CanvasBinding, - type CanvasComponentPropertyValue, - type CanvasDesignReference, - type CanvasFigmaComponentPropertyDefinition, - type CanvasFigmaEffect, - type CanvasFigmaLayoutGrid, - type CanvasFigmaPaint, - type CanvasFigmaShaderPropertyValue, - type CanvasFigmaSlotProperty, - type CanvasFigmaTextRange, - type CanvasFigmaVectorNetwork, - type CanvasHyperlink, - type CanvasPageProperties, - type CanvasPageSnapshot, - type CanvasStyleBindings, - type CanvasStyleReference, - type CanvasStyleResource, - type CanvasVariableBindings, - type CanvasVariableReference, - MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES, - MCP_TOOL_INLINE_BUDGET_BYTES, - buildApplyCanvasToolResult, - measureCallToolResultBytes, - TEMPAD_MCP_ERROR_CODES -} from '@tempad-dev/shared' +import type { ApplyCanvasResult } from '@tempad-dev/shared' -import type { - CanvasGridHint, - CanvasGridLayout, - CanvasGridTrack, - CanvasNodeTypeHints, - CanvasNodeSpec, - CanvasSizingMode, - CanvasPreservedNodeType, - ParsedCanvasInput, - ParsedCanvasNativeUpdateInput, - ParsedCanvasPageInput, - ParsedCanvasTreeInput -} from './model' +import type { ParsedCanvasInput } from './model' -import { createCodedError } from '../../errors' -import { - getContainingPage, - getCurrentContextNodeById, - getLocalEffectStyles, - getLocalPaintStyles, - getMainComponent, - getNodeById, - getVariableCollectionById -} from '../../local-resources' -import { - type ResolvedCanvasAssets, - resolveCanvasAssets, - resolvedSvgAsset, - SVG_POLICY_VERSION -} from './assets' -import { applyError, canvasReadOnlyError, errorMessage, scopeError, specError } from './errors' -import { reportCanvasPlacement } from './feedback' -import { - type CanvasFontState, - createFontState, - loadFont, - loadFonts, - resolveFamilyFont, - resolvePortableFont, - currentTextFonts -} from './fonts' +import { resolveCanvasAssets } from './assets' +import { scopeError, specError } from './errors' +import { pageSnapshot } from './identity' +import { applyMask } from './reconcile/appearance' +import { finalizeGridsAfterRemovals } from './reconcile/grid' +import { placeCreatedRoot, finalizeAbsolutePositions } from './reconcile/layout' +import { reconcileNativeUpdate } from './reconcile/native-update' import { - CANVAS_COUNTER_AXIS_SYNC_NAME, - CANVAS_KEY_NAMESPACE, - CANVAS_NODE_KEY_NAME, - CANVAS_NODE_OWNER_NAME, - CANVAS_PAGE_KEY_NAME, - type MutationCounter, - claimNodeKey, - designReferenceCacheKey, - pageById, - pageSnapshot, - pagesByKey, - readOwnedNodeKey -} from './identity' -import { validateAssetReferences } from './markup' + lookupNodeById, + isSupportedSceneNode, + assertOutsideInstance, + collectDesiredKeys, + resolveExplicitNodes, + preflightExistingNodeIdentities, + walkSpecs, + outermostNodes +} from './reconcile/nodes' import { - type CanvasMediaState, - collectCanvasMedia, - createMediaState, - importCanvasMedia -} from './media' + reconcilePageOperation, + prepareResultPage, + applyResultPage, + applyPage +} from './reconcile/pages' import { - isCanvasNodeType, - isFrameContainerType, - isIntrinsicContainer, - isPreservedNodeType -} from './model' + removeUpdateRoot, + resolveRemovalNodes, + loadRemovalPages, + validateRemovalComponents, + applyRemovals +} from './reconcile/removals' import { - type CanvasStyleState, - createStyleState, - prepareStyleResources, - preflightStyleDeclarations, - removeStyleResources, - resolveStyle -} from './styles' -import { normalizePortableFontStyle } from './tailwind' + preflightVectorPaths, + preflightVariableModes, + preflightResources, + hasDeclaredResources, + preflightDeclaredResources, + prepareMedia, + prepareResources, + applyStyleResources, + removeResources +} from './reconcile/resources' +import { boundedApplyResult, buildVerification } from './reconcile/results' +import { type SupportedCanvasNode, createApplyState } from './reconcile/state' import { - isComponentPropertyOwner, - isInsideInstance, - walkAuthoringNodes, - walkPhysicalNodes -} from './traversal' + preflightMasks, + preflightContainers, + preflightMovedChildren +} from './reconcile/structure-preflight' +import { collectSvgColors, finalizeSvgPositions } from './reconcile/svg' +import { reconcileNode, applyCanvasKeyReferences } from './reconcile/tree' +import { withUndoBoundary } from './reconcile/undo' +import { preloadFirstPageModeFonts } from './reconcile/variable-modes' +import { verifyAppliedNode } from './reconcile/verification' import { - type CanvasVariableState, - createVariableState, - preflightVariableDeclarations, - reconcileVariableCollections, - removeVariableResources, - resolveCollection, - resolveModeId, - resolvedCollection, - resolvedModeId, - resolvedVariable, - resolveVariable, - variableReferenceCacheKey -} from './variables' -import { canonicalVectorPaths, vectorPathsEqual } from './vector' - -const CANVAS_COMPONENT_PROPERTY_KEYS_NAME = 'component-property-keys' -const CANVAS_SVG_CHILD_NAME = 'svg-child' -const CANVAS_SVG_COLOR_NAME = 'svg-color' -const CANVAS_SVG_DIGEST_NAME = 'svg-digest' -const CANVAS_SVG_POLICY_NAME = 'svg-policy' -const ROOT_PLACEMENT_GAP = 80 -const GEOMETRY_TOLERANCE = 0.01 -const CONTENT_OVERFLOW_TOLERANCE = 0.5 -type SupportedCanvasNode = Extract -type CanvasFrameContainerNode = ComponentNode | ComponentSetNode | FrameNode | SlotNode -type CanvasParentNode = - | BooleanOperationNode - | ComponentNode - | ComponentSetNode - | FrameNode - | GroupNode - | PageNode - | SectionNode - | SlotNode -type IntrinsicContainerNode = BooleanOperationNode | GroupNode -type WrappedContainerNode = ComponentSetNode | IntrinsicContainerNode -type WrappedContainerSpec = CanvasNodeSpec & { type: WrappedContainerNode['type'] } -type ComponentPropertyOwner = ComponentNode | ComponentSetNode -type ComponentPropertyReferenceField = 'characters' | 'mainComponent' | 'visible' -type ComponentPropertyContext = { - existing?: ComponentPropertyOwner - spec?: CanvasNodeSpec -} - -type ProtectedNodeSnapshot = { - content: string - childIds: string[] | null - componentPropertyNames: string[] | null - geometry: { height: number; width: number; x: number; y: number } | null - key: string - owner: string - parentId: string | null - type: BaseNode['type'] -} - -type ApplyState = { - media: CanvasMediaState - claimedNodeIds: Set - componentCache: Map - componentPropertyKeys: Map> - createdNodeIds: Set - createdPageIds: Set - desiredKeys: Set - explicitNodes: Map - fonts: CanvasFontState - ignoredLiteralWarnings: VerificationWarning[] - keyedNodes: Map - mutations: MutationCounter - nodeIdsByKey: Record - pendingGridRemovalCleanupNodeIds: Set - protectedNodes: Map - removalNodeIds: Set - referencedNodeIds: Set - scope: SupportedCanvasNode | null - shaderCache: Map - stabilizedCrossAxisFillNodeIds: Set - styles: CanvasStyleState - updatedNodeIds: Set - variables: CanvasVariableState -} - -function isSupportedSceneNode(node: BaseNode | null): node is SupportedCanvasNode { - return !!node && isCanvasNodeType(node.type) -} - -function isSceneNode(node: BaseNode | null): node is SceneNode { - return !!node && 'x' in node && 'y' in node -} - -async function lookupNodeById(id: string): Promise { - const node = getCurrentContextNodeById(id) ?? (await getNodeById(id)) - return node && !node.removed ? node : null -} - -export async function collectUpdateNodeTypeHints( - targetNodeId: string -): Promise { - const target = await lookupNodeById(targetNodeId) - if (!isSupportedSceneNode(target)) return undefined - - const byKey = new Map() - const byNodeId = new Map() - const keys = new Set() - const grids = new Map() - for (const node of walkAuthoringNodes([target])) { - const key = readOwnedNodeKey(node) - if (key) keys.add(key) - if (isSupportedSceneNode(node) && isFrameContainer(node) && node.layoutMode === 'GRID') { - const grid = { - itemsPositioning: node.gridItemsPositioning, - children: node.children.flatMap((child) => - 'gridRowAnchorIndex' in child && child.layoutPositioning !== 'ABSOLUTE' - ? [ - { - id: child.id, - key: readOwnedNodeKey(child), - row: child.gridRowAnchorIndex, - column: child.gridColumnAnchorIndex, - rowSpan: child.gridRowSpan, - columnSpan: child.gridColumnSpan - } - ] - : [] - ) - } - grids.set(node.id, grid) - if (key) grids.set(key, grid) - } - if (!isPreservedNodeType(node.type)) continue - const type = node.type - byNodeId.set(node.id, type) - if (key && !byKey.has(key)) byKey.set(key, type) - } - - const root = isPreservedNodeType(target.type) ? target.type : undefined - return { byKey, byNodeId, keys, grids, ...(root ? { root } : {}) } -} - -function snapshotProtectedNode(node: BaseNode): ProtectedNodeSnapshot { - return { - // Serialize copied native values so a later in-place paint change cannot also - // mutate the expected snapshot. This detects content loss, not a full undo log. - content: JSON.stringify({ - name: node.name, - ...(node.type === 'PAGE' - ? { - backgrounds: node.backgrounds, - guides: node.guides, - explicitVariableModes: node.explicitVariableModes, - index: figma.root.children.indexOf(node) - } - : {}), - ...('visible' in node ? { visible: node.visible, locked: node.locked } : {}), - ...('fills' in node - ? { - fills: node.fills, - ...('fillStyleId' in node ? { fillStyleId: node.fillStyleId } : {}) - } - : {}), - ...('strokes' in node ? { strokes: node.strokes, strokeStyleId: node.strokeStyleId } : {}), - ...('effects' in node ? { effects: node.effects, effectStyleId: node.effectStyleId } : {}), - ...('opacity' in node ? { opacity: node.opacity } : {}), - ...('relativeTransform' in node ? { relativeTransform: node.relativeTransform } : {}), - ...('clipsContent' in node ? { clipsContent: node.clipsContent } : {}), - ...('minWidth' in node - ? { - minWidth: node.minWidth, - maxWidth: node.maxWidth, - minHeight: node.minHeight, - maxHeight: node.maxHeight - } - : {}), - ...('targetAspectRatio' in node ? { targetAspectRatio: node.targetAspectRatio } : {}), - ...('layoutSizingHorizontal' in node - ? { - layoutSizingHorizontal: node.layoutSizingHorizontal, - layoutSizingVertical: node.layoutSizingVertical - } - : {}), - ...('layoutPositioning' in node - ? { layoutPositioning: node.layoutPositioning, layoutGrow: node.layoutGrow } - : {}), - ...('layoutMode' in node - ? { - layoutMode: node.layoutMode, - primaryAxisSizingMode: node.primaryAxisSizingMode, - counterAxisSizingMode: node.counterAxisSizingMode, - primaryAxisAlignItems: node.primaryAxisAlignItems, - counterAxisAlignItems: node.counterAxisAlignItems, - counterAxisAlignContent: node.counterAxisAlignContent, - layoutWrap: node.layoutWrap, - itemSpacing: node.itemSpacing, - counterAxisSpacing: node.counterAxisSpacing, - paddingTop: node.paddingTop, - paddingRight: node.paddingRight, - paddingBottom: node.paddingBottom, - paddingLeft: node.paddingLeft, - strokesIncludedInLayout: node.strokesIncludedInLayout, - itemReverseZIndex: node.itemReverseZIndex, - ...(node.layoutMode === 'GRID' - ? { - gridRowCount: node.gridRowCount, - gridColumnCount: node.gridColumnCount, - gridRowSizes: node.gridRowSizes, - gridColumnSizes: node.gridColumnSizes, - gridRowGap: node.gridRowGap, - gridColumnGap: node.gridColumnGap, - gridAutoTracks: node.gridAutoTracks, - gridItemsPositioning: node.gridItemsPositioning - } - : {}) - } - : {}), - ...('gridRowAnchorIndex' in node && - node.parent && - 'layoutMode' in node.parent && - node.parent.layoutMode === 'GRID' - ? { - gridRowAnchorIndex: node.gridRowAnchorIndex, - gridColumnAnchorIndex: node.gridColumnAnchorIndex, - gridRowSpan: node.gridRowSpan, - gridColumnSpan: node.gridColumnSpan, - gridChildHorizontalAlign: node.gridChildHorizontalAlign, - gridChildVerticalAlign: node.gridChildVerticalAlign - } - : {}), - ...(node.type === 'TEXT' - ? { - characters: node.characters, - fontName: node.fontName, - fontSize: node.fontSize, - textStyleId: node.textStyleId - } - : {}) - }), - childIds: 'children' in node ? node.children.map((child) => child.id) : null, - componentPropertyNames: isComponentPropertyOwner(node) - ? Object.keys(node.componentPropertyDefinitions).sort() - : null, - geometry: isSceneNode(node) - ? { height: node.height, width: node.width, x: node.x, y: node.y } - : null, - key: node.getSharedPluginData( - CANVAS_KEY_NAMESPACE, - node.type === 'PAGE' ? CANVAS_PAGE_KEY_NAME : CANVAS_NODE_KEY_NAME - ), - owner: node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_OWNER_NAME), - parentId: node.parent?.id ?? null, - type: node.type - } -} - -function protectNode(state: ApplyState, node: BaseNode, snapshot = true): void { - if (state.createdNodeIds.has(node.id) || state.createdPageIds.has(node.id)) return - if (snapshot && !state.protectedNodes.get(node.id)) { - state.protectedNodes.set(node.id, snapshotProtectedNode(node)) - } else if (!state.protectedNodes.has(node.id)) { - state.protectedNodes.set(node.id, null) - } -} - -function topLevelPageChild(node: BaseNode, page: PageNode): BaseNode | null { - let current: BaseNode | null = node - while (current?.parent && current.parent.id !== page.id) current = current.parent - return current?.parent?.id === page.id ? current : null -} - -function protectUnrelatedPageRoots(state: ApplyState, page: PageNode): void { - const mutableRoot = state.scope ? topLevelPageChild(state.scope, page) : null - for (const child of page.children) { - if (child.id !== mutableRoot?.id) protectNode(state, child) - } -} - -function isMaskNode(node: SceneNode): boolean { - return 'isMask' in node && node.isMask -} - -function isWrappedSpec(spec: CanvasNodeSpec): spec is WrappedContainerSpec { - return spec.type === 'BOOLEAN_OPERATION' || spec.type === 'COMPONENT_SET' || spec.type === 'GROUP' -} - -function isIntrinsicNode(node: SupportedCanvasNode): node is IntrinsicContainerNode { - return isIntrinsicContainer(node.type) -} - -function isFrameContainer( - node: SupportedCanvasNode | CanvasParentNode -): node is CanvasFrameContainerNode { - return isFrameContainerType(node.type) -} - -function isWithinScope(node: BaseNode, scope: BaseNode): boolean { - let current: BaseNode | null = node - while (current) { - if (current.id === scope.id) return true - current = current.parent - } - return false -} - -function assertOutsideInstance(node: BaseNode): void { - if (isInsideInstance(node)) { - scopeError(`Node "${node.id}" is inside an instance and cannot be targeted by apply_canvas.`) - } -} - -function containingPage(node: BaseNode): PageNode { - const page = getContainingPage(node) - if (page) return page - scopeError(`Node "${node.id}" is not attached to a page.`) -} - -function pageByKey(key: string): PageNode | undefined { - const matches = pagesByKey(key) - if (matches.length > 1) specError(`Page key "${key}" identifies more than one local page.`) - return matches[0] -} - -async function prepareResultPage( - properties: CanvasPageProperties | undefined, - target: SupportedCanvasNode | null -): Promise { - const containing = target ? containingPage(target) : figma.currentPage - const id = properties?.id - const key = properties?.pageKey - const explicit = id ? pageById(id) : undefined - if (id && !explicit) specError(`Page "${id}" does not exist.`) - const keyed = key ? pageByKey(key) : undefined - if (explicit && keyed && explicit.id !== keyed.id) { - specError(`Page key "${key}" does not identify "${explicit.id}".`) - } - - let page = explicit ?? keyed - if (target) { - if (page && page.id !== containing.id) { - scopeError(`The update target belongs to page "${containing.id}", not "${page.id}".`) - } - page = containing - } - const createsPage = !page && key !== undefined - if (createsPage && properties?.name === undefined) { - specError(`New page "${key}" requires a name.`) - } - const index = properties?.index - const maxIndex = figma.root.children.length - (createsPage ? 0 : 1) - if (index !== undefined && index > maxIndex) { - specError(`Page index ${index} exceeds the maximum index ${maxIndex}.`) - } - if (createsPage) return undefined - page ??= containing - if (page.id !== figma.currentPage.id) await page.loadAsync() - const currentKey = key ? page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) : '' - if (key && currentKey && currentKey !== key) { - specError(`Page "${page.id}" is already owned by authoring key "${currentKey}".`) - } - return page -} - -function applyResultPage( - page: PageNode | undefined, - properties: CanvasPageProperties | undefined, - state: ApplyState -): PageNode { - if (!page) { - page = figma.createPage() - state.createdPageIds.add(page.id) - state.mutations.count += 1 - } else { - protectNode(state, page) - if (!state.scope) protectUnrelatedPageRoots(state, page) - } - const key = properties?.pageKey - const currentKey = key ? page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) : '' - // Other preflight work can yield after the page was prepared. - if (key && currentKey && currentKey !== key) { - specError(`Page "${page.id}" is already owned by authoring key "${currentKey}".`) - } - if (key && !currentKey) { - page.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME, key) - markMutation(state, page) - } - return page -} - -function collectKeyedNodes(scope: SupportedCanvasNode): Map { - const keyed = new Map() - for (const node of walkAuthoringNodes([scope])) { - if (!isSupportedSceneNode(node)) continue - const key = readOwnedNodeKey(node) - if (!key) continue - if (keyed.has(key)) { - scopeError( - `Canvas key "${key}" is duplicated on nodes "${keyed.get(key)?.id}" and "${node.id}" inside the update scope. Narrow targetNodeId to one managed subtree, or ask the user to remove the duplicate.` - ) - } - keyed.set(key, node) - } - return keyed -} - -function* walkSpecs(spec: CanvasNodeSpec): Generator { - yield spec - for (const child of spec.children ?? []) yield* walkSpecs(child) -} - -function collectDesiredKeys(root: CanvasNodeSpec): Set { - return new Set([...walkSpecs(root)].map((spec) => spec.key)) -} - -async function resolveExplicitNodes(root: CanvasNodeSpec, state: ApplyState): Promise { - const ids = new Set() - for (const spec of walkSpecs(root)) { - if (spec.nodeId) ids.add(spec.nodeId) - } - const orderedIds = [...ids] - const nodes = await Promise.all(orderedIds.map(lookupNodeById)) - for (const [index, id] of orderedIds.entries()) { - const node = nodes[index] ?? null - const supported = isSupportedSceneNode(node) ? node : null - if (supported) assertOutsideInstance(supported) - state.explicitNodes.set(id, supported) - } -} - -type CanvasNodeReference = { nodeId: string } | { canvasKey: string } - -function preflightRetainedNode(node: BaseNode, state: ApplyState): void { - for (let ancestor: BaseNode | null = node; ancestor; ancestor = ancestor.parent) { - if (state.removalNodeIds.has(ancestor.id)) { - specError(`Referenced node "${node.id}" would be removed by this result.`) - } - // A desired ancestor can carry this reference out of a removed subtree. - if ( - state.explicitNodes.has(ancestor.id) || - (isSceneNode(ancestor) && state.desiredKeys.has(readOwnedNodeKey(ancestor) ?? '')) - ) - return - } -} - -async function preflightNodeReference( - reference: CanvasNodeReference, - context: string, - state: ApplyState, - sceneOnly = false -): Promise { - if ('canvasKey' in reference) { - if (!state.desiredKeys.has(reference.canvasKey) && !state.keyedNodes.has(reference.canvasKey)) { - specError( - `${context} canvas key "${reference.canvasKey}" does not exist in the desired result or update scope.` - ) - } - const existing = state.keyedNodes.get(reference.canvasKey) - if (existing) preflightRetainedNode(existing, state) - return - } - const node = await lookupNodeById(reference.nodeId) - if (!node || (sceneOnly && !isSceneNode(node))) { - specError( - `${context} "${reference.nodeId}" does not exist${sceneOnly ? ' or is not a scene node' : ''}.` - ) - } - preflightRetainedNode(node, state) - protectNode(state, node) - state.referencedNodeIds.add(node.id) -} - -async function preflightHyperlink( - hyperlink: CanvasHyperlink | undefined, - key: string, - state: ApplyState -): Promise { - if (hyperlink?.type !== 'NODE') return - await preflightNodeReference( - typeof hyperlink.value === 'string' - ? { nodeId: hyperlink.value } - : { canvasKey: hyperlink.value.canvasKey }, - `Hyperlink target on "${key}"`, - state - ) -} - -function resolveCanvasKey(key: string, state: ApplyState): SupportedCanvasNode { - const node = state.keyedNodes.get(key) ?? null - if (!isSupportedSceneNode(node)) { - specError(`Canvas key "${key}" did not resolve to a reconciled scene node.`) - } - state.referencedNodeIds.add(node.id) - return node -} - -function outermostNodes(nodes: SupportedCanvasNode[]): SupportedCanvasNode[] { - const ids = new Set(nodes.map((node) => node.id)) - return nodes.filter((node) => { - let parent = node.parent - while (parent) { - if (ids.has(parent.id)) return false - parent = parent.parent - } - return true - }) -} - -function* walkRemovalOwnershipNodes( - roots: Iterable, - state: ApplyState -): Generator { - const stack = [...roots] - while (stack.length) { - const node = stack.pop()! - const svgWrapper = isOwnedSvgChild(node) && node.parent?.type === 'FRAME' ? node.parent : null - const wrapperKey = svgWrapper && readOwnedNodeKey(svgWrapper) - if (wrapperKey && state.keyedNodes.get(wrapperKey)?.id === svgWrapper.id) continue - yield node - if ('children' in node && node.type !== 'INSTANCE') stack.push(...node.children) - } -} - -function validateRemovalOwnership(root: SupportedCanvasNode, state: ApplyState): void { - for (const node of walkRemovalOwnershipNodes([root], state)) { - if (!isSupportedSceneNode(node)) { - scopeError(`Removing "${root.id}" would also remove an unsupported canvas node.`) - } - const key = readOwnedNodeKey(node) - if (!key || state.keyedNodes.get(key)?.id !== node.id) { - scopeError(`Removing "${root.id}" would also remove a node not owned by apply_canvas.`) - } - } -} - -function validateRemovalAncestors(node: SupportedCanvasNode): void { - let ancestor = node.parent - while (ancestor) { - if ((ancestor.type === 'COMPONENT' || ancestor.type === 'COMPONENT_SET') && ancestor.remote) { - scopeError(`Remote ${ancestor.type.toLowerCase()} "${ancestor.id}" is read-only.`) - } - ancestor = ancestor.parent - } -} - -function resolveRemovalNodes( - input: ParsedCanvasTreeInput, - state: ApplyState -): SupportedCanvasNode[] { - const nodes: SupportedCanvasNode[] = [] - for (const key of input.removeKeys) { - const node = state.keyedNodes.get(key) - if (!node) continue - if (node.id === state.scope?.id) { - scopeError('The update root cannot be removed.') - } - validateRemovalAncestors(node) - validateRemovalOwnership(node, state) - state.removalNodeIds.add(node.id) - nodes.push(node) - } - return nodes -} - -function collectRemovalComponents(roots: SupportedCanvasNode[]): ComponentNode[] { - const components: ComponentNode[] = [] - for (const node of walkAuthoringNodes(roots)) { - if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { - if (node.remote) { - scopeError(`Remote ${node.type.toLowerCase()} "${node.id}" cannot be removed.`) - } - if (node.type === 'COMPONENT') components.push(node) - } - } - return components -} - -async function validateRemovalComponents(roots: SupportedCanvasNode[]): Promise { - for (const component of collectRemovalComponents(roots)) { - const instances = await component.getInstancesAsync() - if ( - instances.some( - (instance) => !instance.removed && !roots.some((root) => isWithinScope(instance, root)) - ) - ) { - scopeError(`Component "${component.id}" has instances outside the removal scope.`) - } - } -} - -type RemovalReferences = { - removed: { componentKeys: Set; nodeIds: Set } - shaders: Array -} - -function validateRemovalReference(value: string, removed: Set, kind: string): void { - if (removed.has(value)) { - scopeError(`${kind} "${value}" is still referenced outside the removal scope.`) - } -} - -function collectReferences(value: unknown, references: RemovalReferences): void { - if (Array.isArray(value)) { - value.forEach((item) => collectReferences(item, references)) - return - } - if (!value || typeof value !== 'object') return - const record = value as Record - if (record.type === 'PATTERN' && typeof record.sourceNodeId === 'string') { - validateRemovalReference(record.sourceNodeId, references.removed.nodeIds, 'Node') - } else if (record.type === 'NODE' && typeof record.value === 'string') { - validateRemovalReference(record.value, references.removed.nodeIds, 'Node') - } else if (record.type === 'SHADER' && typeof record.id === 'string') { - references.shaders.push(value as ShaderEffect | ShaderPaint) - } - Object.values(record).forEach((item) => collectReferences(item, references)) -} - -function collectComponentReferences( - properties: ComponentProperties | ComponentPropertyDefinitions, - references: RemovalReferences -): void { - for (const property of Object.values(properties)) { - if (property.type === 'INSTANCE_SWAP') { - const value = 'defaultValue' in property ? property.defaultValue : property.value - if (typeof value === 'string') { - validateRemovalReference(value, references.removed.nodeIds, 'Node') - } - } - for (const preferred of property.preferredValues ?? []) { - validateRemovalReference(preferred.key, references.removed.componentKeys, 'Component key') - } - } -} - -function collectSceneReferences(node: SceneNode, references: RemovalReferences): void { - const record = node as unknown as Record - collectReferences(record.fills, references) - collectReferences(record.strokes, references) - collectReferences(record.effects, references) - if (node.type === 'VECTOR') { - collectReferences(node.vectorNetwork.regions, references) - } - if (isComponentPropertyOwner(node)) { - collectComponentReferences(node.componentPropertyDefinitions, references) - } else if (node.type === 'INSTANCE') { - collectComponentReferences(node.componentProperties, references) - } - if (node.type !== 'TEXT') return - collectReferences(node.hyperlink, references) - if (node.hyperlink !== figma.mixed && node.fills !== figma.mixed) return - let segments: Array> - try { - segments = node.getStyledTextSegments(['fills', 'hyperlink']) - } catch { - scopeError(`Rich text on node "${node.id}" could not be inspected before node removal.`) - } - collectReferences(segments, references) -} - -function collectRemovedIdentities(roots: SupportedCanvasNode[]): { - componentKeys: Set - nodeIds: Set -} { - const componentKeys = new Set() - const nodeIds = new Set([...walkPhysicalNodes(roots)].map((node) => node.id)) - for (const node of walkAuthoringNodes(roots)) { - if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') && node.key) { - componentKeys.add(node.key) - } - } - return { componentKeys, nodeIds } -} - -const fullyLoadedRemovalDocuments = new WeakSet() - -async function loadRemovalPages(roots: SupportedCanvasNode[]): Promise { - if ( - !roots.length || - fullyLoadedRemovalDocuments.has(figma.root) || - !figma.root.children.some((page) => page.id !== figma.currentPage.id) - ) - return - try { - await figma.loadAllPagesAsync() - fullyLoadedRemovalDocuments.add(figma.root) - } catch { - scopeError('Document pages could not be inspected before node removal.') - } -} - -async function validateRemovalReferences( - roots: SupportedCanvasNode[], - state: ApplyState -): Promise { - const removed = collectRemovedIdentities(roots) - // Reparenting the last child can auto-delete a Group before this final scan. - // Keep its explicit removal identity without including children rescued from it. - for (const id of state.removalNodeIds) removed.nodeIds.add(id) - if (!removed.nodeIds.size) return - const references: RemovalReferences = { - removed, - shaders: [] - } - await loadRemovalPages(roots) - for (const page of figma.root.children) { - collectReferences(page.backgrounds, references) - for (const node of walkPhysicalNodes(page.children)) { - if (removed.nodeIds.has(node.id)) continue - collectSceneReferences(node, references) - } - } - const removedStyleIds = new Set(state.styles.removals.map(({ style }) => style.id)) - const [paintStyles, effectStyles] = await Promise.all([ - getLocalPaintStyles(), - getLocalEffectStyles() - ]) - for (const style of [...paintStyles, ...effectStyles]) { - if (removedStyleIds.has(style.id)) continue - collectReferences(style.type === 'PAINT' ? style.paints : style.effects, references) - } - for (const usage of references.shaders) { - const candidates = Object.entries(usage.properties ?? {}).filter( - (entry): entry is [string, string] => - typeof entry[1] === 'string' && - (removed.nodeIds.has(entry[1]) || removed.componentKeys.has(entry[1])) - ) - if (!candidates.length) continue - const definitions = (await resolveShader(usage.id, state)).propertyDefinitions ?? {} - for (const [propertyId, value] of candidates) { - const type = definitions[propertyId]?.type - if (type === 'INSTANCE_SWAP' || type === 'SLOT') { - validateRemovalReference(value, removed.nodeIds, 'Node') - validateRemovalReference(value, removed.componentKeys, 'Component key') - } - } - } -} - -function validateRemovalResult(roots: SupportedCanvasNode[], state: ApplyState): void { - for (const root of roots) { - for (const node of walkPhysicalNodes([root])) { - if (state.claimedNodeIds.has(node.id)) { - specError(`Desired node "${node.id}" would remain inside a removed subtree.`) - } - if (state.referencedNodeIds.has(node.id)) { - specError(`Referenced node "${node.id}" would be removed by this result.`) - } - } - } - - const rootsByParent = new Map>() - for (const root of roots) { - const parent = root.parent - if (!parent || !('children' in parent)) continue - const ids = rootsByParent.get(parent) ?? new Set() - ids.add(root.id) - rootsByParent.set(parent, ids) - } - for (const [parent, removedIds] of rootsByParent) { - const remaining = parent.children.filter((child) => !removedIds.has(child.id)) - if ( - parent.children.some(isMaskNode) && - remaining.some((child) => !state.claimedNodeIds.has(child.id)) - ) { - specError( - `Removing a sibling in mask container "${parent.id}" requires every remaining sibling in the desired result.` - ) - } - const last = remaining.at(-1) - if (last && isMaskNode(last)) { - specError(`Mask "${last.id}" must precede at least one remaining sibling.`) - } - if (parent.type === 'GROUP' && remaining.length < 1) { - specError(`Removing these nodes would implicitly remove group "${parent.id}".`) - } - if (parent.type === 'BOOLEAN_OPERATION' && remaining.length < 2) { - specError(`Boolean operation "${parent.id}" requires at least two remaining operands.`) - } - if (parent.type === 'COMPONENT_SET' && remaining.length < 1) { - specError(`Component set "${parent.id}" requires at least one remaining variant.`) - } - } -} - -async function applyRemovals( - removalNodes: SupportedCanvasNode[], - state: ApplyState -): Promise { - const roots = outermostNodes(removalNodes.filter((node) => !node.removed)) - validateRemovalResult(roots, state) - await validateRemovalReferences(roots, state) - removeValidatedNodes(roots, state) - return removalNodes.map((node) => node.id) -} - -function removeValidatedNodes(roots: SupportedCanvasNode[], state: ApplyState): void { - for (const root of roots) { - root.remove() - state.mutations.count += 1 - } -} - -function markMutation(state: ApplyState, node: BaseNode): void { - state.mutations.count += 1 - if (!state.createdNodeIds.has(node.id)) { - state.updatedNodeIds.add(node.id) - } -} - -function setNodeKey(state: ApplyState, node: SupportedCanvasNode, key: string): void { - const changed = claimNodeKey(node, key) - state.keyedNodes.set(key, node) - if (changed) markMutation(state, node) -} - -function componentPropertyOwner(node: BaseNode): ComponentPropertyOwner | null { - let current = node.parent - while (current) { - if (current.type === 'COMPONENT_SET') return current - if (current.type === 'COMPONENT') { - return current.parent?.type === 'COMPONENT_SET' ? current.parent : current - } - current = current.parent - } - return null -} - -function componentDefinitionOwner(component: ComponentNode): ComponentPropertyOwner { - return component.parent?.type === 'COMPONENT_SET' ? component.parent : component -} - -function componentPropertyKeys( - owner: ComponentPropertyOwner, - state: ApplyState -): Record { - const cached = state.componentPropertyKeys.get(owner.id) - if (cached) return cached - const raw = owner.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COMPONENT_PROPERTY_KEYS_NAME) - let keys: Record = Object.create(null) as Record - if (raw) { - try { - const parsed: unknown = JSON.parse(raw) - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - Object.values(parsed).some((value) => typeof value !== 'string') - ) { - throw new Error() - } - keys = Object.assign(Object.create(null) as Record, parsed) - } catch { - scopeError(`Component property identity data on "${owner.id}" is invalid.`) - } - } - state.componentPropertyKeys.set(owner.id, keys) - return keys -} - -function setComponentPropertyKey( - owner: ComponentPropertyOwner, - key: string, - propertyName: string, - state: ApplyState -): void { - const keys = componentPropertyKeys(owner, state) - if (keys[key] === propertyName) return - keys[key] = propertyName - owner.setSharedPluginData( - CANVAS_KEY_NAMESPACE, - CANVAS_COMPONENT_PROPERTY_KEYS_NAME, - JSON.stringify(keys) - ) - markMutation(state, owner) -} - -function componentPropertyName( - owner: ComponentPropertyOwner, - key: string, - state: ApplyState -): string | undefined { - const mapped = componentPropertyKeys(owner, state)[key] - if (mapped) return mapped - return Object.hasOwn(owner.componentPropertyDefinitions, key) ? key : undefined -} - -function findExistingNode( - spec: CanvasNodeSpec, - state: ApplyState, - forcedNode?: SupportedCanvasNode -): SupportedCanvasNode | null { - if (forcedNode) return forcedNode - if (spec.nodeId) { - return state.explicitNodes.get(spec.nodeId) ?? null - } - return state.keyedNodes.get(spec.key) ?? null -} - -function validateExistingNodeIdentity( - spec: CanvasNodeSpec, - state: ApplyState, - node: SupportedCanvasNode -): void { - const keyedNode = state.keyedNodes.get(spec.key) - if (keyedNode && keyedNode.id !== node.id) { - specError( - `Canvas key "${spec.key}" already identifies node "${keyedNode.id}", not "${node.id}".` - ) - } - if (state.scope && !isWithinScope(node, state.scope)) { - scopeError(`Node "${node.id}" is outside the requested update scope.`) - } - if (node.type !== spec.type) { - const recovery = - spec.type === 'INSTANCE' - ? ' A keyed primitive cannot become an instance in place; give the instance a new key and remove the primitive key in the same update.' - : node.type === 'INSTANCE' - ? ' Omit this keyed subtree to preserve the instance in a partial ancestor update, or include its component binding when the instance itself is part of the desired result.' - : '' - specError( - `Canvas key "${spec.key}" expects ${spec.type}, but node "${node.id}" is ${node.type}.${recovery}` - ) - } -} - -function preflightExistingNodeIdentities( - root: CanvasNodeSpec, - state: ApplyState, - target: SupportedCanvasNode | null -): void { - const claimed = new Set() - const visit = (spec: CanvasNodeSpec, forcedNode?: SupportedCanvasNode): void => { - const node = findExistingNode(spec, state, forcedNode) - if (!node) { - if (spec.nodeId) { - scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) - } - } else { - validateExistingNodeIdentity(spec, state, node) - if (claimed.has(node.id)) { - specError(`Node "${node.id}" is referenced more than once in the desired result.`) - } - claimed.add(node.id) - } - for (const child of spec.children ?? []) visit(child) - } - visit(root, target ?? undefined) -} - -function resolveExistingNode( - spec: CanvasNodeSpec, - state: ApplyState, - forcedNode?: SupportedCanvasNode -): SupportedCanvasNode | null { - const node = findExistingNode(spec, state, forcedNode) - if (!node) { - if (spec.nodeId) { - scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) - } - return null - } - - validateExistingNodeIdentity(spec, state, node) - if (state.claimedNodeIds.has(node.id)) { - specError(`Node "${node.id}" is referenced more than once in the desired result.`) - } - state.claimedNodeIds.add(node.id) - return node -} - -async function resolveComponent(reference: CanvasDesignReference, state: ApplyState) { - const cacheKey = designReferenceCacheKey(reference) - const cached = state.componentCache.get(cacheKey) - if (cached) return cached - - let component: ComponentNode | null = null - if (reference.id !== undefined) { - const node = await lookupNodeById(reference.id) - if (node?.type === 'COMPONENT') { - component = node - protectNode(state, node) - } else if (node?.type === 'COMPONENT_SET') { - component = node.defaultVariant - protectNode(state, node) - protectNode(state, component) - } - } else { - try { - component = await figma.importComponentByKeyAsync(reference.key) - } catch { - specError(`Component key "${reference.key}" could not be imported.`) - } - } - - if (!component) { - specError('The requested component could not be resolved.') - } - state.componentCache.set(cacheKey, component) - return component -} - -async function resolveShader(id: string, state: ApplyState): Promise { - const cached = state.shaderCache.get(id) - if (cached) return cached - let shader: Shader - try { - shader = await figma.importShaderById(id) - } catch { - specError(`Shader "${id}" could not be imported.`) - } - state.shaderCache.set(id, shader) - return shader -} - -const STYLE_TYPES = { - fill: 'PAINT', - stroke: 'PAINT', - text: 'TEXT', - effect: 'EFFECT', - grid: 'GRID' -} satisfies Record - -function validateStyleType(field: keyof CanvasStyleBindings, style: BaseStyle, key: string): void { - const expected = STYLE_TYPES[field] - if (style.type !== expected) { - specError( - `Style "${style.id}" for ${field} on "${key}" is ${style.type}, expected ${expected}.` - ) - } -} - -function expectedVariableType(field: keyof CanvasVariableBindings): VariableResolvedDataType { - if (field === 'fill' || field === 'stroke') return 'COLOR' - if (field === 'characters' || field === 'fontFamily' || field === 'fontStyle') return 'STRING' - if (field === 'visible') return 'BOOLEAN' - return 'FLOAT' -} - -function validateVariableType( - field: keyof CanvasVariableBindings, - variable: Pick, - key: string -): void { - validateVariableDataType(variable, expectedVariableType(field), `${field} on "${key}"`) -} - -function validateVariableDataType( - variable: Pick, - expected: VariableResolvedDataType, - usage: string -): void { - if (variable.resolvedType !== expected) { - specError( - `Variable "${variable.id}" for ${usage} is ${variable.resolvedType}, expected ${expected}.` - ) - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function isVariableBinding(value: unknown): value is { variable: CanvasVariableReference } { - return isRecord(value) && 'variable' in value -} - -function collectShaderVariableReferences( - value: CanvasFigmaShaderPropertyValue, - references: CanvasVariableReference[] -): void { - if (!isRecord(value)) return - if (isVariableBinding(value)) { - references.push(value.variable) - return - } - if ('color' in value) { - collectShaderVariableReferences(value.color as CanvasFigmaShaderPropertyValue, references) - } else if ('stops' in value) { - for (const stop of value.stops as Array<{ color: CanvasFigmaShaderPropertyValue }>) { - collectShaderVariableReferences(stop.color, references) - } - } -} - -function shaderPropertyMatches( - type: ShaderPropertyDefinition['type'], - value: CanvasFigmaShaderPropertyValue -): boolean { - if (isVariableBinding(value)) return true - switch (type) { - case 'BOOLEAN': - return typeof value === 'boolean' - case 'TEXT': - case 'IMAGE': - case 'INSTANCE_SWAP': - case 'SLOT': - return typeof value === 'string' - case 'NUMBER': - return typeof value === 'number' - case 'COLOR': - return isRecord(value) && 'r' in value - case 'POINT': - return isRecord(value) && 'x' in value && Object.keys(value).length === 2 - case 'LINE': - return isRecord(value) && 'x2' in value - case 'CIRCLE': - return isRecord(value) && 'radius' in value && !('angle' in value) - case 'CIRCLE_POINT': - return isRecord(value) && 'angle' in value - case 'COLOR_POINT': - return isRecord(value) && 'color' in value - case 'GRADIENT': - return isRecord(value) && 'stops' in value - } -} - -async function preflightEffects( - effects: CanvasFigmaEffect[] | undefined, - key: string, - state: ApplyState -): Promise { - for (const [index, effect] of (effects ?? []).entries()) { - if ('variables' in effect && effect.variables) { - for (const [field, reference] of Object.entries(effect.variables)) { - const variable = await resolveVariable(reference, state.variables) - const expected = field === 'color' ? 'COLOR' : 'FLOAT' - validateVariableDataType(variable, expected, `effect ${index} ${field} on "${key}"`) - } - } - if (effect.type !== 'SHADER') continue - await preflightShader(effect.id, effect.properties, 'effect', key, state) - } -} - -async function preflightShader( - id: string, - properties: Record | undefined, - type: Shader['type'], - key: string, - state: ApplyState -): Promise { - const shader = await resolveShader(id, state) - if (shader.type !== type) { - specError(`Shader "${id}" on "${key}" is a ${shader.type} shader, not a ${type} shader.`) - } - const definitions = shader.propertyDefinitions ?? {} - for (const [propertyId, value] of Object.entries(properties ?? {})) { - const definition = definitions[propertyId] - if (!definition) { - specError(`Shader "${id}" has no property "${propertyId}" on "${key}".`) - } - if (!shaderPropertyMatches(definition.type, value)) { - specError(`Shader property "${propertyId}" on "${key}" expects ${definition.type}.`) - } - const references: CanvasVariableReference[] = [] - collectShaderVariableReferences(value, references) - await Promise.all(references.map((reference) => resolveVariable(reference, state.variables))) - } -} - -async function preflightPaintVariable( - reference: CanvasVariableReference, - field: string, - index: number, - key: string, - state: ApplyState -): Promise { - const variable = await resolveVariable(reference, state.variables) - validateVariableDataType(variable, 'COLOR', `${field} paint ${index} on "${key}"`) -} - -function paintColorVariables(paint: CanvasFigmaPaint): CanvasVariableReference[] { - if (paint.type === 'SOLID' && paint.variables) return [paint.variables.color] - if ('gradientStops' in paint) { - return paint.gradientStops.flatMap((stop) => (stop.variables ? [stop.variables.color] : [])) - } - return [] -} - -function hasCanvasKeyPattern(paints: CanvasFigmaPaint[] | undefined): boolean { - return ( - paints?.some((paint) => paint.type === 'PATTERN' && paint.sourceCanvasKey !== undefined) ?? - false - ) -} - -function hasCanvasKeyPaints(spec: CanvasNodeSpec): boolean { - return hasCanvasKeyPattern(spec.figma?.fills) || hasCanvasKeyPattern(spec.figma?.strokes) -} - -function hasCanvasKeyVectorPattern(spec: CanvasNodeSpec): boolean { - return ( - spec.figma?.shape?.type === 'VECTOR' && - (spec.figma.shape.network?.regions?.some((region) => hasCanvasKeyPattern(region.fills)) ?? - false) - ) -} - -function isCanvasKeyHyperlink( - hyperlink: CanvasHyperlink | undefined -): hyperlink is { type: 'NODE'; value: { canvasKey: string } } { - return hyperlink?.type === 'NODE' && typeof hyperlink.value !== 'string' -} - -function hasDeferredTextRanges(spec: CanvasNodeSpec): boolean { - const ranges = spec.figma?.text?.ranges - return ( - ranges !== undefined && - (hasCanvasKeyPaints(spec) || - isCanvasKeyHyperlink(spec.figma?.text?.hyperlink) || - ranges.some( - (range) => hasCanvasKeyPattern(range.fills) || isCanvasKeyHyperlink(range.hyperlink) - )) - ) -} - -async function preflightPaintSource( - paint: CanvasFigmaPaint, - field: string, - index: number, - key: string, - state: ApplyState -): Promise { - if (paint.type === 'IMAGE') { - if (paint.imageUrl !== undefined) { - if (!state.media.imageUrls.has(paint.imageUrl)) { - state.media.imageUrls.set(paint.imageUrl, `${field} paint ${index} on "${key}"`) - } - } else if (paint.assetKey !== undefined) { - state.media.imageAssetKeys.add(paint.assetKey) - } else if (paint.imageHash && !figma.getImageByHash(paint.imageHash)) { - specError( - `Image "${paint.imageHash}" for ${field} paint ${index} on "${key}" does not exist.` - ) - } - } - if (paint.type === 'VIDEO' && paint.videoUrl !== undefined) { - state.media.videoUrls.add(paint.videoUrl) - } - if (paint.type === 'PATTERN') { - await preflightNodeReference( - paint.sourceCanvasKey - ? { canvasKey: paint.sourceCanvasKey } - : { nodeId: paint.sourceNodeId! }, - `Pattern source for ${field} paint ${index} on "${key}"`, - state, - true - ) - } -} - -async function preflightPaintStack( - paints: CanvasFigmaPaint[] | undefined, - field: string, - key: string, - state: ApplyState -): Promise { - for (const [index, paint] of (paints ?? []).entries()) { - for (const reference of paintColorVariables(paint)) - await preflightPaintVariable(reference, field, index, key, state) - await preflightPaintSource(paint, field, index, key, state) - if (paint.type === 'SHADER') { - await preflightShader(paint.id, paint.properties, 'fill', key, state) - } - } -} - -async function preflightPaints(spec: CanvasNodeSpec, state: ApplyState): Promise { - await preflightPaintStack(spec.figma?.fills, 'fill', spec.key, state) - await preflightPaintStack(spec.figma?.strokes, 'stroke', spec.key, state) -} - -function preflightVectorPaths(spec: CanvasNodeSpec): void { - const shape = spec.figma?.shape - if (shape?.type !== 'VECTOR') return - if (shape.paths) { - try { - canonicalVectorPaths(shape.paths) - } catch (error) { - specError( - `Vector path on "${spec.key}" is invalid: ${error instanceof Error ? error.message : String(error)}` - ) - } - } -} - -async function preflightVector(spec: CanvasNodeSpec, state: ApplyState): Promise { - const shape = spec.figma?.shape - if (shape?.type !== 'VECTOR') return - for (const [index, region] of (shape.network?.regions ?? []).entries()) { - await preflightPaintStack(region.fills, `vector region ${index} fill`, spec.key, state) - if (!region.fillStyle) continue - const style = await resolveStyle(region.fillStyle, state.styles) - validateStyleType('fill', style, spec.key) - } -} - -function preflightTextRangeBounds(spec: CanvasNodeSpec, state: ApplyState): void { - for (const range of spec.figma?.text?.ranges ?? []) { - const existing = findExistingNode(spec, state) - if ( - spec.text && - !spec.variables?.characters && - !(existing && currentBoundVariableId(existing, 'characters')) && - range.end > spec.text.characters.length - ) { - specError( - `Text range ${range.start}:${range.end} on "${spec.key}" exceeds ${spec.text.characters.length} UTF-16 code units.` - ) - } - } -} - -async function preflightTextRanges(spec: CanvasNodeSpec, state: ApplyState): Promise { - preflightTextRangeBounds(spec, state) - for (const [index, range] of (spec.figma?.text?.ranges ?? []).entries()) { - const key = `${spec.key} text range ${index}` - if (range.fontName) await loadFont(range.fontName, state.fonts) - for (const [field, reference] of [ - ['text', range.textStyle], - ['fill', range.fillStyle] - ] as const) { - if (!reference) continue - const style = await resolveStyle(reference, state.styles) - validateStyleType(field, style, key) - if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) - } - for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< - [keyof CanvasVariableBindings, CanvasVariableReference | null] - >) { - if (!reference) continue - validateVariableType(field, await resolveVariable(reference, state.variables), key) - } - await preflightHyperlink(range.hyperlink, key, state) - await preflightPaintStack(range.fills, `text range ${index} fill`, spec.key, state) - const decorationColor = range.textDecorationColor - if (decorationColor && decorationColor.value !== 'AUTO') { - await preflightPaintStack( - [decorationColor.value], - `text range ${index} decoration`, - spec.key, - state - ) - } - } -} - -async function preflightLayoutGrids( - grids: CanvasFigmaLayoutGrid[] | undefined, - key: string, - state: ApplyState -): Promise { - for (const [index, grid] of (grids ?? []).entries()) { - for (const [field, reference] of Object.entries(grid.variables ?? {})) { - const variable = await resolveVariable(reference, state.variables) - validateVariableDataType(variable, 'FLOAT', `layout grid ${index} ${field} on "${key}"`) - } - } -} - -const TEXT_STYLE_VALUE_FIELDS = [ - 'fontName', - 'fontSize', - 'textDecoration', - 'letterSpacing', - 'lineHeight', - 'leadingTrim', - 'paragraphIndent', - 'paragraphSpacing', - 'listSpacing', - 'hangingPunctuation', - 'hangingList', - 'textCase' -] as const satisfies ReadonlyArray> - -const TEXT_STYLE_VARIABLES_BY_VALUE = { - fontName: ['fontFamily', 'fontStyle', 'fontWeight'], - fontSize: ['fontSize'], - textDecoration: [], - letterSpacing: ['letterSpacing'], - lineHeight: ['lineHeight'], - leadingTrim: [], - paragraphIndent: ['paragraphIndent'], - paragraphSpacing: ['paragraphSpacing'], - listSpacing: [], - hangingPunctuation: [], - hangingList: [], - textCase: [] -} satisfies Record<(typeof TEXT_STYLE_VALUE_FIELDS)[number], readonly VariableBindableTextField[]> - -type TextStyleResource = Extract - -function textStyleVariableEntries( - spec: TextStyleResource -): Array<[VariableBindableTextField, CanvasVariableReference | null]> { - return Object.entries(spec.variables ?? {}) as Array< - [VariableBindableTextField, CanvasVariableReference | null] - > -} - -async function defaultFontVariableValue( - variable: Variable, - state: ApplyState, - seen = new Set() -): Promise { - if (seen.has(variable.id) || seen.size >= 64) { - specError('Text style font variable aliases contain a cycle or exceed 64 levels.') - } - seen.add(variable.id) - const collection = await getVariableCollectionById(variable.variableCollectionId) - const value = collection && variable.valuesByMode[collection.defaultModeId] - if (value === undefined || value === null) { - scopeError(`Font variable "${variable.id}" has no accessible default-mode value.`) - } - if (typeof value === 'object' && 'type' in value && value.type === 'VARIABLE_ALIAS') { - return defaultFontVariableValue( - await resolveVariable({ id: value.id }, state.variables), - state, - seen - ) - } - return value -} - -async function loadTextStyleVariableFonts( - style: TextStyle, - spec: TextStyleResource, - state: ApplyState -): Promise { - const fields = TEXT_STYLE_VARIABLES_BY_VALUE.fontName - const variables = await Promise.all( - fields.map(async (field) => { - const reference = spec.variables?.[field] - const retained = style.boundVariables?.[field] - if (reference === null) return undefined - if (reference) return resolveVariable(reference, state.variables) - if (retained) return resolveVariable({ id: retained.id }, state.variables) - return undefined - }) - ) - if (!variables.some(Boolean)) return - const hasRetainedBinding = fields.some( - (field) => style.boundVariables?.[field] && spec.variables?.[field] !== null - ) - const base = (!hasRetainedBinding && spec.fontName) || style.fontName - // A style has no consumer mode of its own. Cover its default values and every - // existing consumer context without creating temporary canvas nodes. - const contexts: Array = [ - undefined, - ...(await style.getStyleConsumersAsync()).map(({ node }) => node) - ] - for (const consumer of contexts) { - const [family = base.family, fontStyle = base.style, weight] = await Promise.all( - variables.map((variable) => { - if (!variable) return undefined - if (consumer) return variable.resolveForConsumer(consumer).value - return defaultFontVariableValue(variable, state) - }) - ) - if ( - typeof family !== 'string' || - typeof fontStyle !== 'string' || - (weight !== undefined && - (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000)) - ) { - specError( - `Font variables on Text style "${style.id}" must resolve to STRING family/style and a weight between 1 and 1000.` - ) - } - const font = - weight === undefined - ? { ...base, family, style: fontStyle } - : await resolveFamilyFont(family, fontStyle, state.fonts, weight) - await loadVariableFontTransitions( - [base], - spec.variables, - { family, style: fontStyle, weight }, - state - ) - await loadFont(font, state.fonts) - } -} - -function preflightStylePatternSources( - paints: CanvasFigmaPaint[] | undefined, - key: string, - state: ApplyState -): void { - for (const paint of paints ?? []) { - if ( - paint.type === 'PATTERN' && - paint.sourceCanvasKey !== undefined && - !state.keyedNodes.has(paint.sourceCanvasKey) - ) { - specError( - `Pattern source "${paint.sourceCanvasKey}" on Paint style "${key}" must already exist in the update scope; use sourceNodeId when creating the source separately.` - ) - } - } -} - -async function preflightStyleResources(state: ApplyState): Promise { - for (const { key, spec, style } of state.styles.resources) { - switch (spec.type) { - case 'PAINT': - preflightStylePatternSources(spec.paints, key, state) - await preflightPaintStack(spec.paints, 'style', key, state) - break - case 'TEXT': - if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) - if (spec.fontName) await loadFont(spec.fontName, state.fonts) - for (const [field, reference] of textStyleVariableEntries(spec)) { - if (!reference) continue - validateVariableType( - field as keyof CanvasVariableBindings, - await resolveVariable(reference, state.variables), - key - ) - } - if (style.type === 'TEXT') await loadTextStyleVariableFonts(style, spec, state) - break - case 'EFFECT': - await preflightEffects(spec.effects, key, state) - break - case 'GRID': - await preflightLayoutGrids(spec.layoutGrids, key, state) - break - } - } -} - -function componentPropertyDisplayName(propertyName: string): string { - const suffix = propertyName.lastIndexOf('#') - return suffix < 0 ? propertyName : propertyName.slice(0, suffix) -} - -function nextComponentPropertyContext( - spec: CanvasNodeSpec, - existing: SupportedCanvasNode | undefined, - inherited: ComponentPropertyContext | undefined -): ComponentPropertyContext | undefined { - if (spec.type === 'COMPONENT_SET') { - return { - spec, - ...(existing?.type === 'COMPONENT_SET' ? { existing } : {}) - } - } - if (spec.type === 'COMPONENT') { - if (inherited?.spec?.type === 'COMPONENT_SET') return inherited - if (existing?.type === 'COMPONENT' && existing.parent?.type === 'COMPONENT_SET') { - return { existing: existing.parent } - } - return { - spec, - ...(existing?.type === 'COMPONENT' ? { existing } : {}) - } - } - if (inherited) return inherited - const owner = existing ? componentPropertyOwner(existing) : null - return owner ? { existing: owner } : undefined -} - -function contextPropertyType( - context: ComponentPropertyContext, - key: string, - state: ApplyState -): ComponentPropertyType | undefined { - const desired = context.spec?.figma?.component?.properties?.[key] - if (desired !== undefined) return desired?.type - const owner = context.existing - if (!owner) return undefined - const name = componentPropertyName(owner, key, state) - return name ? owner.componentPropertyDefinitions[name]?.type : undefined -} - -function expectedComponentPropertyVariableType( - type: CanvasFigmaComponentPropertyDefinition['type'] -): VariableResolvedDataType { - return type === 'BOOLEAN' ? 'BOOLEAN' : 'STRING' -} - -type ComponentPreflightResources = { - variable: ( - reference: CanvasVariableReference - ) => Promise | undefined> - component: (reference: CanvasDesignReference) => Promise -} - -async function preflightComponentPropertyDefinition( - key: string, - definition: CanvasFigmaComponentPropertyDefinition, - resources: ComponentPreflightResources -): Promise { - if (isVariableBinding(definition.defaultValue)) { - const variable = await resources.variable(definition.defaultValue.variable) - const expected = expectedComponentPropertyVariableType(definition.type) - if (variable) validateVariableDataType(variable, expected, `component property "${key}"`) - } else if (definition.type === 'INSTANCE_SWAP') { - await resources.component(definition.defaultValue) - } -} - -async function preflightAuthoredComponentProperties( - spec: CanvasNodeSpec, - context: ComponentPropertyContext | undefined, - state: ApplyState, - resources: ComponentPreflightResources -): Promise { - const properties = spec.figma?.component?.properties - if (!properties) return - if (!context || context.spec !== spec) { - specError( - `Component property definitions on variant "${spec.key}" belong on its component set.` - ) - } - const owner = context.existing - const keys = owner ? componentPropertyKeys(owner, state) : undefined - for (const [key, desired] of Object.entries(properties)) { - const propertyName = owner ? (keys?.[key] ?? key) : undefined - const current = propertyName ? owner?.componentPropertyDefinitions[propertyName] : undefined - if (desired === null) { - if (!owner || (!keys?.[key] && !current)) { - specError(`Component property "${key}" on "${spec.key}" does not exist.`) - } - if (current?.type === 'VARIANT' || current?.type === 'SLOT') { - specError( - `${current.type} property "${key}" on "${spec.key}" cannot be deleted through component properties.` - ) - } - continue - } - if (current && current.type !== desired.type) { - specError( - `Component property "${key}" on "${spec.key}" is ${current.type}, expected ${desired.type}.` - ) - } - await preflightComponentPropertyDefinition(key, desired, resources) - } -} - -function expectedComponentPropertyReferenceType( - field: ComponentPropertyReferenceField -): ComponentPropertyType { - if (field === 'characters') return 'TEXT' - if (field === 'mainComponent') return 'INSTANCE_SWAP' - return 'BOOLEAN' -} - -function preflightComponentPropertyReferences( - spec: CanvasNodeSpec, - existing: SupportedCanvasNode | undefined, - context: ComponentPropertyContext | undefined, - state: ApplyState -): void { - const references = spec.figma?.componentPropertyReferences - if (references) { - if (!context) { - specError(`Component property references on "${spec.key}" require a component sublayer.`) - } - for (const [field, key] of Object.entries(references) as Array< - [ComponentPropertyReferenceField, string | null] - >) { - if (key === null) continue - const actual = contextPropertyType(context, key, state) - const expected = expectedComponentPropertyReferenceType(field) - if (actual !== expected) { - specError( - `Component property reference "${key}" for ${field} on "${spec.key}" is ${actual ?? 'missing'}, expected ${expected}.` - ) - } - } - } - const effective = (field: ComponentPropertyReferenceField) => - references?.[field] === undefined - ? existing?.componentPropertyReferences?.[field] - : references[field] - if (effective('characters') && (spec.variables?.characters || spec.figma?.text?.ranges)) { - specError( - `A characters property reference on "${spec.key}" cannot be combined with a characters variable or rich-text ranges.` - ) - } - if (effective('visible') && spec.variables?.visible) { - specError( - `A visible property reference on "${spec.key}" cannot be combined with a visibility variable.` - ) - } - if (effective('mainComponent') && spec.figma?.instance?.preserveOverrides !== undefined) { - specError( - `Instance override preservation on "${spec.key}" cannot be combined with a mainComponent property reference.` - ) - } -} - -function slotPropertyName( - owner: ComponentPropertyOwner, - spec: CanvasNodeSpec, - state: ApplyState -): string | undefined { - const direct = componentPropertyName(owner, spec.key, state) - if (direct && owner.componentPropertyDefinitions[direct]?.type === 'SLOT') return direct - const desiredName = spec.figma?.slot?.property?.name - if (!desiredName) return undefined - const matches = Object.entries(owner.componentPropertyDefinitions) - .filter( - ([name, definition]) => - definition.type === 'SLOT' && componentPropertyDisplayName(name) === desiredName - ) - .map(([name]) => name) - if (matches.length > 1) { - specError(`Slot property "${desiredName}" on "${spec.key}" is ambiguous.`) - } - return matches[0] -} - -function preflightSlot( - spec: CanvasNodeSpec, - existing: SupportedCanvasNode | undefined, - context: ComponentPropertyContext | undefined, - state: ApplyState -): void { - if (spec.type !== 'SLOT') return - if (!existing) { - if (!spec.figma?.slot?.property) { - specError(`New slot "${spec.key}" requires property metadata.`) - } - if (!context) { - specError(`New slot "${spec.key}" must be nested inside an authored component.`) - } - return - } - if (existing.type !== 'SLOT' || !spec.figma?.slot?.property) return - const owner = componentPropertyOwner(existing) - const propertyName = owner ? slotPropertyName(owner, spec, state) : undefined - if (!owner || !propertyName) { - specError(`Slot property for "${spec.key}" could not be resolved.`) - } - const current = owner.componentPropertyDefinitions[propertyName] - const settings = spec.figma.slot.property.settings - if (settings) { - const merged = { ...current?.slotSettings, ...settings } - if ( - merged.minChildren != null && - merged.maxChildren != null && - merged.minChildren > merged.maxChildren - ) { - specError(`Slot minChildren on "${spec.key}" cannot exceed maxChildren.`) - } - } -} - -async function preflightComponentProperties( - spec: CanvasNodeSpec, - component: ComponentNode, - state: ApplyState, - resources: ComponentPreflightResources -): Promise { - const owner = componentDefinitionOwner(component) - for (const [key, value] of Object.entries(spec.componentProperties ?? {})) { - const name = componentPropertyName(owner, key, state) ?? key - const definition = Object.hasOwn(owner.componentPropertyDefinitions, name) - ? owner.componentPropertyDefinitions[name] - : undefined - if (!definition) { - specError(`Component "${component.id}" has no property "${key}" for "${spec.key}".`) - } - if (definition.type === 'SLOT') { - specError(`Slot property "${name}" on "${spec.key}" cannot be set with componentProperties.`) - } - if (isVariableBinding(value)) { - if (definition.type === 'VARIANT' || definition.type === 'INSTANCE_SWAP') { - specError( - `Component property "${name}" on "${spec.key}" cannot bind a variable because it is ${definition.type}.` - ) - } - const variable = await resources.variable(value.variable) - const expected = expectedComponentPropertyVariableType(definition.type) - if (variable && variable.resolvedType !== expected) { - specError( - `Variable "${variable.id}" for component property "${name}" on "${spec.key}" is ${variable.resolvedType}, expected ${expected}.` - ) - } - continue - } - const expected = definition.type === 'BOOLEAN' ? 'boolean' : 'string' - if (typeof value !== expected) { - specError(`Component property "${name}" on "${spec.key}" expects ${expected}.`) - } - if ( - definition.type === 'VARIANT' && - definition.variantOptions && - !definition.variantOptions.includes(value as string) - ) { - specError(`Component property "${name}" on "${spec.key}" has no variant "${value}".`) - } - if (definition.type !== 'INSTANCE_SWAP') continue - const replacement = await lookupNodeById(value as string) - if (replacement?.type !== 'COMPONENT' && replacement?.type !== 'COMPONENT_SET') { - specError( - `Instance-swap property "${name}" on "${spec.key}" must reference a component node.` - ) - } - if (replacement.type === 'COMPONENT_SET' && spec.componentProperties) { - spec.componentProperties[key] = replacement.defaultVariant.id - } - } -} - -function isPrimaryNestedInstance(node: InstanceNode): boolean { - let parent = node.parent - while (parent) { - if (parent.type === 'INSTANCE') return false - if (parent.type === 'COMPONENT' || parent.type === 'COMPONENT_SET') return true - parent = parent.parent - } - return false -} - -async function preflightVariableModes( - modes: CanvasNodeSpec['variableModes'], - state: ApplyState -): Promise { - for (const [collectionId, modeId] of Object.entries(modes ?? {})) { - const collection = await resolveCollection(collectionId, state.variables) - if (modeId !== null) await resolveModeId(collection, modeId, state.variables) - } -} - -function findOmittedChild( - specs: CanvasNodeSpec[], - parent: ChildrenMixin, - state: ApplyState -): SceneNode | undefined { - const describedIds = new Set( - specs - .map((spec) => findExistingNode(spec, state)) - .filter((node): node is SupportedCanvasNode => node !== null) - .map((node) => node.id) - ) - return parent.children.find( - (child) => !describedIds.has(child.id) && !state.removalNodeIds.has(child.id) - ) -} - -function preflightMasks( - spec: CanvasNodeSpec, - state: ApplyState, - existing: SupportedCanvasNode | null = null, - isRoot = true -): void { - const isDesiredMask = desiredMaskState(spec, existing) - if (isRoot && isDesiredMask) { - specError('The canvas root cannot be a mask because its scope would escape the desired tree.') - } - - const children = spec.children ?? [] - const hasMask = children.some((child) => desiredMaskState(child, findExistingNode(child, state))) - const lastChild = children.at(-1) - if (lastChild && desiredMaskState(lastChild, findExistingNode(lastChild, state))) { - specError(`Mask "${lastChild.key}" must precede at least one sibling to mask.`) - } - - if (hasMask && existing && 'children' in existing) { - const omitted = findOmittedChild(children, existing, state) - if (omitted) { - specError( - `Mask container "${spec.key}" has omitted live child "${omitted.id}"; describe every direct child so the mask scope is deterministic.` - ) - } - } - - for (const child of children) { - preflightMasks(child, state, findExistingNode(child, state), false) - } -} - -function desiredMaskState(spec: CanvasNodeSpec, existing: SupportedCanvasNode | null): boolean { - const desired = spec.figma?.mask - if (desired !== undefined) return desired !== null - return !!existing && 'isMask' in existing && existing.isMask -} - -function preflightContainers( - spec: CanvasNodeSpec, - state: ApplyState, - existing: SupportedCanvasNode | null = null -): void { - const childCount = spec.children?.length ?? 0 - if (!existing && spec.type === 'GROUP' && childCount === 0) { - specError(`New group "${spec.key}" requires at least one child.`) - } - if (!existing && spec.type === 'BOOLEAN_OPERATION' && childCount < 2) { - specError(`New boolean operation "${spec.key}" requires at least two children.`) - } - if (!existing && spec.type === 'COMPONENT_SET' && childCount === 0) { - specError(`New component set "${spec.key}" requires at least one component child.`) - } - if (spec.type === 'COMPONENT_SET' && spec.children?.some((child) => child.type !== 'COMPONENT')) { - specError(`Component set "${spec.key}" can contain only component nodes.`) - } - if ( - existing && - (existing.type === 'COMPONENT' || existing.type === 'COMPONENT_SET') && - existing.remote - ) { - specError(`Remote ${existing.type} node "${spec.key}" is read-only.`) - } - if (existing && isIntrinsicNode(existing) && spec.children?.length) { - const omitted = findOmittedChild(spec.children, existing, state) - if (omitted) { - specError( - `Intrinsic container "${spec.key}" has omitted live child "${omitted.id}"; describe every direct child when reconciling its contents.` - ) - } - } - for (const child of spec.children ?? []) { - preflightContainers(child, state, findExistingNode(child, state)) - } -} - -function preflightMovedChildren(root: CanvasNodeSpec, state: ApplyState): void { - const order = new Map() - let nextOrder = 0 - function recordOrder(spec: CanvasNodeSpec): void { - const start = nextOrder++ - for (const child of spec.children ?? []) recordOrder(child) - order.set(spec, { start, end: nextOrder++ }) - } - recordOrder(root) - const incoming = new Map() - const outgoing = new Map; lastMove: number }>() - for (const parentSpec of walkSpecs(root)) { - const destination = findExistingNode(parentSpec, state) - for (const childSpec of parentSpec.children ?? []) { - const child = findExistingNode(childSpec, state) - const parent = child?.parent - if (destination && 'children' in destination && parent?.id !== destination.id) { - const insertions = incoming.get(destination.id) ?? [] - // Only new wrapped containers wait for their children before insertion. - const timing = order.get(childSpec)! - insertions.push(!child && isWrappedSpec(childSpec) ? timing.end : timing.start) - incoming.set(destination.id, insertions) - } - if ( - !child || - !parent || - parent.id === destination?.id || - (parent.type !== 'GROUP' && - parent.type !== 'BOOLEAN_OPERATION' && - parent.type !== 'COMPONENT_SET') - ) - continue - const moves = outgoing.get(parent) ?? { ids: new Set(), lastMove: -1 } - moves.ids.add(child.id) - // New sets stage every variant before reconciling any variant's descendants. - const moveOrder = !destination && parentSpec.type === 'COMPONENT_SET' ? parentSpec : childSpec - moves.lastMove = Math.max(moves.lastMove, order.get(moveOrder)!.start) - outgoing.set(parent, moves) - } - } - for (const initialParent of [...outgoing.keys()]) { - let parent = initialParent - while (true) { - const { ids, lastMove } = outgoing.get(parent)! - const addedBeforeMove = (incoming.get(parent.id) ?? []).filter( - (time) => time < lastMove - ).length - // An explicitly removed Group can disappear during the move, emptying its - // parent before the ordinary removal checks get a live subtree to inspect. - if ( - parent.type !== 'GROUP' || - addedBeforeMove || - parent.children.some((child) => !ids.has(child.id)) - ) - break - const ancestor = parent.parent - if ( - !ancestor || - (ancestor.type !== 'GROUP' && - ancestor.type !== 'BOOLEAN_OPERATION' && - ancestor.type !== 'COMPONENT_SET') - ) - break - const ancestorMoves = outgoing.get(ancestor) ?? { ids: new Set(), lastMove: -1 } - if (ancestorMoves.ids.has(parent.id)) break - ancestorMoves.ids.add(parent.id) - ancestorMoves.lastMove = Math.max(ancestorMoves.lastMove, lastMove) - outgoing.set(ancestor, ancestorMoves) - parent = ancestor - } - } - // Aggregate sibling cascades before checking their common ancestor's last move. - for (const [parent, { ids, lastMove }] of outgoing) { - const remaining = parent.children.filter( - (child) => !ids.has(child.id) && !state.removalNodeIds.has(child.id) - ) - const addedBeforeMove = (incoming.get(parent.id) ?? []).filter((time) => time < lastMove).length - const minimum = parent.type === 'BOOLEAN_OPERATION' ? 2 : 1 - if (!state.removalNodeIds.has(parent.id) && remaining.length + addedBeforeMove < minimum) { - specError( - `Moving these children would leave ${parent.type} "${parent.id}" with fewer than ${minimum} children. Preserve its required children or explicitly remove that container in the same update.` - ) - } - } -} - -async function preflightTextFont( - spec: CanvasNodeSpec, - state: ApplyState, - existing?: SupportedCanvasNode -): Promise { - if (spec.type !== 'TEXT' || !spec.text) return - const textNode = existing?.type === 'TEXT' ? existing : null - const hasTextStyle = !!(spec.styles?.text || textNode?.textStyleId) - const hasFontFamilyVariable = !!( - spec.variables?.fontFamily || - (textNode && currentBoundVariableId(textNode, 'fontFamily')) - ) - const hasFontStyleVariable = !!( - spec.variables?.fontStyle || - (textNode && currentBoundVariableId(textNode, 'fontStyle')) - ) - const fontFamily = hasTextStyle || hasFontFamilyVariable ? undefined : spec.text.fontFamily - const fontStyle = hasTextStyle || hasFontStyleVariable ? undefined : spec.text.fontStyle - if ( - (fontFamily !== undefined || fontStyle !== undefined) && - !(spec.text.fontStyleMatching && (hasFontFamilyVariable || hasFontStyleVariable)) - ) { - const currentFont = textNode?.fontName ?? { family: 'Inter', style: 'Regular' } - if (currentFont === figma.mixed && (!fontFamily || !fontStyle)) { - specError( - `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` - ) - } - const desiredFamily = fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family) - const desiredStyle = fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) - const desiredFont = spec.text.portableFontFamily - ? await resolvePortableFont(spec.text.portableFontFamily, desiredStyle, state.fonts) - : spec.text.fontStyleMatching - ? await resolveFamilyFont(desiredFamily, desiredStyle, state.fonts) - : await resolveLiteralFont(desiredFamily, desiredStyle, spec, state) - if (spec.text.portableFontFamily) { - spec.text.fontFamily = desiredFont.family - spec.text.fontStyle = desiredFont.style - } - await loadFont(desiredFont, state.fonts) - } -} - -async function preflightComponentState( - spec: CanvasNodeSpec, - state: ApplyState, - existing?: SupportedCanvasNode, - inheritedComponent?: ComponentPropertyContext, - resources: ComponentPreflightResources = { - variable: (reference) => resolveVariable(reference, state.variables), - component: (reference) => resolveComponent(reference, state) - } -): Promise { - const component = nextComponentPropertyContext(spec, existing, inheritedComponent) - if (component?.existing?.remote) { - specError(`Remote ${component.existing.type} containing "${spec.key}" is read-only.`) - } - await preflightAuthoredComponentProperties(spec, component, state, resources) - preflightComponentPropertyReferences(spec, existing, component, state) - preflightSlot(spec, existing, component, state) - if (spec.component) { - const instanceComponent = await resources.component(spec.component) - if (instanceComponent) - await preflightComponentProperties(spec, instanceComponent, state, resources) - } else if (spec.componentProperties || spec.figma?.instance) { - const instance = existing ?? findExistingNode(spec, state) - if (instance?.type !== 'INSTANCE') { - specError( - `Instance state on "${spec.key}" requires an existing instance or a component reference.` - ) - } - if (spec.componentProperties) { - const instanceComponent = await getMainComponent(instance) - if (!instanceComponent) { - specError(`Existing instance "${spec.key}" has no main component.`) - } - await preflightComponentProperties(spec, instanceComponent, state, resources) - } - } - if (spec.figma?.instance?.exposed !== undefined) { - const instance = existing ?? findExistingNode(spec, state) - if (instance?.type !== 'INSTANCE' || !isPrimaryNestedInstance(instance)) { - specError( - `Instance exposure on "${spec.key}" requires an existing primary instance inside a component.` - ) - } - } - return component -} - -async function preflightResources( - spec: CanvasNodeSpec, - state: ApplyState, - existing?: SupportedCanvasNode, - inheritedComponent?: ComponentPropertyContext -): Promise { - preflightShadowSpread(spec, existing) - if (existing?.type === 'TEXT') await loadFonts(currentTextFonts(existing), state.fonts) - const component = await preflightComponentState(spec, state, existing, inheritedComponent) - if (spec.variables) { - for (const field of Object.keys(spec.variables) as Array) { - const reference = spec.variables[field] - if (!reference) continue - const variable = await resolveVariable(reference, state.variables) - validateVariableType(field, variable, spec.key) - } - } - await preflightVariableModes(spec.variableModes, state) - if (spec.styles) { - for (const field of Object.keys(spec.styles) as Array) { - const reference = spec.styles[field] - if (!reference) continue - const style = await resolveStyle(reference, state.styles) - validateStyleType(field, style, spec.key) - if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) - } - } - await preflightTextFont(spec, state, existing) - await preflightHyperlink(spec.figma?.text?.hyperlink, spec.key, state) - await preflightPaints(spec, state) - await preflightVector(spec, state) - await preflightTextRanges(spec, state) - await preflightEffects(spec.figma?.effects, spec.key, state) - await preflightLayoutGrids(spec.figma?.layoutGrids, spec.key, state) - for (const child of spec.children ?? []) { - await preflightResources(child, state, findExistingNode(child, state) ?? undefined, component) - } -} - -function recordCreatedNode(node: SupportedCanvasNode, state: ApplyState, claimed = true): void { - state.mutations.count += 1 - state.createdNodeIds.add(node.id) - if (claimed) state.claimedNodeIds.add(node.id) -} - -function containingComponentNode(parent: CanvasParentNode | undefined): ComponentNode | null { - let current: BaseNode | null = parent ?? null - while (current) { - if (current.type === 'COMPONENT') return current - current = current.parent - } - return null -} - -function createSlotNode( - spec: CanvasNodeSpec, - parent: CanvasParentNode | undefined, - state: ApplyState -): SlotNode { - const component = containingComponentNode(parent) - if (!component || component.remote) { - specError(`New slot "${spec.key}" must be nested inside a local authored component.`) - } - const owner = componentDefinitionOwner(component) - componentPropertyKeys(owner, state) - const previousNames = new Set(Object.keys(owner.componentPropertyDefinitions)) - const slot = component.createSlot() - recordCreatedNode(slot, state) - const propertyNames = Object.entries(owner.componentPropertyDefinitions) - .filter(([name, definition]) => !previousNames.has(name) && definition.type === 'SLOT') - .map(([name]) => name) - if (propertyNames.length !== 1) { - specError(`Figma did not create exactly one slot property for "${spec.key}".`) - } - setComponentPropertyKey(owner, spec.key, propertyNames[0]!, state) - return slot -} - -async function createNode(spec: CanvasNodeSpec, state: ApplyState): Promise { - let node: SupportedCanvasNode - switch (spec.type) { - case 'BOOLEAN_OPERATION': - case 'COMPONENT_SET': - case 'GROUP': - return specError(`${spec.type} nodes must be created from their children.`) - case 'SLOT': - return specError('SLOT nodes must be created by their containing component.') - case 'COMPONENT': - node = figma.createComponent() - break - case 'FRAME': - node = figma.createFrame() - break - case 'INSTANCE': { - const component = await resolveComponent(spec.component!, state) - node = component.createInstance() - break - } - case 'SECTION': - node = figma.createSection() - break - case 'TEXT': - node = figma.createText() - break - case 'RECTANGLE': - node = figma.createRectangle() - break - case 'LINE': - node = figma.createLine() - break - case 'ELLIPSE': - node = figma.createEllipse() - break - case 'POLYGON': - node = figma.createPolygon() - break - case 'STAR': - node = figma.createStar() - break - case 'VECTOR': - node = figma.createVector() - break - } - recordCreatedNode(node, state) - normalizeCreatedFrame(node, spec, state) - return node -} - -function normalizeCreatedFrame( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - if ( - (node.type === 'FRAME' || node.type === 'COMPONENT') && - spec.appearance?.clipsContent === undefined && - node.clipsContent - ) { - node.clipsContent = false - markMutation(state, node) - } - if ( - (node.type === 'FRAME' || node.type === 'COMPONENT') && - spec.appearance?.fill === undefined && - node.fills !== figma.mixed && - node.fills.length > 0 - ) { - node.fills = [] - markMutation(state, node) - } -} - -function moveIntoParent( - node: Child, - parent: BaseNode & { - readonly children: readonly Child[] - insertChild(index: number, child: Child): void - }, - index: number, - state: ApplyState -): void { - const sameParent = node.parent?.id === parent.id - if (sameParent && parent.children.indexOf(node) === index) return - const preceding = sameParent ? [] : parent.children.slice(0, index) - parent.insertChild(index, node) - // Moving out of a Group can remove that Group (and empty ancestor Groups) - // from this parent, shifting the requested insertion slot. - const expectedIndex = index - preceding.filter((sibling) => sibling.removed).length - if ((sameParent || expectedIndex !== index) && parent.children.indexOf(node) !== expectedIndex) { - const currentIndex = parent.children.indexOf(node) - parent.insertChild(currentIndex < expectedIndex ? expectedIndex + 1 : expectedIndex, node) - } - if (parent.children.indexOf(node) !== expectedIndex) { - specError(`Node "${node.id}" could not be placed at child index ${expectedIndex}.`) - } - markMutation(state, node) -} - -function setValue( - node: BaseNode, - current: unknown, - desired: T | undefined, - apply: (value: T) => void, - state: ApplyState -): void { - if ( - desired === undefined || - Object.is(current, desired) || - (typeof current === 'number' && typeof desired === 'number' && current === Math.fround(desired)) - ) - return - apply(desired) - markMutation(state, node) -} - -const PADDING_FIELDS = [ - ['top', 'paddingTop'], - ['right', 'paddingRight'], - ['bottom', 'paddingBottom'], - ['left', 'paddingLeft'] -] as const - -function applyCounterAxisSpacing( - node: CanvasFrameContainerNode, - desired: number | null | undefined, - state: ApplyState -): void { - if (desired === undefined) return - const synced = - node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME) === 'true' - if (desired !== null) { - setValue( - node, - synced ? undefined : node.counterAxisSpacing, - desired, - (value) => (node.counterAxisSpacing = value), - state - ) - if (synced) { - node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME, '') - markMutation(state, node) - } - return - } - - if (!synced || !Object.is(node.counterAxisSpacing, node.itemSpacing)) { - node.counterAxisSpacing = null - markMutation(state, node) - } - if (!synced) { - node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME, 'true') - markMutation(state, node) - } -} - -function applyLayout( - node: CanvasFrameContainerNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - const layout = spec.layout - if (!layout) return - const bindings = spec.variables - - setValue(node, node.layoutMode, layout.mode, (value) => (node.layoutMode = value), state) - if (layout.mode === 'NONE') return - - if (layout.padding !== undefined) { - for (const [side, field] of PADDING_FIELDS) { - const desired = typeof layout.padding === 'number' ? layout.padding : layout.padding[side] - setValue( - node, - node[field], - bindings?.[field] || currentBoundVariableId(node, field) ? undefined : desired, - (value) => (node[field] = value), - state - ) - } - } - setValue( - node, - node.strokesIncludedInLayout, - layout.strokesIncluded, - (value) => (node.strokesIncludedInLayout = value), - state - ) - - if (layout.mode === 'GRID') { - setValue( - node, - node.gridRowGap, - bindings?.gridRowGap || currentBoundVariableId(node, 'gridRowGap') - ? undefined - : layout.rowGap, - (value) => (node.gridRowGap = value), - state - ) - setValue( - node, - node.gridColumnGap, - bindings?.gridColumnGap || currentBoundVariableId(node, 'gridColumnGap') - ? undefined - : layout.columnGap, - (value) => (node.gridColumnGap = value), - state - ) - - const rowCount = layout.rows?.length - if (layout.autoRows !== undefined) { - setValue( - node, - node.gridAutoTracks, - layout.autoRows ? ('ROWS' as const) : ('NONE' as const), - (value) => (node.gridAutoTracks = value), - state - ) - } - if (node.gridColumnCount < layout.columns.length) { - setValue( - node, - node.gridColumnCount, - layout.columns.length, - (value) => (node.gridColumnCount = value), - state - ) - } - if (rowCount !== undefined && node.gridRowCount < rowCount) { - setValue(node, node.gridRowCount, rowCount, (value) => (node.gridRowCount = value), state) - } - return - } - - const autoLayout = spec.figma?.autoLayout - setValue( - node, - node.itemSpacing, - bindings?.gap || currentBoundVariableId(node, 'itemSpacing') - ? undefined - : (autoLayout?.itemSpacing ?? layout.gap), - (value) => (node.itemSpacing = value), - state - ) - setValue(node, node.layoutWrap, layout.wrap, (value) => (node.layoutWrap = value), state) - const counterAxisSpacing = - autoLayout?.counterAxisSpacing !== undefined ? autoLayout.counterAxisSpacing : layout.counterGap - if (!bindings?.counterAxisSpacing && !currentBoundVariableId(node, 'counterAxisSpacing')) { - applyCounterAxisSpacing(node, counterAxisSpacing, state) - } - setValue( - node, - node.itemReverseZIndex, - autoLayout?.itemReverseZIndex, - (value) => (node.itemReverseZIndex = value), - state - ) - setValue( - node, - node.primaryAxisAlignItems, - layout.primaryAlign, - (value) => (node.primaryAxisAlignItems = value), - state - ) - setValue( - node, - node.counterAxisAlignItems, - layout.counterAlign, - (value) => (node.counterAxisAlignItems = value), - state - ) - setValue( - node, - node.counterAxisAlignContent, - layout.counterAlignContent, - (value) => (node.counterAxisAlignContent = value), - state - ) -} - -const SIZE_BOUND_FIELDS = ['minWidth', 'maxWidth', 'minHeight', 'maxHeight'] as const - -function applySizingModes( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - parent: CanvasParentNode | undefined, - state: ApplyState -): void { - if (isIntrinsicNode(node) || !supportsLayoutSizing(node, parent)) return - const size = spec.size - setValue( - node, - node.layoutSizingHorizontal, - size.horizontal, - (value) => (node.layoutSizingHorizontal = value), - state - ) - setValue( - node, - node.layoutSizingVertical, - size.vertical, - (value) => (node.layoutSizingVertical = value), - state - ) - setValue( - node, - node.layoutGrow, - spec.grow === undefined ? undefined : spec.grow ? 1 : 0, - (value) => (node.layoutGrow = value), - state - ) - - if (!isFrameContainer(node) || node.layoutMode === 'NONE' || node.layoutMode === 'GRID') return - const horizontalMode: 'AUTO' | 'FIXED' = size.horizontal === 'HUG' ? 'AUTO' : 'FIXED' - const verticalMode: 'AUTO' | 'FIXED' = size.vertical === 'HUG' ? 'AUTO' : 'FIXED' - const horizontal = node.layoutMode === 'HORIZONTAL' - setValue( - node, - node.primaryAxisSizingMode, - horizontal ? horizontalMode : verticalMode, - (value) => (node.primaryAxisSizingMode = value), - state - ) - setValue( - node, - node.counterAxisSizingMode, - horizontal ? verticalMode : horizontalMode, - (value) => (node.counterAxisSizingMode = value), - state - ) -} - -type CrossAxisFill = { - axis: 'horizontal' | 'vertical' - recoverySize: number -} - -function clampSize(value: number, min: number | null, max: number | null): number { - return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, value)) -} - -function layoutStrokeWeight( - node: CanvasFrameContainerNode, - field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' -): number { - const value = node[field] - return typeof value === 'number' - ? value - : typeof node.strokeWeight === 'number' - ? node.strokeWeight - : 0 -} - -function includedLayoutEdgeStroke( - node: CanvasFrameContainerNode, - field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' -): number { - if ( - !node.strokesIncludedInLayout || - node.strokeAlign !== 'INSIDE' || - !Array.isArray(node.strokes) || - !node.strokes.some((stroke) => stroke.visible !== false) - ) { - return 0 - } - return layoutStrokeWeight(node, field) -} - -function includedLayoutStroke( - node: CanvasFrameContainerNode, - axis: 'horizontal' | 'vertical' -): number { - return axis === 'horizontal' - ? includedLayoutEdgeStroke(node, 'strokeLeftWeight') + - includedLayoutEdgeStroke(node, 'strokeRightWeight') - : includedLayoutEdgeStroke(node, 'strokeTopWeight') + - includedLayoutEdgeStroke(node, 'strokeBottomWeight') -} - -function crossAxisFill( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - parent?: SupportedCanvasNode | CanvasParentNode -): CrossAxisFill | null { - if ( - isIntrinsicNode(node) || - !parent || - !isFrameContainer(parent) || - !('layoutPositioning' in node) || - parent.counterAxisSizingMode !== 'FIXED' || - parent.layoutWrap !== 'NO_WRAP' || - node.layoutPositioning !== 'AUTO' - ) { - return null - } - if (parent.layoutMode === 'VERTICAL' && spec.size.horizontal === 'FILL') { - return { - axis: 'horizontal', - recoverySize: Math.max( - GEOMETRY_TOLERANCE, - clampSize( - parent.width - - parent.paddingLeft - - parent.paddingRight - - includedLayoutStroke(parent, 'horizontal'), - node.minWidth, - node.maxWidth - ) - ) - } - } - if (parent.layoutMode === 'HORIZONTAL' && spec.size.vertical === 'FILL') { - return { - axis: 'vertical', - recoverySize: Math.max( - GEOMETRY_TOLERANCE, - clampSize( - parent.height - - parent.paddingTop - - parent.paddingBottom - - includedLayoutStroke(parent, 'vertical'), - node.minHeight, - node.maxHeight - ) - ) - } - } - return null -} - -function stabilizeCrossAxisFill( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - parent: CanvasParentNode | undefined, - state: ApplyState -): void { - if (!('layoutSizingHorizontal' in node)) return - const fill = crossAxisFill(node, spec, parent) - if (!fill) return - const current = fill.axis === 'horizontal' ? node.width : node.height - if ( - current >= GEOMETRY_TOLERANCE && - (!state.createdNodeIds.has(node.id) || state.stabilizedCrossAxisFillNodeIds.has(node.id)) - ) { - return - } - - if (fill.axis === 'horizontal') { - node.layoutSizingHorizontal = 'FIXED' - node.resize(fill.recoverySize, node.height) - node.layoutSizingHorizontal = 'FILL' - } else { - node.layoutSizingVertical = 'FIXED' - node.resize(node.width, fill.recoverySize) - node.layoutSizingVertical = 'FILL' - } - state.stabilizedCrossAxisFillNodeIds.add(node.id) - markMutation(state, node) -} - -function stabilizeGrowingTextWidth( - node: SupportedCanvasNode, - parent: CanvasParentNode | undefined, - state: ApplyState -): void { - if ( - node.type !== 'TEXT' || - node.characters.length === 0 || - node.textAutoResize !== 'HEIGHT' || - node.layoutGrow <= 0 || - node.width > GEOMETRY_TOLERANCE - ) { - return - } - - let width = Math.max(node.minWidth ?? 0, 1) - if (parent && isFrameContainer(parent)) { - if (parent.layoutMode === 'VERTICAL') { - width = Math.max( - width, - parent.width - - parent.paddingLeft - - parent.paddingRight - - includedLayoutStroke(parent, 'horizontal') - ) - } else if (parent.layoutMode === 'HORIZONTAL') { - const flow = parent.children.filter( - ( - child - ): child is SupportedCanvasNode & { - layoutGrow: number - layoutPositioning: 'ABSOLUTE' | 'AUTO' - } => - isSupportedSceneNode(child) && - 'layoutGrow' in child && - 'layoutPositioning' in child && - child.layoutPositioning !== 'ABSOLUTE' - ) - const fixedWidth = flow.reduce( - (total, child) => total + (child.id !== node.id && child.layoutGrow <= 0 ? child.width : 0), - 0 - ) - const growCount = Math.max(1, flow.filter((child) => child.layoutGrow > 0).length) - const available = - parent.width - - parent.paddingLeft - - parent.paddingRight - - includedLayoutStroke(parent, 'horizontal') - - Math.max(0, flow.length - 1) * parent.itemSpacing - - fixedWidth - width = Math.max(width, available / growCount) - } - } - width = Math.max(GEOMETRY_TOLERANCE, clampSize(width, node.minWidth, node.maxWidth)) - - node.layoutSizingHorizontal = 'FIXED' - node.resize(width, node.height) - node.layoutSizingHorizontal = 'FILL' - markMutation(state, node) -} - -function supportsLayoutSizing( - node: SupportedCanvasNode, - parent: SupportedCanvasNode | CanvasParentNode | undefined -): node is SupportedCanvasNode & LayoutMixin { - return ( - 'layoutSizingHorizontal' in node && - (('layoutMode' in node && node.layoutMode !== 'NONE') || - (!!parent && 'layoutMode' in parent && parent.layoutMode !== 'NONE')) - ) -} - -function applySize( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - parent: CanvasParentNode | undefined, - state: ApplyState -): void { - if (isIntrinsicNode(node)) return - const size = spec.size - for (const field of SIZE_BOUND_FIELDS) { - setValue( - node, - node[field], - spec.variables?.[field] || currentBoundVariableId(node, field) ? undefined : size[field], - (value) => (node[field] = value), - state - ) - } - const width = - !spec.variables?.width && !currentBoundVariableId(node, 'width') && size.width !== undefined - ? size.width - : node.width - const height = - !spec.variables?.height && !currentBoundVariableId(node, 'height') && size.height !== undefined - ? size.height - : node.height - if (Math.abs(node.width - width) > 0.01 || Math.abs(node.height - height) > 0.01) { - node.resize(width, height) - markMutation(state, node) - } - applySizingModes(node, spec, parent, state) -} - -function applyPosition( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - parent: CanvasParentNode, - state: ApplyState -): void { - if (spec.positioning !== undefined && isFrameContainer(parent) && parent.layoutMode !== 'NONE') { - if (node.type === 'SECTION') { - specError(`Section "${spec.key}" cannot be a child of an Auto Layout frame.`) - } - setValue( - node, - node.layoutPositioning, - spec.positioning, - (value) => (node.layoutPositioning = value), - state - ) - } - if (!spec.position) return - const { right, bottom } = spec.absoluteOffsets ?? {} - const x = - right !== undefined && 'width' in parent ? parent.width - right - node.width : spec.position.x - const y = - bottom !== undefined && 'height' in parent - ? parent.height - bottom - node.height - : spec.position.y - setValue(node, node.x, x, (value) => (node.x = value), state) - setValue(node, node.y, y, (value) => (node.y = value), state) -} - -function finalizeAbsolutePositions(root: CanvasNodeSpec, state: ApplyState): void { - for (const spec of walkSpecs(root)) { - const offsets = spec.absoluteOffsets - if (!offsets) continue - const node = state.keyedNodes.get(spec.key) - const parent = node?.parent - if (!node || !parent || !('width' in parent)) continue - // Bindings, consumer modes, and layout may change either bound after markup parsing. - if (offsets.right !== undefined) { - setValue(node, node.x, parent.width - offsets.right - node.width, (x) => (node.x = x), state) - } - if (offsets.bottom !== undefined) { - setValue( - node, - node.y, - parent.height - offsets.bottom - node.height, - (y) => (node.y = y), - state - ) - } - } -} - -function transformsMatch(current: Transform, desired: Transform): boolean { - return current.every((row, rowIndex) => - row.every((value, columnIndex) => Math.abs(value - desired[rowIndex]![columnIndex]!) <= 1e-6) - ) -} - -function applyRelativeTransform( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - parent: CanvasParentNode | undefined, - state: ApplyState -): void { - const transform = spec.figma?.relativeTransform - if (!transform) return - const current = node.relativeTransform - const autoLayoutChild = !!parent && isFrameContainer(parent) && parent.layoutMode !== 'NONE' - const desired: Transform = autoLayoutChild - ? [ - [transform[0][0], transform[0][1], current[0][2]], - [transform[1][0], transform[1][1], current[1][2]] - ] - : transform - if (transformsMatch(current, desired)) return - node.relativeTransform = desired - markMutation(state, node) -} - -function applyPaint( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - field: 'fill' | 'stroke', - state: ApplyState -): void { - if (!('fills' in node)) { - specError(`${field} paints are not supported on ${node.type} node "${spec.key}".`) - } - const color = spec.appearance?.[field] - if (color === undefined) return - - const property = field === 'fill' ? 'fills' : 'strokes' - const styleProperty = field === 'fill' ? 'fillStyleId' : 'strokeStyleId' - if (spec.styles?.[field] || (node[styleProperty] && !spec.variables?.[field])) return - const paints = node[property] - const desired = color === null ? [] : [figma.util.solidPaint(color)] - const binding = spec.variables?.[field] - const currentVariable = node.boundVariables?.[property]?.[0] - if (!binding && currentVariable) return - if (binding) { - if (color === null) { - const label = field === 'fill' ? 'Fill' : 'Stroke' - specError(`${label} variable binding on "${spec.key}" requires a solid fallback paint.`) - } - const variable = state.variables.variableCache.get(variableReferenceCacheKey(binding)) - if (variable && currentVariable?.id === variable.id) return - } - if (paints !== figma.mixed && paintStacksEqual(paints, desired)) { - return - } - - node[property] = desired - markMutation(state, node) -} - -const STROKE_WEIGHT_FIELDS = [ - 'strokeTopWeight', - 'strokeRightWeight', - 'strokeBottomWeight', - 'strokeLeftWeight' -] as const satisfies ReadonlyArray -const CORNER_RADIUS_FIELDS = [ - 'topLeftRadius', - 'topRightRadius', - 'bottomRightRadius', - 'bottomLeftRadius' -] as const satisfies ReadonlyArray - -function hasDesiredVariable( - spec: CanvasNodeSpec, - fields: ReadonlyArray -): boolean { - return fields.some((field) => spec.variables?.[field] !== undefined) -} - -function hasCurrentVariable( - node: SupportedCanvasNode, - fields: ReadonlyArray -): boolean { - return fields.some((field) => currentBoundVariableId(node, field) !== undefined) -} - -function applyIndividualValue( - node: SupportedCanvasNode, - current: number, - desired: number | undefined, - field: VariableBindableNodeField, - uniformField: VariableBindableNodeField, - spec: CanvasNodeSpec, - apply: (value: number) => void, - state: ApplyState -): void { - if (spec.variables?.[uniformField as keyof CanvasVariableBindings]) return - if (currentBoundVariableId(node, uniformField)) return - if (spec.variables?.[field as keyof CanvasVariableBindings]) return - if (currentBoundVariableId(node, field)) return - setValue(node, current, desired, apply, state) -} - -function applyAppearance(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - const appearance = spec.appearance - if (!appearance) return - - if ('fills' in node) { - applyPaint(node, spec, 'fill', state) - applyPaint(node, spec, 'stroke', state) - } - if ('strokeWeight' in node) { - setValue( - node, - node.strokeWeight, - appearance.strokeTopWeight !== undefined || - spec.variables?.strokeWeight || - hasDesiredVariable(spec, STROKE_WEIGHT_FIELDS) || - currentBoundVariableId(node, 'strokeWeight') || - hasCurrentVariable(node, STROKE_WEIGHT_FIELDS) - ? undefined - : appearance.strokeWeight, - (value) => (node.strokeWeight = value), - state - ) - if ('strokeTopWeight' in node) { - for (const field of STROKE_WEIGHT_FIELDS) { - applyIndividualValue( - node, - node[field], - appearance[field], - field, - 'strokeWeight', - spec, - (value) => (node[field] = value), - state - ) - } - } - } - if ('cornerRadius' in node) { - setValue( - node, - node.cornerRadius, - appearance.topLeftRadius !== undefined || - spec.variables?.cornerRadius || - hasDesiredVariable(spec, CORNER_RADIUS_FIELDS) || - currentBoundVariableId(node, 'cornerRadius') || - hasCurrentVariable(node, CORNER_RADIUS_FIELDS) - ? undefined - : appearance.cornerRadius, - (value) => (node.cornerRadius = value), - state - ) - if ('topLeftRadius' in node) { - for (const field of CORNER_RADIUS_FIELDS) { - applyIndividualValue( - node, - node[field], - appearance[field], - field, - 'cornerRadius', - spec, - (value) => (node[field] = value), - state - ) - } - } - } - if ('clipsContent' in node) { - setValue( - node, - node.clipsContent, - appearance.clipsContent, - (value) => (node.clipsContent = value), - state - ) - } - if ('opacity' in node) { - setValue( - node, - node.opacity, - spec.variables?.opacity || currentBoundVariableId(node, 'opacity') - ? undefined - : appearance.opacity, - (value) => (node.opacity = value), - state - ) - } - - const stroke = spec.figma?.stroke - if (stroke) { - if (!('strokeAlign' in node)) { - specError(`Stroke geometry is not supported on ${node.type} node "${spec.key}".`) - } - setValue(node, node.strokeAlign, stroke.align, (value) => (node.strokeAlign = value), state) - if ('strokeCap' in node) { - setValue(node, node.strokeCap, stroke.cap, (value) => (node.strokeCap = value), state) - } - setValue(node, node.strokeJoin, stroke.join, (value) => (node.strokeJoin = value), state) - if ('strokeMiterLimit' in node) { - setValue( - node, - node.strokeMiterLimit, - stroke.miterLimit, - (value) => (node.strokeMiterLimit = value), - state - ) - } - if ( - stroke.dashPattern !== undefined && - !nativeValueEqual(node.dashPattern, stroke.dashPattern) - ) { - node.dashPattern = stroke.dashPattern - markMutation(state, node) - } - } - if ('cornerSmoothing' in node) { - setValue( - node, - node.cornerSmoothing, - spec.figma?.corners?.smoothing, - (value) => (node.cornerSmoothing = value), - state - ) - } -} - -function resolvedComponent(reference: CanvasDesignReference, state: ApplyState): ComponentNode { - const component = state.componentCache.get(designReferenceCacheKey(reference)) - if (!component) - applyError( - 'A preflighted component could not be resolved. This is an internal apply failure; do not retry the same payload.' - ) - return component -} - -function nativeShaderValue( - value: CanvasFigmaShaderPropertyValue, - state: ApplyState -): ShaderPropertyValue { - if (!isRecord(value)) return value - if (isVariableBinding(value)) { - return figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) - } - if ('color' in value) { - return { - ...value, - color: nativeShaderValue(value.color as CanvasFigmaShaderPropertyValue, state) as - | RGB - | RGBA - | VariableAlias - } - } - if ('stops' in value) { - return { - stops: ( - value.stops as Array<{ - position: number - color: CanvasFigmaShaderPropertyValue - }> - ).map((stop) => ({ - position: stop.position, - color: nativeShaderValue(stop.color, state) as RGB | RGBA | VariableAlias - })) - } - } - return value -} - -function nativeShaderProperties( - id: string, - values: Record | undefined, - state: ApplyState -): Record | undefined { - const shader = state.shaderCache.get(id) - if (!shader) applyError(`Shader "${id}" was not preflighted.`) - const properties = Object.fromEntries( - Object.entries(shader.propertyDefinitions ?? {}) - .filter(([, definition]) => definition.defaultValue !== undefined) - .map(([propertyId, definition]) => [propertyId, definition.defaultValue!]) - ) as Record - for (const [propertyId, value] of Object.entries(values ?? {})) { - properties[propertyId] = nativeShaderValue(value, state) - } - return Object.keys(properties).length ? properties : undefined -} - -function paintDefaults(paint: { visible?: boolean; opacity?: number; blendMode?: BlendMode }) { - return { - visible: paint.visible ?? true, - opacity: paint.opacity ?? 1, - blendMode: paint.blendMode ?? 'NORMAL' - } as const -} - -function requiredMediaHash(hashes: ReadonlyMap, key: string): string { - const hash = hashes.get(key) - if (!hash) - applyError( - 'A preflighted media import is missing. This is an internal apply failure; do not retry the same payload.' - ) - return hash -} - -function nativePaint(paint: CanvasFigmaPaint, state: ApplyState): Paint { - switch (paint.type) { - case 'SOLID': { - const { variables, ...fields } = paint - const value: SolidPaint = { - ...fields, - ...paintDefaults(fields) - } - return variables - ? figma.variables.setBoundVariableForPaint( - value, - 'color', - resolvedVariable(variables.color, state.variables) - ) - : value - } - case 'GRADIENT_LINEAR': - case 'GRADIENT_RADIAL': - case 'GRADIENT_ANGULAR': - case 'GRADIENT_DIAMOND': - return { - ...paint, - gradientStops: paint.gradientStops.map(({ variables, ...stop }) => ({ - ...stop, - ...(variables - ? { - boundVariables: { - color: figma.variables.createVariableAlias( - resolvedVariable(variables.color, state.variables) - ) - } - } - : {}) - })), - ...paintDefaults(paint) - } - case 'IMAGE': { - const { assetKey, imageUrl, ...fields } = paint - return { - ...fields, - imageHash: - assetKey !== undefined - ? requiredMediaHash(state.media.imageHashes, `asset:${assetKey}`) - : imageUrl === undefined - ? (fields.imageHash ?? null) - : requiredMediaHash(state.media.imageHashes, imageUrl), - ...paintDefaults(paint) - } - } - case 'VIDEO': { - const { videoUrl, ...fields } = paint - return { - ...fields, - videoHash: - videoUrl === undefined - ? (fields.videoHash ?? null) - : requiredMediaHash(state.media.videoHashes, videoUrl), - ...paintDefaults(paint) - } - } - case 'PATTERN': { - const { sourceCanvasKey, ...fields } = paint - return { - ...fields, - sourceNodeId: - sourceCanvasKey === undefined - ? fields.sourceNodeId! - : resolveCanvasKey(sourceCanvasKey, state).id, - ...paintDefaults(paint) - } - } - case 'SHADER': { - const { properties: values, ...fields } = paint - const properties = nativeShaderProperties(paint.id, values, state) - return { - ...fields, - ...paintDefaults(paint), - ...(properties ? { properties } : {}) - } - } - } -} - -function bindEffectVariables( - effect: Effect, - bindings: - | NonNullable['variables']> - | undefined, - state: ApplyState -): Effect { - let bound = effect - for (const [field, reference] of Object.entries(bindings ?? {})) { - bound = figma.variables.setBoundVariableForEffect( - bound, - field as VariableBindableEffectField, - resolvedVariable(reference, state.variables) - ) - } - return bound -} - -function nativeEffect(effect: CanvasFigmaEffect, state: ApplyState): Effect { - switch (effect.type) { - case 'DROP_SHADOW': - case 'INNER_SHADOW': { - const { variables, ...fields } = effect - return bindEffectVariables( - { - ...fields, - ...(variables?.spread !== undefined && fields.spread === undefined ? { spread: 0 } : {}), - visible: fields.visible ?? true, - blendMode: fields.blendMode ?? 'NORMAL' - }, - variables, - state - ) - } - case 'LAYER_BLUR': - case 'BACKGROUND_BLUR': { - const { variables, ...fields } = effect - return bindEffectVariables({ ...fields, visible: fields.visible ?? true }, variables, state) - } - case 'NOISE': - return { - ...effect, - visible: effect.visible ?? true, - blendMode: effect.blendMode ?? 'NORMAL' - } - case 'TEXTURE': - case 'GLASS': - return { ...effect, visible: effect.visible ?? true } - case 'SHADER': { - const properties = nativeShaderProperties(effect.id, effect.properties, state) - return { - type: 'SHADER', - id: effect.id, - visible: effect.visible ?? true, - ...(properties ? { properties } : {}) - } - } - } -} - -function comparableEntries(value: Record): Array<[string, unknown]> { - return Object.entries(value).filter( - ([key, field]) => - !( - (key === 'boundVariables' || key === 'properties') && - isRecord(field) && - !Object.keys(field).length - ) - ) -} - -function nativeValueEqual(current: unknown, desired: unknown, numberTolerance = 0): boolean { - if (Object.is(current, desired)) return true - if (typeof current === 'number' && typeof desired === 'number') { - return Math.abs(current - desired) <= numberTolerance - } - if (Array.isArray(current) || Array.isArray(desired)) { - return ( - Array.isArray(current) && - Array.isArray(desired) && - current.length === desired.length && - current.every((value, index) => nativeValueEqual(value, desired[index], numberTolerance)) - ) - } - if (!isRecord(current) || !isRecord(desired)) return false - const currentEntries = comparableEntries(current) - const desiredEntries = comparableEntries(desired) - return ( - currentEntries.length === desiredEntries.length && - currentEntries.every(([key, value]) => nativeValueEqual(value, desired[key], numberTolerance)) - ) -} - -const FIGMA_NATIVE_TOLERANCE = 1 / 255 + Number.EPSILON - -function nativeLayoutGrid(grid: CanvasFigmaLayoutGrid, state: ApplyState): LayoutGrid { - const { variables, ...fields } = grid - let native: LayoutGrid = - fields.pattern === 'GRID' - ? fields - : { - ...fields, - count: fields.count === 'AUTO' ? Infinity : fields.count - } - for (const [field, reference] of Object.entries(variables ?? {}) as Array< - [VariableBindableLayoutGridField, CanvasVariableReference] - >) { - native = figma.variables.setBoundVariableForLayoutGrid( - native, - field, - resolvedVariable(reference, state.variables) - ) - } - return native -} - -function comparableLayoutGrid(grid: LayoutGrid, expected: LayoutGrid): Record { - const comparable = Object.fromEntries( - Object.keys(expected) - .filter((field) => !['boundVariables', 'color', 'visible'].includes(field)) - .map((field) => [field, grid[field as keyof LayoutGrid]]) - ) - return { - ...comparable, - visible: grid.visible ?? true, - ...(expected.color === undefined ? {} : { color: grid.color }), - boundVariables: grid.boundVariables ?? {} - } -} - -function layoutGridsEqual(current: readonly LayoutGrid[], desired: readonly LayoutGrid[]): boolean { - return ( - current.length === desired.length && - current.every((grid, index) => - nativeValueEqual( - comparableLayoutGrid(grid, desired[index]!), - comparableLayoutGrid(desired[index]!, desired[index]!), - FIGMA_NATIVE_TOLERANCE - ) - ) - ) -} - -const IMAGE_FILTER_FIELDS = [ - 'exposure', - 'contrast', - 'saturation', - 'temperature', - 'tint', - 'highlights', - 'shadows' -] as const satisfies ReadonlyArray - -function comparablePaint(paint: Paint, expected: Paint): unknown { - if (paint.type === 'IMAGE' && expected.type === 'IMAGE') { - return { - type: paint.type, - imageHash: paint.imageHash, - scaleMode: paint.scaleMode, - ...paintDefaults(paint), - filters: Object.fromEntries( - IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) - ), - ...(paint.scaleMode === 'CROP' && expected.imageTransform !== undefined - ? { imageTransform: paint.imageTransform } - : {}), - ...(paint.scaleMode === 'TILE' && expected.scalingFactor !== undefined - ? { scalingFactor: paint.scalingFactor } - : {}), - ...(paint.scaleMode === 'CROP' ? {} : { rotation: paint.rotation ?? 0 }) - } - } - if (paint.type === 'VIDEO' && expected.type === 'VIDEO') { - return { - type: paint.type, - videoHash: paint.videoHash, - scaleMode: paint.scaleMode, - ...paintDefaults(paint), - filters: Object.fromEntries( - IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) - ), - ...(paint.scaleMode === 'CROP' && expected.videoTransform !== undefined - ? { videoTransform: paint.videoTransform } - : {}), - ...(paint.scaleMode === 'TILE' && expected.scalingFactor !== undefined - ? { scalingFactor: paint.scalingFactor } - : {}), - ...(paint.scaleMode === 'CROP' ? {} : { rotation: paint.rotation ?? 0 }) - } - } - return { - ...paint, - ...paintDefaults(paint) - } -} - -function paintStacksEqual(current: readonly Paint[], desired: readonly Paint[]): boolean { - return ( - current.length === desired.length && - current.every((paint, index) => { - const expected = desired[index]! - return ( - paint.type === expected.type && - nativeValueEqual( - comparablePaint(paint, expected), - comparablePaint(expected, expected), - FIGMA_NATIVE_TOLERANCE - ) - ) - }) - ) -} - -function isShadowEffect(effect: Effect): effect is DropShadowEffect | InnerShadowEffect { - return effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW' -} - -function comparableShadow( - effect: DropShadowEffect | InnerShadowEffect, - expected: DropShadowEffect | InnerShadowEffect -): Effect { - if (effect.type !== 'DROP_SHADOW' || expected.type !== 'DROP_SHADOW') { - return { ...effect, spread: effect.spread ?? 0 } - } - if (expected.showShadowBehindNode !== undefined) { - return { - ...effect, - spread: effect.spread ?? 0, - showShadowBehindNode: effect.showShadowBehindNode ?? false - } - } - const { showShadowBehindNode: _showShadowBehindNode, ...withoutBehindNode } = effect - return { ...withoutBehindNode, spread: effect.spread ?? 0 } -} - -function comparableEffect(effect: Effect, expected: Effect): unknown { - return isShadowEffect(effect) && isShadowEffect(expected) - ? comparableShadow(effect, expected) - : effect -} - -function effectEqual(current: Effect, desired: Effect): boolean { - return ( - current.type === desired.type && - nativeValueEqual( - comparableEffect(current, desired), - comparableEffect(desired, desired), - FIGMA_NATIVE_TOLERANCE - ) - ) -} - -function effectsEqual(current: readonly Effect[], desired: readonly Effect[]): boolean { - return ( - current.length === desired.length && - current.every((effect, index) => effectEqual(effect, desired[index]!)) - ) -} - -function summarizeNativeValue(value: unknown): string { - const serialized = JSON.stringify(value) - if (serialized === undefined) return String(value) - return serialized.length <= 400 ? serialized : `${serialized.slice(0, 397)}...` -} - -function describeEffectMismatch(current: readonly Effect[], desired: readonly Effect[]): string { - if (current.length !== desired.length) { - return `expected ${desired.length} effect${desired.length === 1 ? '' : 's'}, found ${current.length}.` - } - const index = current.findIndex( - (effect, effectIndex) => !effectEqual(effect, desired[effectIndex]!) - ) - if (index < 0) return 'effect stack changed before verification completed.' - const expected = desired[index]! - const found = current[index]! - return `effect ${index} does not match; expected ${summarizeNativeValue(comparableEffect(expected, expected))}, found ${summarizeNativeValue(comparableEffect(found, expected))}.` -} - -function setStyleValue( - current: T, - desired: T | undefined, - apply: (value: T) => void, - state: ApplyState, - equal: (left: T, right: T) => boolean = nativeValueEqual -): void { - if (desired === undefined || equal(current, desired)) return - apply(desired) - state.mutations.count += 1 -} - -function applyStyleMetadata(style: BaseStyle, spec: CanvasStyleResource, state: ApplyState): void { - setStyleValue(style.name, spec.name, (value) => (style.name = value), state) - setStyleValue( - style.descriptionMarkdown, - spec.descriptionMarkdown, - (value) => (style.descriptionMarkdown = value), - state - ) - if (spec.documentationLink === undefined) return - const links = spec.documentationLink === null ? [] : [{ uri: spec.documentationLink }] - setStyleValue( - style.documentationLinks, - links, - (value) => (style.documentationLinks = value), - state - ) -} - -function applyTextStyle(style: TextStyle, spec: TextStyleResource, state: ApplyState): void { - for (const [field, reference] of textStyleVariableEntries(spec)) { - if (reference !== null || !style.boundVariables?.[field]) continue - style.setBoundVariable(field, null) - state.mutations.count += 1 - } - for (const field of TEXT_STYLE_VALUE_FIELDS) { - const desired = spec[field] - if ( - desired === undefined || - TEXT_STYLE_VARIABLES_BY_VALUE[field].some( - (variableField) => style.boundVariables?.[variableField] - ) || - nativeValueEqual(style[field], desired) - ) { - continue - } - Object.assign(style, { [field]: desired }) - state.mutations.count += 1 - } - for (const [field, reference] of textStyleVariableEntries(spec)) { - if (!reference) continue - const variable = resolvedVariable(reference, state.variables) - if (style.boundVariables?.[field]?.id === variable.id) continue - style.setBoundVariable(field, variable) - state.mutations.count += 1 - } -} - -function applyStyleResources(state: ApplyState): void { - for (const { spec, style } of state.styles.resources) { - applyStyleMetadata(style, spec, state) - switch (spec.type) { - case 'PAINT': { - if (spec.paints === undefined) break - const desired = spec.paints.map((paint) => nativePaint(paint, state)) - setStyleValue( - (style as PaintStyle).paints, - desired, - (value) => ((style as PaintStyle).paints = value), - state, - paintStacksEqual - ) - break - } - case 'TEXT': - applyTextStyle(style as TextStyle, spec, state) - break - case 'EFFECT': { - if (spec.effects === undefined) break - const desired = spec.effects.map((effect) => nativeEffect(effect, state)) - setStyleValue( - (style as EffectStyle).effects, - desired, - (value) => ((style as EffectStyle).effects = value), - state, - effectsEqual - ) - break - } - case 'GRID': { - if (spec.layoutGrids === undefined) break - const desired = spec.layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) - setStyleValue( - (style as GridStyle).layoutGrids, - desired, - (value) => ((style as GridStyle).layoutGrids = value), - state, - layoutGridsEqual - ) - break - } - } - } -} - -function applyPaintStacks( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - for (const [property, styleProperty] of [ - ['fills', 'fillStyleId'], - ['strokes', 'strokeStyleId'] - ] as const) { - const paints = spec.figma?.[property] - if (paints === undefined) continue - if (!('fills' in node)) { - specError(`Direct paints are not supported on ${node.type} node "${spec.key}".`) - } - const desired = paints.map((paint) => nativePaint(paint, state)) - const current = node[property] - if (current !== figma.mixed && !node[styleProperty] && paintStacksEqual(current, desired)) { - continue - } - node[property] = desired - markMutation(state, node) - } -} - -function hasShadowSpread(spec: CanvasNodeSpec): boolean { - return ( - spec.figma?.effects?.some( - (effect) => - (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') && - (effect.spread !== undefined || effect.variables?.spread !== undefined) - ) ?? false - ) -} - -function shadowSpreadError(spec: CanvasNodeSpec): never { - return specError( - `Shadow spread on "${spec.key}" requires a rectangle, ellipse, or a clipped frame/instance with a visible fill; authored components and component sets count as frames.` - ) -} - -function preflightShadowSpread(spec: CanvasNodeSpec, existing?: SupportedCanvasNode): void { - if (!hasShadowSpread(spec) || spec.type === 'RECTANGLE' || spec.type === 'ELLIPSE') return - // Instance swaps and deferred fills need the final native state. Frame clipping, - // however, is known before resources are created, including normalized defaults. - if (spec.type !== 'FRAME' && spec.type !== 'COMPONENT') return - const clipsContent = - spec.appearance?.clipsContent ?? - (existing && 'clipsContent' in existing ? existing.clipsContent : false) - if (!clipsContent) shadowSpreadError(spec) -} - -function validateShadowSpread(node: SupportedCanvasNode, spec: CanvasNodeSpec): void { - if (!hasShadowSpread(spec) || node.type === 'RECTANGLE' || node.type === 'ELLIPSE') return - const authoredFills = spec.figma?.fills - const hasLiveVisibleFill = - 'fills' in node && - node.fills !== figma.mixed && - node.fills.some((paint) => paint.visible ?? true) - const hasVisibleFill = - authoredFills === undefined - ? hasLiveVisibleFill - : authoredFills.some((paint) => paint.visible ?? true) - if ((isFrameContainer(node) || node.type === 'INSTANCE') && node.clipsContent && hasVisibleFill) { - return - } - shadowSpreadError(spec) -} - -function applyEffects(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - const effects = spec.figma?.effects - if (effects === undefined) return - if (!('effects' in node)) { - specError(`Effects are not supported on ${node.type} node "${spec.key}".`) - } - validateShadowSpread(node, spec) - const desired = effects.map((effect) => nativeEffect(effect, state)) - if (!node.effectStyleId && effectsEqual(node.effects, desired)) return - node.effects = desired - markMutation(state, node) -} - -function applyLayoutAids(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - if (!isFrameContainer(node) && node.type !== 'INSTANCE') return - const layoutGrids = spec.figma?.layoutGrids - if (layoutGrids !== undefined) { - const desired = layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) - if (node.gridStyleId || !layoutGridsEqual(node.layoutGrids, desired)) { - node.layoutGrids = desired - markMutation(state, node) - } - } - applyGuides(node, spec.figma?.guides, state) -} - -function applyGuides( - node: CanvasFrameContainerNode | InstanceNode | PageNode, - guides: CanvasPageProperties['guides'], - state: ApplyState -): void { - if (guides === undefined || nativeValueEqual(node.guides, guides)) return - node.guides = guides - markMutation(state, node) -} - -async function nativeVectorNetwork( - network: CanvasFigmaVectorNetwork, - state: ApplyState -): Promise { - const { regions, ...geometry } = network - if (!regions) return geometry - - return { - ...geometry, - regions: await Promise.all( - regions.map(async ({ fills, fillStyle, ...region }) => ({ - ...region, - ...(fills === undefined ? {} : { fills: fills.map((paint) => nativePaint(paint, state)) }), - ...(fillStyle ? { fillStyleId: (await resolveStyle(fillStyle, state.styles)).id } : {}) - })) - ) - } -} - -function comparableVectorNetwork(network: VectorNetwork, expected: VectorNetwork): unknown { - return { - vertices: network.vertices, - segments: network.segments.map((segment) => ({ - ...segment, - tangentStart: segment.tangentStart ?? { x: 0, y: 0 }, - tangentEnd: segment.tangentEnd ?? { x: 0, y: 0 } - })), - regions: (network.regions ?? []).map(({ fills, fillStyleId, ...region }, regionIndex) => { - const expectedFills = expected.regions?.[regionIndex]?.fills - return { - ...region, - ...(fillStyleId - ? { fillStyleId } - : fills === undefined - ? {} - : { - fills: fills.map((paint, paintIndex) => - comparablePaint(paint, expectedFills?.[paintIndex] ?? paint) - ) - }) - } - }) - } -} - -function vectorNetworksEqual(current: VectorNetwork, desired: VectorNetwork): boolean { - return nativeValueEqual( - comparableVectorNetwork(current, desired), - comparableVectorNetwork(desired, desired), - FIGMA_NATIVE_TOLERANCE - ) -} - -async function applyShape( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState, - resolveCanvasReferences = false -): Promise { - const shape = spec.figma?.shape - if (!shape) return - - switch (shape.type) { - case 'RECTANGLE': - case 'LINE': - return - case 'ELLIPSE': { - if (!shape.arc) return - if (node.type !== 'ELLIPSE') specError(`Native shape "${spec.key}" is not an ellipse.`) - const desired = { - startingAngle: (shape.arc.startAngle * Math.PI) / 180, - endingAngle: (shape.arc.endAngle * Math.PI) / 180, - innerRadius: shape.arc.innerRadius - } - const current = node.arcData - if ( - Math.abs(current.startingAngle - desired.startingAngle) <= 1e-6 && - Math.abs(current.endingAngle - desired.endingAngle) <= 1e-6 && - Math.abs(current.innerRadius - desired.innerRadius) <= 1e-6 - ) { - return - } - node.arcData = desired - markMutation(state, node) - return - } - case 'POLYGON': - if (shape.pointCount === undefined) return - if (node.type !== 'POLYGON') specError(`Native shape "${spec.key}" is not a polygon.`) - setValue(node, node.pointCount, shape.pointCount, (value) => (node.pointCount = value), state) - return - case 'STAR': - if (node.type !== 'STAR') specError(`Native shape "${spec.key}" is not a star.`) - setValue(node, node.pointCount, shape.pointCount, (value) => (node.pointCount = value), state) - setValue( - node, - node.innerRadius, - shape.innerRadius, - (value) => (node.innerRadius = value), - state - ) - return - case 'VECTOR': { - if (node.type !== 'VECTOR') specError(`Native shape "${spec.key}" is not a vector.`) - setValue( - node, - node.handleMirroring, - shape.handleMirroring, - (value) => (node.handleMirroring = value), - state - ) - if (shape.paths !== undefined) { - if (vectorPathsEqual(node.vectorPaths, shape.paths)) return - node.vectorPaths = canonicalVectorPaths(shape.paths) - markMutation(state, node) - return - } - if (shape.network !== undefined) { - if (!resolveCanvasReferences && hasCanvasKeyVectorPattern(spec)) return - const network = await nativeVectorNetwork(shape.network, state) - if (vectorNetworksEqual(node.vectorNetwork, network)) return - await node.setVectorNetworkAsync(network) - markMutation(state, node) - } - } - } -} - -async function resolveLiteralFont( - family: string, - style: string, - spec: CanvasNodeSpec, - state: ApplyState -): Promise { - if (spec.figma?.text?.fontName) return { family, style } - if (family === 'Inter' || family === 'Noto Serif' || family === 'Noto Sans Mono') { - return { family, style: normalizePortableFontStyle(family, style) } - } - return resolveFamilyFont(family, style, state.fonts) -} - -async function loadTextFonts( - node: TextNode, - spec: CanvasNodeSpec, - state: ApplyState -): Promise { - const text = spec.text - const currentFont = node.fontName - const hasTextStyle = !!(spec.styles?.text || node.textStyleId) - if (text?.fontStyleMatching && !hasTextStyle) { - const familyReference = spec.variables?.fontFamily - const styleReference = spec.variables?.fontStyle - const weightReference = spec.variables?.fontWeight - const family = familyReference - ? resolvedFontVariableValue(node, familyReference, state) - : currentBoundVariableId(node, 'fontFamily') && currentFont !== figma.mixed - ? currentFont.family - : (text.fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family)) - const style = styleReference - ? resolvedFontVariableValue(node, styleReference, state) - : currentBoundVariableId(node, 'fontStyle') && currentFont !== figma.mixed - ? currentFont.style - : (text.fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style)) - if (currentFont === figma.mixed && (!family || !style)) { - specError( - `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` - ) - } - const weight = weightReference - ? resolvedVariable(weightReference, state.variables).resolveForConsumer(node).value - : undefined - if ( - weight !== undefined && - (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000) - ) { - specError(`Font weight on "${spec.key}" must resolve to a number between 1 and 1000.`) - } - const desiredFont = styleReference - ? { family, style } - : await resolveFamilyFont(family, style, state.fonts, weight) - await loadFont(desiredFont, state.fonts) - return desiredFont - } - const fontFamily = - hasTextStyle || spec.variables?.fontFamily || currentBoundVariableId(node, 'fontFamily') - ? undefined - : text?.fontFamily - const fontStyle = - hasTextStyle || spec.variables?.fontStyle || currentBoundVariableId(node, 'fontStyle') - ? undefined - : text?.fontStyle - const hasExplicitFont = fontFamily !== undefined || fontStyle !== undefined - if (currentFont === figma.mixed && hasExplicitFont && (!fontFamily || !fontStyle)) { - specError( - `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` - ) - } - - const desiredFamily = fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family) - const desiredStyle = fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) - const desiredFont: FontName | null = hasExplicitFont - ? text?.portableFontFamily - ? await resolvePortableFont(text.portableFontFamily, desiredStyle, state.fonts) - : await resolveLiteralFont(desiredFamily, desiredStyle, spec, state) - : null - if (desiredFont && text?.portableFontFamily) { - text.fontFamily = desiredFont.family - text.fontStyle = desiredFont.style - } - const fonts = desiredFont - ? [desiredFont] - : currentFont === figma.mixed - ? node.getRangeAllFontNames(0, node.characters.length) - : [currentFont] - await loadFonts(fonts, state.fonts) - return desiredFont -} - -function preservesComponentPropertyReference( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - field: ComponentPropertyReferenceField -): boolean { - const desired = spec.figma?.componentPropertyReferences?.[field] - return desired === undefined - ? node.componentPropertyReferences?.[field] !== undefined - : desired !== null -} - -async function applyText(node: TextNode, spec: CanvasNodeSpec, state: ApplyState): Promise { - const text = spec.text - if (!text) return - const native = spec.figma?.text - const desiredFont = await loadTextFonts(node, spec, state) - const hasTextStyle = !!(spec.styles?.text || node.textStyleId) - if ( - desiredFont && - (node.fontName === figma.mixed || - node.fontName.family !== desiredFont.family || - node.fontName.style !== desiredFont.style) - ) { - node.fontName = desiredFont - markMutation(state, node) - } - setValue(node, node.autoRename, native?.autoRename, (value) => (node.autoRename = value), state) - setValue( - node, - node.characters, - preservesComponentPropertyReference(node, spec, 'characters') || - spec.variables?.characters || - currentBoundVariableId(node, 'characters') - ? undefined - : text.characters, - (value) => (node.characters = value), - state - ) - setValue( - node, - node.fontSize, - hasTextStyle || spec.variables?.fontSize || currentBoundVariableId(node, 'fontSize') - ? undefined - : text.fontSize, - (value) => (node.fontSize = value), - state - ) - setTextMeasure( - node, - node.lineHeight, - hasTextStyle || spec.variables?.lineHeight || currentBoundVariableId(node, 'lineHeight') - ? undefined - : text.lineHeight, - (value) => (node.lineHeight = value), - state - ) - setTextMeasure( - node, - node.letterSpacing, - hasTextStyle || spec.variables?.letterSpacing || currentBoundVariableId(node, 'letterSpacing') - ? undefined - : text.letterSpacing, - (value) => (node.letterSpacing = value), - state - ) - setValue( - node, - node.textAlignHorizontal, - text.alignHorizontal, - (value) => (node.textAlignHorizontal = value), - state - ) - setValue( - node, - node.textAlignVertical, - text.alignVertical, - (value) => (node.textAlignVertical = value), - state - ) - setValue( - node, - node.textCase, - native?.case ?? (hasTextStyle ? undefined : text.textCase), - (value) => (node.textCase = value), - state - ) - setValue( - node, - node.textDecoration, - hasTextStyle ? undefined : text.textDecoration, - (value) => (node.textDecoration = value), - state - ) - setValue( - node, - node.textTruncation, - text.textTruncation, - (value) => (node.textTruncation = value), - state - ) - setValue(node, node.maxLines, text.maxLines, (value) => (node.maxLines = value), state) - setValue( - node, - node.textAutoResize, - text.autoResize, - (value) => (node.textAutoResize = value), - state - ) - setValue( - node, - node.paragraphIndent, - spec.variables?.paragraphIndent || currentBoundVariableId(node, 'paragraphIndent') - ? undefined - : native?.paragraphIndent, - (value) => (node.paragraphIndent = value), - state - ) - setValue( - node, - node.paragraphSpacing, - spec.variables?.paragraphSpacing || currentBoundVariableId(node, 'paragraphSpacing') - ? undefined - : native?.paragraphSpacing, - (value) => (node.paragraphSpacing = value), - state - ) - setValue( - node, - node.listSpacing, - native?.listSpacing, - (value) => (node.listSpacing = value), - state - ) - setValue( - node, - node.hangingPunctuation, - native?.hangingPunctuation, - (value) => (node.hangingPunctuation = value), - state - ) - setValue( - node, - node.hangingList, - native?.hangingList, - (value) => (node.hangingList = value), - state - ) - setValue( - node, - node.leadingTrim, - native?.leadingTrim, - (value) => (node.leadingTrim = value), - state - ) - if (!isCanvasKeyHyperlink(native?.hyperlink)) { - applyTextHyperlink(node, native?.hyperlink, state) - } -} - -function textMeasuresEqual( - current: LineHeight | LetterSpacing | typeof figma.mixed, - desired: LineHeight | LetterSpacing -): boolean { - return ( - current !== figma.mixed && - current.unit === desired.unit && - (current.unit === 'AUTO' || (desired.unit !== 'AUTO' && current.value === desired.value)) - ) -} - -function setTextMeasure( - node: TextNode, - current: T | typeof figma.mixed, - desired: T | undefined, - apply: (value: T) => void, - state: ApplyState -): void { - if (desired === undefined || textMeasuresEqual(current, desired)) return - apply(desired) - markMutation(state, node) -} - -function hyperlinksEqual( - current: HyperlinkTarget | null | typeof figma.mixed, - desired: HyperlinkTarget | null -): boolean { - return ( - current !== figma.mixed && - (current === desired || - (!!current && !!desired && current.type === desired.type && current.value === desired.value)) - ) -} - -function nativeHyperlink( - hyperlink: CanvasHyperlink | undefined, - state: ApplyState -): HyperlinkTarget | null | undefined { - if (hyperlink === undefined || hyperlink === null || hyperlink.type === 'URL') { - return hyperlink - } - return { - type: 'NODE', - value: - typeof hyperlink.value === 'string' - ? hyperlink.value - : resolveCanvasKey(hyperlink.value.canvasKey, state).id - } -} - -function applyTextHyperlink( - node: TextNode, - hyperlink: CanvasHyperlink | undefined, - state: ApplyState -): void { - const desired = nativeHyperlink(hyperlink, state) - if (desired === undefined || hyperlinksEqual(node.hyperlink, desired)) return - node.hyperlink = desired - markMutation(state, node) -} - -function applyTextRangeValue( - node: TextNode, - current: T | typeof figma.mixed | null, - desired: T | undefined, - apply: (value: T) => void, - state: ApplyState -): void { - if (desired === undefined || nativeValueEqual(current, desired)) return - apply(desired) - markMutation(state, node) -} - -async function applyTextRangeStyle( - node: TextNode, - reference: CanvasStyleReference | null | undefined, - current: () => string | typeof figma.mixed, - apply: (id: string) => Promise, - state: ApplyState -): Promise { - if (reference === undefined) return - const styleId = reference ? (await resolveStyle(reference, state.styles)).id : '' - if (current() === styleId) return - await apply(styleId) - markMutation(state, node) -} - -function applyTextRangeFills(node: TextNode, range: CanvasFigmaTextRange, state: ApplyState): void { - if (range.fills === undefined) return - const desired = range.fills.map((paint) => nativePaint(paint, state)) - const current = node.getRangeFills(range.start, range.end) - const style = node.getRangeFillStyleId(range.start, range.end) - if (current !== figma.mixed && !style && paintStacksEqual(current, desired)) return - node.setRangeFills(range.start, range.end, desired) - markMutation(state, node) -} - -function nativeTextDecorationColor( - range: CanvasFigmaTextRange, - state: ApplyState -): TextDecorationColor | undefined { - const color = range.textDecorationColor - if (!color || color.value === 'AUTO') return color - return { value: nativePaint(color.value, state) as SolidPaint } -} - -type FontVariableBindings = Pick - -async function loadVariableFontTransitions( - current: FontName[], - bindings: FontVariableBindings | undefined, - values: { family?: string; style?: string; weight?: number }, - state: ApplyState -): Promise { - let fonts = current - const required = [...fonts] - // Native setters apply one binding at a time, in the supplied binding order. - for (const field of Object.keys(bindings ?? {}) as Array) { - if (!bindings?.[field]) continue - if (field === 'fontFamily' && values.family !== undefined) { - const family = values.family - fonts = await Promise.all( - fonts.map((font) => - font.family === family ? font : resolveFamilyFont(family, font.style, state.fonts) - ) - ) - } else if (field === 'fontStyle' && values.style !== undefined) { - const style = values.style - fonts = fonts.map((font) => ({ family: font.family, style })) - } else if (field === 'fontWeight' && values.weight !== undefined) { - fonts = await Promise.all( - fonts.map((font) => resolveFamilyFont(font.family, font.style, state.fonts, values.weight)) - ) - } else continue - required.push(...fonts) - } - await loadFonts(required, state.fonts) -} - -function resolvedFontVariableValue( - node: TextNode, - reference: CanvasVariableReference, - state: ApplyState -): string { - const value = resolvedVariable(reference, state.variables).resolveForConsumer(node).value - if (typeof value !== 'string') { - applyError('A preflighted font variable did not resolve to a string.') - } - return value -} - -async function loadVariableFonts( - node: TextNode, - bindings: FontVariableBindings | undefined, - state: ApplyState, - range?: Pick -): Promise { - const familyReference = bindings?.fontFamily - const styleReference = bindings?.fontStyle - const weightReference = bindings?.fontWeight - if (!familyReference && !styleReference && !weightReference) return - const weight = weightReference - ? resolvedVariable(weightReference, state.variables).resolveForConsumer(node).value - : undefined - if ( - weight !== undefined && - (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000) - ) { - specError(`Font weight on "${node.id}" must resolve to a number between 1 and 1000.`) - } - - await loadVariableFontTransitions( - currentTextFonts(node, range), - bindings, - { - family: familyReference ? resolvedFontVariableValue(node, familyReference, state) : undefined, - style: styleReference ? resolvedFontVariableValue(node, styleReference, state) : undefined, - weight - }, - state - ) -} - -async function applyTextRangeVariables( - node: TextNode, - range: CanvasFigmaTextRange, - state: ApplyState -): Promise { - await loadVariableFonts(node, range.variables, state, range) - for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< - [VariableBindableTextField, CanvasVariableReference | null] - >) { - const variable = reference ? resolvedVariable(reference, state.variables) : null - const current = node.getRangeBoundVariable(range.start, range.end, field) - if (current !== figma.mixed && current?.id === variable?.id) continue - node.setRangeBoundVariable(range.start, range.end, field, variable) - markMutation(state, node) - } -} - -async function applyTextRanges( - node: TextNode, - ranges: CanvasFigmaTextRange[] | undefined, - state: ApplyState -): Promise { - for (const range of ranges ?? []) { - if (range.end > node.characters.length) { - specError( - `Text range ${range.start}:${range.end} exceeds TEXT node "${node.id}" with ${node.characters.length} UTF-16 code units.` - ) - } - await applyTextRangeStyle( - node, - range.textStyle, - () => node.getRangeTextStyleId(range.start, range.end), - (id) => node.setRangeTextStyleIdAsync(range.start, range.end, id), - state - ) - await applyTextRangeStyle( - node, - range.fillStyle, - () => node.getRangeFillStyleId(range.start, range.end), - (id) => node.setRangeFillStyleIdAsync(range.start, range.end, id), - state - ) - applyTextRangeFills(node, range, state) - applyTextRangeValue( - node, - node.getRangeFontName(range.start, range.end), - range.fontName, - (value) => node.setRangeFontName(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeFontSize(range.start, range.end), - range.fontSize, - (value) => node.setRangeFontSize(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextCase(range.start, range.end), - range.textCase, - (value) => node.setRangeTextCase(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeLetterSpacing(range.start, range.end), - range.letterSpacing, - (value) => node.setRangeLetterSpacing(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeLineHeight(range.start, range.end), - range.lineHeight, - (value) => node.setRangeLineHeight(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextDecoration(range.start, range.end), - range.textDecoration, - (value) => node.setRangeTextDecoration(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextDecorationStyle(range.start, range.end), - range.textDecorationStyle, - (value) => node.setRangeTextDecorationStyle(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextDecorationOffset(range.start, range.end), - range.textDecorationOffset, - (value) => node.setRangeTextDecorationOffset(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextDecorationThickness(range.start, range.end), - range.textDecorationThickness, - (value) => node.setRangeTextDecorationThickness(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextDecorationColor(range.start, range.end), - nativeTextDecorationColor(range, state), - (value) => node.setRangeTextDecorationColor(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeTextDecorationSkipInk(range.start, range.end), - range.textDecorationSkipInk, - (value) => node.setRangeTextDecorationSkipInk(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeListOptions(range.start, range.end), - range.listOptions, - (value) => node.setRangeListOptions(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeListSpacing(range.start, range.end), - range.listSpacing, - (value) => node.setRangeListSpacing(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeIndentation(range.start, range.end), - range.indentation, - (value) => node.setRangeIndentation(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeParagraphIndent(range.start, range.end), - range.paragraphIndent, - (value) => node.setRangeParagraphIndent(range.start, range.end, value), - state - ) - applyTextRangeValue( - node, - node.getRangeParagraphSpacing(range.start, range.end), - range.paragraphSpacing, - (value) => node.setRangeParagraphSpacing(range.start, range.end, value), - state - ) - const hyperlink = nativeHyperlink(range.hyperlink, state) - if ( - hyperlink !== undefined && - !hyperlinksEqual(node.getRangeHyperlink(range.start, range.end), hyperlink) - ) { - node.setRangeHyperlink(range.start, range.end, hyperlink) - markMutation(state, node) - } - await applyTextRangeVariables(node, range, state) - } -} - -function componentPropertyMatches( - current: ComponentProperties[string] | undefined, - value: CanvasComponentPropertyValue, - state: ApplyState -): boolean { - return isVariableBinding(value) - ? current?.boundVariables?.value?.id === resolvedVariable(value.variable, state.variables).id - : current?.value === value && current.boundVariables?.value === undefined -} - -async function applyComponent( - node: InstanceNode, - spec: CanvasNodeSpec, - state: ApplyState -): Promise { - const instance = spec.figma?.instance - const component = spec.component - ? resolvedComponent(spec.component, state) - : await getMainComponent(node) - if (!component) { - specError(`Existing instance "${spec.key}" has no main component.`) - } - if (spec.component && !preservesComponentPropertyReference(node, spec, 'mainComponent')) { - const currentComponent = await getMainComponent(node) - if (!componentLinkMatches(spec, node, component, currentComponent, state)) { - if (instance?.preserveOverrides === false) node.mainComponent = component - else node.swapComponent(component) - markMutation(state, node) - } - } - - setValue( - node, - node.scaleFactor, - instance?.scaleFactor, - (value) => (node.scaleFactor = value), - state - ) - setValue( - node, - node.isExposedInstance, - instance?.exposed, - (value) => (node.isExposedInstance = value), - state - ) - - const owner = componentDefinitionOwner(component) - const desiredProperties = Object.entries(spec.componentProperties ?? {}).map( - ([key, value]) => [componentPropertyName(owner, key, state) ?? key, value] as const - ) - const changedProperties = desiredProperties.filter( - ([name, value]) => !componentPropertyMatches(node.componentProperties[name], value, state) - ) - if (changedProperties.length) { - node.setProperties( - Object.fromEntries( - changedProperties.map(([name, value]) => [ - name, - isVariableBinding(value) - ? figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) - : value - ]) - ) - ) - markMutation(state, node) - } -} - -const STYLE_FIELDS = ['fill', 'stroke', 'text', 'effect', 'grid'] as const - -type StyleField = (typeof STYLE_FIELDS)[number] - -function styleTarget( - node: SupportedCanvasNode, - field: StyleField -): { - apply: (id: string) => Promise - current: string | symbol - text?: TextNode -} { - switch (field) { - case 'fill': - if (!('fillStyleId' in node)) { - specError(`Fill styles are not supported on ${node.type} nodes.`) - } - return { - current: node.fillStyleId, - apply: (id) => node.setFillStyleIdAsync(id) - } - case 'stroke': - if (!('strokeStyleId' in node)) { - specError(`Stroke styles are not supported on ${node.type} nodes.`) - } - return { - current: node.strokeStyleId, - apply: (id) => node.setStrokeStyleIdAsync(id) - } - case 'text': - if (node.type !== 'TEXT') specError(`Text styles require a TEXT node, not ${node.type}.`) - return { - current: node.textStyleId, - apply: (id) => node.setTextStyleIdAsync(id), - text: node - } - case 'effect': - if (!('effectStyleId' in node)) { - specError(`Effect styles are not supported on ${node.type} nodes.`) - } - return { - current: node.effectStyleId, - apply: (id) => node.setEffectStyleIdAsync(id) - } - case 'grid': - if (!isFrameContainer(node) && node.type !== 'INSTANCE') { - specError('Grid styles require a frame container or instance node.') - } - return { - current: node.gridStyleId, - apply: (id) => node.setGridStyleIdAsync(id) - } - } -} - -async function setStyleLink( - node: SupportedCanvasNode, - field: StyleField, - id: string, - state: ApplyState -): Promise { - const target = styleTarget(node, field) - if (target.current === id) return - if (!id && target.text) await loadFonts(currentTextFonts(target.text), state.fonts) - await target.apply(id) - markMutation(state, node) -} - -async function unlinkStyles( - node: SupportedCanvasNode, - bindings: CanvasStyleBindings | undefined, - state: ApplyState -): Promise { - for (const field of STYLE_FIELDS) { - if (bindings?.[field] !== null) continue - await setStyleLink(node, field, '', state) - } -} - -async function applyStyles( - node: SupportedCanvasNode, - bindings: CanvasStyleBindings | undefined, - state: ApplyState -): Promise { - if (!bindings) return - for (const field of STYLE_FIELDS) { - const reference = bindings[field] - if (!reference) continue - const style = await resolveStyle(reference, state.styles) - await setStyleLink(node, field, style.id, state) - } -} - -type DirectVariableField = Exclude - -const DIRECT_VARIABLE_FIELDS: Record< - DirectVariableField, - VariableBindableNodeField | VariableBindableTextField -> = { - characters: 'characters', - visible: 'visible', - width: 'width', - height: 'height', - minWidth: 'minWidth', - maxWidth: 'maxWidth', - minHeight: 'minHeight', - maxHeight: 'maxHeight', - gap: 'itemSpacing', - counterAxisSpacing: 'counterAxisSpacing', - gridRowGap: 'gridRowGap', - gridColumnGap: 'gridColumnGap', - paddingTop: 'paddingTop', - paddingRight: 'paddingRight', - paddingBottom: 'paddingBottom', - paddingLeft: 'paddingLeft', - cornerRadius: 'cornerRadius', - topLeftRadius: 'topLeftRadius', - topRightRadius: 'topRightRadius', - bottomRightRadius: 'bottomRightRadius', - bottomLeftRadius: 'bottomLeftRadius', - strokeWeight: 'strokeWeight', - strokeTopWeight: 'strokeTopWeight', - strokeRightWeight: 'strokeRightWeight', - strokeBottomWeight: 'strokeBottomWeight', - strokeLeftWeight: 'strokeLeftWeight', - opacity: 'opacity', - fontFamily: 'fontFamily', - fontStyle: 'fontStyle', - fontWeight: 'fontWeight', - fontSize: 'fontSize', - lineHeight: 'lineHeight', - letterSpacing: 'letterSpacing', - paragraphIndent: 'paragraphIndent', - paragraphSpacing: 'paragraphSpacing' -} - -function currentBoundVariableId( - node: SupportedCanvasNode, - field: VariableBindableNodeField | VariableBindableTextField -): string | undefined { - const value = node.boundVariables?.[field] - const directId = Array.isArray(value) ? value[0]?.id : value?.id - if (directId || field !== 'cornerRadius') return directId - - const aliases = [ - node.boundVariables?.topLeftRadius, - node.boundVariables?.topRightRadius, - node.boundVariables?.bottomLeftRadius, - node.boundVariables?.bottomRightRadius - ] - const radiusId = aliases[0]?.id - return radiusId && aliases.every((alias) => alias?.id === radiusId) ? radiusId : undefined -} - -function applyPaintVariable( - node: SupportedCanvasNode, - field: 'fill' | 'stroke', - variable: Variable | null, - state: ApplyState -): void { - if (!('fills' in node)) { - specError(`${field} variables are not supported on ${node.type} node "${node.id}".`) - } - const property = field === 'fill' ? 'fills' : 'strokes' - const currentVariable = node.boundVariables?.[property]?.[0] - if (currentVariable?.id === variable?.id || (!currentVariable && !variable)) return - const styleId = field === 'fill' ? node.fillStyleId : node.strokeStyleId - if (!variable && styleId) { - specError( - `${field} variable bindings cannot be cleared without replacing the existing Paint style on node "${node.id}".` - ) - } - - const currentPaints = node[property] - if (currentPaints === figma.mixed) { - specError(`${field} variable bindings cannot target mixed paints on node "${node.id}".`) - } - const paints = [...currentPaints] - if (paints.length !== 1 || paints[0]?.type !== 'SOLID') { - specError(`${field} variable bindings require exactly one solid paint on node "${node.id}".`) - } - paints[0] = figma.variables.setBoundVariableForPaint(paints[0], 'color', variable) - node[property] = paints - markMutation(state, node) -} - -function clearVariables( - node: SupportedCanvasNode, - bindings: CanvasVariableBindings | undefined, - state: ApplyState -): void { - if (!bindings) return - for (const field of Object.keys(bindings) as Array) { - if (bindings[field] !== null) continue - if (field === 'fill' || field === 'stroke') { - applyPaintVariable(node, field, null, state) - continue - } - const figmaField = DIRECT_VARIABLE_FIELDS[field] - if (!currentBoundVariableId(node, figmaField)) continue - node.setBoundVariable(figmaField, null) - markMutation(state, node) - } -} - -async function applyVariables( - node: SupportedCanvasNode, - bindings: CanvasVariableBindings | undefined, - state: ApplyState -): Promise { - if (!bindings) return - if (node.type === 'TEXT') await loadVariableFonts(node, bindings, state) - for (const field of Object.keys(bindings) as Array) { - const reference = bindings[field] - if (!reference) continue - const variable = await resolveVariable(reference, state.variables) - if (field === 'fill' || field === 'stroke') { - applyPaintVariable(node, field, variable, state) - continue - } - const figmaField = DIRECT_VARIABLE_FIELDS[field] - if (currentBoundVariableId(node, figmaField) === variable.id) continue - node.setBoundVariable(figmaField, variable) - markMutation(state, node) - } -} - -type FontModeValues = { values: VariableValue[]; optional: boolean; affected: boolean } - -const TEXT_MODE_FONT_FIELDS = [ - 'characters', - 'width', - 'height', - ...SIZE_BOUND_FIELDS, - ...Object.values(TEXT_STYLE_VARIABLES_BY_VALUE).flat() -] as const - -async function modeFontVariableValues( - variable: Variable, - consumer: TextNode, - target: SupportedCanvasNode | PageNode, - changedCollectionId: string, - changedModeId: string | undefined, - state: ApplyState, - cache = new Map(), - seen = new Set() -): Promise { - if (seen.has(variable.id) || seen.size >= 64) { - specError('Font variable aliases contain a cycle or exceed 64 levels.') - } - const cached = cache.get(variable.id) - if (cached) return cached - const active = new Set(seen).add(variable.id) - const base = await getVariableCollectionById(variable.variableCollectionId) - if (!base) - scopeError(`Font variable collection "${variable.variableCollectionId}" is unavailable.`) - const related = new Map([[base.id, base]]) - const ancestors: BaseNode[] = [] - const candidateIds = new Set([ - changedCollectionId, - ...Object.keys(consumer.resolvedVariableModes ?? {}) - ]) - for (let ancestor: BaseNode | null = consumer; ancestor; ancestor = ancestor.parent) { - ancestors.push(ancestor) - if ('explicitVariableModes' in ancestor) { - for (const id of Object.keys(ancestor.explicitVariableModes)) candidateIds.add(id) - } - } - for (const id of candidateIds) { - if (related.has(id)) continue - const collection = await getVariableCollectionById(id) - if (collection?.isExtension && collection.variableIds.includes(variable.id)) - related.set(id, collection) - } - let selected: Array<[string, string]> = [] - let affected = false - for (const ancestor of ancestors) { - const explicit = - 'explicitVariableModes' in ancestor ? { ...ancestor.explicitVariableModes } : {} - if (ancestor === target) { - affected = related.has(changedCollectionId) && explicit[changedCollectionId] !== changedModeId - delete explicit[changedCollectionId] - if (changedModeId !== undefined) explicit[changedCollectionId] = changedModeId - } - selected = Object.entries(explicit).filter(([id]) => related.has(id)) - if (selected.length) break - } - if (!selected.length) { - // An unaffected consumer or the parent above a cleared override exposes native - // Auto resolution. Page Auto has no such reader: preload candidates, don't guess. - const context = related.has(changedCollectionId) ? target.parent : consumer - if (context && 'resolvedVariableModes' in context) { - selected = Object.entries(context.resolvedVariableModes).filter(([id]) => related.has(id)) - } - } - let optional = selected.length !== 1 - const candidates: VariableValue[] = [] - for (const collection of related.values()) { - const modes = selected.filter(([id]) => id === collection.id).map(([, mode]) => mode) - if (selected.length && !modes.length) continue - const values = - collection.id === base.id - ? variable.valuesByMode - : await variable.valuesByModeForCollectionAsync(collection) - const modeValues = modes.length ? modes.map((mode) => values[mode]) : Object.values(values) - for (const value of modeValues) { - if (value === undefined) - scopeError(`Font variable "${variable.id}" has no value for the selected mode.`) - if (typeof value === 'object' && 'type' in value && value.type === 'VARIABLE_ALIAS') { - const alias = await modeFontVariableValues( - await resolveVariable({ id: value.id }, state.variables), - consumer, - target, - changedCollectionId, - changedModeId, - state, - cache, - active - ) - candidates.push(...alias.values) - optional ||= alias.optional - affected ||= alias.affected - } else candidates.push(value) - } - } - const values = [...new Set(candidates)] - if (values.length > 64 || cache.size >= 256) - scopeError('Font mode preloading exceeds the bounded candidate budget.') - const result = { values, optional, affected } - cache.set(variable.id, result) - return result -} - -async function loadModeFonts( - target: SupportedCanvasNode | PageNode, - collectionId: string, - modeId: string | undefined, - state: ApplyState -): Promise { - const roots = target.type === 'PAGE' ? target.children : [target] - for (const node of walkPhysicalNodes(roots)) { - if (node.type !== 'TEXT') continue - const cache = new Map() - const segments = node.getStyledTextSegments(['fontName', 'textStyleId', 'boundVariables']) - for (const segment of segments) { - const style = segment.textStyleId - ? await resolveStyle({ id: segment.textStyleId }, state.styles) - : undefined - const bindings = { - ...node.boundVariables, - ...(style?.type === 'TEXT' ? style.boundVariables : {}), - ...segment.boundVariables - } - const values = new Map() - for (const field of TEXT_MODE_FONT_FIELDS) { - const reference = bindings[field] - // Mixed node bindings are arrays; segment aliases identify the actual run. - if (!reference || Array.isArray(reference)) continue - values.set( - field, - await modeFontVariableValues( - await resolveVariable({ id: reference.id }, state.variables), - node, - target, - collectionId, - modeId, - state, - cache - ) - ) - } - if (![...values.values()].some((value) => value.affected)) continue - await loadFonts(currentTextFonts(node), state.fonts) - const families = values.get('fontFamily') - const styles = values.get('fontStyle') - const weights = values.get('fontWeight') - const familyValues = families?.values.length ? families.values : [segment.fontName.family] - const styleValues = styles?.values.length ? styles.values : [segment.fontName.style] - const weightValues = weights?.values.length ? weights.values : [undefined] - const optional = families?.optional || styles?.optional || weights?.optional - if (familyValues.length * styleValues.length * weightValues.length > 256) { - scopeError('Font mode preloading exceeds 256 possible font combinations.') - } - // Native mode changes can apply family before weight; both the intermediate - // face and the final face must be loaded before invoking the synchronous setter. - for (const family of familyValues) - for (const fontStyle of styleValues) - for (const weight of weightValues) { - if ( - typeof family !== 'string' || - typeof fontStyle !== 'string' || - (weight !== undefined && - (typeof weight !== 'number' || - !Number.isFinite(weight) || - weight < 1 || - weight > 1000)) - ) { - if (optional) continue - specError( - `Font variables on "${node.id}" must resolve to STRING family/style and a weight between 1 and 1000.` - ) - } - try { - await loadFont( - await resolveFamilyFont(family, segment.fontName.style, state.fonts), - state.fonts - ) - await loadFont( - await resolveFamilyFont(family, fontStyle, state.fonts, weight), - state.fonts - ) - } catch (error) { - // A possible Auto font need not be the selected one. The native setter - // remains authoritative; known explicit targets retain load failures. - if (!optional) throw error - } - } - } - } -} - -async function applyVariableModes( - node: SupportedCanvasNode | PageNode, - modes: CanvasNodeSpec['variableModes'], - state: ApplyState -): Promise { - for (const [collectionReference, modeReference] of Object.entries(modes ?? {})) { - const collection = resolvedCollection(collectionReference, state.variables) - const current = node.explicitVariableModes[collection.id] - if (modeReference === null) { - if (current === undefined) continue - await loadModeFonts(node, collection.id, undefined, state) - node.clearExplicitVariableModeForCollection(collection) - } else { - const modeId = resolvedModeId(collection, modeReference, state.variables) - if (current === modeId) continue - await loadModeFonts(node, collection.id, modeId, state) - node.setExplicitVariableModeForCollection(collection, modeId) - } - markMutation(state, node) - } -} - -async function preloadFirstPageModeFonts( - page: PageNode, - modes: CanvasPageProperties['variableModes'], - state: ApplyState -): Promise { - for (const [collectionReference, modeReference] of Object.entries(modes ?? {})) { - const collection = resolvedCollection(collectionReference, state.variables) - const modeId = - modeReference === null - ? undefined - : resolvedModeId(collection, modeReference, state.variables) - if (page.explicitVariableModes[collection.id] === modeId) continue - // Later transitions can depend on the preceding mode setters. Warm only the - // first one; applyVariableModes still checks the live font requirements. - await loadModeFonts(page, collection.id, modeId, state) - return - } -} - -async function applyPage( - page: PageNode, - properties: CanvasPageProperties, - state: ApplyState -): Promise { - protectNode(state, page) - if (properties.index !== undefined) { - moveIntoParent(page, figma.root, properties.index, state) - } - setValue(page, page.name, properties.name, (value) => (page.name = value), state) - if (properties.background) { - const { a: opacity, ...color } = properties.background - const background: SolidPaint[] = [{ type: 'SOLID', color, opacity }] - if (!paintStacksEqual(page.backgrounds, background)) { - page.backgrounds = background - markMutation(state, page) - } - } - applyGuides(page, properties.guides, state) - await applyVariableModes(page, properties.variableModes, state) -} - -function nativeComponentPropertyDefault( - definition: CanvasFigmaComponentPropertyDefinition, - state: ApplyState -): string | boolean | VariableAlias { - const value = definition.defaultValue - if (isVariableBinding(value)) { - return figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) - } - return definition.type === 'INSTANCE_SWAP' - ? resolvedComponent(value as CanvasDesignReference, state).id - : (value as string | boolean) -} - -function componentPropertyDefaultMatches( - current: ComponentPropertyDefinitions[string], - desired: string | boolean | VariableAlias -): boolean { - return isRecord(desired) - ? current.boundVariables?.defaultValue?.id === desired.id - : current.boundVariables?.defaultValue === undefined && current.defaultValue === desired -} - -function componentPropertyOptions( - definition: CanvasFigmaComponentPropertyDefinition -): ComponentPropertyOptions | undefined { - return definition.type === 'INSTANCE_SWAP' && definition.preferredValues !== undefined - ? { preferredValues: definition.preferredValues } - : undefined -} - -function applyAuthoredComponentProperties( - owner: ComponentPropertyOwner, - spec: CanvasNodeSpec, - state: ApplyState -): void { - const properties = spec.figma?.component?.properties - if (!properties) return - const keys = componentPropertyKeys(owner, state) - for (const [key, desired] of Object.entries(properties)) { - const propertyName = keys[key] ?? key - const current = owner.componentPropertyDefinitions[propertyName] - if (desired === null) { - if (!current) continue - if (!keys[key]) setComponentPropertyKey(owner, key, propertyName, state) - owner.deleteComponentProperty(propertyName) - markMutation(state, owner) - continue - } - - const defaultValue = nativeComponentPropertyDefault(desired, state) - if (!current) { - const createdName = owner.addComponentProperty( - desired.name, - desired.type, - defaultValue, - componentPropertyOptions(desired) - ) - markMutation(state, owner) - setComponentPropertyKey(owner, key, createdName, state) - continue - } - - const edit: { - name?: string - defaultValue?: string | boolean | VariableAlias - preferredValues?: InstanceSwapPreferredValue[] - } = {} - if (componentPropertyDisplayName(propertyName) !== desired.name) { - edit.name = desired.name - } - if (!componentPropertyDefaultMatches(current, defaultValue)) { - edit.defaultValue = defaultValue - } - if ( - desired.type === 'INSTANCE_SWAP' && - desired.preferredValues !== undefined && - !nativeValueEqual(current.preferredValues ?? [], desired.preferredValues) - ) { - edit.preferredValues = desired.preferredValues - } - if (!Object.keys(edit).length) continue - const editedName = owner.editComponentProperty(propertyName, edit) - markMutation(state, owner) - setComponentPropertyKey(owner, key, editedName, state) - } -} - -function slotSettingsChanged( - current: SlotSettings | undefined, - desired: NonNullable -): boolean { - return Object.entries(desired).some( - ([field, value]) => current?.[field as keyof SlotSettings] !== value - ) -} - -function applySlotProperty(node: SlotNode, spec: CanvasNodeSpec, state: ApplyState): void { - const desired = spec.figma?.slot?.property - if (!desired) return - const owner = componentPropertyOwner(node) - if (!owner) specError(`Slot "${spec.key}" has no authored component owner.`) - const propertyName = slotPropertyName(owner, spec, state) - if (!propertyName) specError(`Slot property for "${spec.key}" could not be resolved.`) - const current = owner.componentPropertyDefinitions[propertyName] - if (!current || current.type !== 'SLOT') { - specError(`Component property "${propertyName}" for "${spec.key}" is not a slot.`) - } - const edit: { - name?: string - preferredValues?: InstanceSwapPreferredValue[] - description?: string - slotSettings?: SlotSettings - } = {} - if (componentPropertyDisplayName(propertyName) !== desired.name) { - edit.name = desired.name - } - if ( - desired.preferredValues !== undefined && - !nativeValueEqual(current.preferredValues ?? [], desired.preferredValues) - ) { - edit.preferredValues = desired.preferredValues - } - if (desired.description !== undefined && current.description !== desired.description) { - edit.description = desired.description - } - if (desired.settings && slotSettingsChanged(current.slotSettings, desired.settings)) { - edit.slotSettings = { ...current.slotSettings, ...desired.settings } - } - if (!Object.keys(edit).length) return - const editedName = owner.editComponentProperty(propertyName, edit) - markMutation(state, owner) - setComponentPropertyKey(owner, spec.key, editedName, state) -} - -function applyComponentPropertyReferences( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - const desired = spec.figma?.componentPropertyReferences - if (!desired) return - const owner = componentPropertyOwner(node) - if (!owner) { - specError(`Component property references on "${spec.key}" require a component sublayer.`) - } - const next = { ...(node.componentPropertyReferences ?? {}) } - for (const [field, key] of Object.entries(desired) as Array< - [ComponentPropertyReferenceField, string | null] - >) { - if (key === null) { - delete next[field] - continue - } - const propertyName = componentPropertyName(owner, key, state) - if (!propertyName || !owner.componentPropertyDefinitions[propertyName]) { - specError(`Component property reference "${key}" on "${spec.key}" could not be resolved.`) - } - next[field] = propertyName - } - const references = Object.keys(next).length ? next : null - if (nativeValueEqual(node.componentPropertyReferences, references)) return - node.componentPropertyReferences = references - markMutation(state, node) -} - -function applyComponentMetadata( - node: ComponentNode | ComponentSetNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - const metadata = spec.figma?.component - if (!metadata) return - setValue( - node, - node.descriptionMarkdown, - metadata.descriptionMarkdown, - (value) => (node.descriptionMarkdown = value), - state - ) - if (metadata.documentationLink === undefined) return - const links = metadata.documentationLink === null ? [] : [{ uri: metadata.documentationLink }] - if (nativeValueEqual(node.documentationLinks, links)) return - node.documentationLinks = links - markMutation(state, node) -} - -function isOwnedSvgChild(node: SceneNode): node is FrameNode { - return ( - node.type === 'FRAME' && - node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_CHILD_NAME) === 'true' - ) -} - -function countSceneNodes(node: SceneNode): number { - return ( - 1 + - ('children' in node - ? node.children.reduce((count, child) => count + countSceneNodes(child), 0) - : 0) - ) -} - -function placeSvgChild(child: FrameNode, wrapper: FrameNode, state: ApplyState): void { - if ( - ![child.width, child.height, wrapper.width, wrapper.height].every( - (value) => Number.isFinite(value) && value > 0 - ) - ) { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, - 'SVG import produced invalid geometry.' - ) - } - const scale = Math.min(wrapper.width / child.width, wrapper.height / child.height) - let changed = false - if (Math.abs(scale - 1) > 0.0001) { - child.rescale(scale) - changed = true - } - const x = (wrapper.width - child.width) / 2 - const y = (wrapper.height - child.height) / 2 - if (Math.abs(child.x - x) > 0.001 || Math.abs(child.y - y) > 0.001) { - child.x = x - child.y = y - changed = true - } - if (changed) state.mutations.count += 1 -} - -function finalizeSvgPositions(specs: Iterable, state: ApplyState): void { - for (const spec of specs) { - if (!spec.figma?.svg) continue - const wrapper = state.keyedNodes.get(spec.key) - if (wrapper?.type !== 'FRAME') continue - for (const child of wrapper.children) { - if (isOwnedSvgChild(child)) placeSvgChild(child, wrapper, state) - } - } -} - -function setSvgMetadata( - wrapper: FrameNode, - digest: string, - color: string | undefined, - state: ApplyState -): void { - let changed = false - for (const [name, value] of [ - [CANVAS_SVG_DIGEST_NAME, digest], - [CANVAS_SVG_COLOR_NAME, color?.toUpperCase() ?? ''], - [CANVAS_SVG_POLICY_NAME, SVG_POLICY_VERSION] - ] as const) { - if (wrapper.getSharedPluginData(CANVAS_KEY_NAMESPACE, name) === value) continue - wrapper.setSharedPluginData(CANVAS_KEY_NAMESPACE, name, value) - changed = true - } - if (changed) markMutation(state, wrapper) -} - -async function applySvg( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): Promise { - const placement = spec.figma?.svg - if (!placement) return - if (node.type !== 'FRAME') { - specError(`SVG binding "${spec.key}" requires a frame wrapper.`) - } - const asset = resolvedSvgAsset(state.media.assets, placement.assetKey, placement.color) - if (!asset) { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, - `SVG asset "${placement.assetKey}" was not resolved.` - ) - } - const owned = node.children.filter(isOwnedSvgChild) - const unexpected = node.children.filter((child) => !isOwnedSvgChild(child)) - if (owned.length > 1 || unexpected.length) { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.SVG_WRAPPER_DIRTY, - `SVG wrapper "${spec.key}" contains unexpected children.` - ) - } - if ( - owned.length === 1 && - node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_DIGEST_NAME) === asset.digest - ) { - placeSvgChild(owned[0]!, node, state) - setSvgMetadata(node, asset.digest, placement.color, state) - return - } - - let imported: FrameNode - try { - imported = figma.createNodeFromSvg(asset.svg) - } catch { - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, - `SVG asset "${placement.assetKey}" could not be imported by Figma.` - ) - } - if ( - !Number.isFinite(imported.width) || - !Number.isFinite(imported.height) || - imported.width <= 0 || - imported.height <= 0 || - countSceneNodes(imported) > 500 - ) { - imported.remove() - throw createCodedError( - TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, - `SVG asset "${placement.assetKey}" produced invalid or excessive Figma layers.` - ) - } - imported.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_CHILD_NAME, 'true') - node.appendChild(imported) - markMutation(state, node) - placeSvgChild(imported, node, state) - for (const child of owned) { - child.remove() - state.mutations.count += 1 - } - setSvgMetadata(node, asset.digest, placement.color, state) -} - -async function applyNodeProperties( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState, - parent?: CanvasParentNode, - nativeOnly = false -): Promise { - try { - state.ignoredLiteralWarnings.push(...ignoredLiteralWarnings(node, spec, nativeOnly)) - await applyNodePropertiesUnchecked(node, spec, state, parent) - } catch (error) { - throwNodeError(error, spec.key, node.id) - } -} - -function throwNodeError(error: unknown, key: string, nodeId?: string): never { - const readOnly = canvasReadOnlyError(error) - if (readOnly) throw readOnly - const failure = - error instanceof Error ? error : new Error(errorMessage(error, 'Native mutation failed.')) - failure.message = `${failure.message} on "${key}"${nodeId ? ` (${nodeId})` : ''}.` - throw failure -} - -async function applyNodePropertiesUnchecked( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState, - parent?: CanvasParentNode -): Promise { - if (node.type === 'TEXT') { - await loadFonts(currentTextFonts(node), state.fonts) - } - if (node.type === 'INSTANCE') await applyComponent(node, spec, state) - await applyVariableModes(node, spec.variableModes, state) - await unlinkStyles(node, spec.styles, state) - clearVariables(node, spec.variables, state) - setValue( - node, - node.name, - node.type === 'TEXT' && spec.figma?.text?.autoRename - ? undefined - : (spec.displayName ?? (state.createdNodeIds.has(node.id) ? spec.key : undefined)), - (value) => (node.name = value), - state - ) - if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { - applyComponentMetadata(node, spec, state) - applyAuthoredComponentProperties(node, spec, state) - } - if (node.type === 'SLOT') applySlotProperty(node, spec, state) - if (isFrameContainer(node)) applyLayout(node, spec, state) - if (node.type === 'BOOLEAN_OPERATION') { - setValue( - node, - node.booleanOperation, - spec.figma?.booleanOperation, - (value) => (node.booleanOperation = value), - state - ) - } - if (node.type === 'SECTION') { - setValue( - node, - node.sectionContentsHidden, - spec.figma?.section?.contentsHidden, - (value) => (node.sectionContentsHidden = value), - state - ) - } - await applyShape(node, spec, state) - applySize(node, spec, parent, state) - await applySvg(node, spec, state) - if (parent) applyPosition(node, spec, parent, state) - applyRelativeTransform(node, spec, parent, state) - applyAppearance(node, spec, state) - await applyStyles(node, spec.styles, state) - applyLayoutAids(node, spec, state) - if (!hasCanvasKeyPaints(spec)) { - applyPaintStacks(node, spec, state) - } - applyEffects(node, spec, state) - if (node.type === 'TEXT') await applyText(node, spec, state) - await applyVariables(node, spec.variables, state) - if (node.type === 'TEXT' && !hasDeferredTextRanges(spec)) { - await applyTextRanges(node, spec.figma?.text?.ranges, state) - } - applySharedLayerState(node, spec, state) - applyComponentPropertyReferences(node, spec, state) - // Text and layout setters can leave a derived sizing mode or its geometry stale. - if (node.type === 'TEXT') applySizingModes(node, spec, parent, state) - stabilizeCrossAxisFill(node, spec, parent, state) - stabilizeGrowingTextWidth(node, parent, state) -} - -function collectSvgColorsFromSpecs( - specs: Iterable -): Map> { - const colors = new Map>() - for (const spec of specs) { - const svg = spec.figma?.svg - if (svg) { - const values = colors.get(svg.assetKey) ?? new Set() - values.add(svg.color) - colors.set(svg.assetKey, values) - } - } - return colors -} - -function collectSvgColors(root: CanvasNodeSpec): Map> { - return collectSvgColorsFromSpecs(walkSpecs(root)) -} - -async function applyCanvasKeyReferences( - spec: CanvasNodeSpec, - state: ApplyState, - parent?: CanvasParentNode -): Promise { - const node = state.keyedNodes.get(spec.key) ?? null - if (!isSupportedSceneNode(node)) { - applyError(`Desired node "${spec.key}" was not reconciled.`) - } - if (hasCanvasKeyVectorPattern(spec)) { - await applyShape(node, spec, state, true) - applySize(node, spec, parent, state) - if (parent) applyPosition(node, spec, parent, state) - applyRelativeTransform(node, spec, parent, state) - } - if (hasCanvasKeyPaints(spec)) { - applyPaintStacks(node, spec, state) - } - if (node.type === 'TEXT') { - if (isCanvasKeyHyperlink(spec.figma?.text?.hyperlink)) { - applyTextHyperlink(node, spec.figma.text.hyperlink, state) - } - if (hasDeferredTextRanges(spec)) { - await applyTextRanges(node, spec.figma?.text?.ranges, state) - } - } - stabilizeCrossAxisFill(node, spec, parent, state) - for (const child of spec.children ?? []) { - await applyCanvasKeyReferences(child, state, node as CanvasParentNode) - } -} - -function rotationsMatch(current: number, desired: number): boolean { - const delta = ((((current - desired + 180) % 360) + 360) % 360) - 180 - return Math.abs(delta) <= 0.001 -} - -function applySharedLayerState( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - setValue( - node, - node.visible, - preservesComponentPropertyReference(node, spec, 'visible') || - spec.variables?.visible || - currentBoundVariableId(node, 'visible') - ? undefined - : spec.visible, - (value) => (node.visible = value), - state - ) - if ('blendMode' in node) { - setValue(node, node.blendMode, spec.blendMode, (value) => (node.blendMode = value), state) - } - if ( - 'rotation' in node && - spec.rotation !== undefined && - !rotationsMatch(node.rotation, spec.rotation) - ) { - node.rotation = spec.rotation - markMutation(state, node) - } - const aspectRatioLocked = spec.figma?.aspectRatioLocked - if (aspectRatioLocked !== undefined) { - if (!('targetAspectRatio' in node)) { - specError(`Aspect-ratio locking is not supported on ${node.type} node "${spec.key}".`) - } - if ((node.targetAspectRatio !== null) !== aspectRatioLocked) { - if (aspectRatioLocked) node.lockAspectRatio() - else node.unlockAspectRatio() - markMutation(state, node) - } - } - setValue(node, node.locked, spec.figma?.locked, (value) => (node.locked = value), state) -} - -function applyMask(node: SupportedCanvasNode, spec: CanvasNodeSpec, state: ApplyState): void { - const mask = spec.figma?.mask - if (mask === undefined) return - if (!('isMask' in node)) { - specError(`Masks are not supported on ${node.type} node "${spec.key}".`) - } - if (mask !== null) { - setValue(node, node.maskType, mask, (value) => (node.maskType = value), state) - } - setValue(node, node.isMask, mask !== null, (value) => (node.isMask = value), state) -} - -function applyGridTracks( - node: CanvasFrameContainerNode, - field: 'gridColumnSizes' | 'gridRowSizes', - desired: CanvasGridTrack[], - state: ApplyState, - preserveTrailing = false -): void { - const current = node[field] - if (current.length < desired.length || (!preserveTrailing && current.length !== desired.length)) { - specError(`Figma returned ${current.length} ${field}, expected ${desired.length}.`) - } - for (const [index, track] of desired.entries()) { - const target = current[index]! - const valueMatches = - track.type === 'HUG' || - (target.value ?? (target.type === 'FLEX' ? 1 : undefined)) === track.value - if (target.type === track.type && valueMatches) continue - target.type = track.type - if (track.type !== 'HUG') target.value = track.value - markMutation(state, node) - } -} - -type GridChildNode = Exclude - -function applyGridChildAlignment( - node: GridChildNode, - spec: CanvasNodeSpec, - state: ApplyState -): void { - const grid = spec.gridChild - if (!grid) return - setValue( - node, - node.gridChildHorizontalAlign, - grid.horizontalAlign, - (value) => (node.gridChildHorizontalAlign = value), - state - ) - setValue( - node, - node.gridChildVerticalAlign, - grid.verticalAlign, - (value) => (node.gridChildVerticalAlign = value), - state - ) -} - -function setGridChildSpans( - node: GridChildNode, - rowSpan: number, - columnSpan: number, - state: ApplyState -): void { - setValue(node, node.gridRowSpan, rowSpan, (value) => (node.gridRowSpan = value), state) - setValue(node, node.gridColumnSpan, columnSpan, (value) => (node.gridColumnSpan = value), state) -} - -function setGridChildPosition( - node: GridChildNode, - row: number, - column: number, - state: ApplyState -): void { - if (node.gridRowAnchorIndex === row && node.gridColumnAnchorIndex === column) return - node.setGridChildPosition(row, column) - markMutation(state, node) -} - -function gridAreasOverlap( - left: { column: number; columnSpan: number; row: number; rowSpan: number }, - right: { column: number; columnSpan: number; row: number; rowSpan: number } -): boolean { - return ( - left.row < right.row + right.rowSpan && - left.row + left.rowSpan > right.row && - left.column < right.column + right.columnSpan && - left.column + left.columnSpan > right.column - ) -} - -type ReconciledGridChild = { - node: GridChildNode - spec: CanvasNodeSpec -} - -type ReconciledChild = { - node: SupportedCanvasNode - spec: CanvasNodeSpec -} - -function liveGridExtent(node: CanvasFrameContainerNode): { columns: number; rows: number } { - return node.children.reduce( - (extent, child) => - 'gridRowAnchorIndex' in child - ? { - columns: Math.max(extent.columns, child.gridColumnAnchorIndex + child.gridColumnSpan), - rows: Math.max(extent.rows, child.gridRowAnchorIndex + child.gridRowSpan) - } - : extent, - { columns: 1, rows: 1 } - ) -} - -function finalizeManualGrid( - node: CanvasFrameContainerNode, - layout: CanvasGridLayout, - children: ReconciledGridChild[], - state: ApplyState, - forceFinalRowCount = false -): void { - const autoRows = layout.autoRows ?? (layout.rows === undefined && node.gridAutoTracks === 'ROWS') - const rowCount = - layout.rows?.length ?? - (autoRows - ? Math.max(1, ...children.map(({ spec }) => spec.gridChild!.row! + spec.gridChild!.rowSpan)) - : node.gridRowCount) - setValue( - node, - node.gridItemsPositioning, - 'MANUAL' as const, - (value) => (node.gridItemsPositioning = value), - state - ) - - const moving = children.filter(({ node: child, spec }) => { - const grid = spec.gridChild! - return ( - child.gridRowAnchorIndex !== grid.row || - child.gridColumnAnchorIndex !== grid.column || - child.gridRowSpan !== grid.rowSpan || - child.gridColumnSpan !== grid.columnSpan - ) - }) - const desiredAreas = children.map(({ spec }) => { - const grid = spec.gridChild! - return { - column: grid.column!, - columnSpan: grid.columnSpan, - row: grid.row!, - rowSpan: grid.rowSpan - } - }) - const removalBlockers = node.children.filter((child): child is GridChildNode => { - if ( - !state.removalNodeIds.has(child.id) || - !isSupportedSceneNode(child) || - child.type === 'SECTION' - ) { - return false - } - const liveArea = { - column: child.gridColumnAnchorIndex, - columnSpan: child.gridColumnSpan, - row: child.gridRowAnchorIndex, - rowSpan: child.gridRowSpan - } - return desiredAreas.some((area) => gridAreasOverlap(liveArea, area)) - }) - const staging = [...moving.map(({ node: child }) => child), ...removalBlockers] - if (staging.length) { - setValue( - node, - node.gridAutoTracks, - 'NONE' as const, - (value) => (node.gridAutoTracks = value), - state - ) - const stagingStart = Math.max(node.gridRowCount, rowCount) - setValue( - node, - node.gridRowCount, - stagingStart + staging.length, - (value) => (node.gridRowCount = value), - state - ) - for (const [index, child] of staging.entries()) { - setGridChildSpans(child, 1, 1, state) - setGridChildPosition(child, stagingStart + index, 0, state) - } - if (removalBlockers.length) state.pendingGridRemovalCleanupNodeIds.add(node.id) - } - - for (const { node: child, spec } of moving) { - const grid = spec.gridChild! - setGridChildPosition(child, grid.row!, grid.column!, state) - setGridChildSpans(child, grid.rowSpan, grid.columnSpan, state) - } - const extent = liveGridExtent(node) - const finalColumnCount = Math.max(layout.columns.length, extent.columns) - setValue( - node, - node.gridColumnCount, - finalColumnCount, - (value) => (node.gridColumnCount = value), - state - ) - if (!autoRows || moving.length || forceFinalRowCount) { - const finalRowCount = autoRows ? extent.rows : Math.max(rowCount, extent.rows) - setValue(node, node.gridRowCount, finalRowCount, (value) => (node.gridRowCount = value), state) - } - - applyGridTracks( - node, - 'gridColumnSizes', - layout.columns, - state, - finalColumnCount > layout.columns.length - ) - if (layout.rows) { - applyGridTracks( - node, - 'gridRowSizes', - layout.rows, - state, - node.gridRowCount > layout.rows.length - ) - } - if (autoRows && staging.length) { - setValue( - node, - node.gridAutoTracks, - 'ROWS' as const, - (value) => (node.gridAutoTracks = value), - state - ) - } -} - -function finalizeFlowGrid( - node: CanvasFrameContainerNode, - layout: CanvasGridLayout, - children: ReconciledGridChild[], - state: ApplyState -): void { - setValue( - node, - node.gridItemsPositioning, - 'ROW_AUTO_FLOW' as const, - (value) => (node.gridItemsPositioning = value), - state - ) - for (const { node: child, spec } of children) { - const grid = spec.gridChild! - setGridChildSpans(child, grid.rowSpan, grid.columnSpan, state) - } - const extent = liveGridExtent(node) - const finalColumnCount = Math.max(layout.columns.length, extent.columns) - setValue( - node, - node.gridColumnCount, - finalColumnCount, - (value) => (node.gridColumnCount = value), - state - ) - - if (layout.rows) { - const finalRowCount = Math.max(layout.rows.length, extent.rows) - setValue(node, node.gridRowCount, finalRowCount, (value) => (node.gridRowCount = value), state) - applyGridTracks(node, 'gridRowSizes', layout.rows, state, finalRowCount > layout.rows.length) - } - applyGridTracks( - node, - 'gridColumnSizes', - layout.columns, - state, - finalColumnCount > layout.columns.length - ) -} - -function finalizeGrid( - node: CanvasFrameContainerNode, - spec: CanvasNodeSpec, - children: ReconciledChild[], - state: ApplyState, - forceFinalRowCount = false -): void { - const layout = spec.layout - if (layout?.mode !== 'GRID') return - const gridChildren: ReconciledGridChild[] = children - .filter(({ spec: child }) => child.gridChild) - .map((child) => { - if (child.node.type === 'SECTION') { - specError(`Section "${child.spec.key}" cannot be a child of a grid frame.`) - } - return { ...child, node: child.node } - }) - if ((layout.itemsPositioning ?? node.gridItemsPositioning) === 'MANUAL') { - finalizeManualGrid(node, layout, gridChildren, state, forceFinalRowCount) - } else { - finalizeFlowGrid(node, layout, gridChildren, state) - } - for (const child of gridChildren) { - applyGridChildAlignment(child.node, child.spec, state) - // Figma can reset a grid child's sizing modes while changing its cell or span. - applySizingModes(child.node, child.spec, node, state) - } -} - -function finalizeGridsAfterRemovals(rootSpec: CanvasNodeSpec, state: ApplyState): void { - if (!state.pendingGridRemovalCleanupNodeIds.size) return - for (const spec of walkSpecs(rootSpec)) { - const node = state.keyedNodes.get(spec.key) - if ( - !node || - !isFrameContainer(node) || - !state.pendingGridRemovalCleanupNodeIds.delete(node.id) - ) { - continue - } - const children = (spec.children ?? []).map((childSpec) => { - const child = state.keyedNodes.get(childSpec.key) - if (!child || child.removed) { - specError(`Desired grid child "${childSpec.key}" was unavailable after node removal.`) - } - return { node: child, spec: childSpec } - }) - finalizeGrid(node, spec, children, state, true) - } - if (state.pendingGridRemovalCleanupNodeIds.size) { - specError('A grid staged for node removal was unavailable for final layout cleanup.') - } -} - -function createWrappedContainer( - spec: WrappedContainerSpec, - children: SupportedCanvasNode[], - parent: CanvasParentNode | undefined, - index: number, - state: ApplyState -): WrappedContainerNode { - const destination = parent ?? figma.currentPage - const destinationIndex = parent ? index : undefined - let node: WrappedContainerNode - switch (spec.type) { - case 'COMPONENT_SET': { - const variants = children.filter( - (child): child is ComponentNode => child.type === 'COMPONENT' - ) - if (variants.length !== children.length) { - specError(`Component set "${spec.key}" can contain only component nodes.`) - } - node = figma.combineAsVariants(variants, destination, destinationIndex) - break - } - case 'GROUP': - node = figma.group(children, destination, destinationIndex) - break - case 'BOOLEAN_OPERATION': - switch (spec.figma!.booleanOperation!) { - case 'UNION': - node = figma.union(children, destination, destinationIndex) - break - case 'SUBTRACT': - node = figma.subtract(children, destination, destinationIndex) - break - case 'INTERSECT': - node = figma.intersect(children, destination, destinationIndex) - break - case 'EXCLUDE': - node = figma.exclude(children, destination, destinationIndex) - break - } - break - } - recordCreatedNode(node, state) - return node -} - -async function reconcileNewWrappedContainer( - spec: WrappedContainerSpec, - state: ApplyState, - parent: CanvasParentNode | undefined, - index: number -): Promise { - state.nodeIdsByKey[spec.key] = '' - // Keep staged descendants inside the update scope when their existing ancestors move. - const stagingParent = parent ?? figma.currentPage - if (spec.type === 'COMPONENT_SET') { - const children = spec.children! - const variants = children.map((child) => { - const existing = findExistingNode(child, state) - if (existing && existing.type !== 'COMPONENT') { - specError(`Component set "${spec.key}" can contain only component nodes.`) - } - const variant = existing ?? figma.createComponent() - if (!existing) { - recordCreatedNode(variant, state, false) - normalizeCreatedFrame(variant, child, state) - } - setValue(variant, variant.name, child.displayName, (value) => (variant.name = value), state) - if (variant.parent?.id !== stagingParent.id) { - moveIntoParent(variant, stagingParent, stagingParent.children.length, state) - } - return variant - }) - const node = createWrappedContainer(spec, variants, parent, index, state) as ComponentSetNode - setNodeKey(state, node, spec.key) - await applyNodeProperties(node, spec, state, parent) - state.nodeIdsByKey[spec.key] = node.id - const reconciled: ReconciledChild[] = [] - for (const [childIndex, child] of children.entries()) { - const variant = await reconcileNode( - child, - state, - node, - desiredChildIndex( - child, - children.slice(childIndex + 1), - state, - node, - reconciled.at(-1)?.node, - variants[childIndex] - ), - variants[childIndex] - ) - reconciled.push({ node: variant, spec: child }) - } - finalizeGrid(node, spec, reconciled, state) - for (const child of reconciled) applyMask(child.node, child.spec, state) - return node - } - - const stagingIndex = stagingParent.children.length - const children: ReconciledChild[] = [] - for (const [childIndex, child] of spec.children!.entries()) { - children.push({ - node: await reconcileNode( - child, - state, - stagingParent, - desiredChildIndex( - child, - spec.children!.slice(childIndex + 1), - state, - stagingParent, - children.at(-1)?.node, - undefined, - stagingIndex - ) - ), - spec: child - }) - } - - const node = createWrappedContainer( - spec, - children.map(({ node: child }) => child), - parent, - index, - state - ) - setNodeKey(state, node, spec.key) - await applyNodeProperties(node, spec, state, parent) - state.nodeIdsByKey[spec.key] = node.id - for (const child of children) applyMask(child.node, child.spec, state) - return node -} - -function desiredChildIndex( - spec: CanvasNodeSpec, - following: CanvasNodeSpec[], - state: ApplyState, - parent: CanvasParentNode, - previous?: SupportedCanvasNode, - forcedNode?: SupportedCanvasNode, - minimumIndex = 0 -): number { - const existing = findExistingNode(spec, state, forcedNode) - const currentIndex = existing?.parent?.id === parent.id ? parent.children.indexOf(existing) : -1 - if (!previous) { - if (currentIndex >= 0) return currentIndex - for (const candidate of following) { - const followingNode = findExistingNode(candidate, state) - if (followingNode?.parent?.id !== parent.id) continue - return Math.max(minimumIndex, parent.children.indexOf(followingNode)) - } - return Math.max(minimumIndex, parent.children.length) - } - - const previousIndex = parent.children.indexOf(previous) - if (currentIndex > previousIndex) return currentIndex - return currentIndex >= 0 && currentIndex < previousIndex ? previousIndex : previousIndex + 1 -} - -async function reconcileNode( - spec: CanvasNodeSpec, - state: ApplyState, - parent?: CanvasParentNode, - index = 0, - forcedNode?: SupportedCanvasNode -): Promise { - const existing = resolveExistingNode(spec, state, forcedNode) - if (!existing && isWrappedSpec(spec)) { - try { - return await reconcileNewWrappedContainer(spec, state, parent, index) - } catch (error) { - throwNodeError(error, spec.key, state.nodeIdsByKey[spec.key]) - } - } - let node = existing - try { - node ??= - spec.type === 'SLOT' ? createSlotNode(spec, parent, state) : await createNode(spec, state) - if (parent) moveIntoParent(node, parent, index, state) - setNodeKey(state, node, spec.key) - } catch (error) { - throwNodeError(error, spec.key, node?.id) - } - if (!isIntrinsicNode(node)) { - await applyNodeProperties(node, spec, state, parent) - } - state.nodeIdsByKey[spec.key] = node.id - - const children: ReconciledChild[] = [] - if (spec.children?.length) { - if ( - node.type !== 'BOOLEAN_OPERATION' && - node.type !== 'COMPONENT' && - node.type !== 'COMPONENT_SET' && - node.type !== 'FRAME' && - node.type !== 'GROUP' && - node.type !== 'SECTION' && - node.type !== 'SLOT' - ) { - specError(`Node "${spec.key}" of type ${node.type} cannot contain desired children.`) - } - for (const [childIndex, child] of spec.children.entries()) { - children.push({ - node: await reconcileNode( - child, - state, - node, - desiredChildIndex( - child, - spec.children.slice(childIndex + 1), - state, - node, - children.at(-1)?.node - ) - ), - spec: child - }) - } - } - if (isFrameContainer(node)) finalizeGrid(node, spec, children, state) - for (const child of children) applyMask(child.node, child.spec, state) - if (isIntrinsicNode(node)) { - await applyNodeProperties(node, spec, state, parent) - } - return node -} - -function placementBounds(node: SceneNode): Rect | null { - const bounds = ('absoluteRenderBounds' in node ? node.absoluteRenderBounds : null) ?? - ('absoluteBoundingBox' in node ? node.absoluteBoundingBox : null) ?? { - x: node.x, - y: node.y, - width: node.width, - height: node.height - } - return finiteRect(bounds) -} - -function placementOverlap(candidate: Rect, obstacle: Rect): boolean { - return ( - candidate.x < obstacle.x + obstacle.width + ROOT_PLACEMENT_GAP && - candidate.x + candidate.width + ROOT_PLACEMENT_GAP > obstacle.x && - candidate.y < obstacle.y + obstacle.height + ROOT_PLACEMENT_GAP && - candidate.y + candidate.height + ROOT_PLACEMENT_GAP > obstacle.y - ) -} - -function placeCreatedRoot(node: SupportedCanvasNode, page: PageNode, state: ApplyState): void { - const bounds = placementBounds(node) - if (!bounds) specError(`Created root "${node.id}" has invalid placement bounds.`) - const center = figma.viewport.center - const candidate = { - x: center.x - bounds.width / 2, - y: center.y - bounds.height / 2, - width: bounds.width, - height: bounds.height - } - const obstacles = page.children - .filter((child) => child.id !== node.id) - .map(placementBounds) - .filter((value): value is Rect => value !== null) - .sort((a, b) => a.x - b.x) - - for (const obstacle of obstacles) { - if (!placementOverlap(candidate, obstacle)) continue - candidate.x = obstacle.x + obstacle.width + ROOT_PLACEMENT_GAP - if (!Number.isFinite(candidate.x)) { - specError(`Page "${page.id}" has invalid placement bounds.`) - } - } - - if (obstacles.some((obstacle) => placementOverlap(candidate, obstacle))) { - specError(`Created root "${node.id}" could not be placed without overlap.`) - } - - const deltaX = candidate.x - bounds.x - const deltaY = candidate.y - bounds.y - if (deltaX !== 0 || deltaY !== 0) { - const transform = node.relativeTransform - node.relativeTransform = [ - [transform[0][0], transform[0][1], transform[0][2] + deltaX], - [transform[1][0], transform[1][1], transform[1][2] + deltaY] - ] - markMutation(state, node) - } - reportCanvasPlacement(node) -} - -function createApplyState( - target: SupportedCanvasNode | null, - desiredKeys: Set, - assets: ResolvedCanvasAssets = new Map() -): ApplyState { - const state: ApplyState = { - media: createMediaState(assets), - claimedNodeIds: new Set(), - componentCache: new Map(), - componentPropertyKeys: new Map(), - createdNodeIds: new Set(), - createdPageIds: new Set(), - desiredKeys, - explicitNodes: new Map(), - fonts: createFontState(), - ignoredLiteralWarnings: [], - keyedNodes: target ? collectKeyedNodes(target) : new Map(), - mutations: { count: 0 }, - nodeIdsByKey: Object.create(null) as Record, - pendingGridRemovalCleanupNodeIds: new Set(), - protectedNodes: new Map(), - removalNodeIds: new Set(), - referencedNodeIds: new Set(), - scope: target, - shaderCache: new Map(), - stabilizedCrossAxisFillNodeIds: new Set(), - styles: createStyleState(), - updatedNodeIds: new Set(), - variables: createVariableState() - } - if (target) protectNode(state, target, false) - return state -} - -type VerificationWarning = ApplyCanvasResult['verification']['warnings'][number] - -function buildVerification( - nodesChecked = 0, - referencesChecked = 0, - nativeFieldsChecked = 0, - warnings: VerificationWarning[] = [] -) { - return { - status: warnings.length ? ('warning' as const) : ('passed' as const), - nodesChecked, - referencesChecked, - nativeFieldsChecked, - warnings - } -} - -function collectKeyReferences(value: unknown, field: string, references: Set): void { - if (Array.isArray(value)) { - for (const item of value) collectKeyReferences(item, field, references) - return - } - if (!isRecord(value)) return - if (typeof value[field] === 'string') references.add(value[field]) - for (const nested of Object.values(value)) collectKeyReferences(nested, field, references) -} - -function desiredKeyReferences(values: unknown[], field: string): Set { - const references = new Set() - for (const value of values) collectKeyReferences(value, field, references) - return references -} - -function unboundCreatedResourceWarnings( - desiredValues: unknown[], - state: ApplyState -): VerificationWarning[] { - return [ - { - code: 'unbound-created-variable' as const, - consumer: 'node or style', - keys: state.variables.createdVariableKeys, - referenceField: 'variableKey', - resource: 'variable' - }, - { - code: 'unbound-created-style' as const, - consumer: 'node', - keys: state.styles.createdStyleKeys, - referenceField: 'styleKey', - resource: 'style' - } - ].flatMap(({ code, consumer, keys, referenceField, resource }) => { - if (!keys.size) return [] - const references = desiredKeyReferences(desiredValues, referenceField) - return [...keys] - .filter((key) => !references.has(key)) - .map((key) => ({ - code, - key, - message: `This new ${resource} is not referenced by the desired result. Bind it to a representative ${consumer}, or remove it if it is speculative.` - })) - }) -} - -type VariableFallback = boolean | number | string | RGBA - -function ignoredLiteralWarnings( - node: SupportedCanvasNode, - spec: CanvasNodeSpec, - nativeOnly: boolean -): VerificationWarning[] { - const fields: string[] = [] - for (const [field, nativeField] of Object.entries(DIRECT_VARIABLE_FIELDS) as Array< - [DirectVariableField, VariableBindableNodeField | VariableBindableTextField] - >) { - if (spec.variables?.[field] !== undefined || !currentBoundVariableId(node, nativeField)) - continue - // Native-only specs copy size and characters to preserve live state, not to author literals. - if (nativeOnly && (field === 'characters' || Object.hasOwn(spec.size, field))) continue - const literal = variableFallback(spec, field) - if (literal !== undefined) fields.push(field) - } - for (const field of ['fill', 'stroke'] as const) { - if ( - spec.variables?.[field] !== undefined || - spec.styles?.[field] !== undefined || - spec.appearance?.[field] === undefined || - !('fills' in node) - ) - continue - const property = field === 'fill' ? 'fills' : 'strokes' - const style = field === 'fill' ? node.fillStyleId : node.strokeStyleId - if (style || node.boundVariables?.[property]?.length) fields.push(field) - } - if (node.type === 'TEXT' && node.textStyleId && spec.styles?.text === undefined) { - for (const field of [ - 'fontFamily', - 'fontStyle', - 'fontSize', - 'lineHeight', - 'letterSpacing' - ] as const) { - if (spec.text?.[field] !== undefined) fields.push(field) - } - } - return fields.length - ? [ - { - code: 'literal-ignored-by-binding', - key: spec.key, - message: `Literal values for ${[...new Set(fields)].join(', ')} were ignored because existing variable or style bindings own those fields. Clear the corresponding native.${spec.key}.variables or styles binding with null before setting literal values.` - } - ] - : [] -} - -function parseFallbackColor(value: string): RGBA | undefined { - const hex = value.slice(1) - const expanded = - hex.length === 3 || hex.length === 4 - ? [...hex].map((character) => `${character}${character}`).join('') - : hex - if (expanded.length !== 6 && expanded.length !== 8) return undefined - const channels = expanded.match(/.{2}/g)?.map((channel) => Number.parseInt(channel, 16)) - if (!channels || channels.some((channel) => Number.isNaN(channel))) return undefined - return { - r: channels[0]! / 255, - g: channels[1]! / 255, - b: channels[2]! / 255, - a: channels[3] === undefined ? 1 : channels[3] / 255 - } -} - -function paddingFallback( - layout: CanvasNodeSpec['layout'], - side: 'bottom' | 'left' | 'right' | 'top' -): number | undefined { - if (!layout || layout.mode === 'NONE' || layout.padding === undefined) return undefined - return typeof layout.padding === 'number' ? layout.padding : layout.padding[side] -} - -function metricFallback(value: unknown): number | undefined { - return isRecord(value) && value.unit === 'PIXELS' && typeof value.value === 'number' - ? value.value - : undefined -} - -function variableFallback( - spec: CanvasNodeSpec, - field: keyof CanvasVariableBindings -): VariableFallback | undefined { - switch (field) { - case 'fill': - case 'stroke': { - const value = spec.appearance?.[field] - return typeof value === 'string' ? parseFallbackColor(value) : undefined - } - case 'characters': - return spec.text?.characters - case 'visible': - return spec.visible - case 'width': - case 'height': - case 'minWidth': - case 'maxWidth': - case 'minHeight': - case 'maxHeight': - return spec.size[field] ?? undefined - case 'gap': - return spec.layout?.mode === 'HORIZONTAL' || spec.layout?.mode === 'VERTICAL' - ? spec.layout.gap - : undefined - case 'counterAxisSpacing': - return spec.layout?.mode === 'HORIZONTAL' || spec.layout?.mode === 'VERTICAL' - ? spec.layout.counterGap - : undefined - case 'gridRowGap': - return spec.layout?.mode === 'GRID' ? spec.layout.rowGap : undefined - case 'gridColumnGap': - return spec.layout?.mode === 'GRID' ? spec.layout.columnGap : undefined - case 'paddingTop': - return paddingFallback(spec.layout, 'top') - case 'paddingRight': - return paddingFallback(spec.layout, 'right') - case 'paddingBottom': - return paddingFallback(spec.layout, 'bottom') - case 'paddingLeft': - return paddingFallback(spec.layout, 'left') - case 'cornerRadius': - case 'topLeftRadius': - case 'topRightRadius': - case 'bottomRightRadius': - case 'bottomLeftRadius': - case 'strokeWeight': - case 'strokeTopWeight': - case 'strokeRightWeight': - case 'strokeBottomWeight': - case 'strokeLeftWeight': - case 'opacity': - return spec.appearance?.[field] - case 'fontFamily': - return spec.text?.fontFamily - case 'fontStyle': - return spec.text?.fontStyle - case 'fontSize': - return spec.text?.fontSize - case 'lineHeight': - return metricFallback(spec.text?.lineHeight) - case 'letterSpacing': - return metricFallback(spec.text?.letterSpacing) - case 'fontWeight': - return spec.text?.literalFontWeight - case 'paragraphIndent': - case 'paragraphSpacing': - return undefined - } -} - -function colorChannels(value: unknown): RGBA | null { - if ( - !isRecord(value) || - typeof value.r !== 'number' || - typeof value.g !== 'number' || - typeof value.b !== 'number' - ) { - return null - } - return { - r: value.r, - g: value.g, - b: value.b, - a: typeof value.a === 'number' ? value.a : 1 - } -} - -function fallbackMatches(value: unknown, fallback: VariableFallback): boolean { - if (typeof fallback === 'number') { - return typeof value === 'number' && Math.abs(value - fallback) <= GEOMETRY_TOLERANCE - } - if (typeof fallback === 'string' || typeof fallback === 'boolean') return value === fallback - const channels = colorChannels(value) - if (!channels) return false - return ( - Math.abs(channels.r - fallback.r) <= 1 / 255 && - Math.abs(channels.g - fallback.g) <= 1 / 255 && - Math.abs(channels.b - fallback.b) <= 1 / 255 && - Math.abs(channels.a - fallback.a) <= 1 / 255 - ) -} - -function formatFallback(value: unknown): string { - if (typeof value !== 'object' || value === null) return String(value) - const channels = colorChannels(value) - if (!channels) return JSON.stringify(value) - const bytes = [channels.r, channels.g, channels.b, channels.a].map((channel) => - Math.round(channel * 255) - .toString(16) - .padStart(2, '0') - .toUpperCase() - ) - return `#${bytes.slice(0, channels.a === 1 ? 3 : 4).join('')}` -} - -function authoredVariableFallbackWarnings( - specs: Iterable, - variableCollections: ParsedCanvasTreeInput['variableCollections'] -): VerificationWarning[] { - const authoredValues = new Map() - for (const collection of Object.values(variableCollections ?? {})) { - if (!collection) continue - for (const [key, variable] of Object.entries(collection.variables ?? {})) { - const values = Object.values(variable?.values ?? {}) - if (values.length) authoredValues.set(key, values) - } - } - - const directValues = (key: string, seen = new Set()): unknown[] => { - if (seen.has(key)) return [] - seen.add(key) - return (authoredValues.get(key) ?? []).flatMap((value) => { - if (!isRecord(value) || !isRecord(value.variable)) return [value] - const alias = value.variable.variableKey - return typeof alias === 'string' ? directValues(alias, new Set(seen)) : [] - }) - } - - const warnings: VerificationWarning[] = [] - for (const spec of specs) { - for (const [field, reference] of Object.entries(spec.variables ?? {}) as Array< - [keyof CanvasVariableBindings, CanvasVariableReference | null] - >) { - if (!reference || !('variableKey' in reference)) continue - if (spec.themeVariableFields?.includes(field)) continue - const values = directValues(reference.variableKey) - const fallback = variableFallback(spec, field) - const nativeFallback = - field === 'opacity' && typeof fallback === 'number' ? fallback * 100 : fallback - if ( - !values.length || - nativeFallback === undefined || - values.some((value) => fallbackMatches(value, nativeFallback)) - ) { - continue - } - const authoredValues = [...new Set(values.map(formatFallback))].join(', ') - const units = - field === 'opacity' - ? ' Opacity variables use 0–100 percentages; literal opacity uses 0–1.' - : '' - warnings.push({ - code: 'variable-fallback-mismatch', - key: spec.key, - message: `"${spec.key}" binds ${field} ${formatFallback(fallback)} to authored variable "${reference.variableKey}", whose direct mode values are ${authoredValues}.${units} Use a matching literal fallback or bind the variable that owns this value.` - }) - } - } - return warnings -} - -function layoutAffectingVisibilityWarnings( - root: CanvasNodeSpec, - state: ApplyState -): VerificationWarning[] { - const warnings: VerificationWarning[] = [] - for (const spec of walkSpecs(root)) { - const property = spec.figma?.componentPropertyReferences?.visible - if (!property) continue - const node = state.keyedNodes.get(spec.key) - const parent = node?.parent - if ( - !node || - !parent || - !isSupportedSceneNode(parent) || - !isFrameContainer(parent) || - parent.layoutMode === 'NONE' || - ('layoutPositioning' in node && node.layoutPositioning === 'ABSOLUTE') - ) { - continue - } - const flowSibling = parent.children.some( - (child) => - child.id !== node.id && - isSceneNode(child) && - (!('layoutPositioning' in child) || child.layoutPositioning !== 'ABSOLUTE') - ) - if ( - !flowSibling && - parent.primaryAxisSizingMode !== 'AUTO' && - parent.counterAxisSizingMode !== 'AUTO' - ) { - continue - } - warnings.push({ - code: 'layout-affecting-visibility-property', - key: spec.key, - message: `Boolean component property "${property}" controls an Auto Layout flow child. Hiding it can resize its parent or move siblings. If geometry must stay stable, put the visible layer inside an always-present fixed slot, make it absolute, or use geometry-equivalent variants; otherwise verify both states and accept the intended reflow.` - }) - } - return warnings -} - -type ContentOverflow = { - bottom: number - left: number - right: number - top: number -} - -function finiteRect(value: Rect | null | undefined): Rect | null { - return value && - [value.x, value.y, value.width, value.height].every(Number.isFinite) && - value.width >= 0 && - value.height >= 0 - ? value - : null -} - -function overflowFromRects(child: Rect, parent: Rect): ContentOverflow | null { - const overflow = { - bottom: Math.max(0, child.y + child.height - (parent.y + parent.height)), - left: Math.max(0, parent.x - child.x), - right: Math.max(0, child.x + child.width - (parent.x + parent.width)), - top: Math.max(0, parent.y - child.y) - } - return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > - CONTENT_OVERFLOW_TOLERANCE - ? overflow - : null -} - -function localNodeBounds(node: SupportedCanvasNode): Rect | null { - if (![node.width, node.height].every(Number.isFinite) || node.width < 0 || node.height < 0) { - return null - } - const transform = node.relativeTransform - const corners = [ - { x: 0, y: 0 }, - { x: node.width, y: 0 }, - { x: 0, y: node.height }, - { x: node.width, y: node.height } - ].map(({ x, y }) => ({ - x: transform[0][0] * x + transform[0][1] * y + transform[0][2], - y: transform[1][0] * x + transform[1][1] * y + transform[1][2] - })) - const x = Math.min(...corners.map((corner) => corner.x)) - const y = Math.min(...corners.map((corner) => corner.y)) - const right = Math.max(...corners.map((corner) => corner.x)) - const bottom = Math.max(...corners.map((corner) => corner.y)) - return finiteRect({ x, y, width: right - x, height: bottom - y }) -} - -function contentOverflow( - node: SupportedCanvasNode, - parent: CanvasFrameContainerNode -): ContentOverflow | null { - const parentBounds = finiteRect( - 'absoluteBoundingBox' in parent ? parent.absoluteBoundingBox : null - ) - const childBounds = [ - node.type === 'TEXT' && 'absoluteRenderBounds' in node ? node.absoluteRenderBounds : null, - 'absoluteBoundingBox' in node ? node.absoluteBoundingBox : null - ].map(finiteRect) - if (parentBounds && childBounds.some(Boolean)) { - const overflow: ContentOverflow = { bottom: 0, left: 0, right: 0, top: 0 } - for (const bounds of childBounds) { - if (!bounds) continue - const current = overflowFromRects(bounds, parentBounds) - if (!current) continue - overflow.bottom = Math.max(overflow.bottom, current.bottom) - overflow.left = Math.max(overflow.left, current.left) - overflow.right = Math.max(overflow.right, current.right) - overflow.top = Math.max(overflow.top, current.top) - } - return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > - CONTENT_OVERFLOW_TOLERANCE - ? overflow - : null - } - if (parent.layoutMode !== 'NONE') return null - const local = localNodeBounds(node) - return local - ? overflowFromRects(local, { x: 0, y: 0, width: parent.width, height: parent.height }) - : null -} - -function formatOverflow(overflow: ContentOverflow): string { - const edges: Array<[string, number]> = [ - ['top', overflow.top], - ['right', overflow.right], - ['bottom', overflow.bottom], - ['left', overflow.left] - ] - return edges - .filter(([, value]) => value > CONTENT_OVERFLOW_TOLERANCE) - .map(([edge, value]) => `${edge} ${Math.round(value * 10) / 10}px`) - .join(', ') -} - -function instanceDescendantOverflow(node: InstanceNode): ContentOverflow | null { - const absoluteRootBounds = finiteRect(node.absoluteBoundingBox) - const rootBounds = absoluteRootBounds ?? { x: 0, y: 0, width: node.width, height: node.height } - const overflow: ContentOverflow = { bottom: 0, left: 0, right: 0, top: 0 } - const stack: SceneNode[] = [...node.children] - while (stack.length) { - const descendant = stack.pop()! - if (descendant.visible === false) continue - const bounds = absoluteRootBounds - ? finiteRect( - descendant.type === 'TEXT' - ? descendant.absoluteRenderBounds - : descendant.absoluteBoundingBox - ) - : descendant.parent === node && isSupportedSceneNode(descendant) - ? localNodeBounds(descendant) - : null - if (bounds) { - const current = overflowFromRects(bounds, rootBounds) - if (current) { - overflow.bottom = Math.max(overflow.bottom, current.bottom) - overflow.left = Math.max(overflow.left, current.left) - overflow.right = Math.max(overflow.right, current.right) - overflow.top = Math.max(overflow.top, current.top) - } - } - if ('children' in descendant) stack.push(...descendant.children) - } - return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > - CONTENT_OVERFLOW_TOLERANCE - ? overflow - : null -} - -function managedContentOverflowWarnings( - root: CanvasNodeSpec, - state: ApplyState -): VerificationWarning[] { - const warnings: VerificationWarning[] = [] - for (const spec of walkSpecs(root)) { - const node = state.keyedNodes.get(spec.key) - if (!node) continue - const parent = node.parent - if (!parent || !isSupportedSceneNode(parent) || !isFrameContainer(parent)) continue - const isFlowChild = - parent.layoutMode !== 'NONE' && - (!('layoutPositioning' in node) || node.layoutPositioning !== 'ABSOLUTE') - if (node.type !== 'TEXT' && node.type !== 'INSTANCE' && !isFlowChild) continue - const parentKey = readOwnedNodeKey(parent) - if (!parentKey) continue - const overflow = contentOverflow(node, parent) - if (overflow) { - warnings.push({ - code: 'managed-content-overflow', - key: spec.key, - message: `"${spec.key}" extends beyond parent "${parentKey}" by ${formatOverflow(overflow)}. Parent clipping is ${parent.clipsContent ? 'enabled, so the content will be clipped' : 'disabled, so the content may overlap adjacent content'}. Resize or realign the content when unintended; otherwise verify and retain the intentional overflow.` - }) - } - if (node.type !== 'INSTANCE') continue - const instanceOverflow = instanceDescendantOverflow(node) - if (!instanceOverflow) continue - warnings.push({ - code: 'managed-content-overflow', - key: spec.key, - message: `"${spec.key}" contains instance content extending beyond its root by ${formatOverflow(instanceOverflow)}. Figma can paint this overflow without expanding the native INSTANCE bounds. Resize the component definition for the real property values, add a size variant, or choose a smaller stable component boundary.` - }) - } - return warnings -} - -type RoundedCorner = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' - -function hasVisibleAreaFill(node: SupportedCanvasNode): boolean { - if ( - node.type === 'TEXT' || - node.type === 'LINE' || - node.type === 'VECTOR' || - !('fills' in node) || - node.fills === figma.mixed - ) { - return false - } - return node.fills.some( - (paint) => paint.visible !== false && (paint.opacity === undefined || paint.opacity > 0) - ) -} - -function roundedCornerRadius(node: SupportedCanvasNode, corner: RoundedCorner): number { - if (!('topLeftRadius' in node)) return 0 - if ('cornerRadius' in node && typeof node.cornerRadius === 'number') { - return Number.isFinite(node.cornerRadius) ? node.cornerRadius : 0 - } - const field = - corner === 'top-left' - ? 'topLeftRadius' - : corner === 'top-right' - ? 'topRightRadius' - : corner === 'bottom-right' - ? 'bottomRightRadius' - : 'bottomLeftRadius' - const value = node[field] - return typeof value === 'number' && Number.isFinite(value) ? value : 0 -} - -function managedRoundedStrokeOcclusionWarnings( - root: CanvasNodeSpec, - state: ApplyState -): VerificationWarning[] { - const warnings: VerificationWarning[] = [] - for (const parentSpec of walkSpecs(root)) { - const parent = state.keyedNodes.get(parentSpec.key) - if ( - !parent || - !isFrameContainer(parent) || - !parent.clipsContent || - parent.strokeAlign === 'OUTSIDE' || - !parent.strokes.some( - (paint) => paint.visible !== false && (paint.opacity === undefined || paint.opacity > 0) - ) - ) { - continue - } - - const innerStrokeWeight = ( - field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' - ): number => layoutStrokeWeight(parent, field) * (parent.strokeAlign === 'CENTER' ? 0.5 : 1) - - const strokes = { - bottom: innerStrokeWeight('strokeBottomWeight'), - left: innerStrokeWeight('strokeLeftWeight'), - right: innerStrokeWeight('strokeRightWeight'), - top: innerStrokeWeight('strokeTopWeight') - } - for (const child of parent.children) { - if ( - child.visible === false || - !isSupportedSceneNode(child) || - !hasVisibleAreaFill(child) || - ('rotation' in child && Math.abs(child.rotation) > GEOMETRY_TOLERANCE) - ) { - continue - } - const childKey = readOwnedNodeKey(child) - const bounds = localNodeBounds(child) - if (!childKey || !bounds) continue - const touches = { - bottom: - bounds.y + bounds.height >= parent.height - strokes.bottom - CONTENT_OVERFLOW_TOLERANCE, - left: bounds.x <= strokes.left + CONTENT_OVERFLOW_TOLERANCE, - right: bounds.x + bounds.width >= parent.width - strokes.right - CONTENT_OVERFLOW_TOLERANCE, - top: bounds.y <= strokes.top + CONTENT_OVERFLOW_TOLERANCE - } - const affected = ( - [ - ['top-left', touches.top && touches.left, Math.max(strokes.top, strokes.left)], - ['top-right', touches.top && touches.right, Math.max(strokes.top, strokes.right)], - [ - 'bottom-right', - touches.bottom && touches.right, - Math.max(strokes.bottom, strokes.right) - ], - ['bottom-left', touches.bottom && touches.left, Math.max(strokes.bottom, strokes.left)] - ] as Array<[RoundedCorner, boolean, number]> - ).filter(([corner, touching, stroke]) => { - if (!touching) return false - const requiredRadius = Math.max(0, roundedCornerRadius(parent, corner) - stroke) - return ( - requiredRadius > CONTENT_OVERFLOW_TOLERANCE && - roundedCornerRadius(child, corner) + CONTENT_OVERFLOW_TOLERANCE < requiredRadius - ) - }) - if (!affected.length) continue - warnings.push({ - code: 'managed-rounded-stroke-occlusion', - key: childKey, - message: `"${childKey}" has a visible fill reaching the ${affected - .map(([corner]) => corner) - .join( - ' and ' - )} of rounded, stroked parent "${parentSpec.key}" without a corresponding inner radius. Figma clips to the outer frame but paints the parent stroke behind its children, so the fill can square off or hide that boundary. Inset the child, give its touching corners an inner radius that follows the parent, or add a dedicated foreground boundary; then inspect the rendered pixels.` - }) - } - } - return warnings -} - -function includedLayoutTrailingStroke(node: CanvasFrameContainerNode): number { - return includedLayoutEdgeStroke( - node, - node.layoutMode === 'HORIZONTAL' ? 'strokeRightWeight' : 'strokeBottomWeight' - ) -} - -function formatGeometry(value: number): string { - return `${Math.round(value * 10) / 10}px` -} - -function managedAutoLayoutInsetWarnings( - root: CanvasNodeSpec, - state: ApplyState -): VerificationWarning[] { - const warnings: VerificationWarning[] = [] - for (const spec of walkSpecs(root)) { - const node = state.keyedNodes.get(spec.key) - if ( - !node || - !isFrameContainer(node) || - (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') || - node.primaryAxisSizingMode === 'AUTO' || - node.primaryAxisAlignItems !== 'MIN' || - node.layoutWrap !== 'NO_WRAP' || - (node.layoutMode === 'HORIZONTAL' - ? spec.size.horizontal !== 'FIXED' - : spec.size.vertical !== 'FIXED') || - Math.abs(node.rotation) > GEOMETRY_TOLERANCE - ) { - continue - } - const children = node.children.filter( - (child) => - child.visible !== false && - (!('layoutPositioning' in child) || child.layoutPositioning !== 'ABSOLUTE') - ) - if ( - !children.length || - children.some( - (child) => - ('layoutGrow' in child && child.layoutGrow > 0) || - ('rotation' in child && Math.abs(child.rotation) > GEOMETRY_TOLERANCE) - ) - ) { - continue - } - const bounds = finiteRect(node.absoluteBoundingBox) - const childBounds = children.map((child) => finiteRect(child.absoluteBoundingBox)) - if (!bounds || childBounds.some((child) => !child)) continue - const horizontal = node.layoutMode === 'HORIZONTAL' - const childEnd = Math.max( - ...childBounds.map((child) => - horizontal ? child!.x + child!.width : child!.y + child!.height - ) - ) - const actual = (horizontal ? bounds.x + bounds.width : bounds.y + bounds.height) - childEnd - const padding = horizontal ? node.paddingRight : node.paddingBottom - const expected = padding + includedLayoutTrailingStroke(node) - if (Math.abs(actual - expected) <= CONTENT_OVERFLOW_TOLERANCE) continue - const side = horizontal ? 'right' : 'bottom' - const diagnosis = - actual > expected - ? `The fixed start-aligned main axis leaves ${formatGeometry(actual - expected)} of unmodeled trailing space.` - : `Resolved content consumes ${formatGeometry(expected - actual)} of the intended trailing inset.` - warnings.push({ - code: 'managed-auto-layout-inset-mismatch', - key: spec.key, - message: `"${spec.key}" resolves to a ${formatGeometry(actual)} ${side} inset after its last in-flow child, while its padding and included inside stroke account for ${formatGeometry(expected)}. ${diagnosis} Use a hugging main axis or resize after content resolves; if the empty space is intentional, express it with main-axis alignment or a growing spacer.` - }) - } - return warnings -} - -function removedRootResult( - rootNodeId: string, - removedNodeIds: string[] = [], - state?: ApplyState -): ApplyCanvasResult { - return { - rootNodeId, - rootRemoved: true, - nodeIdsByKey: state?.nodeIdsByKey ?? {}, - createdNodeIds: [], - updatedNodeIds: [], - removedNodeIds, - mutationCount: state?.mutations.count ?? 0, - verification: buildVerification() - } -} - -function pageApplyResult(page: CanvasPageSnapshot, state: ApplyState): ApplyCanvasResult { - return boundedApplyResult({ - nodeIdsByKey: state.nodeIdsByKey, - createdNodeIds: [...state.createdNodeIds], - updatedNodeIds: [...state.updatedNodeIds], - removedNodeIds: [], - page, - mutationCount: state.mutations.count, - verification: buildVerification() - }) -} - -function boundedApplyResult(result: ApplyCanvasResult): ApplyCanvasResult { - const bytes = measureCallToolResultBytes(buildApplyCanvasToolResult(result)) - if (bytes > MCP_TOOL_INLINE_BUDGET_BYTES - MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES) { - specError( - 'apply_canvas result exceeds the 64 KiB inline budget. Reduce the desired subtree or split the operation.' - ) - } - return result -} - -function appliedVariableId( - node: SupportedCanvasNode, - field: keyof CanvasVariableBindings -): string | undefined { - if (field === 'fill' || field === 'stroke') { - const paintField = field === 'fill' ? 'fills' : 'strokes' - return node.boundVariables?.[paintField]?.[0]?.id - } - return currentBoundVariableId(node, DIRECT_VARIABLE_FIELDS[field]) -} - -function verifySizingGeometry( - spec: CanvasNodeSpec, - node: SupportedCanvasNode, - parent?: SupportedCanvasNode -): void { - if (parent && spec.absoluteOffsets) { - const { right, bottom } = spec.absoluteOffsets - if ( - (right !== undefined && - Math.abs(parent.width - node.x - node.width - right) > GEOMETRY_TOLERANCE) || - (bottom !== undefined && - Math.abs(parent.height - node.y - node.height - bottom) > GEOMETRY_TOLERANCE) - ) { - applyError(`Verification failed for "${spec.key}": absolute edge placement does not match.`) - } - } - if (!isIntrinsicNode(node) && supportsLayoutSizing(node, parent)) { - if ( - (spec.size.horizontal !== undefined && - node.layoutSizingHorizontal !== spec.size.horizontal) || - (spec.size.vertical !== undefined && node.layoutSizingVertical !== spec.size.vertical) || - (spec.grow !== undefined && node.layoutGrow !== (spec.grow ? 1 : 0)) - ) { - applyError( - `Verification failed for "${spec.key}": sizing modes do not match (declared horizontal=${spec.size.horizontal}, vertical=${spec.size.vertical}, grow=${spec.grow ?? false}; applied horizontal=${node.layoutSizingHorizontal}, vertical=${node.layoutSizingVertical}, grow=${node.layoutGrow === 1}).` - ) - } - } - - const fixedWidth = - spec.size.horizontal === 'FIXED' && - spec.size.width !== undefined && - !spec.variables?.width && - !currentBoundVariableId(node, 'width') - ? spec.size.width - : null - const fixedHeight = - spec.size.vertical === 'FIXED' && - spec.size.height !== undefined && - !spec.variables?.height && - !currentBoundVariableId(node, 'height') - ? spec.size.height - : null - if ( - (fixedWidth !== null && Math.abs(node.width - fixedWidth) > GEOMETRY_TOLERANCE) || - (fixedHeight !== null && Math.abs(node.height - fixedHeight) > GEOMETRY_TOLERANCE) - ) { - applyError(`Verification failed for "${spec.key}": fixed geometry does not match.`) - } - - const fill = crossAxisFill(node, spec, parent) - if (fill) { - const actual = fill.axis === 'horizontal' ? node.width : node.height - if (actual < GEOMETRY_TOLERANCE) { - applyError(`Verification failed for "${spec.key}": fill geometry does not match.`) - } - } - - if (node.type !== 'TEXT') return - if (node.textAutoResize !== spec.text?.autoResize) { - applyError(`Verification failed for "${spec.key}": text auto-resize does not match.`) - } - if ( - node.characters.length > 0 && - (node.textAutoResize === 'HEIGHT' || node.textAutoResize === 'WIDTH_AND_HEIGHT') && - node.height <= GEOMETRY_TOLERANCE - ) { - applyError(`Verification failed for "${spec.key}": auto-resizing text has no height.`) - } - if ( - node.characters.length > 0 && - node.textAutoResize === 'WIDTH_AND_HEIGHT' && - node.width <= GEOMETRY_TOLERANCE - ) { - applyError(`Verification failed for "${spec.key}": auto-resizing text has no width.`) - } - if ( - node.characters.length > 0 && - node.textAutoResize === 'HEIGHT' && - node.layoutGrow > 0 && - node.width <= GEOMETRY_TOLERANCE && - (node.minWidth ?? 0) <= GEOMETRY_TOLERANCE - ) { - applyError( - `Verification failed for "${spec.key}": growing text collapsed to zero width; provide a positive min-width.` - ) - } -} - -function componentLinkMatches( - spec: CanvasNodeSpec, - node: InstanceNode, - expected: ComponentNode, - actual: ComponentNode | null, - state: ApplyState -): boolean { - if (actual?.id === expected.id) return true - const componentSet = expected.parent - if ( - !actual || - componentSet?.type !== 'COMPONENT_SET' || - actual.parent?.type !== 'COMPONENT_SET' || - actual.parent.id !== componentSet.id - ) { - return false - } - - const variantProperties = Object.entries(spec.componentProperties ?? {}).flatMap( - ([key, value]) => { - const name = componentPropertyName(componentSet, key, state) ?? key - return componentSet.componentPropertyDefinitions[name]?.type === 'VARIANT' - ? ([[name, value]] as const) - : [] - } - ) - return ( - variantProperties.length > 0 && - variantProperties.every(([name, value]) => - componentPropertyMatches(node.componentProperties[name], value, state) - ) - ) -} - -async function verifyInstanceState( - spec: CanvasNodeSpec, - node: SupportedCanvasNode, - state: ApplyState -): Promise { - if (!spec.componentProperties && !spec.figma?.instance) return - if (node.type !== 'INSTANCE') { - applyError(`Verification failed for "${spec.key}": expected an instance.`) - } - const instance = spec.figma?.instance - if ( - instance?.scaleFactor !== undefined && - Math.abs(node.scaleFactor - instance.scaleFactor) > GEOMETRY_TOLERANCE - ) { - applyError(`Verification failed for "${spec.key}": instance scale does not match.`) - } - if (instance?.exposed !== undefined && node.isExposedInstance !== instance.exposed) { - applyError(`Verification failed for "${spec.key}": instance exposure does not match.`) - } - if (!spec.componentProperties) return - const component = await getMainComponent(node) - if (!component) { - applyError(`Verification failed for "${spec.key}": instance has no main component.`) - } - const owner = componentDefinitionOwner(component) - for (const [key, value] of Object.entries(spec.componentProperties)) { - const name = componentPropertyName(owner, key, state) ?? key - if (!componentPropertyMatches(node.componentProperties[name], value, state)) { - applyError( - `Verification failed for "${spec.key}": component property "${name}" does not match.` - ) - } - } -} - -function verifyNativeNodeState( - spec: CanvasNodeSpec, - node: SupportedCanvasNode, - state: ApplyState -): number { - let fields = 0 - for (const [property, styleProperty] of [ - ['fills', 'fillStyleId'], - ['strokes', 'strokeStyleId'] - ] as const) { - const paints = spec.figma?.[property] - if (paints === undefined) continue - fields += 1 - if (!('fills' in node)) { - applyError(`Verification failed for "${spec.key}": direct ${property} are unsupported.`) - } - const desired = paints.map((paint) => nativePaint(paint, state)) - const current = node[property] - if (current === figma.mixed || !!node[styleProperty] || !paintStacksEqual(current, desired)) { - applyError(`Verification failed for "${spec.key}": direct ${property} do not match.`) - } - } - - const effects = spec.figma?.effects - if (effects !== undefined) { - fields += 1 - if (!('effects' in node)) { - applyError(`Verification failed for "${spec.key}": direct effects are unsupported.`) - } - const desired = effects.map((effect) => nativeEffect(effect, state)) - if (node.effectStyleId) { - applyError( - `Verification failed for "${spec.key}": expected direct effects, but effect style "${node.effectStyleId}" remains applied.` - ) - } - if (!effectsEqual(node.effects, desired)) { - applyError( - `Verification failed for "${spec.key}": direct ${describeEffectMismatch(node.effects, desired)}` - ) - } - } - - const layoutGrids = spec.figma?.layoutGrids - if (layoutGrids !== undefined) { - fields += 1 - if ((!isFrameContainer(node) && node.type !== 'INSTANCE') || !('layoutGrids' in node)) { - applyError(`Verification failed for "${spec.key}": layout grids are unsupported.`) - } - const desired = layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) - if (!!node.gridStyleId || !layoutGridsEqual(node.layoutGrids, desired)) { - applyError(`Verification failed for "${spec.key}": layout grids do not match.`) - } - } - - const guides = spec.figma?.guides - if (guides !== undefined) { - fields += 1 - if ((!isFrameContainer(node) && node.type !== 'INSTANCE') || !('guides' in node)) { - applyError(`Verification failed for "${spec.key}": guides are unsupported.`) - } - if (!nativeValueEqual(node.guides, guides)) { - applyError(`Verification failed for "${spec.key}": guides do not match.`) - } - } - - return fields -} - -async function verifyAppliedNode( - spec: CanvasNodeSpec, - node: SupportedCanvasNode, - state: ApplyState, - parent?: SupportedCanvasNode -): Promise<{ nodes: number; references: number; nativeFields: number }> { - if (node.type !== spec.type) { - applyError(`Verification failed for "${spec.key}": expected ${spec.type}, found ${node.type}.`) - } - if (state.nodeIdsByKey[spec.key] !== node.id) { - applyError( - `Verification failed for "${spec.key}": stable identity did not resolve to its node.` - ) - } - if (readOwnedNodeKey(node) !== spec.key) { - applyError(`Verification failed for "${spec.key}": native stable identity is missing.`) - } - if (parent && node.parent?.id !== parent.id) { - applyError(`Verification failed for "${spec.key}": parent does not match the desired tree.`) - } - const geometry = [ - node.x, - node.y, - node.width, - node.height, - ...('rotation' in node ? [node.rotation] : []) - ] - if (!geometry.every(Number.isFinite)) { - applyError(`Verification failed for "${spec.key}": geometry is not finite.`) - } - verifySizingGeometry(spec, node, parent) - - let references = 0 - let nativeFields = verifyNativeNodeState(spec, node, state) - if (spec.component) { - const expected = resolvedComponent(spec.component, state) - const actual = node.type === 'INSTANCE' ? await getMainComponent(node) : null - references += 1 - if (node.type !== 'INSTANCE' || !componentLinkMatches(spec, node, expected, actual, state)) { - applyError(`Verification failed for "${spec.key}": component link does not match.`) - } - } - await verifyInstanceState(spec, node, state) - for (const [field, reference] of Object.entries(spec.variables ?? {}) as Array< - [keyof CanvasVariableBindings, CanvasVariableBindings[keyof CanvasVariableBindings]] - >) { - const expected = reference ? resolvedVariable(reference, state.variables).id : undefined - references += 1 - if (appliedVariableId(node, field) !== expected) { - applyError(`Verification failed for "${spec.key}": variable link "${field}" does not match.`) - } - } - for (const field of STYLE_FIELDS) { - const reference = spec.styles?.[field] - if (reference === undefined) continue - const expected = reference ? (await resolveStyle(reference, state.styles)).id : '' - references += 1 - if (styleTarget(node, field).current !== expected) { - applyError(`Verification failed for "${spec.key}": style link "${field}" does not match.`) - } - } - for (const [collectionReference, modeReference] of Object.entries(spec.variableModes ?? {})) { - const collection = resolvedCollection(collectionReference, state.variables) - const expected = - modeReference === null - ? undefined - : resolvedModeId(collection, modeReference, state.variables) - references += 1 - if (node.explicitVariableModes[collection.id] !== expected) { - applyError(`Verification failed for "${spec.key}": variable mode does not match.`) - } - } - if (spec.figma?.mask !== undefined) { - nativeFields += 1 - const mask = spec.figma.mask - if ( - !('isMask' in node) || - node.isMask !== (mask !== null) || - (mask !== null && (!('maskType' in node) || node.maskType !== mask)) - ) { - applyError(`Verification failed for "${spec.key}": mask state does not match.`) - } - } - if (spec.figma?.svg) { - nativeFields += 1 - if (node.type !== 'FRAME') { - applyError(`Verification failed for "${spec.key}": SVG wrapper is not a frame.`) - } - const asset = resolvedSvgAsset( - state.media.assets, - spec.figma.svg.assetKey, - spec.figma.svg.color - ) - const owned = node.children.filter(isOwnedSvgChild) - const unexpected = node.children.filter((child) => !isOwnedSvgChild(child)) - if ( - !asset || - owned.length !== 1 || - unexpected.length || - node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_DIGEST_NAME) !== asset.digest || - node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_POLICY_NAME) !== - SVG_POLICY_VERSION || - node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_COLOR_NAME) !== - (spec.figma.svg.color?.toUpperCase() ?? '') - ) { - applyError(`Verification failed for "${spec.key}": SVG import state does not match.`) - } - const child = owned[0]! - if ( - child.x < -0.01 || - child.y < -0.01 || - child.x + child.width > node.width + 0.01 || - child.y + child.height > node.height + 0.01 - ) { - applyError(`Verification failed for "${spec.key}": SVG is outside its wrapper.`) - } - references += 1 - } - - const childSpecs = spec.children ?? [] - const childNodes = childSpecs.map((child) => { - const candidate = state.keyedNodes.get(child.key) ?? null - if (!isSupportedSceneNode(candidate)) { - applyError(`Verification failed for "${child.key}": desired child is missing.`) - } - return candidate - }) - if (childNodes.length) { - if (!('children' in node)) { - applyError(`Verification failed for "${spec.key}": desired children have no container.`) - } - let previous = -1 - for (const child of childNodes) { - const index = node.children.findIndex((candidate) => candidate.id === child.id) - if (index <= previous) { - applyError(`Verification failed for "${spec.key}": desired child order does not match.`) - } - previous = index - } - } - let nodes = 1 - for (const [index, childSpec] of childSpecs.entries()) { - const verified = await verifyAppliedNode(childSpec, childNodes[index]!, state, node) - nodes += verified.nodes - references += verified.references - nativeFields += verified.nativeFields - } - return { nodes, references, nativeFields } -} - -async function verifyRollbackProtectedNodes(state: ApplyState): Promise { - const protectedEntries = [...state.protectedNodes] - const protectedIds = protectedEntries.map(([id]) => id) - const nodes = await Promise.all( - protectedEntries.map(([id, snapshot]) => - snapshot?.type === 'PAGE' ? (pageById(id) ?? null) : lookupNodeById(id) - ) - ) - const missing = protectedIds.filter((_, index) => !nodes[index]) - if (missing.length) { - const shown = missing.slice(0, 8) - const suffix = missing.length > shown.length ? ` and ${missing.length - shown.length} more` : '' - throw new Error( - `Rollback did not preserve pre-existing node${missing.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` - ) - } - - const changed = protectedEntries - .filter(([, expected], index) => { - const node = nodes[index] - return expected && node && !nativeValueEqual(snapshotProtectedNode(node), expected) - }) - .map(([id]) => id) - if (!changed.length) return - const shown = changed.slice(0, 8) - const suffix = changed.length > shown.length ? ` and ${changed.length - shown.length} more` : '' - throw new Error( - `Rollback changed pre-existing node${changed.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` - ) -} - -async function removeRollbackCreatedNodes(state: ApplyState): Promise { - const created = ( - await Promise.all([...state.createdNodeIds].map((id) => lookupNodeById(id))) - ).filter(isSupportedSceneNode) - if (!created.length) return - - for (const node of outermostNodes(created)) node.remove() - - const remaining = ( - await Promise.all([...state.createdNodeIds].map((id) => lookupNodeById(id))) - ).filter(isSupportedSceneNode) - if (!remaining.length) return - const shown = remaining.slice(0, 8).map((node) => node.id) - const suffix = - remaining.length > shown.length ? ` and ${remaining.length - shown.length} more` : '' - throw new Error( - `Rollback did not remove created node${remaining.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` - ) -} - -async function removeRollbackCreatedPages( - state: ApplyState, - previousPage: PageNode -): Promise { - const created = figma.root.children.filter((page) => state.createdPageIds.has(page.id)) - if (state.createdPageIds.has(figma.currentPage.id) && !previousPage.removed) { - await figma.setCurrentPageAsync(previousPage) - } - if (!created.length) return - for (const page of created) { - if (!page.removed) page.remove() - } - const remaining = figma.root.children.filter((page) => state.createdPageIds.has(page.id)) - if (remaining.length) { - throw new Error(`Rollback did not remove created page ${remaining[0]!.id}`) - } -} - -async function withUndoBoundary(apply: () => Promise, state: ApplyState): Promise { - const previousPage = figma.currentPage - try { - if (state.scope) { - for (const node of walkAuthoringNodes([state.scope])) protectNode(state, node) - protectUnrelatedPageRoots(state, containingPage(state.scope)) - } - figma.commitUndo() - const result = await apply() - figma.commitUndo() - return result - } catch (error) { - const readOnly = canvasReadOnlyError(error) - if (state.mutations.count > 0) { - try { - // Make this partial attempt the newest history entry before undoing. In a long-lived - // plugin session, the preceding entry may be an earlier successful apply. - figma.commitUndo() - figma.triggerUndo() - // Figma's undo boundary is authoritative for existing content and resources. Remove any - // newly created nodes that remain visible after the undo so a failed apply cannot leave - // partial component or screen roots behind in a long-lived MCP session. - await removeRollbackCreatedNodes(state) - await removeRollbackCreatedPages(state, previousPage) - await verifyRollbackProtectedNodes(state) - } catch (rollbackError) { - const rollbackMessage = errorMessage(rollbackError) - const detail = rollbackMessage ? ` ${rollbackMessage}.` : '' - const failure = createCodedError( - TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, - `Canvas apply failed: ${errorMessage(error, 'Unknown apply error')}; automatic rollback was not available.${detail} Stop writing and ask the user to inspect the canvas or use Figma Undo.` - ) - failure.cause = error - throw failure - } - } - if (readOnly) throw readOnly - throw error - } -} - -async function resolveExactPage(properties: CanvasPageProperties): Promise { - const explicit = properties.id ? pageById(properties.id) : undefined - if (properties.id && !explicit) specError(`Page "${properties.id}" does not exist.`) - const keyed = properties.pageKey ? pageByKey(properties.pageKey) : undefined - if (properties.pageKey && !keyed) { - specError(`Page key "${properties.pageKey}" does not identify a local page.`) - } - if (explicit && keyed && explicit.id !== keyed.id) { - specError(`Page key "${properties.pageKey}" does not identify "${explicit.id}".`) - } - const page = explicit ?? keyed - if (!page) specError('An exact page id or pageKey is required.') - if (page.id !== figma.currentPage.id) await page.loadAsync() - return page -} - -async function resolvePageSelection(page: PageNode, ids: string[]): Promise { - const nodes: SceneNode[] = [] - const seen = new Set() - for (const id of ids) { - if (seen.has(id)) specError(`Selection node "${id}" is duplicated.`) - seen.add(id) - const node = await lookupNodeById(id) - if (!isSceneNode(node) || node.removed || !node.visible) { - scopeError(`Selection node "${id}" does not exist, is hidden, or is not a scene node.`) - } - if (containingPage(node).id !== page.id) { - scopeError(`Selection node "${id}" does not belong to page "${page.id}".`) - } - nodes.push(node) - } - return nodes -} - -function collectPageRemovalRoots(page: PageNode, state: ApplyState): SupportedCanvasNode[] { - const roots: SupportedCanvasNode[] = [] - for (const child of page.children) { - if (!isSupportedSceneNode(child)) { - scopeError(`Page "${page.id}" contains an unsupported canvas root.`) - } - roots.push(child) - for (const node of walkRemovalOwnershipNodes([child], state)) { - if (!isSupportedSceneNode(node)) { - scopeError(`Page "${page.id}" contains an unsupported canvas node.`) - } - const key = readOwnedNodeKey(node) - if (!key) { - scopeError(`Page "${page.id}" contains content not owned by apply_canvas.`) - } - const existing = state.keyedNodes.get(key) - if (existing && existing.id !== node.id) { - scopeError(`Canvas key "${key}" is duplicated on page "${page.id}".`) - } - state.keyedNodes.set(key, node) - } - } - for (const root of roots) { - validateRemovalAncestors(root) - validateRemovalOwnership(root, state) - } - return roots -} - -async function createPageOnly(input: ParsedCanvasPageInput): Promise { - if (pageByKey(input.page.pageKey!)) { - specError(`Page key "${input.page.pageKey}" already identifies a local page.`) - } - const state = createApplyState(null, new Set()) - const preparedPage = await prepareResultPage(input.page, null) - if (preparedPage) specError('Page-only create requires a new pageKey.') - await preflightVariableModes(input.page.variableModes, state) - return withUndoBoundary(async () => { - const page = applyResultPage(preparedPage, input.page, state) - await applyPage(page, input.page, state) - page.selection = [] - if (page.selection.length !== 0) { - applyError(`Verification failed: page "${page.id}" does not have an empty selection.`) - } - return pageApplyResult(pageSnapshot(page), state) - }, state) -} - -async function updatePageOnly(input: ParsedCanvasPageInput): Promise { - const page = await resolveExactPage(input.page) - const state = createApplyState(null, new Set()) - await preflightVariableModes(input.page.variableModes, state) - await preloadFirstPageModeFonts(page, input.page.variableModes, state) - return withUndoBoundary(async () => { - protectUnrelatedPageRoots(state, page) - await applyPage(page, input.page, state) - return pageApplyResult(pageSnapshot(page), state) - }, state) -} - -async function activatePage(input: ParsedCanvasPageInput): Promise { - const page = await resolveExactPage(input.page) - const selection = - input.selection === undefined ? undefined : await resolvePageSelection(page, input.selection) - await figma.setCurrentPageAsync(page) - if (selection) page.selection = selection - if (figma.currentPage.id !== page.id) { - applyError(`Verification failed: page "${page.id}" is not active.`) - } - if ( - selection && - (page.selection.length !== selection.length || - page.selection.some((node, index) => node.id !== selection[index]?.id)) - ) { - applyError(`Verification failed: page "${page.id}" selection does not match.`) - } - return pageApplyResult(pageSnapshot(page), createApplyState(null, new Set())) -} - -async function removePage(input: ParsedCanvasPageInput): Promise { - const page = await resolveExactPage(input.page) - const ownedKey = page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) - if (!ownedKey || ownedKey !== input.page.pageKey) { - scopeError(`Page "${page.id}" is not owned by pageKey "${input.page.pageKey}".`) - } - if (figma.root.children.length <= 1) { - scopeError('The last Figma page cannot be removed.') - } - const state = createApplyState(null, new Set()) - const roots = collectPageRemovalRoots(page, state) - await validateRemovalComponents(roots) - await validateRemovalReferences(roots, state) - const previousPage = figma.currentPage - const fallback = figma.root.children.find((candidate) => candidate.id !== page.id)! - const snapshot = pageSnapshot(page, { active: false, removed: true, selectionCount: 0 }) - const removedNodeIds = roots.flatMap((root) => - [...walkPhysicalNodes([root])].map((node) => node.id) - ) - - try { - return await withUndoBoundary(async () => { - protectNode(state, page) - if (figma.currentPage.id === page.id) await figma.setCurrentPageAsync(fallback) - page.remove() - state.mutations.count += 1 - if (figma.root.children.some((candidate) => candidate.id === page.id)) { - applyError(`Verification failed: page "${page.id}" is still present.`) - } - return boundedApplyResult({ - nodeIdsByKey: {}, - createdNodeIds: [], - updatedNodeIds: [], - removedNodeIds, - page: snapshot, - mutationCount: state.mutations.count, - verification: buildVerification() - }) - }, state) - } catch (error) { - if (!previousPage.removed && figma.currentPage.id !== previousPage.id) { - try { - await figma.setCurrentPageAsync(previousPage) - } catch { - // Preserve the apply error; rollback state remains authoritative. - } - } - throw error - } -} - -async function reconcilePageOperation(input: ParsedCanvasPageInput): Promise { - if (input.mode === 'create') return createPageOnly(input) - if (input.mode === 'update') return updatePageOnly(input) - if (input.mode === 'remove') return removePage(input) - return activatePage(input) -} - -async function removeUpdateRoot(targetNodeId: string): Promise { - const candidate = await lookupNodeById(targetNodeId) - if (!candidate) return removedRootResult(targetNodeId) - if (!isSupportedSceneNode(candidate)) { - scopeError('The requested removal target is not a supported scene node.') - } - assertOutsideInstance(candidate) - - const state = createApplyState(candidate, new Set()) - validateRemovalAncestors(candidate) - validateRemovalOwnership(candidate, state) - state.removalNodeIds.add(candidate.id) - const parent = candidate.parent - await loadRemovalPages([candidate]) - await validateRemovalComponents([candidate]) - validateRemovalResult([candidate], state) - await validateRemovalReferences([candidate], state) - - return withUndoBoundary(async () => { - removeValidatedNodes([candidate], state) - const removedNodeIds = [candidate.id] - if ( - !candidate.removed && - (!parent || - !('children' in parent) || - parent.children.some((child) => child.id === candidate.id)) - ) { - applyError(`Verification failed: root "${candidate.id}" is still present.`) - } - return boundedApplyResult(removedRootResult(candidate.id, removedNodeIds, state)) - }, state) -} - -function preservedSizingMode(value: unknown): CanvasSizingMode { - return value === 'FILL' || value === 'HUG' ? value : 'FIXED' -} - -function validateNativeUpdateBinding( - key: string, - binding: CanvasBinding, - node: SupportedCanvasNode -): void { - const properties = binding.figma - const declaredTypes: Array = [ - binding.component || binding.componentProperties || properties?.instance - ? 'INSTANCE' - : undefined, - properties?.text ? 'TEXT' : undefined, - properties?.shape?.type, - properties?.section ? 'SECTION' : undefined, - properties?.group ? 'GROUP' : undefined, - properties?.booleanOperation ? 'BOOLEAN_OPERATION' : undefined, - properties?.component?.type, - properties?.slot ? 'SLOT' : undefined, - properties?.svg ? 'FRAME' : undefined - ] - for (const type of declaredTypes) { - if (type !== undefined && type !== node.type) { - specError(`Native state on "${key}" requires ${type}, but the existing node is ${node.type}.`) - } - } - const requiredFields: Array<[unknown, string]> = [ - [properties?.stroke, 'strokeAlign'], - [properties?.stroke?.weights, 'strokeTopWeight'], - [properties?.stroke?.cap, 'strokeCap'], - [properties?.stroke?.miterLimit, 'strokeMiterLimit'], - [properties?.corners, 'cornerRadius'], - [properties?.corners?.radii, 'topLeftRadius'], - [properties?.corners?.smoothing, 'cornerSmoothing'], - [properties?.layoutGrids, 'layoutGrids'], - [properties?.guides, 'guides'], - [properties?.aspectRatioLocked, 'targetAspectRatio'] - ] - for (const [value, field] of requiredFields) { - if (value !== undefined && !(field in node)) { - specError(`Native field "${field}" is not supported on ${node.type} node "${key}".`) - } - } - if (properties?.text?.fontName) { - if (binding.variables?.fontFamily || binding.variables?.fontStyle) { - specError(`Font on "${key}" cannot use both variables and an exact Figma font name.`) - } - if (binding.styles?.text) { - specError(`Font on "${key}" cannot use both a Text style and an exact Figma font name.`) - } - } -} - -function nativeUpdateLayout( - key: string, - binding: CanvasBinding, - node: SupportedCanvasNode -): CanvasNodeSpec['layout'] { - const autoLayout = binding.figma?.autoLayout - if (!autoLayout) return - - if ( - !isFrameContainer(node) || - (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') - ) { - specError( - `Figma Auto Layout properties on "${key}" require an existing linear Auto Layout container.` - ) - } - if (autoLayout.counterAxisSpacing !== undefined && node.layoutWrap !== 'WRAP') { - specError( - `Figma counter-axis spacing on "${key}" requires an existing wrapping Auto Layout container.` - ) - } - if (autoLayout.counterAxisSpacing === null && binding.variables?.counterAxisSpacing) { - specError( - `Synchronized counter-axis spacing and a counter-axis variable cannot be combined on "${key}".` - ) - } - return { mode: node.layoutMode } -} - -function nativeUpdateSpec( - key: string, - binding: CanvasBinding, - node: SupportedCanvasNode -): CanvasNodeSpec { - validateNativeUpdateBinding(key, binding, node) - const layout = nativeUpdateLayout(key, binding, node) - const stroke = binding.figma?.stroke - const corners = binding.figma?.corners - const size: CanvasNodeSpec['size'] = { - ...(node.type === 'INSTANCE' - ? {} - : { - width: node.width, - height: node.height, - horizontal: preservedSizingMode( - 'layoutSizingHorizontal' in node ? node.layoutSizingHorizontal : undefined - ), - vertical: preservedSizingMode( - 'layoutSizingVertical' in node ? node.layoutSizingVertical : undefined - ) - }), - ...('minWidth' in node ? { minWidth: node.minWidth } : {}), - ...('maxWidth' in node ? { maxWidth: node.maxWidth } : {}), - ...('minHeight' in node ? { minHeight: node.minHeight } : {}), - ...('maxHeight' in node ? { maxHeight: node.maxHeight } : {}) - } - return { - key, - type: node.type, - ...(binding.figma?.name !== undefined ? { displayName: binding.figma.name } : {}), - size, - ...(layout ? { layout } : {}), - ...(stroke || corners - ? { - appearance: { - strokeWeight: stroke?.weight, - strokeTopWeight: stroke?.weights?.top, - strokeRightWeight: stroke?.weights?.right, - strokeBottomWeight: stroke?.weights?.bottom, - strokeLeftWeight: stroke?.weights?.left, - cornerRadius: corners?.radius, - topLeftRadius: corners?.radii?.topLeft, - topRightRadius: corners?.radii?.topRight, - bottomRightRadius: corners?.radii?.bottomRight, - bottomLeftRadius: corners?.radii?.bottomLeft - } - } - : {}), - ...(node.type !== 'INSTANCE' && 'layoutGrow' in node ? { grow: node.layoutGrow > 0 } : {}), - ...(node.type === 'TEXT' - ? { - text: { - characters: node.characters, - autoResize: node.textAutoResize, - ...(binding.figma?.text?.fontName - ? { - fontFamily: binding.figma.text.fontName.family, - fontStyle: binding.figma.text.fontName.style - } - : {}), - ...(binding.figma?.text?.verticalAlign !== undefined - ? { alignVertical: binding.figma.text.verticalAlign } - : {}) - } - } - : {}), - ...binding - } -} - -function nativeUpdateParent(node: SupportedCanvasNode): CanvasParentNode | undefined { - const parent = node.parent - if (!parent || parent.type === 'DOCUMENT' || parent.type === 'INSTANCE') return undefined - return parent as CanvasParentNode -} - -type ResourceInput = Pick - -function hasDeclaredResources(input: ResourceInput): boolean { - return ( - Object.keys(input.variableCollections ?? {}).length > 0 || - Object.keys(input.styles ?? {}).length > 0 - ) -} - -async function preflightDeclaredResources( - specs: Iterable, - input: ResourceInput, - state: ApplyState -): Promise { - const nodes = [...specs] - const specsByKey = new Map(nodes.map((spec) => [spec.key, spec])) - const componentContexts = new Map() - await preflightStyleDeclarations(input.styles, state.styles) - for (const { spec, style } of state.styles.selections ?? []) { - if (spec !== null && style?.type === 'TEXT') await loadFont(style.fontName, state.fonts) - } - const declaredVariables = await preflightVariableDeclarations( - input.variableCollections, - state.variables - ) - async function knownVariable(reference: CanvasVariableReference) { - // Key-only library references can import resources; keep those in the full preflight. - if (!('variableKey' in reference) && reference.id === undefined) return - return ( - ('variableKey' in reference ? declaredVariables.get(reference.variableKey) : undefined) ?? - (await resolveVariable(reference, state.variables)) - ) - } - const componentResources: ComponentPreflightResources = { - variable: knownVariable, - component: (reference) => - reference.id !== undefined ? resolveComponent(reference, state) : Promise.resolve(undefined) - } - async function preflightDeclaredPaints( - paints: CanvasFigmaPaint[] | undefined, - field: string, - key: string - ) { - for (const [index, paint] of (paints ?? []).entries()) { - for (const reference of paintColorVariables(paint)) { - const variable = await knownVariable(reference) - if (variable) - validateVariableDataType(variable, 'COLOR', `${field} paint ${index} on "${key}"`) - } - await preflightPaintSource(paint, field, index, key, state) - } - } - async function preflightEffectVariables(effects: CanvasFigmaEffect[] | undefined, key: string) { - for (const [index, effect] of (effects ?? []).entries()) { - if (!('variables' in effect)) continue - for (const [field, reference] of Object.entries(effect.variables ?? {})) { - const variable = await knownVariable(reference) - if (variable) - validateVariableDataType( - variable, - field === 'color' ? 'COLOR' : 'FLOAT', - `effect ${index} ${field} on "${key}"` - ) - } - } - } - async function preflightGridVariables(grids: CanvasFigmaLayoutGrid[] | undefined, key: string) { - for (const [index, grid] of (grids ?? []).entries()) { - for (const [field, reference] of Object.entries(grid.variables ?? {})) { - const variable = await knownVariable(reference) - if (variable) - validateVariableDataType(variable, 'FLOAT', `layout grid ${index} ${field} on "${key}"`) - } - } - } - async function preflightBindings( - key: string, - variables: CanvasVariableBindings | undefined, - styles: CanvasStyleBindings | undefined - ): Promise { - for (const [field, reference] of Object.entries(variables ?? {}) as Array< - [keyof CanvasVariableBindings, CanvasVariableReference | null] - >) { - if (!reference) continue - const variable = await knownVariable(reference) - if (variable) validateVariableType(field, variable, key) - } - for (const [field, reference] of Object.entries(styles ?? {}) as Array< - [keyof CanvasStyleBindings, CanvasStyleReference | null] - >) { - if (!reference) continue - const declared = 'styleKey' in reference ? input.styles?.[reference.styleKey] : undefined - if (declared) { - if (declared.type !== STYLE_TYPES[field]) { - specError( - `Declared style for ${field} on "${key}" has incompatible type ${declared.type}.` - ) - } - continue - } - if (!('styleKey' in reference) && reference.id === undefined) continue - const style = await resolveStyle(reference, state.styles) - validateStyleType(field, style, key) - if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) - } - } - for (const [key, style] of Object.entries(input.styles ?? {})) { - if (style?.type === 'TEXT') { - if (style.fontName) await loadFont(style.fontName, state.fonts) - await preflightBindings(key, style.variables, undefined) - } - if (style?.type === 'PAINT') { - preflightStylePatternSources(style.paints, key, state) - await preflightDeclaredPaints(style.paints, 'style', key) - } - if (style?.type === 'EFFECT') await preflightEffectVariables(style.effects, key) - if (style?.type === 'GRID') await preflightGridVariables(style.layoutGrids, key) - } - if (Object.keys(input.variableCollections ?? {}).length === 0) { - for (const { spec, style } of state.styles.selections ?? []) { - if (spec?.type !== 'TEXT' || style?.type !== 'TEXT') continue - const importsFontVariable = TEXT_STYLE_VARIABLES_BY_VALUE.fontName.some((field) => { - const reference = spec.variables?.[field] - return reference && !('variableKey' in reference) && reference.id === undefined - }) - if (importsFontVariable) continue - // Existing consumer modes are ready to read when no variable declarations - // or library imports can change their fonts. The full preflight rechecks them. - await loadTextStyleVariableFonts(style, spec, state) - } - } - for (const spec of nodes) { - preflightTextRangeBounds(spec, state) - const existing = findExistingNode(spec, state) - const owner = existing ? componentPropertyOwner(existing) : null - // Tree children inherit the desired context; native-only patches use their live owner. - const inherited = componentContexts.has(spec) - ? componentContexts.get(spec) - : owner - ? { existing: owner, spec: specsByKey.get(readOwnedNodeKey(owner) ?? '') } - : undefined - const component = await preflightComponentState( - spec, - state, - existing ?? undefined, - inherited, - componentResources - ) - for (const child of spec.children ?? []) componentContexts.set(child, component) - preflightShadowSpread(spec, existing ?? undefined) - if (existing?.type === 'TEXT') await loadFonts(currentTextFonts(existing), state.fonts) - await preflightBindings(spec.key, spec.variables, spec.styles) - const hasBoundFont = - spec.variables?.fontFamily || - spec.variables?.fontStyle || - (existing?.type === 'TEXT' && - (currentBoundVariableId(existing, 'fontFamily') || - currentBoundVariableId(existing, 'fontStyle'))) - // Same-call variable declarations can change the bound half of a literal font pair. - // Resolve that pair in the full preflight, after those values have been applied. - if (!hasBoundFont) await preflightTextFont(spec, state, existing ?? undefined) - await preflightHyperlink(spec.figma?.text?.hyperlink, spec.key, state) - await preflightDeclaredPaints(spec.figma?.fills, 'fill', spec.key) - await preflightDeclaredPaints(spec.figma?.strokes, 'stroke', spec.key) - await preflightEffectVariables(spec.figma?.effects, spec.key) - await preflightGridVariables(spec.figma?.layoutGrids, spec.key) - if (spec.figma?.shape?.type === 'VECTOR') { - for (const [index, region] of (spec.figma.shape.network?.regions ?? []).entries()) { - await preflightBindings(spec.key, undefined, { fill: region.fillStyle }) - await preflightDeclaredPaints(region.fills, `vector region ${index} fill`, spec.key) - } - } - for (const [index, range] of (spec.figma?.text?.ranges ?? []).entries()) { - if (range.fontName) await loadFont(range.fontName, state.fonts) - await preflightBindings(`${spec.key} text range ${index}`, range.variables, { - text: range.textStyle, - fill: range.fillStyle - }) - await preflightHyperlink(range.hyperlink, `${spec.key} text range ${index}`, state) - await preflightDeclaredPaints(range.fills, `text range ${index} fill`, spec.key) - if (range.textDecorationColor && range.textDecorationColor.value !== 'AUTO') { - await preflightDeclaredPaints( - [range.textDecorationColor.value], - `text range ${index} decoration`, - spec.key - ) - } - } - } -} - -async function prepareResources(input: ResourceInput, state: ApplyState): Promise { - await reconcileVariableCollections(input.variableCollections, state.variables, state.mutations) - await prepareStyleResources(input.styles, state.styles, state.mutations) - await preflightStyleResources(state) -} - -async function prepareMedia( - specs: Iterable, - input: ResourceInput, - state: ApplyState -): Promise { - for (const spec of specs) collectCanvasMedia(spec.figma, `node "${spec.key}"`, state.media) - collectCanvasMedia(input.styles, 'styles', state.media) - await importCanvasMedia(state.media) -} - -async function removeResources(state: ApplyState): Promise { - await removeStyleResources(state.styles, state.mutations) - await removeVariableResources(state.variables, state.mutations) -} - -function layoutWarnings(specs: CanvasNodeSpec[], state: ApplyState): VerificationWarning[] { - return [ - ...state.ignoredLiteralWarnings, - ...specs.flatMap((spec) => layoutAffectingVisibilityWarnings(spec, state)), - ...specs.flatMap((spec) => managedContentOverflowWarnings(spec, state)), - ...specs.flatMap((spec) => managedRoundedStrokeOcclusionWarnings(spec, state)), - ...specs.flatMap((spec) => managedAutoLayoutInsetWarnings(spec, state)) - ] -} - -async function reconcileNativeUpdate( - input: ParsedCanvasNativeUpdateInput -): Promise { - const candidate = await lookupNodeById(input.targetNodeId) - if (!isSupportedSceneNode(candidate)) { - scopeError('The requested update target does not exist or is not a supported scene node.') - } - assertOutsideInstance(candidate) - if (!readOwnedNodeKey(candidate)) { - scopeError('A markup-less native update requires an exact managed root.') - } - - const keyedNodes = collectKeyedNodes(candidate) - const specsByKey = new Map() - for (const [key, binding] of Object.entries(input.bindings)) { - const node = keyedNodes.get(key) - if (!node) scopeError(`Canvas key "${key}" does not exist inside the update scope.`) - assertOutsideInstance(node) - if (binding.figma?.mask !== undefined) { - specError( - `Mask state on "${key}" requires a structural markup update that describes its sibling scope.` - ) - } - specsByKey.set(key, nativeUpdateSpec(key, binding, node)) - } - // Apply owners before sublayers, regardless of the binding order. - const specs: CanvasNodeSpec[] = [] - for (const key of keyedNodes.keys()) { - const spec = specsByKey.get(key) - if (spec) specs.push(spec) - } - try { - validateAssetReferences(specs, input.assets, input.styles) - } catch (error) { - specError(errorMessage(error)) - } - const assets = await resolveCanvasAssets(input.assets, collectSvgColorsFromSpecs(specs)) - const state = createApplyState(candidate, new Set(Object.keys(input.bindings)), assets) - for (const spec of specs) { - const node = state.keyedNodes.get(spec.key)! - state.nodeIdsByKey[spec.key] = node.id - preflightVectorPaths(spec) - preflightContainers(spec, state, node) - } - - const preflight = async (): Promise => { - for (const spec of specs) { - const node = state.keyedNodes.get(spec.key)! - const owner = componentPropertyOwner(node) - const inherited = owner - ? { existing: owner, spec: specsByKey.get(readOwnedNodeKey(owner) ?? '') } - : undefined - await preflightResources(spec, state, node, inherited) - } - } - const declaresResources = hasDeclaredResources(input) - if (declaresResources) await preflightDeclaredResources(specs, input, state) - if (!declaresResources) await preflight() - await prepareMedia(specs, input, state) - return withUndoBoundary(async () => { - if (declaresResources) { - await prepareResources(input, state) - await preflight() - } - applyStyleResources(state) - for (const spec of specs) { - const node = state.keyedNodes.get(spec.key)! - await applyNodeProperties(node, spec, state, nativeUpdateParent(node), true) - } - for (const spec of specs) { - const node = state.keyedNodes.get(spec.key)! - await applyCanvasKeyReferences(spec, state, nativeUpdateParent(node)) - } - finalizeSvgPositions(specs, state) - await removeResources(state) - - const verified = { nodes: 0, references: 0, nativeFields: 0 } - for (const spec of specs) { - const node = state.keyedNodes.get(spec.key)! - const result = await verifyAppliedNode( - spec, - node, - state, - isSupportedSceneNode(node.parent) ? node.parent : undefined - ) - verified.nodes += result.nodes - verified.references += result.references - verified.nativeFields += result.nativeFields - } - const warnings = [ - ...unboundCreatedResourceWarnings([...specs, input.styles, input.variableCollections], state), - ...authoredVariableFallbackWarnings(specs, input.variableCollections), - ...layoutWarnings(specs, state) - ] - return boundedApplyResult({ - rootNodeId: candidate.id, - nodeIdsByKey: state.nodeIdsByKey, - createdNodeIds: [], - updatedNodeIds: [...state.updatedNodeIds], - removedNodeIds: [], - page: pageSnapshot(containingPage(candidate)), - mutationCount: state.mutations.count, - verification: buildVerification( - verified.nodes, - verified.references, - verified.nativeFields, - warnings - ) - }) - }, state) -} + unboundCreatedResourceWarnings, + authoredVariableFallbackWarnings, + layoutWarnings +} from './reconcile/warnings' +export { collectUpdateNodeTypeHints } from './reconcile/nodes' export async function reconcileCanvas(input: ParsedCanvasInput): Promise { if ('bindings' in input) return reconcileNativeUpdate(input) @@ -8272,6 +146,7 @@ export async function reconcileCanvas(input: ParsedCanvasInput): Promise + +const CORNER_RADIUS_FIELDS = [ + 'topLeftRadius', + 'topRightRadius', + 'bottomRightRadius', + 'bottomLeftRadius' +] as const satisfies ReadonlyArray + +function hasDesiredVariable( + spec: CanvasNodeSpec, + fields: ReadonlyArray +): boolean { + return fields.some((field) => spec.variables?.[field] !== undefined) +} + +function hasCurrentVariable( + node: SupportedCanvasNode, + fields: ReadonlyArray +): boolean { + return fields.some((field) => currentBoundVariableId(node, field) !== undefined) +} + +function applyIndividualValue( + node: SupportedCanvasNode, + current: number, + desired: number | undefined, + field: VariableBindableNodeField, + uniformField: VariableBindableNodeField, + spec: CanvasNodeSpec, + apply: (value: number) => void, + state: ApplyState +): void { + if (spec.variables?.[uniformField as keyof CanvasVariableBindings]) return + if (currentBoundVariableId(node, uniformField)) return + if (spec.variables?.[field as keyof CanvasVariableBindings]) return + if (currentBoundVariableId(node, field)) return + setValue(node, current, desired, apply, state) +} + +export function applyAppearance( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const appearance = spec.appearance + if (!appearance) return + + if ('fills' in node) { + applyPaint(node, spec, 'fill', state) + applyPaint(node, spec, 'stroke', state) + } + if ('strokeWeight' in node) { + setValue( + node, + node.strokeWeight, + appearance.strokeTopWeight !== undefined || + spec.variables?.strokeWeight || + hasDesiredVariable(spec, STROKE_WEIGHT_FIELDS) || + currentBoundVariableId(node, 'strokeWeight') || + hasCurrentVariable(node, STROKE_WEIGHT_FIELDS) + ? undefined + : appearance.strokeWeight, + (value) => (node.strokeWeight = value), + state + ) + if ('strokeTopWeight' in node) { + for (const field of STROKE_WEIGHT_FIELDS) { + applyIndividualValue( + node, + node[field], + appearance[field], + field, + 'strokeWeight', + spec, + (value) => (node[field] = value), + state + ) + } + } + } + if ('cornerRadius' in node) { + setValue( + node, + node.cornerRadius, + appearance.topLeftRadius !== undefined || + spec.variables?.cornerRadius || + hasDesiredVariable(spec, CORNER_RADIUS_FIELDS) || + currentBoundVariableId(node, 'cornerRadius') || + hasCurrentVariable(node, CORNER_RADIUS_FIELDS) + ? undefined + : appearance.cornerRadius, + (value) => (node.cornerRadius = value), + state + ) + if ('topLeftRadius' in node) { + for (const field of CORNER_RADIUS_FIELDS) { + applyIndividualValue( + node, + node[field], + appearance[field], + field, + 'cornerRadius', + spec, + (value) => (node[field] = value), + state + ) + } + } + } + if ('clipsContent' in node) { + setValue( + node, + node.clipsContent, + appearance.clipsContent, + (value) => (node.clipsContent = value), + state + ) + } + if ('opacity' in node) { + setValue( + node, + node.opacity, + spec.variables?.opacity || currentBoundVariableId(node, 'opacity') + ? undefined + : appearance.opacity, + (value) => (node.opacity = value), + state + ) + } + + const stroke = spec.figma?.stroke + if (stroke) { + if (!('strokeAlign' in node)) { + specError(`Stroke geometry is not supported on ${node.type} node "${spec.key}".`) + } + setValue(node, node.strokeAlign, stroke.align, (value) => (node.strokeAlign = value), state) + if ('strokeCap' in node) { + setValue(node, node.strokeCap, stroke.cap, (value) => (node.strokeCap = value), state) + } + setValue(node, node.strokeJoin, stroke.join, (value) => (node.strokeJoin = value), state) + if ('strokeMiterLimit' in node) { + setValue( + node, + node.strokeMiterLimit, + stroke.miterLimit, + (value) => (node.strokeMiterLimit = value), + state + ) + } + if ( + stroke.dashPattern !== undefined && + !nativeValueEqual(node.dashPattern, stroke.dashPattern, scalarValuesEqual) + ) { + node.dashPattern = stroke.dashPattern + markMutation(state, node) + } + } + if ('cornerSmoothing' in node) { + setValue( + node, + node.cornerSmoothing, + spec.figma?.corners?.smoothing, + (value) => (node.cornerSmoothing = value), + state + ) + } +} + +export function applyPaintStacks( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + for (const [property, styleProperty] of [ + ['fills', 'fillStyleId'], + ['strokes', 'strokeStyleId'] + ] as const) { + const paints = spec.figma?.[property] + if (paints === undefined) continue + if (!('fills' in node)) { + specError(`Direct paints are not supported on ${node.type} node "${spec.key}".`) + } + const desired = paints.map((paint) => nativePaint(paint, state)) + const current = node[property] + const ranges = + node.type === 'TEXT' && node.characters === spec.text?.characters + ? spec.figma?.text?.ranges + : undefined + if ( + node.type === 'TEXT' && property === 'fills' + ? textFillsMatch(node, desired, ranges) + : current !== figma.mixed && !node[styleProperty] && paintStacksEqual(current, desired) + ) { + continue + } + node[property] = desired + markMutation(state, node) + } +} + +function hasShadowSpread(spec: CanvasNodeSpec): boolean { + return ( + spec.figma?.effects?.some( + (effect) => + (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') && + (effect.spread !== undefined || effect.variables?.spread !== undefined) + ) ?? false + ) +} + +function shadowSpreadError(spec: CanvasNodeSpec): never { + return specError( + `Shadow spread on "${spec.key}" requires a rectangle, ellipse, or a clipped frame/instance with a visible fill; authored components and component sets count as frames.` + ) +} + +export function preflightShadowSpread(spec: CanvasNodeSpec, existing?: SupportedCanvasNode): void { + if (!hasShadowSpread(spec) || spec.type === 'RECTANGLE' || spec.type === 'ELLIPSE') return + // Instance swaps and deferred fills need the final native state. Frame clipping, + // however, is known before resources are created, including normalized defaults. + if (spec.type !== 'FRAME' && spec.type !== 'COMPONENT') return + const clipsContent = + spec.appearance?.clipsContent ?? + (existing && 'clipsContent' in existing ? existing.clipsContent : false) + if (!clipsContent) shadowSpreadError(spec) +} + +function validateShadowSpread(node: SupportedCanvasNode, spec: CanvasNodeSpec): void { + if (!hasShadowSpread(spec) || node.type === 'RECTANGLE' || node.type === 'ELLIPSE') return + const authoredFills = spec.figma?.fills + const hasLiveVisibleFill = + 'fills' in node && + node.fills !== figma.mixed && + node.fills.some((paint) => paint.visible ?? true) + const hasVisibleFill = + authoredFills === undefined + ? hasLiveVisibleFill + : authoredFills.some((paint) => paint.visible ?? true) + if ((isFrameContainer(node) || node.type === 'INSTANCE') && node.clipsContent && hasVisibleFill) { + return + } + shadowSpreadError(spec) +} + +export function applyEffects( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const effects = spec.figma?.effects + if (effects === undefined) return + if (!('effects' in node)) { + specError(`Effects are not supported on ${node.type} node "${spec.key}".`) + } + validateShadowSpread(node, spec) + const desired = effects.map((effect) => nativeEffect(effect, state)) + if (!node.effectStyleId && effectsEqual(node.effects, desired)) return + node.effects = desired + markMutation(state, node) +} + +export function applyLayoutAids( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + if (!isFrameContainer(node) && node.type !== 'INSTANCE') return + const layoutGrids = spec.figma?.layoutGrids + if (layoutGrids !== undefined) { + const desired = layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) + if (node.gridStyleId || !layoutGridsEqual(node.layoutGrids, desired)) { + node.layoutGrids = desired + markMutation(state, node) + } + } + applyGuides(node, spec.figma?.guides, state) +} + +export function applyGuides( + node: CanvasFrameContainerNode | InstanceNode | PageNode, + guides: CanvasPageProperties['guides'], + state: ApplyState +): void { + if (guides === undefined || nativeValueEqual(node.guides, guides, scalarValuesEqual)) return + node.guides = guides + markMutation(state, node) +} + +async function nativeVectorNetwork( + network: CanvasFigmaVectorNetwork, + state: ApplyState +): Promise { + const { regions, ...geometry } = network + if (!regions) return geometry + + return { + ...geometry, + regions: await Promise.all( + regions.map(async ({ fills, fillStyle, ...region }) => ({ + ...region, + ...(fills === undefined ? {} : { fills: fills.map((paint) => nativePaint(paint, state)) }), + ...(fillStyle ? { fillStyleId: (await resolveStyle(fillStyle, state.styles)).id } : {}) + })) + ) + } +} + +function comparableVectorNetwork(network: VectorNetwork, expected: VectorNetwork): unknown { + return { + vertices: network.vertices, + segments: network.segments.map((segment) => ({ + ...segment, + tangentStart: segment.tangentStart ?? { x: 0, y: 0 }, + tangentEnd: segment.tangentEnd ?? { x: 0, y: 0 } + })), + regions: (network.regions ?? []).map(({ fills, fillStyleId, ...region }, regionIndex) => { + const expectedFills = expected.regions?.[regionIndex]?.fills + return { + ...region, + ...(fillStyleId + ? { fillStyleId } + : fills === undefined + ? {} + : { + fills: fills.map((paint, paintIndex) => + comparablePaint(paint, expectedFills?.[paintIndex] ?? paint) + ) + }) + } + }) + } +} + +function vectorNetworksEqual(current: VectorNetwork, desired: VectorNetwork): boolean { + return nativeValueEqual( + comparableVectorNetwork(current, desired), + comparableVectorNetwork(desired, desired), + scalarValuesEqual + ) +} + +export async function applyShape( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState, + resolveCanvasReferences = false +): Promise { + const shape = spec.figma?.shape + if (!shape) return + + switch (shape.type) { + case 'RECTANGLE': + case 'LINE': + return + case 'ELLIPSE': { + if (!shape.arc) return + if (node.type !== 'ELLIPSE') specError(`Native shape "${spec.key}" is not an ellipse.`) + const desired = { + startingAngle: (shape.arc.startAngle * Math.PI) / 180, + endingAngle: (shape.arc.endAngle * Math.PI) / 180, + innerRadius: shape.arc.innerRadius + } + const current = node.arcData + if ( + Math.abs(current.startingAngle - desired.startingAngle) <= 1e-6 && + Math.abs(current.endingAngle - desired.endingAngle) <= 1e-6 && + Math.abs(current.innerRadius - desired.innerRadius) <= 1e-6 + ) { + return + } + node.arcData = desired + markMutation(state, node) + return + } + case 'POLYGON': + if (shape.pointCount === undefined) return + if (node.type !== 'POLYGON') specError(`Native shape "${spec.key}" is not a polygon.`) + setValue(node, node.pointCount, shape.pointCount, (value) => (node.pointCount = value), state) + return + case 'STAR': + if (node.type !== 'STAR') specError(`Native shape "${spec.key}" is not a star.`) + setValue(node, node.pointCount, shape.pointCount, (value) => (node.pointCount = value), state) + setValue( + node, + node.innerRadius, + shape.innerRadius, + (value) => (node.innerRadius = value), + state + ) + return + case 'VECTOR': { + if (node.type !== 'VECTOR') specError(`Native shape "${spec.key}" is not a vector.`) + setValue( + node, + node.handleMirroring, + shape.handleMirroring, + (value) => (node.handleMirroring = value), + state + ) + if (shape.paths !== undefined) { + if (vectorPathsEqual(node.vectorPaths, shape.paths)) return + node.vectorPaths = canonicalVectorPaths(shape.paths) + markMutation(state, node) + return + } + if (shape.network !== undefined) { + if (!resolveCanvasReferences && hasCanvasKeyVectorPattern(spec)) return + const network = await nativeVectorNetwork(shape.network, state) + if (vectorNetworksEqual(node.vectorNetwork, network)) return + await node.setVectorNetworkAsync(network) + markMutation(state, node) + } + } + } +} + +export function applySharedLayerState( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + setValue( + node, + node.visible, + preservesComponentPropertyReference(node, spec, 'visible') || + spec.variables?.visible || + currentBoundVariableId(node, 'visible') + ? undefined + : spec.visible, + (value) => (node.visible = value), + state + ) + if ('blendMode' in node) { + setValue(node, node.blendMode, spec.blendMode, (value) => (node.blendMode = value), state) + } + if ( + 'rotation' in node && + spec.rotation !== undefined && + !rotationsMatch(node.rotation, spec.rotation) + ) { + node.rotation = spec.rotation + markMutation(state, node) + } + const aspectRatioLocked = spec.figma?.aspectRatioLocked + if (aspectRatioLocked !== undefined) { + if (!('targetAspectRatio' in node)) { + specError(`Aspect-ratio locking is not supported on ${node.type} node "${spec.key}".`) + } + if ((node.targetAspectRatio !== null) !== aspectRatioLocked) { + if (aspectRatioLocked) node.lockAspectRatio() + else node.unlockAspectRatio() + markMutation(state, node) + } + } + setValue(node, node.locked, spec.figma?.locked, (value) => (node.locked = value), state) +} + +export function applyMask( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const mask = spec.figma?.mask + if (mask === undefined) return + if (!('isMask' in node)) { + specError(`Masks are not supported on ${node.type} node "${spec.key}".`) + } + if (mask !== null) { + setValue(node, node.maskType, mask, (value) => (node.maskType = value), state) + } + setValue(node, node.isMask, mask !== null, (value) => (node.isMask = value), state) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/binding-values.ts b/packages/extension/mcp/tools/canvas/reconcile/binding-values.ts new file mode 100644 index 00000000..e100028e --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/binding-values.ts @@ -0,0 +1,312 @@ +import type { + CanvasFigmaTextRange, + CanvasStyleBindings, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' +import type { SupportedCanvasNode } from './state' + +import { specError } from '../errors' +import { isFrameContainer } from './nodes' +import { + textParagraphRanges, + textMetricRange, + textRangesMatchOutside, + textRangesOutside, + type TextRange +} from './text-ranges' + +export const STYLE_TYPES = { + fill: 'PAINT', + stroke: 'PAINT', + text: 'TEXT', + effect: 'EFFECT', + grid: 'GRID' +} satisfies Record + +export function validateStyleType( + field: keyof CanvasStyleBindings, + style: BaseStyle, + key: string +): void { + const expected = STYLE_TYPES[field] + if (style.type !== expected) { + specError( + `Style "${style.id}" for ${field} on "${key}" is ${style.type}, expected ${expected}.` + ) + } +} + +function expectedVariableType(field: keyof CanvasVariableBindings): VariableResolvedDataType { + if (field === 'fill' || field === 'stroke') return 'COLOR' + if (field === 'characters' || field === 'fontFamily' || field === 'fontStyle') return 'STRING' + if (field === 'visible') return 'BOOLEAN' + return 'FLOAT' +} + +export function validateVariableType( + field: keyof CanvasVariableBindings, + variable: Pick, + key: string +): void { + validateVariableDataType(variable, expectedVariableType(field), `${field} on "${key}"`) +} + +export function validateVariableDataType( + variable: Pick, + expected: VariableResolvedDataType, + usage: string +): void { + if (variable.resolvedType !== expected) { + specError( + `Variable "${variable.id}" for ${usage} is ${variable.resolvedType}, expected ${expected}.` + ) + } +} + +export const STYLE_FIELDS = ['fill', 'stroke', 'text', 'effect', 'grid'] as const + +export type StyleField = (typeof STYLE_FIELDS)[number] + +export function styleLinkMatches( + node: SupportedCanvasNode, + field: StyleField, + id: string, + ranges: CanvasFigmaTextRange[] | undefined +): boolean { + if (styleTarget(node, field).current === id) return true + if ( + node.type !== 'TEXT' || + node.characters.length === 0 || + (field !== 'text' && field !== 'fill') + ) + return false + const overrides = ranges?.filter((range) => + field === 'text' + ? range.textStyle !== undefined + : range.fillStyle !== undefined || range.fills !== undefined + ) + if (!overrides?.length) return false + return textRangesMatchOutside( + { start: 0, end: node.characters.length }, + overrides, + (start, end) => + (field === 'text' + ? node.getRangeTextStyleId(start, end) + : node.getRangeFillStyleId(start, end)) === id + ) +} + +export function styleTarget( + node: SupportedCanvasNode, + field: StyleField +): { + apply: (id: string) => Promise + current: string | symbol + text?: TextNode +} { + switch (field) { + case 'fill': + if (!('fillStyleId' in node)) { + specError(`Fill styles are not supported on ${node.type} nodes.`) + } + return { + current: node.fillStyleId, + apply: (id) => node.setFillStyleIdAsync(id) + } + case 'stroke': + if (!('strokeStyleId' in node)) { + specError(`Stroke styles are not supported on ${node.type} nodes.`) + } + return { + current: node.strokeStyleId, + apply: (id) => node.setStrokeStyleIdAsync(id) + } + case 'text': + if (node.type !== 'TEXT') specError(`Text styles require a TEXT node, not ${node.type}.`) + return { + current: node.textStyleId, + apply: (id) => node.setTextStyleIdAsync(id), + text: node + } + case 'effect': + if (!('effectStyleId' in node)) { + specError(`Effect styles are not supported on ${node.type} nodes.`) + } + return { + current: node.effectStyleId, + apply: (id) => node.setEffectStyleIdAsync(id) + } + case 'grid': + if (!isFrameContainer(node) && node.type !== 'INSTANCE') { + specError('Grid styles require a frame container or instance node.') + } + return { + current: node.gridStyleId, + apply: (id) => node.setGridStyleIdAsync(id) + } + } +} + +export type DirectVariableField = Exclude + +export const DIRECT_VARIABLE_FIELDS: Record< + DirectVariableField, + VariableBindableNodeField | VariableBindableTextField +> = { + characters: 'characters', + visible: 'visible', + width: 'width', + height: 'height', + minWidth: 'minWidth', + maxWidth: 'maxWidth', + minHeight: 'minHeight', + maxHeight: 'maxHeight', + gap: 'itemSpacing', + counterAxisSpacing: 'counterAxisSpacing', + gridRowGap: 'gridRowGap', + gridColumnGap: 'gridColumnGap', + paddingTop: 'paddingTop', + paddingRight: 'paddingRight', + paddingBottom: 'paddingBottom', + paddingLeft: 'paddingLeft', + cornerRadius: 'cornerRadius', + topLeftRadius: 'topLeftRadius', + topRightRadius: 'topRightRadius', + bottomRightRadius: 'bottomRightRadius', + bottomLeftRadius: 'bottomLeftRadius', + strokeWeight: 'strokeWeight', + strokeTopWeight: 'strokeTopWeight', + strokeRightWeight: 'strokeRightWeight', + strokeBottomWeight: 'strokeBottomWeight', + strokeLeftWeight: 'strokeLeftWeight', + opacity: 'opacity', + fontFamily: 'fontFamily', + fontStyle: 'fontStyle', + fontWeight: 'fontWeight', + fontSize: 'fontSize', + lineHeight: 'lineHeight', + letterSpacing: 'letterSpacing', + paragraphIndent: 'paragraphIndent', + paragraphSpacing: 'paragraphSpacing' +} + +export function currentBoundVariableId( + node: SupportedCanvasNode, + field: VariableBindableNodeField | VariableBindableTextField +): string | undefined { + const value = node.boundVariables?.[field] + const directId = Array.isArray(value) ? value[0]?.id : value?.id + if (directId || field !== 'cornerRadius') return directId + + const aliases = [ + node.boundVariables?.topLeftRadius, + node.boundVariables?.topRightRadius, + node.boundVariables?.bottomLeftRadius, + node.boundVariables?.bottomRightRadius + ] + const radiusId = aliases[0]?.id + return radiusId && aliases.every((alias) => alias?.id === radiusId) ? radiusId : undefined +} + +export function isTextVariableField( + field: VariableBindableNodeField | VariableBindableTextField +): field is VariableBindableTextField { + return [ + 'fontFamily', + 'fontStyle', + 'fontWeight', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing' + ].includes(field) +} + +export function rangeHasVariableLiteral( + range: CanvasFigmaTextRange, + field: VariableBindableTextField +): boolean { + return field === 'fontFamily' || field === 'fontStyle' || field === 'fontWeight' + ? range.fontName !== undefined + : range[field] !== undefined +} + +export function variableLinkMatches( + node: SupportedCanvasNode, + field: VariableBindableNodeField | VariableBindableTextField, + expected: string | undefined, + ranges?: CanvasFigmaTextRange[] +): boolean { + if (node.type !== 'TEXT' || !node.characters.length || !isTextVariableField(field)) { + return currentBoundVariableId(node, field) === expected + } + for (const { start, end } of textVariableLinkRanges(node, field, ranges)) { + const alias = node.getRangeBoundVariable(start, end, field) + if (alias === figma.mixed || alias?.id !== expected) return false + } + return true +} + +export function textVariableLinkRanges( + node: TextNode, + field: VariableBindableTextField, + ranges?: CanvasFigmaTextRange[] +): Generator { + const declared = (ranges ?? []).filter( + (range) => + range.variables?.[field] !== undefined || + range.textStyle || + rangeHasVariableLiteral(range, field) + ) + let overrides: TextRange[] = declared + if (field === 'paragraphIndent' || field === 'paragraphSpacing') { + overrides = textParagraphRanges(node.characters, overrides) + } else if (field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing') { + overrides = declared.map((range, index) => + range.variables?.[field] !== undefined || rangeHasVariableLiteral(range, field) + ? textMetricRange(node.characters, range, field, declared[index + 1]) + : range + ) + } + return textRangesOutside({ start: 0, end: node.characters.length }, overrides) +} + +// Undefined permits a whole-node write; an empty list preserves the whole field. +export function unboundTextMetricRanges( + node: TextNode, + spec: CanvasNodeSpec, + field: 'fontSize' | 'lineHeight' | 'letterSpacing' | 'paragraphIndent' | 'paragraphSpacing' +): TextRange[] | undefined { + if (spec.variables?.[field]) return [] + if (spec.variables?.[field] === null || !currentBoundVariableId(node, field)) return undefined + if (node.characters !== spec.text?.characters || !node.characters.length) return [] + const scope = { start: 0, end: node.characters.length } + const current = node.getRangeBoundVariable(scope.start, scope.end, field) + let bound: TextRange[] + if (current !== figma.mixed) { + if (!current) return [] + bound = [scope] + } else { + const segments = node.getStyledTextSegments(['boundVariables']) + let end = 0 + bound = [] + for (const segment of segments) { + if (segment.start !== end || segment.end <= end || segment.end > scope.end) return [] + end = segment.end + if (segment.boundVariables?.[field]) bound.push(segment) + } + // Keep aggregate ownership when segment data cannot account for its alias. + if (end !== scope.end || !bound.length) return [] + } + let clearing: TextRange[] = + spec.figma?.text?.ranges?.filter((range) => range.variables?.[field] === null) ?? [] + if (field === 'paragraphIndent' || field === 'paragraphSpacing') { + bound = textParagraphRanges(node.characters, bound) + clearing = textParagraphRanges(node.characters, clearing) + } + const retained = bound.flatMap((range) => [...textRangesOutside(range, clearing)]) + return retained.length ? [...textRangesOutside(scope, retained)] : undefined +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/bindings.ts b/packages/extension/mcp/tools/canvas/reconcile/bindings.ts new file mode 100644 index 00000000..6abb125e --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/bindings.ts @@ -0,0 +1,370 @@ +import type { + CanvasFigmaTextRange, + CanvasStyleBindings, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import type { TextParagraph } from './text-paragraphs' + +import { applyError, specError } from '../errors' +import { loadFonts, currentTextFonts } from '../fonts' +import { resolveStyle } from '../styles' +import { resolveVariable } from '../variables' +import { + type StyleField, + styleTarget, + styleLinkMatches, + STYLE_FIELDS, + DIRECT_VARIABLE_FIELDS, + isTextVariableField, + variableLinkMatches +} from './binding-values' +import { scalarValuesEqual, textMeasuresEqual } from './comparison' +import { type SupportedCanvasNode, type ApplyState, markMutation } from './state' +import { loadVariableFonts } from './text-fonts' +import { resolvedRangeTextStyle } from './text-range-style' +import { + textMetricRange, + textParagraphContents, + textParagraphRange, + type TextMetricField, + type TextRange +} from './text-ranges' + +async function setStyleLink( + node: SupportedCanvasNode, + field: StyleField, + id: string, + state: ApplyState, + ranges: CanvasFigmaTextRange[] | undefined +): Promise { + if (styleLinkMatches(node, field, id, ranges)) return + const target = styleTarget(node, field) + if (!id && target.text) await loadFonts(currentTextFonts(target.text), state.fonts) + await target.apply(id) + markMutation(state, node) +} + +export async function unlinkStyles( + node: SupportedCanvasNode, + bindings: CanvasStyleBindings | undefined, + state: ApplyState, + ranges: CanvasFigmaTextRange[] | undefined +): Promise { + for (const field of STYLE_FIELDS) { + if (bindings?.[field] !== null) continue + await setStyleLink(node, field, '', state, ranges) + } +} + +export async function applyStyles( + node: SupportedCanvasNode, + bindings: CanvasStyleBindings | undefined, + state: ApplyState, + ranges: CanvasFigmaTextRange[] | undefined +): Promise { + if (!bindings) return + for (const field of STYLE_FIELDS) { + const reference = bindings[field] + if (!reference) continue + const style = await resolveStyle(reference, state.styles) + await setStyleLink(node, field, style.id, state, ranges) + } +} + +function applyPaintVariable( + node: SupportedCanvasNode, + field: 'fill' | 'stroke', + variable: Variable | null, + state: ApplyState +): void { + if (!('fills' in node)) { + specError(`${field} variables are not supported on ${node.type} node "${node.id}".`) + } + const property = field === 'fill' ? 'fills' : 'strokes' + const currentVariable = node.boundVariables?.[property]?.[0] + if (currentVariable?.id === variable?.id || (!currentVariable && !variable)) return + const styleId = field === 'fill' ? node.fillStyleId : node.strokeStyleId + if (!variable && styleId) { + specError( + `${field} variable bindings cannot be cleared without replacing the existing Paint style on node "${node.id}".` + ) + } + + const currentPaints = node[property] + if (currentPaints === figma.mixed) { + specError(`${field} variable bindings cannot target mixed paints on node "${node.id}".`) + } + const paints = [...currentPaints] + if (paints.length !== 1 || paints[0]?.type !== 'SOLID') { + specError(`${field} variable bindings require exactly one solid paint on node "${node.id}".`) + } + paints[0] = figma.variables.setBoundVariableForPaint(paints[0], 'color', variable) + node[property] = paints + markMutation(state, node) +} + +async function retainedTextBindings( + node: TextNode, + field: VariableBindableTextField, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState +): Promise { + const retained: CanvasFigmaTextRange[] = [] + for (const range of ranges ?? []) { + if (range.variables?.[field]) { + retained.push(range) + continue + } + if (range.variables?.[field] === null || !range.textStyle) continue + const style = await resolveStyle(range.textStyle, state.styles) + const expected = style.type === 'TEXT' ? style.boundVariables?.[field] : undefined + if (!expected) continue + const span = + field === 'paragraphIndent' || field === 'paragraphSpacing' + ? textParagraphRange(node.characters, range) + : range + const current = node.getRangeBoundVariable(span.start, span.end, field) + // A style can carry local overrides. Only its actual alias supersedes the + // base null; this rule also checks the final state after setters have run. + if (current !== figma.mixed && current?.id === expected.id) retained.push(range) + } + return retained +} + +export async function clearedVariableMatches( + node: SupportedCanvasNode, + field: VariableBindableNodeField | VariableBindableTextField, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState +): Promise { + const retained = + node.type === 'TEXT' && isTextVariableField(field) + ? await retainedTextBindings(node, field, ranges, state) + : undefined + return variableLinkMatches(node, field, undefined, retained) +} + +export async function clearVariables( + node: SupportedCanvasNode, + bindings: CanvasVariableBindings | undefined, + state: ApplyState, + ranges?: CanvasFigmaTextRange[] +): Promise { + if (!bindings) return + for (const field of Object.keys(bindings) as Array) { + if (bindings[field] !== null) continue + if (field === 'fill' || field === 'stroke') { + applyPaintVariable(node, field, null, state) + continue + } + const figmaField = DIRECT_VARIABLE_FIELDS[field] + if (await clearedVariableMatches(node, figmaField, ranges, state)) continue + node.setBoundVariable(figmaField, null) + markMutation(state, node) + } +} + +export async function applyVariables( + node: SupportedCanvasNode, + bindings: CanvasVariableBindings | undefined, + state: ApplyState, + ranges?: CanvasFigmaTextRange[] +): Promise { + if (!bindings) return + if (node.type === 'TEXT') await loadVariableFonts(node, bindings, state) + for (const field of Object.keys(bindings) as Array) { + const reference = bindings[field] + if (!reference) continue + const variable = await resolveVariable(reference, state.variables) + if (field === 'fill' || field === 'stroke') { + applyPaintVariable(node, field, variable, state) + continue + } + const figmaField = DIRECT_VARIABLE_FIELDS[field] + if (variableLinkMatches(node, figmaField, variable.id)) continue + if ( + node.type === 'TEXT' && + isTextVariableField(figmaField) && + ranges?.length && + variableLinkMatches(node, figmaField, variable.id, ranges) + ) + continue + node.setBoundVariable(figmaField, variable) + markMutation(state, node) + } +} + +function literalKeepsVariable( + range: CanvasFigmaTextRange, + field: TextMetricField | 'paragraphIndent' | 'paragraphSpacing', + value: number +): boolean { + const nativeValue = Math.fround(value) + if (field === 'lineHeight' || field === 'letterSpacing') { + return textMeasuresEqual(field, { unit: 'PIXELS', value: nativeValue }, range[field]!) + } + return scalarValuesEqual(nativeValue, range[field]) +} + +export async function repairTextRangeBindings( + node: TextNode, + paragraphs: readonly TextParagraph[], + index: number, + bindings: CanvasVariableBindings | undefined, + earlierBindings: Record<'paragraphIndent' | 'paragraphSpacing', number>, + state: ApplyState +): Promise { + const paragraph = paragraphs[index]! + const range = paragraph.range + if (range.textStyle) return + for (const field of [ + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing' + ] as const) { + const reference = bindings?.[field] + if (!reference || range.variables?.[field] !== undefined) continue + if (range[field] === undefined) continue + const variable = await resolveVariable(reference, state.variables) + const value = variable.resolveForConsumer(node).value + if (typeof value !== 'number') continue + const keep = literalKeepsVariable(range, field, value) + const scope = + field === 'paragraphIndent' || field === 'paragraphSpacing' + ? { + start: Math.max(paragraph.effective[field].start, earlierBindings[field]), + end: paragraph.effective[field].end + } + : textMetricRange(node.characters, range, field, paragraphs[index + 1]?.range) + if (scope.start >= scope.end) continue + const alias = node.getRangeBoundVariable(scope.start, scope.end, field) + // A native node rebind may retain local aliases. Repair only plain literal + // owners before applying the ordered ranges. Earlier paragraph binding + // owners retain their precedence when an equal later literal skips its setter. + // A base alias is cleared by a differing literal. + if (alias !== figma.mixed && (alias?.id === variable.id || (!keep && !alias))) continue + if (field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing') + await protectFollowingTextStyle( + node, + range, + field, + paragraphs[index + 1]?.range, + state, + keep ? variable.id : undefined + ) + const writable = field === 'paragraphIndent' || field === 'paragraphSpacing' ? scope : range + node.setRangeBoundVariable(writable.start, writable.end, field, keep ? variable : null) + markMutation(state, node) + if (!keep) { + ensureTextRangeUnbound(node, scope, field, state) + } else { + verifyTextRangeBinding(node, scope, field, variable.id) + } + } +} + +export function verifyTextRangeBinding( + node: TextNode, + range: TextRange, + field: VariableBindableTextField, + expected: string, + key?: string +): void { + const actual = node.getRangeBoundVariable(range.start, range.end, field) + if (actual !== figma.mixed && actual?.id === expected) return + let boundary = '' + if ( + (field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing') && + node.characters[range.start] === '\n' + ) { + const newline = node.getRangeBoundVariable(range.start, range.start + 1, field) + if (newline === figma.mixed || newline?.id !== expected) { + boundary = ` The leading newline at ${range.start}:${range.start + 1} did not retain the requested binding. Start this range at its text content only if preserving that newline's current formatting is intended; otherwise preserve or explicitly replace the affected paragraph. Do not repeat this unchanged request.` + } + } + applyError( + `Verification failed${key ? ` for "${key}"` : ''}: range variable link "${field}" does not match in range ${range.start}:${range.end}.${boundary}` + ) +} + +export async function protectFollowingTextStyle( + node: TextNode, + range: TextRange, + field: TextMetricField, + later: CanvasFigmaTextRange | undefined, + state: ApplyState, + aliasId: string | undefined, + literal?: number | LineHeight | LetterSpacing +): Promise { + if (node.characters[range.end] !== '\n' || later?.start !== range.end || !later.textStyle) return + const alias = node.getRangeBoundVariable(range.end, range.end + 1, field) + let unchanged = alias !== figma.mixed && alias?.id === aliasId + if (unchanged && literal !== undefined) { + const value = + field === 'fontSize' + ? node.getRangeFontSize(range.end, range.end + 1) + : field === 'lineHeight' + ? node.getRangeLineHeight(range.end, range.end + 1) + : node.getRangeLetterSpacing(range.end, range.end + 1) + unchanged = + field === 'fontSize' + ? scalarValuesEqual(value, literal) + : textMeasuresEqual( + field, + value as LineHeight | LetterSpacing | typeof figma.mixed, + literal as LineHeight | LetterSpacing + ) + } + if (unchanged) return + const style = await resolveStyle(later.textStyle, state.styles) + if ( + node.getRangeTextStyleId(later.start, later.end) === style.id && + !(await resolvedRangeTextStyle(node, later, state)) + ) { + applyError( + `Cannot change range ${range.start}:${range.end} field "${field}" without changing the retained TextStyle on its following newline (range ${later.start}:${later.end}, style "${style.id}"). Keep this range unchanged or explicitly replace the adjacent style; repeating the same request will not repair this native boundary.` + ) + } +} + +export function ensureTextRangeUnbound( + node: TextNode, + range: TextRange, + field: VariableBindableTextField, + state: ApplyState +): void { + if (field === 'paragraphIndent' || field === 'paragraphSpacing') { + // Native null can retain a paragraph alias even for a complete paragraph. + // Force its current literal, separately for each paragraph so mixed values + // survive, and require the alias to disappear before later owners run. + for (const { + span: { start, end }, + content + } of textParagraphContents(node.characters, range)) { + const alias = node.getRangeBoundVariable(start, end, field) + // Including the paragraph break in a literal setter can also change the + // following paragraph. Empty paragraphs have no proven safe fallback. + if (alias && start < content.end) { + const value = + field === 'paragraphIndent' + ? node.getRangeParagraphIndent(start, end) + : node.getRangeParagraphSpacing(start, end) + if (value !== figma.mixed) { + if (field === 'paragraphIndent') node.setRangeParagraphIndent(start, content.end, value) + else node.setRangeParagraphSpacing(start, content.end, value) + markMutation(state, node) + } + } + } + } else if (field !== 'fontSize' && field !== 'lineHeight' && field !== 'letterSpacing') { + return + } + if (node.getRangeBoundVariable(range.start, range.end, field)) { + applyError( + `Cannot clear range ${range.start}:${range.end} variable "${field}": Figma retained its alias and no safe literal fallback is available. Preserve the binding or explicitly replace the affected text range; do not repeat this unchanged request.` + ) + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/comparison.ts b/packages/extension/mcp/tools/canvas/reconcile/comparison.ts new file mode 100644 index 00000000..87ee3585 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/comparison.ts @@ -0,0 +1,117 @@ +import type { CanvasVariableReference } from '@tempad-dev/shared' + +export const GEOMETRY_TOLERANCE = 0.01 + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +export function isVariableBinding(value: unknown): value is { variable: CanvasVariableReference } { + return isRecord(value) && 'variable' in value +} + +export function scalarValuesEqual(current: unknown, desired: unknown): boolean { + return ( + Object.is(current, desired) || + (typeof current === 'number' && typeof desired === 'number' && current === Math.fround(desired)) + ) +} + +export function transformsMatch(current: Transform, desired: Transform): boolean { + return current.every((row, rowIndex) => + row.every((value, columnIndex) => { + const expected = desired[rowIndex]![columnIndex]! + return ( + Math.abs(value - expected) <= 1e-6 || + (columnIndex === 2 && scalarValuesEqual(value, expected)) + ) + }) + ) +} + +function comparableEntries(value: Record): Array<[string, unknown]> { + return Object.entries(value).filter( + ([key, field]) => + !( + (key === 'boundVariables' || key === 'properties') && + isRecord(field) && + !Object.keys(field).length + ) + ) +} + +export function nativeValueEqual( + current: unknown, + desired: unknown, + numbersEqual: (current: number, desired: number) => boolean = (a, b) => a === b +): boolean { + if (Object.is(current, desired)) return true + if (typeof current === 'number' && typeof desired === 'number') { + return numbersEqual(current, desired) + } + if (Array.isArray(current) || Array.isArray(desired)) { + return ( + Array.isArray(current) && + Array.isArray(desired) && + current.length === desired.length && + current.every((value, index) => nativeValueEqual(value, desired[index], numbersEqual)) + ) + } + if (!isRecord(current) || !isRecord(desired)) return false + const currentEntries = comparableEntries(current) + const desiredEntries = comparableEntries(desired) + return ( + currentEntries.length === desiredEntries.length && + currentEntries.every( + ([key, value]) => + Object.hasOwn(desired, key) && nativeValueEqual(value, desired[key], numbersEqual) + ) + ) +} + +export function textMeasuresEqual( + field: 'lineHeight' | 'letterSpacing', + current: LineHeight | LetterSpacing | typeof figma.mixed, + desired: LineHeight | LetterSpacing +): boolean { + if (current === figma.mixed || current.unit !== desired.unit) return false + if (current.unit === 'AUTO' || desired.unit === 'AUTO') return true + if (field === 'lineHeight' && desired.unit === 'PERCENT') { + // Figma stores percent line height as a float32 ratio, not a percent value. + return ( + current.value === desired.value || current.value === Math.fround(desired.value / 100) * 100 + ) + } + return scalarValuesEqual(current.value, desired.value) +} + +export function fontNamesEqual(current: FontName | typeof figma.mixed, desired: FontName): boolean { + return ( + current !== figma.mixed && current.family === desired.family && current.style === desired.style + ) +} + +export function hyperlinksEqual( + current: HyperlinkTarget | null | typeof figma.mixed, + desired: HyperlinkTarget | null +): boolean { + return ( + current !== figma.mixed && + (current === desired || + (!!current && !!desired && current.type === desired.type && current.value === desired.value)) + ) +} + +export function rotationsMatch(current: number, desired: number): boolean { + const delta = ((((current - desired + 180) % 360) + 360) % 360) - 180 + return Math.abs(delta) <= 0.001 +} + +export function finiteRect(value: Rect | null | undefined): Rect | null { + return value && + [value.x, value.y, value.width, value.height].every(Number.isFinite) && + value.width >= 0 && + value.height >= 0 + ? value + : null +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/components.ts b/packages/extension/mcp/tools/canvas/reconcile/components.ts new file mode 100644 index 00000000..809d30c9 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/components.ts @@ -0,0 +1,808 @@ +import type { + CanvasDesignReference, + CanvasFigmaComponentPropertyDefinition, + CanvasVariableReference, + CanvasComponentPropertyValue, + CanvasFigmaSlotProperty +} from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' + +import { getMainComponent } from '../../../local-resources' +import { scopeError, specError, applyError } from '../errors' +import { CANVAS_KEY_NAMESPACE, designReferenceCacheKey } from '../identity' +import { resolveVariable, resolvedVariable } from '../variables' +import { validateVariableDataType } from './binding-values' +import { isVariableBinding, isRecord, nativeValueEqual } from './comparison' +import { lookupNodeById, findExistingNode } from './nodes' +import { type ApplyState, markMutation, type SupportedCanvasNode, setValue } from './state' +import { protectNode } from './undo' + +const CANVAS_COMPONENT_PROPERTY_KEYS_NAME = 'component-property-keys' +const CANVAS_SLOT_PROPERTY_NAME = 'slot-property-name' + +type ComponentPropertyOwner = ComponentNode | ComponentSetNode + +type ComponentPropertyReferenceField = 'characters' | 'mainComponent' | 'visible' + +export type ComponentPropertyContext = { + existing?: ComponentPropertyOwner + spec?: CanvasNodeSpec +} + +export function componentPropertyOwner(node: BaseNode): ComponentPropertyOwner | null { + let current = node.parent + while (current) { + if (current.type === 'COMPONENT_SET') return current + if (current.type === 'COMPONENT') { + return current.parent?.type === 'COMPONENT_SET' ? current.parent : current + } + current = current.parent + } + return null +} + +export function componentDefinitionOwner(component: ComponentNode): ComponentPropertyOwner { + return component.parent?.type === 'COMPONENT_SET' ? component.parent : component +} + +export function componentPropertyKeys( + owner: ComponentPropertyOwner, + state: ApplyState +): Record { + const cached = state.componentPropertyKeys.get(owner.id) + if (cached) return cached + const raw = owner.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COMPONENT_PROPERTY_KEYS_NAME) + let keys: Record = Object.create(null) as Record + if (raw) { + try { + const parsed: unknown = JSON.parse(raw) + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string') + ) { + throw new Error() + } + keys = Object.assign(Object.create(null) as Record, parsed) + } catch { + scopeError(`Component property identity data on "${owner.id}" is invalid.`) + } + } + state.componentPropertyKeys.set(owner.id, keys) + return keys +} + +function setComponentPropertyKey( + owner: ComponentPropertyOwner, + key: string, + propertyName: string, + state: ApplyState +): void { + const keys = componentPropertyKeys(owner, state) + if (keys[key] === propertyName) return + keys[key] = propertyName + owner.setSharedPluginData( + CANVAS_KEY_NAMESPACE, + CANVAS_COMPONENT_PROPERTY_KEYS_NAME, + JSON.stringify(keys) + ) + markMutation(state, owner) +} + +export function componentPropertyName( + owner: ComponentPropertyOwner, + key: string, + state: ApplyState +): string | undefined { + const mapped = componentPropertyKeys(owner, state)[key] + // Older slot identities shared this map with authored component-property keys. + if (mapped && owner.componentPropertyDefinitions[mapped]?.type !== 'SLOT') return mapped + return Object.hasOwn(owner.componentPropertyDefinitions, key) ? key : undefined +} + +function setSlotPropertyName(node: SlotNode, propertyName: string, state: ApplyState): void { + state.slotPropertyNames.set(node.id, propertyName) + if (node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SLOT_PROPERTY_NAME) === propertyName) + return + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SLOT_PROPERTY_NAME, propertyName) + markMutation(state, node) +} + +export async function resolveComponent(reference: CanvasDesignReference, state: ApplyState) { + const cacheKey = designReferenceCacheKey(reference) + const cached = state.componentCache.get(cacheKey) + if (cached) return cached + + let component: ComponentNode | null = null + if (reference.id !== undefined) { + const node = await lookupNodeById(reference.id) + if (node?.type === 'COMPONENT') { + component = node + protectNode(state, node) + } else if (node?.type === 'COMPONENT_SET') { + component = node.defaultVariant + protectNode(state, node) + protectNode(state, component) + } + } else { + try { + component = await figma.importComponentByKeyAsync(reference.key) + } catch { + specError(`Component key "${reference.key}" could not be imported.`) + } + } + + if (!component) { + specError('The requested component could not be resolved.') + } + state.componentCache.set(cacheKey, component) + return component +} + +function componentPropertyDisplayName(propertyName: string): string { + const suffix = propertyName.lastIndexOf('#') + return suffix < 0 ? propertyName : propertyName.slice(0, suffix) +} + +function nextComponentPropertyContext( + spec: CanvasNodeSpec, + existing: SupportedCanvasNode | undefined, + inherited: ComponentPropertyContext | undefined +): ComponentPropertyContext | undefined { + if (spec.type === 'COMPONENT_SET') { + return { + spec, + ...(existing?.type === 'COMPONENT_SET' ? { existing } : {}) + } + } + if (spec.type === 'COMPONENT') { + if (inherited?.spec?.type === 'COMPONENT_SET') return inherited + if (existing?.type === 'COMPONENT' && existing.parent?.type === 'COMPONENT_SET') { + return { existing: existing.parent } + } + return { + spec, + ...(existing?.type === 'COMPONENT' ? { existing } : {}) + } + } + if (inherited) return inherited + const owner = existing ? componentPropertyOwner(existing) : null + return owner ? { existing: owner } : undefined +} + +function contextPropertyType( + context: ComponentPropertyContext, + key: string, + state: ApplyState +): ComponentPropertyType | undefined { + const properties = context.spec?.figma?.component?.properties + const desired = properties && Object.hasOwn(properties, key) ? properties[key] : undefined + if (desired !== undefined) return desired?.type + const owner = context.existing + if (!owner) return undefined + const name = componentPropertyName(owner, key, state) + return name ? owner.componentPropertyDefinitions[name]?.type : undefined +} + +function expectedComponentPropertyVariableType( + type: CanvasFigmaComponentPropertyDefinition['type'] +): VariableResolvedDataType { + return type === 'BOOLEAN' ? 'BOOLEAN' : 'STRING' +} + +export type ComponentPreflightResources = { + variable: ( + reference: CanvasVariableReference + ) => Promise | undefined> + component: (reference: CanvasDesignReference) => Promise +} + +async function preflightComponentPropertyDefinition( + key: string, + definition: CanvasFigmaComponentPropertyDefinition, + resources: ComponentPreflightResources +): Promise { + if (isVariableBinding(definition.defaultValue)) { + const variable = await resources.variable(definition.defaultValue.variable) + const expected = expectedComponentPropertyVariableType(definition.type) + if (variable) validateVariableDataType(variable, expected, `component property "${key}"`) + } else if (definition.type === 'INSTANCE_SWAP') { + await resources.component(definition.defaultValue) + } +} + +async function preflightAuthoredComponentProperties( + spec: CanvasNodeSpec, + context: ComponentPropertyContext | undefined, + state: ApplyState, + resources: ComponentPreflightResources +): Promise { + const properties = spec.figma?.component?.properties + if (!properties) return + if (!context || context.spec !== spec) { + specError( + `Component property definitions on variant "${spec.key}" belong on its component set.` + ) + } + const owner = context.existing + const keys = owner ? componentPropertyKeys(owner, state) : undefined + for (const [key, desired] of Object.entries(properties)) { + const propertyName = + owner && desired !== null + ? (componentPropertyName(owner, key, state) ?? key) + : (keys?.[key] ?? key) + const current = + owner && propertyName && Object.hasOwn(owner.componentPropertyDefinitions, propertyName) + ? owner.componentPropertyDefinitions[propertyName] + : undefined + if (desired === null) { + if (!owner || (!keys?.[key] && !current)) { + specError(`Component property "${key}" on "${spec.key}" does not exist.`) + } + if (current?.type === 'VARIANT' || current?.type === 'SLOT') { + specError( + `${current.type} property "${key}" on "${spec.key}" cannot be deleted through component properties.` + ) + } + continue + } + if (current && current.type !== desired.type) { + specError( + `Component property "${key}" on "${spec.key}" is ${current.type}, expected ${desired.type}.` + ) + } + await preflightComponentPropertyDefinition(key, desired, resources) + } +} + +function expectedComponentPropertyReferenceType( + field: ComponentPropertyReferenceField +): ComponentPropertyType { + if (field === 'characters') return 'TEXT' + if (field === 'mainComponent') return 'INSTANCE_SWAP' + return 'BOOLEAN' +} + +function preflightComponentPropertyReferences( + spec: CanvasNodeSpec, + existing: SupportedCanvasNode | undefined, + context: ComponentPropertyContext | undefined, + state: ApplyState +): void { + const references = spec.figma?.componentPropertyReferences + if (references) { + if (!context) { + specError(`Component property references on "${spec.key}" require a component sublayer.`) + } + for (const [field, key] of Object.entries(references) as Array< + [ComponentPropertyReferenceField, string | null] + >) { + if (key === null) continue + const actual = contextPropertyType(context, key, state) + const expected = expectedComponentPropertyReferenceType(field) + if (actual !== expected) { + specError( + `Component property reference "${key}" for ${field} on "${spec.key}" is ${actual ?? 'missing'}, expected ${expected}.` + ) + } + } + } + const effective = (field: ComponentPropertyReferenceField) => + references?.[field] === undefined + ? existing?.componentPropertyReferences?.[field] + : references[field] + if (effective('characters') && (spec.variables?.characters || spec.figma?.text?.ranges)) { + specError( + `A characters property reference on "${spec.key}" cannot be combined with a characters variable or rich-text ranges.` + ) + } + if (effective('visible') && spec.variables?.visible) { + specError( + `A visible property reference on "${spec.key}" cannot be combined with a visibility variable.` + ) + } + if (effective('mainComponent') && spec.figma?.instance?.preserveOverrides !== undefined) { + specError( + `Instance override preservation on "${spec.key}" cannot be combined with a mainComponent property reference.` + ) + } +} + +function slotPropertyName( + owner: ComponentPropertyOwner, + node: SlotNode, + spec: CanvasNodeSpec, + state: ApplyState +): string | undefined { + const stored = + state.slotPropertyNames.get(node.id) ?? + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SLOT_PROPERTY_NAME) + if (stored && owner.componentPropertyDefinitions[stored]?.type === 'SLOT') return stored + const legacy = componentPropertyKeys(owner, state)[spec.key] ?? spec.key + if (owner.componentPropertyDefinitions[legacy]?.type === 'SLOT') return legacy + const desiredName = spec.figma?.slot?.property?.name + if (!desiredName) return undefined + const matches = Object.entries(owner.componentPropertyDefinitions) + .filter( + ([name, definition]) => + definition.type === 'SLOT' && componentPropertyDisplayName(name) === desiredName + ) + .map(([name]) => name) + if (matches.length > 1) { + specError(`Slot property "${desiredName}" on "${spec.key}" is ambiguous.`) + } + return matches[0] +} + +function preflightSlot( + spec: CanvasNodeSpec, + existing: SupportedCanvasNode | undefined, + context: ComponentPropertyContext | undefined, + state: ApplyState +): void { + if (spec.type !== 'SLOT') return + if (!existing) { + if (!spec.figma?.slot?.property) { + specError(`New slot "${spec.key}" requires property metadata.`) + } + if (!context) { + specError(`New slot "${spec.key}" must be nested inside an authored component.`) + } + if (context.existing) protectNode(state, context.existing) + return + } + if (existing.type !== 'SLOT' || !spec.figma?.slot?.property) return + const owner = componentPropertyOwner(existing) + const propertyName = owner ? slotPropertyName(owner, existing, spec, state) : undefined + if (!owner || !propertyName) { + specError(`Slot property for "${spec.key}" could not be resolved.`) + } + // Slot metadata writes the definition owner, which can be outside the update root. + protectNode(state, owner) + // Keep legacy resolution stable if a normal property takes the same key during apply. + state.slotPropertyNames.set(existing.id, propertyName) + const current = owner.componentPropertyDefinitions[propertyName] + const settings = spec.figma.slot.property.settings + if (settings) { + const merged = { ...current?.slotSettings, ...settings } + if ( + merged.minChildren != null && + merged.maxChildren != null && + merged.minChildren > merged.maxChildren + ) { + specError(`Slot minChildren on "${spec.key}" cannot exceed maxChildren.`) + } + } +} + +async function preflightComponentProperties( + spec: CanvasNodeSpec, + component: ComponentNode, + state: ApplyState, + resources: ComponentPreflightResources +): Promise { + const owner = componentDefinitionOwner(component) + for (const [key, value] of Object.entries(spec.componentProperties ?? {})) { + const name = componentPropertyName(owner, key, state) ?? key + const definition = Object.hasOwn(owner.componentPropertyDefinitions, name) + ? owner.componentPropertyDefinitions[name] + : undefined + if (!definition) { + specError(`Component "${component.id}" has no property "${key}" for "${spec.key}".`) + } + if (definition.type === 'SLOT') { + specError(`Slot property "${name}" on "${spec.key}" cannot be set with componentProperties.`) + } + if (isVariableBinding(value)) { + if (definition.type === 'VARIANT' || definition.type === 'INSTANCE_SWAP') { + specError( + `Component property "${name}" on "${spec.key}" cannot bind a variable because it is ${definition.type}.` + ) + } + const variable = await resources.variable(value.variable) + const expected = expectedComponentPropertyVariableType(definition.type) + if (variable && variable.resolvedType !== expected) { + specError( + `Variable "${variable.id}" for component property "${name}" on "${spec.key}" is ${variable.resolvedType}, expected ${expected}.` + ) + } + continue + } + const expected = definition.type === 'BOOLEAN' ? 'boolean' : 'string' + if (typeof value !== expected) { + specError(`Component property "${name}" on "${spec.key}" expects ${expected}.`) + } + if ( + definition.type === 'VARIANT' && + definition.variantOptions && + !definition.variantOptions.includes(value as string) + ) { + specError(`Component property "${name}" on "${spec.key}" has no variant "${value}".`) + } + if (definition.type !== 'INSTANCE_SWAP') continue + const replacement = await lookupNodeById(value as string) + if (replacement?.type !== 'COMPONENT' && replacement?.type !== 'COMPONENT_SET') { + specError( + `Instance-swap property "${name}" on "${spec.key}" must reference a component node.` + ) + } + if (replacement.type === 'COMPONENT_SET' && spec.componentProperties) { + spec.componentProperties[key] = replacement.defaultVariant.id + } + } +} + +function isPrimaryNestedInstance(node: InstanceNode): boolean { + let parent = node.parent + while (parent) { + if (parent.type === 'INSTANCE') return false + if (parent.type === 'COMPONENT' || parent.type === 'COMPONENT_SET') return true + parent = parent.parent + } + return false +} + +export async function preflightComponentState( + spec: CanvasNodeSpec, + state: ApplyState, + existing?: SupportedCanvasNode, + inheritedComponent?: ComponentPropertyContext, + resources: ComponentPreflightResources = { + variable: (reference) => resolveVariable(reference, state.variables), + component: (reference) => resolveComponent(reference, state) + } +): Promise { + const component = nextComponentPropertyContext(spec, existing, inheritedComponent) + if (component?.existing?.remote) { + specError(`Remote ${component.existing.type} containing "${spec.key}" is read-only.`) + } + await preflightAuthoredComponentProperties(spec, component, state, resources) + preflightComponentPropertyReferences(spec, existing, component, state) + preflightSlot(spec, existing, component, state) + if (spec.component) { + const instanceComponent = await resources.component(spec.component) + if (instanceComponent) + await preflightComponentProperties(spec, instanceComponent, state, resources) + } else if (spec.componentProperties || spec.figma?.instance) { + const instance = existing ?? findExistingNode(spec, state) + if (instance?.type !== 'INSTANCE') { + specError( + `Instance state on "${spec.key}" requires an existing instance or a component reference.` + ) + } + if (spec.componentProperties) { + const instanceComponent = await getMainComponent(instance) + if (!instanceComponent) { + specError(`Existing instance "${spec.key}" has no main component.`) + } + await preflightComponentProperties(spec, instanceComponent, state, resources) + } + } + if (spec.figma?.instance?.exposed !== undefined) { + const instance = existing ?? findExistingNode(spec, state) + if (instance?.type !== 'INSTANCE' || !isPrimaryNestedInstance(instance)) { + specError( + `Instance exposure on "${spec.key}" requires an existing primary instance inside a component.` + ) + } + } + return component +} + +export function resolvedComponent( + reference: CanvasDesignReference, + state: ApplyState +): ComponentNode { + const component = state.componentCache.get(designReferenceCacheKey(reference)) + if (!component) + applyError( + 'A preflighted component could not be resolved. This is an internal apply failure; do not retry the same payload.' + ) + return component +} + +export function preservesComponentPropertyReference( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + field: ComponentPropertyReferenceField +): boolean { + const desired = spec.figma?.componentPropertyReferences?.[field] + return desired === undefined + ? node.componentPropertyReferences?.[field] !== undefined + : desired !== null +} + +export function componentPropertyMatches( + current: ComponentProperties[string] | undefined, + value: CanvasComponentPropertyValue, + state: ApplyState +): boolean { + return isVariableBinding(value) + ? current?.boundVariables?.value?.id === resolvedVariable(value.variable, state.variables).id + : current?.value === value && current.boundVariables?.value === undefined +} + +export async function applyComponent( + node: InstanceNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const instance = spec.figma?.instance + const component = spec.component + ? resolvedComponent(spec.component, state) + : await getMainComponent(node) + if (!component) { + specError(`Existing instance "${spec.key}" has no main component.`) + } + if (spec.component && !preservesComponentPropertyReference(node, spec, 'mainComponent')) { + const currentComponent = await getMainComponent(node) + if (!componentLinkMatches(spec, node, component, currentComponent, state)) { + if (instance?.preserveOverrides === false) node.mainComponent = component + else node.swapComponent(component) + markMutation(state, node) + } + } + + setValue( + node, + node.scaleFactor, + instance?.scaleFactor, + (value) => (node.scaleFactor = value), + state + ) + setValue( + node, + node.isExposedInstance, + instance?.exposed, + (value) => (node.isExposedInstance = value), + state + ) + + const owner = componentDefinitionOwner(component) + const desiredProperties = Object.entries(spec.componentProperties ?? {}).map( + ([key, value]) => [componentPropertyName(owner, key, state) ?? key, value] as const + ) + const changedProperties = desiredProperties.filter( + ([name, value]) => !componentPropertyMatches(node.componentProperties[name], value, state) + ) + if (changedProperties.length) { + node.setProperties( + Object.fromEntries( + changedProperties.map(([name, value]) => [ + name, + isVariableBinding(value) + ? figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) + : value + ]) + ) + ) + markMutation(state, node) + } +} + +function nativeComponentPropertyDefault( + definition: CanvasFigmaComponentPropertyDefinition, + state: ApplyState +): string | boolean | VariableAlias { + const value = definition.defaultValue + if (isVariableBinding(value)) { + return figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) + } + return definition.type === 'INSTANCE_SWAP' + ? resolvedComponent(value as CanvasDesignReference, state).id + : (value as string | boolean) +} + +function componentPropertyDefaultMatches( + current: ComponentPropertyDefinitions[string], + desired: string | boolean | VariableAlias +): boolean { + return isRecord(desired) + ? current.boundVariables?.defaultValue?.id === desired.id + : current.boundVariables?.defaultValue === undefined && current.defaultValue === desired +} + +function componentPropertyOptions( + definition: CanvasFigmaComponentPropertyDefinition +): ComponentPropertyOptions | undefined { + return definition.type === 'INSTANCE_SWAP' && definition.preferredValues !== undefined + ? { preferredValues: definition.preferredValues } + : undefined +} + +export function applyAuthoredComponentProperties( + owner: ComponentPropertyOwner, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const properties = spec.figma?.component?.properties + if (!properties) return + const keys = componentPropertyKeys(owner, state) + for (const [key, desired] of Object.entries(properties)) { + const propertyName = + desired === null ? (keys[key] ?? key) : (componentPropertyName(owner, key, state) ?? key) + const current = Object.hasOwn(owner.componentPropertyDefinitions, propertyName) + ? owner.componentPropertyDefinitions[propertyName] + : undefined + if (desired === null) { + if (!current) continue + if (!keys[key]) setComponentPropertyKey(owner, key, propertyName, state) + owner.deleteComponentProperty(propertyName) + markMutation(state, owner) + continue + } + + const defaultValue = nativeComponentPropertyDefault(desired, state) + if (!current) { + const createdName = owner.addComponentProperty( + desired.name, + desired.type, + defaultValue, + componentPropertyOptions(desired) + ) + markMutation(state, owner) + setComponentPropertyKey(owner, key, createdName, state) + continue + } + + const edit: { + name?: string + defaultValue?: string | boolean | VariableAlias + preferredValues?: InstanceSwapPreferredValue[] + } = {} + if (componentPropertyDisplayName(propertyName) !== desired.name) { + edit.name = desired.name + } + if (!componentPropertyDefaultMatches(current, defaultValue)) { + edit.defaultValue = defaultValue + } + if ( + desired.type === 'INSTANCE_SWAP' && + desired.preferredValues !== undefined && + !nativeValueEqual(current.preferredValues ?? [], desired.preferredValues) + ) { + edit.preferredValues = desired.preferredValues + } + if (!Object.keys(edit).length) continue + const editedName = owner.editComponentProperty(propertyName, edit) + markMutation(state, owner) + setComponentPropertyKey(owner, key, editedName, state) + } +} + +function slotSettingsChanged( + current: SlotSettings | undefined, + desired: NonNullable +): boolean { + return Object.entries(desired).some( + ([field, value]) => current?.[field as keyof SlotSettings] !== value + ) +} + +export function applySlotProperty(node: SlotNode, spec: CanvasNodeSpec, state: ApplyState): void { + const desired = spec.figma?.slot?.property + if (!desired) return + const owner = componentPropertyOwner(node) + if (!owner) specError(`Slot "${spec.key}" has no authored component owner.`) + const propertyName = slotPropertyName(owner, node, spec, state) + if (!propertyName) specError(`Slot property for "${spec.key}" could not be resolved.`) + const current = owner.componentPropertyDefinitions[propertyName] + if (!current || current.type !== 'SLOT') { + specError(`Component property "${propertyName}" for "${spec.key}" is not a slot.`) + } + const edit: { + name?: string + preferredValues?: InstanceSwapPreferredValue[] + description?: string + slotSettings?: SlotSettings + } = {} + if (componentPropertyDisplayName(propertyName) !== desired.name) { + edit.name = desired.name + } + if ( + desired.preferredValues !== undefined && + !nativeValueEqual(current.preferredValues ?? [], desired.preferredValues) + ) { + edit.preferredValues = desired.preferredValues + } + if (desired.description !== undefined && current.description !== desired.description) { + edit.description = desired.description + } + if (desired.settings && slotSettingsChanged(current.slotSettings, desired.settings)) { + edit.slotSettings = { ...current.slotSettings, ...desired.settings } + } + let editedName = propertyName + if (Object.keys(edit).length) { + editedName = owner.editComponentProperty(propertyName, edit) + markMutation(state, owner) + } + setSlotPropertyName(node, editedName, state) +} + +export function applyComponentPropertyReferences( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const desired = spec.figma?.componentPropertyReferences + if (!desired) return + const owner = componentPropertyOwner(node) + if (!owner) { + specError(`Component property references on "${spec.key}" require a component sublayer.`) + } + const next = { ...(node.componentPropertyReferences ?? {}) } + for (const [field, key] of Object.entries(desired) as Array< + [ComponentPropertyReferenceField, string | null] + >) { + if (key === null) { + delete next[field] + continue + } + const propertyName = componentPropertyName(owner, key, state) + if (!propertyName || !owner.componentPropertyDefinitions[propertyName]) { + specError(`Component property reference "${key}" on "${spec.key}" could not be resolved.`) + } + next[field] = propertyName + } + const references = Object.keys(next).length ? next : null + if (nativeValueEqual(node.componentPropertyReferences, references)) return + node.componentPropertyReferences = references + markMutation(state, node) +} + +export function applyComponentMetadata( + node: ComponentNode | ComponentSetNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const metadata = spec.figma?.component + if (!metadata) return + setValue( + node, + node.descriptionMarkdown, + metadata.descriptionMarkdown, + (value) => (node.descriptionMarkdown = value), + state + ) + if (metadata.documentationLink === undefined) return + const links = metadata.documentationLink === null ? [] : [{ uri: metadata.documentationLink }] + if (nativeValueEqual(node.documentationLinks, links)) return + node.documentationLinks = links + markMutation(state, node) +} + +export function componentLinkMatches( + spec: CanvasNodeSpec, + node: InstanceNode, + expected: ComponentNode, + actual: ComponentNode | null, + state: ApplyState +): boolean { + if (actual?.id === expected.id) return true + const componentSet = expected.parent + if ( + !actual || + componentSet?.type !== 'COMPONENT_SET' || + actual.parent?.type !== 'COMPONENT_SET' || + actual.parent.id !== componentSet.id + ) { + return false + } + + const variantProperties = Object.entries(spec.componentProperties ?? {}).flatMap( + ([key, value]) => { + const name = componentPropertyName(componentSet, key, state) ?? key + return componentSet.componentPropertyDefinitions[name]?.type === 'VARIANT' + ? ([[name, value]] as const) + : [] + } + ) + return ( + variantProperties.length > 0 && + variantProperties.every(([name, value]) => + componentPropertyMatches(node.componentProperties[name], value, state) + ) + ) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/grid.ts b/packages/extension/mcp/tools/canvas/reconcile/grid.ts new file mode 100644 index 00000000..044ded75 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/grid.ts @@ -0,0 +1,347 @@ +import type { CanvasGridTrack, CanvasNodeSpec, CanvasGridLayout } from '../model' + +import { applyError, specError } from '../errors' +import { scalarValuesEqual } from './comparison' +import { applySizingModes } from './layout' +import { isSupportedSceneNode, walkSpecs, isFrameContainer } from './nodes' +import { + type CanvasFrameContainerNode, + type ApplyState, + markMutation, + type SupportedCanvasNode, + setValue +} from './state' + +function gridTrackMatches(current: GridTrackSize | undefined, desired: CanvasGridTrack): boolean { + return ( + current?.type === desired.type && + (desired.type === 'HUG' || + scalarValuesEqual(current.value ?? (current.type === 'FLEX' ? 1 : undefined), desired.value)) + ) +} + +function applyGridTracks( + node: CanvasFrameContainerNode, + field: 'gridColumnSizes' | 'gridRowSizes', + desired: CanvasGridTrack[], + state: ApplyState, + preserveTrailing = false +): void { + const current = node[field] + if (current.length < desired.length || (!preserveTrailing && current.length !== desired.length)) { + specError(`Figma returned ${current.length} ${field}, expected ${desired.length}.`) + } + for (const [index, track] of desired.entries()) { + const target = current[index]! + if (gridTrackMatches(target, track)) continue + target.type = track.type + if (track.type !== 'HUG') target.value = track.value + markMutation(state, node) + } + const applied = node[field] + const mismatch = desired.findIndex((track, index) => !gridTrackMatches(applied[index], track)) + if (mismatch !== -1) { + applyError(`Verification failed for "${node.id}": ${field} track ${mismatch} does not match.`) + } +} + +type GridChildNode = Exclude + +function applyGridChildAlignment( + node: GridChildNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const grid = spec.gridChild + if (!grid) return + setValue( + node, + node.gridChildHorizontalAlign, + grid.horizontalAlign, + (value) => (node.gridChildHorizontalAlign = value), + state + ) + setValue( + node, + node.gridChildVerticalAlign, + grid.verticalAlign, + (value) => (node.gridChildVerticalAlign = value), + state + ) +} + +function setGridChildSpans( + node: GridChildNode, + rowSpan: number, + columnSpan: number, + state: ApplyState +): void { + setValue(node, node.gridRowSpan, rowSpan, (value) => (node.gridRowSpan = value), state) + setValue(node, node.gridColumnSpan, columnSpan, (value) => (node.gridColumnSpan = value), state) +} + +function setGridChildPosition( + node: GridChildNode, + row: number, + column: number, + state: ApplyState +): void { + if (node.gridRowAnchorIndex === row && node.gridColumnAnchorIndex === column) return + node.setGridChildPosition(row, column) + markMutation(state, node) +} + +function gridAreasOverlap( + left: { column: number; columnSpan: number; row: number; rowSpan: number }, + right: { column: number; columnSpan: number; row: number; rowSpan: number } +): boolean { + return ( + left.row < right.row + right.rowSpan && + left.row + left.rowSpan > right.row && + left.column < right.column + right.columnSpan && + left.column + left.columnSpan > right.column + ) +} + +type ReconciledGridChild = { + node: GridChildNode + spec: CanvasNodeSpec +} + +export type ReconciledChild = { + node: SupportedCanvasNode + spec: CanvasNodeSpec +} + +function liveGridExtent(node: CanvasFrameContainerNode): { columns: number; rows: number } { + return node.children.reduce( + (extent, child) => + 'gridRowAnchorIndex' in child + ? { + columns: Math.max(extent.columns, child.gridColumnAnchorIndex + child.gridColumnSpan), + rows: Math.max(extent.rows, child.gridRowAnchorIndex + child.gridRowSpan) + } + : extent, + { columns: 1, rows: 1 } + ) +} + +function finalizeManualGrid( + node: CanvasFrameContainerNode, + layout: CanvasGridLayout, + children: ReconciledGridChild[], + state: ApplyState, + forceFinalRowCount = false +): void { + const autoRows = layout.autoRows ?? (layout.rows === undefined && node.gridAutoTracks === 'ROWS') + const rowCount = + layout.rows?.length ?? + (autoRows + ? Math.max(1, ...children.map(({ spec }) => spec.gridChild!.row! + spec.gridChild!.rowSpan)) + : node.gridRowCount) + setValue( + node, + node.gridItemsPositioning, + 'MANUAL' as const, + (value) => (node.gridItemsPositioning = value), + state + ) + + const moving = children.filter(({ node: child, spec }) => { + const grid = spec.gridChild! + return ( + child.gridRowAnchorIndex !== grid.row || + child.gridColumnAnchorIndex !== grid.column || + child.gridRowSpan !== grid.rowSpan || + child.gridColumnSpan !== grid.columnSpan + ) + }) + const desiredAreas = children.map(({ spec }) => { + const grid = spec.gridChild! + return { + column: grid.column!, + columnSpan: grid.columnSpan, + row: grid.row!, + rowSpan: grid.rowSpan + } + }) + const removalBlockers = node.children.filter((child): child is GridChildNode => { + if ( + !state.removalNodeIds.has(child.id) || + !isSupportedSceneNode(child) || + child.type === 'SECTION' + ) { + return false + } + const liveArea = { + column: child.gridColumnAnchorIndex, + columnSpan: child.gridColumnSpan, + row: child.gridRowAnchorIndex, + rowSpan: child.gridRowSpan + } + return desiredAreas.some((area) => gridAreasOverlap(liveArea, area)) + }) + const staging = [...moving.map(({ node: child }) => child), ...removalBlockers] + if (staging.length) { + setValue( + node, + node.gridAutoTracks, + 'NONE' as const, + (value) => (node.gridAutoTracks = value), + state + ) + const stagingStart = Math.max(node.gridRowCount, rowCount) + setValue( + node, + node.gridRowCount, + stagingStart + staging.length, + (value) => (node.gridRowCount = value), + state + ) + for (const [index, child] of staging.entries()) { + setGridChildSpans(child, 1, 1, state) + setGridChildPosition(child, stagingStart + index, 0, state) + } + if (removalBlockers.length) state.pendingGridRemovalCleanupNodeIds.add(node.id) + } + + for (const { node: child, spec } of moving) { + const grid = spec.gridChild! + setGridChildPosition(child, grid.row!, grid.column!, state) + setGridChildSpans(child, grid.rowSpan, grid.columnSpan, state) + } + const extent = liveGridExtent(node) + const finalColumnCount = Math.max(layout.columns.length, extent.columns) + setValue( + node, + node.gridColumnCount, + finalColumnCount, + (value) => (node.gridColumnCount = value), + state + ) + if (!autoRows || moving.length || forceFinalRowCount) { + const finalRowCount = autoRows ? extent.rows : Math.max(rowCount, extent.rows) + setValue(node, node.gridRowCount, finalRowCount, (value) => (node.gridRowCount = value), state) + } + + applyGridTracks( + node, + 'gridColumnSizes', + layout.columns, + state, + finalColumnCount > layout.columns.length + ) + if (layout.rows) { + applyGridTracks( + node, + 'gridRowSizes', + layout.rows, + state, + node.gridRowCount > layout.rows.length + ) + } + if (autoRows && staging.length) { + setValue( + node, + node.gridAutoTracks, + 'ROWS' as const, + (value) => (node.gridAutoTracks = value), + state + ) + } +} + +function finalizeFlowGrid( + node: CanvasFrameContainerNode, + layout: CanvasGridLayout, + children: ReconciledGridChild[], + state: ApplyState +): void { + setValue( + node, + node.gridItemsPositioning, + 'ROW_AUTO_FLOW' as const, + (value) => (node.gridItemsPositioning = value), + state + ) + for (const { node: child, spec } of children) { + const grid = spec.gridChild! + setGridChildSpans(child, grid.rowSpan, grid.columnSpan, state) + } + const extent = liveGridExtent(node) + const finalColumnCount = Math.max(layout.columns.length, extent.columns) + setValue( + node, + node.gridColumnCount, + finalColumnCount, + (value) => (node.gridColumnCount = value), + state + ) + + if (layout.rows) { + const finalRowCount = Math.max(layout.rows.length, extent.rows) + setValue(node, node.gridRowCount, finalRowCount, (value) => (node.gridRowCount = value), state) + applyGridTracks(node, 'gridRowSizes', layout.rows, state, finalRowCount > layout.rows.length) + } + applyGridTracks( + node, + 'gridColumnSizes', + layout.columns, + state, + finalColumnCount > layout.columns.length + ) +} + +export function finalizeGrid( + node: CanvasFrameContainerNode, + spec: CanvasNodeSpec, + children: ReconciledChild[], + state: ApplyState, + forceFinalRowCount = false +): void { + const layout = spec.layout + if (layout?.mode !== 'GRID') return + const gridChildren: ReconciledGridChild[] = children + .filter(({ spec: child }) => child.gridChild) + .map((child) => { + if (child.node.type === 'SECTION') { + specError(`Section "${child.spec.key}" cannot be a child of a grid frame.`) + } + return { ...child, node: child.node } + }) + if ((layout.itemsPositioning ?? node.gridItemsPositioning) === 'MANUAL') { + finalizeManualGrid(node, layout, gridChildren, state, forceFinalRowCount) + } else { + finalizeFlowGrid(node, layout, gridChildren, state) + } + for (const child of gridChildren) { + applyGridChildAlignment(child.node, child.spec, state) + // Figma can reset a grid child's sizing modes while changing its cell or span. + applySizingModes(child.node, child.spec, node, state) + } +} + +export function finalizeGridsAfterRemovals(rootSpec: CanvasNodeSpec, state: ApplyState): void { + if (!state.pendingGridRemovalCleanupNodeIds.size) return + for (const spec of walkSpecs(rootSpec)) { + const node = state.keyedNodes.get(spec.key) + if ( + !node || + !isFrameContainer(node) || + !state.pendingGridRemovalCleanupNodeIds.delete(node.id) + ) { + continue + } + const children = (spec.children ?? []).map((childSpec) => { + const child = state.keyedNodes.get(childSpec.key) + if (!child || child.removed) { + specError(`Desired grid child "${childSpec.key}" was unavailable after node removal.`) + } + return { node: child, spec: childSpec } + }) + finalizeGrid(node, spec, children, state, true) + } + if (state.pendingGridRemovalCleanupNodeIds.size) { + specError('A grid staged for node removal was unavailable for final layout cleanup.') + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/layout.ts b/packages/extension/mcp/tools/canvas/reconcile/layout.ts new file mode 100644 index 00000000..a8853f62 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/layout.ts @@ -0,0 +1,604 @@ +import type { CanvasNodeSpec } from '../model' + +import { specError } from '../errors' +import { reportCanvasPlacement } from '../feedback' +import { CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME } from '../identity' +import { currentBoundVariableId } from './binding-values' +import { GEOMETRY_TOLERANCE, transformsMatch, finiteRect } from './comparison' +import { isIntrinsicNode, isFrameContainer, isSupportedSceneNode, walkSpecs } from './nodes' +import { + type CanvasFrameContainerNode, + type ApplyState, + setValue, + markMutation, + type SupportedCanvasNode, + type CanvasParentNode +} from './state' + +const ROOT_PLACEMENT_GAP = 80 + +const PADDING_FIELDS = [ + ['top', 'paddingTop'], + ['right', 'paddingRight'], + ['bottom', 'paddingBottom'], + ['left', 'paddingLeft'] +] as const + +function applyCounterAxisSpacing( + node: CanvasFrameContainerNode, + desired: number | null | undefined, + state: ApplyState +): void { + if (desired === undefined) return + const synced = + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME) === 'true' + if (desired !== null) { + setValue( + node, + synced ? undefined : node.counterAxisSpacing, + desired, + (value) => (node.counterAxisSpacing = value), + state + ) + if (synced) { + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME, '') + markMutation(state, node) + } + return + } + + if (!synced || !Object.is(node.counterAxisSpacing, node.itemSpacing)) { + node.counterAxisSpacing = null + markMutation(state, node) + } + if (!synced) { + node.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_COUNTER_AXIS_SYNC_NAME, 'true') + markMutation(state, node) + } +} + +export function applyLayout( + node: CanvasFrameContainerNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + const layout = spec.layout + if (!layout) return + const bindings = spec.variables + + setValue(node, node.layoutMode, layout.mode, (value) => (node.layoutMode = value), state) + if (layout.mode === 'NONE') return + + if (layout.padding !== undefined) { + for (const [side, field] of PADDING_FIELDS) { + const desired = typeof layout.padding === 'number' ? layout.padding : layout.padding[side] + setValue( + node, + node[field], + bindings?.[field] || currentBoundVariableId(node, field) ? undefined : desired, + (value) => (node[field] = value), + state + ) + } + } + setValue( + node, + node.strokesIncludedInLayout, + layout.strokesIncluded, + (value) => (node.strokesIncludedInLayout = value), + state + ) + + if (layout.mode === 'GRID') { + setValue( + node, + node.gridRowGap, + bindings?.gridRowGap || currentBoundVariableId(node, 'gridRowGap') + ? undefined + : layout.rowGap, + (value) => (node.gridRowGap = value), + state + ) + setValue( + node, + node.gridColumnGap, + bindings?.gridColumnGap || currentBoundVariableId(node, 'gridColumnGap') + ? undefined + : layout.columnGap, + (value) => (node.gridColumnGap = value), + state + ) + + const rowCount = layout.rows?.length + if (layout.autoRows !== undefined) { + setValue( + node, + node.gridAutoTracks, + layout.autoRows ? ('ROWS' as const) : ('NONE' as const), + (value) => (node.gridAutoTracks = value), + state + ) + } + if (node.gridColumnCount < layout.columns.length) { + setValue( + node, + node.gridColumnCount, + layout.columns.length, + (value) => (node.gridColumnCount = value), + state + ) + } + if (rowCount !== undefined && node.gridRowCount < rowCount) { + setValue(node, node.gridRowCount, rowCount, (value) => (node.gridRowCount = value), state) + } + return + } + + const autoLayout = spec.figma?.autoLayout + setValue( + node, + node.itemSpacing, + bindings?.gap || currentBoundVariableId(node, 'itemSpacing') + ? undefined + : (autoLayout?.itemSpacing ?? layout.gap), + (value) => (node.itemSpacing = value), + state + ) + setValue(node, node.layoutWrap, layout.wrap, (value) => (node.layoutWrap = value), state) + const counterAxisSpacing = + autoLayout?.counterAxisSpacing !== undefined ? autoLayout.counterAxisSpacing : layout.counterGap + if (!bindings?.counterAxisSpacing && !currentBoundVariableId(node, 'counterAxisSpacing')) { + applyCounterAxisSpacing(node, counterAxisSpacing, state) + } + setValue( + node, + node.itemReverseZIndex, + autoLayout?.itemReverseZIndex, + (value) => (node.itemReverseZIndex = value), + state + ) + setValue( + node, + node.primaryAxisAlignItems, + layout.primaryAlign, + (value) => (node.primaryAxisAlignItems = value), + state + ) + setValue( + node, + node.counterAxisAlignItems, + layout.counterAlign, + (value) => (node.counterAxisAlignItems = value), + state + ) + setValue( + node, + node.counterAxisAlignContent, + layout.counterAlignContent, + (value) => (node.counterAxisAlignContent = value), + state + ) +} + +export const SIZE_BOUND_FIELDS = ['minWidth', 'maxWidth', 'minHeight', 'maxHeight'] as const + +export function applySizingModes( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if (isIntrinsicNode(node) || !supportsLayoutSizing(node, parent)) return + const size = spec.size + setValue( + node, + node.layoutSizingHorizontal, + size.horizontal, + (value) => (node.layoutSizingHorizontal = value), + state + ) + setValue( + node, + node.layoutSizingVertical, + size.vertical, + (value) => (node.layoutSizingVertical = value), + state + ) + setValue( + node, + node.layoutGrow, + spec.grow === undefined ? undefined : spec.grow ? 1 : 0, + (value) => (node.layoutGrow = value), + state + ) + + if (!isFrameContainer(node) || node.layoutMode === 'NONE' || node.layoutMode === 'GRID') return + const horizontalMode: 'AUTO' | 'FIXED' = size.horizontal === 'HUG' ? 'AUTO' : 'FIXED' + const verticalMode: 'AUTO' | 'FIXED' = size.vertical === 'HUG' ? 'AUTO' : 'FIXED' + const horizontal = node.layoutMode === 'HORIZONTAL' + setValue( + node, + node.primaryAxisSizingMode, + horizontal ? horizontalMode : verticalMode, + (value) => (node.primaryAxisSizingMode = value), + state + ) + setValue( + node, + node.counterAxisSizingMode, + horizontal ? verticalMode : horizontalMode, + (value) => (node.counterAxisSizingMode = value), + state + ) +} + +type CrossAxisFill = { + axis: 'horizontal' | 'vertical' + recoverySize: number +} + +function clampSize(value: number, min: number | null, max: number | null): number { + return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? 0, value)) +} + +export function layoutStrokeWeight( + node: CanvasFrameContainerNode, + field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' +): number { + const value = node[field] + return typeof value === 'number' + ? value + : typeof node.strokeWeight === 'number' + ? node.strokeWeight + : 0 +} + +export function includedLayoutEdgeStroke( + node: CanvasFrameContainerNode, + field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' +): number { + if ( + !node.strokesIncludedInLayout || + node.strokeAlign !== 'INSIDE' || + !Array.isArray(node.strokes) || + !node.strokes.some((stroke) => stroke.visible !== false) + ) { + return 0 + } + return layoutStrokeWeight(node, field) +} + +function includedLayoutStroke( + node: CanvasFrameContainerNode, + axis: 'horizontal' | 'vertical' +): number { + return axis === 'horizontal' + ? includedLayoutEdgeStroke(node, 'strokeLeftWeight') + + includedLayoutEdgeStroke(node, 'strokeRightWeight') + : includedLayoutEdgeStroke(node, 'strokeTopWeight') + + includedLayoutEdgeStroke(node, 'strokeBottomWeight') +} + +export function crossAxisFill( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent?: SupportedCanvasNode | CanvasParentNode +): CrossAxisFill | null { + if ( + isIntrinsicNode(node) || + !parent || + !isFrameContainer(parent) || + !('layoutPositioning' in node) || + parent.counterAxisSizingMode !== 'FIXED' || + parent.layoutWrap !== 'NO_WRAP' || + node.layoutPositioning !== 'AUTO' + ) { + return null + } + if (parent.layoutMode === 'VERTICAL' && spec.size.horizontal === 'FILL') { + return { + axis: 'horizontal', + recoverySize: Math.max( + GEOMETRY_TOLERANCE, + clampSize( + parent.width - + parent.paddingLeft - + parent.paddingRight - + includedLayoutStroke(parent, 'horizontal'), + node.minWidth, + node.maxWidth + ) + ) + } + } + if (parent.layoutMode === 'HORIZONTAL' && spec.size.vertical === 'FILL') { + return { + axis: 'vertical', + recoverySize: Math.max( + GEOMETRY_TOLERANCE, + clampSize( + parent.height - + parent.paddingTop - + parent.paddingBottom - + includedLayoutStroke(parent, 'vertical'), + node.minHeight, + node.maxHeight + ) + ) + } + } + return null +} + +export function stabilizeCrossAxisFill( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if (!('layoutSizingHorizontal' in node)) return + const fill = crossAxisFill(node, spec, parent) + if (!fill) return + const current = fill.axis === 'horizontal' ? node.width : node.height + if ( + current >= GEOMETRY_TOLERANCE && + (!state.createdNodeIds.has(node.id) || state.stabilizedCrossAxisFillNodeIds.has(node.id)) + ) { + return + } + + if (fill.axis === 'horizontal') { + node.layoutSizingHorizontal = 'FIXED' + node.resize(fill.recoverySize, node.height) + node.layoutSizingHorizontal = 'FILL' + } else { + node.layoutSizingVertical = 'FIXED' + node.resize(node.width, fill.recoverySize) + node.layoutSizingVertical = 'FILL' + } + state.stabilizedCrossAxisFillNodeIds.add(node.id) + markMutation(state, node) +} + +export function stabilizeGrowingTextWidth( + node: SupportedCanvasNode, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if ( + node.type !== 'TEXT' || + node.characters.length === 0 || + node.textAutoResize !== 'HEIGHT' || + node.layoutGrow <= 0 || + node.width > GEOMETRY_TOLERANCE + ) { + return + } + + let width = Math.max(node.minWidth ?? 0, 1) + if (parent && isFrameContainer(parent)) { + if (parent.layoutMode === 'VERTICAL') { + width = Math.max( + width, + parent.width - + parent.paddingLeft - + parent.paddingRight - + includedLayoutStroke(parent, 'horizontal') + ) + } else if (parent.layoutMode === 'HORIZONTAL') { + const flow = parent.children.filter( + ( + child + ): child is SupportedCanvasNode & { + layoutGrow: number + layoutPositioning: 'ABSOLUTE' | 'AUTO' + } => + isSupportedSceneNode(child) && + 'layoutGrow' in child && + 'layoutPositioning' in child && + child.layoutPositioning !== 'ABSOLUTE' + ) + const fixedWidth = flow.reduce( + (total, child) => total + (child.id !== node.id && child.layoutGrow <= 0 ? child.width : 0), + 0 + ) + const growCount = Math.max(1, flow.filter((child) => child.layoutGrow > 0).length) + const available = + parent.width - + parent.paddingLeft - + parent.paddingRight - + includedLayoutStroke(parent, 'horizontal') - + Math.max(0, flow.length - 1) * parent.itemSpacing - + fixedWidth + width = Math.max(width, available / growCount) + } + } + width = Math.max(GEOMETRY_TOLERANCE, clampSize(width, node.minWidth, node.maxWidth)) + + node.layoutSizingHorizontal = 'FIXED' + node.resize(width, node.height) + node.layoutSizingHorizontal = 'FILL' + markMutation(state, node) +} + +export function supportsLayoutSizing( + node: SupportedCanvasNode, + parent: SupportedCanvasNode | CanvasParentNode | undefined +): node is SupportedCanvasNode & LayoutMixin { + return ( + 'layoutSizingHorizontal' in node && + (('layoutMode' in node && node.layoutMode !== 'NONE') || + (!!parent && 'layoutMode' in parent && parent.layoutMode !== 'NONE')) + ) +} + +export function applySize( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + if (isIntrinsicNode(node)) return + const size = spec.size + for (const field of SIZE_BOUND_FIELDS) { + setValue( + node, + node[field], + spec.variables?.[field] || currentBoundVariableId(node, field) ? undefined : size[field], + (value) => (node[field] = value), + state + ) + } + const width = + !spec.variables?.width && !currentBoundVariableId(node, 'width') && size.width !== undefined + ? size.width + : node.width + const height = + !spec.variables?.height && !currentBoundVariableId(node, 'height') && size.height !== undefined + ? size.height + : node.height + if (Math.abs(node.width - width) > 0.01 || Math.abs(node.height - height) > 0.01) { + node.resize(width, height) + markMutation(state, node) + } + applySizingModes(node, spec, parent, state) +} + +export function applyPosition( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode, + state: ApplyState +): void { + if (spec.positioning !== undefined && isFrameContainer(parent) && parent.layoutMode !== 'NONE') { + if (node.type === 'SECTION') { + specError(`Section "${spec.key}" cannot be a child of an Auto Layout frame.`) + } + setValue( + node, + node.layoutPositioning, + spec.positioning, + (value) => (node.layoutPositioning = value), + state + ) + } + if (!spec.position) return + const { right, bottom } = spec.absoluteOffsets ?? {} + const x = + right !== undefined && 'width' in parent ? parent.width - right - node.width : spec.position.x + const y = + bottom !== undefined && 'height' in parent + ? parent.height - bottom - node.height + : spec.position.y + setValue(node, node.x, x, (value) => (node.x = value), state) + setValue(node, node.y, y, (value) => (node.y = value), state) +} + +export function finalizeAbsolutePositions(root: CanvasNodeSpec, state: ApplyState): void { + for (const spec of walkSpecs(root)) { + const offsets = spec.absoluteOffsets + if (!offsets) continue + const node = state.keyedNodes.get(spec.key) + const parent = node?.parent + if (!node || !parent || !('width' in parent)) continue + // Bindings, consumer modes, and layout may change either bound after markup parsing. + if (offsets.right !== undefined) { + setValue(node, node.x, parent.width - offsets.right - node.width, (x) => (node.x = x), state) + } + if (offsets.bottom !== undefined) { + setValue( + node, + node.y, + parent.height - offsets.bottom - node.height, + (y) => (node.y = y), + state + ) + } + } +} + +export function applyRelativeTransform( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): void { + const transform = spec.figma?.relativeTransform + if (!transform) return + const current = node.relativeTransform + const autoLayoutChild = !!parent && isFrameContainer(parent) && parent.layoutMode !== 'NONE' + const desired: Transform = autoLayoutChild + ? [ + [transform[0][0], transform[0][1], current[0][2]], + [transform[1][0], transform[1][1], current[1][2]] + ] + : transform + if (transformsMatch(current, desired)) return + node.relativeTransform = desired + markMutation(state, node) +} + +function placementBounds(node: SceneNode): Rect | null { + const bounds = ('absoluteRenderBounds' in node ? node.absoluteRenderBounds : null) ?? + ('absoluteBoundingBox' in node ? node.absoluteBoundingBox : null) ?? { + x: node.x, + y: node.y, + width: node.width, + height: node.height + } + return finiteRect(bounds) +} + +function placementOverlap(candidate: Rect, obstacle: Rect): boolean { + return ( + candidate.x < obstacle.x + obstacle.width + ROOT_PLACEMENT_GAP && + candidate.x + candidate.width + ROOT_PLACEMENT_GAP > obstacle.x && + candidate.y < obstacle.y + obstacle.height + ROOT_PLACEMENT_GAP && + candidate.y + candidate.height + ROOT_PLACEMENT_GAP > obstacle.y + ) +} + +export function placeCreatedRoot( + node: SupportedCanvasNode, + page: PageNode, + state: ApplyState +): void { + const bounds = placementBounds(node) + if (!bounds) specError(`Created root "${node.id}" has invalid placement bounds.`) + const center = figma.viewport.center + const candidate = { + x: center.x - bounds.width / 2, + y: center.y - bounds.height / 2, + width: bounds.width, + height: bounds.height + } + const obstacles = page.children + .filter((child) => child.id !== node.id) + .map(placementBounds) + .filter((value): value is Rect => value !== null) + .sort((a, b) => a.x - b.x) + + for (const obstacle of obstacles) { + if (!placementOverlap(candidate, obstacle)) continue + candidate.x = obstacle.x + obstacle.width + ROOT_PLACEMENT_GAP + if (!Number.isFinite(candidate.x)) { + specError(`Page "${page.id}" has invalid placement bounds.`) + } + } + + if (obstacles.some((obstacle) => placementOverlap(candidate, obstacle))) { + specError(`Created root "${node.id}" could not be placed without overlap.`) + } + + const deltaX = candidate.x - bounds.x + const deltaY = candidate.y - bounds.y + if (deltaX !== 0 || deltaY !== 0) { + const transform = node.relativeTransform + node.relativeTransform = [ + [transform[0][0], transform[0][1], transform[0][2] + deltaX], + [transform[1][0], transform[1][1], transform[1][2] + deltaY] + ] + markMutation(state, node) + } + reportCanvasPlacement(node) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/native-update.ts b/packages/extension/mcp/tools/canvas/reconcile/native-update.ts new file mode 100644 index 00000000..04f8dd9f --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/native-update.ts @@ -0,0 +1,312 @@ +import type { CanvasBinding, ApplyCanvasResult } from '@tempad-dev/shared' + +import type { CanvasSizingMode, CanvasNodeSpec, ParsedCanvasNativeUpdateInput } from '../model' + +import { resolveCanvasAssets } from '../assets' +import { specError, scopeError, errorMessage } from '../errors' +import { readOwnedNodeKey, pageSnapshot } from '../identity' +import { validateAssetReferences } from '../markup' +import { componentPropertyOwner } from './components' +import { + isFrameContainer, + lookupNodeById, + isSupportedSceneNode, + assertOutsideInstance, + collectKeyedNodes, + containingPage +} from './nodes' +import { + preflightVectorPaths, + preflightResources, + hasDeclaredResources, + preflightDeclaredResources, + prepareMedia, + prepareResources, + applyStyleResources, + removeResources +} from './resources' +import { boundedApplyResult, buildVerification } from './results' +import { type SupportedCanvasNode, type CanvasParentNode, createApplyState } from './state' +import { preflightContainers } from './structure-preflight' +import { collectSvgColorsFromSpecs, finalizeSvgPositions } from './svg' +import { applyNodeProperties, applyCanvasKeyReferences } from './tree' +import { withUndoBoundary } from './undo' +import { verifyAppliedNode } from './verification' +import { + unboundCreatedResourceWarnings, + authoredVariableFallbackWarnings, + layoutWarnings +} from './warnings' + +function preservedSizingMode(value: unknown): CanvasSizingMode { + return value === 'FILL' || value === 'HUG' ? value : 'FIXED' +} + +function validateNativeUpdateBinding( + key: string, + binding: CanvasBinding, + node: SupportedCanvasNode +): void { + const properties = binding.figma + const declaredTypes: Array = [ + binding.component || binding.componentProperties || properties?.instance + ? 'INSTANCE' + : undefined, + properties?.text ? 'TEXT' : undefined, + properties?.shape?.type, + properties?.section ? 'SECTION' : undefined, + properties?.group ? 'GROUP' : undefined, + properties?.booleanOperation ? 'BOOLEAN_OPERATION' : undefined, + properties?.component?.type, + properties?.slot ? 'SLOT' : undefined, + properties?.svg ? 'FRAME' : undefined + ] + for (const type of declaredTypes) { + if (type !== undefined && type !== node.type) { + specError(`Native state on "${key}" requires ${type}, but the existing node is ${node.type}.`) + } + } + const requiredFields: Array<[unknown, string]> = [ + [properties?.stroke, 'strokeAlign'], + [properties?.stroke?.weights, 'strokeTopWeight'], + [properties?.stroke?.cap, 'strokeCap'], + [properties?.stroke?.miterLimit, 'strokeMiterLimit'], + [properties?.corners, 'cornerRadius'], + [properties?.corners?.radii, 'topLeftRadius'], + [properties?.corners?.smoothing, 'cornerSmoothing'], + [properties?.layoutGrids, 'layoutGrids'], + [properties?.guides, 'guides'], + [properties?.aspectRatioLocked, 'targetAspectRatio'] + ] + for (const [value, field] of requiredFields) { + if (value !== undefined && !(field in node)) { + specError(`Native field "${field}" is not supported on ${node.type} node "${key}".`) + } + } + if (properties?.text?.fontName) { + if (binding.variables?.fontFamily || binding.variables?.fontStyle) { + specError(`Font on "${key}" cannot use both variables and an exact Figma font name.`) + } + if (binding.styles?.text) { + specError(`Font on "${key}" cannot use both a Text style and an exact Figma font name.`) + } + } +} + +function nativeUpdateLayout( + key: string, + binding: CanvasBinding, + node: SupportedCanvasNode +): CanvasNodeSpec['layout'] { + const autoLayout = binding.figma?.autoLayout + if (!autoLayout) return + + if ( + !isFrameContainer(node) || + (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') + ) { + specError( + `Figma Auto Layout properties on "${key}" require an existing linear Auto Layout container.` + ) + } + if (autoLayout.counterAxisSpacing !== undefined && node.layoutWrap !== 'WRAP') { + specError( + `Figma counter-axis spacing on "${key}" requires an existing wrapping Auto Layout container.` + ) + } + if (autoLayout.counterAxisSpacing === null && binding.variables?.counterAxisSpacing) { + specError( + `Synchronized counter-axis spacing and a counter-axis variable cannot be combined on "${key}".` + ) + } + return { mode: node.layoutMode } +} + +function nativeUpdateSpec( + key: string, + binding: CanvasBinding, + node: SupportedCanvasNode +): CanvasNodeSpec { + validateNativeUpdateBinding(key, binding, node) + const layout = nativeUpdateLayout(key, binding, node) + const stroke = binding.figma?.stroke + const corners = binding.figma?.corners + const size: CanvasNodeSpec['size'] = { + ...(node.type === 'INSTANCE' + ? {} + : { + width: node.width, + height: node.height, + horizontal: preservedSizingMode( + 'layoutSizingHorizontal' in node ? node.layoutSizingHorizontal : undefined + ), + vertical: preservedSizingMode( + 'layoutSizingVertical' in node ? node.layoutSizingVertical : undefined + ) + }), + ...('minWidth' in node ? { minWidth: node.minWidth } : {}), + ...('maxWidth' in node ? { maxWidth: node.maxWidth } : {}), + ...('minHeight' in node ? { minHeight: node.minHeight } : {}), + ...('maxHeight' in node ? { maxHeight: node.maxHeight } : {}) + } + return { + key, + type: node.type, + ...(binding.figma?.name !== undefined ? { displayName: binding.figma.name } : {}), + size, + ...(layout ? { layout } : {}), + ...(stroke || corners + ? { + appearance: { + strokeWeight: stroke?.weight, + strokeTopWeight: stroke?.weights?.top, + strokeRightWeight: stroke?.weights?.right, + strokeBottomWeight: stroke?.weights?.bottom, + strokeLeftWeight: stroke?.weights?.left, + cornerRadius: corners?.radius, + topLeftRadius: corners?.radii?.topLeft, + topRightRadius: corners?.radii?.topRight, + bottomRightRadius: corners?.radii?.bottomRight, + bottomLeftRadius: corners?.radii?.bottomLeft + } + } + : {}), + ...(node.type !== 'INSTANCE' && 'layoutGrow' in node ? { grow: node.layoutGrow > 0 } : {}), + ...(node.type === 'TEXT' + ? { + text: { + characters: node.characters, + autoResize: node.textAutoResize, + ...(binding.figma?.text?.fontName + ? { + fontFamily: binding.figma.text.fontName.family, + fontStyle: binding.figma.text.fontName.style + } + : {}), + ...(binding.figma?.text?.verticalAlign !== undefined + ? { alignVertical: binding.figma.text.verticalAlign } + : {}) + } + } + : {}), + ...binding + } +} + +function nativeUpdateParent(node: SupportedCanvasNode): CanvasParentNode | undefined { + const parent = node.parent + if (!parent || parent.type === 'DOCUMENT' || parent.type === 'INSTANCE') return undefined + return parent as CanvasParentNode +} + +export async function reconcileNativeUpdate( + input: ParsedCanvasNativeUpdateInput +): Promise { + const candidate = await lookupNodeById(input.targetNodeId) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested update target does not exist or is not a supported scene node.') + } + assertOutsideInstance(candidate) + if (!readOwnedNodeKey(candidate)) { + scopeError('A markup-less native update requires an exact managed root.') + } + + const keyedNodes = collectKeyedNodes(candidate) + const specsByKey = new Map() + for (const [key, binding] of Object.entries(input.bindings)) { + const node = keyedNodes.get(key) + if (!node) scopeError(`Canvas key "${key}" does not exist inside the update scope.`) + assertOutsideInstance(node) + if (binding.figma?.mask !== undefined) { + specError( + `Mask state on "${key}" requires a structural markup update that describes its sibling scope.` + ) + } + specsByKey.set(key, nativeUpdateSpec(key, binding, node)) + } + // Apply owners before sublayers, regardless of the binding order. + const specs: CanvasNodeSpec[] = [] + for (const key of keyedNodes.keys()) { + const spec = specsByKey.get(key) + if (spec) specs.push(spec) + } + try { + validateAssetReferences(specs, input.assets, input.styles) + } catch (error) { + specError(errorMessage(error)) + } + const assets = await resolveCanvasAssets(input.assets, collectSvgColorsFromSpecs(specs)) + const state = createApplyState(candidate, new Set(Object.keys(input.bindings)), assets) + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + state.nodeIdsByKey[spec.key] = node.id + preflightVectorPaths(spec) + preflightContainers(spec, state, node) + } + + const preflight = async (): Promise => { + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + const owner = componentPropertyOwner(node) + const inherited = owner + ? { existing: owner, spec: specsByKey.get(readOwnedNodeKey(owner) ?? '') } + : undefined + await preflightResources(spec, state, node, inherited) + } + } + const declaresResources = hasDeclaredResources(input) + if (declaresResources) await preflightDeclaredResources(specs, input, state) + if (!declaresResources) await preflight() + await prepareMedia(specs, input, state) + return withUndoBoundary(async () => { + if (declaresResources) { + await prepareResources(input, state) + await preflight() + } + applyStyleResources(state) + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + await applyNodeProperties(node, spec, state, nativeUpdateParent(node), true) + } + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + await applyCanvasKeyReferences(spec, state, nativeUpdateParent(node)) + } + finalizeSvgPositions(specs, state) + await removeResources(state) + + const verified = { nodes: 0, references: 0, nativeFields: 0 } + for (const spec of specs) { + const node = state.keyedNodes.get(spec.key)! + const result = await verifyAppliedNode( + spec, + node, + state, + isSupportedSceneNode(node.parent) ? node.parent : undefined + ) + verified.nodes += result.nodes + verified.references += result.references + verified.nativeFields += result.nativeFields + } + const warnings = [ + ...unboundCreatedResourceWarnings([...specs, input.styles, input.variableCollections], state), + ...authoredVariableFallbackWarnings(specs, input.variableCollections), + ...layoutWarnings(specs, state) + ] + return boundedApplyResult({ + rootNodeId: candidate.id, + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [], + updatedNodeIds: [...state.updatedNodeIds], + removedNodeIds: [], + page: pageSnapshot(containingPage(candidate)), + mutationCount: state.mutations.count, + verification: buildVerification( + verified.nodes, + verified.references, + verified.nativeFields, + warnings + ) + }) + }, state) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/native-values.ts b/packages/extension/mcp/tools/canvas/reconcile/native-values.ts new file mode 100644 index 00000000..60755de7 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/native-values.ts @@ -0,0 +1,475 @@ +import type { + CanvasFigmaShaderPropertyValue, + CanvasFigmaPaint, + CanvasFigmaEffect, + CanvasFigmaLayoutGrid, + CanvasVariableReference +} from '@tempad-dev/shared' + +import type { ApplyState } from './state' + +import { applyError } from '../errors' +import { resolvedVariable } from '../variables' +import { isRecord, isVariableBinding, nativeValueEqual, scalarValuesEqual } from './comparison' +import { resolveCanvasKey } from './nodes' + +function nativeShaderValue( + value: CanvasFigmaShaderPropertyValue, + state: ApplyState +): ShaderPropertyValue { + if (!isRecord(value)) return value + if (isVariableBinding(value)) { + return figma.variables.createVariableAlias(resolvedVariable(value.variable, state.variables)) + } + if ('color' in value) { + return { + ...value, + color: nativeShaderValue(value.color as CanvasFigmaShaderPropertyValue, state) as + | RGB + | RGBA + | VariableAlias + } + } + if ('stops' in value) { + return { + stops: ( + value.stops as Array<{ + position: number + color: CanvasFigmaShaderPropertyValue + }> + ).map((stop) => ({ + position: stop.position, + color: nativeShaderValue(stop.color, state) as RGB | RGBA | VariableAlias + })) + } + } + return value +} + +function nativeShaderProperties( + id: string, + values: Record | undefined, + state: ApplyState +): Record | undefined { + const shader = state.shaderCache.get(id) + if (!shader) applyError(`Shader "${id}" was not preflighted.`) + const properties = Object.fromEntries([ + ...Object.entries(shader.propertyDefinitions ?? {}) + .filter(([, definition]) => definition.defaultValue !== undefined) + .map(([propertyId, definition]) => [propertyId, definition.defaultValue!]), + ...Object.entries(values ?? {}).map(([propertyId, value]) => [ + propertyId, + nativeShaderValue(value, state) + ]) + ]) as Record + return Object.keys(properties).length ? properties : undefined +} + +function paintDefaults(paint: { visible?: boolean; opacity?: number; blendMode?: BlendMode }) { + return { + visible: paint.visible ?? true, + opacity: paint.opacity ?? 1, + blendMode: paint.blendMode ?? 'NORMAL' + } as const +} + +function requiredMediaHash(hashes: ReadonlyMap, key: string): string { + const hash = hashes.get(key) + if (!hash) + applyError( + 'A preflighted media import is missing. This is an internal apply failure; do not retry the same payload.' + ) + return hash +} + +export function nativePaint(paint: CanvasFigmaPaint, state: ApplyState): Paint { + switch (paint.type) { + case 'SOLID': { + const { variables, ...fields } = paint + const value: SolidPaint = { + ...fields, + ...paintDefaults(fields) + } + return variables + ? figma.variables.setBoundVariableForPaint( + value, + 'color', + resolvedVariable(variables.color, state.variables) + ) + : value + } + case 'GRADIENT_LINEAR': + case 'GRADIENT_RADIAL': + case 'GRADIENT_ANGULAR': + case 'GRADIENT_DIAMOND': + return { + ...paint, + gradientStops: paint.gradientStops.map(({ variables, ...stop }) => ({ + ...stop, + ...(variables + ? { + boundVariables: { + color: figma.variables.createVariableAlias( + resolvedVariable(variables.color, state.variables) + ) + } + } + : {}) + })), + ...paintDefaults(paint) + } + case 'IMAGE': { + const { assetKey, imageUrl, ...fields } = paint + return { + ...fields, + imageHash: + assetKey !== undefined + ? requiredMediaHash(state.media.imageHashes, `asset:${assetKey}`) + : imageUrl === undefined + ? (fields.imageHash ?? null) + : requiredMediaHash(state.media.imageHashes, imageUrl), + ...paintDefaults(paint) + } + } + case 'VIDEO': { + const { videoUrl, ...fields } = paint + return { + ...fields, + videoHash: + videoUrl === undefined + ? (fields.videoHash ?? null) + : requiredMediaHash(state.media.videoHashes, videoUrl), + ...paintDefaults(paint) + } + } + case 'PATTERN': { + const { sourceCanvasKey, ...fields } = paint + return { + ...fields, + sourceNodeId: + sourceCanvasKey === undefined + ? fields.sourceNodeId! + : resolveCanvasKey(sourceCanvasKey, state).id, + ...paintDefaults(paint) + } + } + case 'SHADER': { + const { properties: values, ...fields } = paint + const properties = nativeShaderProperties(paint.id, values, state) + return { + ...fields, + ...paintDefaults(paint), + ...(properties ? { properties } : {}) + } + } + } +} + +function bindEffectVariables( + effect: Effect, + bindings: + | NonNullable['variables']> + | undefined, + state: ApplyState +): Effect { + let bound = effect + for (const [field, reference] of Object.entries(bindings ?? {})) { + bound = figma.variables.setBoundVariableForEffect( + bound, + field as VariableBindableEffectField, + resolvedVariable(reference, state.variables) + ) + } + return bound +} + +export function nativeEffect(effect: CanvasFigmaEffect, state: ApplyState): Effect { + switch (effect.type) { + case 'DROP_SHADOW': + case 'INNER_SHADOW': { + const { variables, ...fields } = effect + return bindEffectVariables( + { + ...fields, + ...(variables?.spread !== undefined && fields.spread === undefined ? { spread: 0 } : {}), + visible: fields.visible ?? true, + blendMode: fields.blendMode ?? 'NORMAL' + }, + variables, + state + ) + } + case 'LAYER_BLUR': + case 'BACKGROUND_BLUR': { + const { variables, ...fields } = effect + return bindEffectVariables({ ...fields, visible: fields.visible ?? true }, variables, state) + } + case 'NOISE': + return { + ...effect, + visible: effect.visible ?? true, + blendMode: effect.blendMode ?? 'NORMAL' + } + case 'TEXTURE': + case 'GLASS': + return { ...effect, visible: effect.visible ?? true } + case 'SHADER': { + const properties = nativeShaderProperties(effect.id, effect.properties, state) + return { + type: 'SHADER', + id: effect.id, + visible: effect.visible ?? true, + ...(properties ? { properties } : {}) + } + } + } +} + +export function nativeLayoutGrid(grid: CanvasFigmaLayoutGrid, state: ApplyState): LayoutGrid { + const { variables, ...fields } = grid + let native: LayoutGrid = + fields.pattern === 'GRID' + ? fields + : { + ...fields, + count: fields.count === 'AUTO' ? Infinity : fields.count + } + for (const [field, reference] of Object.entries(variables ?? {}) as Array< + [VariableBindableLayoutGridField, CanvasVariableReference] + >) { + native = figma.variables.setBoundVariableForLayoutGrid( + native, + field, + resolvedVariable(reference, state.variables) + ) + } + return native +} + +function comparableLayoutGrid(grid: LayoutGrid, expected: LayoutGrid): Record { + const bindings: RowsColsLayoutGrid['boundVariables'] = expected.boundVariables + const comparable = Object.fromEntries( + Object.keys(expected) + .filter( + (field) => + !['boundVariables', 'color', 'visible'].includes(field) && + !bindings?.[field as VariableBindableLayoutGridField] + ) + .map((field) => [field, grid[field as keyof LayoutGrid]]) + ) + return { + ...comparable, + visible: grid.visible ?? true, + boundVariables: grid.boundVariables ?? {} + } +} + +export function layoutGridsEqual( + current: readonly LayoutGrid[], + desired: readonly LayoutGrid[] +): boolean { + return ( + current.length === desired.length && + current.every((grid, index) => { + const expected = desired[index]! + return ( + // Grid colors may be stored as byte channels; this does not justify + // rounding grid dimensions or accepting arbitrary nearby color values. + (expected.color === undefined || + nativeValueEqual( + grid.color, + expected.color, + (actual, value) => + scalarValuesEqual(actual, value) || + scalarValuesEqual(actual, Math.round(value * 255) / 255) + )) && + nativeValueEqual( + comparableLayoutGrid(grid, expected), + comparableLayoutGrid(expected, expected), + scalarValuesEqual + ) + ) + }) + ) +} + +const IMAGE_FILTER_FIELDS = [ + 'exposure', + 'contrast', + 'saturation', + 'temperature', + 'tint', + 'highlights', + 'shadows' +] as const satisfies ReadonlyArray + +export function comparablePaint(paint: Paint, expected: Paint): unknown { + if (paint.type === 'SOLID' && expected.type === 'SOLID' && expected.boundVariables?.color) { + // Figma replaces both RGB and opacity with the bound COLOR variable's value. + const { + color: _color, + opacity: _opacity, + ...boundPaint + } = { + ...paint, + ...paintDefaults(paint) + } + return boundPaint + } + if ('gradientStops' in paint && 'gradientStops' in expected) { + return { + ...paint, + ...paintDefaults(paint), + gradientStops: paint.gradientStops.map((stop, index) => { + if (!expected.gradientStops[index]?.boundVariables?.color) return stop + const { color: _color, ...boundStop } = stop + return boundStop + }) + } + } + if (paint.type === 'IMAGE' && expected.type === 'IMAGE') { + return { + type: paint.type, + imageHash: paint.imageHash, + scaleMode: paint.scaleMode, + ...paintDefaults(paint), + filters: Object.fromEntries( + IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) + ), + ...(paint.scaleMode === 'CROP' && expected.imageTransform !== undefined + ? { imageTransform: paint.imageTransform } + : {}), + ...(paint.scaleMode === 'TILE' && expected.scalingFactor !== undefined + ? { scalingFactor: paint.scalingFactor } + : {}), + ...(paint.scaleMode === 'CROP' ? {} : { rotation: paint.rotation ?? 0 }) + } + } + if (paint.type === 'VIDEO' && expected.type === 'VIDEO') { + return { + type: paint.type, + videoHash: paint.videoHash, + scaleMode: paint.scaleMode, + ...paintDefaults(paint), + filters: Object.fromEntries( + IMAGE_FILTER_FIELDS.map((field) => [field, paint.filters?.[field] ?? 0]) + ), + ...(paint.scaleMode === 'CROP' && expected.videoTransform !== undefined + ? { videoTransform: paint.videoTransform } + : {}), + ...(paint.scaleMode === 'TILE' && expected.scalingFactor !== undefined + ? { scalingFactor: paint.scalingFactor } + : {}), + ...(paint.scaleMode === 'CROP' ? {} : { rotation: paint.rotation ?? 0 }) + } + } + return { + ...paint, + ...paintDefaults(paint) + } +} + +export function paintStacksEqual(current: readonly Paint[], desired: readonly Paint[]): boolean { + return ( + current.length === desired.length && + current.every((paint, index) => { + const expected = desired[index]! + return ( + paint.type === expected.type && + nativeValueEqual( + comparablePaint(paint, expected), + comparablePaint(expected, expected), + scalarValuesEqual + ) + ) + }) + ) +} + +function isShadowEffect(effect: Effect): effect is DropShadowEffect | InnerShadowEffect { + return effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW' +} + +function comparableShadow( + effect: DropShadowEffect | InnerShadowEffect, + expected: DropShadowEffect | InnerShadowEffect +): Effect { + if (effect.type !== 'DROP_SHADOW' || expected.type !== 'DROP_SHADOW') { + return { ...effect, spread: effect.spread ?? 0 } + } + if (expected.showShadowBehindNode !== undefined) { + return { + ...effect, + spread: effect.spread ?? 0, + showShadowBehindNode: effect.showShadowBehindNode ?? false + } + } + const { showShadowBehindNode: _showShadowBehindNode, ...withoutBehindNode } = effect + return { ...withoutBehindNode, spread: effect.spread ?? 0 } +} + +function comparableEffect(effect: Effect, expected: Effect): unknown { + if (isShadowEffect(effect) && isShadowEffect(expected)) { + const bindings = expected.boundVariables + const comparable: Record = { ...comparableShadow(effect, expected) } + // Native readback resolves bound literals; binding identity and every other + // field still describe the requested state, including each unbound offset axis. + if (bindings?.color) delete comparable.color + if (bindings?.radius) delete comparable.radius + if (bindings?.spread) delete comparable.spread + comparable.offset = { + ...(bindings?.offsetX ? {} : { x: effect.offset.x }), + ...(bindings?.offsetY ? {} : { y: effect.offset.y }) + } + return comparable + } + if ( + (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') && + (expected.type === 'LAYER_BLUR' || expected.type === 'BACKGROUND_BLUR') && + expected.boundVariables?.radius + ) { + const { radius: _radius, ...boundEffect } = effect + return boundEffect + } + return effect +} + +function effectEqual(current: Effect, desired: Effect): boolean { + return ( + current.type === desired.type && + nativeValueEqual( + comparableEffect(current, desired), + comparableEffect(desired, desired), + scalarValuesEqual + ) + ) +} + +export function effectsEqual(current: readonly Effect[], desired: readonly Effect[]): boolean { + return ( + current.length === desired.length && + current.every((effect, index) => effectEqual(effect, desired[index]!)) + ) +} + +function summarizeNativeValue(value: unknown): string { + const serialized = JSON.stringify(value) + if (serialized === undefined) return String(value) + return serialized.length <= 400 ? serialized : `${serialized.slice(0, 397)}...` +} + +export function describeEffectMismatch( + current: readonly Effect[], + desired: readonly Effect[] +): string { + if (current.length !== desired.length) { + return `expected ${desired.length} effect${desired.length === 1 ? '' : 's'}, found ${current.length}.` + } + const index = current.findIndex( + (effect, effectIndex) => !effectEqual(effect, desired[effectIndex]!) + ) + if (index < 0) return 'effect stack changed before verification completed.' + const expected = desired[index]! + const found = current[index]! + return `effect ${index} does not match; expected ${summarizeNativeValue(comparableEffect(expected, expected))}, found ${summarizeNativeValue(comparableEffect(found, expected))}.` +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/nodes.ts b/packages/extension/mcp/tools/canvas/reconcile/nodes.ts new file mode 100644 index 00000000..50ac412a --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/nodes.ts @@ -0,0 +1,326 @@ +import type { CanvasFigmaPaint, CanvasHyperlink } from '@tempad-dev/shared' + +import type { + SupportedCanvasNode, + WrappedContainerSpec, + IntrinsicContainerNode, + CanvasParentNode, + CanvasFrameContainerNode, + ApplyState +} from './state' + +import { getCurrentContextNodeById, getNodeById, getContainingPage } from '../../../local-resources' +import { scopeError, specError } from '../errors' +import { readOwnedNodeKey, pagesByKey, assertNodeKey } from '../identity' +import { + isCanvasNodeType, + type CanvasNodeTypeHints, + type CanvasPreservedNodeType, + type CanvasGridHint, + isPreservedNodeType, + type CanvasNodeSpec, + isIntrinsicContainer, + isFrameContainerType +} from '../model' +import { walkAuthoringNodes, isInsideInstance } from '../traversal' + +export function isSupportedSceneNode(node: BaseNode | null): node is SupportedCanvasNode { + return !!node && isCanvasNodeType(node.type) +} + +export function isSceneNode(node: BaseNode | null): node is SceneNode { + return !!node && 'x' in node && 'y' in node +} + +export async function lookupNodeById(id: string): Promise { + const node = getCurrentContextNodeById(id) ?? (await getNodeById(id)) + return node && !node.removed ? node : null +} + +export async function collectUpdateNodeTypeHints( + targetNodeId: string +): Promise { + const target = await lookupNodeById(targetNodeId) + if (!isSupportedSceneNode(target)) return undefined + + const byKey = new Map() + const byNodeId = new Map() + const keys = new Set() + const grids = new Map() + for (const node of walkAuthoringNodes([target])) { + const key = readOwnedNodeKey(node) + if (key) keys.add(key) + if (isSupportedSceneNode(node) && isFrameContainer(node) && node.layoutMode === 'GRID') { + const grid = { + itemsPositioning: node.gridItemsPositioning, + children: node.children.flatMap((child) => + 'gridRowAnchorIndex' in child && child.layoutPositioning !== 'ABSOLUTE' + ? [ + { + id: child.id, + key: readOwnedNodeKey(child), + row: child.gridRowAnchorIndex, + column: child.gridColumnAnchorIndex, + rowSpan: child.gridRowSpan, + columnSpan: child.gridColumnSpan + } + ] + : [] + ) + } + grids.set(node.id, grid) + if (key) grids.set(key, grid) + } + if (!isPreservedNodeType(node.type)) continue + const type = node.type + byNodeId.set(node.id, type) + if (key && !byKey.has(key)) byKey.set(key, type) + } + + const root = isPreservedNodeType(target.type) ? target.type : undefined + return { byKey, byNodeId, keys, grids, ...(root ? { root } : {}) } +} + +export function isMaskNode(node: SceneNode): boolean { + return 'isMask' in node && node.isMask +} + +export function isWrappedSpec(spec: CanvasNodeSpec): spec is WrappedContainerSpec { + return spec.type === 'BOOLEAN_OPERATION' || spec.type === 'COMPONENT_SET' || spec.type === 'GROUP' +} + +export function isIntrinsicNode(node: SupportedCanvasNode): node is IntrinsicContainerNode { + return isIntrinsicContainer(node.type) +} + +export function isFrameContainer( + node: SupportedCanvasNode | CanvasParentNode +): node is CanvasFrameContainerNode { + return isFrameContainerType(node.type) +} + +export function isWithinScope(node: BaseNode, scope: BaseNode): boolean { + let current: BaseNode | null = node + while (current) { + if (current.id === scope.id) return true + current = current.parent + } + return false +} + +export function assertOutsideInstance(node: BaseNode): void { + if (isInsideInstance(node)) { + scopeError(`Node "${node.id}" is inside an instance and cannot be targeted by apply_canvas.`) + } +} + +export function containingPage(node: BaseNode): PageNode { + const page = getContainingPage(node) + if (page) return page + scopeError(`Node "${node.id}" is not attached to a page.`) +} + +export function pageByKey(key: string): PageNode | undefined { + const matches = pagesByKey(key) + if (matches.length > 1) specError(`Page key "${key}" identifies more than one local page.`) + return matches[0] +} + +export function collectKeyedNodes(scope: SupportedCanvasNode): Map { + const keyed = new Map() + for (const node of walkAuthoringNodes([scope])) { + if (!isSupportedSceneNode(node)) continue + const key = readOwnedNodeKey(node) + if (!key) continue + if (keyed.has(key)) { + scopeError( + `Canvas key "${key}" is duplicated on nodes "${keyed.get(key)?.id}" and "${node.id}" inside the update scope. Narrow targetNodeId to one managed subtree, or ask the user to remove the duplicate.` + ) + } + keyed.set(key, node) + } + return keyed +} + +export function* walkSpecs(spec: CanvasNodeSpec): Generator { + yield spec + for (const child of spec.children ?? []) yield* walkSpecs(child) +} + +export function collectDesiredKeys(root: CanvasNodeSpec): Set { + return new Set([...walkSpecs(root)].map((spec) => spec.key)) +} + +export async function resolveExplicitNodes(root: CanvasNodeSpec, state: ApplyState): Promise { + const ids = new Set() + for (const spec of walkSpecs(root)) { + if (spec.nodeId) ids.add(spec.nodeId) + } + const orderedIds = [...ids] + const nodes = await Promise.all(orderedIds.map(lookupNodeById)) + for (const [index, id] of orderedIds.entries()) { + const node = nodes[index] ?? null + const supported = isSupportedSceneNode(node) ? node : null + if (supported) assertOutsideInstance(supported) + state.explicitNodes.set(id, supported) + } +} + +export function resolveCanvasKey(key: string, state: ApplyState): SupportedCanvasNode { + const node = state.keyedNodes.get(key) ?? null + if (!isSupportedSceneNode(node)) { + specError(`Canvas key "${key}" did not resolve to a reconciled scene node.`) + } + state.referencedNodeIds.add(node.id) + return node +} + +export function outermostNodes(nodes: SupportedCanvasNode[]): SupportedCanvasNode[] { + const ids = new Set(nodes.map((node) => node.id)) + return nodes.filter((node) => { + let parent = node.parent + while (parent) { + if (ids.has(parent.id)) return false + parent = parent.parent + } + return true + }) +} + +export function findExistingNode( + spec: CanvasNodeSpec, + state: ApplyState, + forcedNode?: SupportedCanvasNode +): SupportedCanvasNode | null { + if (forcedNode) return forcedNode + if (spec.nodeId) { + return state.explicitNodes.get(spec.nodeId) ?? null + } + return state.keyedNodes.get(spec.key) ?? null +} + +function validateExistingNodeIdentity( + spec: CanvasNodeSpec, + state: ApplyState, + node: SupportedCanvasNode +): void { + const keyedNode = state.keyedNodes.get(spec.key) + if (keyedNode && keyedNode.id !== node.id) { + specError( + `Canvas key "${spec.key}" already identifies node "${keyedNode.id}", not "${node.id}".` + ) + } + if (state.scope && !isWithinScope(node, state.scope)) { + scopeError(`Node "${node.id}" is outside the requested update scope.`) + } + assertNodeKey(node.id, spec.key, readOwnedNodeKey(node)) + if (node.type !== spec.type) { + const recovery = + spec.type === 'INSTANCE' + ? ' A keyed primitive cannot become an instance in place; give the instance a new key and remove the primitive key in the same update.' + : node.type === 'INSTANCE' + ? ' Omit this keyed subtree to preserve the instance in a partial ancestor update, or include its component binding when the instance itself is part of the desired result.' + : '' + specError( + `Canvas key "${spec.key}" expects ${spec.type}, but node "${node.id}" is ${node.type}.${recovery}` + ) + } +} + +export function preflightExistingNodeIdentities( + root: CanvasNodeSpec, + state: ApplyState, + target: SupportedCanvasNode | null +): void { + const claimed = new Set() + const visit = (spec: CanvasNodeSpec, forcedNode?: SupportedCanvasNode): void => { + const node = findExistingNode(spec, state, forcedNode) + if (!node) { + if (spec.nodeId) { + scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) + } + } else { + validateExistingNodeIdentity(spec, state, node) + if (claimed.has(node.id)) { + specError(`Node "${node.id}" is referenced more than once in the desired result.`) + } + claimed.add(node.id) + } + for (const child of spec.children ?? []) visit(child) + } + visit(root, target ?? undefined) +} + +export function resolveExistingNode( + spec: CanvasNodeSpec, + state: ApplyState, + forcedNode?: SupportedCanvasNode +): SupportedCanvasNode | null { + const node = findExistingNode(spec, state, forcedNode) + if (!node) { + if (spec.nodeId) { + scopeError(`Node "${spec.nodeId}" does not exist or is not supported by apply_canvas.`) + } + return null + } + + validateExistingNodeIdentity(spec, state, node) + if (state.claimedNodeIds.has(node.id)) { + specError(`Node "${node.id}" is referenced more than once in the desired result.`) + } + state.claimedNodeIds.add(node.id) + return node +} + +function hasCanvasKeyPattern(paints: CanvasFigmaPaint[] | undefined): boolean { + return ( + paints?.some((paint) => paint.type === 'PATTERN' && paint.sourceCanvasKey !== undefined) ?? + false + ) +} + +export function hasCanvasKeyPaints(spec: CanvasNodeSpec): boolean { + return hasCanvasKeyPattern(spec.figma?.fills) || hasCanvasKeyPattern(spec.figma?.strokes) +} + +export function hasCanvasKeyVectorPattern(spec: CanvasNodeSpec): boolean { + return ( + spec.figma?.shape?.type === 'VECTOR' && + (spec.figma.shape.network?.regions?.some((region) => hasCanvasKeyPattern(region.fills)) ?? + false) + ) +} + +export function isCanvasKeyHyperlink( + hyperlink: CanvasHyperlink | undefined +): hyperlink is { type: 'NODE'; value: { canvasKey: string } } { + return hyperlink?.type === 'NODE' && typeof hyperlink.value !== 'string' +} + +export function hasDeferredTextRanges(spec: CanvasNodeSpec): boolean { + const ranges = spec.figma?.text?.ranges + return ( + ranges !== undefined && + (hasCanvasKeyPaints(spec) || + isCanvasKeyHyperlink(spec.figma?.text?.hyperlink) || + ranges.some( + (range) => hasCanvasKeyPattern(range.fills) || isCanvasKeyHyperlink(range.hyperlink) + )) + ) +} + +export function nativeHyperlink( + hyperlink: CanvasHyperlink | undefined, + state: ApplyState +): HyperlinkTarget | null | undefined { + if (hyperlink === undefined || hyperlink === null || hyperlink.type === 'URL') { + return hyperlink + } + return { + type: 'NODE', + value: + typeof hyperlink.value === 'string' + ? hyperlink.value + : resolveCanvasKey(hyperlink.value.canvasKey, state).id + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/pages.ts b/packages/extension/mcp/tools/canvas/reconcile/pages.ts new file mode 100644 index 00000000..8ba2cc14 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/pages.ts @@ -0,0 +1,302 @@ +import type { CanvasPageProperties, ApplyCanvasResult } from '@tempad-dev/shared' + +import type { ParsedCanvasPageInput } from '../model' + +import { specError, scopeError, applyError } from '../errors' +import { + pageById, + CANVAS_KEY_NAMESPACE, + CANVAS_PAGE_KEY_NAME, + readOwnedNodeKey, + pageSnapshot +} from '../identity' +import { walkPhysicalNodes } from '../traversal' +import { applyGuides } from './appearance' +import { paintStacksEqual } from './native-values' +import { + containingPage, + pageByKey, + lookupNodeById, + isSceneNode, + isSupportedSceneNode +} from './nodes' +import { + walkRemovalOwnershipNodes, + validateRemovalAncestors, + validateRemovalOwnership, + validateRemovalComponents, + validateRemovalReferences +} from './removals' +import { preflightVariableModes } from './resources' +import { pageApplyResult, boundedApplyResult, buildVerification } from './results' +import { + type SupportedCanvasNode, + type ApplyState, + markMutation, + moveIntoParent, + setValue, + createApplyState +} from './state' +import { protectNode, protectUnrelatedPageRoots, withUndoBoundary } from './undo' +import { applyVariableModes, preloadFirstPageModeFonts } from './variable-modes' + +export async function prepareResultPage( + properties: CanvasPageProperties | undefined, + target: SupportedCanvasNode | null +): Promise { + const containing = target ? containingPage(target) : figma.currentPage + const id = properties?.id + const key = properties?.pageKey + const explicit = id ? pageById(id) : undefined + if (id && !explicit) specError(`Page "${id}" does not exist.`) + const keyed = key ? pageByKey(key) : undefined + if (explicit && keyed && explicit.id !== keyed.id) { + specError(`Page key "${key}" does not identify "${explicit.id}".`) + } + + let page = explicit ?? keyed + if (target) { + if (page && page.id !== containing.id) { + scopeError(`The update target belongs to page "${containing.id}", not "${page.id}".`) + } + page = containing + } + const createsPage = !page && key !== undefined + if (createsPage && properties?.name === undefined) { + specError(`New page "${key}" requires a name.`) + } + const index = properties?.index + const maxIndex = figma.root.children.length - (createsPage ? 0 : 1) + if (index !== undefined && index > maxIndex) { + specError(`Page index ${index} exceeds the maximum index ${maxIndex}.`) + } + if (createsPage) return undefined + page ??= containing + if (page.id !== figma.currentPage.id) await page.loadAsync() + const currentKey = key ? page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) : '' + if (key && currentKey && currentKey !== key) { + specError(`Page "${page.id}" is already owned by authoring key "${currentKey}".`) + } + return page +} + +export function applyResultPage( + page: PageNode | undefined, + properties: CanvasPageProperties | undefined, + state: ApplyState +): PageNode { + if (!page) { + page = figma.createPage() + state.createdPageIds.add(page.id) + state.mutations.count += 1 + } else { + protectNode(state, page) + if (!state.scope) protectUnrelatedPageRoots(state, page) + } + const key = properties?.pageKey + const currentKey = key ? page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) : '' + // Other preflight work can yield after the page was prepared. + if (key && currentKey && currentKey !== key) { + specError(`Page "${page.id}" is already owned by authoring key "${currentKey}".`) + } + if (key && !currentKey) { + page.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME, key) + markMutation(state, page) + } + return page +} + +export async function applyPage( + page: PageNode, + properties: CanvasPageProperties, + state: ApplyState +): Promise { + protectNode(state, page) + if (properties.index !== undefined) { + moveIntoParent(page, figma.root, properties.index, state) + } + setValue(page, page.name, properties.name, (value) => (page.name = value), state) + if (properties.background) { + const { a: opacity, ...color } = properties.background + const background: SolidPaint[] = [{ type: 'SOLID', color, opacity }] + if (!paintStacksEqual(page.backgrounds, background)) { + page.backgrounds = background + markMutation(state, page) + } + } + applyGuides(page, properties.guides, state) + await applyVariableModes(page, properties.variableModes, state) +} + +async function resolveExactPage(properties: CanvasPageProperties): Promise { + const explicit = properties.id ? pageById(properties.id) : undefined + if (properties.id && !explicit) specError(`Page "${properties.id}" does not exist.`) + const keyed = properties.pageKey ? pageByKey(properties.pageKey) : undefined + if (properties.pageKey && !keyed) { + specError(`Page key "${properties.pageKey}" does not identify a local page.`) + } + if (explicit && keyed && explicit.id !== keyed.id) { + specError(`Page key "${properties.pageKey}" does not identify "${explicit.id}".`) + } + const page = explicit ?? keyed + if (!page) specError('An exact page id or pageKey is required.') + if (page.id !== figma.currentPage.id) await page.loadAsync() + return page +} + +async function resolvePageSelection(page: PageNode, ids: string[]): Promise { + const nodes: SceneNode[] = [] + const seen = new Set() + for (const id of ids) { + if (seen.has(id)) specError(`Selection node "${id}" is duplicated.`) + seen.add(id) + const node = await lookupNodeById(id) + if (!isSceneNode(node) || node.removed || !node.visible) { + scopeError(`Selection node "${id}" does not exist, is hidden, or is not a scene node.`) + } + if (containingPage(node).id !== page.id) { + scopeError(`Selection node "${id}" does not belong to page "${page.id}".`) + } + nodes.push(node) + } + return nodes +} + +function collectPageRemovalRoots(page: PageNode, state: ApplyState): SupportedCanvasNode[] { + const roots: SupportedCanvasNode[] = [] + for (const child of page.children) { + if (!isSupportedSceneNode(child)) { + scopeError(`Page "${page.id}" contains an unsupported canvas root.`) + } + roots.push(child) + for (const node of walkRemovalOwnershipNodes([child], state)) { + if (!isSupportedSceneNode(node)) { + scopeError(`Page "${page.id}" contains an unsupported canvas node.`) + } + const key = readOwnedNodeKey(node) + if (!key) { + scopeError(`Page "${page.id}" contains content not owned by apply_canvas.`) + } + const existing = state.keyedNodes.get(key) + if (existing && existing.id !== node.id) { + scopeError(`Canvas key "${key}" is duplicated on page "${page.id}".`) + } + state.keyedNodes.set(key, node) + } + } + for (const root of roots) { + validateRemovalAncestors(root) + validateRemovalOwnership(root, state) + } + return roots +} + +async function createPageOnly(input: ParsedCanvasPageInput): Promise { + if (pageByKey(input.page.pageKey!)) { + specError(`Page key "${input.page.pageKey}" already identifies a local page.`) + } + const state = createApplyState(null, new Set()) + const preparedPage = await prepareResultPage(input.page, null) + if (preparedPage) specError('Page-only create requires a new pageKey.') + await preflightVariableModes(input.page.variableModes, state) + return withUndoBoundary(async () => { + const page = applyResultPage(preparedPage, input.page, state) + await applyPage(page, input.page, state) + page.selection = [] + if (page.selection.length !== 0) { + applyError(`Verification failed: page "${page.id}" does not have an empty selection.`) + } + return pageApplyResult(pageSnapshot(page), state) + }, state) +} + +async function updatePageOnly(input: ParsedCanvasPageInput): Promise { + const page = await resolveExactPage(input.page) + const state = createApplyState(null, new Set()) + await preflightVariableModes(input.page.variableModes, state) + await preloadFirstPageModeFonts(page, input.page.variableModes, state) + return withUndoBoundary(async () => { + protectUnrelatedPageRoots(state, page) + await applyPage(page, input.page, state) + return pageApplyResult(pageSnapshot(page), state) + }, state) +} + +async function activatePage(input: ParsedCanvasPageInput): Promise { + const page = await resolveExactPage(input.page) + const selection = + input.selection === undefined ? undefined : await resolvePageSelection(page, input.selection) + await figma.setCurrentPageAsync(page) + if (selection) page.selection = selection + if (figma.currentPage.id !== page.id) { + applyError(`Verification failed: page "${page.id}" is not active.`) + } + if ( + selection && + (page.selection.length !== selection.length || + page.selection.some((node, index) => node.id !== selection[index]?.id)) + ) { + applyError(`Verification failed: page "${page.id}" selection does not match.`) + } + return pageApplyResult(pageSnapshot(page), createApplyState(null, new Set())) +} + +async function removePage(input: ParsedCanvasPageInput): Promise { + const page = await resolveExactPage(input.page) + const ownedKey = page.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME) + if (!ownedKey || ownedKey !== input.page.pageKey) { + scopeError(`Page "${page.id}" is not owned by pageKey "${input.page.pageKey}".`) + } + if (figma.root.children.length <= 1) { + scopeError('The last Figma page cannot be removed.') + } + const state = createApplyState(null, new Set()) + const roots = collectPageRemovalRoots(page, state) + await validateRemovalComponents(roots) + await validateRemovalReferences(roots, state) + const previousPage = figma.currentPage + const fallback = figma.root.children.find((candidate) => candidate.id !== page.id)! + const snapshot = pageSnapshot(page, { active: false, removed: true, selectionCount: 0 }) + const removedNodeIds = roots.flatMap((root) => + [...walkPhysicalNodes([root])].map((node) => node.id) + ) + + try { + return await withUndoBoundary(async () => { + protectNode(state, page) + if (figma.currentPage.id === page.id) await figma.setCurrentPageAsync(fallback) + page.remove() + markMutation(state, page) + if (figma.root.children.some((candidate) => candidate.id === page.id)) { + applyError(`Verification failed: page "${page.id}" is still present.`) + } + return boundedApplyResult({ + nodeIdsByKey: {}, + createdNodeIds: [], + updatedNodeIds: [], + removedNodeIds, + page: snapshot, + mutationCount: state.mutations.count, + verification: buildVerification() + }) + }, state) + } catch (error) { + if (!previousPage.removed && figma.currentPage.id !== previousPage.id) { + try { + await figma.setCurrentPageAsync(previousPage) + } catch { + // Preserve the apply error; rollback state remains authoritative. + } + } + throw error + } +} + +export async function reconcilePageOperation( + input: ParsedCanvasPageInput +): Promise { + if (input.mode === 'create') return createPageOnly(input) + if (input.mode === 'update') return updatePageOnly(input) + if (input.mode === 'remove') return removePage(input) + return activatePage(input) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/removals.ts b/packages/extension/mcp/tools/canvas/reconcile/removals.ts new file mode 100644 index 00000000..1db53f8e --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/removals.ts @@ -0,0 +1,351 @@ +import type { ApplyCanvasResult } from '@tempad-dev/shared' + +import type { ParsedCanvasTreeInput } from '../model' + +import { getLocalPaintStyles, getLocalEffectStyles } from '../../../local-resources' +import { scopeError, specError, applyError } from '../errors' +import { readOwnedNodeKey } from '../identity' +import { walkAuthoringNodes, isComponentPropertyOwner, walkPhysicalNodes } from '../traversal' +import { + isSupportedSceneNode, + isWithinScope, + isMaskNode, + outermostNodes, + lookupNodeById, + assertOutsideInstance +} from './nodes' +import { resolveShader } from './resources' +import { removedRootResult, boundedApplyResult } from './results' +import { type ApplyState, type SupportedCanvasNode, createApplyState } from './state' +import { isOwnedSvgChild } from './svg' +import { withUndoBoundary } from './undo' + +export function* walkRemovalOwnershipNodes( + roots: Iterable, + state: ApplyState +): Generator { + const stack = [...roots] + while (stack.length) { + const node = stack.pop()! + const svgWrapper = isOwnedSvgChild(node) && node.parent?.type === 'FRAME' ? node.parent : null + const wrapperKey = svgWrapper && readOwnedNodeKey(svgWrapper) + if (wrapperKey && state.keyedNodes.get(wrapperKey)?.id === svgWrapper.id) continue + yield node + if ('children' in node && node.type !== 'INSTANCE') { + for (const child of node.children) stack.push(child) + } + } +} + +export function validateRemovalOwnership(root: SupportedCanvasNode, state: ApplyState): void { + for (const node of walkRemovalOwnershipNodes([root], state)) { + if (!isSupportedSceneNode(node)) { + scopeError(`Removing "${root.id}" would also remove an unsupported canvas node.`) + } + const key = readOwnedNodeKey(node) + if (!key || state.keyedNodes.get(key)?.id !== node.id) { + scopeError(`Removing "${root.id}" would also remove a node not owned by apply_canvas.`) + } + } +} + +export function validateRemovalAncestors(node: SupportedCanvasNode): void { + let ancestor = node.parent + while (ancestor) { + if ((ancestor.type === 'COMPONENT' || ancestor.type === 'COMPONENT_SET') && ancestor.remote) { + scopeError(`Remote ${ancestor.type.toLowerCase()} "${ancestor.id}" is read-only.`) + } + ancestor = ancestor.parent + } +} + +export function resolveRemovalNodes( + input: ParsedCanvasTreeInput, + state: ApplyState +): SupportedCanvasNode[] { + const nodes: SupportedCanvasNode[] = [] + for (const key of input.removeKeys) { + const node = state.keyedNodes.get(key) + if (!node) continue + if (node.id === state.scope?.id) { + scopeError('The update root cannot be removed.') + } + validateRemovalAncestors(node) + validateRemovalOwnership(node, state) + state.removalNodeIds.add(node.id) + nodes.push(node) + } + return nodes +} + +function collectRemovalComponents(roots: SupportedCanvasNode[]): ComponentNode[] { + const components: ComponentNode[] = [] + for (const node of walkAuthoringNodes(roots)) { + if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { + if (node.remote) { + scopeError(`Remote ${node.type.toLowerCase()} "${node.id}" cannot be removed.`) + } + if (node.type === 'COMPONENT') components.push(node) + } + } + return components +} + +export async function validateRemovalComponents(roots: SupportedCanvasNode[]): Promise { + for (const component of collectRemovalComponents(roots)) { + const instances = await component.getInstancesAsync() + if ( + instances.some( + (instance) => !instance.removed && !roots.some((root) => isWithinScope(instance, root)) + ) + ) { + scopeError(`Component "${component.id}" has instances outside the removal scope.`) + } + } +} + +type RemovalReferences = { + removed: { componentKeys: Set; nodeIds: Set } + shaders: Array +} + +function validateRemovalReference(value: string, removed: Set, kind: string): void { + if (removed.has(value)) { + scopeError(`${kind} "${value}" is still referenced outside the removal scope.`) + } +} + +function collectReferences(value: unknown, references: RemovalReferences): void { + if (Array.isArray(value)) { + value.forEach((item) => collectReferences(item, references)) + return + } + if (!value || typeof value !== 'object') return + const record = value as Record + if (record.type === 'PATTERN' && typeof record.sourceNodeId === 'string') { + validateRemovalReference(record.sourceNodeId, references.removed.nodeIds, 'Node') + } else if (record.type === 'NODE' && typeof record.value === 'string') { + validateRemovalReference(record.value, references.removed.nodeIds, 'Node') + } else if (record.type === 'SHADER' && typeof record.id === 'string') { + references.shaders.push(value as ShaderEffect | ShaderPaint) + } + Object.values(record).forEach((item) => collectReferences(item, references)) +} + +function collectComponentReferences( + properties: ComponentProperties | ComponentPropertyDefinitions, + references: RemovalReferences +): void { + for (const property of Object.values(properties)) { + if (property.type === 'INSTANCE_SWAP') { + const value = 'defaultValue' in property ? property.defaultValue : property.value + if (typeof value === 'string') { + validateRemovalReference(value, references.removed.nodeIds, 'Node') + } + } + for (const preferred of property.preferredValues ?? []) { + validateRemovalReference(preferred.key, references.removed.componentKeys, 'Component key') + } + } +} + +function collectSceneReferences(node: SceneNode, references: RemovalReferences): void { + const record = node as unknown as Record + collectReferences(record.fills, references) + collectReferences(record.strokes, references) + collectReferences(record.effects, references) + if (node.type === 'VECTOR') { + collectReferences(node.vectorNetwork.regions, references) + } + if (isComponentPropertyOwner(node)) { + collectComponentReferences(node.componentPropertyDefinitions, references) + } else if (node.type === 'INSTANCE') { + collectComponentReferences(node.componentProperties, references) + } + if (node.type !== 'TEXT') return + collectReferences(node.hyperlink, references) + if (node.hyperlink !== figma.mixed && node.fills !== figma.mixed) return + let segments: Array> + try { + segments = node.getStyledTextSegments(['fills', 'hyperlink']) + } catch { + scopeError(`Rich text on node "${node.id}" could not be inspected before node removal.`) + } + collectReferences(segments, references) +} + +function collectRemovedIdentities(roots: SupportedCanvasNode[]): { + componentKeys: Set + nodeIds: Set +} { + const componentKeys = new Set() + const nodeIds = new Set([...walkPhysicalNodes(roots)].map((node) => node.id)) + for (const node of walkAuthoringNodes(roots)) { + if ((node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') && node.key) { + componentKeys.add(node.key) + } + } + return { componentKeys, nodeIds } +} + +const fullyLoadedRemovalDocuments = new WeakSet() + +export async function loadRemovalPages(roots: SupportedCanvasNode[]): Promise { + if ( + !roots.length || + fullyLoadedRemovalDocuments.has(figma.root) || + !figma.root.children.some((page) => page.id !== figma.currentPage.id) + ) + return + try { + await figma.loadAllPagesAsync() + fullyLoadedRemovalDocuments.add(figma.root) + } catch { + scopeError('Document pages could not be inspected before node removal.') + } +} + +export async function validateRemovalReferences( + roots: SupportedCanvasNode[], + state: ApplyState +): Promise { + const removed = collectRemovedIdentities(roots) + // Reparenting the last child can auto-delete a Group before this final scan. + // Keep its explicit removal identity without including children rescued from it. + for (const id of state.removalNodeIds) removed.nodeIds.add(id) + if (!removed.nodeIds.size) return + const references: RemovalReferences = { + removed, + shaders: [] + } + await loadRemovalPages(roots) + for (const page of figma.root.children) { + collectReferences(page.backgrounds, references) + for (const node of walkPhysicalNodes(page.children)) { + if (removed.nodeIds.has(node.id)) continue + collectSceneReferences(node, references) + } + } + const removedStyleIds = new Set(state.styles.removals.map(({ style }) => style.id)) + const [paintStyles, effectStyles] = await Promise.all([ + getLocalPaintStyles(), + getLocalEffectStyles() + ]) + for (const style of [...paintStyles, ...effectStyles]) { + if (removedStyleIds.has(style.id)) continue + collectReferences(style.type === 'PAINT' ? style.paints : style.effects, references) + } + for (const usage of references.shaders) { + const candidates = Object.entries(usage.properties ?? {}).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && + (removed.nodeIds.has(entry[1]) || removed.componentKeys.has(entry[1])) + ) + if (!candidates.length) continue + const definitions = (await resolveShader(usage.id, state)).propertyDefinitions ?? {} + for (const [propertyId, value] of candidates) { + const type = definitions[propertyId]?.type + if (type === 'INSTANCE_SWAP' || type === 'SLOT') { + validateRemovalReference(value, removed.nodeIds, 'Node') + validateRemovalReference(value, removed.componentKeys, 'Component key') + } + } + } +} + +function validateRemovalResult(roots: SupportedCanvasNode[], state: ApplyState): void { + for (const root of roots) { + for (const node of walkPhysicalNodes([root])) { + if (state.claimedNodeIds.has(node.id)) { + specError(`Desired node "${node.id}" would remain inside a removed subtree.`) + } + if (state.referencedNodeIds.has(node.id)) { + specError(`Referenced node "${node.id}" would be removed by this result.`) + } + } + } + + const rootsByParent = new Map>() + for (const root of roots) { + const parent = root.parent + if (!parent || !('children' in parent)) continue + const ids = rootsByParent.get(parent) ?? new Set() + ids.add(root.id) + rootsByParent.set(parent, ids) + } + for (const [parent, removedIds] of rootsByParent) { + const remaining = parent.children.filter((child) => !removedIds.has(child.id)) + if ( + parent.children.some(isMaskNode) && + remaining.some((child) => !state.claimedNodeIds.has(child.id)) + ) { + specError( + `Removing a sibling in mask container "${parent.id}" requires every remaining sibling in the desired result.` + ) + } + const last = remaining.at(-1) + if (last && isMaskNode(last)) { + specError(`Mask "${last.id}" must precede at least one remaining sibling.`) + } + if (parent.type === 'GROUP' && remaining.length < 1) { + specError(`Removing these nodes would implicitly remove group "${parent.id}".`) + } + if (parent.type === 'BOOLEAN_OPERATION' && remaining.length < 2) { + specError(`Boolean operation "${parent.id}" requires at least two remaining operands.`) + } + if (parent.type === 'COMPONENT_SET' && remaining.length < 1) { + specError(`Component set "${parent.id}" requires at least one remaining variant.`) + } + } +} + +export async function applyRemovals( + removalNodes: SupportedCanvasNode[], + state: ApplyState +): Promise { + const roots = outermostNodes(removalNodes.filter((node) => !node.removed)) + validateRemovalResult(roots, state) + await validateRemovalReferences(roots, state) + removeValidatedNodes(roots, state) + return removalNodes.map((node) => node.id) +} + +function removeValidatedNodes(roots: SupportedCanvasNode[], state: ApplyState): void { + for (const root of roots) { + root.remove() + state.mutations.count += 1 + } +} + +export async function removeUpdateRoot(targetNodeId: string): Promise { + const candidate = await lookupNodeById(targetNodeId) + if (!candidate) return removedRootResult(targetNodeId) + if (!isSupportedSceneNode(candidate)) { + scopeError('The requested removal target is not a supported scene node.') + } + assertOutsideInstance(candidate) + + const state = createApplyState(candidate, new Set()) + validateRemovalAncestors(candidate) + validateRemovalOwnership(candidate, state) + state.removalNodeIds.add(candidate.id) + const parent = candidate.parent + await loadRemovalPages([candidate]) + await validateRemovalComponents([candidate]) + validateRemovalResult([candidate], state) + await validateRemovalReferences([candidate], state) + + return withUndoBoundary(async () => { + removeValidatedNodes([candidate], state) + const removedNodeIds = [candidate.id] + if ( + !candidate.removed && + (!parent || + !('children' in parent) || + parent.children.some((child) => child.id === candidate.id)) + ) { + applyError(`Verification failed: root "${candidate.id}" is still present.`) + } + return boundedApplyResult(removedRootResult(candidate.id, removedNodeIds, state)) + }, state) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/resources.ts b/packages/extension/mcp/tools/canvas/reconcile/resources.ts new file mode 100644 index 00000000..6fcebd88 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/resources.ts @@ -0,0 +1,834 @@ +import type { + CanvasHyperlink, + CanvasFigmaShaderPropertyValue, + CanvasVariableReference, + CanvasFigmaEffect, + CanvasFigmaPaint, + CanvasVariableBindings, + CanvasFigmaLayoutGrid, + CanvasStyleBindings, + CanvasStyleResource, + CanvasStyleReference +} from '@tempad-dev/shared' + +import type { CanvasNodeSpec, ParsedCanvasTreeInput } from '../model' +import type { ApplyState, SupportedCanvasNode } from './state' + +import { specError } from '../errors' +import { loadFont, loadFonts, currentTextFonts } from '../fonts' +import { readOwnedNodeKey } from '../identity' +import { collectCanvasMedia, importCanvasMedia } from '../media' +import { + resolveStyle, + preflightStyleDeclarations, + prepareStyleResources, + removeStyleResources +} from '../styles' +import { + resolveVariable, + resolveCollection, + resolveModeId, + resolvedVariable, + preflightVariableDeclarations, + reconcileVariableCollections, + removeVariableResources +} from '../variables' +import { canonicalVectorPaths } from '../vector' +import { preflightShadowSpread } from './appearance' +import { + validateVariableDataType, + validateStyleType, + currentBoundVariableId, + validateVariableType, + STYLE_TYPES +} from './binding-values' +import { + isRecord, + isVariableBinding, + nativeValueEqual, + scalarValuesEqual, + textMeasuresEqual, + fontNamesEqual +} from './comparison' +import { + type ComponentPropertyContext, + preflightComponentState, + type ComponentPreflightResources, + resolveComponent, + componentPropertyOwner +} from './components' +import { + nativePaint, + paintStacksEqual, + nativeEffect, + effectsEqual, + nativeLayoutGrid, + layoutGridsEqual +} from './native-values' +import { isSceneNode, lookupNodeById, findExistingNode } from './nodes' +import { + textStyleVariableEntries, + loadTextStyleVariableFonts, + preflightTextFont, + type TextStyleResource, + TEXT_STYLE_VALUE_FIELDS, + TEXT_STYLE_VARIABLES_BY_VALUE +} from './text-fonts' +import { rangeHasSemanticTextOverrides } from './text-range-style' +import { protectNode } from './undo' + +type CanvasNodeReference = { nodeId: string } | { canvasKey: string } + +function preflightRetainedNode(node: BaseNode, state: ApplyState): void { + for (let ancestor: BaseNode | null = node; ancestor; ancestor = ancestor.parent) { + if (state.removalNodeIds.has(ancestor.id)) { + specError(`Referenced node "${node.id}" would be removed by this result.`) + } + // A desired ancestor can carry this reference out of a removed subtree. + if ( + state.explicitNodes.has(ancestor.id) || + (isSceneNode(ancestor) && state.desiredKeys.has(readOwnedNodeKey(ancestor) ?? '')) + ) + return + } +} + +async function preflightNodeReference( + reference: CanvasNodeReference, + context: string, + state: ApplyState, + sceneOnly = false +): Promise { + if ('canvasKey' in reference) { + if (!state.desiredKeys.has(reference.canvasKey) && !state.keyedNodes.has(reference.canvasKey)) { + specError( + `${context} canvas key "${reference.canvasKey}" does not exist in the desired result or update scope.` + ) + } + const existing = state.keyedNodes.get(reference.canvasKey) + if (existing) preflightRetainedNode(existing, state) + return + } + const node = await lookupNodeById(reference.nodeId) + if (!node || (sceneOnly && !isSceneNode(node))) { + specError( + `${context} "${reference.nodeId}" does not exist${sceneOnly ? ' or is not a scene node' : ''}.` + ) + } + preflightRetainedNode(node, state) + protectNode(state, node) + state.referencedNodeIds.add(node.id) +} + +async function preflightHyperlink( + hyperlink: CanvasHyperlink | undefined, + key: string, + state: ApplyState +): Promise { + if (hyperlink?.type !== 'NODE') return + await preflightNodeReference( + typeof hyperlink.value === 'string' + ? { nodeId: hyperlink.value } + : { canvasKey: hyperlink.value.canvasKey }, + `Hyperlink target on "${key}"`, + state + ) +} + +export async function resolveShader(id: string, state: ApplyState): Promise { + const cached = state.shaderCache.get(id) + if (cached) return cached + let shader: Shader + try { + shader = await figma.importShaderById(id) + } catch { + specError(`Shader "${id}" could not be imported.`) + } + state.shaderCache.set(id, shader) + return shader +} + +function collectShaderVariableReferences( + value: CanvasFigmaShaderPropertyValue, + references: CanvasVariableReference[] +): void { + if (!isRecord(value)) return + if (isVariableBinding(value)) { + references.push(value.variable) + return + } + if ('color' in value) { + collectShaderVariableReferences(value.color as CanvasFigmaShaderPropertyValue, references) + } else if ('stops' in value) { + for (const stop of value.stops as Array<{ color: CanvasFigmaShaderPropertyValue }>) { + collectShaderVariableReferences(stop.color, references) + } + } +} + +function shaderPropertyMatches( + type: ShaderPropertyDefinition['type'], + value: CanvasFigmaShaderPropertyValue +): boolean { + if (isVariableBinding(value)) return true + switch (type) { + case 'BOOLEAN': + return typeof value === 'boolean' + case 'TEXT': + case 'IMAGE': + case 'INSTANCE_SWAP': + case 'SLOT': + return typeof value === 'string' + case 'NUMBER': + return typeof value === 'number' + case 'COLOR': + return isRecord(value) && 'r' in value + case 'POINT': + return isRecord(value) && 'x' in value && Object.keys(value).length === 2 + case 'LINE': + return isRecord(value) && 'x2' in value + case 'CIRCLE': + return isRecord(value) && 'radius' in value && !('angle' in value) + case 'CIRCLE_POINT': + return isRecord(value) && 'angle' in value + case 'COLOR_POINT': + return isRecord(value) && 'color' in value + case 'GRADIENT': + return isRecord(value) && 'stops' in value + } +} + +async function preflightEffects( + effects: CanvasFigmaEffect[] | undefined, + key: string, + state: ApplyState +): Promise { + for (const [index, effect] of (effects ?? []).entries()) { + if ('variables' in effect && effect.variables) { + for (const [field, reference] of Object.entries(effect.variables)) { + const variable = await resolveVariable(reference, state.variables) + const expected = field === 'color' ? 'COLOR' : 'FLOAT' + validateVariableDataType(variable, expected, `effect ${index} ${field} on "${key}"`) + } + } + if (effect.type !== 'SHADER') continue + await preflightShader(effect.id, effect.properties, 'effect', key, state) + } +} + +async function preflightShader( + id: string, + properties: Record | undefined, + type: Shader['type'], + key: string, + state: ApplyState +): Promise { + const shader = await resolveShader(id, state) + if (shader.type !== type) { + specError(`Shader "${id}" on "${key}" is a ${shader.type} shader, not a ${type} shader.`) + } + const definitions = shader.propertyDefinitions ?? {} + for (const [propertyId, value] of Object.entries(properties ?? {})) { + const definition = definitions[propertyId] + if (!definition) { + specError(`Shader "${id}" has no property "${propertyId}" on "${key}".`) + } + if (!shaderPropertyMatches(definition.type, value)) { + specError(`Shader property "${propertyId}" on "${key}" expects ${definition.type}.`) + } + const references: CanvasVariableReference[] = [] + collectShaderVariableReferences(value, references) + await Promise.all(references.map((reference) => resolveVariable(reference, state.variables))) + } +} + +async function preflightPaintVariable( + reference: CanvasVariableReference, + field: string, + index: number, + key: string, + state: ApplyState +): Promise { + const variable = await resolveVariable(reference, state.variables) + validateVariableDataType(variable, 'COLOR', `${field} paint ${index} on "${key}"`) +} + +function paintColorVariables(paint: CanvasFigmaPaint): CanvasVariableReference[] { + if (paint.type === 'SOLID' && paint.variables) return [paint.variables.color] + if ('gradientStops' in paint) { + return paint.gradientStops.flatMap((stop) => (stop.variables ? [stop.variables.color] : [])) + } + return [] +} + +async function preflightPaintSource( + paint: CanvasFigmaPaint, + field: string, + index: number, + key: string, + state: ApplyState +): Promise { + if (paint.type === 'IMAGE') { + if (paint.imageUrl !== undefined) { + if (!state.media.imageUrls.has(paint.imageUrl)) { + state.media.imageUrls.set(paint.imageUrl, `${field} paint ${index} on "${key}"`) + } + } else if (paint.assetKey !== undefined) { + state.media.imageAssetKeys.add(paint.assetKey) + } else if (paint.imageHash && !figma.getImageByHash(paint.imageHash)) { + specError( + `Image "${paint.imageHash}" for ${field} paint ${index} on "${key}" does not exist.` + ) + } + } + if (paint.type === 'VIDEO' && paint.videoUrl !== undefined) { + state.media.videoUrls.add(paint.videoUrl) + } + if (paint.type === 'PATTERN') { + await preflightNodeReference( + paint.sourceCanvasKey + ? { canvasKey: paint.sourceCanvasKey } + : { nodeId: paint.sourceNodeId! }, + `Pattern source for ${field} paint ${index} on "${key}"`, + state, + true + ) + } +} + +async function preflightPaintStack( + paints: CanvasFigmaPaint[] | undefined, + field: string, + key: string, + state: ApplyState +): Promise { + for (const [index, paint] of (paints ?? []).entries()) { + for (const reference of paintColorVariables(paint)) + await preflightPaintVariable(reference, field, index, key, state) + await preflightPaintSource(paint, field, index, key, state) + if (paint.type === 'SHADER') { + await preflightShader(paint.id, paint.properties, 'fill', key, state) + } + } +} + +async function preflightPaints(spec: CanvasNodeSpec, state: ApplyState): Promise { + await preflightPaintStack(spec.figma?.fills, 'fill', spec.key, state) + await preflightPaintStack(spec.figma?.strokes, 'stroke', spec.key, state) +} + +export function preflightVectorPaths(spec: CanvasNodeSpec): void { + const shape = spec.figma?.shape + if (shape?.type !== 'VECTOR') return + if (shape.paths) { + try { + canonicalVectorPaths(shape.paths) + } catch (error) { + specError( + `Vector path on "${spec.key}" is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } +} + +async function preflightVector(spec: CanvasNodeSpec, state: ApplyState): Promise { + const shape = spec.figma?.shape + if (shape?.type !== 'VECTOR') return + for (const [index, region] of (shape.network?.regions ?? []).entries()) { + await preflightPaintStack(region.fills, `vector region ${index} fill`, spec.key, state) + if (!region.fillStyle) continue + const style = await resolveStyle(region.fillStyle, state.styles) + validateStyleType('fill', style, spec.key) + } +} + +function preflightTextRangeBounds(spec: CanvasNodeSpec, state: ApplyState): void { + for (const range of spec.figma?.text?.ranges ?? []) { + const existing = findExistingNode(spec, state) + if ( + spec.text && + !spec.variables?.characters && + !(existing && currentBoundVariableId(existing, 'characters')) && + range.end > spec.text.characters.length + ) { + specError( + `Text range ${range.start}:${range.end} on "${spec.key}" exceeds ${spec.text.characters.length} UTF-16 code units.` + ) + } + } +} + +async function preflightTextRanges(spec: CanvasNodeSpec, state: ApplyState): Promise { + preflightTextRangeBounds(spec, state) + const existing = findExistingNode(spec, state) + for (const [index, range] of (spec.figma?.text?.ranges ?? []).entries()) { + if ( + existing?.type === 'TEXT' && + range.end <= existing.characters.length && + spec.text?.characters === existing.characters + ) { + rangeHasSemanticTextOverrides(existing, range, state) + } + const key = `${spec.key} text range ${index}` + if (range.fontName) await loadFont(range.fontName, state.fonts) + for (const [field, reference] of [ + ['text', range.textStyle], + ['fill', range.fillStyle] + ] as const) { + if (!reference) continue + const style = await resolveStyle(reference, state.styles) + validateStyleType(field, style, key) + if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) + } + for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableReference | null] + >) { + if (!reference) continue + validateVariableType(field, await resolveVariable(reference, state.variables), key) + } + await preflightHyperlink(range.hyperlink, key, state) + await preflightPaintStack(range.fills, `text range ${index} fill`, spec.key, state) + const decorationColor = range.textDecorationColor + if (decorationColor && decorationColor.value !== 'AUTO') { + await preflightPaintStack( + [decorationColor.value], + `text range ${index} decoration`, + spec.key, + state + ) + } + } +} + +async function preflightLayoutGrids( + grids: CanvasFigmaLayoutGrid[] | undefined, + key: string, + state: ApplyState +): Promise { + for (const [index, grid] of (grids ?? []).entries()) { + for (const [field, reference] of Object.entries(grid.variables ?? {})) { + const variable = await resolveVariable(reference, state.variables) + validateVariableDataType(variable, 'FLOAT', `layout grid ${index} ${field} on "${key}"`) + } + } +} + +function preflightStylePatternSources( + paints: CanvasFigmaPaint[] | undefined, + key: string, + state: ApplyState +): void { + for (const paint of paints ?? []) { + if ( + paint.type === 'PATTERN' && + paint.sourceCanvasKey !== undefined && + !state.keyedNodes.has(paint.sourceCanvasKey) + ) { + specError( + `Pattern source "${paint.sourceCanvasKey}" on Paint style "${key}" must already exist in the update scope; use sourceNodeId when creating the source separately.` + ) + } + } +} + +async function preflightStyleResources(state: ApplyState): Promise { + for (const { key, spec, style } of state.styles.resources) { + switch (spec.type) { + case 'PAINT': + preflightStylePatternSources(spec.paints, key, state) + await preflightPaintStack(spec.paints, 'style', key, state) + break + case 'TEXT': + if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) + if (spec.fontName) await loadFont(spec.fontName, state.fonts) + for (const [field, reference] of textStyleVariableEntries(spec)) { + if (!reference) continue + validateVariableType( + field as keyof CanvasVariableBindings, + await resolveVariable(reference, state.variables), + key + ) + } + if (style.type === 'TEXT') await loadTextStyleVariableFonts(style, spec, state) + break + case 'EFFECT': + await preflightEffects(spec.effects, key, state) + break + case 'GRID': + await preflightLayoutGrids(spec.layoutGrids, key, state) + break + } + } +} + +export async function preflightVariableModes( + modes: CanvasNodeSpec['variableModes'], + state: ApplyState +): Promise { + for (const [collectionId, modeId] of Object.entries(modes ?? {})) { + const collection = await resolveCollection(collectionId, state.variables) + if (modeId !== null) await resolveModeId(collection, modeId, state.variables) + } +} + +export async function preflightResources( + spec: CanvasNodeSpec, + state: ApplyState, + existing?: SupportedCanvasNode, + inheritedComponent?: ComponentPropertyContext +): Promise { + preflightShadowSpread(spec, existing) + if (existing?.type === 'TEXT') await loadFonts(currentTextFonts(existing), state.fonts) + const component = await preflightComponentState(spec, state, existing, inheritedComponent) + if (spec.variables) { + for (const field of Object.keys(spec.variables) as Array) { + const reference = spec.variables[field] + if (!reference) continue + const variable = await resolveVariable(reference, state.variables) + validateVariableType(field, variable, spec.key) + } + } + await preflightVariableModes(spec.variableModes, state) + if (spec.styles) { + for (const field of Object.keys(spec.styles) as Array) { + const reference = spec.styles[field] + if (!reference) continue + const style = await resolveStyle(reference, state.styles) + validateStyleType(field, style, spec.key) + if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) + } + } + await preflightTextFont(spec, state, existing) + await preflightHyperlink(spec.figma?.text?.hyperlink, spec.key, state) + await preflightPaints(spec, state) + await preflightVector(spec, state) + await preflightTextRanges(spec, state) + await preflightEffects(spec.figma?.effects, spec.key, state) + await preflightLayoutGrids(spec.figma?.layoutGrids, spec.key, state) + for (const child of spec.children ?? []) { + await preflightResources(child, state, findExistingNode(child, state) ?? undefined, component) + } +} + +function setStyleValue( + current: T, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState, + equal: (left: T, right: T) => boolean = nativeValueEqual +): void { + if (desired === undefined || equal(current, desired)) return + apply(desired) + state.mutations.count += 1 +} + +function applyStyleMetadata(style: BaseStyle, spec: CanvasStyleResource, state: ApplyState): void { + setStyleValue(style.name, spec.name, (value) => (style.name = value), state) + setStyleValue( + style.descriptionMarkdown, + spec.descriptionMarkdown, + (value) => (style.descriptionMarkdown = value), + state + ) + if (spec.documentationLink === undefined) return + const links = spec.documentationLink === null ? [] : [{ uri: spec.documentationLink }] + setStyleValue( + style.documentationLinks, + links, + (value) => (style.documentationLinks = value), + state + ) +} + +function applyTextStyle(style: TextStyle, spec: TextStyleResource, state: ApplyState): void { + for (const [field, reference] of textStyleVariableEntries(spec)) { + if (reference !== null || !style.boundVariables?.[field]) continue + style.setBoundVariable(field, null) + state.mutations.count += 1 + } + for (const field of TEXT_STYLE_VALUE_FIELDS) { + const desired = spec[field] + if ( + desired === undefined || + TEXT_STYLE_VARIABLES_BY_VALUE[field].some( + (variableField) => style.boundVariables?.[variableField] + ) + ) { + continue + } + const equal = + field === 'fontName' + ? fontNamesEqual(style.fontName, spec.fontName!) + : field === 'lineHeight' || field === 'letterSpacing' + ? textMeasuresEqual(field, style[field], spec[field]!) + : scalarValuesEqual(style[field], desired) + if (equal) continue + Object.assign(style, { [field]: desired }) + state.mutations.count += 1 + } + for (const [field, reference] of textStyleVariableEntries(spec)) { + if (!reference) continue + const variable = resolvedVariable(reference, state.variables) + if (style.boundVariables?.[field]?.id === variable.id) continue + style.setBoundVariable(field, variable) + state.mutations.count += 1 + } +} + +export function applyStyleResources(state: ApplyState): void { + for (const { spec, style } of state.styles.resources) { + applyStyleMetadata(style, spec, state) + switch (spec.type) { + case 'PAINT': { + if (spec.paints === undefined) break + const desired = spec.paints.map((paint) => nativePaint(paint, state)) + setStyleValue( + (style as PaintStyle).paints, + desired, + (value) => ((style as PaintStyle).paints = value), + state, + paintStacksEqual + ) + break + } + case 'TEXT': + applyTextStyle(style as TextStyle, spec, state) + break + case 'EFFECT': { + if (spec.effects === undefined) break + const desired = spec.effects.map((effect) => nativeEffect(effect, state)) + setStyleValue( + (style as EffectStyle).effects, + desired, + (value) => ((style as EffectStyle).effects = value), + state, + effectsEqual + ) + break + } + case 'GRID': { + if (spec.layoutGrids === undefined) break + const desired = spec.layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) + setStyleValue( + (style as GridStyle).layoutGrids, + desired, + (value) => ((style as GridStyle).layoutGrids = value), + state, + layoutGridsEqual + ) + break + } + } + } +} + +type ResourceInput = Pick + +export function hasDeclaredResources(input: ResourceInput): boolean { + return ( + Object.keys(input.variableCollections ?? {}).length > 0 || + Object.keys(input.styles ?? {}).length > 0 + ) +} + +export async function preflightDeclaredResources( + specs: Iterable, + input: ResourceInput, + state: ApplyState +): Promise { + const nodes = [...specs] + const specsByKey = new Map(nodes.map((spec) => [spec.key, spec])) + const componentContexts = new Map() + await preflightStyleDeclarations(input.styles, state.styles) + for (const { spec, style } of state.styles.selections ?? []) { + if (spec !== null && style?.type === 'TEXT') await loadFont(style.fontName, state.fonts) + } + const declaredVariables = await preflightVariableDeclarations( + input.variableCollections, + state.variables + ) + async function knownVariable(reference: CanvasVariableReference) { + // Key-only library references can import resources; keep those in the full preflight. + if (!('variableKey' in reference) && reference.id === undefined) return + return ( + ('variableKey' in reference ? declaredVariables.get(reference.variableKey) : undefined) ?? + (await resolveVariable(reference, state.variables)) + ) + } + const componentResources: ComponentPreflightResources = { + variable: knownVariable, + component: (reference) => resolveComponent(reference, state) + } + async function preflightDeclaredPaints( + paints: CanvasFigmaPaint[] | undefined, + field: string, + key: string + ) { + for (const [index, paint] of (paints ?? []).entries()) { + for (const reference of paintColorVariables(paint)) { + const variable = await knownVariable(reference) + if (variable) + validateVariableDataType(variable, 'COLOR', `${field} paint ${index} on "${key}"`) + } + await preflightPaintSource(paint, field, index, key, state) + } + } + async function preflightEffectVariables(effects: CanvasFigmaEffect[] | undefined, key: string) { + for (const [index, effect] of (effects ?? []).entries()) { + if (!('variables' in effect)) continue + for (const [field, reference] of Object.entries(effect.variables ?? {})) { + const variable = await knownVariable(reference) + if (variable) + validateVariableDataType( + variable, + field === 'color' ? 'COLOR' : 'FLOAT', + `effect ${index} ${field} on "${key}"` + ) + } + } + } + async function preflightGridVariables(grids: CanvasFigmaLayoutGrid[] | undefined, key: string) { + for (const [index, grid] of (grids ?? []).entries()) { + for (const [field, reference] of Object.entries(grid.variables ?? {})) { + const variable = await knownVariable(reference) + if (variable) + validateVariableDataType(variable, 'FLOAT', `layout grid ${index} ${field} on "${key}"`) + } + } + } + async function preflightBindings( + key: string, + variables: CanvasVariableBindings | undefined, + styles: CanvasStyleBindings | undefined + ): Promise { + for (const [field, reference] of Object.entries(variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableReference | null] + >) { + if (!reference) continue + const variable = await knownVariable(reference) + if (variable) validateVariableType(field, variable, key) + } + for (const [field, reference] of Object.entries(styles ?? {}) as Array< + [keyof CanvasStyleBindings, CanvasStyleReference | null] + >) { + if (!reference) continue + const declared = 'styleKey' in reference ? input.styles?.[reference.styleKey] : undefined + if (declared) { + if (declared.type !== STYLE_TYPES[field]) { + specError( + `Declared style for ${field} on "${key}" has incompatible type ${declared.type}.` + ) + } + continue + } + if (!('styleKey' in reference) && reference.id === undefined) continue + const style = await resolveStyle(reference, state.styles) + validateStyleType(field, style, key) + if (style.type === 'TEXT') await loadFont(style.fontName, state.fonts) + } + } + for (const [key, style] of Object.entries(input.styles ?? {})) { + if (style?.type === 'TEXT') { + if (style.fontName) await loadFont(style.fontName, state.fonts) + await preflightBindings(key, style.variables, undefined) + } + if (style?.type === 'PAINT') { + preflightStylePatternSources(style.paints, key, state) + await preflightDeclaredPaints(style.paints, 'style', key) + } + if (style?.type === 'EFFECT') await preflightEffectVariables(style.effects, key) + if (style?.type === 'GRID') await preflightGridVariables(style.layoutGrids, key) + } + if (Object.keys(input.variableCollections ?? {}).length === 0) { + for (const { spec, style } of state.styles.selections ?? []) { + if (spec?.type !== 'TEXT' || style?.type !== 'TEXT') continue + const importsFontVariable = TEXT_STYLE_VARIABLES_BY_VALUE.fontName.some((field) => { + const reference = spec.variables?.[field] + return reference && !('variableKey' in reference) && reference.id === undefined + }) + if (importsFontVariable) continue + // Existing consumer modes are ready to read when no variable declarations + // or library imports can change their fonts. The full preflight rechecks them. + await loadTextStyleVariableFonts(style, spec, state) + } + } + for (const spec of nodes) { + preflightTextRangeBounds(spec, state) + const existing = findExistingNode(spec, state) + const owner = existing ? componentPropertyOwner(existing) : null + // Tree children inherit the desired context; native-only patches use their live owner. + const inherited = componentContexts.has(spec) + ? componentContexts.get(spec) + : owner + ? { existing: owner, spec: specsByKey.get(readOwnedNodeKey(owner) ?? '') } + : undefined + const component = await preflightComponentState( + spec, + state, + existing ?? undefined, + inherited, + componentResources + ) + for (const child of spec.children ?? []) componentContexts.set(child, component) + preflightShadowSpread(spec, existing ?? undefined) + if (existing?.type === 'TEXT') await loadFonts(currentTextFonts(existing), state.fonts) + await preflightBindings(spec.key, spec.variables, spec.styles) + const hasBoundFont = + spec.variables?.fontFamily || + spec.variables?.fontStyle || + (existing?.type === 'TEXT' && + (currentBoundVariableId(existing, 'fontFamily') || + currentBoundVariableId(existing, 'fontStyle'))) + // Same-call variable declarations can change the bound half of a literal font pair. + // Resolve that pair in the full preflight, after those values have been applied. + if (!hasBoundFont) await preflightTextFont(spec, state, existing ?? undefined) + await preflightHyperlink(spec.figma?.text?.hyperlink, spec.key, state) + await preflightDeclaredPaints(spec.figma?.fills, 'fill', spec.key) + await preflightDeclaredPaints(spec.figma?.strokes, 'stroke', spec.key) + await preflightEffectVariables(spec.figma?.effects, spec.key) + await preflightGridVariables(spec.figma?.layoutGrids, spec.key) + if (spec.figma?.shape?.type === 'VECTOR') { + for (const [index, region] of (spec.figma.shape.network?.regions ?? []).entries()) { + await preflightBindings(spec.key, undefined, { fill: region.fillStyle }) + await preflightDeclaredPaints(region.fills, `vector region ${index} fill`, spec.key) + } + } + for (const [index, range] of (spec.figma?.text?.ranges ?? []).entries()) { + if (range.fontName) await loadFont(range.fontName, state.fonts) + await preflightBindings(`${spec.key} text range ${index}`, range.variables, { + text: range.textStyle, + fill: range.fillStyle + }) + await preflightHyperlink(range.hyperlink, `${spec.key} text range ${index}`, state) + await preflightDeclaredPaints(range.fills, `text range ${index} fill`, spec.key) + if (range.textDecorationColor && range.textDecorationColor.value !== 'AUTO') { + await preflightDeclaredPaints( + [range.textDecorationColor.value], + `text range ${index} decoration`, + spec.key + ) + } + } + } +} + +export async function prepareResources(input: ResourceInput, state: ApplyState): Promise { + await reconcileVariableCollections(input.variableCollections, state.variables, state.mutations) + await prepareStyleResources(input.styles, state.styles, state.mutations) + await preflightStyleResources(state) +} + +export async function prepareMedia( + specs: Iterable, + input: ResourceInput, + state: ApplyState +): Promise { + for (const spec of specs) collectCanvasMedia(spec.figma, `node "${spec.key}"`, state.media) + collectCanvasMedia(input.styles, 'styles', state.media) + await importCanvasMedia(state.media) +} + +export async function removeResources(state: ApplyState): Promise { + await removeStyleResources(state.styles, state.mutations) + await removeVariableResources(state.variables, state.mutations) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/results.ts b/packages/extension/mcp/tools/canvas/reconcile/results.ts new file mode 100644 index 00000000..1b0ac59f --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/results.ts @@ -0,0 +1,66 @@ +import { + type ApplyCanvasResult, + type CanvasPageSnapshot, + measureCallToolResultBytes, + buildApplyCanvasToolResult, + MCP_TOOL_INLINE_BUDGET_BYTES, + MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES +} from '@tempad-dev/shared' + +import type { VerificationWarning, ApplyState } from './state' + +import { specError } from '../errors' + +export function buildVerification( + nodesChecked = 0, + referencesChecked = 0, + nativeFieldsChecked = 0, + warnings: VerificationWarning[] = [] +) { + return { + status: warnings.length ? ('warning' as const) : ('passed' as const), + nodesChecked, + referencesChecked, + nativeFieldsChecked, + warnings + } +} + +export function removedRootResult( + rootNodeId: string, + removedNodeIds: string[] = [], + state?: ApplyState +): ApplyCanvasResult { + return { + rootNodeId, + rootRemoved: true, + nodeIdsByKey: state?.nodeIdsByKey ?? {}, + createdNodeIds: [], + updatedNodeIds: [], + removedNodeIds, + mutationCount: state?.mutations.count ?? 0, + verification: buildVerification() + } +} + +export function pageApplyResult(page: CanvasPageSnapshot, state: ApplyState): ApplyCanvasResult { + return boundedApplyResult({ + nodeIdsByKey: state.nodeIdsByKey, + createdNodeIds: [...state.createdNodeIds], + updatedNodeIds: [...state.updatedNodeIds], + removedNodeIds: [], + page, + mutationCount: state.mutations.count, + verification: buildVerification() + }) +} + +export function boundedApplyResult(result: ApplyCanvasResult): ApplyCanvasResult { + const bytes = measureCallToolResultBytes(buildApplyCanvasToolResult(result)) + if (bytes > MCP_TOOL_INLINE_BUDGET_BYTES - MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES) { + specError( + 'apply_canvas result exceeds the 64 KiB inline budget. Reduce the desired subtree or split the operation.' + ) + } + return result +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/state.ts b/packages/extension/mcp/tools/canvas/reconcile/state.ts new file mode 100644 index 00000000..d04fea50 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/state.ts @@ -0,0 +1,174 @@ +import type { ApplyCanvasResult } from '@tempad-dev/shared' + +import type { ResolvedCanvasAssets } from '../assets' +import type { CanvasNodeSpec } from '../model' + +import { specError } from '../errors' +import { type CanvasFontState, createFontState } from '../fonts' +import { type MutationCounter, claimNodeKey } from '../identity' +import { type CanvasMediaState, createMediaState } from '../media' +import { type CanvasStyleState, createStyleState } from '../styles' +import { type CanvasVariableState, createVariableState } from '../variables' +import { scalarValuesEqual } from './comparison' +import { collectKeyedNodes } from './nodes' +import { protectNode } from './undo' + +export type SupportedCanvasNode = Extract + +export type CanvasFrameContainerNode = ComponentNode | ComponentSetNode | FrameNode | SlotNode + +export type CanvasParentNode = + | BooleanOperationNode + | ComponentNode + | ComponentSetNode + | FrameNode + | GroupNode + | PageNode + | SectionNode + | SlotNode + +export type IntrinsicContainerNode = BooleanOperationNode | GroupNode + +export type WrappedContainerNode = ComponentSetNode | IntrinsicContainerNode + +export type WrappedContainerSpec = CanvasNodeSpec & { type: WrappedContainerNode['type'] } + +export type ProtectedNodeSnapshot = { + content: string + childIds: string[] | null + geometry: { height: number; width: number; x: number; y: number } | null + key: string + owner: string + parentId: string | null + type: BaseNode['type'] + textSegments?: string +} + +export type ApplyState = { + media: CanvasMediaState + claimedNodeIds: Set + componentCache: Map + componentPropertyKeys: Map> + createdNodeIds: Set + createdPageIds: Set + desiredKeys: Set + explicitNodes: Map + fonts: CanvasFontState + ignoredLiteralWarnings: VerificationWarning[] + keyedNodes: Map + mutations: MutationCounter + nodeIdsByKey: Record + pendingGridRemovalCleanupNodeIds: Set + protectedNodes: Map + removalNodeIds: Set + referencedNodeIds: Set + scope: SupportedCanvasNode | null + shaderCache: Map + slotPropertyNames: Map + stabilizedCrossAxisFillNodeIds: Set + styles: CanvasStyleState + textRangeSemanticOverrides: Map + updatedNodeIds: Set + undoStarted: boolean + variables: CanvasVariableState +} + +export function markMutation(state: ApplyState, node: BaseNode): void { + state.mutations.count += 1 + if (!state.createdNodeIds.has(node.id)) { + state.updatedNodeIds.add(node.id) + } +} + +export function setNodeKey(state: ApplyState, node: SupportedCanvasNode, key: string): void { + const changed = claimNodeKey(node, key) + state.keyedNodes.set(key, node) + if (changed) markMutation(state, node) +} + +export function recordCreatedNode( + node: SupportedCanvasNode, + state: ApplyState, + claimed = true +): void { + state.mutations.count += 1 + state.createdNodeIds.add(node.id) + if (claimed) state.claimedNodeIds.add(node.id) +} + +export function moveIntoParent( + node: Child, + parent: BaseNode & { + readonly children: readonly Child[] + insertChild(index: number, child: Child): void + }, + index: number, + state: ApplyState +): void { + const sameParent = node.parent?.id === parent.id + if (sameParent && parent.children.indexOf(node) === index) return + const preceding = sameParent ? [] : parent.children.slice(0, index) + parent.insertChild(index, node) + // Moving out of a Group can remove that Group (and empty ancestor Groups) + // from this parent, shifting the requested insertion slot. + const expectedIndex = index - preceding.filter((sibling) => sibling.removed).length + if ((sameParent || expectedIndex !== index) && parent.children.indexOf(node) !== expectedIndex) { + const currentIndex = parent.children.indexOf(node) + parent.insertChild(currentIndex < expectedIndex ? expectedIndex + 1 : expectedIndex, node) + } + if (parent.children.indexOf(node) !== expectedIndex) { + specError(`Node "${node.id}" could not be placed at child index ${expectedIndex}.`) + } + markMutation(state, node) +} + +export function setValue( + node: BaseNode, + current: unknown, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState +): void { + if (desired === undefined || scalarValuesEqual(current, desired)) return + apply(desired) + markMutation(state, node) +} + +export function createApplyState( + target: SupportedCanvasNode | null, + desiredKeys: Set, + assets: ResolvedCanvasAssets = new Map() +): ApplyState { + const state: ApplyState = { + media: createMediaState(assets), + claimedNodeIds: new Set(), + componentCache: new Map(), + componentPropertyKeys: new Map(), + createdNodeIds: new Set(), + createdPageIds: new Set(), + desiredKeys, + explicitNodes: new Map(), + fonts: createFontState(), + ignoredLiteralWarnings: [], + keyedNodes: target ? collectKeyedNodes(target) : new Map(), + mutations: { count: 0 }, + nodeIdsByKey: Object.create(null) as Record, + pendingGridRemovalCleanupNodeIds: new Set(), + protectedNodes: new Map(), + removalNodeIds: new Set(), + referencedNodeIds: new Set(), + scope: target, + shaderCache: new Map(), + slotPropertyNames: new Map(), + stabilizedCrossAxisFillNodeIds: new Set(), + styles: createStyleState(), + textRangeSemanticOverrides: new Map(), + updatedNodeIds: new Set(), + undoStarted: false, + variables: createVariableState() + } + if (target) protectNode(state, target) + return state +} + +export type VerificationWarning = ApplyCanvasResult['verification']['warnings'][number] diff --git a/packages/extension/mcp/tools/canvas/reconcile/structure-preflight.ts b/packages/extension/mcp/tools/canvas/reconcile/structure-preflight.ts new file mode 100644 index 00000000..ed89f40c --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/structure-preflight.ts @@ -0,0 +1,184 @@ +import type { CanvasNodeSpec } from '../model' +import type { ApplyState, SupportedCanvasNode, CanvasParentNode } from './state' + +import { specError } from '../errors' +import { findExistingNode, isIntrinsicNode, walkSpecs, isWrappedSpec } from './nodes' + +function findOmittedChild( + specs: CanvasNodeSpec[], + parent: ChildrenMixin, + state: ApplyState +): SceneNode | undefined { + const describedIds = new Set( + specs + .map((spec) => findExistingNode(spec, state)) + .filter((node): node is SupportedCanvasNode => node !== null) + .map((node) => node.id) + ) + return parent.children.find( + (child) => !describedIds.has(child.id) && !state.removalNodeIds.has(child.id) + ) +} + +export function preflightMasks( + spec: CanvasNodeSpec, + state: ApplyState, + existing: SupportedCanvasNode | null = null, + isRoot = true +): void { + const isDesiredMask = desiredMaskState(spec, existing) + if (isRoot && isDesiredMask) { + specError('The canvas root cannot be a mask because its scope would escape the desired tree.') + } + + const children = spec.children ?? [] + const hasMask = children.some((child) => desiredMaskState(child, findExistingNode(child, state))) + const lastChild = children.at(-1) + if (lastChild && desiredMaskState(lastChild, findExistingNode(lastChild, state))) { + specError(`Mask "${lastChild.key}" must precede at least one sibling to mask.`) + } + + if (hasMask && existing && 'children' in existing) { + const omitted = findOmittedChild(children, existing, state) + if (omitted) { + specError( + `Mask container "${spec.key}" has omitted live child "${omitted.id}"; describe every direct child so the mask scope is deterministic.` + ) + } + } + + for (const child of children) { + preflightMasks(child, state, findExistingNode(child, state), false) + } +} + +function desiredMaskState(spec: CanvasNodeSpec, existing: SupportedCanvasNode | null): boolean { + const desired = spec.figma?.mask + if (desired !== undefined) return desired !== null + return !!existing && 'isMask' in existing && existing.isMask +} + +export function preflightContainers( + spec: CanvasNodeSpec, + state: ApplyState, + existing: SupportedCanvasNode | null = null +): void { + const childCount = spec.children?.length ?? 0 + if (!existing && spec.type === 'GROUP' && childCount === 0) { + specError(`New group "${spec.key}" requires at least one child.`) + } + if (!existing && spec.type === 'BOOLEAN_OPERATION' && childCount < 2) { + specError(`New boolean operation "${spec.key}" requires at least two children.`) + } + if (!existing && spec.type === 'COMPONENT_SET' && childCount === 0) { + specError(`New component set "${spec.key}" requires at least one component child.`) + } + if (spec.type === 'COMPONENT_SET' && spec.children?.some((child) => child.type !== 'COMPONENT')) { + specError(`Component set "${spec.key}" can contain only component nodes.`) + } + if ( + existing && + (existing.type === 'COMPONENT' || existing.type === 'COMPONENT_SET') && + existing.remote + ) { + specError(`Remote ${existing.type} node "${spec.key}" is read-only.`) + } + if (existing && isIntrinsicNode(existing) && spec.children?.length) { + const omitted = findOmittedChild(spec.children, existing, state) + if (omitted) { + specError( + `Intrinsic container "${spec.key}" has omitted live child "${omitted.id}"; describe every direct child when reconciling its contents.` + ) + } + } + for (const child of spec.children ?? []) { + preflightContainers(child, state, findExistingNode(child, state)) + } +} + +export function preflightMovedChildren(root: CanvasNodeSpec, state: ApplyState): void { + const order = new Map() + let nextOrder = 0 + function recordOrder(spec: CanvasNodeSpec): void { + const start = nextOrder++ + for (const child of spec.children ?? []) recordOrder(child) + order.set(spec, { start, end: nextOrder++ }) + } + recordOrder(root) + const incoming = new Map() + const outgoing = new Map; lastMove: number }>() + for (const parentSpec of walkSpecs(root)) { + // targetNodeId identifies an adopted root before its canvas key is claimed. + const destination = parentSpec === root ? state.scope : findExistingNode(parentSpec, state) + for (const childSpec of parentSpec.children ?? []) { + const child = findExistingNode(childSpec, state) + const parent = child?.parent + if (destination && 'children' in destination && parent?.id !== destination.id) { + const insertions = incoming.get(destination.id) ?? [] + // Only new wrapped containers wait for their children before insertion. + const timing = order.get(childSpec)! + insertions.push(!child && isWrappedSpec(childSpec) ? timing.end : timing.start) + incoming.set(destination.id, insertions) + } + if ( + !child || + !parent || + parent.id === destination?.id || + (parent.type !== 'GROUP' && + parent.type !== 'BOOLEAN_OPERATION' && + parent.type !== 'COMPONENT_SET') + ) + continue + const moves = outgoing.get(parent) ?? { ids: new Set(), lastMove: -1 } + moves.ids.add(child.id) + // New sets stage every variant before reconciling any variant's descendants. + const moveOrder = !destination && parentSpec.type === 'COMPONENT_SET' ? parentSpec : childSpec + moves.lastMove = Math.max(moves.lastMove, order.get(moveOrder)!.start) + outgoing.set(parent, moves) + } + } + for (const initialParent of [...outgoing.keys()]) { + let parent = initialParent + while (true) { + const { ids, lastMove } = outgoing.get(parent)! + const addedBeforeMove = (incoming.get(parent.id) ?? []).filter( + (time) => time < lastMove + ).length + // An explicitly removed Group can disappear during the move, emptying its + // parent before the ordinary removal checks get a live subtree to inspect. + if ( + parent.type !== 'GROUP' || + addedBeforeMove || + parent.children.some((child) => !ids.has(child.id)) + ) + break + const ancestor = parent.parent + if ( + !ancestor || + (ancestor.type !== 'GROUP' && + ancestor.type !== 'BOOLEAN_OPERATION' && + ancestor.type !== 'COMPONENT_SET') + ) + break + const ancestorMoves = outgoing.get(ancestor) ?? { ids: new Set(), lastMove: -1 } + if (ancestorMoves.ids.has(parent.id)) break + ancestorMoves.ids.add(parent.id) + ancestorMoves.lastMove = Math.max(ancestorMoves.lastMove, lastMove) + outgoing.set(ancestor, ancestorMoves) + parent = ancestor + } + } + // Aggregate sibling cascades before checking their common ancestor's last move. + for (const [parent, { ids, lastMove }] of outgoing) { + const remaining = parent.children.filter( + (child) => !ids.has(child.id) && !state.removalNodeIds.has(child.id) + ) + const addedBeforeMove = (incoming.get(parent.id) ?? []).filter((time) => time < lastMove).length + const minimum = parent.type === 'BOOLEAN_OPERATION' ? 2 : 1 + if (!state.removalNodeIds.has(parent.id) && remaining.length + addedBeforeMove < minimum) { + specError( + `Moving these children would leave ${parent.type} "${parent.id}" with fewer than ${minimum} children. Preserve its required children or explicitly remove that container in the same update.` + ) + } + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/svg.ts b/packages/extension/mcp/tools/canvas/reconcile/svg.ts new file mode 100644 index 00000000..14185fd6 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/svg.ts @@ -0,0 +1,177 @@ +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' + +import { createCodedError } from '../../../errors' +import { SVG_POLICY_VERSION, resolvedSvgAsset } from '../assets' +import { specError } from '../errors' +import { CANVAS_KEY_NAMESPACE } from '../identity' +import { walkSpecs } from './nodes' +import { type ApplyState, markMutation, recordCreatedNode, type SupportedCanvasNode } from './state' + +const CANVAS_SVG_CHILD_NAME = 'svg-child' + +export const CANVAS_SVG_COLOR_NAME = 'svg-color' + +export const CANVAS_SVG_DIGEST_NAME = 'svg-digest' + +export const CANVAS_SVG_POLICY_NAME = 'svg-policy' + +export function isOwnedSvgChild(node: SceneNode): node is FrameNode { + return ( + node.type === 'FRAME' && + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_CHILD_NAME) === 'true' + ) +} + +function countSceneNodes(node: SceneNode): number { + return ( + 1 + + ('children' in node + ? node.children.reduce((count, child) => count + countSceneNodes(child), 0) + : 0) + ) +} + +function placeSvgChild(child: FrameNode, wrapper: FrameNode, state: ApplyState): void { + if ( + ![child.width, child.height, wrapper.width, wrapper.height].every( + (value) => Number.isFinite(value) && value > 0 + ) + ) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, + 'SVG import produced invalid geometry.' + ) + } + const scale = Math.min(wrapper.width / child.width, wrapper.height / child.height) + let changed = false + if (Math.abs(scale - 1) > 0.0001) { + child.rescale(scale) + changed = true + } + const x = (wrapper.width - child.width) / 2 + const y = (wrapper.height - child.height) / 2 + if (Math.abs(child.x - x) > 0.001 || Math.abs(child.y - y) > 0.001) { + child.x = x + child.y = y + changed = true + } + if (changed) markMutation(state, child) +} + +export function finalizeSvgPositions(specs: Iterable, state: ApplyState): void { + for (const spec of specs) { + if (!spec.figma?.svg) continue + const wrapper = state.keyedNodes.get(spec.key) + if (wrapper?.type !== 'FRAME') continue + for (const child of wrapper.children) { + if (isOwnedSvgChild(child)) placeSvgChild(child, wrapper, state) + } + } +} + +function setSvgMetadata( + wrapper: FrameNode, + digest: string, + color: string | undefined, + state: ApplyState +): void { + let changed = false + for (const [name, value] of [ + [CANVAS_SVG_DIGEST_NAME, digest], + [CANVAS_SVG_COLOR_NAME, color?.toUpperCase() ?? ''], + [CANVAS_SVG_POLICY_NAME, SVG_POLICY_VERSION] + ] as const) { + if (wrapper.getSharedPluginData(CANVAS_KEY_NAMESPACE, name) === value) continue + wrapper.setSharedPluginData(CANVAS_KEY_NAMESPACE, name, value) + changed = true + } + if (changed) markMutation(state, wrapper) +} + +export async function applySvg( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const placement = spec.figma?.svg + if (!placement) return + if (node.type !== 'FRAME') { + specError(`SVG binding "${spec.key}" requires a frame wrapper.`) + } + const asset = resolvedSvgAsset(state.media.assets, placement.assetKey, placement.color) + if (!asset) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.ASSET_NOT_FOUND, + `SVG asset "${placement.assetKey}" was not resolved.` + ) + } + const owned = node.children.filter(isOwnedSvgChild) + const unexpected = node.children.filter((child) => !isOwnedSvgChild(child)) + if (owned.length > 1 || unexpected.length) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_WRAPPER_DIRTY, + `SVG wrapper "${spec.key}" contains unexpected children.` + ) + } + if ( + owned.length === 1 && + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_DIGEST_NAME) === asset.digest + ) { + placeSvgChild(owned[0]!, node, state) + setSvgMetadata(node, asset.digest, placement.color, state) + return + } + + let imported: FrameNode + try { + imported = figma.createNodeFromSvg(asset.svg) + } catch { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, + `SVG asset "${placement.assetKey}" could not be imported by Figma.` + ) + } + recordCreatedNode(imported, state, false) + if ( + !Number.isFinite(imported.width) || + !Number.isFinite(imported.height) || + imported.width <= 0 || + imported.height <= 0 || + countSceneNodes(imported) > 500 + ) { + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.SVG_IMPORT_FAILED, + `SVG asset "${placement.assetKey}" produced invalid or excessive Figma layers.` + ) + } + imported.setSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_CHILD_NAME, 'true') + node.appendChild(imported) + markMutation(state, node) + placeSvgChild(imported, node, state) + for (const child of owned) { + child.remove() + state.mutations.count += 1 + } + setSvgMetadata(node, asset.digest, placement.color, state) +} + +export function collectSvgColorsFromSpecs( + specs: Iterable +): Map> { + const colors = new Map>() + for (const spec of specs) { + const svg = spec.figma?.svg + if (svg) { + const values = colors.get(svg.assetKey) ?? new Set() + values.add(svg.color) + colors.set(svg.assetKey, values) + } + } + return colors +} + +export function collectSvgColors(root: CanvasNodeSpec): Map> { + return collectSvgColorsFromSpecs(walkSpecs(root)) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text-fonts.ts b/packages/extension/mcp/tools/canvas/reconcile/text-fonts.ts new file mode 100644 index 00000000..0c0fd423 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text-fonts.ts @@ -0,0 +1,369 @@ +import type { + CanvasStyleResource, + CanvasVariableReference, + CanvasVariableBindings, + CanvasFigmaTextRange +} from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' +import type { ApplyState, SupportedCanvasNode } from './state' + +import { getVariableCollectionById } from '../../../local-resources' +import { specError, scopeError, applyError } from '../errors' +import { + resolveFamilyFont, + loadFont, + resolvePortableFont, + loadFonts, + currentTextFonts +} from '../fonts' +import { normalizePortableFontStyle } from '../tailwind' +import { resolveVariable, resolvedVariable } from '../variables' +import { currentBoundVariableId } from './binding-values' +import { fontNamesEqual } from './comparison' +import { baseTextStyleScope, type BaseTextStyleScope } from './text-style-scope' + +export const TEXT_STYLE_VALUE_FIELDS = [ + 'fontName', + 'fontSize', + 'textDecoration', + 'letterSpacing', + 'lineHeight', + 'leadingTrim', + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing', + 'hangingPunctuation', + 'hangingList', + 'textCase' +] as const satisfies ReadonlyArray> + +export const TEXT_STYLE_VARIABLES_BY_VALUE = { + fontName: ['fontFamily', 'fontStyle', 'fontWeight'], + fontSize: ['fontSize'], + textDecoration: [], + letterSpacing: ['letterSpacing'], + lineHeight: ['lineHeight'], + leadingTrim: [], + paragraphIndent: ['paragraphIndent'], + paragraphSpacing: ['paragraphSpacing'], + listSpacing: [], + hangingPunctuation: [], + hangingList: [], + textCase: [] +} satisfies Record<(typeof TEXT_STYLE_VALUE_FIELDS)[number], readonly VariableBindableTextField[]> + +export type TextStyleResource = Extract + +export function textStyleVariableEntries( + spec: TextStyleResource +): Array<[VariableBindableTextField, CanvasVariableReference | null]> { + return Object.entries(spec.variables ?? {}) as Array< + [VariableBindableTextField, CanvasVariableReference | null] + > +} + +async function defaultFontVariableValue( + variable: Variable, + state: ApplyState, + seen = new Set() +): Promise { + if (seen.has(variable.id) || seen.size >= 64) { + specError('Text style font variable aliases contain a cycle or exceed 64 levels.') + } + seen.add(variable.id) + const collection = await getVariableCollectionById(variable.variableCollectionId) + const value = collection && variable.valuesByMode[collection.defaultModeId] + if (value === undefined || value === null) { + scopeError(`Font variable "${variable.id}" has no accessible default-mode value.`) + } + if (typeof value === 'object' && 'type' in value && value.type === 'VARIABLE_ALIAS') { + return defaultFontVariableValue( + await resolveVariable({ id: value.id }, state.variables), + state, + seen + ) + } + return value +} + +export async function loadTextStyleVariableFonts( + style: TextStyle, + spec: TextStyleResource, + state: ApplyState +): Promise { + const fields = TEXT_STYLE_VARIABLES_BY_VALUE.fontName + const variables = await Promise.all( + fields.map(async (field) => { + const reference = spec.variables?.[field] + const retained = style.boundVariables?.[field] + if (reference === null) return undefined + if (reference) return resolveVariable(reference, state.variables) + if (retained) return resolveVariable({ id: retained.id }, state.variables) + return undefined + }) + ) + if (!variables.some(Boolean)) return + const hasRetainedBinding = fields.some( + (field) => style.boundVariables?.[field] && spec.variables?.[field] !== null + ) + const base = (!hasRetainedBinding && spec.fontName) || style.fontName + // A style has no consumer mode of its own. Cover its default values and every + // existing consumer context without creating temporary canvas nodes. + const contexts: Array = [ + undefined, + ...(await style.getStyleConsumersAsync()).map(({ node }) => node) + ] + for (const consumer of contexts) { + const [family = base.family, fontStyle = base.style, weight] = await Promise.all( + variables.map((variable) => { + if (!variable) return undefined + if (consumer) return variable.resolveForConsumer(consumer).value + return defaultFontVariableValue(variable, state) + }) + ) + if ( + typeof family !== 'string' || + typeof fontStyle !== 'string' || + (weight !== undefined && + (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000)) + ) { + specError( + `Font variables on Text style "${style.id}" must resolve to STRING family/style and a weight between 1 and 1000.` + ) + } + const font = + weight === undefined + ? { ...base, family, style: fontStyle } + : await resolveFamilyFont(family, fontStyle, state.fonts, weight) + await loadVariableFontTransitions( + [base], + spec.variables, + { family, style: fontStyle, weight }, + state + ) + await loadFont(font, state.fonts) + } +} + +export async function preflightTextFont( + spec: CanvasNodeSpec, + state: ApplyState, + existing?: SupportedCanvasNode +): Promise { + if (spec.type !== 'TEXT' || !spec.text) return + const textNode = existing?.type === 'TEXT' ? existing : null + const styleScope = baseTextStyleScope(spec, textNode) + const hasTextStyle = styleScope.kind === 'retained' + const hasFontFamilyVariable = !!( + spec.variables?.fontFamily || + (textNode && currentBoundVariableId(textNode, 'fontFamily')) + ) + const hasFontStyleVariable = !!( + spec.variables?.fontStyle || + (textNode && currentBoundVariableId(textNode, 'fontStyle')) + ) + const fontFamily = hasTextStyle || hasFontFamilyVariable ? undefined : spec.text.fontFamily + const fontStyle = hasTextStyle || hasFontStyleVariable ? undefined : spec.text.fontStyle + if ( + (fontFamily !== undefined || fontStyle !== undefined) && + !(spec.text.fontStyleMatching && (hasFontFamilyVariable || hasFontStyleVariable)) + ) { + const currentFont = textNode + ? baseTextFont(textNode, styleScope) + : { family: 'Inter', style: 'Regular' } + if (currentFont === figma.mixed && (!fontFamily || !fontStyle)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + const desiredFamily = fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family) + const desiredStyle = fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) + const desiredFont = spec.text.portableFontFamily + ? await resolvePortableFont(spec.text.portableFontFamily, desiredStyle, state.fonts) + : spec.text.fontStyleMatching + ? await resolveFamilyFont(desiredFamily, desiredStyle, state.fonts) + : await resolveLiteralFont(desiredFamily, desiredStyle, spec, state) + if (spec.text.portableFontFamily) { + spec.text.fontFamily = desiredFont.family + spec.text.fontStyle = desiredFont.style + } + await loadFont(desiredFont, state.fonts) + } +} + +function baseTextFont(node: TextNode, scope: BaseTextStyleScope): FontName | typeof figma.mixed { + if (scope.kind !== 'partial' || node.fontName !== figma.mixed) return node.fontName + // Retained styled characters do not supply missing family/style fields for + // the base font. The writable characters must still agree on one named face. + let font: FontName | undefined + for (const { start, end } of scope.ranges) { + const current = node.getRangeFontName(start, end) + if (current === figma.mixed || (font && !fontNamesEqual(font, current))) return figma.mixed + font ??= current + } + return font ?? figma.mixed +} + +async function resolveLiteralFont( + family: string, + style: string, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + if (spec.figma?.text?.fontName) return { family, style } + if (family === 'Inter' || family === 'Noto Serif' || family === 'Noto Sans Mono') { + return { family, style: normalizePortableFontStyle(family, style) } + } + return resolveFamilyFont(family, style, state.fonts) +} + +export async function loadTextFonts( + node: TextNode, + spec: CanvasNodeSpec, + state: ApplyState, + styleScope: BaseTextStyleScope +): Promise { + const text = spec.text + const currentFont = baseTextFont(node, styleScope) + const hasTextStyle = styleScope.kind === 'retained' + if (text?.fontStyleMatching && !hasTextStyle) { + const familyReference = spec.variables?.fontFamily + const styleReference = spec.variables?.fontStyle + const weightReference = spec.variables?.fontWeight + const family = familyReference + ? resolvedFontVariableValue(node, familyReference, state) + : currentBoundVariableId(node, 'fontFamily') && currentFont !== figma.mixed + ? currentFont.family + : (text.fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family)) + const style = styleReference + ? resolvedFontVariableValue(node, styleReference, state) + : currentBoundVariableId(node, 'fontStyle') && currentFont !== figma.mixed + ? currentFont.style + : (text.fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style)) + if (currentFont === figma.mixed && (!family || !style)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + const weight = weightReference + ? resolvedVariable(weightReference, state.variables).resolveForConsumer(node).value + : undefined + if ( + weight !== undefined && + (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000) + ) { + specError(`Font weight on "${spec.key}" must resolve to a number between 1 and 1000.`) + } + const desiredFont = styleReference + ? { family, style } + : await resolveFamilyFont(family, style, state.fonts, weight) + await loadFont(desiredFont, state.fonts) + return desiredFont + } + const fontFamily = + hasTextStyle || spec.variables?.fontFamily || currentBoundVariableId(node, 'fontFamily') + ? undefined + : text?.fontFamily + const fontStyle = + hasTextStyle || spec.variables?.fontStyle || currentBoundVariableId(node, 'fontStyle') + ? undefined + : text?.fontStyle + const hasExplicitFont = fontFamily !== undefined || fontStyle !== undefined + if (currentFont === figma.mixed && hasExplicitFont && (!fontFamily || !fontStyle)) { + specError( + `TEXT "${spec.key}" has mixed fonts; provide both fontFamily and fontStyle to replace them.` + ) + } + + const desiredFamily = fontFamily ?? (currentFont === figma.mixed ? '' : currentFont.family) + const desiredStyle = fontStyle ?? (currentFont === figma.mixed ? '' : currentFont.style) + const desiredFont: FontName | null = hasExplicitFont + ? text?.portableFontFamily + ? await resolvePortableFont(text.portableFontFamily, desiredStyle, state.fonts) + : await resolveLiteralFont(desiredFamily, desiredStyle, spec, state) + : null + if (desiredFont && text?.portableFontFamily) { + text.fontFamily = desiredFont.family + text.fontStyle = desiredFont.style + } + await loadFonts(desiredFont ? [desiredFont] : currentTextFonts(node), state.fonts) + return desiredFont +} + +type FontVariableBindings = Pick + +async function loadVariableFontTransitions( + current: FontName[], + bindings: FontVariableBindings | undefined, + values: { family?: string; style?: string; weight?: number }, + state: ApplyState +): Promise { + let fonts = current + const required = [...fonts] + // Native setters apply one binding at a time, in the supplied binding order. + for (const field of Object.keys(bindings ?? {}) as Array) { + if (!bindings?.[field]) continue + if (field === 'fontFamily' && values.family !== undefined) { + const family = values.family + fonts = await Promise.all( + fonts.map((font) => + font.family === family ? font : resolveFamilyFont(family, font.style, state.fonts) + ) + ) + } else if (field === 'fontStyle' && values.style !== undefined) { + const style = values.style + fonts = fonts.map((font) => ({ family: font.family, style })) + } else if (field === 'fontWeight' && values.weight !== undefined) { + fonts = await Promise.all( + fonts.map((font) => resolveFamilyFont(font.family, font.style, state.fonts, values.weight)) + ) + } else continue + required.push(...fonts) + } + await loadFonts(required, state.fonts) +} + +function resolvedFontVariableValue( + node: TextNode, + reference: CanvasVariableReference, + state: ApplyState +): string { + const value = resolvedVariable(reference, state.variables).resolveForConsumer(node).value + if (typeof value !== 'string') { + applyError('A preflighted font variable did not resolve to a string.') + } + return value +} + +export async function loadVariableFonts( + node: TextNode, + bindings: FontVariableBindings | undefined, + state: ApplyState, + range?: Pick +): Promise { + const familyReference = bindings?.fontFamily + const styleReference = bindings?.fontStyle + const weightReference = bindings?.fontWeight + if (!familyReference && !styleReference && !weightReference) return + const weight = weightReference + ? resolvedVariable(weightReference, state.variables).resolveForConsumer(node).value + : undefined + if ( + weight !== undefined && + (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 1 || weight > 1000) + ) { + specError(`Font weight on "${node.id}" must resolve to a number between 1 and 1000.`) + } + + await loadVariableFontTransitions( + currentTextFonts(node, range), + bindings, + { + family: familyReference ? resolvedFontVariableValue(node, familyReference, state) : undefined, + style: styleReference ? resolvedFontVariableValue(node, styleReference, state) : undefined, + weight + }, + state + ) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text-paints.ts b/packages/extension/mcp/tools/canvas/reconcile/text-paints.ts new file mode 100644 index 00000000..c0a26319 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text-paints.ts @@ -0,0 +1,48 @@ +import type { CanvasFigmaTextRange } from '@tempad-dev/shared' + +import { paintStacksEqual } from './native-values' +import { textRangesMatchOutside } from './text-ranges' + +export function textFillsUnstyledOutsideRanges( + node: TextNode, + ranges: CanvasFigmaTextRange[] | undefined +): boolean { + // Explicit unlinking does not retain a style in this span. Only links outside + // the authored overrides can protect the base color. + const overrides = + ranges?.filter((range) => range.fills !== undefined || range.fillStyle !== undefined) ?? [] + return ( + node.characters.length > 0 && + overrides.length > 0 && + textRangesMatchOutside( + { start: 0, end: node.characters.length }, + overrides, + (start, end) => node.getRangeFillStyleId(start, end) === '' + ) + ) +} + +export function textFillsMatch( + node: TextNode, + desired: Paint[], + ranges: CanvasFigmaTextRange[] | undefined +): boolean { + const current = node.fills + if (current !== figma.mixed && !node.fillStyleId && paintStacksEqual(current, desired)) { + return true + } + // Clearing a range's style alone does not replace its paint value. + const overrides = ranges?.filter((range) => range.fills !== undefined || range.fillStyle) ?? [] + return ( + node.characters.length > 0 && + overrides.length > 0 && + textRangesMatchOutside({ start: 0, end: node.characters.length }, overrides, (start, end) => { + const paints = node.getRangeFills(start, end) + return ( + paints !== figma.mixed && + node.getRangeFillStyleId(start, end) === '' && + paintStacksEqual(paints, desired) + ) + }) + ) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text-paragraphs.ts b/packages/extension/mcp/tools/canvas/reconcile/text-paragraphs.ts new file mode 100644 index 00000000..c94f1dd8 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text-paragraphs.ts @@ -0,0 +1,50 @@ +import type { CanvasFigmaTextRange } from '@tempad-dev/shared' + +import { textParagraphRanges, type TextRange } from './text-ranges' + +const PARAGRAPH_FIELDS = [ + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing', + 'textWrapStyle' +] as const +type ParagraphField = (typeof PARAGRAPH_FIELDS)[number] +export type TextParagraph = { + range: CanvasFigmaTextRange + span: TextRange + effective: Record +} + +export function textParagraphs( + characters: string, + ranges: readonly CanvasFigmaTextRange[] +): TextParagraph[] { + const spans = textParagraphRanges(characters, ranges) + const nextOwner = { + paragraphIndent: characters.length, + paragraphSpacing: characters.length, + listSpacing: characters.length, + textWrapStyle: characters.length + } + const result: TextParagraph[] = Array(ranges.length) + for (let index = ranges.length - 1; index >= 0; index--) { + const range = ranges[index]! + const span = spans[index]! + const effective = {} as Record + for (const field of PARAGRAPH_FIELDS) { + // The schema requires ordered, non-overlapping character ranges. A later + // range can therefore overlap only this range's final paragraph; its first + // owning paragraph is enough to truncate the effective comparison span. + effective[field] = { start: span.start, end: Math.min(span.end, nextOwner[field]) } + if ( + range.textStyle || + (field !== 'textWrapStyle' && range[field] !== undefined) || + ((field === 'paragraphIndent' || field === 'paragraphSpacing') && range.variables?.[field]) + ) { + nextOwner[field] = span.start + } + } + result[index] = { range, span, effective } + } + return result +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text-range-style.ts b/packages/extension/mcp/tools/canvas/reconcile/text-range-style.ts new file mode 100644 index 00000000..764ba562 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text-range-style.ts @@ -0,0 +1,253 @@ +import type { CanvasFigmaTextRange, CanvasVariableReference } from '@tempad-dev/shared' + +import type { ApplyState } from './state' +import type { TextParagraph } from './text-paragraphs' + +import { applyError } from '../errors' +import { resolveFamilyFont } from '../fonts' +import { resolveStyle } from '../styles' +import { resolveVariable } from '../variables' +import { + fontNamesEqual, + nativeValueEqual, + scalarValuesEqual, + textMeasuresEqual +} from './comparison' +import { TEXT_STYLE_VARIABLES_BY_VALUE } from './text-fonts' + +const READERS = { + fontName: (node: TextNode, start: number, end: number) => node.getRangeFontName(start, end), + fontSize: (node: TextNode, start: number, end: number) => node.getRangeFontSize(start, end), + lineHeight: (node: TextNode, start: number, end: number) => node.getRangeLineHeight(start, end), + letterSpacing: (node: TextNode, start: number, end: number) => + node.getRangeLetterSpacing(start, end), + textCase: (node: TextNode, start: number, end: number) => node.getRangeTextCase(start, end), + textDecoration: (node: TextNode, start: number, end: number) => + node.getRangeTextDecoration(start, end), + paragraphIndent: (node: TextNode, start: number, end: number) => + node.getRangeParagraphIndent(start, end), + paragraphSpacing: (node: TextNode, start: number, end: number) => + node.getRangeParagraphSpacing(start, end), + listSpacing: (node: TextNode, start: number, end: number) => node.getRangeListSpacing(start, end), + textWrapStyle: (node: TextNode, start: number, end: number) => + node.getRangeTextWrapStyle(start, end) +} +type Field = keyof typeof READERS +type RangeStyleValues = Pick +type RangeStyleState = { + style: TextStyle + values: RangeStyleValues + aliases: Partial> + namedFont: boolean + detaches: boolean +} + +const LINK_PRESERVING_FIELDS = new Set([ + 'start', + 'end', + 'textStyle', + 'fills', + 'fillStyle', + 'textDecoration', + 'hyperlink' +]) +const VARIABLE_FIELDS = [ + 'fontFamily', + 'fontStyle', + 'fontWeight', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing' +] as const satisfies readonly VariableBindableTextField[] +export function rangeHasSemanticTextOverrides( + node: TextNode, + range: CanvasFigmaTextRange, + state: ApplyState +): boolean { + if (!rangeNeedsTextStyleComposition(range)) return false + const ranges = state.textRangeSemanticOverrides + const key = `${node.id}:${range.start}:${range.end}` + const known = ranges.get(key) + if (known !== undefined) return known + const retained = node + .getStyledTextSegments(['textStyleOverrides'], range.start, range.end) + .some((segment) => segment.textStyleOverrides?.length) + ranges.set(key, retained) + return retained +} + +export function rangeNeedsTextStyleComposition(range: CanvasFigmaTextRange): boolean { + return !!range.textStyle && Object.keys(range).some((field) => !LINK_PRESERVING_FIELDS.has(field)) +} + +async function applyVariableValue( + values: RangeStyleValues, + field: VariableBindableTextField, + reference: CanvasVariableReference, + node: TextNode, + state: ApplyState +): Promise { + const variable = await resolveVariable(reference, state.variables) + const value = variable.resolveForConsumer(node).value + if (field === 'fontFamily' || field === 'fontStyle') { + if (typeof value !== 'string') applyError('A text font variable did not resolve to a string.') + if (field === 'fontFamily') { + if (values.fontName.family !== value) { + values.fontName = await resolveFamilyFont(value, values.fontName.style, state.fonts) + } + } else if (values.fontName.style !== value) { + values.fontName = { family: values.fontName.family, style: value } + } + } else { + if (typeof value !== 'number' || !Number.isFinite(value)) { + applyError('A text typography variable did not resolve to a finite number.') + } + if (field === 'lineHeight' || field === 'letterSpacing') { + values[field] = { unit: 'PIXELS', value } + } else if ( + field === 'fontSize' || + field === 'paragraphIndent' || + field === 'paragraphSpacing' + ) { + values[field] = value + } + } + return variable.id +} + +export async function resolvedRangeTextStyle( + node: TextNode, + range: CanvasFigmaTextRange, + state: ApplyState +): Promise { + if (!range.textStyle || !rangeNeedsTextStyleComposition(range)) { + return null + } + if (rangeHasSemanticTextOverrides(node, range, state)) return null + const style = (await resolveStyle(range.textStyle, state.styles)) as TextStyle + // A weight variable may select an unnamed variable-font instance. Font + // loading's nearest named face does not prove that instance's exact identity. + if (style.boundVariables?.fontWeight || range.variables?.fontWeight) return null + const values = Object.fromEntries( + Object.keys(READERS).map((field) => [field, style[field as Field]]) + ) as RangeStyleValues + const aliases: RangeStyleState['aliases'] = {} + let detaches = false + // Match native order: style defaults in the consumer's mode, literal + // overrides (which clear their bindings), then explicit range bindings. + for (const field of VARIABLE_FIELDS) { + const alias = style.boundVariables?.[field] + if (alias) + aliases[field] = await applyVariableValue(values, field, { id: alias.id }, node, state) + } + for (const field of Object.keys(READERS) as Field[]) { + if (field === 'textWrapStyle' || range[field] === undefined) continue + if (fieldMatches(field, values[field], range[field], field === 'fontName')) continue + Object.assign(values, { [field]: range[field] }) + for (const variable of TEXT_STYLE_VARIABLES_BY_VALUE[field]) delete aliases[variable] + if (!LINK_PRESERVING_FIELDS.has(field)) detaches = true + } + for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< + [VariableBindableTextField, CanvasVariableReference | null] + >) { + const previous = aliases[field] + if (reference === null) delete aliases[field] + else aliases[field] = await applyVariableValue(values, field, reference, node, state) + if (previous !== aliases[field]) detaches = true + } + const unknownOverrides = Object.keys(range).some( + (field) => !LINK_PRESERVING_FIELDS.has(field) && !(field in READERS) && field !== 'variables' + ) + // Unknown-only combinations retain their previous acceptance. A known + // typography override still permits checking its composed style defaults. + if (!detaches && unknownOverrides) return null + return { style, values, aliases, namedFont: range.fontName !== undefined, detaches } +} + +function fieldMatches( + field: Field, + current: unknown, + desired: unknown, + namedFont: boolean +): boolean { + if (field === 'fontName') { + if (!fontNamesEqual(current as FontName | typeof figma.mixed, desired as FontName)) return false + // The authored font schema names a face. Native styles can additionally own + // variable-font axes, whose changes must not disappear during convergence. + const axes = (desired as FontName).variationSettings + return ( + namedFont || + axes === undefined || + nativeValueEqual((current as FontName).variationSettings, axes) + ) + } + if (field === 'lineHeight' || field === 'letterSpacing') { + return textMeasuresEqual( + field, + current as LineHeight | LetterSpacing | typeof figma.mixed, + desired as LineHeight | LetterSpacing + ) + } + return scalarValuesEqual(current, desired) +} + +function effectiveFieldMatches( + range: CanvasFigmaTextRange, + field: Field, + paragraphs: TextParagraph['effective'], + matches: (start: number, end: number) => boolean +): boolean { + if ( + field !== 'paragraphIndent' && + field !== 'paragraphSpacing' && + field !== 'listSpacing' && + field !== 'textWrapStyle' + ) { + return matches(range.start, range.end) + } + const scope = paragraphs[field] + return scope.start === scope.end || matches(scope.start, scope.end) +} + +export function rangeTextStyleMatches( + node: TextNode, + range: CanvasFigmaTextRange, + expected: RangeStyleState, + paragraphs: TextParagraph['effective'] +): boolean { + const currentStyle = node.getRangeTextStyleId(range.start, range.end) + if (currentStyle !== '' && currentStyle !== expected.style.id) return false + if (!currentStyle && !expected.detaches) return false + for (const field of Object.keys(READERS) as Field[]) { + const desired = expected.values[field] + if (desired === undefined) continue + if ( + !effectiveFieldMatches(range, field, paragraphs, (start, end) => + fieldMatches(field, READERS[field](node, start, end), desired, expected.namedFont) + ) + ) + return false + } + for (const field of VARIABLE_FIELDS) { + const valueField = + field === 'fontFamily' || field === 'fontStyle' || field === 'fontWeight' ? 'fontName' : field + if ( + !effectiveFieldMatches(range, valueField, paragraphs, (start, end) => { + const current = node.getRangeBoundVariable(start, end, field) + return current !== figma.mixed && current?.id === expected.aliases[field] + }) + ) + return false + } + return true +} + +export function rangeTextStyleCanSkip(node: TextNode, expected: RangeStyleState): boolean { + // These native style fields have no range getter. A whole-node mismatch + // cannot prove a no-op, but is not evidence of an invalid range override. + return (['leadingTrim', 'hangingPunctuation', 'hangingList'] as const).every( + (field) => expected.style[field] === undefined || node[field] === expected.style[field] + ) +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text-ranges.ts b/packages/extension/mcp/tools/canvas/reconcile/text-ranges.ts new file mode 100644 index 00000000..a6b8db90 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text-ranges.ts @@ -0,0 +1,113 @@ +import type { CanvasFigmaTextRange } from '@tempad-dev/shared' + +export type TextRange = { start: number; end: number } +export type TextMetricField = 'fontSize' | 'lineHeight' | 'letterSpacing' + +export function textRangeWithTrailingNewline(characters: string, range: TextRange): TextRange { + // Native character metrics also apply to an immediately following paragraph break. + return characters[range.end] === '\n' ? { start: range.start, end: range.end + 1 } : range +} + +export function textMetricRange( + characters: string, + range: TextRange, + field: TextMetricField, + next?: CanvasFigmaTextRange +): TextRange { + // Native setter extent does not give an earlier declaration ownership over a + // later range's newline. Compare only the field's final owner. + if ( + next?.start === range.end && + (next.textStyle || next[field] !== undefined || next.variables?.[field] !== undefined) + ) + return range + return textRangeWithTrailingNewline(characters, range) +} + +export function* textParagraphContents( + characters: string, + range: TextRange +): Generator<{ span: TextRange; content: TextRange }> { + let start = range.start + while (start < range.end) { + const newline = characters.indexOf('\n', start) + const end = Math.min(range.end, newline === -1 ? characters.length : newline + 1) + yield { + span: { start, end }, + content: { start, end: characters[end - 1] === '\n' ? end - 1 : end } + } + start = end + } +} + +export function textParagraphRange(characters: string, range: TextRange): TextRange { + // Paragraph setters affect every paragraph touched, including its newline. + const start = range.start === 0 ? 0 : characters.lastIndexOf('\n', range.start - 1) + 1 + const newline = characters.indexOf('\n', range.end - 1) + return { start, end: newline === -1 ? characters.length : newline + 1 } +} + +export function textParagraphRanges(characters: string, ranges: readonly TextRange[]): TextRange[] { + if (!ranges.length) return [] + let start = 0 + let end = 0 + const advance = () => { + start = end + const newline = characters.indexOf('\n', start) + end = newline === -1 ? characters.length : newline + 1 + } + advance() + // Authored ranges are ordered and do not overlap, so both paragraph boundaries + // advance monotonically. Each newline is visited once, regardless of range count. + return ranges.map((range) => { + while (end <= range.start && end < characters.length) advance() + const first = start + while (end < range.end && end < characters.length) advance() + return { start: first, end } + }) +} + +export function* textRangesOutside( + scope: TextRange, + overrides: readonly TextRange[] +): Generator { + let position = scope.start + // Authored ranges are ordered. Paragraph expansion can make them overlap. + for (const range of overrides) { + const start = Math.max(scope.start, range.start) + if (start >= scope.end) break + const end = Math.min(scope.end, range.end) + if (end <= position) continue + if (position < start) yield { start: position, end: start } + position = end + } + if (position < scope.end) yield { start: position, end: scope.end } +} + +export function* textRangesIntersect( + left: readonly TextRange[], + right: readonly TextRange[] +): Generator { + let leftIndex = 0 + let rightIndex = 0 + while (leftIndex < left.length && rightIndex < right.length) { + const a = left[leftIndex]! + const b = right[rightIndex]! + const start = Math.max(a.start, b.start) + const end = Math.min(a.end, b.end) + if (start < end) yield { start, end } + if (a.end <= b.end) leftIndex++ + if (b.end <= a.end) rightIndex++ + } +} + +export function textRangesMatchOutside( + scope: TextRange, + overrides: readonly TextRange[], + matches: (start: number, end: number) => boolean +): boolean { + for (const { start, end } of textRangesOutside(scope, overrides)) { + if (!matches(start, end)) return false + } + return true +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text-style-scope.ts b/packages/extension/mcp/tools/canvas/reconcile/text-style-scope.ts new file mode 100644 index 00000000..6f6a5b42 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text-style-scope.ts @@ -0,0 +1,55 @@ +import type { CanvasNodeSpec } from '../model' + +import { textRangesOutside, type TextRange } from './text-ranges' + +export type BaseTextStyleScope = + | { kind: 'whole' } + | { kind: 'retained' } + | { kind: 'partial'; ranges: TextRange[] } + +export function baseTextStyleScope( + spec: CanvasNodeSpec, + node?: TextNode | null +): BaseTextStyleScope { + if (!node) return { kind: spec.styles?.text ? 'retained' : 'whole' } + // Old character ranges cannot safely describe replacement text. Preserve the + // existing conservative ownership until native text replacement has happened. + if (spec.text?.characters !== node.characters) { + return { + kind: + spec.styles?.text || (spec.styles?.text !== null && node.textStyleId) ? 'retained' : 'whole' + } + } + const scope = { start: 0, end: node.characters.length } + const ranges = spec.figma?.text?.ranges ?? [] + const clearing = ranges.filter((range) => range.textStyle === null) + let retained: TextRange[] + if (spec.styles?.text === null) { + retained = ranges.filter((range) => range.textStyle) + } else if (spec.styles?.text || (node.textStyleId && node.textStyleId !== figma.mixed)) { + // A whole style owns every character except ranges explicitly cleared by + // this request, even when that whole style is declared again. + if (!clearing.length) return { kind: 'retained' } + retained = [...textRangesOutside(scope, clearing)] + } else if (!node.textStyleId) { + return { kind: 'whole' } + } else { + const segments = node.getStyledTextSegments(['textStyleId']) + // Native segments cover the complete string. Incomplete bridge data must + // not silently turn an unknown linked span into writable plain text. + let end = 0 + for (const segment of segments) { + if (segment.start !== end || segment.end <= end || typeof segment.textStyleId !== 'string') { + return { kind: 'retained' } + } + end = segment.end + } + if (end !== scope.end) return { kind: 'retained' } + retained = segments.flatMap((segment) => + segment.textStyleId ? [...textRangesOutside(segment, clearing)] : [] + ) + } + if (!retained.length) return { kind: 'whole' } + const writable = [...textRangesOutside(scope, retained)] + return writable.length ? { kind: 'partial', ranges: writable } : { kind: 'retained' } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/text.ts b/packages/extension/mcp/tools/canvas/reconcile/text.ts new file mode 100644 index 00000000..15b5e08d --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/text.ts @@ -0,0 +1,872 @@ +import type { + CanvasHyperlink, + CanvasStyleReference, + CanvasFigmaTextRange, + CanvasVariableReference, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' + +import { applyError, specError } from '../errors' +import { resolveStyle } from '../styles' +import { resolveVariable, resolvedVariable } from '../variables' +import { currentBoundVariableId, unboundTextMetricRanges } from './binding-values' +import { + ensureTextRangeUnbound, + protectFollowingTextStyle, + repairTextRangeBindings +} from './bindings' +import { + textMeasuresEqual, + hyperlinksEqual, + nativeValueEqual, + fontNamesEqual, + scalarValuesEqual +} from './comparison' +import { preservesComponentPropertyReference } from './components' +import { nativePaint, paintStacksEqual } from './native-values' +import { isCanvasKeyHyperlink, nativeHyperlink } from './nodes' +import { type ApplyState, markMutation, setValue } from './state' +import { loadTextFonts, loadVariableFonts, TEXT_STYLE_VARIABLES_BY_VALUE } from './text-fonts' +import { textParagraphs, type TextParagraph } from './text-paragraphs' +import { + rangeTextStyleCanSkip, + rangeTextStyleMatches, + resolvedRangeTextStyle +} from './text-range-style' +import { + textParagraphRanges, + textParagraphContents, + textMetricRange, + textRangesIntersect, + textRangesMatchOutside, + textRangesOutside, + type TextRange +} from './text-ranges' +import { baseTextStyleScope, type BaseTextStyleScope } from './text-style-scope' + +export async function applyText( + node: TextNode, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + const text = spec.text + if (!text) return + const native = spec.figma?.text + let styleScope = baseTextStyleScope(spec, node) + await applyBaseTextFont(node, spec, state, styleScope) + setValue(node, node.autoRename, native?.autoRename, (value) => (node.autoRename = value), state) + const previousCharacters = node.characters + setValue( + node, + node.characters, + preservesComponentPropertyReference(node, spec, 'characters') || + spec.variables?.characters || + currentBoundVariableId(node, 'characters') + ? undefined + : text.characters, + (value) => (node.characters = value), + state + ) + // Character replacement can change both the range boundaries and their aliases. + styleScope = baseTextStyleScope(spec, node) + if (node.characters !== previousCharacters) await applyBaseTextFont(node, spec, state, styleScope) + const hasTextStyle = styleScope.kind === 'retained' + const variableRanges = { + fontSize: unboundTextMetricRanges(node, spec, 'fontSize'), + lineHeight: unboundTextMetricRanges(node, spec, 'lineHeight'), + letterSpacing: unboundTextMetricRanges(node, spec, 'letterSpacing'), + paragraphIndent: unboundTextMetricRanges(node, spec, 'paragraphIndent'), + paragraphSpacing: unboundTextMetricRanges(node, spec, 'paragraphSpacing') + } + await setStyledBaseTextValue( + node, + 'fontSize', + hasTextStyle ? undefined : text.fontSize, + { + current: node.fontSize, + apply: (value) => (node.fontSize = value), + applyRange: (start, end, value) => node.setRangeFontSize(start, end, value), + readRange: (start, end) => node.getRangeFontSize(start, end) + }, + native?.ranges, + state, + styleScope, + variableRanges.fontSize + ) + await setStyledBaseTextValue( + node, + 'lineHeight', + hasTextStyle ? undefined : text.lineHeight, + { + current: node.lineHeight, + apply: (value) => (node.lineHeight = value), + applyRange: (start, end, value) => node.setRangeLineHeight(start, end, value), + readRange: (start, end) => node.getRangeLineHeight(start, end), + equal: (current, desired) => textMeasuresEqual('lineHeight', current, desired) + }, + native?.ranges, + state, + styleScope, + variableRanges.lineHeight + ) + await setStyledBaseTextValue( + node, + 'letterSpacing', + hasTextStyle ? undefined : text.letterSpacing, + { + current: node.letterSpacing, + apply: (value) => (node.letterSpacing = value), + applyRange: (start, end, value) => node.setRangeLetterSpacing(start, end, value), + readRange: (start, end) => node.getRangeLetterSpacing(start, end), + equal: (current, desired) => textMeasuresEqual('letterSpacing', current, desired) + }, + native?.ranges, + state, + styleScope, + variableRanges.letterSpacing + ) + setValue( + node, + node.textAlignHorizontal, + text.alignHorizontal, + (value) => (node.textAlignHorizontal = value), + state + ) + setValue( + node, + node.textAlignVertical, + text.alignVertical, + (value) => (node.textAlignVertical = value), + state + ) + await setStyledBaseTextValue( + node, + 'textCase', + native?.case ?? (hasTextStyle ? undefined : text.textCase), + { + current: node.textCase, + apply: (value) => (node.textCase = value), + applyRange: (start, end, value) => node.setRangeTextCase(start, end, value), + readRange: (start, end) => node.getRangeTextCase(start, end) + }, + native?.ranges, + state, + native?.case !== undefined ? { kind: 'whole' } : styleScope + ) + await setStyledBaseTextValue( + node, + 'textDecoration', + hasTextStyle ? undefined : text.textDecoration, + { + current: node.textDecoration, + apply: (value) => (node.textDecoration = value), + applyRange: (start, end, value) => node.setRangeTextDecoration(start, end, value), + readRange: (start, end) => node.getRangeTextDecoration(start, end) + }, + native?.ranges, + state, + styleScope + ) + setValue( + node, + node.textTruncation, + text.textTruncation, + (value) => (node.textTruncation = value), + state + ) + setValue(node, node.maxLines, text.maxLines, (value) => (node.maxLines = value), state) + setValue( + node, + node.textAutoResize, + text.autoResize, + (value) => (node.textAutoResize = value), + state + ) + setBaseParagraphValue( + node, + 'paragraphIndent', + native?.paragraphIndent, + { + current: node.paragraphIndent, + apply: (value) => (node.paragraphIndent = value), + applyRange: (start, end, value) => node.setRangeParagraphIndent(start, end, value), + readRange: (start, end) => node.getRangeParagraphIndent(start, end) + }, + native?.ranges, + state, + variableRanges.paragraphIndent + ) + setBaseParagraphValue( + node, + 'paragraphSpacing', + native?.paragraphSpacing, + { + current: node.paragraphSpacing, + apply: (value) => (node.paragraphSpacing = value), + applyRange: (start, end, value) => node.setRangeParagraphSpacing(start, end, value), + readRange: (start, end) => node.getRangeParagraphSpacing(start, end) + }, + native?.ranges, + state, + variableRanges.paragraphSpacing + ) + setBaseParagraphValue( + node, + 'listSpacing', + native?.listSpacing, + { + current: node.listSpacing, + apply: (value) => (node.listSpacing = value), + applyRange: (start, end, value) => node.setRangeListSpacing(start, end, value), + readRange: (start, end) => node.getRangeListSpacing(start, end) + }, + native?.ranges, + state + ) + setValue( + node, + node.hangingPunctuation, + native?.hangingPunctuation, + (value) => (node.hangingPunctuation = value), + state + ) + setValue( + node, + node.hangingList, + native?.hangingList, + (value) => (node.hangingList = value), + state + ) + setValue( + node, + node.leadingTrim, + native?.leadingTrim, + (value) => (node.leadingTrim = value), + state + ) + if (!isCanvasKeyHyperlink(native?.hyperlink)) { + applyTextHyperlink(node, native?.hyperlink, state, native?.ranges) + } +} + +async function applyBaseTextFont( + node: TextNode, + spec: CanvasNodeSpec, + state: ApplyState, + styleScope: BaseTextStyleScope +): Promise { + const desired = await loadTextFonts(node, spec, state, styleScope) + await setStyledBaseTextValue( + node, + 'fontName', + desired ?? undefined, + { + current: node.fontName, + apply: (value) => (node.fontName = value), + applyRange: (start, end, value) => node.setRangeFontName(start, end, value), + readRange: (start, end) => node.getRangeFontName(start, end), + equal: fontNamesEqual + }, + node.characters === spec.text?.characters ? spec.figma?.text?.ranges : undefined, + state, + styleScope + ) +} + +type BaseTextField = + | 'fontName' + | 'fontSize' + | 'lineHeight' + | 'letterSpacing' + | 'textCase' + | 'textDecoration' + | 'hyperlink' + +type BaseParagraphField = 'paragraphIndent' | 'paragraphSpacing' | 'listSpacing' + +function rangeOwnsTextField( + range: CanvasFigmaTextRange, + field: BaseTextField | BaseParagraphField +): boolean { + if (range[field] !== undefined) return true + if (field === 'hyperlink') return false + if (range.textStyle) return true + if (field === 'fontName') { + return TEXT_STYLE_VARIABLES_BY_VALUE.fontName.some((variable) => range.variables?.[variable]) + } + if (field === 'textCase' || field === 'textDecoration' || field === 'listSpacing') return false + return !!range.variables?.[field] +} + +type TextValueAccess = { + current: T | typeof figma.mixed + apply: (value: T) => void + readRange: (start: number, end: number) => T | typeof figma.mixed + equal?: (current: T | typeof figma.mixed, desired: T) => boolean +} + +async function setStyledBaseTextValue( + node: TextNode, + field: BaseTextField, + desired: T | undefined, + access: TextValueAccess & { applyRange: (start: number, end: number, value: T) => void }, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState, + styleScope: BaseTextStyleScope, + variableRanges?: TextRange[] +): Promise { + if (desired === undefined || variableRanges?.length === 0) return + if (styleScope.kind !== 'partial' && !variableRanges) { + setBaseTextValue(node, field, desired, access, ranges, state) + return + } + // Restrict writes, not only comparisons, to characters whose existing styles + // and variables do not own the field. + const whole = { start: 0, end: node.characters.length } + const styleRanges = styleScope.kind === 'partial' ? styleScope.ranges : [whole] + for (const scope of textRangesIntersect(styleRanges, variableRanges ?? [whole])) { + const metric = + field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing' + ? field + : undefined + const trailing = metric && node.characters[scope.end] === '\n' + const alias = trailing + ? node.getRangeBoundVariable(scope.end, scope.end + 1, metric) + : undefined + const retained = + alias && alias !== figma.mixed + ? await resolveVariable({ id: alias.id }, state.variables) + : undefined + setBaseTextValue( + node, + field, + desired, + { + current: access.readRange(scope.start, scope.end), + apply: (value) => { + if ( + trailing && + (node.getRangeTextStyleId(scope.end, scope.end + 1) || + alias === figma.mixed || + (retained && node.characters[scope.end + 1] === '\n')) + ) { + applyError( + `Cannot change "${field}" in range ${scope.start}:${scope.end} without changing the retained newline at ${scope.end}:${scope.end + 1}. Preserve this value or explicitly author the adjacent range; do not repeat this unchanged request.` + ) + } + access.applyRange(scope.start, scope.end, value) + if (retained && metric) { + node.setRangeBoundVariable(scope.end, scope.end + 1, metric, retained) + markMutation(state, node) + const actual = node.getRangeBoundVariable(scope.end, scope.end + 1, metric) + if (actual === figma.mixed || actual?.id !== retained.id) + applyError( + `Could not preserve "${field}" on retained newline ${scope.end}:${scope.end + 1}; read the text before further edits.` + ) + } + }, + readRange: access.readRange, + equal: access.equal + }, + ranges, + state, + scope + ) + } +} + +function setBaseTextValue( + node: TextNode, + field: BaseTextField, + desired: T | undefined, + { current, apply, readRange, equal = scalarValuesEqual }: TextValueAccess, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState, + scope = { start: 0, end: node.characters.length } +): void { + if (desired === undefined || equal(current, desired)) return + if (ranges?.length) { + const overrides = ranges + .filter((range) => rangeOwnsTextField(range, field)) + .map((range, index, overrides) => + (field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing') && + (range[field] !== undefined || range.variables?.[field]) + ? textMetricRange(node.characters, range, field, overrides[index + 1]) + : range + ) + if ( + textRangesMatchOutside(scope, overrides, (start, end) => + equal(readRange(start, end), desired) + ) + ) + return + } + // Compare only spans not owned by later overrides. Actual changes retain the + // existing setter and subsequent range restoration. + apply(desired) + markMutation(state, node) +} + +function setBaseParagraphValue( + node: TextNode, + field: BaseParagraphField, + desired: number | undefined, + access: TextValueAccess & { + applyRange: (start: number, end: number, value: number) => void + }, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState, + variableRanges?: TextRange[] +): void { + if (desired === undefined || variableRanges?.length === 0) return + if (!node.characters.length) { + setValue(node, access.current, desired, access.apply, state) + return + } + const owned = (ranges ?? []).filter((range) => rangeOwnsTextField(range, field)) + const overrides = textParagraphRanges(node.characters, owned) + // Native whole-node paragraph writes leave existing range values intact. + // Set only the paragraphs not owned by the later range declarations. + const scope = { start: 0, end: node.characters.length } + const writable = [...textRangesOutside(scope, overrides)] + for (const range of textRangesIntersect(writable, variableRanges ?? [scope])) { + setParagraphValue(node, field, desired, access, range, state) + } +} + +function setParagraphValue( + node: TextNode, + field: BaseParagraphField, + desired: number | undefined, + access: Pick, 'readRange'> & { + applyRange: (start: number, end: number, value: number) => void + }, + range: TextRange, + state: ApplyState +): void { + if (desired === undefined || scalarValuesEqual(access.readRange(range.start, range.end), desired)) + return + for (const { span, content } of textParagraphContents(node.characters, range)) { + if (content.start === content.end) { + if (scalarValuesEqual(access.readRange(span.start, span.end), desired)) continue + applyError( + `Cannot change "${field}" on empty paragraph ${span.start}:${span.end} without affecting the following paragraph. Preserve this value or explicitly replace the empty paragraph; do not repeat this unchanged request.` + ) + } + access.applyRange(content.start, content.end, desired) + markMutation(state, node) + } +} + +export function applyTextHyperlink( + node: TextNode, + hyperlink: CanvasHyperlink | undefined, + state: ApplyState, + ranges: CanvasFigmaTextRange[] | undefined +): void { + const desired = nativeHyperlink(hyperlink, state) + setBaseTextValue( + node, + 'hyperlink', + desired, + { + current: node.hyperlink, + apply: (value) => (node.hyperlink = value), + readRange: (start, end) => node.getRangeHyperlink(start, end), + equal: hyperlinksEqual + }, + ranges, + state + ) +} + +function applyTextRangeValue( + node: TextNode, + current: T | typeof figma.mixed | null, + desired: T | undefined, + apply: (value: T) => void, + state: ApplyState, + equal: (current: T | typeof figma.mixed | null, desired: T) => boolean = nativeValueEqual +): void { + if (desired === undefined || equal(current, desired)) return + apply(desired) + markMutation(state, node) +} + +async function applyTextRangeStyle( + node: TextNode, + reference: CanvasStyleReference | null | undefined, + current: () => string | typeof figma.mixed, + apply: (id: string) => Promise, + state: ApplyState, + force = false +): Promise { + if (reference === undefined) return + const styleId = reference ? (await resolveStyle(reference, state.styles)).id : '' + if (current() === styleId && !force) return + await apply(styleId) + markMutation(state, node) +} + +function applyTextRangeFills(node: TextNode, range: CanvasFigmaTextRange, state: ApplyState): void { + if (range.fills === undefined) return + const desired = range.fills.map((paint) => nativePaint(paint, state)) + const current = node.getRangeFills(range.start, range.end) + const style = node.getRangeFillStyleId(range.start, range.end) + if (current !== figma.mixed && !style && paintStacksEqual(current, desired)) return + node.setRangeFills(range.start, range.end, desired) + markMutation(state, node) +} + +function nativeTextDecorationColor( + range: CanvasFigmaTextRange, + state: ApplyState +): TextDecorationColor | undefined { + const color = range.textDecorationColor + if (!color || color.value === 'AUTO') return color + return { value: nativePaint(color.value, state) as SolidPaint } +} + +async function paragraphUnbindingRange( + node: TextNode, + paragraphs: readonly TextParagraph[], + index: number, + field: 'paragraphIndent' | 'paragraphSpacing', + state: ApplyState +): Promise { + const span = paragraphs[index]!.span + for (let laterIndex = index + 1; laterIndex < paragraphs.length; laterIndex++) { + const later = paragraphs[laterIndex]! + if (later.span.start >= span.end) break + let ownsBinding = !!later.range.variables?.[field] + if (!ownsBinding && later.range.textStyle) { + const style = await resolveStyle(later.range.textStyle, state.styles) + const desired = style.type === 'TEXT' ? style.boundVariables?.[field] : undefined + if (desired) { + const current = node.getRangeBoundVariable(later.span.start, span.end, field) + // A retained style can have semantic overrides. Preserve its own alias, + // not a different one that an equal literal may leave untouched. + ownsBinding = + node.getRangeTextStyleId(later.range.start, later.range.end) !== style.id || + (current !== figma.mixed && current?.id === desired.id) + } + } + if (ownsBinding) { + return { start: span.start, end: later.span.start } + } + } + return span +} + +async function applyTextRangeVariables( + node: TextNode, + paragraphs: readonly TextParagraph[], + index: number, + state: ApplyState +): Promise { + const paragraph = paragraphs[index]! + const range = paragraph.range + await loadVariableFonts(node, range.variables, state, range) + for (const [field, reference] of Object.entries(range.variables ?? {}) as Array< + [VariableBindableTextField, CanvasVariableReference | null] + >) { + const variable = reference ? resolvedVariable(reference, state.variables) : null + // Null setters need complete paragraphs, but must leave a later binding + // owner intact. An equal literal alone does not own the alias. + const bindingRange = + !variable && (field === 'paragraphIndent' || field === 'paragraphSpacing') + ? await paragraphUnbindingRange(node, paragraphs, index, field, state) + : range + if (bindingRange.start === bindingRange.end) continue + const comparisonRange = + field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing' + ? textMetricRange(node.characters, bindingRange, field, paragraphs[index + 1]?.range) + : bindingRange + const current = node.getRangeBoundVariable(comparisonRange.start, comparisonRange.end, field) + if (current !== figma.mixed && current?.id === variable?.id) continue + if (variable && (field === 'paragraphIndent' || field === 'paragraphSpacing')) { + const scope = paragraph.effective[field] + const alias = + scope.start < scope.end + ? node.getRangeBoundVariable(scope.start, scope.end, field) + : undefined + const unchanged = + scope.start === scope.end || (alias !== figma.mixed && alias?.id === variable.id) + const overriding: TextParagraph[] = [] + for (let laterIndex = index + 1; laterIndex < paragraphs.length; laterIndex++) { + const later = paragraphs[laterIndex]! + if (later.span.start >= paragraph.span.end) break + if (rangeOwnsTextField(later.range, field)) overriding.push(later) + } + if (unchanged && overriding.length) { + // An equal later literal may skip its native setter. Do not let its value + // ownership retain a different alias or swallow an explicit null unbinding. + const value = variable.resolveForConsumer(node).value + const clearsBinding = overriding.every( + (later) => + later.range.textStyle === undefined && + later.range.variables?.[field] === undefined && + later.range[field] !== undefined && + !scalarValuesEqual(value, later.range[field]) + ) + // The later setter can also clear aliases beyond the earlier range. + const unbound = overriding.every(({ span }) => { + const alias = node.getRangeBoundVariable(span.start, span.end, field) + return alias !== figma.mixed && !alias + }) + if (clearsBinding && unbound) continue + } + } + if (field === 'fontSize' || field === 'lineHeight' || field === 'letterSpacing') + await protectFollowingTextStyle( + node, + bindingRange, + field, + paragraphs[index + 1]?.range, + state, + variable?.id + ) + node.setRangeBoundVariable(bindingRange.start, bindingRange.end, field, variable) + markMutation(state, node) + if (!variable) ensureTextRangeUnbound(node, comparisonRange, field, state) + } +} + +export async function applyTextRanges( + node: TextNode, + ranges: CanvasFigmaTextRange[] | undefined, + state: ApplyState, + bindings?: CanvasVariableBindings +): Promise { + const paragraphs = textParagraphs(node.characters, ranges ?? []) + const earlierBindings = { paragraphIndent: 0, paragraphSpacing: 0 } + for (const [index, paragraph] of paragraphs.entries()) { + const range = paragraph.range + if (range.end > node.characters.length) { + specError( + `Text range ${range.start}:${range.end} exceeds TEXT node "${node.id}" with ${node.characters.length} UTF-16 code units.` + ) + } + // Repair this owner after earlier setters have run: their native newline + // extent can otherwise invalidate a later equal literal before it is applied. + await repairTextRangeBindings(node, paragraphs, index, bindings, earlierBindings, state) + const composed = await resolvedRangeTextStyle(node, range, state) + const matches = composed && rangeTextStyleMatches(node, range, composed, paragraph.effective) + const unchanged = composed && matches && rangeTextStyleCanSkip(node, composed) + const typography: CanvasFigmaTextRange = unchanged + ? { start: range.start, end: range.end } + : range + if (!unchanged) + await applyTextRangeStyle( + node, + range.textStyle, + () => node.getRangeTextStyleId(range.start, range.end), + (id) => node.setRangeTextStyleIdAsync(range.start, range.end, id), + state, + !!composed && !matches + ) + await applyTextRangeStyle( + node, + range.fillStyle, + () => node.getRangeFillStyleId(range.start, range.end), + (id) => node.setRangeFillStyleIdAsync(range.start, range.end, id), + state + ) + applyTextRangeFills(node, range, state) + applyTextRangeValue( + node, + node.getRangeFontName(range.start, range.end), + typography.fontName, + (value) => node.setRangeFontName(range.start, range.end, value), + state, + (current, desired) => current !== null && fontNamesEqual(current, desired) + ) + if ( + typography.fontSize !== undefined && + !scalarValuesEqual(node.getRangeFontSize(range.start, range.end), typography.fontSize) + ) + await protectFollowingTextStyle( + node, + range, + 'fontSize', + ranges?.[index + 1], + state, + undefined, + typography.fontSize + ) + applyTextRangeValue( + node, + node.getRangeFontSize(range.start, range.end), + typography.fontSize, + (value) => node.setRangeFontSize(range.start, range.end, value), + state, + scalarValuesEqual + ) + applyTextRangeValue( + node, + node.getRangeTextCase(range.start, range.end), + typography.textCase, + (value) => node.setRangeTextCase(range.start, range.end, value), + state + ) + if ( + typography.letterSpacing !== undefined && + !textMeasuresEqual( + 'letterSpacing', + node.getRangeLetterSpacing(range.start, range.end), + typography.letterSpacing + ) + ) + await protectFollowingTextStyle( + node, + range, + 'letterSpacing', + ranges?.[index + 1], + state, + undefined, + typography.letterSpacing + ) + applyTextRangeValue( + node, + node.getRangeLetterSpacing(range.start, range.end), + typography.letterSpacing, + (value) => node.setRangeLetterSpacing(range.start, range.end, value), + state, + (current, desired) => current !== null && textMeasuresEqual('letterSpacing', current, desired) + ) + if ( + typography.lineHeight !== undefined && + !textMeasuresEqual( + 'lineHeight', + node.getRangeLineHeight(range.start, range.end), + typography.lineHeight + ) + ) + await protectFollowingTextStyle( + node, + range, + 'lineHeight', + ranges?.[index + 1], + state, + undefined, + typography.lineHeight + ) + applyTextRangeValue( + node, + node.getRangeLineHeight(range.start, range.end), + typography.lineHeight, + (value) => node.setRangeLineHeight(range.start, range.end, value), + state, + (current, desired) => current !== null && textMeasuresEqual('lineHeight', current, desired) + ) + applyTextRangeValue( + node, + node.getRangeTextDecoration(range.start, range.end), + typography.textDecoration, + (value) => node.setRangeTextDecoration(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationStyle(range.start, range.end), + range.textDecorationStyle, + (value) => node.setRangeTextDecorationStyle(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationOffset(range.start, range.end), + range.textDecorationOffset, + (value) => node.setRangeTextDecorationOffset(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationThickness(range.start, range.end), + range.textDecorationThickness, + (value) => node.setRangeTextDecorationThickness(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationColor(range.start, range.end), + nativeTextDecorationColor(range, state), + (value) => node.setRangeTextDecorationColor(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeTextDecorationSkipInk(range.start, range.end), + range.textDecorationSkipInk, + (value) => node.setRangeTextDecorationSkipInk(range.start, range.end, value), + state + ) + applyTextRangeValue( + node, + node.getRangeListOptions(range.start, range.end), + range.listOptions, + (value) => node.setRangeListOptions(range.start, range.end, value), + state + ) + const listSpacing = paragraph.effective.listSpacing + if (listSpacing.start < listSpacing.end) + setParagraphValue( + node, + 'listSpacing', + typography.listSpacing, + { + readRange: (start, end) => node.getRangeListSpacing(start, end), + applyRange: (start, end, value) => node.setRangeListSpacing(start, end, value) + }, + listSpacing, + state + ) + applyTextRangeValue( + node, + node.getRangeIndentation(range.start, range.end), + range.indentation, + (value) => node.setRangeIndentation(range.start, range.end, value), + state + ) + const indent = paragraph.effective.paragraphIndent + if (indent.start < indent.end) + setParagraphValue( + node, + 'paragraphIndent', + typography.paragraphIndent, + { + readRange: (start, end) => node.getRangeParagraphIndent(start, end), + applyRange: (start, end, value) => node.setRangeParagraphIndent(start, end, value) + }, + indent, + state + ) + const spacing = paragraph.effective.paragraphSpacing + if (spacing.start < spacing.end) + setParagraphValue( + node, + 'paragraphSpacing', + typography.paragraphSpacing, + { + readRange: (start, end) => node.getRangeParagraphSpacing(start, end), + applyRange: (start, end, value) => node.setRangeParagraphSpacing(start, end, value) + }, + spacing, + state + ) + const hyperlink = nativeHyperlink(range.hyperlink, state) + if ( + hyperlink !== undefined && + !hyperlinksEqual(node.getRangeHyperlink(range.start, range.end), hyperlink) + ) { + node.setRangeHyperlink(range.start, range.end, hyperlink) + markMutation(state, node) + } + await applyTextRangeVariables(node, paragraphs, index, state) + for (const field of ['paragraphIndent', 'paragraphSpacing'] as const) { + if (range.textStyle || range.variables?.[field]) earlierBindings[field] = paragraph.span.end + } + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/tree.ts b/packages/extension/mcp/tools/canvas/reconcile/tree.ts new file mode 100644 index 00000000..0fadbfff --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/tree.ts @@ -0,0 +1,547 @@ +import type { CanvasNodeSpec } from '../model' + +import { specError, canvasReadOnlyError, errorMessage, applyError } from '../errors' +import { loadFonts, currentTextFonts } from '../fonts' +import { + applyShape, + applyAppearance, + applyLayoutAids, + applyPaintStacks, + applyEffects, + applySharedLayerState, + applyMask +} from './appearance' +import { unlinkStyles, clearVariables, applyStyles, applyVariables } from './bindings' +import { + componentDefinitionOwner, + componentPropertyKeys, + resolveComponent, + applyComponent, + applyComponentMetadata, + applyAuthoredComponentProperties, + applySlotProperty, + applyComponentPropertyReferences +} from './components' +import { type ReconciledChild, finalizeGrid } from './grid' +import { + applyLayout, + applySize, + applyPosition, + applyRelativeTransform, + applySizingModes, + stabilizeCrossAxisFill, + stabilizeGrowingTextWidth +} from './layout' +import { + isFrameContainer, + hasCanvasKeyPaints, + hasDeferredTextRanges, + isSupportedSceneNode, + hasCanvasKeyVectorPattern, + isCanvasKeyHyperlink, + findExistingNode, + resolveExistingNode, + isWrappedSpec, + isIntrinsicNode +} from './nodes' +import { + type CanvasParentNode, + type ApplyState, + recordCreatedNode, + type SupportedCanvasNode, + markMutation, + setValue, + type WrappedContainerSpec, + type WrappedContainerNode, + moveIntoParent, + setNodeKey +} from './state' +import { applySvg } from './svg' +import { applyText, applyTextRanges, applyTextHyperlink } from './text' +import { applyVariableModes } from './variable-modes' +import { ignoredLiteralWarnings } from './warnings' + +function containingComponentNode(parent: CanvasParentNode | undefined): ComponentNode | null { + let current: BaseNode | null = parent ?? null + while (current) { + if (current.type === 'COMPONENT') return current + current = current.parent + } + return null +} + +function createSlotNode( + spec: CanvasNodeSpec, + parent: CanvasParentNode | undefined, + state: ApplyState +): SlotNode { + const component = containingComponentNode(parent) + if (!component || component.remote) { + specError(`New slot "${spec.key}" must be nested inside a local authored component.`) + } + const owner = componentDefinitionOwner(component) + componentPropertyKeys(owner, state) + const previousNames = new Set(Object.keys(owner.componentPropertyDefinitions)) + const slot = component.createSlot() + recordCreatedNode(slot, state) + // createSlot also adds a definition to the component or its variant set. + markMutation(state, owner) + const propertyNames = Object.entries(owner.componentPropertyDefinitions) + .filter(([name, definition]) => !previousNames.has(name) && definition.type === 'SLOT') + .map(([name]) => name) + if (propertyNames.length !== 1) { + specError(`Figma did not create exactly one slot property for "${spec.key}".`) + } + state.slotPropertyNames.set(slot.id, propertyNames[0]!) + return slot +} + +async function createNode(spec: CanvasNodeSpec, state: ApplyState): Promise { + let node: SupportedCanvasNode + switch (spec.type) { + case 'BOOLEAN_OPERATION': + case 'COMPONENT_SET': + case 'GROUP': + return specError(`${spec.type} nodes must be created from their children.`) + case 'SLOT': + return specError('SLOT nodes must be created by their containing component.') + case 'COMPONENT': + node = figma.createComponent() + break + case 'FRAME': + node = figma.createFrame() + break + case 'INSTANCE': { + const component = await resolveComponent(spec.component!, state) + node = component.createInstance() + break + } + case 'SECTION': + node = figma.createSection() + break + case 'TEXT': + node = figma.createText() + break + case 'RECTANGLE': + node = figma.createRectangle() + break + case 'LINE': + node = figma.createLine() + break + case 'ELLIPSE': + node = figma.createEllipse() + break + case 'POLYGON': + node = figma.createPolygon() + break + case 'STAR': + node = figma.createStar() + break + case 'VECTOR': + node = figma.createVector() + break + } + recordCreatedNode(node, state) + normalizeCreatedFrame(node, spec, state) + return node +} + +function normalizeCreatedFrame( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState +): void { + if ( + (node.type === 'FRAME' || node.type === 'COMPONENT') && + spec.appearance?.clipsContent === undefined && + node.clipsContent + ) { + node.clipsContent = false + markMutation(state, node) + } + if ( + (node.type === 'FRAME' || node.type === 'COMPONENT') && + spec.appearance?.fill === undefined && + node.fills !== figma.mixed && + node.fills.length > 0 + ) { + node.fills = [] + markMutation(state, node) + } +} + +export async function applyNodeProperties( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode, + nativeOnly = false +): Promise { + try { + if (node.type !== 'TEXT') { + state.ignoredLiteralWarnings.push(...ignoredLiteralWarnings(node, spec, nativeOnly)) + } + await applyNodePropertiesUnchecked(node, spec, state, parent, nativeOnly) + } catch (error) { + throwNodeError(error, spec.key, node.id) + } +} + +const errorsWithNodeContext = new WeakSet() + +function throwNodeError(error: unknown, key: string, nodeId?: string): never { + const readOnly = canvasReadOnlyError(error) + if (readOnly) throw readOnly + const failure = + error instanceof Error ? error : new Error(errorMessage(error, 'Native mutation failed.')) + if (errorsWithNodeContext.has(failure)) throw failure + failure.message = `${failure.message.replace(/\.$/, '')} on "${key}"${nodeId ? ` (${nodeId})` : ''}.` + errorsWithNodeContext.add(failure) + throw failure +} + +async function applyNodePropertiesUnchecked( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + state: ApplyState, + parent: CanvasParentNode | undefined, + nativeOnly: boolean +): Promise { + if (node.type === 'TEXT') { + await loadFonts(currentTextFonts(node), state.fonts) + } + if (node.type === 'INSTANCE') await applyComponent(node, spec, state) + await applyVariableModes(node, spec.variableModes, state) + const textRanges = + node.type === 'TEXT' && node.characters === spec.text?.characters + ? spec.figma?.text?.ranges + : undefined + await unlinkStyles(node, spec.styles, state, textRanges) + await clearVariables(node, spec.variables, state, textRanges) + setValue( + node, + node.name, + node.type === 'TEXT' && spec.figma?.text?.autoRename + ? undefined + : (spec.displayName ?? (state.createdNodeIds.has(node.id) ? spec.key : undefined)), + (value) => (node.name = value), + state + ) + if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') { + applyComponentMetadata(node, spec, state) + applyAuthoredComponentProperties(node, spec, state) + } + if (node.type === 'SLOT') applySlotProperty(node, spec, state) + if (isFrameContainer(node)) applyLayout(node, spec, state) + if (node.type === 'BOOLEAN_OPERATION') { + setValue( + node, + node.booleanOperation, + spec.figma?.booleanOperation, + (value) => (node.booleanOperation = value), + state + ) + } + if (node.type === 'SECTION') { + setValue( + node, + node.sectionContentsHidden, + spec.figma?.section?.contentsHidden, + (value) => (node.sectionContentsHidden = value), + state + ) + } + await applyShape(node, spec, state) + applySize(node, spec, parent, state) + await applySvg(node, spec, state) + if (parent) applyPosition(node, spec, parent, state) + applyRelativeTransform(node, spec, parent, state) + applyAppearance(node, spec, state) + await applyStyles(node, spec.styles, state, textRanges) + applyLayoutAids(node, spec, state) + if (!hasCanvasKeyPaints(spec)) { + applyPaintStacks(node, spec, state) + } + applyEffects(node, spec, state) + if (node.type === 'TEXT') { + const previousCharacters = node.characters + await applyText(node, spec, state) + // Replacement can discard mixed fill styles as well as typography aliases. + // Re-evaluate the literal paint against the resulting text's ownership. + if (node.characters !== previousCharacters) applyAppearance(node, spec, state) + // Character replacement can discard old aliases. Inspect the remaining + // ownership before this request adds new node or range bindings. + state.ignoredLiteralWarnings.push(...ignoredLiteralWarnings(node, spec, nativeOnly)) + } + await applyVariables(node, spec.variables, state, spec.figma?.text?.ranges) + if (node.type === 'TEXT' && !hasDeferredTextRanges(spec)) { + await applyTextRanges(node, spec.figma?.text?.ranges, state, spec.variables) + } + applySharedLayerState(node, spec, state) + applyComponentPropertyReferences(node, spec, state) + // Text and layout setters can leave a derived sizing mode or its geometry stale. + if (node.type === 'TEXT') applySizingModes(node, spec, parent, state) + stabilizeCrossAxisFill(node, spec, parent, state) + stabilizeGrowingTextWidth(node, parent, state) +} + +export async function applyCanvasKeyReferences( + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode +): Promise { + const node = state.keyedNodes.get(spec.key) ?? null + if (!isSupportedSceneNode(node)) { + applyError(`Desired node "${spec.key}" was not reconciled.`) + } + if (hasCanvasKeyVectorPattern(spec)) { + await applyShape(node, spec, state, true) + applySize(node, spec, parent, state) + if (parent) applyPosition(node, spec, parent, state) + applyRelativeTransform(node, spec, parent, state) + } + if (hasCanvasKeyPaints(spec)) { + applyPaintStacks(node, spec, state) + } + if (node.type === 'TEXT') { + if (isCanvasKeyHyperlink(spec.figma?.text?.hyperlink)) { + applyTextHyperlink(node, spec.figma.text.hyperlink, state, spec.figma.text.ranges) + } + if (hasDeferredTextRanges(spec)) { + await applyTextRanges(node, spec.figma?.text?.ranges, state, spec.variables) + } + } + stabilizeCrossAxisFill(node, spec, parent, state) + for (const child of spec.children ?? []) { + await applyCanvasKeyReferences(child, state, node as CanvasParentNode) + } +} + +function createWrappedContainer( + spec: WrappedContainerSpec, + children: SupportedCanvasNode[], + parent: CanvasParentNode | undefined, + index: number, + state: ApplyState +): WrappedContainerNode { + const destination = parent ?? figma.currentPage + const destinationIndex = parent ? index : undefined + let node: WrappedContainerNode + switch (spec.type) { + case 'COMPONENT_SET': { + const variants = children.filter( + (child): child is ComponentNode => child.type === 'COMPONENT' + ) + if (variants.length !== children.length) { + specError(`Component set "${spec.key}" can contain only component nodes.`) + } + node = figma.combineAsVariants(variants, destination, destinationIndex) + break + } + case 'GROUP': + node = figma.group(children, destination, destinationIndex) + break + case 'BOOLEAN_OPERATION': + switch (spec.figma!.booleanOperation!) { + case 'UNION': + node = figma.union(children, destination, destinationIndex) + break + case 'SUBTRACT': + node = figma.subtract(children, destination, destinationIndex) + break + case 'INTERSECT': + node = figma.intersect(children, destination, destinationIndex) + break + case 'EXCLUDE': + node = figma.exclude(children, destination, destinationIndex) + break + } + break + } + recordCreatedNode(node, state) + return node +} + +async function reconcileNewWrappedContainer( + spec: WrappedContainerSpec, + state: ApplyState, + parent: CanvasParentNode | undefined, + index: number +): Promise { + state.nodeIdsByKey[spec.key] = '' + // Keep staged descendants inside the update scope when their existing ancestors move. + const stagingParent = parent ?? figma.currentPage + if (spec.type === 'COMPONENT_SET') { + const children = spec.children! + const variants = children.map((child) => { + const existing = findExistingNode(child, state) + if (existing && existing.type !== 'COMPONENT') { + specError(`Component set "${spec.key}" can contain only component nodes.`) + } + const variant = existing ?? figma.createComponent() + if (!existing) { + recordCreatedNode(variant, state, false) + normalizeCreatedFrame(variant, child, state) + } + setValue(variant, variant.name, child.displayName, (value) => (variant.name = value), state) + if (variant.parent?.id !== stagingParent.id) { + moveIntoParent(variant, stagingParent, stagingParent.children.length, state) + } + return variant + }) + const node = createWrappedContainer(spec, variants, parent, index, state) as ComponentSetNode + setNodeKey(state, node, spec.key) + await applyNodeProperties(node, spec, state, parent) + state.nodeIdsByKey[spec.key] = node.id + const reconciled: ReconciledChild[] = [] + for (const [childIndex, child] of children.entries()) { + const variant = await reconcileNode( + child, + state, + node, + desiredChildIndex( + child, + children.slice(childIndex + 1), + state, + node, + reconciled.at(-1)?.node, + variants[childIndex] + ), + variants[childIndex] + ) + reconciled.push({ node: variant, spec: child }) + } + finalizeGrid(node, spec, reconciled, state) + for (const child of reconciled) applyMask(child.node, child.spec, state) + return node + } + + const stagingIndex = stagingParent.children.length + const children: ReconciledChild[] = [] + for (const [childIndex, child] of spec.children!.entries()) { + children.push({ + node: await reconcileNode( + child, + state, + stagingParent, + desiredChildIndex( + child, + spec.children!.slice(childIndex + 1), + state, + stagingParent, + children.at(-1)?.node, + undefined, + stagingIndex + ) + ), + spec: child + }) + } + + const node = createWrappedContainer( + spec, + children.map(({ node: child }) => child), + parent, + index, + state + ) + setNodeKey(state, node, spec.key) + await applyNodeProperties(node, spec, state, parent) + state.nodeIdsByKey[spec.key] = node.id + for (const child of children) applyMask(child.node, child.spec, state) + return node +} + +function desiredChildIndex( + spec: CanvasNodeSpec, + following: CanvasNodeSpec[], + state: ApplyState, + parent: CanvasParentNode, + previous?: SupportedCanvasNode, + forcedNode?: SupportedCanvasNode, + minimumIndex = 0 +): number { + const existing = findExistingNode(spec, state, forcedNode) + const currentIndex = existing?.parent?.id === parent.id ? parent.children.indexOf(existing) : -1 + if (!previous) { + if (currentIndex >= 0) return currentIndex + for (const candidate of following) { + const followingNode = findExistingNode(candidate, state) + if (followingNode?.parent?.id !== parent.id) continue + return Math.max(minimumIndex, parent.children.indexOf(followingNode)) + } + return Math.max(minimumIndex, parent.children.length) + } + + const previousIndex = parent.children.indexOf(previous) + if (currentIndex > previousIndex) return currentIndex + return currentIndex >= 0 && currentIndex < previousIndex ? previousIndex : previousIndex + 1 +} + +export async function reconcileNode( + spec: CanvasNodeSpec, + state: ApplyState, + parent?: CanvasParentNode, + index = 0, + forcedNode?: SupportedCanvasNode +): Promise { + const existing = resolveExistingNode(spec, state, forcedNode) + if (!existing && isWrappedSpec(spec)) { + try { + return await reconcileNewWrappedContainer(spec, state, parent, index) + } catch (error) { + throwNodeError(error, spec.key, state.nodeIdsByKey[spec.key]) + } + } + let node = existing + try { + node ??= + spec.type === 'SLOT' ? createSlotNode(spec, parent, state) : await createNode(spec, state) + if (parent) moveIntoParent(node, parent, index, state) + setNodeKey(state, node, spec.key) + } catch (error) { + throwNodeError(error, spec.key, node?.id) + } + if (!isIntrinsicNode(node)) { + await applyNodeProperties(node, spec, state, parent) + } + state.nodeIdsByKey[spec.key] = node.id + + const children: ReconciledChild[] = [] + if (spec.children?.length) { + if ( + node.type !== 'BOOLEAN_OPERATION' && + node.type !== 'COMPONENT' && + node.type !== 'COMPONENT_SET' && + node.type !== 'FRAME' && + node.type !== 'GROUP' && + node.type !== 'SECTION' && + node.type !== 'SLOT' + ) { + specError(`Node "${spec.key}" of type ${node.type} cannot contain desired children.`) + } + for (const [childIndex, child] of spec.children.entries()) { + children.push({ + node: await reconcileNode( + child, + state, + node, + desiredChildIndex( + child, + spec.children.slice(childIndex + 1), + state, + node, + children.at(-1)?.node + ) + ), + spec: child + }) + } + } + if (isFrameContainer(node)) finalizeGrid(node, spec, children, state) + for (const child of children) applyMask(child.node, child.spec, state) + if (isIntrinsicNode(node)) { + await applyNodeProperties(node, spec, state, parent) + } + return node +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/undo-resources.ts b/packages/extension/mcp/tools/canvas/reconcile/undo-resources.ts new file mode 100644 index 00000000..b47d9251 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/undo-resources.ts @@ -0,0 +1,153 @@ +import type { ApplyState } from './state' + +import { getStyleById, getVariableById, getVariableCollectionById } from '../../../local-resources' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_STYLE_KEY_NAME, + CANVAS_VARIABLE_COLLECTION_KEY_NAME, + CANVAS_VARIABLE_KEY_NAME, + CANVAS_VARIABLE_MODE_KEYS_NAME +} from '../identity' +import { TEXT_STYLE_VALUE_FIELDS } from './text-fonts' + +type ResourceSnapshot = { + id: string + before: string + read: () => Promise +} + +export function createdResourceChecks(state: ApplyState): Array<{ + id: string + exists: () => Promise +}> { + return [ + ...[...state.variables.createdCollectionIds].map((id) => ({ + id, + exists: async () => !!(await getVariableCollectionById(id)) + })), + ...[...state.variables.createdVariableIds].map((id) => ({ + id, + exists: async () => !!(await getVariableById(id)) + })), + ...[...state.styles.createdStyleIds].map((id) => ({ + id, + exists: async () => !!(await getStyleById(id)) + })) + ] +} + +function snapshotStyle(style: BaseStyle): string { + return JSON.stringify({ + name: style.name, + descriptionMarkdown: style.descriptionMarkdown, + documentationLinks: style.documentationLinks, + key: style.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_STYLE_KEY_NAME), + ...(style.type === 'TEXT' + ? { + boundVariables: (style as TextStyle).boundVariables, + ...Object.fromEntries( + TEXT_STYLE_VALUE_FIELDS.map((field) => [field, (style as TextStyle)[field]]) + ) + } + : {}), + ...(style.type === 'PAINT' ? { paints: (style as PaintStyle).paints } : {}), + ...(style.type === 'EFFECT' ? { effects: (style as EffectStyle).effects } : {}), + ...(style.type === 'GRID' ? { layoutGrids: (style as GridStyle).layoutGrids } : {}) + }) +} + +function snapshotVariable(variable: Variable): string { + return JSON.stringify({ + name: variable.name, + description: variable.description, + hiddenFromPublishing: variable.hiddenFromPublishing, + scopes: variable.scopes, + codeSyntax: variable.codeSyntax, + valuesByMode: variable.valuesByMode, + key: variable.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_KEY_NAME) + }) +} + +function snapshotCollection(collection: VariableCollection): string { + return JSON.stringify({ + name: collection.name, + hiddenFromPublishing: collection.hiddenFromPublishing, + modes: collection.modes, + defaultModeId: collection.defaultModeId, + variableIds: collection.variableIds, + ...(collection.isExtension + ? { + variableOverrides: (collection as unknown as ExtendedVariableCollection).variableOverrides + } + : {}), + key: collection.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_COLLECTION_KEY_NAME), + modeKeys: collection.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_VARIABLE_MODE_KEYS_NAME) + }) +} + +export function snapshotDeclaredResources(state: ApplyState): ResourceSnapshot[] { + const snapshots: ResourceSnapshot[] = [] + function capture( + resource: T | undefined, + read: (id: string) => Promise, + snapshot: (resource: T) => string + ): void { + if (!resource) return + const id = resource.id + snapshots.push({ + id, + before: snapshot(resource), + read: async () => { + const current = await read(id) + return current ? snapshot(current) : null + } + }) + } + for (const selection of state.styles.selections ?? []) { + capture(selection.style, getStyleById, snapshotStyle) + } + for (const collection of state.variables.selectedCollections?.values() ?? []) { + capture(collection, getVariableCollectionById, snapshotCollection) + } + for (const variable of state.variables.selectedVariables?.values() ?? []) { + capture(variable, getVariableById, snapshotVariable) + } + return snapshots +} + +export async function hasResourceChange( + snapshots: ResourceSnapshot[], + state: ApplyState +): Promise { + for (const snapshot of snapshots) { + try { + const current = await snapshot.read() + if (current !== null) { + if (current !== snapshot.before) return true + } else if ( + state.styles.removedStyleIds.has(snapshot.id) || + state.variables.removedCollectionIds.has(snapshot.id) || + state.variables.removedVariableIds.has(snapshot.id) + ) + return true + } catch { + // An unavailable resource cannot erase evidence from another resource. + } + } + return false +} + +export async function verifyResourceRollback( + snapshots: ResourceSnapshot[], + state: ApplyState +): Promise { + for (const snapshot of snapshots) { + if ((await snapshot.read()) !== snapshot.before) { + throw new Error(`Rollback did not restore pre-existing resource ${snapshot.id}`) + } + } + for (const resource of createdResourceChecks(state)) { + if (await resource.exists()) + throw new Error(`Rollback did not remove created resource ${resource.id}`) + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/undo.ts b/packages/extension/mcp/tools/canvas/reconcile/undo.ts new file mode 100644 index 00000000..1f3b1c56 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/undo.ts @@ -0,0 +1,474 @@ +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' + +import type { ProtectedNodeSnapshot, ApplyState } from './state' + +import { createCodedError } from '../../../errors' +import { canvasReadOnlyError, errorMessage } from '../errors' +import { + CANVAS_KEY_NAMESPACE, + CANVAS_PAGE_KEY_NAME, + CANVAS_NODE_KEY_NAME, + CANVAS_NODE_OWNER_NAME, + pageById +} from '../identity' +import { isComponentPropertyOwner, walkAuthoringNodes } from '../traversal' +import { nativeValueEqual } from './comparison' +import { + isSceneNode, + lookupNodeById, + isSupportedSceneNode, + outermostNodes, + containingPage +} from './nodes' +import { + snapshotDeclaredResources, + hasResourceChange, + verifyResourceRollback, + createdResourceChecks +} from './undo-resources' + +function snapshotProtectedNode(node: BaseNode, captureTextRanges = true): ProtectedNodeSnapshot { + let textSegments: string | undefined + if (node.type === 'TEXT' && captureTextRanges) { + try { + textSegments = JSON.stringify( + node.getStyledTextSegments([ + 'fontName', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing', + 'indentation', + 'listOptions', + 'textStyleId', + 'fillStyleId', + 'fills', + 'boundVariables', + 'textStyleOverrides', + 'hyperlink', + 'textCase', + 'textDecoration', + 'textDecorationStyle', + 'textDecorationOffset', + 'textDecorationThickness', + 'textDecorationColor', + 'textDecorationSkipInk', + 'textWrapStyle', + 'openTypeFeatures' + ]) + ) + } catch { + // Some referenced text cannot expose its ranges. The other fields remain usable. + } + } + return { + // Serialize copied native values so a later in-place paint change cannot also + // mutate the expected snapshot. This detects content loss, not a full undo log. + content: JSON.stringify({ + name: node.name, + ...(node.type === 'PAGE' + ? { + backgrounds: node.backgrounds, + index: figma.root.children.indexOf(node) + } + : {}), + ...('visible' in node ? { visible: node.visible, locked: node.locked } : {}), + ...('fills' in node + ? { + fills: node.fills, + ...('fillStyleId' in node ? { fillStyleId: node.fillStyleId } : {}) + } + : {}), + ...('strokes' in node ? { strokes: node.strokes, strokeStyleId: node.strokeStyleId } : {}), + ...('effects' in node ? { effects: node.effects, effectStyleId: node.effectStyleId } : {}), + ...('opacity' in node ? { opacity: node.opacity } : {}), + ...('boundVariables' in node ? { boundVariables: node.boundVariables } : {}), + ...('explicitVariableModes' in node + ? { explicitVariableModes: node.explicitVariableModes } + : {}), + ...('componentPropertyReferences' in node + ? { componentPropertyReferences: node.componentPropertyReferences } + : {}), + ...('constraints' in node ? { constraints: node.constraints } : {}), + ...('blendMode' in node ? { blendMode: node.blendMode } : {}), + ...('isMask' in node ? { isMask: node.isMask, maskType: node.maskType } : {}), + ...('strokeWeight' in node + ? { + strokeWeight: node.strokeWeight, + strokeAlign: node.strokeAlign, + strokeJoin: node.strokeJoin, + dashPattern: node.dashPattern + } + : {}), + ...('strokeTopWeight' in node + ? { + strokeTopWeight: node.strokeTopWeight, + strokeRightWeight: node.strokeRightWeight, + strokeBottomWeight: node.strokeBottomWeight, + strokeLeftWeight: node.strokeLeftWeight + } + : {}), + ...('strokeCap' in node + ? { strokeCap: node.strokeCap, strokeMiterLimit: node.strokeMiterLimit } + : {}), + ...('cornerRadius' in node ? { cornerRadius: node.cornerRadius } : {}), + ...('cornerSmoothing' in node ? { cornerSmoothing: node.cornerSmoothing } : {}), + ...('topLeftRadius' in node + ? { + topLeftRadius: node.topLeftRadius, + topRightRadius: node.topRightRadius, + bottomRightRadius: node.bottomRightRadius, + bottomLeftRadius: node.bottomLeftRadius + } + : {}), + ...('layoutGrids' in node + ? { layoutGrids: node.layoutGrids, gridStyleId: node.gridStyleId } + : {}), + ...('guides' in node ? { guides: node.guides } : {}), + ...('arcData' in node ? { arcData: node.arcData } : {}), + ...('pointCount' in node ? { pointCount: node.pointCount } : {}), + ...('innerRadius' in node ? { innerRadius: node.innerRadius } : {}), + ...(node.type === 'VECTOR' + ? { + vectorPaths: node.vectorPaths, + vectorNetwork: node.vectorNetwork, + handleMirroring: node.handleMirroring + } + : {}), + ...('descriptionMarkdown' in node + ? { + descriptionMarkdown: node.descriptionMarkdown, + documentationLinks: node.documentationLinks + } + : {}), + ...(isComponentPropertyOwner(node) + ? { componentPropertyDefinitions: node.componentPropertyDefinitions } + : {}), + ...(node.type === 'INSTANCE' + ? { + componentProperties: node.componentProperties, + scaleFactor: node.scaleFactor, + isExposedInstance: node.isExposedInstance + } + : {}), + ...('relativeTransform' in node ? { relativeTransform: node.relativeTransform } : {}), + ...('clipsContent' in node ? { clipsContent: node.clipsContent } : {}), + ...('minWidth' in node + ? { + minWidth: node.minWidth, + maxWidth: node.maxWidth, + minHeight: node.minHeight, + maxHeight: node.maxHeight + } + : {}), + ...('targetAspectRatio' in node ? { targetAspectRatio: node.targetAspectRatio } : {}), + ...('layoutSizingHorizontal' in node + ? { + layoutSizingHorizontal: node.layoutSizingHorizontal, + layoutSizingVertical: node.layoutSizingVertical + } + : {}), + ...('layoutPositioning' in node + ? { layoutPositioning: node.layoutPositioning, layoutGrow: node.layoutGrow } + : {}), + ...('layoutMode' in node + ? { + layoutMode: node.layoutMode, + primaryAxisSizingMode: node.primaryAxisSizingMode, + counterAxisSizingMode: node.counterAxisSizingMode, + primaryAxisAlignItems: node.primaryAxisAlignItems, + counterAxisAlignItems: node.counterAxisAlignItems, + counterAxisAlignContent: node.counterAxisAlignContent, + layoutWrap: node.layoutWrap, + itemSpacing: node.itemSpacing, + counterAxisSpacing: node.counterAxisSpacing, + paddingTop: node.paddingTop, + paddingRight: node.paddingRight, + paddingBottom: node.paddingBottom, + paddingLeft: node.paddingLeft, + strokesIncludedInLayout: node.strokesIncludedInLayout, + itemReverseZIndex: node.itemReverseZIndex, + ...(node.layoutMode === 'GRID' + ? { + gridRowCount: node.gridRowCount, + gridColumnCount: node.gridColumnCount, + gridRowSizes: node.gridRowSizes, + gridColumnSizes: node.gridColumnSizes, + gridRowGap: node.gridRowGap, + gridColumnGap: node.gridColumnGap, + gridAutoTracks: node.gridAutoTracks, + gridItemsPositioning: node.gridItemsPositioning + } + : {}) + } + : {}), + ...('gridRowAnchorIndex' in node && + node.parent && + 'layoutMode' in node.parent && + node.parent.layoutMode === 'GRID' + ? { + gridRowAnchorIndex: node.gridRowAnchorIndex, + gridColumnAnchorIndex: node.gridColumnAnchorIndex, + gridRowSpan: node.gridRowSpan, + gridColumnSpan: node.gridColumnSpan, + gridChildHorizontalAlign: node.gridChildHorizontalAlign, + gridChildVerticalAlign: node.gridChildVerticalAlign + } + : {}), + ...(node.type === 'TEXT' + ? { + characters: node.characters, + fontName: node.fontName, + fontSize: node.fontSize, + textStyleId: node.textStyleId, + lineHeight: node.lineHeight, + letterSpacing: node.letterSpacing, + paragraphIndent: node.paragraphIndent, + paragraphSpacing: node.paragraphSpacing, + listSpacing: node.listSpacing, + autoRename: node.autoRename, + leadingTrim: node.leadingTrim, + hangingPunctuation: node.hangingPunctuation, + hangingList: node.hangingList, + textAlignHorizontal: node.textAlignHorizontal, + textAlignVertical: node.textAlignVertical, + textAutoResize: node.textAutoResize, + textTruncation: node.textTruncation, + maxLines: node.maxLines + } + : {}) + }), + childIds: 'children' in node ? node.children.map((child) => child.id) : null, + geometry: isSceneNode(node) + ? { height: node.height, width: node.width, x: node.x, y: node.y } + : null, + key: node.getSharedPluginData( + CANVAS_KEY_NAMESPACE, + node.type === 'PAGE' ? CANVAS_PAGE_KEY_NAME : CANVAS_NODE_KEY_NAME + ), + owner: node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_NODE_OWNER_NAME), + parentId: node.parent?.id ?? null, + type: node.type, + ...(textSegments === undefined ? {} : { textSegments }) + } +} + +function matchesProtectedNode(node: BaseNode, expected: ProtectedNodeSnapshot): boolean { + const actual = snapshotProtectedNode(node, expected.textSegments !== undefined) + if (expected.textSegments !== undefined && actual.textSegments === undefined) + throw new Error(`Text ranges on "${node.id}" are unavailable`) + return nativeValueEqual(actual, expected) +} + +export function protectNode(state: ApplyState, node: BaseNode): void { + if (state.createdNodeIds.has(node.id) || state.createdPageIds.has(node.id)) return + if (state.protectedNodes.has(node.id)) return + state.protectedNodes.set(node.id, { + node, + ...(state.undoStarted ? { snapshot: snapshotProtectedNode(node) } : {}) + }) +} + +function topLevelPageChild(node: BaseNode, page: PageNode): BaseNode | null { + let current: BaseNode | null = node + while (current?.parent && current.parent.id !== page.id) current = current.parent + return current?.parent?.id === page.id ? current : null +} + +export function protectUnrelatedPageRoots(state: ApplyState, page: PageNode): void { + const mutableRoot = state.scope ? topLevelPageChild(state.scope, page) : null + for (const child of page.children) { + if (child.id !== mutableRoot?.id) protectNode(state, child) + } +} + +async function verifyRollbackProtectedNodes(state: ApplyState): Promise { + const protectedEntries = [...state.protectedNodes] + const protectedIds = protectedEntries.map(([id]) => id) + const nodes = await Promise.all( + protectedEntries.map(([id, { snapshot }]) => + snapshot?.type === 'PAGE' ? (pageById(id) ?? null) : lookupNodeById(id) + ) + ) + const missing = protectedIds.filter((_, index) => !nodes[index]) + if (missing.length) { + const shown = missing.slice(0, 8) + const suffix = missing.length > shown.length ? ` and ${missing.length - shown.length} more` : '' + throw new Error( + `Rollback did not preserve pre-existing node${missing.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` + ) + } + + const changed = protectedEntries + .filter(([, { snapshot: expected }], index) => { + const node = nodes[index] + return expected && node && !matchesProtectedNode(node, expected) + }) + .map(([id]) => id) + if (!changed.length) return + const shown = changed.slice(0, 8) + const suffix = changed.length > shown.length ? ` and ${changed.length - shown.length} more` : '' + throw new Error( + `Rollback changed pre-existing node${changed.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` + ) +} + +async function removeRollbackCreatedNodes(state: ApplyState): Promise { + const created = ( + await Promise.all([...state.createdNodeIds].map((id) => lookupNodeById(id))) + ).filter(isSupportedSceneNode) + if (!created.length) return + + for (const node of outermostNodes(created)) node.remove() + + const remaining = ( + await Promise.all([...state.createdNodeIds].map((id) => lookupNodeById(id))) + ).filter(isSupportedSceneNode) + if (!remaining.length) return + const shown = remaining.slice(0, 8).map((node) => node.id) + const suffix = + remaining.length > shown.length ? ` and ${remaining.length - shown.length} more` : '' + throw new Error( + `Rollback did not remove created node${remaining.length === 1 ? '' : 's'} ${shown.join(', ')}${suffix}` + ) +} + +async function removeRollbackCreatedPages( + state: ApplyState, + previousPage: PageNode +): Promise { + const created = figma.root.children.filter((page) => state.createdPageIds.has(page.id)) + if (state.createdPageIds.has(figma.currentPage.id) && !previousPage.removed) { + await figma.setCurrentPageAsync(previousPage) + } + if (!created.length) return + for (const page of created) { + if (!page.removed) page.remove() + } + const remaining = figma.root.children.filter((page) => state.createdPageIds.has(page.id)) + if (remaining.length) { + throw new Error(`Rollback did not remove created page ${remaining[0]!.id}`) + } +} + +async function hasRollbackChange(state: ApplyState): Promise { + const checks: Array<() => unknown | Promise> = [ + ...[...state.createdPageIds].map((id) => () => { + const page = pageById(id) + return page && !page.removed + }), + ...[...state.createdNodeIds].map((id) => () => lookupNodeById(id)), + ...createdResourceChecks(state).map((resource) => resource.exists), + ...[...new Set([...state.updatedNodeIds, ...state.removalNodeIds])].map((id) => async () => { + if (state.createdNodeIds.has(id) || state.createdPageIds.has(id)) return false + const protectedNode = state.protectedNodes.get(id) + if (!protectedNode?.snapshot) return false + if (protectedNode.node.removed) return true + const node = protectedNode.snapshot.type === 'PAGE' ? pageById(id) : await lookupNodeById(id) + return node && !matchesProtectedNode(node, protectedNode.snapshot) + }) + ] + for (const check of checks) { + try { + if (await check()) return true + } catch { + // One unavailable read cannot invalidate independent retained-change evidence. + } + } + return false +} + +export async function withUndoBoundary(apply: () => Promise, state: ApplyState): Promise { + const previousPage = figma.currentPage + let resourceSnapshots: ReturnType = [] + let applyStarted = false + try { + const mutableNodeIds = new Set(state.explicitNodes.keys()) + if (state.scope) { + for (const node of walkAuthoringNodes([state.scope])) { + mutableNodeIds.add(node.id) + protectNode(state, node) + } + protectUnrelatedPageRoots(state, containingPage(state.scope)) + } + // Preflight can yield for media/fonts while the user edits referenced nodes. + // Freeze their baseline only when this attempt is about to start writing. + for (const protectedNode of state.protectedNodes.values()) { + protectedNode.snapshot = snapshotProtectedNode( + protectedNode.node, + mutableNodeIds.has(protectedNode.node.id) + ) + } + resourceSnapshots = snapshotDeclaredResources(state) + state.undoStarted = true + figma.commitUndo() + applyStarted = true + const result = await apply() + figma.commitUndo() + return result + } catch (error) { + const readOnly = canvasReadOnlyError(error) + const original = readOnly ?? error + const code = original instanceof Error && 'code' in original ? original.code : undefined + const message = errorMessage(original, 'Canvas apply failed.') + const diagnostic = + typeof code === 'string' && + code !== TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED && + code !== TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED + ? `${message} [${code}]` + : message + if (state.mutations.count > 0) { + try { + // Counted setters can be no-ops or cancel each other out. Undo would then + // consume an earlier edit. Partial snapshots only justify an attempt when + // they show a retained change; equality never certifies an unchanged canvas. + if ( + !(await hasRollbackChange(state)) && + !(await hasResourceChange(resourceSnapshots, state)) + ) { + throw new Error( + 'A distinct change for this attempt could not be confirmed; Undo was skipped', + { cause: error } + ) + } + // Make this partial attempt the newest history entry before undoing. In a long-lived + // plugin session, the preceding entry may be an earlier successful apply. + figma.commitUndo() + figma.triggerUndo() + // Figma's undo boundary is authoritative for existing content and resources. Remove any + // newly created nodes that remain visible after the undo so a failed apply cannot leave + // partial component or screen roots behind in a long-lived MCP session. + await removeRollbackCreatedNodes(state) + await removeRollbackCreatedPages(state, previousPage) + await verifyRollbackProtectedNodes(state) + await verifyResourceRollback(resourceSnapshots, state) + } catch (rollbackError) { + const rollbackMessage = errorMessage(rollbackError) + const detail = rollbackMessage ? ` ${rollbackMessage.replace(/\.$/, '')}.` : '' + const failure = createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + `Canvas apply failed: ${diagnostic.replace(/\.$/, '')}; automatic rollback was not available.${detail} Read back the affected canvas before writing again. Inspect retained changes and repair only the affected scope; ask the user to inspect if the partial state is unclear. Do not use Undo without confirming which edit it would revert.` + ) + failure.cause = error + throw failure + } + } + if (!applyStarted) { + if (readOnly) throw readOnly + if (error instanceof Error && 'code' in error) throw error + throw createCodedError( + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + errorMessage(error, 'Canvas apply failed.') + ) + } + if (error instanceof Error && code === TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED) throw error + // A native setter can throw before mutation accounting, and protected snapshots + // cover only part of document state. Neither zero writes nor a verified snapshot + // proves that every native field or resource was restored. + const failure = createCodedError(TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, diagnostic) + failure.cause = error + throw failure + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/variable-modes.ts b/packages/extension/mcp/tools/canvas/reconcile/variable-modes.ts new file mode 100644 index 00000000..47227671 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/variable-modes.ts @@ -0,0 +1,343 @@ +import type { CanvasPageProperties } from '@tempad-dev/shared' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' + +import { coerceToolErrorPayload } from '../../../errors' +import { getStyleById, getVariableById, getVariableCollectionById } from '../../../local-resources' +import { applyError, scopeError, errorMessage } from '../errors' +import { loadFonts, currentTextFonts, loadFont, resolveFamilyFont } from '../fonts' +import { walkPhysicalNodes } from '../traversal' +import { resolvedCollection, resolvedModeId } from '../variables' +import { SIZE_BOUND_FIELDS } from './layout' +import { type ApplyState, type SupportedCanvasNode, markMutation } from './state' +import { TEXT_STYLE_VARIABLES_BY_VALUE } from './text-fonts' + +type FontModeValues = { + values: VariableValue[] + optional: boolean + affected: boolean + unavailable?: string +} +type MissingModeResources = { + variables: Set + collections: Set + styles: Set +} + +const TEXT_MODE_FONT_FIELDS = [ + 'characters', + 'width', + 'height', + ...SIZE_BOUND_FIELDS, + ...Object.values(TEXT_STYLE_VARIABLES_BY_VALUE).flat() +] as const + +async function cachedModeResource( + id: string, + cache: Map, + missing: Set, + lookup: (id: string) => Promise +): Promise { + const key = `id:${id}` + const cached = cache.get(key) + if (cached) return cached + if (missing.has(id)) return null + const resource = await lookup(id) + if (resource) cache.set(key, resource) + else missing.add(id) + return resource +} + +async function modeFontVariableValues( + variable: Variable, + consumer: TextNode, + target: SupportedCanvasNode | PageNode, + changedCollectionId: string, + changedModeId: string | undefined, + state: ApplyState, + missing: MissingModeResources, + cache = new Map(), + seen = new Set() +): Promise { + if (seen.has(variable.id) || seen.size >= 64) { + applyError('Text variable aliases contain a cycle or exceed 64 levels.') + } + const cached = cache.get(variable.id) + if (cached) return cached + const active = new Set(seen).add(variable.id) + const base = await cachedModeResource( + variable.variableCollectionId, + state.variables.collectionCache, + missing.collections, + getVariableCollectionById + ) + if (!base) { + return { + values: [], + optional: true, + affected: false, + unavailable: `Text variable collection "${variable.variableCollectionId}" is unavailable.` + } + } + const related = new Map([[base.id, base]]) + const ancestors: BaseNode[] = [] + const candidateIds = new Set([ + changedCollectionId, + ...Object.keys(consumer.resolvedVariableModes ?? {}) + ]) + for (let ancestor: BaseNode | null = consumer; ancestor; ancestor = ancestor.parent) { + ancestors.push(ancestor) + if ('explicitVariableModes' in ancestor) { + for (const id of Object.keys(ancestor.explicitVariableModes)) candidateIds.add(id) + } + } + for (const id of candidateIds) { + if (related.has(id)) continue + const collection = await cachedModeResource( + id, + state.variables.collectionCache, + missing.collections, + getVariableCollectionById + ) + if (collection?.isExtension && collection.variableIds.includes(variable.id)) + related.set(id, collection) + } + let selected: Array<[string, string]> = [] + let affected = false + for (const ancestor of ancestors) { + const explicit = + 'explicitVariableModes' in ancestor ? { ...ancestor.explicitVariableModes } : {} + if (ancestor === target) { + affected = related.has(changedCollectionId) && explicit[changedCollectionId] !== changedModeId + delete explicit[changedCollectionId] + if (changedModeId !== undefined) explicit[changedCollectionId] = changedModeId + } + selected = Object.entries(explicit).filter(([id]) => related.has(id)) + if (selected.length) break + } + if (!selected.length) { + // An unaffected consumer or the parent above a cleared override exposes native + // Auto resolution. Page Auto has no such reader: preload candidates, don't guess. + const context = related.has(changedCollectionId) ? target.parent : consumer + if (context && 'resolvedVariableModes' in context) { + selected = Object.entries(context.resolvedVariableModes).filter(([id]) => related.has(id)) + } + } + let optional = selected.length === 0 + let unavailable: string | undefined + const candidates: VariableValue[] = [] + for (const collection of related.values()) { + const modes = selected.filter(([id]) => id === collection.id).map(([, mode]) => mode) + if (selected.length && !modes.length) continue + const values = + collection.id === base.id + ? variable.valuesByMode + : await variable.valuesByModeForCollectionAsync(collection) + const modeValues = modes.length ? modes.map((mode) => values[mode]) : Object.values(values) + for (const value of modeValues) { + if (value === undefined) { + unavailable ??= `Text variable "${variable.id}" has no value for the selected mode.` + continue + } + if (typeof value === 'object' && 'type' in value && value.type === 'VARIABLE_ALIAS') { + const referenced = await cachedModeResource( + value.id, + state.variables.variableCache, + missing.variables, + getVariableById + ) + if (!referenced) { + unavailable ??= `Text variable alias "${value.id}" is unavailable.` + continue + } + const alias = await modeFontVariableValues( + referenced, + consumer, + target, + changedCollectionId, + changedModeId, + state, + missing, + cache, + active + ) + candidates.push(...alias.values) + optional ||= alias.optional + affected ||= alias.affected + unavailable ??= alias.unavailable + } else candidates.push(value) + } + } + const values = [...new Set(candidates)] + if (values.length > 64 || cache.size >= 256) + scopeError('Font mode preloading exceeds the bounded candidate budget.') + // Page Auto candidates are possibilities, not selected dependencies. A missing + // candidate must not block the native Auto setter; known selections stay strict. + const result = { + values, + optional, + affected, + ...(selected.length && unavailable ? { unavailable } : {}) + } + cache.set(variable.id, result) + return result +} + +async function loadModeFonts( + target: SupportedCanvasNode | PageNode, + collectionId: string, + modeId: string | undefined, + state: ApplyState +): Promise { + const roots = target.type === 'PAGE' ? target.children : [target] + // Only misses are scan-local: later imports can expose previously unavailable resources. + const missing: MissingModeResources = { + variables: new Set(), + collections: new Set(), + styles: new Set() + } + for (const node of walkPhysicalNodes(roots)) { + if (node.type !== 'TEXT') continue + const cache = new Map() + const segments = node.getStyledTextSegments(['fontName', 'textStyleId', 'boundVariables']) + for (const segment of segments) { + const style = segment.textStyleId + ? await cachedModeResource( + segment.textStyleId, + state.styles.cache, + missing.styles, + getStyleById + ) + : undefined + const bindings = { + ...node.boundVariables, + ...(style?.type === 'TEXT' ? style.boundVariables : {}), + ...segment.boundVariables + } + const values = new Map() + for (const field of TEXT_MODE_FONT_FIELDS) { + const reference = bindings[field] + // Mixed node bindings are arrays; segment aliases identify the actual run. + if (!reference || Array.isArray(reference)) continue + try { + const variable = await cachedModeResource( + reference.id, + state.variables.variableCache, + missing.variables, + getVariableById + ) + if (!variable) continue + const resolved = await modeFontVariableValues( + variable, + node, + target, + collectionId, + modeId, + state, + missing, + cache + ) + // A dangling native binding cannot be changed by an unrelated collection. + // Keep its failure until aliases establish whether this transition reaches it. + if (resolved.affected && resolved.unavailable) applyError(resolved.unavailable) + values.set(field, resolved) + } catch (error) { + if (coerceToolErrorPayload(error).code === TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE) + throw error + applyError( + `Cannot resolve text variable dependency "${field}" on "${node.id}": ${errorMessage(error)}` + ) + } + } + if (![...values.values()].some((value) => value.affected)) continue + await loadFonts(currentTextFonts(node), state.fonts) + const families = values.get('fontFamily') + const styles = values.get('fontStyle') + const weights = values.get('fontWeight') + const familyValues = families?.values.length ? families.values : [segment.fontName.family] + const styleValues = styles?.values.length ? styles.values : [segment.fontName.style] + const weightValues = weights?.values.length ? weights.values : [undefined] + const optional = families?.optional || styles?.optional || weights?.optional + if (familyValues.length * styleValues.length * weightValues.length > 256) { + scopeError('Font mode preloading exceeds 256 possible font combinations.') + } + // Native mode changes can apply family before weight; both the intermediate + // face and the final face must be loaded before invoking the synchronous setter. + for (const family of familyValues) + for (const fontStyle of styleValues) + for (const weight of weightValues) { + if ( + typeof family !== 'string' || + typeof fontStyle !== 'string' || + (weight !== undefined && + (typeof weight !== 'number' || + !Number.isFinite(weight) || + weight < 1 || + weight > 1000)) + ) { + if (optional) continue + applyError( + `Font variables on "${node.id}" must resolve to STRING family/style and a weight between 1 and 1000.` + ) + } + try { + await loadFont( + await resolveFamilyFont(family, segment.fontName.style, state.fonts), + state.fonts + ) + await loadFont( + await resolveFamilyFont(family, fontStyle, state.fonts, weight), + state.fonts + ) + } catch (error) { + // A possible Auto font need not be the selected one. The native setter + // remains authoritative; known explicit targets retain load failures. + if (!optional) throw error + } + } + } + } +} + +export async function applyVariableModes( + node: SupportedCanvasNode | PageNode, + modes: CanvasNodeSpec['variableModes'], + state: ApplyState +): Promise { + for (const [collectionReference, modeReference] of Object.entries(modes ?? {})) { + const collection = resolvedCollection(collectionReference, state.variables) + const current = node.explicitVariableModes[collection.id] + if (modeReference === null) { + if (current === undefined) continue + await loadModeFonts(node, collection.id, undefined, state) + node.clearExplicitVariableModeForCollection(collection) + } else { + const modeId = resolvedModeId(collection, modeReference, state.variables) + if (current === modeId) continue + await loadModeFonts(node, collection.id, modeId, state) + node.setExplicitVariableModeForCollection(collection, modeId) + } + markMutation(state, node) + } +} + +export async function preloadFirstPageModeFonts( + page: PageNode, + modes: CanvasPageProperties['variableModes'], + state: ApplyState +): Promise { + for (const [collectionReference, modeReference] of Object.entries(modes ?? {})) { + const collection = resolvedCollection(collectionReference, state.variables) + const modeId = + modeReference === null + ? undefined + : resolvedModeId(collection, modeReference, state.variables) + if (page.explicitVariableModes[collection.id] === modeId) continue + // Later transitions can depend on the preceding mode setters. Warm only the + // first one; applyVariableModes still checks the live font requirements. + await loadModeFonts(page, collection.id, modeId, state) + return + } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/verification.ts b/packages/extension/mcp/tools/canvas/reconcile/verification.ts new file mode 100644 index 00000000..dc64301d --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/verification.ts @@ -0,0 +1,507 @@ +import type { CanvasVariableBindings } from '@tempad-dev/shared' + +import type { CanvasNodeSpec } from '../model' +import type { SupportedCanvasNode, ApplyState } from './state' + +import { getMainComponent } from '../../../local-resources' +import { resolvedSvgAsset, SVG_POLICY_VERSION } from '../assets' +import { applyError } from '../errors' +import { readOwnedNodeKey, CANVAS_KEY_NAMESPACE } from '../identity' +import { resolveStyle } from '../styles' +import { resolvedVariable, resolvedCollection, resolvedModeId } from '../variables' +import { + currentBoundVariableId, + DIRECT_VARIABLE_FIELDS, + STYLE_FIELDS, + styleLinkMatches, + isTextVariableField, + textVariableLinkRanges, + variableLinkMatches +} from './binding-values' +import { clearedVariableMatches, verifyTextRangeBinding } from './bindings' +import { GEOMETRY_TOLERANCE, nativeValueEqual, scalarValuesEqual } from './comparison' +import { + componentDefinitionOwner, + componentPropertyName, + componentPropertyMatches, + resolvedComponent, + componentLinkMatches +} from './components' +import { supportsLayoutSizing, crossAxisFill } from './layout' +import { + nativePaint, + paintStacksEqual, + nativeEffect, + effectsEqual, + describeEffectMismatch, + nativeLayoutGrid, + layoutGridsEqual +} from './native-values' +import { isIntrinsicNode, isFrameContainer, isSupportedSceneNode } from './nodes' +import { + isOwnedSvgChild, + CANVAS_SVG_DIGEST_NAME, + CANVAS_SVG_POLICY_NAME, + CANVAS_SVG_COLOR_NAME +} from './svg' +import { textFillsMatch } from './text-paints' +import { textParagraphs } from './text-paragraphs' +import { + rangeNeedsTextStyleComposition, + rangeTextStyleMatches, + resolvedRangeTextStyle +} from './text-range-style' +import { textMetricRange } from './text-ranges' + +function textVariableMismatchHint( + node: SupportedCanvasNode, + field: keyof CanvasVariableBindings, + expected: string | undefined, + spec: CanvasNodeSpec +): string { + if (node.type !== 'TEXT' || !expected || field === 'fill' || field === 'stroke') return '' + const nativeField = DIRECT_VARIABLE_FIELDS[field] + if (!isTextVariableField(nativeField)) return '' + for (const scope of textVariableLinkRanges(node, nativeField, spec.figma?.text?.ranges)) { + const alias = node.getRangeBoundVariable(scope.start, scope.end, nativeField) + if (alias !== figma.mixed && alias?.id === expected) continue + return ` Inspect range ${scope.start}:${scope.end}: native whole-node binding can retain local overrides. Explicitly bind the affected text range to "${expected}" to replace those overrides; repeating only the node binding will not repair them.` + } + return '' +} + +async function appliedVariableMatches( + node: SupportedCanvasNode, + field: keyof CanvasVariableBindings, + expected: string | undefined, + spec: CanvasNodeSpec, + state: ApplyState +): Promise { + if (field === 'fill' || field === 'stroke') { + const paintField = field === 'fill' ? 'fills' : 'strokes' + return node.boundVariables?.[paintField]?.[0]?.id === expected + } + if (expected === undefined) { + return clearedVariableMatches( + node, + DIRECT_VARIABLE_FIELDS[field], + spec.figma?.text?.ranges, + state + ) + } + return variableLinkMatches( + node, + DIRECT_VARIABLE_FIELDS[field], + expected, + spec.figma?.text?.ranges + ) +} + +function verifySizingGeometry( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + parent?: SupportedCanvasNode +): void { + if (parent && spec.absoluteOffsets) { + const { right, bottom } = spec.absoluteOffsets + if ( + (right !== undefined && + Math.abs(parent.width - node.x - node.width - right) > GEOMETRY_TOLERANCE) || + (bottom !== undefined && + Math.abs(parent.height - node.y - node.height - bottom) > GEOMETRY_TOLERANCE) + ) { + applyError(`Verification failed for "${spec.key}": absolute edge placement does not match.`) + } + } + if (!isIntrinsicNode(node) && supportsLayoutSizing(node, parent)) { + if ( + (spec.size.horizontal !== undefined && + node.layoutSizingHorizontal !== spec.size.horizontal) || + (spec.size.vertical !== undefined && node.layoutSizingVertical !== spec.size.vertical) || + (spec.grow !== undefined && node.layoutGrow !== (spec.grow ? 1 : 0)) + ) { + applyError( + `Verification failed for "${spec.key}": sizing modes do not match (declared horizontal=${spec.size.horizontal}, vertical=${spec.size.vertical}, grow=${spec.grow ?? false}; applied horizontal=${node.layoutSizingHorizontal}, vertical=${node.layoutSizingVertical}, grow=${node.layoutGrow === 1}).` + ) + } + } + + const fixedWidth = + spec.size.horizontal === 'FIXED' && + spec.size.width !== undefined && + !spec.variables?.width && + !currentBoundVariableId(node, 'width') + ? spec.size.width + : null + const fixedHeight = + spec.size.vertical === 'FIXED' && + spec.size.height !== undefined && + !spec.variables?.height && + !currentBoundVariableId(node, 'height') + ? spec.size.height + : null + if ( + (fixedWidth !== null && Math.abs(node.width - fixedWidth) > GEOMETRY_TOLERANCE) || + (fixedHeight !== null && Math.abs(node.height - fixedHeight) > GEOMETRY_TOLERANCE) + ) { + applyError(`Verification failed for "${spec.key}": fixed geometry does not match.`) + } + + const fill = crossAxisFill(node, spec, parent) + if (fill) { + const actual = fill.axis === 'horizontal' ? node.width : node.height + if (actual < GEOMETRY_TOLERANCE) { + applyError(`Verification failed for "${spec.key}": fill geometry does not match.`) + } + } + + if (node.type !== 'TEXT') return + if (node.textAutoResize !== spec.text?.autoResize) { + applyError(`Verification failed for "${spec.key}": text auto-resize does not match.`) + } + if ( + node.characters.length > 0 && + (node.textAutoResize === 'HEIGHT' || node.textAutoResize === 'WIDTH_AND_HEIGHT') && + node.height <= GEOMETRY_TOLERANCE + ) { + applyError(`Verification failed for "${spec.key}": auto-resizing text has no height.`) + } + if ( + node.characters.length > 0 && + node.textAutoResize === 'WIDTH_AND_HEIGHT' && + node.width <= GEOMETRY_TOLERANCE + ) { + applyError(`Verification failed for "${spec.key}": auto-resizing text has no width.`) + } + if ( + node.characters.length > 0 && + node.textAutoResize === 'HEIGHT' && + node.layoutGrow > 0 && + node.width <= GEOMETRY_TOLERANCE && + (node.minWidth ?? 0) <= GEOMETRY_TOLERANCE + ) { + applyError( + `Verification failed for "${spec.key}": growing text collapsed to zero width; provide a positive min-width.` + ) + } +} + +async function verifyInstanceState( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState +): Promise { + if (!spec.componentProperties && !spec.figma?.instance) return + if (node.type !== 'INSTANCE') { + applyError(`Verification failed for "${spec.key}": expected an instance.`) + } + const instance = spec.figma?.instance + if ( + instance?.scaleFactor !== undefined && + Math.abs(node.scaleFactor - instance.scaleFactor) > GEOMETRY_TOLERANCE + ) { + applyError(`Verification failed for "${spec.key}": instance scale does not match.`) + } + if (instance?.exposed !== undefined && node.isExposedInstance !== instance.exposed) { + applyError(`Verification failed for "${spec.key}": instance exposure does not match.`) + } + if (!spec.componentProperties) return + const component = await getMainComponent(node) + if (!component) { + applyError(`Verification failed for "${spec.key}": instance has no main component.`) + } + const owner = componentDefinitionOwner(component) + for (const [key, value] of Object.entries(spec.componentProperties)) { + const name = componentPropertyName(owner, key, state) ?? key + if (!componentPropertyMatches(node.componentProperties[name], value, state)) { + applyError( + `Verification failed for "${spec.key}": component property "${name}" does not match.` + ) + } + } +} + +function verifyNativeNodeState( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState +): number { + let fields = 0 + for (const [property, styleProperty] of [ + ['fills', 'fillStyleId'], + ['strokes', 'strokeStyleId'] + ] as const) { + const paints = spec.figma?.[property] + if (paints === undefined) continue + fields += 1 + if (!('fills' in node)) { + applyError(`Verification failed for "${spec.key}": direct ${property} are unsupported.`) + } + const desired = paints.map((paint) => nativePaint(paint, state)) + const current = node[property] + const matches = + node.type === 'TEXT' && property === 'fills' + ? textFillsMatch(node, desired, spec.figma?.text?.ranges) + : current !== figma.mixed && !node[styleProperty] && paintStacksEqual(current, desired) + if (!matches) { + applyError(`Verification failed for "${spec.key}": direct ${property} do not match.`) + } + } + + const effects = spec.figma?.effects + if (effects !== undefined) { + fields += 1 + if (!('effects' in node)) { + applyError(`Verification failed for "${spec.key}": direct effects are unsupported.`) + } + const desired = effects.map((effect) => nativeEffect(effect, state)) + if (node.effectStyleId) { + applyError( + `Verification failed for "${spec.key}": expected direct effects, but effect style "${node.effectStyleId}" remains applied.` + ) + } + if (!effectsEqual(node.effects, desired)) { + applyError( + `Verification failed for "${spec.key}": direct ${describeEffectMismatch(node.effects, desired)}` + ) + } + } + + const layoutGrids = spec.figma?.layoutGrids + if (layoutGrids !== undefined) { + fields += 1 + if ((!isFrameContainer(node) && node.type !== 'INSTANCE') || !('layoutGrids' in node)) { + applyError(`Verification failed for "${spec.key}": layout grids are unsupported.`) + } + const desired = layoutGrids.map((grid) => nativeLayoutGrid(grid, state)) + if (!!node.gridStyleId || !layoutGridsEqual(node.layoutGrids, desired)) { + applyError(`Verification failed for "${spec.key}": layout grids do not match.`) + } + } + + const guides = spec.figma?.guides + if (guides !== undefined) { + fields += 1 + if ((!isFrameContainer(node) && node.type !== 'INSTANCE') || !('guides' in node)) { + applyError(`Verification failed for "${spec.key}": guides are unsupported.`) + } + if (!nativeValueEqual(node.guides, guides, scalarValuesEqual)) { + applyError(`Verification failed for "${spec.key}": guides do not match.`) + } + } + + return fields +} + +export async function verifyAppliedNode( + spec: CanvasNodeSpec, + node: SupportedCanvasNode, + state: ApplyState, + parent?: SupportedCanvasNode +): Promise<{ nodes: number; references: number; nativeFields: number }> { + if (node.type !== spec.type) { + applyError(`Verification failed for "${spec.key}": expected ${spec.type}, found ${node.type}.`) + } + if (state.nodeIdsByKey[spec.key] !== node.id) { + applyError( + `Verification failed for "${spec.key}": stable identity did not resolve to its node.` + ) + } + if (readOwnedNodeKey(node) !== spec.key) { + applyError(`Verification failed for "${spec.key}": native stable identity is missing.`) + } + if (parent && node.parent?.id !== parent.id) { + applyError(`Verification failed for "${spec.key}": parent does not match the desired tree.`) + } + const geometry = [ + node.x, + node.y, + node.width, + node.height, + ...('rotation' in node ? [node.rotation] : []) + ] + if (!geometry.every(Number.isFinite)) { + applyError(`Verification failed for "${spec.key}": geometry is not finite.`) + } + verifySizingGeometry(spec, node, parent) + + let references = 0 + let nativeFields = verifyNativeNodeState(spec, node, state) + if (spec.component) { + const expected = resolvedComponent(spec.component, state) + const actual = node.type === 'INSTANCE' ? await getMainComponent(node) : null + references += 1 + if (node.type !== 'INSTANCE' || !componentLinkMatches(spec, node, expected, actual, state)) { + applyError(`Verification failed for "${spec.key}": component link does not match.`) + } + } + await verifyInstanceState(spec, node, state) + for (const [field, reference] of Object.entries(spec.variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableBindings[keyof CanvasVariableBindings]] + >) { + const expected = reference ? resolvedVariable(reference, state.variables).id : undefined + references += 1 + if (!(await appliedVariableMatches(node, field, expected, spec, state))) { + applyError( + `Verification failed for "${spec.key}": variable link "${field}" does not match.${textVariableMismatchHint(node, field, expected, spec)}` + ) + } + } + for (const field of STYLE_FIELDS) { + const reference = spec.styles?.[field] + if (reference === undefined) continue + const expected = reference ? (await resolveStyle(reference, state.styles)).id : '' + references += 1 + if (!styleLinkMatches(node, field, expected, spec.figma?.text?.ranges)) { + applyError(`Verification failed for "${spec.key}": style link "${field}" does not match.`) + } + } + if (node.type === 'TEXT') { + const ranges = spec.figma?.text?.ranges ?? [] + const paragraphs = textParagraphs(node.characters, ranges) + for (const [index, range] of ranges.entries()) { + for (const field of ['fontSize', 'lineHeight', 'letterSpacing'] as const) { + const reference = range.variables?.[field] + if (!reference) continue + references += 1 + verifyTextRangeBinding( + node, + textMetricRange(node.characters, range, field, ranges[index + 1]), + field, + resolvedVariable(reference, state.variables).id, + spec.key + ) + } + for (const field of ['textStyle', 'fillStyle'] as const) { + const reference = range[field] + if (reference === undefined) continue + const composed = + field === 'textStyle' ? await resolvedRangeTextStyle(node, range, state) : null + // Semantic overrides and variable-font weights retain the previous + // acceptance path until their native style projection can be proved. + if (field === 'textStyle' && !composed && rangeNeedsTextStyleComposition(range)) continue + if (composed) { + nativeFields += 1 + if (!rangeTextStyleMatches(node, range, composed, paragraphs[index]!.effective)) { + applyError( + `Verification failed for "${spec.key}": range ${range.start}:${range.end} TextStyle typography does not match.` + ) + } + continue + } + const expected = reference ? (await resolveStyle(reference, state.styles)).id : '' + const current = + field === 'textStyle' + ? node.getRangeTextStyleId(range.start, range.end) + : node.getRangeFillStyleId(range.start, range.end) + references += 1 + if (current !== expected) { + applyError( + `Verification failed for "${spec.key}": range ${range.start}:${range.end} ${field} does not match.` + ) + } + } + if (range.fills !== undefined) { + const current = node.getRangeFills(range.start, range.end) + const desired = range.fills.map((paint) => nativePaint(paint, state)) + nativeFields += 1 + if ( + current === figma.mixed || + node.getRangeFillStyleId(range.start, range.end) !== '' || + !paintStacksEqual(current, desired) + ) { + applyError( + `Verification failed for "${spec.key}": range ${range.start}:${range.end} fills do not match.` + ) + } + } + } + } + for (const [collectionReference, modeReference] of Object.entries(spec.variableModes ?? {})) { + const collection = resolvedCollection(collectionReference, state.variables) + const expected = + modeReference === null + ? undefined + : resolvedModeId(collection, modeReference, state.variables) + references += 1 + if (node.explicitVariableModes[collection.id] !== expected) { + applyError(`Verification failed for "${spec.key}": variable mode does not match.`) + } + } + if (spec.figma?.mask !== undefined) { + nativeFields += 1 + const mask = spec.figma.mask + if ( + !('isMask' in node) || + node.isMask !== (mask !== null) || + (mask !== null && (!('maskType' in node) || node.maskType !== mask)) + ) { + applyError(`Verification failed for "${spec.key}": mask state does not match.`) + } + } + if (spec.figma?.svg) { + nativeFields += 1 + if (node.type !== 'FRAME') { + applyError(`Verification failed for "${spec.key}": SVG wrapper is not a frame.`) + } + const asset = resolvedSvgAsset( + state.media.assets, + spec.figma.svg.assetKey, + spec.figma.svg.color + ) + const owned = node.children.filter(isOwnedSvgChild) + const unexpected = node.children.filter((child) => !isOwnedSvgChild(child)) + if ( + !asset || + owned.length !== 1 || + unexpected.length || + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_DIGEST_NAME) !== asset.digest || + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_POLICY_NAME) !== + SVG_POLICY_VERSION || + node.getSharedPluginData(CANVAS_KEY_NAMESPACE, CANVAS_SVG_COLOR_NAME) !== + (spec.figma.svg.color?.toUpperCase() ?? '') + ) { + applyError(`Verification failed for "${spec.key}": SVG import state does not match.`) + } + const child = owned[0]! + if ( + child.x < -0.01 || + child.y < -0.01 || + child.x + child.width > node.width + 0.01 || + child.y + child.height > node.height + 0.01 + ) { + applyError(`Verification failed for "${spec.key}": SVG is outside its wrapper.`) + } + references += 1 + } + + const childSpecs = spec.children ?? [] + const childNodes = childSpecs.map((child) => { + const candidate = state.keyedNodes.get(child.key) ?? null + if (!isSupportedSceneNode(candidate)) { + applyError(`Verification failed for "${child.key}": desired child is missing.`) + } + return candidate + }) + if (childNodes.length) { + if (!('children' in node)) { + applyError(`Verification failed for "${spec.key}": desired children have no container.`) + } + let previous = -1 + for (const child of childNodes) { + const index = node.children.findIndex((candidate) => candidate.id === child.id) + if (index <= previous) { + applyError(`Verification failed for "${spec.key}": desired child order does not match.`) + } + previous = index + } + } + let nodes = 1 + for (const [index, childSpec] of childSpecs.entries()) { + const verified = await verifyAppliedNode(childSpec, childNodes[index]!, state, node) + nodes += verified.nodes + references += verified.references + nativeFields += verified.nativeFields + } + return { nodes, references, nativeFields } +} diff --git a/packages/extension/mcp/tools/canvas/reconcile/warnings.ts b/packages/extension/mcp/tools/canvas/reconcile/warnings.ts new file mode 100644 index 00000000..ead20b45 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/reconcile/warnings.ts @@ -0,0 +1,763 @@ +import type { CanvasVariableBindings, CanvasVariableReference } from '@tempad-dev/shared' + +import type { CanvasNodeSpec, ParsedCanvasTreeInput } from '../model' +import type { + ApplyState, + VerificationWarning, + SupportedCanvasNode, + CanvasFrameContainerNode +} from './state' + +import { readOwnedNodeKey } from '../identity' +import { + DIRECT_VARIABLE_FIELDS, + type DirectVariableField, + currentBoundVariableId, + unboundTextMetricRanges +} from './binding-values' +import { isRecord, GEOMETRY_TOLERANCE, finiteRect } from './comparison' +import { layoutStrokeWeight, includedLayoutEdgeStroke } from './layout' +import { walkSpecs, isSupportedSceneNode, isFrameContainer, isSceneNode } from './nodes' +import { textFillsUnstyledOutsideRanges } from './text-paints' +import { textRangesIntersect } from './text-ranges' +import { baseTextStyleScope } from './text-style-scope' + +const CONTENT_OVERFLOW_TOLERANCE = 0.5 + +function collectKeyReferences(value: unknown, field: string, references: Set): void { + if (Array.isArray(value)) { + for (const item of value) collectKeyReferences(item, field, references) + return + } + if (!isRecord(value)) return + if (typeof value[field] === 'string') references.add(value[field]) + for (const nested of Object.values(value)) collectKeyReferences(nested, field, references) +} + +function desiredKeyReferences(values: unknown[], field: string): Set { + const references = new Set() + for (const value of values) collectKeyReferences(value, field, references) + return references +} + +export function unboundCreatedResourceWarnings( + desiredValues: unknown[], + state: ApplyState +): VerificationWarning[] { + return [ + { + code: 'unbound-created-variable' as const, + consumer: 'node or style', + keys: state.variables.createdVariableKeys, + referenceField: 'variableKey', + resource: 'variable' + }, + { + code: 'unbound-created-style' as const, + consumer: 'node', + keys: state.styles.createdStyleKeys, + referenceField: 'styleKey', + resource: 'style' + } + ].flatMap(({ code, consumer, keys, referenceField, resource }) => { + if (!keys.size) return [] + const references = desiredKeyReferences(desiredValues, referenceField) + return [...keys] + .filter((key) => !references.has(key)) + .map((key) => ({ + code, + key, + message: `This new ${resource} is not referenced by the desired result. Bind it to a representative ${consumer}, or remove it if it is speculative.` + })) + }) +} + +type VariableFallback = boolean | number | string | RGBA + +export function ignoredLiteralWarnings( + node: SupportedCanvasNode, + spec: CanvasNodeSpec, + nativeOnly: boolean +): VerificationWarning[] { + const fields: string[] = [] + for (const [field, nativeField] of Object.entries(DIRECT_VARIABLE_FIELDS) as Array< + [DirectVariableField, VariableBindableNodeField | VariableBindableTextField] + >) { + if (spec.variables?.[field] !== undefined || !currentBoundVariableId(node, nativeField)) + continue + if ( + node.type === 'TEXT' && + (field === 'fontSize' || + field === 'lineHeight' || + field === 'letterSpacing' || + field === 'paragraphIndent' || + field === 'paragraphSpacing') + ) { + const unbound = unboundTextMetricRanges(node, spec, field) + if (unbound === undefined) continue + if ((field === 'paragraphIndent' || field === 'paragraphSpacing') && unbound.length) continue + const styleScope = baseTextStyleScope(spec, node) + if ( + unbound.length && + (styleScope.kind !== 'partial' || + !textRangesIntersect(styleScope.ranges, unbound).next().done) + ) + continue + } + // Native-only specs copy size and characters to preserve live state, not to author literals. + if (nativeOnly && (field === 'characters' || Object.hasOwn(spec.size, field))) continue + const literal = variableFallback(spec, field) + if (literal !== undefined) fields.push(field) + } + for (const field of ['fill', 'stroke'] as const) { + if ( + spec.variables?.[field] !== undefined || + spec.styles?.[field] !== undefined || + spec.appearance?.[field] === undefined || + !('fills' in node) + ) + continue + const property = field === 'fill' ? 'fills' : 'strokes' + const style = field === 'fill' ? node.fillStyleId : node.strokeStyleId + const retainedStyle = + style && + !( + node.type === 'TEXT' && + field === 'fill' && + textFillsUnstyledOutsideRanges( + node, + node.characters === spec.text?.characters ? spec.figma?.text?.ranges : undefined + ) + ) + if (retainedStyle || node.boundVariables?.[property]?.length) fields.push(field) + } + if ( + node.type === 'TEXT' && + spec.styles?.text === undefined && + baseTextStyleScope(spec, node).kind === 'retained' + ) { + for (const field of [ + 'fontFamily', + 'fontStyle', + 'fontSize', + 'lineHeight', + 'letterSpacing' + ] as const) { + if (spec.text?.[field] !== undefined) fields.push(field) + } + } + return fields.length + ? [ + { + code: 'literal-ignored-by-binding', + key: spec.key, + message: `Literal values for ${[...new Set(fields)].join(', ')} were ignored because existing variable or style bindings own those fields. Clear the corresponding native.${spec.key}.variables or styles binding with null before setting literal values.` + } + ] + : [] +} + +function parseFallbackColor(value: string): RGBA | undefined { + const hex = value.slice(1) + const expanded = + hex.length === 3 || hex.length === 4 + ? [...hex].map((character) => `${character}${character}`).join('') + : hex + if (expanded.length !== 6 && expanded.length !== 8) return undefined + const channels = expanded.match(/.{2}/g)?.map((channel) => Number.parseInt(channel, 16)) + if (!channels || channels.some((channel) => Number.isNaN(channel))) return undefined + return { + r: channels[0]! / 255, + g: channels[1]! / 255, + b: channels[2]! / 255, + a: channels[3] === undefined ? 1 : channels[3] / 255 + } +} + +function paddingFallback( + layout: CanvasNodeSpec['layout'], + side: 'bottom' | 'left' | 'right' | 'top' +): number | undefined { + if (!layout || layout.mode === 'NONE' || layout.padding === undefined) return undefined + return typeof layout.padding === 'number' ? layout.padding : layout.padding[side] +} + +function metricFallback(value: unknown): number | undefined { + return isRecord(value) && value.unit === 'PIXELS' && typeof value.value === 'number' + ? value.value + : undefined +} + +function variableFallback( + spec: CanvasNodeSpec, + field: keyof CanvasVariableBindings +): VariableFallback | undefined { + switch (field) { + case 'fill': + case 'stroke': { + const value = spec.appearance?.[field] + return typeof value === 'string' ? parseFallbackColor(value) : undefined + } + case 'characters': + return spec.text?.characters + case 'visible': + return spec.visible + case 'width': + case 'height': + case 'minWidth': + case 'maxWidth': + case 'minHeight': + case 'maxHeight': + return spec.size[field] ?? undefined + case 'gap': + return spec.layout?.mode === 'HORIZONTAL' || spec.layout?.mode === 'VERTICAL' + ? spec.layout.gap + : undefined + case 'counterAxisSpacing': + return spec.layout?.mode === 'HORIZONTAL' || spec.layout?.mode === 'VERTICAL' + ? spec.layout.counterGap + : undefined + case 'gridRowGap': + return spec.layout?.mode === 'GRID' ? spec.layout.rowGap : undefined + case 'gridColumnGap': + return spec.layout?.mode === 'GRID' ? spec.layout.columnGap : undefined + case 'paddingTop': + return paddingFallback(spec.layout, 'top') + case 'paddingRight': + return paddingFallback(spec.layout, 'right') + case 'paddingBottom': + return paddingFallback(spec.layout, 'bottom') + case 'paddingLeft': + return paddingFallback(spec.layout, 'left') + case 'cornerRadius': + case 'topLeftRadius': + case 'topRightRadius': + case 'bottomRightRadius': + case 'bottomLeftRadius': + case 'strokeWeight': + case 'strokeTopWeight': + case 'strokeRightWeight': + case 'strokeBottomWeight': + case 'strokeLeftWeight': + case 'opacity': + return spec.appearance?.[field] + case 'fontFamily': + return spec.text?.fontFamily + case 'fontStyle': + return spec.text?.fontStyle + case 'fontSize': + return spec.text?.fontSize + case 'lineHeight': + return metricFallback(spec.text?.lineHeight) + case 'letterSpacing': + return metricFallback(spec.text?.letterSpacing) + case 'fontWeight': + return spec.text?.literalFontWeight + case 'paragraphIndent': + case 'paragraphSpacing': + return undefined + } +} + +function colorChannels(value: unknown): RGBA | null { + if ( + !isRecord(value) || + typeof value.r !== 'number' || + typeof value.g !== 'number' || + typeof value.b !== 'number' + ) { + return null + } + return { + r: value.r, + g: value.g, + b: value.b, + a: typeof value.a === 'number' ? value.a : 1 + } +} + +function fallbackMatches(value: unknown, fallback: VariableFallback): boolean { + if (typeof fallback === 'number') { + return typeof value === 'number' && Math.abs(value - fallback) <= GEOMETRY_TOLERANCE + } + if (typeof fallback === 'string' || typeof fallback === 'boolean') return value === fallback + const channels = colorChannels(value) + if (!channels) return false + return ( + Math.abs(channels.r - fallback.r) <= 1 / 255 && + Math.abs(channels.g - fallback.g) <= 1 / 255 && + Math.abs(channels.b - fallback.b) <= 1 / 255 && + Math.abs(channels.a - fallback.a) <= 1 / 255 + ) +} + +function formatFallback(value: unknown): string { + if (typeof value !== 'object' || value === null) return String(value) + const channels = colorChannels(value) + if (!channels) return JSON.stringify(value) + const bytes = [channels.r, channels.g, channels.b, channels.a].map((channel) => + Math.round(channel * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase() + ) + return `#${bytes.slice(0, channels.a === 1 ? 3 : 4).join('')}` +} + +export function authoredVariableFallbackWarnings( + specs: Iterable, + variableCollections: ParsedCanvasTreeInput['variableCollections'] +): VerificationWarning[] { + const authoredValues = new Map() + for (const collection of Object.values(variableCollections ?? {})) { + if (!collection) continue + for (const [key, variable] of Object.entries(collection.variables ?? {})) { + const values = Object.values(variable?.values ?? {}) + if (values.length) authoredValues.set(key, values) + } + } + + const directValues = (key: string, seen = new Set()): unknown[] => { + if (seen.has(key)) return [] + seen.add(key) + return (authoredValues.get(key) ?? []).flatMap((value) => { + if (!isRecord(value) || !isRecord(value.variable)) return [value] + const alias = value.variable.variableKey + return typeof alias === 'string' ? directValues(alias, seen) : [] + }) + } + + const warnings: VerificationWarning[] = [] + for (const spec of specs) { + for (const [field, reference] of Object.entries(spec.variables ?? {}) as Array< + [keyof CanvasVariableBindings, CanvasVariableReference | null] + >) { + if (!reference || !('variableKey' in reference)) continue + if (spec.themeVariableFields?.includes(field)) continue + const values = directValues(reference.variableKey) + const fallback = variableFallback(spec, field) + const nativeFallback = + field === 'opacity' && typeof fallback === 'number' ? fallback * 100 : fallback + if ( + !values.length || + nativeFallback === undefined || + values.some((value) => fallbackMatches(value, nativeFallback)) + ) { + continue + } + const authoredValues = [...new Set(values.map(formatFallback))].join(', ') + const units = + field === 'opacity' + ? ' Opacity variables use 0–100 percentages; literal opacity uses 0–1.' + : '' + warnings.push({ + code: 'variable-fallback-mismatch', + key: spec.key, + message: `"${spec.key}" binds ${field} ${formatFallback(fallback)} to authored variable "${reference.variableKey}", whose direct mode values are ${authoredValues}.${units} Use a matching literal fallback or bind the variable that owns this value.` + }) + } + } + return warnings +} + +function layoutAffectingVisibilityWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const spec of walkSpecs(root)) { + const property = spec.figma?.componentPropertyReferences?.visible + if (!property) continue + const node = state.keyedNodes.get(spec.key) + const parent = node?.parent + if ( + !node || + !parent || + !isSupportedSceneNode(parent) || + !isFrameContainer(parent) || + parent.layoutMode === 'NONE' || + ('layoutPositioning' in node && node.layoutPositioning === 'ABSOLUTE') + ) { + continue + } + const flowSibling = parent.children.some( + (child) => + child.id !== node.id && + isSceneNode(child) && + (!('layoutPositioning' in child) || child.layoutPositioning !== 'ABSOLUTE') + ) + if ( + !flowSibling && + parent.primaryAxisSizingMode !== 'AUTO' && + parent.counterAxisSizingMode !== 'AUTO' + ) { + continue + } + warnings.push({ + code: 'layout-affecting-visibility-property', + key: spec.key, + message: `Boolean component property "${property}" controls an Auto Layout flow child. Hiding it can resize its parent or move siblings. If geometry must stay stable, put the visible layer inside an always-present fixed slot, make it absolute, or use geometry-equivalent variants; otherwise verify both states and accept the intended reflow.` + }) + } + return warnings +} + +type ContentOverflow = { + bottom: number + left: number + right: number + top: number +} + +function overflowFromRects(child: Rect, parent: Rect): ContentOverflow | null { + const overflow = { + bottom: Math.max(0, child.y + child.height - (parent.y + parent.height)), + left: Math.max(0, parent.x - child.x), + right: Math.max(0, child.x + child.width - (parent.x + parent.width)), + top: Math.max(0, parent.y - child.y) + } + return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > + CONTENT_OVERFLOW_TOLERANCE + ? overflow + : null +} + +function localNodeBounds(node: SupportedCanvasNode): Rect | null { + if (![node.width, node.height].every(Number.isFinite) || node.width < 0 || node.height < 0) { + return null + } + const transform = node.relativeTransform + const corners = [ + { x: 0, y: 0 }, + { x: node.width, y: 0 }, + { x: 0, y: node.height }, + { x: node.width, y: node.height } + ].map(({ x, y }) => ({ + x: transform[0][0] * x + transform[0][1] * y + transform[0][2], + y: transform[1][0] * x + transform[1][1] * y + transform[1][2] + })) + const x = Math.min(...corners.map((corner) => corner.x)) + const y = Math.min(...corners.map((corner) => corner.y)) + const right = Math.max(...corners.map((corner) => corner.x)) + const bottom = Math.max(...corners.map((corner) => corner.y)) + return finiteRect({ x, y, width: right - x, height: bottom - y }) +} + +function contentOverflow( + node: SupportedCanvasNode, + parent: CanvasFrameContainerNode +): ContentOverflow | null { + const parentBounds = finiteRect( + 'absoluteBoundingBox' in parent ? parent.absoluteBoundingBox : null + ) + const childBounds = [ + node.type === 'TEXT' && 'absoluteRenderBounds' in node ? node.absoluteRenderBounds : null, + 'absoluteBoundingBox' in node ? node.absoluteBoundingBox : null + ].map(finiteRect) + if (parentBounds && childBounds.some(Boolean)) { + const overflow: ContentOverflow = { bottom: 0, left: 0, right: 0, top: 0 } + for (const bounds of childBounds) { + if (!bounds) continue + const current = overflowFromRects(bounds, parentBounds) + if (!current) continue + overflow.bottom = Math.max(overflow.bottom, current.bottom) + overflow.left = Math.max(overflow.left, current.left) + overflow.right = Math.max(overflow.right, current.right) + overflow.top = Math.max(overflow.top, current.top) + } + return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > + CONTENT_OVERFLOW_TOLERANCE + ? overflow + : null + } + if (parent.layoutMode !== 'NONE') return null + const local = localNodeBounds(node) + return local + ? overflowFromRects(local, { x: 0, y: 0, width: parent.width, height: parent.height }) + : null +} + +function formatOverflow(overflow: ContentOverflow): string { + const edges: Array<[string, number]> = [ + ['top', overflow.top], + ['right', overflow.right], + ['bottom', overflow.bottom], + ['left', overflow.left] + ] + return edges + .filter(([, value]) => value > CONTENT_OVERFLOW_TOLERANCE) + .map(([edge, value]) => `${edge} ${Math.round(value * 10) / 10}px`) + .join(', ') +} + +function instanceDescendantOverflow(node: InstanceNode): ContentOverflow | null { + const absoluteRootBounds = finiteRect(node.absoluteBoundingBox) + const rootBounds = absoluteRootBounds ?? { x: 0, y: 0, width: node.width, height: node.height } + const overflow: ContentOverflow = { bottom: 0, left: 0, right: 0, top: 0 } + const stack: SceneNode[] = [...node.children] + while (stack.length) { + const descendant = stack.pop()! + if (descendant.visible === false) continue + const bounds = absoluteRootBounds + ? finiteRect( + descendant.type === 'TEXT' + ? descendant.absoluteRenderBounds + : descendant.absoluteBoundingBox + ) + : descendant.parent === node && isSupportedSceneNode(descendant) + ? localNodeBounds(descendant) + : null + if (bounds) { + const current = overflowFromRects(bounds, rootBounds) + if (current) { + overflow.bottom = Math.max(overflow.bottom, current.bottom) + overflow.left = Math.max(overflow.left, current.left) + overflow.right = Math.max(overflow.right, current.right) + overflow.top = Math.max(overflow.top, current.top) + } + } + if ('children' in descendant) { + for (const child of descendant.children) stack.push(child) + } + } + return Math.max(overflow.top, overflow.right, overflow.bottom, overflow.left) > + CONTENT_OVERFLOW_TOLERANCE + ? overflow + : null +} + +function managedContentOverflowWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const spec of walkSpecs(root)) { + const node = state.keyedNodes.get(spec.key) + if (!node) continue + const parent = node.parent + if (!parent || !isSupportedSceneNode(parent) || !isFrameContainer(parent)) continue + const isFlowChild = + parent.layoutMode !== 'NONE' && + (!('layoutPositioning' in node) || node.layoutPositioning !== 'ABSOLUTE') + if (node.type !== 'TEXT' && node.type !== 'INSTANCE' && !isFlowChild) continue + const parentKey = readOwnedNodeKey(parent) + if (!parentKey) continue + const overflow = contentOverflow(node, parent) + if (overflow) { + warnings.push({ + code: 'managed-content-overflow', + key: spec.key, + message: `"${spec.key}" extends beyond parent "${parentKey}" by ${formatOverflow(overflow)}. Parent clipping is ${parent.clipsContent ? 'enabled, so the content will be clipped' : 'disabled, so the content may overlap adjacent content'}. Resize or realign the content when unintended; otherwise verify and retain the intentional overflow.` + }) + } + if (node.type !== 'INSTANCE') continue + const instanceOverflow = instanceDescendantOverflow(node) + if (!instanceOverflow) continue + warnings.push({ + code: 'managed-content-overflow', + key: spec.key, + message: `"${spec.key}" contains instance content extending beyond its root by ${formatOverflow(instanceOverflow)}. Figma can paint this overflow without expanding the native INSTANCE bounds. Resize the component definition for the real property values, add a size variant, or choose a smaller stable component boundary.` + }) + } + return warnings +} + +type RoundedCorner = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' + +function hasVisibleAreaFill(node: SupportedCanvasNode): boolean { + if ( + node.type === 'TEXT' || + node.type === 'LINE' || + node.type === 'VECTOR' || + !('fills' in node) || + node.fills === figma.mixed + ) { + return false + } + return node.fills.some( + (paint) => paint.visible !== false && (paint.opacity === undefined || paint.opacity > 0) + ) +} + +function roundedCornerRadius(node: SupportedCanvasNode, corner: RoundedCorner): number { + if (!('topLeftRadius' in node)) return 0 + if ('cornerRadius' in node && typeof node.cornerRadius === 'number') { + return Number.isFinite(node.cornerRadius) ? node.cornerRadius : 0 + } + const field = + corner === 'top-left' + ? 'topLeftRadius' + : corner === 'top-right' + ? 'topRightRadius' + : corner === 'bottom-right' + ? 'bottomRightRadius' + : 'bottomLeftRadius' + const value = node[field] + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +function managedRoundedStrokeOcclusionWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const parentSpec of walkSpecs(root)) { + const parent = state.keyedNodes.get(parentSpec.key) + if ( + !parent || + !isFrameContainer(parent) || + !parent.clipsContent || + parent.strokeAlign === 'OUTSIDE' || + !parent.strokes.some( + (paint) => paint.visible !== false && (paint.opacity === undefined || paint.opacity > 0) + ) + ) { + continue + } + + const innerStrokeWeight = ( + field: 'strokeBottomWeight' | 'strokeLeftWeight' | 'strokeRightWeight' | 'strokeTopWeight' + ): number => layoutStrokeWeight(parent, field) * (parent.strokeAlign === 'CENTER' ? 0.5 : 1) + + const strokes = { + bottom: innerStrokeWeight('strokeBottomWeight'), + left: innerStrokeWeight('strokeLeftWeight'), + right: innerStrokeWeight('strokeRightWeight'), + top: innerStrokeWeight('strokeTopWeight') + } + for (const child of parent.children) { + if ( + child.visible === false || + !isSupportedSceneNode(child) || + !hasVisibleAreaFill(child) || + ('rotation' in child && Math.abs(child.rotation) > GEOMETRY_TOLERANCE) + ) { + continue + } + const childKey = readOwnedNodeKey(child) + const bounds = localNodeBounds(child) + if (!childKey || !bounds) continue + const touches = { + bottom: + bounds.y + bounds.height >= parent.height - strokes.bottom - CONTENT_OVERFLOW_TOLERANCE, + left: bounds.x <= strokes.left + CONTENT_OVERFLOW_TOLERANCE, + right: bounds.x + bounds.width >= parent.width - strokes.right - CONTENT_OVERFLOW_TOLERANCE, + top: bounds.y <= strokes.top + CONTENT_OVERFLOW_TOLERANCE + } + const affected = ( + [ + ['top-left', touches.top && touches.left, Math.max(strokes.top, strokes.left)], + ['top-right', touches.top && touches.right, Math.max(strokes.top, strokes.right)], + [ + 'bottom-right', + touches.bottom && touches.right, + Math.max(strokes.bottom, strokes.right) + ], + ['bottom-left', touches.bottom && touches.left, Math.max(strokes.bottom, strokes.left)] + ] as Array<[RoundedCorner, boolean, number]> + ).filter(([corner, touching, stroke]) => { + if (!touching) return false + const requiredRadius = Math.max(0, roundedCornerRadius(parent, corner) - stroke) + return ( + requiredRadius > CONTENT_OVERFLOW_TOLERANCE && + roundedCornerRadius(child, corner) + CONTENT_OVERFLOW_TOLERANCE < requiredRadius + ) + }) + if (!affected.length) continue + warnings.push({ + code: 'managed-rounded-stroke-occlusion', + key: childKey, + message: `"${childKey}" has a visible fill reaching the ${affected + .map(([corner]) => corner) + .join( + ' and ' + )} of rounded, stroked parent "${parentSpec.key}" without a corresponding inner radius. Figma clips to the outer frame but paints the parent stroke behind its children, so the fill can square off or hide that boundary. Inset the child, give its touching corners an inner radius that follows the parent, or add a dedicated foreground boundary; then inspect the rendered pixels.` + }) + } + } + return warnings +} + +function includedLayoutTrailingStroke(node: CanvasFrameContainerNode): number { + return includedLayoutEdgeStroke( + node, + node.layoutMode === 'HORIZONTAL' ? 'strokeRightWeight' : 'strokeBottomWeight' + ) +} + +function formatGeometry(value: number): string { + return `${Math.round(value * 10) / 10}px` +} + +function managedAutoLayoutInsetWarnings( + root: CanvasNodeSpec, + state: ApplyState +): VerificationWarning[] { + const warnings: VerificationWarning[] = [] + for (const spec of walkSpecs(root)) { + const node = state.keyedNodes.get(spec.key) + if ( + !node || + !isFrameContainer(node) || + (node.layoutMode !== 'HORIZONTAL' && node.layoutMode !== 'VERTICAL') || + node.primaryAxisSizingMode === 'AUTO' || + node.primaryAxisAlignItems !== 'MIN' || + node.layoutWrap !== 'NO_WRAP' || + (node.layoutMode === 'HORIZONTAL' + ? spec.size.horizontal !== 'FIXED' + : spec.size.vertical !== 'FIXED') || + Math.abs(node.rotation) > GEOMETRY_TOLERANCE + ) { + continue + } + const children = node.children.filter( + (child) => + child.visible !== false && + (!('layoutPositioning' in child) || child.layoutPositioning !== 'ABSOLUTE') + ) + if ( + !children.length || + children.some( + (child) => + ('layoutGrow' in child && child.layoutGrow > 0) || + ('rotation' in child && Math.abs(child.rotation) > GEOMETRY_TOLERANCE) + ) + ) { + continue + } + const bounds = finiteRect(node.absoluteBoundingBox) + const childBounds = children.map((child) => finiteRect(child.absoluteBoundingBox)) + if (!bounds || childBounds.some((child) => !child)) continue + const horizontal = node.layoutMode === 'HORIZONTAL' + const childEnd = childBounds.reduce( + (end, child) => + Math.max(end, horizontal ? child!.x + child!.width : child!.y + child!.height), + Number.NEGATIVE_INFINITY + ) + const actual = (horizontal ? bounds.x + bounds.width : bounds.y + bounds.height) - childEnd + const padding = horizontal ? node.paddingRight : node.paddingBottom + const expected = padding + includedLayoutTrailingStroke(node) + if (Math.abs(actual - expected) <= CONTENT_OVERFLOW_TOLERANCE) continue + const side = horizontal ? 'right' : 'bottom' + const diagnosis = + actual > expected + ? `The fixed start-aligned main axis leaves ${formatGeometry(actual - expected)} of unmodeled trailing space.` + : `Resolved content consumes ${formatGeometry(expected - actual)} of the intended trailing inset.` + warnings.push({ + code: 'managed-auto-layout-inset-mismatch', + key: spec.key, + message: `"${spec.key}" resolves to a ${formatGeometry(actual)} ${side} inset after its last in-flow child, while its padding and included inside stroke account for ${formatGeometry(expected)}. ${diagnosis} Use a hugging main axis or resize after content resolves; if the empty space is intentional, express it with main-axis alignment or a growing spacer.` + }) + } + return warnings +} + +export function layoutWarnings(specs: CanvasNodeSpec[], state: ApplyState): VerificationWarning[] { + return [ + ...state.ignoredLiteralWarnings, + ...specs.flatMap((spec) => layoutAffectingVisibilityWarnings(spec, state)), + ...specs.flatMap((spec) => managedContentOverflowWarnings(spec, state)), + ...specs.flatMap((spec) => managedRoundedStrokeOcclusionWarnings(spec, state)), + ...specs.flatMap((spec) => managedAutoLayoutInsetWarnings(spec, state)) + ] +} diff --git a/packages/extension/mcp/tools/canvas/styles.ts b/packages/extension/mcp/tools/canvas/styles.ts index 799a6670..af3b4882 100644 --- a/packages/extension/mcp/tools/canvas/styles.ts +++ b/packages/extension/mcp/tools/canvas/styles.ts @@ -26,8 +26,10 @@ type StyleSelection = { export type CanvasStyleState = { byKey: Map cache: Map + createdStyleIds: Set createdStyleKeys: Set indexing?: Promise + removedStyleIds: Set removals: Array<{ key: string; style: BaseStyle }> resources: CanvasStyleResourceState[] selections?: StyleSelection[] @@ -121,7 +123,9 @@ export function createStyleState(): CanvasStyleState { return { byKey: new Map(), cache: new Map(), + createdStyleIds: new Set(), createdStyleKeys: new Set(), + removedStyleIds: new Set(), removals: [], resources: [] } @@ -142,6 +146,7 @@ export async function prepareStyleResources( const style = selected ?? createStyle(spec.type) if (!selected) { mutations.count += 1 + state.createdStyleIds.add(style.id) state.createdStyleKeys.add(key) } claimAuthoringKey(style, key, CANVAS_STYLE_KEY_NAME, 'Style', mutations) @@ -165,7 +170,9 @@ export async function removeStyleResources( } } for (const { style } of state.removals) { + const id = style.id style.remove() + state.removedStyleIds.add(id) mutations.count += 1 } } diff --git a/packages/extension/mcp/tools/canvas/svg-xml.ts b/packages/extension/mcp/tools/canvas/svg-xml.ts new file mode 100644 index 00000000..46fbc8d3 --- /dev/null +++ b/packages/extension/mcp/tools/canvas/svg-xml.ts @@ -0,0 +1,120 @@ +import { parseSync, stringify, type INode } from 'svgson' + +const XML_ENTITIES: Record = { + amp: '&', + apos: "'", + gt: '>', + lt: '<', + quot: '"' +} +const TEXT_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '\r': ' ' +} + +function assertXmlCharacters(value: string): void { + for (const character of value) { + const code = character.codePointAt(0)! + if ( + code === 9 || + code === 10 || + code === 13 || + (code >= 0x20 && code <= 0xd7ff) || + (code >= 0xe000 && code <= 0xfffd) || + code >= 0x10000 + ) + continue + throw new Error('SVG XML contains invalid characters.') + } +} + +function preserveWhitespace(value: string): string { + // svgson drops text nodes whose entire value is JavaScript whitespace. + return value.trim() ? value : value.replace(/\s/g, (character) => `&#${character.charCodeAt(0)};`) +} + +function escapeText(value: string): string { + return preserveWhitespace(value.replace(/[&<>\r]/g, (character) => TEXT_ESCAPES[character]!)) +} + +function decodeEntities(value: string): string { + const decoded = value.replace( + /&(#x[\dA-Fa-f]+|#\d+|amp|apos|gt|lt|quot);|&/g, + (_match, reference: string | undefined) => { + if (!reference) throw new Error('SVG XML contains an unsupported or malformed reference.') + if (!reference.startsWith('#')) return XML_ENTITIES[reference]! + const hex = reference.startsWith('#x') + const codePoint = Number.parseInt(reference.slice(hex ? 2 : 1), hex ? 16 : 10) + if (codePoint > 0x10ffff) throw new Error('SVG XML contains an invalid character reference.') + return String.fromCodePoint(codePoint) + } + ) + assertXmlCharacters(decoded) + return decoded +} + +export function parseSvgXml(source: string): INode { + assertXmlCharacters(source) + // svgson does not distinguish CDATA from encoded text. Escape it before parsing + // so both can be decoded once. Keep tags/quoted attributes opaque, discard + // complete comments, and preserve internal whitespace without encoding prologs. + let depth = 0 + const normalized = source + .replace(/\r\n?/g, '\n') + .replace( + /|$)||$)|<(?:"[^"]*"|'[^']*'|[^'"><])*>|[^<]+/g, + (token, cdata: string | undefined) => { + if (token.startsWith('')) throw new Error('SVG XML contains an unclosed comment.') + return '' + } + if (cdata !== undefined) { + if (!token.endsWith(']]>')) throw new Error('SVG XML contains unclosed CDATA.') + return escapeText(cdata) + } + if (!token.startsWith('<')) return depth > 0 ? preserveWhitespace(token) : token + if (token.startsWith('')) + depth += 1 + return token + } + ) + const root = parseSync(normalized) + if (Array.isArray(root)) throw new Error('SVG XML requires exactly one document root.') + const pending = [root] + while (pending.length) { + const node = pending.pop()! + node.attributes = Object.fromEntries( + Object.entries(node.attributes).map(([name, value]) => [ + name, + decodeEntities(value.replace(/[\t\n]/g, ' ')) + ]) + ) + if (node.type === 'text') node.value = decodeEntities(node.value) + for (const child of node.children) pending.push(child) + } + return root +} + +export function stringifySvgXml(root: INode): string { + const texts: string[] = [] + const serialized = stringify(root, { + transformNode(node) { + if (node.type !== 'text') return node + // Valid XML cannot contain NUL, making these placeholders collision-free. + const index = texts.push(escapeText(node.value)) - 1 + return { ...node, value: `\0${index}\0` } + }, + transformAttr(name, value, escape) { + const escaped = escape(value).replace( + /[\t\n\r]/g, + (character) => `&#${character.charCodeAt(0)};` + ) + return `${name}="${escaped}"` + } + }) + // svgson's text serializer only escapes the first CDATA closing sequence. + return serialized.replace(/\0(\d+)\0/g, (_match, index: string) => texts[Number(index)]!) +} diff --git a/packages/extension/mcp/tools/canvas/tailwind.ts b/packages/extension/mcp/tools/canvas/tailwind.ts index ffeff4e3..c9106e70 100644 --- a/packages/extension/mcp/tools/canvas/tailwind.ts +++ b/packages/extension/mcp/tools/canvas/tailwind.ts @@ -329,10 +329,14 @@ export type CanvasClasses = { } export function normalizePortableFontStyle(fontFamily: string, fontStyle: string): string { - const aliases = PORTABLE_FONT_STYLE_ALIASES[ - fontFamily as keyof typeof PORTABLE_FONT_STYLE_ALIASES - ] as Record | undefined - return aliases?.[fontStyle] ?? fontStyle + const aliases = ownValue(PORTABLE_FONT_STYLE_ALIASES, fontFamily) as + | Record + | undefined + return (aliases && ownValue(aliases, fontStyle)) ?? fontStyle +} + +function ownValue(values: T, key: string | undefined): T[keyof T] | undefined { + return key !== undefined && Object.hasOwn(values, key) ? values[key as keyof T] : undefined } function classError(message: string): never { @@ -388,9 +392,9 @@ export function findUnsupportedCanvasClasses(value: string): { function finiteNumber( raw: string, token: string, - options: { allowNegative?: boolean; positive?: boolean } = {} + options: { allowNegative?: boolean; positive?: boolean; scale?: number } = {} ): number { - const value = Number(raw) + const value = Number(raw) * (options.scale ?? 1) if ( !Number.isFinite(value) || (options.positive ? value <= 0 : !options.allowNegative && value < 0) @@ -413,19 +417,19 @@ function pixels( if (options.numericScale === undefined || !/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(raw)) { return null } - return finiteNumber(raw, token) * options.numericScale + return finiteNumber(raw, token, { scale: options.numericScale }) } function fixedSize(raw: string, token: string, containers = false): number | null { const value = pixels(raw, token, { numericScale: 4 }) if (value !== null) return value - return containers ? (CONTAINER_WIDTHS[raw as keyof typeof CONTAINER_WIDTHS] ?? null) : null + return containers ? (ownValue(CONTAINER_WIDTHS, raw) ?? null) : null } function radius(raw: string, token: string): number | null { const arbitrary = pixels(raw, token) if (arbitrary !== null) return arbitrary - return RADII[raw as keyof typeof RADII] ?? null + return ownValue(RADII, raw) ?? null } function color(raw: string): `#${string}` | null { @@ -511,10 +515,10 @@ function cssColor(raw: string): RGBA | null { return r === null || g === null || b === null || a === null ? null : { r, g, b, a } } -function shadowLength(raw: string): number | null { +function shadowLength(raw: string, token: string): number | null { if (/^-?0(?:\.0+)?$/.test(raw)) return 0 const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))px$/.exec(raw) - return match ? Number(match[1]) : null + return match ? finiteNumber(match[1]!, token, { allowNegative: true }) : null } function parseShadowEffects( @@ -542,7 +546,7 @@ function parseShadowEffects( color = parsedColor continue } - const length = shadowLength(part) + const length = shadowLength(part, token) if (length === null) classError(`Invalid shadow value "${part}" in class "${token}".`) values.push(length) } @@ -566,25 +570,26 @@ function parseShadowEffects( } function lineHeight(raw: string, token: string): LineHeight | null { - const named = LINE_HEIGHTS[raw as keyof typeof LINE_HEIGHTS] + const named = ownValue(LINE_HEIGHTS, raw) if (named !== undefined) return { unit: 'PERCENT', value: named } const spacing = /^(?:\d+(?:\.\d+)?|\.\d+)$/.test(raw) - ? finiteNumber(raw, token, { positive: true }) * 4 + ? finiteNumber(raw, token, { positive: true, scale: 4 }) : null if (spacing !== null) return { unit: 'PIXELS', value: spacing } const arbitrary = /^\[((?:\d+(?:\.\d+)?|\.\d+))(px|%|)\]$/.exec(raw) if (!arbitrary) return null - const value = finiteNumber(arbitrary[1]!, token, { positive: true }) - return arbitrary[2] === 'px' - ? { unit: 'PIXELS', value } - : { unit: 'PERCENT', value: arbitrary[2] === '%' ? value : value * 100 } + const value = finiteNumber(arbitrary[1]!, token, { + positive: true, + scale: arbitrary[2] === '' ? 100 : 1 + }) + return { unit: arbitrary[2] === 'px' ? 'PIXELS' : 'PERCENT', value } } function textSize( raw: string, token: string ): { defaultLineHeight?: LineHeight; value: number } | null { - const named = FONT_SIZES[raw as keyof typeof FONT_SIZES] + const named = ownValue(FONT_SIZES, raw) if (named) { return { value: named[0], defaultLineHeight: { unit: 'PIXELS', value: named[1] } } } @@ -707,7 +712,7 @@ export function parseCanvasClasses(value: string): CanvasClasses { } if (token.startsWith('mix-blend-')) { const name = token.slice('mix-blend-'.length) - const blendMode = BLEND_MODES[name as keyof typeof BLEND_MODES] + const blendMode = ownValue(BLEND_MODES, name) if (!blendMode) classError(`Unsupported blend mode class "${token}".`) assign(classes, 'blendMode', blendMode, token) continue @@ -863,15 +868,15 @@ export function parseCanvasClasses(value: string): CanvasClasses { continue } - const itemAlignment = ALIGN_ITEM_CLASSES[token] - const counterAlign = ITEM_ALIGNMENTS[itemAlignment as keyof typeof ITEM_ALIGNMENTS] + const itemAlignment = ownValue(ALIGN_ITEM_CLASSES, token) + const counterAlign = ownValue(ITEM_ALIGNMENTS, itemAlignment) if (counterAlign) { assign(classes, 'counterAlign', counterAlign, token) classes.layoutClass ??= token continue } - const justifyContent = JUSTIFY_CONTENT_CLASSES[token] - const primaryAlign = JUSTIFY_ALIGNMENTS[justifyContent as keyof typeof JUSTIFY_ALIGNMENTS] + const justifyContent = ownValue(JUSTIFY_CONTENT_CLASSES, token) + const primaryAlign = ownValue(JUSTIFY_ALIGNMENTS, justifyContent) if (primaryAlign) { assign(classes, 'primaryAlign', primaryAlign, token) classes.layoutClass ??= token @@ -1054,7 +1059,7 @@ export function parseCanvasClasses(value: string): CanvasClasses { classes.textClass ??= token continue } - const fontFamily = FONT_FAMILY_CLASSES[token as keyof typeof FONT_FAMILY_CLASSES] + const fontFamily = ownValue(FONT_FAMILY_CLASSES, token) if (fontFamily) { assign(classes, 'fontFamily', fontFamily, token) classes.portableFontFamily = token.slice(5) as PortableFontFamily @@ -1066,8 +1071,8 @@ export function parseCanvasClasses(value: string): CanvasClasses { classes.textClass ??= token continue } - const fontWeight = FONT_WEIGHT_CLASSES[token] - const fontStyle = FONT_STYLES[fontWeight as keyof typeof FONT_STYLES] + const fontWeight = ownValue(FONT_WEIGHT_CLASSES, token) + const fontStyle = ownValue(FONT_STYLES, fontWeight) if (fontStyle) { assign(classes, 'fontStyle', fontStyle, token) classes.literalFontWeight = Number(fontWeight) @@ -1106,12 +1111,15 @@ export function parseCanvasClasses(value: string): CanvasClasses { token ) if (letterSpacing) { - const named = LETTER_SPACINGS[letterSpacing[3] as keyof typeof LETTER_SPACINGS] + const named = ownValue(LETTER_SPACINGS, letterSpacing[3]) if (letterSpacing[3] && named === undefined) classError(`Unsupported class "${token}".`) const unit = letterSpacing[2] const value = named ?? - finiteNumber(letterSpacing[1]!, token, { allowNegative: true }) * (unit === 'em' ? 100 : 1) + finiteNumber(letterSpacing[1]!, token, { + allowNegative: true, + scale: unit === 'em' ? 100 : 1 + }) assign( classes, 'letterSpacing', @@ -1124,22 +1132,22 @@ export function parseCanvasClasses(value: string): CanvasClasses { classes.textClass ??= token continue } - const textAlign = TEXT_ALIGN_CLASSES[token] - const textAlignment = TEXT_ALIGNMENTS[textAlign as keyof typeof TEXT_ALIGNMENTS] + const textAlign = ownValue(TEXT_ALIGN_CLASSES, token) + const textAlignment = ownValue(TEXT_ALIGNMENTS, textAlign) if (textAlignment) { assign(classes, 'textAlign', textAlignment, token) classes.textClass ??= token continue } - const textTransform = TEXT_CASE_CLASSES[token] - const textCase = TEXT_CASES[textTransform as keyof typeof TEXT_CASES] + const textTransform = ownValue(TEXT_CASE_CLASSES, token) + const textCase = ownValue(TEXT_CASES, textTransform) if (textCase) { assign(classes, 'textCase', textCase, token) classes.textClass ??= token continue } - const decorationLine = TEXT_DECORATION_CLASSES[token] - const textDecoration = TEXT_DECORATIONS[decorationLine as keyof typeof TEXT_DECORATIONS] + const decorationLine = ownValue(TEXT_DECORATION_CLASSES, token) + const textDecoration = ownValue(TEXT_DECORATIONS, decorationLine) if (textDecoration) { assign(classes, 'textDecoration', textDecoration, token) classes.textClass ??= token diff --git a/packages/extension/mcp/tools/canvas/traversal.ts b/packages/extension/mcp/tools/canvas/traversal.ts index f03c7863..4811accd 100644 --- a/packages/extension/mcp/tools/canvas/traversal.ts +++ b/packages/extension/mcp/tools/canvas/traversal.ts @@ -6,7 +6,9 @@ function* walkNodes( while (stack.length) { const node = stack.pop()! yield node - if ('children' in node && shouldDescend(node)) stack.push(...node.children) + if ('children' in node && shouldDescend(node)) { + for (const child of node.children) stack.push(child) + } } } diff --git a/packages/extension/mcp/tools/canvas/variables.ts b/packages/extension/mcp/tools/canvas/variables.ts index 8bbd7c4f..9f190384 100644 --- a/packages/extension/mcp/tools/canvas/variables.ts +++ b/packages/extension/mcp/tools/canvas/variables.ts @@ -26,6 +26,7 @@ import { parseVariableModeKeys, readAuthoringKey } from './identity' +import { scalarValuesEqual } from './reconcile/comparison' import { isComponentPropertyOwner, walkPhysicalNodes } from './traversal' type CollectionSpec = Exclude @@ -42,11 +43,15 @@ export type CanvasVariableState = { collectionCache: Map collectionsByKey: Map collectionRemovals: VariableCollection[] + createdCollectionIds: Set + createdVariableIds: Set createdVariableKeys: Set localIndex?: Promise loadedRemovalPages: Set modeIdsByCollection: Map> modeRemovals: ModeRemoval[] + removedCollectionIds: Set + removedVariableIds: Set variableCache: Map variableRemovals: Variable[] variablesByKey: Map @@ -79,10 +84,14 @@ export function createVariableState(): CanvasVariableState { collectionCache: new Map(), collectionsByKey: new Map(), collectionRemovals: [], + createdCollectionIds: new Set(), + createdVariableIds: new Set(), createdVariableKeys: new Set(), loadedRemovalPages: new Set(), modeIdsByCollection: new Map(), modeRemovals: [], + removedCollectionIds: new Set(), + removedVariableIds: new Set(), variableCache: new Map(), variableRemovals: [], variablesByKey: new Map() @@ -176,7 +185,7 @@ export function resolvedVariable( state: CanvasVariableState ): Variable { const variable = state.variableCache.get(variableReferenceCacheKey(reference)) - if (!variable) specError('A preflighted variable could not be resolved.') + if (!variable) applyError('A preflighted variable could not be resolved.') return variable } @@ -205,7 +214,7 @@ export function resolvedCollection( const collection = state.collectionCache.get(`id:${reference}`) ?? state.collectionsByKey.get(reference) if (!collection) { - specError(`Preflighted variable collection "${reference}" could not be resolved.`) + applyError(`Preflighted variable collection "${reference}" could not be resolved.`) } return collection } @@ -248,7 +257,7 @@ export function resolvedModeId( const inherited = extended.modes.find((mode) => mode.parentModeId === parentModeId) if (inherited) return inherited.modeId } - specError(`Preflighted variable mode "${reference}" could not be resolved.`) + applyError(`Preflighted variable mode "${reference}" could not be resolved.`) } async function collectionByReference( @@ -360,6 +369,7 @@ async function selectCollection( collection = figma.variables.createVariableCollection(spec.name) } mutations.count += 1 + state.createdCollectionIds.add(collection.id) } if (collection.remote) { specError(`Variable collection "${collection.id}" is not an editable local collection.`) @@ -608,6 +618,7 @@ async function selectVariable( validateRequiredModeValues(collection, key, values, removedModeIds) variable = figma.variables.createVariable(spec.name, collection, spec.type) mutations.count += 1 + state.createdVariableIds.add(variable.id) } validateVariableCollection(variable, collection, key, state) claimAuthoringKey(variable, key, CANVAS_VARIABLE_KEY_NAME, 'Resource', mutations) @@ -706,16 +717,17 @@ function isColor(value: unknown): value is RGB | RGBA { function valuesEqual(left: VariableValue | undefined, right: VariableValue): boolean { if (left === right) return true + if (typeof left === 'number' && typeof right === 'number') return scalarValuesEqual(left, right) if (left === undefined || typeof left !== 'object' || typeof right !== 'object') return false if (isAlias(left) || isAlias(right)) { return isAlias(left) && isAlias(right) && left.id === right.id } if (!isColor(left) || !isColor(right)) return false return ( - left.r === right.r && - left.g === right.g && - left.b === right.b && - ('a' in left ? left.a : 1) === ('a' in right ? right.a : 1) + scalarValuesEqual(left.r, right.r) && + scalarValuesEqual(left.g, right.g) && + scalarValuesEqual(left.b, right.b) && + scalarValuesEqual('a' in left ? left.a : 1, 'a' in right ? right.a : 1) ) } @@ -916,7 +928,8 @@ export async function preflightVariableDeclarations( } for (const [variableKey, variable] of Object.entries(spec.variables ?? {})) { if (!variable) { - if (collection) variableForRemoval(collection, variableKey, state) + if (collection) + variables.set(variableKey, variableForRemoval(collection, variableKey, state)) continue } if (types.has(variableKey)) @@ -938,6 +951,9 @@ export async function preflightVariableDeclarations( } state.selectedCollections = collections state.selectedVariables = variables + for (const [key, spec] of Object.entries(specs)) { + if (spec === null) collections.set(key, state.collectionsByKey.get(key)) + } const changedModeCollections = new Set() for (const [key, spec] of Object.entries(specs)) { const collection = collections.get(key) @@ -1390,7 +1406,9 @@ export async function removeVariableResources( mutations.count += 1 + updateModeKeys(collection, modeId, state) } for (const variable of state.variableRemovals) { + const id = variable.id variable.remove() + state.removedVariableIds.add(id) mutations.count += 1 } const collectionsById = new Map(collections.map((collection) => [collection.id, collection])) @@ -1399,7 +1417,9 @@ export async function removeVariableResources( collectionDepth(right, collectionsById) - collectionDepth(left, collectionsById) ) for (const collection of collectionRemovals) { + const id = collection.id collection.remove() + state.removedCollectionIds.add(id) mutations.count += 1 } } diff --git a/packages/extension/mcp/tools/canvas/vector.ts b/packages/extension/mcp/tools/canvas/vector.ts index e7604c56..f462bdb5 100644 --- a/packages/extension/mcp/tools/canvas/vector.ts +++ b/packages/extension/mcp/tools/canvas/vector.ts @@ -21,6 +21,7 @@ function numberToken(token: string | undefined): number { } function formatNumber(value: number): string { + if (!Number.isFinite(value)) throw new Error('Derived vector path coordinates must be finite.') return String(Object.is(value, -0) ? 0 : value) } diff --git a/packages/extension/mcp/tools/code/assets/export.ts b/packages/extension/mcp/tools/code/assets/export.ts index 573628ac..76b0f16a 100644 --- a/packages/extension/mcp/tools/code/assets/export.ts +++ b/packages/extension/mcp/tools/code/assets/export.ts @@ -78,7 +78,14 @@ export async function exportVectorAssets( assertActive() results.forEach(({ entry, exportedAssets, id }) => { - exportedAssets.forEach((asset, hash) => assetRegistry.set(hash, asset)) + exportedAssets.forEach((asset, hash) => { + const existing = assetRegistry.get(hash) + const descriptor = { ...asset } + // Shared bytes do not establish shared variable semantics. A fixed-color + // consumer keeps the deduplicated asset fixed, regardless of export order. + if (existing && !existing.themeable) delete descriptor.themeable + assetRegistry.set(hash, descriptor) + }) if (!entry) { if (cache?.metrics) cache.metrics.vectorExportNull += 1 return diff --git a/packages/extension/mcp/tools/code/assets/media.ts b/packages/extension/mcp/tools/code/assets/media.ts index c4b57db9..c30fc981 100644 --- a/packages/extension/mcp/tools/code/assets/media.ts +++ b/packages/extension/mcp/tools/code/assets/media.ts @@ -4,7 +4,7 @@ import type { CodegenConfig } from '@/utils/codegen' import { ensureAssetUploaded } from '@/mcp/assets' import { detectImageMime, isVisibleMediaPaint } from '@/mcp/media' -import { BG_URL_RE } from '@/utils/css' +import { BG_URL_RE, parseBackgroundShorthand, splitByTopLevelComma } from '@/utils/css' import { getCssPaintLayers } from '@/utils/figma-paint' import { logger } from '@/utils/log' import { toDecimalPlace } from '@/utils/number' @@ -13,6 +13,10 @@ import type { GetCodeCacheContext } from '../cache' import { getNodeSemanticsCached } from '../cache' +type MediaFillAsset = { type: 'IMAGE' | 'VIDEO'; asset?: AssetDescriptor; tileSize?: string } + +const IMAGE_SIZE_TIMEOUT_MS = 1000 + export function hasMediaFills(node: SceneNode, ctx?: GetCodeCacheContext): boolean { if (ctx) { return getNodeSemanticsCached(node, ctx).paint.hasMediaFill @@ -27,6 +31,7 @@ export async function replaceMediaUrlsWithAssets( assetRegistry: Map, videoPreviewAssetHashes?: Set, imageBytesCache = new Map>(), + imageSizesCache: GetCodeCacheContext['imageSizes'] = new Map(), assertActive?: () => void ): Promise> { assertActive?.() @@ -35,34 +40,95 @@ export async function replaceMediaUrlsWithAssets( node, assetRegistry, imageBytesCache, + imageSizesCache, videoPreviewAssetHashes, assertActive ) assertActive?.() - const lastAsset = fills.at(-1) - if (!lastAsset) return replaceMediaUrlsWithPlaceholder(style, node, config) - + const placeholderUrl = getPlaceholderUrl(node, config) + const sharedImageAsset = getSharedImageAsset(fills) const result = { ...style } const regex = new RegExp(BG_URL_RE.source, 'gi') for (const key of ['background', 'background-image']) { if (!result[key]) continue + const mappedFills = getCssMediaFills(result[key], fills) let index = 0 result[key] = result[key].replace(regex, () => { - const asset = fills[index] ?? lastAsset + const asset = (mappedFills[index] ?? mappedFills.at(-1))?.asset ?? sharedImageAsset index++ - return `url('${asset.url}')` + return `url('${asset ? asset.url : placeholderUrl}')` }) } + if (fills.some((fill) => fill.tileSize)) correctTileSizes(result, fills) return result } -function replaceMediaUrlsWithPlaceholder( - style: Record, - node: SceneNode, - config: CodegenConfig -): Record { +function getCssMediaFills(value: string, fills: MediaFillAsset[]): MediaFillAsset[] { + const urlCount = value.match(new RegExp(BG_URL_RE.source, 'gi'))?.length ?? 0 + if (urlCount === fills.length) return fills + // Native CSS can omit videos from a mixed media stack. Keep failed image slots + // so an unavailable asset never shifts a later image into the wrong layer. + const images = fills.filter((fill) => fill.type === 'IMAGE') + if (urlCount === images.length) return images + // Repeated URLs can safely share one paint. Keep partial multi-paint stacks + // unmapped because their source paints cannot be identified. + return fills.length === 1 ? fills : [] +} + +function getSharedImageAsset(fills: MediaFillAsset[]): AssetDescriptor | undefined { + const asset = fills[0]?.asset + if (!asset?.figmaImageHash) return + // Uncertain paint geometry still permits the URL when every candidate uses the + // same native image. Do not extend this fallback to Tile dimensions or previews. + return fills.every( + (fill) => + fill.type === 'IMAGE' && + fill.asset?.figmaImageHash === asset.figmaImageHash && + fill.asset?.hash === asset.hash + ) + ? asset + : undefined +} + +function correctTileSizes(style: Record, fills: MediaFillAsset[]): void { + let tileSizes: (string | undefined)[] = [] + for (const key of ['background', 'background-image']) { + if (!style[key]) continue + tileSizes = [] + const mappedFills = getCssMediaFills(style[key], fills) + const urlCount = style[key].match(new RegExp(BG_URL_RE.source, 'gi'))?.length ?? 0 + // A partial stack does not identify which native paint supplied each URL. + if (urlCount !== mappedFills.length) continue + let index = 0 + const layers = splitByTopLevelComma(style[key]) + for (const [layerIndex, layer] of layers.entries()) { + const urls = layer.match(new RegExp(BG_URL_RE.source, 'gi')) ?? [] + const tileSize = urls.length === 1 ? mappedFills[index]?.tileSize : undefined + index += urls.length + tileSizes.push(tileSize) + if (!tileSize || key !== 'background') continue + // Mask URLs so their slashes and dimension-like path segments are never rewritten. + const layout = layer.replace(BG_URL_RE, (url) => ' '.repeat(url.length)) + const size = parseBackgroundShorthand(layout).size + const slash = /\/\s*/.exec(layout) + if (!size || !slash) continue + const start = slash.index + slash[0].length + if (!layout.startsWith(size, start)) continue + layers[layerIndex] = layer.slice(0, start) + tileSize + layer.slice(start + size.length) + } + style[key] = layers.join(', ') + } + if (style['background-size'] && tileSizes.some(Boolean)) { + const sizes = splitByTopLevelComma(style['background-size']) + style['background-size'] = tileSizes + .map((tileSize, index) => tileSize ?? sizes[index % sizes.length]) + .join(', ') + } +} + +function getPlaceholderUrl(node: SceneNode, config: CodegenConfig): string { const { scale = 1 } = config let w = 100 let h = 100 @@ -74,26 +140,17 @@ function replaceMediaUrlsWithPlaceholder( h = Math.round(toDecimalPlace(node.height) * scale) } - const placeholderUrl = `https://placehold.co/${w}x${h}` - const result = { ...style } - const regex = new RegExp(BG_URL_RE.source, 'gi') - - for (const key of ['background', 'background-image']) { - if (result[key]) { - result[key] = result[key].replace(regex, `url('${placeholderUrl}')`) - } - } - - return result + return `https://placehold.co/${w}x${h}` } async function collectMediaFillAssets( node: SceneNode, assetRegistry: Map, imageBytesCache: Map>, + imageSizesCache: GetCodeCacheContext['imageSizes'], videoPreviewAssetHashes?: Set, assertActive?: () => void -): Promise { +): Promise { if (!('fills' in node)) return [] const fills = Array.isArray(node.fills) ? (node.fills as Paint[]) : null if (!fills?.length) return [] @@ -104,7 +161,8 @@ async function collectMediaFillAssets( const videoHashes = collectMediaHashes(fills, (fill) => fill.type === 'VIDEO' ? fill.videoHash : null ) - const assets: AssetDescriptor[] = [] + const assets: MediaFillAsset[] = [] + const sizeBudget = { remainingMs: IMAGE_SIZE_TIMEOUT_MS } let preview: Promise | undefined const getPreview = () => { assertActive?.() @@ -117,6 +175,8 @@ async function collectMediaFillAssets( for (const { paint: fill } of getCssPaintLayers(fills)) { assertActive?.() if (!isVisibleMediaPaint(fill)) continue + const slot: MediaFillAsset = { type: fill.type } + assets.push(slot) if (fill.type === 'VIDEO') { if (!fill.videoHash) continue @@ -128,10 +188,10 @@ async function collectMediaFillAssets( assertActive?.() registerAsset(assetRegistry, asset) videoPreviewAssetHashes?.add(asset.hash) - if (!imageHashes.length) assets.push(asset) - } catch (error) { + slot.asset = asset + } catch { assertActive?.() - logger.warn('Failed to export video fill preview:', error) + logger.warn('Failed to export video fill preview.') } continue } @@ -146,11 +206,19 @@ async function collectMediaFillAssets( figmaImageHash: hash } assertActive?.() + // Figma CSS can round the Tile period; only native bytes share these intrinsic dimensions. + let tileSize: string | undefined + if (fill.scaleMode === 'TILE') { + tileSize = await getTileSize(fill, hash, imageSizesCache, sizeBudget) + } + assertActive?.() registerAsset(assetRegistry, asset) - assets.push(asset) - } catch (error) { + slot.asset = asset + slot.tileSize = tileSize + } catch { assertActive?.() - logger.warn(`Image bytes unavailable for hash ${hash}, falling back to node export.`, error) + // Upload errors can include capability URLs; retain the stage, not their private details. + logger.warn(`Failed to export image fill ${hash}; falling back to node export.`) try { const asset = { ...(await getPreview()), @@ -158,10 +226,10 @@ async function collectMediaFillAssets( } assertActive?.() registerAsset(assetRegistry, asset) - assets.push(asset) - } catch (fallbackError) { + slot.asset = asset + } catch { assertActive?.() - logger.warn('Failed to export node for image fill fallback:', fallbackError) + logger.warn('Failed to export node for image fill fallback.') } } } @@ -169,6 +237,47 @@ async function collectMediaFillAssets( return assets } +async function getTileSize( + fill: ImagePaint, + hash: string, + cache: GetCodeCacheContext['imageSizes'], + budget: { remainingMs: number } +): Promise { + const scale = fill.scalingFactor ?? 1 + if (!Number.isFinite(scale) || scale <= 0) return + let promise = cache.get(hash) + if (!promise) { + if (budget.remainingMs <= 0) return + const read = Promise.resolve() + .then(() => figma.getImageByHash(hash)?.getSizeAsync?.() ?? null) + .catch(() => null) + // Dimensions refine native CSS but must never block export. Cache the bounded + // result so later roots and shell retries do not wait on the same stalled read. + promise = withSizeTimeout(read, IMAGE_SIZE_TIMEOUT_MS) + cache.set(hash, promise) + } + const start = performance.now() + // A node's remaining budget limits this wait, not the shared read. Later nodes + // may still use dimensions that finish within the read's full timeout. + const size = await withSizeTimeout(promise, budget.remainingMs) + budget.remainingMs = Math.max(0, budget.remainingMs - (performance.now() - start)) + if (!size) return + const width = size.width * scale + const height = size.height * scale + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return + return `${toDecimalPlace(width)}px ${toDecimalPlace(height)}px` +} + +function withSizeTimeout(read: Promise, timeoutMs: number): Promise { + // A settled cache entry costs no wait; an exhausted budget never waits for a pending read. + if (timeoutMs <= 0) return Promise.race([read, Promise.resolve(null)]) + let timer: ReturnType + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(null), timeoutMs) + }) + return Promise.race([read, timeout]).finally(() => clearTimeout(timer)) +} + function collectMediaHashes( fills: Paint[], getHash: (fill: ImagePaint | VideoPaint) => string | null | undefined @@ -183,7 +292,14 @@ function collectMediaHashes( } function registerAsset(registry: Map, asset: AssetDescriptor): void { - registry.set(asset.hash, { ...registry.get(asset.hash), ...asset }) + const existing = registry.get(asset.hash) + const merged = { ...existing, ...asset } + for (const key of ['figmaImageHashes', 'figmaVideoHashes'] as const) { + if (existing?.[key] && asset[key]) { + merged[key] = [...new Set([...existing[key], ...asset[key]])] + } + } + registry.set(asset.hash, merged) } function loadImageBytes( diff --git a/packages/extension/mcp/tools/code/assets/paint.ts b/packages/extension/mcp/tools/code/assets/paint.ts index 2d01dd3e..a26f25d9 100644 --- a/packages/extension/mcp/tools/code/assets/paint.ts +++ b/packages/extension/mcp/tools/code/assets/paint.ts @@ -44,13 +44,13 @@ export function resolveStylePaintChannel( const styleId = ctx ? getStyleIdFromSemantics(node, kind, ctx) : getPaintStyleId(node, kind) if (!styleId) return null - if (ctx) { - const summary = getPaintStyleCached(styleId, ctx) - if (!summary?.singleVisibleSolidPaint) return null - return resolveSolidPaintChannel(summary.singleVisibleSolidPaint, ctx) - } - try { + if (ctx) { + const summary = getPaintStyleCached(styleId, ctx) + if (!summary?.singleVisibleSolidPaint) return null + return resolveSolidPaintChannel(summary.singleVisibleSolidPaint, ctx) + } + const style = figma.getStyleById(styleId) if (!style || !('paints' in style) || !Array.isArray(style.paints)) return null diff --git a/packages/extension/mcp/tools/code/assets/plan.ts b/packages/extension/mcp/tools/code/assets/plan.ts index c64f4d22..ee3f7153 100644 --- a/packages/extension/mcp/tools/code/assets/plan.ts +++ b/packages/extension/mcp/tools/code/assets/plan.ts @@ -53,16 +53,9 @@ export function planAssets( info.allNonMaskVectorLike && info.nonMaskLeafCount >= 1 && !hasOwnBoxSemantics(node, ctx) && - !hasDesignComponentHint(node) && - (hasSingleArtworkChild(children) || info.hasMask || info.nonMaskLeafCount > 1) + (children.length === 1 || info.hasMask || info.nonMaskLeafCount > 1) - if (isVectorGroup) { - vectorRoots.add(id) - children.forEach((child) => addSubtreeIds(child.id, tree, skipped)) - continue - } - - if (node.assetKind === 'vector') { + if (isVectorGroup || node.assetKind === 'vector') { vectorRoots.add(id) children.forEach((child) => addSubtreeIds(child.id, tree, skipped)) } @@ -102,7 +95,10 @@ function computeVectorInfo( continue } - let allNonMaskVectorLike = true + // A component or another structural container must survive even when all of + // its leaves are artwork. Carry that boundary through every ancestor summary. + let allNonMaskVectorLike = + (isEligibleContainer(node) || node.assetKind === 'vector') && !hasDesignComponentHint(node) let nonMaskLeafCount = 0 let hasMask = isMaskNode(node, ctx) for (const childId of node.children) { @@ -129,10 +125,6 @@ function isEligibleContainer(node: NodeSnapshot): boolean { return node.type === 'GROUP' || node.type === 'FRAME' } -function hasSingleArtworkChild(children: NodeSnapshot[]): boolean { - return children.length === 1 -} - function hasDesignComponentHint(node: NodeSnapshot): boolean { return Boolean(node.dataHint?.['data-hint-design-component']?.trim()) } diff --git a/packages/extension/mcp/tools/code/assets/vector.ts b/packages/extension/mcp/tools/code/assets/vector.ts index 8f394c1a..d8a0bab8 100644 --- a/packages/extension/mcp/tools/code/assets/vector.ts +++ b/packages/extension/mcp/tools/code/assets/vector.ts @@ -117,11 +117,11 @@ export async function exportSvgEntry( assertActive?.() assetRegistry.set(asset.hash, asset) entry.props = { ...entry.props, 'data-src': asset.url } - } catch (uploadError) { + } catch { assertActive?.() + // The transport error may contain the upload capability URL. logger.warn( - 'Failed to upload vector asset; inlining SVG fallback to preserve source of truth.', - uploadError + 'Failed to upload vector asset; inlining SVG fallback to preserve source of truth.' ) const themeableFallback = themeable && vectorMode !== 'snapshot' diff --git a/packages/extension/mcp/tools/code/budget-preflight.ts b/packages/extension/mcp/tools/code/budget-preflight.ts index 64b303a2..99477329 100644 --- a/packages/extension/mcp/tools/code/budget-preflight.ts +++ b/packages/extension/mcp/tools/code/budget-preflight.ts @@ -9,6 +9,7 @@ export function preflightGetCodeBudget( maxResultBytes: number pluginEnabled: boolean unbounded: boolean + checkpoint?: () => void } ): { kind: 'full' } | { kind: 'shell'; scannedDescendants: number } { const root = tree.nodes.get(rootId) @@ -25,6 +26,7 @@ export function preflightGetCodeBudget( let textBytes = 0 let scannedDescendants = 0 for (const id of tree.order) { + options.checkpoint?.() if (id === rootId) continue scannedDescendants += 1 const node = tree.nodes.get(id)?.node diff --git a/packages/extension/mcp/tools/code/cache/context.ts b/packages/extension/mcp/tools/code/cache/context.ts index ff60ac53..2d47b4b5 100644 --- a/packages/extension/mcp/tools/code/cache/context.ts +++ b/packages/extension/mcp/tools/code/cache/context.ts @@ -34,46 +34,57 @@ export function createGetCodeCacheContext( TextNode, Map> >() - const ctx = { + const ctx: GetCodeCacheContext = { rawCss: new Map(), + pluginResponses: new Map(), imageBytes: new Map(), + imageSizes: new Map(), variables: variableCache, styles: new Map(), paintStyles: new Map(), + childOverflow: new WeakMap(), nodeSemantics: new Map(), vectorAnalysis: new Map(), - metrics: options?.metrics ? createDefaultMetrics() : undefined - } as GetCodeCacheContext - - ctx.readers = { - getStyleById: (id: string) => getStyleByIdFromContext(id, ctx), - getVariableById: (id: string) => getVariableByIdFromContext(id, ctx), - getRangeBoundVariable: ( - node: TextNode, - start: number, - end: number, - field: VariableBindableTextField - ) => { - let bindings = textRangeBindings.get(node) - if (!bindings) { - bindings = new Map() - textRangeBindings.set(node, bindings) + metrics: options?.metrics ? createDefaultMetrics() : undefined, + readers: { + getStyleById: (id) => getStyleByIdFromContext(id, ctx), + getVariableById: (id) => getVariableByIdFromContext(id, ctx), + getRangeBoundVariable: (node, start, end, field) => { + let bindings = textRangeBindings.get(node) + if (!bindings) { + bindings = new Map() + textRangeBindings.set(node, bindings) + } + const key = `${start}:${end}:${field}` + if (bindings.has(key)) { + incrementMetric(ctx.metrics, 'textRangeHits') + return bindings.get(key)! + } + incrementMetric(ctx.metrics, 'textRangeMisses') + const binding = node.getRangeBoundVariable(start, end, field) + bindings.set(key, binding) + return binding } - const key = `${start}:${end}:${field}` - if (bindings.has(key)) { - incrementMetric(ctx.metrics, 'textRangeHits') - return bindings.get(key)! - } - incrementMetric(ctx.metrics, 'textRangeMisses') - const binding = node.getRangeBoundVariable(start, end, field) - bindings.set(key, binding) - return binding } } return ctx } +export function getRawCssCached( + node: SceneNode, + ctx: GetCodeCacheContext +): Promise> { + ctx.signal?.throwIfAborted() + ctx.checkpoint?.() + let css = ctx.rawCss.get(node.id) + if (!css) { + css = Promise.resolve().then(() => node.getCSSAsync()) + ctx.rawCss.set(node.id, css) + } + return css +} + export function getVariableByIdFromContext(id: string, ctx: GetCodeCacheContext): Variable | null { if (ctx.variables.has(id)) { incrementMetric(ctx.metrics, 'variableHits') diff --git a/packages/extension/mcp/tools/code/cache/node-semantics.ts b/packages/extension/mcp/tools/code/cache/node-semantics.ts index 0607b75e..e3f1760b 100644 --- a/packages/extension/mcp/tools/code/cache/node-semantics.ts +++ b/packages/extension/mcp/tools/code/cache/node-semantics.ts @@ -6,6 +6,19 @@ import type { GetCodeCacheContext, NodeSemanticSnapshot, PaintArrayState } from import { getInferredAutoLayout, hasClippedOverflow } from '../layout-parent' import { computeChildOverflow } from '../styles/overflow' +export function getChildOverflowCached( + node: SceneNode, + ctx: GetCodeCacheContext, + checkpoint = ctx.checkpoint +): { x: boolean; y: boolean } { + checkpoint?.() + const cached = ctx.childOverflow.get(node) + if (cached) return cached + const overflow = computeChildOverflow(node, checkpoint) + ctx.childOverflow.set(node, overflow) + return overflow +} + export function getNodeSemanticsCached( node: SceneNode, ctx: GetCodeCacheContext @@ -23,7 +36,7 @@ export function getNodeSemanticsCached( const strokesState = readPaintArrayState(node, 'strokes') const childOverflow = 'clipsContent' in node && node.clipsContent - ? computeChildOverflow(node, ctx.checkpoint) + ? getChildOverflowCached(node, ctx) : { x: false, y: false } const clippedOverflow = hasClippedOverflow(node, childOverflow) const snapshot: NodeSemanticSnapshot = { diff --git a/packages/extension/mcp/tools/code/cache/types.ts b/packages/extension/mcp/tools/code/cache/types.ts index 37180cfa..74965a36 100644 --- a/packages/extension/mcp/tools/code/cache/types.ts +++ b/packages/extension/mcp/tools/code/cache/types.ts @@ -1,3 +1,4 @@ +import type { ResponsePayload } from '@/types/codegen' import type { FigmaLookupReaders } from '@/utils/figma-style/types' import type { TokenReadContext } from '../../token/context' @@ -92,13 +93,16 @@ export type GetCodeCacheContext = { tokens?: TokenReadContext pluginName?: string rawCss: Map>> + pluginResponses: Map imageBytes: Map> + imageSizes: Map> signal?: AbortSignal checkpoint?: () => void readers: FigmaLookupReaders variables: Map styles: Map paintStyles: Map + childOverflow: WeakMap nodeSemantics: Map vectorAnalysis: Map metrics?: CacheMetrics diff --git a/packages/extension/mcp/tools/code/collect.ts b/packages/extension/mcp/tools/code/collect.ts index a1e5af78..a27ed9e6 100644 --- a/packages/extension/mcp/tools/code/collect.ts +++ b/packages/extension/mcp/tools/code/collect.ts @@ -12,7 +12,7 @@ import type { GetCodeCacheContext } from './cache' import type { CollectedData, NodeSnapshot, VisibleTree } from './model' import { hasMediaFills, replaceMediaUrlsWithAssets } from './assets' -import { getNodeSemanticsCached, getPaintsFromState } from './cache' +import { getNodeSemanticsCached, getPaintsFromState, getRawCssCached } from './cache' import { getInferredAutoLayout, getLayoutParent, @@ -55,13 +55,8 @@ export async function collectNodeData( } try { - let rawCss = cache.rawCss.get(id) - if (!rawCss) { - rawCss = node.getCSSAsync() - cache.rawCss.set(id, rawCss) - } // Root-specific preprocessing must never mutate the shared raw read. - let css = { ...(await rawCss) } + let css = { ...(await getRawCssCached(node, cache)) } assertActive() css = await resolveStylesFromNodeData( css, @@ -112,6 +107,7 @@ export async function collectNodeData( assetRegistry, nodeVideoPreviewAssetHashes, cache.imageBytes, + cache.imageSizes, assertActive ) assertActive() diff --git a/packages/extension/mcp/tools/code/deadline.ts b/packages/extension/mcp/tools/code/deadline.ts index fd18bce8..b127540d 100644 --- a/packages/extension/mcp/tools/code/deadline.ts +++ b/packages/extension/mcp/tools/code/deadline.ts @@ -32,10 +32,8 @@ export async function withCodeDeadline( }) try { checkpoint() - const result = await Promise.race([run(controller.signal, checkpoint), timeout]) - // Synchronous work and its microtasks can finish before an overdue timer runs. - checkpoint() - return result + // Preserve a completed result if synchronous work finishes before an overdue timer runs. + return await Promise.race([run(controller.signal, checkpoint), timeout]) } catch (error) { controller.abort(error) throw error diff --git a/packages/extension/mcp/tools/code/index.ts b/packages/extension/mcp/tools/code/index.ts index bcae7ddb..a021e647 100644 --- a/packages/extension/mcp/tools/code/index.ts +++ b/packages/extension/mcp/tools/code/index.ts @@ -51,12 +51,13 @@ import { resolvePluginComponents } from './render/plugin' import { buildLayoutStyles, prepareStyles } from './styles' import { createTokenConsumerResolver, + createTokenNameRewriter, createStyleVarResolver, processTokens, - resolveStyleMap, - rewriteTokenNamesInCode + resolveStyleMap } from './tokens' import { createTokenDiagnostics } from './tokens/diagnostics' +import { mapTokenValues } from './tokens/markup' import { addVariableModeHints } from './tokens/modes' import { addSubtreeIds, buildVisibleTree } from './tree' @@ -126,7 +127,6 @@ type PipelineInput = { rootTag?: string lang?: CodeLanguage variableIds: Set - usedCandidateIds: Set variableCache: Map tokenContext: TokenReadContext resolveTokens?: boolean @@ -148,6 +148,14 @@ export type GetCodeRuntimeOptions = { type RenderStep = 'render' | 'stringify' | 'transform' +type RenderedMarkup = { + component: DevComponent + serialize: (component: DevComponent) => string + lang: CodeLanguage + fontStyleVariableIds: Set + textStyleInputs: Array<{ style: Record; node?: SceneNode }> +} + export async function handleGetCode( nodes: SceneNode[], preferredLang?: CodeLanguage, @@ -172,9 +180,11 @@ export async function handleGetCode( const t = now() const fullTimeoutMs = (MCP_GET_CODE_TIMEOUT_MS * 2) / 3 const startedAt = performance.now() + const cache = runtimeOptions.cache ?? createGetCodeCacheContext(undefined, { metrics: measure }) const tree = buildVisibleTree( nodes, - runtimeOptions.unbounded ? undefined : createCodeCheckpoint(fullTimeoutMs) + runtimeOptions.unbounded ? undefined : createCodeCheckpoint(fullTimeoutMs), + cache ) stamp('tree', t) const rootId = tree.rootIds[0] @@ -186,7 +196,6 @@ export async function handleGetCode( logger.warn(`[get_code] Tree depth capped at ${depth}; output may be incomplete.`) } - const cache = runtimeOptions.cache ?? createGetCodeCacheContext(undefined, { metrics: measure }) const tokenContext = (cache.tokens ??= createTokenReadContext( currentCodegenConfig(), activePlugin.value?.code, @@ -285,7 +294,8 @@ async function generateCode({ : preflightGetCodeBudget(tree, rootId, { maxResultBytes, pluginEnabled: !!pluginCode, - unbounded: !!runtimeOptions.unbounded + unbounded: !!runtimeOptions.unbounded, + checkpoint }) const earlyShell = forceShell || budgetPreflight?.kind === 'shell' checkpoint?.() @@ -293,9 +303,8 @@ async function generateCode({ let t = now() const variableCache = cache.variables const nodeVariableIds = new Map>() - const mappings = collectCandidateVariableIds( + const variableIds = collectCandidateVariableIds( earlyShell ? nodes : [...tree.nodes.values()].map((snapshot) => snapshot.node), - variableCache, cache.readers, { checkpoint, @@ -307,10 +316,9 @@ async function generateCode({ stamp('vars', t) checkpoint?.() - const { pluginComponents, pluginSkipped } = - pluginCode && !earlyShell - ? await collectPluginOutput(tree, config, pluginCode, preferredLang, cache.signal) - : { pluginComponents: undefined, pluginSkipped: new Set() } + const { pluginComponents, pluginSkipped } = pluginCode + ? await collectPluginOutput(tree, config, pluginCode, preferredLang, cache, earlyShell) + : { pluginComponents: undefined, pluginSkipped: new Set() } cache.signal?.throwIfAborted() checkpoint?.() @@ -345,11 +353,9 @@ async function generateCode({ ensureEarlyShellRootPositioning(rootId, tree, collected.styles) } - const { usedCandidateIds, layout: layoutStyles } = prepareStyles({ + const { layout: layoutStyles } = prepareStyles({ tree, styles: collected.styles, - mappings, - variableCache, vectorRoots: plan.vectorRoots, cache, trace: traceInfo @@ -393,8 +399,7 @@ async function generateCode({ vectorRoots: plan.vectorRoots, rootTag, lang: preferredLang, - variableIds: mappings.variableIds, - usedCandidateIds, + variableIds, variableCache, tokenContext, resolveTokens, @@ -524,9 +529,10 @@ async function tryRenderPipeline(input: PipelineInput): Promise { async function finalizeRenderedOutput( input: PipelineInput, - rendered: { code: string; lang: CodeLanguage; fontStyleVariableIds: Set }, + rendered: RenderedMarkup, diagnostics: ReturnType ): Promise { const collected = getTokenCollectedContext(input) @@ -573,9 +579,9 @@ async function finalizeRenderedOutput( resolveNodeIds, rewriteMap } = await processTokens({ - code: rendered.code, + component: rendered.component, + serialize: rendered.serialize, variableIds: input.variableIds, - usedCandidateIds: input.usedCandidateIds, fontStyleVariableIds: rendered.fontStyleVariableIds, variableCache: input.variableCache, styles: collected.styles, @@ -606,6 +612,7 @@ async function finalizeRenderedOutput( ...input, collected, lang: rendered.lang, + textStyleInputs: rendered.textStyleInputs, sourceIndex, resolveNodeIds, tokenMatcher, @@ -620,7 +627,12 @@ async function finalizeRenderedOutput( report: diagnostics.report }) if (resolved) { - outputCode = rewriteTokenNamesInCode(stripFallback(resolved.code), rewriteMap ?? new Map()) + const rewriteNames = createTokenNameRewriter(rewriteMap ?? new Map()) + outputCode = resolved.serialize( + mapTokenValues(resolved.component, (value, syntax) => + rewriteNames(stripFallback(value, syntax), syntax) + ) + ) } } if (stamp && now) { @@ -662,6 +674,7 @@ async function rerenderResolvedOutput({ resolveNodeIds, tokenMatcher, styleResolver, + textStyleInputs, report, ...input }: { @@ -669,10 +682,11 @@ async function rerenderResolvedOutput({ resolveNodeIds?: Set tokenMatcher?: (value: string) => boolean styleResolver?: StyleVarResolver + textStyleInputs: RenderedMarkup['textStyleInputs'] report: ReturnType['report'] } & PipelineInput & { lang: CodeLanguage - }): Promise<{ code: string } | null> { + }): Promise { const resolveStyleVars = styleResolver ?? createStyleVarResolver( @@ -689,7 +703,7 @@ async function rerenderResolvedOutput({ if ( !resolvedEntriesChanged(input.collected.styles, resolvedStyles) && !resolvedEntriesChanged(input.ctx.svgs, resolvedSvgs) && - !input.collected.textSegments.size + !textStyleInputs.some(({ style, node }) => resolveStyleVars(style, node) !== style) ) { return null } @@ -753,15 +767,16 @@ async function collectPluginOutput( tree: VisibleTree, config: CodegenConfig, pluginCode: string, - preferredLang?: CodeLanguage, - signal?: AbortSignal + preferredLang: CodeLanguage | undefined, + cache: GetCodeCacheContext, + rootOnly: boolean ): Promise<{ pluginComponents: Map pluginSkipped: Set }> { const pluginComponents = new Map() const instances: Array<{ id: string; node: InstanceNode }> = [] - for (const id of tree.order) { + for (const id of rootOnly ? tree.rootIds : tree.order) { const snapshot = tree.nodes.get(id) if (snapshot?.node.type === 'INSTANCE') { instances.push({ id: snapshot.id, node: snapshot.node }) @@ -773,7 +788,7 @@ async function collectPluginOutput( config, pluginCode, preferredLang, - signal + cache ) instances.forEach(({ id }, resultIndex) => { pluginComponents.set(id, components[resultIndex] ?? null) @@ -804,34 +819,6 @@ function buildNodeMap(nodes: Map): Map string -}): Promise<{ code: string; lang: CodeLanguage; fontStyleVariableIds: Set } | null> { +}): Promise { const clock = trace?.now let t = clock ? clock() : 0 checkpoint?.() const fontStyleVariableIds = new Set() - const renderContext = { ...ctx, fontStyleVariableIds } + const textStyleInputs: RenderedMarkup['textStyleInputs'] = [] + const renderContext: RenderContext = { + ...ctx, + fontStyleVariableIds, + // Capture the actual run styles while rendering, including inheritance pruning + // and normal-weight resets. Token checks can reuse them without native reads. + resolveStyleVars: + ctx.resolveStyleVars ?? + ((style, node) => { + textStyleInputs.push({ style, node }) + return style + }) + } const rendered = await renderTreeForMode(mode, rootId, tree, renderContext) checkpoint?.() if (!rendered) { @@ -859,22 +858,24 @@ async function renderMarkup({ const resolvedLang = lang ?? renderContext.detectedLang ?? 'jsx' t = clock ? clock() : 0 - const markup = stringifyComponentTree(rendered, rootTag, rootId, resolvedLang) - stampRenderPhase(trace, mode, 'stringify', t) - - t = clock ? clock() : 0 - const output = transform ? transform(markup) : markup + const component: DevComponent = + typeof rendered === 'string' + ? { name: rootTag || 'div', props: { 'data-hint-id': rootId }, children: [rendered] } + : rendered + const transformed = transform ? mapTokenValues(component, transform) : component stampRenderPhase(trace, mode, 'transform', t) - return { code: output, lang: resolvedLang, fontStyleVariableIds } -} - -function createStringifyOptions(lang: CodeLanguage): { - lang: CodeLanguage - isInline: (tag: string) => boolean -} { + const serialize = (value: DevComponent) => { + const start = clock ? clock() : 0 + const code = stringifyComponent(value, { lang: resolvedLang, isInline: isCompactTag }) + stampRenderPhase(trace, mode, 'stringify', start) + return code + } return { - lang, - isInline: isCompactTag + component: transformed, + serialize, + lang: resolvedLang, + fontStyleVariableIds, + textStyleInputs } } diff --git a/packages/extension/mcp/tools/code/render/index.ts b/packages/extension/mcp/tools/code/render/index.ts index 4637d0a0..c15a0125 100644 --- a/packages/extension/mcp/tools/code/render/index.ts +++ b/packages/extension/mcp/tools/code/render/index.ts @@ -10,8 +10,16 @@ import type { PluginComponent } from './plugin' import type { CodeLanguage, RenderContext } from './types' import { renderTextSegments } from '../text' +import { nativeSvg } from '../tokens/markup' import { renderPluginComponent } from './plugin' -import { classProps, classProp, filterGridProps, mergeClass } from './props' +import { + classProps, + classProp, + filterGridProps, + mergeClass, + toMarkupAttributes, + type RenderProps +} from './props' export type { RenderContext, CodeLanguage } from './types' @@ -99,15 +107,15 @@ async function renderNode( includeDataHint: false }) - const svgProps: Record = { ...(svgEntry.props ?? {}) } + const svgProps: RenderProps = { ...(svgEntry.props ?? {}) } const legacyAssetUrl = svgProps['data-asset-url'] if (!svgProps['data-src'] && legacyAssetUrl) { svgProps['data-src'] = legacyAssetUrl delete svgProps['data-asset-url'] } if (svgEntry.raw) { - const mergedProps = Object.keys(props).length ? props : undefined - const content = raw(svgEntry.raw, mergedProps as Record | undefined) + const mergedProps = Object.keys(props).length ? toMarkupAttributes(props) : undefined + const content = nativeSvg(svgEntry.raw, mergedProps) return wrapperProps ? { name: 'div', props: wrapperProps, children: [content] } : content } const className = props[classAttr] @@ -159,6 +167,7 @@ async function renderNode( node.type === 'TEXT' ? await renderTextSegments(node, classAttr, ctx, { inheritedTextStyle, + nodeTextStyle: cleanedTextStyle, segments: preSegments }) : undefined @@ -231,7 +240,7 @@ async function renderNode( } if (pluginComponent.code) { - return raw(pluginComponent.code, pluginProps as Record) + return raw(pluginComponent.code, pluginProps ? toMarkupAttributes(pluginProps) : undefined) } return null } @@ -244,8 +253,12 @@ async function renderNode( const single = segments[0] if (single && typeof single !== 'string') return mergeDevComponentProps(single, props) } + // A multiline TEXT tag is usually p, but mixed text blocks can contain lists. + const hasBlockChildren = segments.some( + (segment) => typeof segment !== 'string' && ['p', 'ol', 'ul'].includes(segment.name) + ) return { - name: snapshot.tag || 'span', + name: hasBlockChildren ? 'div' : snapshot.tag || 'span', props, children: segments.filter(Boolean) } @@ -350,7 +363,7 @@ function buildFallbackSnapshotProps( ctx: RenderContext, style: Record, langHint: CodeLanguage | undefined -): Record { +): RenderProps { const classAttr = classProp(langHint) const dataHint = snapshot.dataHint ? { ...snapshot.dataHint, 'data-hint-id': snapshot.id } @@ -441,19 +454,16 @@ function diffTextStyles( return { appliedTextStyle, nextTextStyle } } -function mergeDevComponentProps( - component: DevComponent, - props?: Record -): DevComponent { +function mergeDevComponentProps(component: DevComponent, props?: RenderProps): DevComponent { if (!props || !Object.keys(props).length) return component const next = { ...component } - const existing = (next.props ?? {}) as Record - const merged: Record = { ...existing } + const existing = next.props ?? {} + const merged = { ...existing } Object.entries(props).forEach(([key, value]) => { if (!value) return if (key === 'class' || key === 'className') { - if (existing[key]) { + if (typeof existing[key] === 'string' && typeof value === 'string') { merged[key] = mergeClass(existing[key], value) } else { merged[key] = value @@ -521,7 +531,7 @@ function hasVisibleTextFill( return true } -function ensureSvgSize(svgProps: Record, snapshot: NodeSnapshot): void { +function ensureSvgSize(svgProps: RenderProps, snapshot: NodeSnapshot): void { const hasWidth = typeof svgProps.width === 'string' && svgProps.width.trim().length > 0 const hasHeight = typeof svgProps.height === 'string' && svgProps.height.trim().length > 0 if (hasWidth && hasHeight) return diff --git a/packages/extension/mcp/tools/code/render/plugin.ts b/packages/extension/mcp/tools/code/render/plugin.ts index 8dc04f58..f3026d7b 100644 --- a/packages/extension/mcp/tools/code/render/plugin.ts +++ b/packages/extension/mcp/tools/code/render/plugin.ts @@ -5,6 +5,8 @@ import { generateCodeBlocksForNode, generateCodeBlocksForNodes } from '@/utils/c import type { CodeLanguage, RenderContext } from './types' +import { getRawCssCached, type GetCodeCacheContext } from '../cache' + export type PluginComponent = { component?: DevComponent; code?: string; lang?: CodeLanguage } export async function renderPluginComponent( @@ -32,13 +34,22 @@ export async function resolvePluginComponents( config: RenderContext['config'], pluginCode: string, preferredLang?: CodeLanguage, - signal?: AbortSignal + cache?: GetCodeCacheContext ): Promise> { - const responses = await generateCodeBlocksForNodes(nodes, config, pluginCode, { - returnDevComponent: true, - ...(signal ? { signal } : {}) - }) - return responses.map((response) => resolvePluginComponentResponse(response, preferredLang)) + cache?.signal?.throwIfAborted() + cache?.checkpoint?.() + const responses = cache?.pluginResponses ?? new Map() + const missing = nodes.filter((node) => !responses.has(node.id)) + if (missing.length) { + const generated = await generateCodeBlocksForNodes(missing, config, pluginCode, { + returnDevComponent: true, + ...(cache ? { readCss: (node) => getRawCssCached(node, cache), signal: cache.signal } : {}) + }) + cache?.signal?.throwIfAborted() + cache?.checkpoint?.() + missing.forEach((node, index) => responses.set(node.id, generated[index]!)) + } + return nodes.map((node) => resolvePluginComponentResponse(responses.get(node.id)!, preferredLang)) } function resolvePluginComponentResponse( diff --git a/packages/extension/mcp/tools/code/render/props.ts b/packages/extension/mcp/tools/code/render/props.ts index bd57b973..bf41030c 100644 --- a/packages/extension/mcp/tools/code/render/props.ts +++ b/packages/extension/mcp/tools/code/render/props.ts @@ -1,11 +1,15 @@ import type { CodegenConfig } from '@/utils/codegen' +import { normalizeStyleValues } from '@/utils/css' +import { camelToKebab, kebabToCamel } from '@/utils/string' import { joinClassNames } from '@/utils/tailwind' import type { DataHint } from '../model' import { styleToClassNames } from '../styles' +export type RenderProps = Record> + export function classProps( style: Record, config: CodegenConfig, @@ -13,8 +17,27 @@ export function classProps( dataHint: DataHint | undefined, options: { isFallback?: boolean; includeDataHint?: boolean } = {} ) { - const classNames = styleToClassNames(style, config) - const props: Record = {} + let classStyle = style + const props: RenderProps = {} + if (style.background?.trim()) { + // Keep quoted URLs out of Tailwind's source scanner and keep the shorthand before + // its longhand overrides without depending on generated utility order. + classStyle = { ...style } + const background: Record = { background: style.background } + delete classStyle.background + for (const [key, value] of Object.entries(style)) { + if (!key.startsWith('background-')) continue + if (value.trim()) background[key] = value + delete classStyle[key] + } + props.style = Object.fromEntries( + Object.entries(normalizeStyleValues(background, config)).map(([key, value]) => [ + kebabToCamel(key), + value + ]) + ) + } + const classNames = styleToClassNames(classStyle, config) if (classNames.length) props[defaultClassProp] = joinClassNames(classNames) @@ -41,6 +64,19 @@ export function classProps( return { classNames, props } } +export function toMarkupAttributes(props: RenderProps): Record { + return Object.fromEntries( + Object.entries(props).map(([key, value]) => [ + key, + typeof value === 'string' + ? value + : Object.entries(value) + .map(([property, entry]) => `${camelToKebab(property)}: ${entry}`) + .join('; ') + ]) + ) +} + export function filterGridProps(style: Record): Record { const res: Record = {} for (const [k, v] of Object.entries(style)) { diff --git a/packages/extension/mcp/tools/code/sanitize/auto-layout-canonical.ts b/packages/extension/mcp/tools/code/sanitize/auto-layout-canonical.ts index 108c354c..95e0334c 100644 --- a/packages/extension/mcp/tools/code/sanitize/auto-layout-canonical.ts +++ b/packages/extension/mcp/tools/code/sanitize/auto-layout-canonical.ts @@ -5,7 +5,6 @@ import { getNodeSemanticsCached } from '../cache' type StyleMap = Map> type Axis = 'x' | 'y' -type AxisState = 'fixed' | 'hug' | 'fill' | 'absolute' | 'unknown' const FLEX_DISPLAYS = new Set(['flex', 'inline-flex']) const EPSILON = 0.5 @@ -47,43 +46,14 @@ function canonicalizeAxis( return } - const state = resolveAxisState(axis, snapshot, style, child, childStyle, ctx) - if (state === 'fixed') { - canonicalizeFixedAxis(axis, snapshot, style, child, childStyle, ctx) + const ownSizing = getSizingMode(snapshot.node, axis, ctx) + if ( + ownSizing === 'HUG' || + ownSizing === 'FILL' || + getSizingMode(child.node, axis, ctx) === 'FILL' + ) { return } - if (state === 'hug') { - canonicalizeHugAxis(axis, snapshot, style, child) - } -} - -function resolveAxisState( - axis: Axis, - snapshot: NodeSnapshot, - style: Record, - child: NodeSnapshot, - childStyle: Record, - ctx?: GetCodeCacheContext -): AxisState { - if (isAbsolute(childStyle)) return 'absolute' - if (isFillSizing(child.node, axis, ctx)) return 'fill' - - const ownSizing = getSizingMode(snapshot.node, axis, ctx) - if (ownSizing === 'FILL') return 'fill' - if (ownSizing === 'HUG') return 'hug' - if (ownSizing === 'FIXED') return 'fixed' - - return hasExplicitAxisSize(style, axis) ? 'fixed' : 'unknown' -} - -function canonicalizeFixedAxis( - axis: Axis, - snapshot: NodeSnapshot, - style: Record, - child: NodeSnapshot, - childStyle: Record, - ctx?: GetCodeCacheContext -): void { if ( !hasExplicitAxisSize(style, axis) || !isCenteredOnAxis(axis, snapshot.node, style, childStyle, ctx) @@ -109,27 +79,6 @@ function canonicalizeFixedAxis( delete style[endKey] } -function canonicalizeHugAxis( - axis: Axis, - snapshot: NodeSnapshot, - style: Record, - child: NodeSnapshot -): void { - const sizeKey = getSizeKey(axis) - const explicitSize = style[sizeKey] - if (!explicitSize || parsePxLiteral(explicitSize) == null) return - - const [startKey, endKey] = getPaddingKeys(axis) - const start = parsePxOrZero(style[startKey]) - const end = parsePxOrZero(style[endKey]) - if (start == null || end == null) return - - const expected = getAxisSize(child.bounds, axis) + start + end - if (!approxEqual(getAxisSize(snapshot.bounds, axis), expected)) return - - delete style[sizeKey] -} - function isAutoLayoutContainer(style: Record): boolean { return FLEX_DISPLAYS.has((style.display ?? '').trim().toLowerCase()) } @@ -180,10 +129,6 @@ function getSizingMode( return undefined } -function isFillSizing(node: SceneNode, axis: Axis, ctx?: GetCodeCacheContext): boolean { - return getSizingMode(node, axis, ctx) === 'FILL' -} - function hasExplicitAxisSize(style: Record, axis: Axis): boolean { return parsePxLiteral(style[getSizeKey(axis)]) != null } diff --git a/packages/extension/mcp/tools/code/sanitize/negative-gap.ts b/packages/extension/mcp/tools/code/sanitize/negative-gap.ts index a5772fb3..2efa2db2 100644 --- a/packages/extension/mcp/tools/code/sanitize/negative-gap.ts +++ b/packages/extension/mcp/tools/code/sanitize/negative-gap.ts @@ -1,50 +1,37 @@ +import { expandShorthands } from '@/utils/css' + import type { VisibleTree } from '../model' type StyleMap = Map> export function patchNegativeGapStyles(tree: VisibleTree, styles: StyleMap): void { - tree.rootIds.forEach((rootId) => patchNode(rootId, tree, styles)) -} - -function patchNode(nodeId: string, tree: VisibleTree, styles: StyleMap): void { - const node = tree.nodes.get(nodeId) - if (!node) return - - const style = styles.get(node.id) - const children = node.children ?? [] - if (!style) { - children.forEach((childId) => patchNode(childId, tree, styles)) - return - } - - const [row, col] = readGap(style) - const negRow = row != null && row < 0 - const negCol = col != null && col < 0 - if (!negRow && !negCol) { - children.forEach((childId) => patchNode(childId, tree, styles)) - return - } - - const overlapRow = negRow ? -row! : 0 - const overlapCol = negCol ? -col! : 0 - const shouldCompensate = children.length > 1 + for (const nodeId of tree.order) { + const node = tree.nodes.get(nodeId) + const style = styles.get(nodeId) + if (!node || !style) continue + + // Expand only the gap, preserving explicit longhands and unknown shorthand arity. + const gap = style.gap ? expandShorthands({ gap: style.gap }) : undefined + const row = parsePxLiteral(style['row-gap'] ?? gap?.['row-gap']) + const col = parsePxLiteral(style['column-gap'] ?? gap?.['column-gap']) + const overlapRow = row != null && row < 0 ? -row : 0 + const overlapCol = col != null && col < 0 ? -col : 0 + if (!overlapRow && !overlapCol) continue + + const nextStyle = { ...gap, ...style } + if (!gap?.gap) delete nextStyle.gap + if (overlapRow) nextStyle['row-gap'] = '0px' + if (overlapCol) nextStyle['column-gap'] = '0px' + styles.set(nodeId, nextStyle) + + const children = (node.children ?? []).filter( + (id) => !/^(absolute|fixed)$/i.test(styles.get(id)?.position?.trim() ?? '') + ) + if (children.length <= 1) continue - const nextStyle = { ...style } - delete nextStyle['gap'] - delete nextStyle['row-gap'] - delete nextStyle['column-gap'] - - if (row != null) nextStyle['row-gap'] = `${Math.max(row, 0)}px` - if (col != null) nextStyle['column-gap'] = `${Math.max(col, 0)}px` - - if (shouldCompensate) { if (overlapRow) nextStyle['padding-top'] = addPxOrCalc(nextStyle['padding-top'], overlapRow) if (overlapCol) nextStyle['padding-left'] = addPxOrCalc(nextStyle['padding-left'], overlapCol) - } - styles.set(node.id, nextStyle) - - for (const childId of children) { - if (shouldCompensate) { + for (const childId of children) { const childStyle = styles.get(childId) ?? {} const nextChildStyle = { ...childStyle } if (!nextChildStyle['box-sizing']) nextChildStyle['box-sizing'] = 'border-box' @@ -56,33 +43,15 @@ function patchNode(nodeId: string, tree: VisibleTree, styles: StyleMap): void { } styles.set(childId, nextChildStyle) } - patchNode(childId, tree, styles) } } -function readGap(style: Record): [number | undefined, number | undefined] { - const fromGap = parseGap(style['gap']) - return [ - parsePxLiteral(style['row-gap']) ?? fromGap[0], - parsePxLiteral(style['column-gap']) ?? fromGap[1] - ] -} - function parsePxLiteral(value?: string): number | null { if (!value) return null - const match = value.trim().match(/^(-?\d+(?:\.\d+)?)px$/i) + const match = value.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(px)?$/i) if (!match) return null const n = Number(match[1]) - return Number.isFinite(n) ? n : null -} - -function parseGap(gap?: string): [number | undefined, number | undefined] { - if (!gap) return [undefined, undefined] - const [r, c] = gap.trim().split(/\s+/).filter(Boolean) - if (!r) return [undefined, undefined] - const row = parsePxLiteral(r) - const col = parsePxLiteral(c ?? r) - return [row ?? undefined, col ?? undefined] + return Number.isFinite(n) && (match[2] || n === 0) ? n : null } function addPxOrCalc(existing: string | undefined, deltaPx: number): string { diff --git a/packages/extension/mcp/tools/code/sanitize/relative-parent.ts b/packages/extension/mcp/tools/code/sanitize/relative-parent.ts index 8fecbb50..dd084b2c 100644 --- a/packages/extension/mcp/tools/code/sanitize/relative-parent.ts +++ b/packages/extension/mcp/tools/code/sanitize/relative-parent.ts @@ -4,35 +4,15 @@ import { getLayoutParent } from '../layout-parent' type StyleMap = Map> -export function ensureRelativeForAbsoluteChildren( - tree: VisibleTree, - styles: StyleMap, - svgRoots?: Set -): void { - const layoutParents = new Set() - tree.rootIds.forEach((rootId) => collect(layoutParents, rootId, tree, styles, svgRoots)) - layoutParents.forEach((id) => { - const style = styles.get(id) ?? {} - if (style.position) return - styles.set(id, { ...style, position: 'relative' }) - }) -} - -function collect( - layoutParents: Set, - nodeId: string, - tree: VisibleTree, - styles: StyleMap, - svgRoots?: Set -): void { - const node = tree.nodes.get(nodeId) - if (!node) return - const style = styles.get(node.id) - if (style?.position?.toLowerCase() === 'absolute') { +export function ensureRelativeForAbsoluteChildren(tree: VisibleTree, styles: StyleMap): void { + for (const id of tree.order) { + const node = tree.nodes.get(id) + if (!node || styles.get(id)?.position?.trim().toLowerCase() !== 'absolute') continue const layoutParent = getLayoutParent(tree, node) - if (layoutParent) layoutParents.add(layoutParent.id) + if (!layoutParent) continue + const style = styles.get(layoutParent.id) ?? {} + const position = style.position?.trim().toLowerCase() + if (position && position !== 'static') continue + styles.set(layoutParent.id, { ...style, position: 'relative' }) } - - const children = node.children ?? [] - children.forEach((childId) => collect(layoutParents, childId, tree, styles, svgRoots)) } diff --git a/packages/extension/mcp/tools/code/styles/background.ts b/packages/extension/mcp/tools/code/styles/background.ts index bb2ae6d5..e46be574 100644 --- a/packages/extension/mcp/tools/code/styles/background.ts +++ b/packages/extension/mcp/tools/code/styles/background.ts @@ -8,12 +8,20 @@ import { splitByTopLevelComma, stripFallback } from '@/utils/css' -import { getCssPaintLayers, getPaintResolutionSize, isVisiblePaint } from '@/utils/figma-paint' +import { + getBackgroundColorPaint, + getCssPaintLayers, + getPaintResolutionSize, + isRenderablePaint +} from '@/utils/figma-paint' import { formatGradientStopColor as formatPaintGradientStopColor, + matchBackgroundPaintLayers, + preserveGradientGeometry, resolveBackgroundFillFromPaints, resolveGradientPaintCss } from '@/utils/figma-style/gradient' +import { stripFigmaImageFallback } from '@/utils/figma-style/style-resolver' import { getVariableCssExpr } from '@/utils/figma-variables' import type { GetCodeCacheContext } from '../cache' @@ -21,8 +29,7 @@ import type { StyleMap } from './types' import { getNodeSemanticsCached, getPaintStyleCached, getPaintsFromState } from '../cache' -const BG_URL_LIGHTGRAY_RE = /url\(.*?\)\s+lightgray/i -const GRADIENT_FN_RE = /(linear-gradient|radial-gradient|conic-gradient)\s*\(/i +const BG_URL_RE = /url\(/i type PaintList = Paint[] | ReadonlyArray | null | undefined @@ -44,54 +51,58 @@ export function cleanFigmaSpecificStyles( const activeFillPaints = styleFillPaints ?? fills const gradientSize = getPaintResolutionSize(node) const readers = ctx?.readers ?? DEFAULT_READERS - const backgroundFill = resolveBackgroundFillFromPaints(activeFillPaints, gradientSize, readers, { - background: processed.background, - resolveGradientPaint: resolveGradientPaintValue, - resolveSolidPaint: resolveSolidPaintColor - }) + const hasUrlBackground = + BG_URL_RE.test(processed.background ?? '') || + BG_URL_RE.test(processed['background-image'] ?? '') + const backgroundFill = hasUrlBackground + ? null + : resolveBackgroundFillFromPaints(activeFillPaints, gradientSize, readers, { + background: processed.background, + resolveGradientPaint: resolveGradientPaintValue, + resolveSolidPaint: resolveSolidPaintColor + }) const backgroundLayers = backgroundFill?.kind === 'layers' ? backgroundFill.value.join(', ') : null - const fallbackSolidColor = resolveVisibleSolidPaintColor(activeFillPaints, readers) + const fallbackPaint = activeFillPaints + ? hasUrlBackground + ? getBackgroundColorPaint(activeFillPaints)?.paint + : findVisibleSolidPaint(activeFillPaints) + : null + const fallbackSolidColor = fallbackPaint ? resolveSolidPaintColor(fallbackPaint, readers) : null if (processed.background) { const bgValue = processed.background - const normalized = stripFallback(preprocessCssValue(bgValue)).trim() - - const gradient = resolveGradientWithOpacity(normalized, activeFillPaints, readers) - if (gradient) { - processed.background = gradient - return processed + const imageBackground = activeFillPaints ? stripFigmaImageFallback(bgValue) : bgValue + const removedFallback = imageBackground !== bgValue + if (removedFallback) { + processed.background = imageBackground + if (fallbackSolidColor) processed['background-color'] = fallbackSolidColor } + const normalized = stripFallback(preprocessCssValue(imageBackground)).trim() - if (backgroundLayers && (isVarOnly(normalized) || isSolidBackground(normalized))) { - processed.background = backgroundLayers - delete processed['background-color'] + const layersWithOpacity = resolveGradientWithOpacity(normalized, activeFillPaints, readers) + if (layersWithOpacity) { + processed.background = layersWithOpacity return processed } if (isSolidBackground(normalized)) { - if (backgroundFill?.kind === 'color') { - processed['background-color'] = backgroundFill.value - delete processed.background - } else if (backgroundLayers) { + if (backgroundLayers) { processed.background = backgroundLayers delete processed['background-color'] - } else { - processed['background-color'] = fallbackSolidColor ?? normalized - delete processed.background + return processed } - } else if (BG_URL_LIGHTGRAY_RE.test(bgValue) && activeFillPaints) { - const parsed = parseBackgroundShorthand(bgValue) + processed['background-color'] = + backgroundFill?.kind === 'color' ? backgroundFill.value : (fallbackSolidColor ?? normalized) + delete processed.background + } else if (removedFallback && splitByTopLevelComma(imageBackground).length === 1) { + const parsed = parseBackgroundShorthand(imageBackground) if (parsed.image) processed['background-image'] = parsed.image if (parsed.size) processed['background-size'] = parsed.size if (parsed.repeat) processed['background-repeat'] = parsed.repeat if (parsed.position) processed['background-position'] = parsed.position - if (fallbackSolidColor) { - processed['background-color'] = fallbackSolidColor - } - delete processed.background } } @@ -141,15 +152,16 @@ function isGradientPaint(paint: Paint): paint is GradientPaint { return 'gradientStops' in paint && Array.isArray(paint.gradientStops) } -function isVisibleLinearGradientPaint(paint: Paint): paint is GradientPaint { - return isVisiblePaint(paint) && paint.type === 'GRADIENT_LINEAR' && isGradientPaint(paint) -} - function findVisibleSolidPaint(paints: ReadonlyArray): SolidPaint | null { - const solid = paints.find( - (paint): paint is SolidPaint => isVisiblePaint(paint) && isSolidPaint(paint) + const layers = getCssPaintLayers(paints) + const paint = layers[0]?.paint + if (!paint || !isSolidPaint(paint)) return null + if ( + layers.length > 1 && + ((paint.opacity ?? 1) < 1 || (paint.blendMode !== undefined && paint.blendMode !== 'NORMAL')) ) - return solid ?? null + return null + return paint } function getFillStylePaints( @@ -191,16 +203,6 @@ function resolveSolidPaintColor( return formatHexAlpha(paint.color, paint.opacity ?? 1) } -function resolveVisibleSolidPaintColor( - paints: PaintList, - readers: FigmaLookupReaders = DEFAULT_READERS -): string | null { - if (!paints || !Array.isArray(paints)) return null - - const paint = findVisibleSolidPaint(paints) - return paint ? resolveSolidPaintColor(paint, readers) : null -} - function resolveGradientPaintValue( gradientPaint: GradientPaint, size?: PaintResolutionSize, @@ -217,77 +219,29 @@ function resolveGradientWithOpacity( if (!value || !/gradient\(/i.test(value)) return null if (!fills) return null - const paints = getCssPaintLayers(fills) - const layers = splitByTopLevelComma(value) + const layers = matchBackgroundPaintLayers(value, fills) // Match complete CSS layers, including synthetic solid gradients and images. // A partial native CSS stack cannot safely be paired by paint index. - if (layers.length !== paints.length) return null + if (!layers) return null let changed = false - const rewritten = layers.map((layer, index) => { - if (!/^linear-gradient\(/i.test(layer.trim())) return layer - const fill = paints[index]!.paint - if (!isVisibleLinearGradientPaint(fill)) return layer + const rewritten = layers.flatMap(({ paint: fill, sources: group }) => { + if (!isRenderablePaint(fill) || !isGradientPaint(fill)) return group const fillOpacity = typeof fill.opacity === 'number' ? fill.opacity : 1 const hasStopAlpha = fill.gradientStops.some((stop) => (stop.color?.a ?? 1) < 1) - if (fillOpacity >= 0.99 && !hasStopAlpha) return layer - - const parsed = parseGradient(layer) - if (!parsed) return layer - - const angle = parsed.args[0]?.trim() - const hasAngle = - !!angle && - (angle.endsWith('deg') || - angle.endsWith('rad') || - angle.endsWith('turn') || - angle.startsWith('to ')) - const stops = fill.gradientStops.map((stop) => { - const pct = formatPercent(stop.position) - const color = formatGradientStopColor(stop, fillOpacity, readers) - return `${color} ${pct}` + if (fillOpacity >= 1 && !hasStopAlpha) return group + + const resolved = resolveGradientPaintValue(fill, undefined, readers) + return group.map((layer) => { + const corrected = resolved ? preserveGradientGeometry(layer, resolved, fill.type) : null + if (!corrected) return layer + changed = true + return corrected }) - - const args = hasAngle ? [angle, ...stops] : stops - changed = true - return `${parsed.fn}(${args.join(', ')})` }) return changed ? rewritten.join(', ') : null } -function parseGradient(value: string): { fn: string; args: string[] } | null { - const match = value.match(GRADIENT_FN_RE) - if (!match || match.index == null) return null - - const fn = match[1] - if (!fn) return null - const start = value.indexOf('(', match.index) - if (start < 0) return null - - let depth = 0 - let end = -1 - for (let i = start; i < value.length; i += 1) { - const ch = value[i] - if (ch === '(') depth += 1 - else if (ch === ')') { - depth -= 1 - if (depth === 0) { - end = i - break - } - } - } - if (end < 0) return null - - const inner = value.slice(start + 1, end) - return { fn, args: splitByTopLevelComma(inner) } -} - -function formatPercent(pos: number): string { - const pct = Math.round(pos * 10000) / 100 - return `${pct}%` -} - function formatGradientStopColor( stop: ColorStop, fillOpacity: number, @@ -301,11 +255,6 @@ function isSolidBackground(value: string): boolean { const trimmed = value.trim() if (!trimmed) return false - if (/var\(\s*--[A-Za-z0-9_-]+\s*\)/i.test(trimmed)) return true + if (/^var\(\s*--[A-Za-z0-9_-]+\s*\)$/i.test(trimmed)) return true return canonicalizeColor(trimmed.toLowerCase()) !== null } - -function isVarOnly(value: string): boolean { - if (!value) return false - return /^var\(\s*--[A-Za-z0-9_-]+\s*\)$/i.test(value.trim()) -} diff --git a/packages/extension/mcp/tools/code/styles/layout.ts b/packages/extension/mcp/tools/code/styles/layout.ts index 1f674a65..77a41b20 100644 --- a/packages/extension/mcp/tools/code/styles/layout.ts +++ b/packages/extension/mcp/tools/code/styles/layout.ts @@ -46,7 +46,7 @@ export function mergeInferredAutoLayout( merged['align-items'] = align } - if (hasAtomicPadding(merged)) { + if (hasPadding(merged)) { return merged } @@ -97,11 +97,11 @@ export function inferResizingStyles( } if (layoutNode.layoutSizingHorizontal === 'HUG') { - delete next.width + next.width = 'max-content' } if (layoutNode.layoutSizingVertical === 'HUG') { - delete next.height + next.height = 'max-content' } return next @@ -218,6 +218,12 @@ function hasGap(s: StyleMap) { return !!(s.gap || s['row-gap'] || s['column-gap']) } -function hasAtomicPadding(s: StyleMap) { - return !!(s['padding-top'] || s['padding-right'] || s['padding-bottom'] || s['padding-left']) +function hasPadding(s: StyleMap) { + return !!( + s.padding || + s['padding-top'] || + s['padding-right'] || + s['padding-bottom'] || + s['padding-left'] + ) } diff --git a/packages/extension/mcp/tools/code/styles/normalize.ts b/packages/extension/mcp/tools/code/styles/normalize.ts index b1527bd8..96863501 100644 --- a/packages/extension/mcp/tools/code/styles/normalize.ts +++ b/packages/extension/mcp/tools/code/styles/normalize.ts @@ -13,7 +13,7 @@ import { parseBoxValues } from '@/utils/css' import { isRenderablePaint } from '@/utils/figma-paint' -import { cssToClassNames, nestedCssToClassNames } from '@/utils/tailwind' +import { nestedCssToClassNames } from '@/utils/tailwind' import type { GetCodeCacheContext } from '../cache' import type { StyleMap, StyleStep } from './types' @@ -148,11 +148,7 @@ export function buildLayoutStyles( export function styleToClassNames(style: StyleMap, config: CodegenConfig): string[] { const normalizedStyle = canonicalizeUniformVisibleBorderColor(normalizeStyleValues(style, config)) const resolved = resolveGradientBorderClasses(normalizedStyle) - if (!resolved) { - return cssToClassNames(normalizedStyle) - } - - return nestedCssToClassNames(resolved.style) + return nestedCssToClassNames(resolved?.style ?? normalizedStyle) } function canonicalizeUniformVisibleBorderColor(style: StyleMap): StyleMap { @@ -249,8 +245,8 @@ function getBorderWidth(style: StyleMap): string | null { const borderWidth = style['border-width'] if (borderWidth) { - const [t, r, b, l] = parseBoxValues(normalizeStyleValue(borderWidth)) - if (t === r && r === b && b === l) return t + const widths = parseBoxValues(normalizeStyleValue(borderWidth)) + if (widths && widths.every((width) => width === widths[0])) return widths[0] } if (style.border) { diff --git a/packages/extension/mcp/tools/code/styles/prepare.ts b/packages/extension/mcp/tools/code/styles/prepare.ts index ae993a3c..29e974d9 100644 --- a/packages/extension/mcp/tools/code/styles/prepare.ts +++ b/packages/extension/mcp/tools/code/styles/prepare.ts @@ -1,16 +1,12 @@ -import type { VariableMappings } from '../../token/mapping' import type { GetCodeCacheContext } from '../cache' import type { VisibleTree } from '../model' -import { normalizeStyleVars } from '../../token/mapping' import { sanitizeStyles } from '../sanitize' import { buildLayoutStyles } from './normalize' type PrepareStylesInput = { tree: VisibleTree styles: Map> - mappings: VariableMappings - variableCache: Map vectorRoots: Set cache: GetCodeCacheContext trace?: { @@ -22,26 +18,19 @@ type PrepareStylesInput = { type PrepareStylesResult = { styles: Map> layout: Map> - usedCandidateIds: Set } export function prepareStyles({ tree, styles, - mappings, - variableCache, vectorRoots, cache, trace }: PrepareStylesInput): PrepareStylesResult { - const t0 = trace?.now?.() - const usedCandidateIds = normalizeStyleVars(styles, mappings, variableCache) - if (t0 != null) trace?.stamp?.('normalize-vars', t0) - - const t1 = trace?.now?.() + const startedAt = trace?.now?.() sanitizeStyles(tree, styles, vectorRoots, cache) const layout = buildLayoutStyles(styles, vectorRoots) - if (t1 != null) trace?.stamp?.('layout', t1) + if (startedAt != null) trace?.stamp?.('layout', startedAt) - return { styles, layout, usedCandidateIds } + return { styles, layout } } diff --git a/packages/extension/mcp/tools/code/text/render.ts b/packages/extension/mcp/tools/code/text/render.ts index 03e3bf37..e5234618 100644 --- a/packages/extension/mcp/tools/code/text/render.ts +++ b/packages/extension/mcp/tools/code/text/render.ts @@ -1,6 +1,6 @@ import type { DevComponent } from '@/types/plugin' -import { pruneInheritedTextStyles, stripDefaultTextStyles } from '@/utils/css' +import { canonicalizeValue, pruneInheritedTextStyles, stripDefaultTextStyles } from '@/utils/css' import { resolveTextNodeVariableId, resolveTextSegmentVariable } from '@/utils/figma-variables' import { joinClassNames } from '@/utils/tailwind' @@ -31,7 +31,7 @@ export async function renderTextSegments( segmentCount: number fontStyleVariableIds: Set }> { - const { inheritedTextStyle, segments: providedSegments } = options + const { inheritedTextStyle, nodeTextStyle, segments: providedSegments } = options const rawSegments = providedSegments === undefined ? getStyledSegments(node) : providedSegments const fontStyleVariableIds = new Set() @@ -57,38 +57,58 @@ export async function renderTextSegments( const segmentCount = rawSegments.length const allRunStyles: Array<{ style: Record; weight: number }> = [] + const runs = blocks.flatMap((block) => block.lines.flatMap((line) => line.runs)) - for (const block of blocks) { - for (const line of block.lines) { - for (const run of line.runs) { - const cleaned = stripDefaultTextStyles({ ...run.attrs }) - pruneInheritedTextStyles(cleaned, inheritedTextStyle) - const resolved = ctx.resolveStyleVars ? ctx.resolveStyleVars(cleaned, node) : cleaned - - const hoistableCandidate: Record = {} - for (const [key, value] of Object.entries(resolved)) { - if (HOIST_ALLOWLIST.has(key)) { - hoistableCandidate[key] = value - } - } - - if (Object.keys(hoistableCandidate).length > 0) { - allRunStyles.push({ style: hoistableCandidate, weight: run.text.length }) - } + for (const run of runs) { + const cleaned = stripDefaultTextStyles({ ...run.attrs }) + pruneInheritedTextStyles(cleaned, inheritedTextStyle) + const resolved = ctx.resolveStyleVars ? ctx.resolveStyleVars(cleaned, node) : cleaned + + const hoistableCandidate: Record = {} + for (const [key, value] of Object.entries(resolved)) { + if (HOIST_ALLOWLIST.has(key)) { + hoistableCandidate[key] = value } } + + if (Object.keys(hoistableCandidate).length > 0) { + allRunStyles.push({ style: hoistableCandidate, weight: run.text.length }) + } } const commonStyle = computeDominantStyle(allRunStyles) + const inheritedBaseStyle = { ...inheritedTextStyle, ...nodeTextStyle } + const sharedDefaults: Record = {} + for (const [key, value] of [ + ['line-height', 'normal'], + ['letter-spacing', '0'] + ] as const) { + if ( + runs.length && + runs.every((run) => canonicalizeValue(key, run.attrs[key] ?? '') === value) + ) { + sharedDefaults[key] = value + } + } + // A uniform reset belongs on the text wrapper. Its single child can be + // flattened later, so emitting conflicting wrapper/run utilities is unsafe. + restoreDefaultTextMetrics(commonStyle, sharedDefaults, inheritedBaseStyle) + const inheritedRunStyle = { ...inheritedBaseStyle, ...commonStyle } + const renderRuns = (runs: TextRun[]) => + buildInlineTree(runs, commonStyle, classProp, ctx, node, inheritedRunStyle) const outputSegments: Array = [] - for (const block of blocks) { - const renderedBlock = renderBlock(block, commonStyle, classProp, ctx, node) - if (renderedBlock) { - optimizeComponentTree(renderedBlock, classProp) - outputSegments.push(renderedBlock) - } + for (const [index, block] of blocks.entries()) { + const renderedBlock = renderBlock( + block, + classProp, + ctx.config, + index < blocks.length - 1, + renderRuns + ) + optimizeComponentTree(renderedBlock, classProp) + outputSegments.push(renderedBlock) } return { segments: outputSegments, commonStyle, metas: [], segmentCount, fontStyleVariableIds } @@ -96,25 +116,24 @@ export async function renderTextSegments( function renderBlock( block: TextBlock, - commonStyle: Record, classProp: 'class' | 'className', - ctx: RenderContext, - node: TextNode -): DevComponent | null { + config: RenderContext['config'], + hasFollowingBlock: boolean, + renderRuns: (runs: TextRun[]) => Array +): DevComponent { const { type, lines, attrs } = block - const { paragraphSpacing } = attrs - const { config } = ctx + const { paragraphIndent, paragraphSpacing } = attrs const isList = type === 'ordered-list' || type === 'unordered-list' const props: Record = {} const blockClasses: string[] = [] - if (Object.keys(commonStyle).length) { - blockClasses.push(...styleToClassNames(commonStyle, config)) + if (block.paragraphLayout) { + blockClasses.push(...styleToClassNames({ 'text-indent': `${paragraphIndent}px` }, config)) } - if (paragraphSpacing > 0) { + if (paragraphSpacing > 0 && hasFollowingBlock) { const mbStyle = { 'margin-bottom': `${paragraphSpacing}px` } blockClasses.push(...styleToClassNames(mbStyle, config)) } @@ -123,13 +142,36 @@ function renderBlock( if (cls) props[classProp] = cls if (!isList) { - const isMultiline = lines.length > 1 - const tagName = isMultiline || paragraphSpacing > 0 ? 'p' : 'span' + const isMultiline = + lines.length > 1 || lines.some((line) => line.runs.some((run) => run.text.includes('\u2028'))) + const tagName = block.paragraphLayout || isMultiline || paragraphSpacing > 0 ? 'p' : 'span' const container: DevComponent = { name: tagName, props, children: [] } lines.forEach((line, idx) => { - const lineNodes = buildInlineTree(line.runs, commonStyle, classProp, ctx, node) - container.children.push(...lineNodes) + if (paragraphIndent !== 0 && line.runs.some((run) => run.text.includes('\u2028'))) { + // Native forced lines repeat the indent, but automatic wraps do not. + // Separate line boxes inherit text-indent without adding paragraph gaps. + for (const runs of splitSoftLines(line.runs)) { + const children = renderRuns(runs) + if (isCollapsibleLine(runs)) { + children.push({ name: 'br', props: {}, children: [] }) + } + container.children.push({ + name: 'span', + props: { [classProp]: 'block' }, + children + }) + } + } else { + container.children.push(...renderRuns(line.runs)) + if ( + idx === lines.length - 1 && + (isCollapsibleLine(line.runs) || endsWithEmptySoftLine(line.runs)) + ) { + // A final br alone does not create the trailing empty line box. + container.children.push({ name: 'br', props: {}, children: [] }) + } + } if (isMultiline && idx < lines.length - 1) { container.children.push({ name: 'br', props: {}, children: [] }) } @@ -153,46 +195,59 @@ function renderBlock( const rootStackItem: ListStackItem = { list: rootList, - level: lines[0]?.attrs.indentation || 1 + // Native lists start at level 1; retain level 0 for existing segment sources. + level: lines.reduce((minimum, line) => Math.min(minimum, line.attrs.indentation), 1) } const stack: ListStackItem[] = [rootStackItem] for (const line of lines) { const currentIndent = line.attrs.indentation - + const listTag = line.attrs.listType === 'ORDERED' ? 'ol' : 'ul' while (stack.length > 1) { const activeItem = stack.at(-1) - if (!activeItem || currentIndent >= activeItem.level) break + if ( + !activeItem || + currentIndent > activeItem.level || + (currentIndent === activeItem.level && listTag === activeItem.list.name) + ) + break stack.pop() } - const parentStackItem = stack.at(-1) ?? rootStackItem - if (currentIndent > parentStackItem.level) { + let parentStackItem = stack.at(-1) ?? rootStackItem + while (currentIndent > parentStackItem.level) { if (!parentStackItem.lastLi) { - const dummyLi: DevComponent = { name: 'li', props: {}, children: [] } + // Skipped levels need containers without extra visible markers or counters. + const dummyLi: DevComponent = { + name: 'li', + props: { + [classProp]: 'list-none', + ...(parentStackItem.list.name === 'ol' ? { value: '0' } : {}) + }, + children: [] + } parentStackItem.list.children.push(dummyLi) parentStackItem.lastLi = dummyLi } const currentDepth = stack.length const newListTypeKey = line.attrs.listType === 'ORDERED' ? 'ORDERED' : 'UNORDERED' - const newListTag = newListTypeKey === 'ORDERED' ? 'ol' : 'ul' - const newListStyle = getListStyleClass(newListTypeKey, currentDepth) const newListClasses = [newListStyle, 'list-outside', 'pl-[1.2em]'] const newList: DevComponent = { - name: newListTag, + name: listTag, props: { [classProp]: joinClassNames(newListClasses) }, children: [] } parentStackItem.lastLi.children.push(newList) - stack.push({ list: newList, level: currentIndent }) + parentStackItem = { list: newList, level: parentStackItem.level + 1 } + stack.push(parentStackItem) } - const lineChildren = buildInlineTree(line.runs, commonStyle, classProp, ctx, node) - if (lineChildren.length === 0) { + const lineChildren = renderRuns(line.runs) + if (isCollapsibleLine(line.runs) || endsWithEmptySoftLine(line.runs)) { lineChildren.push({ name: 'br', props: {}, children: [] }) } @@ -206,6 +261,26 @@ function renderBlock( return rootList } +function isCollapsibleLine(runs: TextRun[]): boolean { + // NBSP and zero-width spaces already create a line box. + return runs.every((run) => !/[^\t\n\f\r ]/.test(run.text)) +} + +function endsWithEmptySoftLine(runs: TextRun[]): boolean { + return /\u2028[\t\n\f\r ]*$/.test(runs.map((run) => run.text).join('')) +} + +function splitSoftLines(runs: TextRun[]): TextRun[][] { + const lines: TextRun[][] = [[]] + for (const run of runs) { + for (const [index, text] of run.text.split('\u2028').entries()) { + if (index) lines.push([]) + if (text) lines.at(-1)!.push({ ...run, text }) + } + } + return lines +} + function getListStyleClass(type: 'ORDERED' | 'UNORDERED', depth: number): string { if (type === 'UNORDERED') { return 'list-disc' @@ -270,7 +345,8 @@ function buildInlineTree( commonStyle: Record, classProp: 'class' | 'className', ctx: RenderContext, - node: TextNode + node: TextNode, + inheritedRunStyle: Record ): Array { const root: { children: Array } = { children: [] } const stack: StackNode[] = [{ container: root }] @@ -319,6 +395,7 @@ function buildInlineTree( for (const key of ['font-weight', 'font-style']) { if (run.attrs[key] !== undefined) cleanedAttrs[key] = run.attrs[key] } + restoreDefaultTextMetrics(cleanedAttrs, run.attrs, inheritedRunStyle) const resolvedAttrs = ctx.resolveStyleVars ? ctx.resolveStyleVars(cleanedAttrs, node) : cleanedAttrs @@ -328,21 +405,48 @@ function buildInlineTree( if (style['font-style'] === 'normal') classNames.push('not-italic') const cls = joinClassNames(classNames) const top = stack.at(-1)?.container ?? root + const children: Array = [] + for (const [index, text] of run.text.split('\u2028').entries()) { + if (index) children.push({ name: 'br', props: {}, children: [] }) + if (text) children.push(text) + } if (cls) { top.children.push({ name: 'span', props: { [classProp]: cls }, - children: [run.text] + children }) } else { - top.children.push(run.text) + top.children.push(...children) } } return root.children } +function restoreDefaultTextMetrics( + style: Record, + attrs: Record, + inheritedStyle: Record +): void { + // AUTO line height and zero tracking must reset a nondefault ancestor, + // node CSS, or hoisted run value rather than inheriting it. + for (const key of ['line-height', 'letter-spacing']) { + const value = attrs[key] + const inherited = inheritedStyle[key] + if ( + style[key] === undefined && + value !== undefined && + inherited !== undefined && + !(key === 'letter-spacing' && inherited === 'normal') && + canonicalizeValue(key, value) !== canonicalizeValue(key, inherited) + ) { + style[key] = value + } + } +} + function createMarkComponent(mark: TextMark, run: TextRun): DevComponent { switch (mark) { case 'bold': diff --git a/packages/extension/mcp/tools/code/text/segments.ts b/packages/extension/mcp/tools/code/text/segments.ts index e9d73288..da56a8e1 100644 --- a/packages/extension/mcp/tools/code/text/segments.ts +++ b/packages/extension/mcp/tools/code/text/segments.ts @@ -4,7 +4,7 @@ import { canonicalizeValue } from '@/utils/css' import { inferFontWeight, isCodeFont, resolveRunAttrs, resolveTokens } from './style' import { - NEWLINE_RE, + PARAGRAPH_BREAK_RE, REQUESTED_SEGMENT_FIELDS, type StyledTextSegmentSubset, type TextBlock, @@ -44,11 +44,10 @@ function splitIntoLines( const lines: TextLine[] = [] let currentRuns: TextRun[] = [] - let currentSegmentForAttrs: StyledTextSegmentSubset | null = null for (const seg of segments) { const text = seg.characters - const parts = text.split(NEWLINE_RE) + const parts = text.split(PARAGRAPH_BREAK_RE) for (const [i, partText] of parts.entries()) { if (partText.length > 0) { @@ -64,13 +63,14 @@ function splitIntoLines( currentRuns = [] } } - currentSegmentForAttrs = seg } - if (currentRuns.length > 0 && currentSegmentForAttrs) { + // A terminal paragraph break still starts an empty paragraph or list item. + const lastSegment = segments.at(-1) + if ((currentRuns.length > 0 || lines.length > 0) && lastSegment) { lines.push({ runs: optimizeRuns(currentRuns), - attrs: extractLineAttrs(currentSegmentForAttrs) + attrs: extractLineAttrs(lastSegment) }) } @@ -80,6 +80,13 @@ function splitIntoLines( function groupLinesIntoBlocks(lines: TextLine[]): TextBlock[] { const blocks: TextBlock[] = [] if (!lines.length) return blocks + // Plain paragraphs can share compact p/br output. Native paragraph metrics + // need separate block boxes so indentation and spacing apply to each one. + const paragraphLayout = lines.some( + ({ attrs }) => + attrs.listType === 'NONE' && (attrs.paragraphIndent !== 0 || attrs.paragraphSpacing !== 0) + ) + let rootIndent = 0 for (const line of lines) { const { listType } = line.attrs @@ -91,13 +98,20 @@ function groupLinesIntoBlocks(lines: TextLine[]): TextBlock[] { : 'paragraph' const currentBlock = blocks.at(-1) - if (currentBlock?.type === blockType) { + if ( + currentBlock && + ((currentBlock.type === blockType && (isList || !paragraphLayout)) || + (isList && currentBlock.type !== 'paragraph' && line.attrs.indentation > rootIndent)) + ) { currentBlock.lines.push(line) + rootIndent = Math.min(rootIndent, line.attrs.indentation) } else { + rootIndent = line.attrs.indentation blocks.push({ type: blockType, lines: [line], - attrs: line.attrs + attrs: line.attrs, + ...(paragraphLayout && !isList ? { paragraphLayout: true } : {}) }) } } @@ -248,6 +262,7 @@ function extractLineAttrs(seg: StyledTextSegmentSubset): TextLine['attrs'] { listType: seg.listOptions?.type || 'NONE', indentation: seg.indentation || 0, listSpacing: seg.listOptions?.type !== 'NONE' ? seg.paragraphSpacing || 0 : 0, + paragraphIndent: seg.paragraphIndent || 0, paragraphSpacing: seg.paragraphSpacing || 0 } } diff --git a/packages/extension/mcp/tools/code/text/style.ts b/packages/extension/mcp/tools/code/text/style.ts index 7d155768..08d6ad43 100644 --- a/packages/extension/mcp/tools/code/text/style.ts +++ b/packages/extension/mcp/tools/code/text/style.ts @@ -27,22 +27,19 @@ export function resolveRunAttrs( fills: ResolvedFill[] ): Record { const style: Record = {} - let visibleSolid: Extract | undefined - let hasVisiblePaint = false - for (const fill of fills) { - if (!isRenderablePaint(fill.raw)) continue - hasVisiblePaint = true - if (fill.type === 'SOLID') { - visibleSolid = fill - break - } - } - - if (visibleSolid) { - const val = formatHexAlpha(visibleSolid.raw.color, visibleSolid.raw.opacity ?? 1) - const colorValue = constructCssVar(visibleSolid.token, val) + const visible = fills.filter((fill) => isRenderablePaint(fill.raw)) + const front = visible.at(-1) + const composited = + front && + visible.length > 1 && + ((front.raw.opacity ?? 1) < 1 || + (front.raw.blendMode !== undefined && front.raw.blendMode !== 'NORMAL')) + + if (front?.type === 'SOLID' && !composited) { + const val = formatHexAlpha(front.raw.color, front.raw.opacity ?? 1) + const colorValue = constructCssVar(front.token, val) if (colorValue) style.color = colorValue - } else if (fills.length === 0 || !hasVisiblePaint) { + } else if (!front) { style.color = 'transparent' } diff --git a/packages/extension/mcp/tools/code/text/types.ts b/packages/extension/mcp/tools/code/text/types.ts index 7f10a399..5a22b2cc 100644 --- a/packages/extension/mcp/tools/code/text/types.ts +++ b/packages/extension/mcp/tools/code/text/types.ts @@ -28,7 +28,7 @@ export const TYPO_FIELDS = [ 'paragraphIndent' ] as const -export const NEWLINE_RE = /\r\n|[\n\r\u2028\u2029]/ +export const PARAGRAPH_BREAK_RE = /\r\n|[\n\r\u2029]/ export type BlockType = 'paragraph' | 'ordered-list' | 'unordered-list' @@ -46,6 +46,7 @@ export interface TextLine { listType: 'ORDERED' | 'UNORDERED' | 'NONE' indentation: number listSpacing: number + paragraphIndent: number paragraphSpacing: number } } @@ -54,6 +55,7 @@ export interface TextBlock { type: BlockType lines: TextLine[] attrs: TextLine['attrs'] + paragraphLayout?: true } export type TokenRef = { id: string; name: string } @@ -104,6 +106,7 @@ export const REQUESTED_SEGMENT_FIELDS: SegmentFieldForRequest[] = [ 'textDecorationColor', 'textDecorationSkipInk', 'paragraphSpacing', + 'paragraphIndent', 'indentation', 'listOptions', 'fills', @@ -115,6 +118,7 @@ export const REQUESTED_SEGMENT_FIELDS: SegmentFieldForRequest[] = [ export type RenderTextSegmentsOptions = { inheritedTextStyle?: Record + nodeTextStyle?: Record segments?: StyledTextSegmentSubset[] | null } diff --git a/packages/extension/mcp/tools/code/tokens/diagnostics.ts b/packages/extension/mcp/tools/code/tokens/diagnostics.ts index b04591b8..166c4ce7 100644 --- a/packages/extension/mcp/tools/code/tokens/diagnostics.ts +++ b/packages/extension/mcp/tools/code/tokens/diagnostics.ts @@ -27,7 +27,7 @@ export function createTokenDiagnostics() { type, message: `${ type === 'token-resolution' - ? 'Some token references could not be resolved or normalized for their consuming properties and were preserved.' + ? 'Some token references have resolution, normalization, or compatibility limitations.' : 'Some token definitions or mode labels are unavailable or ambiguous.' } ${Array.from(samples).join(' ')}${truncated ? ' Additional issues omitted.' : ''}` })) diff --git a/packages/extension/mcp/tools/code/tokens/markup.ts b/packages/extension/mcp/tools/code/tokens/markup.ts new file mode 100644 index 00000000..1868ad25 --- /dev/null +++ b/packages/extension/mcp/tools/code/tokens/markup.ts @@ -0,0 +1,224 @@ +import { raw, RAW_TAG_NAME } from '@tempad-dev/plugins' + +import type { DevComponent } from '@/types/plugin' +import type { CssValueSyntax } from '@/utils/css' + +import { escapeComponentText } from '@/utils/component' +import { transformCssValue } from '@/utils/css' +import { camelToKebab } from '@/utils/string' + +const SVG_STYLE_ATTRIBUTES = new Set([ + 'alignment-baseline', + 'baseline-shift', + 'clip', + 'clip-rule', + 'color', + 'color-interpolation', + 'color-interpolation-filters', + 'color-profile', + 'color-rendering', + 'cursor', + 'direction', + 'display', + 'dominant-baseline', + 'enable-background', + 'fill', + 'fill-opacity', + 'fill-rule', + 'font-family', + 'font-size', + 'font-size-adjust', + 'font-stretch', + 'font-style', + 'font-variant', + 'font-weight', + 'glyph-orientation-horizontal', + 'glyph-orientation-vertical', + 'image-rendering', + 'kerning', + 'letter-spacing', + 'marker-end', + 'marker-mid', + 'marker-start', + 'stroke', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-miterlimit', + 'stroke-opacity', + 'stroke-width', + 'opacity', + 'overflow', + 'pointer-events', + 'shape-rendering', + 'solid-color', + 'solid-opacity', + 'clip-path', + 'filter', + 'mask', + 'stop-color', + 'stop-opacity', + 'flood-color', + 'flood-opacity', + 'lighting-color', + 'text-anchor', + 'text-decoration', + 'text-rendering', + 'transform', + 'transform-origin', + 'unicode-bidi', + 'vector-effect', + 'visibility', + 'word-spacing', + 'writing-mode', + 'x', + 'y', + 'width', + 'height', + 'cx', + 'cy', + 'r', + 'rx', + 'ry', + 'd' +]) + +const NATIVE_SVG = Symbol('native-svg') +type NativeSvgComponent = DevComponent & { [NATIVE_SVG]?: true } +type TokenTransform = (value: string, syntax?: CssValueSyntax) => string + +export function nativeSvg(content: string, props?: Record): DevComponent { + return { ...raw(content, props), [NATIVE_SVG]: true } as NativeSvgComponent +} + +/** Visit styling values before serialization, while text and literal props remain distinct. */ +export function mapTokenValues( + component: DevComponent, + transform: TokenTransform, + inSvg = false +): DevComponent { + const isNativeSvg = !!(component as NativeSvgComponent)[NATIVE_SVG] + const svg = inSvg || component.name === 'svg' || isNativeSvg + const props = mapProps(component.props, transform, svg) + if (component.name === RAW_TAG_NAME) { + if (typeof props.content === 'string') { + // Exported SVG has XML text nodes; plugin raw() instead opts into code-level processing. + props.content = isNativeSvg + ? mapSvgValues(props.content, transform) + : transform(props.content, 'code') + } + if (props.injectedProps && typeof props.injectedProps === 'object') { + props.injectedProps = mapProps(props.injectedProps as Record, transform, svg) + } + } + return { + ...component, + props, + children: component.children.map((child) => + typeof child === 'string' + ? child + : mapTokenValues(child, transform, svg && component.name !== 'foreignObject') + ) + } +} + +function mapProps( + props: Record, + transform: TokenTransform, + inSvg: boolean +): Record { + const transformValue = (value: string) => transformCssValue(value, transform) + return Object.fromEntries( + Object.entries(props).map(([key, value]) => { + if (key === 'class' || key === 'className') { + return [ + key, + mapClasses(value, (value) => + transformCssValue(value, (part) => transform(part, 'tailwind'), { syntax: 'tailwind' }) + ) + ] + } + if (key === 'style') return [key, mapStyle(value, transformValue)] + if (inSvg && SVG_STYLE_ATTRIBUTES.has(camelToKebab(key)) && typeof value === 'string') { + return [key, transformValue(value)] + } + return [key, value] + }) + ) +} + +function mapClasses(value: unknown, transform: (value: string) => string): unknown { + if (typeof value === 'string') return transform(value) + if (Array.isArray(value)) return value.map((entry) => mapClasses(entry, transform)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, enabled]) => [transform(key), enabled]) + ) + } + return value +} + +function mapStyle(value: unknown, transform: (value: string) => string): unknown { + if (typeof value === 'string') return transform(value) + if (Array.isArray(value)) return value.map((entry) => mapStyle(entry, transform)) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key.startsWith('--') ? transform(key) : key, + typeof entry === 'string' ? transform(entry) : entry + ]) + ) + } + return value +} + +function mapSvgValues(content: string, transform: TokenTransform): string { + // Literal exports need no token work. Entities can encode either CSS syntax or + // template delimiters, so they must still pass through the XML text boundary. + if (!/var\(|--|color-mix|[{}&]| { + const svg = inSvg || element.localName === 'svg' + const original = Object.fromEntries( + Array.from(element.attributes, ({ name, value }) => [name, value]) + ) + const attributes = mapProps(original, transform, svg) as Record + for (const [key, value] of Object.entries(attributes)) { + if (value === original[key]) continue + changed = true + element.setAttribute(key, value) + } + for (const child of element.children) visit(child, svg && element.localName !== 'foreignObject') + } + visit(document.documentElement, true) + const textNodes: CharacterData[] = [] + const walker = document.createTreeWalker( + document.documentElement, + NodeFilter.SHOW_TEXT | NodeFilter.SHOW_CDATA_SECTION + ) + while (walker.nextNode()) { + const node = walker.currentNode as CharacterData + if (node.nodeType === Node.CDATA_SECTION_NODE || /[{}]/.test(node.data)) textNodes.push(node) + } + if (!changed && !textNodes.length) return content + const serializer = new XMLSerializer() + const serialized = serializer.serializeToString(document.documentElement) + if (!textNodes.length) return serialized + + // JSX/Vue parse braces as expressions before decoding entities. XML serialization + // would escape entity ampersands, so restore literal text after serializing placeholders. + let prefix = '__tempad_svg_text__' + while (serialized.includes(prefix)) prefix += '_' + textNodes.forEach((node, index) => { + node.replaceWith(document.createTextNode(`${prefix}${index}__`)) + }) + return serializer + .serializeToString(document.documentElement) + .replace(new RegExp(`${prefix}(\\d+)__`, 'g'), (_match, index: string) => + escapeComponentText(textNodes[Number(index)]!.data) + ) +} diff --git a/packages/extension/mcp/tools/code/tokens/modes.ts b/packages/extension/mcp/tools/code/tokens/modes.ts index 763bbd1e..0c01f61b 100644 --- a/packages/extension/mcp/tools/code/tokens/modes.ts +++ b/packages/extension/mcp/tools/code/tokens/modes.ts @@ -10,22 +10,28 @@ export function addVariableModeHints( tree: VisibleTree, variableIds: Set, context: TokenReadContext, - report: ReportTokenDiagnostic + report: ReportTokenDiagnostic, + checkpoint?: () => void ): void { + checkpoint?.() const collections = new Map() const seen = new Set() const pending = [...variableIds] for (let i = 0; i < pending.length; i += 1) { + checkpoint?.() const id = pending[i]! if (seen.has(id)) continue seen.add(id) const variable = context.getVariable(id) + checkpoint?.() if (!variable) continue collections.set( variable.variableCollectionId, context.getCollection(variable.variableCollectionId) ) + checkpoint?.() for (const value of Object.values(variable.valuesByMode)) { + checkpoint?.() if (isVariableAlias(value)) pending.push(value.id) } } @@ -33,6 +39,7 @@ export function addVariableModeHints( const idsByName = new Map() const ambiguous = new Set() for (const [id, collection] of collections) { + checkpoint?.() if (!collection) { report('token-definition', `Collection ${id} is unavailable; mode hints use IDs.`) continue @@ -50,19 +57,30 @@ export function addVariableModeHints( const rootIds = new Set(tree.rootIds) for (const snapshot of tree.nodes.values()) { + checkpoint?.() const node = snapshot.node const modes = rootIds.has(node.id) ? node.resolvedVariableModes : node.explicitVariableModes + checkpoint?.() const parts = Object.entries(modes ?? {}) .filter(([id]) => collections.has(id)) .sort(([a], [b]) => a.localeCompare(b)) .map(([id, modeId]) => { + checkpoint?.() const collection = collections.get(id) const name = collection && !ambiguous.has(collection.name) ? collection.name : id - const modeName = collection?.modes.find((mode) => mode.modeId === modeId)?.name ?? modeId - return `${name}=${modeName}` + const mode = collection?.modes.find((mode) => mode.modeId === modeId) + if (collection && !mode) { + report( + 'token-definition', + `Mode ${modeId} in collection ${id} is unavailable; mode hints use IDs.` + ) + } + return `${name}=${mode?.name ?? modeId}` }) + checkpoint?.() if (!parts.length) continue snapshot.dataHint ??= {} snapshot.dataHint['data-hint-variable-mode'] = parts.join(';') } + checkpoint?.() } diff --git a/packages/extension/mcp/tools/code/tokens/process.ts b/packages/extension/mcp/tools/code/tokens/process.ts index 8b0d84e6..2739738c 100644 --- a/packages/extension/mcp/tools/code/tokens/process.ts +++ b/packages/extension/mcp/tools/code/tokens/process.ts @@ -1,5 +1,6 @@ import type { GetTokenDefsResult } from '@tempad-dev/shared' +import type { DevComponent } from '@/types/plugin' import type { CodegenConfig } from '@/utils/codegen' import { normalizeFigmaVarName, stripFallback } from '@/utils/css' @@ -12,14 +13,15 @@ import { getVariableByIdCached } from '../../token/cache' import { createTokenReadContext } from '../../token/context' import { resolveTokenDefsByIds } from '../../token/defs' import { createTokenMatcher, extractTokenNames } from './extract' -import { rewriteTokenNamesInCode } from './rewrite' +import { mapTokenValues } from './markup' +import { createTokenNameRewriter } from './rewrite' import { buildSourceNameIndex } from './source-index' import { applyPluginTransformToNames } from './transform' type ProcessTokensInput = { - code: string + component: DevComponent + serialize: (component: DevComponent) => string variableIds: Set - usedCandidateIds: Set fontStyleVariableIds?: Set variableCache: Map styles: Map> @@ -45,9 +47,9 @@ type ProcessTokensResult = { } export async function processTokens({ - code: inputCode, + component, + serialize, variableIds, - usedCandidateIds, fontStyleVariableIds = new Set(), variableCache, styles, @@ -64,12 +66,18 @@ export async function processTokens({ }: ProcessTokensInput): Promise { checkpoint?.() const clock = now ?? (() => Date.now()) - let code = stripFallback(inputCode) + const values: string[] = [] + let markup = mapTokenValues(component, (value, syntax) => { + const normalized = stripFallback(value, syntax) + // Class encoding escapes literal underscores; token identity is the CSS name. + values.push(syntax === 'tailwind' ? normalized.replaceAll('\\_', '_') : normalized) + return normalized + }) + const tokenSource = values.join('\n') - const candidateIds = - usedCandidateIds.size || fontStyleVariableIds.size - ? new Set([...variableIds, ...usedCandidateIds, ...fontStyleVariableIds]) - : variableIds + const candidateIds = fontStyleVariableIds.size + ? new Set([...variableIds, ...fontStyleVariableIds]) + : variableIds const fontStyleNames = new Set() for (const id of fontStyleVariableIds) { checkpoint?.() @@ -80,7 +88,7 @@ export async function processTokens({ candidateIds, variableCache, (name) => { - if (fontStyleNames.has(name) || extractTokenNames(code, new Set([name])).size) { + if (fontStyleNames.has(name) || extractTokenNames(tokenSource, new Set([name])).size) { report?.('token-definition', `Token name "${name}" matches multiple variables.`) } }, @@ -89,7 +97,7 @@ export async function processTokens({ checkpoint?.() const sourceNames = new Set(sourceIndex.keys()) const emptyResult = () => ({ - code, + code: serialize(markup), tokensByCanonical: {}, sourceIndex }) @@ -98,7 +106,7 @@ export async function processTokens({ } let t = clock() - const usedNamesRaw = extractTokenNames(code, sourceNames) + const usedNamesRaw = extractTokenNames(tokenSource, sourceNames) for (const name of fontStyleNames) { checkpoint?.() if (sourceIndex.has(name)) usedNamesRaw.add(name) @@ -121,7 +129,7 @@ export async function processTokens({ checkpoint?.() if (rewriteMap.size) { - code = rewriteTokenNamesInCode(code, rewriteMap) + markup = mapTokenValues(markup, createTokenNameRewriter(rewriteMap)) } if (stamp) stamp('tokens:rewrite', t) checkpoint?.() @@ -147,7 +155,7 @@ export async function processTokens({ : undefined return { - code, + code: serialize(markup), tokensByCanonical, sourceIndex, tokenMatcher, diff --git a/packages/extension/mcp/tools/code/tokens/resolve.ts b/packages/extension/mcp/tools/code/tokens/resolve.ts index b79c28ea..651f9dea 100644 --- a/packages/extension/mcp/tools/code/tokens/resolve.ts +++ b/packages/extension/mcp/tools/code/tokens/resolve.ts @@ -206,6 +206,12 @@ export function createTokenConsumerResolver( } const divisor = property === 'opacity' ? 100 : 1 if (dimension === 'number' && divisor === 1) return full + if (dimension === 'length') { + report?.( + 'token-resolution', + `${property} for ${canonical} requires CSS typed arithmetic support. Use resolveTokens:true for portable literal output.` + ) + } // A 1px divisor rounds to 0.063rem at the default root size; using 100px // keeps the ratio accurate when the renderer rounds converted lengths. const denominator = dimension === 'number' ? String(divisor) : '100px' diff --git a/packages/extension/mcp/tools/code/tokens/rewrite.ts b/packages/extension/mcp/tools/code/tokens/rewrite.ts index d9e70515..6173c5e5 100644 --- a/packages/extension/mcp/tools/code/tokens/rewrite.ts +++ b/packages/extension/mcp/tools/code/tokens/rewrite.ts @@ -1,5 +1,18 @@ +import type { CssValueSyntax } from '@/utils/css' + import { buildTokenRegex } from './extract' +export function createTokenNameRewriter(names: Map) { + const tailwindNames = new Map() + for (const [source, target] of names) { + const encodedTarget = target.replaceAll('_', '\\_') + tailwindNames.set(source, encodedTarget) + tailwindNames.set(source.replaceAll('_', '\\_'), encodedTarget) + } + return (value: string, syntax?: CssValueSyntax): string => + rewriteTokenNamesInCode(value, syntax === 'tailwind' ? tailwindNames : names) +} + export function rewriteTokenNamesInCode(code: string, rewriteMap: Map): string { if (!rewriteMap.size) return code diff --git a/packages/extension/mcp/tools/code/tokens/source-index.ts b/packages/extension/mcp/tools/code/tokens/source-index.ts index 2ec1556e..36dd139f 100644 --- a/packages/extension/mcp/tools/code/tokens/source-index.ts +++ b/packages/extension/mcp/tools/code/tokens/source-index.ts @@ -31,6 +31,8 @@ export function buildSourceNameIndex( const cs = v.codeSyntax?.WEB?.trim() if (cs) { + // Literal codeSyntax can coincide with unrelated colors, lengths or classes. + // Per-node projection has already converted bound values to CSS references. let canonical = canonicalizeVarName(cs) if (!canonical) { const trimmed = cs.replace(/^[$@]/, '').trim() @@ -42,8 +44,6 @@ export function buildSourceNameIndex( if (canonical) addName(canonical, id) // Match the normalized name that may appear in var(--...) outputs. addName(normalizeFigmaVarName(cs), id) - // Non-var codeSyntax should also be matched (for example, rounded-2xl). - addName(cs, id) } const figmaName = normalizeFigmaVarName(v.name ?? '') diff --git a/packages/extension/mcp/tools/code/tree.ts b/packages/extension/mcp/tools/code/tree.ts index e2484654..25feb69c 100644 --- a/packages/extension/mcp/tools/code/tree.ts +++ b/packages/extension/mcp/tools/code/tree.ts @@ -8,11 +8,16 @@ import { toDecimalPlace } from '@/utils/number' import type { AutoLayoutHint, DataHint, NodeSnapshot, TreeStats, VisibleTree } from './model' +import { type GetCodeCacheContext, getChildOverflowCached } from './cache' import { getInferredAutoLayout } from './layout-parent' const NODE_WORK_BUDGET = 2000 -export function buildVisibleTree(roots: SceneNode[], checkpoint?: () => void): VisibleTree { +export function buildVisibleTree( + roots: SceneNode[], + checkpoint?: () => void, + cache?: GetCodeCacheContext +): VisibleTree { const depthLimit = suggestDepthLimit(roots, checkpoint, NODE_WORK_BUDGET) const stats: TreeStats = { totalNodes: 0, @@ -96,7 +101,7 @@ export function buildVisibleTree(roots: SceneNode[], checkpoint?: () => void): V // Bound the snapshot before native layout inference can inspect physical descendants. for (const snapshot of nodes.values()) { checkpoint?.() - const autoLayoutHint = resolveAutoLayoutHint(snapshot.node, checkpoint) + const autoLayoutHint = resolveAutoLayoutHint(snapshot.node, checkpoint, cache) if (autoLayoutHint) { snapshot.autoLayoutHint = autoLayoutHint snapshot.dataHint = snapshot.dataHint ?? {} @@ -126,12 +131,24 @@ function getRenderBounds( function resolveAutoLayoutHint( node: SceneNode, - checkpoint?: () => void + checkpoint?: () => void, + cache?: GetCodeCacheContext ): AutoLayoutHint | undefined { if ('layoutMode' in node && node.layoutMode && node.layoutMode !== 'NONE') { return undefined } - if (getInferredAutoLayout(node, undefined, checkpoint)) { + let clippedOverflow: boolean | undefined + if ( + cache && + 'inferredAutoLayout' in node && + 'clipsContent' in node && + node.clipsContent && + node.layoutMode === 'NONE' + ) { + const overflow = getChildOverflowCached(node, cache, checkpoint) + clippedOverflow = overflow.x || overflow.y + } + if (getInferredAutoLayout(node, clippedOverflow, checkpoint)) { return 'inferred' } return undefined diff --git a/packages/extension/mcp/tools/code/variables.ts b/packages/extension/mcp/tools/code/variables.ts deleted file mode 100644 index 8db009f6..00000000 --- a/packages/extension/mcp/tools/code/variables.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { CodegenConfig } from '@/utils/codegen' - -import { runTransformVariableBatch } from '@/mcp/transform-variables/requester' -import { workerUnitOptions } from '@/utils/codegen' -import { - canonicalizeVarName, - extractVarNames, - normalizeCustomPropertyBody, - preprocessCssValue, - replaceVarFunctions, - stripFallback, - toVarExpr -} from '@/utils/css' - -type VariableReferenceInternal = { - nodeId: string - property: string - code: string - name: string - value?: string -} - -type PropertyBucket = { - nodeId: string - property: string - value: string - matchIndices: number[] -} - -type TransformOptions = { - pluginCode?: string - config: CodegenConfig -} - -export function transform( - styles: Map>, - { pluginCode, config }: TransformOptions -): Promise> { - const { references, buckets } = collectRefs(styles) - if (!references.length) return Promise.resolve(new Set()) - - return runTransformVariableBatch( - references.map(({ code, name, value }) => ({ code, name, value })), - workerUnitOptions(config), - pluginCode - ).then((transformResults) => { - const replacements = transformResults.map((result) => { - const noFallback = stripFallback(result) - const canonicalName = canonicalizeVarName(noFallback) - return canonicalName ? toVarExpr(canonicalName) : noFallback - }) - const usedNames = new Set() - // Keep original reference names to handle transforms that inline literal values. - references.forEach(({ name }) => usedNames.add(`--${name}`)) - replacements.forEach((repl) => extractVarNames(repl).forEach((n) => usedNames.add(n))) - - for (const bucket of buckets.values()) { - const style = styles.get(bucket.nodeId) - if (!style) continue - - let occurrence = 0 - style[bucket.property] = replaceVarFunctions(bucket.value, ({ full }) => { - const refIndex = bucket.matchIndices[occurrence++]! - return replacements[refIndex] ?? full - }) - } - - return usedNames - }) -} - -export function collectRefs(styles: Map>) { - const references: VariableReferenceInternal[] = [] - const buckets = new Map() - - for (const [nodeId, style] of styles.entries()) { - for (const [property, value] of Object.entries(style)) { - let hasMatch = false - const indices: number[] = [] - - const normalized = preprocessCssValue(value) - if (normalized !== value) { - style[property] = normalized - } - - replaceVarFunctions(normalized, ({ full, name, fallback }) => { - const trimmed = name.trim() - if (!trimmed.startsWith('--')) return full - - hasMatch = true - const refIndex = - references.push({ - nodeId, - property, - code: full, - name: normalizeCustomPropertyBody(trimmed), - value: fallback?.trim() - }) - 1 - indices.push(refIndex) - return full - }) - - if (hasMatch) { - const key = `${nodeId}:${property}` - buckets.set(key, { nodeId, property, value: normalized, matchIndices: indices }) - } - } - } - return { references, buckets } -} diff --git a/packages/extension/mcp/tools/design-system.ts b/packages/extension/mcp/tools/design-system.ts index 5b7f869f..4ff4819d 100644 --- a/packages/extension/mcp/tools/design-system.ts +++ b/packages/extension/mcp/tools/design-system.ts @@ -1117,7 +1117,7 @@ function exactCatalogPayload( } } -function exactResultFits(result: DesignSystemResourcesResult): boolean { +function resultFits(result: DesignSystemResourcesResult): boolean { return ( measureCallToolResultBytes(buildGetDesignSystemToolResult(result)) <= MCP_TOOL_INLINE_BUDGET_BYTES @@ -1128,6 +1128,19 @@ function removeTailHalf(items: T[]): T[] { return items.splice(Math.floor(items.length / 2)) } +function reducePropertyOptions( + properties: Record | undefined +): number { + let removed = 0 + for (const property of Object.values(properties ?? {})) { + if (!property.options?.length) continue + const count = removeTailHalf(property.options).length + property.omittedOptions = (property.omittedOptions ?? 0) + count + removed += count + } + return removed +} + function compactComponentDetailResult( catalogId: string, entry: CatalogComponent, @@ -1156,7 +1169,7 @@ function compactComponentDetailResult( } const originalAnatomyCount = source.anatomy.nodes.length + (source.anatomy.omitted ?? 0) const result = () => exactCatalogPayload(catalogId, entry, detail) - const fits = () => exactResultFits(result()) + const fits = () => resultFits(result()) while (!fits() && detail.anatomy.nodes.length) { removeTailHalf(detail.anatomy.nodes) @@ -1192,14 +1205,7 @@ function compactComponentDetailResult( detail.detailTruncated = true } while (!fits()) { - let removed = 0 - for (const property of Object.values(mutableProperties ?? {})) { - if (!property.options?.length) continue - const removeCount = removeTailHalf(property.options).length - property.omittedOptions = (property.omittedOptions ?? 0) + removeCount - removed += removeCount - } - if (!removed) break + if (!reducePropertyOptions(mutableProperties)) break detail.detailTruncated = true } const propertyNames = Object.keys(mutableProperties ?? {}) @@ -1243,7 +1249,7 @@ function compactComponentDetailResult( ...(source.properties ? { omittedProperties: Object.keys(source.properties).length } : {}) } const minimalResult = exactCatalogPayload(catalogId, entry, minimalDefinition) - if (!exactResultFits(minimalResult)) { + if (!resultFits(minimalResult)) { throw new Error(`Design-system component identity "${entry.ref}" exceeds the inline budget.`) } return minimalResult @@ -1259,7 +1265,7 @@ async function exactCatalogResult( const definition = entry.kind === 'component' ? await describeComponentDetail(entry) : entry.definition const result = exactCatalogPayload(catalogId, entry, definition) - if (exactResultFits(result)) return result + if (resultFits(result)) return result if (entry.kind === 'component') { return compactComponentDetailResult(catalogId, entry, definition as ComponentDetail) } @@ -1268,6 +1274,25 @@ async function exactCatalogResult( type CatalogDisplayKind = Exclude +function boundCompactResult(result: DesignSystemResourcesResult): DesignSystemResourcesResult { + if (resultFits(result)) return result + for (const component of result.components) { + while (!resultFits(result)) { + if (!reducePropertyOptions(component.props)) break + } + const propertyNames = Object.keys(component.props) + while (!resultFits(result) && propertyNames.length) { + const removed = removeTailHalf(propertyNames) + for (const name of removed) delete component.props[name] + component.omittedProps = (component.omittedProps ?? 0) + removed.length + } + } + if (!resultFits(result)) { + throw new Error('Design-system catalog entry exceeds the 64 KiB inline result budget.') + } + return result +} + function orderEntries(entries: CatalogEntry[]): CatalogEntry[] { const interleave = (kinds: ReadonlyArray): CatalogEntry[] => { const groups = kinds.map((kind) => entries.filter((entry) => entry.kind === kind)) @@ -1357,7 +1382,7 @@ function buildCompactResult( } break } - return build() + return boundCompactResult(build()) } function continueCatalog(catalogId: string, cursor: number): DesignSystemResourcesResult { @@ -1400,13 +1425,16 @@ async function createCatalog(pageId?: string): Promise(['Div', 'Span', 'Br']) + const componentTags = new Set() for (const [index, family] of orderedComponents.entries()) { const component = family.item - const tag = uniqueName( - toIdentifier(component.componentSetName ?? component.name, `Component${index + 1}`, true), - componentTags + const baseTag = toIdentifier( + component.componentSetName ?? component.name, + `Component${index + 1}`, + true ) + if (/^(div|span|br)$/i.test(baseTag)) componentTags.add(baseTag) + const tag = uniqueName(baseTag, componentTags) entries.push({ kind: 'component', ref: `c${index + 1}`, diff --git a/packages/extension/mcp/tools/fonts.ts b/packages/extension/mcp/tools/fonts.ts index b97cf61b..db8f90b3 100644 --- a/packages/extension/mcp/tools/fonts.ts +++ b/packages/extension/mcp/tools/fonts.ts @@ -44,6 +44,8 @@ export async function queryAvailableFonts( const row = rows[index]! if (typeof row === 'string') result.families!.push(row) else result.fonts!.push(row) + if (index + 1 < rows.length) result.nextCursor = index + 1 + else delete result.nextCursor if (utf8Bytes(result) > TARGET_BYTES) { if (typeof row === 'string') result.families!.pop() else result.fonts!.pop() diff --git a/packages/extension/mcp/tools/token/candidates.ts b/packages/extension/mcp/tools/token/candidates.ts index 20e1559b..a8b0674b 100644 --- a/packages/extension/mcp/tools/token/candidates.ts +++ b/packages/extension/mcp/tools/token/candidates.ts @@ -1,21 +1,13 @@ import type { FigmaLookupReaders } from '@/utils/figma-style/types' -import { canonicalizeVarName, normalizeFigmaVarName, toFigmaVarExpr } from '@/utils/css' -import { collectNodeVariableIdsInto, getVariableRawName } from '@/utils/figma-variables' +import { collectNodeVariableIdsInto } from '@/utils/figma-variables' import { logger } from '@/utils/log' -import { getVariableByIdCached } from './cache' - const DEFAULT_READERS: FigmaLookupReaders = { getStyleById: (id: string) => figma.getStyleById(id), getVariableById: (id: string) => figma.variables.getVariableById(id) } -export type CandidateResult = { - variableIds: Set - rewrites: Map -} - type CandidateOptions = { checkpoint?: () => void traverseChildren?: boolean @@ -29,14 +21,12 @@ function hasChildren(node: SceneNode): node is SceneNode & ChildrenMixin { export function collectCandidateVariableIds( roots: SceneNode[], - cache?: Map, readers: FigmaLookupReaders = DEFAULT_READERS, options: CandidateOptions = {} -): CandidateResult { +): Set { const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) const startedAt = now() const variableIds = new Set() - const rewrites = new Map() const visit = (node: SceneNode) => { options.checkpoint?.() @@ -63,41 +53,9 @@ export function collectCandidateVariableIds( if (root.visible) visit(root) }) - const scannedAt = now() - - // Build lookup tables based on collected ids. - for (const id of variableIds) { - options.checkpoint?.() - const v = getVariableByIdCached(id, cache) - options.checkpoint?.() - if (!v) continue - - const canonical = normalizeFigmaVarName(getVariableRawName(v)) - - const cs = v.codeSyntax?.WEB?.trim() - if (cs) { - const canonicalFromSyntax = canonicalizeVarName(cs) - if (canonicalFromSyntax && !rewrites.has(cs)) { - rewrites.set(cs, { canonical, id }) - } - } - - const cssVar = toFigmaVarExpr(v.name ?? '')?.trim() - if (cssVar) { - if (!rewrites.has(cssVar)) { - rewrites.set(cssVar, { canonical, id }) - } - } - } - options.checkpoint?.() - const total = Math.round((now() - startedAt) * 10) / 10 - const scanMs = Math.round((scannedAt - startedAt) * 10) / 10 - const buildMs = Math.round((now() - scannedAt) * 10) / 10 - logger.debug( - `vars scan=${scanMs}ms build=${buildMs}ms total=${total}ms ids=${variableIds.size} rewrites=${rewrites.size}` - ) + logger.debug(`vars scan=${total}ms ids=${variableIds.size}`) - return { variableIds, rewrites } + return variableIds } diff --git a/packages/extension/mcp/tools/token/defs.ts b/packages/extension/mcp/tools/token/defs.ts index 638eacb3..bfe712cf 100644 --- a/packages/extension/mcp/tools/token/defs.ts +++ b/packages/extension/mcp/tools/token/defs.ts @@ -195,7 +195,11 @@ export async function resolveTokenDefsByIds( report(`Variable ${id}, mode ${modeId} has no usable definition.`) continue } - const modeName = collection?.modes.find((mode) => mode.modeId === modeId)?.name ?? modeId + const mode = collection?.modes.find((mode) => mode.modeId === modeId) + if (collection && !mode) { + report(`Mode ${modeId} in collection ${collection.id} is unavailable; definitions use IDs.`) + } + const modeName = mode?.name ?? modeId const key = collection?.name ? `${collection.name}:${modeName}` : modeName if (key in values) { report(`Variable ${id} has duplicate mode label "${key}".`) diff --git a/packages/extension/mcp/tools/token/mapping.ts b/packages/extension/mcp/tools/token/mapping.ts deleted file mode 100644 index 5be7967e..00000000 --- a/packages/extension/mcp/tools/token/mapping.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { normalizeFigmaVarName, replaceVarFunctions } from '@/utils/css' - -import type { CandidateResult } from './candidates' - -import { getVariableByIdCached } from './cache' - -export type VariableMappings = CandidateResult - -export function normalizeStyleVars( - styles: Map>, - mappings: VariableMappings, - cache?: Map -): Set { - const used = new Set() - if (!mappings) return used - - const syntaxMap = buildCodeSyntaxIndex(mappings.variableIds, cache) - const replaceMap = buildReplaceMap(mappings.variableIds, cache) - - for (const style of styles.values()) { - for (const [prop, raw] of Object.entries(style)) { - if (!raw) continue - const value = raw.trim() - if (!value) continue - - const rewrite = mappings.rewrites.get(value) - if (rewrite) { - style[prop] = `var(${rewrite.canonical})` - used.add(rewrite.id) - continue - } - - const matched = syntaxMap.get(value) - if (matched) { - used.add(matched.id) - style[prop] = `var(${matched.canonical})` - continue - } - - const replaced = replaceKnownNames(raw, replaceMap, used) - if (replaced !== raw) style[prop] = replaced - } - } - - return used -} - -function buildCodeSyntaxIndex( - variableIds: Set, - cache?: Map -): Map { - const map = new Map() - for (const id of variableIds) { - const v = getVariableByIdCached(id, cache) - if (!v) continue - const cs = v.codeSyntax?.WEB?.trim() - if (!cs) continue - if (cs.toLowerCase().startsWith('var(')) continue - if (!map.has(cs)) { - const canonical = getVariableCanonicalName(v) - if (canonical) map.set(cs, { canonical, id }) - } - } - return map -} - -type ReplaceEntry = { name: string; normalized: string; id: string } - -function buildReplaceMap( - variableIds: Set, - cache?: Map -): ReplaceEntry[] { - const entries: ReplaceEntry[] = [] - for (const id of variableIds) { - const v = getVariableByIdCached(id, cache) - if (!v) continue - - const normalizedName = normalizeFigmaVarName(v.name ?? '') - if (normalizedName && normalizedName !== '--unnamed') { - entries.push({ name: normalizedName, normalized: normalizedName, id }) - } - - const cs = v.codeSyntax?.WEB?.trim() - if (cs) { - const normalized = getVariableCanonicalName(v) - if (normalized) { - entries.push({ name: cs, normalized, id }) - } - } - } - - entries.sort((a, b) => b.name.length - a.name.length) - return entries -} - -function getVariableCanonicalName(variable: Variable): string | null { - const name = normalizeFigmaVarName(variable.name ?? '') - return name === '--unnamed' ? null : name -} - -function replaceKnownNames(value: string, entries: ReplaceEntry[], used: Set): string { - if (!value || !entries.length) return value - - const placeholders: string[] = [] - let out = replaceVarFunctions(value, ({ full }) => { - const token = `__VAR_${placeholders.length}__` - placeholders.push(full) - return token - }) - - let changed = false - - for (const entry of entries) { - const escaped = entry.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const re = new RegExp(`(^|[^A-Za-z0-9_-])(${escaped})(?=[^A-Za-z0-9_-]|$)`, 'g') - out = out.replace(re, (_match, prefix: string, _name: string, offset: number, str: string) => { - const start = offset + prefix.length - const left = str.slice(0, start).replace(/\s+$/, '') - if (left.toLowerCase().endsWith('var(')) { - return _match - } - used.add(entry.id) - changed = true - return `${prefix}var(${entry.normalized})` - }) - } - - if (placeholders.length) { - out = out.replace(/__VAR_(\d+)__/g, (_match, index: string) => { - const i = Number(index) - return Number.isFinite(i) ? (placeholders[i] ?? _match) : _match - }) - } - - return changed ? out : value -} diff --git a/packages/extension/package.json b/packages/extension/package.json index a360d95c..b3f31a1e 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -1,6 +1,6 @@ { "name": "@tempad-dev/extension", - "version": "0.23.4", + "version": "1.0.0", "private": true, "description": "Create, edit, and implement Figma designs with your coding agent.", "type": "module", @@ -36,7 +36,7 @@ "format:check": "oxfmt -c ../../.oxfmtrc.json --ignore-path ../../.gitignore --check" }, "dependencies": { - "@tempad-dev/plugins": "workspace:^0.6.2", + "@tempad-dev/plugins": "workspace:^1.0.0", "@tempad-dev/shared": "workspace:^0.1.0", "@vueuse/core": "^15.0.0", "es-module-lexer": "3.0.4", diff --git a/packages/extension/plugin-sandbox/client.ts b/packages/extension/plugin-sandbox/client.ts index da399506..3c17f988 100644 --- a/packages/extension/plugin-sandbox/client.ts +++ b/packages/extension/plugin-sandbox/client.ts @@ -37,6 +37,7 @@ export class PluginSandboxClient { readonly #pending = new Map() #connectPromise: Promise | null = null + #cancelConnect: ((error: Error) => void) | null = null #iframe: HTMLIFrameElement | null = null #nextId = 0 #port: MessagePort | null = null @@ -61,6 +62,9 @@ export class PluginSandboxClient { } const port = await this.#connect() + if (port !== this.#port) { + throw new Error('Plugin sandbox connection was reset before sending the request.') + } const id = this.#nextId this.#nextId += 1 @@ -101,11 +105,12 @@ export class PluginSandboxClient { if (this.#port) return this.#port if (this.#connectPromise) return await this.#connectPromise - this.#connectPromise = this.#createConnection() + const connecting = this.#createConnection() + this.#connectPromise = connecting try { - return await this.#connectPromise + return await connecting } finally { - this.#connectPromise = null + if (this.#connectPromise === connecting) this.#connectPromise = null } } @@ -123,6 +128,7 @@ export class PluginSandboxClient { const cleanup = () => { clearTimeout(timer) window.removeEventListener('message', onReady) + if (this.#cancelConnect === fail) this.#cancelConnect = null } const fail = (error: Error) => { if (settled) return @@ -179,6 +185,7 @@ export class PluginSandboxClient { ) } + this.#cancelConnect = fail window.addEventListener('message', onReady) document.documentElement.append(iframe) }) @@ -204,6 +211,7 @@ export class PluginSandboxClient { } #reset(error: Error): void { + this.#cancelConnect?.(error) this.#port?.close() this.#port = null this.#iframe?.remove() diff --git a/packages/extension/plugin-sandbox/limits.ts b/packages/extension/plugin-sandbox/limits.ts index acecf0db..c3ea2148 100644 --- a/packages/extension/plugin-sandbox/limits.ts +++ b/packages/extension/plugin-sandbox/limits.ts @@ -16,7 +16,6 @@ const encoder = new TextEncoder() export function inspectSandboxValue(value: unknown): SandboxValueInspection { const stack: Array<{ depth: number; exit?: boolean; value: unknown }> = [{ depth: 0, value }] const active = new WeakSet() - const seen = new WeakSet() let bytes = 0 let entries = 0 @@ -41,31 +40,40 @@ export function inspectSandboxValue(value: unknown): SandboxValueInspection { bytes += encoder.encode(current).byteLength } else if (typeof current === 'object') { if (active.has(current)) return invalid() - if (seen.has(current)) continue - seen.add(current) active.add(current) stack.push({ depth: item.depth, exit: true, value: current }) + let keys: string[] if (Array.isArray(current)) { entries += current.length if (entries > PLUGIN_SANDBOX_LIMITS.maxPayloadEntries) return tooLarge() + // Structured clone retains named array properties as well as indexed slots. + keys = Object.keys(current).filter((key) => { + const index = Number(key) + return ( + !Number.isInteger(index) || + index < 0 || + index >= current.length || + String(index) !== key + ) + }) for (let index = current.length - 1; index >= 0; index -= 1) { stack.push({ depth: item.depth + 1, value: current[index] }) } } else { const prototype = Object.getPrototypeOf(current) if (prototype !== Object.prototype && prototype !== null) return invalid() - const keys = Object.keys(current) - entries += keys.length - if (entries > PLUGIN_SANDBOX_LIMITS.maxPayloadEntries) return tooLarge() - for (const key of keys) { - if (key.length > PLUGIN_SANDBOX_LIMITS.maxPayloadBytes - bytes) return tooLarge() - bytes += encoder.encode(key).byteLength - stack.push({ - depth: item.depth + 1, - value: (current as Record)[key] - }) - } + keys = Object.keys(current) + } + entries += keys.length + if (entries > PLUGIN_SANDBOX_LIMITS.maxPayloadEntries) return tooLarge() + for (const key of keys) { + if (key.length > PLUGIN_SANDBOX_LIMITS.maxPayloadBytes - bytes) return tooLarge() + bytes += encoder.encode(key).byteLength + stack.push({ + depth: item.depth + 1, + value: (current as Record)[key] + }) } } else { return invalid() diff --git a/packages/extension/public/rules/figma.json b/packages/extension/public/rules/figma.json index e442f339..20dac72a 100644 --- a/packages/extension/public/rules/figma.json +++ b/packages/extension/public/rules/figma.json @@ -27,7 +27,9 @@ } }, "condition": { - "regexFilter": "/webpack-artifacts/assets/(?:figma_app[^.]+|[0-9a-zA-Z-]+)\\.min\\.js(?:\\.br)?$", + "requestDomains": ["www.figma.com"], + "initiatorDomains": ["www.figma.com"], + "regexFilter": "^https://www\\.figma\\.com/webpack-artifacts/assets/(?:figma_app[^.]+|[0-9a-zA-Z-]+)\\.min\\.js(?:\\.br)?$", "resourceTypes": ["script"] } } diff --git a/packages/extension/rewrite/config.ts b/packages/extension/rewrite/config.ts index 1bba5a2a..dcdcf9e7 100644 --- a/packages/extension/rewrite/config.ts +++ b/packages/extension/rewrite/config.ts @@ -24,7 +24,7 @@ export const GROUPS: Group[] = [ replacements: [ { pattern: - /let\{canRunExtensions:([A-Za-z_$][A-Za-z0-9_$]*),canAccessFullDevMode:([A-Za-z_$][A-Za-z0-9_$]*)\}=([A-Za-z_$][A-Za-z0-9_$]*).openFile;/, + /let\{canRunExtensions:([A-Za-z_$][A-Za-z0-9_$]*),canAccessFullDevMode:([A-Za-z_$][A-Za-z0-9_$]*)\}=([A-Za-z_$][A-Za-z0-9_$]*)\.openFile;/, replacer: 'let{canRunExtensions:$1,canAccessFullDevMode:$2}=$3.openFile;$1=true;' } ] diff --git a/packages/extension/rewrite/runtime.ts b/packages/extension/rewrite/runtime.ts index 69395be3..463fbb43 100644 --- a/packages/extension/rewrite/runtime.ts +++ b/packages/extension/rewrite/runtime.ts @@ -22,7 +22,7 @@ function replaceScript(current: HTMLScriptElement, src: string): void { } function withCurrentScript(current: HTMLScriptElement, run: () => void): void { - const descriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'currentScript') + const descriptor = Object.getOwnPropertyDescriptor(document, 'currentScript') Object.defineProperty(document, 'currentScript', { configurable: true, @@ -56,6 +56,7 @@ export async function rewriteCurrentScript(groups: Group[]): Promise { try { const response = await fetch(src) + if (!response.ok) throw new Error(`Script request failed with HTTP ${response.status}: ${src}`) const original = await response.text() const { content: rewritten, changed } = applyGroups(original, groups) diff --git a/packages/extension/screenshots/capture-runtime.ts b/packages/extension/screenshots/capture-runtime.ts new file mode 100644 index 00000000..3b742281 --- /dev/null +++ b/packages/extension/screenshots/capture-runtime.ts @@ -0,0 +1,860 @@ +import { AGENT_INTEGRATIONS } from '@tempad-dev/shared' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Locator, type Page } from 'playwright' + +import { + needsFixtureRuntime, + parseScreenshotOptions, + selectionOptions, + selectScenarios, + selectThemes, + type ScreenshotScenario +} from '../scripts/screenshot-plan' +import { captureDialogPng } from './dialog-capture.mjs' + +type Point = { x: number; y: number } +type Rect = Point & { height: number; width: number } +type Theme = 'dark' | 'light' + +type Assertion = { + kind: string + name?: string + text?: string + tool?: string + value?: boolean | number | string +} + +type Scenario = ScreenshotScenario & { + assertions: Assertion[] + clip?: Rect & { + anchor?: { + edge: string + kind: string + name?: string + offset?: Point + } + } + figma?: { + captureAnchor?: Point + focus: string + selection: string[] + zoom?: number + } + height: number + id: string + mcpStatus?: 'active' | 'inactive' | 'unavailable' + panel?: { + deepSelect?: boolean + focus?: { + name: string + role: string + selectText?: boolean + } + setupTarget?: string + setupScroll?: 'start' | 'end' + visibleActionIds?: string[] + mcpEnabled?: boolean + measure?: boolean + options?: { + cssUnit?: string + rootFontSize?: number + scale?: number + } + plugins?: string[] + preferencesOpen?: boolean + } + pointer: { + target: { + anchor?: 'center' | Point + control?: string + kind: string + marker?: string + name?: string + role?: string + } + tooltip?: string + visible: boolean + } + width: number +} + +type Manifest = { + baseline: { + panel: { + deepSelect: boolean + mcpEnabled: boolean + measure: boolean + plugins: string[] + preferencesOpen: boolean + } + } + capture: { + clipScale: number + canvasAnchor: Point + clip: Rect + hiddenPointer: Point + panel: Point + panelControls: Record + settleMs: number + sourceScale: number + themes: Theme[] + viewport: { height: number; width: number } + } + fixture: { + file: { key: string; title: string; url: string } + } + scenarios: Scenario[] +} + +type FixtureRuntime = { + bounds(marker: string): Rect + list(): Array<{ marker: string }> + setCanvasTheme(theme: Theme): unknown + stage(input: { + focus: string + selection: string[] + theme: Theme + x: number + y: number + zoom: number + }): Promise<{ selection: unknown[] }> +} + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const manifestPath = fileURLToPath(new URL('../screenshots/scenarios.json', import.meta.url)) +const fixtureRuntimePath = fileURLToPath( + new URL('../screenshots/fixture-runtime.js', import.meta.url) +) + +function fail(message: string): never { + throw new Error(message) +} + +function usage(): string { + return [ + 'Capture README screenshots from an already-open, logged-in Chrome:', + '', + ' pnpm screenshots capture --cdp-url http://127.0.0.1:9222', + ' pnpm screenshots capture --only code,unit,deep', + ' pnpm screenshots capture --group setup', + '', + 'Options:', + ' --cdp-url Chrome DevTools endpoint (default: TEMPAD_SCREENSHOT_CDP_URL or http://127.0.0.1:9222)', + ' --output-dir Candidate directory (default: .artifacts/marketing-screenshots)', + ' --only Comma-separated scenario ids', + ' --group inspect, setup, or status (exclusive with --only)', + ' --themes light,dark or one theme', + ' --help Show this help', + '', + 'The default run captures every scene except MCP unavailable/inactive.', + 'MCP unavailable/inactive captures are state-gated: prepare the real bridge state and leave', + 'TemPad Dev preferences open before running that scenario alone.' + ].join('\n') +} + +function readPngSize(buffer: Buffer): { height: number; width: number } | null { + const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) + if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) return null + return { height: buffer.readUInt32BE(20), width: buffer.readUInt32BE(16) } +} + +async function expectRect(locator: Locator, label: string): Promise { + const box = await locator.boundingBox() + if (!box) fail(`${label} is not visible.`) + return box +} + +async function isPreferencesOpen(page: Page): Promise { + return (await page.locator('article main').innerText()).startsWith('Tools') +} + +async function ensurePreferences(page: Page, open: boolean): Promise { + if ((await isPreferencesOpen(page)) === open) return + await page.locator('article button').nth(0).click() + await page.waitForTimeout(250) + if ((await isPreferencesOpen(page)) !== open) { + fail(`Could not ${open ? 'open' : 'close'} TemPad Dev preferences.`) + } +} + +async function setFigmaTheme(page: Page, theme: Theme): Promise { + await page.getByRole('button', { name: 'Main menu', exact: true }).click() + await page.getByRole('menuitem', { name: 'Preferences', exact: true }).click() + await page.getByRole('menuitem', { name: 'Theme', exact: true }).click() + await page + .getByRole('menuitemcheckbox', { name: theme === 'dark' ? 'Dark' : 'Light', exact: true }) + .click() + await page.waitForTimeout(500) +} + +async function minimizeFigmaUi(page: Page): Promise { + const collapse = page.getByRole('button', { name: /Collapse UI for file named|Minimize UI/ }) + if (await collapse.count()) { + await collapse.click() + await page.waitForTimeout(350) + } + if (!(await page.getByRole('button', { name: /Expand UI for file named/ }).count())) { + fail('Figma did not enter the minimized capture layout.') + } +} + +async function placePanel(page: Page, target: Point): Promise { + const panel = page.locator('article') + const panelBox = await expectRect(panel, 'TemPad Dev panel') + const delta = { x: target.x - panelBox.x, y: target.y - panelBox.y } + if (Math.abs(delta.x) < 1 && Math.abs(delta.y) < 1) return + + const headerBox = await expectRect(panel.locator('header').first(), 'TemPad Dev panel header') + const start = { x: headerBox.x + headerBox.width / 2, y: headerBox.y + headerBox.height / 2 } + await page.mouse.move(start.x, start.y) + await page.mouse.down() + await page.mouse.move(start.x + delta.x, start.y + delta.y, { steps: 12 }) + await page.mouse.up() + await page.waitForTimeout(300) + + const placed = await expectRect(panel, 'TemPad Dev panel') + if (Math.abs(placed.x - target.x) > 2 || Math.abs(placed.y - target.y) > 2) { + fail( + `TemPad Dev panel landed at ${placed.x.toFixed(1)},${placed.y.toFixed(1)} instead of ${target.x},${target.y}.` + ) + } +} + +export async function setSelect(page: Page, name: string, value: string): Promise { + const select = page.getByRole('combobox', { name, exact: true }) + if ((await select.inputValue()) === value) return + await select.selectOption(value) +} + +async function setInputValue(page: Page, value: number, index: number): Promise { + const inputs = page.locator('article main input') + const input = inputs.nth(index) + if ((await input.inputValue()) === String(value)) return + await input.fill(String(value)) + await input.press('Enter') +} + +async function setToggleButton(page: Page, index: number, active: boolean): Promise { + const button = page.locator('article main button').nth(index) + const selected = await button.evaluate((element) => + element.classList.contains('tp-button-selected') + ) + if (selected !== active) await button.click() +} + +async function setMcpEnabled(page: Page, enabled: boolean): Promise { + await page.getByRole('radio', { name: enabled ? 'Enabled' : 'Disabled', exact: true }).click() + await page.waitForTimeout(250) +} + +async function setKongPlugin(page: Page, enabled: boolean): Promise { + const checkbox = page.getByRole('checkbox', { name: 'Kong UI', exact: true }) + if ((await checkbox.isChecked()) !== enabled) await checkbox.setChecked(enabled, { force: true }) + await page.waitForTimeout(250) +} + +async function closeSetupDialog(page: Page): Promise { + const tempad = page.locator('tempad') + const setupDialog = tempad.getByRole('dialog', { name: 'Set up agents', exact: true }) + if (await setupDialog.count()) { + await setupDialog.getByRole('button', { name: 'Close', exact: true }).click() + await setupDialog.waitFor({ state: 'detached' }) + } +} + +async function resetPanel(page: Page): Promise { + await closeSetupDialog(page) + await ensurePreferences(page, true) + await setSelect(page, 'CSS unit', 'px') + await setSelect(page, 'Variable display', 'both') + await setInputValue(page, 16, 0) + await setInputValue(page, 1, 1) + await setToggleButton(page, 0, false) + await setToggleButton(page, 1, false) + await setMcpEnabled(page, false) + await setKongPlugin(page, false) +} + +async function configureScenario(page: Page, scenario: Scenario): Promise { + const panel = scenario.panel ?? {} + if (scenario.group === 'setup') { + await closeSetupDialog(page) + await ensurePreferences(page, true) + await setMcpEnabled(page, panel.mcpEnabled ?? true) + if (!panel.setupTarget) fail(`${scenario.id}: a setup target is required.`) + const tempad = page.locator('tempad') + await tempad.getByRole('button', { name: 'Set up agents', exact: true }).click() + await tempad.getByRole('tab', { name: panel.setupTarget, exact: true }).click() + await page + .locator('#tp-agent-setup-panel [data-overlayscrollbars-viewport]') + .press(panel.setupScroll === 'end' ? 'End' : 'Home') + return + } + await resetPanel(page) + + if (panel.options?.cssUnit) await setSelect(page, 'CSS unit', panel.options.cssUnit) + if (panel.options?.rootFontSize !== undefined) { + await setInputValue(page, panel.options.rootFontSize, 0) + } + if (panel.options?.scale !== undefined) await setInputValue(page, panel.options.scale, 1) + if (panel.deepSelect !== undefined) await setToggleButton(page, 0, panel.deepSelect) + if (panel.measure !== undefined) await setToggleButton(page, 1, panel.measure) + if (panel.mcpEnabled !== undefined) await setMcpEnabled(page, panel.mcpEnabled) + await setKongPlugin(page, panel.plugins?.includes('Kong UI') ?? false) + + if (panel.setupTarget) { + await page.locator('tempad').getByRole('button', { name: 'Set up agents', exact: true }).click() + const target = page.locator('tempad').getByRole('tab', { name: panel.setupTarget, exact: true }) + if ((await target.getAttribute('aria-selected')) !== 'true') await target.click() + } + + await ensurePreferences(page, panel.preferencesOpen ?? false) +} + +async function stageCanvas( + page: Page, + manifest: Manifest, + scenario: Scenario, + theme: Theme +): Promise<{ selection: unknown[] }> { + if (!scenario.figma) fail(`${scenario.id}: missing Figma fixture contract.`) + const anchor = scenario.figma.captureAnchor ?? manifest.capture.canvasAnchor + return page.evaluate( + ({ input }) => { + const runtime = ( + globalThis as typeof globalThis & { + __TEMPAD_README_SCREENSHOTS__: FixtureRuntime + } + ).__TEMPAD_README_SCREENSHOTS__ + return runtime.stage(input) + }, + { + input: { + focus: scenario.figma.focus, + selection: scenario.figma.selection, + theme, + x: anchor.x, + y: anchor.y, + zoom: scenario.figma.zoom ?? 1 + } + } + ) +} + +async function fixtureBounds(page: Page, marker: string): Promise { + return page.evaluate((value) => { + const runtime = ( + globalThis as typeof globalThis & { + __TEMPAD_README_SCREENSHOTS__: FixtureRuntime + } + ).__TEMPAD_README_SCREENSHOTS__ + return runtime.bounds(value) + }, marker) +} + +async function panelControl(page: Page, scenario: Scenario): Promise { + const target = scenario.pointer.target + if (target.control === 'scrollIntoView') { + return page.locator('article main button[data-tooltip="Scroll into view"]') + } + if (target.role === 'spinbutton' && target.name) { + return page.getByRole('spinbutton', { name: target.name }) + } + fail(`Unsupported panel pointer target in ${scenario.id}.`) +} + +async function pointerPoint(page: Page, manifest: Manifest, scenario: Scenario): Promise { + const target = scenario.pointer.target + if (!scenario.pointer.visible || target.kind === 'hiddenPointer') { + return manifest.capture.hiddenPointer + } + if (target.kind === 'canvas-node' && target.marker) { + const bounds = await fixtureBounds(page, target.marker) + const anchor = target.anchor ?? 'center' + const normalized = anchor === 'center' ? { x: 0.5, y: 0.5 } : anchor + return { + x: bounds.x + bounds.width * normalized.x, + y: bounds.y + bounds.height * normalized.y + } + } + if (target.kind === 'panel-control') { + const bounds = await expectRect( + await panelControl(page, scenario), + `${scenario.id} pointer target` + ) + const anchor = target.anchor ?? 'center' + const normalized = anchor === 'center' ? { x: 0.5, y: 0.5 } : anchor + return { + x: bounds.x + bounds.width * normalized.x, + y: bounds.y + bounds.height * normalized.y + } + } + fail(`Unsupported pointer target ${target.kind} in ${scenario.id}.`) +} + +async function renderPointer(page: Page, point: Point, visible: boolean): Promise { + await page.evaluate( + ({ point: value, visible: show }) => { + document.querySelector('#tempad-readme-cursor')?.remove() + if (!show) return + const cursor = document.createElement('div') + cursor.id = 'tempad-readme-cursor' + cursor.style.cssText = [ + 'position:fixed', + `left:${value.x}px`, + `top:${value.y}px`, + 'width:32px', + 'height:32px', + 'pointer-events:none', + 'z-index:2147483647', + 'transform:translate(-5px,-3.5px)', + 'filter:drop-shadow(0 0 7px rgba(0,152,255,.8))' + ].join(';') + cursor.innerHTML = + '' + document.body.append(cursor) + }, + { point, visible } + ) +} + +async function stabilizeCompositor(page: Page, point: Point): Promise { + await page.evaluate((value) => { + document.querySelector('#tempad-readme-compositor')?.remove() + const guard = document.createElement('div') + guard.id = 'tempad-readme-compositor' + guard.style.cssText = [ + 'position:fixed', + `left:${value.x}px`, + `top:${value.y}px`, + 'width:32px', + 'height:32px', + 'pointer-events:none', + 'z-index:2147483647', + 'filter:drop-shadow(0 0 7px rgba(0,152,255,.8))' + ].join(';') + guard.innerHTML = + '' + document.body.append(guard) + }, point) +} + +async function resolveClip(page: Page, manifest: Manifest, scenario: Scenario): Promise { + const source = { ...manifest.capture.clip, ...scenario.clip } + const clip = { height: source.height, width: source.width, x: source.x, y: source.y } + const anchor = scenario.clip?.anchor + if (!anchor) return clip + + const offset = anchor.offset ?? { x: 0, y: 0 } + if (anchor.kind === 'panel') { + const panel = await expectRect(page.locator('article'), 'TemPad Dev panel') + return { ...clip, x: panel.x + offset.x, y: panel.y + offset.y } + } + if (anchor.kind === 'panel-section' && anchor.name) { + const heading = page.getByText(anchor.name, { exact: true }).first() + const section = heading.locator('xpath=ancestor::section[1]') + const box = await expectRect(section, `${anchor.name} section`) + return { ...clip, x: box.x + offset.x, y: box.y + offset.y } + } + if (anchor.kind === 'dialog') { + const dialog = await expectRect( + page.locator('tempad').getByRole('dialog', { name: anchor.name, exact: true }), + `${anchor.name ?? 'Open'} dialog` + ) + return { ...clip, x: dialog.x + offset.x, y: dialog.y + offset.y } + } + fail(`Unsupported clip anchor ${anchor.kind} in ${scenario.id}.`) +} + +async function assertMcpStatus( + page: Page, + status: NonNullable +): Promise { + const badge = page.locator('article .tp-mcp-badge') + const className = await badge.getAttribute('class') + const expected = { + active: 'tp-mcp-badge-active', + inactive: 'tp-mcp-badge-inactive', + unavailable: 'tp-mcp-badge-error' + }[status] + if (!className?.includes(expected)) { + fail(`Expected MCP ${status}, got badge class ${className ?? ''}.`) + } +} + +export async function assertScenario( + page: Page, + scenario: Scenario, + stage: { selection: unknown[] } | null, + clip: Rect +): Promise { + const panelText = await page.locator('article main').innerText() + let dialogText: string | undefined + if (scenario.mcpStatus) await assertMcpStatus(page, scenario.mcpStatus) + + for (const assertion of scenario.assertions) { + switch (assertion.kind) { + case 'setup-contract': { + const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel?.setupTarget) + if (!target) fail(`${scenario.id}: unknown agent setup target.`) + const selected = page.locator('tempad').getByRole('tab', { name: target.name, exact: true }) + if ((await selected.getAttribute('aria-selected')) !== 'true') { + fail(`${scenario.id}: ${target.name} is not the selected setup target.`) + } + const content = await page.locator('#tp-agent-setup-panel').innerText() + const pluginActions = target.actions.filter(({ id }) => id.startsWith('plugin-')) + const actions = pluginActions.length ? pluginActions : target.actions + for (const action of actions) { + if (action.kind !== 'deep-link' && !content.includes(action.value)) { + fail( + `${scenario.id}: displayed ${action.id} does not match the current shared setup configuration.` + ) + } + } + break + } + case 'setup-visible-actions': { + const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel?.setupTarget) + if (!target) fail(`${scenario.id}: unknown setup target.`) + const viewport = await expectRect( + page.locator('#tp-agent-setup-panel [data-overlayscrollbars-viewport]'), + 'Setup content' + ) + for (const id of scenario.panel?.visibleActionIds ?? []) { + const action = target.actions.find((action) => action.id === id) + if (!action) fail(`${scenario.id}: unknown action ${id}.`) + const code = await expectRect( + page.locator('#tp-agent-setup-panel code').filter({ hasText: action.value }), + id + ) + if (code.y < viewport.y - 1 || code.y + code.height > viewport.y + viewport.height + 1) { + fail(`${scenario.id}: ${id} is clipped at the declared scroll position.`) + } + } + break + } + case 'dialog-fits-clip': { + const dialog = await expectRect( + page.locator('tempad').getByRole('dialog', { name: 'Set up agents', exact: true }), + 'Agent setup dialog' + ) + if ( + dialog.x < clip.x - 1 || + dialog.y < clip.y - 1 || + dialog.x + dialog.width > clip.x + clip.width + 1 || + dialog.y + dialog.height > clip.y + clip.height + 1 + ) { + fail(`${scenario.id}: the capture would crop the agent setup dialog.`) + } + break + } + case 'figma-selection-count': + if (stage && stage.selection.length !== assertion.value) { + fail( + `${scenario.id}: selection count is ${stage.selection.length}, not ${assertion.value}.` + ) + } + break + case 'mcp-status': + break + case 'panel-control-state': { + if (assertion.name === 'MCP access') { + const enabled = await page + .getByRole('radio', { name: 'Enabled', exact: true }) + .isChecked() + if (enabled !== assertion.value) fail(`${scenario.id}: MCP enabled state is incorrect.`) + } + break + } + case 'panel-control-value': { + if (!assertion.name) fail(`${scenario.id}: panel-control-value requires a control name.`) + const select = page.getByRole('combobox', { name: assertion.name, exact: true }) + const control = + (await select.count()) > 0 + ? select + : page.getByRole('spinbutton', { name: assertion.name, exact: true }) + if (!(await control.count())) { + fail(`${scenario.id}: unsupported or missing control ${assertion.name}.`) + } + if ((await control.inputValue()) !== String(assertion.value)) { + fail(`${scenario.id}: ${assertion.name} does not show ${assertion.value}.`) + } + break + } + case 'panel-tool-active': { + const index = assertion.tool === 'deepSelect' ? 0 : 1 + const active = await page + .locator('article main button') + .nth(index) + .evaluate((element) => element.classList.contains('tp-button-selected')) + if (!active) fail(`${scenario.id}: ${assertion.tool} is not active.`) + break + } + case 'tooltip': + if (assertion.text && !(await page.getByText(assertion.text, { exact: true }).count())) { + fail(`${scenario.id}: tooltip ${assertion.text} is not visible.`) + } + break + case 'panel-text-absent': + if (assertion.text && panelText.includes(assertion.text)) { + fail(`${scenario.id}: panel unexpectedly contains ${assertion.text}.`) + } + break + case 'dialog-text': { + dialogText ??= await page + .locator('tempad') + .getByRole('dialog', { name: 'Set up agents', exact: true }) + .innerText() + if (assertion.text && !dialogText.includes(assertion.text)) { + fail(`${scenario.id}: dialog does not contain ${assertion.text}.`) + } + break + } + case 'panel-title': + case 'panel-heading': + case 'panel-badge': + case 'panel-text': + if (assertion.text && !panelText.includes(assertion.text)) { + fail(`${scenario.id}: panel does not contain ${assertion.text}.`) + } + break + default: + fail(`${scenario.id}: unsupported screenshot assertion ${assertion.kind}.`) + } + } +} + +async function prepareFocus(page: Page, scenario: Scenario): Promise { + const focus = scenario.panel?.focus + if (!focus) return + const input = page.getByRole(focus.role as 'spinbutton', { name: focus.name }) + await input.focus() + if (focus.selectText) await input.evaluate((element) => (element as HTMLInputElement).select()) +} + +async function captureScenario( + page: Page, + manifest: Manifest, + scenario: Scenario, + theme: Theme, + outputDir: string +): Promise { + const isStateGated = scenario.mcpStatus === 'inactive' || scenario.mcpStatus === 'unavailable' + if (!isStateGated) await configureScenario(page, scenario) + if (isStateGated && !(await isPreferencesOpen(page))) { + fail(`${scenario.id}: leave TemPad Dev preferences open before capturing this MCP state.`) + } + + const stage = + isStateGated || scenario.group === 'setup' + ? null + : await stageCanvas(page, manifest, scenario, theme) + if (isStateGated) { + await page.evaluate((value) => { + const runtime = ( + globalThis as typeof globalThis & { + __TEMPAD_README_SCREENSHOTS__: FixtureRuntime + } + ).__TEMPAD_README_SCREENSHOTS__ + runtime.setCanvasTheme(value) + }, theme) + } + + await prepareFocus(page, scenario) + const point = await pointerPoint(page, manifest, scenario) + await page.mouse.move(point.x, point.y) + await renderPointer(page, point, scenario.pointer.visible) + await stabilizeCompositor(page, manifest.capture.hiddenPointer) + await page.waitForTimeout(scenario.pointer.tooltip ? 700 : manifest.capture.settleMs) + const clip = await resolveClip(page, manifest, scenario) + await assertScenario(page, scenario, stage, clip) + const outputPath = resolve(outputDir, `${scenario.id}-${theme}.png`) + if (scenario.group === 'setup') { + const cdp = await page.context().newCDPSession(page) + try { + const capture = await captureDialogPng((method, params) => cdp.send(method, params), { + ...clip, + scale: manifest.capture.clipScale + }) + await writeFile(outputPath, Buffer.from(capture.data, 'base64')) + } finally { + await cdp.detach() + } + } else { + await page.screenshot({ animations: 'disabled', clip, path: outputPath, scale: 'device' }) + } + const size = readPngSize(await readFile(outputPath)) + if (!size || size.width !== scenario.width || size.height !== scenario.height) { + fail( + `${scenario.id}-${theme}: expected ${scenario.width}x${scenario.height}, got ${size ? `${size.width}x${size.height}` : 'a non-PNG file'}.` + ) + } + console.log(`Captured ${outputPath}`) +} + +export async function captureScreenshots(): Promise { + const options = parseScreenshotOptions({ + ...selectionOptions, + 'cdp-url': { type: 'string' }, + 'output-dir': { type: 'string' }, + help: { type: 'boolean', short: 'h' } + }) + if (options.help) { + console.log(usage()) + return + } + + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest + const fixtureRuntime = await readFile(fixtureRuntimePath, 'utf8') + const cdpUrl = + options['cdp-url'] ?? process.env.TEMPAD_SCREENSHOT_CDP_URL ?? 'http://127.0.0.1:9222' + const outputDirArgument = options['output-dir'] + const outputDir = outputDirArgument + ? resolve(repoRoot, outputDirArgument) + : `${repoRoot}.artifacts/marketing-screenshots` + const scenarios = selectScenarios(manifest.scenarios, { + only: options.only, + group: options.group, + capture: true + }) + const selectedThemes = new Set(selectThemes(manifest.capture.themes, options.themes ?? null)) + const orderedScenarios = [...scenarios].sort( + (a, b) => Number(a.id === 'plugins') - Number(b.id === 'plugins') + ) + const useFixtures = needsFixtureRuntime(scenarios) + const hasStateGatedScenario = scenarios.some(({ mcpStatus }) => + ['inactive', 'unavailable'].includes(mcpStatus ?? '') + ) + + await mkdir(outputDir, { recursive: true }) + const browser = await chromium.connectOverCDP(cdpUrl) + const failures: unknown[] = [] + const attemptRestore = async (restore: () => Promise) => { + try { + await restore() + } catch (error) { + failures.push(error) + } + } + let page: Page | undefined + let restorePage = false + let fixturesReady = false + + try { + const pages = browser.contexts().flatMap((context) => context.pages()) + page = pages.find((candidate) => candidate.url().includes(manifest.fixture.file.key)) + if (!page) fail(`Open ${manifest.fixture.file.url} in the connected Chrome before capture.`) + + await page.bringToFront() + await page.waitForLoadState('domcontentloaded') + const viewport = await page.evaluate(() => ({ height: innerHeight, width: innerWidth })) + if ( + viewport.width !== manifest.capture.viewport.width || + viewport.height !== manifest.capture.viewport.height + ) { + fail( + `Expected viewport ${manifest.capture.viewport.width}x${manifest.capture.viewport.height}, got ${viewport.width}x${viewport.height}.` + ) + } + + if (useFixtures) { + const cdp = await page.context().newCDPSession(page) + try { + const editable = await cdp.send('Runtime.evaluate', { + expression: + "typeof figma !== 'undefined' && figma.editorType === 'figma' && figma.mode === 'default'", + returnByValue: true + }) + if (!editable.result.value) { + fail( + 'Fixture capture requires an editable Figma Design file. Setup-only captures work with --group setup without changing canvas content.' + ) + } + restorePage = true + const result = await cdp.send('Runtime.evaluate', { + awaitPromise: true, + expression: fixtureRuntime, + returnByValue: true + }) + if (result.exceptionDetails) + fail('Could not prepare the canonical Figma fixtures. No screenshots were captured.') + fixturesReady = true + } finally { + await cdp.detach() + } + await page.waitForFunction(() => '__TEMPAD_README_SCREENSHOTS__' in globalThis) + const markers = await page.evaluate(() => { + const runtime = ( + globalThis as typeof globalThis & { + __TEMPAD_README_SCREENSHOTS__: FixtureRuntime + } + ).__TEMPAD_README_SCREENSHOTS__ + return runtime.list().map((node) => node.marker) + }) + const requiredMarkers = new Set( + scenarios.flatMap(({ figma }) => (figma ? [figma.focus, ...figma.selection] : [])) + ) + if ([...requiredMarkers].some((marker) => !markers.includes(marker))) { + fail('The canonical Figma fixture page is incomplete.') + } + await minimizeFigmaUi(page) + if (!hasStateGatedScenario) await placePanel(page, manifest.capture.panel) + } + + restorePage = true + for (const theme of manifest.capture.themes) { + if (selectedThemes.has(theme)) { + await closeSetupDialog(page) + await setFigmaTheme(page, theme) + for (const scenario of orderedScenarios) { + await captureScenario(page, manifest, scenario, theme, outputDir) + } + } + } + } catch (error) { + failures.push(error) + } finally { + const capturedPage = page + if (capturedPage && restorePage) { + await attemptRestore(() => closeSetupDialog(capturedPage)) + await attemptRestore(() => setFigmaTheme(capturedPage, 'light')) + if (fixturesReady) { + await attemptRestore(() => + capturedPage.evaluate(() => { + const runtime = ( + globalThis as typeof globalThis & { + __TEMPAD_README_SCREENSHOTS__: FixtureRuntime + } + ).__TEMPAD_README_SCREENSHOTS__ + runtime.setCanvasTheme('light') + }) + ) + } + await attemptRestore(() => + capturedPage.evaluate(() => { + document.querySelector('#tempad-readme-cursor')?.remove() + document.querySelector('#tempad-readme-compositor')?.remove() + }) + ) + if (fixturesReady && !hasStateGatedScenario) { + await attemptRestore(() => resetPanel(capturedPage)) + await attemptRestore(() => ensurePreferences(capturedPage, false)) + const code = manifest.scenarios.find((scenario) => scenario.id === 'code') + if (code) await attemptRestore(() => stageCanvas(capturedPage, manifest, code, 'light')) + } + } + // Closing a connectOverCDP handle disconnects this client, not the user's Chrome. + await attemptRestore(() => browser.close()) + } + + if (failures.length === 1) throw failures[0] + if (failures.length) { + throw new AggregateError( + failures, + `Screenshot capture or restoration failed: ${failures.map((error) => (error instanceof Error ? error.message : String(error))).join('; ')}` + ) + } + console.log(`Candidates written to ${outputDir}`) +} diff --git a/packages/extension/screenshots/fixture-runtime.js b/packages/extension/screenshots/fixture-runtime.js index c9ef9aee..3a784d96 100644 --- a/packages/extension/screenshots/fixture-runtime.js +++ b/packages/extension/screenshots/fixture-runtime.js @@ -21,38 +21,25 @@ } } - let page = figma.root.children.find( - (node) => node.type === 'PAGE' && node.name === 'README Fixtures' - ) - - if (!page) { - page = figma.root.children.find( - (node) => node.type === 'PAGE' && node.findOne(isKongButton) !== null - ) - - if (page) { - page.name = 'README Fixtures' - } else { - page = figma.createPage() - page.name = 'README Fixtures' - } - } - - figma.currentPage = page - - const markedPluginFixture = page.findOne( - (node) => markerOf(node) === 'plugins' && node.type === 'INSTANCE' - ) - const selectedKongButton = page.selection.find(isKongButton) - const kongButton = page.findOne(isKongButton) - const button = markedPluginFixture ?? selectedKongButton ?? kongButton - - if (!button || button.type !== 'INSTANCE') { + const page = + figma.root.children.find((node) => node.type === 'PAGE' && node.name === 'README Fixtures') ?? + figma.root.children.find((node) => node.type === 'PAGE' && node.findOne(isKongButton) !== null) + const button = + page?.findOne((node) => markerOf(node) === 'plugins' && isKongButton(node)) ?? + page?.selection.find(isKongButton) ?? + page?.findOne(isKongButton) + + if (!page || !button) { throw new Error( 'A real remote Kong Button instance is required on README Fixtures before running this script' ) } + // Validate before changing the scene, and keep the real instance out of geometry being replaced. + if (button.parent !== page) page.appendChild(button) + page.name = 'README Fixtures' + figma.currentPage = page + const managedTopLevelMarkers = new Set(['code', 'deep_outer', 'measure_outer', 'plugins']) for (const child of [...page.children]) { if (child !== button && managedTopLevelMarkers.has(markerOf(child))) { diff --git a/packages/extension/screenshots/scenarios.json b/packages/extension/screenshots/scenarios.json index dd87d80c..48267a71 100644 --- a/packages/extension/screenshots/scenarios.json +++ b/packages/extension/screenshots/scenarios.json @@ -333,12 +333,9 @@ { "kind": "panel-title", "text": "Frame 2" - }, - { - "kind": "measurement-labels", - "values": [20, 20, 20, 20] } ], + "visualReview": [{ "kind": "measurement-labels", "values": [20, 20, 20, 20] }], "width": 1440, "height": 960, "group": "inspect", diff --git a/packages/extension/scripts/agent-authoring-runtime-preflight.ts b/packages/extension/scripts/agent-authoring-runtime-preflight.ts index e67fa1dc..93b078e5 100644 --- a/packages/extension/scripts/agent-authoring-runtime-preflight.ts +++ b/packages/extension/scripts/agent-authoring-runtime-preflight.ts @@ -1,13 +1,20 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' -import { access, readFile, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, isAbsolute, join, normalize, resolve } from 'node:path' +import { access, readFile, realpath, stat } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { promisify } from 'node:util' - -import { resolveDefaultRoot } from '../../../agent-plugin/src/clients/shared/runtime-paths.mjs' +import { isDeepStrictEqual, promisify } from 'node:util' + +import { + resolveRuntimeProfile, + runtimeDirectories +} from '../../../agent-plugin/src/clients/shared/runtime-profile.mjs' +import { + nativeFixtureHubEntry, + nativeFixtureServer, + readNativeRuntimeFixture +} from '../../../scripts/native-runtime-fixture.mjs' const execFileAsync = promisify(execFile) const defaultRepoRoot = fileURLToPath(new URL('../../../', import.meta.url)) @@ -22,6 +29,7 @@ const monthIndexes = new Map( export interface RuntimeProcess { command: string + executable: string | null pid: number ppid: number startedAtMs: number @@ -30,6 +38,7 @@ export interface RuntimeProcess { interface RuntimePaths { cli: string hub: string + managed?: boolean } interface EnabledPlugin { @@ -277,20 +286,28 @@ export function evaluateActiveExtensionRuntime( } } -/** Matches the path as a whole argument, so a longer path that merely starts with it cannot pass. */ -export function exactPathPattern(path: string): RegExp { - const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return new RegExp(`(?:^|[\\s'"])${escapedPath}(?=$|[\\s'"])`) +function argumentPattern(value: string): string { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return `(?:${escaped}|"${escaped}"|'${escaped}')` } -export function commandIncludesExactPath(command: string, path: string): boolean { - return exactPathPattern(path).test(command) +/** Generated CLI and Hub launches put the entry immediately after the Node executable. */ +export function processRunsRuntimeEntry(process: RuntimeProcess, entry: string): boolean { + if (!process.executable || !['node', 'nodejs'].includes(basename(process.executable))) { + return false + } + const executable = [process.executable, basename(process.executable)] + .map(argumentPattern) + .join('|') + return new RegExp(`^(?:${executable})\\s+${argumentPattern(entry)}(?=$|\\s)`).test( + process.command + ) } -export function parseProcessTable(output: string): RuntimeProcess[] { +function parseProcessRows(output: string): Omit[] { return output .split('\n') - .map((line): RuntimeProcess | null => { + .map((line): Omit | null => { const match = line.match( /^\s*(\d+)\s+(\d+)\s+\S+\s+(\S+)\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})\s+(.*)$/ ) @@ -321,7 +338,21 @@ export function parseProcessTable(output: string): RuntimeProcess[] { if (!Number.isFinite(startedAtMs)) return null return { command: command.trim(), pid: Number(pid), ppid: Number(ppid), startedAtMs } }) - .filter((process): process is RuntimeProcess => process !== null) + .filter((process): process is Omit => process !== null) +} + +export function parseProcessTable(output: string, executableOutput: string): RuntimeProcess[] { + const executables = new Map(parseProcessRows(executableOutput).map((entry) => [entry.pid, entry])) + return parseProcessRows(output).map((process) => { + const executable = executables.get(process.pid) + return { + ...process, + executable: + executable?.ppid === process.ppid && executable.startedAtMs === process.startedAtMs + ? executable.command + : null + } + }) } export function evaluateRuntimeProcesses( @@ -332,8 +363,12 @@ export function evaluateRuntimeProcesses( hub: RuntimeProcess[] issues: PreflightIssue[] } { - const cli = processes.filter(({ command }) => commandIncludesExactPath(command, paths.cli)) - const hub = processes.filter(({ command }) => commandIncludesExactPath(command, paths.hub)) + const cli = processes.filter((process) => processRunsRuntimeEntry(process, paths.cli)) + const hub = processes.filter( + (process) => + processRunsRuntimeEntry(process, paths.hub) && + (!paths.managed || /\s--role hub(?:\s|$)/.test(process.command)) + ) const issues: PreflightIssue[] = [] if (cli.length === 0 && hub.length === 0) { @@ -394,14 +429,6 @@ export function evaluateTempadPluginIdentity( return { installed, issues } } -export function resolveDefaultRuntimeDir( - systemTmpDir: string = tmpdir(), - platform: NodeJS.Platform = process.platform, - uid: number | undefined = process.getuid?.() -): string { - return join(resolveDefaultRoot(systemTmpDir, platform, uid), 'run') -} - async function resolveRuntimeConfiguration(checkout: string): Promise { const pluginRoot = join(checkout, '.dev/plugins/tempad-dev-dev') const manifestPath = join(pluginRoot, '.codex-plugin/plugin.json') @@ -419,19 +446,49 @@ async function resolveRuntimeConfiguration(checkout: string): Promise actual === expected) + .catch(() => false)) + if (!sameCheckout) { + fail( + 'Generated development plugin CLI does not belong to this checkout. Run pnpm agent-plugin:build before preflight.' + ) + } const hub = join(dirname(cli), 'hub.mjs') const serverEnv = server.env === undefined ? {} : objectValue(server.env, `${mcpPath}#env`) const configuredRuntimeDir = serverEnv.TEMPAD_MCP_RUNTIME_DIR - if (configuredRuntimeDir !== undefined && typeof configuredRuntimeDir !== 'string') { - fail(`Invalid TEMPAD_MCP_RUNTIME_DIR in ${mcpPath}.`) - } - const runtimeDir = configuredRuntimeDir - ? normalize( - isAbsolute(configuredRuntimeDir) - ? configuredRuntimeDir - : resolve(pluginRoot, configuredRuntimeDir) - ) - : resolveDefaultRuntimeDir() + if ( + configuredRuntimeDir !== undefined && + (typeof configuredRuntimeDir !== 'string' || + (configuredRuntimeDir && !isAbsolute(configuredRuntimeDir))) + ) { + fail(`TEMPAD_MCP_RUNTIME_DIR in ${mcpPath} must be an absolute path.`) + } + if (typeof serverEnv.TEMPAD_MCP_DEV_CHECKOUT !== 'string') + fail(`Missing checkout identity in ${mcpPath}. Run pnpm agent-plugin:build.`) + const env = { + ...process.env, + TEMPAD_MCP_DEV_CHECKOUT: serverEnv.TEMPAD_MCP_DEV_CHECKOUT, + ...(configuredRuntimeDir !== undefined ? { TEMPAD_MCP_RUNTIME_DIR: configuredRuntimeDir } : {}) + } + const runtimeDir = runtimeDirectories(env, resolveRuntimeProfile(env, pathToFileURL(cli))).runtime await Promise.all([access(cli), access(hub)]) return { generatedVersion: manifest.version, @@ -451,12 +508,22 @@ async function resolveCheckoutExtensionFingerprint(checkout: string): Promise { - const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,lstart=,command='], { - encoding: 'utf8', - maxBuffer: 4 * 1024 * 1024 - }) - return parseProcessTable(stdout) +export async function listRuntimeProcesses( + platform: NodeJS.Platform = process.platform +): Promise { + // Keep each free-form value last: command paths can contain spaces. Reject rows + // whose parent or start time changed between the command and executable snapshots. + const readTable = (field: string) => + execFileAsync('ps', ['-axww', '-o', `pid=,ppid=,lstart=,${field}=`], { + encoding: 'utf8', + env: { ...process.env, LC_ALL: undefined, LC_TIME: 'C' }, + maxBuffer: 16 * 1024 * 1024 + }) + const [commands, executables] = await Promise.all([ + readTable('command'), + readTable(platform === 'linux' ? 'exe' : 'comm') + ]) + return parseProcessTable(commands.stdout, executables.stdout) } export function resolveCodexExecutable( @@ -519,7 +586,7 @@ export async function runPreflight( stat(runtime.paths.cli), stat(runtime.paths.hub), readFile(runtime.paths.hub), - listProcesses(), + listRuntimeProcesses(), listEnabledPlugins(codexExecutable), resolveCheckoutExtensionFingerprint(args.checkout), readOptionalJson(runtime.hubRuntimeIdentityPath), diff --git a/packages/extension/scripts/capture-screenshots.ts b/packages/extension/scripts/capture-screenshots.ts index 88e1fd79..d98c82c5 100644 --- a/packages/extension/scripts/capture-screenshots.ts +++ b/packages/extension/scripts/capture-screenshots.ts @@ -1,821 +1,6 @@ -import { AGENT_INTEGRATIONS } from '@tempad-dev/shared' -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { chromium, type Locator, type Page } from 'playwright' +import { captureScreenshots } from '../screenshots/capture-runtime' -import { captureDialogPng } from '../screenshots/dialog-capture.mjs' -import { - needsFixtureRuntime, - selectScenarios, - selectThemes, - type ScreenshotScenario -} from './screenshot-plan' - -type Point = { x: number; y: number } -type Rect = Point & { height: number; width: number } -type Theme = 'dark' | 'light' - -type Assertion = { - kind: string - name?: string - text?: string - tool?: string - value?: boolean | number | string - values?: number[] -} - -type Scenario = ScreenshotScenario & { - assertions: Assertion[] - clip?: Rect & { - anchor?: { - edge: string - kind: string - name?: string - offset?: Point - } - } - figma?: { - captureAnchor?: Point - focus: string - selection: string[] - zoom?: number - } - height: number - id: string - mcpStatus?: 'active' | 'inactive' | 'unavailable' - panel?: { - deepSelect?: boolean - focus?: { - name: string - role: string - selectText?: boolean - } - setupTarget?: string - setupScroll?: 'start' | 'end' - visibleActionIds?: string[] - mcpEnabled?: boolean - measure?: boolean - options?: { - cssUnit?: string - rootFontSize?: number - scale?: number - } - plugins?: string[] - preferencesOpen?: boolean - } - pointer: { - target: { - anchor?: 'center' | Point - control?: string - kind: string - marker?: string - name?: string - role?: string - } - tooltip?: string - visible: boolean - } - width: number -} - -type Manifest = { - baseline: { - panel: { - deepSelect: boolean - mcpEnabled: boolean - measure: boolean - plugins: string[] - preferencesOpen: boolean - } - } - capture: { - clipScale: number - canvasAnchor: Point - clip: Rect - hiddenPointer: Point - panel: Point - panelControls: Record - settleMs: number - sourceScale: number - themes: Theme[] - viewport: { height: number; width: number } - } - fixture: { - file: { key: string; title: string; url: string } - } - scenarios: Scenario[] -} - -type FixtureRuntime = { - bounds(marker: string): Rect - list(): Array<{ marker: string }> - setCanvasTheme(theme: Theme): unknown - stage(input: { - focus: string - selection: string[] - theme: Theme - x: number - y: number - zoom: number - }): Promise<{ selection: unknown[] }> -} - -const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) -const manifestPath = fileURLToPath(new URL('../screenshots/scenarios.json', import.meta.url)) -const fixtureRuntimePath = fileURLToPath( - new URL('../screenshots/fixture-runtime.js', import.meta.url) -) - -function readArgument(name: string): string | null { - const index = process.argv.indexOf(name) - return index >= 0 ? (process.argv[index + 1] ?? null) : null -} - -function hasArgument(name: string): boolean { - return process.argv.includes(name) -} - -function fail(message: string): never { - throw new Error(message) -} - -function usage(): string { - return [ - 'Capture README screenshots from an already-open, logged-in Chrome:', - '', - ' pnpm screenshots capture --cdp-url http://127.0.0.1:9222', - ' pnpm screenshots capture --only code,unit,deep', - ' pnpm screenshots capture --group setup', - '', - 'Options:', - ' --cdp-url Chrome DevTools endpoint (default: TEMPAD_SCREENSHOT_CDP_URL or http://127.0.0.1:9222)', - ' --output-dir Candidate directory (default: .artifacts/marketing-screenshots)', - ' --only Comma-separated scenario ids', - ' --group inspect, setup, or status (exclusive with --only)', - ' --themes light,dark or one theme', - ' --help Show this help', - '', - 'The default run captures every scene except MCP unavailable/inactive.', - 'MCP unavailable/inactive captures are state-gated: prepare the real bridge state and leave', - 'TemPad Dev preferences open before running that scenario alone.' - ].join('\n') -} - -function readPngSize(buffer: Buffer): { height: number; width: number } | null { - const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) - if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) return null - return { height: buffer.readUInt32BE(20), width: buffer.readUInt32BE(16) } -} - -async function expectRect(locator: Locator, label: string): Promise { - const box = await locator.boundingBox() - if (!box) fail(`${label} is not visible.`) - return box -} - -async function isPreferencesOpen(page: Page): Promise { - return (await page.locator('article main').innerText()).startsWith('Tools') -} - -async function ensurePreferences(page: Page, open: boolean): Promise { - if ((await isPreferencesOpen(page)) === open) return - await page.locator('article button').nth(0).click() - await page.waitForTimeout(250) - if ((await isPreferencesOpen(page)) !== open) { - fail(`Could not ${open ? 'open' : 'close'} TemPad Dev preferences.`) - } -} - -async function setFigmaTheme(page: Page, theme: Theme): Promise { - await page.getByRole('button', { name: 'Main menu', exact: true }).click() - await page.getByRole('menuitem', { name: 'Preferences', exact: true }).click() - await page.getByRole('menuitem', { name: 'Theme', exact: true }).click() - await page - .getByRole('menuitemcheckbox', { name: theme === 'dark' ? 'Dark' : 'Light', exact: true }) - .click() - await page.waitForTimeout(500) -} - -async function minimizeFigmaUi(page: Page): Promise { - const collapse = page.getByRole('button', { name: /Collapse UI for file named|Minimize UI/ }) - if (await collapse.count()) { - await collapse.click() - await page.waitForTimeout(350) - } - if (!(await page.getByRole('button', { name: /Expand UI for file named/ }).count())) { - fail('Figma did not enter the minimized capture layout.') - } -} - -async function placePanel(page: Page, target: Point): Promise { - const panel = page.locator('article') - const panelBox = await expectRect(panel, 'TemPad Dev panel') - const delta = { x: target.x - panelBox.x, y: target.y - panelBox.y } - if (Math.abs(delta.x) < 1 && Math.abs(delta.y) < 1) return - - const headerBox = await expectRect(panel.locator('header').first(), 'TemPad Dev panel header') - const start = { x: headerBox.x + headerBox.width / 2, y: headerBox.y + headerBox.height / 2 } - await page.mouse.move(start.x, start.y) - await page.mouse.down() - await page.mouse.move(start.x + delta.x, start.y + delta.y, { steps: 12 }) - await page.mouse.up() - await page.waitForTimeout(300) - - const placed = await expectRect(panel, 'TemPad Dev panel') - if (Math.abs(placed.x - target.x) > 2 || Math.abs(placed.y - target.y) > 2) { - fail( - `TemPad Dev panel landed at ${placed.x.toFixed(1)},${placed.y.toFixed(1)} instead of ${target.x},${target.y}.` - ) - } -} - -async function setSelect(page: Page, name: string, value: string): Promise { - const select = page.getByRole('combobox', { name }) - const current = (await select.innerText()).trim() - if (current.includes(value)) return - await select.click() - await page.getByRole('option', { name: value, exact: true }).click() -} - -async function setInputValue(page: Page, value: number, index: number): Promise { - const inputs = page.locator('article main input') - const input = inputs.nth(index) - if ((await input.inputValue()) === String(value)) return - await input.fill(String(value)) - await input.press('Enter') -} - -async function setToggleButton(page: Page, index: number, active: boolean): Promise { - const button = page.locator('article main button').nth(index) - const selected = await button.evaluate((element) => - element.classList.contains('tp-button-selected') - ) - if (selected !== active) await button.click() -} - -async function setMcpEnabled(page: Page, enabled: boolean): Promise { - await page.getByRole('radio', { name: enabled ? 'Enabled' : 'Disabled', exact: true }).click() - await page.waitForTimeout(250) -} - -async function setKongPlugin(page: Page, enabled: boolean): Promise { - const checkbox = page.getByRole('checkbox', { name: 'Kong UI', exact: true }) - if ((await checkbox.isChecked()) !== enabled) await checkbox.setChecked(enabled, { force: true }) - await page.waitForTimeout(250) -} - -async function closeSetupDialog(page: Page): Promise { - const tempad = page.locator('tempad') - const setupDialog = tempad.getByRole('dialog', { name: 'Set up agents', exact: true }) - if (await setupDialog.count()) { - await setupDialog.getByRole('button', { name: 'Close', exact: true }).click() - await setupDialog.waitFor({ state: 'detached' }) - } -} - -async function resetPanel(page: Page): Promise { - await closeSetupDialog(page) - await ensurePreferences(page, true) - await setSelect(page, 'CSS unit', 'px') - await setSelect(page, 'Variable display', 'Both') - await setInputValue(page, 16, 0) - await setInputValue(page, 1, 1) - await setToggleButton(page, 0, false) - await setToggleButton(page, 1, false) - await setMcpEnabled(page, false) - await setKongPlugin(page, false) -} - -async function configureScenario(page: Page, scenario: Scenario): Promise { - const panel = scenario.panel ?? {} - if (scenario.group === 'setup') { - await closeSetupDialog(page) - await ensurePreferences(page, true) - await setMcpEnabled(page, panel.mcpEnabled ?? true) - if (!panel.setupTarget) fail(`${scenario.id}: a setup target is required.`) - const tempad = page.locator('tempad') - await tempad.getByRole('button', { name: 'Set up agents', exact: true }).click() - await tempad.getByRole('tab', { name: panel.setupTarget, exact: true }).click() - await page - .locator('#tp-agent-setup-panel [data-overlayscrollbars-viewport]') - .press(panel.setupScroll === 'end' ? 'End' : 'Home') - return - } - await resetPanel(page) - - if (panel.options?.cssUnit) await setSelect(page, 'CSS unit', panel.options.cssUnit) - if (panel.options?.rootFontSize !== undefined) { - await setInputValue(page, panel.options.rootFontSize, 0) - } - if (panel.options?.scale !== undefined) await setInputValue(page, panel.options.scale, 1) - if (panel.deepSelect !== undefined) await setToggleButton(page, 0, panel.deepSelect) - if (panel.measure !== undefined) await setToggleButton(page, 1, panel.measure) - if (panel.mcpEnabled !== undefined) await setMcpEnabled(page, panel.mcpEnabled) - await setKongPlugin(page, panel.plugins?.includes('Kong UI') ?? false) - - if (panel.setupTarget) { - await page.locator('tempad').getByRole('button', { name: 'Set up agents', exact: true }).click() - const target = page.locator('tempad').getByRole('tab', { name: panel.setupTarget, exact: true }) - if ((await target.getAttribute('aria-selected')) !== 'true') await target.click() - } - - await ensurePreferences(page, panel.preferencesOpen ?? false) -} - -async function stageCanvas( - page: Page, - manifest: Manifest, - scenario: Scenario, - theme: Theme -): Promise<{ selection: unknown[] }> { - if (!scenario.figma) fail(`${scenario.id}: missing Figma fixture contract.`) - const anchor = scenario.figma.captureAnchor ?? manifest.capture.canvasAnchor - return page.evaluate( - ({ input }) => { - const runtime = ( - globalThis as typeof globalThis & { - __TEMPAD_README_SCREENSHOTS__: FixtureRuntime - } - ).__TEMPAD_README_SCREENSHOTS__ - return runtime.stage(input) - }, - { - input: { - focus: scenario.figma.focus, - selection: scenario.figma.selection, - theme, - x: anchor.x, - y: anchor.y, - zoom: scenario.figma.zoom ?? 1 - } - } - ) -} - -async function fixtureBounds(page: Page, marker: string): Promise { - return page.evaluate((value) => { - const runtime = ( - globalThis as typeof globalThis & { - __TEMPAD_README_SCREENSHOTS__: FixtureRuntime - } - ).__TEMPAD_README_SCREENSHOTS__ - return runtime.bounds(value) - }, marker) -} - -async function panelControl(page: Page, scenario: Scenario): Promise { - const target = scenario.pointer.target - if (target.control === 'scrollIntoView') { - return page.locator('article main button[data-tooltip="Scroll into view"]') - } - if (target.role === 'spinbutton' && target.name) { - return page.getByRole('spinbutton', { name: target.name }) - } - fail(`Unsupported panel pointer target in ${scenario.id}.`) -} - -async function pointerPoint(page: Page, manifest: Manifest, scenario: Scenario): Promise { - const target = scenario.pointer.target - if (!scenario.pointer.visible || target.kind === 'hiddenPointer') { - return manifest.capture.hiddenPointer - } - if (target.kind === 'canvas-node' && target.marker) { - const bounds = await fixtureBounds(page, target.marker) - const anchor = target.anchor ?? 'center' - const normalized = anchor === 'center' ? { x: 0.5, y: 0.5 } : anchor - return { - x: bounds.x + bounds.width * normalized.x, - y: bounds.y + bounds.height * normalized.y - } - } - if (target.kind === 'panel-control') { - const bounds = await expectRect( - await panelControl(page, scenario), - `${scenario.id} pointer target` - ) - const anchor = target.anchor ?? 'center' - const normalized = anchor === 'center' ? { x: 0.5, y: 0.5 } : anchor - return { - x: bounds.x + bounds.width * normalized.x, - y: bounds.y + bounds.height * normalized.y - } - } - fail(`Unsupported pointer target ${target.kind} in ${scenario.id}.`) -} - -async function renderPointer(page: Page, point: Point, visible: boolean): Promise { - await page.evaluate( - ({ point: value, visible: show }) => { - document.querySelector('#tempad-readme-cursor')?.remove() - if (!show) return - const cursor = document.createElement('div') - cursor.id = 'tempad-readme-cursor' - cursor.style.cssText = [ - 'position:fixed', - `left:${value.x}px`, - `top:${value.y}px`, - 'width:32px', - 'height:32px', - 'pointer-events:none', - 'z-index:2147483647', - 'transform:translate(-5px,-3.5px)', - 'filter:drop-shadow(0 0 7px rgba(0,152,255,.8))' - ].join(';') - cursor.innerHTML = - '' - document.body.append(cursor) - }, - { point, visible } - ) -} - -async function stabilizeCompositor(page: Page, point: Point): Promise { - await page.evaluate((value) => { - document.querySelector('#tempad-readme-compositor')?.remove() - const guard = document.createElement('div') - guard.id = 'tempad-readme-compositor' - guard.style.cssText = [ - 'position:fixed', - `left:${value.x}px`, - `top:${value.y}px`, - 'width:32px', - 'height:32px', - 'pointer-events:none', - 'z-index:2147483647', - 'filter:drop-shadow(0 0 7px rgba(0,152,255,.8))' - ].join(';') - guard.innerHTML = - '' - document.body.append(guard) - }, point) -} - -async function resolveClip(page: Page, manifest: Manifest, scenario: Scenario): Promise { - const source = { ...manifest.capture.clip, ...scenario.clip } - const clip = { height: source.height, width: source.width, x: source.x, y: source.y } - const anchor = scenario.clip?.anchor - if (!anchor) return clip - - const offset = anchor.offset ?? { x: 0, y: 0 } - if (anchor.kind === 'panel') { - const panel = await expectRect(page.locator('article'), 'TemPad Dev panel') - return { ...clip, x: panel.x + offset.x, y: panel.y + offset.y } - } - if (anchor.kind === 'panel-section' && anchor.name) { - const heading = page.getByText(anchor.name, { exact: true }).first() - const section = heading.locator('xpath=ancestor::section[1]') - const box = await expectRect(section, `${anchor.name} section`) - return { ...clip, x: box.x + offset.x, y: box.y + offset.y } - } - if (anchor.kind === 'dialog') { - const dialog = await expectRect( - page.locator('tempad').getByRole('dialog', { name: anchor.name, exact: true }), - `${anchor.name ?? 'Open'} dialog` - ) - return { ...clip, x: dialog.x + offset.x, y: dialog.y + offset.y } - } - fail(`Unsupported clip anchor ${anchor.kind} in ${scenario.id}.`) -} - -async function assertMcpStatus( - page: Page, - status: NonNullable -): Promise { - const badge = page.locator('article .tp-mcp-badge') - const className = await badge.getAttribute('class') - const expected = { - active: 'tp-mcp-badge-active', - inactive: 'tp-mcp-badge-inactive', - unavailable: 'tp-mcp-badge-error' - }[status] - if (!className?.includes(expected)) { - fail(`Expected MCP ${status}, got badge class ${className ?? ''}.`) - } -} - -async function assertScenario( - page: Page, - scenario: Scenario, - stage: { selection: unknown[] } | null, - clip: Rect -): Promise { - const panelText = await page.locator('article main').innerText() - let dialogText: string | undefined - if (scenario.mcpStatus) await assertMcpStatus(page, scenario.mcpStatus) - - for (const assertion of scenario.assertions) { - switch (assertion.kind) { - case 'setup-contract': { - const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel?.setupTarget) - if (!target) fail(`${scenario.id}: unknown agent setup target.`) - const selected = page.locator('tempad').getByRole('tab', { name: target.name, exact: true }) - if ((await selected.getAttribute('aria-selected')) !== 'true') { - fail(`${scenario.id}: ${target.name} is not the selected setup target.`) - } - const content = await page.locator('#tp-agent-setup-panel').innerText() - const pluginActions = target.actions.filter(({ id }) => id.startsWith('plugin-')) - const actions = pluginActions.length ? pluginActions : target.actions - for (const action of actions) { - if (action.kind !== 'deep-link' && !content.includes(action.value)) { - fail( - `${scenario.id}: displayed ${action.id} does not match the current shared setup configuration.` - ) - } - } - break - } - case 'setup-visible-actions': { - const target = AGENT_INTEGRATIONS.find(({ name }) => name === scenario.panel?.setupTarget) - if (!target) fail(`${scenario.id}: unknown setup target.`) - const viewport = await expectRect( - page.locator('#tp-agent-setup-panel [data-overlayscrollbars-viewport]'), - 'Setup content' - ) - for (const id of scenario.panel?.visibleActionIds ?? []) { - const action = target.actions.find((action) => action.id === id) - if (!action) fail(`${scenario.id}: unknown action ${id}.`) - const code = await expectRect( - page.locator('#tp-agent-setup-panel code').filter({ hasText: action.value }), - id - ) - if (code.y < viewport.y - 1 || code.y + code.height > viewport.y + viewport.height + 1) { - fail(`${scenario.id}: ${id} is clipped at the declared scroll position.`) - } - } - break - } - case 'dialog-fits-clip': { - const dialog = await expectRect( - page.locator('tempad').getByRole('dialog', { name: 'Set up agents', exact: true }), - 'Agent setup dialog' - ) - if ( - dialog.x < clip.x - 1 || - dialog.y < clip.y - 1 || - dialog.x + dialog.width > clip.x + clip.width + 1 || - dialog.y + dialog.height > clip.y + clip.height + 1 - ) { - fail(`${scenario.id}: the capture would crop the agent setup dialog.`) - } - break - } - case 'figma-selection-count': - if (stage && stage.selection.length !== assertion.value) { - fail( - `${scenario.id}: selection count is ${stage.selection.length}, not ${assertion.value}.` - ) - } - break - case 'mcp-status': - break - case 'panel-control-state': { - if (assertion.name === 'MCP access') { - const enabled = await page - .getByRole('radio', { name: 'Enabled', exact: true }) - .isChecked() - if (enabled !== assertion.value) fail(`${scenario.id}: MCP enabled state is incorrect.`) - } - break - } - case 'panel-control-value': { - if (!assertion.name) break - const control = page.getByRole('combobox', { name: assertion.name }) - if (await control.count()) { - if (!(await control.innerText()).includes(String(assertion.value))) { - fail(`${scenario.id}: ${assertion.name} does not show ${assertion.value}.`) - } - } else if (!panelText.includes(String(assertion.value))) { - fail(`${scenario.id}: ${assertion.name} does not show ${assertion.value}.`) - } - break - } - case 'panel-tool-active': { - const index = assertion.tool === 'deepSelect' ? 0 : 1 - const active = await page - .locator('article main button') - .nth(index) - .evaluate((element) => element.classList.contains('tp-button-selected')) - if (!active) fail(`${scenario.id}: ${assertion.tool} is not active.`) - break - } - case 'tooltip': - if (assertion.text && !(await page.getByText(assertion.text, { exact: true }).count())) { - fail(`${scenario.id}: tooltip ${assertion.text} is not visible.`) - } - break - case 'panel-text-absent': - if (assertion.text && panelText.includes(assertion.text)) { - fail(`${scenario.id}: panel unexpectedly contains ${assertion.text}.`) - } - break - case 'dialog-text': { - dialogText ??= await page - .locator('tempad') - .getByRole('dialog', { name: 'Set up agents', exact: true }) - .innerText() - if (assertion.text && !dialogText.includes(assertion.text)) { - fail(`${scenario.id}: dialog does not contain ${assertion.text}.`) - } - break - } - default: - if (assertion.text && !panelText.includes(assertion.text)) { - fail(`${scenario.id}: panel does not contain ${assertion.text}.`) - } - } - } -} - -async function prepareFocus(page: Page, scenario: Scenario): Promise { - const focus = scenario.panel?.focus - if (!focus) return - const input = page.getByRole(focus.role as 'spinbutton', { name: focus.name }) - await input.focus() - if (focus.selectText) await input.evaluate((element) => (element as HTMLInputElement).select()) -} - -async function captureScenario( - page: Page, - manifest: Manifest, - scenario: Scenario, - theme: Theme, - outputDir: string -): Promise { - const isStateGated = scenario.mcpStatus === 'inactive' || scenario.mcpStatus === 'unavailable' - if (!isStateGated) await configureScenario(page, scenario) - if (isStateGated && !(await isPreferencesOpen(page))) { - fail(`${scenario.id}: leave TemPad Dev preferences open before capturing this MCP state.`) - } - - const stage = - isStateGated || scenario.group === 'setup' - ? null - : await stageCanvas(page, manifest, scenario, theme) - if (isStateGated) { - await page.evaluate((value) => { - const runtime = ( - globalThis as typeof globalThis & { - __TEMPAD_README_SCREENSHOTS__: FixtureRuntime - } - ).__TEMPAD_README_SCREENSHOTS__ - runtime.setCanvasTheme(value) - }, theme) - } - - await prepareFocus(page, scenario) - const point = await pointerPoint(page, manifest, scenario) - await page.mouse.move(point.x, point.y) - await renderPointer(page, point, scenario.pointer.visible) - await stabilizeCompositor(page, manifest.capture.hiddenPointer) - await page.waitForTimeout(scenario.pointer.tooltip ? 700 : manifest.capture.settleMs) - const clip = await resolveClip(page, manifest, scenario) - await assertScenario(page, scenario, stage, clip) - const outputPath = resolve(outputDir, `${scenario.id}-${theme}.png`) - if (scenario.group === 'setup') { - const cdp = await page.context().newCDPSession(page) - try { - const capture = await captureDialogPng((method, params) => cdp.send(method, params), { - ...clip, - scale: manifest.capture.clipScale - }) - await writeFile(outputPath, Buffer.from(capture.data, 'base64')) - } finally { - await cdp.detach() - } - } else { - await page.screenshot({ animations: 'disabled', clip, path: outputPath, scale: 'device' }) - } - const size = readPngSize(await readFile(outputPath)) - if (!size || size.width !== scenario.width || size.height !== scenario.height) { - fail( - `${scenario.id}-${theme}: expected ${scenario.width}x${scenario.height}, got ${size ? `${size.width}x${size.height}` : 'a non-PNG file'}.` - ) - } - console.log(`Captured ${outputPath}`) -} - -async function main(): Promise { - if (hasArgument('--help')) { - console.log(usage()) - return - } - - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest - const fixtureRuntime = await readFile(fixtureRuntimePath, 'utf8') - const cdpUrl = - readArgument('--cdp-url') ?? process.env.TEMPAD_SCREENSHOT_CDP_URL ?? 'http://127.0.0.1:9222' - const outputDirArgument = readArgument('--output-dir') - const outputDir = outputDirArgument - ? resolve(repoRoot, outputDirArgument) - : `${repoRoot}.artifacts/marketing-screenshots` - const scenarios = selectScenarios(manifest.scenarios, { - only: readArgument('--only'), - group: readArgument('--group'), - capture: true - }) - const selectedThemes = new Set(selectThemes(manifest.capture.themes, readArgument('--themes'))) - const orderedScenarios = [...scenarios].sort( - (a, b) => Number(a.id === 'plugins') - Number(b.id === 'plugins') - ) - const useFixtures = needsFixtureRuntime(scenarios) - const hasStateGatedScenario = scenarios.some(({ mcpStatus }) => - ['inactive', 'unavailable'].includes(mcpStatus ?? '') - ) - - await mkdir(outputDir, { recursive: true }) - const browser = await chromium.connectOverCDP(cdpUrl) - const pages = browser.contexts().flatMap((context) => context.pages()) - const page = pages.find((candidate) => candidate.url().includes(manifest.fixture.file.key)) - if (!page) fail(`Open ${manifest.fixture.file.url} in the connected Chrome before capture.`) - - await page.bringToFront() - await page.waitForLoadState('domcontentloaded') - const viewport = await page.evaluate(() => ({ height: innerHeight, width: innerWidth })) - if ( - viewport.width !== manifest.capture.viewport.width || - viewport.height !== manifest.capture.viewport.height - ) { - fail( - `Expected viewport ${manifest.capture.viewport.width}x${manifest.capture.viewport.height}, got ${viewport.width}x${viewport.height}.` - ) - } - - if (useFixtures) { - const cdp = await page.context().newCDPSession(page) - const editable = await cdp.send('Runtime.evaluate', { - expression: - "typeof figma !== 'undefined' && figma.editorType === 'figma' && figma.mode === 'default'", - returnByValue: true - }) - if (!editable.result.value) { - fail( - 'Fixture capture requires an editable Figma Design file. Setup-only captures work with --group setup without changing canvas content.' - ) - } - const result = await cdp.send('Runtime.evaluate', { - awaitPromise: true, - expression: fixtureRuntime, - returnByValue: true - }) - if (result.exceptionDetails) - fail('Could not prepare the canonical Figma fixtures. No screenshots were captured.') - await page.waitForFunction(() => '__TEMPAD_README_SCREENSHOTS__' in globalThis) - const markers = await page.evaluate(() => { - const runtime = ( - globalThis as typeof globalThis & { - __TEMPAD_README_SCREENSHOTS__: FixtureRuntime - } - ).__TEMPAD_README_SCREENSHOTS__ - return runtime.list().map((node) => node.marker) - }) - const requiredMarkers = new Set( - scenarios.flatMap(({ figma }) => (figma ? [figma.focus, ...figma.selection] : [])) - ) - if ([...requiredMarkers].some((marker) => !markers.includes(marker))) { - fail('The canonical Figma fixture page is incomplete.') - } - await minimizeFigmaUi(page) - if (!hasStateGatedScenario) await placePanel(page, manifest.capture.panel) - } - - try { - for (const theme of manifest.capture.themes) { - if (selectedThemes.has(theme)) { - await closeSetupDialog(page) - await setFigmaTheme(page, theme) - for (const scenario of orderedScenarios) { - await captureScenario(page, manifest, scenario, theme, outputDir) - } - } - } - } finally { - await closeSetupDialog(page).catch(() => undefined) - await setFigmaTheme(page, 'light').catch(() => undefined) - await page - .evaluate((restoreFixtures) => { - const runtime = ( - globalThis as typeof globalThis & { - __TEMPAD_README_SCREENSHOTS__: FixtureRuntime - } - ).__TEMPAD_README_SCREENSHOTS__ - if (restoreFixtures) runtime?.setCanvasTheme('light') - document.querySelector('#tempad-readme-cursor')?.remove() - document.querySelector('#tempad-readme-compositor')?.remove() - }, useFixtures) - .catch(() => undefined) - - if (useFixtures && !hasStateGatedScenario) { - await resetPanel(page).catch(() => undefined) - await ensurePreferences(page, false).catch(() => undefined) - const code = manifest.scenarios.find((scenario) => scenario.id === 'code') - if (code) await stageCanvas(page, manifest, code, 'light').catch(() => undefined) - } - } - console.log(`Candidates written to ${outputDir}`) -} - -main().then( +captureScreenshots().then( () => process.exit(0), (error: unknown) => { console.error(error instanceof Error ? error.message : error) diff --git a/packages/extension/scripts/check-plugin-sandbox.ts b/packages/extension/scripts/check-plugin-sandbox.ts index 3b60f700..8e4d3790 100644 --- a/packages/extension/scripts/check-plugin-sandbox.ts +++ b/packages/extension/scripts/check-plugin-sandbox.ts @@ -3,8 +3,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { chromium, type BrowserContext } from 'playwright' import type { PluginSandboxResponse } from '../plugin-sandbox/protocol' @@ -54,18 +54,20 @@ type CapabilityProbeResult = { const extensionDir = fileURLToPath(new URL('../.output/chrome-mv3/', import.meta.url)) -async function main(): Promise { +export async function runCheck(): Promise { verifyManifest(await readManifest()) - const networkProbe = await startNetworkProbe() const profileDir = await mkdtemp(path.join(tmpdir(), 'tempad-plugin-sandbox-')) - const context = await chromium.launchPersistentContext(profileDir, { - channel: 'chromium', - headless: true, - args: [`--disable-extensions-except=${extensionDir}`, `--load-extension=${extensionDir}`] - }) + let networkProbe: Awaited> | undefined + let context: BrowserContext | undefined try { + networkProbe = await startNetworkProbe() + context = await chromium.launchPersistentContext(profileDir, { + channel: 'chromium', + headless: true, + args: [`--disable-extensions-except=${extensionDir}`, `--load-extension=${extensionDir}`] + }) const extensionId = await resolveExtensionId(context) // Exercise the installed manifest's storage permission in the real background worker. const stored = await context.serviceWorkers()[0]!.evaluate(async () => { @@ -110,8 +112,15 @@ async function main(): Promise { const channel = new MessageChannel() const pending = new Map void>() let resolveConnected!: () => void - const connected = new Promise((resolve) => { - resolveConnected = resolve + const connected = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + channel.port1.close() + reject(new Error('Sandbox connection timed out.')) + }, 7000) + resolveConnected = () => { + clearTimeout(timer) + resolve() + } }) channel.port1.onmessage = ({ data }: MessageEvent>) => { @@ -229,6 +238,54 @@ async function main(): Promise { } const statefulBatch = await request(12, statefulBatchPayload) const isolatedBatch = await request(13, statefulBatchPayload) + const oversizedArrayProperty = await request( + 14, + { + options: validJob.options, + references: [{ name: 'color', code: 'var(--color)' }], + pluginCode: ` + export default { + name: 'array-property-output', + code: { + css: { + transformVariable() { + return Object.assign([], { extra: 'x'.repeat(${maxPayloadBytes + 1}) }) + } + } + } + } + ` + }, + workers.transformVariable + ) + const oversizedSharedTree = await request(15, { + ...validJob, + component: { name: 'Example', type: 'INSTANCE', properties: {}, children: [] }, + returnDevComponent: true, + pluginCode: ` + JSON.stringify = (value) => value + JSON.parse = (value) => value + let tree = { name: 'span', props: {}, children: [] } + for (let depth = 0; depth < 17; depth++) + tree = { name: 'span', props: {}, children: [tree, tree] } + export default { + name: 'shared-tree-output', + code: { + component: { + transformComponent() { + return { + name: '__tempad_raw__', + props: { content: '' }, + children: [tree] + } + } + }, + css: false, + js: false + } + } + ` + }) channel.port1.close() return { @@ -237,7 +294,9 @@ async function main(): Promise { isolatedBatch, invalidWorker, oversized, + oversizedArrayProperty, oversizedOutput, + oversizedSharedTree, polluted, pollutionProbe, recovered, @@ -270,9 +329,15 @@ async function main(): Promise { await verifyFigmaEmbedding(context, extensionId) } finally { - await context.close() - await networkProbe.close() - await rm(profileDir, { force: true, recursive: true }) + try { + await context?.close() + } finally { + try { + await networkProbe?.close() + } finally { + await rm(profileDir, { force: true, recursive: true }) + } + } } console.log('[plugin-sandbox-check] Opaque-origin capability and broker lifecycle probes passed.') @@ -514,7 +579,9 @@ function verifyProtocolResult(result: { isolatedBatch: PluginSandboxResponse invalidWorker: PluginSandboxResponse oversized: PluginSandboxResponse + oversizedArrayProperty: PluginSandboxResponse oversizedOutput: PluginSandboxResponse + oversizedSharedTree: PluginSandboxResponse polluted: PluginSandboxResponse pollutionProbe: PluginSandboxResponse recovered: PluginSandboxResponse @@ -529,6 +596,13 @@ function verifyProtocolResult(result: { 'sandbox-protocol-probe' ) assert.equal(result.oversized.error?.code, 'payload-too-large') + assert.deepEqual( + { + arrayProperty: result.oversizedArrayProperty.error?.code, + sharedTree: result.oversizedSharedTree.error?.code + }, + { arrayProperty: 'payload-too-large', sharedTree: 'payload-too-large' } + ) assert.equal(result.invalidWorker.error?.code, 'protocol-error') assert.equal(result.timedOut.error?.code, 'timeout') assert.equal(result.hiddenImport.error?.code, 'worker-error') @@ -690,7 +764,9 @@ async function startNetworkProbe(): Promise<{ } } -main().catch((error) => { - console.error(error) - process.exitCode = 1 -}) +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCheck().catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/packages/extension/scripts/check-rewrite-runtime.ts b/packages/extension/scripts/check-rewrite-runtime.ts index 91e6e4a7..2f433800 100644 --- a/packages/extension/scripts/check-rewrite-runtime.ts +++ b/packages/extension/scripts/check-rewrite-runtime.ts @@ -1,8 +1,9 @@ import assert from 'node:assert/strict' import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { chromium, type BrowserContext } from 'playwright' import bundledRules from '../public/rules/figma.json' @@ -54,7 +55,7 @@ async function verifyRules(context: BrowserContext) { 'The persisted rewrite update alarm must be removed.' ) assert.deepEqual(await worker.evaluate(() => Reflect.get(globalThis, 'workerFetches')), []) - await verifyCspScope(context, true) + await verifyRuleScope(context, { csp: true, scripts: true }) } async function verifyScripts(context: BrowserContext) { @@ -253,7 +254,55 @@ async function verifyLegacyRules( ) } -async function verifyCspScope(context: BrowserContext, scoped: boolean) { +async function verifyLoopbackScript(context: BrowserContext) { + let scriptRequests = 0 + const server = createServer((request, response) => { + if (request.url === '/webpack-artifacts/assets/probe.min.js') { + scriptRequests++ + response.setHeader('Content-Type', 'application/javascript') + response.end('window.originalScriptExecuted = true;') + } else { + response.setHeader('Content-Type', 'text/html') + response.end( + `` + ) + } + }) + let page: Awaited> | undefined + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + assert.ok(address && typeof address !== 'string') + page = await context.newPage() + page.setDefaultTimeout(10000) + await page.goto(`http://127.0.0.1:${address.port}`) + await page.waitForFunction(() => Reflect.get(window, 'scriptResult')) + assert.deepEqual( + await page.evaluate(() => ({ + result: Reflect.get(window, 'scriptResult'), + executed: Reflect.get(window, 'originalScriptExecuted') === true + })), + { result: 'load', executed: true }, + 'Loopback permission must not redirect a local application script.' + ) + assert.equal(scriptRequests, 1) + } finally { + try { + await page?.close() + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + } +} + +async function verifyRuleScope( + context: BrowserContext, + scoped: { csp: boolean; scripts: boolean } +) { const worker = await getWorker(context) // Chrome's unpacked-extension matcher checks real installed DNR rules. A routed // Playwright response bypasses header rewriting, so it cannot verify this boundary. @@ -292,7 +341,7 @@ async function verifyCspScope(context: BrowserContext, scoped: boolean) { } }, url) assert.equal(result.permitted, true, `The fixture needs host access to test ${url}.`) - assert.deepEqual(result.ruleIds, !scoped || matches ? [1] : [], url) + assert.deepEqual(result.ruleIds, !scoped.csp || matches ? [1] : [], url) } const scriptMatches = await worker.evaluate(async () => { const result = await Reflect.get(globalThis, 'chrome').declarativeNetRequest.testMatchOutcome({ @@ -303,9 +352,31 @@ async function verifyCspScope(context: BrowserContext, scoped: boolean) { return result.matchedRules.map(({ ruleId }: { ruleId: number }) => ruleId) }) assert.deepEqual(scriptMatches, [2], 'The old script redirect must still match.') + const scriptCases = [ + ...extraTestOrigins.map((origin) => ({ + url: `${origin}/webpack-artifacts/assets/entry.min.js`, + initiator: origin + })), + { + url: 'https://www.figma.com/webpack-artifacts/assets/entry.min.js', + initiator: 'http://127.0.0.1' + } + ] + for (const request of scriptCases) { + const ruleIds = await worker.evaluate(async (request) => { + const result = await Reflect.get(globalThis, 'chrome').declarativeNetRequest.testMatchOutcome( + { + ...request, + type: 'script' + } + ) + return result.matchedRules.map(({ ruleId }: { ruleId: number }) => ruleId) + }, request) + assert.deepEqual(ruleIds, scoped.scripts ? [] : [2], JSON.stringify(request)) + } } -async function main() { +export async function runCheck() { const temporary = await mkdtemp(path.join(tmpdir(), 'tempad-local-rewrite-')) const extension = path.join(temporary, 'extension') const output = path.join(root, '.output/chrome-mv3') @@ -367,6 +438,7 @@ async function main() { await writeFile(manifestPath, JSON.stringify(manifest)) context = await launch('clean-profile') await verifyRules(context) + await verifyLoopbackScript(context) await verifyScripts(context) await context.close() context = undefined @@ -411,7 +483,7 @@ async function main() { ) context = await launch('upgrade-profile') await verifyLegacyRules(context, legacyRules) - await verifyCspScope(context, false) + await verifyRuleScope(context, { csp: false, scripts: false }) const oldWorker = await getWorker(context) // The download changes, not the installed extension. Exercise the same refresh // function used by its ten-minute alarm without delaying the test ten minutes. @@ -420,7 +492,7 @@ async function main() { await Reflect.get(globalThis, 'syncRules')() }, narrowedRules) await verifyLegacyRules(context, narrowedRules) - await verifyCspScope(context, true) + await verifyRuleScope(context, { csp: true, scripts: false }) await oldWorker.evaluate(async () => { Reflect.set(globalThis, 'fetch', async () => { throw new Error('Offline fixture') @@ -453,12 +525,17 @@ async function main() { 'Local rewrite: packaged rules, offline startup, reconstructed 0.23.1 migration, CSP scope, static scripts, Rspack chunks and fallback passed.' ) } finally { - await context?.close() - await rm(temporary, { recursive: true, force: true }) + try { + await context?.close() + } finally { + await rm(temporary, { recursive: true, force: true }) + } } } -main().catch((error) => { - console.error(error) - process.exitCode = 1 -}) +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCheck().catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/packages/extension/scripts/check-rewrite.ts b/packages/extension/scripts/check-rewrite.ts index f13840fa..69f6f1aa 100644 --- a/packages/extension/scripts/check-rewrite.ts +++ b/packages/extension/scripts/check-rewrite.ts @@ -1,5 +1,5 @@ import { pathToFileURL } from 'node:url' -import { chromium, type Response } from 'playwright' +import { chromium, type Page, type Response } from 'playwright' import { GROUPS } from '@/rewrite/config' import { REWRITE_TARGET_REGEX } from '@/rewrite/rules' @@ -27,7 +27,7 @@ export async function runCheck() { const scriptReads: Promise<{ url: string; content: string } | { error: unknown }>[] = [] const browser = await chromium.launch() - const page = await browser.newPage() + let page: Page | undefined const captureScript = (response: Response) => { if (response.request().resourceType() !== 'script') return const url = response.url() @@ -42,6 +42,7 @@ export async function runCheck() { } try { + page = await browser.newPage() page.on('response', captureScript) try { @@ -174,7 +175,7 @@ export async function runCheck() { logger.log(reportLines.join('\n')) return !hasFailure } finally { - page.off('response', captureScript) + page?.off('response', captureScript) await browser.close() } } diff --git a/packages/extension/scripts/check-worker-sandbox.ts b/packages/extension/scripts/check-worker-sandbox.ts index d97eb985..f34bb3f0 100644 --- a/packages/extension/scripts/check-worker-sandbox.ts +++ b/packages/extension/scripts/check-worker-sandbox.ts @@ -27,7 +27,7 @@ const CHECKS: WorkerCheck[] = [ /^codegen\/worker\.ts$/, /^worker\//, /^utils\//, - /^\.\.\/plugins\/dist\//, + /^\.\.\/plugins\/src\/index\.ts$/, ...CODEGEN_WORKER_DEPENDENCIES.map(createPnpmDependencyPattern) ] }, diff --git a/packages/extension/scripts/compare-screenshots.ts b/packages/extension/scripts/compare-screenshots.ts index b19d74a0..2db343e2 100644 --- a/packages/extension/scripts/compare-screenshots.ts +++ b/packages/extension/scripts/compare-screenshots.ts @@ -2,7 +2,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { selectScenarios, selectThemes, type ScreenshotScenario } from './screenshot-plan' +import { + parseScreenshotOptions, + selectionOptions, + selectScenarios, + selectThemes, + type ScreenshotScenario +} from './screenshot-plan' type Scenario = ScreenshotScenario & { height: number; width: number } type Manifest = { @@ -13,11 +19,6 @@ type Manifest = { const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const manifestPath = fileURLToPath(new URL('../screenshots/scenarios.json', import.meta.url)) -function readArgument(name: string): string | null { - const index = process.argv.indexOf(name) - return index >= 0 ? (process.argv[index + 1] ?? null) : null -} - function escapeHtml(value: string): string { return value .replaceAll('&', '&') @@ -31,28 +32,40 @@ async function imageData(path: string): Promise { } async function main(): Promise { + const options = parseScreenshotOptions({ + ...selectionOptions, + 'candidate-dir': { type: 'string' }, + 'baseline-dir': { type: 'string' }, + output: { type: 'string' } + }) const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest - const candidateDirArgument = readArgument('--candidate-dir') + const candidateDirArgument = options['candidate-dir'] const candidateDir = candidateDirArgument ? resolve(repoRoot, candidateDirArgument) : `${repoRoot}.artifacts/marketing-screenshots` - const baselineDirArgument = readArgument('--baseline-dir') + const baselineDirArgument = options['baseline-dir'] const baselineDir = baselineDirArgument ? resolve(repoRoot, baselineDirArgument) : `${repoRoot}packages/site/public/marketing` - const outputArgument = readArgument('--output') + const outputArgument = options.output const outputPath = outputArgument ? resolve(repoRoot, outputArgument) : `${candidateDir}/comparison.html` const selected = selectScenarios(manifest.scenarios, { - only: readArgument('--only'), - group: readArgument('--group') + only: options.only, + group: options.group }) - const themes = selectThemes(manifest.capture.themes, readArgument('--themes')) + const themes = selectThemes(manifest.capture.themes, options.themes ?? null) const rows: string[] = [] for (const scenario of selected) { const displayWidth = scenario.width / manifest.capture.sourceScale + const visualReview = (scenario.visualReview ?? []) + .map( + ({ values }) => + `

Visual review required: confirm the native canvas measurement labels read ${escapeHtml(values.join(', '))}.

` + ) + .join('') for (const theme of themes) { const filename = `${scenario.id}-${theme}.png` const baselinePath = resolve(baselineDir, filename) @@ -68,6 +81,7 @@ async function main(): Promise { rows.push(`

${escapeHtml(scenario.id)} ${escapeHtml(theme)}

+ ${visualReview}
Committed baseline
${baseline ? `` : '

New scenario — no committed baseline.

'}
New candidate
diff --git a/packages/extension/scripts/inspect-agent-authoring-rollout.ts b/packages/extension/scripts/inspect-agent-authoring-rollout.ts index 2818b915..ac6594f3 100644 --- a/packages/extension/scripts/inspect-agent-authoring-rollout.ts +++ b/packages/extension/scripts/inspect-agent-authoring-rollout.ts @@ -1,9 +1,8 @@ import { Buffer } from 'node:buffer' -import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { pathToFileURL } from 'node:url' -import { decodeXmlText, inspectAgentRunIdentity } from './inspect-agent-run-identity' +import { inspectAgentRun } from './inspect-agent-run-identity' import { parseRolloutRows } from './rollout-rows' interface NodeLimitAttempt { @@ -13,7 +12,7 @@ interface NodeLimitAttempt { } interface AuthoringRolloutInspection { - execution: ReturnType + execution: ReturnType['identity'] prompt: { text: string | null sha256: string | null @@ -424,64 +423,6 @@ function completedToolEvents(parsedRows: unknown[]): CompletedToolEvent[] { }) } -function messageText(row: unknown): { role: string | null; text: string } | null { - if (get(row, 'type') !== 'response_item') return null - const payload = get(row, 'payload') - if (get(payload, 'type') !== 'message') return null - const content = get(payload, 'content') - if (!Array.isArray(content)) return null - return { - role: typeof get(payload, 'role') === 'string' ? String(get(payload, 'role')) : null, - text: content - .map((item) => (typeof get(item, 'text') === 'string' ? String(get(item, 'text')) : '')) - .join('\n') - } -} - -function createThreadOutputText(row: unknown): string | null { - if (get(row, 'type') !== 'response_item') return null - const payload = get(row, 'payload') - if ( - get(payload, 'type') !== 'function_call_output' || - get(payload, 'namespace') !== 'codex_app' || - get(payload, 'name') !== 'create_thread' - ) { - return null - } - const output = get(payload, 'output') - return typeof output === 'string' ? output : null -} - -function normalizePrompt(value: string): string { - return value.trim().replaceAll(/\s+/g, ' ') -} - -function extractPrompt(parsedRows: unknown[]): string | null { - const messages = parsedRows.flatMap((row) => { - const message = messageText(row) - return message ? [message] : [] - }) - const delegatedTexts = [ - ...messages.map(({ text }) => text), - ...parsedRows.flatMap((row) => { - const output = createThreadOutputText(row) - return output ? [output] : [] - }) - ] - for (const text of delegatedTexts) { - const delegated = text.match(/[\s\S]*?([\s\S]*?)<\/input>/) - if (delegated?.[1]) return normalizePrompt(decodeXmlText(delegated[1])) - } - const userMessages = messages - .filter( - ({ role, text }) => - role === 'user' && !/|/.test(text) - ) - .map(({ text }) => normalizePrompt(text)) - .filter(Boolean) - return userMessages.at(-1) ?? null -} - function skillResources(input: string): string[] { const normalized = input.replaceAll('\\/', '/').replaceAll('\\\\', '/') const expanded = normalized.replace( @@ -516,14 +457,20 @@ function runtimeObservation(value: unknown, seen = new Set()): RuntimeO const hubFingerprint = get(hub, 'runtimeFingerprint') const extensionFingerprint = get(extension, 'runtimeFingerprint') const issues = get(value, 'issues') + const complete = [hubFingerprint, extensionFingerprint].every( + (fingerprint) => typeof fingerprint === 'string' && /^[a-f0-9]{64}$/.test(fingerprint) + ) + const identityIssues = Array.isArray(issues) + ? issues.filter((issue): issue is string => typeof issue === 'string') + : [] + if (!complete) + identityIssues.push('An apply_canvas runtime identity is missing valid fingerprints.') return { locked, - valid, + valid: valid && complete, hubFingerprint: typeof hubFingerprint === 'string' ? hubFingerprint : null, extensionFingerprint: typeof extensionFingerprint === 'string' ? extensionFingerprint : null, - issues: Array.isArray(issues) - ? issues.filter((issue): issue is string => typeof issue === 'string') - : [] + issues: identityIssues } } for (const nested of Object.values(value)) { @@ -603,7 +550,7 @@ export function inspectAuthoringRollout(rolloutJsonl: string): AuthoringRolloutI .map((row) => timestampMs(get(row, 'timestamp'))) .filter((value): value is number => value !== null) const rolloutStartedMs = rowTimestamps.length ? Math.min(...rowTimestamps) : null - const prompt = extractPrompt(parsedRows) + const { identity: execution, prompt } = inspectAgentRun(rows) const finalResponseMs = parsedRows.reduce((latest, row) => { const value = itemTimestampMs(row, 'AgentMessage') return value === null || (latest !== null && value <= latest) ? latest : value @@ -748,10 +695,10 @@ export function inspectAuthoringRollout(rolloutJsonl: string): AuthoringRolloutI } return { - execution: inspectAgentRunIdentity(rows), + execution, prompt: { text: prompt, - sha256: prompt ? createHash('sha256').update(prompt).digest('hex') : null, + sha256: execution.promptSha256, wordCount: prompt ? prompt.split(/\s+/).filter(Boolean).length : 0, characterCount: prompt?.length ?? 0 }, diff --git a/packages/extension/scripts/inspect-agent-run-identity.ts b/packages/extension/scripts/inspect-agent-run-identity.ts index 73e8c3f6..ce90842d 100644 --- a/packages/extension/scripts/inspect-agent-run-identity.ts +++ b/packages/extension/scripts/inspect-agent-run-identity.ts @@ -19,27 +19,30 @@ function text(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value : null } -export function decodeXmlText(value: string): string { - return value - .replaceAll(/&#(x[0-9a-f]+|\d+);/gi, (entity, code: string) => { - const point = code.toLowerCase().startsWith('x') - ? Number.parseInt(code.slice(1), 16) - : Number.parseInt(code, 10) +function decodeXmlText(value: string): string { + const named: Record = { lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' } + return value.replaceAll( + /&(#(?:[xX][0-9a-fA-F]+|\d+)|lt|gt|quot|apos|amp);/g, + (entity, code: string) => { + if (!code.startsWith('#')) return named[code]! + const numeric = code.slice(1) + const point = numeric.toLowerCase().startsWith('x') + ? Number.parseInt(numeric.slice(1), 16) + : Number.parseInt(numeric, 10) try { return String.fromCodePoint(point) } catch { return entity } - }) - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll(''', "'") - .replaceAll('&', '&') + } + ) } // Inspect source records, not model prose or tool output claiming an identity. -export function inspectAgentRunIdentity(rows: Iterable): AgentRunIdentity { +export function inspectAgentRun(rows: Iterable): { + identity: AgentRunIdentity + prompt: string | null +} { const taskIds = new Set() const models = new Set() const efforts = new Set() @@ -132,13 +135,16 @@ export function inspectAgentRunIdentity(rows: Iterable): AgentRunIde const reasoningEffort = single(efforts, 'Reasoning effort') prompts.unshift(...delegatedPrompts) if (prompts.length !== 1) issues.add('A clean run requires exactly one original task prompt.') - const prompt = prompts[0]?.trim().replaceAll(/\s+/g, ' ') + const prompt = prompts[0]?.trim().replaceAll(/\s+/g, ' ') || null return { - taskId, - model, - reasoningEffort, - promptCount: prompts.length, - promptSha256: prompt ? createHash('sha256').update(prompt).digest('hex') : null, - issues: [...issues] + prompt, + identity: { + taskId, + model, + reasoningEffort, + promptCount: prompts.length, + promptSha256: prompt ? createHash('sha256').update(prompt).digest('hex') : null, + issues: [...issues] + } } } diff --git a/packages/extension/scripts/inspect-agent-skill-catalog.ts b/packages/extension/scripts/inspect-agent-skill-catalog.ts index 99141b78..c5290aa8 100644 --- a/packages/extension/scripts/inspect-agent-skill-catalog.ts +++ b/packages/extension/scripts/inspect-agent-skill-catalog.ts @@ -29,9 +29,15 @@ function hash(value: unknown): string { } function messageText(value: unknown): string { - if (!value || typeof value !== 'object') return '' + if (!value || typeof value !== 'object' || Reflect.get(value, 'type') !== 'response_item') + return '' const payload = Reflect.get(value, 'payload') - if (!payload || typeof payload !== 'object' || Reflect.get(payload, 'type') !== 'message') { + if ( + !payload || + typeof payload !== 'object' || + Reflect.get(payload, 'type') !== 'message' || + !['developer', 'system'].includes(String(Reflect.get(payload, 'role'))) + ) { return '' } const content = Reflect.get(payload, 'content') @@ -46,35 +52,38 @@ function messageText(value: unknown): string { } export function extractSkillCatalog(rolloutJsonl: string): SkillCatalogEntry[] { + let catalog: SkillCatalogEntry[] | undefined for (const entry of parseRolloutRows(rolloutJsonl)) { if ('error' in entry) continue - const row = entry.value - const text = messageText(row) - const start = text.indexOf('') - const end = text.indexOf('') - if (start === -1 || end === -1 || end <= start) continue - - const block = text.slice(start, end) - const roots = new Map() - for (const match of block.matchAll(SKILL_ROOT_PATTERN)) { - const [, alias, root] = match - if (alias && root) roots.set(alias, root) - } - const entries: SkillCatalogEntry[] = [] - for (const match of block.matchAll(SKILL_ENTRY_PATTERN)) { - const [, name, description, locatorKind, locator] = match - if (!name || !description || !locatorKind || !locator) continue - const [alias, ...relativeSegments] = locator.split('/') - const root = alias ? roots.get(alias) : undefined - entries.push({ - name, - description, - locatorKind: locatorKind as SkillCatalogEntry['locatorKind'], - locator: root && relativeSegments.length ? join(root, ...relativeSegments) : locator - }) + const text = messageText(entry.value) + for (const [, block] of text.matchAll( + /([\s\S]*?)<\/skills_instructions>/g + )) { + const roots = new Map() + for (const match of block!.matchAll(SKILL_ROOT_PATTERN)) { + const [, alias, root] = match + if (alias && root) roots.set(alias, root) + } + const entries: SkillCatalogEntry[] = [] + for (const match of block!.matchAll(SKILL_ENTRY_PATTERN)) { + const [, name, description, locatorKind, locator] = match + if (!name || !description || !locatorKind || !locator) continue + const [alias, ...relativeSegments] = locator.split('/') + const root = alias ? roots.get(alias) : undefined + entries.push({ + name, + description, + locatorKind: locatorKind as SkillCatalogEntry['locatorKind'], + locator: root && relativeSegments.length ? join(root, ...relativeSegments) : locator + }) + } + if (catalog && JSON.stringify(entries) !== JSON.stringify(catalog)) { + throw new Error('Skill catalog changed within the rollout.') + } + catalog = entries } - if (entries.length) return entries } + if (catalog?.length) return catalog throw new Error('No complete catalog found in rollout.') } diff --git a/packages/extension/scripts/process-screenshot.ts b/packages/extension/scripts/process-screenshot.ts index d52e590f..c4d66a49 100644 --- a/packages/extension/scripts/process-screenshot.ts +++ b/packages/extension/scripts/process-screenshot.ts @@ -5,6 +5,8 @@ import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' +import { parseScreenshotOptions } from './screenshot-plan' + type Scenario = { clip?: ClipRect id: string @@ -37,11 +39,14 @@ const execFileAsync = promisify(execFile) const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const manifestPath = fileURLToPath(new URL('../screenshots/scenarios.json', import.meta.url)) const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ScreenshotManifest - -function readArgument(name: string): string | null { - const index = process.argv.indexOf(name) - return index >= 0 ? (process.argv[index + 1] ?? null) : null -} +const options = parseScreenshotOptions({ + scenario: { type: 'string' }, + theme: { type: 'string' }, + input: { type: 'string' }, + output: { type: 'string' }, + 'clip-x': { type: 'string' }, + 'clip-y': { type: 'string' } +}) function fail(message: string): never { throw new Error( @@ -49,21 +54,21 @@ function fail(message: string): never { ) } -function readNumberArgument(name: string): number | null { - const value = readArgument(name) - if (value === null) return null +function readNumberArgument(name: 'clip-x' | 'clip-y'): number | null { + const value = options[name] + if (value === undefined) return null const number = Number(value) if (!Number.isFinite(number)) { - fail(`${name} must be a finite number.`) + fail(`--${name} must be a finite number.`) } return number } -const scenarioId = readArgument('--scenario') ?? fail('Missing --scenario.') -const theme = readArgument('--theme') ?? fail('Missing --theme.') -const input = readArgument('--input') ?? fail('Missing --input.') +const scenarioId = options.scenario ?? fail('Missing --scenario.') +const theme = options.theme ?? fail('Missing --theme.') +const input = options.input ?? fail('Missing --input.') const scenario = manifest.scenarios.find((candidate) => candidate.id === scenarioId) if (!scenario) { @@ -80,11 +85,10 @@ if (process.platform !== 'darwin') { const inputPath = resolve(input) const outputPath = resolve( - readArgument('--output') ?? - `${repoRoot}packages/site/public/marketing/${scenario.id}-${theme}.png` + options.output ?? `${repoRoot}packages/site/public/marketing/${scenario.id}-${theme}.png` ) -const clipX = readNumberArgument('--clip-x') -const clipY = readNumberArgument('--clip-y') +const clipX = readNumberArgument('clip-x') +const clipY = readNumberArgument('clip-y') if ((clipX === null) !== (clipY === null)) { fail('--clip-x and --clip-y must be provided together.') diff --git a/packages/extension/scripts/promote-screenshots.ts b/packages/extension/scripts/promote-screenshots.ts index c92e9a6b..94680041 100644 --- a/packages/extension/scripts/promote-screenshots.ts +++ b/packages/extension/scripts/promote-screenshots.ts @@ -2,7 +2,13 @@ import { copyFile, readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { selectScenarios, selectThemes, type ScreenshotScenario } from './screenshot-plan' +import { + parseScreenshotOptions, + selectionOptions, + selectScenarios, + selectThemes, + type ScreenshotScenario +} from './screenshot-plan' type Scenario = ScreenshotScenario & { height: number; width: number } type Manifest = { capture: { themes: string[] }; scenarios: Scenario[] } @@ -10,11 +16,6 @@ type Manifest = { capture: { themes: string[] }; scenarios: Scenario[] } const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const manifestPath = fileURLToPath(new URL('../screenshots/scenarios.json', import.meta.url)) -function readArgument(name: string): string | null { - const index = process.argv.indexOf(name) - return index >= 0 ? (process.argv[index + 1] ?? null) : null -} - function readPngSize(buffer: Buffer): { height: number; width: number } | null { const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) if (buffer.length < 24 || !buffer.subarray(0, 8).equals(signature)) return null @@ -22,22 +23,27 @@ function readPngSize(buffer: Buffer): { height: number; width: number } | null { } async function main(): Promise { - if (!process.argv.includes('--yes')) { + const options = parseScreenshotOptions({ + ...selectionOptions, + 'candidate-dir': { type: 'string' }, + yes: { type: 'boolean' } + }) + if (!options.yes) { throw new Error( 'Promotion overwrites committed marketing assets. Review `pnpm screenshots compare` first, then rerun with --yes.' ) } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest - const candidateDirArgument = readArgument('--candidate-dir') + const candidateDirArgument = options['candidate-dir'] const candidateDir = candidateDirArgument ? resolve(repoRoot, candidateDirArgument) : `${repoRoot}.artifacts/marketing-screenshots` const selected = selectScenarios(manifest.scenarios, { - only: readArgument('--only'), - group: readArgument('--group') + only: options.only, + group: options.group }) - const themes = selectThemes(manifest.capture.themes, readArgument('--themes')) + const themes = selectThemes(manifest.capture.themes, options.themes ?? null) const candidates: Array<{ source: string; target: string }> = [] for (const scenario of selected) { diff --git a/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts b/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts index 2c80bd51..1e9452b7 100644 --- a/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts +++ b/packages/extension/scripts/reinstall-codex-dev-plugin-runtime.ts @@ -1,6 +1,6 @@ -import { normalize, resolve } from 'node:path' +import { isAbsolute, normalize, resolve } from 'node:path' -import { exactPathPattern, type RuntimeProcess } from './agent-authoring-runtime-preflight' +import { processRunsRuntimeEntry, type RuntimeProcess } from './agent-authoring-runtime-preflight' export const devPluginName = 'tempad-dev-dev' export const devPluginId = `${devPluginName}@${devPluginName}` @@ -12,6 +12,8 @@ export type ReinstallArguments = { pageUrl?: string restartCodex: boolean resumeAfterRestart: boolean + resumeThread?: string + reportPath?: string timeoutMs: number version?: string } @@ -24,7 +26,9 @@ const BOOLEAN_FLAGS = { const VALUE_FLAGS = { '--app-path': 'appPath', '--cdp-url': 'cdpUrl', - '--page-url': 'pageUrl' + '--page-url': 'pageUrl', + '--resume-thread': 'resumeThread', + '--report-path': 'reportPath' } as const export function parseReinstallArguments(argv: string[]): ReinstallArguments | null { @@ -62,9 +66,32 @@ export function parseReinstallArguments(argv: string[]): ReinstallArguments | nu if (args.version) throw new Error(`Unexpected positional argument: ${argument}`) args.version = argument } + if (args.restartCodex && args.resumeAfterRestart) throw new Error('Use only one restart mode.') + if (args.restartCodex) args.resumeThread ??= process.env.CODEX_THREAD_ID + if (args.resumeThread) assertThreadId(args.resumeThread) + if ((args.restartCodex || args.resumeAfterRestart) && !args.resumeThread) + throw new Error('Restart requires --resume-thread or CODEX_THREAD_ID to restore the task.') + if (args.resumeAfterRestart && !args.reportPath) + throw new Error('Detached restart requires its saved --report-path.') + if (args.reportPath && !isAbsolute(args.reportPath)) + throw new Error('--report-path must be absolute.') return { ...args, cdpPort: localCdpPort(args.cdpUrl) } } +export function assertThreadId(id: string): void { + if (!/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(id)) + throw new Error('Expected a Codex thread UUID for --resume-thread.') +} + +export function threadPageUrl(pageUrl: string, threadId: string): string { + assertThreadId(threadId) + selectCodexPageUrl([pageUrl]) + const url = new URL(pageUrl) + url.searchParams.set('initialRoute', `/local/${threadId}`) + url.hash = '' + return url.href +} + export function resolveDevPluginVersion(input: unknown, requested?: string): string { if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new Error('Generated development plugin manifest must be an object.') @@ -113,7 +140,7 @@ export async function inspectDevPlugin( runCodex: (args: string[]) => Promise, pluginRoot: string, version?: string -): Promise { +): Promise { const listed = objectValue( await runCodex(['plugin', 'list', '--available', '--json', '--marketplace', devPluginName]) ) @@ -128,12 +155,56 @@ export async function inspectDevPlugin( } const entry = candidates[0]! assertLocalPlugin(entry, pluginRoot) + if (typeof entry.installed !== 'boolean') + throw new Error('Codex did not report whether the development plugin is installed.') if ( version && (entry.installed !== true || entry.enabled !== true || entry.version !== version) ) { throw new Error(`The installed development plugin is not enabled at version ${version}.`) } + return entry.installed +} + +/** An empty snapshot is insufficient: a host can respawn its MCP process after uninstall. */ +export async function stopRuntimeProcesses( + list: () => Promise, + terminate: (pid: number) => void, + options: { + timeoutMs?: number + quietMs?: number + sleep?: (ms: number) => Promise + now?: () => number + } = {} +): Promise { + const { + timeoutMs = 15_000, + quietMs = 1500, + sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + now = Date.now + } = options + const stopped = await list() + for (const pid of stopped) { + try { + terminate(pid) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + } + } + const deadline = now() + timeoutMs + let quietSince: number | undefined + do { + const remaining = await list() + if (remaining.some((pid) => !stopped.includes(pid))) + throw new Error( + 'Checkout MCP processes respawned after uninstall; stop the owning host before reinstalling.' + ) + if (remaining.length) quietSince = undefined + else quietSince ??= now() + if (quietSince !== undefined && now() - quietSince >= quietMs) return stopped + await sleep(250) + } while (now() < deadline) + throw new Error('Timed out waiting for this checkout’s MCP processes to remain stopped.') } export function localCdpPort(value: string): number { @@ -194,14 +265,17 @@ export function selectCodexPageUrl(urls: string[], requested?: string): string { export function matchingProcesses(processes: RuntimeProcess[], executable: string): number[] { return processes - .filter(({ command }) => command === executable || command.startsWith(`${executable} `)) + .filter( + (process) => + process.executable === executable && + (process.command === executable || process.command.startsWith(`${executable} `)) + ) .map(({ pid }) => pid) } export function checkoutRuntimeProcesses(processes: RuntimeProcess[], entries: string[]): number[] { - const patterns = entries.map(exactPathPattern) return processes - .filter(({ command }) => patterns.some((pattern) => pattern.test(command))) + .filter((process) => entries.some((entry) => processRunsRuntimeEntry(process, entry))) .map(({ pid }) => pid) } diff --git a/packages/extension/scripts/reinstall-codex-dev-plugin-ui.ts b/packages/extension/scripts/reinstall-codex-dev-plugin-ui.ts new file mode 100644 index 00000000..d1e6a463 --- /dev/null +++ b/packages/extension/scripts/reinstall-codex-dev-plugin-ui.ts @@ -0,0 +1,116 @@ +import type { Page } from 'playwright' + +import { threadPageUrl } from './reinstall-codex-dev-plugin-runtime' + +const displayName = 'TemPad Dev (Dev)' +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function openPlugin(page: Page, reopen = false) { + // The heading's accessible name also includes its folder-marketplace icon. + const title = page.getByRole('heading', { level: 1 }).filter({ hasText: /^TemPad Dev \(Dev\)$/ }) + if (!reopen && (await title.isVisible())) return + const main = page.getByRole('main') + const search = main.getByRole('textbox', { name: 'Search plugins', exact: true }) + if (!(await search.isVisible())) { + await page + .getByRole('button', { name: 'Plugins', exact: true }) + .evaluate((element: HTMLElement) => element.click()) + await page.getByRole('heading', { name: 'Plugins', exact: true }).waitFor({ state: 'visible' }) + } + // Navigation and debounced search settle separately; transient results can reset the catalog. + await pause(500) + await search.evaluate((input: HTMLInputElement, value) => { + input.blur() + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + if (!setter) throw new Error('Could not update the plugin search input.') + setter.call(input, value) + input.dispatchEvent(new Event('input', { bubbles: true })) + }, displayName) + await pause(500) + await main + .getByRole('button') + .filter({ has: page.getByText(displayName, { exact: true }) }) + .and(page.locator(':not([data-search-result-active])')) + .or(main.getByRole('link', { name: displayName, exact: true })) + .evaluate((element: HTMLElement) => element.click()) + await search.waitFor({ state: 'hidden' }) + await title.waitFor({ state: 'visible' }) + await main + .getByRole('button', { name: 'Install plugin', exact: true }) + .or(main.getByRole('button', { name: 'More actions', exact: true })) + .first() + .waitFor({ state: 'visible' }) +} + +export async function reinstallInApp( + page: Page, + version: string, + afterUninstall: () => Promise +) { + await openPlugin(page) + // Detail and sticky-header controls can both render the same plugin action. + const main = page.getByRole('main') + const install = main.getByRole('button', { name: 'Install plugin', exact: true }).first() + const actions = main.getByRole('button', { name: 'More actions', exact: true }).first() + if (await actions.isVisible()) { + await actions.click() + await page.getByRole('menuitem', { name: 'Uninstall', exact: true }).click() + const confirm = page.getByRole('dialog').getByRole('button', { name: 'Uninstall', exact: true }) + await confirm.or(install).first().waitFor({ state: 'visible' }) + if (await confirm.isVisible()) await confirm.click() + await install.waitFor({ state: 'visible' }) + } + // Installation must not proceed until disk state AND stable process exit are verified. + await afterUninstall() + // A local rebuild can leave the open detail page showing the previous manifest. + await openPlugin(page, true) + const expectedVersion = main.getByText(version, { exact: true }) + if (!(await expectedVersion.isVisible())) { + // Catalog navigation can retain the renderer's cached local manifest. + await page.reload() + await openPlugin(page) + } + await expectedVersion.waitFor({ state: 'visible' }) + await install.evaluate((element: HTMLElement) => element.click()) + await install.waitFor({ state: 'hidden' }) + // Installing can navigate to a new chat. Reopen the detail page to verify its state. + await openPlugin(page, true) + await actions.waitFor({ state: 'visible' }) + await expectedVersion.waitFor({ state: 'visible' }) + if (await install.isVisible()) + throw new Error('Codex App still offers installation; reinstall is not confirmed.') +} + +export async function resumeThreadInApp( + page: Page, + threadId: string, + message: string, + beforeSend: () => Promise +): Promise<'sent' | 'already-running'> { + // A fresh local route avoids relying on whichever task happened to be selected at restart. + await page.goto(threadPageUrl(page.url(), threadId)) + const composer = page.locator('[data-codex-composer-root]:visible') + await composer.waitFor({ state: 'visible' }) + const editor = composer.locator('[contenteditable="true"]:visible, textarea:visible') + await editor.waitFor({ state: 'visible' }) + if (await composer.getByRole('button', { name: 'Stop', exact: true }).isVisible()) + return 'already-running' + const draft = await editor.evaluate((element) => + element instanceof HTMLTextAreaElement ? element.value : element.textContent + ) + if (draft?.trim()) + throw new Error('The original task has a draft; leaving it intact instead of resuming.') + const send = composer.getByRole('button', { name: 'Send', exact: true }) + // Empty interrupted tasks offer Resume, and an empty idle composer may offer voice. + await editor.fill(message) + await send.waitFor({ state: 'visible' }) + // Persist uncertainty before the single click. A crash must never trigger an automatic retry. + await beforeSend() + await send.click() + // Text locators handle inline links and normalized whitespace in the transcript. + await page + .getByText(message, { exact: true }) + .and(page.locator(':not([data-codex-composer-root], [data-codex-composer-root] *)')) + .waitFor({ state: 'visible' }) + return 'sent' +} diff --git a/packages/extension/scripts/reinstall-codex-dev-plugin.ts b/packages/extension/scripts/reinstall-codex-dev-plugin.ts index 0f87fd77..eb5bb8b0 100644 --- a/packages/extension/scripts/reinstall-codex-dev-plugin.ts +++ b/packages/extension/scripts/reinstall-codex-dev-plugin.ts @@ -1,17 +1,15 @@ -import type { Page } from 'playwright' - import { execFile } from 'node:child_process' -import { access, mkdir, readFile, writeFile } from 'node:fs/promises' +import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { chromium } from 'playwright' import { - parseProcessTable, - resolveCodexExecutable, - type RuntimeProcess -} from './agent-authoring-runtime-preflight' + nativeFixtureRuntimeEntries, + readNativeRuntimeFixture +} from '../../../scripts/native-runtime-fixture.mjs' +import { listRuntimeProcesses, resolveCodexExecutable } from './agent-authoring-runtime-preflight' import { assertCdpOwner, assertNoRestartJob, @@ -23,15 +21,31 @@ import { resolveDevPluginVersion, restartJobPrefix, selectCodexPageUrl, + stopRuntimeProcesses, type ReinstallArguments } from './reinstall-codex-dev-plugin-runtime' +import { reinstallInApp, resumeThreadInApp } from './reinstall-codex-dev-plugin-ui' const exec = promisify(execFile) const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const pluginRoot = join(repoRoot, '.dev/plugins/tempad-dev-dev') -const displayName = 'TemPad Dev (Dev)' const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +type RecoveryReport = { + checkout: string + threadId: string + version: string + logPath: string + phase: string + updatedAt: string + error?: string +} + +async function saveReport(path: string, report: RecoveryReport) { + await writeFile(`${path}.tmp`, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }) + await rename(`${path}.tmp`, path) +} + async function waitUntil(check: () => Promise, label: string, timeoutMs: number) { const deadline = Date.now() + timeoutMs do { @@ -49,14 +63,6 @@ async function cdpReady(url: string): Promise { } } -/** One table serves executable matching, checkout runtime matching, and CDP ownership. */ -async function processes(): Promise { - const { stdout } = await exec('ps', ['-axo', 'pid=,ppid=,lstart=,command='], { - maxBuffer: 16 * 1024 * 1024 - }) - return parseProcessTable(stdout) -} - async function appExecutable(appPath: string): Promise { const { stdout } = await exec('/usr/libexec/PlistBuddy', [ '-c', @@ -71,11 +77,11 @@ async function appExecutable(appPath: string): Promise { async function restartApp(args: ReinstallArguments, appPath: string, appBinary: string) { if (await cdpReady(args.cdpUrl)) throw new Error('CDP became available before restart; rerun without --restart-codex.') - const pids = matchingProcesses(await processes(), appBinary) + const pids = matchingProcesses(await listRuntimeProcesses(), appBinary) if (pids.length > 1) throw new Error('Multiple main processes match the configured Codex App.') const waitForExit = (label: string) => waitUntil( - async () => matchingProcesses(await processes(), appBinary).length === 0, + async () => matchingProcesses(await listRuntimeProcesses(), appBinary).length === 0, label, 10_000 ) @@ -91,7 +97,7 @@ async function restartApp(args: ReinstallArguments, appPath: string, appBinary: ) await waitForExit('Codex to quit') } catch { - const remaining = matchingProcesses(await processes(), appBinary) + const remaining = matchingProcesses(await listRuntimeProcesses(), appBinary) if (remaining.some((pid) => !pids.includes(pid))) throw new Error('Codex process changed during restart; refusing to stop its replacement.') for (const pid of remaining) process.kill(pid, 'SIGTERM') @@ -109,11 +115,20 @@ async function restartApp(args: ReinstallArguments, appPath: string, appBinary: } async function detachRestart(args: ReinstallArguments, appPath: string) { - assertNoRestartJob((await exec('/bin/launchctl', ['list'])).stdout) const label = `${restartJobPrefix}${process.pid}.${Date.now()}` const logPath = join(repoRoot, '.dev', `${label}.log`) + const reportPath = join(repoRoot, '.dev', `${label}.json`) await mkdir(dirname(logPath), { recursive: true }) - await writeFile(logPath, '') + await writeFile(logPath, '', { mode: 0o600 }) + // Save identity before the independent job can quit the parent App. + await saveReport(reportPath, { + checkout: repoRoot, + threadId: args.resumeThread!, + version: args.version!, + logPath, + phase: 'scheduled', + updatedAt: new Date().toISOString() + }) await exec( '/bin/launchctl', [ @@ -129,10 +144,19 @@ async function detachRestart(args: ReinstallArguments, appPath: string) { '-c', '"$@"\nstatus=$?\n/bin/launchctl remove "$0"\nexit "$status"', label, + '/usr/bin/env', + '-i', + ...['HOME', 'USER', 'LOGNAME', 'PATH', 'CODEX_HOME', 'CODEX_SQLITE_HOME', 'TMPDIR'].flatMap( + (key) => (process.env[key] === undefined ? [] : [`${key}=${process.env[key]}`]) + ), process.execPath, ...process.execArgv, fileURLToPath(import.meta.url), '--resume-after-restart', + '--resume-thread', + args.resumeThread!, + '--report-path', + reportPath, '--app-path', appPath, '--cdp-url', @@ -144,94 +168,34 @@ async function detachRestart(args: ReinstallArguments, appPath: string) { ], { cwd: repoRoot } ) - console.log(`Restart and App reinstall scheduled. Log: ${logPath}`) + console.log( + `Restart, reinstall and task recovery scheduled. Report: ${reportPath}\nLog: ${logPath}` + ) } async function stopCheckoutRuntime() { const entries = ['cli.mjs', 'hub.mjs'].map((name) => join(repoRoot, 'packages/mcp-server/dist', name) ) - const matching = async () => checkoutRuntimeProcesses(await processes(), entries) - // Uninstall in the App first, so its MCP connections cannot relaunch the old runtime. - for (const pid of await matching()) { - try { - process.kill(pid, 'SIGTERM') - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error - } + let fixtureMarker + try { + fixtureMarker = JSON.parse(await readFile(join(pluginRoot, 'runtime-fixture.json'), 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } - await waitUntil( - async () => (await matching()).length === 0, - 'this checkout’s old MCP processes to exit', - 15_000 + if (fixtureMarker) + entries.push( + ...nativeFixtureRuntimeEntries( + readNativeRuntimeFixture(fixtureMarker.descriptor, repoRoot, { allowStaleSource: true }) + ) + ) + const matching = async () => checkoutRuntimeProcesses(await listRuntimeProcesses(), entries) + const stopped = await stopRuntimeProcesses(matching, (pid) => process.kill(pid, 'SIGTERM')) + console.log( + `Confirmed checkout CLI/Hub processes stayed stopped for 1500ms. Stopped PIDs: ${stopped.join(', ') || 'none'}.` ) } -async function openPlugin(page: Page) { - // The heading's accessible name also includes its "Folder marketplace plugin" icon. - const title = page.getByRole('heading', { level: 1 }).filter({ hasText: /^TemPad Dev \(Dev\)$/ }) - if (await title.isVisible()) return - const main = page.getByRole('main') - const search = main.getByRole('textbox', { name: 'Search plugins', exact: true }) - if (!(await search.isVisible())) { - await page - .getByRole('button', { name: 'Plugins', exact: true }) - .evaluate((element: HTMLElement) => element.click()) - await page.getByRole('heading', { name: 'Plugins', exact: true }).waitFor({ state: 'visible' }) - } - // The App commits catalog navigation and debounced search asynchronously. - // Selecting the transient search result can otherwise reset to the empty catalog. - await pause(500) - await search.evaluate((input: HTMLInputElement, value) => { - input.blur() - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set - if (!setter) throw new Error('Could not update the plugin search input.') - setter.call(input, value) - input.dispatchEvent(new Event('input', { bubbles: true })) - }, displayName) - await pause(500) - await main - .getByRole('button') - .filter({ has: page.getByText(displayName, { exact: true }) }) - .and(page.locator(':not([data-search-result-active])')) - .or(main.getByRole('link', { name: displayName, exact: true })) - .evaluate((element: HTMLElement) => element.click()) - await search.waitFor({ state: 'hidden' }) - await title.waitFor({ state: 'visible' }) - await main - .getByRole('button', { name: 'Install plugin', exact: true }) - .or(main.getByRole('button', { name: 'More actions', exact: true })) - .first() - .waitFor({ state: 'visible' }) -} - -async function reinstallInApp(page: Page, version: string) { - await openPlugin(page) - console.log('Opened the development plugin detail in Codex App.') - // Detail and sticky-header controls can both render the same plugin action. - const main = page.getByRole('main') - const install = main.getByRole('button', { name: 'Install plugin', exact: true }).first() - const actions = main.getByRole('button', { name: 'More actions', exact: true }).first() - if (await actions.isVisible()) { - await actions.click() - await page.getByRole('menuitem', { name: 'Uninstall', exact: true }).click() - const confirm = page.getByRole('dialog').getByRole('button', { name: 'Uninstall', exact: true }) - await confirm.or(install).first().waitFor({ state: 'visible' }) - if (await confirm.isVisible()) await confirm.click() - await install.waitFor({ state: 'visible' }) - console.log('Codex App confirms the development plugin is uninstalled.') - } - await stopCheckoutRuntime() - // Reopen the catalog entry to read the generated source rather than cached installed details. - await openPlugin(page) - await page.getByText(version, { exact: true }).waitFor({ state: 'visible' }) - await install.evaluate((element: HTMLElement) => element.click()) - await actions.waitFor({ state: 'visible' }) - await page.getByText(version, { exact: true }).waitFor({ state: 'visible' }) - if (await install.isVisible()) - throw new Error('Codex App still offers installation; reinstall is not confirmed.') -} - async function main() { const args = parseReinstallArguments(process.argv.slice(2)) if (!args) { @@ -243,6 +207,7 @@ async function main() { ' --app-path Host app (default: CODEX_APP_PATH or /Applications/ChatGPT.app)', ' --timeout-ms Each UI transition timeout (default: 60000)', ' --restart-codex Explicitly authorize restarting the App to enable CDP', + ' --resume-thread Restore and continue this task (restart defaults to CODEX_THREAD_ID)', 'CLI discovery checks the source only. Success requires uninstall/install in the App.', 'Restart recovery runs detached and logs to .dev; it can interrupt other Codex tasks.' ].join('\n') @@ -251,6 +216,7 @@ async function main() { } if (process.platform !== 'darwin') throw new Error('Codex App reinstall currently supports macOS only.') + if (!args.resumeAfterRestart) assertNoRestartJob((await exec('/bin/launchctl', ['list'])).stdout) const appPath = resolve(args.appPath ?? process.env.CODEX_APP_PATH ?? '/Applications/ChatGPT.app') const version = resolveDevPluginVersion( JSON.parse(await readFile(join(pluginRoot, '.codex-plugin/plugin.json'), 'utf8')), @@ -264,8 +230,7 @@ async function main() { .stdout ) as unknown await inspectDevPlugin(runCodex, pluginRoot) - if (args.resumeAfterRestart) await restartApp(args, appPath, appBinary) - else if (args.restartCodex) { + if (args.restartCodex) { if (await cdpReady(args.cdpUrl)) throw new Error( 'CDP is already available. Retry without --restart-codex; select a page with --page-url if needed.' @@ -273,51 +238,118 @@ async function main() { await detachRestart({ ...args, version }, appPath) return } - if (!(await cdpReady(args.cdpUrl))) - throw new Error( - `Codex CDP is unavailable at ${args.cdpUrl}. Use --restart-codex to authorize restarting the App and enabling CDP.` - ) - const [listeners, running] = await Promise.all([ - exec('lsof', ['-nP', `-iTCP:${args.cdpPort}`, '-sTCP:LISTEN', '-Fp']), - processes() - ]) - assertCdpOwner(listeners.stdout, matchingProcesses(running, appBinary), running) - // Resolve via native fetch, then connect directly: the App's local endpoint must not - // depend on Playwright's proxy-aware HTTP discovery. - const discovery = await fetch(new URL('/json/version', args.cdpUrl), { - signal: AbortSignal.timeout(args.timeoutMs) - }) - if (!discovery.ok) throw new Error(`CDP discovery failed: HTTP ${discovery.status}.`) - const browser = await chromium.connectOverCDP( - cdpWebSocketUrl(await discovery.json(), args.cdpUrl), - { timeout: args.timeoutMs } + const report: RecoveryReport | undefined = args.reportPath + ? JSON.parse(await readFile(args.reportPath, 'utf8')) + : undefined + if ( + args.reportPath && + (!report || + report.checkout !== repoRoot || + report.threadId !== args.resumeThread || + report.version !== version || + report.phase !== 'scheduled') ) + throw new Error('Recovery report does not match this scheduled restart; refusing to replay it.') + const progress = async (phase: string, error?: string) => { + console.log(phase, error ?? '') + if (report && args.reportPath) { + Object.assign(report, { + phase, + updatedAt: new Date().toISOString(), + ...(error ? { error } : {}) + }) + await saveReport(args.reportPath, report) + } + } try { - await waitUntil( - async () => - browser - .contexts() - .some((context) => context.pages().some((page) => page.url().startsWith('app:'))), - 'the Codex App page', - args.timeoutMs - ) - const pages = browser.contexts().flatMap((context) => context.pages()) - const url = selectCodexPageUrl( - pages.map((page) => page.url()), - args.pageUrl - ) - const page = pages.find((page) => page.url() === url)! - page.setDefaultTimeout(args.timeoutMs) - console.log(`Reinstalling ${displayName} ${version} in ${url}.`) - await reinstallInApp(page, version) - await inspectDevPlugin(runCodex, pluginRoot, version) - console.log(`Verified Codex App installation and enabled disk version ${version}.`) - console.log( - 'Run preflight and a native MCP read before live testing; installation alone does not prove task transport readiness.' + if (args.resumeAfterRestart) { + await progress('restarting') + await restartApp(args, appPath, appBinary) + } + if (!(await cdpReady(args.cdpUrl))) + throw new Error( + `Codex CDP is unavailable at ${args.cdpUrl}. Use --restart-codex to authorize restarting the App and enabling CDP.` + ) + const [listeners, running] = await Promise.all([ + exec('lsof', ['-nP', `-iTCP:${args.cdpPort}`, '-sTCP:LISTEN', '-Fp']), + listRuntimeProcesses() + ]) + assertCdpOwner(listeners.stdout, matchingProcesses(running, appBinary), running) + // Resolve via native fetch, then connect directly: the App's local endpoint must not + // depend on Playwright's proxy-aware HTTP discovery. + const discovery = await fetch(new URL('/json/version', args.cdpUrl), { + signal: AbortSignal.timeout(args.timeoutMs) + }) + if (!discovery.ok) throw new Error(`CDP discovery failed: HTTP ${discovery.status}.`) + const browser = await chromium.connectOverCDP( + cdpWebSocketUrl(await discovery.json(), args.cdpUrl), + { timeout: args.timeoutMs } ) - } finally { - // For a CDP-attached browser, close disconnects this client without quitting Codex. - await browser.close() + try { + await waitUntil( + async () => + browser + .contexts() + .some((context) => context.pages().some((page) => page.url().startsWith('app:'))), + 'the Codex App page', + args.timeoutMs + ) + const pages = browser.contexts().flatMap((context) => context.pages()) + const url = selectCodexPageUrl( + pages.map((page) => page.url()), + args.pageUrl + ) + const page = pages.find((page) => page.url() === url)! + page.setDefaultTimeout(args.timeoutMs) + let installError: unknown + try { + await progress('reinstalling') + await reinstallInApp(page, version, async () => { + if (await inspectDevPlugin(runCodex, pluginRoot)) + throw new Error( + 'Plugin still installed on disk after UI uninstall; refusing to continue.' + ) + await progress('uninstalled') + await stopCheckoutRuntime() + await progress('runtime-stopped') + }) + await inspectDevPlugin(runCodex, pluginRoot, version) + await progress('installed') + console.log(`Verified Codex App installation and enabled disk version ${version}.`) + } catch (error) { + installError = error + await progress('install-failed', error instanceof Error ? error.message : String(error)) + } + if (args.resumeThread) { + const message = [ + 'Continue the TemPad Dev runtime refresh task after the authorized App restart.', + installError + ? 'The plugin reinstall failed; inspect its log before taking further action.' + : `The development plugin was reinstalled at ${version}; verify runtime readiness before live testing.`, + args.reportPath ? `Recovery report: ${args.reportPath}` : '', + installError + ? 'Do not repeat the restart or dispatch live Figma writes. Diagnose the saved failure first; preserve the existing task scope.' + : 'Do not repeat this restart. Continue with Chrome extension reload, Figma refresh, MCP activation and preflight; preserve the existing task scope.' + ] + .filter(Boolean) + .join(' ') + const result = await resumeThreadInApp(page, args.resumeThread, message, () => + progress('resume-dispatching') + ) + await progress(`resume-${result}`) + } + if (installError) throw installError + await progress('complete') + console.log( + 'Run preflight and a native MCP read before live testing; installation alone does not prove task transport readiness.' + ) + } finally { + // For a CDP-attached browser, close disconnects this client without quitting Codex. + await browser.close() + } + } catch (error) { + await progress('failed', error instanceof Error ? error.message : String(error)) + throw error } } diff --git a/packages/extension/scripts/screenshot-plan.ts b/packages/extension/scripts/screenshot-plan.ts index d862705d..88743cb0 100644 --- a/packages/extension/scripts/screenshot-plan.ts +++ b/packages/extension/scripts/screenshot-plan.ts @@ -1,8 +1,28 @@ +import { parseArgs, type ParseArgsOptionsConfig } from 'node:util' + export type ScreenshotScenario = { group: 'inspect' | 'setup' | 'status' id: string mcpStatus?: string view: string + visualReview?: Array<{ kind: 'measurement-labels'; values: number[] }> +} + +export const selectionOptions = { + only: { type: 'string' }, + group: { type: 'string' }, + themes: { type: 'string' } +} as const + +export function parseScreenshotOptions( + options: T, + args = process.argv.slice(2) +) { + const { values } = parseArgs({ options, args }) + for (const [name, value] of Object.entries(values)) { + if (value === '') throw new Error(`--${name} requires a value.`) + } + return values } export function needsFixtureRuntime(scenarios: readonly ScreenshotScenario[]): boolean { diff --git a/packages/extension/scripts/verify-screenshots.ts b/packages/extension/scripts/verify-screenshots.ts index ec3f0013..0a549e29 100644 --- a/packages/extension/scripts/verify-screenshots.ts +++ b/packages/extension/scripts/verify-screenshots.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { AGENT_SETUP_SHOT, INSPECTION_SLIDES } from '../../site/src/content/landing' +import { parseScreenshotOptions } from './screenshot-plan' type Scenario = { assertions: unknown[] @@ -80,6 +81,7 @@ type ScreenshotManifest = { } const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +parseScreenshotOptions({}) const manifestPath = fileURLToPath(new URL('../screenshots/scenarios.json', import.meta.url)) const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as ScreenshotManifest const readmes = await Promise.all( diff --git a/packages/extension/tests/components/agent-setup-dialog.browser.test.ts b/packages/extension/tests/components/agent-setup-dialog.browser.test.ts index 41014968..4425acc2 100644 --- a/packages/extension/tests/components/agent-setup-dialog.browser.test.ts +++ b/packages/extension/tests/components/agent-setup-dialog.browser.test.ts @@ -186,7 +186,7 @@ describe('AgentSetupDialog', () => { await page.getByRole('tab', { name: 'Gemini' }).click() expect(getCode(host)).toEqual([ - 'gemini mcp add --scope user "tempad-dev" npx -y @tempad-dev/mcp@latest', + 'gemini mcp add --scope user "tempad-dev" npx -y @tempad-dev/mcp@latest --runtime-updates=stable', 'gemini skills install https://github.com/ecomfe/tempad-dev.git --path agent-plugin/targets/standard/skills/figma-design-to-code', 'gemini skills install https://github.com/ecomfe/tempad-dev.git --path agent-plugin/targets/standard/skills/design-in-figma' ]) @@ -203,7 +203,7 @@ describe('AgentSetupDialog', () => { mcp: { 'tempad-dev': { type: 'local', - command: ['npx', '-y', '@tempad-dev/mcp@latest'] + command: ['npx', '-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] } } }) diff --git a/packages/extension/tests/components/code-section.browser.test.ts b/packages/extension/tests/components/code-section.browser.test.ts index e13cdcd3..efba3fec 100644 --- a/packages/extension/tests/components/code-section.browser.test.ts +++ b/packages/extension/tests/components/code-section.browser.test.ts @@ -7,7 +7,7 @@ vi.mock('@/utils/codegen', async (original) => ({ })) import CodeSection from '@/components/sections/CodeSection.vue' -import { options, selection } from '@/ui/state' +import { activePlugin, options, plugins, selection, selectionRevision } from '@/ui/state' import { generateCodeBlocksForNode } from '@/utils/codegen' import { mount, unmountAll } from './mount' @@ -15,7 +15,11 @@ import { mount, unmountAll } from './mount' afterEach(() => { unmountAll() selection.value = [] - vi.clearAllMocks() + options.value.cssUnit = 'px' + options.value.activePluginSource = null + plugins.value = {} + vi.useRealTimers() + vi.resetAllMocks() }) it('keeps the latest selection code and does not regenerate when the panel moves', async () => { @@ -56,7 +60,142 @@ it('clears stale code and exposes generation errors', async () => { .toContain('Code generation failed') }) +it('retains identical same-selection code and its text selection without loading announcements', async () => { + const generate = vi.mocked(generateCodeBlocksForNode) + const result = { + codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css' as const, code: 'unchanged-code' }] + } + generate.mockResolvedValueOnce(result) + const pending = Promise.withResolvers>>() + generate.mockReturnValueOnce(pending.promise) + const host = mount(CodeSection) + selection.value = [{ id: 'same' } as SceneNode] + await expect.poll(() => host.textContent).toContain('unchanged-code') + await expect.poll(() => host.querySelector('.tp-section-enter-active')).toBeNull() + const block = host.querySelector('.tp-code-code')! + const code = block.querySelector('code')! + const range = document.createRange() + range.selectNodeContents(code) + window.getSelection()!.removeAllRanges() + window.getSelection()!.addRange(range) + expect(window.getSelection()!.toString()).toBe('unchanged-code') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + selectionRevision.value++ + await nextTick() + await vi.advanceTimersByTimeAsync(200) + expect(host.querySelector('.tp-code-code')).toBe(block) + expect(window.getSelection()!.toString()).toBe('unchanged-code') + expect(host.querySelector('[role="status"]')?.textContent).toBe('') + expect(host.querySelector('[aria-busy="true"]')).toBeNull() + pending.resolve(structuredClone(result)) + await nextTick() + expect(host.querySelector('.tp-code-code')).toBe(block) + expect(window.getSelection()!.toString()).toBe('unchanged-code') +}) + +it('retains code and its text selection when plugin storage changes without changing active code', async () => { + const source = 'https://example.com/plugin.js' + const plugin = { source, name: 'Plugin', code: 'plugin-code' } + plugins.value = { [source]: plugin } + options.value.activePluginSource = source + await expect.poll(() => activePlugin.value?.code).toBe(plugin.code) + const generate = vi.mocked(generateCodeBlocksForNode) + generate.mockResolvedValueOnce({ + codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'plugin-output' }] + }) + const pending = Promise.withResolvers>>() + generate.mockReturnValue(pending.promise) + const host = mount(CodeSection) + selection.value = [{ id: 'same' } as SceneNode] + await expect.poll(() => host.textContent).toContain('plugin-output') + await expect.poll(() => host.querySelector('.tp-section-enter-active')).toBeNull() + const block = host.querySelector('.tp-code-code')! + const range = document.createRange() + range.selectNodeContents(block.querySelector('code')!) + window.getSelection()!.removeAllRanges() + window.getSelection()!.addRange(range) + + const records: (typeof plugins.value)[] = [ + { [source]: { ...plugin }, other: { source: 'other', name: 'Other', code: 'other-code' } }, + { [source]: { ...plugin, name: 'Renamed plugin' } } + ] + for (const record of records) { + plugins.value = record + await expect.poll(() => activePlugin.value).toBe(record[source]) + await nextTick() + expect(generate).toHaveBeenCalledOnce() + expect(host.querySelector('.tp-code-code')).toBe(block) + expect(window.getSelection()!.toString()).toBe('plugin-output') + expect(host.querySelector('[aria-busy="true"]')).toBeNull() + } + expect(host.textContent).toContain('Renamed plugin') + + plugins.value = { [source]: { ...plugin, code: 'changed-plugin-code' } } + await expect.poll(() => generate.mock.calls.length).toBe(2) + expect(host.textContent).not.toContain('plugin-output') + expect(generate.mock.lastCall?.[2]).toBe('changed-plugin-code') + pending.resolve({ + codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'changed-output' }] + }) + await expect.poll(() => host.textContent).toContain('changed-output') +}) + +it('clears retained code if refreshing the same node fails', async () => { + const generate = vi.mocked(generateCodeBlocksForNode) + generate.mockResolvedValueOnce({ + codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'old-code' }] + }) + const pending = Promise.withResolvers>>() + generate.mockReturnValueOnce(pending.promise) + const host = mount(CodeSection) + selection.value = [{ id: 'same' } as SceneNode] + await expect.poll(() => host.textContent).toContain('old-code') + selectionRevision.value++ + await nextTick() + expect(host.textContent).toContain('old-code') + pending.reject(new Error('Changed plugin failed')) + await expect + .poll(() => host.querySelector('[role="alert"]')?.textContent) + .toContain('Code generation failed') + expect(host.textContent).not.toContain('old-code') +}) + +it.each(['unit', 'plugin'] as const)( + 'does not offer previous code for copying while a new %s configuration is pending', + async (setting) => { + const generate = vi.mocked(generateCodeBlocksForNode) + generate.mockResolvedValueOnce({ + codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'old-configuration' }] + }) + const pending = Promise.withResolvers>>() + generate.mockReturnValueOnce(pending.promise) + const host = mount(CodeSection) + selection.value = [{ id: 'same' } as SceneNode] + await expect.poll(() => host.textContent).toContain('old-configuration') + if (setting === 'unit') { + options.value.cssUnit = 'rem' + } else { + const source = 'https://example.com/new-plugin.js' + plugins.value = { [source]: { source, name: 'New plugin', code: 'new-plugin' } } + options.value.activePluginSource = source + await expect.poll(() => activePlugin.value?.code).toBe('new-plugin') + } + await nextTick() + expect(generate).toHaveBeenCalledTimes(2) + expect(host.textContent).not.toContain('old-configuration') + expect(host.querySelector('.tp-code [data-tooltip="Copy"]')).toBeNull() + expect(host.querySelector('[aria-busy="true"]')).not.toBeNull() + if (setting === 'plugin') expect(host.textContent).toContain('New plugin') + pending.resolve({ + codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'new-configuration' }] + }) + await expect.poll(() => host.textContent).toContain('new-configuration') + expect(host.querySelector('.tp-code [data-tooltip="Copy"]')).not.toBeNull() + } +) + it('keeps the Code surface while loading and retries without changing selection', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const generate = vi.mocked(generateCodeBlocksForNode) const pending = Promise.withResolvers>>() generate.mockReturnValueOnce(pending.promise) @@ -66,15 +205,48 @@ it('keeps the Code surface while loading and retries without changing selection' const host = mount(CodeSection) selection.value = [{ id: 'slow' } as SceneNode] await nextTick() - expect(host.querySelector('[role="status"]')?.textContent).toContain('Generating code') + expect(host.querySelector('[aria-busy="true"]')).not.toBeNull() + expect(host.querySelector('[role="status"]')?.textContent).toBe('') expect(host.querySelector('section')).not.toBeNull() + await vi.advanceTimersByTimeAsync(149) + expect(host.querySelector('[role="status"]')?.textContent).toBe('') + await vi.advanceTimersByTimeAsync(1) + const status = host.querySelector('[role="status"]')! + expect(status.textContent).toContain('Generating code') + expect(status.closest('[aria-busy="true"]')).toBeNull() pending.reject(new Error('temporary failure')) await expect.poll(() => host.querySelector('[role="alert"]')).not.toBeNull() + expect(host.querySelector('[role="status"]')?.textContent).toBe('') host.querySelector('.tp-code-retry')!.click() await expect.poll(() => host.textContent).toContain('retried') expect(generate).toHaveBeenCalledTimes(2) }) +it('does not show stale loading hints for quick or replaced generations', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const generate = vi.mocked(generateCodeBlocksForNode) + const first = Promise.withResolvers>>() + generate.mockReturnValueOnce(first.promise) + generate.mockImplementation(() => new Promise(() => undefined)) + const host = mount(CodeSection) + selection.value = [{ id: 'quick' } as SceneNode] + await nextTick() + await vi.advanceTimersByTimeAsync(100) + first.resolve({ codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'quick-code' }] }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain('quick-code') + expect(host.querySelector('[role="status"]')?.textContent).toBe('') + selection.value = [{ id: 'replaced' } as SceneNode] + await nextTick() + await vi.advanceTimersByTimeAsync(100) + selection.value = [{ id: 'current' } as SceneNode] + await nextTick() + await vi.advanceTimersByTimeAsync(50) + expect(host.querySelector('[role="status"]')?.textContent).toBe('') + await vi.advanceTimersByTimeAsync(100) + expect(host.querySelector('[role="status"]')?.textContent).toContain('Generating code') +}) + it('keeps the previous content height without presenting stale code for the new selection', async () => { const generate = vi.mocked(generateCodeBlocksForNode) const pending = Promise.withResolvers>>() @@ -88,14 +260,14 @@ it('keeps the previous content height without presenting stale code for the new selection.value = [{ id: 'first' } as SceneNode] await expect.poll(() => host.textContent).toContain('old-selection-code') const section = host.querySelector('section')! - const height = host.querySelector('[aria-busy]')!.getBoundingClientRect().height + const height = host.querySelector('.tp-code-content')!.getBoundingClientRect().height selection.value = [{ id: 'second' } as SceneNode] await nextTick() expect(host.querySelector('section')).toBe(section) expect(host.textContent).not.toContain('old-selection-code') - expect(host.querySelector('[aria-busy]')!.getBoundingClientRect().height).toBeGreaterThanOrEqual( - height - ) + expect( + host.querySelector('.tp-code-content')!.getBoundingClientRect().height + ).toBeGreaterThanOrEqual(height) pending.resolve({ codeBlocks: [{ name: 'css', title: 'CSS', lang: 'css', code: 'new-selection-code' }] }) diff --git a/packages/extension/tests/components/code.browser.test.ts b/packages/extension/tests/components/code.browser.test.ts new file mode 100644 index 00000000..155e1b6d --- /dev/null +++ b/packages/extension/tests/components/code.browser.test.ts @@ -0,0 +1,41 @@ +import Prism from 'prismjs' +import { afterEach, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' + +vi.mock('@/composables/toast', () => ({ useToast: () => ({ show: vi.fn() }) })) + +import Code from '@/components/Code.vue' + +import { mount, unmountAll } from './mount' + +afterEach(() => { + unmountAll() + vi.unstubAllGlobals() +}) + +it.each([false, true])( + 'preserves code whitespace when displaying and copying (highlighted: %s)', + async (highlighted) => { + vi.stubGlobal('Prism', highlighted ? Prism : undefined) + const clipboard = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const source = ref('const text = `hello \t\n\nworld\n`\n') + const host = mount( + defineComponent( + () => () => + h(Code, { + title: 'JavaScript', + lang: 'javascript', + code: source.value + }) + ) + ) + for (const text of [source.value, 'const text = `updated \n\n`\n']) { + source.value = text + await nextTick() + expect(host.querySelector('code')!.textContent).toBe(text) + expect(host.querySelectorAll('.tp-code-line-number')).toHaveLength(text.split('\n').length) + host.querySelector('[aria-label="Copy"]')!.click() + await expect.poll(() => clipboard.mock.lastCall?.[0]).toBe(text) + } + } +) diff --git a/packages/extension/tests/components/copyable.browser.test.ts b/packages/extension/tests/components/copyable.browser.test.ts new file mode 100644 index 00000000..24fbeded --- /dev/null +++ b/packages/extension/tests/components/copyable.browser.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { userEvent } from 'vitest/browser' +import { defineComponent, h, useTemplateRef } from 'vue' + +vi.mock('@/composables/toast', () => ({ useToast: () => ({ show: vi.fn() }) })) + +import Copyable from '@/components/Copyable.vue' + +import { mount, unmountAll } from './mount' + +beforeEach(() => { + vi.stubGlobal('useTemplateRef', useTemplateRef) +}) +afterEach(() => { + unmountAll() + vi.unstubAllGlobals() +}) + +it.each([ + { label: 'Layer name', copy: undefined }, + { label: 'Library label', copy: 'Complete library name' } +])('copies $label with Enter and Space from the tab sequence', async ({ label, copy }) => { + const clipboard = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + const submit = vi.fn((event: Event) => event.preventDefault()) + const host = mount( + defineComponent( + () => () => + h('form', { onSubmit: submit }, [ + h('button', { type: 'button' }, 'Before'), + h(Copyable, { 'data-copy': copy }, () => label), + h('button', { type: 'button' }, 'After') + ]) + ), + { tokens: { '--color-border-selected': '#0d99ff' } } + ) + const target = host.querySelector('.tp-copyable')! + host.querySelector('button')!.focus() + await userEvent.keyboard('{Tab}') + expect(document.activeElement).toBe(target) + expect(target.getAttribute('role')).toBe('button') + const focusStyle = getComputedStyle(target) + expect(focusStyle.outlineStyle).not.toBe('none') + expect(parseFloat(focusStyle.outlineWidth)).toBeGreaterThan(0) + expect(focusStyle.outlineColor).toBe('rgb(13, 153, 255)') + + await userEvent.keyboard('{Enter}') + await expect.poll(() => clipboard.mock.calls.length).toBe(1) + expect(clipboard).toHaveBeenLastCalledWith(copy ?? label) + await userEvent.keyboard(' ') + await expect.poll(() => clipboard.mock.calls.length).toBe(2) + expect(clipboard).toHaveBeenLastCalledWith(copy ?? label) + expect(document.activeElement).toBe(target) + expect(submit).not.toHaveBeenCalled() +}) diff --git a/packages/extension/tests/components/design-task-feedback.browser.test.ts b/packages/extension/tests/components/design-task-feedback.browser.test.ts index a19a722a..b736834b 100644 --- a/packages/extension/tests/components/design-task-feedback.browser.test.ts +++ b/packages/extension/tests/components/design-task-feedback.browser.test.ts @@ -7,7 +7,7 @@ import type { } from '@tempad-dev/shared' import { afterEach, describe, expect, it, vi } from 'vitest' -import { page, userEvent } from 'vitest/browser' +import { cdp, page, userEvent } from 'vitest/browser' import { defineComponent, h, nextTick, shallowRef } from 'vue' import DesignTaskStatus from '@/components/DesignTaskStatus.vue' @@ -1470,6 +1470,64 @@ describe('element feedback drafts', () => { ).toEqual(['Keep the button', 'Keep this heading']) }) + it('uses a static nontext warning for unsaved comments with reduced motion', async () => { + await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] + }) + try { + await page.viewport(900, 700) + expect(window.matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true) + const f = fixture() + f.api.currentPage.selection = [f.nodes[0]!] + await expect.poll(() => document.querySelector('.tp-feedback-marker-add')).not.toBeNull() + click('.tp-feedback-marker-add') + const input = page.getByRole('textbox', { name: 'Element comment', exact: true }) + await input.fill('Unsaved thought') + const outside = document.createElement('button') + outside.textContent = 'Outside editor' + Object.assign(outside.style, { position: 'fixed', left: '0', top: '0' }) + f.host.append(outside) + await page.getByRole('button', { name: 'Outside editor', exact: true }).click() + await expect.poll(() => document.querySelector('.tp-feedback-editor-warned')).not.toBeNull() + const editor = document.querySelector('.tp-feedback-editor')! + expect(getComputedStyle(editor).animationName).toBe('none') + expect(getComputedStyle(editor).outlineStyle).toBe('solid') + expect(getComputedStyle(editor).outlineWidth).toBe('2px') + const luminance = (color: string) => { + const [r, g, b] = color + .match(/\d+/g)! + .slice(0, 3) + .map((channel) => { + const value = Number(channel) / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b! + } + for (const surface of ['#fff', '#2c2c2c']) { + editor.style.setProperty('--color-bg', surface) + const style = getComputedStyle(editor) + const foreground = luminance(style.outlineColor) + const background = luminance(style.backgroundColor) + expect( + (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05) + ).toBeGreaterThanOrEqual(3) + } + editor.style.setProperty('--color-border-warning', '#c47800') + expect(getComputedStyle(editor).outlineColor).toBe('rgb(196, 120, 0)') + expect(editor.querySelector('[role="status"]')).toBeNull() + await expect.element(input).toHaveValue('Unsaved thought') + await input.fill('Revised thought') + expect(getComputedStyle(editor).outlineStyle).toBe('none') + await page.getByRole('button', { name: 'Outside editor', exact: true }).click() + await page.getByRole('button', { name: 'Outside editor', exact: true }).click() + await expect.poll(() => document.querySelector('.tp-feedback-editor')).toBeNull() + expect((await f.store.request({ operation: 'load', scope })).items).toHaveLength(0) + expect(f.send).not.toHaveBeenCalled() + } finally { + await cdp().send('Emulation.setEmulatedMedia', { features: [] }) + } + }) + it('warns once before discarding an unsaved edit on outside click, and resets the warning after typing', async () => { await page.viewport(900, 700) const f = fixture() @@ -2260,9 +2318,16 @@ describe('element feedback drafts', () => { done().click() await page.getByRole('button', { name: 'Discard comments and finish' }).click() await nextTick() - expect(done().disabled).toBe(true) + expect(done().matches(':disabled, [aria-disabled="true"]')).toBe(true) + await expect.poll(() => document.querySelector('[role="dialog"]')).toBeNull() + done().click() + await nextTick() + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(f.requestDrafts.mock.calls.some(([request]) => request.operation === 'clear')).toBe( + false + ) await finishSave() - await expect.poll(() => done().disabled).toBe(false) + await expect.poll(() => done().matches(':disabled, [aria-disabled="true"]')).toBe(false) expect(await f.store.request({ operation: 'load', scope })).toEqual({ items: [], comment: 'Last guidance' diff --git a/packages/extension/tests/components/design-task-status.browser.test.ts b/packages/extension/tests/components/design-task-status.browser.test.ts index 0e5eda74..d16e7436 100644 --- a/packages/extension/tests/components/design-task-status.browser.test.ts +++ b/packages/extension/tests/components/design-task-status.browser.test.ts @@ -151,6 +151,105 @@ describe('design task status and canvas overlay', () => { .toBe(true) await expect.poll(() => f.done.mock.calls).toEqual([['task-a']]) }) + + it.each([false, true])( + 'requires an explicit retry after cancelled draft cleanup fails (anchor: %s)', + async (anchored) => { + await page.viewport(900, 700) + const pending: Array<{ + resolve: (snapshot: { items: [] }) => void + reject: (error: Error) => void + }> = [] + const requestDrafts = vi.fn< + NonNullable['$props']['requestDrafts']> + >(async (request) => { + if (request.operation !== 'clear') return { items: [] } + return new Promise((resolve, reject) => pending.push({ resolve, reject })) + }) + const close = vi.fn() + const f = fixture(vi.fn(), requestDrafts, close) + f.task.value = { + ...f.initial, + client: { kind: 'codex', name: 'Codex', sessionId: 'thread-a' } + } + if (anchored) f.anchor.value = f.node as unknown as SceneNode + await nextTick() + document.querySelector('.tp-design-stop')!.click() + expect(f.stop).toHaveBeenCalledOnce() + expect(document.querySelector('[role="dialog"]')).toBeNull() + f.task.value = { ...f.task.value, status: 'cancelled' } + await expect.poll(() => pending.length).toBe(1) + pending[0]!.reject(new Error('Comments are unavailable in this file.')) + await expect.poll(() => f.api.notify.mock.calls.length).toBe(1) + // Ordinary task refreshes must not turn the clear's busy transitions into retries. + f.task.value = { ...f.task.value, revision: 2 } + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(pending).toHaveLength(1) + expect(f.done).not.toHaveBeenCalled() + await expect.element(page.getByRole('button', { name: 'Done', exact: true })).toBeEnabled() + const button = document.querySelector('.tp-design-done')! + button.focus() + expect(document.activeElement).toBe(button) + await page.getByRole('button', { name: 'Done', exact: true }).click() + await expect.poll(() => pending.length).toBe(2) + expect(button.isConnected).toBe(true) + expect(document.activeElement).toBe(button) + expect(button.matches(':disabled, [aria-disabled="true"]')).toBe(true) + expect( + getComputedStyle( + button.closest('.tp-design-overlay, .tp-design-panel-controls')! + ).display + ).not.toBe('none') + button.click() + await nextTick() + expect(pending).toHaveLength(2) + pending[1]!.reject(new Error('Comments are unavailable in this file.')) + await expect.poll(() => f.api.notify.mock.calls.length).toBe(2) + expect(document.activeElement).toBe(button) + expect(button.matches(':disabled, [aria-disabled="true"]')).toBe(false) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(pending).toHaveLength(2) + await page.getByRole('button', { name: 'Done', exact: true }).click() + await expect.poll(() => pending.length).toBe(3) + pending[2]!.resolve({ items: [] }) + await expect.poll(() => f.done.mock.calls).toEqual([['task-a']]) + expect(f.api.notify).toHaveBeenCalledTimes(2) + expect(close).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + } + ) + + it('keeps a replacement task independent of a cancelled cleanup failure', async () => { + let rejectClear!: (error: Error) => void + const requestDrafts = vi.fn< + NonNullable['$props']['requestDrafts']> + >(async (request) => { + if (request.operation === 'clear' && request.scope.taskId === 'task-a') + return new Promise((_resolve, reject) => { + rejectClear = reject + }) + return { items: [] } + }) + const f = fixture(vi.fn(), requestDrafts) + const client = { kind: 'codex' as const, name: 'Codex', sessionId: 'thread-a' } + f.task.value = { ...f.initial, client, status: 'cancelled' } + await expect.poll(() => rejectClear).toBeTypeOf('function') + f.task.value = { ...f.initial, client, taskId: 'task-b' } + await nextTick() + rejectClear(new Error('Old cleanup failed')) + await nextTick() + expect(f.api.notify).not.toHaveBeenCalled() + expect(document.querySelector('.tp-design-done')).toBeNull() + await expect.element(page.getByRole('button', { name: 'Stop design task' })).toBeEnabled() + f.task.value = { ...f.task.value, status: 'cancelled' } + await expect.poll(() => f.done.mock.calls).toEqual([['task-b']]) + expect( + requestDrafts.mock.calls + .filter(([request]) => request.operation === 'clear') + .map(([request]) => request.scope.taskId) + ).toEqual(['task-a', 'task-b']) + }) + it('expands and collapses the panel entry in document flow with contextual text before an anchor exists', async () => { const f = fixture() const following = document.createElement('div') @@ -292,8 +391,10 @@ describe('design task status and canvas overlay', () => { operation: 'writing' } await nextTick() - const label = () => document.querySelector('.tp-design-anchor-label')!.textContent + const label = () => document.querySelector('.tp-design-label-text')!.textContent + const liveLabel = () => document.querySelector('[role="status"]')!.textContent expect(label()).toBe('My Design Client is updating the design') + expect(liveLabel()).toBe('My Design Client is designing') const panelText = f.panel.querySelector('.tp-design-locate > span')! expect(panelText.textContent).toBe('My Design Client is designing') expect(getComputedStyle(panelText).animationIterationCount).toBe('infinite') @@ -303,11 +404,17 @@ describe('design task status and canvas overlay', () => { f.task.value = { ...f.task.value, operation: 'reading' } await nextTick() expect(label()).toBe('My Design Client is reviewing the design') + expect(liveLabel()).toBe('My Design Client is designing') f.task.value = { ...f.task.value, operation: null } await nextTick() expect(label()).toBe('My Design Client is working on the design') + expect(liveLabel()).toBe('My Design Client is designing') + expect(document.querySelector('.tp-design-label-text')!.getAttribute('aria-hidden')).toBe( + 'true' + ) f.task.value = { ...f.task.value, status: 'paused' } await nextTick() + expect(liveLabel()).toBe("My Design Client's design task is paused") expect(getComputedStyle(panelText).animationName).toBe('none') }) @@ -806,7 +913,15 @@ describe('design task status and canvas overlay', () => { f.anchor.value = f.node as unknown as SceneNode await expect.element(page.getByRole('button', { name: 'Review comments' })).toBeVisible() expect(f.panel.querySelector('.tp-feedback-toggle')).toBeNull() + // The trigger's box is stable while the toolbar's opening animation still clips it. + document + .querySelector('.tp-design-feedback')! + .getAnimations() + .forEach((animation) => animation.finish()) await page.getByRole('button', { name: 'Review comments' }).click() + await expect + .element(page.getByRole('button', { name: 'Review comments' })) + .toHaveAttribute('aria-expanded', 'true') const comment = () => document.querySelector('#tp-feedback-comment') await expect.poll(() => comment()?.value).toBe('Saved comment') await page.getByRole('textbox', { name: 'General comment' }).fill('Keep this revision') @@ -833,7 +948,9 @@ describe('design task status and canvas overlay', () => { await page.getByRole('button', { name: 'Keep reviewing' }).click() expect(f.done).not.toHaveBeenCalled() expect(requestDrafts.mock.calls.some(([request]) => request.operation === 'clear')).toBe(false) - expect(comment()?.value).toBe('Keep this revision') + await expect.poll(comment).toBeNull() + await page.getByRole('button', { name: 'Review comments' }).click() + await expect.poll(() => comment()?.value).toBe('Keep this revision') await page.getByRole('button', { name: 'Done' }).click() await page.getByRole('button', { name: 'Discard comments and finish' }).click() await expect.poll(() => f.done.mock.calls.length).toBe(1) diff --git a/packages/extension/tests/components/icons.browser.test.ts b/packages/extension/tests/components/icons.browser.test.ts new file mode 100644 index 00000000..21d20fa1 --- /dev/null +++ b/packages/extension/tests/components/icons.browser.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, it } from 'vitest' +import { defineComponent, h } from 'vue' + +import Inspect from '@/components/icons/Inspect.vue' + +import { mount, unmountAll } from './mount' + +afterEach(unmountAll) + +it('resolves each inspect icon mask locally when the host page contains the same artwork', () => { + const host = mount( + defineComponent( + () => () => + h('div', [ + h('svg', [h('defs', [h('mask', { id: 'fpl-icon-24-move-a' })])]), + h(Inspect), + h(Inspect) + ]) + ) + ) + const icons = host.querySelectorAll('svg.tp-icon') + expect(icons).toHaveLength(2) + for (const icon of icons) { + const mask = icon.querySelector('mask')! + const reference = icon.querySelector('path[mask]')!.getAttribute('mask')! + expect(document.getElementById(reference.slice(5, -1))).toBe(mask) + } +}) diff --git a/packages/extension/tests/components/mcp-badge.browser.test.ts b/packages/extension/tests/components/mcp-badge.browser.test.ts index 97952f16..6e665009 100644 --- a/packages/extension/tests/components/mcp-badge.browser.test.ts +++ b/packages/extension/tests/components/mcp-badge.browser.test.ts @@ -1,5 +1,5 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { computed, nextTick, ref, watch } from 'vue' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computed, nextTick, ref, watch, type Ref } from 'vue' vi.mock('@/composables', async () => { const { ref } = await import('vue') @@ -46,18 +46,32 @@ import { layoutReady, options, runtimeMode } from '@/ui/state' import { mount, unmountAll } from './mount' -afterEach(() => { - unmountAll() - vi.unstubAllGlobals() -}) +// The fixture uses writable refs for the two production computed values. +const selfActive = useMcp().selfActive as Ref +const needsLocalHostPermission = useMcp().needsLocalHostPermission as Ref -it('shows recovery guidance on the connecting badge and clears it after reconnecting', async () => { +beforeEach(() => { vi.stubGlobal('computed', computed) vi.stubGlobal('ref', ref) vi.stubGlobal('watch', watch) layoutReady.value = true runtimeMode.value = 'standard' options.value.mcpOn = true + const mcp = useMcp() + mcp.status.value = 'connecting' + selfActive.value = false + mcp.count.value = 1 + mcp.errorMessage.value = null + needsLocalHostPermission.value = false +}) + +afterEach(() => { + unmountAll() + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +it('shows recovery guidance on the connecting badge and clears it after reconnecting', async () => { const mcp = useMcp() const host = mount(App) const badge = host.querySelector('.tp-mcp-badge')! @@ -79,3 +93,31 @@ it('shows recovery guidance on the connecting badge and clears it after reconnec badge.click() expect(mcp.activate).toHaveBeenCalledOnce() }) + +it('explains an unavailable selected tab while keeping manual activation available', async () => { + const mcp = useMcp() + mcp.status.value = 'connected' + mcp.errorMessage.value = 'The selected Figma tab is unavailable.' + const host = mount(App) + const badge = host.querySelector('.tp-mcp-badge')! + expect(badge.dataset.tooltip).toBe( + 'Inactive\nThe selected Figma tab is unavailable.\nClick to activate' + ) + badge.click() + expect(mcp.activate).toHaveBeenCalledOnce() + + mcp.errorMessage.value = null + mcp.count.value = 2 + await nextTick() + expect(badge.dataset.tooltip).toBe('Inactive • 2 files\nClick to activate') + selfActive.value = true + await nextTick() + expect(badge.dataset.tooltip).toBe('Active • 2 files') + + needsLocalHostPermission.value = true + await nextTick() + expect(badge.dataset.tooltip).toBe('Local host permission is required. Click to grant access.') + badge.click() + expect(mcp.requestLocalHostPermission).toHaveBeenCalledOnce() + expect(mcp.activate).toHaveBeenCalledOnce() +}) diff --git a/packages/extension/tests/components/meta-section.browser.test.ts b/packages/extension/tests/components/meta-section.browser.test.ts index ca8d2fae..9561a474 100644 --- a/packages/extension/tests/components/meta-section.browser.test.ts +++ b/packages/extension/tests/components/meta-section.browser.test.ts @@ -1,9 +1,9 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { page } from 'vitest/browser' -import { computed, shallowRef, useTemplateRef } from 'vue' +import { computed, nextTick, shallowRef, useTemplateRef } from 'vue' import MetaSection from '@/components/sections/MetaSection.vue' -import { selection } from '@/ui/state' +import { selection, selectionRevision } from '@/ui/state' import { mount, unmountAll } from './mount' @@ -16,10 +16,15 @@ vi.mock('@/composables', async () => ({ vi.mock('@/ui/state', async () => { const { shallowRef, computed } = await import('vue') const selection = shallowRef([]) + const selectionRevision = shallowRef(0) return { selection, + selectionRevision, selectedNode: computed(() => selection.value[0] ?? null), - selectedTemPadComponent: shallowRef(null) + selectedTemPadComponent: computed(() => { + void selectionRevision.value + return null + }) } }) @@ -54,6 +59,19 @@ afterEach(() => { vi.unstubAllGlobals() }) +it('updates the title and copied name when the same ordinary layer is renamed', async () => { + const selected = node(async () => []) + selection.value = [selected] + const host = mount(MetaSection) + const title = host.querySelector('.tp-meta-title .tp-copyable')! + expect(title.textContent).toBe('Layer') + selected.name = 'Renamed layer' + selectionRevision.value++ + await nextTick() + expect(title.textContent).toBe('Renamed layer') + expect(title.getAttribute('data-tooltip')).toBe('Click to copy layer name: Renamed layer') +}) + it('refreshes empty, changed, and deleted links without changing selection', async () => { const read = vi.fn<() => Promise>().mockResolvedValue([]) selection.value = [node(read)] diff --git a/packages/extension/tests/components/panel-layering.browser.test.ts b/packages/extension/tests/components/panel-layering.browser.test.ts index 29be1238..c36c7586 100644 --- a/packages/extension/tests/components/panel-layering.browser.test.ts +++ b/packages/extension/tests/components/panel-layering.browser.test.ts @@ -21,6 +21,43 @@ afterEach(() => { vi.unstubAllGlobals() }) +it('finishes resizing when the page intercepts pointer release', async () => { + await page.viewport(900, 700) + vi.stubGlobal('computed', computed) + vi.stubGlobal('ref', ref) + vi.stubGlobal('useTemplateRef', useTemplateRef) + options.value.panelPosition = { left: 100, top: 100, width: 300 } + const host = mount( + defineComponent(() => () => h(Panel, { style: { height: '220px' } })), + { tag: 'tempad' } + ) + const panel = host.querySelector('.tp-panel')! + const handle = host.querySelector('.tp-panel-resize-handle-right')! + const rect = handle.getBoundingClientRect() + const target = document.createElement('div') + Object.assign(target.style, { + position: 'fixed', + width: '2px', + height: '2px', + left: `${rect.left + rect.width / 2 + 79}px`, + top: `${rect.top + rect.height / 2 - 1}px` + }) + document.body.append(target) + const released = vi.fn() + handle.addEventListener('lostpointercapture', released) + const intercept = (event: PointerEvent) => event.stopPropagation() + document.addEventListener('pointerup', intercept, true) + try { + await userEvent.dragAndDrop(handle, target) + expect(released).toHaveBeenCalledOnce() + expect(panel.getBoundingClientRect().width).toBe(380) + expect(options.value.panelPosition.width).toBe(380) + } finally { + document.removeEventListener('pointerup', intercept, true) + target.remove() + } +}) + it('layers feedback popovers above the main panel while keeping canvas controls below it and native tooltips above both', async () => { await page.viewport(900, 700) // Supply the same Vue auto-imports as the extension build. diff --git a/packages/extension/tests/components/plugins-section.browser.test.ts b/packages/extension/tests/components/plugins-section.browser.test.ts index 203975c5..2cfd415b 100644 --- a/packages/extension/tests/components/plugins-section.browser.test.ts +++ b/packages/extension/tests/components/plugins-section.browser.test.ts @@ -7,7 +7,7 @@ vi.mock('@/utils/codegen', async (original) => ({ })) import PluginsSection from '@/components/sections/PluginsSection.vue' -import { options, plugins } from '@/ui/state' +import { options, plugins, pluginStorageCorrupted } from '@/ui/state' import { codegen } from '@/utils/codegen' import { mount, unmountAll } from './mount' @@ -17,6 +17,8 @@ const previous = { name: 'Previous', source, code: 'old code' } const notify = vi.fn<(message: string) => { cancel: () => void }>(() => ({ cancel: vi.fn() })) beforeEach(async () => { + await navigator.locks.request('tempad-dev-plugins', () => undefined) + localStorage.removeItem('tempad-dev-plugins') for (const [name, value] of Object.entries({ computed, nextTick, @@ -27,6 +29,7 @@ beforeEach(async () => { vi.stubGlobal(name, value) } plugins.value = {} + pluginStorageCorrupted.value = false options.value.activePluginSource = null await nextTick() notify.mockClear() @@ -45,11 +48,106 @@ afterEach(() => { vi.unstubAllGlobals() }) +it.each(['{broken', '{"x":null}'])( + 'offers explicit corruption recovery for %s, retains failed resets, and permits a fresh install', + async (record) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + localStorage.setItem('tempad-dev-plugins', record) + options.value.activePluginSource = source + window.dispatchEvent( + new StorageEvent('storage', { key: 'tempad-dev-plugins', storageArea: localStorage }) + ) + const host = mount(PluginsSection) + await expect.poll(() => host.textContent).toContain('Stored plugin data is invalid.') + expect(host.textContent).toContain('Resetting removes installed code-output plugins.') + expect(localStorage.getItem('tempad-dev-plugins')).toBe(record) + const nativeSetItem = Storage.prototype.setItem + let rejectWrite = true + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key === 'tempad-dev-plugins' && rejectWrite) + throw new DOMException('Storage quota exceeded', 'QuotaExceededError') + nativeSetItem.call(this, key, value) + }) + const reset = host.querySelector('.tp-plugins-reset')! + reset.click() + await expect + .poll(() => notify.mock.calls.map(([message]) => message)) + .toContain('Could not reset plugins. Browser storage may be full or unavailable.') + expect(localStorage.getItem('tempad-dev-plugins')).toBe(record) + expect(options.value.activePluginSource).toBe(source) + expect(host.contains(reset)).toBe(true) + rejectWrite = false + reset.click() + await expect.poll(() => localStorage.getItem('tempad-dev-plugins')).toBe('{}') + await expect.poll(() => host.querySelector('.tp-plugins-reset')).toBeNull() + expect(options.value.activePluginSource).toBeNull() + + host.querySelector('.tp-plugins-add')!.click() + await nextTick() + const input = host.querySelector('input[type="text"]')! + input.value = source + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + host.querySelector('.tp-action-button')!.click() + await expect.poll(() => plugins.value[source]?.name).toBe('New plugin') + expect(options.value.activePluginSource).toBe(source) + } +) + +it('identifies corruption discovered during installation without replacing the invalid record', async () => { + const host = mount(PluginsSection) + host.querySelector('.tp-plugins-add')!.click() + await nextTick() + const input = host.querySelector('input[type="text"]')! + input.value = source + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + localStorage.setItem('tempad-dev-plugins', '{broken') + host.querySelector('.tp-action-button')!.click() + await expect + .poll(() => notify.mock.calls.map(([message]) => message)) + .toContain('Stored plugin data is invalid. Reset plugins to continue.') + expect(localStorage.getItem('tempad-dev-plugins')).toBe('{broken') + expect(host.querySelector('.tp-plugins-reset')).not.toBeNull() + expect(options.value.activePluginSource).toBeNull() +}) + +it('cancels an update when another tab removes its plugin before the download finishes', async () => { + plugins.value = { [source]: previous } + localStorage.setItem('tempad-dev-plugins', JSON.stringify(plugins.value)) + options.value.activePluginSource = source + const response = Promise.withResolvers() + const fetchPlugin = vi.mocked(fetch).mockReturnValueOnce(response.promise) + vi.mocked(codegen).mockClear() + const host = mount(PluginsSection) + host.querySelector('[aria-label="Update"]')!.click() + await expect.poll(() => fetchPlugin.mock.calls.length).toBe(1) + const signal = fetchPlugin.mock.calls[0]![1]!.signal! + try { + localStorage.setItem('tempad-dev-plugins', '{}') + window.dispatchEvent( + new StorageEvent('storage', { key: 'tempad-dev-plugins', storageArea: localStorage }) + ) + await nextTick() + expect(host.querySelector('[aria-label="Update"]')).toBeNull() + expect(signal.aborted).toBe(true) + const obsolete = new Response('obsolete code') + const cancelBody = vi.spyOn(obsolete.body!, 'cancel') + response.resolve(obsolete) + await expect.poll(() => cancelBody.mock.calls.length).toBe(1) + expect(codegen).not.toHaveBeenCalled() + expect(localStorage.getItem('tempad-dev-plugins')).toBe('{}') + } finally { + response.resolve(new Response(null)) + } +}) + it.each(['install', 'update', 'remove'] as const)( 'preserves plugin and active state when %s cannot be saved, then retries durably', async (action) => { if (action !== 'install') { plugins.value = { [source]: previous } + localStorage.setItem('tempad-dev-plugins', JSON.stringify(plugins.value)) options.value.activePluginSource = source } await nextTick() diff --git a/packages/extension/tests/components/segmented-control.browser.test.ts b/packages/extension/tests/components/segmented-control.browser.test.ts new file mode 100644 index 00000000..8a3704a6 --- /dev/null +++ b/packages/extension/tests/components/segmented-control.browser.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { page, userEvent } from 'vitest/browser' +import { defineComponent, h, ref } from 'vue' + +import Minus from '@/components/icons/Minus.vue' +import Tick from '@/components/icons/Tick.vue' +import SegmentedControl from '@/components/SegmentedControl.vue' + +import { mount, unmountAll } from './mount' + +afterEach(unmountAll) + +it('preserves native mouse and keyboard selection with a visible keyboard focus indicator', async () => { + const value = ref(false) + const update = vi.fn((next: boolean | undefined) => (value.value = next === true)) + const host = mount( + defineComponent( + () => () => + h(SegmentedControl, { + 'aria-label': 'MCP access', + modelValue: value.value, + 'onUpdate:modelValue': update, + options: [ + { label: 'Disabled', value: false, icon: Minus }, + { label: 'Enabled', value: true, icon: Tick } + ] + }) + ), + { tokens: { '--color-border-selected': '#0d99ff' } } + ) + + await page.getByRole('radio', { name: 'Enabled', exact: true }).click() + expect(value.value).toBe(true) + expect(update).toHaveBeenCalledExactlyOnceWith(true) + update.mockClear() + + await userEvent.keyboard('{ArrowLeft}') + expect(value.value).toBe(false) + await expect.element(page.getByRole('radio', { name: 'Disabled', exact: true })).toBeChecked() + expect(update).toHaveBeenCalledExactlyOnceWith(false) + const focused = host.querySelector('input:focus-visible')! + expect(focused).not.toBeNull() + const iconStyle = getComputedStyle(focused.nextElementSibling!) + expect(iconStyle.outlineStyle).not.toBe('none') + expect(parseFloat(iconStyle.outlineWidth)).toBeGreaterThan(0) + expect(iconStyle.outlineColor).toBe('rgb(13, 153, 255)') +}) diff --git a/packages/extension/tests/components/selection-code.browser.test.ts b/packages/extension/tests/components/selection-code.browser.test.ts new file mode 100644 index 00000000..6b782bfb --- /dev/null +++ b/packages/extension/tests/components/selection-code.browser.test.ts @@ -0,0 +1,608 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick } from 'vue' + +vi.mock('@/utils/codegen', async (original) => ({ + ...(await original()), + generateCodeBlocksForNode: vi.fn() +})) + +import CodeSection from '@/components/sections/CodeSection.vue' +import { syncSelection, useSelection } from '@/composables/selection' +import { layoutReady, runtimeMode, selection, selectionRevision } from '@/ui/state' +import { generateCodeBlocksForNode } from '@/utils/codegen' + +import { mount, unmountAll } from './mount' + +function events() { + const callbacks = new Map void>>() + return { + on: vi.fn((type: string, callback: (event?: unknown) => void) => { + if (!callbacks.has(type)) callbacks.set(type, new Set()) + callbacks.get(type)!.add(callback) + }), + off: vi.fn((type: string, callback: (event?: unknown) => void) => + callbacks.get(type)?.delete(callback) + ), + emit: (type: string, event?: unknown) => + callbacks.get(type)?.forEach((callback) => callback(event)), + count: () => [...callbacks.values()].reduce((sum, listeners) => sum + listeners.size, 0) + } +} + +function fixture() { + const node = { id: 'selected', width: 100, characters: 'Before' } + const page = { ...events(), selection: [node] } + const api = { ...events(), fileKey: 'selection-code', currentPage: page } + vi.stubGlobal('figma', api) + const canvasRoot = document.createElement('div') + canvasRoot.id = 'fullscreen-root' + const canvasView = document.createElement('div') + canvasView.className = 'gpu-view-content' + const canvas = document.createElement('canvas') + canvasView.append(canvas) + canvasRoot.append(canvasView) + document.body.append(canvasRoot) + return { node, page, api, canvas } +} + +const View = defineComponent({ + setup() { + useSelection() + return () => h(CodeSection) + } +}) + +beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + layoutReady.value = true + runtimeMode.value = 'standard' + vi.mocked(generateCodeBlocksForNode).mockImplementation(async (node) => ({ + codeBlocks: [ + { + name: 'css', + title: 'CSS', + lang: 'css', + code: `width: ${node.width}px; text: ${(node as TextNode).characters}` + } + ] + })) +}) + +afterEach(() => { + unmountAll() + layoutReady.value = false + selection.value = [] + vi.unstubAllGlobals() + vi.resetAllMocks() + vi.useRealTimers() +}) + +it('reads the initial selection when the Figma layout becomes ready after mounting', async () => { + fixture() + layoutReady.value = false + const host = mount(View) + expect(generateCodeBlocksForNode).not.toHaveBeenCalled() + layoutReady.value = true + await expect.poll(() => host.textContent).toContain('width: 100px; text: Before') + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() +}) + +it('ignores unrelated changes and no-op interactions when native events are available', async () => { + const { page, api, canvas } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + const code = host.querySelector('.tp-code-code') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + for (const origin of ['LOCAL', 'REMOTE']) { + page.emit('nodechange', { + nodeChanges: [ + { + id: 'unrelated', + node: { id: 'unrelated' }, + type: 'PROPERTY_CHANGE', + origin, + properties: ['name'] + } + ] + }) + await vi.advanceTimersByTimeAsync(120) + } + api.emit('stylechange', { styleChanges: [{ id: 'unrelated-style' }] }) + await vi.advanceTimersByTimeAsync(120) + for (let index = 0; index < 3; index++) { + canvas.dispatchEvent(new MouseEvent('click', { bubbles: true })) + canvas.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' })) + api.emit('selectionchange') + await vi.advanceTimersByTimeAsync(120) + } + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() + expect(host.querySelector('.tp-code-code')).toBe(code) +}) + +it('does not treat focus-target shortcuts or inspector navigation as content edits', async () => { + const { node } = fixture() + const children = vi.fn(() => []) + Object.defineProperty(node, 'children', { get: children }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + const traversals = children.mock.calls.length + const canvasInput = document.createElement('textarea') + canvasInput.className = 'focus-target' + const inspectorInput = document.createElement('input') + document.body.append(canvasInput, inspectorInput) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + for (const target of [canvasInput, inspectorInput]) { + for (const keys of [ + { key: 'ArrowLeft' }, + { key: 'v' }, + { key: 'c', metaKey: true }, + { key: 'c', ctrlKey: true } + ]) { + target.dispatchEvent(new KeyboardEvent('keyup', { ...keys, bubbles: true })) + await vi.advanceTimersByTimeAsync(120) + } + } + canvasInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })) + await vi.advanceTimersByTimeAsync(120) + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() + expect(children).toHaveBeenCalledTimes(traversals) + + node.width = 200 + canvasInput.dispatchEvent(new InputEvent('input', { data: 'a', bubbles: true })) + await vi.advanceTimersByTimeAsync(120) + expect(host.textContent).toContain('width: 200px') +}) + +it('refreshes linked style changes and selected instance definition metadata', async () => { + const { node, api, page } = fixture() + const mainComponent = { id: 'definition' } + Object.assign(node, { type: 'INSTANCE', mainComponent, fillStyleId: 'fill-style' }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + node.width = 200 + api.emit('stylechange', { styleChanges: [{ id: 'fill-style' }] }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain('width: 200px') + node.width = 300 + page.emit('nodechange', { + nodeChanges: [ + { + id: mainComponent.id, + node: mainComponent, + type: 'PROPERTY_CHANGE', + origin: 'REMOTE', + properties: ['name'] + } + ] + }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain('width: 300px') +}) + +it.each(['instance', 'instance-child', 'variant-set'] as const)( + 'refreshes %s dependencies on accessible component pages and cleans up their listeners', + async (selected) => { + const { node, api, page } = fixture() + const library = { ...events(), id: 'library-page', type: 'PAGE', parent: null } + const variants = { id: 'variants', type: 'COMPONENT_SET', parent: library } + const definition = { id: 'definition', type: 'COMPONENT', parent: variants } + const text = { id: 'definition-text', type: 'TEXT', parent: definition } + Object.assign(definition, { children: [text] }) + if (selected === 'instance-child') { + Object.assign(node, { + parent: { id: 'instance', type: 'INSTANCE', mainComponent: definition, parent: page } + }) + } else { + Object.assign(node, { type: 'INSTANCE', mainComponent: definition, parent: page }) + } + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + const changed = selected === 'variant-set' ? variants : text + node.width = 200 + library.emit('nodechange', { + nodeChanges: [ + { + id: changed.id, + node: changed, + type: 'PROPERTY_CHANGE', + origin: 'REMOTE', + properties: ['characters'] + } + ] + }) + await expect.poll(() => host.textContent).toContain('width: 200px') + expect(library.count()).toBe(1) + + runtimeMode.value = 'unavailable' + await nextTick() + selectionRevision.value++ + await nextTick() + expect(library.count()).toBe(0) + runtimeMode.value = 'standard' + await expect.poll(() => library.count()).toBe(1) + + page.selection = [{ id: 'other', width: 300, characters: 'Other' }] + api.emit('selectionchange') + await expect.poll(() => host.textContent).toContain('width: 300px') + expect(library.count()).toBe(0) + unmountAll() + expect(page.count()).toBe(0) + } +) + +it('refreshes new and removed descendants of main components without retaining obsolete dependencies', async () => { + const { node, page } = fixture() + const definition = { id: 'definition', type: 'COMPONENT', children: [] as object[] } + Object.assign(node, { type: 'INSTANCE', mainComponent: definition }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + const child = { id: 'new-definition-child', parent: definition } + definition.children = [child] + node.width = 200 + page.emit('nodechange', { + nodeChanges: [{ id: child.id, node: child, type: 'CREATE', origin: 'REMOTE' }] + }) + await expect.poll(() => host.textContent).toContain('width: 200px') + definition.children = [] + node.width = 300 + page.emit('nodechange', { + nodeChanges: [ + { id: child.id, node: { id: child.id, removed: true }, type: 'DELETE', origin: 'REMOTE' } + ] + }) + await expect.poll(() => host.textContent).toContain('width: 300px') +}) + +it.each(['page-local', 'callback-global'] as const)( + 'keeps the current page subscribed after removing a definition page with %s cleanup', + async (cleanup) => { + const { node, api, page } = fixture() + const library = { ...events(), id: 'library-page', type: 'PAGE', parent: null } + const definition = { id: 'definition', type: 'COMPONENT', parent: library } + Object.assign(node, { type: 'INSTANCE', mainComponent: definition, parent: page }) + if (cleanup === 'callback-global') { + // Exercise the stronger removal contract without assuming native Figma uses it. + const removers = [page.off.getMockImplementation()!, library.off.getMockImplementation()!] + for (const source of [page, library]) { + source.off.mockImplementation((type, callback) => { + for (const remove of removers) remove(type, callback) + return true + }) + } + } + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + syncSelection() + await nextTick() + expect(page.on).toHaveBeenCalledOnce() + expect(library.on).toHaveBeenCalledOnce() + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const other = { id: 'other', width: 300, characters: 'Other' } + page.selection = [other] + api.emit('selectionchange') + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain('width: 300px') + expect(library.count()).toBe(0) + expect(page.count()).toBe(1) + + const generated = vi.mocked(generateCodeBlocksForNode).mock.calls.length + other.width = 400 + const change = { + nodeChanges: [{ id: other.id, node: other, type: 'PROPERTY_CHANGE', properties: ['width'] }] + } + page.emit('nodechange', change) + page.emit('nodechange', change) + await vi.advanceTimersByTimeAsync(99) + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(generated) + await vi.advanceTimersByTimeAsync(1) + expect(host.textContent).toContain('width: 400px') + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(generated + 1) + + page.selection = [node] + api.emit('selectionchange') + await vi.advanceTimersByTimeAsync(100) + expect(page.on).toHaveBeenCalledOnce() + expect(library.on).toHaveBeenCalledTimes(2) + expect(library.count()).toBe(1) + unmountAll() + expect(api.count()).toBe(0) + expect(page.count()).toBe(0) + expect(library.count()).toBe(0) + } +) + +it('refreshes actual editor input without native node events while ignoring TemPad inputs', async () => { + const { node } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + const editor = document.createElement('input') + document.body.append(editor) + for (const event of ['input', 'change', 'keyup']) { + node.width += 100 + editor.dispatchEvent( + event === 'keyup' + ? new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }) + : new Event(event, { bubbles: true }) + ) + await expect.poll(() => host.textContent).toContain(`width: ${node.width}px`) + } + const panel = document.createElement('tempad') + panel.append(editor) + document.body.append(panel) + node.width = 900 + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + editor.dispatchEvent(new Event('input', { bubbles: true })) + await vi.advanceTimersByTimeAsync(150) + expect(host.textContent).toContain('width: 400px') +}) + +it('does not traverse hidden code dependencies for a multiple selection', async () => { + const { node, page, api } = fixture() + const children = vi.fn(() => []) + Object.defineProperty(node, 'children', { get: children }) + page.selection = [node, { id: 'second', width: 200, characters: 'Other' }] + const host = mount(View) + await nextTick() + expect(children).not.toHaveBeenCalled() + expect(generateCodeBlocksForNode).not.toHaveBeenCalled() + syncSelection() + await nextTick() + expect(children).not.toHaveBeenCalled() + page.selection = [node] + api.emit('selectionchange') + await expect.poll(() => host.textContent).toContain('width: 100px') + expect(children).toHaveBeenCalled() +}) + +it.each(['textStyleId', 'fillStyleId'] as const)( + 'refreshes styles referenced by mixed text ranges through %s', + async (field) => { + const { node, api } = fixture() + Object.assign(node, { + type: 'TEXT', + [field]: Symbol('mixed'), + getStyledTextSegments: () => [{ [field]: 'range-style' }] + }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + node.width = 200 + api.emit('stylechange', { styleChanges: [{ id: 'other-range-style' }] }) + await vi.advanceTimersByTimeAsync(100) + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() + api.emit('stylechange', { styleChanges: [{ id: 'range-style' }] }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain('width: 200px') + } +) + +it('drops a pending content invalidation once focus recovery has refreshed it', async () => { + const { node, page, api } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + node.width = 200 + page.emit('nodechange') + window.dispatchEvent(new FocusEvent('blur')) + await nextTick() + await vi.advanceTimersByTimeAsync(200) + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() + window.dispatchEvent(new FocusEvent('focus')) + await nextTick() + await nextTick() + expect(host.textContent).toContain('width: 200px') + api.emit('selectionchange') + await vi.advanceTimersByTimeAsync(100) + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(2) +}) + +it('refreshes relevant remote ancestors and descendants, including removed and newly added children', async () => { + const { node, page } = fixture() + const parent = { id: 'parent', parent: null } + const child = { id: 'child', parent: node } + Object.assign(node, { parent, children: [child] }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + for (const changed of [node, parent, child]) { + node.width += 10 + page.emit('nodechange', { + nodeChanges: [ + { + id: changed.id, + node: changed, + type: 'PROPERTY_CHANGE', + origin: 'REMOTE', + properties: ['width'] + } + ] + }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain(`width: ${node.width}px`) + } + Object.assign(node, { children: [] }) + node.width += 10 + page.emit('nodechange', { + nodeChanges: [ + { id: child.id, node: { id: child.id, removed: true }, type: 'DELETE', origin: 'REMOTE' } + ] + }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain(`width: ${node.width}px`) + const added = { id: 'added', parent: node } + Object.assign(node, { children: [added] }) + node.width += 10 + page.emit('nodechange', { + nodeChanges: [{ id: added.id, node: added, type: 'CREATE', origin: 'REMOTE' }] + }) + await vi.advanceTimersByTimeAsync(100) + expect(host.textContent).toContain(`width: ${node.width}px`) + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(6) +}) + +it('regenerates the same selected node after explicit sync and current-page edits', async () => { + const { node, page } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px; text: Before') + const selected = selection.value + node.width = 200 + syncSelection() + await expect.poll(() => host.textContent).toContain('width: 200px; text: Before') + expect(selection.value).toBe(selected) + + node.characters = 'After' + page.emit('nodechange') + await expect.poll(() => host.textContent).toContain('width: 200px; text: After') + expect(selection.value).toBe(selected) +}) + +it('refreshes after inspector edits and window focus without reacting to TemPad inputs', async () => { + const { node, page } = fixture() + page.on.mockImplementation(() => { + throw new Error('Native node events unavailable') + }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + const editor = document.createElement('input') + document.body.append(editor) + editor.dispatchEvent(new Event('input', { bubbles: true })) + node.width = 200 + await expect.poll(() => host.textContent).toContain('width: 200px') + + const count = vi.mocked(generateCodeBlocksForNode).mock.calls.length + const panel = document.createElement('tempad') + const pluginInput = document.createElement('input') + panel.append(pluginInput) + document.body.append(panel) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + pluginInput.dispatchEvent(new Event('input', { bubbles: true })) + await vi.advanceTimersByTimeAsync(150) + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(count) + vi.useRealTimers() + + window.dispatchEvent(new FocusEvent('blur')) + await nextTick() + node.width = 300 + window.dispatchEvent(new FocusEvent('focus')) + await expect.poll(() => host.textContent).toContain('width: 300px') +}) + +it('rebinds native listeners on page changes and removes them when inactive or unmounted', async () => { + const { api, page, node } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + expect(page.count()).toBe(1) + const nextPage = { ...events(), selection: [node] } + api.currentPage = nextPage + api.emit('currentpagechange') + await expect.poll(() => nextPage.count()).toBe(1) + expect(page.count()).toBe(0) + + runtimeMode.value = 'unavailable' + await nextTick() + expect(api.count()).toBe(0) + expect(nextPage.count()).toBe(0) + runtimeMode.value = 'standard' + await expect.poll(() => nextPage.count()).toBe(1) + unmountAll() + expect(api.count()).toBe(0) + expect(nextPage.count()).toBe(0) +}) + +it('falls back to post-event refresh when native subscriptions are unavailable', async () => { + const { api, node, canvas } = fixture() + api.on.mockImplementation(() => { + throw new Error('Unsupported event API') + }) + api.currentPage.on.mockImplementation(() => { + throw new Error('Unsupported page events') + }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + canvas.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + node.width = 400 + await expect.poll(() => host.textContent).toContain('width: 400px') +}) + +it('cleans up old page subscriptions when the native page becomes unavailable', async () => { + const { api, page, canvas } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + Object.defineProperty(api, 'currentPage', { + configurable: true, + get: () => { + throw new Error('Native page is unavailable') + } + }) + canvas.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + await expect.poll(() => selection.value.length).toBe(0) + expect(page.count()).toBe(0) + Object.defineProperty(api, 'currentPage', { configurable: true, value: page }) + api.emit('currentpagechange') + await expect.poll(() => host.textContent).toContain('width: 100px') +}) + +it('refreshes cached TemPad component metadata without changing the selection', async () => { + const { node, page } = fixture() + let component = 'OriginalButton' + Object.assign(node, { + type: 'FRAME', + name: '🧩 Button', + getSharedPluginData: (_namespace: string, key: string) => { + if (key === 'code') return `<>\n\n <${component} />\n\n` + if (key === 'link') return 'https://example.com/playground' + return '' + } + }) + const host = mount(View) + await expect.poll(() => host.textContent).toContain('') + component = 'UpdatedButton' + page.emit('nodechange') + await expect.poll(() => host.textContent).toContain('') +}) + +it('coalesces native changes and defers background updates until focus returns', async () => { + const { node, page, api } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + node.width = 200 + page.emit('nodechange') + api.emit('stylechange') + api.emit('selectionchange') + await vi.advanceTimersByTimeAsync(99) + expect(generateCodeBlocksForNode).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(host.textContent).toContain('width: 200px') + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(2) + + window.dispatchEvent(new FocusEvent('blur')) + await nextTick() + node.width = 300 + page.emit('nodechange') + await vi.advanceTimersByTimeAsync(200) + expect(generateCodeBlocksForNode).toHaveBeenCalledTimes(2) + window.dispatchEvent(new FocusEvent('focus')) + await nextTick() + await nextTick() + expect(host.textContent).toContain('width: 300px') +}) + +it('fences surviving native callbacks when listener cleanup fails on unmount', async () => { + const { page } = fixture() + const host = mount(View) + await expect.poll(() => host.textContent).toContain('width: 100px') + page.off.mockImplementation(() => { + throw new Error('Native listener removal failed') + }) + unmountAll() + const revision = selectionRevision.value + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + page.emit('nodechange') + await vi.advanceTimersByTimeAsync(200) + expect(selectionRevision.value).toBe(revision) +}) diff --git a/packages/extension/tests/composables/dev-resources.test.ts b/packages/extension/tests/composables/dev-resources.test.ts index e1b75468..710b78de 100644 --- a/packages/extension/tests/composables/dev-resources.test.ts +++ b/packages/extension/tests/composables/dev-resources.test.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computed, effectScope, nextTick, ref, shallowRef } from 'vue' vi.mock('@/ui/state', async () => { const { shallowRef } = await import('vue') return { selection: shallowRef([]), + selectionRevision: shallowRef(0), layoutReady: shallowRef(false), runtimeMode: shallowRef('standard') } @@ -47,10 +48,12 @@ function createNode(overrides: Record = {}): SceneNode { } as unknown as SceneNode } -async function importComposable() { +let devResources: typeof import('@/composables/dev-resources') + +beforeEach(async () => { vi.resetModules() - return import('@/composables/dev-resources') -} + devResources = await import('@/composables/dev-resources') +}) afterEach(() => { vi.unstubAllGlobals() @@ -59,7 +62,7 @@ afterEach(() => { describe('composables/dev-resources', () => { it('retains cached links when a reselect and explicit refresh share a failed read', async () => { - const api = await importComposable() + const api = devResources let reject!: (error: unknown) => void const pending = new Promise((_, fail) => (reject = fail)) const read = vi @@ -90,7 +93,7 @@ describe('composables/dev-resources', () => { }) it('explicitly refreshes a selected node and shares the current request', async () => { - const api = await importComposable() + const api = devResources let resolve!: (resources: DevResource[]) => void const pending = new Promise((done) => (resolve = done)) const read = vi.fn(() => pending) @@ -122,7 +125,7 @@ describe('composables/dev-resources', () => { .mockResolvedValue([{ name: 'Recovered', url: '' }]) const node = createNode({ getDevResourcesAsync }) const selected = shallowRef(node) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const scope = effectScope() try { const links = scope.run(() => useDevResourceLinks(selected))! @@ -150,7 +153,7 @@ describe('composables/dev-resources', () => { .mockResolvedValue([{ name: 'Updated', url: '' }]) const node = createNode({ getDevResourcesAsync }) const selected = shallowRef(node) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const scope = effectScope() try { const first = scope.run(() => useDevResourceLinks(selected))! @@ -179,7 +182,7 @@ describe('composables/dev-resources', () => { it.each(['api', 'url'] as const)( 'refreshes resources for identical node IDs after a document switch using %s identity', async (identity) => { - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const { syncSelection } = await import('@/composables/selection') const { selection } = await import('@/ui/state') vi.stubGlobal( @@ -258,7 +261,7 @@ describe('composables/dev-resources', () => { ) }) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const links = useDevResourceLinks(ref(node)) await flushEffects() @@ -298,7 +301,7 @@ describe('composables/dev-resources', () => { getDevResourcesAsync }) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const links = useDevResourceLinks(ref(node)) await flushEffects() @@ -325,7 +328,7 @@ describe('composables/dev-resources', () => { getDevResourcesAsync: vi.fn(async () => [] as DevResource[]) }) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const links = useDevResourceLinks(ref(node)) await flushEffects() @@ -368,7 +371,7 @@ describe('composables/dev-resources', () => { } : null }) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const scope = effectScope() try { const links = scope.run(() => useDevResourceLinks(ref(node)))! @@ -401,7 +404,7 @@ describe('composables/dev-resources', () => { ) }) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const links = useDevResourceLinks(ref(node)) await flushEffects() @@ -429,7 +432,7 @@ describe('composables/dev-resources', () => { const node = createNode({ getDevResourcesAsync: vi.fn(async () => [{ name: 'Docs', url: 'https://docs.example.com' }]) }) - const { useDevResourceLinks } = await importComposable() + const { useDevResourceLinks } = devResources const scope = effectScope() try { const links = scope.run(() => useDevResourceLinks(ref(node)))! diff --git a/packages/extension/tests/composables/key-lock.browser.test.ts b/packages/extension/tests/composables/key-lock.browser.test.ts new file mode 100644 index 00000000..ae50d391 --- /dev/null +++ b/packages/extension/tests/composables/key-lock.browser.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { defineComponent, nextTick } from 'vue' + +vi.mock('@/ui/state', async () => { + const { shallowRef } = await import('vue') + return { + layoutReady: shallowRef(true), + options: shallowRef({ deepSelectOn: true, measureOn: true }) + } +}) +vi.mock('@/utils', () => ({ + getCanvas: vi.fn(), + setLockAltKey: vi.fn(), + setLockMetaKey: vi.fn() +})) + +import { useKeyLock } from '@/composables/key-lock' +import { layoutReady, options } from '@/ui/state' +import { getCanvas, setLockAltKey, setLockMetaKey } from '@/utils' + +import { mount, unmountAll } from '../components/mount' + +let canvas: HTMLElement +beforeEach(() => { + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') + const host = document.createElement('div') + const parent = host.appendChild(document.createElement('div')) + canvas = parent.appendChild(document.createElement('div')) + document.body.append(host) + vi.spyOn(canvas, 'matches').mockReturnValue(true) + vi.mocked(getCanvas).mockReturnValue(canvas) + layoutReady.value = true + options.value = { deepSelectOn: true, measureOn: true } as typeof options.value + mount( + defineComponent({ + setup() { + useKeyLock() + return () => null + } + }) + ) +}) +afterEach(() => { + unmountAll() + vi.useRealTimers() + vi.clearAllMocks() +}) + +it('clears a lost Space keyup on blur and restores both locks on focus', async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + expect(setLockMetaKey).toHaveBeenLastCalledWith(false) + const cursorHost = canvas.parentElement!.parentElement! + cursorHost.dataset.tpCursorOverride = '' + window.dispatchEvent(new Event('blur')) + expect(cursorHost.dataset.tpCursorOverride).toBeUndefined() + window.dispatchEvent(new Event('focus')) + canvas.dispatchEvent(new MouseEvent('mouseenter')) + await nextTick() + expect(setLockMetaKey).toHaveBeenLastCalledWith(true) + expect(setLockAltKey).toHaveBeenLastCalledWith(true) +}) + +it('clears a lost Alt keyup when hidden and waits for focus before restoring measurement', async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })) + expect(setLockAltKey).toHaveBeenLastCalledWith(false) + vi.mocked(document.hasFocus).mockReturnValue(false) + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden') + document.dispatchEvent(new Event('visibilitychange')) + vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible') + document.dispatchEvent(new Event('visibilitychange')) + expect(setLockAltKey).toHaveBeenLastCalledWith(false) + vi.mocked(document.hasFocus).mockReturnValue(true) + window.dispatchEvent(new Event('focus')) + await nextTick() + expect(setLockAltKey).toHaveBeenLastCalledWith(true) +}) + +it('resets pressed keys when the layout or measurement mode is disabled', async () => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })) + options.value = { ...options.value, measureOn: false } + await nextTick() + options.value = { ...options.value, measureOn: true } + await nextTick() + expect(setLockAltKey).toHaveBeenLastCalledWith(true) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + layoutReady.value = false + await nextTick() + layoutReady.value = true + await nextTick() + canvas.dispatchEvent(new MouseEvent('mouseleave')) + canvas.dispatchEvent(new MouseEvent('mouseenter')) + expect(setLockAltKey).toHaveBeenLastCalledWith(true) + expect(setLockMetaKey).toHaveBeenLastCalledWith(true) +}) + +it('does not restore locks from pending timers or preference changes while inactive', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + canvas.dispatchEvent(new WheelEvent('wheel')) + canvas.dispatchEvent(new PointerEvent('pointerdown')) + window.dispatchEvent(new Event('blur')) + options.value = { ...options.value, deepSelectOn: false, measureOn: false } + await nextTick() + options.value = { ...options.value, deepSelectOn: true, measureOn: true } + await nextTick() + await vi.advanceTimersByTimeAsync(200) + expect(setLockAltKey).toHaveBeenLastCalledWith(false) + expect(setLockMetaKey).toHaveBeenLastCalledWith(false) +}) + +it('cancels pending pointer and wheel callbacks when disposed', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + canvas.dispatchEvent(new PointerEvent('pointerdown')) + canvas.dispatchEvent(new WheelEvent('wheel')) + unmountAll() + expect(setLockAltKey).toHaveBeenLastCalledWith(false) + expect(setLockMetaKey).toHaveBeenLastCalledWith(false) + vi.mocked(setLockAltKey).mockClear() + vi.mocked(setLockMetaKey).mockClear() + await vi.advanceTimersByTimeAsync(200) + expect(setLockAltKey).not.toHaveBeenCalled() + expect(setLockMetaKey).not.toHaveBeenCalled() +}) diff --git a/packages/extension/tests/composables/key-lock.test.ts b/packages/extension/tests/composables/key-lock.test.ts index 8a3220e0..1bd541c2 100644 --- a/packages/extension/tests/composables/key-lock.test.ts +++ b/packages/extension/tests/composables/key-lock.test.ts @@ -36,6 +36,7 @@ function resolveWatchSource(source: unknown) { } vi.mock('vue', () => ({ + onScopeDispose: vi.fn(), shallowRef: (value: T) => ({ value }), watch: ( source: unknown, @@ -133,6 +134,7 @@ async function mountComposable() { } beforeEach(() => { + vi.stubGlobal('document', { visibilityState: 'visible', hasFocus: () => true }) mocks.layoutReady.value = false mocks.options.value = { deepSelectOn: true, @@ -165,11 +167,13 @@ describe('composables/key-lock', () => { const layoutWatcher = mocks.watchers[0] if (!layoutWatcher) throw new Error('Expected layout watcher') + mocks.layoutReady.value = true layoutWatcher.callback(true) expect(mocks.getCanvas).toHaveBeenCalledTimes(1) expect(mocks.setLockMetaKey).toHaveBeenLastCalledWith(true) expect(mocks.setLockAltKey).toHaveBeenLastCalledWith(true) + mocks.layoutReady.value = false layoutWatcher.callback(false) expect(mocks.setLockMetaKey).toHaveBeenLastCalledWith(false) expect(mocks.setLockAltKey).toHaveBeenLastCalledWith(false) diff --git a/packages/extension/tests/composables/mcp.test.ts b/packages/extension/tests/composables/mcp.test.ts index b7726d3a..1d39da0f 100644 --- a/packages/extension/tests/composables/mcp.test.ts +++ b/packages/extension/tests/composables/mcp.test.ts @@ -1,4 +1,9 @@ -import type { BridgeToPageMessage, DesignTask, PageToBridgeMessage } from '@tempad-dev/shared' +import type { + BridgeToPageMessage, + DesignTask, + FeedbackDraftRequest, + PageToBridgeMessage +} from '@tempad-dev/shared' import { TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, @@ -80,14 +85,6 @@ vi.mock('@/mcp/assets', () => ({ setAssetUploader: mocks.setAssetUploader })) -vi.mock('@/mcp/errors', () => ({ - createCodedError: (code: string, message: string) => Object.assign(new Error(message), { code }), - coerceToolErrorPayload: (error: Error & { code?: string }) => ({ - message: error.message, - code: error.code - }) -})) - vi.mock('@/mcp/runtime', () => ({ runMcpTool: mocks.runMcpTool })) @@ -165,6 +162,40 @@ describe('composables/mcp', () => { vi.stubGlobal('location', { origin: ORIGIN }) }) + it('reports a lost result when a completed write cannot be transferred', async () => { + Object.assign(mocks.window, { + figma: { fileKey: 'file-a', root: { name: 'Design' }, currentPage: { id: 'page-a' } }, + INITIAL_OPTIONS: { editor_type: 'design' } + }) + const mcp = useMcp() + const state = bridgeState(mcp.sessionId, 'connected', null) + if (state.type === 'mcp.state') state.payload.gatewayId = 'gateway-a' + receive(state) + mocks.runMcpTool.mockResolvedValueOnce({ rootNodeId: 'written', mutationCount: 1 }) + mocks.window.postMessage.mockImplementation((message: PageToBridgeMessage) => { + if (message.type === 'mcp.toolResult' && 'payload' in message) + throw new DOMException('Cannot clone result', 'DataCloneError') + }) + receive({ + type: 'mcp.toolCall', + source: TEMPAD_MCP_BROWSER_SOURCE, + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, + callId: 'write', + route: { gatewayId: 'gateway-a', sessionId: mcp.sessionId, fileKey: 'file-a' }, + payload: { name: 'apply_canvas', args: { mode: 'create' } } + }) + await vi.waitFor(() => + expect(mocks.window.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + callId: 'write', + error: expect.objectContaining({ code: TEMPAD_MCP_ERROR_CODES.EXTENSION_RESULT_LOST }) + }), + ORIGIN + ) + ) + expect(mocks.runMcpTool).toHaveBeenCalledOnce() + }) + it.each([false, true])( 'synchronizes a dismissed offline Stop after re-enable (storage unavailable=%s)', async (unavailable) => { @@ -226,10 +257,10 @@ describe('composables/mcp', () => { ) it.each([ - ['stop', 'Stop confirmation timed out.'], + ['stop', 'Stop acknowledgement timed out.'], ['done', 'Finishing the review timed out.'], ['feedback', 'Sending comments timed out.'] - ] as const)('identifies the %s action when confirmation times out', async (action, message) => { + ] as const)('identifies the %s action when its reply times out', async (action, message) => { vi.useFakeTimers() try { Object.assign(mocks.window, { @@ -286,6 +317,58 @@ describe('composables/mcp', () => { } }) + it.each([ + ['load', 'Loading comments', {}], + ['clear', 'Clearing comments', {}], + ['comment', 'Saving comments', { comment: 'Keep this' }], + [ + 'save', + 'Saving comments', + { + item: { + nodeId: 'node-a', + nodeName: 'Node', + pageId: 'page-a', + text: 'Keep this', + createdAt: 1 + } + } + ], + ['remove', 'Removing comments', { nodeId: 'node-a' }] + ] as const)( + 'identifies draft operation %s when its request times out or disconnects', + async (operation, action, payload) => { + vi.useFakeTimers() + try { + Object.assign(mocks.window, { + figma: { fileKey: 'file-a', root: { name: 'Design' }, currentPage: { id: 'page-a' } }, + INITIAL_OPTIONS: { editor_type: 'design' } + }) + const mcp = useMcp() + const request = { + operation, + scope: { + taskId: 'task-a', + fileKey: 'file-a', + clientKind: 'codex-app', + conversationId: 'thread-a' + }, + ...payload + } as FeedbackDraftRequest + const timeout = mcp.requestFeedbackDrafts(request).catch((error: unknown) => error) + await vi.advanceTimersByTimeAsync(8000) + expect(await timeout).toEqual(new Error(`${action} timed out.`)) + const disconnected = mcp.requestFeedbackDrafts(request).catch((error: unknown) => error) + mocks.options.value.mcpOn = false + mocks.watchCallbacks.forEach((callback) => callback()) + expect(await disconnected).toEqual(new Error(`Disconnected while ${action.toLowerCase()}.`)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + } + ) + it.each([ { kind: 'feedback', failure: 'post' }, { kind: 'drafts', failure: 'post' }, @@ -472,7 +555,10 @@ describe('composables/mcp', () => { expect(mocks.runMcpTool).not.toHaveBeenCalled() expect(getPostedMessage('mcp.toolResult')).toMatchObject({ callId: 'stale-write', - error: { code: TEMPAD_MCP_ERROR_CODES.DESIGN_TASK_INACTIVE } + error: { + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + message: expect.stringContaining('DESIGN_TASK_INACTIVE') + } }) update({ ...task, taskId: 'task-b', revision: 4 }) mcp.dismissDesignTask('task-a') @@ -612,7 +698,10 @@ describe('composables/mcp', () => { }) expect(mocks.runMcpTool).not.toHaveBeenCalled() expect(getPostedMessage('mcp.toolResult')).toMatchObject({ - error: { code: TEMPAD_MCP_ERROR_CODES.DESIGN_TASK_INACTIVE } + error: { + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + message: expect.stringContaining('DESIGN_TASK_INACTIVE') + } }) } ) diff --git a/packages/extension/tests/composables/selection.test.ts b/packages/extension/tests/composables/selection.test.ts index ea2b97bc..f1517220 100644 --- a/packages/extension/tests/composables/selection.test.ts +++ b/packages/extension/tests/composables/selection.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ layoutReady: { value: false } as RefLike, runtimeMode: { value: 'standard' } as RefLike<'standard' | 'unavailable'>, selection: { value: [] as SceneNode[] } as RefLike, + selectionRevision: { value: 0 }, visibility: { value: 'visible' } as RefLike<'visible' | 'hidden'>, focused: { value: true } as RefLike, getCanvas: vi.fn(), @@ -53,7 +54,8 @@ vi.mock('vue', () => ({ } return vi.fn() }, - onMounted: (callback: () => void) => callback() + onMounted: (callback: () => void) => callback(), + onBeforeUnmount: vi.fn() })) vi.mock('@vueuse/core', () => ({ @@ -68,7 +70,8 @@ vi.mock('@vueuse/core', () => ({ vi.mock('@/ui/state', () => ({ layoutReady: mocks.layoutReady, runtimeMode: mocks.runtimeMode, - selection: mocks.selection + selection: mocks.selection, + selectionRevision: mocks.selectionRevision })) vi.mock('@/utils', () => ({ @@ -110,9 +113,11 @@ function findListener(event: string, index = 0) { describe('composables/selection', () => { beforeEach(() => { + vi.useFakeTimers() mocks.layoutReady.value = false mocks.runtimeMode.value = 'standard' mocks.selection.value = [] + mocks.selectionRevision.value = 0 mocks.visibility.value = 'visible' mocks.focused.value = true mocks.getCanvas.mockReset() @@ -122,6 +127,8 @@ describe('composables/selection', () => { }) afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() vi.unstubAllGlobals() vi.clearAllMocks() }) @@ -139,7 +146,7 @@ describe('composables/selection', () => { expect(selection.value).toEqual([]) }) - it('syncSelection updates only when selected node ids change', () => { + it('syncSelection keeps selection identity stable while invalidating mutable node content', () => { const sameRef = [createSceneNode('same-ref')] setFigmaSelection(sameRef) selection.value = sameRef @@ -152,6 +159,7 @@ describe('composables/selection', () => { syncSelection() expect(selection.value).toBe(current) + expect(mocks.selectionRevision.value).toBe(2) const next = [createSceneNode('next')] setFigmaSelection(next) @@ -172,22 +180,25 @@ describe('composables/selection', () => { expect(selection.value[0]?.id).toBe('initial') expect(mocks.listeners.filter((entry) => entry.event === 'click')).toHaveLength(2) - expect(mocks.listeners.filter((entry) => entry.event === 'keydown')).toHaveLength(1) + expect(mocks.listeners.filter((entry) => entry.event === 'keyup')).toHaveLength(1) setFigmaSelection([createSceneNode('click-sync')]) findListener('click', 0)?.handler({}) + vi.advanceTimersByTime(100) expect(selection.value[0]?.id).toBe('click-sync') setFigmaSelection([createSceneNode('focus-sync')]) - findListener('keydown', 0)?.handler({ + findListener('keyup', 0)?.handler({ target: { classList: { contains: (name: string) => name === 'focus-target' } } }) + vi.advanceTimersByTime(100) expect(selection.value[0]?.id).toBe('focus-sync') - findListener('keydown', 0)?.handler({ + findListener('keyup', 0)?.handler({ target: { classList: { contains: () => false } } }) - findListener('keydown', 0)?.handler({ target: null }) + findListener('keyup', 0)?.handler({ target: null }) + vi.advanceTimersByTime(100) expect(selection.value[0]?.id).toBe('focus-sync') const layoutWatcher = mocks.watchers[0] @@ -202,21 +213,22 @@ describe('composables/selection', () => { expect(selection.value).toEqual([]) layoutWatcher.callback(true) - expect(mocks.getCanvas).toHaveBeenCalledTimes(3) - expect(mocks.getLeftPanel).toHaveBeenCalledTimes(3) + expect(mocks.getCanvas).toHaveBeenCalledTimes(2) + expect(mocks.getLeftPanel).toHaveBeenCalledTimes(2) + const readySelection = selection.value setFigmaSelection([createSceneNode('mode-guard')]) mocks.layoutReady.value = true modeWatcher.callback(['unavailable', true]) - expect(selection.value).toEqual([]) + expect(selection.value).toBe(readySelection) mocks.layoutReady.value = false modeWatcher.callback(['standard', true]) - expect(selection.value).toEqual([]) + expect(selection.value).toBe(readySelection) mocks.layoutReady.value = true modeWatcher.callback(['standard', false]) - expect(selection.value).toEqual([]) + expect(selection.value).toBe(readySelection) modeWatcher.callback(['standard', true]) expect(selection.value[0]?.id).toBe('mode-guard') @@ -234,8 +246,8 @@ describe('composables/selection', () => { useSelection() - expect(mocks.watchers).toHaveLength(2) + expect(mocks.watchers).toHaveLength(3) expect(mocks.listeners.filter((entry) => entry.event === 'click')).toHaveLength(2) - expect(mocks.listeners.filter((entry) => entry.event === 'keydown')).toHaveLength(1) + expect(mocks.listeners.filter((entry) => entry.event === 'keyup')).toHaveLength(1) }) }) diff --git a/packages/extension/tests/config/workspace-source.browser.test.ts b/packages/extension/tests/config/workspace-source.browser.test.ts new file mode 100644 index 00000000..33d78525 --- /dev/null +++ b/packages/extension/tests/config/workspace-source.browser.test.ts @@ -0,0 +1,8 @@ +import { raw } from '@tempad-dev/plugins' +import { expect, it } from 'vitest' + +import { raw as sourceRaw } from '../../../plugins/src/index' + +it('runs browser tests against the authored SDK', () => { + expect(raw).toBe(sourceRaw) +}) diff --git a/packages/extension/tests/config/workspace-source.test.ts b/packages/extension/tests/config/workspace-source.test.ts new file mode 100644 index 00000000..90cd0b33 --- /dev/null +++ b/packages/extension/tests/config/workspace-source.test.ts @@ -0,0 +1,66 @@ +import { raw } from '@tempad-dev/plugins' +import { fileURLToPath } from 'node:url' +import { build } from 'vite' +import { describe, expect, it } from 'vitest' + +import { raw as sourceRaw } from '../../../plugins/src/index' +import { bundleSandboxWorker, pluginSandboxWorkers } from '../../build/plugin-sandbox-workers' +import config from '../../wxt.config' + +const extensionRoot = fileURLToPath(new URL('../../', import.meta.url)) +const sdkSource = fileURLToPath(new URL('../../../plugins/src/index.ts', import.meta.url)) + +describe('workspace SDK source resolution', () => { + it('runs Node tests against the authored SDK', () => { + expect(raw).toBe(sourceRaw) + }) + + it('bundles authored SDK code into the extension and its production Workers', async () => { + const modules: string[] = [] + const workerModules: string[] = [] + const entry = 'virtual:sdk-source-probe' + const workerEntry = fileURLToPath(new URL('../../codegen/worker.ts', import.meta.url)) + await build({ + configFile: false, + root: extensionRoot, + logLevel: 'silent', + resolve: { alias: config.alias }, + plugins: [ + pluginSandboxWorkers(false), + { + name: 'sdk-source-probe', + resolveId: (id) => (id === entry ? `\0${entry}` : null), + load: (id) => + id === `\0${entry}` + ? `export { raw } from '@tempad-dev/plugins'; export { default as Worker } from ${JSON.stringify(`${workerEntry}?sandbox-worker`)}` + : null, + generateBundle() { + modules.push(...this.getModuleIds()) + } + } + ], + worker: { + plugins: () => [ + { + name: 'sdk-worker-source-probe', + generateBundle() { + workerModules.push(...this.getModuleIds()) + } + } + ] + }, + build: { write: false, rolldownOptions: { input: entry } } + }) + + for (const bundledModules of [modules, workerModules]) { + expect(bundledModules).toContain(sdkSource) + expect(bundledModules.some((id) => id.includes('/plugins/dist/'))).toBe(false) + } + }) + + it('bundles authored SDK code into development sandbox Workers', async () => { + const { inputs } = await bundleSandboxWorker('codegen/worker.ts') + expect(inputs).toContain('../plugins/src/index.ts') + expect(inputs.some((id) => id.includes('/plugins/dist/'))).toBe(false) + }) +}) diff --git a/packages/extension/tests/config/wxt.test.ts b/packages/extension/tests/config/wxt.test.ts index bc7088cd..de20480c 100644 --- a/packages/extension/tests/config/wxt.test.ts +++ b/packages/extension/tests/config/wxt.test.ts @@ -1,8 +1,8 @@ -import type { ConfigEnv } from 'wxt' +import type { ConfigEnv, Wxt, WxtHooks } from 'wxt' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' -import config, { getPluginSandboxCsp } from '../../wxt.config' +import config, { extensionRuntimeProfile, getPluginSandboxCsp } from '../../wxt.config' const env = (command: ConfigEnv['command']): ConfigEnv => ({ browser: 'chrome', @@ -11,7 +11,37 @@ const env = (command: ConfigEnv['command']): ConfigEnv => ({ mode: command === 'serve' ? 'development' : 'production' }) +afterEach(() => vi.unstubAllEnvs()) + describe('WXT plugin sandbox configuration', () => { + it.each([false, true])('only packages release profiles (checkout: %s)', async (checkout) => { + vi.stubEnv( + 'TEMPAD_MCP_DEV_CHECKOUT', + checkout ? new URL('../../../../', import.meta.url).pathname : undefined + ) + // WXT runs zip with command "build", including programmatic zip() calls. + const wxt = { config: { command: 'build' }, logger: { info: vi.fn() } } as unknown as Wxt + const hooks = config.hooks as Partial + await hooks['build:before']!(wxt) + expect(wxt.logger.info).toHaveBeenCalledWith( + expect.stringContaining(`TemPad MCP profile: ${checkout ? 'checkout' : 'release'}`) + ) + const zip = () => hooks['zip:start']!(wxt) + if (checkout) expect(zip).toThrow('Unset TEMPAD_MCP_DEV_CHECKOUT') + else { + expect(zip).not.toThrow() + const vite = await config.vite?.(env('build')) + expect(JSON.parse(vite!.define!.__TEMPAD_RUNTIME_PROFILE__)).toEqual({ kind: 'release' }) + } + }) + + it('separates release, development serving and explicitly pinned production builds', () => { + expect(extensionRuntimeProfile('build')).toEqual({ kind: 'release' }) + const checkout = new URL('../../../../', import.meta.url).pathname + expect(extensionRuntimeProfile('build', checkout)).toEqual(extensionRuntimeProfile('serve')) + expect(extensionRuntimeProfile('serve').kind).toBe('checkout') + expect(() => extensionRuntimeProfile('build', '/')).toThrow('does not belong') + }) it('allows only the local Vite server required during development', async () => { const csp = getPluginSandboxCsp(true) const vite = await config.vite?.(env('serve')) diff --git a/packages/extension/tests/entrypoints/loader.browser.test.ts b/packages/extension/tests/entrypoints/loader.browser.test.ts new file mode 100644 index 00000000..16db139a --- /dev/null +++ b/packages/extension/tests/entrypoints/loader.browser.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeAll, expect, it, vi } from 'vitest' + +let load: () => void +let injected: HTMLScriptElement | undefined + +beforeAll(async () => { + vi.stubGlobal('defineUnlistedScript', (main: () => void) => ({ main })) + const entrypoint = await import('@/entrypoints/loader') + load = entrypoint.default.main + vi.unstubAllGlobals() +}) + +afterEach(() => { + injected?.remove() + Reflect.deleteProperty(globalThis, '__tempadLoaderProbe') + vi.unstubAllGlobals() +}) + +it('executes the inline UI bundle and removes its script without waiting for a load event', async () => { + const loader = document.createElement('script') + loader.dataset.entry = 'chrome-extension://loader-fixture/ui.js' + vi.spyOn(document, 'currentScript', 'get').mockReturnValueOnce(loader) + const fetch = vi.fn().mockResolvedValue( + new Response(` + globalThis.__tempadLoaderProbe = { + script: document.currentScript, + sandbox: globalThis.__TEMPAD_PLUGIN_SANDBOX_URL__ + }; + `) + ) + vi.stubGlobal('fetch', fetch) + + load() + await vi.waitFor(() => expect(Reflect.get(globalThis, '__tempadLoaderProbe')).toBeDefined()) + const result = Reflect.get(globalThis, '__tempadLoaderProbe') + injected = result.script + expect(fetch).toHaveBeenCalledExactlyOnceWith(new URL(loader.dataset.entry)) + expect(result.sandbox).toBe('chrome-extension://loader-fixture/plugin-sandbox.html') + expect(injected).toBeInstanceOf(HTMLScriptElement) + expect(injected?.isConnected).toBe(false) +}) diff --git a/packages/extension/tests/fakes/figma.test.ts b/packages/extension/tests/fakes/figma.test.ts index f1e40981..f1452dc7 100644 --- a/packages/extension/tests/fakes/figma.test.ts +++ b/packages/extension/tests/fakes/figma.test.ts @@ -8,6 +8,34 @@ afterEach(() => { }) describe('Figma authoring fixture', () => { + it.each(['fontSize', 'lineHeight', 'letterSpacing'] as const)( + 'propagates direct %s aliases to one newline without widening TextStyle scope', + async (field) => { + const fixture = createFixture() + const text = fixture.createNode('TEXT') as TextNode + text.characters = 'A\n\nB' + const base = fixture.variables.get('variable:spacing')! + const local = fixture.variables.get('variable:range-size')! + text.setBoundVariable(field, base) + text.setRangeBoundVariable(0, 1, field, local) + expect(text.getRangeBoundVariable(0, 1, field)).toMatchObject({ id: local.id }) + expect(text.getRangeBoundVariable(0, 2, field)).toMatchObject({ id: local.id }) + expect(text.getRangeBoundVariable(1, 2, field)).toMatchObject({ id: local.id }) + expect(text.getRangeBoundVariable(2, 3, field)).toMatchObject({ id: base.id }) + text.setRangeBoundVariable(0, 1, field, null) + expect(text.getRangeBoundVariable(0, 2, field)).toBeNull() + expect(text.getRangeBoundVariable(2, 3, field)).toMatchObject({ id: base.id }) + const style = fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: local.id } } + }) + await text.setRangeTextStyleIdAsync(0, 1, style.id) + expect(text.getRangeBoundVariable(0, 1, field)).toMatchObject({ id: local.id }) + expect(text.getRangeBoundVariable(1, 2, field)).toBeNull() + expect(text.getRangeBoundVariable(0, 2, field)).toBe(figma.mixed) + } + ) + it('keeps document, resource, plugin data, and current-page state local to each fixture', async () => { const first = createFixture() first.page.name = 'First document' @@ -45,6 +73,65 @@ describe('Figma authoring fixture', () => { expect(other.loadAsync).toHaveBeenCalledOnce() }) + it('resolves range-style bindings from their owning fixture after another fixture starts', async () => { + const first = createFixture() + const text = first.createNode('TEXT') as TextNode + text.characters = 'Owner' + const style = first.styles.get('style:text') as TextStyle + const variable = first.variables.get('variable:range-size')! + Object.assign(style, { + boundVariables: { fontSize: { type: 'VARIABLE_ALIAS', id: variable.id } } + }) + + const second = createFixture() + second.variables.set(variable.id, { + ...variable, + resolveForConsumer: () => ({ value: 99, resolvedType: 'FLOAT' }) + } as Variable) + const foreignLookup = vi.mocked(figma.variables.getVariableByIdAsync) + + await text.setRangeTextStyleIdAsync(0, text.characters.length, style.id) + + expect(text.getRangeFontSize(0, text.characters.length)).toBe(18) + expect(text.getRangeBoundVariable(0, text.characters.length, 'fontSize')).toEqual({ + type: 'VARIABLE_ALIAS', + id: variable.id + }) + expect(foreignLookup).not.toHaveBeenCalled() + }) + + it('stores and deletes shared metadata consistently for pages, nodes, and resources', () => { + const fixture = createFixture() + const collection = figma.variables.createVariableCollection('Tokens') + const objects: PluginDataMixin[] = [ + fixture.page, + fixture.createNode('FRAME'), + figma.createTextStyle(), + collection, + figma.variables.createVariable('Spacing', collection, 'FLOAT') + ] + for (const object of objects) { + object.setSharedPluginData('tempad_dev', 'key', 'retained') + object.setSharedPluginData('tempad_dev', 'custom:key', 'removed') + object.setSharedPluginData('tempad_dev_extra', 'key', 'separate') + expect(object.getSharedPluginDataKeys('tempad_dev').sort()).toEqual(['custom:key', 'key']) + expect(object.getSharedPluginDataKeys('tempad_dev_extra')).toEqual(['key']) + + object.setSharedPluginData('tempad_dev', 'custom:key', '') + expect(object.getSharedPluginData('tempad_dev', 'custom:key')).toBe('') + expect(object.getSharedPluginDataKeys('tempad_dev')).toEqual(['key']) + expect(object.getSharedPluginData('tempad_dev_extra', 'key')).toBe('separate') + } + for (const object of objects) { + expect(() => object.getSharedPluginDataKeys('invalid:namespace')).toThrow('namespace') + } + const copy = (objects[1] as FrameNode).clone() + expect(copy.getSharedPluginDataKeys('tempad_dev')).toEqual(['key']) + expect(copy.getSharedPluginData('tempad_dev', 'key')).toBe('retained') + copy.setSharedPluginData('tempad_dev', 'key', '') + expect(objects[1]!.getSharedPluginData('tempad_dev', 'key')).toBe('retained') + }) + it('stores opted-in native scalars at float32 precision from creation', () => { const fixture = createFixture({ float32: ['opacity', 'width', 'height'] }) const node = fixture.createNode('FRAME') diff --git a/packages/extension/tests/fakes/figma.ts b/packages/extension/tests/fakes/figma.ts index 4edd7cea..731a15e2 100644 --- a/packages/extension/tests/fakes/figma.ts +++ b/packages/extension/tests/fakes/figma.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util' import { vi } from 'vitest' // This is a focused authoring fake, not a complete Plugin API implementation. @@ -11,8 +12,10 @@ function assertSharedPluginDataNamespace(namespace: string): void { } } -function withSharedPluginData(value: T): T & PluginDataMixin { - const shared = new Map() +function withSharedPluginData( + value: T, + shared = new Map() +): T & PluginDataMixin { return Object.assign(value, { getPluginData: vi.fn(() => ''), getPluginDataKeys: vi.fn(() => []), @@ -20,7 +23,13 @@ function withSharedPluginData(value: T): T & PluginDataMixin { assertSharedPluginDataNamespace(namespace) return shared.get(`${namespace}:${key}`) ?? '' }, - getSharedPluginDataKeys: vi.fn(() => []), + getSharedPluginDataKeys(namespace: string) { + assertSharedPluginDataNamespace(namespace) + const prefix = `${namespace}:` + return [...shared.keys()] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)) + }, setPluginData: vi.fn(), setSharedPluginData(namespace: string, key: string, data: string) { assertSharedPluginDataNamespace(namespace) @@ -479,14 +488,6 @@ export function createFixture({ componentPropertyReferences: null, explicitVariableModes, exportAsync: vi.fn(() => Promise.resolve(new Uint8Array([1, 2, 3]))), - getSharedPluginData(namespace: string, key: string) { - assertSharedPluginDataNamespace(namespace) - return pluginData.get(`${namespace}:${key}`) ?? '' - }, - setSharedPluginData(namespace: string, key: string, value: string) { - assertSharedPluginDataNamespace(namespace) - pluginData.set(`${namespace}:${key}`, value) - }, clone() { // Only ordinary subtrees are modeled; reject unsupported descendants before // creating anything, rather than silently losing text/component/grid state. @@ -690,6 +691,7 @@ export function createFixture({ } }) } as unknown as MutableNode + withSharedPluginData(node, pluginData) function normalizePaints( value: readonly Paint[], @@ -731,6 +733,7 @@ export function createFixture({ } }, relativeTransform: { + configurable: true, get: () => relativeTransform, set: (value: Transform) => { relativeTransform = value.map((row) => [...row]) as Transform @@ -1122,20 +1125,67 @@ export function createFixture({ let textTruncation: TextNode['textTruncation'] = 'DISABLED' let maxLines: TextNode['maxLines'] = null let layoutSizingHorizontal = text.layoutSizingHorizontal - const rangeValues = new Map() - const rangeKey = (start: number, end: number, field: string) => `${start}:${end}:${field}` - const rangeValue = (start: number, end: number, field: string, fallback: T): T => { - const key = rangeKey(start, end, field) - return rangeValues.has(key) ? (rangeValues.get(key) as T) : fallback + let applyingTextStyle = false + const rangeValues = new Map< + string, + { start: number; end: number; field: string; value: unknown } + >() + const isCharacterMetric = (field: string) => + ['fontSize', 'lineHeight', 'letterSpacing'].includes(field.replace('variable:', '')) + const rangeScope = (start: number, end: number, field: string) => { + if ( + field.replace('variable:', '') === 'paragraphIndent' || + field.replace('variable:', '') === 'paragraphSpacing' || + field === 'listSpacing' + ) { + start = start === 0 ? 0 : characters.lastIndexOf('\n', start - 1) + 1 + const newline = characters.indexOf('\n', end - 1) + end = newline === -1 ? characters.length : newline + 1 + } + return { start, end } + } + const rangeValue = ( + start: number, + end: number, + field: string, + fallback: T + ): T | typeof MIXED => { + ;({ start, end } = rangeScope(start, end, field)) + // Every supported property follows the same ordered range model. An + // exact query-key lookup loses overlapping writes and untouched text. + const values: unknown[] = Array(end - start).fill(fallback) + for (const range of rangeValues.values()) { + if (range.field !== field) continue + const from = Math.max(start, range.start) + const to = Math.min(end, range.end) + if (from < to) values.fill(range.value, from - start, to - start) + } + const value = values.length ? values[0] : fallback + return values.every((candidate) => isDeepStrictEqual(candidate, value)) + ? (value as T) + : MIXED } const setRangeValue = (start: number, end: number, field: string, value: unknown) => { - rangeValues.set(rangeKey(start, end, field), value) + const scope = rangeScope(start, end, field) + const key = `${scope.start}:${scope.end}:${field}` + rangeValues.delete(key) + rangeValues.set(key, { ...scope, field, value }) } const rangeGetter = (field: string, fallback: () => T) => vi.fn((start: number, end: number) => rangeValue(start, end, field, fallback())) const rangeSetter = (field: string) => - vi.fn((start: number, end: number, value: T) => setRangeValue(start, end, field, value)) + vi.fn((start: number, end: number, value: T) => { + if (isCharacterMetric(field) && !applyingTextStyle && characters[end] === '\n') end++ + setRangeValue(start, end, field, value) + }) + const bindNode = vi.mocked(text.setBoundVariable).getMockImplementation()! Object.assign(node, { + setBoundVariable: vi.fn((field: string, variable: Variable | null) => { + bindNode(field as VariableBindableTextField, variable) + for (const [key, range] of rangeValues) { + if (range.field === `variable:${field}`) rangeValues.delete(key) + } + }), fontName: { family: 'Inter', style: 'Regular' }, fontSize: 12, fontWeight: 400, @@ -1181,7 +1231,17 @@ export function createFixture({ leadingTrim: style.leadingTrim }) }), - getRangeAllFontNames: vi.fn(() => [{ family: 'Inter', style: 'Regular' } as FontName]), + getRangeAllFontNames: vi.fn((start: number, end: number) => { + const fonts: FontName[] = [] + for (let index = start; index < end; index++) { + const fontName = text.getRangeFontName(index, index + 1) + if (typeof fontName === 'symbol') { + throw new Error('Mock mixed fontName requires explicit range font values') + } + if (!fonts.some((font) => isDeepStrictEqual(font, fontName))) fonts.push(fontName) + } + return fonts + }), getRangeFontName: rangeGetter('fontName', () => text.fontName), setRangeFontName: rangeSetter('fontName'), getRangeFontSize: rangeGetter('fontSize', () => text.fontSize), @@ -1229,6 +1289,45 @@ export function createFixture({ setRangeTextStyleIdAsync: vi.fn(async (start: number, end: number, styleId: string) => { if (styleId && styles.get(styleId)?.type !== 'TEXT') throw new Error('invalid text style') setRangeValue(start, end, 'textStyleId', styleId) + if (!styleId) return + const style = styles.get(styleId) as TextStyle + for (const field of [ + 'fontName', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textCase', + 'textDecoration', + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing' + ] as const) { + setRangeValue(start, end, field, style[field]) + } + text.leadingTrim = style.leadingTrim + text.hangingPunctuation = style.hangingPunctuation + text.hangingList = style.hangingList + for (const field of [ + 'fontFamily', + 'fontStyle', + 'fontWeight', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing' + ] as const) { + const alias = style.boundVariables?.[field] + const variable = alias ? await getVariableByIdAsync(alias.id) : null + // Direct metric binding extent is native-proven; TextStyle's + // implicit newline propagation is not. Preserve its authored scope. + applyingTextStyle = true + try { + text.setRangeBoundVariable(start, end, field, variable) + } finally { + applyingTextStyle = false + } + } }), getRangeFillStyleId: rangeGetter('fillStyleId', () => text.fillStyleId), setRangeFillStyleIdAsync: vi.fn(async (start: number, end: number, styleId: string) => { @@ -1253,57 +1352,89 @@ export function createFixture({ setRangeParagraphSpacing: rangeSetter('paragraphSpacing'), getRangeHyperlink: rangeGetter('hyperlink', () => text.hyperlink), setRangeHyperlink: rangeSetter('hyperlink'), - getStyledTextSegments: vi.fn((fields: string[]) => { - const ranges = new Set([...rangeValues.keys()].map((key) => key.split(':', 2).join(':'))) - if (!ranges.size) ranges.add(`0:${characters.length}`) - return [...ranges].map((range) => { - const [start, end] = range.split(':').map(Number) as [number, number] - const segment: Record = { - start, - end, - characters: characters.slice(start, end) + getStyledTextSegments: vi.fn((fields: string[], start = 0, end = characters.length) => { + const boundaries = new Set([start, end]) + const variableFields = new Set() + for (const range of rangeValues.values()) { + for (const boundary of [range.start, range.end]) { + if (boundary > start && boundary < end) boundaries.add(boundary) } - if (fields.includes('fontName')) - segment.fontName = rangeValue(start, end, 'fontName', text.fontName) - if (fields.includes('textStyleId')) - segment.textStyleId = rangeValue(start, end, 'textStyleId', text.textStyleId) - if (fields.includes('fills')) { - segment.fills = rangeValue(start, end, 'fills', text.fills) + if (range.field.startsWith('variable:')) { + variableFields.add(range.field.slice('variable:'.length)) } - if (fields.includes('hyperlink')) { - segment.hyperlink = rangeValue(start, end, 'hyperlink', text.hyperlink) - } - if (fields.includes('textDecorationColor')) { - segment.textDecorationColor = rangeValue( - start, - end, - 'textDecorationColor', - text.textDecorationColor - ) - } - if (fields.includes('boundVariables')) { - const variables = { ...text.boundVariables } as Record - const prefix = `${start}:${end}:variable:` - for (const [key, value] of rangeValues) { - if (!key.startsWith(prefix) || !value) continue - variables[key.slice(prefix.length)] = value as VariableAlias + } + const offsets = [...boundaries].sort((left, right) => left - right) + const segments: Array & { end: number; characters: string }> = [] + let previous: Record | undefined + for (let index = 1; index < offsets.length; index++) { + const start = offsets[index - 1]! + const end = offsets[index]! + const values: Record = {} + for (const field of fields) { + if (field === 'boundVariables') { + const variables: Record = { ...text.boundVariables } + for (const property of variableFields) { + const alias = rangeValue( + start, + end, + `variable:${property}`, + boundVariables[property] ?? null + ) + if (alias) variables[property] = alias + else delete variables[property] + } + values.boundVariables = variables + } else { + const fallback = + field === 'listOptions' + ? { type: 'NONE' } + : field === 'indentation' + ? 0 + : Reflect.get(text, field) + if (fallback !== undefined) values[field] = rangeValue(start, end, field, fallback) } - segment.boundVariables = variables } - return segment - }) + const charactersInRange = characters.slice(start, end) + if (previous && isDeepStrictEqual(previous, values)) { + const previousSegment = segments[segments.length - 1]! + previousSegment.end = end + previousSegment.characters += charactersInRange + } else { + segments.push({ start, end, characters: charactersInRange, ...values }) + previous = values + } + } + return segments }), getRangeBoundVariable: vi.fn((start: number, end: number, field: string) => - rangeValue(start, end, `variable:${field}`, null) + rangeValue( + start, + end, + `variable:${field}`, + (boundVariables[field] as VariableAlias | undefined) ?? null + ) ), setRangeBoundVariable: vi.fn( - (start: number, end: number, field: string, variable: Variable | null) => + (start: number, end: number, field: string, variable: Variable | null) => { + if (!applyingTextStyle && isCharacterMetric(field) && characters[end] === '\n') end++ setRangeValue( start, end, `variable:${field}`, variable ? { type: 'VARIABLE_ALIAS', id: variable.id } : null ) + if (!variable) return + const value = variable.resolveForConsumer(text).value + if (field === 'fontFamily' || field === 'fontStyle') { + const current = text.getRangeFontName(start, end) as FontName + setRangeValue(start, end, 'fontName', { + ...current, + [field === 'fontFamily' ? 'family' : 'style']: value + }) + } else if (field === 'lineHeight' || field === 'letterSpacing') { + setRangeValue(start, end, field, { unit: 'PIXELS', value }) + } else if (field !== 'fontWeight') setRangeValue(start, end, field, value) + } ) }) Object.defineProperties(node, { @@ -1583,7 +1714,11 @@ export function createFixture({ { id: 'variable:range-size', key: 'range-size-key', - resolvedType: 'FLOAT' + resolvedType: 'FLOAT', + resolveForConsumer: (consumer: SceneNode) => ({ + value: consumer.explicitVariableModes['collection:tokens'] === 'mode:dark' ? 22 : 18, + resolvedType: 'FLOAT' + }) } as unknown as Variable ], [ @@ -1641,6 +1776,7 @@ export function createFixture({ variableIds: [] } as unknown as VariableCollection const variableCollectionsById = new Map([[variableCollection.id, variableCollection]]) + const getVariableByIdAsync = vi.fn((id: string) => Promise.resolve(variablesById.get(id) ?? null)) const localCollectionIds = new Set() const localVariableIds = new Set() const extendedModes = new Map() @@ -2126,7 +2262,7 @@ export function createFixture({ getLocalVariableCollectionsAsync: vi.fn(() => Promise.resolve([...localCollectionIds].map((id) => variableCollectionsById.get(id)!)) ), - getVariableByIdAsync: vi.fn((id: string) => Promise.resolve(variablesById.get(id) ?? null)), + getVariableByIdAsync, getVariableCollectionByIdAsync: vi.fn((id: string) => Promise.resolve(variableCollectionsById.get(id) ?? null) ), diff --git a/packages/extension/tests/fakes/text-ranges.test.ts b/packages/extension/tests/fakes/text-ranges.test.ts new file mode 100644 index 00000000..6d52dfba --- /dev/null +++ b/packages/extension/tests/fakes/text-ranges.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createFixture } from './figma' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Figma text-range fixture', () => { + it('includes untouched text and clips requested segments without losing mixed range state', () => { + const text = createFixture().createNode('TEXT') as TextNode + text.characters = 'ABCDEF' + const bold = { family: 'Inter', style: 'Bold' } + const regular = { family: 'Inter', style: 'Regular' } + text.setRangeFontName(0, 2, bold) + expect(text.getStyledTextSegments(['fontName'])).toEqual([ + { start: 0, end: 2, characters: 'AB', fontName: bold }, + { start: 2, end: 6, characters: 'CDEF', fontName: regular } + ]) + expect(text.getStyledTextSegments(['fontName'], 1, 5)).toEqual([ + { start: 1, end: 2, characters: 'B', fontName: bold }, + { start: 2, end: 5, characters: 'CDE', fontName: regular } + ]) + expect(text.getRangeFontName(0, 6)).toBe(figma.mixed) + expect(text.getRangeFontName(1, 2)).toEqual(bold) + expect(text.getRangeFontName(3, 5)).toEqual(regular) + expect(text.getRangeAllFontNames(0, 6)).toEqual([bold, regular]) + expect(text.getRangeAllFontNames(1, 2)).toEqual([bold]) + }) + + it('applies overlapping writes in order and merges by the requested properties', () => { + const text = createFixture().createNode('TEXT') as TextNode + text.characters = 'ABCDEF' + text.setRangeFontSize(0, 4, 20) + text.setRangeFontSize(2, 6, 30) + text.setRangeFontSize(1, 3, 20) + text.setRangeTextDecoration(1, 5, 'UNDERLINE') + expect(text.getStyledTextSegments(['fontSize'])).toEqual([ + { start: 0, end: 3, characters: 'ABC', fontSize: 20 }, + { start: 3, end: 6, characters: 'DEF', fontSize: 30 } + ]) + expect(text.getStyledTextSegments(['fontSize', 'textDecoration'])).toEqual([ + { start: 0, end: 1, characters: 'A', fontSize: 20, textDecoration: 'NONE' }, + { start: 1, end: 3, characters: 'BC', fontSize: 20, textDecoration: 'UNDERLINE' }, + { start: 3, end: 5, characters: 'DE', fontSize: 30, textDecoration: 'UNDERLINE' }, + { start: 5, end: 6, characters: 'F', fontSize: 30, textDecoration: 'NONE' } + ]) + expect(text.getRangeTextDecoration(0, 6)).toBe(figma.mixed) + expect(text.getRangeFontSize(1, 3)).toBe(20) + }) + + it('reports local unbinding and the remaining node binding consistently', () => { + const fixture = createFixture() + const text = fixture.createNode('TEXT') as TextNode + text.characters = 'ABCDEF' + const variable = fixture.variables.get('variable:spacing')! + const alias = { type: 'VARIABLE_ALIAS', id: variable.id } + text.setBoundVariable('fontSize', variable) + text.setRangeBoundVariable(0, 2, 'fontSize', null) + expect(text.getRangeBoundVariable(0, 2, 'fontSize')).toBeNull() + expect(text.getRangeBoundVariable(0, 6, 'fontSize')).toBe(figma.mixed) + expect(text.getStyledTextSegments(['boundVariables'])).toEqual([ + { start: 0, end: 2, characters: 'AB', boundVariables: {} }, + { start: 2, end: 6, characters: 'CDEF', boundVariables: { fontSize: alias } } + ]) + }) + + it('clips segments using UTF-16 offsets, including a split surrogate pair', () => { + const text = createFixture().createNode('TEXT') as TextNode + text.characters = 'A😀B' + text.setRangeFontSize(1, 3, 20) + expect(text.getStyledTextSegments(['fontSize'], 2, 4)).toEqual([ + { start: 2, end: 3, characters: '\uDE00', fontSize: 20 }, + { start: 3, end: 4, characters: 'B', fontSize: 12 } + ]) + }) + + it('keeps distinct variable-font settings while deduplicating repeated faces', () => { + const text = createFixture().createNode('TEXT') as TextNode + text.characters = 'ABCDEF' + const regular = { family: 'Inter', style: 'Regular', variationSettings: { wght: 400 } } + const bold = { ...regular, variationSettings: { wght: 700 } } + text.setRangeFontName(0, 2, regular) + text.setRangeFontName(2, 4, bold) + text.setRangeFontName(4, 6, structuredClone(regular)) + expect(text.getRangeAllFontNames(0, 6)).toEqual([regular, bold]) + expect(text.getRangeAllFontNames(2, 4)).toEqual([bold]) + }) +}) diff --git a/packages/extension/tests/mcp/assets.test.ts b/packages/extension/tests/mcp/assets.test.ts index 63ee853f..4d89e9fe 100644 --- a/packages/extension/tests/mcp/assets.test.ts +++ b/packages/extension/tests/mcp/assets.test.ts @@ -18,6 +18,7 @@ import { setAssetServerUrl, setAssetUploader } from '@/mcp/assets' +import { logger } from '@/utils/log' const DIGEST_BYTES = new Uint8Array(Array.from({ length: 32 }, (_, index) => index)) const DIGEST_HEX = Array.from(DIGEST_BYTES) @@ -149,7 +150,8 @@ describe('mcp/assets', () => { mockCryptoDigest() const uploadMock = vi.fn().mockResolvedValue(undefined) setAssetUploader(uploadMock) - setAssetServerUrl('http://assets.local') + const assetServerUrl = 'http://127.0.0.1:6221/private-upload-capability' + setAssetServerUrl(assetServerUrl) const bytes = new Uint8Array([10, 20, 30, 40]) @@ -180,12 +182,15 @@ describe('mcp/assets', () => { hash: EXPECTED_HASH, mimeType: 'image/png', size: bytes.byteLength, - url: `http://assets.local/assets/${EXPECTED_HASH}`, + url: `${assetServerUrl}/assets/${EXPECTED_HASH}`, width: 300, height: 200, themeable: true }) expect(second).toEqual(first) + expect(vi.mocked(logger.log).mock.calls.flat().join(' ')).not.toContain( + 'private-upload-capability' + ) }) it('deduplicates in-flight uploads for identical server/hash pairs', async () => { @@ -279,16 +284,18 @@ describe('mcp/assets', () => { await expect(joined).resolves.toMatchObject({ mimeType: 'image/png' }) }) - it('propagates uploader errors', async () => { + it('propagates uploader errors without logging their private details', async () => { mockCryptoDigest() - const uploadMock = vi - .fn() - .mockRejectedValue(new Error('Upload failed with status 413 Too Large')) + const error = new Error( + 'Upload failed with status 413 Too Large at http://127.0.0.1:6221/private-error-capability' + ) + const uploadMock = vi.fn().mockRejectedValue(error) setAssetUploader(uploadMock) setAssetServerUrl('http://assets.local') - await expect(ensureAssetUploaded(new Uint8Array([9, 9, 9]), 'image/png')).rejects.toThrow( - 'Upload failed with status 413 Too Large' + await expect(ensureAssetUploaded(new Uint8Array([9, 9, 9]), 'image/png')).rejects.toBe(error) + expect(vi.mocked(logger.error).mock.calls.flat().join(' ')).not.toContain( + 'private-error-capability' ) }) diff --git a/packages/extension/tests/mcp/bridge/content.test.ts b/packages/extension/tests/mcp/bridge/content.test.ts index 06a3b78d..46e67916 100644 --- a/packages/extension/tests/mcp/bridge/content.test.ts +++ b/packages/extension/tests/mcp/bridge/content.test.ts @@ -1,4 +1,4 @@ -import type { BridgeToPageMessage, PageToBridgeMessage } from '@tempad-dev/shared' +import type { BridgeToPageMessage, DesignTask, PageToBridgeMessage } from '@tempad-dev/shared' import { TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, @@ -9,8 +9,10 @@ import { import { afterEach, describe, expect, it, vi } from 'vitest' import { startMcpContentBridge } from '@/mcp/bridge/content' +import { DesignReviews } from '@/mcp/broker/design-reviews' import { MCP_LOCAL_HOST_PERMISSION_ERROR, + MCP_PERMISSION_MESSAGE_SOURCE, MCP_PERMISSION_REQUEST_EVENT, createMcpPermissionMessage } from '@/mcp/permissions' @@ -50,6 +52,7 @@ function installBrowser( vi.stubGlobal('browser', { runtime: { id: 'test-extension', + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, connect: vi.fn(({ name }: { name: string }) => { expect(name).toBe(TEMPAD_MCP_SESSION_PORT_NAME) const port = createPort() @@ -163,6 +166,47 @@ function stateMessage(): BridgeToPageMessage { } } +const task: DesignTask = { + taskId: 'task-a', + title: 'Design', + target: { sessionId: 'session-1', fileKey: 'file-a', fileName: 'Design', pageId: 'page-a' }, + status: 'active', + operation: null, + expiresAt: 1000, + revision: 1 +} + +function enableReview(): Extract { + return { + ...pageMessage('mcp.enable'), + type: 'mcp.enable', + document: { fileKey: 'file-a', fileName: 'Design', pageId: 'page-a', busy: false } + } +} + +function taskState(value = task): BridgeToPageMessage { + return { + source: TEMPAD_MCP_BROWSER_SOURCE, + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, + type: 'mcp.designTaskState', + gatewayId: 'gateway-a', + task: value + } +} + +function stopTask(taskId = task.taskId): PageToBridgeMessage { + return { + ...pageMessage('mcp.enable'), + type: 'mcp.designAction', + action: { + requestId: '00000000-0000-4000-8000-000000000001', + taskId, + epoch: 0, + action: 'stop' + } + } +} + afterEach(() => { vi.unstubAllGlobals() vi.useRealTimers() @@ -217,28 +261,36 @@ describe('mcp/bridge/content', () => { } ) - it('stops retrying and reports reload guidance when the extension context is invalidated', async () => { - vi.useFakeTimers() - const ports: Array> = [] - installBrowser(ports) - const page = installWindow() - const invalidate = startMcpContentBridge() - page.sendPageMessage(pageMessage('mcp.enable')) - await flushMicrotasks() - invalidate() - await vi.advanceTimersByTimeAsync(5000) - expect(ports).toHaveLength(1) - expect(page.postMessage).toHaveBeenLastCalledWith( - expect.objectContaining({ - type: 'mcp.state', - payload: expect.objectContaining({ - status: 'connecting', - errorMessage: expect.stringContaining('Reload this Figma tab') + it.each([false, true])( + 'disconnects and stops retrying when invalidated (listener cleanup fails=%s)', + async (cleanupFails) => { + vi.useFakeTimers() + const ports: Array> = [] + installBrowser(ports) + const page = installWindow() + const invalidate = startMcpContentBridge() + page.sendPageMessage(pageMessage('mcp.enable')) + await flushMicrotasks() + if (cleanupFails) + vi.mocked(browser.runtime.onMessage.removeListener).mockImplementation(() => { + throw new Error('Extension context invalidated.') }) - }), - TEMPAD_MCP_FIGMA_ORIGIN - ) - }) + invalidate() + expect(ports[0]!.port.disconnect).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5000) + expect(ports).toHaveLength(1) + expect(page.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'mcp.state', + payload: expect.objectContaining({ + status: 'connecting', + errorMessage: expect.stringContaining('Reload this Figma tab') + }) + }), + TEMPAD_MCP_FIGMA_ORIGIN + ) + } + ) it('forwards page messages and replays the last enable after reconnect', async () => { vi.useFakeTimers() const ports: Array> = [] @@ -259,6 +311,177 @@ describe('mcp/bridge/content', () => { expect(ports[1]?.postMessage).toHaveBeenCalledWith(enable) }) + it.each(['owner', 'other-tab'])( + 'recovers an unsaved Stop after worker replacement for the %s review', + async (claim) => { + vi.useFakeTimers() + const ports: Array> = [] + const page = installWindow() + installBrowser(ports) + startMcpContentBridge() + page.sendPageMessage(enableReview()) + await flushMicrotasks() + const current = { + ...task, + operation: 'writing' as const, + target: { ...task.target, sessionId: claim === 'owner' ? 'session-1' : 'other-tab' } + } + ports[0]!.receive(taskState(current)) + const storage = { + get: async () => ({}), + set: vi.fn().mockRejectedValue(new Error('Storage full')) + } + const worker = new DesignReviews(storage) + const stopped = { ...current, status: 'cancelled' as const, operation: null } + page.sendPageMessage(stopTask()) + await expect(worker.save(stopped)).rejects.toThrow('Storage full') + expect(worker.get('file-a')?.status).toBe('cancelled') + ports[0]!.receive(taskState({ ...current, revision: 2 })) + ports[0]!.disconnect() + await vi.advanceTimersByTimeAsync(1000) + + const replay = ports[1]!.postMessage.mock.calls[0]![0] as ReturnType + expect(replay.reviewTask).toMatchObject({ + taskId: task.taskId, + status: 'cancelled', + operation: null + }) + const replacement = new DesignReviews(storage) + await replacement.ready() + expect(replacement.get('file-a')).toBeUndefined() + await expect(replacement.restore(replay.reviewTask!)).rejects.toThrow('Storage full') + expect(replacement.get('file-a')).toMatchObject({ status: 'cancelled' }) + } + ) + + it('caches Stop before reconnecting and does not let a stale task stop its replacement', async () => { + vi.useFakeTimers() + const ports: Array> = [] + const page = installWindow() + installBrowser(ports) + startMcpContentBridge() + page.sendPageMessage(enableReview()) + await flushMicrotasks() + ports[0]!.receive(taskState()) + ports[0]!.disconnect() + page.sendPageMessage(stopTask()) + await flushMicrotasks() + expect(ports[1]!.postMessage.mock.calls.map(([message]) => message)).toEqual([ + expect.objectContaining({ reviewTask: { ...task, status: 'cancelled' } }), + stopTask() + ]) + const next = { ...task, taskId: 'new-task', revision: 3 } + ports[1]!.receive(taskState(next)) + page.sendPageMessage(stopTask()) + // A replaced runtime port cannot overwrite the current task snapshot. + ports[0]!.receive(taskState({ ...task, status: 'cancelled', revision: 4 })) + ports[1]!.disconnect() + await vi.advanceTimersByTimeAsync(1000) + expect(ports[2]!.postMessage).toHaveBeenCalledWith({ ...enableReview(), reviewTask: next }) + }) + + it('keeps Stop before Done when re-enabling with a stale snapshot', async () => { + vi.useFakeTimers() + const ports: Array> = [] + const page = installWindow() + installBrowser(ports) + startMcpContentBridge() + page.sendPageMessage({ ...enableReview(), reviewTask: task }) + await flushMicrotasks() + page.sendPageMessage(stopTask()) + ports[0]!.receive({ + source: TEMPAD_MCP_BROWSER_SOURCE, + version: TEMPAD_MCP_BROWSER_PROTOCOL_VERSION, + type: 'mcp.designReviewClosed', + taskId: task.taskId, + fileKey: task.target.fileKey + }) + page.sendPageMessage({ ...enableReview(), reviewTask: task }) + await flushMicrotasks() + ports[0]!.disconnect() + await vi.advanceTimersByTimeAsync(1000) + expect(ports[1]!.postMessage).toHaveBeenCalledWith({ + ...enableReview(), + reviewTask: { ...task, status: 'cancelled', reviewClosed: true } + }) + }) + + it("does not restore another tab's active review or invalid task state as this page's claim", async () => { + vi.useFakeTimers() + const ports: Array> = [] + const page = installWindow() + installBrowser(ports) + startMcpContentBridge() + page.sendPageMessage(enableReview()) + await flushMicrotasks() + ports[0]!.receive(taskState({ ...task, target: { ...task.target, sessionId: 'other-tab' } })) + ports[0]!.receive({ ...taskState(), task: { ...task, client: { kind: 'invalid' } } }) + ports[0]!.disconnect() + await vi.advanceTimersByTimeAsync(1000) + expect(ports[1]!.postMessage).toHaveBeenCalledWith(enableReview()) + }) + + it.each(['file', 'session', 'disable'])( + 'does not carry a stopped review across a changed %s', + async (change) => { + vi.useFakeTimers() + const ports: Array> = [] + const page = installWindow() + installBrowser(ports) + startMcpContentBridge() + page.sendPageMessage(enableReview()) + await flushMicrotasks() + ports[0]!.receive(taskState()) + page.sendPageMessage(stopTask()) + if (change === 'file') + page.sendPageMessage({ + ...enableReview(), + type: 'mcp.sessionInfo', + document: { ...enableReview().document!, fileKey: 'file-b' } + }) + else { + if (change === 'disable') page.sendPageMessage(pageMessage('mcp.disable')) + page.sendPageMessage({ + ...enableReview(), + sessionId: change === 'session' ? 'session-2' : 'session-1' + }) + } + await flushMicrotasks() + // Neither foreign-file state nor an old session's Stop may claim the new scope. + ports[0]!.receive(taskState({ ...task, target: { ...task.target, fileKey: 'foreign' } })) + page.sendPageMessage(stopTask()) + ports[0]!.disconnect() + await vi.advanceTimersByTimeAsync(1000) + expect(ports[1]!.postMessage.mock.calls[0]![0]).not.toHaveProperty('reviewTask') + } + ) + + it('shows reload guidance when the runtime object disappears during disconnect', async () => { + vi.useFakeTimers() + const ports: Array> = [] + const page = installWindow() + const sendMessage = installBrowser(ports) + startMcpContentBridge() + page.sendPageMessage(pageMessage('mcp.enable')) + await flushMicrotasks() + Reflect.deleteProperty(browser, 'runtime') + expect(() => ports[0]!.disconnect()).not.toThrow() + expect(page.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'mcp.state', + payload: expect.objectContaining({ + errorMessage: expect.stringContaining('Reload this Figma tab') + }) + }), + TEMPAD_MCP_FIGMA_ORIGIN + ) + await vi.advanceTimersByTimeAsync(5000) + page.sendPageMessage(pageMessage('mcp.enable')) + page.dispatchEvent(MCP_PERMISSION_REQUEST_EVENT) + expect(ports).toHaveLength(1) + expect(sendMessage).toHaveBeenCalledTimes(1) + }) + it('forwards an initial draft load after permission checking and session registration', async () => { const ports: Array> = [] const windowMock = installWindow() @@ -445,6 +668,63 @@ describe('mcp/bridge/content', () => { expect(ports).toHaveLength(1) }) + it.each(['enabled', 'disabled', 'invalidated'] as const)( + 'rechecks an external permission change only for a live %s page', + async (state) => { + const ports: Array> = [] + const send = installBrowser(ports, vi.fn().mockResolvedValue({ granted: false })) + const page = installWindow() + const dispose = startMcpContentBridge() + const changed = vi.mocked(browser.runtime.onMessage.addListener).mock.calls[0]![0] + const message = { source: MCP_PERMISSION_MESSAGE_SOURCE, type: 'mcp.permissions.changed' } + page.sendPageMessage(pageMessage('mcp.enable')) + await flushMicrotasks() + expect(ports).toHaveLength(0) + changed(message, { id: 'other-extension' }, vi.fn()) + changed({ ...message, source: 'unknown' }, { id: 'test-extension' }, vi.fn()) + expect(send).toHaveBeenCalledTimes(1) + if (state === 'disabled') page.sendPageMessage(pageMessage('mcp.disable')) + if (state === 'invalidated') dispose() + send.mockResolvedValue({ granted: true }) + changed(message, { id: 'test-extension' }, vi.fn()) + await flushMicrotasks() + expect(ports).toHaveLength(state === 'enabled' ? 1 : 0) + if (state === 'enabled') { + expect(send).toHaveBeenLastCalledWith( + createMcpPermissionMessage('mcp.permissions.contains') + ) + expect(ports[0]!.postMessage).toHaveBeenCalledWith(pageMessage('mcp.enable')) + } + dispose() + expect(browser.runtime.onMessage.removeListener).toHaveBeenCalledWith(changed) + } + ) + + it('ignores an older denied check after an external permission grant', async () => { + const ports: Array> = [] + const earlier = createDeferred<{ granted: boolean }>() + installBrowser( + ports, + vi.fn().mockReturnValueOnce(earlier.promise).mockResolvedValue({ granted: true }) + ) + const page = installWindow() + const dispose = startMcpContentBridge() + page.sendPageMessage(pageMessage('mcp.enable')) + const changed = vi.mocked(browser.runtime.onMessage.addListener).mock.calls[0]![0] + changed( + { source: MCP_PERMISSION_MESSAGE_SOURCE, type: 'mcp.permissions.changed' }, + { id: 'test-extension' }, + vi.fn() + ) + await flushMicrotasks() + expect(ports).toHaveLength(1) + page.postMessage.mockClear() + earlier.resolve({ granted: false }) + await flushMicrotasks() + expect(page.postMessage).not.toHaveBeenCalled() + dispose() + }) + it('does not prompt while auto-enabling and reports missing local host permission', async () => { const ports: Array> = [] const windowMock = installWindow() diff --git a/packages/extension/tests/mcp/broker/design-reviews.test.ts b/packages/extension/tests/mcp/broker/design-reviews.test.ts index 504101b6..3cbb6a48 100644 --- a/packages/extension/tests/mcp/broker/design-reviews.test.ts +++ b/packages/extension/tests/mcp/broker/design-reviews.test.ts @@ -1,5 +1,6 @@ import type { DesignTask, FeedbackDraftScope } from '@tempad-dev/shared' +import { DesignTaskSchema } from '@tempad-dev/shared' import { describe, expect, it, vi } from 'vitest' import { DesignReviews } from '@/mcp/broker/design-reviews' @@ -102,6 +103,102 @@ describe('saved design reviews', () => { expect(reviews.get('file-a')?.status).toBe('cancelled') }) + it('projects additive stored fields without weakening wire validation or losing them on save', async () => { + const data = storage() + const saved = { + ...task, + status: 'cancelled', + futureTask: { value: 1 }, + target: { ...task.target, futureTarget: true }, + client: { ...task.client, futureClient: true }, + capabilities: { + interrupt: false, + queue: false, + steer: false, + continue: false, + futureCapability: true + } + } + await data.set({ 'tempad.design-reviews.v1': [saved] }) + const reviews = new DesignReviews(data) + await reviews.ready() + const projected = reviews.get('file-a')! + expect(projected).toMatchObject({ taskId: task.taskId, status: 'cancelled' }) + expect(DesignTaskSchema.safeParse(saved).success).toBe(false) + expect(DesignTaskSchema.safeParse(projected).success).toBe(true) + expect(projected).not.toHaveProperty('futureTask') + expect(projected.target).not.toHaveProperty('futureTarget') + expect(projected.client).not.toHaveProperty('futureClient') + expect(projected.capabilities).not.toHaveProperty('futureCapability') + await reviews.save({ ...projected, status: 'active', revision: 6 }) + expect((await data.get('tempad.design-reviews.v1'))['tempad.design-reviews.v1']).toEqual([ + { ...saved, revision: 6 } + ]) + const restarted = new DesignReviews(data) + await restarted.ready() + expect(restarted.get('file-a')).toMatchObject({ status: 'cancelled', revision: 6 }) + }) + + it('preserves incompatible records and their order while saving another file or a new task', async () => { + const data = storage() + const future = { ...task, taskId: 'future-task', status: 'future-status' } + const invalid = { broken: true } + const unknownClient = { ...task, client: { ...task.client, kind: 'future-agent' } } + await data.set({ 'tempad.design-reviews.v1': [task, future, invalid, unknownClient] }) + const reviews = new DesignReviews(data) + await reviews.ready() + const other = { ...task, taskId: 'other', target: { ...task.target, fileKey: 'other-file' } } + await reviews.save(other) + expect((await data.get('tempad.design-reviews.v1'))['tempad.design-reviews.v1']).toEqual([ + task, + future, + invalid, + unknownClient, + other + ]) + const fresh = { ...task, taskId: 'fresh-task' } + await reviews.save(fresh) + expect((await data.get('tempad.design-reviews.v1'))['tempad.design-reviews.v1']).toEqual([ + future, + invalid, + unknownClient, + other, + fresh + ]) + const restarted = new DesignReviews(data) + await restarted.ready() + expect(restarted.get('file-a')?.taskId).toBe('fresh-task') + }) + + it('flushes an unsaved Stop alongside another file without reviving cleared identity fields', async () => { + const data = storage() + const saved = { ...task, client: { ...task.client, futureClient: true }, futureTask: true } + await data.set({ 'tempad.design-reviews.v1': [saved] }) + const reviews = new DesignReviews(data) + await reviews.ready() + const set = vi.spyOn(data, 'set').mockRejectedValueOnce(new Error('Storage unavailable')) + await expect( + reviews.save({ ...task, client: undefined, status: 'cancelled' }) + ).rejects.toThrow() + const other = { ...task, taskId: 'other', target: { ...task.target, fileKey: 'other-file' } } + await reviews.save(other) + expect(set).toHaveBeenCalledTimes(2) + const restarted = new DesignReviews(data) + await restarted.ready() + expect(restarted.get('file-a')).toMatchObject({ status: 'cancelled' }) + expect(restarted.get('file-a')?.client).toBeUndefined() + expect((await data.get('tempad.design-reviews.v1'))['tempad.design-reviews.v1']).toEqual([ + { + ...task, + client: undefined, + status: 'cancelled', + reviewClosed: undefined, + futureTask: true + }, + other + ]) + }) + it('merges an offline Stop without replacing a newer review', async () => { const reviews = new DesignReviews(storage()) await reviews.save(task) diff --git a/packages/extension/tests/mcp/broker/hub-client.test.ts b/packages/extension/tests/mcp/broker/hub-client.test.ts index 5a3f8e1f..5b46274f 100644 --- a/packages/extension/tests/mcp/broker/hub-client.test.ts +++ b/packages/extension/tests/mcp/broker/hub-client.test.ts @@ -116,6 +116,211 @@ afterEach(() => { }) describe('mcp/broker/hub-client', () => { + it.each(['missing', 'release', 'foreign'] as const)( + 'never probes any port when checkout identity is %s', + async (kind) => { + vi.useFakeTimers() + vi.stubGlobal('__TEMPAD_RUNTIME_PROFILE__', { kind: 'checkout', id: 'a'.repeat(16) }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const client = createClient( + sockets, + {}, + kind === 'missing' + ? null + : { + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64), + ...(kind === 'foreign' + ? { runtimeProfile: { kind: 'checkout', id: 'b'.repeat(16) } } + : {}) + } + ) + client.start() + await vi.advanceTimersByTimeAsync(10000) + expect(fetch).not.toHaveBeenCalled() + expect(sockets).toEqual([]) + expect(client.getSnapshot()).toMatchObject({ + status: 'error', + errorMessage: expect.stringContaining('Rebuild and reload') + }) + client.stop() + } + ) + + it.each([false, true])( + 'uses checkout ports and verifies identity before state (receipts: %s)', + async (receipts) => { + vi.useFakeTimers() + vi.stubGlobal('WebSocket', { OPEN: 1 }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const hello: RuntimeHelloMessage = { + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64), + runtimeProfile: { kind: 'checkout', id: 'a'.repeat(16) } + } + vi.stubGlobal('__TEMPAD_RUNTIME_PROFILE__', hello.runtimeProfile) + const client = createClient(sockets, {}, hello) + client.start() + await flushMicrotasks() + expect(sockets[0]?.url).toBe('ws://127.0.0.1:16220') + sockets[0]!.open() + sockets[0]!.receive({ + type: 'registered', + id: 'other', + protocolVersion: 15, + runtimeProfile: { kind: 'checkout', id: 'b'.repeat(16) } + }) + await flushMicrotasks() + expect(sockets[0]!.readyState).toBe(3) + expect(sockets[0]!.sent).toEqual([]) + const matched = sockets[1]! + expect(matched.url).toBe('ws://127.0.0.1:17431') + matched.open() + matched.receive({ + type: 'registered', + id: 'own', + protocolVersion: 15, + runtimeProfile: hello.runtimeProfile, + ...(receipts ? { terminalReceiptGeneration: 'generation' } : {}) + }) + expect(matched.sent.map((value) => JSON.parse(value))).toEqual([ + { ...hello, ...(receipts ? { terminalReceipts: true } : {}) } + ]) + matched.receive(stateMessage()) + await flushMicrotasks() + expect(client.getSnapshot().status).toBe('connected') + expect(matched.sent).toHaveLength(1) + client.stop() + } + ) + + it('closes a failed checkout hello and continues discovery', async () => { + vi.useFakeTimers() + installHubProbe() + const sockets: FakeWebSocket[] = [] + const runtimeProfile = { kind: 'checkout' as const, id: 'a'.repeat(16) } + vi.stubGlobal('__TEMPAD_RUNTIME_PROFILE__', runtimeProfile) + const client = createClient( + sockets, + {}, + { + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64), + runtimeProfile + } + ) + client.start() + await flushMicrotasks() + const failed = sockets[0]! + failed.open() + vi.spyOn(failed, 'send').mockImplementation(() => { + throw new Error('Socket closed') + }) + failed.receive({ type: 'registered', id: 'hub', protocolVersion: 15, runtimeProfile }) + await flushMicrotasks() + expect(failed.readyState).toBe(3) + expect(sockets[1]!.url).toBe('ws://127.0.0.1:17431') + expect(client.getSnapshot().status).toBe('connecting') + client.stop() + }) + + it('rejects checkout registration on a release discovery port', async () => { + vi.useFakeTimers() + installHubProbe() + const sockets: FakeWebSocket[] = [] + const client = createClient(sockets) + client.start() + await flushMicrotasks() + sockets[0]!.receive({ + type: 'registered', + id: 'dev', + protocolVersion: 15, + runtimeProfile: { kind: 'checkout', id: 'a'.repeat(16) } + }) + await flushMicrotasks() + expect(sockets[0]!.readyState).toBe(3) + expect(sockets[1]!.url).toBe('ws://127.0.0.1:7431') + client.stop() + }) + + it.each([true, false])( + 'negotiates durable receipts (%s) only when offered and handles failed sends', + async (durable) => { + vi.useFakeTimers() + vi.stubGlobal('WebSocket', { OPEN: 1 }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const onToolCompletionAck = vi.fn() + const onKeepalive = vi.fn() + const hello: RuntimeHelloMessage = { + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64) + } + const client = createClient(sockets, { onToolCompletionAck, onKeepalive }, hello) + const result = { id: 'call', type: 'toolResult' as const, payload: {} } + const completion = { + id: 'call', + type: 'toolCompletion' as const, + generation: 'hub', + receiptId: 'receipt' + } + expect(client.sendToolResult(result)).toBe(false) + client.start() + await flushMicrotasks() + const socket = sockets[0]! + socket.open() + socket.receive({ + type: 'registered', + id: 'gateway', + protocolVersion: 15, + terminalReceiptGeneration: 'hub', + ...(durable ? { durableTerminalReceipts: true } : {}) + }) + socket.receive(stateMessage()) + await flushMicrotasks() + expect(JSON.parse(socket.sent[0]!)).toEqual({ + ...hello, + terminalReceipts: true, + ...(durable ? { durableTerminalReceipts: true } : {}) + }) + expect(client.sendToolResult(result)).toBe(true) + expect(client.sendToolCompletion(completion)).toBe(true) + expect(client.sendToolCompletion({ ...completion, generation: 'old-hub' })).toBe(durable) + socket.readyState = 2 + expect(client.sendToolResult(result)).toBe(false) + expect(client.sendToolCompletion(completion)).toBe(false) + socket.readyState = 1 + vi.spyOn(socket, 'send').mockImplementation(() => { + throw new Error('Socket write failed') + }) + expect(client.sendToolResult(result)).toBe(false) + expect(client.sendToolCompletion(completion)).toBe(false) + await vi.advanceTimersByTimeAsync(20000) + expect(onKeepalive).toHaveBeenCalledOnce() + const ack = { ...completion, type: 'toolCompletionAck', accepted: true } + socket.receive(ack) + expect(onToolCompletionAck).toHaveBeenCalledWith(ack) + socket.closeFromRemote() + expect(client.getSnapshot().terminalReceiptGeneration).toBe('hub') + await vi.advanceTimersByTimeAsync(3000) + await flushMicrotasks() + completeHandshake(sockets[1]!) // Released Hub: no capability offer. + await flushMicrotasks() + expect(JSON.parse(sockets[1]!.sent[0]!)).toEqual(hello) + expect(client.getSnapshot().terminalReceiptGeneration).toBeUndefined() + client.stop() + } + ) it('aborts a reachable HTTP probe after headers while still opening its WebSocket', async () => { vi.stubGlobal('WebSocket', { OPEN: 1 }) let signal: AbortSignal | undefined @@ -386,6 +591,50 @@ describe('mcp/broker/hub-client', () => { client.stop() }) + it('revokes a rejected socket immediately without waiting for its close handshake', async () => { + vi.useFakeTimers() + vi.stubGlobal('WebSocket', { OPEN: 1 }) + installHubProbe() + const sockets: FakeWebSocket[] = [] + const onToolCall = vi.fn() + const client = createClient(sockets, { onToolCall }) + try { + client.start() + await flushMicrotasks() + const rejected = sockets[0]! + completeHandshake(rejected, 'gateway-1') + await flushMicrotasks() + // Browser close() enters CLOSING before a later close event confirms teardown. + vi.spyOn(rejected, 'close').mockImplementation(() => { + rejected.readyState = 2 + }) + rejected.receiveRaw('{') + + expect(client.getSnapshot()).toMatchObject({ + activeId: null, + assetServerUrl: null, + registeredId: null, + status: 'connecting', + errorMessage: 'Received malformed message from MCP server' + }) + await vi.advanceTimersByTimeAsync(3000) + expect(sockets).toHaveLength(2) + completeHandshake(sockets[1]!, 'gateway-1') + await flushMicrotasks() + rejected.receive({ + id: 'stale-call', + payload: { args: {}, name: 'get_code' }, + type: 'toolCall' + }) + rejected.closeFromRemote() + expect(onToolCall).not.toHaveBeenCalled() + expect(client.getSnapshot()).toMatchObject({ status: 'connected', errorMessage: null }) + expect(sockets[1]!.readyState).toBe(1) + } finally { + client.stop() + } + }) + it.each([ ['malformed traffic', ['{']], [ diff --git a/packages/extension/tests/mcp/broker/service-worker.test.ts b/packages/extension/tests/mcp/broker/service-worker.test.ts index 3d5395ab..07170f37 100644 --- a/packages/extension/tests/mcp/broker/service-worker.test.ts +++ b/packages/extension/tests/mcp/broker/service-worker.test.ts @@ -1,4 +1,4 @@ -import type { ToolCallMessage } from '@tempad-dev/shared' +import type { PageToBridgeMessage, ToolCallMessage } from '@tempad-dev/shared' import { MCP_MAX_ASSET_BYTES, @@ -17,7 +17,8 @@ import { type McpPermissionMessageType, createMcpPermissionMessage, MCP_LOCAL_HOST_PERMISSION_ERROR, - MCP_LOCAL_HOST_ORIGIN + MCP_LOCAL_HOST_ORIGIN, + MCP_PERMISSION_CHANGE_MESSAGE } from '@/mcp/permissions' const ASSET_HASH = '039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81' @@ -51,6 +52,16 @@ beforeEach(() => { type Listener = (payload: T) => void type BrokerInternals = { + uploadAsset: ( + port: McpBrokerPort, + message: Extract + ) => Promise + downloadAsset: ( + port: McpBrokerPort, + message: Extract + ) => Promise + handleToolCompletionAck: (message: import('@tempad-dev/shared').ToolCompletionAckMessage) => void + flushToolCompletions: () => void handleDesignActionResult: ( sessionId: string, result: import('@tempad-dev/shared').DesignActionResult @@ -70,10 +81,15 @@ function createHubClient( assetServerUrl: snapshot.assetServerUrl ?? null, errorMessage: snapshot.errorMessage ?? null, registeredId: snapshot.registeredId ?? null, - status: snapshot.status ?? 'idle' + status: snapshot.status ?? 'idle', + ...(snapshot.durableTerminalReceipts ? { durableTerminalReceipts: true as const } : {}), + ...(snapshot.terminalReceiptGeneration + ? { terminalReceiptGeneration: snapshot.terminalReceiptGeneration } + : {}) }), sendActivate: vi.fn(), sendToolResult: vi.fn(), + sendToolCompletion: vi.fn(), sendSessions: vi.fn(), sendDesignAction: vi.fn(), start: vi.fn(), @@ -145,6 +161,61 @@ function designBroker() { } describe('design task broker routing', () => { + it('retains the live Stop fence through failed stale-state saves and allows Done to retry cleanup', async () => { + const f = designBroker() + const task = { + taskId: 'task-a', + title: 'Design', + status: 'active' as const, + operation: null, + target: { sessionId: 'tab-a', fileKey: 'file-a', fileName: 'Design', pageId: 'page-a' }, + expiresAt: 1000, + revision: 1 + } + await f.internals.routeDesignTask({ type: 'designTaskState', task }) + const save = vi.spyOn(browser.storage.local, 'set').mockRejectedValue(new Error('Storage full')) + f.a.message({ + ...pageMessage('mcp.enable', 'tab-a'), + type: 'mcp.designAction', + action: { + requestId: '00000000-0000-4000-8000-000000000001', + taskId: task.taskId, + epoch: 0, + action: 'stop' + } + }) + await vi.waitFor(() => expect(f.client.sendDesignAction).toHaveBeenCalledOnce()) + await f.internals.routeDesignTask({ type: 'designTaskState', task: { ...task, revision: 2 } }) + expect(f.a.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'mcp.designTaskState', + task: expect.objectContaining({ status: 'cancelled', revision: 2 }) + }) + ) + save.mockRestore() + f.a.message({ + ...pageMessage('mcp.enable', 'tab-a'), + type: 'mcp.designAction', + action: { + requestId: '00000000-0000-4000-8000-000000000002', + taskId: task.taskId, + epoch: 0, + action: 'done' + } + }) + await vi.waitFor(() => + expect(f.a.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'mcp.designActionResult', + result: expect.objectContaining({ + requestId: '00000000-0000-4000-8000-000000000002', + status: 'delivered' + }) + }) + ) + ) + }) + it('forwards task state and Stop even when review persistence is unavailable', async () => { vi.stubGlobal('browser', { storage: { @@ -707,7 +778,10 @@ describe('design task broker routing', () => { expect(f.client.sendToolResult).toHaveBeenCalledWith( expect.objectContaining({ id: 'late-write', - error: expect.objectContaining({ code: 'DESIGN_TARGET_CHANGED' }) + error: expect.objectContaining({ + code: 'CANVAS_APPLY_ABORTED', + message: expect.stringContaining('DESIGN_TARGET_CHANGED') + }) }) ) expect(f.b.postMessage).not.toHaveBeenCalled() @@ -799,7 +873,9 @@ function toolResult(callId: string, sessionId = 'session-1') { } } -function assetUpload(sessionId = 'session-1') { +function assetUpload( + sessionId = 'session-1' +): Extract { return { payload: { base64: 'AQID', @@ -815,7 +891,9 @@ function assetUpload(sessionId = 'session-1') { } } -function assetDownload(sessionId = 'session-1') { +function assetDownload( + sessionId = 'session-1' +): Extract { return { payload: { hash: ASSET_HASH }, requestId: 'download-1', @@ -1108,15 +1186,37 @@ describe('mcp/broker/service-worker', () => { .mocked(client.sendSessions) .mock.calls.every(([snapshot]) => snapshot.activeSessionId === null) ).toBe(true) + ;(broker as unknown as BrokerInternals).handleHubSnapshot(client.getSnapshot()) + expect(ports[0]!.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: 'mcp.state', + payload: expect.objectContaining({ + sessionId: 'tab-a', + activeSessionId: null, + errorMessage: 'The selected Figma tab is unavailable.' + }) + }) + ) + expect(JSON.stringify(ports[0]!.postMessage.mock.lastCall)).not.toContain('tab-b') enable(1) expect(client.sendSessions).toHaveBeenLastCalledWith( expect.objectContaining({ activeSessionId: 'tab-b' }) ) + expect(ports[0]!.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ errorMessage: null }) }) + ) ports[1]!.disconnect() await vi.waitFor(() => expect(query).toHaveBeenCalled()) expect(client.sendSessions).toHaveBeenLastCalledWith( expect.objectContaining({ activeSessionId: null }) ) + expect(ports[0]!.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + errorMessage: 'The selected Figma tab is unavailable.' + }) + }) + ) query.mockResolvedValue([{ id: 1 }]) ;(broker as unknown as BrokerInternals).handleHubSnapshot({ ...client.getSnapshot(), @@ -1127,8 +1227,38 @@ describe('mcp/broker/service-worker', () => { expect.objectContaining({ activeSessionId: 'tab-a' }) ) ) + expect(ports[0]!.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ errorMessage: null }) }) + ) }) + it.each([null, 'Hub connection lost.'])( + 'clears unavailable-tab guidance on explicit takeover without replacing Hub errors: %s', + async (errorMessage) => { + const f = designBroker() + await vi.waitFor(() => expect(f.client.start).toHaveBeenCalled()) + vi.spyOn(f.client, 'getSnapshot').mockReturnValue({ + ...f.client.getSnapshot(), + errorMessage + }) + f.b.disconnect() + expect(f.a.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + activeSessionId: null, + errorMessage: errorMessage ?? 'The selected Figma tab is unavailable.' + }) + }) + ) + f.a.message(pageMessage('mcp.activateSession', 'tab-a')) + expect(f.a.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ activeSessionId: 'tab-a', errorMessage }) + }) + ) + } + ) + it.each([ { tabId: 3, documentId: 'document-tab-b', fileKey: 'file-b' }, { tabId: 2, documentId: 'replacement-document', fileKey: 'file-b' }, @@ -1401,6 +1531,158 @@ describe('mcp/broker/service-worker', () => { expect(hubClient.sendToolResult).not.toHaveBeenCalled() }) + it.each([true, false])( + 'retains a terminal receipt until acknowledgment after send returned %s', + async (sent) => { + const snapshot = { + status: 'connected' as const, + registeredId: 'gateway-1', + terminalReceiptGeneration: 'hub' + } + const client = createHubClient(snapshot) + vi.mocked(client.sendToolResult).mockReturnValue(sent) + const broker = new McpServiceWorkerBroker(client) + const internals = broker as unknown as BrokerInternals + internals.handleHubSnapshot(client.getSnapshot()) + const page = createPort('https://www.figma.com/design/abc/File') + broker.handlePort(page.port) + page.message(pageMessage('mcp.enable')) + await vi.waitFor(() => expect(client.start).toHaveBeenCalled()) + const receipt = { generation: 'hub', receiptId: 'receipt' } + const call = { + type: 'toolCall' as const, + id: 'call', + terminalReceipt: receipt, + payload: { name: 'apply_canvas', args: {} } + } + internals.routeToolCall(call) + internals.flushToolCompletions() + expect(client.sendToolCompletion).not.toHaveBeenCalled() // Still executing. + page.message(toolResult('call')) + expect(client.sendToolResult).toHaveBeenCalledOnce() + expect(client.sendToolCompletion).toHaveBeenCalledWith({ + type: 'toolCompletion', + id: 'call', + ...receipt + }) + const beforeReconnect = vi.mocked(client.sendToolCompletion).mock.calls.length + snapshot.status = 'connected' + snapshot.registeredId = 'gateway-2' + internals.handleHubSnapshot(client.getSnapshot()) + expect(client.sendToolCompletion).toHaveBeenCalledTimes(beforeReconnect + 1) + expect(vi.mocked(client.sendSessions).mock.invocationCallOrder.at(-1)).toBeLessThan( + vi.mocked(client.sendToolCompletion).mock.invocationCallOrder.at(-1)! + ) + internals.handleToolCompletionAck({ + type: 'toolCompletionAck', + id: 'call', + ...receipt, + receiptId: 'wrong', + accepted: true + }) + internals.flushToolCompletions() + expect(client.sendToolCompletion).toHaveBeenCalledTimes(beforeReconnect + 2) + internals.handleToolCompletionAck({ + type: 'toolCompletionAck', + id: 'call', + ...receipt, + accepted: true + }) + internals.flushToolCompletions() + expect(client.sendToolCompletion).toHaveBeenCalledTimes(beforeReconnect + 2) + expect(client.sendToolResult).toHaveBeenCalledOnce() // No payload/write replay. + } + ) + + it.each(['same-hub', 'new-hub', 'durable-hub', 'new-page', 'port-disconnect'] as const)( + 'handles a late page result after %s without replaying the call', + async (kind) => { + const snapshot: Partial> = { + status: 'connected', + registeredId: 'gateway-1', + terminalReceiptGeneration: 'hub', + ...(kind === 'durable-hub' ? { durableTerminalReceipts: true } : {}) + } + const client = createHubClient(snapshot) + const broker = new McpServiceWorkerBroker(client) + const internals = broker as unknown as BrokerInternals + internals.handleHubSnapshot(client.getSnapshot()) + const page = createPort('https://www.figma.com/design/abc/File') + broker.handlePort(page.port) + page.message(pageMessage('mcp.enable')) + await vi.waitFor(() => expect(client.start).toHaveBeenCalled()) + internals.routeToolCall({ + type: 'toolCall', + id: 'late', + terminalReceipt: { generation: 'hub', receiptId: 'receipt' }, + payload: { name: 'apply_canvas', args: {} } + }) + snapshot.status = 'connecting' + snapshot.registeredId = null + internals.handleHubSnapshot(client.getSnapshot()) + if (kind === 'new-page') page.message(pageMessage('mcp.disable')) + if (kind === 'port-disconnect') page.disconnect() + snapshot.status = 'connected' + snapshot.registeredId = 'gateway-2' + if (kind === 'new-hub' || kind === 'durable-hub') + snapshot.terminalReceiptGeneration = 'new-hub' + internals.handleHubSnapshot(client.getSnapshot()) + page.message(toolResult('late')) + expect(client.sendToolCompletion).toHaveBeenCalledTimes( + ['same-hub', 'durable-hub'].includes(kind) ? 1 : 0 + ) + if (kind === 'port-disconnect') + expect(client.sendToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ code: 'EXTENSION_DISCONNECTED' }) + }) + ) + expect( + page.postMessage.mock.calls.filter( + ([message]) => (message as { type: string }).type === 'mcp.toolCall' + ) + ).toHaveLength(1) + } + ) + + it('rejects excess calls before page dispatch instead of evicting unacknowledged completions', async () => { + const client = createHubClient({ + status: 'connected', + registeredId: 'gateway', + terminalReceiptGeneration: 'hub' + }) + const broker = new McpServiceWorkerBroker(client) + const internals = broker as unknown as BrokerInternals + internals.handleHubSnapshot(client.getSnapshot()) + const page = createPort('https://www.figma.com/design/abc/File') + broker.handlePort(page.port) + page.message(pageMessage('mcp.enable')) + await vi.waitFor(() => expect(client.start).toHaveBeenCalled()) + for (let index = 0; index < 129; index++) { + internals.routeToolCall({ + type: 'toolCall', + id: String(index), + terminalReceipt: { generation: 'hub', receiptId: String(index) }, + payload: { name: 'get_structure', args: {} } + }) + } + expect( + page.postMessage.mock.calls.filter( + ([message]) => (message as { type: string }).type === 'mcp.toolCall' + ) + ).toHaveLength(128) + expect(client.sendToolResult).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: '128', + error: expect.objectContaining({ code: 'EXTENSION_DELIVERY_FAILED' }) + }) + ) + page.message(toolResult('0')) + expect(client.sendToolCompletion).toHaveBeenCalledWith( + expect.objectContaining({ id: '0', receiptId: '0' }) + ) + }) + it('ignores tool results from ports that do not own the claimed session', () => { const hubClient = createHubClient() const broker = new McpServiceWorkerBroker(hubClient) @@ -1470,7 +1752,7 @@ describe('mcp/broker/service-worker', () => { expect(hubClient.sendToolResult).toHaveBeenCalledWith({ error: { - code: TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED, + code: TEMPAD_MCP_ERROR_CODES.EXTENSION_DELIVERY_FAILED, message: 'Figma session disconnected before receiving a tool call.' }, id: 'call-1', @@ -1732,6 +2014,46 @@ describe('mcp/broker/service-worker', () => { expect(hubClient.stop).toHaveBeenCalledTimes(1) }) + it.each(['uploadAsset', 'downloadAsset'] as const)( + 'does not unregister a replacement port when an earlier %s settles', + async (operation) => { + let finish!: (response: Response) => void + vi.stubGlobal( + 'fetch', + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + ) + const client = createHubClient({ assetServerUrl: 'http://127.0.0.1:9000' }) + const broker = new McpServiceWorkerBroker(client) + const first = createPort('https://www.figma.com/design/abc/File') + const replacement = createPort('https://www.figma.com/design/abc/File') + broker.handlePort(first.port) + first.message(pageMessage('mcp.enable')) + const internals = broker as unknown as BrokerInternals + const pending = + operation === 'uploadAsset' + ? internals.uploadAsset(first.port, assetUpload()) + : internals.downloadAsset(first.port, assetDownload()) + broker.handlePort(replacement.port) + replacement.message(pageMessage('mcp.enable')) + first.postMessage.mockImplementation(() => { + throw new Error('Port closed') + }) + finish(new Response(null, { status: 503 })) + await pending + expect(client.stop).not.toHaveBeenCalled() + replacement.postMessage.mockClear() + routeToolCall(broker) + expect(replacement.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'mcp.toolCall', callId: 'call-1' }) + ) + } + ) + it('returns a coded error when no active session can receive a tool call', () => { const hubClient = createHubClient() const broker = new McpServiceWorkerBroker(hubClient) @@ -1748,6 +2070,71 @@ describe('mcp/broker/service-worker', () => { }) }) + it.each(['registered', 'unregistered', 'revoked'] as const)( + 'rechecks external permission grants and wakes initially denied pages (%s)', + async (state) => { + let added!: (value: { origins: string[] }) => void + let removed!: (value: { origins: string[] }) => void + let resolveGrant!: (granted: boolean) => void + const contains = vi.fn().mockResolvedValue(false) + const query = vi.fn().mockResolvedValue([{ id: 1 }, { id: 2 }, {}]) + const sendMessage = vi + .fn() + .mockRejectedValueOnce(new Error('Tab closed')) + .mockResolvedValue(undefined) + vi.stubGlobal('browser', { + ...browser, + permissions: { + contains, + request: vi.fn(), + onAdded: { + addListener: (listener: typeof added) => { + added = listener + } + }, + onRemoved: { + addListener: (listener: typeof removed) => { + removed = listener + } + } + }, + tabs: { query, sendMessage }, + runtime: { onConnect: { addListener: vi.fn() }, onMessage: { addListener: vi.fn() } } + }) + const client = createHubClient() + const broker = new McpServiceWorkerBroker(client) + broker.start() + if (state !== 'unregistered') { + const page = createPort('https://www.figma.com/design/abc/File') + broker.handlePort(page.port) + page.message(pageMessage('mcp.enable')) + await vi.waitFor(() => expect(client.stop).toHaveBeenCalled()) + } + contains.mockClear() + added({ origins: ['https://example.com/*'] }) + expect(contains).not.toHaveBeenCalled() + contains.mockImplementation( + () => + new Promise((resolve) => { + resolveGrant = resolve + }) + ) + added({ origins: [MCP_LOCAL_HOST_ORIGIN] }) + expect(contains).toHaveBeenCalledOnce() + if (state === 'revoked') removed({ origins: [MCP_LOCAL_HOST_ORIGIN] }) + resolveGrant(true) + await flushMicrotasks() + await flushMicrotasks() + expect(browser.permissions.request).not.toHaveBeenCalled() + expect(client.start).toHaveBeenCalledTimes(state === 'registered' ? 1 : 0) + expect(sendMessage).toHaveBeenCalledTimes(state === 'revoked' ? 0 : 2) + if (state !== 'revoked') { + expect(query).toHaveBeenCalledWith({ url: 'https://www.figma.com/*' }) + expect(sendMessage).toHaveBeenCalledWith(2, MCP_PERMISSION_CHANGE_MESSAGE) + } + } + ) + it('checks local host permissions without prompting', async () => { const contains = vi .fn() diff --git a/packages/extension/tests/mcp/canvas-overlay.browser.test.ts b/packages/extension/tests/mcp/canvas-overlay.browser.test.ts index c9c9d571..8ea49ff8 100644 --- a/packages/extension/tests/mcp/canvas-overlay.browser.test.ts +++ b/packages/extension/tests/mcp/canvas-overlay.browser.test.ts @@ -7,6 +7,7 @@ let host: HTMLElement | undefined afterEach(() => { stop?.() host?.remove() + vi.useRealTimers() vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -79,3 +80,51 @@ it('reuses tooltip membership while still measuring visibility, geometry and DOM expect(scans()).toBe(beforeResubscribe + 1) expect(receive.mock.lastCall?.[0].tooltips).toEqual([]) }) + +it('wakes idle overlays before descendant activity stops propagating and removes capture listeners', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const now = vi.spyOn(performance, 'now').mockReturnValue(0) + const frames = new Map() + let frameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frames.set(++frameId, callback) + return frameId + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + frames.delete(id) + }) + vi.stubGlobal('figma', { + fileKey: 'file-a', + currentPage: { id: 'page-a', selection: [] }, + viewport: { bounds: { x: 0, y: 0, width: 600, height: 500 }, zoom: 1 } + }) + host = document.createElement('div') + host.id = 'fullscreen-root' + host.innerHTML = + '
' + const target = document.createElement('button') + host.append(target) + document.body.append(host) + const stopPropagation = vi.fn((event: Event) => event.stopPropagation()) + target.addEventListener('pointermove', stopPropagation) + const observer = vi.fn() + stop = observeCanvasOverlay(observer) + expect(observer).toHaveBeenCalledOnce() + now.mockReturnValue(1000) + const [id, callback] = [...frames][0]! + frames.delete(id) + callback(1000) + expect(frames.size).toBe(0) + expect(vi.getTimerCount()).toBe(1) + target.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + expect(stopPropagation).toHaveBeenCalledOnce() + expect(frames.size).toBe(1) + expect(vi.getTimerCount()).toBe(0) + stop() + expect(frames.size).toBe(0) + expect(vi.getTimerCount()).toBe(0) + now.mockClear() + target.dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + expect(frames.size).toBe(0) + expect(now).not.toHaveBeenCalled() +}) diff --git a/packages/extension/tests/mcp/errors.test.ts b/packages/extension/tests/mcp/errors.test.ts index feb279df..a8ad35a5 100644 --- a/packages/extension/tests/mcp/errors.test.ts +++ b/packages/extension/tests/mcp/errors.test.ts @@ -1,9 +1,21 @@ import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' import { describe, expect, it } from 'vitest' -import { coerceToolErrorPayload, createCodedError } from '@/mcp/errors' +import { canvasAbortedError, coerceToolErrorPayload, createCodedError } from '@/mcp/errors' describe('mcp/errors', () => { + it('marks proven pre-execution rejection without losing its original diagnostic', () => { + const cause = createCodedError(TEMPAD_MCP_ERROR_CODES.DESIGN_TARGET_CHANGED, 'Wrong file') + const error = canvasAbortedError(cause) + expect(error).toMatchObject({ code: 'CANVAS_APPLY_ABORTED', cause }) + expect(coerceToolErrorPayload(error)).toEqual({ + code: 'CANVAS_APPLY_ABORTED', + message: 'Wrong file [DESIGN_TARGET_CHANGED]' + }) + expect(canvasAbortedError(error).message).toBe(error.message) + expect(canvasAbortedError(new Error('Invalid input')).message).toBe('Invalid input') + }) + it('creates coded errors with message and code', () => { const error = createCodedError(TEMPAD_MCP_ERROR_CODES.NO_ACTIVE_EXTENSION, 'bad request') diff --git a/packages/extension/tests/mcp/runtime.test.ts b/packages/extension/tests/mcp/runtime.test.ts index d1134dec..758ac542 100644 --- a/packages/extension/tests/mcp/runtime.test.ts +++ b/packages/extension/tests/mcp/runtime.test.ts @@ -66,6 +66,7 @@ afterEach(() => { mocks.selection.value = [] vi.clearAllMocks() vi.unstubAllGlobals() + vi.unstubAllEnvs() }) describe('mcp/runtime', () => { @@ -107,6 +108,21 @@ describe('mcp/runtime', () => { expect(tools).toEqual(runtime.WINDOW_TEMPAD_TOOL_HANDLERS) }) + it.each([undefined, { existing: true }])( + 'does not expose or alter window debug tools in production', + async (existing) => { + vi.stubEnv('DEV', false) + const page = existing ? { tempadTools: existing } : {} + vi.stubGlobal('window', page) + setFigmaGetNodeById(null) + + await importRuntime() + + expect(page).toEqual(existing ? { tempadTools: existing } : {}) + expect((page as { tempadTools?: unknown }).tempadTools).toBe(existing) + } + ) + it('routes get_code to tool implementation with resolved node and options', async () => { const node = createSceneNode('node-1') setFigmaGetNodeById(node) @@ -139,6 +155,22 @@ describe('mcp/runtime', () => { expect(result).toEqual({ blocks: [] }) }) + it('distinguishes rejected canvas arguments from a handler failure', async () => { + const runtime = await importRuntime() + await expect(runtime.runMcpTool('apply_canvas', { mode: 'invalid' })).rejects.toMatchObject({ + code: 'CANVAS_APPLY_ABORTED' + }) + expect(mocks.runApplyCanvas).not.toHaveBeenCalled() + + const error = Object.assign(new Error('A native operation failed'), { + code: 'INVALID_CANVAS_SPEC' + }) + mocks.runApplyCanvas.mockRejectedValueOnce(error) + await expect( + runtime.runMcpTool('apply_canvas', { mode: 'create', markup: '
' }) + ).rejects.toBe(error) + }) + it.each(['missing', 'toString'])( 'rejects unknown bridge tool name "%s" at the runtime boundary', async (name) => { diff --git a/packages/extension/tests/mcp/tools/canvas-assets.test.ts b/packages/extension/tests/mcp/tools/canvas-assets.test.ts index c72aa106..cd0713aa 100644 --- a/packages/extension/tests/mcp/tools/canvas-assets.test.ts +++ b/packages/extension/tests/mcp/tools/canvas-assets.test.ts @@ -14,6 +14,59 @@ function colors(color?: string): Map> { } describe('mcp/tools/canvas SVG assets', () => { + it.each([ + 'ignored()', + '
', + '' + ])('validates expanded element names before native import: %s', async (svg) => { + await expect(resolveCanvasAssets(svgAssets(svg), colors())).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.SVG_INVALID + }) + }) + + it('checks cycles through namespaced SVG use elements', async () => { + await expect( + resolveCanvasAssets( + svgAssets( + '' + ), + colors() + ) + ).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.SVG_TOO_COMPLEX }) + }) + + it.each([ + String.raw`u\72l(https://example.com/paint.svg#p)`, + 'url(https://example.com/paint.svg#p)' + ])('rejects external CSS URLs after decoding syntax: %s', async (fill) => { + await expect( + resolveCanvasAssets( + svgAssets(``), + colors() + ) + ).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.SVG_EXTERNAL_REFERENCE }) + }) + + it.each(['currentColor', String.raw`curr\65 ntColor`])( + 'preserves local references and resolves %s once', + async (fill) => { + const assets = await resolveCanvasAssets( + svgAssets( + `` + ), + colors('#336699') + ) + expect(resolvedSvgAsset(assets, 'icon', '#336699')?.svg).toContain('href="#shape"') + expect(resolvedSvgAsset(assets, 'icon', '#336699')?.svg).toContain('fill="#336699"') + } + ) + + it('keeps an escaped local CSS URL available to the SVG importer', async () => { + const source = String.raw`` + const assets = await resolveCanvasAssets(svgAssets(source), colors()) + expect(resolvedSvgAsset(assets, 'icon', undefined)?.svg).toContain(String.raw`fill="u\72l(#g)"`) + }) + it('bounds repeated and cyclic use expansion before native import', async () => { const repeated = `${''.repeat(20)}${''.repeat(20)}` for (const svg of [repeated, '']) { diff --git a/packages/extension/tests/mcp/tools/canvas-bound-native-values.test.ts b/packages/extension/tests/mcp/tools/canvas-bound-native-values.test.ts new file mode 100644 index 00000000..b83d42f5 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-bound-native-values.test.ts @@ -0,0 +1,406 @@ +import type { CanvasFigmaProperties, CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { applyResolvedCanvas as applyCanvas } from '@/mcp/tools/canvas' +import { + effectsEqual, + layoutGridsEqual, + paintStacksEqual +} from '@/mcp/tools/canvas/reconcile/native-values' + +import { createFixture } from '../../fakes/figma' + +const color: RGBA = { r: 1, g: 0, b: 0, a: 1 } +const resolvedColor: RGBA = { r: 0, g: 0, b: 1, a: 0.5 } +const alias: VariableAlias = { type: 'VARIABLE_ALIAS', id: 'variable:color' } +const otherAlias: VariableAlias = { type: 'VARIABLE_ALIAS', id: 'variable:other' } + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function gradient(type: GradientPaint['type']): GradientPaint { + return { + type, + gradientTransform: [ + [1, 0, 0], + [0, 1, 0] + ], + opacity: 0.75, + visible: true, + blendMode: 'NORMAL', + gradientStops: [ + { position: 0.25, color, boundVariables: { color: alias } }, + { position: 0.75, color } + ] + } +} + +function shadow( + type: DropShadowEffect['type'] | InnerShadowEffect['type'] +): DropShadowEffect | InnerShadowEffect { + return { + type, + color, + radius: 3, + spread: 2, + offset: { x: 1, y: 2 }, + visible: true, + blendMode: 'NORMAL', + boundVariables: { color: alias, radius: alias, spread: alias, offsetX: alias } + } +} + +describe('bound native values', () => { + it.each(['GRADIENT_LINEAR', 'GRADIENT_RADIAL', 'GRADIENT_ANGULAR', 'GRADIENT_DIAMOND'] as const)( + 'accepts resolved %s stop colors while retaining exact bindings and paint geometry', + (type) => { + const expected = gradient(type) + const current: GradientPaint = { + ...expected, + gradientStops: [ + { ...expected.gradientStops[0]!, color: resolvedColor }, + expected.gradientStops[1]! + ] + } + expect(paintStacksEqual([current], [expected])).toBe(true) + const changes: GradientPaint[] = [ + { ...current, opacity: 0.5 }, + { ...current, visible: false }, + { ...current, blendMode: 'MULTIPLY' }, + { + ...current, + gradientTransform: [ + [1, 0, 0.2], + [0, 1, 0] + ] + }, + { + ...current, + gradientStops: [{ ...current.gradientStops[0]!, position: 0 }, current.gradientStops[1]!] + }, + { + ...current, + gradientStops: [ + current.gradientStops[0]!, + { ...current.gradientStops[1]!, color: resolvedColor } + ] + }, + { ...current, gradientStops: current.gradientStops.slice(0, 1) } + ] + for (const boundVariables of [undefined, { color: otherAlias }]) { + changes.push({ + ...current, + gradientStops: [ + { ...current.gradientStops[0]!, boundVariables }, + current.gradientStops[1]! + ] + }) + } + changes.push({ + ...current, + gradientStops: [ + current.gradientStops[0]!, + { ...current.gradientStops[1]!, boundVariables: { color: alias } } + ] + }) + for (const changed of changes) expect(paintStacksEqual([changed], [expected])).toBe(false) + } + ) + + it.each(['DROP_SHADOW', 'INNER_SHADOW'] as const)( + 'accepts resolved %s fields without hiding unbound offsets or paint state', + (type) => { + const expected = shadow(type) + const current = { + ...expected, + color: resolvedColor, + radius: 24, + spread: 12, + offset: { x: 8, y: 2 } + } + expect(effectsEqual([current], [expected])).toBe(true) + for (const changed of [ + { ...current, offset: { x: 8, y: 3 } }, + { ...current, visible: false }, + { ...current, blendMode: 'MULTIPLY' as const }, + { ...current, boundVariables: undefined }, + { ...current, boundVariables: { ...current.boundVariables, radius: otherAlias } }, + { ...current, boundVariables: { ...current.boundVariables, offsetY: alias } } + ]) + expect(effectsEqual([changed], [expected])).toBe(false) + + const vertical = { ...expected, boundVariables: { offsetY: alias } } + expect(effectsEqual([{ ...vertical, offset: { x: 1, y: 8 } }], [vertical])).toBe(true) + for (const changed of [ + { ...vertical, offset: { x: 2, y: 8 } }, + { ...vertical, color: resolvedColor }, + { ...vertical, radius: 24 }, + { ...vertical, spread: 12 } + ]) + expect(effectsEqual([changed], [vertical])).toBe(false) + } + ) + + it.each(['LAYER_BLUR', 'BACKGROUND_BLUR'] as const)( + 'accepts a resolved %s radius without masking other progressive blur fields', + (type) => { + const expected: BlurEffect = { + type, + blurType: 'PROGRESSIVE', + radius: 3, + startRadius: 1, + startOffset: { x: 0, y: 0 }, + endOffset: { x: 1, y: 1 }, + visible: true, + boundVariables: { radius: alias } + } + const current = { ...expected, radius: 24 } + expect(effectsEqual([current], [expected])).toBe(true) + for (const changed of [ + { ...current, startRadius: 2 }, + { ...current, endOffset: { x: 0, y: 1 } }, + { ...current, visible: false }, + { ...current, boundVariables: undefined }, + { ...current, boundVariables: { radius: otherAlias } } + ]) + expect(effectsEqual([changed], [expected])).toBe(false) + } + ) + + it.each(['GRID', 'ROWS', 'COLUMNS'] as const)( + 'accepts resolved %s grid values while retaining geometry and aliases', + (pattern) => { + const expected: LayoutGrid = + pattern === 'GRID' + ? { + pattern, + sectionSize: 8, + visible: true, + color, + boundVariables: { sectionSize: alias } + } + : { + pattern, + alignment: 'MIN', + sectionSize: 8, + count: 3, + gutterSize: 4, + offset: 2, + visible: true, + color, + boundVariables: { sectionSize: alias, count: alias, gutterSize: alias, offset: alias } + } + const current: LayoutGrid = + expected.pattern === 'GRID' + ? { ...expected, sectionSize: 24 } + : { ...expected, sectionSize: 24, count: 6, gutterSize: 12, offset: 8 } + expect(layoutGridsEqual([current], [expected])).toBe(true) + const changes: LayoutGrid[] = [ + current.pattern === 'GRID' + ? { ...current, pattern: 'ROWS', alignment: 'MIN', count: 3, gutterSize: 4 } + : { ...current, pattern: 'GRID', sectionSize: 24 }, + { ...current, visible: false }, + { ...current, color: resolvedColor }, + { ...current, boundVariables: undefined }, + { ...current, boundVariables: { ...current.boundVariables, sectionSize: otherAlias } } + ] + if (current.pattern !== 'GRID' && expected.pattern !== 'GRID') { + changes.push({ ...current, alignment: 'MAX' }) + for (const field of ['sectionSize', 'count', 'gutterSize', 'offset'] as const) { + const bindings = { ...expected.boundVariables } + delete bindings[field] + expect( + layoutGridsEqual( + [{ ...current, boundVariables: bindings }], + [{ ...expected, boundVariables: bindings }] + ) + ).toBe(false) + } + } + for (const changed of changes) expect(layoutGridsEqual([changed], [expected])).toBe(false) + } + ) +}) + +type NativeProperty = 'fills' | 'strokes' | 'effects' | 'layoutGrids' + +function nativeProperties(property: NativeProperty): CanvasFigmaProperties { + if (property === 'fills' || property === 'strokes') + return { + [property]: [ + { + type: 'GRADIENT_RADIAL', + gradientTransform: [ + [1, 0, 0], + [0, 1, 0] + ], + gradientStops: [ + { position: 0, color, variables: { color: { variableKey: 'color' } } }, + { position: 1, color } + ] + } + ] + } + if (property === 'effects') + return { + effects: [ + { + type: 'DROP_SHADOW', + color, + radius: 3, + offset: { x: 1, y: 2 }, + variables: { color: { variableKey: 'color' }, radius: { variableKey: 'size' } } + } + ] + } + return { + layoutGrids: [ + { pattern: 'GRID', sectionSize: 8, variables: { sectionSize: { variableKey: 'size' } } } + ] + } +} + +function resourceSpec(): NonNullable { + return { + tokens: { + name: 'Tokens', + modes: { base: { name: 'Base' } }, + variables: { + color: { name: 'Color', type: 'COLOR', values: { base: resolvedColor } }, + size: { name: 'Size', type: 'FLOAT', values: { base: 24 } } + } + } + } +} + +// This accessor models the captured native readback, without expanding the shared fake. +function normalizeBoundStack( + property: NativeProperty, + values: unknown, + loseBinding = false +): unknown { + if (property === 'fills' || property === 'strokes') + return (values as Paint[]).map((paint) => + 'gradientStops' in paint + ? { + ...paint, + gradientStops: paint.gradientStops.map((stop) => + stop.boundVariables?.color + ? { + ...stop, + color: resolvedColor, + boundVariables: loseBinding ? undefined : stop.boundVariables + } + : stop + ) + } + : paint + ) + if (property === 'effects') + return (values as Effect[]).map((effect) => + effect.type === 'DROP_SHADOW' + ? { + ...effect, + color: resolvedColor, + radius: 24, + boundVariables: loseBinding ? undefined : effect.boundVariables + } + : effect + ) + return (values as LayoutGrid[]).map((grid) => ({ + ...grid, + sectionSize: 24, + boundVariables: loseBinding ? undefined : grid.boundVariables + })) +} + +describe('native bound-value reconciliation', () => { + it.each(['fills', 'strokes', 'effects', 'layoutGrids'] as const)( + 'converges resolved direct %s but rejects a lost binding', + async (property) => { + const fixture = createFixture() + let loseBinding = false + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') as unknown as FrameNode + let value: unknown = node[property] + Object.defineProperty(node, property, { + configurable: true, + get: () => value, + set: (next: unknown) => { + value = normalizeBoundStack(property, next, loseBinding) + } + }) + return node + }) + const spec: CanvasResolvedApplyParameters = { + mode: 'create', + markup: '
', + variableCollections: resourceSpec(), + bindings: { root: { figma: nativeProperties(property) } } + } + const created = await applyCanvas(spec) + const root = fixture.getNode(created.rootNodeId!) as unknown as FrameNode + const update = { ...spec, mode: 'update' as const, targetNodeId: created.rootNodeId! } + await expect(applyCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + await expect(applyCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + + // Remove native state so the next request must exercise the setter again. + loseBinding = true + root[property] = [] + await expect(applyCanvas(update)).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: expect.stringContaining('Verification failed') + }) + } + ) + + it.each(['PAINT', 'EFFECT', 'GRID'] as const)( + 'converges resolved values in authored %s styles', + async (type) => { + createFixture() + const property: NativeProperty = + type === 'PAINT' ? 'fills' : type === 'EFFECT' ? 'effects' : 'layoutGrids' + const field = type === 'PAINT' ? 'paints' : property + function normalizeStyle(factory: () => T): void { + const original = vi.mocked(factory).getMockImplementation()! + vi.mocked(factory).mockImplementation(() => { + const style = original() + let value: unknown = [] + Object.defineProperty(style, field, { + configurable: true, + get: () => value, + set: (next: unknown) => { + value = normalizeBoundStack(property, next) + } + }) + return style + }) + } + if (type === 'PAINT') normalizeStyle(figma.createPaintStyle) + else if (type === 'EFFECT') normalizeStyle(figma.createEffectStyle) + else normalizeStyle(figma.createGridStyle) + const native = nativeProperties(property) + const spec: CanvasResolvedApplyParameters = { + mode: 'create', + markup: '
', + variableCollections: resourceSpec(), + styles: { + authored: + type === 'PAINT' + ? { type, name: 'Bound paint', paints: native.fills } + : type === 'EFFECT' + ? { type, name: 'Bound effect', effects: native.effects } + : { type, name: 'Bound grid', layoutGrids: native.layoutGrids } + } + } + const created = await applyCanvas(spec) + const update = { ...spec, mode: 'update' as const, targetNodeId: created.rootNodeId! } + await expect(applyCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + await expect(applyCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + } + ) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-dictionaries.test.ts b/packages/extension/tests/mcp/tools/canvas-dictionaries.test.ts new file mode 100644 index 00000000..7b3c9ece --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-dictionaries.test.ts @@ -0,0 +1,205 @@ +import type { ApplyCanvasParametersInput, CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { ApplyCanvasResultSchema } from '@tempad-dev/shared' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { applyResolvedCanvas, handleApplyCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('canvas dictionary ownership', () => { + it.each(['__proto__', 'constructor', 'toString'])( + 'creates and reuses a component property with stable key %s', + async (key) => { + const fixture = createFixture() + const input: CanvasResolvedApplyParameters = { + mode: 'create', + markup: + '
', + bindings: { + component: { + figma: { + component: { + type: 'COMPONENT', + properties: { [key]: { type: 'BOOLEAN', name: 'Enabled', defaultValue: true } } + } + } + }, + child: { figma: { componentPropertyReferences: { visible: key } } } + } + } + const result = await applyResolvedCanvas(input) + const component = fixture.getNode(result.rootNodeId!) as unknown as ComponentNode + const child = fixture.getNode(result.nodeIdsByKey.child!) + const name = Object.keys(component.componentPropertyDefinitions)[0]! + expect(component.componentPropertyDefinitions[name]).toEqual({ + type: 'BOOLEAN', + defaultValue: true + }) + expect(child.componentPropertyReferences).toEqual({ visible: name }) + expect( + JSON.parse(component.getSharedPluginData('tempad_dev', 'component-property-keys')) + ).toEqual({ + [key]: name + }) + + const update: CanvasResolvedApplyParameters = { + ...input, + mode: 'update', + targetNodeId: component.id + } + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + update.bindings!.component!.figma!.component!.properties = { + other: { type: 'BOOLEAN', name: 'Other', defaultValue: false } + } + await applyResolvedCanvas(update) + expect(child.componentPropertyReferences).toEqual({ visible: name }) + expect(component.componentPropertyDefinitions[name]).toMatchObject({ + type: 'BOOLEAN', + defaultValue: true + }) + } + ) + + it('adds an inherited-name property to an existing component', async () => { + const fixture = createFixture() + const created = await applyResolvedCanvas({ + mode: 'create', + markup: '
', + bindings: { component: { figma: { component: { type: 'COMPONENT' } } } } + }) + const component = fixture.getNode(created.rootNodeId!) as unknown as ComponentNode + const key = '__proto__' + const update: CanvasResolvedApplyParameters = { + mode: 'update', + targetNodeId: component.id, + bindings: { + component: { + figma: { + component: { + type: 'COMPONENT', + properties: { [key]: { type: 'BOOLEAN', name: 'Enabled', defaultValue: true } } + } + } + } + } + } + await applyResolvedCanvas(update) + expect(Object.values(component.componentPropertyDefinitions)).toEqual([ + { type: 'BOOLEAN', defaultValue: true } + ]) + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it.each(['fill', 'effect'] as const)( + 'preserves a shader %s property without a default named __proto__', + async (type) => { + const fixture = createFixture() + const key = '__proto__' + fixture.importShaderById.mockResolvedValue({ + id: 'shader:prototype', + type, + imported: true, + propertyDefinitions: { [key]: { name: 'Strength', type: 'NUMBER' } } + }) + const shader = { + type: 'SHADER' as const, + id: 'shader:prototype', + properties: { [key]: 0.75 } + } + const input: CanvasResolvedApplyParameters = { + mode: 'create', + markup: '
', + bindings: { + root: { figma: type === 'fill' ? { fills: [shader] } : { effects: [shader] } } + } + } + const result = await applyResolvedCanvas(input) + const root = fixture.getNode(result.rootNodeId!) + const value = (type === 'fill' ? root.fills : root.effects) as Array< + ShaderPaint | ShaderEffect + > + expect(value[0]?.properties).toEqual({ [key]: 0.75 }) + expect(Object.hasOwn(value[0]!.properties!, key)).toBe(true) + await expect( + applyResolvedCanvas({ ...input, mode: 'update', targetNodeId: root.id }) + ).resolves.toMatchObject({ mutationCount: 0 }) + } + ) + + it('preserves own prototype keys through public input, resources, reconciliation and wire results', async () => { + const fixture = createFixture() + const key = '__proto__' + const input: ApplyCanvasParametersInput = { + mode: 'create', + markup: + '
', + theme: { variables: { '--surface': { variableKey: key } } }, + native: { + [key]: { + figma: { name: 'Prototype root' }, + styles: { stroke: { styleKey: key } }, + variableModes: { [key]: key } + }, + icon: { figma: { svg: { assetKey: key } } } + }, + variableCollections: { + [key]: { + name: 'Prototype collection', + modes: { [key]: { name: 'Prototype mode' }, alternate: { name: 'Alternate mode' } }, + variables: { + [key]: { + name: 'Prototype color', + type: 'COLOR', + values: { [key]: { r: 1, g: 0, b: 0 }, alternate: { r: 0, g: 1, b: 0 } } + } + } + } + }, + styles: { + [key]: { + type: 'PAINT', + name: 'Prototype style', + paints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1 } }] + } + }, + assets: { + [key]: { type: 'SVG', svg: '' } + } + } + const result = await handleApplyCanvas(JSON.parse(JSON.stringify(input))) + const root = fixture.getNode(result.nodeIdsByKey[key]!) + const collection = [...fixture.variableCollections.values()].find( + (value) => value.name === 'Prototype collection' + )! + const variable = [...fixture.variables.values()].find( + (value) => value.name === 'Prototype color' + )! + const style = [...fixture.styles.values()].find((value) => value.name === 'Prototype style')! + expect(root.name).toBe('Prototype root') + expect((root.fills as SolidPaint[])[0]?.boundVariables?.color).toMatchObject({ + id: variable.id + }) + expect((root.fills as SolidPaint[])[0]?.color).toEqual({ r: 1, g: 0, b: 0 }) + expect(root.strokeStyleId).toBe(style.id) + expect(collection.modes.map((mode) => mode.name)).toEqual(['Prototype mode', 'Alternate mode']) + expect(variable.valuesByMode[collection.defaultModeId]).toEqual({ r: 1, g: 0, b: 0 }) + expect(root.explicitVariableModes[collection.id]).toBe(collection.defaultModeId) + expect(JSON.parse(collection.getSharedPluginData('tempad_dev', 'variable-mode-keys'))).toEqual({ + [key]: collection.defaultModeId, + alternate: collection.modes[1]!.modeId + }) + expect(figma.createNodeFromSvg).toHaveBeenCalledOnce() + const wireResult = ApplyCanvasResultSchema.parse(JSON.parse(JSON.stringify(result))) + expect(Object.hasOwn(wireResult.nodeIdsByKey, key)).toBe(true) + expect(wireResult.nodeIdsByKey[key]).toBe(root.id) + await expect( + handleApplyCanvas({ ...input, mode: 'update', targetNodeId: root.id }) + ).resolves.toMatchObject({ mutationCount: 0 }) + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-font-loading.test.ts b/packages/extension/tests/mcp/tools/canvas-font-loading.test.ts index f23aeba1..099952a4 100644 --- a/packages/extension/tests/mcp/tools/canvas-font-loading.test.ts +++ b/packages/extension/tests/mcp/tools/canvas-font-loading.test.ts @@ -52,6 +52,295 @@ afterEach(() => { }) describe('mcp/tools/canvas font loading', () => { + it('shares collection reads across text consumers and the two mode preloading passes', async () => { + const fixture = createFixture() + const changed = figma.variables.createVariableCollection('Changed') + const alternate = changed.addMode('Alternate') + const unchanged = figma.variables.createVariableCollection('Unchanged') + const size = figma.variables.createVariable('Size', unchanged, 'FLOAT') + size.setValueForMode(unchanged.defaultModeId, 16) + for (let index = 0; index < 50; index++) { + const label = figma.createText() + label.characters = 'Unaffected' + label.setBoundVariable('fontSize', size) + } + const readCollection = vi.mocked(figma.variables.getVariableCollectionByIdAsync) + for (const mode of [alternate, changed.defaultModeId]) { + readCollection.mockClear() + await applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [changed.id]: mode } } + }) + expect(fixture.page.explicitVariableModes[changed.id]).toBe(mode) + expect(readCollection.mock.calls.filter(([id]) => id === changed.id)).toHaveLength(1) + expect(readCollection.mock.calls.filter(([id]) => id === unchanged.id)).toHaveLength(1) + } + expect(fixture.loadFontAsync).not.toHaveBeenCalled() + }) + + it('bounds missing collection reads to one per font-preloading scan', async () => { + const fixture = createFixture() + const changed = figma.variables.createVariableCollection('Changed') + const alternate = changed.addMode('Alternate') + const unchanged = figma.variables.createVariableCollection('Unchanged') + const size = figma.variables.createVariable('Size', unchanged, 'FLOAT') + size.setValueForMode(unchanged.defaultModeId, 16) + for (let index = 0; index < 50; index++) { + const label = figma.createText() + label.characters = 'Unaffected' + label.setBoundVariable('fontSize', size) + Object.assign(label.explicitVariableModes, { 'missing:collection': 'missing:mode' }) + } + const readCollection = vi.mocked(figma.variables.getVariableCollectionByIdAsync) + await applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [changed.id]: alternate } } + }) + expect(fixture.page.explicitVariableModes[changed.id]).toBe(alternate) + // Recheck at the mutation boundary: an import may have made a missing collection available. + expect(readCollection.mock.calls.filter(([id]) => id === 'missing:collection')).toHaveLength(2) + expect(fixture.loadFontAsync).not.toHaveBeenCalled() + }) + + it.each(['variable', 'alias', 'collection'] as const)( + 'ignores an unrelated unavailable metric %s during a page mode change', + async (missing) => { + const fixture = createFixture() + const changed = figma.variables.createVariableCollection('Changed') + const alternate = changed.addMode('Alternate') + const metrics = figma.variables.createVariableCollection('Metrics') + const size = figma.variables.createVariable('Size', metrics, 'FLOAT') + size.setValueForMode(metrics.defaultModeId, 16) + const label = figma.createText() + label.characters = 'Unresolved dependency' + label.setBoundVariable('fontSize', size) + if (missing === 'alias') { + size.setValueForMode(metrics.defaultModeId, { type: 'VARIABLE_ALIAS', id: 'missing' }) + } else if (missing === 'variable') fixture.variables.delete(size.id) + else fixture.variableCollections.delete(metrics.id) + + await applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [changed.id]: alternate } } + }) + expect(fixture.page.explicitVariableModes[changed.id]).toBe(alternate) + expect(fixture.loadFontAsync).not.toHaveBeenCalled() + } + ) + + it.each(['alias', 'collection', 'lookup'] as const)( + 'reports an affected unavailable metric %s as an existing canvas failure before writing', + async (missing) => { + const fixture = createFixture() + const changed = figma.variables.createVariableCollection('Changed') + const alternate = changed.addMode('Alternate') + const metrics = figma.variables.createVariableCollection('Metrics') + const alias = figma.variables.createVariable('Alias', metrics, 'FLOAT') + alias.setValueForMode(metrics.defaultModeId, 20) + const size = figma.variables.createVariable('Size', changed, 'FLOAT') + size.setValueForMode(changed.defaultModeId, 16) + size.setValueForMode(alternate, { type: 'VARIABLE_ALIAS', id: alias.id }) + const label = figma.createText() + label.characters = 'Affected dependency' + label.setBoundVariable('fontSize', size) + if (missing === 'alias') fixture.variables.delete(alias.id) + else if (missing === 'collection') fixture.variableCollections.delete(metrics.id) + else + vi.mocked(figma.variables.getVariableByIdAsync).mockRejectedValue(new Error('Unavailable')) + + await expect( + applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [changed.id]: alternate } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: expect.stringContaining( + `Cannot resolve text variable dependency "fontSize" on "${label.id}"` + ) + }) + expect(fixture.page.explicitVariableModes[changed.id]).toBeUndefined() + expect(fixture.commitUndo).not.toHaveBeenCalled() + } + ) + + it.each(['alias', 'collection'] as const)( + 'clears Page Auto with an unavailable %s in a possible mode', + async (missing) => { + const fixture = createFixture() + const collection = figma.variables.createVariableCollection('Size') + const alternate = collection.addMode('Alternate') + const unavailable = collection.addMode('Unavailable') + const size = figma.variables.createVariable('Size', collection, 'FLOAT') + size.setValueForMode(collection.defaultModeId, 16) + size.setValueForMode(alternate, 20) + const dependencies = figma.variables.createVariableCollection('Dependencies') + const alias = figma.variables.createVariable('Alias', dependencies, 'FLOAT') + alias.setValueForMode(dependencies.defaultModeId, 24) + size.setValueForMode(unavailable, { type: 'VARIABLE_ALIAS', id: alias.id }) + if (missing === 'alias') fixture.variables.delete(alias.id) + else fixture.variableCollections.delete(dependencies.id) + const label = figma.createText() + label.characters = 'Auto' + label.setBoundVariable('fontSize', size) + fixture.page.setExplicitVariableModeForCollection(collection, alternate) + const clear = fixture.page.clearExplicitVariableModeForCollection.bind(fixture.page) + vi.spyOn(fixture.page, 'clearExplicitVariableModeForCollection').mockImplementation( + (owner) => { + expect(fixture.loadFontAsync).toHaveBeenCalledWith(label.fontName) + clear(owner) + } + ) + + const result = await applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [collection.id]: null } } + }) + + expect(result.verification.status).toBe('passed') + expect(fixture.page.explicitVariableModes[collection.id]).toBeUndefined() + expect(fixture.triggerUndo).not.toHaveBeenCalled() + // Choosing that exact unavailable candidate still fails before a native write. + fixture.commitUndo.mockClear() + await expect( + applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [collection.id]: unavailable } } + }) + ).rejects.toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED }) + expect(fixture.page.explicitVariableModes[collection.id]).toBeUndefined() + expect(fixture.commitUndo).not.toHaveBeenCalled() + } + ) + + it.each(['alias', 'font'] as const)( + 'keeps simultaneous explicit base and extended modes strict for an unavailable %s', + async (missing) => { + const fixture = createFixture() + const collection = figma.variables.createVariableCollection('Typography') + const alternate = collection.addMode('Alternate') + const family = figma.variables.createVariable('Family', collection, 'STRING') + family.setValueForMode(collection.defaultModeId, 'Inter') + family.setValueForMode(alternate, 'Inter') + const extended = collection.extend('Extended typography') + const extendedAlternate = extended.modes.find( + (mode) => mode.parentModeId === alternate + )!.modeId + extended.variableOverrides[family.id] = { + [extendedAlternate]: + missing === 'alias' + ? { type: 'VARIABLE_ALIAS', id: 'missing:font-family' } + : 'Unavailable Font' + } + const label = figma.createText() + label.characters = 'Explicit dependency' + label.setBoundVariable('fontFamily', family) + fixture.page.setExplicitVariableModeForCollection(collection, alternate) + + await expect( + applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [extended.id]: extendedAlternate } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + ...(missing === 'font' + ? { cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC } } + : {}), + message: expect.stringContaining( + missing === 'alias' ? 'missing:font-family' : 'Unavailable Font' + ) + }) + expect(fixture.page.explicitVariableModes).toEqual({ [collection.id]: alternate }) + expect(fixture.commitUndo).not.toHaveBeenCalled() + } + ) + + it.each([false, true])( + 'scans retained missing text styles without dropping available bindings: bound=%s', + async (bound) => { + const fixture = createFixture() + const collection = figma.variables.createVariableCollection('Size') + const alternate = collection.addMode('Alternate') + const size = figma.variables.createVariable('Size', collection, 'FLOAT') + size.setValueForMode(collection.defaultModeId, 16) + size.setValueForMode(alternate, 20) + const style = figma.createTextStyle() + for (let index = 0; index < 10; index++) { + const label = figma.createText() + label.characters = 'Missing style' + await label.setTextStyleIdAsync(style.id) + if (bound) label.setBoundVariable('fontSize', size) + } + fixture.styles.delete(style.id) + const readStyle = vi.mocked(figma.getStyleByIdAsync) + readStyle.mockClear() + + const result = await applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [collection.id]: alternate } } + }) + + expect(result.verification.status).toBe('passed') + expect(fixture.page.explicitVariableModes[collection.id]).toBe(alternate) + // Misses are shared within a scan, then checked again before the native write. + expect(readStyle.mock.calls.filter(([id]) => id === style.id)).toHaveLength(2) + expect(fixture.loadFontAsync.mock.calls.length > 0).toBe(bound) + expect(fixture.triggerUndo).not.toHaveBeenCalled() + } + ) + + it('preserves the scope error when Auto font candidates exceed the bounded budget', async () => { + const fixture = createFixture() + const collection = figma.variables.createVariableCollection('Many modes') + const size = figma.variables.createVariable('Size', collection, 'FLOAT') + size.setValueForMode(collection.defaultModeId, 1) + for (let value = 2; value <= 65; value++) { + size.setValueForMode(collection.addMode(`Mode ${value}`), value) + } + const label = figma.createText() + label.characters = 'Many candidates' + label.setBoundVariable('fontSize', size) + fixture.page.setExplicitVariableModeForCollection(collection, collection.defaultModeId) + + await expect( + applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [collection.id]: null } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE }, + message: expect.stringContaining('bounded candidate budget') + }) + expect(fixture.page.explicitVariableModes[collection.id]).toBe(collection.defaultModeId) + expect(fixture.commitUndo).not.toHaveBeenCalled() + }) + + it('bounds missing variable reads to one per font-preloading scan', async () => { + const fixture = createFixture() + const changed = figma.variables.createVariableCollection('Changed') + const alternate = changed.addMode('Alternate') + const metrics = figma.variables.createVariableCollection('Metrics') + const size = figma.variables.createVariable('Size', metrics, 'FLOAT') + size.setValueForMode(metrics.defaultModeId, 16) + for (let index = 0; index < 50; index++) { + const label = figma.createText() + label.characters = 'Unaffected' + label.setBoundVariable('fontSize', size) + } + fixture.variables.delete(size.id) + const readVariable = vi.mocked(figma.variables.getVariableByIdAsync) + readVariable.mockClear() + await applyCanvasFromTool({ + mode: 'update', + page: { id: fixture.page.id, variableModes: { [changed.id]: alternate } } + }) + expect(fixture.page.explicitVariableModes[changed.id]).toBe(alternate) + expect(readVariable.mock.calls.filter(([id]) => id === size.id)).toHaveLength(2) + expect(fixture.loadFontAsync).not.toHaveBeenCalled() + }) + it.each(['unbound', 'unbound style', 'other collection', 'overridden mode'] as const)( 'does not load a missing font on %s text during a page mode change', async (binding) => { @@ -383,14 +672,7 @@ describe('mcp/tools/canvas font loading', () => { const beforeStyles = [...fixture.styles.keys()] fixture.commitUndo.mockClear() fixture.triggerUndo.mockClear() - const started = Promise.withResolvers() - const ready = Promise.withResolvers() - fixture.loadFontAsync.mockImplementation(async (font: FontName) => { - if (font.family === 'Noto Serif' && font.style === 'SemiBold') { - started.resolve() - await ready.promise - } - }) + const fonts = deferFont(fixture, { family: 'Noto Serif', style: 'SemiBold' }) const pending = applyCanvasFromTool({ mode: 'update', targetNodeId: created.rootNodeId!, @@ -413,19 +695,14 @@ describe('mcp/tools/canvas font loading', () => { }) void pending.catch(() => undefined) try { - await Promise.race([ - started.promise, - pending.then(() => { - throw new Error('The desired font was not requested') - }) - ]) + await waitForDeferredFont(pending, fonts.requested) expect([...fixture.variableCollections.keys()]).toEqual(beforeCollections) expect([...fixture.styles.keys()]).toEqual(beforeStyles) expect(title.characters).toBe('Before') expect(title.fontName).toEqual({ family: 'Inter', style: 'Regular' }) expect(fixture.commitUndo).not.toHaveBeenCalled() } finally { - ready.resolve() + fonts.release() await pending } expect(title.fontName).toEqual({ family: 'Noto Serif', style: 'SemiBold' }) @@ -508,7 +785,8 @@ describe('mcp/tools/canvas font loading', () => { styles: { fresh: { type: 'PAINT', name: 'Fresh paint', paints: [] } } }) ).rejects.toMatchObject({ - code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC }, message: expect.stringContaining('Font "Noto Serif Regular" is unavailable') }) expect([...fixture.variableCollections.keys()]).toEqual(beforeCollections) @@ -570,18 +848,18 @@ describe('mcp/tools/canvas font loading', () => { }) void update.catch(() => undefined) try { - await regularStarted.promise + await waitForDeferredFont(update, regularStarted.promise) expect(title.resize).not.toHaveBeenCalled() expect(setFontName).not.toHaveBeenCalled() regular.resolve() - await semiboldStarted.promise + await waitForDeferredFont(update, semiboldStarted.promise) expect(title.resize).not.toHaveBeenCalled() expect(setFontName).not.toHaveBeenCalled() } finally { regular.resolve() semibold.resolve() + await update } - await update expect(title.fontName).toEqual({ family: 'Poppins', style: 'SemiBold' }) expect(title.width).toBe(160) expect(title.height).toBe(40) diff --git a/packages/extension/tests/mcp/tools/canvas-grid.test.ts b/packages/extension/tests/mcp/tools/canvas-grid.test.ts new file mode 100644 index 00000000..6f171730 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-grid.test.ts @@ -0,0 +1,29 @@ +import { expect, it, vi } from 'vitest' + +import { applyResolvedCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +it.each( + (['gridColumnSizes', 'gridRowSizes'] as const).flatMap((tracks) => + (['type', 'value'] as const).map((field) => ({ tracks, field })) + ) +)('rejects an ignored $tracks $field write', async ({ tracks, field }) => { + const fixture = createFixture() + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') as unknown as FrameNode + Object.defineProperty(node[tracks][0]!, field, { + get: () => (field === 'type' ? 'FLEX' : 1), + set: () => {} + }) + return node + }) + + await expect( + applyResolvedCanvas({ + mode: 'create', + markup: '
' + }) + ).rejects.toThrow(`${tracks} track 0 does not match`) + expect(fixture.page.children).toEqual([]) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-input.test.ts b/packages/extension/tests/mcp/tools/canvas-input.test.ts index 066b3fdc..2ac2043a 100644 --- a/packages/extension/tests/mcp/tools/canvas-input.test.ts +++ b/packages/extension/tests/mcp/tools/canvas-input.test.ts @@ -1,8 +1,9 @@ import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { readFileSync } from 'node:fs' import { afterEach, describe, expect, it, vi } from 'vitest' import { createCodedError } from '@/mcp/errors' -import { handleApplyCanvas } from '@/mcp/tools/canvas' +import { applyResolvedCanvas, handleApplyCanvas } from '@/mcp/tools/canvas' import { parseCanvasHtml } from '@/mcp/tools/canvas/html' import { parseCanvasMarkup } from '@/mcp/tools/canvas/markup' @@ -14,6 +15,79 @@ afterEach(() => { }) describe('canvas input diagnostics', () => { + it.each([ + ['AT&T Inc R&D©', 'AT&T\u00a0Inc R&D©', false], + ['Q&A&unknown;', 'Q&A&unknown;', true], + ['&&& A', '&&& A', false] + ] as const)('decodes entities after literal ampersands: %s', (source, decoded, warned) => { + const parsed = parseCanvasHtml(`${source}`) + expect(parsed.text).toBe(decoded) + expect(parsed.attributes['data-name']).toBe(decoded) + expect(!!parsed.hasUnknownEntities).toBe(warned) + }) + + it('decodes every named entity documented by the authoring skill', () => { + const reference = readFileSync( + new URL( + '../../../../../agent-plugin/src/skills/design-in-figma/references/canvas-html.md', + import.meta.url + ), + 'utf8' + ) + const names = [ + ...reference + .match(/these named entities decode: ([\s\S]*?)\. Other names/)![1]! + .matchAll(/`(\w+)`/g) + ].map((match) => match[1]!) + const expected: Record = { + amp: '&', + apos: "'", + bull: '•', + copy: '©', + gt: '>', + hellip: '…', + ldquo: '“', + lsquo: '‘', + lt: '<', + mdash: '—', + middot: '·', + nbsp: '\u00a0', + ndash: '–', + quot: '"', + rdquo: '”', + reg: '®', + rsquo: '’', + trade: '™' + } + expect(names).toEqual(Object.keys(expected)) + for (const name of names) { + expect(parseCanvasHtml(`&${name};`).text).toBe(expected[name]) + } + }) + + it.each([ + ['&unknown; € &unknown;', true], + ['&unknown; € R&D', false] + ] as const)('preserves literal entities with one bounded warning: %s', async (text, warned) => { + const fixture = createFixture() + const result = await handleApplyCanvas({ + mode: 'create', + markup: `
${text}
` + }) + expect((fixture.getNode(result.nodeIdsByKey.label!) as TextNode).characters).toBe( + warned ? text : '&unknown; € R&D' + ) + const warnings = result.verification.warnings.filter( + ({ code }) => code === 'unknown-html-entity' + ) + expect(warnings).toHaveLength(warned ? 1 : 0) + if (warned) + expect(warnings[0]).toMatchObject({ + message: expect.stringContaining('literal'), + key: 'root' + }) + }) + it.each([ '
', '
', @@ -21,13 +95,14 @@ describe('canvas input diagnostics', () => { ])('classifies malformed HTML as input failure: %s', async (markup) => { const fixture = createFixture() await expect(handleApplyCanvas({ mode: 'create', markup })).rejects.toMatchObject({ - code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + message: expect.stringContaining('INVALID_CANVAS_SPEC') }) expect(fixture.commitUndo).not.toHaveBeenCalled() }) it.each(['uncoded', 'coded'] as const)( - 'preserves %s live theme-read failures as execution failures', + 'marks %s local theme-read failures as aborted preparation', async (kind) => { const fixture = createFixture() const failure = @@ -42,11 +117,9 @@ describe('canvas input diagnostics', () => { theme: { variables: { '--width': { variableKey: 'width' } } } }) ).rejects.toMatchObject({ - code: - kind === 'coded' - ? TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE - : TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, - message: failure.message + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + message: expect.stringContaining(failure.message), + cause: failure }) expect(fixture.commitUndo).not.toHaveBeenCalled() } @@ -70,6 +143,75 @@ describe('canvas input diagnostics', () => { ).toThrow('"font-(--weight)"') }) + it('keeps a failed library import uncertain even before the Undo boundary', async () => { + const fixture = createFixture() + vi.mocked(figma.variables.importVariableByKeyAsync).mockRejectedValueOnce( + new Error('Library import interrupted') + ) + await expect( + applyResolvedCanvas({ + mode: 'create', + markup: '
', + bindings: { root: { variables: { width: { key: 'library-width' } } } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: 'Library import interrupted' + }) + expect(figma.variables.importVariableByKeyAsync).toHaveBeenCalledWith('library-width') + expect(fixture.commitUndo).not.toHaveBeenCalled() + }) + + it('keeps a coded validation failure uncertain after importing a library component', async () => { + const fixture = createFixture() + await expect( + applyResolvedCanvas({ + mode: 'create', + markup: + '
', + bindings: { + action: { + component: { key: 'component-key' }, + componentProperties: { Missing: 'value' } + } + } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: expect.stringContaining('INVALID_CANVAS_SPEC'), + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC } + }) + expect(fixture.importComponentByKeyAsync).toHaveBeenCalledWith('component-key') + expect(fixture.commitUndo).not.toHaveBeenCalled() + }) + + it('does not let a later aborted download erase earlier library-import uncertainty', async () => { + const fixture = createFixture() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 403 }))) + await expect( + applyResolvedCanvas({ + mode: 'create', + markup: + '
', + bindings: { + action: { component: { key: 'component-key' } }, + root: { + figma: { + fills: [ + { type: 'VIDEO', videoUrl: 'https://example.com/movie.mp4', scaleMode: 'FILL' } + ] + } + } + } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + message: expect.stringContaining('Video download failed') + }) + expect(fixture.importComponentByKeyAsync).toHaveBeenCalledWith('component-key') + expect(fixture.commitUndo).not.toHaveBeenCalled() + }) + it('does not warn about native-only preservation values the caller never supplied', async () => { const fixture = createFixture() const created = await handleApplyCanvas({ diff --git a/packages/extension/tests/mcp/tools/canvas-list-spacing.test.ts b/packages/extension/tests/mcp/tools/canvas-list-spacing.test.ts new file mode 100644 index 00000000..45ab7a00 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-list-spacing.test.ts @@ -0,0 +1,222 @@ +import type { CanvasFigmaTextRange, CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { describe, expect, it, vi } from 'vitest' + +import { applyResolvedCanvas as applyCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +async function createListProbe(initialValues = [8, 12], characters = 'First\nSecond') { + const fixture = createFixture() + const markup = `
${characters || 'Empty'}
` + const created = await applyCanvas({ mode: 'create', markup }) + const node = fixture.getNode(created.nodeIdsByKey.label!) as unknown as TextNode + if (!characters) node.characters = '' + expect(node.characters).toBe(characters) + + // Observed in native Figma: list spacing applies to touched paragraphs. + // Whole-node writes leave existing range values; range writes replace them. + let offset = 0 + let base = 4 + const paragraphs = characters + ? characters.split('\n').map((text, index, all) => { + const start = offset + offset += text.length + (index < all.length - 1 ? 1 : 0) + return { start, end: offset, value: Math.fround(initialValues[index]!) } + }) + : [] + const affected = (start: number, end: number) => { + if (start >= end || end > characters.length) throw new Error('Invalid native text range') + return paragraphs.filter((paragraph) => paragraph.start < end && paragraph.end > start) + } + const read = (start: number, end: number) => { + const values = affected(start, end).map((paragraph) => paragraph.value) + return values.every((value) => value === values[0]) ? values[0]! : figma.mixed + } + let applyingStyle = false + const write = vi.fn((start: number, end: number, value: number) => { + if (!applyingStyle && characters[end - 1] === '\n' && end < characters.length) end++ + for (const paragraph of affected(start, end)) paragraph.value = Math.fround(value) + }) + const writeBase = vi.fn((value: number) => { + base = Math.fround(value) + }) + Object.defineProperty(node, 'listSpacing', { + configurable: true, + get: () => (characters ? read(0, characters.length) : base), + set: writeBase + }) + node.getRangeListSpacing = read + node.setRangeListSpacing = write + const applyStyle = node.setRangeTextStyleIdAsync.bind(node) + node.setRangeTextStyleIdAsync = vi.fn(async (start, end, id) => { + applyingStyle = true + try { + await applyStyle(start, end, id) + if (id) write(start, end, (fixture.styles.get(id) as TextStyle).listSpacing) + } finally { + applyingStyle = false + } + }) + + return { + node, + values: () => paragraphs.map(({ value }) => value), + write, + writeBase, + update: (listSpacing?: number, ranges?: CanvasFigmaTextRange[]) => { + const request: CanvasResolvedApplyParameters = { + mode: 'update', + targetNodeId: created.rootNodeId!, + ...(characters ? { markup } : {}), + bindings: { label: { figma: { text: { listSpacing, ranges } } } } + } + return applyCanvas(request) + } + } +} + +describe('native list spacing reconciliation', () => { + it('replaces existing range values when only base spacing is authored', async () => { + const probe = await createListProbe() + expect((await probe.update(4)).mutationCount).toBe(2) + expect(probe.values()).toEqual([4, 4]) + expect(probe.writeBase).not.toHaveBeenCalled() + await expect(probe.update(4)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it.each([5, 6])('preserves a first-paragraph override ending at %i', async (end) => { + const probe = await createListProbe() + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end, listOptions: { type: 'UNORDERED' }, listSpacing: 8 }, + { start: 6, end: 12, listOptions: { type: 'UNORDERED' } } + ] + await probe.update(4, ranges) + expect(probe.values()).toEqual([8, 4]) + await expect(probe.update(4, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + + expect((await probe.update(12, ranges)).mutationCount).toBe(1) + expect(probe.values()).toEqual([8, 12]) + await expect(probe.update(12, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.writeBase).not.toHaveBeenCalled() + }) + + it.each([ + { start: 5, end: 6, values: [8, 4] }, + { start: 6, end: 7, values: [4, 8] }, + { start: 5, end: 7, values: [8, 8] } + ])('respects newline coverage at $start:$end', async ({ start, end, values }) => { + const probe = await createListProbe(values) + await expect(probe.update(4, [{ start, end, listSpacing: 8 }])).resolves.toMatchObject({ + mutationCount: 0 + }) + expect(probe.values()).toEqual(values) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('preserves the later spacing without skipping other fields of the earlier range', async () => { + const probe = await createListProbe([30, 4]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, listSpacing: 20, fontSize: 28 }, + { start: 2, end: 3, listSpacing: 30 } + ] + expect((await probe.update(4, ranges)).mutationCount).toBe(1) + expect(probe.node.getRangeFontSize(0, 1)).toBe(28) + expect(probe.values()).toEqual([30, 4]) + expect(probe.write).not.toHaveBeenCalled() + await expect(probe.update(4, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + + const zero = [ranges[0]!, { start: 2, end: 3, listSpacing: 0 }] + expect((await probe.update(4, zero)).mutationCount).toBe(1) + expect(probe.values()).toEqual([0, 4]) + await expect(probe.update(4, zero)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('retains the first paragraph of a cross-paragraph range and applies later changes', async () => { + const probe = await createListProbe([20, 30]) + const ranges = [ + { start: 0, end: 7, listSpacing: 20 }, + { start: 8, end: 9, listSpacing: 30 } + ] + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + const changed = [ranges[0]!, { start: 8, end: 9, listSpacing: 40 }] + expect((await probe.update(undefined, changed)).mutationCount).toBe(1) + expect(probe.values()).toEqual([20, 40]) + await expect(probe.update(undefined, changed)).resolves.toMatchObject({ mutationCount: 0 }) + + const changedFirst = [{ start: 0, end: 7, listSpacing: 25 }, changed[1]!] + await probe.update(undefined, changedFirst) + expect(probe.values()).toEqual([25, 40]) + await expect(probe.update(undefined, changedFirst)).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('restores base spacing after removing an override and preserves omitted spacing', async () => { + const probe = await createListProbe([8, 4]) + await expect(probe.update()).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.values()).toEqual([8, 4]) + + expect((await probe.update(4)).mutationCount).toBe(2) + expect(probe.values()).toEqual([4, 4]) + await expect(probe.update(4)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('keeps style-owned paragraphs outside the base write, including an empty first paragraph', async () => { + const probe = await createListProbe([6, 12], '\nSecond') + const ranges: CanvasFigmaTextRange[] = [{ start: 0, end: 1, textStyle: { id: 'style:text' } }] + await probe.update(4, ranges) + expect(probe.values()).toEqual([6, 4]) + probe.write.mockClear() + await expect(probe.update(4, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.write).not.toHaveBeenCalled() + + await expect(probe.update(4, [{ start: 0, end: 1, textStyle: null }])).rejects.toThrow( + 'empty paragraph 0:1' + ) + expect(probe.values()).toEqual([6, 4]) + }) + + it('converges a composed style whose final paragraph has a later spacing override', async () => { + const probe = await createListProbe([4, 4]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 7, textStyle: { id: 'style:text' }, fontSize: 28 }, + { start: 8, end: 9, listSpacing: 30 } + ] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([6, 30]) + probe.write.mockClear() + vi.mocked(probe.node.setRangeTextStyleIdAsync).mockClear() + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.write).not.toHaveBeenCalled() + expect(probe.node.setRangeTextStyleIdAsync).not.toHaveBeenCalled() + }) + + it('converges float32 spacing while preserving the next representable value', async () => { + const probe = await createListProbe([8, 4.1]) + const ranges = [{ start: 0, end: 5, listSpacing: 8 }] + await expect(probe.update(4.1, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + const next = 4.100000381469727 + expect((await probe.update(next, ranges)).mutationCount).toBe(1) + expect(probe.values()).toEqual([8, next]) + await expect(probe.update(next, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('uses the whole-node setter for empty text without reading an empty range', async () => { + const probe = await createListProbe([], '') + expect((await probe.update(8)).mutationCount).toBe(1) + expect(probe.node.listSpacing).toBe(8) + expect(probe.writeBase).toHaveBeenCalledExactlyOnceWith(8) + expect(probe.write).not.toHaveBeenCalled() + await expect(probe.update(8)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('does not extend an authored paragraph literal past its terminating newline', async () => { + const probe = await createListProbe([8, 18, 28], 'A\nB\nC') + const ranges = [{ start: 2, end: 4, listSpacing: 12 }] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([8, 12, 28]) + expect(probe.write).toHaveBeenCalledExactlyOnceWith(2, 3, 12) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-markup.test.ts b/packages/extension/tests/mcp/tools/canvas-markup.test.ts index eaec3e22..aaecd875 100644 --- a/packages/extension/tests/mcp/tools/canvas-markup.test.ts +++ b/packages/extension/tests/mcp/tools/canvas-markup.test.ts @@ -85,6 +85,35 @@ describe('canvas markup', () => { ).toThrow() }) + it.each(['1:2', ' 1:2 '])('does not reserve the grid cell of adopted node %j', (nodeId) => { + const result = parseCanvasMarkup( + { + mode: 'update', + targetNodeId: '1:1', + markup: `
` + }, + undefined, + { + byKey: new Map(), + byNodeId: new Map(), + keys: new Set(['root']), + grids: new Map([ + [ + 'root', + { + itemsPositioning: 'MANUAL', + children: [{ id: '1:2', row: 0, column: 0, rowSpan: 1, columnSpan: 1 }] + } + ] + ]) + } + ) + expect(result.root.children?.[0]).toMatchObject({ + nodeId: '1:2', + gridChild: { row: 0, column: 0 } + }) + }) + it('scales the default line height with an arbitrary font size', () => { const markup = '
Heading
' @@ -2249,6 +2278,17 @@ describe('canvas markup', () => { expect(result.root.children?.[0]?.text?.characters).toBe('A\u00a0\u00a0B') }) + it.each([ + [' \t Copy \n ', '\u00a0Copy\u00a0'], + [' 
  ', '\u00a0\n\u00a0'], + [' \u2028Copy\u2028 ', '\u2028Copy\u2028'] + ])('preserves non-HTML whitespace at text boundaries: %j', (source, expected) => { + const result = parse( + `
${source}
` + ) + expect(result.root.children?.[0]?.text?.characters).toBe(expected) + }) + it('uses UTF-16 text-range offsets and rejects out-of-bounds ranges', () => { const markup = '
👍
' diff --git a/packages/extension/tests/mcp/tools/canvas-media.test.ts b/packages/extension/tests/mcp/tools/canvas-media.test.ts index f408dfae..4ddf5bb0 100644 --- a/packages/extension/tests/mcp/tools/canvas-media.test.ts +++ b/packages/extension/tests/mcp/tools/canvas-media.test.ts @@ -30,7 +30,7 @@ describe('Canvas video response cleanup', () => { } }) ).rejects.toMatchObject({ - code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC, + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, message: expect.stringContaining('public HTTPS') }) expect(createImageAsync).not.toHaveBeenCalled() @@ -48,7 +48,7 @@ describe('Canvas video response cleanup', () => { state.videoUrls.add('https://media.example.com/video.mp4') await expect(importCanvasMedia(state)).rejects.toMatchObject({ - code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, message: expect.stringContaining('Video download failed') }) diff --git a/packages/extension/tests/mcp/tools/canvas-native-paints.test.ts b/packages/extension/tests/mcp/tools/canvas-native-paints.test.ts new file mode 100644 index 00000000..47265c93 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-native-paints.test.ts @@ -0,0 +1,96 @@ +import type { CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { afterEach, expect, it, vi } from 'vitest' + +import { applyResolvedCanvas } from '@/mcp/tools/canvas' +import { paintStacksEqual } from '@/mcp/tools/canvas/reconcile/native-values' + +import { createFixture } from '../../fakes/figma' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const boundSolid: SolidPaint = { + type: 'SOLID', + color: { r: 1, g: 0, b: 0 }, + opacity: 1, + boundVariables: { color: { type: 'VARIABLE_ALIAS', id: 'variable:color' } } +} + +it.each([ + { alpha: 0.5, opacity: undefined }, + { alpha: 1, opacity: 0.5 }, + { alpha: 0.5, opacity: 0.5 } +])( + 'reconciles bound SOLID readback alpha $alpha with authored opacity $opacity', + async ({ alpha, opacity }) => { + const fixture = createFixture() + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') + const descriptor = Object.getOwnPropertyDescriptor(node, 'fills')! + Object.defineProperty(node, 'fills', { + configurable: true, + get: descriptor.get, + set(value: readonly Paint[]) { + descriptor.set!.call( + node, + value.map((paint) => + paint.type === 'SOLID' && paint.boundVariables?.color + ? { ...paint, color: { r: 0, g: 1, b: 0 }, opacity: alpha } + : paint + ) + ) + } + }) + return node as unknown as FrameNode + }) + const input: CanvasResolvedApplyParameters = { + mode: 'create', + markup: '
', + bindings: { + root: { + figma: { + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 0, b: 0 }, + ...(opacity === undefined ? {} : { opacity }), + variables: { color: { key: 'color-key' } } + } + ] + } + } + } + } + + const created = await applyResolvedCanvas(input) + expect(fixture.getNode(created.rootNodeId!).fills).toEqual([ + expect.objectContaining({ + color: { r: 0, g: 1, b: 0 }, + opacity: alpha, + boundVariables: boundSolid.boundVariables + }) + ]) + await expect( + applyResolvedCanvas({ ...input, mode: 'update', targetNodeId: created.rootNodeId! }) + ).resolves.toMatchObject({ mutationCount: 0 }) + } +) + +it.each([ + ['missing binding', { boundVariables: undefined }], + ['different binding', { boundVariables: { color: { type: 'VARIABLE_ALIAS', id: 'other' } } }], + ['visibility', { visible: false }], + ['blend mode', { blendMode: 'MULTIPLY' }] +] as const)('rejects a bound SOLID difference in %s', (_, fields) => { + expect(paintStacksEqual([{ ...boundSolid, ...fields }], [boundSolid])).toBe(false) +}) + +it('retains strict color and opacity comparison for unbound SOLID paints', () => { + const unbound: SolidPaint = { type: 'SOLID', color: boundSolid.color, opacity: 1 } + expect(paintStacksEqual([{ ...unbound, color: { r: 0, g: 1, b: 0 } }], [unbound])).toBe(false) + expect(paintStacksEqual([{ ...unbound, opacity: 0.5 }], [unbound])).toBe(false) + expect(paintStacksEqual([boundSolid], [unbound])).toBe(false) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-native-precision.test.ts b/packages/extension/tests/mcp/tools/canvas-native-precision.test.ts new file mode 100644 index 00000000..0145f7d4 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-native-precision.test.ts @@ -0,0 +1,256 @@ +import type { CanvasFigmaProperties, CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { expect, it, vi } from 'vitest' + +import { applyResolvedCanvas } from '@/mcp/tools/canvas' +import { nativeValueEqual } from '@/mcp/tools/canvas/reconcile/comparison' +import { layoutGridsEqual, paintStacksEqual } from '@/mcp/tools/canvas/reconcile/native-values' + +import { createFixture } from '../../fakes/figma' + +function rounded(value: unknown): unknown { + if (typeof value === 'number') return Math.fround(value) + if (Array.isArray(value)) return value.map(rounded) + if (value && typeof value === 'object') + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rounded(item)])) + return value +} + +function roundProperty(node: object, field: string): void { + let value: unknown = Reflect.get(node, field) + Object.defineProperty(node, field, { + configurable: true, + get: () => value, + set: (next: unknown) => { + value = rounded(next) + } + }) +} + +const cases = [ + ['fills', (value: number) => [{ type: 'SOLID', color: { r: value, g: 0, b: 0 } }]], + ['strokes', (value: number) => [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: value }]], + ['effects', (value: number) => [{ type: 'LAYER_BLUR', radius: value }]], + ['layoutGrids', (value: number) => [{ pattern: 'GRID', sectionSize: value }]] +] as const + +it.each(cases)( + 'preserves small %s edits and converges native float32 readback', + async (field, stack) => { + const fixture = createFixture() + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') as unknown as FrameNode + roundProperty(node, field) + return node + }) + const request = (value: number): CanvasResolvedApplyParameters => ({ + mode: 'create', + markup: '
', + bindings: { + root: { + figma: { + [field]: stack(value), + ...(field === 'strokes' ? { stroke: { weight: 1 } } : {}) + } as CanvasFigmaProperties + } + } + }) + const created = await applyResolvedCanvas(request(0.3)) + const update = (value: number) => + applyResolvedCanvas({ ...request(value), mode: 'update', targetNodeId: created.rootNodeId! }) + await expect(update(0.3)).resolves.toMatchObject({ mutationCount: 0 }) + await expect(update(0.301)).resolves.toMatchObject({ mutationCount: 1 }) + expect((fixture.getNode(created.rootNodeId!) as unknown as FrameNode)[field]).toMatchObject( + rounded(stack(0.301)) as object[] + ) + await expect(update(0.301)).resolves.toMatchObject({ mutationCount: 0 }) + } +) + +it.each(['guides', 'dashPattern', 'page guides'] as const)( + 'converges float32 %s while preserving edits, ordering and clearing', + async (field) => { + const fixture = createFixture() + const property = field === 'dashPattern' ? 'dashPattern' : 'guides' + if (field === 'page guides') roundProperty(fixture.page, property) + else { + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') as unknown as FrameNode + roundProperty(node, property) + return node + }) + } + const guides = (offsets: number[]): Guide[] => offsets.map((offset) => ({ axis: 'X', offset })) + const request = (offsets: number[]): CanvasResolvedApplyParameters => ({ + mode: 'create', + markup: '
', + ...(field === 'page guides' + ? { page: { guides: guides(offsets) } } + : { + bindings: { + root: { + figma: + field === 'dashPattern' + ? { stroke: { dashPattern: offsets } } + : { guides: guides(offsets) } + } + } + }) + }) + const created = await applyResolvedCanvas(request([0.3, 0.7])) + const node = field === 'page guides' ? fixture.page : fixture.getNode(created.rootNodeId!) + const update = (offsets: number[]) => + applyResolvedCanvas({ + ...request(offsets), + mode: 'update', + targetNodeId: created.rootNodeId! + }) + await expect(update([0.3, 0.7])).resolves.toMatchObject({ mutationCount: 0 }) + for (const offsets of [[0.301, 0.7], [0.7, 0.301], []]) { + await expect(update(offsets)).resolves.toMatchObject({ mutationCount: 1 }) + expect(Reflect.get(node, property)).toEqual( + rounded(field === 'dashPattern' ? offsets : guides(offsets)) + ) + await expect(update(offsets)).resolves.toMatchObject({ mutationCount: 0 }) + } + } +) + +it.each( + (['gridColumnSizes', 'gridRowSizes'] as const).flatMap((field) => + (['px', 'fr'] as const).map((unit) => ({ field, unit })) + ) +)('converges float32 $field $unit tracks while retaining small edits', async ({ field, unit }) => { + const fixture = createFixture() + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') as unknown as FrameNode + roundProperty(node[field][0]!, 'value') + return node + }) + const request = (value: number): CanvasResolvedApplyParameters => ({ + mode: 'create', + markup: `
` + }) + const created = await applyResolvedCanvas(request(0.3)) + const node = fixture.getNode(created.rootNodeId!) as unknown as FrameNode + const update = (value: number) => + applyResolvedCanvas({ ...request(value), mode: 'update', targetNodeId: node.id }) + + await expect(update(0.3)).resolves.toMatchObject({ mutationCount: 0 }) + await expect(update(0.301)).resolves.toMatchObject({ mutationCount: 1 }) + expect(node[field][0]).toEqual({ + type: unit === 'px' ? 'FIXED' : 'FLEX', + value: Math.fround(0.301) + }) + await expect(update(0.301)).resolves.toMatchObject({ mutationCount: 0 }) +}) + +it.each([ + { axis: 'Y', offset: Math.fround(0.3) }, + { axis: 'X', offset: Math.fround(0.301) } +] as const)('rejects mismatched guide readback $axis/$offset', async (guide) => { + const fixture = createFixture() + vi.mocked(figma.createFrame).mockImplementationOnce(() => { + const node = fixture.createNode('FRAME') as unknown as FrameNode + Object.defineProperty(node, 'guides', { + configurable: true, + get: () => [guide], + set: () => {} + }) + return node + }) + await expect( + applyResolvedCanvas({ + mode: 'create', + markup: '
', + bindings: { root: { figma: { guides: [{ axis: 'X', offset: 0.3 }] } } } + }) + ).rejects.toThrow('guides do not match') +}) + +it('compares own record keys even when their values are undefined', () => { + expect(nativeValueEqual({ first: undefined }, { second: undefined })).toBe(false) + expect(nativeValueEqual({ first: undefined }, { first: undefined })).toBe(true) + expect(nativeValueEqual({ boundVariables: {}, properties: {} }, {})).toBe(true) +}) + +it('accepts the rounded grid color but rejects a neighboring byte channel', () => { + const grid: LayoutGrid = { + pattern: 'GRID', + sectionSize: 8, + color: { r: 0.043, g: 0, b: 0, a: 1 } + } + const stored = { ...grid, color: { ...grid.color!, r: Math.round(0.043 * 255) / 255 } } + expect(layoutGridsEqual([stored], [grid])).toBe(true) + expect(layoutGridsEqual([{ ...stored, color: { ...stored.color, r: 10 / 255 } }], [grid])).toBe( + false + ) +}) + +it('preserves gradient position and transform edits below the former color tolerance', () => { + const gradient: GradientPaint = { + type: 'GRADIENT_LINEAR', + gradientTransform: [ + [1, 0, 0], + [0, 1, 0] + ], + gradientStops: [{ position: 0.3, color: { r: 1, g: 0, b: 0, a: 1 } }] + } + expect(paintStacksEqual([rounded(gradient) as GradientPaint], [gradient])).toBe(true) + expect( + paintStacksEqual( + [gradient], + [{ ...gradient, gradientStops: [{ ...gradient.gradientStops[0]!, position: 0.301 }] }] + ) + ).toBe(false) + expect( + paintStacksEqual( + [gradient], + [ + { + ...gradient, + gradientTransform: [ + [1, 0, 0.001], + [0, 1, 0] + ] + } + ] + ) + ).toBe(false) +}) + +it('writes a small vector vertex edit and then converges', async () => { + const fixture = createFixture() + const request = (x: number): CanvasResolvedApplyParameters => ({ + mode: 'create', + markup: + '
', + bindings: { + vector: { + figma: { + shape: { + type: 'VECTOR', + network: { + vertices: [ + { x, y: 0 }, + { x: 10, y: 10 } + ], + segments: [{ start: 0, end: 1 }] + } + } + } + } + } + }) + const created = await applyResolvedCanvas(request(0.3)) + const update = { ...request(0.301), mode: 'update' as const, targetNodeId: created.rootNodeId! } + const vector = fixture.getNode(created.nodeIdsByKey.vector!) as unknown as VectorNode + vi.mocked(vector.setVectorNetworkAsync).mockClear() + await applyResolvedCanvas(update) + expect(vector.setVectorNetworkAsync).toHaveBeenCalledOnce() + expect( + (fixture.getNode(created.nodeIdsByKey.vector!) as unknown as VectorNode).vectorNetwork + .vertices[0]?.x + ).toBe(0.301) + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-native-updates.test.ts b/packages/extension/tests/mcp/tools/canvas-native-updates.test.ts new file mode 100644 index 00000000..bb0c5a10 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-native-updates.test.ts @@ -0,0 +1,771 @@ +import type { CanvasFigmaProperties } from '@tempad-dev/shared' + +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + resetAssetCache, + setAssetDownloader, + setAssetServerUrl, + setAssetUploader +} from '@/mcp/assets' +import { + applyResolvedCanvas as applyCanvas, + handleApplyCanvas as applyCanvasFromTool +} from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +afterEach(() => { + resetAssetCache() + setAssetDownloader(null) + setAssetServerUrl(null) + setAssetUploader(null) + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('mcp/tools/canvas native updates', () => { + it('preserves the size produced by a native-only variant change', async () => { + const fixture = createFixture() + const created = await applyCanvas({ + mode: 'create', + markup: + '
', + bindings: { button: { component: { id: 'component:1' } } } + }) + const button = fixture.getNode(created.nodeIdsByKey.button!) as unknown as InstanceNode + const setProperties = vi.mocked(button.setProperties).getMockImplementation() + if (!setProperties) throw new Error('Expected the fixture instance setter.') + vi.spyOn(button, 'setProperties').mockImplementation((properties) => { + setProperties(properties) + button.resize(160, 64) + }) + const updated = await applyCanvasFromTool({ + mode: 'update', + targetNodeId: created.rootNodeId!, + native: { button: { componentProperties: { State: 'Hover' } } } + }) + expect(button).toMatchObject({ width: 160, height: 64 }) + expect(updated.verification.status).toBe('passed') + }) + + it('validates native-only assets before downloading or mutating', async () => { + const fixture = createFixture() + const created = await applyCanvas({ + mode: 'create', + markup: '
' + }) + const before = fixture.getNode(created.rootNodeId!).name + await expect( + applyCanvasFromTool({ + mode: 'update', + targetNodeId: created.rootNodeId!, + native: { + root: { + figma: { + name: 'Changed', + fills: [{ type: 'IMAGE', assetKey: 'missing', scaleMode: 'FILL' }] + } + } + } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC }, + message: expect.stringContaining('not declared') + }) + expect(fixture.getNode(created.rootNodeId!).name).toBe(before) + }) + + it('updates native state by stable key without moving nested nodes', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
Pool
Book
' + }) + const root = fixture.getNode(created.rootNodeId!) + const media = fixture.getNode(created.nodeIdsByKey.media!) + const photo = fixture.getNode(created.nodeIdsByKey.photo!) + const rootChildren = root.children.map((node) => node.id) + const mediaChildren = media.children.map((node) => node.id) + const imageUrl = 'https://images.example.com/pool.png' + const update = { + mode: 'update' as const, + targetNodeId: root.id, + native: { + photo: { + figma: { fills: [{ type: 'IMAGE' as const, imageUrl, scaleMode: 'FILL' as const }] } + } + } + } + + const updated = await applyCanvasFromTool(update) + + expect(root.children.map((node) => node.id)).toEqual(rootChildren) + expect(media.children.map((node) => node.id)).toEqual(mediaChildren) + expect(photo.parent).toBe(media) + expect(photo.fills).toEqual([ + expect.objectContaining({ type: 'IMAGE', imageHash: `image:${imageUrl}`, scaleMode: 'FILL' }) + ]) + expect(updated).toMatchObject({ + rootNodeId: root.id, + nodeIdsByKey: { photo: photo.id }, + createdNodeIds: [], + updatedNodeIds: [photo.id], + removedNodeIds: [], + verification: { + status: 'passed', + nodesChecked: 1, + referencesChecked: 0, + nativeFieldsChecked: 1, + warnings: [] + } + }) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it.each(['include the bound node', 'separate native-only update'] as const)( + 'repairs a mixed update by choosing to %s without replacing existing nodes', + async (repair) => { + const fixture = createFixture() + const content = + '
' + const markup = (children: string) => + `
${children}
` + const created = await applyCanvasFromTool({ mode: 'create', markup: markup(content) }) + const root = fixture.getNode(created.rootNodeId!) + const parent = fixture.getNode(created.nodeIdsByKey.content!) + const panel = fixture.getNode(created.nodeIdsByKey.panel!) + const update = { + mode: 'update' as const, + targetNodeId: root.id, + native: { panel: { figma: { name: 'Updated panel' } } } + } + const banner = '
' + vi.mocked(figma.createFrame).mockClear() + fixture.commitUndo.mockClear() + + await expect(applyCanvasFromTool({ ...update, markup: markup(banner) })).rejects.toThrow( + 'Binding "panel" has no matching data-key in the supplied markup.' + ) + expect(figma.createFrame).not.toHaveBeenCalled() + expect(fixture.commitUndo).not.toHaveBeenCalled() + expect(root.children.map((node) => node.id)).toEqual([parent.id]) + expect(panel.name).toBe('panel') + + if (repair === 'include the bound node') { + await applyCanvasFromTool({ ...update, markup: markup(banner + content) }) + } else { + await applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + markup: markup(banner) + }) + await applyCanvasFromTool(update) + } + + expect(panel.name).toBe('Updated panel') + expect(panel.parent).toBe(parent) + expect(parent.parent).toBe(root) + expect(parent.children.map((node) => node.id)).toEqual([panel.id]) + expect(root.children).toHaveLength(2) + expect(root.children.map((node) => node.name)).toEqual( + expect.arrayContaining(['banner', 'content']) + ) + expect(figma.createFrame).toHaveBeenCalledOnce() + } + ) + + it('updates native display names without changing stable identity or omitted state', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
Guest
' + }) + const root = fixture.getNode(created.rootNodeId!) + const copy = fixture.getNode(created.nodeIdsByKey.copy!) as unknown as TextNode + const update = { + mode: 'update' as const, + targetNodeId: root.id, + native: { + root: { figma: { name: 'Seating plan' } }, + copy: { figma: { name: 'Guest name' } } + } + } + + const updated = await applyCanvasFromTool(update) + + expect(root.name).toBe('Seating plan') + expect(copy).toMatchObject({ name: 'Guest name', characters: 'Guest' }) + expect(root.children.map((node) => node.id)).toEqual([copy.id]) + expect(updated).toMatchObject({ + nodeIdsByKey: created.nodeIdsByKey, + createdNodeIds: [], + updatedNodeIds: [root.id, copy.id], + removedNodeIds: [] + }) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + + await applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + native: { + root: { figma: { locked: true } }, + copy: { figma: { locked: true } } + } + }) + expect(root.name).toBe('Seating plan') + expect(copy.name).toBe('Guest name') + + await applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + native: { root: { figma: { name: '' } } } + }) + expect(root.name).toBe('') + }) + + it('updates native text properties without markup and preserves omitted typography', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
Guest
' + }) + const copy = fixture.getNode(created.nodeIdsByKey.copy!) as unknown as TextNode + const update = { + mode: 'update' as const, + targetNodeId: created.rootNodeId!, + native: { + copy: { + figma: { + text: { + fontName: { family: 'IBM Plex Sans', style: 'Medium' }, + verticalAlign: 'CENTER' as const, + paragraphSpacing: 8 + } + } + } + } + } + + await applyCanvasFromTool(update) + expect(copy).toMatchObject({ + characters: 'Guest', + textAutoResize: 'NONE', + fontName: { family: 'IBM Plex Sans', style: 'Medium' }, + textAlignVertical: 'CENTER', + fontSize: 20, + lineHeight: { unit: 'PIXELS', value: 28 }, + width: 240, + height: 80 + }) + expect(copy.getRangeParagraphSpacing(0, copy.characters.length)).toBe(8) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + + copy.setRangeFontName(0, 2, { family: 'IBM Plex Sans', style: 'Medium' }) + copy.setRangeFontName(2, copy.characters.length, { family: 'IBM Plex Sans', style: 'Regular' }) + copy.fontName = figma.mixed + copy.textAutoResize = 'TRUNCATE' + await applyCanvasFromTool({ + mode: 'update', + targetNodeId: created.rootNodeId!, + native: { copy: { figma: { name: 'Guest name' } } } + }) + expect(copy.fontName).toBe(figma.mixed) + expect(copy.getRangeAllFontNames(0, copy.characters.length)).toEqual([ + { family: 'IBM Plex Sans', style: 'Medium' }, + { family: 'IBM Plex Sans', style: 'Regular' } + ]) + expect(copy.textAutoResize).toBe('TRUNCATE') + expect(copy.characters).toBe('Guest') + expect(copy.textAlignVertical).toBe('CENTER') + }) + + it('updates native-only Auto Layout spacing and stacking while preserving the live layout', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
' + }) + const root = fixture.getNode(created.rootNodeId!) as unknown as FrameNode + const update = { + mode: 'update' as const, + targetNodeId: root.id, + native: { + root: { + figma: { + autoLayout: { itemSpacing: -12, counterAxisSpacing: 20, itemReverseZIndex: true } + } + } + } + } + + await applyCanvasFromTool(update) + expect(root).toMatchObject({ + itemSpacing: -12, + counterAxisSpacing: 20, + itemReverseZIndex: true, + layoutMode: 'HORIZONTAL', + layoutWrap: 'WRAP', + paddingLeft: 16, + paddingRight: 16, + counterAxisAlignItems: 'CENTER', + width: 320, + height: 200 + }) + expect(root.children.map((node) => node.id)).toEqual([created.nodeIdsByKey.child]) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + + const synchronized = { + ...update, + native: { + root: { + figma: { + autoLayout: { itemSpacing: 0, counterAxisSpacing: null, itemReverseZIndex: false } + } + } + } + } + await applyCanvasFromTool(synchronized) + expect(root).toMatchObject({ itemSpacing: 0, counterAxisSpacing: 0, itemReverseZIndex: false }) + await expect(applyCanvasFromTool(synchronized)).resolves.toMatchObject({ mutationCount: 0 }) + + await applyCanvasFromTool({ + ...update, + native: { root: { figma: { autoLayout: { itemSpacing: 6 } } } } + }) + expect(root).toMatchObject({ itemSpacing: 6, counterAxisSpacing: 6, itemReverseZIndex: false }) + await applyCanvasFromTool({ + ...update, + native: { root: { figma: { autoLayout: { counterAxisSpacing: 6 } } } } + }) + await applyCanvasFromTool({ + ...update, + native: { root: { figma: { autoLayout: { itemSpacing: 12 } } } } + }) + expect(root).toMatchObject({ itemSpacing: 12, counterAxisSpacing: 6 }) + }) + + it.each([ + { text: { paragraphSpacing: 8 } }, + { shape: { type: 'RECTANGLE' } }, + { section: { contentsHidden: true } }, + { group: true }, + { booleanOperation: 'UNION' }, + { component: { type: 'COMPONENT' } }, + { slot: {} } + ])( + 'rejects native-only type declarations that do not match the live frame: %j', + async (properties) => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: '
' + }) + const root = fixture.getNode(created.rootNodeId!) + + await expect( + applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + native: { root: { figma: { ...properties, name: 'Must not be applied' } } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC } + }) + expect(root).toMatchObject({ name: 'root', type: 'FRAME', width: 120, height: 80 }) + } + ) + + it.each([ + { classes: 'w-[120px] h-[80px]', autoLayout: { itemSpacing: 4 } }, + { classes: 'grid grid-cols-2 w-[120px] h-[80px]', autoLayout: { itemSpacing: 4 } }, + { classes: 'flex flex-row w-[120px] h-[80px]', autoLayout: { counterAxisSpacing: 4 } } + ])( + 'rejects native-only Auto Layout state incompatible with the live layout: %j', + async ({ classes, autoLayout }) => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: `
` + }) + const root = fixture.getNode(created.rootNodeId!) + await expect( + applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + native: { root: { figma: { autoLayout, name: 'Must not be applied' } } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC } + }) + expect(root.name).toBe('root') + } + ) + + it.each([ + { corners: { radius: 8 } }, + { corners: { smoothing: 0.5 } }, + { stroke: { weights: { top: 1, right: 2, bottom: 3, left: 4 } } } + ])('rejects native-only geometry unsupported by a text node: %j', async (properties) => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
Text
' + }) + const copy = fixture.getNode(created.nodeIdsByKey.copy!) + await expect( + applyCanvasFromTool({ + mode: 'update', + targetNodeId: copy.id, + native: { copy: { figma: { ...properties, name: 'Must not be applied' } } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC } + }) + expect(copy.name).toBe('copy') + }) + + it('resolves native-only component property definitions before dependent sublayer bindings', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
Title
', + native: { card: { figma: { component: { type: 'COMPONENT' } } } } + }) + const card = fixture.getNode(created.rootNodeId!) as unknown as ComponentNode + const title = fixture.getNode(created.nodeIdsByKey.title!) as unknown as TextNode + const update = { + mode: 'update' as const, + targetNodeId: card.id, + native: { + title: { figma: { componentPropertyReferences: { characters: 'title' } } }, + card: { + figma: { + component: { + type: 'COMPONENT' as const, + properties: { title: { type: 'TEXT' as const, name: 'Title', defaultValue: 'Title' } } + } + } + } + } + } + + await applyCanvasFromTool(update) + const propertyName = Object.keys(card.componentPropertyDefinitions).find((name) => + name.startsWith('Title#') + )! + expect(propertyName).toBeDefined() + expect(title.componentPropertyReferences).toEqual({ characters: propertyName }) + expect(card.children.map((node) => node.id)).toEqual([title.id]) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('preserves Auto Layout translation while resolving native-only vector Pattern references', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
', + native: { + icon: { + figma: { + shape: { + type: 'VECTOR', + paths: [{ windingRule: 'NONZERO', data: 'M 0 0 L 24 0 L 0 24 Z' }] + } + } + } + } + }) + const icon = fixture.getNode(created.nodeIdsByKey.icon!) as unknown as VectorNode + const source = fixture.getNode(created.nodeIdsByKey.source!) + const translation = [icon.relativeTransform[0][2], icon.relativeTransform[1][2]] + const update = { + mode: 'update' as const, + targetNodeId: created.rootNodeId!, + native: { + icon: { + figma: { + relativeTransform: [ + [0, -1, 300], + [1, 0, 400] + ], + shape: { + type: 'VECTOR', + network: { + vertices: [ + { x: 0, y: 0 }, + { x: 24, y: 0 }, + { x: 0, y: 24 } + ], + segments: [ + { start: 0, end: 1 }, + { start: 1, end: 2 }, + { start: 2, end: 0 } + ], + regions: [ + { + windingRule: 'NONZERO', + loops: [[0, 1, 2]], + fills: [ + { + type: 'PATTERN', + sourceCanvasKey: 'source', + tileType: 'RECTANGULAR', + scalingFactor: 1, + spacing: { x: 0, y: 0 }, + horizontalAlignment: 'START' + } + ] + } + ] + } + } + } + } + } + } + + await applyCanvasFromTool(update) + expect(icon.relativeTransform).toEqual([ + [0, -1, translation[0]], + [1, 0, translation[1]] + ]) + expect(icon.vectorNetwork.regions?.[0]?.fills).toMatchObject([ + { type: 'PATTERN', sourceNodeId: source.id } + ]) + expect(icon).toMatchObject({ width: 24, height: 24 }) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('rejects conflicting native-only exact fonts and text styles before mutation', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
Text
', + styles: { + body: { + type: 'TEXT', + name: 'Body', + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 16 + } + } + }) + const copy = fixture.getNode(created.nodeIdsByKey.copy!) as unknown as TextNode + await expect( + applyCanvasFromTool({ + mode: 'update', + targetNodeId: created.rootNodeId!, + native: { + copy: { + styles: { text: { styleKey: 'body' } }, + figma: { + name: 'Must not be applied', + text: { fontName: { family: 'Roboto', style: 'Bold' } } + } + } + } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC } + }) + expect(copy).toMatchObject({ name: 'copy', textStyleId: '' }) + const update = { + mode: 'update' as const, + targetNodeId: created.rootNodeId!, + native: { copy: { styles: { text: { styleKey: 'body' } } } } + } + await applyCanvasFromTool(update) + expect(copy.textStyleId).not.toBe('') + expect(copy.fontName).toEqual({ family: 'Inter', style: 'Regular' }) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + + const unlinked = { + ...update, + native: { + copy: { + styles: { text: null }, + figma: { text: { fontName: { family: 'Roboto', style: 'Bold' } } } + } + } + } + await applyCanvasFromTool(unlinked) + expect(copy).toMatchObject({ textStyleId: '', fontName: { family: 'Roboto', style: 'Bold' } }) + await expect(applyCanvasFromTool(unlinked)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('rejects a markup-less native key outside the update scope', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: '
' + }) + + await expect( + applyCanvasFromTool({ + mode: 'update', + targetNodeId: created.rootNodeId!, + native: { missing: { figma: { locked: true } } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SCOPE }, + message: expect.stringContaining('does not exist inside the update scope') + }) + expect(fixture.getNode(created.rootNodeId!).locked).toBe(false) + }) + + it('preserves float32 transform translations without hiding position or axis changes', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: '
' + }) + const root = fixture.getNode(created.rootNodeId!) + const descriptor = Object.getOwnPropertyDescriptor(root, 'relativeTransform')! + const write = vi.fn((value: Transform) => { + const rounded = value.map((row) => row.map(Math.fround)) as Transform + descriptor.set!.call(root, rounded) + }) + Object.defineProperty(root, 'relativeTransform', { ...descriptor, set: write }) + const update = (relativeTransform: Transform) => + applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + native: { root: { figma: { relativeTransform } } } + }) + const transform: Transform = [ + [1, 0, 1000.1], + [0, 1, -1000.1] + ] + await update(transform) + expect(root.x).toBe(Math.fround(1000.1)) + expect(root.y).toBe(Math.fround(-1000.1)) + expect(write).toHaveBeenCalledOnce() + await expect(update(transform)).resolves.toMatchObject({ mutationCount: 0, updatedNodeIds: [] }) + expect(write).toHaveBeenCalledOnce() + + transform[0][2] = 1000.1002 + await expect(update(transform)).resolves.toMatchObject({ mutationCount: 1 }) + expect(root.x).toBe(Math.fround(1000.1002)) + expect(write).toHaveBeenCalledTimes(2) + await expect(update(transform)).resolves.toMatchObject({ mutationCount: 0 }) + + const angle = 2e-6 + transform[0][0] = transform[1][1] = Math.cos(angle) + transform[0][1] = Math.sin(angle) + transform[1][0] = -Math.sin(angle) + await expect(update(transform)).resolves.toMatchObject({ mutationCount: 1 }) + expect(root.relativeTransform[0][1]).toBe(Math.fround(Math.sin(angle))) + expect(write).toHaveBeenCalledTimes(3) + await expect(update(transform)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('updates native stroke and corner geometry without markup and preserves omitted appearance', async () => { + const fixture = createFixture() + const created = await applyCanvasFromTool({ + mode: 'create', + markup: + '
', + native: { shape: { figma: { shape: { type: 'RECTANGLE' } } } } + }) + const root = fixture.getNode(created.rootNodeId!) + const shape = fixture.getNode(created.nodeIdsByKey.shape!) as unknown as RectangleNode + const fills = shape.fills + const strokes = shape.strokes + const update = { + mode: 'update' as const, + targetNodeId: root.id, + native: { + shape: { + figma: { + stroke: { + weight: 5, + align: 'OUTSIDE' as const, + cap: 'SQUARE' as const, + join: 'BEVEL' as const, + miterLimit: 6, + dashPattern: [4, 3] + }, + corners: { radius: 10, smoothing: 0.75 } + } + } + } + } + + await applyCanvasFromTool(update) + expect(shape).toMatchObject({ + strokeWeight: 5, + strokeAlign: 'OUTSIDE', + strokeCap: 'SQUARE', + strokeJoin: 'BEVEL', + strokeMiterLimit: 6, + dashPattern: [4, 3], + cornerRadius: 10, + cornerSmoothing: 0.75, + width: 80, + height: 48, + opacity: 0.5, + fills, + strokes + }) + expect(root.children.map((node) => node.id)).toEqual([shape.id]) + await expect(applyCanvasFromTool(update)).resolves.toMatchObject({ mutationCount: 0 }) + + await applyCanvasFromTool({ + mode: 'update', + targetNodeId: root.id, + native: { + shape: { figma: { stroke: { dashPattern: [] }, corners: { smoothing: 0 } } } + } + }) + expect(shape).toMatchObject({ + strokeWeight: 5, + strokeAlign: 'OUTSIDE', + dashPattern: [], + cornerRadius: 10, + cornerSmoothing: 0 + }) + + const individual = { + mode: 'update' as const, + targetNodeId: root.id, + native: { + shape: { + figma: { + stroke: { weights: { top: 0, right: 2, bottom: 3, left: 4 } }, + corners: { radii: { topLeft: 0, topRight: 6, bottomRight: 7, bottomLeft: 8 } } + } + } + } + } + await applyCanvasFromTool(individual) + expect(shape).toMatchObject({ + strokeTopWeight: 0, + strokeRightWeight: 2, + strokeBottomWeight: 3, + strokeLeftWeight: 4, + topLeftRadius: 0, + topRightRadius: 6, + bottomRightRadius: 7, + bottomLeftRadius: 8, + fills, + strokes + }) + await expect(applyCanvasFromTool(individual)).resolves.toMatchObject({ mutationCount: 0 }) + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-paragraph-layering.test.ts b/packages/extension/tests/mcp/tools/canvas-paragraph-layering.test.ts new file mode 100644 index 00000000..1cd6f4d9 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-paragraph-layering.test.ts @@ -0,0 +1,1093 @@ +import type { + CanvasFigmaTextRange, + CanvasResolvedApplyParameters, + CanvasVariableBindings +} from '@tempad-dev/shared' + +import { describe, expect, it, vi } from 'vitest' + +import { applyResolvedCanvas as applyCanvas } from '@/mcp/tools/canvas' +import { textParagraphs } from '@/mcp/tools/canvas/reconcile/text-paragraphs' + +import { createFixture } from '../../fakes/figma' + +type ParagraphField = 'paragraphIndent' | 'paragraphSpacing' +type ParagraphRange = { start: number; end: number; value: number } + +async function createParagraphProbe( + field: ParagraphField, + initialValues = [20, 10, 10], + characters = 'Hello\nworld\nagain' +) { + const fixture = createFixture() + const request = ( + base: number | undefined, + ranges?: Array, + variables?: CanvasVariableBindings + ): CanvasResolvedApplyParameters => ({ + mode: 'create', + markup: `
${characters || 'Empty'}
`, + bindings: { + label: { + variables, + figma: { + text: { + hangingPunctuation: false, + ...(base === undefined ? {} : { [field]: base }), + ...(ranges + ? { + ranges: ranges.map((range) => + 'value' in range + ? { start: range.start, end: range.end, [field]: range.value } + : range + ) + } + : {}) + } + } + } + } + }) + const created = await applyCanvas(request(undefined)) + const label = fixture.getNode(created.nodeIdsByKey.label!) as unknown as TextNode + if (!characters) label.characters = '' + expect(label.characters).toBe(characters) + + // Installed @figma/plugin-typings documents setRangeParagraphSpacing(0, 1, 20) + // changing the entire first paragraph, including its newline. ParagraphIndent + // likewise targets paragraphs containing the supplied characters. Model those + // semantics rather than the shared fake's independent start:end range records. + // TextStyle aliases also use this paragraph model as a deterministic stress + // case. Native partial TextStyle alias propagation remains unconfirmed; these + // assertions do not establish its equivalence to direct variable binding. + let base = 0 + let offset = 0 + const paragraphs = characters.split('\n').map((text, index, all) => { + const start = offset + offset += text.length + (index < all.length - 1 ? 1 : 0) + return { + start, + end: offset, + value: initialValues[index]!, + alias: undefined as VariableAlias | undefined + } + }) + const affected = (start: number, end: number) => + paragraphs.filter((paragraph) => paragraph.start < end && paragraph.end > start) + const read = (start: number, end: number): number | typeof figma.mixed => { + const selected = affected(start, end) + return selected.every((paragraph) => paragraph.value === selected[0]!.value) + ? selected[0]!.value + : figma.mixed + } + const write = vi.fn((start: number, end: number, value: number) => { + // Captured native behavior: including the terminating newline in a literal + // setter also changes the following paragraph. Binding setters differ. + if (characters[end - 1] === '\n' && end < characters.length) end++ + for (const paragraph of affected(start, end)) { + paragraph.value = value + paragraph.alias = undefined + } + }) + // Native whole-node writes retain existing explicit paragraph values. + const writeBase = vi.fn((value: number) => { + base = value + }) + Object.defineProperty(label, field, { + configurable: true, + get: () => (characters ? read(0, label.characters.length) : base), + set: writeBase + }) + const suffix = field === 'paragraphIndent' ? 'ParagraphIndent' : 'ParagraphSpacing' + Object.defineProperty(label, `getRange${suffix}`, { value: read }) + Object.defineProperty(label, `setRange${suffix}`, { value: write }) + const readBinding = label.getRangeBoundVariable.bind(label) + const writeBinding = label.setRangeBoundVariable.bind(label) + Object.defineProperty(label, 'getRangeBoundVariable', { + value: (start: number, end: number, property: VariableBindableTextField) => { + if (property !== field) return readBinding(start, end, property) + const selected = affected(start, end) + return selected.every((paragraph) => paragraph.alias?.id === selected[0]!.alias?.id) + ? selected[0]!.alias + : figma.mixed + } + }) + const bind = vi.fn( + ( + start: number, + end: number, + property: VariableBindableTextField, + variable: Variable | null + ) => { + if (property !== field) return writeBinding(start, end, property, variable) + for (const paragraph of affected(start, end)) { + // Native paragraphSpacing null calls on 0:1 retained its alias, while + // clearing the complete paragraph stopped subsequent variable updates. + if (!variable && (start > paragraph.start || end < paragraph.end)) continue + paragraph.alias = variable ? { type: 'VARIABLE_ALIAS', id: variable.id } : undefined + if (variable) paragraph.value = variable.resolveForConsumer(label).value as number + } + } + ) + Object.defineProperty(label, 'setRangeBoundVariable', { value: bind }) + const nodeBind = vi.mocked(label.setBoundVariable).getMockImplementation()! + let baseAlias: string | undefined + vi.spyOn(label, 'setBoundVariable').mockImplementation((property, variable) => { + if (typeof variable === 'string') throw new Error('Deprecated variable IDs are unsupported') + if (property !== field) return nodeBind(property, variable) + if (variable && baseAlias === variable.id) return + baseAlias = variable?.id + for (const paragraph of paragraphs) { + paragraph.alias = variable ? { type: 'VARIABLE_ALIAS', id: variable.id } : undefined + if (variable) paragraph.value = variable.resolveForConsumer(label).value as number + } + }) + const initialBindings = label.boundVariables + Object.defineProperty(label, 'boundVariables', { + configurable: true, + get: () => ({ + ...initialBindings, + [field]: paragraphs.flatMap(({ alias }) => (alias ? [alias] : [])) + }) + }) + const readSegments = vi.mocked(label.getStyledTextSegments).getMockImplementation()! + const bindingSegments: TextNode['getStyledTextSegments'] = ((...args) => { + if (args[0].length !== 1 || args[0][0] !== 'boundVariables') return readSegments(...args) + return paragraphs.map(({ start, end, alias }) => ({ + start, + end, + characters: characters.slice(start, end), + boundVariables: alias ? { [field]: alias } : {} + })) + }) as TextNode['getStyledTextSegments'] + vi.spyOn(label, 'getStyledTextSegments').mockImplementation(bindingSegments) + + return { + fixture, + label, + values: () => paragraphs.map(({ value }) => value), + aliases: () => paragraphs.map(({ alias }) => alias?.id), + write, + writeBase, + bind, + update: ( + base: number | undefined, + ranges?: Array, + variables?: CanvasVariableBindings + ) => + applyCanvas({ + ...request(base, ranges, variables), + ...(!characters ? { markup: undefined } : {}), + mode: 'update', + targetNodeId: created.rootNodeId! + }) + } +} + +function mockRetainedParagraphNull(probe: Awaited>) { + const bind = probe.bind.getMockImplementation()! + probe.bind.mockImplementation((start, end, property, variable) => { + if (variable) bind(start, end, property, variable) + }) +} + +describe.each(['paragraphIndent', 'paragraphSpacing'] as const)('%s layering', (field) => { + it('models one newline extension even when null unbinding is ignored', async () => { + const probe = await createParagraphProbe(field, [8, 8, 18], 'A\n\nC') + mockRetainedParagraphNull(probe) + + // Keep the scoped fixture's setter behavior independent of the null-binding + // simulation: including one newline must not extend twice across an empty paragraph. + probe.write(0, 2, 12) + expect(probe.values()).toEqual([12, 12, 18]) + }) + + it.each([false, true])( + 'does not cross the authored paragraph end: node binding=%s', + async (nodeBinding) => { + const probe = await createParagraphProbe(field, [0, 0, 0], 'A\nB\nC') + const base = nodeBinding ? { [field]: { id: 'variable:spacing' } } : undefined + if (!nodeBinding) + await probe.update(undefined, [ + { start: 4, end: 5, variables: { [field]: { id: 'variable:range-size' } } } + ]) + const ranges = [{ start: 2, end: 4, [field]: 12 }] + await probe.update(undefined, ranges, base) + expect(probe.values()).toEqual(nodeBinding ? [8, 12, 8] : [0, 12, 18]) + expect(probe.aliases()).toEqual( + nodeBinding + ? ['variable:spacing', undefined, 'variable:spacing'] + : [undefined, undefined, 'variable:range-size'] + ) + await expect(probe.update(undefined, ranges, base)).resolves.toMatchObject({ + mutationCount: 0 + }) + } + ) + + it('does not clear a retained middle-paragraph binding when updating surrounding base values', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0], 'A\nB\nC') + await probe.update(undefined, [ + { start: 2, end: 3, variables: { [field]: { id: 'variable:range-size' } } } + ]) + await probe.update(20) + expect(probe.values()).toEqual([20, 18, 20]) + expect(probe.aliases()).toEqual([undefined, 'variable:range-size', undefined]) + await expect(probe.update(20)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('rejects a changed empty paragraph before writing into its neighbor', async () => { + const probe = await createParagraphProbe(field, [8, 8, 18], 'A\n\nC') + probe.write.mockClear() + await expect(probe.update(undefined, [{ start: 2, end: 3, [field]: 12 }])).rejects.toThrow( + 'empty paragraph 2:3' + ) + expect(probe.values()).toEqual([8, 8, 18]) + expect(probe.write).not.toHaveBeenCalled() + }) + it.each([ + { start: 0, end: 17, aliases: [undefined, undefined, undefined] }, + { start: 7, end: 8, aliases: ['variable:spacing', undefined, 'variable:spacing'] } + ])( + 'detaches retained paragraph aliases at $start:$end without flattening values', + async ({ start, end, aliases }) => { + const probe = await createParagraphProbe(field, [8, 18, 8]) + await probe.update( + undefined, + [{ start: 7, end: 8, variables: { [field]: { id: 'variable:range-size' } } }], + { [field]: { id: 'variable:spacing' } } + ) + mockRetainedParagraphNull(probe) + const ranges = [{ start, end, variables: { [field]: null } }] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([8, 18, 8]) + expect(probe.aliases()).toEqual(aliases) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + } + ) + + it('detaches the middle paragraph without changing the following base-bound paragraph', async () => { + const probe = await createParagraphProbe(field, [8, 18, 8], 'A\nB\nC') + const base = { [field]: { id: 'variable:spacing' } } + await probe.update( + undefined, + [{ start: 2, end: 3, variables: { [field]: { id: 'variable:range-size' } } }], + base + ) + mockRetainedParagraphNull(probe) + probe.write.mockClear() + const ranges = [{ start: 2, end: 3, variables: { [field]: null } }] + await probe.update(undefined, ranges, base) + expect(probe.values()).toEqual([8, 18, 8]) + expect(probe.aliases()).toEqual(['variable:spacing', undefined, 'variable:spacing']) + expect(probe.write).toHaveBeenCalledExactlyOnceWith(2, 3, 18) + await expect(probe.update(undefined, ranges, base)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('rejects a retained empty-paragraph alias without writing into the following paragraph', async () => { + const probe = await createParagraphProbe(field, [8, 8, 8], 'A\n\nC') + await probe.update(undefined, undefined, { [field]: { id: 'variable:spacing' } }) + mockRetainedParagraphNull(probe) + probe.write.mockClear() + await expect( + probe.update(undefined, [{ start: 2, end: 3, variables: { [field]: null } }]) + ).rejects.toThrow(`Cannot clear range 2:3 variable "${field}"`) + expect(probe.values()).toEqual([8, 8, 8]) + expect(probe.aliases()).toEqual(Array(3).fill('variable:spacing')) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('rejects a paragraph alias retained by both null and literal setters', async () => { + const probe = await createParagraphProbe(field) + await probe.update(undefined, undefined, { [field]: { id: 'variable:spacing' } }) + probe.bind.mockImplementation(() => {}) + probe.write.mockImplementation(() => {}) + await expect( + probe.update(undefined, [{ start: 7, end: 8, variables: { [field]: null } }]) + ).rejects.toThrow(`Cannot clear range 6:12 variable "${field}"`) + }) + + it('keeps a later style-owned paragraph intact during the null fallback', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const variable = { id: 'variable:spacing' } + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: variable.id } } + }) + const styled = { start: 8, end: 9, textStyle: { id: style.id } } + await probe.update(undefined, [styled], { [field]: variable }) + mockRetainedParagraphNull(probe) + probe.write.mockClear() + const ranges = [{ start: 0, end: 7, variables: { [field]: null } }, styled] + await probe.update(undefined, ranges) + expect(probe.aliases()).toEqual([undefined, variable.id, variable.id]) + expect(probe.values()).toEqual([8, 8, 8]) + expect(probe.write).toHaveBeenCalledExactlyOnceWith(0, 5, 8) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('converges a node binding with a literal override for one complete paragraph', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const variable = { id: 'variable:spacing' } + const ranges = [{ start: 7, end: 8, [field]: 13 }] + await probe.update(undefined, ranges, { [field]: variable }) + expect(probe.values()).toEqual([8, 13, 8]) + expect(probe.aliases()).toEqual([variable.id, undefined, variable.id]) + await expect(probe.update(undefined, ranges, { [field]: variable })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('does not skip a node binding over a foreign paragraph alias equal to the literal', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const variable = { id: 'variable:spacing' } + await probe.update( + undefined, + [{ start: 7, end: 8, variables: { [field]: { id: 'variable:range-size' } } }], + { [field]: variable } + ) + const ranges = [{ start: 7, end: 8, [field]: 18 }] + await probe.update(undefined, ranges, { [field]: variable }) + expect(probe.values()).toEqual([8, 18, 8]) + expect(probe.aliases()).toEqual([variable.id, undefined, variable.id]) + await expect(probe.update(undefined, ranges, { [field]: variable })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('keeps a retained TextStyle paragraph alias while repairing a separate literal', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const base = { id: 'variable:spacing' } + const foreign = { id: 'variable:range-size' } + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: foreign.id } } + }) + const styled = { start: 13, end: 14, textStyle: { id: style.id } } + await probe.update(undefined, [{ start: 7, end: 8, variables: { [field]: foreign } }, styled], { + [field]: base + }) + const ranges = [{ start: 7, end: 8, [field]: 18 }, styled] + await probe.update(undefined, ranges, { [field]: base }) + expect(probe.aliases()).toEqual([base.id, undefined, foreign.id]) + expect(probe.values()).toEqual([8, 18, 18]) + await expect(probe.update(undefined, ranges, { [field]: base })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('retains an alias declared before an equal literal in the same paragraph', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const base = { id: 'variable:spacing' } + const foreign = { id: 'variable:range-size' } + const ranges = [ + { start: 0, end: 1, variables: { [field]: foreign } }, + { start: 2, end: 3, [field]: 18 } + ] + await probe.update(undefined, ranges, { [field]: base }) + await probe.update(undefined, ranges, { [field]: base }) + expect(probe.aliases()).toEqual([foreign.id, base.id, base.id]) + expect(probe.values()).toEqual([18, 8, 8]) + }) + + it('retains an earlier unchanged TextStyle alias under an equal paragraph literal', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const base = { id: 'variable:spacing' } + const foreign = { id: 'variable:range-size' } + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: foreign.id } } + }) + const ranges = [ + { start: 0, end: 1, textStyle: { id: style.id } }, + { start: 2, end: 3, [field]: 18 } + ] + await probe.update(undefined, ranges, { [field]: base }) + await probe.update(undefined, ranges, { [field]: base }) + expect(probe.aliases()).toEqual([foreign.id, base.id, base.id]) + expect(probe.values()).toEqual([18, 8, 8]) + }) + + it('does not let an earlier style unlink protect a foreign literal alias', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const base = { id: 'variable:spacing' } + await probe.update( + undefined, + [{ start: 0, end: 1, variables: { [field]: { id: 'variable:range-size' } } }], + { [field]: base } + ) + const ranges = [ + { start: 0, end: 1, textStyle: null }, + { start: 2, end: 3, [field]: 18 } + ] + await probe.update(undefined, ranges, { [field]: base }) + expect(probe.aliases()).toEqual([undefined, base.id, base.id]) + expect(probe.values()).toEqual([18, 8, 8]) + }) + + it('skips base rebinding when a later style owns the entire literal paragraph', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const base = { id: 'variable:spacing' } + const foreign = { id: 'variable:range-size' } + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: foreign.id } } + }) + const ranges = [ + { start: 0, end: 1, [field]: 18 }, + { start: 2, end: 3, textStyle: { id: style.id } } + ] + await probe.update(undefined, ranges, { [field]: base }) + await expect(probe.update(undefined, ranges, { [field]: base })).resolves.toMatchObject({ + mutationCount: 0 + }) + expect(probe.aliases()).toEqual([foreign.id, base.id, base.id]) + }) + + it('binds the whole node when only one paragraph already uses the requested variable', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const variable = { id: 'variable:spacing' } + await probe.update(undefined, [{ start: 0, end: 1, variables: { [field]: variable } }]) + expect(probe.aliases()).toEqual([variable.id, undefined, undefined]) + + await probe.update(undefined, undefined, { [field]: variable }) + expect(probe.aliases()).toEqual([variable.id, variable.id, variable.id]) + expect(probe.values()).toEqual([8, 8, 8]) + await expect(probe.update(undefined, undefined, { [field]: variable })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('clears a node binding while retaining its explicit range override', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const variable = { id: 'variable:spacing' } + await probe.update(undefined, undefined, { [field]: variable }) + const ranges = [{ start: 6, end: 7, variables: { [field]: variable } }] + await probe.update(undefined, ranges, { [field]: null }) + expect(probe.aliases()).toEqual([undefined, variable.id, undefined]) + await expect(probe.update(undefined, ranges, { [field]: null })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('does not retain an equal literal alias when the node binding is explicitly cleared', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + await probe.update(undefined, undefined, { [field]: { id: 'variable:spacing' } }) + const ranges = [{ start: 6, end: 7, value: 8 }] + await probe.update(undefined, ranges, { [field]: null }) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + expect(probe.values()).toEqual([8, 8, 8]) + await expect(probe.update(undefined, ranges, { [field]: null })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('rejects an ignored node unbinding under a full-text equal literal', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + await probe.update(undefined, undefined, { [field]: { id: 'variable:spacing' } }) + vi.mocked(probe.label.setBoundVariable).mockImplementation(() => {}) + + await expect( + probe.update(undefined, [{ start: 0, end: 17, value: 8 }], { [field]: null }) + ).rejects.toThrow(`variable link "${field}" does not match`) + }) + + it.each([false, true])( + 'rejects an ignored node unbinding under a style: pending=%s', + async (pending) => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: 'variable:spacing' } } + }) + const styled = { start: 0, end: 17, textStyle: { id: style.id }, [field]: 12 } + await probe.update(undefined, [styled]) + const collection = figma.variables.createVariableCollection('Foreign') + const other = figma.variables.createVariable('Foreign', collection, 'FLOAT') + other.setValueForMode(collection.defaultModeId, 12) + await probe.update(undefined, undefined, { [field]: { id: other.id } }) + const segments = vi.mocked(probe.label.getStyledTextSegments).getMockImplementation()! + vi.mocked(probe.label.getStyledTextSegments).mockImplementation((...args) => + segments(...args).map((segment) => ({ + ...segment, + textStyleOverrides: [{ type: 'SEMANTIC_ITALIC' as const }] + })) + ) + vi.mocked(probe.label.setBoundVariable).mockImplementation(() => {}) + if (pending) { + vi.spyOn(probe.label, 'getRangeTextStyleId').mockReturnValue('style:foreign') + vi.spyOn(probe.label, 'setRangeTextStyleIdAsync').mockImplementation(async () => {}) + } + + await expect(probe.update(undefined, [styled], { [field]: null })).rejects.toThrow( + `variable link "${field}" does not match` + ) + } + ) + + it.each(['variable', 'null'] as const)( + 'preserves a range %s while restating the node binding', + async (override) => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const collection = figma.variables.createVariableCollection('Override') + const other = figma.variables.createVariable('Override', collection, 'FLOAT') + other.setValueForMode(collection.defaultModeId, 12) + const reference = override === 'null' ? null : { id: other.id } + const ranges = [{ start: 6, end: 7, variables: { [field]: reference } }] + const bindings = { [field]: { id: 'variable:spacing' } } + await probe.update(undefined, ranges, bindings) + expect(probe.aliases()).toEqual(['variable:spacing', reference?.id, 'variable:spacing']) + await expect(probe.update(undefined, ranges, bindings)).resolves.toMatchObject({ + mutationCount: 0 + }) + } + ) + + it('preserves a later style alias while clearing the node binding', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: 'variable:spacing' } } + }) + const ranges = [{ start: 6, end: 12, textStyle: { id: style.id } }] + await probe.update(undefined, ranges, { [field]: null }) + expect(probe.aliases()).toEqual([undefined, 'variable:spacing', undefined]) + await expect(probe.update(undefined, ranges, { [field]: null })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('does not retain a foreign alias under a locally overridden style when clearing the node', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: 'variable:spacing' } } + }) + const styled = { start: 6, end: 12, textStyle: { id: style.id }, [field]: 12 } + await probe.update(undefined, [styled]) + const collection = figma.variables.createVariableCollection('Foreign') + const other = figma.variables.createVariable('Foreign', collection, 'FLOAT') + other.setValueForMode(collection.defaultModeId, 12) + await probe.update(undefined, [{ start: 6, end: 7, variables: { [field]: { id: other.id } } }]) + expect(probe.aliases()).toEqual([undefined, other.id, undefined]) + + await probe.update(undefined, [styled], { [field]: null }) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + await expect(probe.update(undefined, [styled], { [field]: null })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it.each([false, true])( + 'updates unbound paragraphs while retaining a variable: range redeclared=%s', + async (redeclare) => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + const ranges = [{ start: 12, end: 13, variables: { [field]: { id: 'variable:spacing' } } }] + await probe.update(12, ranges) + expect(probe.values()).toEqual([12, 12, 8]) + + const changed = await probe.update(16, redeclare ? ranges : undefined) + expect(changed.mutationCount).toBe(2) + expect(probe.values()).toEqual([16, 16, 8]) + expect(probe.aliases()).toEqual([undefined, undefined, 'variable:spacing']) + expect(changed.verification.warnings).not.toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'literal-ignored-by-binding' })]) + ) + await expect(probe.update(16, redeclare ? ranges : undefined)).resolves.toMatchObject({ + mutationCount: 0 + }) + } + ) + + it('applies the new base in the same request that clears a partial paragraph binding', async () => { + const probe = await createParagraphProbe(field, [20, 20, 20]) + await probe.update(12, [ + { start: 6, end: 7, variables: { [field]: { id: 'variable:spacing' } } } + ]) + const ranges = [{ start: 7, end: 8, variables: { [field]: null } }] + await probe.update(16, ranges) + expect(probe.values()).toEqual([16, 16, 16]) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + await expect(probe.update(16, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('replaces prior paragraph values when only the base field is authored', async () => { + const probe = await createParagraphProbe(field, [20, 30], 'Hello\nworld') + expect((await probe.update(12)).mutationCount).toBe(2) + expect(probe.values()).toEqual([12, 12]) + expect(probe.writeBase).not.toHaveBeenCalled() + await expect(probe.update(12)).resolves.toMatchObject({ mutationCount: 0 }) + await expect( + probe.update(undefined, [ + { start: 0, end: 1, value: 12 }, + { start: 6, end: 7, value: 12 } + ]) + ).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('uses the whole-node setter for empty text without reading an empty range', async () => { + const probe = await createParagraphProbe(field, [], '') + expect((await probe.update(12)).mutationCount).toBe(1) + expect(probe.label[field]).toBe(12) + expect(probe.writeBase).toHaveBeenCalledExactlyOnceWith(12) + expect(probe.write).not.toHaveBeenCalled() + await expect(probe.update(12)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('preserves a partial-paragraph override without rewriting it on every apply', async () => { + const probe = await createParagraphProbe(field) + const updated = await probe.update(10, [{ start: 1, end: 2, value: 20 }]) + + expect(probe.values()).toEqual([20, 10, 10]) + expect(updated).toMatchObject({ mutationCount: 0, updatedNodeIds: [] }) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('preserves overrides when the base changes and converges', async () => { + const probe = await createParagraphProbe(field) + const ranges = [{ start: 1, end: 2, value: 20 }] + const updated = await probe.update(12, ranges) + + expect(probe.values()).toEqual([20, 12, 12]) + expect(updated.mutationCount).toBeGreaterThan(0) + + probe.write.mockClear() + await expect(probe.update(12, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('preserves an override on an empty first paragraph', async () => { + const probe = await createParagraphProbe(field, [20, 10, 10], '\nworld\nagain') + await expect(probe.update(10, [{ start: 0, end: 1, value: 20 }])).resolves.toMatchObject({ + mutationCount: 0, + updatedNodeIds: [] + }) + expect(probe.values()).toEqual([20, 10, 10]) + expect(probe.write).not.toHaveBeenCalled() + }) + + it.each([ + { start: 1, end: 5, values: [20, 10, 10], boundary: 'before the newline' }, + { start: 1, end: 6, values: [20, 10, 10], boundary: 'after the newline' }, + { start: 5, end: 6, values: [20, 10, 10], boundary: 'only the newline' }, + { start: 6, end: 7, values: [10, 20, 10], boundary: 'the next paragraph start' }, + { start: 5, end: 7, values: [20, 20, 10], boundary: 'across the newline' } + ])('respects $boundary coverage', async ({ start, end, values }) => { + const probe = await createParagraphProbe(field, values) + const updated = await probe.update(10, [{ start, end, value: 20 }]) + + expect(probe.values()).toEqual(values) + expect(updated).toMatchObject({ mutationCount: 0, updatedNodeIds: [] }) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('coalesces adjacent equal overrides within the same paragraph', async () => { + const probe = await createParagraphProbe(field) + const updated = await probe.update(10, [ + { start: 0, end: 1, value: 20 }, + { start: 1, end: 2, value: 20 } + ]) + + expect(probe.values()).toEqual([20, 10, 10]) + expect(updated).toMatchObject({ mutationCount: 0, updatedNodeIds: [] }) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('preserves the later override when adjacent character ranges touch the same paragraph', async () => { + const probe = await createParagraphProbe(field, [30, 10, 10]) + const updated = await probe.update(10, [ + { start: 0, end: 1, value: 20 }, + { start: 1, end: 2, value: 30 } + ]) + + expect(probe.values()).toEqual([30, 10, 10]) + expect(updated).toMatchObject({ mutationCount: 0, updatedNodeIds: [] }) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('restores the authored base when its range override is removed', async () => { + const probe = await createParagraphProbe(field) + expect((await probe.update(10)).mutationCount).toBe(3) + expect(probe.values()).toEqual([10, 10, 10]) + + probe.write.mockClear() + await expect(probe.update(10)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('preserves existing paragraph values when the field is omitted', async () => { + const probe = await createParagraphProbe(field) + await expect(probe.update(undefined)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.values()).toEqual([20, 10, 10]) + expect(probe.write).not.toHaveBeenCalled() + + expect((await probe.update(undefined, [{ start: 1, end: 2, value: 25 }])).mutationCount).toBe(1) + expect(probe.values()).toEqual([25, 10, 10]) + probe.write.mockClear() + await expect(probe.update(undefined)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.values()).toEqual([25, 10, 10]) + expect(probe.write).not.toHaveBeenCalled() + }) + + it('converges a variable overridden by a later literal in the same paragraph', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 7, variables: { [field]: { id: 'variable:spacing' } } }, + { start: 8, end: 9, [field]: 30 } + ] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([8, 30, 0]) + expect(probe.aliases()).toEqual(['variable:spacing', undefined, undefined]) + + probe.write.mockClear() + probe.bind.mockClear() + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.write).not.toHaveBeenCalled() + expect(probe.bind).not.toHaveBeenCalled() + + // Removing the later literal restores the earlier variable on that paragraph. + await probe.update(undefined, [ranges[0]!]) + expect(probe.values()).toEqual([8, 8, 0]) + expect(probe.aliases()).toEqual(['variable:spacing', 'variable:spacing', undefined]) + await expect(probe.update(undefined, [ranges[0]!])).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it('does not treat a later null binding as ownership of the paragraph value', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + await probe.update(undefined, [ + { start: 0, end: 1, variables: { [field]: { id: 'variable:spacing' } } }, + { start: 2, end: 3, variables: { [field]: null } } + ]) + expect(probe.values()).toEqual([8, 0, 0]) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + }) + + it('clears an explicit null binding before an equal later paragraph literal', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + await probe.update(undefined, [ + { start: 0, end: 1, variables: { [field]: { id: 'variable:spacing' } } } + ]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: null } }, + { start: 2, end: 3, [field]: 8 } + ] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([8, 0, 0]) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it.each([ + { start: 0, end: 1, laterStart: 2, laterEnd: 3 }, + { start: 0, end: 7, laterStart: 8, laterEnd: 9 } + ])( + 'preserves a later paragraph binding after null at $start:$end', + async ({ start, end, laterStart, laterEnd }) => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + await probe.update(undefined, [ + { start: 0, end: 17, variables: { [field]: { id: 'variable:spacing' } } } + ]) + const ranges: CanvasFigmaTextRange[] = [ + { start, end, variables: { [field]: null } }, + { + start: laterStart, + end: laterEnd, + variables: { [field]: { id: 'variable:spacing' } } + } + ] + const aliases = + laterStart === 2 + ? ['variable:spacing', 'variable:spacing', 'variable:spacing'] + : [undefined, 'variable:spacing', 'variable:spacing'] + await probe.update(undefined, ranges) + expect(probe.aliases()).toEqual(aliases) + expect(probe.values()).toEqual([8, 8, 8]) + probe.bind.mockClear() + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.bind).not.toHaveBeenCalled() + + // Removing the later owner allows the null declaration to clear its paragraph. + await probe.update(undefined, [ranges[0]!]) + expect(probe.aliases()).toEqual( + laterStart === 2 + ? [undefined, 'variable:spacing', 'variable:spacing'] + : [undefined, undefined, 'variable:spacing'] + ) + } + ) + + it.each([false, true])( + 'preserves a later TextStyle paragraph binding after null: cross-paragraph=%s', + async (crossParagraph) => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: 'variable:spacing' } } + }) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: crossParagraph ? 7 : 1, variables: { [field]: null } }, + { + start: crossParagraph ? 8 : 2, + end: crossParagraph ? 9 : 3, + textStyle: { id: style.id } + } + ] + await probe.update(undefined, ranges) + const aliases = crossParagraph + ? [undefined, 'variable:spacing', undefined] + : ['variable:spacing', undefined, undefined] + expect(probe.aliases()).toEqual(aliases) + for (let repeat = 0; repeat < 2; repeat++) { + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.aliases()).toEqual(aliases) + } + } + ) + + it('clears null before a later unchanged TextStyle without a paragraph binding', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const styled: CanvasFigmaTextRange = { + start: 2, + end: 3, + textStyle: { id: 'style:text' } + } + await probe.update(undefined, [styled]) + await probe.update(undefined, [ + { start: 0, end: 1, variables: { [field]: { id: 'variable:spacing' } } } + ]) + expect(probe.aliases()).toEqual(['variable:spacing', undefined, undefined]) + + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: null } }, + styled + ] + await probe.update(undefined, ranges) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it.each(['equal literal', 'different literal', 'local null', 'later null'] as const)( + 'preserves the final paragraph binding when the later style has a %s', + async (override) => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: 'variable:spacing' } } + }) + const styled: CanvasFigmaTextRange = { + start: 2, + end: 3, + textStyle: { id: style.id } + } + if (override === 'local null') styled.variables = { [field]: null } + else if (override !== 'later null') styled[field] = override === 'equal literal' ? 8 : 12 + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: null } }, + styled, + ...(override === 'later null' ? [{ start: 4, end: 5, variables: { [field]: null } }] : []) + ] + await probe.update(undefined, ranges) + const aliases = [ + override === 'equal literal' ? 'variable:spacing' : undefined, + undefined, + undefined + ] + const values = [override === 'different literal' ? 12 : 8, 0, 0] + expect(probe.aliases()).toEqual(aliases) + expect(probe.values()).toEqual(values) + for (let repeat = 0; repeat < 2; repeat++) { + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + expect(probe.aliases()).toEqual(aliases) + expect(probe.values()).toEqual(values) + } + } + ) + + it('does not preserve a different alias when a later style retains semantic overrides', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const style = probe.fixture.styles.get('style:text') as TextStyle + Object.assign(style, { + hangingPunctuation: false, + boundVariables: { [field]: { type: 'VARIABLE_ALIAS', id: 'variable:spacing' } } + }) + const styled: CanvasFigmaTextRange = { + start: 2, + end: 3, + textStyle: { id: style.id }, + [field]: 12 + } + await probe.update(undefined, [styled]) + const collection = figma.variables.createVariableCollection('Other metric') + const other = figma.variables.createVariable('Other metric', collection, 'FLOAT') + other.setValueForMode(collection.defaultModeId, 12) + await probe.update(undefined, [{ start: 2, end: 3, variables: { [field]: { id: other.id } } }]) + const segments = vi.mocked(probe.label.getStyledTextSegments).getMockImplementation()! + vi.spyOn(probe.label, 'getStyledTextSegments').mockImplementation((...args) => + segments(...args).map((segment) => ({ + ...segment, + textStyleOverrides: [{ type: 'SEMANTIC_ITALIC' as const }] + })) + ) + expect(probe.aliases()).toEqual([other.id, undefined, undefined]) + + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: null } }, + styled + ] + await probe.update(undefined, ranges) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + expect(probe.values()).toEqual([12, 0, 0]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it.each([ + { start: 1, end: 2, aliases: [undefined, 'variable:spacing', 'variable:spacing'] }, + { start: 5, end: 6, aliases: [undefined, 'variable:spacing', 'variable:spacing'] }, + { start: 5, end: 7, aliases: [undefined, undefined, 'variable:spacing'] }, + { start: 0, end: 17, aliases: [undefined, undefined, undefined] } + ])('clears touched paragraphs only for $start:$end', async ({ start, end, aliases }) => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + await probe.update(undefined, [ + { start: 0, end: 17, variables: { [field]: { id: 'variable:spacing' } } } + ]) + const ranges: CanvasFigmaTextRange[] = [{ start, end, variables: { [field]: null } }] + await probe.update(undefined, ranges) + expect(probe.aliases()).toEqual(aliases) + expect(probe.values()).toEqual([8, 8, 8]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('preserves a later independent paragraph binding after clearing the first paragraph', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const collection = figma.variables.createVariableCollection('Later metric') + const later = figma.variables.createVariable('Later metric', collection, 'FLOAT') + later.setValueForMode(collection.defaultModeId, 12) + await probe.update(undefined, [ + { start: 0, end: 17, variables: { [field]: { id: 'variable:spacing' } } } + ]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 1, end: 2, variables: { [field]: null } }, + { start: 6, end: 7, variables: { [field]: { id: later.id } } } + ] + await probe.update(undefined, ranges) + expect(probe.aliases()).toEqual([undefined, later.id, 'variable:spacing']) + expect(probe.values()).toEqual([8, 12, 8]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('retains the requested alias when an equal later literal would not overwrite it', async () => { + const probe = await createParagraphProbe(field, [8, 0, 0]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: { id: 'variable:spacing' } } }, + { start: 2, end: 3, [field]: 8 } + ] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([8, 0, 0]) + expect(probe.aliases()).toEqual(['variable:spacing', undefined, undefined]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('does not preserve a different alias just because its value equals a later literal', async () => { + const probe = await createParagraphProbe(field, [0, 0, 0]) + const collection = figma.variables.createVariableCollection('Other metric') + const other = figma.variables.createVariable('Other metric', collection, 'FLOAT') + other.setValueForMode(collection.defaultModeId, 30) + await probe.update(undefined, [{ start: 0, end: 1, variables: { [field]: { id: other.id } } }]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: { id: 'variable:spacing' } } }, + { start: 2, end: 3, [field]: 30 } + ] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([30, 0, 0]) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('preserves a later literal setter clearing aliases beyond the earlier variable range', async () => { + const probe = await createParagraphProbe(field, [30, 0, 0]) + const collection = figma.variables.createVariableCollection('Other metric') + const other = figma.variables.createVariable('Other metric', collection, 'FLOAT') + other.setValueForMode(collection.defaultModeId, 30) + await probe.update(undefined, [{ start: 6, end: 7, variables: { [field]: { id: other.id } } }]) + const ranges: CanvasFigmaTextRange[] = [ + { start: 0, end: 1, variables: { [field]: { id: 'variable:spacing' } } }, + { start: 2, end: 7, [field]: 30 } + ] + await probe.update(undefined, ranges) + expect(probe.values()).toEqual([30, 30, 0]) + expect(probe.aliases()).toEqual([undefined, undefined, undefined]) + await expect(probe.update(undefined, ranges)).resolves.toMatchObject({ mutationCount: 0 }) + }) +}) + +describe('paragraph comparison scopes', () => { + it('preserves newline, empty-paragraph, and cross-paragraph boundaries for each field', () => { + const paragraphs = textParagraphs('\nHi\nnext\nend', [ + { start: 0, end: 1, paragraphIndent: 1 }, + { start: 1, end: 2, paragraphIndent: 2 }, + { start: 2, end: 5, paragraphSpacing: 3 }, + { start: 5, end: 6, textStyle: null, variables: { paragraphIndent: null } }, + { start: 6, end: 7, textStyle: { id: 'style:type' } }, + { start: 9, end: 10, paragraphIndent: 4 } + ]) + expect(paragraphs.map(({ span }) => span)).toEqual([ + { start: 0, end: 1 }, + { start: 1, end: 4 }, + { start: 1, end: 9 }, + { start: 4, end: 9 }, + { start: 4, end: 9 }, + { start: 9, end: 12 } + ]) + expect(paragraphs[1]!.effective).toEqual({ + paragraphIndent: { start: 1, end: 4 }, + paragraphSpacing: { start: 1, end: 1 }, + listSpacing: { start: 1, end: 4 }, + textWrapStyle: { start: 1, end: 4 } + }) + expect(paragraphs[2]!.effective).toEqual({ + paragraphIndent: { start: 1, end: 4 }, + paragraphSpacing: { start: 1, end: 4 }, + listSpacing: { start: 1, end: 4 }, + textWrapStyle: { start: 1, end: 4 } + }) + }) + + it.each([null, { id: 'variable:spacing' }])( + 'distinguishes a null unbinding from a paragraph variable owner: %j', + (reference) => { + const paragraphs = textParagraphs('Hello\nworld', [ + { start: 0, end: 1, paragraphIndent: 10 }, + { start: 2, end: 3, textStyle: null, variables: { paragraphIndent: reference } } + ]) + expect(paragraphs[0]!.effective).toEqual({ + paragraphIndent: { start: 0, end: reference ? 0 : 6 }, + paragraphSpacing: { start: 0, end: 6 }, + listSpacing: { start: 0, end: 6 }, + textWrapStyle: { start: 0, end: 6 } + }) + } + ) + + it('uses UTF-16 offsets when a range ends exactly after a newline', () => { + const paragraphs = textParagraphs('😀\nline', [ + { start: 0, end: 3, paragraphIndent: 10 }, + { start: 3, end: 4, paragraphIndent: 20 } + ]) + expect(paragraphs.map(({ span }) => span)).toEqual([ + { start: 0, end: 3 }, + { start: 3, end: 7 } + ]) + expect(paragraphs[0]!.effective.paragraphIndent).toEqual({ start: 0, end: 3 }) + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-range-style-overrides.test.ts b/packages/extension/tests/mcp/tools/canvas-range-style-overrides.test.ts new file mode 100644 index 00000000..fcc7a0fd --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-range-style-overrides.test.ts @@ -0,0 +1,317 @@ +import type { CanvasFigmaTextRange } from '@tempad-dev/shared' + +import { describe, expect, it, vi } from 'vitest' + +import { applyResolvedCanvas as applyCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +const FIELDS = [ + 'fontName', + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'textCase', + 'textDecoration', + 'paragraphIndent', + 'paragraphSpacing', + 'listSpacing' +] as const +type Field = (typeof FIELDS)[number] + +async function styledRangeFixture() { + const fixture = createFixture() + const created = await applyCanvas({ + mode: 'create', + markup: + '
Range metrics
' + }) + const node = fixture.getNode(created.nodeIdsByKey.label!) as unknown as TextNode + const style = fixture.styles.get('style:text') as TextStyle + const values = Object.fromEntries(FIELDS.map((field) => [field, node[field]])) as Pick< + TextStyle, + Field + > + const aliases: Partial> = {} + let styleId = '' + for (const field of FIELDS) { + const suffix = field[0]!.toUpperCase() + field.slice(1) + Object.defineProperty(node, `getRange${suffix}`, { + configurable: true, + value: () => values[field] + }) + Object.defineProperty(node, `setRange${suffix}`, { + configurable: true, + value: (_start: number, _end: number, value: never) => { + Object.assign(values, { [field]: field === 'fontSize' ? Math.fround(value) : value }) + if (field !== 'textDecoration') styleId = '' + if (field === 'fontName') { + delete aliases.fontFamily + delete aliases.fontStyle + delete aliases.fontWeight + } else if (field !== 'textCase' && field !== 'textDecoration' && field !== 'listSpacing') { + delete aliases[field] + } + } + }) + } + node.getRangeTextStyleId = () => styleId + node.setRangeTextStyleIdAsync = vi.fn(async (_start, _end, id) => { + styleId = id + if (!id) return + Object.assign(values, Object.fromEntries(FIELDS.map((field) => [field, style[field]]))) + values.fontSize = Math.fround(style.fontSize) + node.leadingTrim = style.leadingTrim + node.hangingPunctuation = style.hangingPunctuation + node.hangingList = style.hangingList + for (const field of Object.keys(aliases) as VariableBindableTextField[]) delete aliases[field] + for (const [field, alias] of Object.entries(style.boundVariables ?? {})) { + const variable = await figma.variables.getVariableByIdAsync(alias.id) + node.setRangeBoundVariable(0, 5, field as VariableBindableTextField, variable) + } + }) + node.getRangeBoundVariable = (_start, _end, field) => aliases[field] ?? null + node.setRangeBoundVariable = vi.fn( + (_start: number, _end: number, field: VariableBindableTextField, variable: Variable | null) => { + if (!variable) { + delete aliases[field] + return + } + aliases[field] = { type: 'VARIABLE_ALIAS', id: variable.id } + const value = variable.resolveForConsumer(node).value + if (field === 'fontSize') values.fontSize = Math.fround(value as number) + else if (field === 'lineHeight' || field === 'letterSpacing') { + values[field] = { unit: 'PIXELS', value: value as number } + } + } + ) + const update = (override: Partial = {}) => + applyCanvas({ + mode: 'update', + targetNodeId: created.rootNodeId!, + bindings: { + label: { + figma: { + text: { ranges: [{ start: 0, end: 5, textStyle: { id: style.id }, ...override }] } + } + } + } + }) + return { node, style, values, aliases, update } +} + +describe('range TextStyle with detached typography overrides', () => { + it.each([{ fontSize: 28 }, { lineHeight: { unit: 'PIXELS' as const, value: 40 } }])( + 'converges repeated overrides: %j', + async (override) => { + const { update, node } = await styledRangeFixture() + expect((await update(override)).mutationCount).toBe(2) + expect(node.getRangeTextStyleId(0, 5)).toBe('') + await expect(update(override)).resolves.toMatchObject({ + mutationCount: 0, + updatedNodeIds: [] + }) + } + ) + + it('applies changed overrides and current defaults, then restores the link when overrides are removed', async () => { + const { update, node, style, values } = await styledRangeFixture() + await update({ fontSize: 28 }) + await update({ fontSize: 30 }) + expect(values.fontSize).toBe(30) + style.fontName = { family: 'Inter', style: 'Regular' } + style.lineHeight = { unit: 'PIXELS', value: 42 } + await update({ fontSize: 30 }) + expect(values.fontName).toEqual(style.fontName) + expect(values.lineHeight).toEqual(style.lineHeight) + await expect(update({ fontSize: 30 })).resolves.toMatchObject({ mutationCount: 0 }) + await update() + expect(node.getRangeTextStyleId(0, 5)).toBe(style.id) + expect(values.fontSize).toBe(style.fontSize) + await update({ textStyle: null, fontSize: 30 }) + expect(node.getRangeTextStyleId(0, 5)).toBe('') + expect(values.fontSize).toBe(30) + await expect(update({ textStyle: null, fontSize: 30 })).resolves.toMatchObject({ + mutationCount: 0 + }) + }) + + it.each(['style', 'override'] as const)('rejects an ignored %s setter', async (ignored) => { + const { update, node } = await styledRangeFixture() + if (ignored === 'style') node.setRangeTextStyleIdAsync = vi.fn(async () => {}) + else node.setRangeFontSize = vi.fn() + await expect(update({ fontSize: 28 })).rejects.toMatchObject({ + code: 'CANVAS_APPLY_FAILED', + message: expect.stringContaining('range 0:5') + }) + }) + + it('resolves inherited style variables in the consumer mode and preserves explicit null unbinding', async () => { + const { update, node, style, values, aliases } = await styledRangeFixture() + const variable = await figma.variables.getVariableByIdAsync('variable:spacing') + variable!.resolveForConsumer = () => ({ value: 21, resolvedType: 'FLOAT' }) + style.boundVariables = { fontSize: { type: 'VARIABLE_ALIAS', id: variable!.id } } + await update({ lineHeight: { unit: 'PIXELS', value: 40 } }) + expect(values.fontSize).toBe(21) + expect(aliases.fontSize?.id).toBe(variable!.id) + await expect(update({ lineHeight: { unit: 'PIXELS', value: 40 } })).resolves.toMatchObject({ + mutationCount: 0 + }) + const cleared = { + lineHeight: { unit: 'PIXELS' as const, value: 40 }, + variables: { fontSize: null } + } + await update(cleared) + expect(values.fontSize).toBe(21) + expect(node.getRangeBoundVariable(0, 5, 'fontSize')).toBeNull() + await expect(update(cleared)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('keeps a final variable binding authoritative over its literal fallback', async () => { + const { update, values, aliases } = await styledRangeFixture() + const override = { fontSize: 28, variables: { fontSize: { id: 'variable:spacing' } } } + await update(override) + expect(values.fontSize).toBe(8) + expect(aliases.fontSize?.id).toBe('variable:spacing') + await expect(update(override)).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('restores the requested link for redundant literals and no-op unbindings', async () => { + const { update, node, style } = await styledRangeFixture() + await update() + node.setRangeFontSize(0, 5, style.fontSize) + expect(node.getRangeTextStyleId(0, 5)).toBe('') + await update({ fontSize: style.fontSize }) + expect(node.getRangeTextStyleId(0, 5)).toBe(style.id) + await expect(update({ fontSize: style.fontSize })).resolves.toMatchObject({ mutationCount: 0 }) + node.setRangeFontSize(0, 5, style.fontSize) + await update({ variables: { fontSize: null } }) + expect(node.getRangeTextStyleId(0, 5)).toBe(style.id) + }) + + it('repairs mismatched defaults even when the native style ID is already correct', async () => { + const { update, node, style, values } = await styledRangeFixture() + await update() + values.fontSize = 28 + values.lineHeight = { unit: 'PIXELS', value: 100 } + expect(node.getRangeTextStyleId(0, 5)).toBe(style.id) + await update({ fontSize: 28 }) + expect(values.lineHeight).toEqual(style.lineHeight) + expect(values.fontSize).toBe(28) + await expect(update({ fontSize: 28 })).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('does not equate a different native style link with matching typography', async () => { + const { update, node, values } = await styledRangeFixture() + await node.setRangeTextStyleIdAsync(0, 5, 'style:other') + values.fontSize = 28 + expect((await update({ fontSize: 28 })).mutationCount).toBe(2) + await expect(update({ fontSize: 28 })).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('retains an inherited alias when a fractional literal equals its native float32 value', async () => { + const { update, style, values, aliases } = await styledRangeFixture() + const variable = await figma.variables.getVariableByIdAsync('variable:spacing') + variable!.resolveForConsumer = () => ({ value: 21.1, resolvedType: 'FLOAT' }) + style.boundVariables = { fontSize: { type: 'VARIABLE_ALIAS', id: variable!.id } } + await update({ fontSize: 21.1 }) + expect(values.fontSize).toBe(21.100000381469727) + expect(aliases.fontSize?.id).toBe(variable!.id) + await expect(update({ fontSize: 21.1 })).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('rejects a silently ignored final variable binding', async () => { + const { update, node } = await styledRangeFixture() + node.setRangeBoundVariable = vi.fn() + await expect( + update({ fontSize: 28, variables: { fontSize: { id: 'variable:spacing' } } }) + ).rejects.toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + }) + + it('checks native style axes while preserving the authored named-face contract', async () => { + const { update, style, values } = await styledRangeFixture() + style.fontName = { family: 'Inter', style: 'Bold', variationSettings: { wght: 700, slnt: 0 } } + await update({ fontSize: 28 }) + style.fontName = { ...style.fontName, variationSettings: { wght: 720, slnt: 0 } } + await update({ fontSize: 28 }) + expect(values.fontName.variationSettings).toEqual({ wght: 720, slnt: 0 }) + await expect(update({ fontSize: 28 })).resolves.toMatchObject({ mutationCount: 0 }) + values.fontName = { ...values.fontName, variationSettings: { wght: 740, slnt: 0 } } + await expect( + update({ fontSize: 28, fontName: { family: 'Inter', style: 'Bold' } }) + ).resolves.toMatchObject({ mutationCount: 0 }) + }) + + it('keeps native semantic overrides on their existing acceptance path', async () => { + const { update, node, values } = await styledRangeFixture() + await update() + const originalSegments = node.getStyledTextSegments.bind(node) + let semantic = true + Object.defineProperty(node, 'getStyledTextSegments', { + value: (fields: string[], start = 0, end = node.characters.length) => { + if (!fields.includes('textStyleOverrides')) + return originalSegments(fields as never, start, end) + return [ + { + start, + end, + characters: node.characters.slice(start, end), + textStyleOverrides: semantic ? ['SEMANTIC_ITALIC'] : [] + } + ] + } + }) + values.fontName = { family: 'Inter', style: 'Bold Italic' } + const originalSetter = node.setRangeFontSize.bind(node) + node.setRangeFontSize = (start, end, value) => { + originalSetter(start, end, value) + semantic = false + } + await expect(update({ fontSize: 28 })).resolves.toMatchObject({ mutationCount: 1 }) + expect(values.fontName.style).toBe('Bold Italic') + expect(semantic).toBe(false) + }) + + it('does not infer exact variable-font identity from a numeric weight', async () => { + const { update, style, values } = await styledRangeFixture() + const variable = await figma.variables.getVariableByIdAsync('variable:spacing') + variable!.resolveForConsumer = () => ({ value: 450, resolvedType: 'FLOAT' }) + style.boundVariables = { fontWeight: { type: 'VARIABLE_ALIAS', id: variable!.id } } + await expect(update({ fontSize: 28 })).resolves.toBeDefined() + values.fontName = { + family: 'Inter', + style: 'Custom', + variationSettings: { wght: 450, slnt: -4 } + } + await expect(update({ fontSize: 28 })).resolves.toBeDefined() + }) + + it('respects a later override affecting the same paragraph as the style defaults', async () => { + const fixture = createFixture() + const created = await applyCanvas({ + mode: 'create', + markup: + '
One paragraph
' + }) + const node = fixture.getNode(created.nodeIdsByKey.label!) as unknown as TextNode + const request = { + mode: 'update' as const, + targetNodeId: created.rootNodeId!, + bindings: { + label: { + figma: { + text: { + ranges: [ + { start: 0, end: 1, textStyle: { id: 'style:text' }, fontSize: 28 }, + { start: 1, end: 2, paragraphSpacing: 30 } + ] + } + } + } + } + } + await applyCanvas(request) + expect(node.getRangeParagraphSpacing(0, node.characters.length)).toBe(30) + await expect(applyCanvas(request)).resolves.toMatchObject({ mutationCount: 0 }) + }) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-rollback.test.ts b/packages/extension/tests/mcp/tools/canvas-rollback.test.ts index f2d1423c..14e6b79f 100644 --- a/packages/extension/tests/mcp/tools/canvas-rollback.test.ts +++ b/packages/extension/tests/mcp/tools/canvas-rollback.test.ts @@ -8,6 +8,22 @@ import { handleApplyCanvas as applyCanvasFromTool } from '@/mcp/tools/canvas' import { CANVAS_KEY_NAMESPACE, CANVAS_PAGE_KEY_NAME } from '@/mcp/tools/canvas/identity' +import { + createApplyState, + markMutation, + recordCreatedNode +} from '@/mcp/tools/canvas/reconcile/state' +import { withUndoBoundary } from '@/mcp/tools/canvas/reconcile/undo' +import { + preflightStyleDeclarations, + prepareStyleResources, + removeStyleResources +} from '@/mcp/tools/canvas/styles' +import { + preflightVariableDeclarations, + reconcileVariableCollections, + removeVariableResources +} from '@/mcp/tools/canvas/variables' import { createFixture } from '../../fakes/figma' @@ -31,6 +47,8 @@ const changes: Array<{ { name: 'padding', classes: 'pl-8', read: (node) => node.paddingLeft }, { name: 'size constraint', classes: 'min-w-40', read: (node) => node.minWidth }, { name: 'rotation', classes: 'rotate-45', read: (node) => node.relativeTransform }, + { name: 'corner radius', classes: 'rounded-[12px]', read: (node) => node.cornerRadius }, + { name: 'stroke width', classes: 'border-[3px]', read: (node) => node.strokeWeight }, { name: 'shear', binding: (node) => ({ @@ -46,6 +64,606 @@ const changes: Array<{ ] describe('canvas rollback verification', () => { + it('certifies a failure before the native apply callback starts as aborted', async () => { + const fixture = createFixture() + const apply = vi.fn() + fixture.commitUndo.mockImplementationOnce(() => { + throw new Error('Undo boundary unavailable') + }) + await expect(withUndoBoundary(apply, createApplyState(null, new Set()))).rejects.toMatchObject({ + code: 'CANVAS_APPLY_ABORTED', + message: 'Undo boundary unavailable' + }) + expect(apply).not.toHaveBeenCalled() + }) + + it.each([undefined, 'INVALID_CANVAS_SPEC', 'CANVAS_APPLY_ABORTED'])( + 'does not certify a partial rollback as safe after %s', + async (code) => { + const fixture = createFixture() + const label = figma.createText() + const before = 12 + label.paragraphSpacing = before + const beforeName = label.name + fixture.triggerUndo.mockImplementationOnce(() => { + label.name = beforeName + }) + const state = createApplyState(label, new Set()) + const cause = Object.assign(new Error('Later native operation failed'), { code }) + const error = await withUndoBoundary(async () => { + label.name = 'Changed by request' + label.paragraphSpacing = before + 20 + markMutation(state, label) + throw cause + }, state).catch((error: unknown) => error) + + // Undo restored the name but left the paragraph metric changed. + expect(label.paragraphSpacing).toBe(before + 20) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED', cause }) + if (code === 'INVALID_CANVAS_SPEC') expect((error as Error).message).toContain(code) + expect((error as Error).message).not.toContain('CANVAS_APPLY_ABORTED') + } + ) + + it.each(['no-op', 'cancelled changes'] as const)( + 'does not consume a previous successful edit after counted %s', + async (kind) => { + const fixture = createFixture() + const label = figma.createText() + label.fontSize = 8 + const first = createApplyState(label, new Set()) + await withUndoBoundary(async () => { + label.fontSize = 13 + markMutation(first, label) + }, first) + fixture.triggerUndo.mockImplementation(() => { + label.fontSize = 8 + }) + const state = createApplyState(label, new Set()) + const cause = new Error('Verification failed after a counted setter') + const error = await withUndoBoundary(async () => { + if (kind === 'cancelled changes') { + label.fontSize = 18 + markMutation(state, label) + } + label.fontSize = 13 + markMutation(state, label) + throw cause + }, state).catch((error: unknown) => error) + + expect(label.fontSize).toBe(13) + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ + code: 'CANVAS_APPLY_FAILED', + cause, + message: expect.stringContaining('Undo was skipped') + }) + } + ) + + it('attempts Undo for paragraph-only changes and detects incomplete restoration', async () => { + const fixture = createFixture() + const label = figma.createText() + label.paragraphSpacing = 12 + const state = createApplyState(label, new Set()) + const cause = new Error('Later operation failed') + const error = await withUndoBoundary(async () => { + label.paragraphSpacing = 24 + markMutation(state, label) + throw cause + }, state).catch((error: unknown) => error) + + expect(label.paragraphSpacing).toBe(24) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED', cause }) + expect((error as Error).message).toContain(`Rollback changed pre-existing node ${label.id}`) + }) + + it.each( + (['add', 'remove', 'value'] as const).flatMap((change) => + [false, true].map((restored) => ({ change, restored })) + ) + )( + 'detects component property $change and verifies restoration=$restored', + async ({ change, restored }) => { + const fixture = createFixture() + const component = figma.createComponent() + const property = component.addComponentProperty('Label', 'TEXT', 'Before') + const definitions = component.componentPropertyDefinitions + const before = structuredClone(definitions) + const state = createApplyState(component, new Set()) + const cause = new Error('Later operation failed') + if (restored) { + fixture.triggerUndo.mockImplementationOnce(() => { + for (const name of Object.keys(definitions)) delete definitions[name] + Object.assign(definitions, before) + }) + } + const error = await withUndoBoundary(async () => { + if (change === 'add') component.addComponentProperty('Visible', 'BOOLEAN', true) + else if (change === 'remove') component.deleteComponentProperty(property) + else component.editComponentProperty(property, { defaultValue: 'After' }) + markMutation(state, component) + throw cause + }, state).catch((error: unknown) => error) + + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED', cause }) + if (restored) { + expect(definitions).toEqual(before) + expect((error as Error).message).not.toContain('automatic rollback was not available') + } else { + expect((error as Error).message).toContain( + `Rollback changed pre-existing node ${component.id}` + ) + } + } + ) + + it.each([ + 'fontSize', + 'lineHeight', + 'letterSpacing', + 'paragraphIndent', + 'paragraphSpacing', + 'boundVariables' + ] as const)( + 'admits a retained rich-text %s change even when whole-node fields stay mixed', + async (field) => { + const fixture = createFixture() + const label = figma.createText() + label.characters = 'A\nB' + let value: unknown = + field === 'boundVariables' ? { fontSize: { type: 'VARIABLE_ALIAS', id: 'before' } } : 12 + const before = value + vi.mocked(label.getStyledTextSegments).mockImplementation( + () => + [{ start: 0, end: 3, characters: label.characters, [field]: value }] as ReturnType< + TextNode['getStyledTextSegments'] + > + ) + const state = createApplyState(label, new Set()) + fixture.triggerUndo.mockImplementationOnce(() => { + value = before + }) + const error = await withUndoBoundary(async () => { + value = + field === 'boundVariables' ? { fontSize: { type: 'VARIABLE_ALIAS', id: 'after' } } : 24 + markMutation(state, label) + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(value).toEqual(before) + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + expect((error as Error).message).not.toContain('automatic rollback was not available') + } + ) + + it.each(['collection', 'variable', 'style'] as const)( + 'admits retained newly created %s evidence and verifies its removal', + async (kind) => { + const fixture = createFixture() + const state = createApplyState(null, new Set()) + const existingCollection = figma.variables.createVariableCollection('Existing') + const beforeCollections = new Set(fixture.variableCollections.keys()) + const beforeVariables = new Set(fixture.variables.keys()) + const beforeStyles = new Set(fixture.styles.keys()) + fixture.triggerUndo.mockImplementationOnce(() => { + for (const [id, style] of fixture.styles) if (!beforeStyles.has(id)) style.remove() + for (const [id, variable] of fixture.variables) + if (!beforeVariables.has(id)) variable.remove() + for (const [id, collection] of fixture.variableCollections) + if (!beforeCollections.has(id)) collection.remove() + }) + const error = await withUndoBoundary(async () => { + if (kind === 'style') { + await prepareStyleResources( + { paint: { type: 'PAINT', name: 'Paint' } }, + state.styles, + state.mutations + ) + } else if (kind === 'collection') { + await reconcileVariableCollections( + { tokens: { name: 'Tokens', modes: { base: { name: 'Base' } } } }, + state.variables, + state.mutations + ) + } else { + const collection = existingCollection + await reconcileVariableCollections( + { + tokens: { + id: collection.id, + variables: { + value: { name: 'Value', type: 'FLOAT', values: { [collection.defaultModeId]: 8 } } + } + } + }, + state.variables, + state.mutations + ) + } + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + expect((error as Error).message).not.toContain('automatic rollback was not available') + expect(new Set(fixture.variableCollections.keys())).toEqual(beforeCollections) + expect(new Set(fixture.variables.keys())).toEqual(beforeVariables) + expect(new Set(fixture.styles.keys())).toEqual(beforeStyles) + } + ) + + it.each(['collection', 'variable', 'style'] as const)( + 'admits an existing %s change without any changed canvas node', + async (kind) => { + const fixture = createFixture() + const state = createApplyState(null, new Set()) + const collection = figma.variables.createVariableCollection('Existing') + const variable = figma.variables.createVariable('Value', collection, 'FLOAT') + variable.setValueForMode(collection.defaultModeId, 8) + const style = figma.createPaintStyle() + if (kind === 'style') + await preflightStyleDeclarations( + { paint: { id: style.id, type: style.type as 'PAINT', name: 'Changed' } }, + state.styles + ) + else + await preflightVariableDeclarations( + { + tokens: { + id: kind === 'variable' ? variable.variableCollectionId : collection.id, + ...(kind === 'variable' + ? { variables: { value: { id: variable.id, name: 'Changed' } } } + : { name: 'Changed' }) + } + }, + state.variables + ) + const resource = kind === 'style' ? style : kind === 'variable' ? variable : collection + const name = resource.name + fixture.triggerUndo.mockImplementationOnce(() => { + resource.name = name + }) + const error = await withUndoBoundary(async () => { + resource.name = 'Changed' + state.mutations.count += 1 + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(resource.name).toBe(name) + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + expect((error as Error).message).not.toContain('automatic rollback was not available') + } + ) + + it('does not discard confirmed creation evidence when another touched node is uninspectable', async () => { + const fixture = createFixture() + const unknown = figma.createFrame() + const state = createApplyState(null, new Set()) + let created: FrameNode | undefined + const error = await withUndoBoundary(async () => { + created = figma.createFrame() + recordCreatedNode(created, state) + markMutation(state, unknown) + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(created?.removed).toBe(true) + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + }) + + it.each(['collection', 'variable', 'style'] as const)( + 'admits a confirmed existing %s removal without trusting a missing lookup alone', + async (kind) => { + const fixture = createFixture() + const state = createApplyState(null, new Set()) + const collection = figma.variables.createVariableCollection('Existing') + const variable = figma.variables.createVariable('Value', collection, 'FLOAT') + const style = figma.createPaintStyle() + const resource = kind === 'style' ? style : kind === 'variable' ? variable : collection + const remove = vi.mocked(resource.remove).getMockImplementation()! + vi.spyOn(resource, 'remove').mockImplementation(() => { + remove() + Object.defineProperty(resource, 'id', { + get() { + throw new Error('Removed resource is unavailable') + } + }) + }) + if (kind === 'style') state.styles.selections = [{ key: 'style', style, spec: null }] + else if (kind === 'variable') + state.variables.selectedVariables = new Map([['value', variable]]) + else state.variables.selectedCollections = new Map([['tokens', collection]]) + const error = await withUndoBoundary(async () => { + if (kind === 'style') { + state.styles.removals.push({ key: 'style', style }) + await removeStyleResources(state.styles, state.mutations) + } else { + if (kind === 'variable') state.variables.variableRemovals.push(variable) + else state.variables.collectionRemovals.push(collection) + await removeVariableResources(state.variables, state.mutations) + } + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + expect((error as Error).message).toContain('Rollback did not restore pre-existing resource') + } + ) + + it.each(['no-op', 'net-zero', 'missing lookup'] as const)( + 'does not treat %s resource observations as a distinct Undo entry', + async (kind) => { + const fixture = createFixture() + const state = createApplyState(null, new Set()) + const style = figma.createPaintStyle() + state.styles.selections = [{ key: 'style', style, spec: { type: 'PAINT' } }] + const before = style.name + const error = await withUndoBoundary(async () => { + if (kind === 'net-zero') { + style.name = 'Changed' + state.mutations.count += 1 + } + style.name = before + state.mutations.count += 1 + if (kind === 'missing lookup') { + vi.mocked(figma.getStyleByIdAsync).mockResolvedValue(null) + vi.mocked(figma.getStyleById).mockReturnValue(null) + } + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + expect((error as Error).message).toContain('Undo was skipped') + } + ) + + it('does not use an unrelated user edit as evidence for this attempt', async () => { + const fixture = createFixture() + const root = figma.createFrame() + const unrelated = figma.createFrame() + const state = createApplyState(root, new Set()) + const error = await withUndoBoundary(async () => { + unrelated.name = 'User edit' + markMutation(state, root) + throw new Error('No-op setter failed verification') + }, state).catch((error: unknown) => error) + + expect(unrelated.name).toBe('User edit') + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + }) + + it.each(['node', 'page'] as const)( + 'does not use a removed newly created %s as evidence for an Undo entry', + async (kind) => { + const fixture = createFixture() + const state = createApplyState(null, new Set()) + const error = await withUndoBoundary(async () => { + if (kind === 'page') { + const page = figma.createPage() + state.createdPageIds.add(page.id) + state.mutations.count += 1 + page.remove() + } else { + const node = figma.createFrame() + recordCreatedNode(node, state) + node.remove() + } + throw new Error('Creation was already cancelled') + }, state).catch((error: unknown) => error) + + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + } + ) + + it('does not attempt Undo when a touched node can no longer be inspected', async () => { + const fixture = createFixture() + const root = figma.createFrame() + const state = createApplyState(root, new Set()) + const cause = new Error('Later operation failed') + const error = await withUndoBoundary(async () => { + markMutation(state, root) + Object.defineProperty(root, 'name', { + get: () => { + throw new Error('Native state unavailable') + } + }) + throw cause + }, state).catch((error: unknown) => error) + + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED', cause }) + }) + + it('does not interpret an unresolved touched node as a confirmed removal', async () => { + const fixture = createFixture() + const root = figma.createFrame() + const state = createApplyState(root, new Set()) + const error = await withUndoBoundary(async () => { + markMutation(state, root) + vi.mocked(figma.getNodeById).mockReturnValue(null) + vi.mocked(figma.getNodeByIdAsync).mockResolvedValue(null) + throw new Error('Native lookup unavailable') + }, state).catch((error: unknown) => error) + + expect(root.removed).toBe(false) + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + }) + + it('attempts Undo for a confirmed removal from this request', async () => { + const fixture = createFixture() + const root = figma.createFrame() + const state = createApplyState(root, new Set()) + state.removalNodeIds.add(root.id) + const error = await withUndoBoundary(async () => { + root.remove() + state.mutations.count += 1 + throw new Error('Later operation failed') + }, state).catch((error: unknown) => error) + + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toMatchObject({ + code: 'CANVAS_APPLY_FAILED', + message: expect.stringContaining('Rollback did not preserve pre-existing node') + }) + }) + + it('keeps an uncounted native failure uncertain after the write phase starts', async () => { + const fixture = createFixture() + const root = figma.createFrame() + const state = createApplyState(root, new Set()) + const cause = new Error('Setter failed after changing native state') + const error = await withUndoBoundary(async () => { + root.name = 'Changed before mutation accounting' + throw cause + }, state).catch((error: unknown) => error) + + expect(root.name).toBe('Changed before mutation accounting') + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED', cause }) + }) + + it.each([false, true])( + 'adds only the innermost context to a nested native failure (non-Error=%s)', + async (nonError) => { + const fixture = createFixture() + const cause = new Error('native cause') + const failure = Object.assign(new Error('paint unavailable.', { cause }), { + code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC + }) + vi.mocked(figma.util.solidPaint).mockImplementationOnce(() => { + throw nonError ? 'paint unavailable.' : failure + }) + const error = await applyCanvas({ + mode: 'create', + markup: + '
', + bindings: { outer: { figma: { group: true } }, inner: { figma: { group: true } } } + }).catch((error: unknown) => error) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toMatch(/^paint unavailable on "child" \(node:\d+\)\./) + expect(error).toMatchObject({ code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED }) + if (!nonError) { + expect((error as Error).cause).toBe(failure) + expect((error as Error).message).toContain('INVALID_CANVAS_SPEC') + expect(failure.cause).toBe(cause) + expect(failure.code).toBe(TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC) + } + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + } + ) + + it('joins a node failure and rollback failure without repeated punctuation', async () => { + const fixture = createFixture() + fixture.triggerUndo.mockImplementationOnce(() => { + throw new Error('Undo unavailable.') + }) + vi.mocked(figma.util.solidPaint).mockImplementationOnce(() => { + throw new Error('paint unavailable.') + }) + const error = await applyCanvas({ + mode: 'create', + markup: '
' + }).catch((error: unknown) => error) + expect(error).toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: expect.objectContaining({ + message: expect.stringMatching(/^paint unavailable on "root"/) + }) + }) + expect((error as Error).message).toMatch(/on "root" \(node:\d+\); automatic rollback/) + expect((error as Error).message).toContain('Undo unavailable. Read back the affected canvas') + expect((error as Error).message).not.toContain('or use Figma Undo') + }) + + it.each( + (['component', 'hyperlink', 'pattern'] as const).flatMap((reference) => + [false, true].map((corruptUndo) => ({ reference, corruptUndo })) + ) + )( + 'snapshots an outside-scope $reference after preparation and detects corruption=$corruptUndo', + async ({ reference, corruptUndo }) => { + const fixture = createFixture() + const otherPage = figma.createPage() + const referenced = reference === 'component' ? figma.createComponent() : figma.createFrame() + otherPage.appendChild(referenced) + referenced.name = 'Before user edit' + const bindings: Record = { + wait: { figma: { text: { fontName: { family: 'Roboto', style: 'Regular' } } } } + } + let referenceMarkup = '' + if (reference === 'component') { + bindings.reference = { component: { id: referenced.id } } + referenceMarkup = '
' + } else if (reference === 'hyperlink') { + bindings.reference = { + figma: { text: { hyperlink: { type: 'NODE', value: referenced.id } } } + } + referenceMarkup = 'Link' + } else { + bindings.root = { + figma: { + fills: [ + { + type: 'PATTERN', + sourceNodeId: referenced.id, + tileType: 'RECTANGULAR', + scalingFactor: 1, + spacing: { x: 0, y: 0 }, + horizontalAlignment: 'CENTER' + } + ] + } + } + } + const requested = Promise.withResolvers() + const ready = Promise.withResolvers() + fixture.loadFontAsync.mockImplementation(async (font: FontName) => { + if (font.family !== 'Roboto') return + requested.resolve() + await ready.promise + }) + fixture.triggerUndo.mockImplementationOnce(() => { + if (corruptUndo) referenced.name = 'Undo corruption' + }) + vi.mocked(figma.util.solidPaint).mockImplementationOnce(() => { + throw new Error('paint unavailable') + }) + const pending = applyCanvas({ + mode: 'create', + markup: `
${referenceMarkup}Wait
`, + bindings + }) + void pending.catch(() => undefined) + let error: unknown + try { + await Promise.race([requested.promise, pending]) + expect(fixture.commitUndo).not.toHaveBeenCalled() + referenced.name = 'User edit before request writes' + } finally { + ready.resolve() + error = await pending.catch((error: unknown) => error) + } + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain('paint unavailable') + if (corruptUndo) { + expect((error as Error).message).toContain( + `Rollback changed pre-existing node ${referenced.id}` + ) + } else { + expect(referenced.name).toBe('User edit before request writes') + expect((error as Error).message).not.toContain('automatic rollback was not available') + } + } + ) + it.each(['background', 'guides', 'mode', 'ownership', 'order'] as const)( 'reports retained page %s after incomplete Undo', async (field) => { diff --git a/packages/extension/tests/mcp/tools/canvas-slots.test.ts b/packages/extension/tests/mcp/tools/canvas-slots.test.ts new file mode 100644 index 00000000..98305cfd --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-slots.test.ts @@ -0,0 +1,275 @@ +import type { CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { expect, it, vi } from 'vitest' + +import { applyResolvedCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +async function adoptSlot(variant = false) { + const fixture = createFixture() + const component = figma.createComponent() + component.name = 'State=Default' + const alternate = variant ? figma.createComponent() : undefined + if (alternate) alternate.name = 'State=Alternate' + const owner = alternate + ? figma.combineAsVariants([component, alternate], figma.currentPage) + : component + const slot = component.createSlot() + const input: CanvasResolvedApplyParameters = { + mode: 'update', + targetNodeId: component.id, + markup: `
`, + bindings: { + card: { figma: { component: { type: 'COMPONENT' } } }, + content: { figma: { slot: { property: { name: 'Slot' } } } } + } + } + await applyResolvedCanvas(input) + return { fixture, component, owner, slot, input } +} + +it.each([false, true])( + 'retains an adopted slot property identity when its initial metadata already matches (variant=%s)', + async (variant) => { + const { owner, slot, input } = await adoptSlot(variant) + const propertyName = Object.keys(owner.componentPropertyDefinitions).find( + (name) => owner.componentPropertyDefinitions[name]?.type === 'SLOT' + )! + vi.mocked(owner.editComponentProperty).mockClear() + await expect(applyResolvedCanvas(input)).resolves.toMatchObject({ mutationCount: 0 }) + expect(owner.editComponentProperty).not.toHaveBeenCalled() + + const rename: CanvasResolvedApplyParameters = { + mode: 'update', + targetNodeId: slot.id, + bindings: { content: { figma: { slot: { property: { name: 'Content' } } } } } + } + await applyResolvedCanvas(rename) + expect(owner.editComponentProperty).toHaveBeenCalledWith(propertyName, { name: 'Content' }) + const names = Object.keys(owner.componentPropertyDefinitions).filter( + (name) => owner.componentPropertyDefinitions[name]?.type === 'SLOT' + ) + expect(names).toHaveLength(1) + expect(names[0]).toMatch(/^Content#/) + await expect(applyResolvedCanvas(rename)).resolves.toMatchObject({ mutationCount: 0 }) + } +) + +it.each([false, true])( + 'keeps slot identity separate from an equally named component property key (legacy=%s)', + async (legacy) => { + const { component, owner, slot, input } = await adoptSlot() + const slotName = Object.keys(owner.componentPropertyDefinitions)[0]! + if (legacy) { + slot.setSharedPluginData('tempad_dev', 'slot-property-name', '') + owner.setSharedPluginData( + 'tempad_dev', + 'component-property-keys', + JSON.stringify({ content: slotName }) + ) + } + const update: CanvasResolvedApplyParameters = { + ...input, + bindings: { + card: { + figma: { + component: { + type: 'COMPONENT', + properties: { content: { type: 'TEXT', name: 'Label', defaultValue: 'Before' } } + } + } + }, + content: { figma: { slot: { property: { name: 'Children' } } } } + } + } + await applyResolvedCanvas(update) + const entries = Object.entries(owner.componentPropertyDefinitions) + expect(entries).toHaveLength(2) + expect(entries.find(([name]) => name.startsWith('Children#'))?.[1].type).toBe('SLOT') + expect(entries.find(([name]) => name.startsWith('Label#'))?.[1]).toMatchObject({ + type: 'TEXT', + defaultValue: 'Before' + }) + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + const instance = component.createInstance() + // Exercise the stable normal-property key, rather than relying on internal map contents. + const result = await applyResolvedCanvas({ + mode: 'update', + targetNodeId: instance.id, + markup: '
', + bindings: { + instance: { component: { id: component.id }, componentProperties: { content: 'After' } } + } + }) + expect(result.rootNodeId).toBe(instance.id) + expect(Object.values(instance.componentProperties)).toContainEqual( + expect.objectContaining({ type: 'TEXT', value: 'After' }) + ) + } +) + +it.each([false, true])( + 'protects a slot property owner outside the native update root (restored=%s)', + async (restored) => { + const { fixture, owner, slot } = await adoptSlot() + const definitions = owner.componentPropertyDefinitions + const before = structuredClone(definitions) + if (restored) { + fixture.triggerUndo.mockImplementationOnce(() => { + for (const name of Object.keys(definitions)) delete definitions[name] + Object.assign(definitions, before) + }) + } + Object.defineProperty(slot, 'guides', { + configurable: true, + get: () => [], + set: () => { + throw new Error('Guide setter failed') + } + }) + const error = await applyResolvedCanvas({ + mode: 'update', + targetNodeId: slot.id, + bindings: { + content: { + figma: { + slot: { property: { name: 'Slot', description: 'Changed' } }, + guides: [{ axis: 'X', offset: 12 }] + } + } + } + }).catch((error: unknown) => error) + expect(error).toMatchObject({ code: 'CANVAS_APPLY_FAILED' }) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + if (restored) { + expect(definitions).toEqual(before) + expect((error as Error).message).not.toContain('automatic rollback was not available') + } else { + expect((error as Error).message).toContain(`Rollback changed pre-existing node ${owner.id}`) + } + } +) + +it('creates a slot without replacing an equally named normal component property key', async () => { + const fixture = createFixture() + const input: CanvasResolvedApplyParameters = { + mode: 'create', + markup: + '
', + bindings: { + card: { + figma: { + component: { + type: 'COMPONENT', + properties: { content: { type: 'TEXT', name: 'Label', defaultValue: 'Before' } } + } + } + }, + content: { figma: { slot: { property: { name: 'Children' } } } } + } + } + const created = await applyResolvedCanvas(input) + const owner = fixture.getNode(created.rootNodeId!) as unknown as ComponentNode + expect( + Object.values(owner.componentPropertyDefinitions) + .map(({ type }) => type) + .sort() + ).toEqual(['SLOT', 'TEXT']) + await expect( + applyResolvedCanvas({ ...input, mode: 'update', targetNodeId: owner.id }) + ).resolves.toMatchObject({ + mutationCount: 0 + }) +}) + +it('rejects an ambiguous native slot name before any write', async () => { + const fixture = createFixture() + const owner = figma.createComponent() + const slot = owner.createSlot() + owner.createSlot() + const before = structuredClone(owner.componentPropertyDefinitions) + await expect( + applyResolvedCanvas({ + mode: 'update', + targetNodeId: owner.id, + markup: `
`, + bindings: { + card: { figma: { component: { type: 'COMPONENT' } } }, + content: { figma: { slot: { property: { name: 'Slot' } } } } + } + }) + ).rejects.toMatchObject({ + code: 'CANVAS_APPLY_FAILED', + cause: { code: 'INVALID_CANVAS_SPEC' }, + message: expect.stringContaining('ambiguous') + }) + expect(owner.componentPropertyDefinitions).toEqual(before) + expect(fixture.commitUndo).not.toHaveBeenCalled() +}) + +it('reports a new slot definition as a mutation of its existing owner', async () => { + const { owner, slot, input } = await adoptSlot(true) + const update: CanvasResolvedApplyParameters = { + ...input, + markup: `
`, + bindings: { + ...input.bindings, + extra: { figma: { slot: { property: { name: 'Slot' } } } } + } + } + const result = await applyResolvedCanvas(update) + expect(result.updatedNodeIds).toContain(owner.id) + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) +}) + +it('detects a retained slot definition on an owner outside the structural update root', async () => { + const fixture = createFixture() + const component = figma.createComponent() + component.name = 'State=Default' + const alternate = figma.createComponent() + alternate.name = 'State=Alternate' + const owner = figma.combineAsVariants([component, alternate], figma.currentPage) + const input: CanvasResolvedApplyParameters = { + mode: 'update', + targetNodeId: component.id, + markup: '
', + bindings: { card: { figma: { component: { type: 'COMPONENT' } } } } + } + await applyResolvedCanvas(input) + const createSlot = vi.mocked(component.createSlot).getMockImplementation()! + vi.mocked(component.createSlot).mockImplementationOnce(() => { + const slot = createSlot() + Object.defineProperty(slot, 'guides', { + configurable: true, + get: () => [], + set: () => { + throw new Error('Guide setter failed') + } + }) + return slot + }) + const error = await applyResolvedCanvas({ + ...input, + markup: + '
', + bindings: { + ...input.bindings, + content: { + figma: { + slot: { property: { name: 'Children' } }, + guides: [{ axis: 'X', offset: 12 }] + } + } + } + }).catch((error: unknown) => error) + expect(error).toMatchObject({ + code: 'CANVAS_APPLY_FAILED', + message: expect.stringContaining(`Rollback changed pre-existing node ${owner.id}`) + }) + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(component.children).toEqual([]) + expect(Object.values(owner.componentPropertyDefinitions)).toContainEqual( + expect.objectContaining({ type: 'SLOT' }) + ) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-structure.test.ts b/packages/extension/tests/mcp/tools/canvas-structure.test.ts new file mode 100644 index 00000000..0b8d0c5a --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-structure.test.ts @@ -0,0 +1,74 @@ +import { TEMPAD_MCP_ERROR_CODES } from '@tempad-dev/shared' +import { expect, it } from 'vitest' + +import { handleApplyCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +it.each([ + { type: 'GROUP', properties: { group: true } }, + { type: 'BOOLEAN_OPERATION', properties: { booleanOperation: 'UNION' } }, + { type: 'COMPONENT_SET', properties: { component: { type: 'COMPONENT_SET' } } } +] as const)( + 'adopts an unowned $type root without treating retained children as outgoing moves', + async ({ type, properties }) => { + const fixture = createFixture() + const root = fixture.createNode(type) + const children = Array.from({ length: 2 }, (_, index) => { + const child = fixture.createNode(type === 'COMPONENT_SET' ? 'COMPONENT' : 'RECTANGLE') + child.name = `State=${index}` + root.appendChild(child) + return child + }) + const input = { + mode: 'update' as const, + targetNodeId: root.id, + markup: `
${children + .map( + (child, index) => + `
` + ) + .join('')}
`, + native: { root: { figma: properties } } + } + + await expect(handleApplyCanvas(input)).resolves.toMatchObject({ + rootNodeId: root.id, + createdNodeIds: [], + verification: { status: 'passed' } + }) + expect(root.children).toEqual(children) + expect(root.removed).toBe(false) + await expect(handleApplyCanvas(input)).resolves.toMatchObject({ mutationCount: 0 }) + } +) + +it.each(['root', 'child'] as const)( + 'rejects an owned %s key change before creating resources or opening Undo', + async (target) => { + const fixture = createFixture() + const root = fixture.createNode('FRAME') + const child = fixture.createNode('FRAME') + root.appendChild(child) + root.setSharedPluginData('tempad_dev', 'canvas-key', 'root') + child.setSharedPluginData('tempad_dev', 'canvas-key', 'child') + + await expect( + handleApplyCanvas({ + mode: 'update', + targetNodeId: root.id, + markup: `
`, + styles: { unrelated: { type: 'PAINT', name: 'Unrelated' } } + }) + ).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED, + cause: { code: TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC }, + message: expect.stringContaining(`already owned by canvas key "${target}"`) + }) + expect(figma.createPaintStyle).not.toHaveBeenCalled() + expect(fixture.commitUndo).not.toHaveBeenCalled() + expect(fixture.triggerUndo).not.toHaveBeenCalled() + expect(root.getSharedPluginData('tempad_dev', 'canvas-key')).toBe('root') + expect(child.getSharedPluginData('tempad_dev', 'canvas-key')).toBe('child') + } +) diff --git a/packages/extension/tests/mcp/tools/canvas-svg-lifecycle.test.ts b/packages/extension/tests/mcp/tools/canvas-svg-lifecycle.test.ts new file mode 100644 index 00000000..66b6d5ae --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-svg-lifecycle.test.ts @@ -0,0 +1,107 @@ +import type { CanvasResolvedApplyParameters } from '@tempad-dev/shared' + +import { expect, it, vi } from 'vitest' + +import { applyResolvedCanvas } from '@/mcp/tools/canvas' + +import { createFixture } from '../../fakes/figma' + +function svgRequest(): CanvasResolvedApplyParameters { + return { + mode: 'create', + markup: + '
', + assets: { icon: { type: 'SVG', svg: '' } }, + bindings: { icon: { figma: { svg: { assetKey: 'icon' } } } } + } +} + +it.each(['metadata', 'placement'] as const)( + 'cleans up a replacement SVG import after %s fails and preserves its prior child', + async (failure) => { + const fixture = createFixture() + const input = svgRequest() + const created = await applyResolvedCanvas(input) + const root = fixture.getNode(created.rootNodeId!) + const wrapper = fixture.getNode(created.nodeIdsByKey.icon!) + const previous = wrapper.children[0]! + let imported: FrameNode | undefined + fixture.createNodeFromSvg.mockImplementationOnce(() => { + imported = fixture.createNode('FRAME') as unknown as FrameNode + imported.resize(48, 48) + vi.spyOn( + imported, + failure === 'metadata' ? 'setSharedPluginData' : 'rescale' + ).mockImplementation(() => { + throw new Error(`Rejected SVG ${failure}`) + }) + return imported + }) + + await expect( + applyResolvedCanvas({ + ...input, + mode: 'update', + targetNodeId: root.id, + assets: { icon: { type: 'SVG', svg: '' } } + }) + ).rejects.toThrow(`Rejected SVG ${failure}`) + + expect(imported?.removed).toBe(true) + expect(wrapper.children).toEqual([previous]) + expect(fixture.page.children).toEqual([root]) + } +) + +it('reports a repaired SVG child and converges without reimporting it', async () => { + const fixture = createFixture() + const input = svgRequest() + const created = await applyResolvedCanvas(input) + const wrapper = fixture.getNode(created.nodeIdsByKey.icon!) + const child = wrapper.children[0]! + expect(created.createdNodeIds).toContain(child.id) + expect(created.updatedNodeIds).toEqual([]) + expect(Object.values(created.nodeIdsByKey)).not.toContain(child.id) + child.x = -10 + const update = { ...input, mode: 'update' as const, targetNodeId: created.rootNodeId! } + + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ + mutationCount: 1, + updatedNodeIds: [child.id] + }) + expect(child.x).toBe(0) + await expect(applyResolvedCanvas(update)).resolves.toMatchObject({ mutationCount: 0 }) + expect(fixture.createNodeFromSvg).toHaveBeenCalledOnce() +}) + +it('tracks an SVG placement repair when a later write fails', async () => { + const fixture = createFixture() + const input = svgRequest() + const created = await applyResolvedCanvas(input) + const child = fixture.getNode(created.nodeIdsByKey.icon!).children[0]! + child.x = -10 + Object.defineProperty(fixture.getNode(created.nodeIdsByKey.later!), 'fills', { + get: () => [], + set: () => { + throw new Error('Rejected later paint') + } + }) + fixture.triggerUndo.mockClear() + fixture.triggerUndo.mockImplementationOnce(() => { + child.x = -10 + }) + + await expect( + applyResolvedCanvas({ + ...input, + mode: 'update', + targetNodeId: created.rootNodeId!, + bindings: { + ...input.bindings, + later: { figma: { fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }] } } + } + }) + ).rejects.toThrow('Rejected later paint') + expect(fixture.triggerUndo).toHaveBeenCalledOnce() + expect(child.x).toBe(-10) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-svg-xml.browser.test.ts b/packages/extension/tests/mcp/tools/canvas-svg-xml.browser.test.ts new file mode 100644 index 00000000..88bb84fa --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-svg-xml.browser.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest' + +import { parseSvgXml, stringifySvgXml } from '@/mcp/tools/canvas/svg-xml' + +it.each([ + 'A&B &lt; &amp;<literal> one]]>two]]>three', + '\n\nA B\n \n\n' +])('preserves native XML attribute and text meanings after SVG normalization', (source) => { + const parse = (value: string) => new DOMParser().parseFromString(value, 'image/svg+xml') + const original = parse(source) + const output = parse(stringifySvgXml(parseSvgXml(source))) + expect(output.querySelector('parsererror')).toBeNull() + expect(output.documentElement.getAttribute('aria-label')).toBe( + original.documentElement.getAttribute('aria-label') + ) + expect(output.querySelector('use')?.getAttribute('href')).toBe( + original.querySelector('use')?.getAttribute('href') + ) + expect(output.querySelector('text')?.textContent).toBe( + original.querySelector('text')?.textContent + ) +}) diff --git a/packages/extension/tests/mcp/tools/canvas-svg-xml.test.ts b/packages/extension/tests/mcp/tools/canvas-svg-xml.test.ts new file mode 100644 index 00000000..39c4e557 --- /dev/null +++ b/packages/extension/tests/mcp/tools/canvas-svg-xml.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { parseSvgXml, stringifySvgXml } from '@/mcp/tools/canvas/svg-xml' + +describe('canvas SVG XML semantics', () => { + it('decodes XML entities and numeric references exactly once in attributes and text', () => { + const root = parseSvgXml( + 'A&B <safe> A🙂 &lt;' + ) + expect(root.children[0]?.attributes.href).toBe('#shape') + expect(root.children[1]?.attributes['aria-label']).toBe(`"'<>&`) + expect(root.children[1]?.children[0]?.value).toBe('A&B A🙂 <') + expect(parseSvgXml(stringifySvgXml(root))).toEqual(root) + }) + + it('keeps CDATA literal while ignoring CDATA markers and entities inside comments', () => { + const root = parseSvgXml( + '&amp;<literal>&unknown;' + ) + expect(root.children).toHaveLength(1) + expect(root.children[0]?.children[0]?.value).toBe('&&unknown;') + expect(parseSvgXml(stringifySvgXml(root))).toEqual(root) + }) + + it.each([' ', '', '\u00a0'])('preserves whitespace-only XML text: %j', (text) => { + const root = parseSvgXml(`${text}`) + expect(root.children[0]?.children[0]?.value).toBe(text === '\u00a0' ? '\u00a0' : ' ') + expect(parseSvgXml(stringifySvgXml(root))).toEqual(root) + }) + + it('serializes every CDATA closing sequence as ordinary escaped text', () => { + const root = parseSvgXml('one]]>two]]>three') + expect(root.children[0]?.children[0]?.value).toBe('one]]>two]]>three') + expect(stringifySvgXml(root)).toBe('one]]>two]]>three') + expect(parseSvgXml(stringifySvgXml(root))).toEqual(root) + }) + + it('normalizes literal XML whitespace while preserving character-reference whitespace', () => { + const root = parseSvgXml( + 'A\rB\r\nC ' + ) + expect(root.attributes['aria-label']).toBe('one two\n\r\tthree') + expect(root.children[0]?.children[0]?.value).toBe('A\nB\nC\r') + expect(stringifySvgXml(root)).toContain('aria-label="one two three"') + expect(parseSvgXml(stringifySvgXml(root))).toEqual(root) + }) + + it.each([ + '&unknown;', + 'R&D', + '&', + '&#x;', + ' oops;', + 'A', + '�', + '�', + '�', + '￾', + '\u0000', + '\u000B', + '\uD800' + ])('rejects malformed references or invalid XML characters: %j', (value) => { + expect(() => parseSvgXml(`${value}`)).toThrow() + expect(() => parseSvgXml(``)).toThrow() + }) + + it.each([ + '{customer}'), + transform + ) + expect(transformed.props.content).toBe( + '{customer}' + ) + }) + + it('keeps the native export boundary when the renderer falls back to inline SVG', async () => { + const snapshot = createSnapshot({ id: 'icon', type: 'VECTOR' }) + const component = await renderTree(snapshot.id, createTree([snapshot]), { + styles: new Map(), + layout: new Map(), + nodes: new Map([[snapshot.id, snapshot.node]]), + svgs: new Map([ + [ + snapshot.id, + { + props: {}, + raw: `${source}`, + presentationStyle: { color: 'var(--brand)' } + } + ] + ]), + textSegments: new Map(), + config: { cssUnit: 'px', rootFontSize: 16, scale: 1 } + }) + if (!component || typeof component === 'string') throw new Error('Expected SVG component') + const code = stringifyComponent(mapTokenValues(component, transform), 'jsx') + expect(code).toContain(`${source}`) + expect(code).toContain('fill="var(--renamed)"') + expect(code).toContain('className="text-(color:--renamed)"') + }) + + it('preserves XML text and literal attributes across multiple transform passes', () => { + const content = `${source}${source}` + const first = mapTokenValues( + nativeSvg(content, { className: 'text-(--brand)', strokeWidth: source }), + transform + ) + const second = mapTokenValues(first, (value) => value.replaceAll('--renamed', '--final')) + expect(second.props.content).toBe( + `${source}${source}` + ) + expect(second.props.injectedProps).toEqual({ + className: 'text-(--final)', + strokeWidth: 'var(--final)' + }) + }) + + it('preserves entities and meaningful whitespace with and without a styling rewrite', () => { + const content = + ' R&D < 3 \nA & B C' + expect(mapTokenValues(nativeSvg(content), (value) => value).props.content).toBe(content) + const transformed = mapTokenValues(nativeSvg(content), transform).props.content as string + const parsed = new DOMParser().parseFromString(transformed, 'image/svg+xml') + expect(parsed.querySelector('title')?.textContent).toBe(' R&D < 3 ') + expect(parsed.querySelector('text')?.textContent).toBe('A & B C') + expect(parsed.querySelector('text')?.getAttribute('fill')).toBe('var(--renamed)') + }) + + it('leaves malformed native SVG content intact', () => { + const content = `${source}` + expect(mapTokenValues(nativeSvg(content), transform).props.content).toBe(content) + }) +}) diff --git a/packages/extension/tests/mcp/tools/code/tokens/markup.test.ts b/packages/extension/tests/mcp/tools/code/tokens/markup.test.ts new file mode 100644 index 00000000..780a0701 --- /dev/null +++ b/packages/extension/tests/mcp/tools/code/tokens/markup.test.ts @@ -0,0 +1,173 @@ +import { raw } from '@tempad-dev/plugins' +import { describe, expect, it, vi } from 'vitest' + +import type { DevComponent } from '@/types/plugin' +import type { CssValueSyntax } from '@/utils/css' + +import { mapTokenValues } from '@/mcp/tools/code/tokens/markup' +import { rewriteTokenNamesInCode } from '@/mcp/tools/code/tokens/rewrite' +import { stripFallback } from '@/utils/css' + +const source = 'var(--brand, #fff)' +const expected = 'var(--renamed)' +const transform = (value: string, syntax?: CssValueSyntax) => + rewriteTokenNamesInCode(stripFallback(value, syntax), new Map([['--brand', '--renamed']])) + +describe('typed markup token values', () => { + it('strips every Tailwind fallback without treating encoded spaces as CSS name characters', () => { + const gradient = 'bg-[linear-gradient(var(--brand,_red),_var(--end,_blue))]' + const component = { + name: 'div', + props: { className: [gradient, { [`hover:${gradient}`]: true }] }, + children: [] + } + const expected = 'bg-[linear-gradient(var(--renamed),_var(--end))]' + expect(mapTokenValues(component, transform).props.className).toEqual([ + expected, + { [`hover:${expected}`]: true } + ]) + }) + + it('keeps Tailwind literal and custom-name boundaries distinct from ordinary CSS', () => { + const className = String.raw`bg-[var(--brand,_red),_url(/var(--literal,_red)_image.svg)] content-['var(--literal,_red)'] [color:foo\_var(--brand,_red)]` + const component = { + name: 'div', + props: { + className, + style: { color: '_var(--brand, red)', background: 'foo_var(--brand, red)' } + }, + children: [] + } + const observed: string[] = [] + const mapped = mapTokenValues(component, (value, syntax) => { + observed.push(value) + return stripFallback(value, syntax) + }) + expect(mapped.props).toEqual({ + ...component.props, + className: String.raw`bg-[var(--brand),_url(/var(--literal,_red)_image.svg)] content-['var(--literal,_red)'] [color:foo\_var(--brand,_red)]` + }) + expect(observed.join('')).not.toContain('--literal') + }) + + it('rewrites styling in nested components without changing literal props, text, or input objects', () => { + const component: DevComponent = { + name: 'Card', + props: { + title: source, + content: source, + settings: { style: source, label: source }, + className: ['text-(--brand)', { 'bg-(--brand)': true, plain: false }], + style: [{ color: source, opacity: 0.5 }, { background: source }] + }, + children: [ + source, + { + name: 'span', + props: { style: `color: ${source}`, 'data-hint-name': source }, + children: [] + } + ] + } + const original = structuredClone(component) + expect(mapTokenValues(component, transform)).toEqual({ + ...component, + props: { + ...component.props, + className: ['text-(--renamed)', { 'bg-(--renamed)': true, plain: false }], + style: [{ color: expected, opacity: 0.5 }, { background: expected }] + }, + children: [ + source, + { + name: 'span', + props: { style: `color: ${expected}`, 'data-hint-name': source }, + children: [] + } + ] + }) + expect(component).toEqual(original) + }) + + it('rewrites SVG presentation attributes while preserving labels and foreignObject text', () => { + const component: DevComponent = { + name: 'svg', + props: { fill: source, 'aria-label': source }, + children: [ + { name: 'path', props: { stroke: source, strokeWidth: source }, children: [] }, + { + name: 'foreignObject', + props: {}, + children: [{ name: 'Card', props: { fill: source }, children: [source] }] + } + ] + } + const result = mapTokenValues(component, transform) + expect(result.props).toEqual({ fill: expected, 'aria-label': source }) + expect(result.children[0]).toMatchObject({ props: { stroke: expected, strokeWidth: expected } }) + expect(result.children[1]).toEqual(component.children[1]) + }) + + it('keeps explicit raw code processing and transforms injected styling separately', () => { + const component = raw(``, { + className: 'text-(--brand)', + title: source + }) + expect(mapTokenValues(component, transform)).toMatchObject({ + props: { + content: ``, + injectedProps: { className: 'text-(--renamed)', title: source } + } + }) + }) + + it('preserves nonstring styling inputs', () => { + const component = { name: 'Card', props: { class: null, style: 0 }, children: [] } + expect(mapTokenValues(component, transform)).toEqual(component) + }) + + it('preserves quoted CSS literals while transforming adjacent references and outer fallbacks', () => { + const component = { + name: 'span', + props: { + class: `content-['var(--brand,_#fff)'] text-(--brand)`, + style: { + content: `"Use ${source}"`, + fontFamily: `'${source}', var(--brand, "default")`, + background: `url("/${source}.svg") ${source}`, + mask: `url(/--brand.svg)`, + filter: String.raw`u\72l(#--brand) ${source}`, + border: `/* Use "${source} */ ${source}`, + '--brand': source + } + }, + children: [] + } + expect(mapTokenValues(component, transform).props).toEqual({ + class: `content-['var(--brand,_#fff)'] text-(--renamed)`, + style: { + content: `"Use ${source}"`, + fontFamily: `'${source}', var(--renamed)`, + background: `url("/${source}.svg") ${expected}`, + mask: `url(/--brand.svg)`, + filter: String.raw`u\72l(#--brand) ${expected}`, + border: `/* Use "${source} */ ${expected}`, + '--renamed': expected + } + }) + }) + + it('keeps escaped quotes and marker-looking text intact without false token detection', () => { + const visit = vi.fn((value: string) => value) + const content = `'\\'${source}\\'\0'` + const component = { name: 'span', props: { style: { content } }, children: [] } + expect(mapTokenValues(component, visit).props).toEqual(component.props) + expect(visit.mock.calls.flat().join('')).not.toContain('--brand') + }) + + it('consumes long names and repeated CSS escapes without changing literal data', () => { + const className = `${'a'.repeat(30_000)} ${String.raw`\61`.repeat(10_000)}` + const component = { name: 'div', props: { className }, children: [] } + expect(mapTokenValues(component, transform).props.className).toBe(className) + }) +}) diff --git a/packages/extension/tests/mcp/tools/code/tokens/modes.test.ts b/packages/extension/tests/mcp/tools/code/tokens/modes.test.ts index 7de1c121..9675b010 100644 --- a/packages/extension/tests/mcp/tools/code/tokens/modes.test.ts +++ b/packages/extension/tests/mcp/tools/code/tokens/modes.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCodeCheckpoint } from '@/mcp/tools/code/deadline' import { addVariableModeHints } from '@/mcp/tools/code/tokens/modes' import { createTokenReadContext } from '@/mcp/tools/token/context' import { createSnapshot, createTree } from '@/tests/mcp/tools/code/test-helpers' @@ -10,7 +11,61 @@ const config = { cssUnit: 'px', rootFontSize: 16, scale: 1 } as const const makeVariable = (id: string, collection: string, values: Record) => ({ id, variableCollectionId: collection, valuesByMode: values }) as unknown as Variable +afterEach(() => vi.restoreAllMocks()) + describe('variable mode hints', () => { + it('preserves an unknown mode ID with a diagnostic', () => { + const root = createSnapshot({ id: 'root' }) + Object.assign(root.node, { resolvedVariableModes: { theme: 'missing' } }) + const context = createTokenReadContext( + config, + undefined, + new Map([['v', makeVariable('v', 'theme', { missing: 1 })]]) + ) + vi.spyOn(context, 'getCollection').mockReturnValue({ + id: 'theme', + name: 'Theme', + modes: [{ modeId: 'm', name: 'Default' }] + } as VariableCollection) + const report = vi.fn() + + addVariableModeHints(createTree([root]), new Set(['v']), context, report) + + expect(root.dataHint?.['data-hint-variable-mode']).toBe('Theme=missing') + expect(report).toHaveBeenCalledWith('token-definition', expect.stringContaining('missing')) + }) + + it.each(['alias-chain', 'mode-map'])( + 'checks native %s reads against the generation deadline', + (stage) => { + let elapsed = 0 + vi.spyOn(performance, 'now').mockImplementation(() => elapsed) + const checkpoint = createCodeCheckpoint(20) + const root = createSnapshot({ id: 'root' }) + const context = createTokenReadContext(config) + const readVariable = vi.spyOn(context, 'getVariable').mockImplementation((id) => { + if (stage === 'alias-chain') elapsed += 21 + return makeVariable(id, 'theme', { m: { type: 'VARIABLE_ALIAS', id: 'next' } }) + }) + vi.spyOn(context, 'getCollection').mockReturnValue({ + name: 'Theme', + modes: [{ modeId: 'm', name: 'Default' }] + } as VariableCollection) + Object.defineProperty(root.node, 'resolvedVariableModes', { + get() { + elapsed += 21 + return { theme: 'm' } + } + }) + + expect(() => + addVariableModeHints(createTree([root]), new Set(['v']), context, vi.fn(), checkpoint) + ).toThrow('timed out') + expect(readVariable).toHaveBeenCalledTimes(stage === 'alias-chain' ? 1 : 2) + expect(root.dataHint?.['data-hint-variable-mode']).toBeUndefined() + } + ) + it('includes inherited modes at the export root and only explicit changes below it', () => { const root = createSnapshot({ id: 'root', children: ['child', 'inherited'] }) const child = createSnapshot({ id: 'child', parentId: 'root' }) diff --git a/packages/extension/tests/mcp/tools/code/tokens/native-svg-template-fixture.ts b/packages/extension/tests/mcp/tools/code/tokens/native-svg-template-fixture.ts new file mode 100644 index 00000000..810f2ba6 --- /dev/null +++ b/packages/extension/tests/mcp/tools/code/tokens/native-svg-template-fixture.ts @@ -0,0 +1,7 @@ +export const nativeSvgSource = + 'R&D __tempad_svg_text__Hello {customer} {{customer}} & < 3' + +export const nativeSvgTemplate = + 'R&D __tempad_svg_text__Hello {customer} {{customer}} & < 3' + +export const nativeSvgText = 'R&D __tempad_svg_text__Hello {customer} {{customer}} & < 3' diff --git a/packages/extension/tests/mcp/tools/code/tokens/native-svg-template.test.ts b/packages/extension/tests/mcp/tools/code/tokens/native-svg-template.test.ts new file mode 100644 index 00000000..39e62449 --- /dev/null +++ b/packages/extension/tests/mcp/tools/code/tokens/native-svg-template.test.ts @@ -0,0 +1,32 @@ +import { transformSync } from 'esbuild' +import { describe, expect, it } from 'vitest' +import { compile } from 'vue' + +import { nativeSvgTemplate, nativeSvgText } from './native-svg-template-fixture' + +function collectText(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(collectText).join('') + if (value && typeof value === 'object' && 'children' in value) return collectText(value.children) + return '' +} + +// The browser test asserts that the production XML path emits this exact fixture. +describe('serialized native SVG templates', () => { + it.each(['jsx', 'tsx', 'vue'] as const)( + 'preserves XML text through the real %s compiler', + (lang) => { + let rendered: unknown + if (lang === 'vue') { + const render = compile(nativeSvgTemplate) as (context: object, cache: unknown[]) => unknown + rendered = render({}, []) + } else { + const code = transformSync(nativeSvgTemplate, { loader: lang, jsxFactory: 'h' }).code + rendered = new Function('h', `return ${code}`)( + (_tag: string, _props: unknown, ...children: unknown[]) => children + ) + } + expect(collectText(rendered)).toBe(nativeSvgText) + } + ) +}) diff --git a/packages/extension/tests/mcp/tools/code/tokens/process-encoding.test.ts b/packages/extension/tests/mcp/tools/code/tokens/process-encoding.test.ts new file mode 100644 index 00000000..2a98ab6f --- /dev/null +++ b/packages/extension/tests/mcp/tools/code/tokens/process-encoding.test.ts @@ -0,0 +1,93 @@ +import { raw } from '@tempad-dev/plugins' +import { afterEach, expect, it, vi } from 'vitest' + +import { processTokens } from '@/mcp/tools/code/tokens/process' +import { runTransformVariableBatch } from '@/mcp/transform-variables/requester' +import { stringifyComponent } from '@/utils/component' +import { cssToClassNames } from '@/utils/tailwind' + +vi.mock('@/ui/state', () => ({ activePlugin: { value: undefined } })) +vi.mock('@/mcp/transform-variables/requester', () => ({ runTransformVariableBatch: vi.fn() })) + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +function inputFor(name: string) { + const variable = { + id: 'value', + name, + variableCollectionId: 'collection', + resolvedType: 'FLOAT', + valuesByMode: { default: 8 }, + scopes: ['WIDTH_HEIGHT'] + } as unknown as Variable + vi.stubGlobal('figma', { + variables: { + getVariableCollectionById: () => ({ + id: 'collection', + name: 'Tokens', + defaultModeId: 'default', + modes: [{ modeId: 'default', name: 'Default' }] + }) + } + }) + return { + component: { + name: 'div', + props: { className: cssToClassNames({ width: `calc(var(--${name}) * 2)` }).join(' ') }, + children: [] + }, + serialize: (component: Parameters[0]) => + stringifyComponent(component, 'jsx'), + variableIds: new Set(['value']), + variableCache: new Map([['value', variable]]), + styles: new Map(), + textSegments: new Map(), + svgs: new Map(), + config: { cssUnit: 'px' as const, rootFontSize: 16, scale: 1 } + } +} + +it('retains definitions for generated escaped names without changing unrenamed classes', async () => { + const input = inputFor('some_token') + input.component.props.className += ` before:content-['var(--only_literal)'] bg-[url(/--only_literal.svg)]` + input.variableIds.add('literal') + input.variableCache.set('literal', { + ...input.variableCache.get('value')!, + id: 'literal', + name: 'only_literal' + }) + const result = await processTokens(input) + + expect(result.tokensByCanonical).toEqual({ '--some_token': { kind: 'number', value: '8px' } }) + expect(result.code).toBe(input.serialize(input.component)) + expect(runTransformVariableBatch).not.toHaveBeenCalled() +}) + +it.each(['some_token', 'plain'])( + 'rewrites %s with encoding specific to each styling boundary', + async (name) => { + const input = inputFor(name) + const literal = `before:content-['var(--${name})'] bg-[url(/--${name}.svg)]` + const component = { + ...input.component, + props: { + className: `${input.component.props.className} h-(--${name}) ${literal}`, + style: { width: `calc(var(--${name}) * 2)` }, + title: `var(--${name})` + }, + children: [raw(`const value = "var(--${name})";`)] + } + vi.mocked(runTransformVariableBatch).mockResolvedValue(['var(--next_token)']) + const result = await processTokens({ ...input, component, pluginCode: 'test-plugin' }) + + expect(result.tokensByCanonical).toEqual({ '--next_token': { kind: 'number', value: '8px' } }) + expect(result.code).toContain(String.raw`w-[calc(var(--next\_token)_*_2)] h-(--next\_token)`) + expect(result.code).toContain(literal) + expect(result.code).toContain('calc(var(--next_token) * 2)') + expect(result.code).toContain('const value = "var(--next_token)";') + expect(result.code).toContain(`title="var(--${name})"`) + } +) diff --git a/packages/extension/tests/mcp/tools/code/tokens/process.test.ts b/packages/extension/tests/mcp/tools/code/tokens/process.test.ts index 04f70d1e..1f6c48bc 100644 --- a/packages/extension/tests/mcp/tools/code/tokens/process.test.ts +++ b/packages/extension/tests/mcp/tools/code/tokens/process.test.ts @@ -1,3 +1,4 @@ +import { raw } from '@tempad-dev/plugins' import { afterEach, describe, expect, it, vi } from 'vitest' import type { CodegenConfig } from '@/utils/codegen' @@ -5,13 +6,15 @@ import type { CodegenConfig } from '@/utils/codegen' import { withCodeDeadline } from '@/mcp/tools/code/deadline' import { createTokenMatcher, extractTokenNames } from '@/mcp/tools/code/tokens/extract' import { processTokens } from '@/mcp/tools/code/tokens/process' -import { rewriteTokenNamesInCode } from '@/mcp/tools/code/tokens/rewrite' +import { createTokenNameRewriter } from '@/mcp/tools/code/tokens/rewrite' import { buildSourceNameIndex } from '@/mcp/tools/code/tokens/source-index' import { applyPluginTransformToNames } from '@/mcp/tools/code/tokens/transform' import { resolveTokenDefsByIds } from '@/mcp/tools/token/defs' +import { stringifyComponent } from '@/utils/component' import { stripFallback } from '@/utils/css' -vi.mock('@/utils/css', () => ({ +vi.mock('@/utils/css', async (importOriginal) => ({ + ...(await importOriginal()), stripFallback: vi.fn((code: string) => code.replace('/*fallback*/', '')) })) @@ -21,7 +24,7 @@ vi.mock('@/mcp/tools/code/tokens/extract', () => ({ })) vi.mock('@/mcp/tools/code/tokens/rewrite', () => ({ - rewriteTokenNamesInCode: vi.fn((code: string) => code) + createTokenNameRewriter: vi.fn(() => (code: string) => code) })) vi.mock('@/mcp/tools/code/tokens/source-index', () => ({ @@ -43,9 +46,9 @@ const CONFIG: CodegenConfig = { } const baseInput = () => ({ - code: 'const a = "/*fallback*/ var(--token)";', + component: raw('const a = "/*fallback*/ var(--token)";'), + serialize: (component: ReturnType) => stringifyComponent(component, 'jsx'), variableIds: new Set(['var-1']), - usedCandidateIds: new Set(), variableCache: new Map(), styles: new Map>(), textSegments: new Map(), @@ -114,7 +117,7 @@ describe('tokens/process processTokens', () => { rewriteMap: new Map([['--token', '--renamed']]), finalBridge: new Map([['--renamed', 'var-1']]) }) - vi.mocked(rewriteTokenNamesInCode).mockReturnValue('const a = "var(--renamed)";') + vi.mocked(createTokenNameRewriter).mockReturnValue(() => 'const a = "var(--renamed)";') vi.mocked(resolveTokenDefsByIds).mockResolvedValue({ '--renamed': { kind: 'color', @@ -207,7 +210,7 @@ describe('tokens/process processTokens', () => { expect(report).toHaveBeenCalledWith('token-definition', 'Variable var-1 is unavailable.') }) - it('merges candidate ids and records used-stage stamp with partial rename map', async () => { + it('merges rendered font-face ids and records used-stage stamp with partial rename map', async () => { const matcher = vi.fn((value: string) => value.includes('--token')) const sourceIndex = new Map([ ['--token', 'var-1'], @@ -220,7 +223,7 @@ describe('tokens/process processTokens', () => { rewriteMap: new Map([['--token', '--renamed']]), finalBridge: new Map([['--renamed', 'var-1']]) }) - vi.mocked(rewriteTokenNamesInCode).mockReturnValue('short') + vi.mocked(createTokenNameRewriter).mockReturnValue(() => 'short') vi.mocked(resolveTokenDefsByIds).mockResolvedValue({ '--renamed': { kind: 'color', @@ -233,7 +236,8 @@ describe('tokens/process processTokens', () => { const result = await processTokens({ ...baseInput(), resolveTokens: true, - usedCandidateIds: new Set(['var-2']), + fontStyleVariableIds: new Set(['var-2']), + variableCache: new Map([['var-2', { id: 'var-2', name: 'other' } as Variable]]), styles: new Map([ [ 'node-a', diff --git a/packages/extension/tests/mcp/tools/code/tokens/resolve.test.ts b/packages/extension/tests/mcp/tools/code/tokens/resolve.test.ts index 028c4091..0332be0f 100644 --- a/packages/extension/tests/mcp/tools/code/tokens/resolve.test.ts +++ b/packages/extension/tests/mcp/tools/code/tokens/resolve.test.ts @@ -49,10 +49,12 @@ describe('consumer token resolution', () => { gap: '50px', width: '50px' }) + const diagnostics = createTokenDiagnostics() const unresolved = createTokenConsumerResolver( { '--Opacity': { kind: 'number', value: '50' } }, new Map([['--Opacity', 'v']]), - undefined + undefined, + diagnostics.report ) expect( unresolved({ opacity: 'var(--Opacity)', gap: 'var(--Opacity)', width: 'var(--Opacity)' }) @@ -61,6 +63,10 @@ describe('consumer token resolution', () => { gap: 'calc(var(--Opacity) * 100px / 100)', width: 'calc(var(--Opacity) * 100px / 100)' }) + expect(unresolved({ 'font-weight': 'var(--Opacity)' })).toEqual({ + 'font-weight': 'var(--Opacity)' + }) + expect(diagnostics.warnings()).toEqual([]) }) it.each([ @@ -107,6 +113,33 @@ describe('consumer token resolution', () => { }) }) + it.each(['opacity', 'font-weight'])( + 'reports a deduplicated compatibility warning for %s using typed length division', + (property) => { + const diagnostics = createTokenDiagnostics() + const resolve = createTokenConsumerResolver( + { + '--canonical': { kind: 'number', value: '--length' }, + '--length': { kind: 'number', value: { Light: '50px', Dark: '0px' } } + }, + new Map([['--source', 'v']]), + new Map([['--source', '--canonical']]), + diagnostics.report + ) + const style = { [property]: 'var(--source)' } + expect(resolve(style)[property]).toBe( + `calc(var(--source) / 100px${property === 'font-weight' ? ' * 100' : ''})` + ) + resolve(style) + const warnings = diagnostics.warnings() + expect(warnings).toHaveLength(1) + expect(warnings[0]?.type).toBe('token-resolution') + expect(warnings[0]?.message).toContain(`${property} for --canonical`) + expect(warnings[0]?.message.match(/requires CSS typed arithmetic support/g)).toHaveLength(1) + expect(warnings[0]?.message).toContain('Use resolveTokens:true for portable literal output.') + } + ) + it('keeps numeric font weights unchanged and normalizes shared length aliases only at consumption', () => { const resolve = createTokenConsumerResolver( { diff --git a/packages/extension/tests/mcp/tools/code/tokens/source-index.test.ts b/packages/extension/tests/mcp/tools/code/tokens/source-index.test.ts index 4d980acb..1d0db0fb 100644 --- a/packages/extension/tests/mcp/tools/code/tokens/source-index.test.ts +++ b/packages/extension/tests/mcp/tools/code/tokens/source-index.test.ts @@ -65,13 +65,13 @@ describe('mcp/code tokens source-index', () => { expect(index.has('--brand-color')).toBe(false) expect(index.has('var(--brand-color)')).toBe(false) expect(index.has('--spacing-lg')).toBe(false) - expect(index.get('$spacing lg')).toBe('id-3') + expect(index.has('$spacing lg')).toBe(false) expect(index.get('--No-Syntax')).toBe('id-4') expect(index.get('--Invalid-Syntax')).toBe('id-5') - expect(index.get('$$??')).toBe('id-5') + expect(index.has('$$??')).toBe(false) expect(index.get('--Brand-Primary')).toBe('id-1') expect(index.get('--Brand-Secondary')).toBe('id-2') - expect(index.get('@--spacing lg')).toBe('id-7') + expect(index.has('@--spacing lg')).toBe(false) expect(index.has('--unnamed')).toBe(false) expect(ambiguous).toHaveBeenCalledWith('--brand-color') expect(index.get('--Spacing-LG')).toBe('id-3') diff --git a/packages/extension/tests/mcp/tools/code/tree.test.ts b/packages/extension/tests/mcp/tools/code/tree.test.ts index fe8acafc..5937471a 100644 --- a/packages/extension/tests/mcp/tools/code/tree.test.ts +++ b/packages/extension/tests/mcp/tools/code/tree.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { suggestDepthLimit } from '@/mcp/semantic-tree' +import { createGetCodeCacheContext, getNodeSemanticsCached } from '@/mcp/tools/code/cache' import { createCodeCheckpoint } from '@/mcp/tools/code/deadline' import { buildVisibleTree } from '@/mcp/tools/code/tree' @@ -41,6 +42,85 @@ function createNode( } describe('mcp/tools/code/tree', () => { + it.each(['NONE', 'HORIZONTAL'] as const)( + 'shares observed clipped overflow with semantics without eager work for %s layout', + (layoutMode) => { + vi.mocked(suggestDepthLimit).mockReturnValue(undefined) + const child = createNode('RECTANGLE', 'child') + const transform = vi.fn(() => [ + [1, 0, 200], + [0, 1, 200] + ]) + Object.defineProperty(child, 'relativeTransform', { get: transform }) + const root = createNode( + 'FRAME', + 'root', + { + clipsContent: true, + layoutMode, + inferredAutoLayout: { layoutMode: 'HORIZONTAL' } + }, + [child] + ) + const cache = createGetCodeCacheContext() + const tree = buildVisibleTree([root], undefined, cache) + expect(transform).toHaveBeenCalledTimes(layoutMode === 'NONE' ? 1 : 0) + expect(tree.nodes.get(root.id)?.autoLayoutHint).toBeUndefined() + const semantics = getNodeSemanticsCached(root, cache) + expect(semantics.layout.childOverflow).toEqual({ x: true, y: true }) + expect(transform).toHaveBeenCalledTimes(1) + transform.mockReturnValue([ + [1, 0, 0], + [0, 1, 0] + ]) + const nextRequest = createGetCodeCacheContext() + buildVisibleTree([root], undefined, nextRequest) + expect(getNodeSemanticsCached(root, nextRequest).layout.childOverflow).toEqual({ + x: false, + y: false + }) + expect(transform).toHaveBeenCalledTimes(2) + } + ) + + it('does not retain overflow from an interrupted scan', () => { + vi.mocked(suggestDepthLimit).mockReturnValue(undefined) + let interrupted = false + let left = 200 + const child = createNode('RECTANGLE', 'child', { width: 10, height: 10 }) + const transform = vi.fn(() => { + interrupted = true + return [ + [1, 0, left], + [0, 1, 0] + ] + }) + Object.defineProperty(child, 'relativeTransform', { get: transform }) + const root = createNode( + 'FRAME', + 'root', + { + clipsContent: true, + layoutMode: 'NONE', + inferredAutoLayout: { layoutMode: 'HORIZONTAL' } + }, + [child] + ) + const cache = createGetCodeCacheContext() + expect(() => + buildVisibleTree( + [root], + () => { + if (interrupted) throw new Error('deadline') + }, + cache + ) + ).toThrow('deadline') + left = 0 + expect(getNodeSemanticsCached(root, cache).layout.childOverflow).toEqual({ x: false, y: false }) + expect(transform).toHaveBeenCalledTimes(2) + }) + it.each([undefined, 0])('checks hidden children even at depth cap %s', (depthLimit) => { vi.mocked(suggestDepthLimit).mockReturnValue(depthLimit) let reads = 0 diff --git a/packages/extension/tests/mcp/tools/code/variables.test.ts b/packages/extension/tests/mcp/tools/code/variables.test.ts deleted file mode 100644 index 0730f2e0..00000000 --- a/packages/extension/tests/mcp/tools/code/variables.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import type { CodegenConfig } from '@/utils/codegen' - -import { collectRefs, transform } from '@/mcp/tools/code/variables' -import { runTransformVariableBatch } from '@/mcp/transform-variables/requester' -import { workerUnitOptions } from '@/utils/codegen' - -vi.mock('@/mcp/transform-variables/requester', () => ({ - runTransformVariableBatch: vi.fn() -})) - -vi.mock('@/utils/codegen', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - workerUnitOptions: vi.fn(() => ({ cssUnit: 'px', rootFontSize: 16, scale: 1 })) - } -}) - -const CONFIG: CodegenConfig = { - cssUnit: 'px', - rootFontSize: 16, - scale: 1 -} - -describe('mcp/code variables', () => { - it('collects only custom property references and keeps buckets by node/property', () => { - const styles = new Map>([ - [ - 'node-1', - { - color: 'var(--brand-primary) var(theme-color)', - size: 'calc(var(--spacing-sm, 8px) + 2px)' - } - ] - ]) - - const { references, buckets } = collectRefs(styles) - - expect(references).toEqual([ - { - nodeId: 'node-1', - property: 'color', - code: 'var(--brand-primary)', - name: 'brand-primary', - value: undefined - }, - { - nodeId: 'node-1', - property: 'size', - code: 'var(--spacing-sm, 8px)', - name: 'spacing-sm', - value: '8px' - } - ]) - expect(buckets.get('node-1:color')?.matchIndices).toEqual([0]) - expect(buckets.get('node-1:size')?.matchIndices).toEqual([1]) - }) - - it('preprocesses comment/scss forms before collecting variable references', () => { - const styles = new Map>([ - [ - 'node-1', - { - color: '/* theme */ $brand-primary' - } - ] - ]) - - const { references, buckets } = collectRefs(styles) - - expect(styles.get('node-1')?.color).toBe(' var(--brand-primary)') - expect(references).toEqual([ - { - nodeId: 'node-1', - property: 'color', - code: 'var(--brand-primary)', - name: 'brand-primary', - value: undefined - } - ]) - expect(buckets.get('node-1:color')).toEqual({ - nodeId: 'node-1', - property: 'color', - value: ' var(--brand-primary)', - matchIndices: [0] - }) - }) - - it('returns empty used-name set when no variable references are found', async () => { - const styles = new Map>([ - ['node-1', { color: '#fff', width: '100px' }] - ]) - - await expect(transform(styles, { config: CONFIG })).resolves.toEqual(new Set()) - expect(runTransformVariableBatch).not.toHaveBeenCalled() - }) - - it('rewrites variable expressions and returns used names from original and transformed refs', async () => { - const styles = new Map>([ - [ - 'node-1', - { - color: 'var(--brand-primary)', - width: 'calc(var(--spacing-sm) + 2px)' - } - ] - ]) - - vi.mocked(runTransformVariableBatch).mockResolvedValue(['--brand-color', '12px']) - - const used = await transform(styles, { config: CONFIG, pluginCode: 'return x' }) - - expect(workerUnitOptions).toHaveBeenCalledWith(CONFIG) - expect(runTransformVariableBatch).toHaveBeenCalledWith( - [ - { code: 'var(--brand-primary)', name: 'brand-primary', value: undefined }, - { code: 'var(--spacing-sm)', name: 'spacing-sm', value: undefined } - ], - { cssUnit: 'px', rootFontSize: 16, scale: 1 }, - 'return x' - ) - expect(styles.get('node-1')).toEqual({ - color: 'var(--brand-color)', - width: 'calc(12px + 2px)' - }) - expect(used).toEqual(new Set(['--brand-primary', '--spacing-sm', '--brand-color'])) - }) - - it('skips rewrite when style bucket target no longer exists and tolerates missing transform output', async () => { - const styles = new Map>([['node-1', { color: 'var(--brand)' }]]) - vi.mocked(runTransformVariableBatch).mockImplementation(async () => { - styles.delete('node-1') - return [] - }) - - const used = await transform(styles, { config: CONFIG }) - - expect(styles.has('node-1')).toBe(false) - expect(used).toEqual(new Set(['--brand'])) - }) - - it('falls back to original var expression when transform result is missing for an occurrence', async () => { - const styles = new Map>([ - [ - 'node-1', - { - color: 'var(--brand)', - width: 'var(--space)' - } - ] - ]) - - vi.mocked(runTransformVariableBatch).mockResolvedValue(['--brand-next']) - - const used = await transform(styles, { config: CONFIG }) - - expect(styles.get('node-1')).toEqual({ - color: 'var(--brand-next)', - width: 'var(--space)' - }) - expect(used).toEqual(new Set(['--brand', '--space', '--brand-next'])) - }) -}) diff --git a/packages/extension/tests/mcp/tools/design-system.test.ts b/packages/extension/tests/mcp/tools/design-system.test.ts index 1dc0e882..5253d9dd 100644 --- a/packages/extension/tests/mcp/tools/design-system.test.ts +++ b/packages/extension/tests/mcp/tools/design-system.test.ts @@ -262,6 +262,23 @@ describe('mcp/tools/design-system', () => { expect(result.components[0]!.props).toHaveProperty('class2') }) + it('avoids reserved native tags in every casing without merging component identities', async () => { + const names = ['DIV', 'DiV', 'SPAN', 'SpAn', 'BR', 'Br', 'Button', 'BUTTON'] + stubFigma({ components: names.map((name) => component(name, name)) }) + const result = await handleGetDesignSystem({}) + const tags = Object.fromEntries(result.components.map((entry) => [entry.name, entry.tag])) + expect(tags).toEqual({ + DIV: 'DIV2', + DiV: 'DiV2', + SPAN: 'SPAN2', + SpAn: 'SpAn2', + BR: 'BR2', + Br: 'Br2', + Button: 'Button', + BUTTON: 'BUTTON' + }) + }) + it('preserves WEB variable syntax for authoritative resource aliases', async () => { stubFigma({ localCollections: [collection('collection:1', 'Theme')], @@ -1089,6 +1106,82 @@ describe('mcp/tools/design-system', () => { expect(last.omitted).toBeUndefined() }) + it('bounds verbose component summaries while retaining exact catalog identities and continuation', async () => { + const options = Array.from({ length: 40 }, (_, index) => `${index}-${'界'.repeat(80)}`) + const definitions = Object.fromEntries( + Array.from({ length: 32 }, (_, index) => [ + `State${index}`, + { type: 'VARIANT' as const, defaultValue: options[0]!, variantOptions: options } + ]) + ) + stubFigma({ + components: [ + component('large', 'A large', { componentPropertyDefinitions: definitions }), + component('small', 'Z small') + ] + }) + + const first = await handleGetDesignSystem() + expect(measureCallToolResultBytes(buildGetDesignSystemToolResult(first))).toBeLessThanOrEqual( + 64 * 1024 + ) + const summary = first.components[0]! + expect(summary.ref).toBe('c1') + expect(summary.tag).toBe('ALarge') + expect(Object.keys(summary.props)).toHaveLength(32) + for (const property of Object.values(summary.props)) { + expect(property.options?.length).toBeLessThan(32) + expect(property.options).toEqual(options.slice(0, property.options!.length)) + expect(property.omittedOptions).toBe(options.length - property.options!.length) + } + expect(first.nextCursor).toBe(1) + expect(first.omitted).toEqual({ components: 1 }) + const catalog = requireDesignSystemCatalog(first.catalogId) + const entry = catalog.entries.get('c1')! + expect(entry.kind === 'component' && entry.properties.state0?.options).toEqual(options) + const detail = await handleGetDesignSystem({ catalogId: first.catalogId, ref: 'c1' }) + expect(detail.details).toMatchObject({ kind: 'component', definition: { id: 'large' } }) + expect(measureCallToolResultBytes(buildGetDesignSystemToolResult(detail))).toBeLessThanOrEqual( + 64 * 1024 + ) + const rest = await handleGetDesignSystem({ catalogId: first.catalogId, cursor: 1 }) + expect(rest.components.map(({ ref }) => ref)).toEqual(['c2']) + expect(rest.nextCursor).toBeUndefined() + }) + + it('reports byte-omitted properties without shortening native property names in the catalog', async () => { + const names = Array.from({ length: 40 }, (_, index) => `${'属性'.repeat(500)}${index}`) + stubFigma({ + components: [ + component('large-labels', 'Labels', { + componentPropertyDefinitions: Object.fromEntries( + names.map((name) => [name, { type: 'BOOLEAN', defaultValue: true }]) + ) + }) + ] + }) + const result = await handleGetDesignSystem() + expect(measureCallToolResultBytes(buildGetDesignSystemToolResult(result))).toBeLessThanOrEqual( + 64 * 1024 + ) + const summary = result.components[0]! + const properties = Object.values(summary.props) + expect(properties.length).toBeLessThan(32) + expect(summary.omittedProps).toBe(names.length - properties.length) + expect(properties.map(({ label }) => label)).toEqual(names.slice(0, properties.length)) + const entry = requireDesignSystemCatalog(result.catalogId).entries.get(summary.ref)! + expect( + entry.kind === 'component' && Object.values(entry.properties).map(({ name }) => name) + ).toEqual(names) + }) + + it('rejects an unbounded catalog identity instead of returning an undeliverable response', async () => { + stubFigma({ components: [component('huge-name', 'A'.repeat(64 * 1024))] }) + await expect(handleGetDesignSystem()).rejects.toThrow( + 'Design-system catalog entry exceeds the 64 KiB inline result budget.' + ) + }) + it('compacts each considered definition once without reading omitted definition names', async () => { stubFigma() const catalog = registerDesignSystemCatalog( diff --git a/packages/extension/tests/mcp/tools/fonts.test.ts b/packages/extension/tests/mcp/tools/fonts.test.ts index c3da5282..f9764458 100644 --- a/packages/extension/tests/mcp/tools/fonts.test.ts +++ b/packages/extension/tests/mcp/tools/fonts.test.ts @@ -1,4 +1,8 @@ -import { buildGetDesignSystemToolResult, measureCallToolResultBytes } from '@tempad-dev/shared' +import { + buildGetDesignSystemToolResult, + measureCallToolResultBytes, + utf8Bytes +} from '@tempad-dev/shared' import { afterEach, describe, expect, it, vi } from 'vitest' import { handleGetDesignSystem } from '@/mcp/tools/design-system' @@ -74,4 +78,53 @@ describe('environment font discovery', () => { fonts([['x'.repeat(16 * 1024), 'Regular']]) await expect(handleGetDesignSystem({ scope: 'fonts' })).rejects.toThrow('budget') }) + + it.each(['families', 'faces'] as const)( + 'includes the continuation cursor in the %s byte budget', + async (kind) => { + const firstName = 'A'.repeat(100) + const emptyPage = + kind === 'families' + ? { scope: 'fonts', families: [firstName, ''] } + : { + scope: 'fonts', + fonts: [ + { family: 'Font', style: firstName }, + { family: 'Font', style: '' } + ] + } + const names = [firstName, 'B'.repeat(12 * 1024 - utf8Bytes(emptyPage)), 'Z'] + fonts(names.map((name) => (kind === 'families' ? [name, 'Regular'] : ['Font', name]))) + const filters = kind === 'families' ? {} : { families: ['Font'] } + const first = await handleGetDesignSystem({ scope: 'fonts', ...filters }) + expect(utf8Bytes(first)).toBeLessThanOrEqual(12 * 1024) + expect(first.nextCursor).toBe(1) + const rest = await handleGetDesignSystem({ + scope: 'fonts', + ...filters, + cursor: first.nextCursor + }) + expect(utf8Bytes(rest)).toBeLessThanOrEqual(12 * 1024) + expect(rest.nextCursor).toBeUndefined() + expect( + kind === 'families' + ? [...first.families!, ...rest.families!] + : [...first.fonts!, ...rest.fonts!].map(({ style }) => style) + ).toEqual(names) + } + ) + + it('only permits a boundary-sized native name when no continuation is needed', async () => { + const name = 'A'.repeat(12 * 1024 - utf8Bytes({ scope: 'fonts', families: [''] })) + fonts([[name, 'Regular']]) + expect(await handleGetDesignSystem({ scope: 'fonts' })).toEqual({ + scope: 'fonts', + families: [name] + }) + fonts([ + [name, 'Regular'], + ['Z', 'Regular'] + ]) + await expect(handleGetDesignSystem({ scope: 'fonts' })).rejects.toThrow('budget') + }) }) diff --git a/packages/extension/tests/mcp/tools/token/candidates.test.ts b/packages/extension/tests/mcp/tools/token/candidates.test.ts index ddebed94..1d66b984 100644 --- a/packages/extension/tests/mcp/tools/token/candidates.test.ts +++ b/packages/extension/tests/mcp/tools/token/candidates.test.ts @@ -1,34 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getVariableByIdCached } from '@/mcp/tools/token/cache' import { collectCandidateVariableIds } from '@/mcp/tools/token/candidates' import { logger } from '@/utils/log' -vi.mock('@/utils/css', () => ({ - canonicalizeVarName: vi.fn((value: string) => { - const match = value.trim().match(/^var\(\s*(--[^,\s)]+)\s*(?:,[^)]+)?\)$/) - return match?.[1] ?? null - }), - normalizeFigmaVarName: vi.fn((value: string) => { - const normalized = value.trim().replace(/\s+/g, '-').toLowerCase() - return normalized ? `--${normalized}` : '--unnamed' - }), - toFigmaVarExpr: vi.fn((value: string) => { - const normalized = value.trim().replace(/\s+/g, '-').toLowerCase() - return normalized ? `var(--${normalized})` : '' - }) -})) - vi.mock('@/utils/log', () => ({ logger: { debug: vi.fn() } })) -vi.mock('@/mcp/tools/token/cache', () => ({ - getVariableByIdCached: vi.fn() -})) - function createNode(input: Record): SceneNode { return input as unknown as SceneNode } @@ -42,6 +22,36 @@ afterEach(() => { }) describe('token/candidates collectCandidateVariableIds', () => { + it('collects gradient stop bindings from node fills and visible style paints', () => { + const gradient = (id: string, visible = true) => ({ + type: 'GRADIENT_LINEAR', + visible, + gradientStops: [ + { boundVariables: { color: { type: 'VARIABLE_ALIAS', id } } }, + { color: { r: 0, g: 0, b: 0, a: 1 } } + ] + }) + const result = collectCandidateVariableIds( + [ + createNode({ + id: 'root', + visible: true, + fills: [gradient('direct')], + fillStyleId: 'style' + }) + ], + { + getStyleById: () => + ({ + paints: [gradient('styled'), gradient('hidden', false)] + }) as unknown as BaseStyle, + getVariableById: () => null + } + ) + + expect(result).toEqual(new Set(['direct', 'styled'])) + }) + it.each(['paints', 'boundVariables'])( 'retains lookup recovery when a native style %s getter fails without a checkpoint', (field) => { @@ -52,13 +62,12 @@ describe('token/candidates collectCandidateVariableIds', () => { }) const result = collectCandidateVariableIds( [createNode({ id: 'root', type: 'RECTANGLE', visible: true, fillStyleId: 'style' })], - undefined, { getStyleById: () => style as unknown as BaseStyle, getVariableById: () => null } ) - expect(result.variableIds).toEqual(new Set()) + expect(result).toEqual(new Set()) } ) @@ -148,32 +157,9 @@ describe('token/candidates collectCandidateVariableIds', () => { }) } as unknown as PluginAPI - const vars: Record = { - 'id-bound': { - id: 'id-bound', - name: 'Primary Color', - codeSyntax: { WEB: 'var(--brand)' } - } as unknown as Variable, - 'id-array': { - id: 'id-array', - name: 'Primary Color', - codeSyntax: { WEB: '$spacing lg' } - } as unknown as Variable, - 'id-fill': { id: 'id-fill', name: 'Fill' } as unknown as Variable, - 'id-fill-ref': { id: 'id-fill-ref', name: 'Fill Ref' } as unknown as Variable, - 'id-stroke': { id: 'id-stroke', name: 'Stroke' } as unknown as Variable, - 'id-stroke-ref': { id: 'id-stroke-ref', name: 'Stroke Ref' } as unknown as Variable, - 'id-effect': { id: 'id-effect', name: 'Effect' } as unknown as Variable, - 'id-effect-ref': { id: 'id-effect-ref', name: 'Effect Ref' } as unknown as Variable, - 'id-child': { id: 'id-child', name: 'Child Var' } as unknown as Variable, - 'id-style-fill': { id: 'id-style-fill', name: 'Style Fill' } as unknown as Variable, - 'id-style-stroke': { id: 'id-style-stroke', name: 'Style Stroke' } as unknown as Variable - } - vi.mocked(getVariableByIdCached).mockImplementation((id: string) => vars[id] ?? null) - const cache = new Map() - const result = collectCandidateVariableIds([root], cache) + const result = collectCandidateVariableIds([root]) - expect(result.variableIds).toEqual( + expect(result).toEqual( new Set([ 'id-bound', 'id-array', @@ -189,15 +175,7 @@ describe('token/candidates collectCandidateVariableIds', () => { 'id-fill-hidden' ]) ) - expect(result.rewrites.get('var(--brand)')).toEqual({ - canonical: '--primary-color', - id: 'id-bound' - }) - expect(result.rewrites.get('var(--primary-color)')).toEqual({ - canonical: '--primary-color', - id: 'id-bound' - }) - expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('ids=12 rewrites=')) + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('ids=12')) }) it('reports each visible node’s ids while preserving the ordered union', () => { @@ -224,11 +202,11 @@ describe('token/candidates collectCandidateVariableIds', () => { ] }) - const result = collectCandidateVariableIds([root], undefined, undefined, { + const result = collectCandidateVariableIds([root], undefined, { onNodeVariableIds }) - expect([...result.variableIds]).toEqual(['shared', 'child-only']) + expect([...result]).toEqual(['shared', 'child-only']) expect(onNodeVariableIds.mock.calls).toEqual([ ['root', new Set(['shared'])], ['child', new Set(['shared', 'child-only'])], @@ -236,11 +214,11 @@ describe('token/candidates collectCandidateVariableIds', () => { ]) onNodeVariableIds.mockClear() - const capped = collectCandidateVariableIds([root], undefined, undefined, { + const capped = collectCandidateVariableIds([root], undefined, { captureNodeId: (id) => id !== 'child', onNodeVariableIds }) - expect(capped.variableIds).toEqual(result.variableIds) + expect(capped).toEqual(result) expect(onNodeVariableIds.mock.calls).toEqual([ ['root', new Set(['shared'])], ['empty', new Set()] @@ -270,10 +248,8 @@ describe('token/candidates collectCandidateVariableIds', () => { }) } as unknown as PluginAPI - vi.mocked(getVariableByIdCached).mockReturnValue(null) const result = collectCandidateVariableIds([hiddenRoot, styleErrorNode, nonStringStyleIdNode]) - expect(result.variableIds).toEqual(new Set()) - expect(result.rewrites).toEqual(new Map()) + expect(result).toEqual(new Set()) }) it('can limit candidate scanning to the selected root', () => { @@ -289,15 +265,12 @@ describe('token/candidates collectCandidateVariableIds', () => { }) ] }) - vi.mocked(getVariableByIdCached).mockImplementation( - (id: string) => ({ id, name: id }) as unknown as Variable - ) - const result = collectCandidateVariableIds([root], undefined, undefined, { + const result = collectCandidateVariableIds([root], undefined, { traverseChildren: false }) - expect(result.variableIds).toEqual(new Set(['id-root'])) + expect(result).toEqual(new Set(['id-root'])) }) it('falls back to Date.now when performance is unavailable', () => { @@ -309,17 +282,9 @@ describe('token/candidates collectCandidateVariableIds', () => { visible: true, boundVariables: { color: { id: 'id-1' } } }) - vi.mocked(getVariableByIdCached).mockReturnValue({ - id: 'id-1', - name: 'Fallback Name' - } as unknown as Variable) const result = collectCandidateVariableIds([root]) - expect(result.variableIds).toEqual(new Set(['id-1'])) - expect(result.rewrites.get('var(--fallback-name)')).toEqual({ - canonical: '--fallback-name', - id: 'id-1' - }) + expect(result).toEqual(new Set(['id-1'])) if (performanceOriginal) { vi.stubGlobal('performance', performanceOriginal) @@ -355,27 +320,12 @@ describe('token/candidates collectCandidateVariableIds', () => { }) } as unknown as PluginAPI - vi.mocked(getVariableByIdCached).mockImplementation((id: string) => { - const vars: Record = { - 'id-range-font': { id: 'id-range-font', name: 'Body Font' } as unknown as Variable, - 'id-style-size': { id: 'id-style-size', name: 'Body Size' } as unknown as Variable - } - return vars[id] ?? null - }) const result = collectCandidateVariableIds([root]) - expect(result.variableIds).toEqual(new Set(['id-range-font', 'id-style-size'])) - expect(result.rewrites.get('var(--body-font)')).toEqual({ - canonical: '--body-font', - id: 'id-range-font' - }) - expect(result.rewrites.get('var(--body-size)')).toEqual({ - canonical: '--body-size', - id: 'id-style-size' - }) + expect(result).toEqual(new Set(['id-range-font', 'id-style-size'])) }) - it('handles sparse payload branches and avoids duplicate rewrite keys', () => { + it('handles sparse payload branches', () => { const root = createNode({ id: 'sparse-root', visible: true, @@ -439,41 +389,10 @@ describe('token/candidates collectCandidateVariableIds', () => { }) } as unknown as PluginAPI - const vars: Record = { - 'id-nested': { - id: 'id-nested', - name: 'Duplicate Name', - codeSyntax: { WEB: 'var(--dup)' } - } as unknown as Variable, - 'id-fill-ref-only': { - id: 'id-fill-ref-only', - name: 'Duplicate Name', - codeSyntax: { WEB: 'var(--dup)' } - } as unknown as Variable, - 'id-style-ref': { - id: 'id-style-ref', - name: '', - codeSyntax: { WEB: 'var(--dup)' } - } as unknown as Variable, - 'id-undefined-name': { - id: 'id-undefined-name', - codeSyntax: { WEB: '$not-canonical' } - } as unknown as Variable - } - vi.mocked(getVariableByIdCached).mockImplementation((id: string) => vars[id] ?? null) const result = collectCandidateVariableIds([root, rootWithNullBound, rootWithNullStyle]) - expect(result.variableIds).toEqual( + expect(result).toEqual( new Set(['id-nested', 'id-fill-ref-only', 'id-style-ref', 'id-undefined-name']) ) - expect(result.rewrites.get('var(--dup)')).toEqual({ - canonical: '--duplicate-name', - id: 'id-nested' - }) - expect(result.rewrites.get('var(--duplicate-name)')).toEqual({ - canonical: '--duplicate-name', - id: 'id-nested' - }) - expect(result.rewrites.size).toBe(2) }) }) diff --git a/packages/extension/tests/mcp/tools/token/defs-by-id.test.ts b/packages/extension/tests/mcp/tools/token/defs-by-id.test.ts index caa568af..0be50888 100644 --- a/packages/extension/tests/mcp/tools/token/defs-by-id.test.ts +++ b/packages/extension/tests/mcp/tools/token/defs-by-id.test.ts @@ -38,6 +38,22 @@ function aliases(count: number): Variable[] { } describe('used token definitions', () => { + it('preserves unlabeled mode values with a diagnostic', async () => { + const variable = { ...aliases(1)[0]!, valuesByMode: { m: 1, missing: 2 } } + const context = createTokenReadContext(config, undefined, new Map([['0', variable]])) + vi.spyOn(context, 'getCollection').mockReturnValue({ + id: 'c', + name: 'Theme', + modes: [{ modeId: 'm', name: 'Default' }] + } as VariableCollection) + const report = vi.fn() + + expect(await resolveTokenDefsByIds(new Map([['--seed', '0']]), context, true, report)).toEqual({ + '--seed': { kind: 'number', value: { 'Theme:Default': '1px', 'Theme:missing': '2px' } } + }) + expect(report).toHaveBeenCalledWith(expect.stringContaining('missing')) + }) + it('stops an alias closure before starting further native lookups after its deadline', async () => { const variables = aliases(100) let reads = 0 diff --git a/packages/extension/tests/mcp/tools/token/mapping.test.ts b/packages/extension/tests/mcp/tools/token/mapping.test.ts deleted file mode 100644 index 311581db..00000000 --- a/packages/extension/tests/mcp/tools/token/mapping.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { getVariableByIdCached } from '@/mcp/tools/token/cache' -import { normalizeStyleVars } from '@/mcp/tools/token/mapping' - -vi.mock('@/mcp/tools/token/cache', () => ({ - getVariableByIdCached: vi.fn() -})) - -describe('token/mapping', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns empty used set when mappings are missing', () => { - const styles = new Map>([['node-1', { color: 'brand' }]]) - - const used = normalizeStyleVars( - styles, - null as unknown as { - variableIds: Set - rewrites: Map - } - ) - - expect(used).toEqual(new Set()) - expect(styles.get('node-1')?.color).toBe('brand') - }) - - it('rewrites direct matches, syntax aliases and raw names with placeholder protection', () => { - const invalidPlaceholder = `__VAR_${'9'.repeat(400)}__` - const vars: Record = { - 'id-syntax-first': { - id: 'id-syntax-first', - name: 'Syntax First', - codeSyntax: { WEB: 'DesignToken' } - } as unknown as Variable, - 'id-syntax-second': { - id: 'id-syntax-second', - name: 'Syntax Second', - codeSyntax: { WEB: 'DesignToken' } - } as unknown as Variable, - 'id-skip-var': { - id: 'id-skip-var', - name: 'Skip Var', - codeSyntax: { WEB: 'var(--skip)' } - } as unknown as Variable, - 'id-short': { - id: 'id-short', - name: 'Brand Color', - codeSyntax: { WEB: 'brand-color' } - } as unknown as Variable, - 'id-long': { - id: 'id-long', - name: 'Brand Color Strong', - codeSyntax: { WEB: 'brand-color-strong' } - } as unknown as Variable, - 'id-unnamed-syntax': { - id: 'id-unnamed-syntax', - codeSyntax: { WEB: '--' } - } as unknown as Variable, - 'id-unnamed': { - id: 'id-unnamed' - } as unknown as Variable - } - vi.mocked(getVariableByIdCached).mockImplementation((id: string) => vars[id] ?? null) - - const mappings = { - variableIds: new Set([ - 'id-syntax-first', - 'id-syntax-second', - 'id-skip-var', - 'id-short', - 'id-long', - 'id-unnamed-syntax', - 'id-unnamed', - 'id-missing' - ]), - rewrites: new Map([ - ['semantic-token', { canonical: '--semantic-token', id: 'id-rewrite' }] - ]) - } - - const styles = new Map>([ - [ - 'node-1', - { - direct: ' semantic-token ', - syntax: 'DesignToken', - replace: `brand-color-strong + brand-color + var(brand-color) + brand-colorful + ${invalidPlaceholder}`, - malformed: 'var(brand-color', - untouched: 'none', - empty: '', - blank: ' ' - } - ] - ]) - - const used = normalizeStyleVars(styles, mappings) - - expect(styles.get('node-1')).toEqual({ - direct: 'var(--semantic-token)', - syntax: 'var(--Syntax-First)', - replace: `var(--Brand-Color-Strong) + var(--Brand-Color) + var(brand-color) + brand-colorful + ${invalidPlaceholder}`, - malformed: 'var(brand-color', - untouched: 'none', - empty: '', - blank: ' ' - }) - expect(used).toEqual(new Set(['id-rewrite', 'id-syntax-first', 'id-short', 'id-long'])) - }) - - it('short-circuits raw-name replacement when there are no replacement entries', () => { - vi.mocked(getVariableByIdCached).mockReturnValue(null) - - const styles = new Map>([ - ['node-2', { color: 'brand-color', blank: ' ' }] - ]) - const mappings = { - variableIds: new Set(), - rewrites: new Map() - } - - const used = normalizeStyleVars(styles, mappings) - - expect(used).toEqual(new Set()) - expect(styles.get('node-2')).toEqual({ color: 'brand-color', blank: ' ' }) - }) -}) diff --git a/packages/extension/tests/mcp/tools/token/mode-regression.test.ts b/packages/extension/tests/mcp/tools/token/mode-regression.test.ts index 9c54e42e..fc3116b9 100644 --- a/packages/extension/tests/mcp/tools/token/mode-regression.test.ts +++ b/packages/extension/tests/mcp/tools/token/mode-regression.test.ts @@ -1,3 +1,4 @@ +import { raw } from '@tempad-dev/plugins' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { processTokens } from '@/mcp/tools/code/tokens/process' @@ -5,6 +6,7 @@ import { createStyleVarResolver } from '@/mcp/tools/code/tokens/resolve' import { createTokenReadContext } from '@/mcp/tools/token/context' import { resolveTokenDefsByIds, resolveTokenDefsByNames } from '@/mcp/tools/token/defs' import { runTransformVariableBatch } from '@/mcp/transform-variables/requester' +import { stringifyComponent } from '@/utils/component' vi.mock('@/mcp/transform-variables/requester', () => ({ runTransformVariableBatch: vi.fn(async (refs: { code: string }[]) => refs.map(({ code }) => code)) @@ -126,9 +128,9 @@ describe('token mode and request regressions', () => { it('returns the same alias definitions with resolveTokens enabled or disabled', async () => { const input = { - code: '
', + component: raw('
'), + serialize: (component: ReturnType) => stringifyComponent(component, 'jsx'), variableIds: new Set([semantic.id]), - usedCandidateIds: new Set(), variableCache: new Map(), styles: new Map([['node', { color: 'var(--semantic)' }]]), textSegments: new Map(), @@ -225,9 +227,9 @@ describe('token mode and request regressions', () => { refs.map(({ name }) => `$brand ${name}`) ) const result = await processTokens({ - code: '
', + component: raw('
'), + serialize: (component) => stringifyComponent(component, 'jsx'), variableIds: new Set([semantic.id]), - usedCandidateIds: new Set(), variableCache: new Map(), styles: new Map(), textSegments: new Map(), @@ -249,9 +251,9 @@ describe('token mode and request regressions', () => { refs.map(({ code, name }) => (name === 'semantic' ? 'var(--brand)' : code)) ) const result = await processTokens({ - code: '
', + component: raw('
'), + serialize: (component) => stringifyComponent(component, 'jsx'), variableIds: new Set(variables.map(({ id }) => id)), - usedCandidateIds: new Set(), variableCache: new Map(), styles: new Map(), textSegments: new Map(), diff --git a/packages/extension/tests/plugin-sandbox/client.browser.test.ts b/packages/extension/tests/plugin-sandbox/client.browser.test.ts new file mode 100644 index 00000000..9e617850 --- /dev/null +++ b/packages/extension/tests/plugin-sandbox/client.browser.test.ts @@ -0,0 +1,109 @@ +import { expect, it, vi } from 'vitest' + +import { PluginSandboxClient } from '@/plugin-sandbox/client' +import { + PLUGIN_SANDBOX_MESSAGE, + PLUGIN_SANDBOX_PROTOCOL_VERSION, + PLUGIN_SANDBOX_WORKER +} from '@/plugin-sandbox/protocol' + +it('rejects a request interrupted after connection and preserves its replacement connection', async () => { + const append = document.documentElement.append.bind(document.documentElement) + vi.spyOn(document.documentElement, 'append').mockImplementation((...nodes) => { + for (const node of nodes) { + if (!(node instanceof HTMLIFrameElement)) continue + // Exercise opaque iframe messages and real transferred ports with a small echo broker. + node.removeAttribute('src') + node.srcdoc = `` + } + append(...nodes) + }) + const client = new PluginSandboxClient( + 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/plugin-sandbox.html' + ) + let interrupted: Promise | undefined + try { + await expect(client.request(PLUGIN_SANDBOX_WORKER.codegen, 'initial')).resolves.toBe('initial') + vi.useFakeTimers() + let failure: unknown + interrupted = client + .request(PLUGIN_SANDBOX_WORKER.codegen, 'discard') + .catch((error: unknown) => { + failure = error + }) + client.dispose() + await expect(client.request(PLUGIN_SANDBOX_WORKER.codegen, 'replacement')).resolves.toBe( + 'replacement' + ) + await vi.advanceTimersByTimeAsync(0) + expect(failure).toMatchObject({ + message: 'Plugin sandbox connection was reset before sending the request.' + }) + expect(vi.getTimerCount()).toBe(0) + await expect(client.request(PLUGIN_SANDBOX_WORKER.codegen, 'still connected')).resolves.toBe( + 'still connected' + ) + } finally { + client.dispose() + await interrupted + vi.useRealTimers() + } +}) + +it('rejects connecting requests and clears the handshake when the client is disposed', async () => { + vi.useFakeTimers() + // Keep the native iframe unmounted to represent an unanswered sandbox load. + vi.spyOn(document.documentElement, 'append').mockImplementation(() => {}) + const createElement = vi.spyOn(document, 'createElement') + const client = new PluginSandboxClient( + 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/plugin-sandbox.html' + ) + let failure: unknown + const pending = client.request(PLUGIN_SANDBOX_WORKER.codegen, {}).catch((error: unknown) => { + failure = error + }) + const retryFailures: unknown[] = [] + const retries: Promise[] = [] + try { + client.dispose() + retries.push( + client.request(PLUGIN_SANDBOX_WORKER.codegen, {}).catch((error: unknown) => { + retryFailures.push(error) + }) + ) + await vi.advanceTimersByTimeAsync(0) + expect(failure).toMatchObject({ message: 'Plugin sandbox client was disposed.' }) + retries.push( + client.request(PLUGIN_SANDBOX_WORKER.codegen, {}).catch((error: unknown) => { + retryFailures.push(error) + }) + ) + expect(createElement.mock.calls.filter(([tag]) => tag === 'iframe')).toHaveLength(2) + client.dispose() + await vi.advanceTimersByTimeAsync(0) + expect(retryFailures).toEqual([ + expect.objectContaining({ message: 'Plugin sandbox client was disposed.' }), + expect.objectContaining({ message: 'Plugin sandbox client was disposed.' }) + ]) + expect(vi.getTimerCount()).toBe(0) + } finally { + client.dispose() + await vi.runAllTimersAsync() + await pending + await Promise.all(retries) + vi.useRealTimers() + } +}) diff --git a/packages/extension/tests/plugin-sandbox/limits.test.ts b/packages/extension/tests/plugin-sandbox/limits.test.ts index 1fd2a2e1..244a7030 100644 --- a/packages/extension/tests/plugin-sandbox/limits.test.ts +++ b/packages/extension/tests/plugin-sandbox/limits.test.ts @@ -42,6 +42,35 @@ describe('plugin sandbox payload limits', () => { }) }) + it('checks enumerable array properties preserved by structured clone', () => { + const withMetadata = structuredClone(Object.assign(['ok'], { metadata: { value: true } })) + expect(inspectSandboxValue(withMetadata)).toMatchObject({ ok: true }) + + const oversized = Object.assign([], { + metadata: 'x'.repeat(PLUGIN_SANDBOX_LIMITS.maxPayloadBytes + 1) + }) + expect(inspectSandboxValue(structuredClone(oversized))).toEqual({ + ok: false, + reason: 'payload-too-large' + }) + + const cyclic: unknown[] & { metadata?: unknown } = [] + cyclic.metadata = cyclic + expect(inspectSandboxValue(structuredClone(cyclic))).toEqual({ + ok: false, + reason: 'invalid-payload' + }) + }) + + it('bounds repeated-reference graphs by the work their consumers must traverse', () => { + let value: unknown = 'leaf' + for (let depth = 0; depth < 20; depth += 1) value = [value, value] + expect(inspectSandboxValue(structuredClone(value))).toEqual({ + ok: false, + reason: 'payload-too-large' + }) + }) + it('bounds nesting depth and entry count', () => { let nested: Record = {} for (let depth = 0; depth <= PLUGIN_SANDBOX_LIMITS.maxPayloadDepth; depth += 1) { diff --git a/packages/extension/tests/rewrite/config.test.ts b/packages/extension/tests/rewrite/config.test.ts index 13fadcc1..5582bf7e 100644 --- a/packages/extension/tests/rewrite/config.test.ts +++ b/packages/extension/tests/rewrite/config.test.ts @@ -31,4 +31,9 @@ describe('rewrite/config', () => { ) ).toBe('let{canRunExtensions:a,canAccessFullDevMode:b}=c.openFile;a=true;return a&&b;') }) + + it('does not confuse a variable ending in openFile with an openFile property access', () => { + const input = 'let{canRunExtensions:a,canAccessFullDevMode:b}=someopenFile;return a&&b;' + expect(applyFirstReplacement(2, input)).toBe(input) + }) }) diff --git a/packages/extension/tests/rewrite/content.browser.test.ts b/packages/extension/tests/rewrite/content.browser.test.ts new file mode 100644 index 00000000..3b9085a3 --- /dev/null +++ b/packages/extension/tests/rewrite/content.browser.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const nativeAppendChild = Element.prototype.appendChild +const nativeInsertBefore = Element.prototype.insertBefore +let install: () => void + +beforeAll(async () => { + vi.stubGlobal('defineContentScript', (definition: unknown) => definition) + const entrypoint = await import('@/entrypoints/rewrite.content') + install = entrypoint.default.main as () => void + vi.unstubAllGlobals() +}) + +beforeEach(() => install()) + +afterEach(() => { + Element.prototype.appendChild = nativeAppendChild + Element.prototype.insertBefore = nativeInsertBefore + document.querySelectorAll('[data-rewrite-test]').forEach((node) => node.remove()) + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function createScript(): HTMLScriptElement { + const script = document.createElement('script') + // Keep loading under test control while exercising native DOM insertion and Blob URLs. + script.type = 'application/json' + script.src = 'https://www.figma.com/webpack-artifacts/assets/shared.min.js' + script.dataset.rewriteTest = '' + return script +} + +describe('dynamic script rewriting', () => { + it('shares an in-flight rewrite and releases its Blob only after every script settles', async () => { + const response = Promise.withResolvers() + const fetch = vi.fn().mockReturnValue(response.promise) + vi.stubGlobal('fetch', fetch) + const create = vi.spyOn(URL, 'createObjectURL') + const revoke = vi.spyOn(URL, 'revokeObjectURL') + const first = createScript() + const second = createScript() + document.head.appendChild(first) + document.head.appendChild(second) + response.resolve(new Response('/* shared chunk */')) + await vi.waitFor(() => expect(first.isConnected && second.isConnected).toBe(true)) + + expect(fetch).toHaveBeenCalledOnce() + expect(create).toHaveBeenCalledOnce() + expect(first.src).toBe(second.src) + expect(first.src).toMatch(/^blob:/) + first.dispatchEvent(new Event('load')) + first.dispatchEvent(new Event('error')) + expect(revoke).not.toHaveBeenCalled() + second.dispatchEvent(new Event('error')) + expect(revoke).toHaveBeenCalledExactlyOnceWith(first.src) + }) + + it('retains the original script on HTTP failure and retries the source on its next insertion', async () => { + const fetch = vi.fn().mockResolvedValueOnce(new Response('', { status: 503 })) + fetch.mockResolvedValueOnce(new Response('/* available now */')) + vi.stubGlobal('fetch', fetch) + const create = vi.spyOn(URL, 'createObjectURL') + const revoke = vi.spyOn(URL, 'revokeObjectURL') + const first = createScript() + const original = first.src + first.integrity = 'sha256-original' + document.head.appendChild(first) + await vi.waitFor(() => expect(first.isConnected).toBe(true)) + + expect(first.src).toBe(original) + expect(first.integrity).toBe('sha256-original') + expect(create).not.toHaveBeenCalled() + const retry = createScript() + document.head.appendChild(retry) + await vi.waitFor(() => expect(retry.isConnected).toBe(true)) + expect(fetch).toHaveBeenCalledTimes(2) + expect(retry.src).toMatch(/^blob:/) + retry.dispatchEvent(new Event('load')) + expect(revoke).toHaveBeenCalledExactlyOnceWith(retry.src) + }) +}) diff --git a/packages/extension/tests/rewrite/rules.test.ts b/packages/extension/tests/rewrite/rules.test.ts index 4147690d..9f79300c 100644 --- a/packages/extension/tests/rewrite/rules.test.ts +++ b/packages/extension/tests/rewrite/rules.test.ts @@ -20,4 +20,15 @@ describe('packaged script matching', () => { REWRITE_TARGET_REGEX.test('https://www.figma.com/WEBPACK-ARTIFACTS/ASSETS/ABC.MIN.JS') ).toBe(true) }) + + it.each([ + 'http://127.0.0.1:6220', + 'http://www.figma.com', + 'https://figma.com', + 'https://sub.www.figma.com', + 'https://www.figma.com.example.com', + 'https://example.com' + ])('leaves matching script paths on %s unchanged', (origin) => { + expect(REWRITE_TARGET_REGEX.test(`${origin}/webpack-artifacts/assets/entry.min.js`)).toBe(false) + }) }) diff --git a/packages/extension/tests/rewrite/runtime.test.ts b/packages/extension/tests/rewrite/runtime.test.ts index 8776f369..cd26cc19 100644 --- a/packages/extension/tests/rewrite/runtime.test.ts +++ b/packages/extension/tests/rewrite/runtime.test.ts @@ -82,6 +82,7 @@ describe('rewrite/runtime rewriteCurrentScript', () => { const src = 'https://example.com/app.js' const { document, current } = setupRuntimeContext(src) const fetchMock = vi.fn().mockResolvedValue({ + ok: true, text: vi.fn().mockResolvedValue('console.log("original");') }) vi.stubGlobal('fetch', fetchMock) @@ -104,6 +105,7 @@ describe('rewrite/runtime rewriteCurrentScript', () => { expect((globalThis as { window: { figma?: unknown } }).window.figma).toBeUndefined() expect((globalThis as { __rewriteExecuted?: string }).__rewriteExecuted).toBe(src) expect(document.currentScript).toBe(current) + expect(Object.hasOwn(document, 'currentScript')).toBe(false) expect(current?.replacedWithNode).toBeNull() }) @@ -130,14 +132,15 @@ describe('rewrite/runtime rewriteCurrentScript', () => { expect(mocks.applyGroups).not.toHaveBeenCalled() }) - it('handles unchanged rewrites and restores currentScript without a prototype descriptor', async () => { + it('handles unchanged rewrites and restores an own currentScript without a prototype descriptor', async () => { const descriptor = Object.getOwnPropertyDescriptor(MockDocument.prototype, 'currentScript') Reflect.deleteProperty(MockDocument.prototype, 'currentScript') try { const src = 'https://example.com/unchanged.js' - const { document } = setupRuntimeContext(src) + const { document, current } = setupRuntimeContext(src) const fetchMock = vi.fn().mockResolvedValue({ + ok: true, text: vi.fn().mockResolvedValue('console.log("same");') }) vi.stubGlobal('fetch', fetchMock) @@ -150,7 +153,8 @@ describe('rewrite/runtime rewriteCurrentScript', () => { expect(mocks.logger.log).not.toHaveBeenCalled() expect((globalThis as { __rewriteExecuted?: string }).__rewriteExecuted).toBe('ok') - expect(Reflect.has(document, 'currentScript')).toBe(false) + expect(document.currentScript).toBe(current) + expect(Object.hasOwn(document, 'currentScript')).toBe(true) } finally { if (descriptor) { Object.defineProperty(MockDocument.prototype, 'currentScript', descriptor) @@ -158,6 +162,57 @@ describe('rewrite/runtime rewriteCurrentScript', () => { } }) + it.each([ + { own: false, throws: false }, + { own: false, throws: true }, + { own: true, throws: false }, + { own: true, throws: true } + ])( + 'restores the currentScript descriptor (own=$own, throws=$throws)', + async ({ own, throws }) => { + const src = 'https://example.com/owned.js' + const { document, current } = setupRuntimeContext(src) + if (own) + Object.defineProperty(document, 'currentScript', { + value: current, + writable: false, + enumerable: true, + configurable: true + }) + const original = Object.getOwnPropertyDescriptor(document, 'currentScript') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => '' })) + mocks.applyGroups.mockReturnValue({ + content: throws ? 'throw new Error("script failed")' : '', + changed: false + }) + + await rewriteCurrentScript([]) + + expect(Object.getOwnPropertyDescriptor(document, 'currentScript')).toEqual(original) + expect(current?.replacedWithNode === null).toBe(!throws) + } + ) + + it.each([403, 503])('falls back without evaluating an HTTP %s response body', async (status) => { + const src = 'https://example.com/unavailable.js' + const { current } = setupRuntimeContext(src) + const text = vi.fn().mockResolvedValue('globalThis.__rewriteExecuted = "error response";') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status, text })) + mocks.applyGroups.mockReturnValue({ content: '', changed: false }) + + await rewriteCurrentScript([]) + + expect(mocks.applyGroups).not.toHaveBeenCalled() + expect(text).not.toHaveBeenCalled() + expect((globalThis as { __rewriteExecuted?: string }).__rewriteExecuted).toBeUndefined() + expect(current?.replacedWithNode?.src).toBe(`${src}?fallback`) + expect(mocks.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining(String(status)) + }) + ) + }) + it('falls back to loading original script when rewrite fails', async () => { const src = 'https://example.com/runtime.js' const { current } = setupRuntimeContext(src) diff --git a/packages/extension/tests/scripts/agent-authoring-run-log.test.ts b/packages/extension/tests/scripts/agent-authoring-run-log.test.ts index cd9a0dd9..389882e1 100644 --- a/packages/extension/tests/scripts/agent-authoring-run-log.test.ts +++ b/packages/extension/tests/scripts/agent-authoring-run-log.test.ts @@ -288,6 +288,57 @@ describe('agent authoring run log', () => { ).toThrow('consistent runtime evidence') }) + it.each(['hub', 'extension'])('requires the %s fingerprint in every successful apply', (side) => { + const start = buildStartEvent(note(), preflight(), '2026-08-29T00:00:11.000Z') + const original = rollout() + const incompleteApply = original + .split('\n') + .find((line) => line.includes('apply_canvas'))! + .replace( + `"${side}":{"runtimeFingerprint":"${side === 'hub' ? hubFingerprint : extensionFingerprint}"}`, + `"${side}":{}` + ) + + expect(() => + buildFinishEvent( + start, + review(), + { + source: '/tmp/incomplete-runtime.jsonl', + text: original + incompleteApply + '\n' + }, + '2026-08-29T00:01:00.000Z' + ) + ).toThrow('consistent runtime evidence') + }) + + it('rejects a host skill catalog change after the initial matching catalog', () => { + const start = buildStartEvent(note(), preflight(), '2026-08-29T00:00:11.000Z') + const source = + rollout() + + row({ + type: 'response_item', + payload: { + type: 'message', + role: 'developer', + content: [ + { + type: 'input_text', + text: '\n- unrelated: Different skills. (file: /other/SKILL.md)\n' + } + ] + } + }) + expect(() => + buildFinishEvent( + start, + review(), + { source: '/tmp/changed-catalog.jsonl', text: source }, + '2026-08-29T00:01:00.000Z' + ) + ).toThrow('authoring skill verified at run start') + }) + it('rejects post-start prompt, runtime, skill, and artifact substitutions', () => { const start = buildStartEvent(note(), preflight(), '2026-08-29T00:00:11.000Z') @@ -359,6 +410,46 @@ describe('agent authoring run log', () => { ).not.toThrow() }) + it('accepts a direct task following host metadata without treating assistant text as identity', () => { + const start = buildStartEvent(note(), preflight(), '2026-08-29T00:00:11.000Z') + const source = + rollout().replace(/.*?<\/codex_delegation>/, '') + + row({ + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [ + { + type: 'input_text', + text: `cwd${note().task.prompt}` + } + ] + } + }) + + row({ + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Invented task' + } + ] + } + }) + expect(() => + buildFinishEvent( + start, + review(), + { source: '/tmp/direct-task.jsonl', text: source }, + '2026-08-29T00:01:00.000Z' + ) + ).not.toThrow() + }) + it.each([false, true])( 'checks missing runtime evidence after a verified apply (isError=%s)', (isError) => { diff --git a/packages/extension/tests/scripts/agent-authoring-runtime-preflight.test.ts b/packages/extension/tests/scripts/agent-authoring-runtime-preflight.test.ts index 241af3eb..b8e1a9af 100644 --- a/packages/extension/tests/scripts/agent-authoring-runtime-preflight.test.ts +++ b/packages/extension/tests/scripts/agent-authoring-runtime-preflight.test.ts @@ -1,59 +1,178 @@ -import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { dirname, join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' import { - commandIncludesExactPath, evaluateActiveExtensionRuntime, evaluateRuntimeProcesses, evaluateTempadPluginIdentity, extractDevelopmentBundleFingerprint, + listRuntimeProcesses, parseEnabledPlugins, parseProcessTable, + processRunsRuntimeEntry, resolveCodexExecutable, - resolveDefaultRuntimeDir + runPreflight } from '@/scripts/agent-authoring-runtime-preflight' +const exec = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ + execFile: Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: exec }) +})) + const paths = { cli: '/repo/packages/mcp-server/dist/cli.mjs', hub: '/repo/packages/mcp-server/dist/hub.mjs' } -function runtimeProcess( - path: string, - pid: number, - startedAtMs: number -): { command: string; pid: number; ppid: number; startedAtMs: number } { - return { command: `/usr/bin/node ${path}`, pid, ppid: 1, startedAtMs } +function runtimeProcess(path: string, pid: number, startedAtMs: number) { + return { + command: `/usr/bin/node ${path}`, + executable: '/usr/bin/node', + pid, + ppid: 1, + startedAtMs + } } describe('agent authoring runtime preflight', () => { - it.skipIf(process.platform === 'win32')( - 'locates the same private Linux root as the Hub, retaining a safe legacy runtime', - () => { - const directory = mkdtempSync(join(tmpdir(), 'tempad-preflight-path-')) - const legacyRoot = join(directory, 'tempad-dev') - const isolated = join(directory, `tempad-dev-${process.getuid!()}`, 'run') + it('counts only the managed Hub role, not its shared-entry supervisor', () => { + const entry = '/private/runtime/package/dist/managed-runtime.mjs' + const edge = runtimeProcess(paths.cli, 1, 1) + const hub = { + ...runtimeProcess(entry, 2, 1), + command: `/usr/bin/node ${entry} --root /private/runtime --role hub` + } + const supervisor = { + ...runtimeProcess(entry, 3, 1), + command: `/usr/bin/node ${entry} --root /private/runtime --role supervisor` + } + expect( + evaluateRuntimeProcesses({ cli: paths.cli, hub: entry, managed: true }, [ + edge, + hub, + supervisor + ]) + ).toEqual({ cli: [edge], hub: [hub], issues: [] }) + }) + it.each([ + { copiedManifest: false, symlinkCheckout: false, relativeRuntime: false }, + { copiedManifest: true, symlinkCheckout: false, relativeRuntime: false }, + { copiedManifest: false, symlinkCheckout: true, relativeRuntime: false }, + { copiedManifest: false, symlinkCheckout: false, relativeRuntime: true } + ])( + 'requires this checkout and an absolute runtime path (copied: $copiedManifest, symlink: $symlinkCheckout, relative: $relativeRuntime)', + async ({ copiedManifest, symlinkCheckout, relativeRuntime }) => { + const directory = mkdtempSync(join(tmpdir(), 'tempad-preflight-checkout-')) + const physicalCheckout = join(directory, 'checkout') + const checkout = symlinkCheckout ? join(directory, 'checkout-link') : physicalCheckout + const pluginRoot = join(checkout, '.dev/plugins/tempad-dev-dev') + const runtimeRoot = copiedManifest ? join(directory, 'old-checkout') : physicalCheckout + const cli = join(runtimeRoot, 'packages/mcp-server/dist/cli.mjs') + const hub = join(runtimeRoot, 'packages/mcp-server/dist/hub.mjs') + const runtimeDir = join(directory, 'run') + const appPath = join(directory, 'Host.app') + const fingerprint = 'a'.repeat(64) + const version = '0.1.2+codex.fixture' + const write = (path: string, text: string) => { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, text) + } + const writeJson = (path: string, value: unknown) => write(path, JSON.stringify(value)) try { - expect(resolveDefaultRuntimeDir(directory, 'linux')).toBe(join(legacyRoot, 'run')) - expect(existsSync(legacyRoot)).toBe(false) - mkdirSync(legacyRoot, { mode: 0o700 }) - expect(resolveDefaultRuntimeDir(directory, 'linux')).toBe(join(legacyRoot, 'run')) - expect(resolveDefaultRuntimeDir(directory, 'linux', process.getuid!() + 1)).toBe( - join(directory, `tempad-dev-${process.getuid!() + 1}`, 'run') + mkdirSync(physicalCheckout) + if (symlinkCheckout) symlinkSync(physicalCheckout, checkout, 'junction') + write(cli, '') + write(hub, '') + write( + resolveCodexExecutable(appPath, 'darwin', () => false), + '' ) - chmodSync(legacyRoot, 0o755) - expect(resolveDefaultRuntimeDir(directory, 'linux')).toBe(isolated) - rmSync(legacyRoot, { recursive: true }) - symlinkSync(directory, legacyRoot) - expect(resolveDefaultRuntimeDir(directory, 'linux')).toBe(isolated) - expect(resolveDefaultRuntimeDir(directory, 'darwin')).toBe(join(legacyRoot, 'run')) + writeJson(join(pluginRoot, '.codex-plugin/plugin.json'), { + name: 'tempad-dev-dev', + version + }) + writeJson(join(pluginRoot, '.mcp.json'), { + mcpServers: { + 'tempad-dev-dev': { + command: 'node', + args: [cli], + env: { + TEMPAD_MCP_RUNTIME_DIR: relativeRuntime ? '../run' : runtimeDir, + TEMPAD_MCP_DEV_CHECKOUT: checkout + } + } + } + }) + const { runtimeDirectories } = + await import('../../../../agent-plugin/src/clients/shared/runtime-profile.mjs') + const profileRuntime = runtimeDirectories({ + TEMPAD_MCP_RUNTIME_DIR: runtimeDir, + TEMPAD_MCP_DEV_CHECKOUT: checkout + }).runtime + writeJson(join(profileRuntime, 'hub-runtime.json'), { + processId: 102, + activeExtension: { + connectedAt: '2026-10-01T00:00:00.000Z', + fingerprint, + id: 'extension-fixture', + version: '0.23.5' + } + }) + write( + join(checkout, 'scripts/extension-runtime-fingerprint.mjs'), + `export const computeExtensionRuntimeFingerprint = () => '${fingerprint}'` + ) + exec.mockImplementation(async (executable: string, args: string[]) => { + if (executable !== 'ps') { + return { + stdout: `tempad-dev-dev@tempad-dev-dev installed, enabled ${version} ${pluginRoot}` + } + } + return { + stdout: /,(?:comm|exe)=$/.test(args.at(-1) ?? '') + ? '101 1 Thu Oct 01 00:00:00 2026 /usr/bin/node\n102 1 Thu Oct 01 00:00:00 2026 /usr/bin/node' + : `101 1 Thu Oct 01 00:00:00 2026 node ${cli}\n102 1 Thu Oct 01 00:00:00 2026 node ${hub}` + } + }) + const preflight = runPreflight({ appPath, checkout }) + if (copiedManifest) { + await expect(preflight).rejects.toThrow('this checkout') + expect(exec).not.toHaveBeenCalled() + } else if (relativeRuntime) { + await expect(preflight).rejects.toThrow('must be an absolute path') + expect(exec).not.toHaveBeenCalled() + } else { + await expect(preflight).resolves.toMatchObject({ valid: true }) + } } finally { + exec.mockReset() rmSync(directory, { recursive: true, force: true }) } } ) + + it.each([ + ['darwin', 'comm'], + ['linux', 'exe'] + ] as const)('uses native executable paths on %s', async (platform, field) => { + const prefix = '101 1 Thu Oct 01 00:00:00 2026 ' + exec.mockImplementation(async (_executable: string, args: string[]) => ({ + stdout: prefix + (args.at(-1)?.endsWith(`,${field}=`) ? '/usr/bin/node' : `node ${paths.cli}`) + })) + try { + const processes = await listRuntimeProcesses(platform) + expect(evaluateRuntimeProcesses(paths, processes).cli.map(({ pid }) => pid)).toEqual([101]) + expect(exec.mock.calls.map(([, args]) => args)).toEqual([ + ['-axww', '-o', 'pid=,ppid=,lstart=,command='], + ['-axww', '-o', `pid=,ppid=,lstart=,${field}=`] + ]) + } finally { + exec.mockReset() + } + }) + it('reads one unambiguous development bundle fingerprint', () => { const fingerprint = 'a'.repeat(64) expect( @@ -86,20 +205,53 @@ describe('agent authoring runtime preflight', () => { } }) - it('parses macOS process start times and matches only an exact bundle path token', () => { + it('parses matching process snapshots and matches only a direct exact entry', () => { const processes = parseProcessTable( [ `50765 91415 Fri Aug 28 00:42:27 2026 /usr/bin/node ${paths.cli}`, `50766 91415 Fri Aug 28 00:42:28 2026 /usr/bin/node ${paths.cli}.backup` + ].join('\n'), + [ + '50765 91415 Fri Aug 28 00:42:27 2026 /usr/bin/node', + '50766 91415 Fri Aug 28 00:42:28 2026 /usr/bin/node' ].join('\n') ) expect(processes).toHaveLength(2) expect(processes[0]).toMatchObject({ pid: 50765, ppid: 91415 }) expect(Number.isFinite(processes[0]?.startedAtMs)).toBe(true) - expect(commandIncludesExactPath(processes[0]!.command, paths.cli)).toBe(true) - expect(commandIncludesExactPath(processes[1]!.command, paths.cli)).toBe(false) - expect(commandIncludesExactPath(`/usr/bin/node "${paths.cli}"`, paths.cli)).toBe(true) + expect(processRunsRuntimeEntry(processes[0]!, paths.cli)).toBe(true) + expect(processRunsRuntimeEntry(processes[1]!, paths.cli)).toBe(false) + expect( + processRunsRuntimeEntry( + { ...processes[0]!, command: `/usr/bin/node "${paths.cli}"` }, + paths.cli + ) + ).toBe(true) + }) + + it('does not infer executable ownership from unmatched or replaced process rows', () => { + const commands = [1, 2, 3, 4].map( + (pid) => `${pid} 9 Fri Aug 28 00:42:27 2026 /usr/bin/node ${paths.cli}` + ) + const processes = parseProcessTable( + commands.join('\n'), + [ + '1 9 Fri Aug 28 00:42:28 2026 /usr/bin/node', + '2 10 Fri Aug 28 00:42:27 2026 /usr/bin/node', + '4 9 Fri Aug 28 00:42:27 2026 /usr/bin/node' + ].join('\n') + ) + expect(processes.map(({ executable }) => executable)).toEqual([ + null, + null, + null, + '/usr/bin/node' + ]) + expect(evaluateRuntimeProcesses(paths, processes).cli.map(({ pid }) => pid)).toEqual([4]) + expect(evaluateRuntimeProcesses(paths, processes.slice(0, 3)).issues).toContainEqual( + expect.objectContaining({ code: 'RUNTIME_ABSENT' }) + ) }) it('accepts one Hub and one or more exact-checkout CLIs', () => { diff --git a/packages/extension/tests/scripts/agent-plugin-packaging.test.ts b/packages/extension/tests/scripts/agent-plugin-packaging.test.ts index 896ebd68..cfc07def 100644 --- a/packages/extension/tests/scripts/agent-plugin-packaging.test.ts +++ b/packages/extension/tests/scripts/agent-plugin-packaging.test.ts @@ -6,7 +6,8 @@ import { mkdtempSync, readFileSync, realpathSync, - rmSync + rmSync, + writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -20,6 +21,9 @@ const PLUGINS_CLI = 'agent-plugin/targets/plugins-cli' const CODEX = 'agent-plugin/targets/codex' const CLAUDE = 'agent-plugin/targets/claude' const DEV = '.dev/plugins/tempad-dev-dev' +const { runtimeSourceHash } = await import( + pathToFileURL(join(repository, 'packages/mcp-server/scripts/runtime-source-hash.mjs')).href +) let root: string @@ -40,6 +44,8 @@ beforeAll(() => { root = realpathSync(mkdtempSync(join(tmpdir(), 'tempad-plugin-packaging-'))) for (const path of [ 'scripts/build-agent-plugin.mjs', + 'scripts/native-runtime-fixture.mjs', + 'packages/mcp-server/scripts/runtime-source-hash.mjs', 'agent-plugin/src', 'packages/extension/public/icon-128.png', '.agents/plugins/marketplace.json', @@ -162,9 +168,9 @@ it.each([STANDARD, PLUGINS_CLI, CODEX, CLAUDE, DEV])( (target) => { expect(has(target, 'skills', 'figma-canvas-authoring')).toBe(target === STANDARD) for (const skill of SKILLS) { - expect(readFileSync(join(root, target, 'skills', skill, 'SKILL.md'), 'utf8')).toBe( - sourceFile(join('skills', skill, 'SKILL.md')) - ) + const content = readFileSync(join(root, target, 'skills', skill, 'SKILL.md'), 'utf8') + expect(content).toBe(sourceFile(join('skills', skill, 'SKILL.md'))) + expect(content.split('\n---\n', 1)[0]?.match(/^name: (.+)$/m)?.[1]).toBe(skill) expect(has(target, 'skills', skill, 'assets/icon.svg')).toBe(true) } expect(has(target, 'assets/icon.png')).toBe(true) @@ -174,7 +180,7 @@ it.each([STANDARD, PLUGINS_CLI, CODEX, CLAUDE, DEV])( it('derives the standalone compatibility skill from the current authoring skill', () => { const alias = join(STANDARD, 'skills/figma-canvas-authoring') const content = readFileSync(join(root, alias, 'SKILL.md'), 'utf8') - expect(content).toContain('name: figma-canvas-authoring') + expect(content.split('\n---\n', 1)[0]?.match(/^name: (.+)$/m)?.[1]).toBe('figma-canvas-authoring') expect(content).toContain( 'Use only when design-in-figma is not installed; otherwise use design-in-figma.' ) @@ -186,11 +192,15 @@ it('derives the standalone compatibility skill from the current authoring skill' ) }) -it('pins releases to npm and the development build to this checkout', () => { - for (const target of [PLUGINS_CLI, CODEX, CLAUDE]) { - expect(read(join(target, '.mcp.json')).mcpServers['tempad-dev'].args).toEqual([ +it('enrolls release installs in stable updates while keeping development pinned to the checkout', () => { + for (const path of [ + join(STANDARD, 'mcp.json'), + ...[PLUGINS_CLI, CODEX, CLAUDE].map((target) => join(target, '.mcp.json')) + ]) { + expect(read(path).mcpServers['tempad-dev'].args).toEqual([ '-y', - '@tempad-dev/mcp@latest' + '@tempad-dev/mcp@latest', + '--runtime-updates=stable' ]) } expect(read(join(DEV, '.mcp.json')).mcpServers['tempad-dev-dev'].args).toEqual([ @@ -201,6 +211,19 @@ it('pins releases to npm and the development build to this checkout', () => { 'TemPad Dev (Dev)' ) expect(read(join(DEV, '.claude-plugin/plugin.json')).hooks).toBe('./clients/claude/hooks.json') + const profile = read(join(DEV, 'clients/shared/runtime-profile.json')) + expect(profile).toEqual({ + checkout: root, + id: expect.stringMatching(/^[a-f0-9]{16}$/), + serverName: read(join(DEV, '.claude-plugin/plugin.json')).name + }) + expect(read(join(DEV, '.claude-plugin/plugin.json')).mcpServers).toBe('./.mcp.json') + expect(Object.keys(read(join(DEV, '.mcp.json')).mcpServers)).toEqual([profile.serverName]) + const hooks = read(join(DEV, 'clients/claude/hooks.json')).hooks + expect(hooks.Stop[0].hooks[0].command).toContain( + '--runtime-profile "${CLAUDE_PLUGIN_ROOT}/clients/shared/runtime-profile.json"' + ) + expect(has(CLAUDE, 'clients/shared/runtime-profile.json')).toBe(false) }) it('gives Codex enough time to receive the bounded canvas outcome', () => { @@ -217,3 +240,86 @@ it('gives Codex enough time to receive the bounded canvas outcome', () => { 'tool_timeout_sec' ) }) + +it('limits the native runtime fixture to the ignored plugin and routes both hosts to it', () => { + for (const path of [ + 'packages/mcp-server/package.json', + 'packages/mcp-server/tsdown.config.ts', + 'packages/mcp-server/scripts/runtime-contract.mjs', + 'packages/mcp-server/src/probe.ts', + 'packages/shared/src/probe.ts', + 'pnpm-lock.yaml' + ]) { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), '{}\n') + } + const fixtureRoot = realpathSync(mkdtempSync(join(root, 'native-fixture-'))) + const registryUrl = 'http://127.0.0.1:34567' + const artifacts = ['0.12.0', '0.12.1'].map((version) => { + const directory = join(fixtureRoot, version) + mkdirSync(directory) + writeFileSync( + join(directory, 'package.json'), + JSON.stringify({ + name: '@tempad-dev/mcp', + version, + private: true, + publishConfig: { registry: registryUrl } + }) + ) + return { version, directory, integrity: `sha512-${Buffer.alloc(64).toString('base64')}` } + }) + const fixture = { + version: 1, + kind: 'native-runtime-fixture', + testOnly: true, + checkout: root, + sourceHash: runtimeSourceHash(root), + root: fixtureRoot, + registryUrl, + env: { + TEMPAD_MCP_RUNTIME_DIR: join(fixtureRoot, 'run'), + TEMPAD_MCP_STATE_DIR: join(fixtureRoot, 'state'), + TEMPAD_MCP_ASSET_DIR: join(fixtureRoot, 'assets'), + TEMPAD_MCP_LOG_DIR: join(fixtureRoot, 'logs') + }, + initial: artifacts[0], + candidate: artifacts[1] + } + const descriptor = join(fixtureRoot, 'runtime-fixture.json') + writeFileSync(descriptor, JSON.stringify(fixture), { mode: 0o600 }) + const released = [join(CODEX, '.mcp.json'), join(CLAUDE, 'clients/claude/hooks.json')] + const before = released.map((path) => read(path)) + try { + execFileSync(process.execPath, [ + join(root, 'scripts/build-agent-plugin.mjs'), + '--runtime-fixture', + descriptor + ]) + expect(released.map((path) => read(path))).toEqual(before) + expect(read(join(DEV, '.mcp.json')).mcpServers['tempad-dev-dev']).toEqual({ + command: 'node', + args: [join(artifacts[0]!.directory, 'dist/cli.mjs'), '--runtime-updates=stable'], + tool_timeout_sec: 300, + env: fixture.env + }) + expect(read(join(DEV, 'runtime-fixture.json'))).toEqual({ descriptor }) + expect(has(DEV, 'clients/shared/runtime-profile.json')).toBe(false) + expect(read(join(DEV, 'clients/claude/hooks.json')).hooks.Stop[0].hooks[0].command).toBe( + `TEMPAD_MCP_RUNTIME_DIR='${fixture.env.TEMPAD_MCP_RUNTIME_DIR}' node "\${CLAUDE_PLUGIN_ROOT}/clients/shared/lifecycle.mjs" claude` + ) + writeFileSync(join(root, 'packages/mcp-server/src/probe.ts'), 'changed source') + expect(() => + execFileSync( + process.execPath, + [join(root, 'scripts/build-agent-plugin.mjs'), '--runtime-fixture', descriptor], + { stdio: 'pipe' } + ) + ).toThrow(/Native fixture sources changed/) + expect(released.map((path) => read(path))).toEqual(before) + } finally { + execFileSync(process.execPath, [join(root, 'scripts/build-agent-plugin.mjs')]) + rmSync(fixtureRoot, { recursive: true, force: true }) + } + expect(has(DEV, 'runtime-fixture.json')).toBe(false) +}) diff --git a/packages/extension/tests/scripts/agent-plugin-version.test.ts b/packages/extension/tests/scripts/agent-plugin-version.test.ts new file mode 100644 index 00000000..8b89ef77 --- /dev/null +++ b/packages/extension/tests/scripts/agent-plugin-version.test.ts @@ -0,0 +1,188 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { afterEach, beforeEach, expect, it } from 'vitest' + +const script = fileURLToPath( + new URL('../../../../scripts/check-agent-plugin-version.mjs', import.meta.url) +) +let directory: string +const environment = { ...process.env } +// Git hooks can pass an absolute index or repository path. Keep every fixture +// command, including the checked script, isolated from the caller's repository. +for (const name of execFileSync('git', ['rev-parse', '--local-env-vars'], { encoding: 'utf8' }) + .trim() + .split('\n')) { + delete environment[name] +} + +function git(...args: string[]) { + return execFileSync( + 'git', + [ + '-c', + 'user.name=Version Test', + '-c', + 'user.email=version@example.invalid', + '-c', + 'commit.gpgsign=false', + '-c', + `core.hooksPath=${join(directory, 'no-hooks')}`, + ...args + ], + { cwd: directory, env: environment, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ).trim() +} + +function write(path: string, content: string) { + mkdirSync(dirname(join(directory, path)), { recursive: true }) + writeFileSync(join(directory, path), content) +} + +function commit() { + git('add', '.') + git('commit', '-m', 'fixture', '--allow-empty') + return git('rev-parse', 'HEAD') +} + +function manifest(version: string) { + write('agent-plugin/src/plugin.json', JSON.stringify({ name: 'test', version })) +} + +function check(base: string, cwd = directory) { + return spawnSync(process.execPath, [script], { + cwd, + env: { ...environment, PLUGIN_VERSION_BASE: base }, + encoding: 'utf8' + }) +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'tempad-plugin-version-')) + git('init', '--quiet') +}) +afterEach(() => rmSync(directory, { recursive: true, force: true })) + +it.each([ + 'agent-plugin/src/skills/example/references/details.md', + 'agent-plugin/src/clients/claude/hooks.json', + 'agent-plugin/src/mcp.json', + 'agent-plugin/targets/codex/skills/example/SKILL.md', + 'agent-plugin/targets/standard/assets/icon.png' +])('requires a version increase for published payload %s', (path) => { + manifest('0.5.1') + const base = commit() + write(path, 'changed') + commit() + expect(check(base)).toMatchObject({ + status: 1, + stderr: expect.stringContaining('without a version increase') + }) +}) + +it.each(['0.5.2', '0.6.0', '1.0.0'])('accepts the stable version advance %s', (version) => { + manifest('0.5.1') + const base = commit() + manifest(version) + commit() + expect(check(base).status).toBe(0) +}) + +it.each(['0.5.0', '0.4.99', '0.5.2-beta.1', '01.5.2'])( + 'rejects a downgrade or unsupported release version %s', + (version) => { + manifest('0.5.1') + const base = commit() + manifest(version) + commit() + expect(check(base).status).toBe(1) + } +) + +it('does not require a version increase for package prose or unchanged generator output', () => { + manifest('0.5.1') + const base = commit() + for (const root of ['src', 'targets/standard', 'targets/codex']) + for (const name of ['README.md', 'README.zh-Hans.md', 'CHANGELOG.md']) + write(`agent-plugin/${root}/${name}`, 'updated prose') + write('scripts/build-agent-plugin.mjs', '// refactored without changing payload') + commit() + expect(check(base).status).toBe(0) +}) + +it('checks deleted payload and rejects unreadable history', () => { + manifest('0.5.1') + write('agent-plugin/src/skills/example/SKILL.md', 'instructions') + const base = commit() + rmSync(join(directory, 'agent-plugin/src/skills/example/SKILL.md')) + commit() + expect(check(base).status).toBe(1) + expect(check('missing-revision')).toMatchObject({ + status: 1, + stderr: expect.stringMatching(/baseline "missing-revision".*not available.*explicit.*baseline/) + }) + expect(check('0000000000000000000000000000000000000000').status).toBe(1) +}) + +it.each(['0000000000000000000000000000000000000000', 'HEAD'])( + 'rejects a shallow checkout even with baseline %s', + (base) => { + manifest('0.5.1') + commit() + write('agent-plugin/src/skills/example/SKILL.md', 'changed without a version increase') + commit() + const clone = join(directory, 'shallow') + git('clone', '--quiet', '--depth=1', pathToFileURL(directory).href, clone) + expect(check(base, clone)).toMatchObject({ + status: 1, + stderr: expect.stringMatching(/shallow.*full history/) + }) + } +) + +it('checks a PR merge against its actual first parent rather than an older event baseline', () => { + manifest('0.5.1') + const eventBase = commit() + const baseBranch = git('branch', '--show-current') + git('branch', 'feature') + manifest('0.5.2') + write('agent-plugin/src/skills/main/SKILL.md', 'updated main payload') + const mergeBase = commit() + git('switch', 'feature') + write( + 'agent-plugin/src/skills/feature/SKILL.md', + 'new feature payload without a version increase' + ) + commit() + git('switch', baseBranch) + git('merge', '--no-ff', '--no-edit', 'feature') + expect(git('rev-parse', 'HEAD^1')).toBe(mergeBase) + expect(check(eventBase).status).toBe(0) + expect(check('HEAD^1')).toMatchObject({ + status: 1, + stderr: expect.stringContaining('without a version increase') + }) +}) + +it('accepts initialization only against an empty plugin baseline', () => { + write('README.md', 'repository') + const empty = commit() + expect(check('0000000000000000000000000000000000000000').status).toBe(0) + manifest('0.1.0') + commit() + expect(check(empty).status).toBe(0) +}) + +it('accepts a plugin in the first commit but not an incomplete older plugin manifest', () => { + manifest('0.1.0') + commit() + expect(check('0000000000000000000000000000000000000000').status).toBe(0) + rmSync(join(directory, 'agent-plugin/src/plugin.json')) + write('agent-plugin/src/skills/example/SKILL.md', 'legacy payload without a manifest') + const incomplete = commit() + manifest('0.1.0') + commit() + expect(check(incomplete).status).toBe(1) +}) diff --git a/packages/extension/tests/scripts/check-plugin-sandbox.test.ts b/packages/extension/tests/scripts/check-plugin-sandbox.test.ts new file mode 100644 index 00000000..a00ee443 --- /dev/null +++ b/packages/extension/tests/scripts/check-plugin-sandbox.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ + mkdtemp: vi.fn(), + readFile: vi.fn(), + rm: vi.fn(), + launch: vi.fn(), + contextClose: vi.fn(), + waitForWorker: vi.fn(), + listen: vi.fn(), + serverClose: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + mkdtemp: fixture.mkdtemp, + readFile: fixture.readFile, + rm: fixture.rm +})) +vi.mock('node:http', () => ({ + createServer: () => ({ + on: vi.fn(), + once: vi.fn(), + listen: fixture.listen, + address: () => ({ port: 12345 }), + close: fixture.serverClose + }) +})) +vi.mock('playwright', () => ({ + chromium: { launchPersistentContext: fixture.launch } +})) + +import { runCheck } from '@/scripts/check-plugin-sandbox' + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() +}) + +beforeEach(() => { + vi.resetAllMocks() + fixture.readFile.mockResolvedValue( + JSON.stringify({ + sandbox: { pages: ['plugin-sandbox.html'] }, + content_security_policy: { + sandbox: "sandbox allow-scripts; default-src 'none'; connect-src 'none'; worker-src blob:" + }, + web_accessible_resources: [ + { + matches: ['https://www.figma.com/*'], + resources: ['/plugin-sandbox.html', '/chunks/plugin-sandbox-*.js'] + } + ] + }) + ) + fixture.mkdtemp.mockResolvedValue('/temporary/sandbox-profile') + fixture.listen.mockImplementation((_port, _host, ready: () => void) => ready()) + fixture.serverClose.mockImplementation((closed: (error?: Error) => void) => closed()) + fixture.launch.mockResolvedValue({ + serviceWorkers: () => [], + waitForEvent: fixture.waitForWorker, + close: fixture.contextClose + }) + fixture.waitForWorker.mockRejectedValue(new Error('Worker did not start')) + fixture.contextClose.mockResolvedValue(undefined) + fixture.rm.mockResolvedValue(undefined) +}) + +it('closes the network listener and removes its profile when Chromium cannot launch', async () => { + const failure = new Error('Chromium executable is missing') + fixture.launch.mockRejectedValue(failure) + + await expect(runCheck()).rejects.toBe(failure) + + expect(fixture.serverClose).toHaveBeenCalledOnce() + expect(fixture.rm).toHaveBeenCalledWith('/temporary/sandbox-profile', { + force: true, + recursive: true + }) +}) + +it('does not open a listener when the temporary profile cannot be created', async () => { + const failure = new Error('Temporary directory is unavailable') + fixture.mkdtemp.mockRejectedValue(failure) + + await expect(runCheck()).rejects.toBe(failure) + + expect(fixture.listen).not.toHaveBeenCalled() + expect(fixture.launch).not.toHaveBeenCalled() +}) + +it.each(['browser', 'listener'])( + 'still removes resources when %s cleanup fails', + async (resource) => { + const failure = new Error(`${resource} cleanup failed`) + if (resource === 'browser') fixture.contextClose.mockRejectedValue(failure) + else fixture.serverClose.mockImplementation((closed: (error: Error) => void) => closed(failure)) + + await expect(runCheck()).rejects.toBe(failure) + + expect(fixture.contextClose).toHaveBeenCalledOnce() + expect(fixture.serverClose).toHaveBeenCalledOnce() + expect(fixture.rm).toHaveBeenCalledOnce() + } +) + +it('bounds an unanswered sandbox handshake and releases its channel and host resources', async () => { + vi.useFakeTimers() + const portClose = vi.fn() + vi.stubGlobal( + 'MessageChannel', + class { + port1 = { start: vi.fn(), close: portClose } + port2 = {} + } + ) + vi.stubGlobal('window', { postMessage: vi.fn() }) + const worker = { + url: () => 'chrome-extension://sandbox/background.js', + evaluate: vi.fn().mockResolvedValue({ + restored: { items: [{ nodeId: 'probe-node', text: 'Persisted annotation' }] }, + removed: null + }) + } + const evaluate = vi + .fn() + .mockResolvedValueOnce({ origin: 'null', chromeRuntime: 'undefined' }) + .mockResolvedValueOnce(undefined) + .mockImplementation(async (callback, argument) => await callback(argument)) + fixture.launch.mockResolvedValue({ + serviceWorkers: () => [worker], + newPage: async () => ({ goto: vi.fn(), evaluate }), + close: fixture.contextClose + }) + let failure: unknown + const pending = runCheck().catch((error: unknown) => { + failure = error + }) + + await vi.advanceTimersByTimeAsync(7000) + + expect(failure).toMatchObject({ message: 'Sandbox connection timed out.' }) + await pending + expect(portClose).toHaveBeenCalledOnce() + expect(fixture.contextClose).toHaveBeenCalledOnce() + expect(fixture.serverClose).toHaveBeenCalledOnce() + expect(fixture.rm).toHaveBeenCalledOnce() +}) diff --git a/packages/extension/tests/scripts/check-rewrite-runtime.test.ts b/packages/extension/tests/scripts/check-rewrite-runtime.test.ts new file mode 100644 index 00000000..69444fde --- /dev/null +++ b/packages/extension/tests/scripts/check-rewrite-runtime.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +import bundledRules from '@/public/rules/figma.json' + +const fixture = vi.hoisted(() => ({ + readFile: vi.fn(), + readdir: vi.fn(), + rm: vi.fn(), + launch: vi.fn(), + contextClose: vi.fn(), + waitForWorker: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + mkdtemp: async () => '/temporary/rewrite-profile', + cp: vi.fn(), + readFile: fixture.readFile, + readdir: fixture.readdir, + writeFile: vi.fn(), + rm: fixture.rm +})) +vi.mock('playwright', () => ({ + chromium: { launchPersistentContext: fixture.launch } +})) + +import { runCheck } from '@/scripts/check-rewrite-runtime' + +beforeEach(() => { + vi.resetAllMocks() + fixture.readFile.mockImplementation(async (file: string) => { + if (file.endsWith('manifest.json')) + return JSON.stringify({ + permissions: ['declarativeNetRequestWithHostAccess', 'alarms', 'storage'], + declarative_net_request: { + rule_resources: [{ id: 'figma', enabled: true, path: 'rules/figma.json' }] + }, + content_scripts: [], + host_permissions: [] + }) + if (file.endsWith('rules/figma.json')) return JSON.stringify(bundledRules) + return '' + }) + fixture.readdir.mockImplementation(async (directory: string) => + directory.endsWith('rules') ? ['figma.json'] : [] + ) + fixture.launch.mockResolvedValue({ + serviceWorkers: () => [], + waitForEvent: fixture.waitForWorker, + close: fixture.contextClose + }) + fixture.waitForWorker.mockRejectedValue(new Error('Worker did not start')) + fixture.contextClose.mockResolvedValue(undefined) + fixture.rm.mockResolvedValue(undefined) +}) + +it('removes the temporary extension and profiles when Chromium cannot launch', async () => { + const failure = new Error('Chromium executable is missing') + fixture.launch.mockRejectedValue(failure) + + await expect(runCheck()).rejects.toBe(failure) + + expect(fixture.rm).toHaveBeenCalledWith('/temporary/rewrite-profile', { + recursive: true, + force: true + }) +}) + +it('removes the temporary extension and profiles even if context cleanup fails', async () => { + const failure = new Error('Chromium cleanup failed') + fixture.contextClose.mockRejectedValue(failure) + + await expect(runCheck()).rejects.toBe(failure) + + expect(fixture.contextClose).toHaveBeenCalledOnce() + expect(fixture.rm).toHaveBeenCalledWith('/temporary/rewrite-profile', { + recursive: true, + force: true + }) +}) diff --git a/packages/extension/tests/scripts/check-rewrite.test.ts b/packages/extension/tests/scripts/check-rewrite.test.ts index 7570e614..7492590a 100644 --- a/packages/extension/tests/scripts/check-rewrite.test.ts +++ b/packages/extension/tests/scripts/check-rewrite.test.ts @@ -6,6 +6,7 @@ const fixture = vi.hoisted(() => ({ listener: undefined as ((response: Response) => void) | undefined, goto: vi.fn(), click: vi.fn(), + newPage: vi.fn(), close: vi.fn(), log: vi.fn(), error: vi.fn() @@ -14,20 +15,7 @@ const fixture = vi.hoisted(() => ({ vi.mock('playwright', () => ({ chromium: { launch: async () => ({ - newPage: async () => ({ - on: (_event: string, listener: (response: Response) => void) => { - fixture.listener = listener - }, - off: () => { - fixture.listener = undefined - }, - goto: fixture.goto, - fill: async () => {}, - click: fixture.click, - waitForURL: async () => {}, - waitForLoadState: async () => {}, - url: () => 'https://www.figma.com/design/test' - }), + newPage: fixture.newPage, close: fixture.close }) } @@ -49,6 +37,20 @@ function emitScript(text: () => Promise) { beforeEach(() => { fixture.listener = undefined + fixture.newPage.mockReset().mockResolvedValue({ + on: (_event: string, listener: (response: Response) => void) => { + fixture.listener = listener + }, + off: () => { + fixture.listener = undefined + }, + goto: fixture.goto, + fill: async () => {}, + click: fixture.click, + waitForURL: async () => {}, + waitForLoadState: async () => {}, + url: () => 'https://www.figma.com/design/test' + }) fixture.goto.mockReset() fixture.click.mockReset() fixture.close.mockReset().mockResolvedValue(undefined) @@ -56,6 +58,15 @@ beforeEach(() => { fixture.error.mockClear() }) +it('closes the launched browser when page creation fails without replacing the error', async () => { + const failure = new Error('Page creation failed') + fixture.newPage.mockRejectedValueOnce(failure) + + await expect(runCheck()).rejects.toBe(failure) + + expect(fixture.close).toHaveBeenCalledOnce() +}) + it('waits for a captured script body before reporting rewrite compatibility', async () => { const body = Promise.withResolvers() const captured = Promise.withResolvers() diff --git a/packages/extension/tests/scripts/coverage-policy.test.ts b/packages/extension/tests/scripts/coverage-policy.test.ts new file mode 100644 index 00000000..fae61728 --- /dev/null +++ b/packages/extension/tests/scripts/coverage-policy.test.ts @@ -0,0 +1,56 @@ +import type { Vitest } from 'vitest/node' + +import { IstanbulCoverageProvider } from '@vitest/coverage-istanbul/dist/provider.js' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it, vi } from 'vitest' + +import config from '../../../../vitest.config' + +const root = fileURLToPath(new URL('../../../../', import.meta.url)) + +it.each([ + ['packages/shared/src/mcp/canvas.ts', true], + ['packages/plugins/src/index.ts', true], + ['packages/shared/src/mcp/responses.ts', false] +] as const)('enforces the workspace coverage policy for %s', async (file, strict) => { + const provider = new IstanbulCoverageProvider() + const error = vi.fn() + provider.initialize({ + version: provider.version, + config: { root }, + _coverageOptions: config.test!.coverage, + projects: [], + logger: { error, warn: vi.fn() } + } as unknown as Vitest) + const coverageMap = provider.createCoverageMap() + const statements = Array.from({ length: 20 }, (_, index) => [ + index, + { start: { line: index + 1, column: 0 }, end: { line: index + 1, column: 10 } } + ]) + coverageMap.addFileCoverage({ + path: join(root, file), + statementMap: Object.fromEntries(statements), + s: Object.fromEntries(statements.map(([index]) => [index, index === 19 ? 0 : 1])), + fnMap: {}, + f: {}, + branchMap: {}, + b: {} + }) + + // 95% meets the aggregate baseline but must fail the schema/SDK per-file policy. + const exitCode = process.exitCode + try { + await provider.reportThresholds(coverageMap, false) + if (strict) { + expect(error).toHaveBeenCalledWith(expect.stringContaining('threshold (100%)')) + expect(error).toHaveBeenCalledWith(expect.stringContaining(file)) + expect(process.exitCode).toBe(1) + } else { + expect(error).not.toHaveBeenCalled() + expect(process.exitCode).toBe(exitCode) + } + } finally { + process.exitCode = exitCode + } +}) diff --git a/packages/extension/tests/scripts/extension-runtime-fingerprint.test.ts b/packages/extension/tests/scripts/extension-runtime-fingerprint.test.ts index 12a442fb..bdd57943 100644 --- a/packages/extension/tests/scripts/extension-runtime-fingerprint.test.ts +++ b/packages/extension/tests/scripts/extension-runtime-fingerprint.test.ts @@ -79,4 +79,46 @@ describe('extension runtime fingerprint', () => { unlinkSync(join(root, 'packages/extension/assets/icons/logo.svg')) expect(computeExtensionRuntimeFingerprint(root)).toBe(clean) }) + + it.each([ + 'packages/plugins/src/runtime/raw.ts', + 'packages/plugins/package.json', + 'packages/plugins/tsconfig.json', + 'packages/shared/tsconfig.json', + 'packages/extension/tsconfig.json', + 'tsconfig.base.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml' + ])('detects changes to consumed runtime or build input %s', (path) => { + write('packages/extension/package.json', '{}') + const clean = computeExtensionRuntimeFingerprint(root) + write(path, 'original input') + const added = computeExtensionRuntimeFingerprint(root) + expect(added).not.toBe(clean) + write(path, 'changed input') + expect(computeExtensionRuntimeFingerprint(root)).not.toBe(added) + unlinkSync(join(root, path)) + expect(computeExtensionRuntimeFingerprint(root)).toBe(clean) + }) + + it('ignores generated dependencies, package prose, and unrelated source and check inputs', () => { + write('packages/plugins/src/index.ts', 'export const RAW_TAG_NAME = "raw"') + const clean = computeExtensionRuntimeFingerprint(root) + for (const path of [ + 'packages/plugins/dist/index.js', + 'packages/plugins/tsdown.config.ts', + 'packages/plugins/node_modules/dependency/index.js', + 'packages/plugins/README.md', + 'packages/plugins/tests/index.test.ts', + 'packages/shared/dist/index.js', + 'packages/shared/tsdown.config.ts', + 'packages/extension/.wxt/tsconfig.json', + 'packages/site/src/index.ts', + 'packages/mcp-server/src/index.ts', + 'tsconfig.configs.json' + ]) { + write(path, 'unrelated to authored extension runtime inputs') + } + expect(computeExtensionRuntimeFingerprint(root)).toBe(clean) + }) }) diff --git a/packages/extension/tests/scripts/inspect-agent-authoring-rollout.test.ts b/packages/extension/tests/scripts/inspect-agent-authoring-rollout.test.ts index 8a61db60..df456bba 100644 --- a/packages/extension/tests/scripts/inspect-agent-authoring-rollout.test.ts +++ b/packages/extension/tests/scripts/inspect-agent-authoring-rollout.test.ts @@ -600,4 +600,23 @@ describe('agent authoring rollout inspection', () => { 'Design a four-state transfer flow & preserve safe retry behavior.' ) }) + + it('uses the original user prompt after host metadata, ignoring assistant delegation claims', () => { + const inspection = inspectAuthoringRollout( + message('user', 'cwdDesign a player.') + + message('assistant', 'Invented task') + ) + + expect(inspection.prompt.text).toBe('Design a player.') + expect(inspection.prompt.sha256).toBe(inspection.execution.promptSha256) + }) + + it('decodes delegated XML entities once so literal entity text remains literal', () => { + const inspection = inspectAuthoringRollout( + createThreadOutput('Write &lt; and &amp; as literal text.') + ) + + expect(inspection.prompt.text).toBe('Write < and & as literal text.') + expect(inspection.prompt.sha256).toBe(inspection.execution.promptSha256) + }) }) diff --git a/packages/extension/tests/scripts/inspect-agent-run-identity.test.ts b/packages/extension/tests/scripts/inspect-agent-run-identity.test.ts index 0a64dae1..bf19f0df 100644 --- a/packages/extension/tests/scripts/inspect-agent-run-identity.test.ts +++ b/packages/extension/tests/scripts/inspect-agent-run-identity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { inspectAgentRunIdentity } from '@/scripts/inspect-agent-run-identity' +import { inspectAgentRun } from '@/scripts/inspect-agent-run-identity' import { parseRolloutRows } from '@/scripts/rollout-rows' const row = (value: unknown): string => `${JSON.stringify(value)}\n` @@ -41,7 +41,7 @@ describe('agent run identity', () => { type: 'response_item', payload: { type: 'function_call_output', output: 'model: other' } }) - expect(inspectAgentRunIdentity(parseRolloutRows(source))).toEqual({ + expect(inspectAgentRun(parseRolloutRows(source)).identity).toEqual({ taskId: 'task-1', model: 'gpt-5.6-sol', reasoningEffort: 'xhigh', @@ -67,10 +67,10 @@ describe('agent run identity', () => { session + context() + message('user', 'cwdShape & finish & form.') - expect(inspectAgentRunIdentity(parseRolloutRows(delegated))).toEqual( - inspectAgentRunIdentity(parseRolloutRows(direct)) + expect(inspectAgentRun(parseRolloutRows(delegated)).identity).toEqual( + inspectAgentRun(parseRolloutRows(direct)).identity ) - expect(inspectAgentRunIdentity(parseRolloutRows(delegated)).issues).toEqual([]) + expect(inspectAgentRun(parseRolloutRows(delegated)).identity.issues).toEqual([]) }) it('accepts the native create-thread envelope and ignores projectless host metadata', () => { @@ -83,10 +83,10 @@ describe('agent run identity', () => { 'Catalog\n# AGENTS.md instructions\nContext\ncwd' ) const direct = session + context() + message('user', 'Shape & finish & form.') - expect(inspectAgentRunIdentity(parseRolloutRows(delegated))).toEqual( - inspectAgentRunIdentity(parseRolloutRows(direct)) + expect(inspectAgentRun(parseRolloutRows(delegated)).identity).toEqual( + inspectAgentRun(parseRolloutRows(direct)).identity ) - expect(inspectAgentRunIdentity(parseRolloutRows(delegated)).issues).toEqual([]) + expect(inspectAgentRun(parseRolloutRows(delegated)).identity.issues).toEqual([]) }) it('retains duplicate prompts, model switches, and malformed lines as invalid evidence', () => { @@ -97,7 +97,7 @@ describe('agent run identity', () => { message('user', 'Design a player.') + context('other-model', 'high') + '{broken\n' - const result = inspectAgentRunIdentity(parseRolloutRows(source)) + const result = inspectAgentRun(parseRolloutRows(source)).identity expect(result.promptCount).toBe(2) expect(result.model).toBeNull() expect(result.reasoningEffort).toBeNull() @@ -112,7 +112,7 @@ describe('agent run identity', () => { }) it('does not infer unknown settings or accept contradictory collaboration settings', () => { - expect(inspectAgentRunIdentity(parseRolloutRows('')).issues).toContain('No model evidence.') + expect(inspectAgentRun(parseRolloutRows('')).identity.issues).toContain('No model evidence.') const source = session + row({ @@ -124,7 +124,7 @@ describe('agent run identity', () => { } }) + message('user', 'Design a player.') - expect(inspectAgentRunIdentity(parseRolloutRows(source)).issues).toEqual([ + expect(inspectAgentRun(parseRolloutRows(source)).identity.issues).toEqual([ 'Turn model disagrees with collaboration settings.', 'Turn reasoning effort disagrees with collaboration settings.' ]) @@ -137,10 +137,10 @@ describe('agent run identity', () => { 'user', '# AGENTS.md instructions for /repo\nContext\nDesign a player.' ) - expect(inspectAgentRunIdentity(parseRolloutRows(source)).issues).toEqual([]) + expect(inspectAgentRun(parseRolloutRows(source)).identity.issues).toEqual([]) const changed = source + row({ type: 'session_meta', payload: { id: 'task-1', session_id: 'task-2' } }) - expect(inspectAgentRunIdentity(parseRolloutRows(changed)).issues).toContain( + expect(inspectAgentRun(parseRolloutRows(changed)).identity.issues).toContain( 'Task identity changed within the rollout.' ) }) @@ -156,9 +156,9 @@ describe('agent run identity', () => { } }) + message('user', 'Design a player.') - expect(inspectAgentRunIdentity(parseRolloutRows(source)).issues).toEqual([]) + expect(inspectAgentRun(parseRolloutRows(source)).identity.issues).toEqual([]) const missing = source + row({ type: 'turn_context', payload: {} }) - expect(inspectAgentRunIdentity(parseRolloutRows(missing)).issues).toEqual( + expect(inspectAgentRun(parseRolloutRows(missing)).identity.issues).toEqual( expect.arrayContaining([ 'A turn context has no model identity.', 'A turn context has no reasoning effort.' diff --git a/packages/extension/tests/scripts/inspect-agent-skill-catalog.test.ts b/packages/extension/tests/scripts/inspect-agent-skill-catalog.test.ts index e7df4daa..d72ee036 100644 --- a/packages/extension/tests/scripts/inspect-agent-skill-catalog.test.ts +++ b/packages/extension/tests/scripts/inspect-agent-skill-catalog.test.ts @@ -76,4 +76,24 @@ describe('agent skill catalog inspection', () => { 'No complete catalog found' ) }) + + it.each(['user', 'assistant', 'tool'])('ignores a catalog quoted by %s', (role) => { + const source = rollout(catalog).replace('"role":"developer"', `"role":"${role}"`) + expect(() => extractSkillCatalog(source)).toThrow('No complete catalog') + expect( + extractSkillCatalog(source + rollout(catalog.replace('+codex.a', '+codex.b')))[1]?.locator + ).toContain('+codex.b') + }) + + it('accepts repeated host catalogs but rejects a changed catalog within one rollout', () => { + expect(extractSkillCatalog(rollout(catalog) + rollout(catalog))).toEqual( + extractSkillCatalog(rollout(catalog)) + ) + expect(() => + extractSkillCatalog(rollout(catalog) + rollout(catalog.replace('+codex.a', '+codex.b'))) + ).toThrow('Skill catalog changed within the rollout.') + expect(() => + extractSkillCatalog(rollout('') + rollout(catalog)) + ).toThrow('Skill catalog changed within the rollout.') + }) }) diff --git a/packages/extension/tests/scripts/native-runtime-fixture.test.ts b/packages/extension/tests/scripts/native-runtime-fixture.test.ts new file mode 100644 index 00000000..a78b6285 --- /dev/null +++ b/packages/extension/tests/scripts/native-runtime-fixture.test.ts @@ -0,0 +1,146 @@ +import { createHash } from 'node:crypto' +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' + +import { + nativeFixtureHubEntry, + nativeFixtureRuntimeEntries, + nativeFixtureServer, + readNativeRuntimeFixture, + shellQuote, + type NativeRuntimeFixture +} from '../../../../scripts/native-runtime-fixture.mjs' + +const { runtimeSourceHash } = await import( + new URL('../../../../packages/mcp-server/scripts/runtime-source-hash.mjs', import.meta.url).href +) + +let root: string +const write = (path: string, value: unknown) => { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeFileSync(path, JSON.stringify(value), { mode: 0o600 }) +} +function descriptor(): NativeRuntimeFixture { + const registryUrl = 'http://127.0.0.1:34567' + const artifacts = ['0.12.0', '0.12.1'].map((version) => { + const directory = join(root, version) + write(join(directory, 'package.json'), { + name: '@tempad-dev/mcp', + version, + private: true, + publishConfig: { registry: registryUrl } + }) + return { version, directory, integrity: `sha512-${Buffer.alloc(64).toString('base64')}` } + }) + return { + version: 1, + kind: 'native-runtime-fixture', + testOnly: true, + checkout: root, + sourceHash: runtimeSourceHash(root), + root, + registryUrl, + env: { + TEMPAD_MCP_RUNTIME_DIR: join(root, 'run'), + TEMPAD_MCP_STATE_DIR: join(root, 'state'), + TEMPAD_MCP_ASSET_DIR: join(root, 'assets'), + TEMPAD_MCP_LOG_DIR: join(root, 'logs') + }, + initial: artifacts[0]!, + candidate: artifacts[1]! + } +} +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'tp-native-test-'))) + for (const path of [ + 'packages/mcp-server/package.json', + 'packages/mcp-server/tsdown.config.ts', + 'packages/mcp-server/scripts/runtime-contract.mjs', + 'packages/mcp-server/src/probe.ts', + 'packages/shared/src/probe.ts', + 'pnpm-lock.yaml' + ]) + write(join(root, path), 'source fixture') +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +it('uses one private release-test namespace without inheriting host or checkout overrides', () => { + const fixture = descriptor() + const path = join(root, 'runtime-fixture.json') + write(path, fixture) + expect(readNativeRuntimeFixture(path, root)).toEqual(fixture) + expect(nativeFixtureServer(fixture, 300)).toEqual({ + command: 'node', + args: [join(fixture.initial.directory, 'dist/cli.mjs'), '--runtime-updates=stable'], + tool_timeout_sec: 300, + env: fixture.env + }) + expect(nativeFixtureHubEntry(fixture)).toBe( + join(fixture.initial.directory, 'dist/managed-runtime.mjs') + ) + expect(shellQuote("/test/user's runtime")).toBe("'/test/user'\\''s runtime'") +}) + +it.each(['registry', 'environment', 'checkout', 'permissions', 'publishable'])( + 'rejects unsafe native fixture %s before changing the generated plugin', + (reason) => { + const fixture = descriptor() + const path = join(root, 'runtime-fixture.json') + if (reason === 'registry') fixture.registryUrl = 'https://registry.npmjs.org' + if (reason === 'environment') fixture.env.TEMPAD_MCP_STATE_DIR = '/tmp/shared-state' + if (reason === 'checkout') fixture.checkout = '/different-checkout' + if (reason === 'publishable') + write(join(fixture.initial.directory, 'package.json'), { + name: '@tempad-dev/mcp', + version: fixture.initial.version, + private: false, + publishConfig: { registry: fixture.registryUrl } + }) + write(path, fixture) + if (reason === 'permissions') chmodSync(path, 0o644) + expect(() => readNativeRuntimeFixture(path, root)).toThrow() + } +) + +it('rejects stale source evidence while allowing teardown of the old fixture', () => { + const fixture = descriptor() + const path = join(root, 'runtime-fixture.json') + write(path, fixture) + write(join(root, 'packages/mcp-server/src/probe.ts'), 'changed source') + expect(() => readNativeRuntimeFixture(path, root)).toThrow(/Native fixture sources changed/) + expect(readNativeRuntimeFixture(path, root, { allowStaleSource: true })).toEqual(fixture) +}) + +it('recognizes the verified replacement Hub without confusing it with the retained supervisor', () => { + const fixture = descriptor() + const directory = join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/cache/b/package') + const entry = join(directory, 'dist/managed-runtime.mjs') + write(entry, 'fixture') + const manifest = { fixture: 'B' } + write(join(fixture.candidate.directory, 'dist/runtime-manifest.json'), manifest) + write(join(directory, 'dist/runtime-manifest.json'), manifest) + const active = { + version: fixture.candidate.version, + entry, + release: { + directory, + manifestHash: createHash('sha256').update(JSON.stringify(manifest)).digest('hex') + } + } + const journal = join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/switch.json') + write(journal, { active }) + expect(nativeFixtureHubEntry(fixture)).toBe(entry) + write(join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/prototype.json'), { entry }) + expect(nativeFixtureRuntimeEntries(fixture)).toEqual([ + join(fixture.initial.directory, 'dist/cli.mjs'), + join(fixture.candidate.directory, 'dist/cli.mjs'), + entry + ]) + write(journal, { active: { ...active, version: '99.0.0' } }) + expect(() => nativeFixtureHubEntry(fixture)).toThrow(/unknown/) + write(journal, { active }) + write(join(directory, 'dist/runtime-manifest.json'), { fixture: 'changed' }) + expect(() => nativeFixtureHubEntry(fixture)).toThrow() +}) diff --git a/packages/extension/tests/scripts/reinstall-codex-dev-plugin-runtime.test.ts b/packages/extension/tests/scripts/reinstall-codex-dev-plugin-runtime.test.ts index 21461b26..c9437715 100644 --- a/packages/extension/tests/scripts/reinstall-codex-dev-plugin-runtime.test.ts +++ b/packages/extension/tests/scripts/reinstall-codex-dev-plugin-runtime.test.ts @@ -12,20 +12,29 @@ import { matchingProcesses, parseReinstallArguments, resolveDevPluginVersion, - selectCodexPageUrl + selectCodexPageUrl, + stopRuntimeProcesses, + threadPageUrl } from '@/scripts/reinstall-codex-dev-plugin-runtime' -/** Real `ps -axo pid=,ppid=,lstart=,command=` rows, so the shared parser is exercised too. */ -function rows(...entries: [pid: number, ppid: number, command: string][]) { +/** Both native ps tables exercise the same discovery parser as preflight and reinstall. */ +function rows(...entries: [pid: number, ppid: number, command: string, executable?: string][]) { return parseProcessTable( entries .map(([pid, ppid, command]) => `${pid} ${ppid} Thu Sep 18 14:00:00 2026 ${command}`) + .join('\n'), + entries + .map( + ([pid, ppid, , executable = '/usr/bin/node']) => + `${pid} ${ppid} Thu Sep 18 14:00:00 2026 ${executable}` + ) .join('\n') ) } const pluginRoot = '/checkout/.dev/plugins/tempad-dev-dev' const version = '0.2.0+codex.new' +const threadId = '019a1234-5678-7abc-8def-123456789abc' const entry = { pluginId: devPluginId, name: 'tempad-dev-dev', @@ -57,6 +66,7 @@ describe('Codex App reinstall arguments', () => { it('requires an explicit restart flag and permits precise App and page selection', () => { vi.stubEnv('CODEX_CDP_URL', 'http://localhost:9444') + vi.stubEnv('CODEX_THREAD_ID', threadId) expect(parseReinstallArguments([])).toEqual({ cdpPort: 9444, cdpUrl: 'http://localhost:9444', @@ -83,11 +93,36 @@ describe('Codex App reinstall arguments', () => { timeoutMs: 2500, cdpUrl: 'http://127.0.0.1:9222', pageUrl: 'window=main', - restartCodex: true + restartCodex: true, + resumeThread: threadId }) expect(parseReinstallArguments(['--help'])).toBeNull() }) + it('requires a captured task and saved report for detached recovery', () => { + vi.stubEnv('CODEX_THREAD_ID', undefined) + expect(() => parseReinstallArguments(['--restart-codex'])).toThrow('--resume-thread') + expect(() => + parseReinstallArguments(['--resume-after-restart', '--resume-thread', threadId]) + ).toThrow('--report-path') + expect( + parseReinstallArguments([ + '--resume-after-restart', + '--resume-thread', + threadId, + '--report-path', + '/tmp/report.json' + ]) + ).toMatchObject({ resumeThread: threadId, reportPath: '/tmp/report.json' }) + expect(() => parseReinstallArguments(['--resume-thread', '/another/task'])).toThrow('UUID') + expect(() => + parseReinstallArguments(['--resume-thread', threadId, '--report-path', 'relative.json']) + ).toThrow('absolute') + expect(() => parseReinstallArguments(['--restart-codex', '--resume-after-restart'])).toThrow( + 'one restart mode' + ) + }) + it.each([ ['--timeout-ms', '0'], ['--timeout-ms', 'NaN'], @@ -134,14 +169,14 @@ describe('read-only plugin source and installation verification', () => { it('accepts an available-but-uninstalled entry as a source, and rejects it as installation proof', async () => { const run = runner([], [{ ...entry, installed: false }]) - await expect(inspectDevPlugin(run, pluginRoot)).resolves.toBeUndefined() + await expect(inspectDevPlugin(run, pluginRoot)).resolves.toBe(false) await expect(inspectDevPlugin(run, pluginRoot, version)).rejects.toThrow( 'not enabled at version' ) }) it('confirms an installed, enabled entry at the generated version', async () => { - await expect(inspectDevPlugin(runner(), pluginRoot, version)).resolves.toBeUndefined() + await expect(inspectDevPlugin(runner(), pluginRoot, version)).resolves.toBe(true) }) it('propagates a CLI failure instead of reporting a verified source', async () => { @@ -178,9 +213,25 @@ describe('read-only plugin source and installation verification', () => { 'not enabled at version' ) }) + + it('does not treat a missing installation flag as successful uninstall', async () => { + await expect( + inspectDevPlugin(runner([{ ...entry, installed: undefined }]), pluginRoot) + ).rejects.toThrow('whether') + }) }) describe('App restart and CDP target boundaries', () => { + it('restores only a local task in the selected App window', () => { + const url = new URL( + threadPageUrl('app://-/index.html?window=main&initialRoute=%2Fplugins#old', threadId) + ) + expect(url.searchParams.get('initialRoute')).toBe(`/local/${threadId}`) + expect(url.searchParams.get('window')).toBe('main') + expect(url.hash).toBe('') + expect(() => threadPageUrl('https://example.com/index.html', threadId)).toThrow('found 0') + expect(() => threadPageUrl('app://-/index.html', '../another')).toThrow('UUID') + }) it('accepts only browser WebSockets on the verified local CDP listener', () => { const url = 'ws://127.0.0.1:9222/devtools/browser/example' expect(cdpWebSocketUrl({ webSocketDebuggerUrl: url }, 'http://127.0.0.1:9222')).toBe(url) @@ -226,18 +277,19 @@ describe('App restart and CDP target boundaries', () => { expect( matchingProcesses( rows( - [1, 1, executable], - [2, 1, `${executable} --flag`], + [1, 1, executable, executable], + [2, 1, `${executable} --flag`, executable], [3, 1, `${executable}Helper`], [4, 1, '/Other/Codex'], - [5, 1, `/bin/sh -c ${executable}`] + [5, 1, `/bin/sh -c ${executable}`, '/bin/sh'], + [6, 1, executable, '/other/program'] ), executable ) ).toEqual([1, 2]) }) - it('selects this checkout’s runtime entries wherever they appear in the command', () => { + it('selects this checkout’s runtime entries launched directly by Node', () => { const cli = '/checkout/packages/mcp-server/dist/cli.mjs' const hub = '/checkout/packages/mcp-server/dist/hub.mjs' expect( @@ -254,6 +306,43 @@ describe('App restart and CDP target boundaries', () => { ).toEqual([1, 2]) }) + it('does not stop editors, wrappers, or other Node scripts that mention a runtime entry', () => { + const cli = '/checkout/packages/mcp-server/dist/cli.mjs' + const hub = '/checkout/packages/mcp-server/dist/hub.mjs' + expect( + checkoutRuntimeProcesses( + rows( + [1, 1, `/usr/bin/node ${cli}`], + [2, 1, `vim ${cli}`, '/usr/bin/vim'], + [3, 1, `node /other/tool.mjs ${hub}`], + [4, 1, `/bin/sh -c "node ${cli}"`, '/bin/sh'], + [5, 1, `/bin/sh -c /usr/bin/node ${hub}`, '/bin/sh'], + [6, 1, `node --eval "console.log('${hub}')"`], + [7, 1, `node /other/node ${cli}`], + [8, 1, `/usr/bin/node /other/node ${cli}`, '/usr/bin/node'] + ), + [cli, hub] + ) + ).toEqual([1]) + }) + + it('supports native Node paths with spaces and symlinks without guessing other executables', () => { + const cli = '/checkout with spaces/packages/mcp-server/dist/cli.mjs' + const executable = '/tools with spaces/node' + expect( + checkoutRuntimeProcesses( + rows( + [1, 1, `${executable} ${cli}`, executable], + [2, 1, `node ${cli}`, executable], + [3, 1, `"${executable}" "${cli}"`, executable], + [4, 1, `/other/node ${cli}`, executable], + [5, 1, `nodejs ${cli}`, '/usr/bin/nodejs'] + ), + [cli] + ) + ).toEqual([1, 2, 3, 5]) + }) + it('rejects duplicate detached jobs before restarting', () => { expect(() => assertNoRestartJob('PID Status Label\n1 0 com.other.job')).not.toThrow() expect(() => assertNoRestartJob('- 0 com.tempad-dev.codex-plugin-reinstall.42.1')).toThrow( @@ -280,3 +369,71 @@ describe('App restart and CDP target boundaries', () => { expect(() => selectCodexPageUrl(['https://example.com'])).toThrow('found 0') }) }) + +describe('runtime shutdown before reinstall', () => { + function clock() { + let elapsed = 0 + return { + now: () => elapsed, + sleep: async (ms: number) => { + elapsed += ms + }, + timeoutMs: 3000, + quietMs: 1000 + } + } + + it('waits for a sustained empty process list, not the first empty snapshot', async () => { + const time = clock() + const stop = vi.fn() + const list = vi + .fn() + .mockResolvedValueOnce([42]) + .mockResolvedValueOnce([42]) + .mockResolvedValue([]) + await expect(stopRuntimeProcesses(list, stop, time)).resolves.toEqual([42]) + expect(stop).toHaveBeenCalledExactlyOnceWith(42) + expect(time.now()).toBe(1250) + }) + + it('refuses installation if a host respawns a checkout process', async () => { + const list = vi + .fn() + .mockResolvedValueOnce([42]) + .mockResolvedValueOnce([]) + .mockResolvedValue([43]) + const stop = vi.fn() + await expect(stopRuntimeProcesses(list, stop, clock())).rejects.toThrow('respawned') + expect(stop.mock.calls).toEqual([[42]]) + }) + + it('does not install when shutdown times out or signalling fails', async () => { + await expect(stopRuntimeProcesses(async () => [42], vi.fn(), clock())).rejects.toThrow( + 'Timed out' + ) + await expect( + stopRuntimeProcesses( + async () => [42], + () => { + throw new Error('denied') + }, + clock() + ) + ).rejects.toThrow('denied') + }) + + it('accepts a process that exited before signalling, but still verifies the quiet period', async () => { + const time = clock() + const list = vi.fn().mockResolvedValueOnce([42]).mockResolvedValue([]) + await expect( + stopRuntimeProcesses( + list, + () => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }) + }, + time + ) + ).resolves.toEqual([42]) + expect(time.now()).toBe(1000) + }) +}) diff --git a/packages/extension/tests/scripts/reinstall-codex-dev-plugin-ui.test.ts b/packages/extension/tests/scripts/reinstall-codex-dev-plugin-ui.test.ts new file mode 100644 index 00000000..4648ae6d --- /dev/null +++ b/packages/extension/tests/scripts/reinstall-codex-dev-plugin-ui.test.ts @@ -0,0 +1,260 @@ +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { reinstallInApp, resumeThreadInApp } from '@/scripts/reinstall-codex-dev-plugin-ui' + +let browser: Browser +let page: Page +const version = '0.5.8+codex.fixture' +const threadId = '019a1234-5678-7abc-8def-123456789abc' + +beforeAll(async () => { + browser = await chromium.launch() +}) +beforeEach(async () => { + page = await browser.newPage() + page.setDefaultTimeout(2000) +}) +afterEach(async () => { + await page.close() +}) +afterAll(async () => { + await browser?.close() +}) + +async function pluginFixture(installed = true, navigateAfterInstall = false) { + await page.setContent(` + +

TemPad Dev (Dev)

${version}

+ + + + +
+
+ `) +} + +describe('plugin UI reinstall ordering (isolated Chromium)', () => { + it('uninstalls before process verification and installs only after it completes', async () => { + await pluginFixture() + await reinstallInApp(page, version, async () => { + expect( + await page.getByRole('button', { name: 'Install plugin', exact: true }).isVisible() + ).toBe(true) + await page.evaluate(() => { + ;(window as unknown as { events: string[] }).events.push('runtime-stopped') + }) + }) + expect(await page.evaluate(() => (window as unknown as { events: string[] }).events)).toEqual([ + 'uninstalled', + 'runtime-stopped', + 'installed' + ]) + }) + + it('leaves the plugin uninstalled when disk or process verification fails', async () => { + await pluginFixture() + await expect( + reinstallInApp(page, version, async () => { + throw new Error('process respawned') + }) + ).rejects.toThrow('process respawned') + expect(await page.evaluate(() => (window as unknown as { events: string[] }).events)).toEqual([ + 'uninstalled' + ]) + }) + + it('checks runtime exit even when the plugin is already absent', async () => { + await pluginFixture(false) + const afterUninstall = vi.fn(async () => {}) + await reinstallInApp(page, version, afterUninstall) + expect(afterUninstall).toHaveBeenCalledOnce() + }) + + it('reopens stale local plugin details after verifying runtime exit', async () => { + await pluginFixture(false) + await page.evaluate((version) => { + const displayed = document.querySelector('#detail > p')! + displayed.textContent = '0.5.8+codex.previous' + document.querySelector('#catalog a')!.addEventListener('click', () => { + displayed.textContent = version + }) + }, version) + await reinstallInApp(page, version, async () => { + expect(await page.locator('#detail > p').textContent()).toBe('0.5.8+codex.previous') + }) + expect(await page.getByRole('button', { name: 'More actions', exact: true }).isVisible()).toBe( + true + ) + }) + + it('refreshes a manifest cached across catalog navigation before installing', async () => { + await pluginFixture(false) + const html = await page.content() + let current = false + let loads = 0 + await page.route('https://codex.fixture/plugins', (route) => { + loads++ + return route.fulfill({ + contentType: 'text/html', + body: current ? html : html.replaceAll(version, '0.5.8+codex.previous') + }) + }) + await page.goto('https://codex.fixture/plugins') + await reinstallInApp(page, version, async () => { + expect(await page.locator('#detail > p').textContent()).toBe('0.5.8+codex.previous') + current = true + }) + expect(loads).toBe(2) + expect(await page.evaluate(() => (window as unknown as { events: string[] }).events)).toEqual([ + 'installed' + ]) + }) + + it('returns to plugin details when installation navigates away', async () => { + await pluginFixture(false, true) + await reinstallInApp(page, version, async () => {}) + expect(await page.getByRole('button', { name: 'More actions', exact: true }).isVisible()).toBe( + true + ) + expect(await page.evaluate(() => (window as unknown as { events: string[] }).events)).toEqual([ + 'installed' + ]) + }) +}) + +async function taskFixture({ + draft = '', + running = false, + acknowledged = true, + richText = false, + interrupted = false +} = {}) { + await page.route('https://codex.fixture/**', (route) => + route.fulfill({ + contentType: 'text/html', + body: `
+ ${richText ? '
' : ''} +
+ ` + }) + ) + const navigate = page.goto.bind(page) + // Chromium has no Electron app: protocol. Only its navigation is adapted; + // locators, clicks, drafts and transcript acknowledgement use a real browser DOM. + const goto = vi.fn((url: string) => navigate(url.replace('app://-', 'https://codex.fixture'))) + const adapted = new Proxy(page, { + get(target, key) { + if (key === 'url') return () => 'app://-/index.html?window=main' + if (key === 'goto') return goto + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) + return { adapted, goto } +} + +describe('task recovery UI (isolated Chromium)', () => { + it.each([false, true])( + 'loads the saved task and confirms one continuation (rich text: %s)', + async (richText) => { + const { adapted, goto } = await taskFixture({ richText }) + const message = 'Continue the authorized runtime refresh.' + const beforeSend = vi.fn(async () => { + expect(await page.locator('#transcript').innerText()).toBe('') + }) + expect(await resumeThreadInApp(adapted, threadId, message, beforeSend)).toBe('sent') + expect(new URL(goto.mock.calls[0]![0]).searchParams.get('initialRoute')).toBe( + `/local/${threadId}` + ) + expect(beforeSend).toHaveBeenCalledOnce() + expect(await page.locator('#transcript > p').allTextContents()).toEqual([message]) + } + ) + + it('does not overwrite the restored draft', async () => { + const { adapted } = await taskFixture({ draft: 'User draft' }) + const beforeSend = vi.fn() + await expect(resumeThreadInApp(adapted, threadId, 'Continue', beforeSend)).rejects.toThrow( + 'draft' + ) + expect(await page.locator('textarea').inputValue()).toBe('User draft') + expect(beforeSend).not.toHaveBeenCalled() + }) + + it('fills the interrupted task before waiting for Send', async () => { + const { adapted } = await taskFixture({ interrupted: true, richText: true }) + expect(await resumeThreadInApp(adapted, threadId, 'Continue', async () => {})).toBe('sent') + expect(await page.locator('#transcript > p').allTextContents()).toEqual(['Continue']) + }) + + it('does not send another prompt to an already-running task', async () => { + const { adapted } = await taskFixture({ running: true }) + const beforeSend = vi.fn() + expect(await resumeThreadInApp(adapted, threadId, 'Continue', beforeSend)).toBe( + 'already-running' + ) + expect(beforeSend).not.toHaveBeenCalled() + }) + + it('does not submit if the uncertainty record could not be saved', async () => { + const { adapted } = await taskFixture() + await expect( + resumeThreadInApp(adapted, threadId, 'Continue', async () => { + throw new Error('ENOSPC') + }) + ).rejects.toThrow('ENOSPC') + expect(await page.locator('#transcript').innerText()).toBe('') + }) + + it.each([false, true])( + 'does not mistake an unsubmitted draft for acknowledgement (rich text: %s)', + async (richText) => { + const { adapted } = await taskFixture({ acknowledged: false, richText }) + const beforeSend = vi.fn(async () => {}) + await expect(resumeThreadInApp(adapted, threadId, 'Continue', beforeSend)).rejects.toThrow( + 'Timeout' + ) + expect(beforeSend).toHaveBeenCalledOnce() + expect(await page.locator('#transcript').innerText()).toBe('') + } + ) +}) diff --git a/packages/extension/tests/scripts/repository-checks.test.ts b/packages/extension/tests/scripts/repository-checks.test.ts index 98a605f9..e1e21127 100644 --- a/packages/extension/tests/scripts/repository-checks.test.ts +++ b/packages/extension/tests/scripts/repository-checks.test.ts @@ -1,7 +1,7 @@ import { ESLint } from 'eslint' -import { globby } from 'globby' import { spawnSync } from 'node:child_process' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -11,19 +11,20 @@ import { MCP_CORE_COVERAGE_FILES } from '../../../../vitest.coverage' const repository = fileURLToPath(new URL('../../../../', import.meta.url)) -it('covers Hub runtime and client modules while excluding only the two process launchers', async () => { - const files = await globby([...MCP_CORE_COVERAGE_FILES], { - cwd: join(repository, 'packages/mcp-server') - }) - expect(files).toEqual( - expect.arrayContaining([ - 'src/hub-runtime.ts', - 'src/client-binding.ts', - 'src/agent-clients/registry.ts' - ]) - ) - expect(files).not.toContain('src/cli.ts') - expect(files).not.toContain('src/hub.ts') +it('covers Hub runtime and client modules while excluding only the two process launchers', () => { + // Use the coverage engine's matcher and exact threshold glob, not a file-listing library. + const picomatch: (pattern: string) => (path: string) => boolean = createRequire( + import.meta.resolve('vitest/package.json') + )('picomatch') + const matches = picomatch(`{${MCP_CORE_COVERAGE_FILES.join(',')}}`) + for (const file of [ + 'src/hub-runtime.ts', + 'src/client-binding.ts', + 'src/agent-clients/registry.ts' + ]) { + expect(matches(file), file).toBe(true) + } + for (const file of ['src/cli.ts', 'src/hub.ts']) expect(matches(file), file).toBe(false) }) it('enables typed promise checks in every package inheriting the root ESLint config', async () => { @@ -40,28 +41,41 @@ it('enables typed promise checks in every package inheriting the root ESLint con } }) -it('accepts Git autosquash subjects without weakening ordinary commit validation', () => { +it.each<[subject: string, status: number, commentChar?: string]>([ + ['fix(extension): preserve layout', 0], + ['fixup! fix(extension): preserve layout', 0], + ['squash! an old nonconventional subject', 0], + ['amend! fix(extension): preserve layout', 0], + ['Merge branch main', 0], + ['Revert "fix: preserve layout"', 0], + ['Reapply "fix: preserve layout"', 0], + ['\n# Editor instructions\n\nfix: preserve layout\n\nBody', 0], + ['\r\n# Editor instructions\r\nfix: preserve layout\r\n', 0], + ['\n# Editor instructions\nsome change', 1], + ['# Only a comment', 1], + ['fixup! ', 1], + ['fix: ', 1], + ['some change', 1], + ['; Editor instructions\n\nfix: preserve layout', 0, ';'] +])('validates cleaned commit message %j', (subject, status, commentChar = '#') => { const directory = mkdtempSync(join(tmpdir(), 'tempad-commit-message-')) const path = join(directory, 'message') try { - for (const [subject, status] of [ - ['fix(extension): preserve layout', 0], - ['fixup! fix(extension): preserve layout', 0], - ['squash! an old nonconventional subject', 0], - ['amend! fix(extension): preserve layout', 0], - ['Merge branch main', 0], - ['Revert "fix: preserve layout"', 0], - ['fixup! ', 1], - ['fix: ', 1], - ['some change', 1] - ] as const) { - writeFileSync(path, subject + '\n') - const result = spawnSync(process.execPath, [ - join(repository, 'scripts/check-commit-message.mjs'), - path - ]) - expect(result.status, subject).toBe(status) - } + writeFileSync(path, subject + '\n') + const result = spawnSync( + process.execPath, + [join(repository, 'scripts/check-commit-message.mjs'), path], + { + cwd: repository, + env: { + ...process.env, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'core.commentChar', + GIT_CONFIG_VALUE_0: commentChar + } + } + ) + expect(result.status, result.error?.message ?? result.stderr?.toString()).toBe(status) } finally { rmSync(directory, { recursive: true, force: true }) } diff --git a/packages/extension/tests/scripts/screenshot-capture.test.ts b/packages/extension/tests/scripts/screenshot-capture.test.ts new file mode 100644 index 00000000..e75a0396 --- /dev/null +++ b/packages/extension/tests/scripts/screenshot-capture.test.ts @@ -0,0 +1,264 @@ +import type { Page } from 'playwright' + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runInNewContext } from 'node:vm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + connect: vi.fn(), + capture: vi.fn(), + manifest: {} as Record +})) +vi.mock('playwright', () => ({ chromium: { connectOverCDP: mocks.connect } })) +vi.mock('../../screenshots/dialog-capture.mjs', () => ({ captureDialogPng: mocks.capture })) +vi.mock('node:fs/promises', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + readFile: (...args: Parameters) => + String(args[0]).endsWith('/screenshots/scenarios.json') + ? Promise.resolve(JSON.stringify(mocks.manifest)) + : original.readFile(...args) + } +}) + +import { assertScenario, captureScreenshots, setSelect } from '../../screenshots/capture-runtime' + +const scenario = { + id: 'setup', + group: 'setup' as const, + view: 'mcpInstall', + width: 1200, + height: 960, + panel: { setupTarget: 'Gemini' }, + pointer: { visible: false, target: { kind: 'hiddenPointer' } }, + assertions: [] as Array<{ kind: string }> +} +const clip = { x: 0, y: 0, width: 600, height: 480 } +let output: string +let originalArgv: string[] + +function driver() { + const click = vi.fn(async () => undefined) + const control = { + click, + count: vi.fn(async () => 0), + innerText: vi.fn(async () => 'Tools'), + press: vi.fn(async () => undefined), + getByRole: vi.fn(() => control) + } + const light = { click: vi.fn(async () => undefined) } + const cdp = { + send: vi.fn(async () => ({ result: { value: true } })), + detach: vi.fn(async () => undefined) + } + const context = { newCDPSession: vi.fn(async () => cdp) } + const page = { + url: () => 'https://www.figma.com/design/fixture-key/Test', + bringToFront: vi.fn(async () => undefined), + waitForLoadState: vi.fn(async () => undefined), + waitForTimeout: vi.fn(async () => undefined), + evaluate: vi.fn().mockResolvedValue({ width: 1728, height: 837 }), + locator: vi.fn(() => control), + getByRole: vi.fn((_role: string, options: { name: string }) => + options.name === 'Light' ? light : control + ), + context: () => context, + mouse: { move: vi.fn(async () => undefined) } + } + const browser = { + contexts: () => [{ pages: () => [page] }], + close: vi.fn(async () => undefined) + } + mocks.connect.mockResolvedValue(browser) + return { page, browser, cdp, light } +} + +beforeEach(async () => { + vi.clearAllMocks() + output = await mkdtemp(join(tmpdir(), 'tempad-capture-driver-')) + originalArgv = process.argv + process.argv = ['node', 'capture-screenshots.ts', '--output-dir', output] + mocks.manifest = { + fixture: { file: { key: 'fixture-key', url: 'https://www.figma.com/design/fixture-key/Test' } }, + capture: { + themes: ['dark'], + viewport: { width: 1728, height: 837 }, + clip, + clipScale: 1, + hiddenPointer: { x: 1200, y: 700 }, + settleMs: 0 + }, + scenarios: [scenario] + } + const header = Buffer.alloc(24) + header.set([137, 80, 78, 71, 13, 10, 26, 10]) + header.writeUInt32BE(scenario.width, 16) + header.writeUInt32BE(scenario.height, 20) + mocks.capture.mockResolvedValue({ data: header.toString('base64') }) +}) + +afterEach(async () => { + process.argv = originalArgv + vi.restoreAllMocks() + await rm(output, { recursive: true, force: true }) +}) + +describe('screenshot capture boundaries', () => { + it('changes the selected CSS unit even when both option labels are present', async () => { + let selected = 'px' + const select = { + innerText: async () => 'px rem', + inputValue: async () => selected, + selectOption: vi.fn(async (value: string) => { + selected = value + }) + } + const page = { getByRole: vi.fn(() => select) } + await setSelect(page as unknown as Page, 'CSS unit', 'rem') + expect(selected).toBe('rem') + await setSelect(page as unknown as Page, 'CSS unit', 'rem') + expect(select.selectOption).toHaveBeenCalledExactlyOnceWith('rem') + expect(page.getByRole).toHaveBeenCalledWith('combobox', { name: 'CSS unit', exact: true }) + }) + + it.each(['px', 'rem'])( + 'checks the selected CSS unit (%s), not all option labels', + async (selected) => { + const page = { + locator: () => ({ innerText: async () => 'CSS unit px rem' }), + getByRole: () => ({ + count: async () => 1, + innerText: async () => 'px rem', + inputValue: async () => selected + }) + } + const result = assertScenario( + page as unknown as Page, + { + ...scenario, + assertions: [{ kind: 'panel-control-value', name: 'CSS unit', value: 'rem' }] + }, + null, + clip + ) + if (selected === 'rem') await expect(result).resolves.toBeUndefined() + else await expect(result).rejects.toThrow('CSS unit does not show rem') + } + ) + + it.each([ + { input: '16', text: 'No generated code', error: null }, + { input: '32', text: 'Generated width: 16px', error: 'Root font size does not show 16' }, + { input: null, text: 'Generated width: 16px', error: 'unsupported or missing control' } + ])( + 'checks the actual font-size input ($input), not unrelated panel text', + async ({ input, text, error }) => { + const page = { + locator: () => ({ innerText: async () => text }), + getByRole: (role: string, options: { name: string; exact: boolean }) => ({ + count: async () => + Number( + role === 'spinbutton' && + options.name === 'Root font size' && + options.exact && + input !== null + ), + inputValue: async () => input + }) + } + const result = assertScenario( + page as unknown as Page, + { + ...scenario, + assertions: [{ kind: 'panel-control-value', name: 'Root font size', value: '16' }] + }, + null, + clip + ) + if (error) await expect(result).rejects.toThrow(error) + else await expect(result).resolves.toBeUndefined() + } + ) + + it('rejects unsupported assertions instead of silently accepting absent evidence', async () => { + const { page } = driver() + await expect( + assertScenario( + page as unknown as Page, + { ...scenario, assertions: [{ kind: 'measurement-labels' }] }, + null, + clip + ) + ).rejects.toThrow('unsupported screenshot assertion measurement-labels') + }) + + it('detaches the fixture CDP session and disconnects even when preparation fails', async () => { + mocks.manifest.scenarios = [{ ...scenario, group: 'inspect' }] + const { browser, cdp } = driver() + const failure = new Error('Cannot read Figma') + cdp.send.mockRejectedValue(failure) + await expect(captureScreenshots()).rejects.toBe(failure) + expect(cdp.detach).toHaveBeenCalledOnce() + expect(browser.close).toHaveBeenCalledOnce() + }) + + it('fails if Light restoration fails after a successful capture and still removes overlays', async () => { + const { page, browser, light, cdp } = driver() + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const failure = new Error('Light restoration failed') + light.click.mockRejectedValue(failure) + await expect(captureScreenshots()).rejects.toBe(failure) + expect(mocks.capture).toHaveBeenCalledOnce() + expect(cdp.detach).toHaveBeenCalledOnce() + expect(page.evaluate.mock.invocationCallOrder.at(-1)).toBeGreaterThan( + light.click.mock.invocationCallOrder[0]! + ) + expect(browser.close).toHaveBeenCalledOnce() + expect(log.mock.calls.flat().join('\n')).not.toContain('Candidates written') + }) + + it('restores prepared fixtures even if detaching their CDP session fails', async () => { + mocks.manifest.scenarios = [{ ...scenario, group: 'status', mcpStatus: 'inactive' }] + process.argv.push('--only', scenario.id) + const { page, browser, cdp } = driver() + const failure = new Error('Detach failed') + const setCanvasTheme = vi.fn() + const remove = vi.fn() + page.evaluate.mockImplementation(async (callback: () => unknown) => + runInNewContext(`(${callback.toString()})()`, { + innerWidth: 1728, + innerHeight: 837, + __TEMPAD_README_SCREENSHOTS__: { setCanvasTheme }, + document: { querySelector: () => ({ remove }) } + }) + ) + cdp.detach.mockRejectedValue(failure) + await expect(captureScreenshots()).rejects.toBe(failure) + expect(setCanvasTheme).toHaveBeenCalledWith('light') + expect(remove).toHaveBeenCalledTimes(2) + expect(browser.close).toHaveBeenCalledOnce() + }) + + it('reports capture and independent restoration failures together', async () => { + const { page, browser, light } = driver() + const captureFailure = new Error('Capture failed') + const restoreFailure = new Error('Light restoration failed') + const disconnectFailure = new Error('Disconnect failed') + mocks.capture.mockRejectedValue(captureFailure) + light.click.mockRejectedValue(restoreFailure) + browser.close.mockRejectedValue(disconnectFailure) + const result = await captureScreenshots().catch((error: unknown) => error) + expect(result).toBeInstanceOf(AggregateError) + expect((result as AggregateError).errors).toEqual([ + captureFailure, + restoreFailure, + disconnectFailure + ]) + expect(page.evaluate.mock.invocationCallOrder.at(-1)).toBeGreaterThan( + light.click.mock.invocationCallOrder[0]! + ) + }) +}) diff --git a/packages/extension/tests/scripts/screenshot-commands.test.ts b/packages/extension/tests/scripts/screenshot-commands.test.ts new file mode 100644 index 00000000..391c2656 --- /dev/null +++ b/packages/extension/tests/scripts/screenshot-commands.test.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +const extensionRoot = fileURLToPath(new URL('../../', import.meta.url)) + +it('rejects an incomplete promotion selection before reading candidates or replacing assets', () => { + const candidates = mkdtempSync(join(tmpdir(), 'tempad-screenshot-selection-')) + try { + for (const selection of [['--only'], ['--group', ''], ['--onnly=code']]) { + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + 'scripts/promote-screenshots.ts', + '--yes', + '--candidate-dir', + candidates, + ...selection + ], + { cwd: extensionRoot, encoding: 'utf8' } + ) + expect(result.status).toBe(1) + expect(result.stderr).toContain(selection[0]!.split('=')[0]) + expect(result.stderr).not.toContain('ENOENT') + } + } finally { + rmSync(candidates, { recursive: true, force: true }) + } +}) + +it('includes the native canvas label requirement in the visual comparison', () => { + const candidates = mkdtempSync(join(tmpdir(), 'tempad-screenshot-comparison-')) + try { + // Comparison embeds candidate bytes; it does not decode or approve the image. + writeFileSync(join(candidates, 'measure-light.png'), 'candidate') + const result = spawnSync( + process.execPath, + [ + '--import', + 'tsx', + 'scripts/compare-screenshots.ts', + '--candidate-dir', + candidates, + '--only', + 'measure', + '--themes', + 'light' + ], + { cwd: extensionRoot, encoding: 'utf8' } + ) + expect(result.status).toBe(0) + expect(readFileSync(join(candidates, 'comparison.html'), 'utf8')).toContain( + 'Visual review required: confirm the native canvas measurement labels read 20, 20, 20, 20.' + ) + } finally { + rmSync(candidates, { recursive: true, force: true }) + } +}) diff --git a/packages/extension/tests/scripts/screenshot-fixture.test.ts b/packages/extension/tests/scripts/screenshot-fixture.test.ts new file mode 100644 index 00000000..d2e46983 --- /dev/null +++ b/packages/extension/tests/scripts/screenshot-fixture.test.ts @@ -0,0 +1,154 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' + +const source = readFileSync( + new URL('../../screenshots/fixture-runtime.js', import.meta.url), + 'utf8' +) +const component = { + remote: true, + key: '1ee41f133f277b708cf54a74a6c2e294be6664fb', + parent: { type: 'COMPONENT_SET', name: 'Button' } +} + +type FixtureNode = { + id: string + type: string + name: string + marker: string + removed: boolean + parent: FixtureNode | null + children: FixtureNode[] + selection: FixtureNode[] + mainComponent: typeof component | null + x: number + y: number + width: number + height: number + getSharedPluginData(): string + setSharedPluginData(namespace: string, key: string, value: string): void + appendChild(child: FixtureNode): void + findOne(predicate: (item: FixtureNode) => boolean): FixtureNode | null + remove(): void + resize(width: number, height: number): void +} + +function node(type: string, name: string, marker = ''): FixtureNode { + const value: FixtureNode = { + id: name, + type, + name, + marker, + removed: false, + parent: null, + children: [], + selection: [], + mainComponent: type === 'INSTANCE' ? component : null, + x: 0, + y: 0, + width: 0, + height: 0, + getSharedPluginData: () => value.marker, + setSharedPluginData: (_namespace: string, _key: string, next: string) => { + value.marker = next + }, + appendChild(child) { + if (child.parent) + child.parent.children = child.parent.children.filter((item) => item !== child) + child.parent = value + value.children.push(child) + }, + findOne(predicate) { + for (const child of value.children) { + if (predicate(child)) return child + const descendant = child.findOne(predicate) + if (descendant) return descendant + } + return null + }, + remove() { + for (const child of [...value.children]) child.remove() + value.removed = true + if (value.parent) + value.parent.children = value.parent.children.filter((child) => child !== value) + }, + resize(width: number, height: number) { + value.width = width + value.height = height + } + } + return value +} + +function scene(pages: [FixtureNode, ...FixtureNode[]]) { + const figma = { + root: { children: pages }, + currentPage: pages[0], + createPage: vi.fn(() => { + const page = node('PAGE', 'New page') + pages.push(page) + return page + }), + createFrame: vi.fn(() => { + const frame = node('FRAME', 'Frame') + figma.currentPage.appendChild(frame) + return frame + }), + viewport: { scrollAndZoomIntoView: vi.fn() } + } + return { figma, run: () => runInNewContext(source, { figma }) as Promise } +} + +describe('marketing fixture preservation', () => { + it('rejects a missing real button before selecting, renaming, or creating a page', async () => { + const original = node('PAGE', 'Original') + const fixturePage = node('PAGE', 'README Fixtures') + const impostor = node('INSTANCE', 'Other button', 'plugins') + fixturePage.appendChild(impostor) + const { figma, run } = scene([original, fixturePage]) + + await expect(run()).rejects.toThrow('real remote Kong Button') + expect(figma.currentPage).toBe(original) + expect(figma.createPage).not.toHaveBeenCalled() + expect(figma.createFrame).not.toHaveBeenCalled() + expect(impostor.removed).toBe(false) + expect(impostor.x).toBe(0) + + const missing = scene([original]) + await expect(missing.run()).rejects.toThrow('real remote Kong Button') + expect(missing.figma.createPage).not.toHaveBeenCalled() + expect(original.name).toBe('Original') + }) + + it('ignores an obsolete marker and uses the verified remote instance', async () => { + const page = node('PAGE', 'README Fixtures') + const impostor = node('INSTANCE', 'Button', 'plugins') + impostor.mainComponent = { ...component, key: 'another-component' } + const button = node('INSTANCE', 'Button') + page.appendChild(impostor) + page.appendChild(button) + await scene([page]).run() + expect(impostor.removed).toBe(true) + expect(button.marker).toBe('plugins') + expect(button.x).toBe(6000) + expect(button.removed).toBe(false) + }) + + it('preserves a verified button inside geometry that is being replaced', async () => { + const page = node('PAGE', 'Existing fixtures') + const oldFrame = node('FRAME', 'Old fixture', 'code') + const button = node('INSTANCE', 'Button', 'plugins') + const unowned = node('FRAME', 'User content') + oldFrame.appendChild(button) + page.appendChild(oldFrame) + page.appendChild(unowned) + await scene([page]).run() + expect(page.name).toBe('README Fixtures') + expect(oldFrame.removed).toBe(true) + expect(button.removed).toBe(false) + expect(button.parent).toBe(page) + expect(button.x).toBe(6000) + expect(unowned.removed).toBe(false) + }) +}) diff --git a/packages/extension/tests/scripts/screenshot-plan.test.ts b/packages/extension/tests/scripts/screenshot-plan.test.ts index e7e00789..228230c0 100644 --- a/packages/extension/tests/scripts/screenshot-plan.test.ts +++ b/packages/extension/tests/scripts/screenshot-plan.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { needsFixtureRuntime, + parseScreenshotOptions, + selectionOptions, selectScenarios, selectThemes, type ScreenshotScenario @@ -17,6 +19,31 @@ const scenarios: ScreenshotScenario[] = [ ] describe('marketing screenshot selection', () => { + it('rejects missing option values instead of selecting every scenario', () => { + const options = { + ...selectionOptions, + 'candidate-dir': { type: 'string' as const }, + 'output-dir': { type: 'string' as const }, + yes: { type: 'boolean' as const } + } + for (const option of ['--only', '--group', '--themes', '--candidate-dir', '--output-dir']) { + expect(() => parseScreenshotOptions(options, ['--yes', option])).toThrow(option) + expect(() => parseScreenshotOptions(options, [option, '--yes'])).toThrow(option) + expect(() => parseScreenshotOptions(options, [option, ''])).toThrow( + `${option} requires a value` + ) + } + expect(parseScreenshotOptions(options, ['--yes']).only).toBeUndefined() + expect(() => parseScreenshotOptions(options, ['--onnly=code'])).toThrow('Unknown option') + expect(() => parseScreenshotOptions(options, ['code'])).toThrow('Unexpected argument') + expect(selectScenarios(scenarios, parseScreenshotOptions(options, ['--only=code']))).toEqual([ + scenarios[0] + ]) + expect(parseScreenshotOptions({ 'clip-x': { type: 'string' } }, ['--clip-x=-1'])).toEqual({ + 'clip-x': '-1' + }) + }) + it('can refresh agent setup without initializing or modifying canvas fixtures', () => { const setup = selectScenarios(scenarios, { group: 'setup', capture: true }) expect(setup.map(({ id }) => id)).toEqual(['mcp-config', 'mcp-config-gemini']) diff --git a/packages/extension/tests/setup.node.ts b/packages/extension/tests/setup.node.ts index 725f102b..94c30161 100644 --- a/packages/extension/tests/setup.node.ts +++ b/packages/extension/tests/setup.node.ts @@ -1,5 +1,7 @@ import { afterEach, vi } from 'vitest' +delete process.env.TEMPAD_MCP_DEV_CHECKOUT + afterEach(() => { vi.restoreAllMocks() }) diff --git a/packages/extension/tests/ui/plugin-storage.browser.test.ts b/packages/extension/tests/ui/plugin-storage.browser.test.ts new file mode 100644 index 00000000..fbb8b16d --- /dev/null +++ b/packages/extension/tests/ui/plugin-storage.browser.test.ts @@ -0,0 +1,295 @@ +import { useStorage } from '@vueuse/core' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { effectScope, nextTick } from 'vue' + +import { + CorruptPluginStorageError, + createPluginStorage, + PLUGIN_STORAGE_KEY +} from '@/ui/plugin-storage' +import { createPreferenceStorage } from '@/ui/preference-storage' + +const plugin = (name: string) => ({ name, source: name, code: `${name} code` }) + +function resetFixture() { + localStorage.removeItem(PLUGIN_STORAGE_KEY) + localStorage.removeItem('tempad-dev') + document.body.replaceChildren() +} + +beforeEach(resetFixture) +afterEach(resetFixture) + +it('serializes concurrent changes across browser contexts without reviving deleted plugins', async () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const remote = frame.contentWindow! + const first = createPluginStorage(localStorage, navigator.locks) + const second = createPluginStorage(remote.localStorage, remote.navigator.locks) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const held = navigator.locks.request(PLUGIN_STORAGE_KEY, () => { + entered.resolve() + return release.promise + }) + await entered.promise + const a = first.save('a', plugin('a')) + const b = second.save('b', plugin('b')) + const whileLocked = localStorage.getItem(PLUGIN_STORAGE_KEY) + release.resolve() + await Promise.all([held, a, b]) + expect(whileLocked).toBeNull() + expect(first.read().plugins).toEqual({ a: plugin('a'), b: plugin('b') }) + + await Promise.all([first.save('a', null), second.save('c', plugin('c'))]) + expect(first.read().plugins).toEqual({ b: plugin('b'), c: plugin('c') }) + await second.save('b', { ...plugin('b'), code: 'updated' }) + expect(first.read().plugins).toEqual({ + b: { ...plugin('b'), code: 'updated' }, + c: plugin('c') + }) +}) + +it('serializes legacy migration with edits and never reapplies a stale legacy backup', async () => { + localStorage.setItem('tempad-dev', JSON.stringify({ plugins: { old: plugin('old') } })) + const first = createPluginStorage(localStorage, navigator.locks) + const second = createPluginStorage(localStorage, navigator.locks) + await Promise.all([first.migrate(), second.save('old', null), first.save('new', plugin('new'))]) + expect(second.read().plugins).toEqual({ new: plugin('new') }) + await second.migrate() + expect(second.read().plugins).toEqual({ new: plugin('new') }) +}) + +it.each(['{broken', '[]', 'null'])( + 'preserves invalid persisted data (%s) until explicit recovery', + async (record) => { + localStorage.setItem(PLUGIN_STORAGE_KEY, record) + const storage = createPluginStorage(localStorage, navigator.locks) + expect(() => storage.read()).toThrow(CorruptPluginStorageError) + await expect(storage.migrate()).rejects.toThrow(CorruptPluginStorageError) + await expect(storage.save('new', plugin('new'))).rejects.toThrow(CorruptPluginStorageError) + expect(localStorage.getItem(PLUGIN_STORAGE_KEY)).toBe(record) + await expect(storage.resetCorruptRecord()).resolves.toBe(true) + expect(storage.read().plugins).toEqual({}) + } +) + +it.each(['canonical', 'legacy'] as const)( + 'preserves malformed %s plugin entries until explicit recovery', + async (location) => { + const invalid = [ + null, + [], + { x: null }, + { x: [] }, + { x: { name: 'x', source: 'x' } }, + { x: { ...plugin('x'), code: 42 } }, + { x: { ...plugin('x'), source: 'other' } }, + { x: { ...plugin('x'), integrity: null } }, + { x: { ...plugin('x'), resolvedUrl: false } } + ] + const storage = createPluginStorage(localStorage, navigator.locks) + for (const plugins of invalid) { + localStorage.removeItem(PLUGIN_STORAGE_KEY) + localStorage.removeItem('tempad-dev') + const key = location === 'canonical' ? PLUGIN_STORAGE_KEY : 'tempad-dev' + const record = JSON.stringify( + location === 'canonical' ? plugins : { minimized: true, plugins } + ) + localStorage.setItem(key, record) + expect(() => storage.read()).toThrow(CorruptPluginStorageError) + await expect(storage.migrate()).rejects.toThrow(CorruptPluginStorageError) + await expect(storage.save('new', plugin('new'))).rejects.toThrow(CorruptPluginStorageError) + expect(localStorage.getItem(key)).toBe(record) + await expect(storage.resetCorruptRecord()).resolves.toBe(true) + expect(storage.read().plugins).toEqual({}) + if (location === 'legacy') + expect(JSON.parse(localStorage.getItem('tempad-dev')!)).toEqual({ minimized: true }) + } + } +) + +it('keeps valid install metadata and additional stored fields through migration', async () => { + const plugins = { + x: { + ...plugin('x'), + integrity: 'sha256-verified', + resolvedUrl: 'https://example.com/plugin.js', + additional: 'retained' + } + } + localStorage.setItem('tempad-dev', JSON.stringify({ plugins })) + const storage = createPluginStorage(localStorage, navigator.locks) + await storage.migrate() + expect(storage.read().plugins).toEqual(plugins) + await expect(storage.resetCorruptRecord()).resolves.toBe(false) +}) + +it('serializes recovery with installation and prevents a legacy backup from returning', async () => { + localStorage.setItem(PLUGIN_STORAGE_KEY, '{broken') + localStorage.setItem( + 'tempad-dev', + JSON.stringify({ minimized: true, plugins: { old: plugin('old') } }) + ) + const frame = document.createElement('iframe') + document.body.append(frame) + const remote = frame.contentWindow! + const first = createPluginStorage(localStorage, navigator.locks) + const second = createPluginStorage(remote.localStorage, remote.navigator.locks) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const held = navigator.locks.request(PLUGIN_STORAGE_KEY, () => { + entered.resolve() + return release.promise + }) + await entered.promise + const reset = first.resetCorruptRecord() + expect( + (await navigator.locks.query()).pending?.some(({ name }) => name === PLUGIN_STORAGE_KEY) + ).toBe(true) + const install = second.save('new', plugin('new')) + const whileLocked = localStorage.getItem(PLUGIN_STORAGE_KEY) + release.resolve() + await Promise.all([held, reset, install]) + expect(whileLocked).toBe('{broken') + expect(first.read().plugins).toEqual({ new: plugin('new') }) + expect(JSON.parse(localStorage.getItem('tempad-dev')!)).toEqual({ minimized: true }) + // A stale recovery action must not clear the other tab's newly installed plugin. + await expect(first.resetCorruptRecord()).resolves.toBe(false) + expect(first.read().plugins).toEqual({ new: plugin('new') }) +}) + +it('keeps a retained legacy backup fenced when its cleanup is unavailable', async () => { + localStorage.setItem(PLUGIN_STORAGE_KEY, '{broken') + const legacy = JSON.stringify({ plugins: { old: plugin('old') } }) + localStorage.setItem('tempad-dev', legacy) + const nativeSetItem = Storage.prototype.setItem + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key === 'tempad-dev') throw new DOMException('Storage unavailable', 'SecurityError') + nativeSetItem.call(this, key, value) + }) + const storage = createPluginStorage(localStorage, navigator.locks) + await expect(storage.resetCorruptRecord()).resolves.toBe(true) + await storage.migrate() + expect(localStorage.getItem('tempad-dev')).toBe(legacy) + expect(storage.read().plugins).toEqual({}) +}) + +it('does not classify inaccessible storage as corruption or reset it', async () => { + const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Storage unavailable', 'SecurityError') + }) + const write = vi.spyOn(Storage.prototype, 'setItem') + const storage = createPluginStorage(localStorage, navigator.locks) + await expect(storage.resetCorruptRecord()).rejects.toThrow('Storage unavailable') + expect(write).not.toHaveBeenCalled() + read.mockRestore() +}) + +it('keeps legacy deletions durable when another tab persists stale preferences under quota pressure', async () => { + const original = { a: plugin('a'), b: plugin('b') } + localStorage.setItem('tempad-dev', JSON.stringify({ minimized: false, plugins: original })) + const frame = document.createElement('iframe') + document.body.append(frame) + const remote = frame.contentWindow! + const scope = effectScope() + const defaults = { minimized: false, plugins: original } + const preferences = scope.run(() => + useStorage( + 'tempad-dev', + defaults, + createPreferenceStorage(remote.localStorage, remote.navigator.locks, defaults), + { + listenToStorageChanges: false + } + ) + )! + for (const prototype of [ + Storage.prototype, + Object.getPrototypeOf(remote.localStorage) as Storage + ]) { + const nativeSetItem = prototype.setItem + vi.spyOn(prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key === PLUGIN_STORAGE_KEY) throw new DOMException('Quota exceeded', 'QuotaExceededError') + nativeSetItem.call(this, key, value) + }) + } + try { + const first = createPluginStorage(localStorage, navigator.locks) + const second = createPluginStorage(remote.localStorage, remote.navigator.locks) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const held = navigator.locks.request(PLUGIN_STORAGE_KEY, () => { + entered.resolve() + return release.promise + }) + await entered.promise + const firstRemoval = first.save('a', null) + preferences.value.minimized = true + await nextTick() + const secondRemoval = second.save('b', null) + const whileLocked = JSON.parse(localStorage.getItem('tempad-dev')!) + release.resolve() + await Promise.all([held, firstRemoval, secondRemoval]) + expect(whileLocked).toEqual({ minimized: false, plugins: original }) + await navigator.locks.request(PLUGIN_STORAGE_KEY, () => undefined) + expect(JSON.parse(localStorage.getItem('tempad-dev')!)).toEqual({ + minimized: true, + plugins: {} + }) + expect(localStorage.getItem(PLUGIN_STORAGE_KEY)).toBeNull() + expect(preferences.value.plugins).toEqual(original) + } finally { + scope.stop() + } +}) + +it('persists a preference reversal made while a plugin write owns the lock', async () => { + localStorage.setItem('tempad-dev', JSON.stringify({ minimized: false })) + const scope = effectScope() + const preferences = scope.run(() => + useStorage( + 'tempad-dev', + { minimized: false }, + createPreferenceStorage(localStorage, navigator.locks, { minimized: false }), + { listenToStorageChanges: false } + ) + )! + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const held = navigator.locks.request(PLUGIN_STORAGE_KEY, () => { + entered.resolve() + return release.promise + }) + try { + await entered.promise + preferences.value.minimized = true + await nextTick() + preferences.value.minimized = false + await nextTick() + } finally { + release.resolve() + await held + await navigator.locks.request(PLUGIN_STORAGE_KEY, () => undefined) + scope.stop() + } + expect(JSON.parse(localStorage.getItem('tempad-dev')!)).toEqual({ minimized: false }) +}) + +it('reports a locked preference write failure and retries the unpersisted edit', async () => { + localStorage.setItem('tempad-dev', JSON.stringify({ minimized: false, cssUnit: 'px' })) + const onError = vi.fn() + const preferences = createPreferenceStorage(localStorage, navigator.locks, {}, onError) + preferences.getItem('tempad-dev') + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }) + await preferences.setItem('tempad-dev', JSON.stringify({ minimized: false, cssUnit: 'rem' })) + expect(onError).toHaveBeenCalledOnce() + write.mockRestore() + await preferences.setItem('tempad-dev', JSON.stringify({ minimized: true, cssUnit: 'rem' })) + expect(JSON.parse(localStorage.getItem('tempad-dev')!)).toEqual({ + minimized: true, + cssUnit: 'rem' + }) +}) diff --git a/packages/extension/tests/ui/preference-storage.browser.test.ts b/packages/extension/tests/ui/preference-storage.browser.test.ts index 875e7bfc..149f0360 100644 --- a/packages/extension/tests/ui/preference-storage.browser.test.ts +++ b/packages/extension/tests/ui/preference-storage.browser.test.ts @@ -2,37 +2,121 @@ import { useStorage } from '@vueuse/core' import { afterEach, expect, it, vi } from 'vitest' import { effectScope, nextTick } from 'vue' +import { PLUGIN_STORAGE_KEY } from '@/ui/plugin-storage' import { createPreferenceStorage } from '@/ui/preference-storage' const key = 'tempad-preference-test' -afterEach(() => { +function usePreferences>( + defaults: T, + onError?: (error: unknown) => void +) { + return useStorage( + key, + defaults, + createPreferenceStorage(localStorage, navigator.locks, defaults, onError), + { mergeDefaults: true, listenToStorageChanges: false, onError } + ) +} + +async function flushStorage() { + await nextTick() + await navigator.locks.request(PLUGIN_STORAGE_KEY, () => undefined) +} +afterEach(async () => { + await flushStorage() localStorage.removeItem(key) vi.restoreAllMocks() }) +it.each(['missing', 'older'])( + 'keeps an early edit when another tab initializes the %s record before the write lock is released', + async (record) => { + if (record === 'older') localStorage.setItem(key, JSON.stringify({ cssUnit: 'px' })) + const frame = document.createElement('iframe') + document.body.append(frame) + const remote = frame.contentWindow! + const flushTabs = async () => { + await flushStorage() + await remote.navigator.locks.request(PLUGIN_STORAGE_KEY, () => undefined) + } + const scope = effectScope() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const held = navigator.locks.request(PLUGIN_STORAGE_KEY, () => { + entered.resolve() + return release.promise + }) + await entered.promise + const openTab = (storage: Storage, locks: LockManager) => + scope.run(() => { + const defaults = { cssUnit: 'px', mcpOn: false } + return useStorage(key, defaults, createPreferenceStorage(storage, locks, defaults), { + mergeDefaults: true, + listenToStorageChanges: false + }) + })! + try { + const a = openTab(localStorage, navigator.locks) + a.value.mcpOn = true + await nextTick() + const b = openTab(remote.localStorage, remote.navigator.locks) + await nextTick() + release.resolve() + await held + await flushTabs() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'px', mcpOn: true }) + b.value.cssUnit = 'rem' + await flushTabs() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'rem', mcpOn: true }) + expect(b.value.mcpOn).toBe(false) + } finally { + release.resolve() + await held + await flushTabs() + scope.stop() + frame.remove() + } + } +) + +it('does not write untouched defaults after the initial storage write fails', async () => { + const scope = effectScope() + const onError = vi.fn() + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }) + try { + const preferences = scope.run(() => usePreferences({ cssUnit: 'px', mcpOn: false }, onError))! + await flushStorage() + expect(onError).toHaveBeenCalledOnce() + write.mockRestore() + localStorage.setItem(key, JSON.stringify({ cssUnit: 'px', mcpOn: true })) + preferences.value.cssUnit = 'rem' + await flushStorage() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'rem', mcpOn: true }) + } finally { + scope.stop() + } +}) + it('merges independent tab edits without moving another open panel or reverting shared preferences', async () => { const scope = effectScope() try { const tabs = scope.run(() => [0, 1].map(() => - useStorage( - key, - { - cssUnit: 'px', - panelPosition: { left: 1, top: 2 }, - minimized: false - }, - createPreferenceStorage(localStorage), - { listenToStorageChanges: false } - ) + usePreferences({ + cssUnit: 'px', + panelPosition: { left: 1, top: 2 }, + minimized: false + }) ) )! tabs[0]!.value.cssUnit = 'rem' - await nextTick() + await flushStorage() tabs[1]!.value.panelPosition.left = 30 - await nextTick() + await flushStorage() tabs[0]!.value.minimized = true - await nextTick() + await flushStorage() expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'rem', panelPosition: { left: 30, top: 2 }, @@ -45,17 +129,119 @@ it('merges independent tab edits without moving another open panel or reverting } }) -it('does not forget an unpersisted edit after a storage failure', () => { +it('does not forget an unpersisted edit after a storage failure', async () => { localStorage.setItem(key, JSON.stringify({ cssUnit: 'px', minimized: false })) - const storage = createPreferenceStorage(localStorage) + const onError = vi.fn() + const storage = createPreferenceStorage(localStorage, navigator.locks, {}, onError) storage.getItem(key) const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { throw new DOMException('Quota exceeded', 'QuotaExceededError') }) const next = JSON.stringify({ cssUnit: 'rem', minimized: false }) - expect(() => storage.setItem(key, next)).toThrow('Quota exceeded') + await storage.setItem(key, next) + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Quota exceeded' })) write.mockRestore() localStorage.setItem(key, JSON.stringify({ cssUnit: 'px', minimized: true })) - storage.setItem(key, next) + await storage.setItem(key, next) expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'rem', minimized: true }) }) + +it.each(['initial', 'later'])( + 'recovers from %s malformed preference JSON through useStorage', + async (when) => { + localStorage.setItem( + key, + when === 'initial' ? '{broken' : JSON.stringify({ cssUnit: 'px', mcpOn: false }) + ) + const scope = effectScope() + const onError = vi.fn() + try { + const preferences = scope.run(() => usePreferences({ cssUnit: 'px', mcpOn: false }, onError))! + await flushStorage() + if (when === 'later') localStorage.setItem(key, '{broken') + preferences.value.cssUnit = 'rem' + await flushStorage() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'rem', mcpOn: false }) + expect(onError).not.toHaveBeenCalled() + } finally { + scope.stop() + } + } +) + +it('advances a tab baseline when another tab already persisted the same local edit', async () => { + localStorage.setItem(key, JSON.stringify({ cssUnit: 'px', mcpOn: false })) + const scope = effectScope() + try { + const [a, b] = scope.run(() => + [0, 1].map(() => usePreferences({ cssUnit: 'px', mcpOn: false })) + )! + await flushStorage() + b!.value.cssUnit = 'rem' + await flushStorage() + const write = vi.spyOn(Storage.prototype, 'setItem') + a!.value.cssUnit = 'rem' + await flushStorage() + expect(write).not.toHaveBeenCalled() + b!.value.cssUnit = 'px' + await flushStorage() + a!.value.mcpOn = true + await flushStorage() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'px', mcpOn: true }) + expect(a!.value.cssUnit).toBe('rem') + } finally { + scope.stop() + } +}) + +it('preserves another tab edit after merging defaults into an older record', async () => { + localStorage.setItem(key, JSON.stringify({ cssUnit: 'px' })) + const scope = effectScope() + try { + const [a, b] = scope.run(() => + [0, 1].map(() => usePreferences({ cssUnit: 'px', mcpOn: false })) + )! + b!.value.mcpOn = true + await flushStorage() + a!.value.cssUnit = 'rem' + await flushStorage() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ cssUnit: 'rem', mcpOn: true }) + expect(a!.value.mcpOn).toBe(false) + } finally { + scope.stop() + } +}) + +it('retries a failed useStorage write without losing remote or unknown fields', async () => { + localStorage.setItem(key, JSON.stringify({ cssUnit: 'px', mcpOn: false })) + const scope = effectScope() + const onError = vi.fn() + try { + const preferences = scope.run(() => + usePreferences({ cssUnit: 'px', mcpOn: false, minimized: false }, onError) + )! + await flushStorage() + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { + throw new DOMException('Quota exceeded', 'QuotaExceededError') + }) + preferences.value.cssUnit = 'rem' + await flushStorage() + expect(onError).toHaveBeenCalledOnce() + write.mockRestore() + localStorage.setItem( + key, + JSON.stringify({ cssUnit: 'px', mcpOn: true, minimized: false, future: { option: 1 } }) + ) + preferences.value.minimized = true + await flushStorage() + expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ + cssUnit: 'rem', + mcpOn: true, + minimized: true, + future: { option: 1 } + }) + expect(preferences.value.mcpOn).toBe(false) + } finally { + scope.stop() + } +}) diff --git a/packages/extension/tests/ui/state.browser.test.ts b/packages/extension/tests/ui/state.browser.test.ts index 45880556..ad62d460 100644 --- a/packages/extension/tests/ui/state.browser.test.ts +++ b/packages/extension/tests/ui/state.browser.test.ts @@ -34,11 +34,14 @@ function createDefaultOptions() { describe('ui/state', () => { beforeEach(async () => { + await navigator.locks.request('tempad-dev-plugins', () => undefined) localStorage.removeItem('tempad-dev') + localStorage.removeItem('tempad-dev-plugins') options.value = createDefaultOptions() plugins.value = {} selection.value = [] await nextTick() + await navigator.locks.request('tempad-dev-plugins', () => undefined) }) it('initializes options with expected panel defaults', () => { @@ -60,6 +63,19 @@ describe('ui/state', () => { expect(selectedNode.value).toBe(node) }) + it('applies plugin edits to the latest persisted collection instead of the tab snapshot', async () => { + const first = { name: 'First', source: 'first', code: 'first code' } + const second = { name: 'Second', source: 'second', code: 'second code' } + localStorage.setItem('tempad-dev-plugins', JSON.stringify({ first })) + + await savePlugin('second', second) + + expect(JSON.parse(localStorage.getItem('tempad-dev-plugins')!)).toEqual({ first, second }) + localStorage.setItem('tempad-dev-plugins', JSON.stringify({ second })) + await savePlugin('second', null) + expect(JSON.parse(localStorage.getItem('tempad-dev-plugins')!)).toEqual({}) + }) + it('retains the migration backup on failure and removes it only after a successful save', async () => { const oldPlugin = { name: 'Old', source: 'old', code: 'old code' } const nextPlugin = { name: 'New', source: 'new', code: 'new code' } @@ -67,21 +83,23 @@ describe('ui/state', () => { plugins?: typeof plugins.value } preferences.plugins = { old: oldPlugin } + localStorage.setItem('tempad-dev', JSON.stringify(preferences)) plugins.value = { old: oldPlugin } await nextTick() + await navigator.locks.request('tempad-dev-plugins', () => undefined) const nativeSetItem = Storage.prototype.setItem const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new DOMException('Storage quota exceeded', 'QuotaExceededError') }) - expect(() => savePlugin('new', nextPlugin)).toThrow('Storage quota exceeded') + await expect(savePlugin('new', nextPlugin)).rejects.toThrow('Storage quota exceeded') expect(plugins.value).toEqual({ old: oldPlugin }) expect(preferences.plugins).toEqual({ old: oldPlugin }) write.mockImplementation(nativeSetItem) - savePlugin('new', nextPlugin) + await savePlugin('new', nextPlugin) expect(preferences.plugins).toBeUndefined() const thirdPlugin = { name: 'Third', source: 'third', code: 'third code' } - savePlugin('third', thirdPlugin) + await savePlugin('third', thirdPlugin) await nextTick() expect(plugins.value).toEqual({ old: oldPlugin, new: nextPlugin, third: thirdPlugin }) expect(JSON.parse(localStorage.getItem('tempad-dev-plugins')!)).toEqual(plugins.value) @@ -121,8 +139,10 @@ describe('ui/state', () => { const original = { first: plugin('first'), second: plugin('second') } const preferences = options.value as typeof options.value & { plugins?: typeof plugins.value } preferences.plugins = original + localStorage.setItem('tempad-dev', JSON.stringify(preferences)) plugins.value = original await nextTick() + await navigator.locks.request('tempad-dev-plugins', () => undefined) localStorage.removeItem('tempad-dev-plugins') const quotaKey = 'tempad-quota-fixture' let low = 0 @@ -138,7 +158,7 @@ describe('ui/state', () => { } } localStorage.setItem(quotaKey, 'x'.repeat(Math.max(0, low - 8192))) - expect(() => savePlugin('first', null)).not.toThrow() + await expect(savePlugin('first', null)).resolves.toBeUndefined() await nextTick() expect(plugins.value).toEqual({ second: original.second }) const saved = @@ -153,6 +173,24 @@ describe('ui/state', () => { } }) + it('refreshes remote plugin changes without replaying a stale storage event', async () => { + const oldPlugin = { name: 'Old', source: 'old', code: 'old code' } + const currentPlugin = { name: 'Current', source: 'current', code: 'current code' } + localStorage.setItem('tempad-dev-plugins', JSON.stringify({ current: currentPlugin })) + window.dispatchEvent( + new StorageEvent('storage', { + key: 'tempad-dev-plugins', + storageArea: localStorage, + newValue: JSON.stringify({ old: oldPlugin }) + }) + ) + await nextTick() + expect(plugins.value).toEqual({ current: currentPlugin }) + localStorage.removeItem('tempad-dev-plugins') + window.dispatchEvent(new StorageEvent('storage', { key: null, storageArea: localStorage })) + expect(plugins.value).toEqual({}) + }) + it('derives tempad component metadata from selected frame node', () => { const selectedFrame = { type: 'FRAME', diff --git a/packages/extension/tests/utils/codegen.test.ts b/packages/extension/tests/utils/codegen.test.ts index 33f83e58..6fb72222 100644 --- a/packages/extension/tests/utils/codegen.test.ts +++ b/packages/extension/tests/utils/codegen.test.ts @@ -214,6 +214,31 @@ describe('utils/codegen', () => { } }) + it('keeps injected raw CSS unchanged while preparing plugin-specific styles', async () => { + const css = { width: '100px' } + const readCss = vi.fn(async () => css) + const node = { getCSSAsync: vi.fn() } as unknown as SceneNode + mocked.resolveStylesFromNode.mockImplementationOnce(async (style) => { + style.width = '50px' + return style + }) + mocked.formatNodeStyleForUi.mockImplementation((style) => ({ style })) + mocked.formatNodeStyleForPluginVariables.mockImplementation((style) => style) + mocked.requestPluginSandbox.mockResolvedValueOnce({ results: [{ codeBlocks: [] }] }) + + await generateCodeBlocksForNodes( + [node], + { cssUnit: 'px', rootFontSize: 16, scale: 1 }, + 'plugin', + { readCss } + ) + + expect(readCss).toHaveBeenCalledWith(node) + expect(node.getCSSAsync).not.toHaveBeenCalled() + expect(css).toEqual({ width: '100px' }) + expect(mocked.requestPluginSandbox.mock.calls[0]?.[1].jobs[0].style).toEqual({ width: '50px' }) + }) + it('splits timed-out batches so a slow aggregate does not break valid per-node work', async () => { const nodes = Array.from({ length: 2 }, (_, index) => ({ getCSSAsync: vi.fn(async () => ({ color: `color-${index}` })) diff --git a/packages/extension/tests/utils/color.test.ts b/packages/extension/tests/utils/color.test.ts index 9cb62ca0..927be387 100644 --- a/packages/extension/tests/utils/color.test.ts +++ b/packages/extension/tests/utils/color.test.ts @@ -1,32 +1,9 @@ import { describe, expect, it } from 'vitest' -import { compressHex, fadeTo, formatHex, rgbToHex, rgbaToCss } from '@/utils/color' +import { rgbToHex } from '@/utils/color' describe('utils/color', () => { - it('fades hex colors and compresses opaque values', () => { - expect(fadeTo('#ff0000')).toBe('#f00') - expect(fadeTo('#112233')).toBe('#123') - expect(fadeTo('#123456')).toBe('#123456') - expect(fadeTo('#f00', 0.25)).toBe('rgba(255, 0, 0, 0.25)') - expect(fadeTo('#00ff00', -1)).toBe('rgba(0, 255, 0, 0)') - expect(fadeTo('#00ff00', 2)).toBe('rgba(0, 255, 0, 1)') - expect(() => fadeTo('#12', 0.5)).toThrow(/Invalid hex color/) - }) - - it('formats rgb/rgba outputs', () => { + it('formats normalized RGB channels as hex', () => { expect(rgbToHex({ r: 1, g: 0.5, b: 0 })).toBe('#ff8000') - - expect(rgbaToCss({ r: 1, g: 0.5, b: 0 })).toBe('rgba(255, 128, 0, 1)') - expect(rgbaToCss({ r: 1, g: 0, b: 0, a: undefined })).toBe('rgba(255, 0, 0, 1)') - expect(rgbaToCss({ r: -1, g: 2, b: 0.5, a: 0.333 })).toBe('rgba(0, 255, 128, 0.33)') - expect(rgbaToCss({ r: 1, g: 0, b: 0, a: 0.2 }, 0.75)).toBe('rgba(255, 0, 0, 0.75)') - }) - - it('formats and compresses hex values', () => { - expect(formatHex(255, 0, 255)).toBe('#ff00ff') - expect(compressHex('#AABBCC')).toBe('#abc') - expect(compressHex('#112233')).toBe('#123') - expect(compressHex('#123456')).toBe('#123456') - expect(compressHex('#XYZXYZ')).toBe('#xyzxyz') }) }) diff --git a/packages/extension/tests/utils/component-text.test.ts b/packages/extension/tests/utils/component-text.test.ts new file mode 100644 index 00000000..72087450 --- /dev/null +++ b/packages/extension/tests/utils/component-text.test.ts @@ -0,0 +1,128 @@ +import { RAW_TAG_NAME } from '@tempad-dev/plugins' +import { transformSync } from 'esbuild' +import { describe, expect, it } from 'vitest' +import { compile } from 'vue' + +import { serializeComponent, stringifyComponent } from '@/utils/component' + +const text = 'Hello {customer}; {{customer}}; & < " > }' +const instance = { + name: 'Label', + type: 'INSTANCE' as const, + visible: true, + properties: {}, + children: [] +} + +function collectText(value: unknown): string { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(collectText).join('') + if (value && typeof value === 'object' && 'children' in value) return collectText(value.children) + return '' +} + +function renderMarkup(markup: string, lang: 'jsx' | 'tsx' | 'vue') { + if (lang === 'vue') { + const render = compile(markup) as (context: object, cache: unknown[]) => unknown + return render({}, []) + } + const code = transformSync(markup, { loader: lang, jsxFactory: 'h' }).code + const render = new Function('h', `return ${code}`) + return render((_tag: string, props: unknown, ...children: unknown[]) => ({ props, children })) +} + +function renderText(markup: string, lang: 'jsx' | 'tsx' | 'vue'): string { + const text = collectText(renderMarkup(markup, lang)) + return lang === 'vue' ? text.trim() : text +} + +describe.each(['jsx', 'tsx', 'vue'] as const)('%s literal component text', (lang) => { + it('preserves significant whitespace and escaped characters in string props', () => { + const props = { value: ' user input ', title: ' "quoted" & ' } + const code = stringifyComponent({ name: 'input', props, children: [] }, lang) + const result = renderMarkup(code, lang) as { props: typeof props } + expect(result.props).toMatchObject(props) + }) + + it('round-trips own prototype-named keys inside object props', () => { + const payload = JSON.parse('{"__proto__":{"label":"root"},"rows":[{"__proto__":"nested"}]}') + const code = stringifyComponent({ name: 'input', props: { payload }, children: [] }, lang) + const rendered = renderMarkup(code, lang) as { props: { payload: typeof payload } } + const result = rendered.props.payload + + expect(result).toEqual(payload) + expect(Object.hasOwn(result, '__proto__')).toBe(true) + expect(Object.hasOwn(result.rows[0], '__proto__')).toBe(true) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.rows[0])).toBe(Object.prototype) + }) + + it.each([ + ['string', ' & value ', ' & value '], + ['object', { label: 'value', empty: { drop: undefined } }, { label: 'value' }], + ['false', false, false], + ['empty object', { drop: undefined }, undefined] + ])('preserves a top-level prototype-named %s prop', (_name, value, expected) => { + const props = { title: 'before', ['__proto__']: value, id: 'after' } + const code = stringifyComponent({ name: 'input', props, children: [] }, lang) + const rendered = renderMarkup(code, lang) as { props: Record } + + expect(rendered.props.title).toBe('before') + expect(rendered.props.id).toBe('after') + expect(Object.hasOwn(rendered.props, '__proto__')).toBe(expected !== undefined) + if (expected !== undefined) expect(rendered.props.__proto__).toEqual(expected) + expect(Object.getPrototypeOf(rendered.props)).toBe(Object.prototype) + }) + + it('preserves entity-looking text inside object props', () => { + const payload = { text: '& { "quoted"', nested: ['<', 'A'] } + const code = stringifyComponent({ name: 'input', props: { payload }, children: [] }, lang) + const rendered = renderMarkup(code, lang) as { props: { payload: typeof payload } } + expect(rendered.props.payload).toEqual(payload) + }) + + it.each([false, true])('renders design text without evaluating it (compact: %s)', (compact) => { + const code = stringifyComponent( + { + name: 'div', + props: {}, + children: [{ name: 'span', props: { title: '{customer}' }, children: [text] }] + }, + { lang, isInline: () => compact } + ) + expect(renderText(code, lang)).toBe(text) + expect(code).toContain('title="{customer}"') + }) + + it('preserves literal text returned as a plugin component tree', () => { + const code = serializeComponent( + instance, + { lang }, + { transformComponent: () => ({ name: 'span', props: {}, children: [text] }) } + ) + expect(renderText(code, lang)).toBe(text) + }) + + it('keeps deliberately authored raw plugin templates unchanged', () => { + const raw = lang === 'vue' ? '{{customer}}' : '{customer}' + expect( + stringifyComponent({ name: RAW_TAG_NAME, props: { content: raw }, children: [] }, lang) + ).toBe(raw) + expect(serializeComponent(instance, { lang }, { transformComponent: () => raw })).toBe(raw) + }) +}) + +it('preserves entity-looking strings in authored Vue event expressions', () => { + const text = '& { "quoted"' + const code = stringifyComponent( + { name: 'button', props: { onClick: `record(${JSON.stringify(text)})` }, children: [] }, + 'vue' + ) + let received: unknown + const render = compile(code) as (context: object, cache: unknown[]) => unknown + const rendered = render({ record: (value: unknown) => (received = value) }, []) as { + props: { onClick: () => void } + } + rendered.props.onClick() + expect(received).toBe(text) +}) diff --git a/packages/extension/tests/utils/component.browser.test.ts b/packages/extension/tests/utils/component.browser.test.ts index 12770022..820da12a 100644 --- a/packages/extension/tests/utils/component.browser.test.ts +++ b/packages/extension/tests/utils/component.browser.test.ts @@ -1,6 +1,203 @@ import { expect, it } from 'vitest' +import * as Vue from 'vue' +import { compileTemplate } from 'vue/compiler-sfc' -import { stringifyComponent } from '@/utils/component' +import { mergeAttributes, stringifyComponent } from '@/utils/component' + +it('leaves a malformed opening tag unchanged without blocking raw component output', async () => { + // Run the scanner in an isolated Worker so a non-advancing parser cannot hang the suite. + const source = '
' + const url = URL.createObjectURL( + new Blob([ + `postMessage((${mergeAttributes.toString()})(${JSON.stringify(source)}, {class: 'one'}))` + ]) + ) + const worker = new Worker(url) + let timer: ReturnType | undefined + try { + const result = await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('Attribute merge did not finish')), 10000) + worker.onmessage = (event) => resolve(event.data) + worker.onerror = (event) => reject(new Error(event.message)) + }) + expect(result).toBe(source) + } finally { + clearTimeout(timer) + worker.terminate() + URL.revokeObjectURL(url) + } +}, 15000) + +function mountVueMarkup(source: string) { + const compiled = compileTemplate({ + source, + filename: 'NativeAttributes.vue', + id: 'native-attributes', + compilerOptions: { mode: 'function' } + }) + expect(compiled.errors).toEqual([]) + const app = Vue.createApp({ render: new Function('Vue', compiled.code)(Vue) }) + const host = document.createElement('div') + document.body.append(host) + app.mount(host) + return { + host, + unmount() { + app.unmount() + host.remove() + } + } +} + +it.each([false, true])('preserves video Boolean and ARIA tokens in Vue (%s)', (value) => { + const mounted = mountVueMarkup( + stringifyComponent( + { + name: 'video', + props: { + playsInline: value, + disablePictureInPicture: value, + disableRemotePlayback: value, + 'aria-hidden': value, + 'aria-expanded': value, + 'aria-checked': value + }, + children: [] + }, + 'vue' + ) + ) + try { + const video = mounted.host.firstElementChild as HTMLVideoElement + expect(video.playsInline).toBe(value) + expect(video.disablePictureInPicture).toBe(value) + expect(video.disableRemotePlayback).toBe(value) + for (const name of ['aria-hidden', 'aria-expanded', 'aria-checked']) { + expect(video.getAttribute(name)).toBe(String(value)) + } + } finally { + mounted.unmount() + } +}) + +it.each([ + ['button', 'popoverTarget', 'panel'], + ['button', 'popoverTargetAction', 'show'], + ['link', 'imageSrcSet', 'image.png 1x'], + ['link', 'imageSizes', '100vw'], + ['template', 'shadowRootMode', 'open'] +] as const)('preserves the native %s attribute %s in Vue', (name, prop, value) => { + const mounted = mountVueMarkup( + stringifyComponent({ name, props: { [prop]: value }, children: [] }, 'vue') + ) + try { + expect(mounted.host.firstElementChild!.getAttribute(prop.toLowerCase())).toBe(value) + } finally { + mounted.unmount() + } +}) + +it.each([false, true])( + 'keeps native HTML properties functional in compiled Vue markup (editable: %s)', + (editable) => { + const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw== 1x' + const source = stringifyComponent( + { + name: 'div', + props: { contentEditable: editable, spellCheck: editable, draggable: editable }, + children: [ + { + name: 'input', + props: { + autoComplete: 'off', + minLength: 4, + maxLength: 12, + readOnly: true, + inputMode: 'email', + enterKeyHint: 'send' + }, + children: [] + }, + { + name: 'table', + props: {}, + children: [ + { + name: 'tbody', + props: {}, + children: [ + { + name: 'tr', + props: {}, + children: [{ name: 'td', props: { colSpan: 2, rowSpan: 3 }, children: [] }] + } + ] + } + ] + }, + { + name: 'img', + props: { srcSet: image, crossOrigin: 'anonymous', referrerPolicy: 'no-referrer' }, + children: [] + } + ] + }, + 'vue' + ) + const mounted = mountVueMarkup(source) + const { host } = mounted + try { + const wrapper = host.firstElementChild as HTMLElement + expect(wrapper.contentEditable).toBe(String(editable)) + expect(wrapper.spellcheck).toBe(editable) + expect(wrapper.draggable).toBe(editable) + const input = host.querySelector('input')! + expect(input.autocomplete).toBe('off') + expect(input.minLength).toBe(4) + expect(input.maxLength).toBe(12) + expect(input.readOnly).toBe(true) + expect(input.inputMode).toBe('email') + expect(input.enterKeyHint).toBe('send') + const cell = host.querySelector('td')! + expect(cell.colSpan).toBe(2) + expect(cell.rowSpan).toBe(3) + const img = host.querySelector('img')! + expect(img.srcset).toBe(image) + expect(img.crossOrigin).toBe('anonymous') + expect(img.referrerPolicy).toBe('no-referrer') + } finally { + mounted.unmount() + } + } +) + +it('keeps native readonly, maxlength and SVG link attributes functional in Vue markup', () => { + const host = document.createElement('div') + document.body.append(host) + try { + host.innerHTML = stringifyComponent( + { + name: 'div', + props: {}, + children: [ + { name: 'input', props: { readOnly: true, maxLength: '12' }, children: [] }, + { + name: 'svg', + props: {}, + children: [{ name: 'use', props: { xlinkHref: '#icon' }, children: [] }] + } + ] + }, + 'vue' + ) + const input = host.querySelector('input')! + expect(input.readOnly).toBe(true) + expect(input.maxLength).toBe(12) + expect(host.querySelector('use')!.href.baseVal).toBe('#icon') + } finally { + host.remove() + } +}) it('keeps native SVG geometry and gradient attributes functional in Vue markup', () => { const host = document.createElement('div') diff --git a/packages/extension/tests/utils/component.test.ts b/packages/extension/tests/utils/component.test.ts index 06b01a53..f16eae0e 100644 --- a/packages/extension/tests/utils/component.test.ts +++ b/packages/extension/tests/utils/component.test.ts @@ -28,6 +28,65 @@ function createNode( } describe('utils/component', () => { + it('normalizes native HTML property names without changing component props or JSX', () => { + const props = { + readOnly: true, + maxLength: 12, + autoComplete: 'off', + contentEditable: true, + spellCheck: false, + ariaLabel: 'Name', + dataTestId: 'input' + } + const input = { name: 'input', props, children: [] } + const vue = stringifyComponent(input, 'vue') + expect(vue).toContain('\n readonly\n') + expect(vue).toContain(':maxlength="12"') + expect(vue).toContain('autocomplete="off"') + expect(vue).toContain(':contenteditable="true"') + expect(vue).toContain(':spellcheck="false"') + expect(vue).toContain('aria-label="Name"') + expect(vue).toContain('data-test-id="input"') + + for (const name of ['InputField', 'custom-input']) { + const custom = stringifyComponent({ name, props, children: [] }, 'vue') + expect(custom).toContain('\n read-only\n') + expect(custom).toContain(':max-length="12"') + expect(custom).toContain('auto-complete="off"') + expect(custom).toContain('\n content-editable\n') + expect(custom).toContain(':spell-check="false"') + } + const jsx = stringifyComponent(input, 'jsx') + expect(jsx).toContain('\n readOnly\n') + expect(jsx).toContain('maxLength={12}') + expect(jsx).toContain('autoComplete="off"') + expect(jsx).toContain('\n contentEditable\n') + expect(jsx).toContain('spellCheck={false}') + }) + + it('normalizes native SVG namespaces while respecting foreignObject and component props', () => { + const markup = stringifyComponent( + { + name: 'svg', + props: {}, + children: [ + { name: 'use', props: { xlinkHref: '#icon' }, children: [] }, + { name: 'Icon', props: { xlinkHref: '#custom' }, children: [] }, + { + name: 'foreignObject', + props: {}, + children: [{ name: 'input', props: { readOnly: false, maxLength: '12' }, children: [] }] + } + ] + }, + 'vue' + ) + expect(markup).toContain('') + expect(markup).toContain('') + expect(markup).toContain(':readonly="false"') + expect(markup).toContain('maxlength="12"') + }) + it.each([false, true])( 'preserves SVG attribute case throughout Vue trees (compact: %s)', (compact) => { @@ -111,6 +170,36 @@ describe('utils/component', () => { expect(getDesignComponent(instance)?.properties).toEqual({ size: 'Large' }) }) + it.each(['TEXT', 'INSTANCE_SWAP'] as const)( + 'preserves a prototype-named %s property as an own data property', + (type) => { + vi.stubGlobal('figma', { + getNodeById: () => ({ type: 'COMPONENT', name: 'Icon' }) + }) + try { + const instance = createNode('INSTANCE', 'instance', { + componentProperties: { '__proto__#1:2': { type, value: 'value' } } + }) + const properties = getDesignComponent(instance)!.properties + expect(Object.keys(properties)).toEqual(['__proto__']) + expect(Object.getPrototypeOf(properties)).toBe(Object.prototype) + expect(properties.__proto__).toEqual( + type === 'TEXT' + ? 'value' + : { + name: 'Icon', + type: 'INSTANCE', + properties: {}, + children: [], + visible: true + } + ) + } finally { + vi.unstubAllGlobals() + } + } + ) + it('extracts design component data with instance swap and vector fills', () => { const getNodeById = vi.fn((id: string) => { if (id === 'swap-target') { @@ -329,6 +418,19 @@ describe('utils/component', () => { ['', { disabled: 'false' }, ''], ['
', { class: 'two three' }, '
'], ['
', { class: 'two' }, '
'], + ['
', { class: 'one' }, '
'], + ['
', { class: 'one' }, '
'], + ['
', { title: 'text' }, '
'], + [ + '
', + { class: 'a&b extra' }, + '
' + ], + [ + "
", + { title: "It's new & safe" }, + "
" + ], ['', { className: 'one two' }, ''] ])('preserves attribute quoting and class merging for %s', (source, attributes, expected) => { expect(mergeAttributes(source, attributes)).toBe(expected) diff --git a/packages/extension/tests/utils/css-canonicalization.test.ts b/packages/extension/tests/utils/css-canonicalization.test.ts new file mode 100644 index 00000000..58fb50fe --- /dev/null +++ b/packages/extension/tests/utils/css-canonicalization.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' + +import { computeDominantStyle, omitCommon } from '@/mcp/tools/code/text/style' +import { canonicalizeValue, pruneInheritedTextStyles, stripDefaultTextStyles } from '@/utils/css' + +describe('CSS text style comparisons', () => { + it.each([ + ['color', 'var(--Accent)', 'var(--accent)'], + ['font-family', '"A B"', '"AB"'], + ['font-family', '"A B"', '"A B"'], + ['font-family', '"Brand Font"', '"brand font"'], + ['font-family', 'var(--Font, "A B")', 'var(--Font, "AB")'] + ])('retains the distinct %s override %s over %s', (key, current, inherited) => { + const style = { [key]: current } + + pruneInheritedTextStyles(style, { [key]: inherited }) + + expect(style).toEqual({ [key]: current }) + expect(omitCommon(style, { [key]: inherited })).toEqual(style) + }) + + it('keeps distinct variable references in dominant style selection', () => { + expect( + computeDominantStyle([ + { style: { color: 'var(--Accent)' }, weight: 1 }, + { style: { color: 'var(--accent)' }, weight: 3 } + ]) + ).toEqual({ color: 'var(--accent)' }) + }) + + it.each([ + ['rgba(0, 0, 0, 0.501)', 'rgba(0, 0, 0, 0.504)'], + ['rgb(0.1, 0, 0)', 'rgb(0.2, 0, 0)'], + ['rgba(255, 255, 255, invalid)', '#fff'], + ['rgb(255, 255, 255, 1, 0)', '#fff'], + ['rgb(, 0, 0)', '#000'], + ['rgb(1., 0, 0)', 'rgb(1, 0, 0)'] + ])('does not discard %s by rounding or accepting it as %s', (current, inherited) => { + const style = { color: current } + pruneInheritedTextStyles(style, { color: inherited }) + expect(style).toEqual({ color: current }) + expect(omitCommon(style, { color: inherited })).toEqual(style) + }) + + it('still removes equivalent literal colors, weights, zero units and defaults', () => { + const style = { + color: 'RGB(170, 187, 204)', + 'font-weight': ' BOLD ', + 'letter-spacing': '0PX' + } + pruneInheritedTextStyles(style, { color: '#aabbcc', 'font-weight': '700' }) + expect(style).toEqual({}) + expect( + stripDefaultTextStyles({ + 'font-weight': 'NORMAL', + 'font-style': 'NORMAL', + 'line-height': ' NORMAL ', + 'text-decoration-style': 'SOLID' + }) + ).toEqual({}) + expect(canonicalizeValue('color', 'RGBA(255, 255, 255, 0.5)')).toBe('#fff/50') + }) +}) diff --git a/packages/extension/tests/utils/css-shorthands.test.ts b/packages/extension/tests/utils/css-shorthands.test.ts new file mode 100644 index 00000000..78b03324 --- /dev/null +++ b/packages/extension/tests/utils/css-shorthands.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' + +import { preprocessStyles, styleToClassNames } from '@/mcp/tools/code/styles/normalize' +import { + parseBorderShorthand, + parseBoxValues, + serializeCSS, + splitByTopLevelComma +} from '@/utils/css' + +const config = { cssUnit: 'px', rootFontSize: 16, scale: 1 } as const + +describe('CSS shorthand output', () => { + it('retains nested calculations as complete spacing values', () => { + const style = preprocessStyles({ + padding: 'calc(100% - 8px) 4px', + margin: 'min(1rem, calc(2px + 3px)) auto', + inset: 'calc(50% - 8px) 0 4px', + gap: 'min(8px, calc(2px + 3px)) calc(1rem + 2px)' + }) + expect(style).toMatchObject({ + 'padding-top': 'calc(100% - 8px)', + 'padding-right': '4px', + 'padding-bottom': 'calc(100% - 8px)', + 'padding-left': '4px', + 'margin-top': 'min(1rem, calc(2px + 3px))', + 'margin-right': 'auto', + top: 'calc(50% - 8px)', + right: '0', + bottom: '4px', + left: '0', + 'row-gap': 'min(8px, calc(2px + 3px))', + 'column-gap': 'calc(1rem + 2px)' + }) + expect(styleToClassNames(style, config)).toEqual( + expect.arrayContaining([ + 'py-[calc(100%_-_8px)]', + 'px-[4px]', + 'gap-y-[min(8px,_calc(2px_+_3px))]', + 'gap-x-[calc(1rem_+_2px)]' + ]) + ) + }) + + it.each(['12px / 24px', '12px 8px/24px 16px'])('preserves elliptical radius %s', (radius) => { + const style = preprocessStyles({ 'border-radius': radius }) + expect(style).toEqual({ 'border-radius': radius }) + expect(styleToClassNames(style, config)).toEqual([`rounded-[${radius.replaceAll(' ', '_')}]`]) + }) + + it.each(['red solid 1px', 'solid 1px red', '1px red solid'])( + 'classifies all components of %s regardless of order', + (border) => { + const style = preprocessStyles({ border }) + for (const side of ['top', 'right', 'bottom', 'left']) { + expect(style).toMatchObject({ + [`border-${side}-width`]: '1px', + [`border-${side}-style`]: 'solid', + [`border-${side}-color`]: 'red' + }) + } + expect(style).not.toHaveProperty('border') + expect(styleToClassNames(style, config)).toEqual(['border-[1px]', 'border-[red]']) + } + ) + + it('keeps unknown arity shorthands through inferred layout', () => { + const node = { + type: 'FRAME', + width: 100, + height: 100, + layoutMode: 'HORIZONTAL', + paddingTop: 1, + paddingRight: 2, + paddingBottom: 3, + paddingLeft: 4, + itemSpacing: 8 + } as unknown as SceneNode + const style = preprocessStyles( + { + padding: 'var(--spacing)', + gap: 'var(--row) calc(1rem + 2px)', + margin: 'env(safe-area-inset-top) auto', + border: 'var(--border)' + }, + node + ) + expect(style).toEqual({ + padding: 'var(--spacing)', + gap: 'var(--row) calc(1rem + 2px)', + margin: 'env(safe-area-inset-top) auto', + border: 'var(--border)', + display: 'flex', + 'flex-direction': 'row' + }) + expect(styleToClassNames(style, config)).toEqual( + expect.arrayContaining([ + 'p-(--spacing)', + '[gap:var(--row)_calc(1rem_+_2px)]', + '[border:var(--border)]' + ]) + ) + }) + + it.each(['solid', 'red', '1px', '1px solid', 'red solid', 'inherit'])( + 'retains %s so omitted border components keep their native defaults', + (border) => { + const style = preprocessStyles({ border }) + expect(style).toEqual({ border }) + expect(styleToClassNames(style, config)).toEqual([`[border:${border.replaceAll(' ', '_')}]`]) + } + ) + + it('retains uniform calculated widths in the gradient-border workaround', () => { + const style = { + 'border-image': 'linear-gradient(red, blue) 1', + 'border-width': 'calc(1px + 1px)' + } + expect(parseBoxValues(style['border-width'])).toEqual(Array(4).fill('calc(1px + 1px)')) + expect(styleToClassNames(style, config)).toContain('before:p-[calc(1px_+_1px)]') + }) + + it.each([ + { border: '1px solid var(--ring-color)' }, + Object.fromEntries( + ['top', 'right', 'bottom', 'left'].map((side) => [ + `border-${side}`, + `1px solid var(--${side}-color)` + ]) + ) + ])('retains concrete widths beside variable colors in gradient borders', (borders) => { + const style = preprocessStyles({ + ...borders, + 'border-image': 'linear-gradient(red, blue) 1', + 'border-radius': '8px' + }) + expect(style).toMatchObject(borders) + expect(styleToClassNames(style, config)).toContain('before:p-[1px]') + const css = serializeCSS(style, { useRem: false, rootFontSize: 16, scale: 1 }) + expect(css).toContain('&::before') + expect(css).toContain('padding: 1px;') + }) + + it.each([ + ['1px solid var(--color)', { width: '1px', style: 'solid' }], + ['var(--width) solid red', { style: 'solid', color: 'red' }], + ['1px var(--style) red', { width: '1px', color: 'red' }], + ['var(--border)', {}] + ])( + 'extracts only concrete components of %s without expanding the shorthand', + (border, parsed) => { + expect(parseBorderShorthand(border)).toEqual(parsed) + expect(preprocessStyles({ border })).toEqual({ border }) + } + ) + + it('splits only the top-level slash in grid placement', () => { + const style = preprocessStyles({ + 'grid-row': 'calc(6 / 2) / span 2', + 'grid-column': 'calc(8 / 2)' + }) + expect(style).toEqual({ + 'grid-row-start': 'calc(6 / 2)', + 'grid-row-span': '2', + 'grid-column': 'calc(8 / 2)' + }) + expect(styleToClassNames(style, config)).toEqual( + expect.arrayContaining(['row-start-[calc(6_/_2)]', 'row-span-2', 'col-[calc(8_/_2)]']) + ) + }) + + it('keeps quoted, commented and escaped commas within their CSS token', () => { + expect(splitByTopLevelComma(String.raw`url("a,b"), rgb(1, 2, 3), foo\,bar /* , */`)).toEqual([ + 'url("a,b")', + 'rgb(1, 2, 3)', + String.raw`foo\,bar /* , */` + ]) + }) +}) diff --git a/packages/extension/tests/utils/css-units.test.ts b/packages/extension/tests/utils/css-units.test.ts new file mode 100644 index 00000000..d833dda5 --- /dev/null +++ b/packages/extension/tests/utils/css-units.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' + +import { normalizeCssValue, serializeCSS } from '@/utils/css' + +const remConfig = { cssUnit: 'rem' as const, rootFontSize: 16, scale: 1 } + +describe('CSS dimension conversion', () => { + it.each([ + ['var(--spacing-16px, 16px)', 'var(--spacing-16px, 1rem)'], + ['calc(var(--spacing-16px) + 32px)', 'calc(var(--spacing-16px) + 2rem)'], + ['size-16px --16px café16px \\31 6px', 'size-16px --16px café16px \\31 6px'], + ['16px-wide 16pxfoo 16p\\78', '16px-wide 16pxfoo 16p\\78'], + ['"16px" \'32px\' "escaped \\"16px\\"" 16px', '"16px" \'32px\' "escaped \\"16px\\"" 1rem'], + [ + 'url(icon-16px.svg) URL("icon)32px.svg") 16px', + 'url(icon-16px.svg) URL("icon)32px.svg") 1rem' + ], + [ + 'url(data:image/svg+xml,16px) url(icon\\)16px.svg) 16px', + 'url(data:image/svg+xml,16px) url(icon\\)16px.svg) 1rem' + ], + ['u\\72 l(16px.png) 16px', 'u\\72 l(16px.png) 1rem'], + ['#16px @size-16px /* 16px */ 16px', '#16px @size-16px /* 16px */ 1rem'], + ['-16px +32PX .8px -.8px 1.6e1px 0px -0px', '-1rem 2rem 0.05rem -0.05rem 1rem 0 0'], + ['16rem 10% 2 1e999px', '16rem 10% 2 1e999px'], + ['16px /* unfinished 16px', '1rem /* unfinished 16px'], + ['16px "unfinished 16px', '1rem "unfinished 16px'], + ['16px url(unfinished-16px', '1rem url(unfinished-16px'] + ])('converts only px dimensions in %s', (input, expected) => { + expect(normalizeCssValue(input, remConfig)).toBe(expected) + }) + + it('preserves token identities when scaling and serializing UI styles', () => { + const value = 'calc(var(--spacing-16px, 16px) + 8px)' + expect(normalizeCssValue(value, { ...remConfig, scale: 2 })).toBe( + 'calc(var(--spacing-16px, 2rem) + 1rem)' + ) + expect( + serializeCSS( + { width: value, 'background-image': 'url(icon-16px.svg)' }, + { useRem: true, rootFontSize: 16, scale: 2, variableDisplay: 'both' } + ) + ).toBe('width: calc(var(--spacing-16px, 2rem) + 1rem);\nbackground-image: url(icon-16px.svg);') + }) + + it('calls plugin dimension transforms only for real length values', () => { + const transformPx = vi.fn(({ value }: { value: number }) => `${value / 16}em`) + const code = serializeCSS( + { width: 'calc(var(--spacing-16px) + 32px)', content: '"16px"' }, + { useRem: false, rootFontSize: 16, scale: 1 }, + { transformPx } + ) + expect(code).toBe('width: calc(var(--spacing-16px) + 2em);\ncontent: "16px";') + expect(transformPx).toHaveBeenCalledOnce() + expect(transformPx).toHaveBeenCalledWith(expect.objectContaining({ value: 32 })) + }) +}) diff --git a/packages/extension/tests/utils/css.test.ts b/packages/extension/tests/utils/css.test.ts index 8c1c5488..cdd19386 100644 --- a/packages/extension/tests/utils/css.test.ts +++ b/packages/extension/tests/utils/css.test.ts @@ -28,6 +28,21 @@ import { } from '@/utils/css' describe('utils/css split and var helpers', () => { + it('replaces actual var functions without changing CSS literals or longer names', () => { + const literal = String.raw`"var(--quoted, red)" url("var(--url, blue)") /* var(--comment, red) */ myvar(--name, red)` + const transform = vi.fn(({ fallback }: { fallback?: string }) => fallback ?? 'none') + expect( + replaceVarFunctions(`${literal} var(--label, "a)b,c") var(--other, red)`, transform) + ).toBe(`${literal} "a)b,c" red`) + expect(transform).toHaveBeenCalledTimes(2) + expect(transform).toHaveBeenCalledWith({ + full: 'var(--label, "a)b,c")', + name: '--label', + fallback: '"a)b,c"' + }) + expect(extractVarNames(`${literal} var(--real)`)).toEqual(new Set(['--real'])) + }) + it('splits by top-level comma with nested expressions and quotes', () => { const input = `a, rgb(1, 2, 3), "x, y", 'z,\\'w'` expect(splitByTopLevelComma(input)).toEqual(['a', 'rgb(1, 2, 3)', '"x, y"', "'z,\\'w'"]) @@ -68,6 +83,8 @@ describe('utils/css split and var helpers', () => { describe('utils/css parsing and normalization', () => { it('formats hex alpha values', () => { + expect(formatHexAlpha({ r: 0, g: 0, b: 1 }, 0.99)).toBe('#0000FFFC') + expect(formatHexAlpha({ r: 0, g: 0, b: 1 }, 0.995)).toBe('#0000FFFE') expect(formatHexAlpha({ r: 1, g: 0, b: 0 }, 1)).toBe('#F00') expect(formatHexAlpha({ r: 1, g: 0, b: 0 }, 0.5)).toBe('#FF000080') expect(formatHexAlpha({ r: 0.1, g: 0.2, b: 0.3 }, 1)).toBe('#1A334D') @@ -103,6 +120,29 @@ describe('utils/css parsing and normalization', () => { }) }) + it.each([ + ['-2.08px -14.75px', '104.167% 145.833%', 'no-repeat'], + ['0% 0%', '20px 20px', 'repeat'], + ['left 10px top 20px', '16px auto', 'repeat-x'] + ])('preserves image position %s and size %s', (position, size, repeat) => { + expect(parseBackgroundShorthand(`url("crop.png") ${position} / ${size} ${repeat}`)).toEqual({ + image: 'url("crop.png")', + position, + size, + repeat + }) + }) + + it('does not read geometry or repeat keywords from the image URL', () => { + expect(parseBackgroundShorthand('url("https://assets.test/20px repeat/40px.png")')).toEqual({ + image: 'url("https://assets.test/20px repeat/40px.png")' + }) + expect(parseBackgroundShorthand('url("tile.png") / 20px 30px')).toEqual({ + image: 'url("tile.png")', + size: '20px 30px' + }) + }) + it('normalizes values with scale/rem and keep-px exceptions', () => { const remConfig = { cssUnit: 'rem' as const, rootFontSize: 20, scale: 2 } @@ -314,11 +354,7 @@ describe('utils/css parsing and normalization', () => { expect(multiBg.background).toContain('linear-gradient') const borderFallback = expandShorthands({ border: '1px solid' }) - expect(borderFallback).toMatchObject({ - 'border-top-width': '1px', - 'border-top-style': 'solid' - }) - expect(borderFallback).not.toHaveProperty('border-top-color') + expect(borderFallback).toEqual({ border: '1px solid' }) }) it('covers shorthand fallback paths for background, grid and border parsing', () => { @@ -367,6 +403,41 @@ describe('utils/css serializeCSS regression paths', () => { scale: 1 } + it.each([undefined, 'reference', 'resolved', 'both'] as const)( + 'keeps literal text and asset URLs unchanged in %s output', + (variableDisplay) => { + const content = '"var(--label, #abcd) @name $value /* literal */ 0px "' + const url = 'url("https://assets.test/$name/@name/#abcd")' + expect( + serializeCSS({ content, 'background-image': url }, { ...baseOptions, variableDisplay }) + ).toBe(`content: ${content};\nbackground-image: ${url};`) + expect(normalizeStyleValue(content)).toBe(content) + expect(simplifyColorMixToRgba('"color-mix(in srgb, #fff 50%, transparent)"')).toBe( + '"color-mix(in srgb, #fff 50%, transparent)"' + ) + } + ) + + it.each([false, true])( + 'preserves custom property keys in evaluated JS styles (gradient: %s)', + (gradient) => { + const style = { + '--my-token': '12px', + width: 'var(--my-token)', + ...(gradient + ? { + 'border-image': 'linear-gradient(red, blue) 1', + 'border-width': '1px' + } + : {}) + } + const code = serializeCSS(style, { ...baseOptions, toJS: true }) + const output = new Function(`return (${code})`)() + expect(output['--my-token']).toBe('12px') + expect(output.width).toBe('var(--my-token)') + } + ) + it('serializes normal styles without gradient border', () => { const code = serializeCSS( { diff --git a/packages/extension/tests/utils/figma-style/gradient-colors.test.ts b/packages/extension/tests/utils/figma-style/gradient-colors.test.ts new file mode 100644 index 00000000..8ee48d2b --- /dev/null +++ b/packages/extension/tests/utils/figma-style/gradient-colors.test.ts @@ -0,0 +1,216 @@ +import { expect, it } from 'vitest' + +import { + matchBackgroundPaintLayers, + preserveGradientGeometry, + resolveBackgroundFillFromPaints +} from '@/utils/figma-style/gradient' + +it.each([ + [ + 'linear-gradient(red 20%, green, blue 40%)', + 'linear-gradient(90deg, #F008 0%, #0F08 50%, #00F8 100%)', + 'linear-gradient(#F008 20%, #0F08, #00F8 40%)' + ], + [ + 'radial-gradient(red, blue)', + 'radial-gradient(20% 30% at 40% 50%, #F008 0%, #00F8 100%)', + 'radial-gradient(#F008, #00F8)' + ], + [ + 'linear-gradient(to bottom right, red 25%, blue 75%)', + 'linear-gradient(90deg, #F008 0%, #00F8 100%)', + 'linear-gradient(to bottom right, #F008 25%, #00F8 75%)' + ], + [ + 'radial-gradient(20% 30% at 40% 50%, var(--a, rgb(1, 2, 3)) 25% 30%, #00F calc(75% - 1px)) center / cover', + 'radial-gradient(#F008 0%, color-mix(in srgb, var(--b) 50%, transparent) 100%)', + 'radial-gradient(20% 30% at 40% 50%, #F008 25% 30%, color-mix(in srgb, var(--b) 50%, transparent) calc(75% - 1px)) center / cover' + ], + [ + 'conic-gradient(from 30deg at 20% 40%, red 90deg, blue 270deg)', + 'conic-gradient(#F008 0%, #00F8 100%)', + 'conic-gradient(from 30deg at 20% 40%, #F008 90deg, #00F8 270deg)' + ], + [ + ' LINEAR-GRADIENT(90deg, red 20%, blue 80%) ', + 'linear-gradient(#F008 0%, #00F8 100%)', + 'linear-gradient(90deg, #F008 20%, #00F8 80%)' + ] +])('replaces colors while retaining native geometry in %s', (source, resolved, expected) => { + expect(preserveGradientGeometry(source, resolved)).toBe(expected) +}) + +it.each([ + ['linear-gradient(red 25%, blue 75%)', 'radial-gradient(#F00 0%, #00F 100%)'], + ['linear-gradient(red 25%, 40%, blue 75%)', 'linear-gradient(#F00 0%, #00F 100%)'], + ['linear-gradient(red 25%, blue 75%, green 100%)', 'linear-gradient(#F00 0%, #00F 100%)'], + ['linear-gradient(var(--a) 25%, blue 75%', 'linear-gradient(#F00 0%, #00F 100%)'], + ['url(a)', 'linear-gradient(#F00 0%, #00F 100%)'] +])('rejects ambiguous or invalid pairing for %s', (source, resolved) => { + expect(preserveGradientGeometry(source, resolved)).toBeNull() +}) + +it.each([ + ['rgba(128, 128, 255, 0.38) 0deg, ', ', rgba(128, 128, 255, 0.38) 360deg', '25%', '75%'], + ['', ', rgba(128, 128, 255, 0.38) 360deg', '0%', '75%'], + ['rgba(128, 128, 255, 0.38) 0deg, ', '', '25%', '100%'] +])('retains synthesized angular endpoints: %s / %s', (prefix, suffix, first, last) => { + const firstAngle = `${Number.parseFloat(first) * 3.6}deg` + const lastAngle = `${Number.parseFloat(last) * 3.6}deg` + expect( + preserveGradientGeometry( + `conic-gradient(from 90deg at 50% 50%, ${prefix}var(--Blue) ${firstAngle}, white ${lastAngle}${suffix})`, + `conic-gradient(color-mix(in srgb, var(--Blue) 50%, transparent) ${first}, #FFFFFF80 ${last})` + ) + ).toBe( + `conic-gradient(from 90deg at 50% 50%, ${prefix}color-mix(in srgb, var(--Blue) 50%, transparent) ${firstAngle}, #FFFFFF80 ${lastAngle}${suffix})` + ) +}) + +it.each([ + [ + 'conic-gradient(black 0deg, red 90deg, blue 270deg, white 360deg)', + 'conic-gradient(red 0%, blue 100%)' + ], + ['conic-gradient(red 90deg, green 180deg, blue 270deg)', 'conic-gradient(red 25%, blue 75%)'], + ['conic-gradient(black 0deg, red 90deg, blue 270deg)', 'conic-gradient(red, blue)'], + [ + 'conic-gradient(black 0deg, red 180deg, blue 270deg, white 360deg)', + 'conic-gradient(red 25%, blue 75%)' + ], + [ + 'conic-gradient(black 0deg, red 0turn, blue 270deg, white 360deg)', + 'conic-gradient(red 25%, blue 75%)' + ] +])('leaves unproven angular stop layouts unchanged: %s', (source, resolved) => { + expect(preserveGradientGeometry(source, resolved)).toBeNull() +}) + +const quadrants = ['bottom right', 'bottom left', 'top left', 'top right'].map( + (corner) => + `linear-gradient(to ${corner}, var(--Blue) 13%, white 38%) ${corner} / 50% 50% no-repeat` +) +const diamond: GradientPaint = { + type: 'GRADIENT_DIAMOND', + gradientTransform: [ + [1, 0, 0], + [0, 1, 0] + ], + gradientStops: [{ position: 0, color: { r: 0, g: 0, b: 1, a: 1 } }] +} +const image = { type: 'IMAGE' } as ImagePaint + +it('matches all four diamond quadrants without consuming adjacent images or gradients', () => { + const linear = { ...diamond, type: 'GRADIENT_LINEAR' } as GradientPaint + const other = 'linear-gradient(90deg, red 0%, blue 100%)' + expect( + matchBackgroundPaintLayers(`${quadrants.join(', ')}, url(image), ${other}`, [ + linear, + image, + diamond + ]) + ).toEqual([ + { paint: diamond, index: 2, sources: quadrants }, + { paint: image, index: 1, sources: ['url(image)'] }, + { paint: linear, index: 0, sources: [other] } + ]) + expect( + preserveGradientGeometry( + quadrants[0]!, + 'radial-gradient(#0000FF40 25%, #FFFFFF80 75%)', + 'GRADIENT_DIAMOND' + ) + ).toBe( + 'linear-gradient(to bottom right, #0000FF40 13%, #FFFFFF80 38%) bottom right / 50% 50% no-repeat' + ) +}) + +it.each([ + quadrants.slice(0, 3), + [quadrants[0]!, quadrants[0]!, quadrants[2]!, quadrants[3]!], + quadrants.map((layer) => layer.replace('50% 50%', '100% 100%')), + quadrants.map((layer) => layer.replace('to bottom right', 'to top left')), + quadrants.map((layer) => layer.replace(/ (?:bottom|top) (?:left|right) \/.*$/, '')), + [...quadrants, 'linear-gradient(red, blue)'] +])('rejects ambiguous or partial diamond stacks %j', (...layers) => { + const background = layers.join(', ') + expect(matchBackgroundPaintLayers(background, [diamond])).toBeNull() + expect(resolveBackgroundFillFromPaints([diamond], undefined, undefined, { background })).toEqual({ + kind: 'native' + }) +}) + +it('matches authored angular positions with native degree rounding', () => { + expect( + preserveGradientGeometry( + 'conic-gradient(black 0deg, red 44deg, blue 270deg, black 360deg)', + 'conic-gradient(#F008 12.345%, #00F8 75%)' + ) + ).toBe('conic-gradient(black 0deg, #F008 44deg, #00F8 270deg, black 360deg)') +}) + +const transparentCss = 'linear-gradient(90deg, rgba(255, 255, 255, 0.00), #FFF0)' +const radialCss = 'radial-gradient(50% 50% at 50% 50%, red 25%, blue 75%)' +const linearCss = 'linear-gradient(90deg, green 0%, yellow 100%)' +const radial = { ...diamond, type: 'GRADIENT_RADIAL', opacity: 0.5 } as GradientPaint +const linear = { ...diamond, type: 'GRADIENT_LINEAR', opacity: 0.25 } as GradientPaint +const transparent = { ...linear, opacity: 0 } +const transparentSolid: SolidPaint = { + type: 'SOLID', + opacity: 0, + color: { r: 1, g: 1, b: 1 } +} + +it.each([ + { paints: [radial, transparent], css: [transparentCss, radialCss], indices: [1, 0] }, + { paints: [radial, transparentSolid], css: [transparentCss, radialCss], indices: [1, 0] }, + { paints: [transparent, radial], css: [radialCss, transparentCss], indices: [1, 0] }, + { + paints: [radial, transparent, linear], + css: [linearCss, transparentCss, radialCss], + indices: [2, 1, 0] + }, + { paints: [radial, transparent, linear], css: [linearCss, radialCss], indices: [2, 0] }, + { paints: [transparent, radial, transparent], css: [transparentCss, radialCss], indices: [2, 1] }, + { paints: [radial, { ...transparent, visible: false }], css: [radialCss], indices: [0] } +])( + 'keeps emitted and omitted transparent paint ownership: $indices', + ({ paints, css, indices }) => { + const result = matchBackgroundPaintLayers(css.join(', '), paints) + expect(result?.map(({ index }) => index)).toEqual(indices) + expect(result?.flatMap(({ sources }) => sources)).toEqual(css) + } +) + +it.each(['transparent', '#FFF0', '#FFFFFF00', 'rgba(255, 255, 255, 0.00)'])( + 'preserves a transparent bottom color %s', + (color) => { + const result = matchBackgroundPaintLayers(`${radialCss}, ${color}`, [transparentSolid, radial]) + expect(result?.map(({ index }) => index)).toEqual([1, 0]) + expect(result?.flatMap(({ sources }) => sources)).toEqual([radialCss, color]) + } +) + +it.each([ + 'linear-gradient(white, white)', + 'linear-gradient(rgba(255, 255, 255, 0.0001), #FFF0)', + 'linear-gradient(var(--Color, transparent), #FFF0)', + 'url(image)', + 'rgba(255, 255, 255, invalid)' +])('does not consume unproven transparent output %s', (extra) => { + const background = `${radialCss}, ${extra}` + expect(matchBackgroundPaintLayers(background, [transparent, radial])).toBeNull() + expect( + resolveBackgroundFillFromPaints([transparent, radial], undefined, undefined, { background }) + ).toEqual({ kind: 'native' }) +}) + +it('rejects a shifted stack rather than assigning a visible paint to transparent CSS', () => { + // The first invisible paint is omitted, while a later one emits transparent CSS. + // A transparent source for the visible radial paint makes that ownership uncertain. + const background = `${transparentCss}, ${transparentCss}, ${linearCss}` + expect( + matchBackgroundPaintLayers(background, [linear, transparent, radial, transparent]) + ).toBeNull() +}) diff --git a/packages/extension/tests/utils/figma-style/gradient.test.ts b/packages/extension/tests/utils/figma-style/gradient.test.ts index a759887b..3ea85503 100644 --- a/packages/extension/tests/utils/figma-style/gradient.test.ts +++ b/packages/extension/tests/utils/figma-style/gradient.test.ts @@ -239,7 +239,20 @@ describe('figma/gradient resolveGradientFromPaints', () => { ) }) - it('returns direct CSS var fallback for near-opaque variable-bound stop color', () => { + it.each([1, 0.5])('applies gradient opacity %s once to a translucent bound stop', (opacity) => { + installFigmaMocks({ variables: { accent: { name: 'Half Red' } as Variable } }) + const paint = createGradientPaint('GRADIENT_RADIAL', { + opacity, + stops: [createStop({ r: 1, g: 0, b: 0, a: 0.5 }, 0, 'accent')] + }) + const color = 'var(--Half-Red, #FF000080)' + + expect(resolveGradientFromPaints([paint])).toBe( + `radial-gradient(100% 100% at 0% 0%, ${opacity === 1 ? color : `color-mix(in srgb, ${color} 50%, transparent)`} 0%)` + ) + }) + + it('returns direct CSS var fallback for an opaque variable-bound stop color', () => { installFigmaMocks({ variables: { accent: { name: 'Primary Color' } as Variable @@ -255,6 +268,24 @@ describe('figma/gradient resolveGradientFromPaints', () => { ) }) + it.each([ + [0.99, '99', '#0000FFFC'], + [0.995, '99.5', '#0000FFFE'] + ] as const)( + 'retains near-opaque paint opacity %s on variable and literal gradient stops', + (opacity, percentage, literal) => { + installFigmaMocks({ variables: { accent: { name: 'Primary Color' } as Variable } }) + const paint = createGradientPaint('GRADIENT_LINEAR', { + opacity, + stops: [createStop({ r: 1, g: 0, b: 0 }, 0, 'accent'), createStop({ r: 0, g: 0, b: 1 }, 1)] + }) + + expect(resolveGradientFromPaints([paint])).toBe( + `linear-gradient(90deg, color-mix(in srgb, var(--Primary-Color, #F00) ${percentage}%, transparent) 0%, ${literal} 100%)` + ) + } + ) + it('falls back to literal stop color when variable lookup throws', () => { installFigmaMocks({ variableErrors: ['accent'] @@ -528,13 +559,13 @@ describe('figma/gradient resolveBackgroundFillFromPaints', () => { expect( resolveBackgroundFillFromPaints([back, front], undefined, undefined, { background: - 'radial-gradient(20% 30% at 10% 15%, red, red), radial-gradient(60% 70% at 80% 90%, blue, blue)' + 'radial-gradient(20% 30% at 10% 15%, red 0%, red 100%), radial-gradient(60% 70% at 80% 90%, blue 0%, blue 100%)' }) ).toEqual({ kind: 'layers', value: [ - 'radial-gradient(20% 30% at 10% 15%, #F00 0%)', - 'radial-gradient(60% 70% at 80% 90%, #00F 0%)' + 'radial-gradient(20% 30% at 10% 15%, #F00 0%, #F00 100%)', + 'radial-gradient(60% 70% at 80% 90%, #00F 0%, #00F 100%)' ] }) }) diff --git a/packages/extension/tests/utils/figma-style/radial.test.ts b/packages/extension/tests/utils/figma-style/radial.test.ts index 8a480161..370e5ff3 100644 --- a/packages/extension/tests/utils/figma-style/radial.test.ts +++ b/packages/extension/tests/utils/figma-style/radial.test.ts @@ -144,7 +144,7 @@ describe('radial gradient geometry', () => { undefined, { background: - 'radial-gradient(20% 30% at 40% 50%, red, blue), radial-gradient(60% 70% at 80% 90%, red, blue)' + 'radial-gradient(20% 30% at 40% 50%, red 10.1%, blue 100%), radial-gradient(60% 70% at 80% 90%, red 10.1%, blue 100%)' } ) expect(result).toEqual({ @@ -156,14 +156,34 @@ describe('radial gradient geometry', () => { }) }) - it.each([ - 'var(--background)', - 'radial-gradient(red, blue)', - 'radial-gradient(10% 20%, red, blue), linear-gradient(red, blue)' - ])('does not confuse missing or unmatched geometry with native evidence: %s', (background) => { + it('retains native CSS when color stops cannot be paired safely', () => { + const background = 'radial-gradient(20% 30% at 40% 50%, red 20%, 30%, blue 40%)' expect(resolveBackgroundFillFromPaints([paint], undefined, undefined, { background })).toEqual({ kind: 'layers', - value: [expected] + value: [background] }) }) + + it.each([ + 'radial-gradient(10% 20%, red, blue)', + 'conic-gradient(from 90deg at 30% 40%, black 0deg, red 90deg, blue 270deg, black 360deg)' + ])('retains native layers when their paints cannot be paired: %s', async (gradient) => { + const layers = [gradient, 'linear-gradient(red, blue)'] + expect( + resolveBackgroundFillFromPaints([paint], undefined, undefined, { + background: layers.join(', ') + }) + ).toEqual({ kind: 'native' }) + expect( + await resolveStylesFromNodeData({ background: layers.join(', ') }, { fills: [paint] }) + ).toEqual({ background: layers.join(', ') }) + }) + + it('reconstructs paint geometry when the CSS only supplies a placeholder', () => { + expect( + resolveBackgroundFillFromPaints([paint], undefined, undefined, { + background: 'var(--background)' + }) + ).toEqual({ kind: 'layers', value: [expected] }) + }) }) diff --git a/packages/extension/tests/utils/figma-style/style-resolver.test.ts b/packages/extension/tests/utils/figma-style/style-resolver.test.ts index 235cc349..e64f2010 100644 --- a/packages/extension/tests/utils/figma-style/style-resolver.test.ts +++ b/packages/extension/tests/utils/figma-style/style-resolver.test.ts @@ -55,6 +55,77 @@ afterEach(() => { }) describe('figma/style-resolver paint precedence', () => { + it('preserves separate native background channels when paint layers cannot be matched', async () => { + installFigmaMocks() + const style = { + background: 'linear-gradient(37deg, #F008 25%, #00F8 75%)', + 'background-color': '#0F0' + } + expect( + await resolveStylesFromNodeData(style, { + fills: [solidPaint({ r: 0, g: 1, b: 0 }), gradientPaint()] + }) + ).toEqual(style) + }) + + it.each<{ style: Record; expected: Record }>([ + { style: {}, expected: {} }, + { style: { 'background-color': '' }, expected: { 'background-color': '' } }, + { + style: { background: '', 'background-color': 'var(--fill)' }, + expected: { background: '', 'background-color': '#F00' } + } + ])('updates only existing nonempty background channels: $style', async ({ style, expected }) => { + installFigmaMocks() + expect( + await resolveStylesFromNodeData(style, { fills: [solidPaint({ r: 1, g: 0, b: 0 })] }) + ).toEqual(expected) + }) + + it.each(['node', 'style'])( + 'uses indexed %s base bindings below images without changing foreground scalar bindings', + async (source) => { + const paints = [ + { ...solidPaint({ r: 0, g: 1, b: 0 }), visible: false }, + solidPaint({ r: 1, g: 0, b: 0 }), + { type: 'IMAGE', imageHash: 'image', scaleMode: 'FILL' } as ImagePaint, + solidPaint({ r: 0, g: 0, b: 1 }) + ] + const bindings = [{ id: 'hidden' }, { id: 'back' }, undefined, { id: 'front' }] + installFigmaMocks({ + variables: { front: { name: 'Front' } as Variable, back: { name: 'Back' } as Variable }, + styles: { paint: paintStyle(paints, { paints: bindings }) } + }) + const result = await resolveStylesFromNodeData( + { background: 'url("image.png") lightgray 50% / cover', color: '#00F', fill: '#00F' }, + source === 'style' + ? { fillStyleId: 'paint', fills: [solidPaint({ r: 0, g: 1, b: 0 })] } + : { fills: paints, fillVariableBindings: bindings } + ) + expect(result).toEqual({ + background: 'url("image.png") 50% / cover', + 'background-color': 'var(--Back, #F00)', + color: 'var(--Front, #00F)', + fill: 'var(--Front, #00F)' + }) + } + ) + + it.each([ + { type: 'IMAGE', imageHash: 'image', scaleMode: 'FILL' }, + { ...solidPaint({ r: 0, g: 1, b: 0 }), opacity: 0.5 }, + { ...solidPaint({ r: 0, g: 1, b: 0 }), blendMode: 'MULTIPLY' }, + gradientPaint() + ])('does not put a foreground solid below an unrepresentable base: %j', async (base) => { + installFigmaMocks() + expect( + await resolveStylesFromNodeData( + { background: 'url("image.png") lightgray', fill: '#F00' }, + { fills: [base as Paint, solidPaint({ r: 1, g: 0, b: 0 })] } + ) + ).toEqual({ background: 'url("image.png")', fill: '#F00' }) + }) + it.each(['node', 'style'])( 'uses frontmost %s paint bindings for scalar color channels', async (source) => { @@ -213,7 +284,10 @@ describe('figma/style-resolver resolveStylesFromNode', () => { it('strips lightgray fallback in background image and keeps resolved background-color', async () => { installFigmaMocks() const node = { - fills: [solidPaint({ r: 0, g: 1, b: 0 })] + fills: [ + solidPaint({ r: 0, g: 1, b: 0 }), + { type: 'IMAGE', imageHash: 'image', scaleMode: 'FILL' } + ] } as unknown as SceneNode const result = await resolveStylesFromNode( @@ -691,10 +765,14 @@ describe('figma/style-resolver resolveStylesFromNode', () => { }) }) - it('keeps image shorthand separate from the frontmost color after removing lightgray fallback', async () => { + it('keeps image shorthand separate from the base color after removing lightgray fallback', async () => { installFigmaMocks() const node = { - fills: [solidPaint({ r: 1, g: 0, b: 0 }), solidPaint({ r: 0, g: 1, b: 0 })] + fills: [ + solidPaint({ r: 1, g: 0, b: 0 }), + { type: 'IMAGE', imageHash: 'image', scaleMode: 'FILL' }, + solidPaint({ r: 0, g: 1, b: 0 }) + ] } as unknown as SceneNode const result = await resolveStylesFromNode( @@ -706,7 +784,7 @@ describe('figma/style-resolver resolveStylesFromNode', () => { expect(result).toEqual({ background: 'url("asset.png")', - 'background-color': '#0F0' + 'background-color': '#F00' }) }) diff --git a/packages/extension/tests/utils/object.test.ts b/packages/extension/tests/utils/object.test.ts index ce1df8a0..9f38c6ff 100644 --- a/packages/extension/tests/utils/object.test.ts +++ b/packages/extension/tests/utils/object.test.ts @@ -38,4 +38,25 @@ describe('utils/object prune', () => { it('returns undefined when the top-level object becomes empty', () => { expect(prune({ only: undefined, nested: { also: undefined } })).toBeUndefined() }) + + it('preserves own dictionary keys without changing result prototypes', () => { + const source = + '{"__proto__":{"label":"Prototype"},"constructor":{"label":"Constructor"},"nested":{"__proto__":"value"}}' + const input = JSON.parse(source) as { + __proto__: { label: string } + constructor: { label: string } + nested: { __proto__: string } + } + const result = prune(input) + + expect(Object.keys(result)).toEqual(['__proto__', 'constructor', 'nested']) + expect(Object.hasOwn(result, '__proto__')).toBe(true) + expect(result.__proto__).toEqual({ label: 'Prototype' }) + expect(Object.getPrototypeOf(result)).toBe(Object.prototype) + expect(Object.getPrototypeOf(result.nested)).toBe(Object.prototype) + expect(Object.hasOwn(result.nested, '__proto__')).toBe(true) + expect(result.nested.__proto__).toBe('value') + expect({}).not.toHaveProperty('label') + expect(input).toEqual(JSON.parse(source)) + }) }) diff --git a/packages/extension/tests/utils/string.test.ts b/packages/extension/tests/utils/string.test.ts index 9bc398fa..7fa9382c 100644 --- a/packages/extension/tests/utils/string.test.ts +++ b/packages/extension/tests/utils/string.test.ts @@ -7,7 +7,6 @@ import { escapeHTML, indentAll, kebabToCamel, - looseEscapeHTML, snakeToKebab, stringify, toPascalCase @@ -23,7 +22,6 @@ describe('utils/string', () => { it('escapes html variants', () => { expect(escapeHTML('&')).toBe('<a href="x">&</a>') - expect(looseEscapeHTML('say "hi"')).toBe('say "hi"') }) it('escapes JavaScript single-quoted string content', () => { @@ -44,6 +42,76 @@ describe('utils/string', () => { expect(stringify(['a', 'b'])).toContain("'a'") }) + it('round-trips own dictionary keys without changing object prototypes', () => { + const input = JSON.parse( + '{"__proto__":{"label":"Root"},"constructor":"constructor","prototype":"prototype","rows":[{"__proto__":"Nested","quoted-key":"Text"}],"text":"__proto__: value"}' + ) + const output = new Function(`return (${stringify(input)})`)() + + expect(output).toEqual(input) + expect(Object.keys(output)).toEqual(Object.keys(input)) + expect(Object.hasOwn(output, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(output)).toBe(Object.prototype) + expect(Object.hasOwn(output.rows[0], '__proto__')).toBe(true) + expect(Object.getPrototypeOf(output.rows[0])).toBe(Object.prototype) + }) + + it('preserves data keys, leaf values and indentation in nested containers', () => { + const symbol = Symbol.for('token') + const input = Object.assign(Object.create(null), { + 'quoted-key': "line\n'quoted'", + default: true, + 名称: 'value', + [symbol]: 12, + empty: {}, + list: [], + date: new Date('2026-01-01T00:00:00.000Z'), + pattern: /title/gi, + callback: (value: number) => value + 1 + }) + Object.defineProperty(input, 'hidden', { value: 'omit' }) + const output = new Function(`return (${stringify(input)})`)() + + expect(output['quoted-key']).toBe(input['quoted-key']) + expect(output.default).toBe(true) + expect(output.名称).toBe('value') + expect(output[symbol]).toBe(12) + expect(output.empty).toEqual({}) + expect(output.list).toEqual([]) + expect(output.date).toEqual(input.date) + expect(output.pattern).toEqual(input.pattern) + expect(output.callback(4)).toBe(5) + expect(output).not.toHaveProperty('hidden') + expect(stringify({ nested: { value: null, unset: undefined } })).toBe( + '{\n nested: {\n value: null,\n unset: undefined\n }\n}' + ) + }) + + it('marks recursive containers while preserving repeated non-circular values', () => { + const shared = { value: 1 } + const input: Record = { first: shared, second: shared } + input.self = input + const array: unknown[] = [] + array.push(array) + input.array = array + + expect(new Function(`return (${stringify(input)})`)()).toEqual({ + first: { value: 1 }, + second: { value: 1 }, + self: '[Circular]', + array: ['[Circular]'] + }) + }) + + it('preserves multiline function values inside nested non-plain containers', () => { + const callback = new Function('return `first\nsecond`') + const input = { nested: { map: new Map([['callback', callback]]), set: new Set([callback]) } } + const output = new Function(`return (${stringify(input)})`)() + + expect(output.nested.map.get('callback')()).toBe('first\nsecond') + expect([...output.nested.set][0]()).toBe('first\nsecond') + }) + it('indents all lines with optional first-line skip', () => { const multi = 'line1\nline2' expect(indentAll(multi, ' ')).toBe(' line1\n line2') diff --git a/packages/extension/tests/utils/tailwind.test.ts b/packages/extension/tests/utils/tailwind.test.ts index 5f0b65af..ed32c752 100644 --- a/packages/extension/tests/utils/tailwind.test.ts +++ b/packages/extension/tests/utils/tailwind.test.ts @@ -8,6 +8,81 @@ import { } from '@/utils/tailwind' describe('utils/tailwind cssToTailwind', () => { + it.each([ + [ + 'background-image', + String.raw`url("https://example.test/front\ image.png")`, + String.raw`bg-[url("https://example.test/front\000020image.png")]` + ], + [ + 'background-image', + String.raw`url(https://example.test/front\ image.png)`, + String.raw`bg-[url(https://example.test/front\000020image.png)]` + ], + [ + 'background-image', + String.raw`url("https://example.test/front\20 image.png")`, + String.raw`bg-[url("https://example.test/front\000020image.png")]` + ], + [ + 'background-image', + String.raw`url( https://example.test/front\ )`, + String.raw`bg-[url(https://example.test/front\000020)]` + ], + [ + 'background-image', + 'url( "https://example.test/front_image.png" )', + 'bg-[url("https://example.test/front_image.png")]' + ], + ['content', String.raw`"a\ b"`, String.raw`content-["a\000020b"]`], + ['content', String.raw`"a\20 b"`, String.raw`content-["a\000020b"]`], + ['content', '"a\\\nb"', 'content-["ab"]'], + [ + 'font-family', + String.raw`Brand\, One, serif`, + String.raw`font-[family-name:Brand\00002c__One,_serif]` + ] + ])('preserves CSS escapes and URL syntax in %s: %s', (property, value, expected) => { + expect(nestedCssToClassNames({ [property]: value })).toEqual([expected]) + }) + + it('escapes literal underscores once while preserving spaces, URLs and variable shorthand', () => { + expect( + nestedCssToClassNames({ + 'animation-name': 'fade_in', + 'font-variation-settings': '"A_BC" 400', + 'font-family': '"Brand_One", serif', + width: 'calc(var(--size_small) * 2)', + height: 'var(--size_small)', + 'background-image': 'url("front_image.png")', + '&::before': { content: '"a_ b"' } + }) + ).toEqual( + expect.arrayContaining([ + String.raw`[animation-name:fade\_in]`, + String.raw`[font-variation-settings:"A\_BC"_400]`, + String.raw`font-[family-name:"Brand\_One",_serif]`, + String.raw`w-[calc(var(--size\_small)_*_2)]`, + 'h-(--size_small)', + 'bg-[url("front_image.png")]', + String.raw`before:content-["a\___b"]` + ]) + ) + }) + + it.each(['round', 'space'])('emits a valid background repeat utility for %s', (repeat) => { + expect(cssToClassNames({ 'background-repeat': repeat })).toEqual([`bg-repeat-${repeat}`]) + }) + + it.each(['repeat no-repeat', 'no-repeat repeat', 'round space'])( + 'preserves both background repeat axes for %s', + (repeat) => { + expect(cssToClassNames({ 'background-repeat': repeat })).toEqual([ + `[background-repeat:${repeat.replace(' ', '_')}]` + ]) + } + ) + it('maps direct keyword families to classes', () => { expect( cssToTailwind({ @@ -122,11 +197,51 @@ describe('utils/tailwind cssToTailwind', () => { ).toEqual([ 'w-[calc(var(--size)_*_2)]', 'bg-[linear-gradient(var(--start),_var(--end))]', - 'font-[var(--font),sans-serif]', + 'font-[family-name:var(--font),_sans-serif]', 'h-[12px]' ]) }) + it('preserves background shorthands as explicit properties instead of image utilities', () => { + expect( + cssToClassNames({ + background: + 'url("front_image.png") left / cover no-repeat, url("rear.png") right / contain repeat, #FF0' + }) + ).toEqual([ + '[background:url("front_image.png")_left_/_cover_no-repeat,_url("rear.png")_right_/_contain_repeat,_#FF0]' + ]) + expect(cssToClassNames({ background: 'var(--surface)' })).toEqual([ + '[background:var(--surface)]' + ]) + expect( + nestedCssToClassNames({ + '&:hover': { background: 'linear-gradient(red, blue) center / cover no-repeat' } + }) + ).toEqual(['hover:[background:linear-gradient(red,_blue)_center_/_cover_no-repeat]']) + }) + + it.each(['104.167% 145.833%', '20px 20px', '16px auto'])( + 'emits an arbitrary background-size utility for %s', + (size) => { + expect( + cssToClassNames({ + 'background-size': size, + 'background-position': '-2.08px -14.75px' + }) + ).toEqual([`bg-[length:${size.replaceAll(' ', '_')}]`, 'bg-[position:-2.08px_-14.75px]']) + } + ) + + it('retains keyword and variable background sizes', () => { + expect(cssToClassNames({ 'background-size': 'cover' })).toEqual(['bg-cover']) + expect(cssToClassNames({ 'background-size': 'contain' })).toEqual(['bg-contain']) + expect(cssToClassNames({ 'background-size': 'auto' })).toEqual(['bg-auto']) + expect(cssToClassNames({ 'background-size': 'var(--image-size)' })).toEqual([ + 'bg-(length:--image-size)' + ]) + }) + it('maps shorthand and pseudo-related properties to canonical utilities', () => { const result = cssToTailwind({ content: '""', @@ -156,7 +271,7 @@ describe('utils/tailwind cssToTailwind', () => { expect(result).toEqual( expect.arrayContaining([ - 'font-[Fira_Sans,serif]', + 'font-[family-name:"Fira_Sans",_serif]', 'grid-cols-3', 'grid-rows-[repeat(auto-fit,_minmax(0,_1fr))]', 'col-[2_/_4]', diff --git a/packages/extension/tests/utils/tempad.test.ts b/packages/extension/tests/utils/tempad.test.ts index 2772954e..b6857a28 100644 --- a/packages/extension/tests/utils/tempad.test.ts +++ b/packages/extension/tests/utils/tempad.test.ts @@ -68,6 +68,25 @@ describe('utils/tempad extractJSX', () => { }) describe('utils/tempad getTemPadComponent', () => { + it.each(['constructor', 'toString', '__proto__'])( + 'does not invent a display name for an unknown library named %s', + (libName) => { + const node = makeFrameNode({ + sharedData: { + source: JSON.stringify({ name: 'Card', libName }), + code: jsxStack('') + } + }) + + expect(getTemPadComponent(node)).toEqual({ + code: '', + link: null, + name: 'Card', + libName + }) + } + ) + it('returns null for non-TemPad nodes', () => { const node = { type: 'FRAME', name: 'Card' } as unknown as SceneNode diff --git a/packages/extension/tests/utils/variable-output.test.ts b/packages/extension/tests/utils/variable-output.test.ts index b474bb85..61e9768f 100644 --- a/packages/extension/tests/utils/variable-output.test.ts +++ b/packages/extension/tests/utils/variable-output.test.ts @@ -71,6 +71,102 @@ function setupFigma({ } describe('utils/variable-output', () => { + it('uses field bindings without guessing ownership of a duplicated code syntax', () => { + setupFigma({ + variableById: { + a: createVariable('a', 'Width', 'shared'), + b: createVariable('b', 'Height', 'shared') + } + }) + const node = { + type: 'FRAME', + boundVariables: { width: { id: 'a' }, height: { id: 'b' } } + } as unknown as SceneNode + expect( + formatNodeStyleForMcp({ width: 'shared', height: 'shared', color: 'shared' }, node) + ).toEqual({ + width: 'var(--Width)', + height: 'var(--Height)', + color: 'shared' + }) + }) + + it.each([ + [ + 'UI', + (style: Record, node: SceneNode) => formatNodeStyleForUi(style, node).style + ], + ['MCP', formatNodeStyleForMcp], + ['plugin', formatNodeStyleForPluginVariables] + ] as const)('preserves CSS literals while rewriting %s variable references', (_name, format) => { + setupFigma({ + variableById: { + accent: createVariable('accent', 'Accent', 'theme.color.accent') + } + }) + const node = { + type: 'FRAME', + boundVariables: { fills: [{ id: 'accent' }] } + } as unknown as FrameNode + const style = { + content: '"theme.color.accent var(--Accent, red)"', + background: 'url("https://assets.test/theme.color.accent.svg") theme.color.accent', + mask: 'url(https://assets.test/theme.color.accent.svg)', + 'border-image': 'linear-gradient(theme.color.accent, var(--other, red))', + outline: '1px /* theme.color.accent */ solid theme.color.accent' + } + + expect(format(style, node)).toEqual({ + ...style, + background: 'url("https://assets.test/theme.color.accent.svg") var(--Accent)', + 'border-image': 'linear-gradient(var(--Accent), var(--other, red))', + outline: '1px /* theme.color.accent */ solid var(--Accent)' + }) + }) + + it('uses binding identity for quoted code syntax without rewriting matching literal content', () => { + setupFigma({ + variableById: { + 'font-body': createVariable('font-body', 'Body font', '"Brand Font"') + } + }) + const node = createTextNode({ fills: [], boundVariables: {} }) + const result = formatNodeStyleForUi( + { 'font-family': '"Brand Font"', content: '"Brand Font"' }, + node + ) + + expect(result).toEqual({ + style: { 'font-family': 'var(--Body-font)', content: '"Brand Font"' }, + variableSyntax: { '--Body-font': '"Brand Font"' } + }) + }) + + it.each(['0', 'theme.color.accent'])( + 'does not confuse %s syntax or CSS identifiers with protected fragments', + (syntax) => { + setupFigma({ variableById: { accent: createVariable('accent', 'Accent', syntax) } }) + const node = { + type: 'FRAME', + boundVariables: { fills: [{ id: 'accent' }] } + } as unknown as FrameNode + expect( + formatNodeStyleForMcp( + { + content: '"0"', + border: `1px solid ${syntax}`, + 'font-family': '__VAR_0__, var(--Accent, red)' + }, + node + ) + ).toEqual({ + content: '"0"', + border: '1px solid var(--Accent)', + 'font-family': '__VAR_0__, var(--Accent)' + }) + } + ) + it.each(['HORIZONTAL', 'VERTICAL'] as const)( 'preserves explicitly synchronized cross-axis variables in %s wrap layouts', (layoutMode) => { diff --git a/packages/extension/types.d.ts b/packages/extension/types.d.ts index 6bed2064..2bf5983a 100644 --- a/packages/extension/types.d.ts +++ b/packages/extension/types.d.ts @@ -9,3 +9,4 @@ interface Window { declare const __DEV__: boolean declare const __TEMPAD_RUNTIME_FINGERPRINT__: string +declare const __TEMPAD_RUNTIME_PROFILE__: import('@tempad-dev/shared').RuntimeProfile diff --git a/packages/extension/ui/plugin-storage.ts b/packages/extension/ui/plugin-storage.ts new file mode 100644 index 00000000..37530eee --- /dev/null +++ b/packages/extension/ui/plugin-storage.ts @@ -0,0 +1,120 @@ +export interface PluginData { + name: string + code: string + integrity?: string + resolvedUrl?: string + source: string +} + +export const PLUGIN_STORAGE_KEY = 'tempad-dev-plugins' +const PREFERENCE_STORAGE_KEY = 'tempad-dev' + +type PluginRecord = Record + +export class CorruptPluginStorageError extends Error { + constructor() { + super('Stored plugin data is invalid.') + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function parseRecord(value: string | null): Record { + if (value === null) return {} + try { + const parsed: unknown = JSON.parse(value) + if (isRecord(parsed)) return parsed + } catch { + // Preserve the invalid record until the user explicitly resets it. + } + throw new CorruptPluginStorageError() +} + +function readPlugins(value: unknown): PluginRecord { + if ( + !isRecord(value) || + Object.entries(value).some( + ([source, plugin]) => + !isRecord(plugin) || + plugin.source !== source || + typeof plugin.name !== 'string' || + typeof plugin.code !== 'string' || + (plugin.integrity !== undefined && typeof plugin.integrity !== 'string') || + (plugin.resolvedUrl !== undefined && typeof plugin.resolvedUrl !== 'string') + ) + ) + throw new CorruptPluginStorageError() + return value as PluginRecord +} + +export function createPluginStorage(storage: Storage, locks: LockManager) { + function removeLegacyBackup() { + try { + const preferences = parseRecord(storage.getItem(PREFERENCE_STORAGE_KEY)) + if (!Object.hasOwn(preferences, 'plugins')) return + delete preferences.plugins + storage.setItem(PREFERENCE_STORAGE_KEY, JSON.stringify(preferences)) + } catch { + // The canonical record is already durable. Retaining its backup is safe; + // a failed cleanup must not report the committed plugin edit as failed. + } + } + + function read() { + const stored = storage.getItem(PLUGIN_STORAGE_KEY) + if (stored !== null) return { plugins: readPlugins(parseRecord(stored)), legacy: false } + const preferences = parseRecord(storage.getItem(PREFERENCE_STORAGE_KEY)) + if (Object.hasOwn(preferences, 'plugins')) { + return { plugins: readPlugins(preferences.plugins), legacy: true } + } + return { plugins: {} as PluginRecord, legacy: false } + } + + async function migrate() { + return locks.request(PLUGIN_STORAGE_KEY, () => { + const current = read() + if (current.legacy) storage.setItem(PLUGIN_STORAGE_KEY, JSON.stringify(current.plugins)) + if (storage.getItem(PLUGIN_STORAGE_KEY) !== null) removeLegacyBackup() + }) + } + + async function save(source: string, plugin: PluginData | null) { + // A fresh read alone is not atomic across renderer processes. Serialize the + // complete mutation, including legacy migration, with the origin's Web Lock. + return locks.request(PLUGIN_STORAGE_KEY, () => { + const current = read() + const next = { ...current.plugins } + if (plugin) next[source] = plugin + else delete next[source] + try { + storage.setItem(PLUGIN_STORAGE_KEY, JSON.stringify(next)) + } catch (error) { + if (plugin || !current.legacy) throw error + // A shrinking legacy record can free quota without duplicating its code. + const preferences = parseRecord(storage.getItem(PREFERENCE_STORAGE_KEY)) + storage.setItem(PREFERENCE_STORAGE_KEY, JSON.stringify({ ...preferences, plugins: next })) + return + } + removeLegacyBackup() + }) + } + + async function resetCorruptRecord() { + return locks.request(PLUGIN_STORAGE_KEY, () => { + try { + read() + // Another tab may have recovered the record while this action was queued. + return false + } catch (error) { + if (!(error instanceof CorruptPluginStorageError)) throw error + } + storage.setItem(PLUGIN_STORAGE_KEY, '{}') + removeLegacyBackup() + return true + }) + } + + return { read, migrate, save, resetCorruptRecord } +} diff --git a/packages/extension/ui/preference-storage.ts b/packages/extension/ui/preference-storage.ts index 6f0e74e8..f7fe4ed2 100644 --- a/packages/extension/ui/preference-storage.ts +++ b/packages/extension/ui/preference-storage.ts @@ -1,33 +1,73 @@ +import { PLUGIN_STORAGE_KEY } from './plugin-storage' + /** Keep tab-local UI state while persisting only the fields this tab changed. */ -export function createPreferenceStorage(storage: Storage) { +export function createPreferenceStorage( + storage: Storage, + locks: LockManager, + defaults: Record, + onError: (error: unknown) => void = console.error +) { + // VueUse may reuse and mutate the supplied defaults when no saved record exists. + const initial = JSON.stringify(defaults) let previous: Record | undefined - const read = (value: string | null): Record => { - const parsed: unknown = JSON.parse(value ?? '{}') - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} + let queued: { value: string | null } | undefined + const read = (value: string | null): Record | undefined => { + try { + const parsed: unknown = JSON.parse(value ?? 'null') + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + return parsed as Record + } catch { + // The next successful write replaces malformed preferences with the local snapshot. + } + } + function mutate(value: string | null, action: () => void) { + const pending = { value } + queued = pending + return locks + .request(PLUGIN_STORAGE_KEY, action) + .catch(onError) + .finally(() => { + if (queued === pending) queued = undefined + }) } return { getItem(key: string) { + // VueUse must see the latest queued edit, including a local reversal while + // the lock is held. `previous` advances only after a durable write. + if (queued) return queued.value + // VueUse compares this with the next local value before calling setItem. + // A remote tab's equal value must not skip acknowledging our own edit. + if (previous) return JSON.stringify(previous) const value = storage.getItem(key) - previous ??= read(value) - return value + // Missing defaults belong to the baseline, not this tab's first edit. + previous = { ...read(initial), ...read(value) } + return value === null ? null : JSON.stringify(previous) }, setItem(key: string, value: string) { - const next = read(value) - const stored = storage.getItem(key) - const merged = stored === null ? { ...next } : read(stored) - for (const field of new Set([...Object.keys(previous ?? {}), ...Object.keys(next)])) { - if (JSON.stringify(previous?.[field]) === JSON.stringify(next[field])) continue - if (Object.hasOwn(next, field)) merged[field] = next[field] - else delete merged[field] - } - storage.setItem(key, JSON.stringify(merged)) - previous = next + return mutate(value, () => { + const next = read(value) ?? {} + const stored = storage.getItem(key) + const persisted = read(stored) + const merged = persisted ?? { ...next } + if (!persisted) delete merged.plugins + for (const field of new Set([...Object.keys(previous ?? {}), ...Object.keys(next)])) { + // Legacy plugin records have their own locked writer. A preference + // watcher must never restore its tab's stale copy after a plugin edit. + if (field === 'plugins') continue + if (JSON.stringify(previous?.[field]) === JSON.stringify(next[field])) continue + if (Object.hasOwn(next, field)) merged[field] = next[field] + else delete merged[field] + } + const serialized = JSON.stringify(merged) + if (stored !== serialized) storage.setItem(key, serialized) + previous = next + }) }, removeItem(key: string) { - storage.removeItem(key) - previous = undefined + return mutate(null, () => { + storage.removeItem(key) + previous = undefined + }) } } } diff --git a/packages/extension/ui/state.ts b/packages/extension/ui/state.ts index 973b1ab4..998235eb 100644 --- a/packages/extension/ui/state.ts +++ b/packages/extension/ui/state.ts @@ -1,20 +1,18 @@ -import { useStorage, computedAsync } from '@vueuse/core' +import { useStorage, computedAsync, useEventListener } from '@vueuse/core' import { computed, shallowRef } from 'vue' import { getTemPadComponent } from '@/utils' import { logger } from '@/utils/log' import { ui } from './figma' +import { + CorruptPluginStorageError, + createPluginStorage, + PLUGIN_STORAGE_KEY, + type PluginData +} from './plugin-storage' import { createPreferenceStorage } from './preference-storage' -interface PluginData { - name: string - code: string - integrity?: string - resolvedUrl?: string - source: string -} - export type Options = { minimized: boolean panelPosition: { @@ -33,70 +31,86 @@ export type Options = { activePluginSource: string | null } +const defaultOptions: Options = { + minimized: false, + panelPosition: { + left: window.innerWidth - ui.nativePanelWidth - ui.tempadPanelWidth, + top: ui.topBoundary, + width: ui.tempadPanelWidth + }, + prefOpen: false, + deepSelectOn: false, + measureOn: false, + cssUnit: 'px', + rootFontSize: 16, + scale: 1, + variableDisplay: 'reference', + mcpOn: false, + activePluginSource: null +} + export const options = useStorage( 'tempad-dev', - { - minimized: false, - panelPosition: { - left: window.innerWidth - ui.nativePanelWidth - ui.tempadPanelWidth, - top: ui.topBoundary, - width: ui.tempadPanelWidth - }, - prefOpen: false, - deepSelectOn: false, - measureOn: false, - cssUnit: 'px', - rootFontSize: 16, - scale: 1, - variableDisplay: 'reference', - mcpOn: false, - activePluginSource: null - }, - createPreferenceStorage(localStorage), + defaultOptions, + createPreferenceStorage(localStorage, navigator.locks, defaultOptions, (error) => { + logger.error('Failed to persist preferences:', error) + }), { mergeDefaults: true, listenToStorageChanges: false } ) -// Migrate the old combined record once before removing plugin code from preferences. -const legacy = options.value as Options & { plugins?: Record } -const PLUGIN_STORAGE_KEY = 'tempad-dev-plugins' -let pluginStorageFailed = false -export const plugins = useStorage>( - PLUGIN_STORAGE_KEY, - legacy.plugins ?? {}, - undefined, - { - listenToStorageChanges: false, - onError(error) { - pluginStorageFailed = true - logger.error('Failed to persist plugin storage:', error) - } +const pluginStorage = createPluginStorage(localStorage, navigator.locks) +export const plugins = shallowRef>({}) +export const pluginStorageCorrupted = shallowRef(false) + +function publishPlugins(current: ReturnType) { + plugins.value = current.plugins + pluginStorageCorrupted.value = false + const preferences = options.value as Options & { plugins?: typeof plugins.value } + if (!current.legacy) delete preferences.plugins +} + +function refreshPlugins() { + try { + publishPlugins(pluginStorage.read()) + } catch (error) { + if (error instanceof CorruptPluginStorageError) pluginStorageCorrupted.value = true + logger.error('Failed to read plugin storage:', error) } -) -if (!pluginStorageFailed) delete legacy.plugins +} -export function savePlugin(source: string, plugin: PluginData | null): void { - const next = { ...plugins.value } - if (plugin) next[source] = plugin - else delete next[source] +refreshPlugins() +void pluginStorage + .migrate() + .then(refreshPlugins) + .catch((error) => { + logger.error('Failed to migrate plugin storage:', error) + }) +useEventListener(window, 'storage', (event) => { + if ( + event.storageArea === localStorage && + (event.key === null || + event.key === PLUGIN_STORAGE_KEY || + (event.key === 'tempad-dev' && localStorage.getItem(PLUGIN_STORAGE_KEY) === null)) + ) { + // Events can arrive after a newer write. Read the durable state, not newValue. + refreshPlugins() + } +}) - // Commit before publishing: useStorage reports asynchronous write errors without - // rolling back its ref. Its later watcher skips this already-persisted value. +export async function savePlugin(source: string, plugin: PluginData | null): Promise { try { - localStorage.setItem(PLUGIN_STORAGE_KEY, JSON.stringify(next)) + await pluginStorage.save(source, plugin) + publishPlugins(pluginStorage.read()) } catch (error) { - const preferences = options.value as typeof legacy - // A shrinking legacy record can free space without temporarily duplicating its code. - // Replacing one key is atomic; a failed write leaves the only durable copy intact. - if (plugin || !preferences.plugins) throw error - const stored = JSON.parse(localStorage.getItem('tempad-dev') ?? '{}') - localStorage.setItem('tempad-dev', JSON.stringify({ ...stored, plugins: next })) - preferences.plugins = next - plugins.value = next - return + if (error instanceof CorruptPluginStorageError) pluginStorageCorrupted.value = true + throw error } - plugins.value = next - // Finish a migration that previously retained its backup after a storage error. - delete (options.value as typeof legacy).plugins +} + +export async function resetPlugins(): Promise { + const reset = await pluginStorage.resetCorruptRecord() + publishPlugins(pluginStorage.read()) + if (reset) options.value.activePluginSource = null } if (!Number.isFinite(options.value.rootFontSize) || options.value.rootFontSize <= 0) @@ -106,8 +120,11 @@ if (!Number.isFinite(options.value.scale) || options.value.scale <= 0) options.v export const runtimeMode = shallowRef<'standard' | 'unavailable'>('standard') export const layoutReady = shallowRef(false) export const selection = shallowRef([]) +export const selectionRevision = shallowRef(0) export const selectedNode = computed(() => selection.value?.[0] ?? null) export const selectedTemPadComponent = computed(() => { + // Native nodes are mutable even while the selected IDs stay unchanged. + void selectionRevision.value const node = selectedNode.value return node ? getTemPadComponent(node) : null }) diff --git a/packages/extension/utils/codegen.ts b/packages/extension/utils/codegen.ts index e3f4be7f..fac436a8 100644 --- a/packages/extension/utils/codegen.ts +++ b/packages/extension/utils/codegen.ts @@ -82,6 +82,12 @@ export type CodegenConfig = { scale: number } +type CodegenOptions = { + returnDevComponent?: boolean + variableDisplay?: VariableDisplayMode + readCss?: (node: SceneNode) => Promise> +} + export function workerUnitOptions( config: CodegenConfig ): Pick { @@ -96,7 +102,7 @@ export async function generateCodeBlocksForNode( node: SceneNode, config: CodegenConfig, pluginCode?: string, - opts?: { returnDevComponent?: boolean; variableDisplay?: VariableDisplayMode } + opts?: CodegenOptions ): Promise { const job = await prepareCodegenJobForNode(node, config, pluginCode, opts) return await requestCodegen(job, pluginCode) @@ -106,11 +112,7 @@ export async function generateCodeBlocksForNodes( nodes: SceneNode[], config: CodegenConfig, pluginCode: string, - opts?: { - returnDevComponent?: boolean - variableDisplay?: VariableDisplayMode - signal?: AbortSignal - } + opts?: CodegenOptions & { signal?: AbortSignal } ): Promise { const results: ResponsePayload[] = [] for (const window of chunk(nodes, CODEGEN_JOB_WINDOW)) { @@ -169,9 +171,9 @@ async function prepareCodegenJobForNode( node: SceneNode, config: CodegenConfig, pluginCode?: string, - opts?: { returnDevComponent?: boolean; variableDisplay?: VariableDisplayMode } + opts?: CodegenOptions ): Promise { - const rawStyle = await node.getCSSAsync() + const rawStyle = { ...(await (opts?.readCss ? opts.readCss(node) : node.getCSSAsync())) } // UI and plugin codegen may emit style-name vars when one CSS value safely represents the style. const style = await resolveStylesFromNode(rawStyle, node, undefined, { diff --git a/packages/extension/utils/color.ts b/packages/extension/utils/color.ts index f5072911..8f45f95e 100644 --- a/packages/extension/utils/color.ts +++ b/packages/extension/utils/color.ts @@ -1,31 +1,3 @@ -export function fadeTo(hex: string, alpha: number = 1): string { - let h = hex.replace(/^#/, '') - - if (alpha === 1) { - if (h.length === 6 && h[0] === h[1] && h[2] === h[3] && h[4] === h[5]) { - return `#${h[0]}${h[2]}${h[4]}` - } - return hex - } - - if (h.length === 3) { - h = [...h].map((digit) => digit + digit).join('') - } - - if (h.length !== 6) { - throw new Error('Invalid hex color') - } - - // Ensure valid alpha value - alpha = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha - - const r = parseInt(h.slice(0, 2), 16) - const g = parseInt(h.slice(2, 4), 16) - const b = parseInt(h.slice(4, 6), 16) - - return `rgba(${r}, ${g}, ${b}, ${alpha})` -} - function toHex(c: number) { return Math.round(c * 255) .toString(16) @@ -36,29 +8,3 @@ export function rgbToHex({ r, g, b }: { r: number; g: number; b: number }): stri // r, g, b are all 0~1 return `#${toHex(r)}${toHex(g)}${toHex(b)}` } - -export function rgbaToCss(color: RGB | RGBA, alphaOverride?: number): string { - const alpha = - typeof alphaOverride === 'number' ? alphaOverride : 'a' in color ? (color.a ?? 1) : 1 - const clamp = (value: number) => Math.round(Math.min(Math.max(value, 0), 1) * 255) - const r = clamp(color.r) - const g = clamp(color.g) - const b = clamp(color.b) - const a = Math.round(Math.min(Math.max(alpha, 0), 1) * 100) / 100 - return `rgba(${r}, ${g}, ${b}, ${a})` -} - -export function formatHex(r: number, g: number, b: number): string { - const toHex = (n: number) => n.toString(16).padStart(2, '0') - return `#${toHex(r)}${toHex(g)}${toHex(b)}` -} - -export function compressHex(hex: string): string { - const h = hex.toLowerCase() - if (!/^#[0-9a-f]{6}$/.test(h)) return h - const [r1, r2, g1, g2, b1, b2] = h.slice(1).split('') - if (r1 === r2 && g1 === g2 && b1 === b2) { - return `#${r1}${g1}${b1}` - } - return h -} diff --git a/packages/extension/utils/component.ts b/packages/extension/utils/component.ts index 4099f607..ef751a62 100644 --- a/packages/extension/utils/component.ts +++ b/packages/extension/utils/component.ts @@ -13,7 +13,7 @@ import type { import { rgbToHex } from './color' import { prune } from './object' -import { camelToKebab, escapeHTML, looseEscapeHTML, stringify, indentAll } from './string' +import { camelToKebab, escapeHTML, stringify, indentAll } from './string' type ComponentPropertyData = { type: string @@ -31,20 +31,24 @@ export function getDesignComponent(node: SceneNode): DesignComponent | null { for (const [name, data] of Object.entries(componentProperties)) { const key = name.split('#')[0] if (!key) continue + let value = data.value as ComponentPropertyValue if (data.type === 'INSTANCE_SWAP') { const component = figma.getNodeById(data.value as string) - if (component?.type === 'COMPONENT') { - properties[key] = { - name: component.name, - type: 'INSTANCE', - properties: {}, - children: [], - visible: true - } + if (component?.type !== 'COMPONENT') continue + value = { + name: component.name, + type: 'INSTANCE', + properties: {}, + children: [], + visible: true } - } else { - properties[key] = data.value as ComponentPropertyValue } + Object.defineProperty(properties, key, { + value, + enumerable: true, + configurable: true, + writable: true + }) } const main = getMainComponentSummary(node) @@ -190,9 +194,14 @@ export function stringifyComponent( const INDENT_UNIT = ' ' +export function escapeComponentText(text: string): string { + // Entities stay literal text in both JSX and Vue, unlike their template delimiters. + return escapeHTML(text).replace(/[{}]/g, (brace) => (brace === '{' ? '{' : '}')) +} + function stringifyBaseComponent( component: DevComponent, - stringifyProp: (key: string, value: unknown, inSvg: boolean) => string, + stringifyProp: (key: string, value: unknown, inSvg: boolean, isCustomTag: boolean) => string, indentLevel = 0, isInline?: (tagName: string) => boolean, parentIsSvg = false @@ -201,6 +210,7 @@ function stringifyBaseComponent( const { name, props, children: rawChildren } = component const inSvg = name === 'svg' || parentIsSvg const childrenInSvg = inSvg && name !== 'foreignObject' + const isCustomTag = isCustomComponentTag(name) if (name === RAW_TAG_NAME) { const content = String(props.content || '') @@ -214,7 +224,7 @@ function stringifyBaseComponent( const propItems = Object.entries(props) .filter(([, value]) => value != null) - .map(([key, value]) => stringifyProp(key, value, inSvg)) + .map(([key, value]) => stringifyProp(key, value, inSvg, isCustomTag)) .filter(Boolean) let propsString = '' @@ -236,7 +246,7 @@ function stringifyBaseComponent( childrenString = children .map((child): string => { if (typeof child === 'string') { - return escapeHTML(child) + return escapeComponentText(child) } return stringifyBaseComponent(child, stringifyProp, 0, isInline, childrenInSvg) }) @@ -249,7 +259,7 @@ function stringifyBaseComponent( childrenString = `\n${children .map((child): string => { if (typeof child === 'string') { - const content = escapeHTML(child) + const content = escapeComponentText(child) return `${indent + INDENT_UNIT}${content}` } return stringifyBaseComponent( @@ -265,7 +275,6 @@ function stringifyBaseComponent( const appendFinalNewline = indentLevel === 0 && !shouldCompact const isVoidTag = VOID_HTML_TAGS.has(name.toLowerCase()) - const isCustomTag = isCustomComponentTag(name) let opening: string let closing: string @@ -309,6 +318,71 @@ const VOID_HTML_TAGS = new Set([ ]) const EVENT_HANDLER_RE = /^on[A-Z]/ +// Native HTML names are case-insensitive, but these DOM/JSX properties have no hyphen. +const HTML_CAMEL_CASE_ATTRIBUTES = new Set([ + 'accessKey', + 'allowFullScreen', + 'autoCapitalize', + 'autoComplete', + 'autoFocus', + 'autoPlay', + 'cellPadding', + 'cellSpacing', + 'charSet', + 'colSpan', + 'contentEditable', + 'contextMenu', + 'controlsList', + 'crossOrigin', + 'dateTime', + 'dirName', + 'disablePictureInPicture', + 'disableRemotePlayback', + 'encType', + 'enterKeyHint', + 'fetchPriority', + 'formAction', + 'formEncType', + 'formMethod', + 'formNoValidate', + 'formTarget', + 'frameBorder', + 'hrefLang', + 'imageSizes', + 'imageSrcSet', + 'inputMode', + 'isMap', + 'itemID', + 'itemProp', + 'itemRef', + 'itemScope', + 'itemType', + 'marginHeight', + 'marginWidth', + 'maxLength', + 'minLength', + 'noModule', + 'noValidate', + 'playsInline', + 'popoverTarget', + 'popoverTargetAction', + 'readOnly', + 'referrerPolicy', + 'rowSpan', + 'shadowRootMode', + 'spellCheck', + 'srcDoc', + 'srcLang', + 'srcSet', + 'useMap' +]) +const HTML_ENUMERATED_BOOLEAN_ATTRIBUTES = new Set(['contenteditable', 'draggable', 'spellcheck']) +// Vue's attribute fallback does not recognize these presence-only Boolean attributes. +const HTML_PRESENCE_BOOLEAN_ATTRIBUTES = new Set([ + 'disablepictureinpicture', + 'disableremoteplayback', + 'playsinline' +]) const SVG_CASE_SENSITIVE_ATTRIBUTES = new Set([ 'viewBox', 'preserveAspectRatio', @@ -381,6 +455,19 @@ function getEventName(key: string) { return null } +function getVueAttributeName(key: string, inSvg: boolean, isCustomTag: boolean): string { + if (key === 'className') return 'class' + if (key === 'tabIndex') return 'tabindex' + if (key === 'htmlFor') return 'for' + + if (!isCustomTag) { + if (inSvg && key === 'xlinkHref') return 'xlink:href' + if (!inSvg && HTML_CAMEL_CASE_ATTRIBUTES.has(key)) return key.toLowerCase() + } + + return inSvg && SVG_CASE_SENSITIVE_ATTRIBUTES.has(key) ? key : camelToKebab(key) +} + function stringifyVueComponent( component: DevComponent, indentLevel = 0, @@ -388,29 +475,31 @@ function stringifyVueComponent( ) { return stringifyBaseComponent( component, - (key, value, inSvg) => { + (key, value, inSvg, isCustomTag) => { const eventName = getEventName(key) if (eventName) { - const callback = typeof value === 'string' ? looseEscapeHTML(value) : '() => {}' + const callback = typeof value === 'string' ? escapeHTML(value) : '() => {}' return `@${camelToKebab(eventName)}="${callback.trim()}"` } + // A static __proto__ attribute becomes an object-literal prototype setter. const name = - key === 'className' - ? 'class' - : key === 'tabIndex' - ? 'tabindex' - : key === 'htmlFor' - ? 'for' - : inSvg && SVG_CASE_SENSITIVE_ATTRIBUTES.has(key) - ? key - : camelToKebab(key) - - if (typeof value === 'string') { - return `${name}="${escapeHTML(value).trim()}"` + key === '__proto__' ? "['__proto__']" : getVueAttributeName(key, inSvg, isCustomTag) + + if (typeof value === 'string' && key !== '__proto__') { + return `${name}="${escapeHTML(value)}"` } - if (typeof value === 'boolean') { + if (typeof value === 'boolean' && key !== '__proto__') { + if (!inSvg && !isCustomTag && HTML_PRESENCE_BOOLEAN_ATTRIBUTES.has(name)) { + return value ? name : '' + } + if ( + name.startsWith('aria-') || + (!inSvg && !isCustomTag && HTML_ENUMERATED_BOOLEAN_ATTRIBUTES.has(name)) + ) { + return `:${name}="${value}"` + } return value ? name : `:${name}="false"` } @@ -419,10 +508,10 @@ function stringifyVueComponent( if (pruned == null) { return '' } - return `:${name}="${looseEscapeHTML(stringify(pruned)).trim()}"` + return `:${name}="${escapeHTML(stringify(pruned)).trim()}"` } - return `:${name}="${looseEscapeHTML(stringify(value)).trim()}"` + return `:${name}="${escapeHTML(stringify(value)).trim()}"` }, indentLevel, isInline @@ -442,23 +531,23 @@ function stringifyJSXComponent( return `${key}={${callback}}` } - if (typeof value === 'string') { - return `${key}="${escapeHTML(value).trim()}"` + if (typeof value === 'string' && key !== '__proto__') { + return `${key}="${escapeHTML(value)}"` } - if (typeof value === 'boolean') { + if (typeof value === 'boolean' && key !== '__proto__') { return value ? key : `${key}={false}` } if (typeof value === 'object' && value != null) { - const pruned = prune(value) - if (pruned == null) { + value = prune(value) + if (value == null) { return '' } - return `${key}={${stringify(pruned)}}` } - return `${key}={${stringify(value)}}` + const expression = stringify(value) + return key === '__proto__' ? `{...{['__proto__']: ${expression}}}` : `${key}={${expression}}` }, indentLevel, isInline @@ -500,7 +589,7 @@ export function mergeAttributes(code: string, attrs: Record): st while (i < code.length && /[a-zA-Z0-9\-_:.]/.test(code.charAt(i))) i++ const tagNameEnd = i - const existingAttrs = new Map() + const existingAttrs = new Map() while (i < code.length) { // Skip whitespace @@ -520,6 +609,8 @@ export function mergeAttributes(code: string, attrs: Record): st const attrNameStart = i while (i < code.length && /[^=\s/>]/.test(code.charAt(i))) i++ const attrName = code.slice(attrNameStart, i) + if (!attrName) return code + const attrNameEnd = i // Skip whitespace after name while (i < code.length && /\s/.test(code.charAt(i))) i++ @@ -555,9 +646,8 @@ export function mergeAttributes(code: string, attrs: Record): st } else { // Boolean attribute existingAttrs.set(attrName, { - valueStart: i, - valueEnd: i, - quote: '' + valueStart: attrNameEnd, + valueEnd: attrNameEnd }) } } @@ -587,13 +677,14 @@ export function mergeAttributes(code: string, attrs: Record): st const { valueStart, valueEnd, quote } = existing const isClass = key === 'class' || key === 'className' - const content = escapeHTML( - isClass ? mergeClasses(code.slice(valueStart, valueEnd), String(value)) : String(value) - ) + // Existing source is already entity-encoded. Escape only the injected value. + let content = escapeHTML(String(value)) + if (isClass) content = mergeClasses(code.slice(valueStart, valueEnd), content) + if (quote === "'") content = content.replaceAll("'", ''') let replacement = content if (!quote) { replacement = `"${content}"` - if (!isClass && valueStart === valueEnd) replacement = `=${replacement}` + if (quote === undefined) replacement = `=${replacement}` } replacements.push({ start: valueStart, end: valueEnd, content: replacement }) } diff --git a/packages/extension/utils/css.ts b/packages/extension/utils/css.ts index bd8df785..4e527a63 100644 --- a/packages/extension/utils/css.ts +++ b/packages/extension/utils/css.ts @@ -7,13 +7,14 @@ import { escapeJsSingleQuotedString, escapeJsTemplateLiteralText, kebabToCamel } export const WHITESPACE_RE = /\s+/ export const ALL_WHITESPACE_RE = /\s+/g -export const COMMA_DELIMITER_RE = /,\s*/g const ZERO_UNITS_RE = /(^|\s)0(px|rem|%|em)(?=$|\s)/g const SCSS_VARS_RE = /(^|[^\w-])[$@]([a-zA-Z0-9_-]+)/g -const PX_VALUE_RE = /\b(-?\d+(?:\.\d+)?)px\b/g -export const QUOTES_RE = /['"]/g +const CSS_NUMBER_RE = /[+-]?(?:\d*\.\d+|\d+)(?:e[+-]?\d+)?/iy +const CSS_NAME_RE = /(?:[-\w\u0080-\uFFFF]|\\(?:[\da-f]{1,6}[ \t\r\n\f]?|[^\r\n\f]))+/iy +// Tailwind arbitrary values encode whitespace as unescaped underscores. +const TAILWIND_NAME_RE = /(?:[-A-Za-z0-9\u0080-\uFFFF]|\\(?:[\da-f]{1,6}[ \t\r\n\f]?|[^\r\n\f]))+/iy const FLEX_FACTOR_RE = /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/i const FLEX_BASIS_RE = /^([+]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?)(%|px|em|rem|ex|ch|lh|rlh|vw|vh|vi|vb|vmin|vmax|cm|mm|q|in|pt|pc)$/i @@ -51,51 +52,43 @@ const RE_CAPS = /^[A-Z]+$/ const RE_SINGLE = /^[A-Za-z]$/ export function splitByTopLevelComma(input: string, keepEmpty = false): string[] { + return splitCssTopLevel(input, ',', keepEmpty) +} + +function splitCssTopLevel(input: string, separator: ',' | '/' | ' ', keepEmpty = false): string[] { if (!input) return [] const out: string[] = [] let depth = 0 - let quote: "'" | '"' | null = null - let buf = '' + let start = 0 for (let i = 0; i < input.length; i++) { - const ch = input[i] - - if (quote) { - if (ch === '\\') { - buf += ch - i++ - if (i < input.length) buf += input[i] - continue - } - if (ch === quote) quote = null - buf += ch + const ch = input[i]! + if (ch === '"' || ch === "'") { + i = skipCssLiteral(input, i, ch) - 1 continue } - - if (ch === '"' || ch === "'") { - quote = ch - buf += ch + if (ch === '/' && input[i + 1] === '*') { + const end = input.indexOf('*/', i + 2) + i = end < 0 ? input.length : end + 1 + continue + } + if (ch === '\\') { + i += Math.max(2, readCssName(input, i).length) - 1 continue } if (ch === '(') depth++ else if (ch === ')') depth = Math.max(0, depth - 1) - else if (ch === ',' && depth === 0) { - const segment = buf.trim() + else if (depth === 0 && (separator === ' ' ? /[\t\n\f\r ]/.test(ch) : ch === separator)) { + const segment = input.slice(start, i).trim() if (segment || keepEmpty) out.push(segment) - buf = '' - continue + start = i + 1 } - - buf += ch - } - - const tail = buf.trim() - if (tail || keepEmpty) { - out.push(tail) } + const tail = input.slice(start).trim() + if (tail || keepEmpty) out.push(tail) return out } @@ -103,10 +96,11 @@ function hasTopLevelComma(value: string): boolean { return splitByTopLevelComma(value, true).length > 1 } -const BG_SIZE_RE = /\/\s*(cover|contain|auto|[\d.]+(?:px|%)?)/i +const BG_SIZE_RE = + /\/\s*(cover|contain|(?:auto|[+\d.]+(?:px|rem|em|%)?)(?:\s+(?:auto|[+\d.]+(?:px|rem|em|%)?))?)(?=$|\s)/i const BG_REPEAT_RE = /(?:^|\s)(no-repeat|repeat-x|repeat-y|repeat|space|round)(?=$|\s)/i const BG_POS_RE = - /(?:^|\s)(center|top|bottom|left|right|[\d.]+(?:%|px))(?:\s+(?:center|top|bottom|left|right|[\d.]+(?:%|px)))?(?=\s*\/|\s*$)/i + /(?:^|\s)((?:center|top|bottom|left|right|[+-]?[\d.]+(?:%|px|rem|em)?)(?:\s+(?:center|top|bottom|left|right|[+-]?[\d.]+(?:%|px|rem|em)?)){0,3})(?=\s*\/|\s*$)/i export const BG_URL_RE = /url\((['"]?)(.*?)\1\)/i const KEEP_PX_PROPS = new Set([ @@ -122,33 +116,50 @@ const KEEP_PX_PROPS = new Set([ 'backdrop-filter' ]) -const CSS_COMMENTS_RE = /\/\*[\s\S]*?\*\//g - type VarFunctionMatch = { full: string name: string fallback?: string } +export type CssValueSyntax = 'css' | 'tailwind' | 'code' + export function replaceVarFunctions( input: string, - replacer: (match: VarFunctionMatch) => string + replacer: (match: VarFunctionMatch) => string, + syntax: CssValueSyntax = 'css' ): string { if (!input) return input let out = '' let i = 0 + let copiedUntil = 0 while (i < input.length) { - const start = input.toLowerCase().indexOf('var(', i) - if (start < 0) { - out += input.slice(i) - break + const ch = input[i] + if (syntax !== 'code' && (ch === '"' || ch === "'")) { + i = skipCssLiteral(input, i, ch) + continue } + if (syntax !== 'code' && ch === '/' && input[i + 1] === '*') { + const end = input.indexOf('*/', i + 2) + i = end < 0 ? input.length : end + 2 + continue + } + const start = i + const functionName = readCssName(input, i, syntax) + i += functionName.length || 1 + if (input[i] !== '(') continue + if ( + syntax !== 'code' && + (functionName.toLowerCase() === 'url' || functionName.includes('\\')) + ) { + i = skipCssLiteral(input, i, ')') + continue + } + if (functionName.toLowerCase() !== 'var') continue - out += input.slice(i, start) - - let j = start + 4 + let j = i + 1 let depth = 1 let nameEnd: number | undefined let commaIndex = -1 @@ -158,7 +169,13 @@ export function replaceVarFunctions( for (; j < input.length; j++) { const ch = input[j] - if (ch === '(') depth++ + if (ch === '"' || ch === "'") { + j = skipCssLiteral(input, j, ch) - 1 + } else if (ch === '/' && input[j + 1] === '*') { + const end = input.indexOf('*/', j + 2) + j = end < 0 ? input.length : end + 1 + } else if (ch === '\\') j++ + else if (ch === '(') depth++ else if (ch === ')') { depth-- if (depth === 0) break @@ -169,7 +186,6 @@ export function replaceVarFunctions( } if (depth !== 0) { - out += input.slice(start) break } @@ -183,11 +199,12 @@ export function replaceVarFunctions( const name = input.slice(nameStart, nameEnd) const fallback = commaIndex >= 0 ? input.slice(commaIndex + 1, j).trim() : undefined - out += replacer({ full, name, ...(fallback ? { fallback } : {}) }) - i = j + 1 + out += + input.slice(copiedUntil, start) + replacer({ full, name, ...(fallback ? { fallback } : {}) }) + i = copiedUntil = j + 1 } - return out + return out + input.slice(copiedUntil) } export function extractVarNames(input: string): Set { @@ -218,7 +235,7 @@ export function formatHexAlpha( const g = toHex(color.g) const b = toHex(color.b) - if (opacity >= 0.99) { + if (opacity >= 1) { if (r[0] === r[1] && g[0] === g[1] && b[0] === b[1]) { return `#${r[0]}${g[0]}${b[0]}` } @@ -243,28 +260,49 @@ export function parseBackgroundShorthand(value: string) { const urlMatch = value.match(BG_URL_RE) if (urlMatch) result.image = urlMatch[0] + const layout = urlMatch ? value.replace(urlMatch[0], '') : value - const sizeMatch = value.match(BG_SIZE_RE) + const sizeMatch = layout.match(BG_SIZE_RE) if (sizeMatch) result.size = sizeMatch[1] - const repeatMatch = value.match(BG_REPEAT_RE) + const repeatMatch = layout.match(BG_REPEAT_RE) if (repeatMatch) { const [, repeatToken] = repeatMatch result.repeat = repeatToken!.trim() } - const posMatch = value.match(BG_POS_RE) - if (posMatch) result.position = posMatch[0].trim() + const position = layout.split('/')[0]!.replace(BG_REPEAT_RE, '').trim() + const posMatch = position.match(BG_POS_RE) + if (posMatch) result.position = posMatch[1] return result } -export function parseBoxValues(value: string): [string, string, string, string] { - const parts = value.trim().split(WHITESPACE_RE) +export function parseBoxValues(value: string): [string, string, string, string] | null { + const parts = parseShorthandValues(value, 4) + if (!parts) return null const [t = '', r = t, b = t, l = r] = parts return [t, r, b, l] } +function parseShorthandValues(value: string, maximum: number): string[] | null { + const parts = splitCssTopLevel(value, ' ') + return parts.length > 0 && + parts.length <= maximum && + splitCssTopLevel(value, '/', true).length === 1 + ? parts + : null +} + +function hasUnresolvedShorthand(value: string): boolean { + // A top-level substitution can expand to several components. A substitution + // inside calc()/min()/etc. still belongs to that one function value. + return splitCssTopLevel(value, ' ').some((part) => { + const name = readCssName(part, 0) + return part[name.length] === '(' && (/^(var|env)$/i.test(name) || name.includes('\\')) + }) +} + function isFlexFactor(value: string): boolean { return FLEX_FACTOR_RE.test(value) && Number.isFinite(Number(value)) && Number(value) >= 0 } @@ -306,11 +344,120 @@ function parseFlexShorthand(value: string): { grow: string; shrink: string; basi } function transformPxValue(value: string, transform: (value: number) => string) { - return value.replace(PX_VALUE_RE, (_, val) => { - const parsed = Number(val) - if (parsed === 0) return '0' - return transform(toDecimalPlace(parsed, 5)) - }) + let output = '' + let copiedUntil = 0 + let index = 0 + + // Consume complete CSS tokens so numbers inside names, strings and URLs stay literal. + while (index < value.length) { + const ch = value[index] + if (ch === '"' || ch === "'") { + index = skipCssLiteral(value, index, ch) + continue + } + if (ch === '/' && value[index + 1] === '*') { + const end = value.indexOf('*/', index + 2) + index = end < 0 ? value.length : end + 2 + continue + } + + const hasNamePrefix = ch === '#' || ch === '@' + CSS_NUMBER_RE.lastIndex = index + const number = hasNamePrefix ? undefined : CSS_NUMBER_RE.exec(value)?.[0] + const nameStart = index + (number?.length ?? (hasNamePrefix ? 1 : 0)) + const name = readCssName(value, nameStart) + const end = nameStart + name.length + + if (number) { + const parsed = Number(number) + if (name.toLowerCase() === 'px' && Number.isFinite(parsed)) { + output += value.slice(copiedUntil, index) + output += parsed === 0 ? '0' : transform(toDecimalPlace(parsed, 5)) + copiedUntil = end + } + index = end + } else if (end > index) { + // Escaped function names are opaque: do not risk rewriting an encoded url(). + index = + value[end] === '(' && (name.toLowerCase() === 'url' || name.includes('\\')) + ? skipCssLiteral(value, end, ')') + : end + } else { + index++ + } + } + + return output + value.slice(copiedUntil) +} + +export function readCssName(value: string, start: number, syntax: CssValueSyntax = 'css'): string { + const pattern = syntax === 'tailwind' ? TAILWIND_NAME_RE : CSS_NAME_RE + pattern.lastIndex = start + return pattern.exec(value)?.[0] ?? '' +} + +export function skipCssLiteral(value: string, start: number, closing: string): number { + let index = start + 1 + while (index < value.length) { + const ch = value[index] + if (ch === '\\') index += 2 + else if (ch === closing) return index + 1 + else if (closing === ')' && (ch === '"' || ch === "'")) { + index = skipCssLiteral(value, index, ch) + } else index++ + } + return value.length +} + +export function transformCssValue( + value: string, + transform: (value: string) => string, + { + removeComments = false, + syntax = 'css' + }: { removeComments?: boolean; syntax?: CssValueSyntax } = {} +): string { + // Keep quoted literals opaque, including Tailwind content-["..."] values. Masking + // lets the transform still remove an outer var(--name, "fallback") as one unit. + let delimiter = '\0' + while (value.includes(delimiter)) delimiter += '\0' + const literals: string[] = [] + let masked = '' + let copiedUntil = 0 + let index = 0 + while (index < value.length) { + const ch = value[index] + let literalEnd: number | undefined + if (ch === '"' || ch === "'") { + literalEnd = skipCssLiteral(value, index, ch) + } else if (ch === '/' && value[index + 1] === '*') { + const end = value.indexOf('*/', index + 2) + literalEnd = end < 0 ? value.length : end + 2 + if (removeComments) { + masked += value.slice(copiedUntil, index) + index = copiedUntil = literalEnd + continue + } + } else { + const name = readCssName(value, index, syntax) + const end = index + name.length + // Consume names once; escaped functions share unit conversion's opaque handling. + if (value[end] === '(' && (name.toLowerCase() === 'url' || name.includes('\\'))) { + literalEnd = skipCssLiteral(value, end, ')') + } else { + index = name ? end : index + 1 + continue + } + } + masked += value.slice(copiedUntil, index) + `${delimiter}${literals.length}${delimiter}` + literals.push(value.slice(index, literalEnd)) + index = copiedUntil = literalEnd + } + masked += value.slice(copiedUntil) + return transform(masked).replace( + new RegExp(`${delimiter}(\\d+)${delimiter}`, 'g'), + (_match, index: string) => literals[Number(index)]! + ) } function scalePxValue(value: string, scale: number): string { @@ -357,28 +504,32 @@ export function normalizeStyleValues( export function simplifyColorMixToRgba(input: string): string { if (!input || !input.includes('color-mix(')) return input - return input.replace( - /color-mix\(\s*in\s+srgb\s*,\s*(#[0-9a-fA-F]{3,8})\s+([0-9.]+)%\s*,\s*transparent\s*\)/g, - (_match, hex: string, pct: string) => { - const parsed = parseHexColor(hex) - if (!parsed) return _match - const weight = Number(pct) / 100 - if (!Number.isFinite(weight)) return _match - const alpha = toDecimalPlace(Math.min(1, Math.max(0, parsed.a * weight)), 2) - return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})` - } + return transformCssValue(input, (value) => + value.replace( + /color-mix\(\s*in\s+srgb\s*,\s*(#[0-9a-fA-F]{3,8})\s+([0-9.]+)%\s*,\s*transparent\s*\)/g, + (_match, hex: string, pct: string) => { + const parsed = parseHexColor(hex) + if (!parsed) return _match + const weight = Number(pct) / 100 + if (!Number.isFinite(weight)) return _match + const alpha = toDecimalPlace(Math.min(1, Math.max(0, parsed.a * weight)), 2) + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})` + } + ) ) } export function simplifyHexAlphaToRgba(input: string): string { if (!input || !input.includes('#')) return input - return input.replace(/#(?:[0-9a-fA-F]{4}|[0-9a-fA-F]{8})(?![0-9a-fA-F])/g, (match) => { - const parsed = parseHexColor(match) - if (!parsed || parsed.a >= 0.999) return match - const alpha = toDecimalPlace(Math.min(1, Math.max(0, parsed.a)), 2) - return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})` - }) + return transformCssValue(input, (value) => + value.replace(/#(?:[0-9a-fA-F]{4}|[0-9a-fA-F]{8})(?![0-9a-fA-F])/g, (match) => { + const parsed = parseHexColor(match) + if (!parsed || parsed.a >= 0.999) return match + const alpha = toDecimalPlace(Math.min(1, Math.max(0, parsed.a)), 2) + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})` + }) + ) } function parseHexColor(input: string): { r: number; g: number; b: number; a: number } | null { @@ -405,18 +556,32 @@ export function parseBorderShorthand(normalized: string): { style?: string color?: string } { - const matched = normalized.match(/^\s*(\S+)\s+(\S+)\s+(.+)\s*$/) - if (matched) { - const [, width, style, color] = matched - return { width, style, color: color!.trim() } - } + const parts = parseShorthandValues(normalized, 3) + if (!parts) return {} - const parts = normalized.split(WHITESPACE_RE).filter(Boolean) - return { - width: parts[0], - style: parts[1], - color: parts.slice(2).join(' ').trim() || undefined + const parsed: { width?: string; style?: string; color?: string } = {} + for (const part of parts) { + if (/^(inherit|initial|unset|revert(?:-layer)?)$/i.test(part)) return {} + if (hasUnresolvedShorthand(part)) continue + + let field: 'width' | 'style' | 'color' + const length = part.match(FLEX_BASIS_RE) + if ( + /^(thin|medium|thick)$/i.test(part) || + (isFlexFactor(part) && Number(part) === 0) || + (length && length[2] !== '%') || + /^(calc|min|max|clamp)\(/i.test(part) + ) { + field = 'width' + } else if (/^(none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/i.test(part)) { + field = 'style' + } else { + field = 'color' + } + if (parsed[field]) return {} + parsed[field] = part } + return parsed } export function extractLeadingGradient(value: string): string | null { @@ -599,11 +764,13 @@ function buildGradientBorderPseudoStyle(spec: GradientBorderSpec): Record): Record { @@ -613,7 +780,9 @@ export function expandShorthands(style: Record): Record { if (expanded[key]) { const val = normalizeStyleValue(expanded[key]) - const [t, r, b, l] = parseBoxValues(val) + const sides = hasUnresolvedShorthand(val) ? null : parseBoxValues(val) + if (!sides) return + const [t, r, b, l] = sides if (key === 'inset') { expanded['top'] = t expanded['right'] = r @@ -631,20 +800,26 @@ export function expandShorthands(style: Record): Record): Record 1) { + const parts = splitCssTopLevel(val, '/', true) + if (parts.length === 2 && parts.every(Boolean) && !hasUnresolvedShorthand(val)) { const start = parts[0]!.trim() const end = parts[1]!.trim() @@ -700,8 +875,8 @@ export function expandShorthands(style: Record): Record 1) { + const parts = splitCssTopLevel(val, '/', true) + if (parts.length === 2 && parts.every(Boolean) && !hasUnresolvedShorthand(val)) { const start = parts[0]!.trim() const end = parts[1]!.trim() @@ -732,10 +907,17 @@ export function expandShorthands(style: Record): Record { - if (width) expanded[`border-${side}-width`] = width - if (borderStyle) expanded[`border-${side}-style`] = borderStyle - if (color) expanded[`border-${side}-color`] = color + expanded[`border-${side}-width`] = width + expanded[`border-${side}-style`] = borderStyle + expanded[`border-${side}-color`] = color }) delete expanded[key] } @@ -807,7 +989,7 @@ export function serializeCSS( if (useDisplayMode) { current = preprocessCssValue(current) - current = current.replace(ZERO_UNITS_RE, '$10').trim() + current = transformCssValue(current, (value) => value.replace(ZERO_UNITS_RE, '$10')).trim() } else { current = normalizeStyleValue(current) } @@ -905,10 +1087,10 @@ export function serializeCSS( if (toJS) { const baseLines = Object.entries(baseStyle).map( - ([key, value]) => ` ${formatJsObjectKey(kebabToCamel(key))}: ${stringifyValue(value)}` + ([key, value]) => ` ${formatJsObjectKey(key)}: ${stringifyValue(value)}` ) const pseudoLines = Object.entries(pseudoStyle).map( - ([key, value]) => ` ${formatJsObjectKey(kebabToCamel(key))}: ${stringifyValue(value)}` + ([key, value]) => ` ${formatJsObjectKey(key)}: ${stringifyValue(value)}` ) baseLines.push(` ${formatJsObjectKey('&::before')}: {\n${pseudoLines.join(',\n')}\n }`) @@ -930,7 +1112,7 @@ export function serializeCSS( code = '{\n' + Object.entries(processedStyle) - .map(([key, value]) => ` ${kebabToCamel(key)}: ${stringifyValue(value)}`) + .map(([key, value]) => ` ${formatJsObjectKey(key)}: ${stringifyValue(value)}`) .join(',\n') + '\n}' } else { @@ -1068,21 +1250,27 @@ export function toFigmaVarExpr(input: string): string { // Intentionally does NOT strip fallbacks. export function preprocessCssValue(value: string): string { if (!value) return value - return value.replace(CSS_COMMENTS_RE, '').replace(SCSS_VARS_RE, '$1var(--$2)') + return transformCssValue(value, (part) => part.replace(SCSS_VARS_RE, '$1var(--$2)'), { + removeComments: true + }) } -export function stripFallback(value: string): string { - return replaceVarFunctions(value, ({ name, full, fallback }) => { - if (!fallback) return full - return `var(${name.trim()})` - }) +export function stripFallback(value: string, syntax: CssValueSyntax = 'css'): string { + return replaceVarFunctions( + value, + ({ name, full, fallback }) => { + if (!fallback) return full + return `var(${name.trim()})` + }, + syntax + ) } export function normalizeStyleValue(raw: string): string { if (!raw) return '' let val = preprocessCssValue(raw) val = stripFallback(val) - val = val.replace(ZERO_UNITS_RE, '$10') + val = transformCssValue(val, (value) => value.replace(ZERO_UNITS_RE, '$10')) return val.trim() } @@ -1159,27 +1347,52 @@ export function pruneInheritedTextStyles( } export function canonicalizeValue(key: string, value: string): string { - const trimmed = value.trim().toLowerCase() + const trimmed = value.trim() + const lower = trimmed.toLowerCase() - if (/^0+(\.0+)?(px|%|rem|em)?$/.test(trimmed)) { + if (/^0+(\.0+)?(px|%|rem|em)?$/.test(lower)) { return '0' } - if (key === 'color') { - const hex = canonicalizeColor(trimmed) + if (key === 'color' && isLosslessColor(lower)) { + const hex = canonicalizeColor(lower) if (hex) return hex } if (key === 'font-weight') { - if (trimmed === 'normal') return '400' - if (trimmed === 'bold') return '700' + if (lower === 'normal') return '400' + if (lower === 'bold') return '700' } - if (key === 'line-height' && trimmed === 'normal') { - return 'normal' + // Only known keywords and literals are case-insensitive. Variable names, + // quoted strings and their whitespace must survive inheritance comparisons. + return TEXT_STYLE_DEFAULTS.get(key) === lower ? lower : trimmed +} + +function isLosslessColor(value: string): boolean { + if (/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/.test(value)) return true + + const parts = value + .match(/^rgba?\(([^)]+)\)$/)?.[1] + ?.split(',') + .map((part) => part.trim()) + if ( + !parts || + (parts.length !== 3 && parts.length !== 4) || + !parts.every((part) => /^[+-]?(?:\d+|\d*\.\d+)(?:e[+-]?\d+)?$/.test(part)) + ) { + return false } - return trimmed.replace(ALL_WHITESPACE_RE, '') + // The diagnostic color formatter rounds channels and alpha percentages. + // That approximation must not remove a distinct inherited text override. + const alpha = Number(parts[3] ?? 1) + return ( + parts.slice(0, 3).every((part) => Number.isInteger(Number(part))) && + alpha >= 0 && + alpha <= 1 && + Math.round(alpha * 100) / 100 === alpha + ) } function formatHex(r: number, g: number, b: number): string { diff --git a/packages/extension/utils/figma-paint.ts b/packages/extension/utils/figma-paint.ts index 0800c8c8..93a02107 100644 --- a/packages/extension/utils/figma-paint.ts +++ b/packages/extension/utils/figma-paint.ts @@ -21,6 +21,24 @@ export function getCssPaintLayers(paints: readonly Paint[]): { paint: Paint; ind .reverse() } +/** CSS background-color sits below every image layer, so only the bottom paint can supply it. */ +export function getBackgroundColorPaint( + paints: readonly Paint[] +): { paint: SolidPaint; index: number } | null { + const index = paints.findIndex(isRenderablePaint) + const paint = paints[index] + if ( + !paint || + paint.type !== 'SOLID' || + !paint.color || + (paint.opacity ?? 1) !== 1 || + (paint.blendMode !== undefined && paint.blendMode !== 'NORMAL') + ) { + return null + } + return { paint, index } +} + export function getPaintResolutionSize(node: SceneNode): PaintResolutionSize | undefined { if (!('width' in node) || !('height' in node)) return undefined diff --git a/packages/extension/utils/figma-style/gradient.ts b/packages/extension/utils/figma-style/gradient.ts index c28088b2..958aa70e 100644 --- a/packages/extension/utils/figma-style/gradient.ts +++ b/packages/extension/utils/figma-style/gradient.ts @@ -2,14 +2,17 @@ * Gradient and color utilities for Figma styles */ -import { getCssPaintLayers } from '@/utils/figma-paint' +import { getCssPaintLayers, isRenderablePaint, isVisiblePaint } from '@/utils/figma-paint' import { getVariableCssName } from '@/utils/figma-variables' import type { FigmaLookupReaders, PaintList, PaintResolutionSize } from './types' import { formatHexAlpha, splitByTopLevelComma } from '../css' -type ResolvedBackgroundFill = { kind: 'color'; value: string } | { kind: 'layers'; value: string[] } +type ResolvedBackgroundFill = + | { kind: 'color'; value: string } + | { kind: 'layers'; value: string[] } + | { kind: 'native' } type BackgroundFillResolverOptions = { background?: string @@ -25,6 +28,8 @@ type BackgroundFillResolverOptions = { ) => string | null } +type BackgroundPaintLayers = { paint: Paint; index: number; sources: string[] } + function isGradientPaint(paint: Paint): paint is GradientPaint { return 'gradientStops' in paint && Array.isArray(paint.gradientStops) } @@ -158,18 +163,28 @@ export function resolveBackgroundFillFromPaints( const resolveGradient = options.resolveGradientPaint ?? resolveGradientPaintCss const resolveSolid = options.resolveSolidPaint ?? resolveSolidPaint - const sourceLayers = splitByTopLevelComma(options.background ?? '') + const background = options.background ?? '' + const sourceLayers = matchBackgroundPaintLayers(background, paints) - if (visible.length === 1) { + if (visible.length === 1 && (!sourceLayers || sourceLayers.length === 1)) { const { paint, index } = visible[0]! if (isSolidPaint(paint)) { const color = resolveSolid(paint, readers, index) return color ? { kind: 'color', value: color } : null } } + if (!sourceLayers && /(?:linear|radial|conic)-gradient\s*\(/i.test(background)) { + // Keep every native channel; returning replacement layers would clear a separate base color. + return { kind: 'native' } + } const layers: string[] = [] - for (const [layerIndex, { paint, index }] of visible.entries()) { + for (const { paint, index, sources } of sourceLayers ?? + visible.map((entry) => ({ ...entry, sources: undefined }))) { + if (!isRenderablePaint(paint)) { + layers.push(...(sources ?? [])) + continue + } if (isSolidPaint(paint)) { const color = resolveSolid(paint, readers, index) if (!color) return null @@ -178,25 +193,202 @@ export function resolveBackgroundFillFromPaints( } if (!isGradientPaint(paint)) return null - let gradient = resolveGradient(paint, size, readers) - if (!gradient) return null - if (paint.type === 'GRADIENT_RADIAL' && sourceLayers.length === visible.length) { - gradient = preserveRadialGeometry(sourceLayers[layerIndex]!, gradient) - } + const resolved = resolveGradient(paint, size, readers) + if (!resolved) return null + const gradient = sources?.every((source) => + /^(?:linear|radial|conic)-gradient\s*\(/i.test(source) + ) + ? sources + .map((source) => preserveGradientGeometry(source, resolved, paint.type) ?? source) + .join(', ') + : resolved layers.push(gradient) } return { kind: 'layers', value: layers } } -function preserveRadialGeometry(source: string, resolved: string): string { - const match = source.match(/^radial-gradient\(([\s\S]*)\)$/i) - if (!match) return resolved - const geometry = splitByTopLevelComma(match[1]!)[0] - if (!geometry || !isRadialGeometry(geometry)) return resolved - const args = splitByTopLevelComma(resolved.slice('radial-gradient('.length, -1)) - if (args[0] && isRadialGeometry(args[0])) args.shift() - return `radial-gradient(${[geometry, ...args].join(', ')})` +/** Pair complete native CSS stacks with their paints, including Figma's diamond quadrants. */ +export function matchBackgroundPaintLayers( + background: string, + paints: ReadonlyArray +): BackgroundPaintLayers[] | null { + const layers = splitByTopLevelComma(background) + const groups: BackgroundPaintLayers[] = [] + let offset = 0 + for (let index = paints.length - 1; index >= 0; index--) { + const paint = paints[index]! + if (!isVisiblePaint(paint)) continue + const sources = paintSourceLayers(paint, layers, offset) + const transparent = sources?.every(isTransparentLayer) ?? false + if (!isRenderablePaint(paint)) { + // Native CSS can omit an invisible paint or retain its transparent layer. + // Consume only proven transparent output; never shift a visible neighbor. + if ((!isSolidPaint(paint) && !isGradientPaint(paint)) || !transparent) continue + } else if (transparent) { + // A renderable paint cannot establish ownership of an all-transparent + // source. Keep native CSS if the stack cannot be matched unambiguously. + return null + } + if (!sources) return null + groups.push({ paint, index, sources }) + offset += sources.length + } + return offset === layers.length ? groups : null +} + +function paintSourceLayers(paint: Paint, layers: string[], offset: number): string[] | null { + const diamond = + paint.type === 'GRADIENT_DIAMOND' && /^linear-gradient\s*\(/i.test(layers[offset] ?? '') + const count = diamond ? 4 : 1 + const group = layers.slice(offset, offset + count) + if (group.length !== count) return null + if (!diamond) return group + const corners = new Set() + for (const layer of group) { + const gradient = parseGradient(layer) + const corner = /^\s+(top|bottom)\s+(left|right)\s*\/\s*50%\s+50%\s+no-repeat\s*$/i.exec( + gradient?.suffix ?? '' + ) + if (gradient?.fn !== 'linear-gradient' || !corner) return null + const position = `${corner[1]!.toLowerCase()} ${corner[2]!.toLowerCase()}` + if (gradient.geometry?.toLowerCase() !== `to ${position}`) return null + corners.add(position) + } + return corners.size === 4 ? group : null +} + +function isTransparentLayer(value: string): boolean { + const gradient = parseGradient(value) + return gradient + ? gradient.stops.every((stop) => isTransparentColor(stop.color)) + : isTransparentColor(value) +} + +function isTransparentColor(value: string): boolean { + value = value.trim().toLowerCase() + if (value === 'transparent' || /^#(?:[\da-f]{3}0|[\da-f]{6}00)$/.test(value)) return true + const match = /^rgba\(([^()]*)\)$/.exec(value) + if (!match) return false + const parts = match[1]!.split(',').map((part) => part.trim()) + return ( + parts.length === 4 && + parts.every((part) => /^[+-]?(?:\d*\.)?\d+$/.test(part)) && + parts.slice(0, 3).every((part) => Number(part) >= 0 && Number(part) <= 255) && + Number(parts[3]) === 0 + ) +} + +export function preserveGradientGeometry( + source: string, + resolved: string, + paintType?: GradientPaint['type'] +): string | null { + const original = parseGradient(source) + const replacement = parseGradient(resolved) + if (!original || !replacement) return null + const diamondQuadrant = + paintType === 'GRADIENT_DIAMOND' && + original.fn === 'linear-gradient' && + replacement.fn === 'radial-gradient' + if (original.fn !== replacement.fn && !diamondQuadrant) return null + + let first = 0 + let last = original.stops.length + if (original.stops.length !== replacement.stops.length && replacement.stops.length !== 1) { + if (original.fn !== 'conic-gradient') return null + // Figma closes angular gradients with interpolated 0/360-degree stops when + // the authored stops do not reach those endpoints. Their native colors + // already include opacity; only the authored stop colors need replacement. + const firstPosition = gradientAngle(replacement.stops[0]!.position) + const lastPosition = gradientAngle(replacement.stops.at(-1)!.position) + if ( + firstPosition !== null && + firstPosition.value > 0 && + gradientAngle(original.stops[0]!.position)?.value === 0 + ) + first++ + if ( + lastPosition !== null && + lastPosition.value < 1 && + gradientAngle(original.stops.at(-1)!.position)?.value === 1 + ) + last-- + for (const [index, stop] of replacement.stops.entries()) { + const sourcePosition = gradientAngle(original.stops[index + first]?.position ?? '') + const targetPosition = gradientAngle(stop.position) + if ( + !sourcePosition || + !targetPosition || + Math.abs(sourcePosition.value - targetPosition.value) > + sourcePosition.rounding + targetPosition.rounding + ) + return null + } + } + if (last - first !== replacement.stops.length && replacement.stops.length !== 1) return null + const stops = original.stops.map((stop, index) => { + if (index < first || index >= last) return stop.color + stop.position + const replacementStop = replacement.stops[index - first] ?? replacement.stops[0]! + return replacementStop.color + stop.position + }) + const geometry = original.geometry + return `${original.fn}(${[...(geometry ? [geometry] : []), ...stops].join(', ')})${original.suffix}` +} + +function gradientAngle(position: string): { value: number; rounding: number } | null { + const match = /^\s*([+-]?(?:\d*\.)?\d+)(%|deg)\s*$/.exec(position) + if (!match) return null + const decimals = match[1]!.split('.')[1]?.length ?? 0 + // Native CSS uses degrees; generated paint stops use formatPercent's two decimals. + return match[2] === 'deg' + ? { value: Number(match[1]) / 360, rounding: (0.5 * 10 ** -decimals) / 360 } + : { value: Number(match[1]) / 100, rounding: 0.00005 } +} + +function parseGradient(value: string): { + fn: string + geometry?: string + stops: { color: string; position: string }[] + suffix: string +} | null { + value = value.trim() + const match = /^(linear-gradient|radial-gradient|conic-gradient)\s*\(/i.exec(value) + if (!match) return null + const fn = match[1]!.toLowerCase() + const start = match[0].length + let depth = 1 + let end = start + for (; end < value.length; end++) { + if (value[end] === '(') depth++ + else if (value[end] === ')' && --depth === 0) break + } + if (depth !== 0) return null + const args = splitByTopLevelComma(value.slice(start, end)) + let geometry: string | undefined + if ( + args[0] && + (fn === 'radial-gradient' + ? isRadialGeometry(args[0]) + : /^(?:to\s|from\s|at\s|in\s|[+-]?(?:\d*\.)?\d+(?:deg|grad|rad|turn)(?:\s|$))/i.test(args[0])) + ) { + geometry = args.shift() + } + const stops: { color: string; position: string }[] = [] + for (const arg of args) { + if (!/^(?:#[\da-f]+|[a-z][\w-]*(?:\(|(?=\s|$)))/i.test(arg)) return null + let colorEnd = 0 + depth = 0 + for (; colorEnd < arg.length; colorEnd++) { + const ch = arg[colorEnd]! + if (ch === '(') depth++ + else if (ch === ')') depth-- + else if (/\s/.test(ch) && depth === 0) break + } + if (depth !== 0) return null + stops.push({ color: arg.slice(0, colorEnd), position: arg.slice(colorEnd) }) + } + return stops.length ? { fn, geometry, stops, suffix: value.slice(end + 1) } : null } function isRadialGeometry(value: string): boolean { @@ -252,13 +444,12 @@ export function formatGradientStopColor( try { const v = readers.getVariableById(bound.id) if (v) { - const fallbackOpaque = formatHexAlpha(stop.color, 1) - const fallbackAlpha = formatHexAlpha(stop.color, alpha) + const fallback = formatHexAlpha(stop.color, baseAlpha) const cssVarName = getVariableCssName(v) - const varName = `var(${cssVarName}, ${fallbackOpaque})` - if (alpha >= 0.99) return `var(${cssVarName}, ${fallbackAlpha})` - // Use color-mix for transparency - const pct = Math.round(alpha * 10000) / 100 + const varName = `var(${cssVarName}, ${fallback})` + // Bound stop alpha is already in the variable; only paint opacity is independent. + if (fillOpacity >= 1) return varName + const pct = Math.round(Math.max(0, Math.min(1, fillOpacity)) * 10000) / 100 return `color-mix(in srgb, ${varName} ${pct}%, transparent)` } } catch { diff --git a/packages/extension/utils/figma-style/style-resolver.ts b/packages/extension/utils/figma-style/style-resolver.ts index 8543f794..ef0d8892 100644 --- a/packages/extension/utils/figma-style/style-resolver.ts +++ b/packages/extension/utils/figma-style/style-resolver.ts @@ -11,8 +11,13 @@ import type { PaintVariableBindings } from './types' -import { formatHexAlpha, normalizeFigmaVarName, replaceVarFunctions } from '../css' -import { getCssPaintLayers, getPaintResolutionSize } from '../figma-paint' +import { + formatHexAlpha, + normalizeFigmaVarName, + replaceVarFunctions, + splitByTopLevelComma +} from '../css' +import { getBackgroundColorPaint, getCssPaintLayers, getPaintResolutionSize } from '../figma-paint' import { getVariableCssName, resolveVariableAlias } from '../figma-variables' import { resolveBackgroundFillFromPaints, @@ -20,7 +25,6 @@ import { resolveSolidFromPaints } from './gradient' -const BG_URL_LIGHTGRAY_RE = /url\(.*?\)\s+lightgray\b/i const BG_URL_RE = /url\(/i const DEFAULT_READERS: FigmaLookupReaders = { @@ -244,6 +248,20 @@ function splitByTopLevelWhitespace(input: string): string[] { return out } +export function stripFigmaImageFallback(value: string): string { + if (!/lightgray/i.test(value)) return value + let changed = false + const layers = splitByTopLevelComma(value).map((layer) => { + const tokens = splitByTopLevelWhitespace(layer) + if (!tokens.some((token) => /^url\(/i.test(token))) return layer + const retained = tokens.filter((token) => token.toLowerCase() !== 'lightgray') + if (retained.length === tokens.length) return layer + changed = true + return retained.join(' ') + }) + return changed ? layers.join(', ') : value +} + function isVarFunctionToken(value: string): boolean { const trimmed = value.trim() return trimmed.startsWith('var(') && trimmed.endsWith(')') @@ -301,6 +319,7 @@ function applyResolvedBackgroundFill( resolved: ResolvedBackgroundFill | null ): boolean { if (!resolved) return false + if (resolved.kind === 'native') return true if (!style.background && !style['background-color']) { return false @@ -312,18 +331,9 @@ function applyResolvedBackgroundFill( return true } - if (style.background) { - style['background-color'] = resolved.value - delete style.background - return true - } - - if (style['background-color']) { - style['background-color'] = resolved.value - return true - } - - return false + style['background-color'] = resolved.value + if (style.background) delete style.background + return true } /** @@ -361,20 +371,31 @@ export async function resolveStylesFromNodeData( ? { ...resolvedFillBase, solidColor: safeFillStyleNameVarExpr } : resolvedFillBase + const hasUrlBackground = + BG_URL_RE.test(processed.background ?? '') || + BG_URL_RE.test(processed['background-image'] ?? '') // Remove Figma's default lightgray fallback for image fills. - if ( - processed.background && - BG_URL_LIGHTGRAY_RE.test(processed.background) && - effectiveFillPaints - ) { - if (resolvedFill?.solidColor) { - processed['background-color'] = resolvedFill.solidColor + const imageBackground = + processed.background && effectiveFillPaints + ? stripFigmaImageFallback(processed.background) + : processed.background + if (imageBackground && imageBackground !== processed.background && effectiveFillPaints) { + const base = getBackgroundColorPaint(effectiveFillPaints) + const baseColor = base + ? (safeFillStyleNameVarExpr ?? + resolveSolidPaintWithBindings( + base.paint, + base.index, + effectiveFillSource.bindings, + readers + )) + : null + if (baseColor) { + processed['background-color'] = baseColor } - processed.background = processed.background.replace(/\s*,?\s*lightgray\b/i, '').trim() + processed.background = imageBackground } - const hasUrlBackground = - typeof processed.background === 'string' && BG_URL_RE.test(processed.background) const resolvedBackgroundFill = hasUrlBackground ? null : resolveBackgroundFillFromPaints(effectiveFillPaints, input.dimensions, readers, { @@ -389,16 +410,16 @@ export async function resolveStylesFromNodeData( ) // Process background/fill styles - if (!appliedResolvedBackgroundFill) { + if (!appliedResolvedBackgroundFill && !hasUrlBackground) { if (resolvedFill?.gradient) { - if (processed.background && !hasUrlBackground) { + if (processed.background) { processed.background = resolvedFill.gradient } else if (processed['background-color']) { processed.background = resolvedFill.gradient delete processed['background-color'] } } else if (resolvedFill?.solidColor) { - if (processed.background && !hasUrlBackground) { + if (processed.background) { // If it's a solid color, use background-color instead processed['background-color'] = resolvedFill.solidColor delete processed.background diff --git a/packages/extension/utils/figma-variables.ts b/packages/extension/utils/figma-variables.ts index 64d5f001..d77fda51 100644 --- a/packages/extension/utils/figma-variables.ts +++ b/packages/extension/utils/figma-variables.ts @@ -288,6 +288,11 @@ function collectPaintOrEffectVariableIds( ids, checkpoint ) + collectPaintOrEffectVariableIds( + (entry as { gradientStops?: unknown }).gradientStops, + ids, + checkpoint + ) }) } @@ -313,14 +318,9 @@ function collectPaintStyleVariableIds( // Keep cancellation outside native lookup recovery so the deadline propagates. checkpoint?.() collectVariableIdsFromValue(boundVariables, ids, checkpoint) - paints.forEach((paint) => { - checkpoint?.() - if (!paint || paint.visible === false) return - collectVariableIdsFromValue(paint.boundVariables, ids, checkpoint) - collectVariableIdsFromValue( - (paint as { variableReferences?: unknown }).variableReferences, - ids, - checkpoint - ) - }) + collectPaintOrEffectVariableIds( + paints.filter((paint) => paint && paint.visible !== false), + ids, + checkpoint + ) } diff --git a/packages/extension/utils/object.ts b/packages/extension/utils/object.ts index 7073e522..fe088ab0 100644 --- a/packages/extension/utils/object.ts +++ b/packages/extension/utils/object.ts @@ -60,7 +60,12 @@ function pruneValue(value: unknown, insideArray: boolean): unknown { const pruned = pruneValue(item, false) if (pruned === undefined) continue - result[key] = pruned + Object.defineProperty(result, key, { + value: pruned, + enumerable: true, + configurable: true, + writable: true + }) } if (Object.keys(result).length === 0) { diff --git a/packages/extension/utils/string.ts b/packages/extension/utils/string.ts index 49fa1911..fdeb63d9 100644 --- a/packages/extension/utils/string.ts +++ b/packages/extension/utils/string.ts @@ -35,10 +35,6 @@ export function escapeHTML(str: string) { return str.replace(ESCAPE_RE, (match) => ESCAPE_MAP[match]!) } -export function looseEscapeHTML(str: string) { - return str.replaceAll('"', '"') -} - const JS_SINGLE_QUOTE_ESCAPE_RE = /['\\\r\n\u2028\u2029]/g const JS_TEMPLATE_LITERAL_ESCAPE_RE = /[`\\]|\$\{/g @@ -68,8 +64,45 @@ export function escapeJsTemplateLiteralText(str: string) { ) } -export function stringify(value: unknown) { - return stringifyObject(value, { indent: ' ' }) +const JS_PROPERTY_NAME_RE = /^[$_\p{ID_Start}][$_\u200C\u200D\p{ID_Continue}]*$/u + +export function stringify(value: unknown): string { + const ancestors = new Set() + + function serialize(value: unknown, pad: string): string { + if (value === null || typeof value !== 'object') { + return stringifyObject(value, { indent: ' ' }) + } + const array = Array.isArray(value) + const prototype = Object.getPrototypeOf(value) + if (!array && prototype !== Object.prototype && prototype !== null) { + return stringifyObject(value, { indent: ' ' }) + } + if (ancestors.has(value)) return '"[Circular]"' + + // The library drops own __proto__ keys. Serialize data containers directly + // so this key remains a property when the generated expression is evaluated. + ancestors.add(value) + const indent = `${pad} ` + const entries = array + ? value.map((item) => `${indent}${serialize(item, indent)}`) + : Reflect.ownKeys(value) + .filter((key) => Object.prototype.propertyIsEnumerable.call(value, key)) + .map((key) => { + const name = + typeof key === 'symbol' || key === '__proto__' + ? `[${stringifyObject(key)}]` + : JS_PROPERTY_NAME_RE.test(key) + ? key + : stringifyObject(key) + return `${indent}${name}: ${serialize(Reflect.get(value, key), indent)}` + }) + ancestors.delete(value) + const [open, close] = array ? ['[', ']'] : ['{', '}'] + return entries.length ? `${open}\n${entries.join(',\n')}\n${pad}${close}` : `${open}${close}` + } + + return serialize(value, '') } export function indentAll(str: string, indent: string, skipFirst = false) { diff --git a/packages/extension/utils/tailwind.ts b/packages/extension/utils/tailwind.ts index f94a2621..5279b9eb 100644 --- a/packages/extension/utils/tailwind.ts +++ b/packages/extension/utils/tailwind.ts @@ -1,9 +1,9 @@ import { ALL_WHITESPACE_RE, - COMMA_DELIMITER_RE, - QUOTES_RE, WHITESPACE_RE, - normalizeStyleValue + normalizeStyleValue, + readCssName, + skipCssLiteral } from '@/utils/css' import { TAILWIND_ALIGN_ITEMS, @@ -116,10 +116,6 @@ function isAxis(f: string): f is Axis { return ['x', 'y'].includes(f) } -function formatFontFamily(val: string): string { - return val.replace(COMMA_DELIMITER_RE, ',').replace(WHITESPACE_RE, '_').replace(QUOTES_RE, '') -} - function formatGridTemplate(val: string): FormatterResult { const trimmed = val.trim() const match = trimmed.match(/^repeat\(\s*(\d+)\s*,\s*minmax\(\s*0\s*,\s*1fr\s*\)\s*\)$/i) @@ -578,8 +574,8 @@ export const TAILWIND_CONFIG: Record = { prefix: 'font', mode: 'direct', valueKind: 'any', - props: { v: 'font-family' }, - formatter: formatFontFamily + arbitraryType: 'family-name', + props: { v: 'font-family' } }, fontSize: { prefix: 'text', @@ -732,6 +728,7 @@ export const TAILWIND_CONFIG: Record = { mode: 'direct', valueKind: 'any', arbitraryType: 'position', + inlineNegative: true, keywords: [ ['center', 'center'], ['top', 'top'], @@ -750,7 +747,8 @@ export const TAILWIND_CONFIG: Record = { backgroundSize: { prefix: 'bg', mode: 'direct', - valueKind: 'keyword', + valueKind: 'any', + arbitraryType: 'length', keywords: ['auto', 'cover', 'contain'], props: { v: 'background-size' } }, @@ -758,7 +756,14 @@ export const TAILWIND_CONFIG: Record = { prefix: 'bg', mode: 'direct', valueKind: 'keyword', - keywords: ['no-repeat', 'repeat', 'repeat-x', 'repeat-y'], + keywords: [ + 'no-repeat', + 'repeat', + 'repeat-x', + 'repeat-y', + ['round', 'repeat-round'], + ['space', 'repeat-space'] + ], props: { v: 'background-repeat' } }, fill: { @@ -932,12 +937,6 @@ function extractValuePart( } } - if (inner.includes('calc')) { - inner = inner.replace(ALL_WHITESPACE_RE, '_') - } - - inner = formatArbitraryValue(inner) - const isStrictKeywordType = kind === 'keyword' let text = inner const isKeyword = overrideIsKeyword ?? isStrictKeywordType @@ -945,7 +944,7 @@ function extractValuePart( if (!isKeyword) { const shouldTag = config.arbitraryType && (inner.includes('var(') || config.valueKind === 'any') const customProperty = inner.match(/^var\((--[A-Za-z0-9_-]+)\)$/)?.[1] - const value = customProperty ?? inner + const value = customProperty ?? formatArbitraryValue(inner) const typedValue = shouldTag ? `${config.arbitraryType}:${value}` : value text = customProperty ? `(${typedValue})` : `[${typedValue}]` } @@ -1150,6 +1149,12 @@ export function cssToTailwind(rawStyle: Record): string { classes.push(...collapseCorners(config, familyKey, buf.corners)) } else if (config.mode === 'axis' && buf.axes) { classes.push(...collapseAxes(config, familyKey, buf.axes)) + } else if (familyKey === 'background' && buf.val) { + // bg-[…] is inferred as a color or image, never a CSS background shorthand. + classes.push(`[background:${formatArbitraryValue(buf.val)}]`) + } else if (familyKey === 'backgroundRepeat' && buf.val && /\s/.test(buf.val)) { + // Two-axis repeat values have no bg-* utility in either Tailwind 3 or 4. + classes.push(`[background-repeat:${formatArbitraryValue(buf.val)}]`) } else if (config.mode === 'direct' && buf.val) { classes.push(buildClass(config, '', extractValuePart(buf.val, config, familyKey))) } else { @@ -1167,8 +1172,55 @@ export function cssToTailwind(rawStyle: Record): string { return classes.join(' ') } +function encodeArbitrarySegment(value: string, inUrl = false): string { + // Consume CSS escapes before encoding class whitespace. Six hex digits remove + // the optional terminator space without joining the escape to the next token. + const normalized = value.replace( + /\\(?:([\da-f]{1,6})(?:\r\n|[ \t\r\n\f])?|(\r\n|[\r\n\f])|(.))/giu, + ( + _token: string, + hex: string | undefined, + continuation: string | undefined, + character: string + ) => { + if (hex) return `\\${hex.padStart(6, '0')}` + if (continuation) return '' + return `\\${character.codePointAt(0)!.toString(16).padStart(6, '0')}` + } + ) + // URL syntax whitespace can be trimmed only after escaped whitespace is encoded. + const input = inUrl ? normalized.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '') : normalized + return input.replace(/_|[ \t\r\n\f]/g, (token) => { + if (token === '_') return inUrl ? '_' : '\\_' + return inUrl ? `\\${token.charCodeAt(0).toString(16).padStart(6, '0')}` : '_' + }) +} + function formatArbitraryValue(value: string): string { - return value.trim().replace(/;+$/, '').replace(ALL_WHITESPACE_RE, '_') + const input = value.trim().replace(/;+$/, '') + let output = '' + for (let index = 0; index < input.length;) { + const start = index + const ch = input[index] + if (ch === '"' || ch === "'") { + index = skipCssLiteral(input, index, ch) + } else { + const name = readCssName(input, index) + index += name.length || 1 + if (name.toLowerCase() === 'url' && input[index] === '(') { + const end = skipCssLiteral(input, index, ')') + const closed = input[end - 1] === ')' + const argument = input.slice(index + 1, closed ? end - 1 : end) + // Tailwind preserves URL underscores. Trim syntax whitespace, then encode + // actual URL whitespace as CSS escapes to keep the class in one token. + output += `url(${encodeArbitrarySegment(argument, true)}${closed ? ')' : ''}` + index = end + continue + } + } + output += encodeArbitrarySegment(input.slice(start, index)) + } + return output } export function cssToClassNames(style: Record): string[] { diff --git a/packages/extension/utils/tempad.ts b/packages/extension/utils/tempad.ts index 446331d7..f95a3c0b 100644 --- a/packages/extension/utils/tempad.ts +++ b/packages/extension/utils/tempad.ts @@ -131,7 +131,7 @@ export function getTemPadComponent(node: SceneNode): TemPadComponent | null { } const libDisplayName = - tempadData?.libName && tempadData.libName in LIB_DISPLAY_NAMES + tempadData?.libName && Object.hasOwn(LIB_DISPLAY_NAMES, tempadData.libName) ? LIB_DISPLAY_NAMES[tempadData.libName] : null diff --git a/packages/extension/utils/variable-output.ts b/packages/extension/utils/variable-output.ts index 766dd223..eab50a68 100644 --- a/packages/extension/utils/variable-output.ts +++ b/packages/extension/utils/variable-output.ts @@ -1,7 +1,7 @@ import type { FigmaLookupReaders } from './figma-style/types' import { CANVAS_COUNTER_AXIS_SYNC_NAME, CANVAS_KEY_NAMESPACE } from './canvas-metadata' -import { normalizeCustomPropertyName, replaceVarFunctions } from './css' +import { normalizeCustomPropertyName, replaceVarFunctions, transformCssValue } from './css' import { collectNodeVariableIds, getSingleVariableId, @@ -26,7 +26,7 @@ type VariableProjectionContext = { variablesByCodeSyntax: Map variableSyntax?: Record } -type CodeSyntaxReplacement = { syntax: string; value: string; pattern: RegExp | null } +type CodeSyntaxReplacement = { value: string; pattern: RegExp | null } export type FormattedStyle = { style: Record @@ -100,6 +100,7 @@ function buildVariableProjectionContext( ): VariableProjectionContext { const variablesById = new Map() const variablesByCodeSyntax = new Map() + const ambiguousSyntax = new Set() const variableSyntax: Record = {} for (const id of nodeVariableIds ?? collectNodeVariableIds(node, readers)) { @@ -111,8 +112,13 @@ function buildVariableProjectionContext( if (!value) continue const syntax = value.trim() - if (syntax && !variablesByCodeSyntax.has(syntax)) { - variablesByCodeSyntax.set(syntax, variable) + if (syntax && !ambiguousSyntax.has(syntax)) { + if (variablesByCodeSyntax.has(syntax)) { + variablesByCodeSyntax.delete(syntax) + ambiguousSyntax.add(syntax) + } else { + variablesByCodeSyntax.set(syntax, variable) + } } const name = getVariableCssName(variable) @@ -173,23 +179,22 @@ function rewriteKnownCodeSyntaxValues( let tokenReplacements: CodeSyntaxReplacement[] | undefined for (const [key, value] of Object.entries(style)) { if (!value) continue - const exact = replacements.find((entry) => value.trim() === entry.syntax) - if (exact) { - style[key] = exact.value - continue - } - - tokenReplacements ??= replacements.map(({ syntax, value }) => ({ - syntax, - value, - pattern: /\s/.test(syntax) - ? null - : new RegExp( - `(^|[^A-Za-z0-9_-])(${syntax.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?=[^A-Za-z0-9_-]|$)`, - 'g' - ) - })) - style[key] = replaceKnownCodeSyntaxTokens(value, tokenReplacements) + style[key] = transformCssValue(value, (value) => { + const exact = replacements.find((entry) => value.trim() === entry.syntax) + if (exact) return exact.value + + tokenReplacements ??= replacements.map(({ syntax, value }) => ({ + value, + // NUL-delimited literal/var placeholders must stay opaque even to numeric syntax. + pattern: /\s/.test(syntax) + ? null + : new RegExp( + `(^|[^A-Za-z0-9_\0-])(${syntax.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})(?=[^A-Za-z0-9_\0-]|$)`, + 'g' + ) + })) + return replaceKnownCodeSyntaxTokens(value, tokenReplacements) + }) } } @@ -198,8 +203,10 @@ function replaceKnownCodeSyntaxTokens( replacements: CodeSyntaxReplacement[] ): string { const placeholders: string[] = [] + let delimiter = '\0' + while (value.includes(delimiter)) delimiter += '\0' let out = replaceVarFunctions(value, ({ full }) => { - const token = `__VAR_${placeholders.length}__` + const token = `${delimiter}${placeholders.length}${delimiter}` placeholders.push(full) return token }) @@ -209,9 +216,9 @@ function replaceKnownCodeSyntaxTokens( out = out.replace(pattern, (_match, prefix: string) => `${prefix}${replacement}`) } - return placeholders.reduce( - (next, placeholder, index) => next.replace(`__VAR_${index}__`, placeholder), - out + return out.replace( + new RegExp(`${delimiter}(\\d+)${delimiter}`, 'g'), + (_match, index: string) => placeholders[Number(index)]! ) } diff --git a/packages/extension/vitest.browser.config.ts b/packages/extension/vitest.browser.config.ts index bf6e7d06..70477539 100644 --- a/packages/extension/vitest.browser.config.ts +++ b/packages/extension/vitest.browser.config.ts @@ -4,13 +4,19 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' +import { workspaceSources } from './build/workspace-sources.ts' + const rootDir = fileURLToPath(new URL('.', import.meta.url)) export default defineConfig({ plugins: [vue()], + optimizeDeps: { + include: ['vue/compiler-sfc', 'prismjs'] + }, resolve: { conditions: ['source', 'module', 'browser', 'development|production'], alias: { + ...workspaceSources, '@': path.resolve(rootDir) } }, diff --git a/packages/extension/vitest.node.config.ts b/packages/extension/vitest.node.config.ts index 744de929..71850f84 100644 --- a/packages/extension/vitest.node.config.ts +++ b/packages/extension/vitest.node.config.ts @@ -7,14 +7,17 @@ import { EXTENSION_COVERAGE_FILES, EXTENSION_CORE_COVERAGE_FILES, EXTENSION_SOURCE_FILES -} from '../../vitest.coverage' +} from '../../vitest.coverage.ts' +import { workspaceSources } from './build/workspace-sources.ts' const rootDir = fileURLToPath(new URL('.', import.meta.url)) export default defineConfig({ + define: { __TEMPAD_RUNTIME_PROFILE__: JSON.stringify({ kind: 'release' }) }, resolve: { conditions: ['source', 'module', 'node', 'development|production'], alias: [ + ...Object.entries(workspaceSources).map(([find, replacement]) => ({ find, replacement })), // Keep node tests independent of build output, including SSR resolution. { find: /^@tempad-dev\/shared$/, diff --git a/packages/extension/wxt.config.ts b/packages/extension/wxt.config.ts index 567ec181..ac3618fe 100644 --- a/packages/extension/wxt.config.ts +++ b/packages/extension/wxt.config.ts @@ -1,9 +1,12 @@ import { fileURLToPath } from 'node:url' +import { defaultClientConditions } from 'vite' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import { defineConfig } from 'wxt' +import { checkoutProfile } from '../../agent-plugin/src/clients/shared/runtime-profile.mjs' import { computeExtensionRuntimeFingerprint } from '../../scripts/extension-runtime-fingerprint.mjs' import { pluginSandboxWorkers } from './build/plugin-sandbox-workers' +import { workspaceSources } from './build/workspace-sources' import { MCP_LOCAL_HOST_ORIGIN } from './mcp/permissions' import packageJson from './package.json' with { type: 'json' } @@ -11,6 +14,17 @@ const newElements = ['selectedcontent'] const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)) const extensionRuntimeFingerprint = computeExtensionRuntimeFingerprint(repositoryRoot) +export function extensionRuntimeProfile( + command: string, + checkout = process.env.TEMPAD_MCP_DEV_CHECKOUT +) { + if (checkout !== undefined && checkoutProfile(checkout).id !== checkoutProfile(repositoryRoot).id) + throw new Error('The extension build does not belong to TEMPAD_MCP_DEV_CHECKOUT.') + return checkout !== undefined || command === 'serve' + ? checkoutProfile(repositoryRoot) + : { kind: 'release' as const } +} + export function getPluginSandboxCsp(development: boolean): string { const connectSources = development ? 'ws://localhost:* ws://127.0.0.1:*' : "'none'" @@ -28,6 +42,21 @@ export function getPluginSandboxCsp(development: boolean): string { } export default defineConfig({ + hooks: { + 'build:before': (wxt) => { + const profile = extensionRuntimeProfile(wxt.config.command) + wxt.logger.info( + `TemPad MCP profile: ${profile.kind}${profile.kind === 'checkout' ? ` (${profile.id})` : ''}` + ) + }, + 'zip:start': (wxt) => { + if (extensionRuntimeProfile(wxt.config.command).kind !== 'release') + throw new Error( + 'Cannot package a checkout extension for release. Unset TEMPAD_MCP_DEV_CHECKOUT and run pnpm zip again.' + ) + } + }, + alias: workspaceSources, modules: ['@wxt-dev/module-vue'], vue: { vite: { @@ -39,6 +68,7 @@ export default defineConfig({ } }, vite: (env) => ({ + resolve: { conditions: ['source', ...defaultClientConditions] }, plugins: [cssInjectedByJsPlugin(), pluginSandboxWorkers(env.command === 'serve')], server: env.command === 'serve' ? { cors: true } : undefined, optimizeDeps: { @@ -46,6 +76,7 @@ export default defineConfig({ }, define: { __DEV__: env.mode === 'development', + __TEMPAD_RUNTIME_PROFILE__: JSON.stringify(extensionRuntimeProfile(env.command)), __TEMPAD_RUNTIME_FINGERPRINT__: JSON.stringify(extensionRuntimeFingerprint) } }), diff --git a/packages/mcp-server/AGENTS.md b/packages/mcp-server/AGENTS.md index 356d0453..4a302cec 100644 --- a/packages/mcp-server/AGENTS.md +++ b/packages/mcp-server/AGENTS.md @@ -67,19 +67,9 @@ pnpm -C packages/mcp-server test:coverage ## Code style and output examples -Tools must be registered via `TOOL_DEFS` and mapped to targets. - -Good: - -```ts -export const TOOL_DEFS = { - get_code: defineTool({ - target: 'extension', - inputSchema: GetCodeParametersSchema, - formatter: formatGetCode - }) -} -``` +Register tools in [`TOOL_DEFS`](src/tools.ts), following its existing `extTool` +or `hubTool` definitions. Extension tools provide a response formatter; Hub tools +may declare an output schema. Avoid embedding binary data directly: @@ -102,14 +92,8 @@ return { image: largeBase64 } ## Testing notes - Follow repo-wide testing workflow in `TESTING.md`. -- Coverage architecture and strict pure inventory are documented in `docs/testing/architecture.md`. +- Coverage architecture is documented in [`docs/testing/architecture.md`](../../docs/testing/architecture.md). - Keep pure-function tests under `tests/**/*.test.ts` with deterministic inputs. -- Strict pure coverage in this package targets: - - `src/asset-utils.ts` - - `src/tools.ts` - - `src/config.ts` - - `src/request.ts` - - `src/asset-store.ts` - - `src/asset-http-server.ts` - - `src/shared.ts` +- Coverage scope and thresholds come from [`vitest.coverage.ts`](../../vitest.coverage.ts) + and are applied by this package's [`vitest.config.ts`](vitest.config.ts). - Cross-package contract changes should be validated in order: `packages/shared` -> `packages/mcp-server` -> `packages/extension`. diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index a5acb7d1..00f7f064 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -1,19 +1,54 @@ # Changelog -## Unreleased +## 1.0.0 + +### Runtime updates + +- Added verified automatic Hub updates on macOS, Linux and Windows. Compatible updates wait until + connected clients are idle and support switching, keeping their MCP connections open. +- Added update status, `off` and `pinned` policies, explicit version switching with confirmation, + and recovery if a replacement fails. Development checkouts never download releases. +- Preserved asset URLs and task protections across updates. Failed downloads leave the current Hub + running; damaged unused downloads can be replaced. +- When upgrading from 0.11.0 or earlier, disconnect all TemPad MCP clients once before restarting + their MCP servers to enable runtime updates. + +### Tasks and feedback - Fixed Claude task routing after reconnects and `/clear`, including sessions launched from Codex. -- Kept stopped tasks cancelled across reconnects and restarts, and prevented overlapping canvas - writes while an earlier operation's outcome remains uncertain. -- Improved connection recovery, permission error messages, and reliability when saving tasks and assets. +- Kept Codex queues, feedback and Stop tied to each agent's configuration when multiple clients share a Hub. +- Kept Stop effective across reconnects and restarts, including for completed, resumable tasks, + without interrupting a later conversation turn. +- Prevented restored reviews from sending feedback to unrelated conversations, and retained + uncertain feedback receipts instead of risking duplicate delivery. - Refreshed completion summaries after resuming a design task. + +### Canvas recovery + +- Prevented overlapping canvas edits after a Hub restart when an earlier operation may still be + running. Added `--resolve-runtime-block` for recovery that preserves Stop and requires readback. +- Blocked further canvas writes after an unknown result or unconfirmed rollback until the affected + content is read back. This protection survives Stop, new tasks, and Hub restarts; insufficient + reads explain what remains. Requests rejected before editing do not require an extra read. +- Added safe recovery for damaged task storage, keeping old tasks retired and recovery errors + readable by MCP clients. Preserved saved records across version changes. + +### Storage and connections + +- Isolated development Hubs from installed releases with separate ports and storage. - Added automatic cleanup of retired task details and confirmed feedback receipts after 30 days, retaining current open reviews, Stop protections, and feedback with uncertain delivery. - Capped each process log at 10 MiB. Logs from exited processes are removed after seven days without updates. -- Hardened local storage and Unix socket access. -- Prevented restored reviews from sending feedback to unrelated conversations. -- Added recovery guidance for damaged task storage while preserving Stop protections. +- Tightened local file and Unix socket permissions and made storage failures actionable. +- Fixed concurrent uploads of identical assets, asset expiry during downloads, and storage + accounting after failed cleanup. +- Prevented malformed asset requests from disconnecting agents and released resources after + cancelled downloads. +- Prevented repeated disconnected reads from exhausting request capacity, and ignored trailing + messages from rejected or replaced connections. +- Showed progress during longer Hub startup and prevented the agent connection process from + hanging after startup fails. ## 0.11.0 diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 26c4432f..480ceb8f 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -13,7 +13,7 @@ Requires the TemPad Dev extension, an open panel, and **MCP access** enabled. Ca "mcpServers": { "tempad-dev": { "command": "npx", - "args": ["-y", "@tempad-dev/mcp@latest"] + "args": ["-y", "@tempad-dev/mcp@latest", "--runtime-updates=stable"] } } } @@ -21,7 +21,44 @@ Requires the TemPad Dev extension, an open panel, and **MCP access** enabled. Ca For agent-specific setup, open TemPad Dev's **Preferences → Agent integration → Set up agents**. It provides guided paths for Codex, Cursor, Claude Code, Gemini, VS Code, OpenCode, and TRAE, plus **Other** for compatible clients not listed. -Supported tools/resources: +### Runtime updates (1.0.0+) + +Verified releases enable background downloads and compatible automatic Hub updates on +macOS, Linux and Windows by default, including existing `npx @latest` configurations after they restart onto +1.0.0 or later. The configuration above and Agent Plugin 1.0.0 select this policy explicitly +with `--runtime-updates=stable`. First enrollment requires closing all existing TemPad MCP +connections once, then restarting them. It preserves the release task and asset stores. +Until that restart, new clients keep using an existing legacy Hub without interrupting it. +Checkout development builds use a separate local configuration and never fetch release runtimes. + +In a terminal using the same runtime configuration: + +- `npx -y @tempad-dev/mcp@latest --runtime-update-status` shows the active/staged versions and blockers. +- `npx -y @tempad-dev/mcp@latest --check-runtime-update` searches for the newest compatible stable + release, no newer than npm's `latest` tag. Large histories resume in bounded batches; status + reports the remaining search and why the latest version was skipped. +- `npx -y @tempad-dev/mcp@latest --update-runtime ` asks you to confirm the exact version + and integrity before switching. Incompatible tool contracts/guidance, unfinished operations, + legacy Claude hooks, and attached clients without opt-in prevent activation. + +Automatic activation requires initialized MCP clients, all using the stable update policy and +capable of waiting through a switch. Claude also requires a verified session with receipt/retry-capable +hooks; unknown sessions and older hooks keep updates staged. +After five seconds without calls, transfers or native actions, the supervisor rechecks eligibility +and switches locally. New, undispatched requests wait up to ten seconds; dispatched writes are +never replayed. Busy work defers the update, and downloads or network failures leave the Hub serving. +Breaking release boundaries, incompatible contracts and guidance require an explicit update or restart. +Existing MCP sessions retain their tool descriptions through a compatible switch; +Figma reconnects and design tasks may require resume/read-back. The running edge and supervisor, +Node, browser extension, and installed plugin still require their normal upgrade/restart. +Use `--runtime-updates=off` to stop background checks when no enrolled +client remains. To enforce the launch package's exact version, use `--runtime-updates=pinned` +with a fixed npm version; a conflict reports a restart requirement instead of creating another Hub. +Unverified local builds keep their bundled runtime and never download releases; explicit enrollment +is rejected. Windows uses named pipes and ACL-checked private storage for managed updates. Its +[process CI](../../TESTING.md#windows-runtime-ci) is separate from installed desktop-host acceptance. + +### Tools and resources - `get_code`: Tailwind-first JSX/Vue markup plus assets and token references. - `get_design_system`: An immutable, deterministic catalog. It returns compact pages of component @@ -66,13 +103,12 @@ Notes: - Tool responses use a shared `64 KiB` inline budget measured on the `CallToolResult` body. When a selection is too large for the `get_code` budget, TemPad Dev may return a shell response instead of failing. The shell keeps the current node wrapper and lists omitted direct child ids in an inline code comment so agents can request them one by one. The accompanying warning stays lightweight and only points agents to that comment. - `apply_canvas` is available whenever MCP access is enabled and the current Figma Design file is editable. Dev Mode and view-only files remain read-only. -- MCP **0.11.0** pairs with extension **0.23.0** and Agent Plugin **0.4.0**. - The node-set and token-definition semantics above require matching protocol 15 Hub and extension +- The node-set and token-definition semantics above require matching protocol 15 Hub and extension builds. Extension 0.22.0 remains supported through protocol 13 for existing reads, sessions, tasks, and canvas writes. New read options prompt an upgrade, and responses identify older selection/token semantics. Update the extension and reload Figma for the new capabilities, update the plugin or installed skills, replace any alpha-pinned MCP configuration with `@tempad-dev/mcp@latest`, - then reconnect the MCP client and start a new task. See the + then reconnect the MCP client and start a new task. For minimum versions and update steps, see the [upgrade guide](https://github.com/ecomfe/tempad-dev/tree/main/agent-plugin/src#upgrading). - Assets are ephemeral and tool-linked. Local stdio clients receive `asset.localPath` when the Hub has the bytes and can open it without a loopback download; other clients use the @@ -91,7 +127,7 @@ Optional environment variables: - `TEMPAD_MCP_GET_CODE_TIMEOUT`: `get_code` timeout in milliseconds (default `30000`; falls back to `TEMPAD_MCP_TOOL_TIMEOUT` when that override is set). - `TEMPAD_MCP_GET_DESIGN_SYSTEM_TIMEOUT`: Initial `get_design_system` resource-discovery timeout in milliseconds (default `45000`; continuation, exact-ref, font, and page-list reads use the general timeout). Falls back to `TEMPAD_MCP_TOOL_TIMEOUT` when that override is set. - `TEMPAD_MCP_PAGE_STRUCTURE_TIMEOUT`: `get_structure` timeout for an explicit page ID or key, which may require loading a cold Figma page (default `45000` milliseconds; node and selection reads use the general timeout). Falls back to `TEMPAD_MCP_TOOL_TIMEOUT` when that override is set. -- `TEMPAD_MCP_APPLY_CANVAS_TIMEOUT`: `apply_canvas` slow-call warning threshold in milliseconds (default `120000`; falls back to `TEMPAD_MCP_TOOL_TIMEOUT` when that override is set). At twice this threshold, the Hub reports an unknown outcome and fences further writes until the result or session recovery resolves it. Inspect the canvas before retrying; the mutation may have completed. +- `TEMPAD_MCP_APPLY_CANVAS_TIMEOUT`: `apply_canvas` slow-call warning threshold in milliseconds (default `120000`; falls back to `TEMPAD_MCP_TOOL_TIMEOUT` when that override is set). At twice this threshold, the Hub reports an unknown outcome. Further writes require both definitive completion or session recovery and a read of that file returning at least one structure root or code result, or an exact `get_structure` page snapshot (including an empty page). Empty selections and failed node reads do not count. `CANVAS_APPLY_FAILED` also requires readback because rollback is unconfirmed, including with older extensions. `CANVAS_APPLY_ABORTED` confirms a preparation failure before writing and does not require an extra file read. Undo-boundary failures remain conservative because rollback verification covers only part of native state. The read requirement survives Hub restarts. Inspect the canvas before continuing; do not replay the write. - `TEMPAD_MCP_AUTO_ACTIVATE_GRACE`: Delay before auto-activating the sole connected extension (default `1500`). - `TEMPAD_MCP_MAX_ASSET_BYTES`: Maximum upload size for captured assets/screenshots in bytes (default `8388608`). - `TEMPAD_MCP_MAX_ASSET_STORE_BYTES`: Maximum aggregate size of the local asset store in bytes (default `268435456`). @@ -134,13 +170,30 @@ existing runtimes and correct ownership/permissions before reconnecting. Other p `/tempad-dev`. Explicit directory overrides take precedence. Unix clients and lifecycle hooks check the socket inode, ownership, and private permissions before connecting; an untrusted socket is rejected without launching a replacement Hub. Correct its ownership/permissions or shut -down the old runtime before reconnecting. Windows retains the released fixed named-pipe path; -per-user pipe isolation is not yet implemented. +down the old runtime before reconnecting. Windows retains the released compatibility pipe name; +managed control/data pipes are scoped by user and runtime directory. Windows ACL checks restrict +managed storage and endpoints to the current user, SYSTEM and Administrators rather than relying +on Unix permission bits. Untrusted paths or permissions fail startup. The hub accepts WebSocket handshakes only from Chrome extension origins on its root path. For a locked-down installation, configure `TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS` with the TemPad Dev extension origin shown by the browser. This origin check and the per-process asset capability reduce cross-origin loopback abuse, but do not authenticate local processes, including another OS user's process that binds the port first; see the [local MCP threat model](../../docs/security/local-mcp-threat-model.md). ## Task storage recovery +An operation that may still be executing keeps its file blocked across Hub restarts. Reading +or resuming does not release this exclusion. Reconnect the original Figma browser: a verified +completion, tab closure or document replacement releases execution, followed by required readback. +If that browser cannot return, run `npx -y @tempad-dev/mcp@latest --runtime-status` to inspect +the file and executor, then `npx -y @tempad-dev/mcp@latest --resolve-runtime-block ` +in an interactive terminal. Confirm only after closing the original execution environment. +This command cannot stop remote code; it retires that execution, preserves Stop fences and +requires an explicit task resume and readback. It does not replay a request or reset task storage. +The CLI is the only operator recovery interface; no MCP tool or Figma page can perform this action. + +An incompatible saved task may have been written by a newer Hub. In that case, upgrade the MCP +runtime and reconnect all clients. If the current version still rejects a record, preserve the +snapshot for diagnosis or restore a verified complete backup containing the latest Stop fences. +The recovery command deliberately refuses readable storage; do not delete it to bypass a block. + If task storage cannot be read safely, the error names the affected snapshot. Reads remain available, but canvas writes stay blocked across restarts because the snapshot may contain permanent Stop fences. The Hub preserves unreadable state in a `.corrupt` file when possible. @@ -149,10 +202,22 @@ To recover, disconnect all MCP clients and wait for the Hub to exit before chang Keep copies of the snapshot and any `.corrupt` file. Restore a verified complete snapshot that includes the latest Stop fences to the original path using an atomic replacement, then reconnect a client. A valid restored snapshot is accepted while the `.corrupt` evidence remains. +After upgrading from a version that quarantined newer data, `.corrupt` may itself be valid. +Verify that it is the latest complete snapshot before restoring it; its filename alone is not proof. An older backup or leftover `.tmp` file may predate a Stop action. Without a trustworthy complete -snapshot, automatic recovery cannot safely restore writes. Do not delete the quarantine marker -or replace the snapshot with empty state to bypass the fence. +snapshot, disconnect all MCP clients and run `npx -y @tempad-dev/mcp@latest --recover-task-storage`. +Run it with the same `TEMPAD_MCP_STATE_DIR` and `TEMPAD_MCP_RUNTIME_DIR` environment overrides +as the MCP client configuration; the command does not read that configuration. It prints the +resolved snapshot path even when recovery is refused. Confirm that it names the affected storage. +The command refuses an active writer or readable storage, preserves the original files in the +reported backup directory, and starts a new task generation. Every old task stays retired: +old reviews cannot restore execution, and taskless writes remain blocked. Every subsequent begin +must include `recoveryGeneration`, including in new sessions and after Hub restarts. A rejected +begin supplies it to clients in `_meta.recoveryGeneration` and to agents in the error text. Each new task +must read its target before writing. This requirement does not expire. Reads remain available. +Keep the backups; this operation does not recover task history. +Do not delete the quarantine marker or replace the snapshot with empty state to bypass the fence. ## Requirements diff --git a/packages/mcp-server/README.zh-Hans.md b/packages/mcp-server/README.zh-Hans.md index 42511a1b..90dab222 100644 --- a/packages/mcp-server/README.zh-Hans.md +++ b/packages/mcp-server/README.zh-Hans.md @@ -11,7 +11,7 @@ TemPad Dev 的 MCP server 将 coding agent 连接到浏览器中打开的 Figma "mcpServers": { "tempad-dev": { "command": "npx", - "args": ["-y", "@tempad-dev/mcp@latest"] + "args": ["-y", "@tempad-dev/mcp@latest", "--runtime-updates=stable"] } } } @@ -19,7 +19,39 @@ TemPad Dev 的 MCP server 将 coding agent 连接到浏览器中打开的 Figma 如需针对不同 agent 配置,请打开 TemPad Dev 的 **Preferences → Agent integration → Set up agents**。界面提供 Codex、Cursor、Claude Code、Gemini、VS Code、OpenCode 和 TRAE 的引导路径;未列出的兼容客户端可选择 **Other**。 -支持的工具和资源: +### 运行时更新(1.0.0+) + +经过校验的正式包在 macOS、Linux 和 Windows 上默认启用后台下载与兼容 Hub 自动更新,既有 `npx @latest` 配置 +重启到 1.0.0 或更高版本后也会启用。上述配置和 Agent Plugin 1.0.0 通过 +`--runtime-updates=stable` 明确选择此策略。首次启用需关闭所有现有 TemPad MCP 连接,再重新启动; +已有 release 任务和资源存储保持不变。 +在这次重启之前,新客户端继续使用仍在运行的旧 Hub,不会中断它。 +Checkout 开发构建使用独立的本地配置,始终不会获取线上运行时。 + +在使用相同运行配置的终端中: + +- `npx -y @tempad-dev/mcp@latest --runtime-update-status`:查看运行版本、候选版本和阻止切换的原因。 +- `npx -y @tempad-dev/mcp@latest --check-runtime-update`:查找不超过 npm `latest` 标签的最新兼容稳定版。 + 版本较多时分批继续查找;状态中会显示剩余进度,以及跳过最新版本的原因。 +- `npx -y @tempad-dev/mcp@latest --update-runtime `:确认具体版本及完整性摘要后切换。 + 工具契约或指导语不兼容、操作尚未结束、旧版 Claude hook,或仍有未启用更新的客户端连接时,均不会激活。 + +自动激活要求所有 MCP 客户端均已初始化、启用稳定版更新并支持切换等待。 +Claude 还需要已验证的会话和支持回执与重试的 hook;身份未确认或 hook 过旧时保持暂存。 +工具调用、传输和原生操作结束并空闲五秒后,supervisor 再次检查条件并执行本地切换。 +尚未派发的新请求最多等待十秒,已派发的写入绝不重放。 +忙碌时延后更新,下载或网络故障不会停止当前 Hub;跨破坏性版本边界、契约或指导语不兼容时,仍需显式更新或重启。 +兼容切换时,已有 MCP +会话保留原有工具说明;Figma 会重新连接,设计任务可能需要 resume 和读回。正在运行的入口进程、 +supervisor、Node、浏览器扩展和已安装插件仍需按原方式升级或重启。 +使用 `--runtime-updates=off`,最后一个启用更新的客户端断开后就会停止后台检查。 +若必须严格使用启动包的版本,请在固定 npm 版本的配置中加 `--runtime-updates=pinned`; +与当前 Hub 冲突时会提示重启,不会另起一个 Hub。 +未经校验的本地构建继续运行随包版本,不下载线上版本,且会拒绝显式启用更新。 +Windows 的托管更新使用命名管道和经过 ACL 校验的私有存储; +[进程级 CI](../../TESTING.md#windows-runtime-ci) 与已安装桌面宿主的交互验收分别进行。 + +### 工具和资源 - `get_code`:以 Tailwind 优先的 JSX/Vue 标记输出,并附带资源和变量引用。 - `get_design_system`:创建不可变、确定性的紧凑目录,按资源类型平衡分页返回可访问页面的 @@ -59,12 +91,11 @@ TemPad Dev 的 MCP server 将 coding agent 连接到浏览器中打开的 Figma - 工具响应共用 `64 KiB` 的 inline budget,按 `CallToolResult` 整体响应体积计算。若选区过大而超出 `get_code` 的预算,TemPad Dev 可能返回 shell response 而不是直接失败。shell 会保留当前节点的包裹结构,并在内联代码注释中列出被省略的直接子节点 id,方便 agent 逐个继续拉取;配套 warning 只保留最小化的提示信息,用来指向这条注释。 - 启用 MCP access 且当前 Figma Design 文件可编辑时,`apply_canvas` 即可使用;Dev Mode 和 只读文件仍不可写。 -- MCP **0.11.0** 配套扩展 **0.23.0** 和 Agent Plugin **0.4.0**。 - 上述多节点读取与 token 定义语义要求 Hub 和扩展均使用匹配的 protocol 15 构建。 +- 上述多节点读取与 token 定义语义要求 Hub 和扩展均使用匹配的 protocol 15 构建。 扩展 0.22.0 仍通过 protocol 13 支持既有读取、会话、任务和画布写入。新版读取选项会提示升级,响应会标注旧版选区和 token 语义。 请更新扩展并重新加载 Figma 以启用新版能力,再更新 plugin 或已安装的 skill, 将固定 alpha 版本的 MCP 配置改为 `@tempad-dev/mcp@latest`,然后重新连接 MCP client 并新建任务。 - 详见[升级指南](https://github.com/ecomfe/tempad-dev/blob/main/agent-plugin/src/README.zh-Hans.md#升级)。 + 最低版本要求和更新步骤详见[升级指南](https://github.com/ecomfe/tempad-dev/blob/main/agent-plugin/src/README.zh-Hans.md#升级)。 - 资源是临时且与工具调用关联的。本地 stdio client 在 Hub 持有字节时会收到 `asset.localPath`,可直接打开而不必经过 loopback 下载;其他 client 使用带 capability 的 HTTP `asset.url`。完整 URL 应视作临时密钥,不要持久化到日志中。 @@ -81,7 +112,7 @@ TemPad Dev 的 MCP server 将 coding agent 连接到浏览器中打开的 Figma - `TEMPAD_MCP_GET_CODE_TIMEOUT`:`get_code` 超时时间(毫秒,默认 `30000`;设置 `TEMPAD_MCP_TOOL_TIMEOUT` 时以其作为回退值)。 - `TEMPAD_MCP_GET_DESIGN_SYSTEM_TIMEOUT`:`get_design_system` 首次资源目录发现的超时时间(毫秒,默认 `45000`;续页、精确引用、字体和页面列表读取仍使用常规超时)。设置 `TEMPAD_MCP_TOOL_TIMEOUT` 时以其作为回退值。 - `TEMPAD_MCP_PAGE_STRUCTURE_TIMEOUT`:指定页面 ID 或 key 的 `get_structure` 超时时间(毫秒,默认 `45000`;冷页面可能需要先加载,节点和当前选择的读取仍使用常规超时)。设置 `TEMPAD_MCP_TOOL_TIMEOUT` 时以其作为回退值。 -- `TEMPAD_MCP_APPLY_CANVAS_TIMEOUT`:`apply_canvas` 慢调用告警阈值(毫秒,默认 `120000`;设置 `TEMPAD_MCP_TOOL_TIMEOUT` 时以其作为回退值)。达到阈值的两倍时,Hub 会报告结果未知并禁止后续写入,直到明确结果或会话恢复解决该状态。重试前请先检查画布,变更可能已完成。 +- `TEMPAD_MCP_APPLY_CANVAS_TIMEOUT`:`apply_canvas` 慢调用告警阈值(毫秒,默认 `120000`;设置 `TEMPAD_MCP_TOOL_TIMEOUT` 时以其作为回退值)。达到阈值的两倍时,Hub 会报告结果未知。继续写入前,既需要明确操作已结束或完成会话恢复,也需要读取该文件并获得至少一个结构根节点或代码结果,或通过 `get_structure` 获得指定页面的快照(含空页面);空选区和失败的节点读取不计入。`CANVAS_APPLY_FAILED` 也要求读回,因为回滚结果未获确认,旧版扩展的此类错误同样如此。`CANVAS_APPLY_ABORTED` 确认写入前准备失败,无需额外读回文件。Undo 边界内的失败仍保留读回要求,因为回滚验证仅覆盖部分原生状态。重启 Hub 不会清除读回要求。请先检查画布,不要重放写入。 - `TEMPAD_MCP_AUTO_ACTIVATE_GRACE`:仅一个扩展连接时自动激活前的延迟(默认 `1500`)。 - `TEMPAD_MCP_MAX_ASSET_BYTES`:截图/资源捕获的最大上传体积(字节,默认 `8388608`)。 - `TEMPAD_MCP_MAX_ASSET_STORE_BYTES`:本地资源存储总量上限(字节,默认 `268435456`)。 @@ -114,22 +145,43 @@ Linux 上,运行时会以 `0700` 权限创建 `<系统临时目录>/tempad-dev 一旦已有私有 UID 目录,旧目录消失或后来出现空目录都不会切回;两个目录同时存在 socket 或 Hub 锁,或当前用户目录中的这些文件权限不安全时,会拒绝自动迁移。请先关闭既有运行时并修正所有者和权限。 其他平台仍使用 `<系统临时目录>/tempad-dev`;显式目录覆盖优先。Unix 客户端与生命周期 hooks -连接前会检查 socket 类型、所有者和私有权限,不可信时直接拒绝,不另启 Hub。Windows 仍保留已发布的 -固定命名管道路径,尚未实现逐用户管道隔离。 +连接前会检查 socket 类型、所有者和私有权限,不可信时直接拒绝,不另启 Hub。Windows 保留已发布的 +兼容管道名,托管运行时的控制与数据管道按用户和运行目录隔离。Windows 使用实际 ACL 校验,将托管 +存储和端点的访问限制为当前用户、SYSTEM 与 Administrators,不依赖 Unix 权限位;路径或权限不可信时拒绝启动。 Hub 仅接受来自 Chrome 扩展 Origin、且目标为根路径的 WebSocket 握手。如需收紧安装环境,请将浏览器中显示的 TemPad Dev 扩展 Origin 写入 `TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS`。Origin 校验与每次进程启动生成的 asset capability 可以降低跨 Origin 的 loopback 滥用,但不能认证本机进程,包括先占用端口的其他操作系统用户进程;详见[本地 MCP 威胁模型](../../docs/security/local-mcp-threat-model.md)。 ## 任务存储恢复 +可能仍在执行的操作会在 Hub 重启后继续阻止该文件的写入;读取或恢复任务不会解除它。 +重新连接原来的 Figma 浏览器后,经过验证的完成回执、标签页关闭或文档替换可以解除执行阻塞,随后仍须读回。 +如果原来的浏览器无法再连接,请运行 `npx -y @tempad-dev/mcp@latest --runtime-status` +查看文件和执行环境,再在交互式终端运行 `npx -y @tempad-dev/mcp@latest --resolve-runtime-block `。 +只有关闭原来的执行环境后才能确认。命令无法停止远端代码;它会注销该次执行,保留 Stop 保护, +并要求显式恢复任务和读回,不会重放请求或重置任务存储。此操作仅提供终端入口,不向 MCP 工具或 Figma 页面开放。 + +不兼容的任务记录可能由新版 Hub 写入。这种情况下,请升级 MCP 运行时并重新连接所有客户端。 +如果当前版本仍拒绝该记录,请保留快照用于诊断,或恢复经过验证、包含最新 Stop 保护的完整备份。 +恢复命令会主动拒绝可读取的存储;不要删除存储来绕过保护。 + 任务存储无法安全读取时,错误会指出受影响的快照路径。读取仍然可用,但画布写入会在重启后 继续被阻止,因为快照可能包含永久 Stop 保护。Hub 会尽可能将无法读取的状态保存在 `.corrupt` 文件中。 恢复前,请断开所有 MCP 客户端,等待 Hub 退出,再修改状态。保留快照和任何 `.corrupt` 文件的副本, 将经过验证、包含最新 Stop 保护的完整快照原子替换到原路径,然后重新连接客户端。 恢复的有效快照可以正常使用,无需删除 `.corrupt` 中保留的证据。 - -旧备份或残留 `.tmp` 文件可能早于某次 Stop 操作。没有可信的完整快照时,无法安全地自动恢复写入。 -请勿删除隔离标记或用空状态替换快照来绕过保护。 +如果旧版本曾隔离它无法识别的新格式数据,升级后 `.corrupt` 本身可能有效。 +恢复前仍须确认它是最新的完整快照,不能仅凭文件名判断。 + +旧备份或残留 `.tmp` 文件可能早于某次 Stop 操作。没有可信的完整快照时,请断开所有 MCP 客户端, +运行 `npx -y @tempad-dev/mcp@latest --recover-task-storage`。请使用与 MCP 客户端配置相同的 +`TEMPAD_MCP_STATE_DIR` 和 `TEMPAD_MCP_RUNTIME_DIR` 环境变量;命令不会读取客户端配置。 +即使拒绝恢复,命令也会输出实际快照路径,请确认它指向受影响的存储。 +命令会拒绝仍有写入者或可正常读取的存储,将原文件保存到输出的备份目录,并创建新一代任务状态。 +所有旧任务继续失效:旧评论不能恢复执行,无任务的写入仍被阻止。此后每次 begin 都必须提供 +`recoveryGeneration`,包括新会话和 Hub 重启之后。被拒绝的 begin 会在 `_meta.recoveryGeneration` +中向客户端提供该值,并在错误文本中向 agent 提供。每个新任务写入前都必须读取目标,此要求不会过期。 +读取仍然可用。请保留备份;此操作不会恢复任务历史。不要删除隔离标记或用空状态绕过保护。 ## 要求 diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 9b207b69..918b3f76 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@tempad-dev/mcp", - "version": "0.11.0", + "version": "1.0.0", "description": "Connect coding agents to Figma for design inspection, native canvas editing, and UI implementation.", "license": "MIT", "repository": { @@ -31,7 +31,7 @@ "lint:fix": "eslint . --fix --ext .ts,.mts,.cts,.js,.mjs,.cjs", "format": "oxfmt -c ../../.oxfmtrc.json --ignore-path ../../.gitignore", "format:check": "oxfmt -c ../../.oxfmtrc.json --ignore-path ../../.gitignore --check", - "prepublishOnly": "pnpm run build" + "prepublishOnly": "node scripts/require-release-archive.mjs" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.32.1", @@ -52,6 +52,15 @@ "unplugin-raw": "^0.8.0", "vitest": "5.0.5" }, + "bundleDependencies": [ + "@modelcontextprotocol/sdk", + "nanoid", + "pino", + "pino-pretty", + "proper-lockfile", + "ws", + "zod" + ], "engines": { "node": "^22 || ^24 || >=26" } diff --git a/packages/mcp-server/runtime-policy.json b/packages/mcp-server/runtime-policy.json new file mode 100644 index 00000000..0d200bc5 --- /dev/null +++ b/packages/mcp-server/runtime-policy.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "guidanceEpoch": 1, + "guidanceReview": { + "version": "1.0.0", + "sourceHash": "84eb3d25427d40d872a6919a7c7eca1a6b28df0966407aaa558bb7a77d6007d0", + "decision": "initial", + "baseline": { + "version": "0.11.0", + "integrity": "sha512-LMvJ39OY9CsZ/aDipJCNPFMcbHtdtZWmmE/aFVgSXKh0aT07tgVuZyhvU5MWVTeL11KEu59apMAb3YSexlY77w==" + }, + "rationale": "First manifest-bearing runtime. Earlier releases lack reviewed epochs and require a coordinated MCP restart; this does not declare their instructions compatible." + }, + "controlProtocols": [2], + "state": { "family": "tempad-runtime", "reads": [1], "writes": 1 }, + "extension": { "protocols": [13, 15], "legacy": true }, + "hookProtocol": 2 +} diff --git a/packages/mcp-server/scripts/check-bridge-compatibility.mjs b/packages/mcp-server/scripts/check-bridge-compatibility.mjs index f479d2e8..daf02fc5 100644 --- a/packages/mcp-server/scripts/check-bridge-compatibility.mjs +++ b/packages/mcp-server/scripts/check-bridge-compatibility.mjs @@ -13,9 +13,9 @@ import { Buffer } from 'node:buffer' import { spawn } from 'node:child_process' import { createHash, randomUUID } from 'node:crypto' import { once } from 'node:events' -import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { access, mkdtemp, readdir, readFile, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import process from 'node:process' import { clearTimeout, setTimeout } from 'node:timers' import { setTimeout as delay } from 'node:timers/promises' @@ -35,10 +35,13 @@ if (process.platform === 'win32') { ) } +// Also accepts an unpacked/installed package, using the same frozen receiving fixtures. +const packageDir = resolve(process.argv[2] ?? fileURLToPath(new URL('..', import.meta.url))) +await Promise.all(['hub.mjs', 'cli.mjs'].map((file) => access(join(packageDir, 'dist', file)))) // Exercise the shipped Hub, without connecting to the user's Hub or Figma sessions. // A dedicated Origin allowlist prevents the browser from joining this test Hub. const origin = 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' -const directory = await mkdtemp(join(tmpdir(), 'tempad-bridge-')) +const directory = await realpath(await mkdtemp(join(tmpdir(), 'tempad-bridge-'))) const peers = [] const failures = [] const metricSamples = [] @@ -55,7 +58,7 @@ const environment = { TEMPAD_MCP_PAGE_STRUCTURE_TIMEOUT: '2000', TEMPAD_MCP_AUTO_ACTIVATE_GRACE: '10' } -const hub = spawn(process.execPath, [fileURLToPath(new URL('../dist/hub.mjs', import.meta.url))], { +const hub = spawn(process.execPath, [join(packageDir, 'dist/hub.mjs')], { env: environment, stdio: ['ignore', 'ignore', 'pipe'] }) @@ -67,7 +70,7 @@ hub.on('error', (error) => failures.push(error)) const client = new Client({ name: 'bridge-test', version: '1.0.0' }) const transport = new StdioClientTransport({ command: process.execPath, - args: [fileURLToPath(new URL('../dist/cli.mjs', import.meta.url))], + args: [join(packageDir, 'dist/cli.mjs')], env: environment, stderr: 'pipe' }) @@ -598,13 +601,83 @@ try { replacementCount, 'disconnected tasks cannot fall back to legacy' ) + // Opt in independently of the wire version; previously released peers above + // keep their frozen receiving schemas and never receive completion envelopes. + const receiptSession = { + sessionId: 'receipt-session', + fileKey: 'receipt-file', + fileName: 'Receipt fixture', + pageId: 'receipt-page', + tabId: 42, + documentId: 'receipt-document', + busy: false + } + const receiptPeer = async () => { + const extension = await peer(port, false) + assert.equal(typeof extension.frames[0].terminalReceiptGeneration, 'string') + send(extension, { + type: 'runtimeHello', + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, + extensionVersion: '0.23.4', + extensionRuntimeFingerprint: 'c'.repeat(64), + terminalReceipts: true + }) + send(extension, { + type: 'sessions', + browserId: 'receipt-browser', + activeSessionId: receiptSession.sessionId, + sessions: [receiptSession] + }) + await activate(extension) + return extension + } + const originalReceiptPeer = await receiptPeer() + const lostWrite = call('apply_canvas', { + mode: 'create', + markup: '
' + }) + await until(() => originalReceiptPeer.calls.length === 1, 'receipt write dispatch') + const lostCall = originalReceiptPeer.calls[0] + assert.ok(lostCall.terminalReceipt) + originalReceiptPeer.socket.close() + await once(originalReceiptPeer.socket, 'close') + assert.match(JSON.stringify(await lostWrite), /EXTENSION_DISCONNECTED/) + const reconnectedReceiptPeer = await receiptPeer() + assert.equal( + (await call('get_structure')).isError, + true, + 'same-document idle must not release the write' + ) + const completion = { type: 'toolCompletion', id: lostCall.id, ...lostCall.terminalReceipt } + send(reconnectedReceiptPeer, completion) + await until( + () => + reconnectedReceiptPeer.frames.some( + (frame) => frame.type === 'toolCompletionAck' && frame.id === lostCall.id && frame.accepted + ), + 'same-Hub receipt acknowledgment' + ) + reconnectedReceiptPeer.respond = () => structure + assert.deepEqual((await call('get_structure')).structuredContent, structure) + send(reconnectedReceiptPeer, completion) + await until( + () => + reconnectedReceiptPeer.frames.filter( + (frame) => frame.type === 'toolCompletionAck' && frame.id === lostCall.id && frame.accepted + ).length === 2, + 'duplicate receipt acknowledgment' + ) assert.deepEqual(failures, []) if (metricSamples.length) { process.stdout.write(`Bridge byte samples: ${JSON.stringify(metricSamples)}\n`) } process.stdout.write( - 'Bridge compatibility passed: protocol 13 reads/writes/tasks/assets/reconnect, legacy reads/assets/reconnect, upgrade errors, current routing and task fences.\n' + 'Bridge compatibility passed: protocol 13 reads/writes/tasks/assets/reconnect, legacy reads/assets/reconnect, upgrade errors, current routing/task fences and negotiated terminal receipts.\n' ) +} catch (error) { + throw new Error(`Bridge compatibility failed. Child diagnostics:\n${stderr.slice(-16_384)}`, { + cause: error + }) } finally { for (const peer of peers) peer.socket.terminate() await client.close() diff --git a/packages/mcp-server/scripts/check-host-context.mjs b/packages/mcp-server/scripts/check-host-context.mjs new file mode 100644 index 00000000..84b9f87b --- /dev/null +++ b/packages/mcp-server/scripts/check-host-context.mjs @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import console from 'node:console' +import { once } from 'node:events' +import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { createInterface } from 'node:readline' +import { setTimeout } from 'node:timers/promises' +import { fileURLToPath, URL } from 'node:url' + +// Release Windows pipes ignore directory overrides. Never risk an installed Hub. +if (process.platform === 'win32') + throw new Error('The isolated CLI host-context check requires Unix sockets.') +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) + +async function waitFor(check, message) { + const deadline = Date.now() + 15000 + do { + const value = await check() + if (value) return value + await setTimeout(25) + } while (Date.now() < deadline) + throw new Error(message) +} + +async function records(path) { + try { + return (await readFile(path, 'utf8')) + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + } catch (error) { + if (error.code === 'ENOENT') return [] + throw error + } +} + +async function stopHub(path) { + try { + const { pid } = JSON.parse(await readFile(path, 'utf8')) + process.kill(pid, 'SIGTERM') + await waitFor(() => { + try { + process.kill(pid, 0) + return false + } catch (error) { + if (error.code === 'ESRCH') return true + throw error + } + }, 'Fixture Hub did not exit') + } catch (error) { + if (!['ENOENT', 'ESRCH'].includes(error.code)) throw error + } +} + +async function checkOrder(firstKind, legacy = false) { + const root = await mkdtemp('/tmp/tp-h-') + const children = [] + const recordPath = join(root, 'frames.jsonl') + const hubPath = join(root, 'hub.json') + try { + await cp(join(packageRoot, 'dist'), join(root, 'dist'), { recursive: true }) + await symlink(join(packageRoot, 'node_modules'), join(root, 'node_modules'), 'dir') + // Only this private fixture substitutes the Hub. The built CLI still launches, + // locks, registers, bridges, reconnects and sanitizes its child environment. + await writeFile( + join(root, 'dist/hub.mjs'), + ` + import { createServer } from 'node:net'; + import { writeFileSync, appendFileSync } from 'node:fs'; + import { fileURLToPath } from 'node:url'; + import { join } from 'node:path'; + import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + import { z } from 'zod'; + const root = fileURLToPath(new URL('..', import.meta.url)); + writeFileSync(join(root, 'hub.json'), JSON.stringify({ pid: process.pid, env: process.env })); + process.umask(0o177); + let next = 0; + createServer((socket) => { + const connection = ++next; + let buffer = ''; + if (${legacy}) { + // Freeze the pre-context connect contract from 74ed70c9. In particular, + // do not register the new notification or import its current schema. + let identity; + const mcp = new McpServer({ name: 'legacy-hub-fixture', version: '0.11.0' }); + mcp.server.setNotificationHandler(z.object({ + method: z.literal('notifications/tempad/client-event'), + params: z.object({ + event: z.literal('connect'), + kind: z.enum(['codex-app', 'claude']), + sessionId: z.string().min(1).max(256).optional(), + turnId: z.string().min(1).max(256).optional() + }).strict() + }), ({ params }) => { identity = params; }); + mcp.registerTool('fixture_identity', {}, async () => ({ + content: [{ type: 'text', text: JSON.stringify(identity) }] + })); + mcp.server.setRequestHandler(z.object({ method: z.literal('fixture/disconnect') }), async () => { + setImmediate(() => socket.end()); + return {}; + }); + void mcp.connect(new StdioServerTransport(socket, socket)); + } + socket.on('error', () => {}); + socket.on('data', (chunk) => { + buffer += chunk; + let end; + while ((end = buffer.indexOf('\\n')) !== -1) { + const frame = JSON.parse(buffer.slice(0, end)); + buffer = buffer.slice(end + 1); + appendFileSync(join(root, 'frames.jsonl'), JSON.stringify({ connection, frame }) + '\\n'); + if (frame.id !== undefined && !${legacy}) { + socket.write(JSON.stringify({ jsonrpc: '2.0', id: frame.id, result: {} }) + '\\n'); + if (frame.method === 'fixture/disconnect') socket.end(); + } + } + }); + }).listen(join(process.env.TEMPAD_MCP_RUNTIME_DIR, 'mcp.sock')); + ` + ) + + async function launch(kind) { + const home = join(root, kind) + await mkdir(home) + const env = { + PATH: process.env.PATH, + HOME: home, + TMPDIR: root, + TEMPAD_MCP_RUNTIME_DIR: join(root, 'run'), + TEMPAD_MCP_LOG_DIR: join(root, 'log'), + TEMPAD_MCP_ASSET_DIR: join(root, 'assets'), + TEMPAD_MCP_STATE_DIR: join(root, 'state'), + CODEX_HOME: join(home, 'codex'), + CODEX_SQLITE_HOME: join(home, 'sqlite'), + NODE_OPTIONS: '--no-warnings', + OPENAI_API_KEY: 'fixture-secret', + ...(kind === 'codex' + ? { CODEX_APP_TOOLS_PIPE_PATH: '/fixture.pipe', CODEX_THREAD_ID: 'codex-thread' } + : { CLAUDECODE: '1', CLAUDE_CODE_SESSION_ID: 'claude-session' }) + } + const child = spawn(process.execPath, [join(root, 'dist/cli.mjs')], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + children.push(child) + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + const replies = new Map() + const lines = createInterface({ input: child.stdout }) + lines.on('line', (line) => { + const message = JSON.parse(line) + replies.set(message.id, message) + }) + const request = async (id, method, params) => { + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') + await waitFor(() => { + if (child.exitCode !== null) throw new Error(`CLI exited: ${stderr}`) + return replies.has(id) + }, `CLI did not bridge ${method}: ${stderr}`) + assert.equal(replies.get(id).error, undefined) + return replies.get(id).result + } + if (legacy) { + const initialized = await request(0, 'initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: kind === 'codex' ? 'codex' : 'claude-code', version: '1.0.0' } + }) + assert.equal(initialized.serverInfo.name, 'legacy-hub-fixture') + child.stdin.write( + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n' + ) + const result = await request(5, 'tools/call', { name: 'fixture_identity', arguments: {} }) + assert.equal( + JSON.parse(result.content[0].text).kind, + kind === 'codex' ? 'codex-app' : 'claude' + ) + } + assert.deepEqual(await request(1, 'ping'), {}) + return { child, request, home } + } + + const first = await launch(firstKind) + const second = await launch(firstKind === 'codex' ? 'claude' : 'codex') + const hub = JSON.parse(await readFile(hubPath, 'utf8')) + assert.equal(hub.env.TEMPAD_MCP_RUNTIME_DIR, join(root, 'run')) + for (const name of [ + 'CODEX_HOME', + 'CODEX_SQLITE_HOME', + 'CODEX_THREAD_ID', + 'CODEX_APP_TOOLS_PIPE_PATH', + 'CLAUDECODE', + 'CLAUDE_CODE_SESSION_ID', + 'NODE_OPTIONS', + 'OPENAI_API_KEY' + ]) + assert.equal(hub.env[name], undefined, `Hub inherited ${name}`) + + await second.request(2, 'fixture/disconnect') + await waitFor( + async () => + (await records(recordPath)).filter( + ({ frame }) => frame.method === 'notifications/tempad/host-context' + ).length === 3, + 'CLI did not re-register context after reconnect' + ) + await second.request(3, 'ping') + const disconnected = once(first.child, 'exit') + first.child.stdin.end() + await disconnected + await second.request(4, 'ping') + if (legacy) { + const result = await second.request(6, 'tools/call', { + name: 'fixture_identity', + arguments: {} + }) + assert.equal( + JSON.parse(result.content[0].text).kind, + firstKind === 'codex' ? 'claude' : 'codex-app' + ) + } + const frames = await records(recordPath) + const registrations = frames.filter( + ({ frame }) => frame.method === 'notifications/tempad/host-context' + ) + assert.deepEqual( + registrations.map(({ frame }) => frame.params.home), + [first.home, second.home, second.home] + ) + assert.deepEqual(registrations[1].frame.params, registrations[2].frame.params) + for (const { connection, frame } of registrations) { + assert.equal(frame.params.version, 1) + assert.equal(frame.params.codexHome, join(frame.params.home, 'codex')) + assert.equal(frame.params.codexSqliteHome, join(frame.params.home, 'sqlite')) + const events = frames.filter((entry) => entry.connection === connection) + assert.equal(events[0].frame.method, 'notifications/tempad/host-context') + assert.equal(events[1].frame.method, 'notifications/tempad/client-event') + assert.equal( + events[1].frame.params.kind, + frame.params.home.endsWith('/codex') ? 'codex-app' : 'claude' + ) + } + console.log( + `Host context: ${firstKind} first, independent registrations, sanitized Hub, reconnect and launcher exit passed${legacy ? '; legacy SDK initialization, strict identity and tools passed' : ''}.` + ) + } finally { + for (const child of children) { + if (child.exitCode !== null || child.signalCode !== null) continue + const exited = once(child, 'exit') + child.kill('SIGTERM') + await exited + } + await stopHub(hubPath) + await rm(root, { recursive: true, force: true }) + } +} + +await checkOrder('claude') +await checkOrder('codex') +await checkOrder('codex', true) diff --git a/packages/mcp-server/scripts/check-protocol-13.mjs b/packages/mcp-server/scripts/check-protocol-13.mjs index 0ad2d456..dd3d5113 100644 --- a/packages/mcp-server/scripts/check-protocol-13.mjs +++ b/packages/mcp-server/scripts/check-protocol-13.mjs @@ -24,7 +24,14 @@ export async function checkProtocol13({ port, peer, send, activate, until, call, busy: false } const connect = async () => { - const extension = await peer(port, false, MessageToExtensionSchema) + const extension = await peer(port, false, { + parse(message) { + const parsed = MessageToExtensionSchema.parse(message) + // Check additive fields on the raw envelope inside peer()'s guarded receiver. + if (parsed.type === 'toolCall') assert.equal(message.terminalReceipt, undefined) + return parsed + } + }) assert.ok(extension.frames[0].supportedProtocolVersions.includes(13)) // Exactly the old hello: no new protocol field, and no package-version inference. send(extension, { diff --git a/packages/mcp-server/scripts/check-runtime-profiles.mjs b/packages/mcp-server/scripts/check-runtime-profiles.mjs new file mode 100644 index 00000000..cbd9b5c8 --- /dev/null +++ b/packages/mcp-server/scripts/check-runtime-profiles.mjs @@ -0,0 +1,201 @@ +import { MCP_CHECKOUT_PORT_CANDIDATES, MCP_PORT_CANDIDATES } from '@tempad-dev/shared' +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import console from 'node:console' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { createInterface } from 'node:readline' +import { setTimeout } from 'node:timers/promises' +import { fileURLToPath, URL } from 'node:url' + +import { runtimeDirectories } from '../../../agent-plugin/src/clients/shared/runtime-profile.mjs' + +if (process.platform === 'win32') + throw new Error('The release pipe is not isolatable on Windows; do not probe an installed Hub.') +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) + +async function identity(path) { + try { + return JSON.parse(await readFile(path, 'utf8')) + } catch (error) { + if (error.code === 'ENOENT') return null + throw error + } +} + +async function waitFor(check, message) { + const deadline = Date.now() + 15000 + do { + const result = await check() + if (result) return result + await setTimeout(25) + } while (Date.now() < deadline) + throw new Error(message) +} + +async function readLogs(directory) { + const files = await readdir(directory).catch((error) => { + if (error.code === 'ENOENT') return [] + throw error + }) + return ( + await Promise.all( + files + .filter((name) => name.endsWith('.log')) + .map((name) => readFile(join(directory, name), 'utf8')) + ) + ).join('\n') +} + +async function checkProfiles(overrides) { + // macOS's per-user temporary root plus a checkout suffix can exceed sun_path. + const root = await realpath(await mkdtemp('/tmp/tp-p-')) + const checkout = join(root, 'c') + const targetPackage = join(checkout, 'packages/mcp-server') + const children = [] + const identities = new Set() + const logDirectories = new Set() + const env = { + PATH: process.env.PATH, + HOME: root, + TMPDIR: root, + TEMP: root, + TMP: root, + ...(overrides + ? { + TEMPAD_MCP_RUNTIME_DIR: join(root, 'r'), + TEMPAD_MCP_LOG_DIR: join(root, 'l'), + TEMPAD_MCP_ASSET_DIR: join(root, 'a'), + TEMPAD_MCP_STATE_DIR: join(root, 's') + } + : {}), + TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + } + + async function startClient(local) { + const childEnv = { ...env, ...(local ? { TEMPAD_MCP_DEV_CHECKOUT: checkout } : {}) } + const cli = join(local ? targetPackage : packageRoot, 'dist/cli.mjs') + const paths = runtimeDirectories(childEnv, undefined, { tmpdir: root, homedir: root }) + const record = join(paths.runtime, 'hub-runtime.json') + identities.add(record) + logDirectories.add(paths.log) + const child = spawn(process.execPath, [cli], { env: childEnv, stdio: ['pipe', 'pipe', 'pipe'] }) + children.push(child) + let stderr = '' + child.stderr.setEncoding('utf8').on('data', (data) => { + stderr += data + }) + const replies = new Map() + const lines = createInterface({ input: child.stdout }) + lines.on('line', (line) => { + const reply = JSON.parse(line) + if (reply.id !== undefined) replies.set(reply.id, reply) + }) + const request = async (id, method, params) => { + child.stdin.write( + JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }) + '\n' + ) + await waitFor(() => { + if (child.exitCode !== null) throw new Error(`CLI exited: ${stderr}`) + return replies.has(id) + }, `No ${method} response: ${stderr}`) + const reply = replies.get(id) + assert.equal(reply.error, undefined) + return reply.result + } + await request(1, 'initialize', { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'isolated-profile-check', version: '1' } + }) + child.stdin.write( + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n' + ) + const hub = await waitFor(async () => { + const value = await identity(record) + return value?.wsPort && value + }, 'Hub identity and WebSocket port were not published') + assert.equal(hub.entryPath, join(local ? targetPackage : packageRoot, 'dist/hub.mjs')) + assert.equal( + hub.runtimeFingerprint, + createHash('sha256') + .update(await readFile(hub.entryPath)) + .digest('hex') + ) + const port = hub.wsPort + assert.ok( + (local ? MCP_CHECKOUT_PORT_CANDIDATES : MCP_PORT_CANDIDATES).includes(port), + `${local ? 'Checkout' : 'Release'} Hub bound the wrong port: ${port}` + ) + return { child, paths, record, hub, request, port } + } + + try { + await mkdir(targetPackage, { recursive: true }) + await cp(join(packageRoot, 'dist'), join(targetPackage, 'dist'), { recursive: true }) + await symlink( + join(packageRoot, 'node_modules'), + join(targetPackage, 'node_modules'), + 'junction' + ) + const release = await startClient(false) + const dev = await startClient(true) + const secondRelease = await startClient(false) + assert.equal( + secondRelease.hub.processId, + release.hub.processId, + 'Release clients must share one Hub' + ) + assert.notEqual(dev.hub.processId, release.hub.processId) + assert.notEqual(dev.port, release.port) + for (const key of ['runtime', 'log', 'assets', 'state']) + assert.notEqual(dev.paths[key], release.paths[key]) + const devExit = once(dev.child, 'close') + dev.child.stdin.end() + await devExit + await waitFor( + async () => !(await identity(dev.record)), + 'Checkout Hub did not stop with its last client' + ) + assert.equal((await identity(release.record)).processId, release.hub.processId) + await release.request(2, 'ping') + await secondRelease.request(2, 'ping') + console.log( + `Runtime profiles (${overrides ? 'overrides' : 'defaults'}): shared release Hub on ${release.port}; isolated checkout Hub on ${dev.port}; independent directories and shutdown.` + ) + } catch (error) { + for (const directory of logDirectories) console.error((await readLogs(directory)).slice(-8000)) + throw error + } finally { + for (const child of children) child.stdin.end() + try { + await waitFor( + async () => (await Promise.all([...identities].map(identity))).every((value) => !value), + 'Profile test Hubs did not stop' + ) + } catch (error) { + console.error(error) + process.exitCode = 1 + } + for (const path of identities) { + const hub = await identity(path) + if (hub) { + try { + process.kill(hub.processId, 'SIGTERM') + } catch (error) { + if (error.code !== 'ESRCH') { + console.error(error) + process.exitCode = 1 + } + } + } + } + for (const child of children) if (child.exitCode === null) child.kill('SIGTERM') + await rm(root, { recursive: true, force: true }) + } +} + +for (const overrides of [false, true]) await checkProfiles(overrides) diff --git a/packages/mcp-server/scripts/check-runtime-prototype.mjs b/packages/mcp-server/scripts/check-runtime-prototype.mjs new file mode 100644 index 00000000..2e937d3f --- /dev/null +++ b/packages/mcp-server/scripts/check-runtime-prototype.mjs @@ -0,0 +1,424 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { TEMPAD_MCP_BRIDGE_SUBPROTOCOL } from '@tempad-dev/shared' +import assert from 'node:assert/strict' +import console from 'node:console' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { connect } from 'node:net' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath, URL } from 'node:url' +import { WebSocket } from 'ws' + +import { runtimeEndpoint } from '../src/runtime-platform.ts' +import { + FIXTURE_INITIALIZE_TIMEOUT_MS, + fixtureEnvironment, + fixtureNodeArguments, + fixtureProcesses as processes, + fixtureRoot, + preserveFixtureEvidence, + processMemory +} from './runtime-test-platform.mjs' + +const root = await fixtureRoot('tp-p-') +const origin = 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const clients = [] +const entry = fileURLToPath(new URL('../dist/prototype/runtime-prototype.mjs', import.meta.url)) +const failures = [] +let extension +let stderr = '' +let passed = false +const started = performance.now() +async function until(check, description, timeout = 15000) { + const deadline = performance.now() + timeout + do { + if (failures.length) throw failures[0] + const value = await check() + if (value) return value + await delay(25) + } while (performance.now() < deadline) + throw new Error(`Timed out: ${description}\n${stderr}\nRoot: ${root}`) +} +async function record(name) { + try { + return JSON.parse(await readFile(join(root, name), 'utf8')) + } catch (error) { + if (error.code === 'ENOENT') return undefined + throw error + } +} +function live(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (error.code === 'ESRCH') return false + throw error + } +} +function owned(pid) { + if (!Number.isSafeInteger(pid) || pid <= 1 || !live(pid)) return false + return fixtureProcesses().some((value) => value.pid === pid) +} +function fixtureProcesses() { + return processes(root, entry) +} +function call(client, name, args = {}, options = {}) { + return client.callTool({ name, arguments: args }, undefined, { timeout: 20000, ...options }) +} +async function launch(label) { + const home = join(root, label) + await mkdir(home, { mode: 0o700 }) + const transport = new StdioClientTransport({ + command: process.execPath, + args: fixtureNodeArguments([entry, '--root', root]), + stderr: 'pipe', + env: { + ...fixtureEnvironment(home, root), + CODEX_HOME: join(home, 'codex'), + CODEX_SQLITE_HOME: join(home, 'sqlite'), + TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS: origin, + TEMPAD_MCP_AUTO_ACTIVATE_GRACE: '10' + } + }) + transport.stderr.on('data', (chunk) => { + stderr += chunk + }) + const client = new Client({ name: `prototype-${label}`, version: '1' }) + clients.push(client) + await client.connect(transport, { timeout: FIXTURE_INITIALIZE_TIMEOUT_MS }) + return { client, transport, home } +} +function send(message) { + extension.send(JSON.stringify(message)) +} +async function probe(path, message) { + const socket = connect(path) + socket.setTimeout(5000, () => socket.destroy(new Error('Probe timed out.'))) + await once(socket, 'connect') + const reply = once(socket, 'data') + socket.write(JSON.stringify(message) + '\n') + const [data] = await reply + socket.destroy() + return JSON.parse(data) +} + +try { + const first = await launch('first') + const coldMs = performance.now() - started + const attached = performance.now() + const second = await launch('second') + const attachMs = performance.now() - attached + const initial = await until(() => record('supervisor.json'), 'supervisor identity') + const hub = await until(async () => { + const value = await record('run/hub-runtime.json') + return value?.wsPort && value + }, 'Hub identity and private WebSocket port') + assert.equal(initial.hubPid, hub.processId) + await delay(250) + const twoClientsMemory = processMemory([ + first.transport.pid, + second.transport.pid, + initial.pid, + hub.processId + ]) + const catalog = await first.client.listTools() + assert.deepEqual(await second.client.listTools(), catalog) + assert.ok(catalog.tools.some((tool) => tool.name === 'apply_canvas')) + + extension = new WebSocket(`ws://127.0.0.1:${hub.wsPort}`, [TEMPAD_MCP_BRIDGE_SUBPROTOCOL], { + origin + }) + const frames = [] + const calls = [] + let held + let assetUrl + let registered + const session = { + sessionId: 'prototype-session', + fileKey: 'prototype-file', + fileName: 'Isolated runtime fixture', + pageId: '0:1', + tabId: 1, + documentId: 'prototype-document', + busy: false + } + const structure = { + roots: [{ id: '1:2', name: 'Fixture', type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }] + } + const written = { + rootNodeId: '1:2', + nodeIdsByKey: { root: '1:2' }, + createdNodeIds: [], + updatedNodeIds: ['1:2'], + removedNodeIds: [], + mutationCount: 1, + verification: { status: 'passed', nodesChecked: 1, referencesChecked: 0, warnings: [] } + } + extension.on('error', (error) => failures.push(error)) + extension.on('message', (bytes) => { + try { + const message = JSON.parse(bytes) + frames.push(message) + if (message.type === 'registered') registered = message.id + if (message.type === 'state') assetUrl = message.assetServerUrl + if (message.type !== 'toolCall') return + calls.push(message) + if (message.payload.name === 'apply_canvas') { + held = message + return + } + const payload = message.payload.name === '__begin_design' ? message.payload.args : structure + send({ type: 'toolResult', id: message.id, payload }) + } catch (error) { + failures.push(error) + } + }) + await once(extension, 'open') + await until(() => registered && assetUrl, 'extension registration') + send({ + type: 'runtimeHello', + extensionVersion: '0.22.0', + extensionRuntimeFingerprint: 'b'.repeat(64) + }) + send({ + type: 'sessions', + browserId: 'prototype-browser', + activeSessionId: session.sessionId, + sessions: [session] + }) + send({ type: 'activate' }) + await until( + () => frames.some((frame) => frame.type === 'state' && frame.activeId === registered), + 'extension activation' + ) + const task = await call(first.client, 'manage_design_task', { + action: 'begin', + title: 'Isolated prototype', + requestId: randomUUID(), + sessionId: session.sessionId + }) + assert.equal(task.isError, undefined, JSON.stringify(task)) + const { taskId, epoch } = task.structuredContent + await call(first.client, 'get_structure', { nodeId: '1:2', taskId, taskEpoch: epoch }) + const write = call(first.client, 'apply_canvas', { + mode: 'update', + targetNodeId: '1:2', + markup: '', + taskId, + taskEpoch: epoch + }) + // Observe an early disconnect immediately; the assertion below still awaits the result. + void write.catch(() => {}) + await until(() => held, 'write admitted once') + const recovering = performance.now() + assert.ok(owned(initial.pid), 'only terminate this fixture supervisor') + process.kill(initial.pid, 'SIGKILL') + await until( + async () => (await globalThis.fetch(`${assetUrl}/assets/${'a'.repeat(64)}`)).status === 503, + 'asset admission closes after control loss' + ) + const before = calls.length + const queuedAt = performance.now() + const queuedRead = call(second.client, 'get_structure', { nodeId: '1:2' }).then( + (result) => ({ result }), + (error) => ({ error, elapsedMs: performance.now() - queuedAt }) + ) + await delay(100) + assert.equal( + (await globalThis.fetch(`${assetUrl}/assets/${'a'.repeat(64)}`)).status, + 503, + 'control admission remains closed during the queued read' + ) + assert.equal(calls.length, before, 'the queued read is not dispatched during control loss') + // The data connection remains valid while its independent control owner recovers. + send({ type: 'toolResult', id: held.id, payload: written }) + assert.equal((await write).isError, undefined) + assert.equal(calls.filter((message) => message.payload.name === 'apply_canvas').length, 1) + await first.client.ping() + assert.deepEqual(await first.client.listTools(), catalog) + const stopId = randomUUID() + send({ + type: 'designAction', + sessionId: session.sessionId, + action: { requestId: stopId, taskId, epoch, action: 'stop' } + }) + await until( + () => + frames.some( + (frame) => + frame.type === 'designActionResult' && + frame.result.requestId === stopId && + frame.result.status === 'delivered' + ), + 'Stop remains available during supervisor recovery' + ) + const recovered = await until( + async () => { + const value = await record('supervisor.json') + return value && value.pid !== initial.pid && value.generation > initial.generation && value + }, + 'one successor supervisor', + 20000 + ) + const recoverMs = performance.now() - recovering + const queued = await queuedRead + // Supervisor recovery may exceed the documented ten-second request wait. + // Expiry must remain undispatched; only a fresh call may run after reattachment. + if (queued.error) { + assert.equal(queued.error.code, -32001) + assert.deepEqual(queued.error.data, { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false }) + assert.ok(queued.elapsedMs >= 10_000, 'the request keeps its full recovery wait budget') + assert.equal(calls.length, before, 'the expired read was never dispatched') + } else { + assert.equal(queued.result.isError, undefined) + assert.equal(calls.length, before + 1, 'the waiting read is dispatched exactly once') + } + assert.equal(recovered.hubPid, hub.processId) + assert.equal(recovered.hubId, initial.hubId) + await until(async () => { + try { + return !(await call(second.client, 'list_design_sessions')).isError + } catch { + return false + } + }, 'second initialized edge reattachment') + if (queued.error) { + assert.equal((await call(second.client, 'get_structure', { nodeId: '1:2' })).isError, undefined) + assert.equal(calls.length, before + 1, 'only the fresh read is dispatched after expiry') + } + const snapshot = await record('state/design-tasks.json') + const saved = snapshot.records.find((value) => value.task.taskId === taskId) + assert.equal(saved.hostContext.codexHome, join(first.home, 'codex')) + assert.equal(saved.task.status, 'cancelled') + await first.client.close() + const secondTask = await call(second.client, 'manage_design_task', { + action: 'begin', + title: 'Second isolated owner', + requestId: randomUUID(), + sessionId: session.sessionId + }) + assert.equal(secondTask.isError, undefined, JSON.stringify(secondTask)) + assert.equal( + ( + await call(second.client, 'get_structure', { + nodeId: '1:2', + taskId: secondTask.structuredContent.taskId, + taskEpoch: secondTask.structuredContent.epoch + }) + ).isError, + undefined + ) + const state = await record('state/design-tasks.json') + assert.equal( + state.records.find((value) => value.task.taskId === secondTask.structuredContent.taskId) + .hostContext.codexHome, + join(second.home, 'codex') + ) + + // Hooks retain the public endpoint; a legacy byte bridge gets restart guidance. + const path = runtimeEndpoint(join(root, 'run'), 'mcp.sock') + assert.match( + (await probe(path, { jsonrpc: '2.0', id: 0, method: 'initialize', params: {} })).error.message, + /MCP_RUNTIME_RESTART_REQUIRED/ + ) + const hook = await probe(path, { + jsonrpc: '2.0', + id: 1, + method: 'tempad/client-hook', + params: { + version: 1, + kind: 'claude', + sessionId: 'isolated-hook', + event: 'UserPromptSubmit', + invocationId: randomUUID() + } + }) + assert.deepEqual(hook.result, {}) + const incompatible = await probe(runtimeEndpoint(root, 'supervisor.sock'), { + jsonrpc: '2.0', + id: 2, + method: 'tempad/runtime-hello', + params: { + sessionId: randomUUID(), + token: randomUUID(), + context: saved.hostContext, + versions: [1] + } + }) + assert.match(incompatible.error.message, /MCP_RUNTIME_RESTART_REQUIRED/) + const processes = fixtureProcesses().filter(({ command }) => + command.includes('--role supervisor') + ) + assert.equal(processes.length, 1, 'only one successor owns supervisor control') + await second.client.close() + await until(() => !live(recovered.pid) && !live(hub.processId), 'last-client shutdown') + console.log( + JSON.stringify( + { + valid: true, + coldMs, + attachMs, + recoverMs, + queuedRead: queued.error ? 'expired-undispatched' : 'completed-once', + twoClientsMemory, + writes: 1, + preservedHub: true, + initializedEdges: 2, + controlProtocol: recovered.version + }, + null, + 2 + ) + ) + passed = true +} catch (error) { + const diagnostic = {} + try { + diagnostic.processes = fixtureProcesses() + diagnostic.status = await probe(runtimeEndpoint(root, 'supervisor.sock'), { + jsonrpc: '2.0', + id: 3, + method: 'tempad/runtime-status' + }) + } catch (inspectionError) { + diagnostic.error = String(inspectionError) + } + await writeFile(join(root, 'failure-status.json'), JSON.stringify(diagnostic, null, 2)).catch( + (inspectionError) => console.error(`Could not save failure status: ${inspectionError}`) + ) + await preserveFixtureEvidence(root, `${error.stack}\n${stderr}`) + throw new Error(`${error.message}\n${stderr}\nRoot: ${root}`, { cause: error }) +} finally { + await Promise.allSettled(clients.map((client) => client.close())) + extension?.terminate() + let cleanupReliable = true + for (const signal of ['SIGTERM', 'SIGKILL']) { + try { + // Include children that failed before publishing their runtime record. + for (const { pid } of fixtureProcesses()) { + try { + process.kill(pid, signal) + } catch (error) { + if (error.code !== 'ESRCH') + console.error(`Could not stop fixture ${pid}: ${error.message}`) + } + } + const deadline = performance.now() + 5000 + while (fixtureProcesses().length && performance.now() < deadline) await delay(50) + } catch { + cleanupReliable = false + } + } + if (!cleanupReliable || fixtureProcesses().length) { + console.error(`Fixture processes did not exit; preserved isolated root: ${root}`) + process.exitCode = 1 + } else if (passed && !process.env.TEMPAD_KEEP_PROTOTYPE) + await rm(root, { recursive: true, force: true }) + else console.log(`Preserved isolated root: ${root}`) +} diff --git a/packages/mcp-server/scripts/check-runtime-switch.mjs b/packages/mcp-server/scripts/check-runtime-switch.mjs new file mode 100644 index 00000000..7cf64b3d --- /dev/null +++ b/packages/mcp-server/scripts/check-runtime-switch.mjs @@ -0,0 +1,830 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { + TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION +} from '@tempad-dev/shared' +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { execFile } from 'node:child_process' +import console from 'node:console' +import { createHash, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { request } from 'node:http' +import { connect, createServer } from 'node:net' +import { basename, dirname, join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import { setTimeout } from 'node:timers' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath, URL } from 'node:url' +import { promisify } from 'node:util' +import { build } from 'tsdown' +import { WebSocket } from 'ws' + +import { runtimeEndpoint } from '../src/runtime-platform.ts' +import config from '../tsdown.prototype.config.ts' +import { + FIXTURE_INITIALIZE_TIMEOUT_MS, + fixtureEnvironment, + fixtureNodeArguments, + fixtureProcesses, + fixtureRoot, + preserveFixtureEvidence +} from './runtime-test-platform.mjs' + +if (process.env.TEMPAD_RUNTIME_EVIDENCE_DIR) await import('./runtime-startup-trace.mjs') + +// Build fixtures from authored source and test-only manifest inputs. Never patch +// a generated runtime or use the user's release/checkout stores and services. +const packageRoot = fileURLToPath(new URL('..', import.meta.url)) +const repo = join(packageRoot, '../..') +await mkdir(join(repo, '.dev'), { recursive: true }) +const artifacts = await mkdtemp(join(repo, '.dev/runtime-switch-')) +await symlink( + join(packageRoot, 'node_modules'), + join(artifacts, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir' +) +const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) +const origin = 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const exec = promisify(execFile) +const results = [] +const fetch = globalThis.fetch +let completed = false +async function artifact(version, incompatible = false) { + const output = join(artifacts, version) + await build({ + ...config, + config: false, + cwd: packageRoot, + entry: ['src/runtime-prototype.ts', 'src/log-transport.ts'], + outDir: output, + sourcemap: false, + plugins: [ + ...config.plugins, + { + name: 'local-runtime-test-input', + load(id) { + if (resolve(id) === join(packageRoot, 'package.json')) + return JSON.stringify({ ...manifest, version }) + }, + transform(code, id) { + if (resolve(id) === join(packageRoot, 'src/runtime-prototype/edge.ts')) { + const boundary = 'await edge.pause(extra.signal)' + assert.ok(code.includes(boundary), 'fixture edge admission boundary changed') + return code.replace( + boundary, + `const { writeFileSync } = await import('node:fs') + const { join } = await import('node:path') + const paused = edge.pause(extra.signal) + writeFileSync(join(config.root, 'test-admission-paused-' + registration.sessionId), '') + await paused` + ) + } + if ( + version === '0.11.1' && + resolve(id) === join(packageRoot, 'src/runtime-prototype/run.ts') + ) { + const boundary = "const { startHub } = await import('../hub-runtime')" + assert.ok(code.includes(boundary), 'fixture Hub startup boundary changed') + return code.replace( + boundary, + ` + if ((await import('node:fs')).existsSync(join(root, 'test-delay-hub'))) + await new Promise(resolve => setTimeout(resolve, 2000)) + ${boundary}` + ) + } + if (resolve(id) === join(packageRoot, 'src/runtime-prototype/supervisor.ts')) { + const boundary = 'writeJournal(config.root, value)' + assert.ok(code.includes(boundary), 'fixture handoff boundary changed') + // Instrument the fixture source at the durable boundary. fs.watch + // can coalesce phases or run after the next assertion; it cannot + // establish where a crash actually occurred. + code = code.replace( + boundary, + `${boundary} + const faultPath = join(config.root, 'test-fault.json') + if (existsSync(faultPath)) { + const fault = JSON.parse(readFileSync(faultPath, 'utf8')) + if (value.phase === fault.phase && + (value.phase !== 'committed' || value.active.version === fault.version)) { + rmSync(faultPath) + writeFileSync(join(config.root, 'test-fault-observed.json'), + JSON.stringify({ phase: value.phase, pid: process.pid })) + if (fault.action === 'crash') process.kill(process.pid, 'SIGKILL') + else { + const deadline = Date.now() + 5000 + while (!existsSync(join(config.root, 'test-fault-release'))) { + if (Date.now() >= deadline) throw new Error('Fixture port barrier timed out') + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10) + } + } + } + }` + ) + const activation = 'await activate(candidate.artifact, automatic)' + assert.ok(code.includes(activation), 'fixture activation boundary changed') + return code.replace( + activation, + `const holdPath = join(config.root, 'test-hold-before-drain') + if (existsSync(holdPath)) { + rmSync(holdPath) + writeFileSync(join(config.root, 'test-before-drain.json'), JSON.stringify({ prepared: true })) + const deadline = Date.now() + 30000 + while (!existsSync(join(config.root, 'test-release-drain'))) { + if (Date.now() >= deadline) throw new Error('Fixture preparation barrier timed out') + await pause(25) + } + } + ${activation}` + ) + } + if (incompatible && resolve(id) === join(packageRoot, 'src/hub-runtime.ts')) + return code.replace("title: 'TemPad Dev'", "title: 'Incompatible fixture'") + } + } + ] + }) + return join(output, 'runtime-prototype.mjs') +} +function alive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (error.code === 'ESRCH') return false + throw error + } +} +async function until(check, label, timeout = 20000) { + const deadline = performance.now() + timeout + do { + const value = await check() + if (value) return value + await delay(25) + } while (performance.now() < deadline) + throw new Error(`Timed out: ${label}`) +} +async function record(root, path) { + try { + return JSON.parse(await readFile(join(root, path), 'utf8')) + } catch (error) { + if (error.code === 'ENOENT') return + throw error + } +} +async function rpc(root, method, params, path = 'supervisor.sock') { + const socket = connect(runtimeEndpoint(dirname(join(root, path)), basename(path))) + socket.setTimeout(5000, () => socket.destroy(new Error('Runtime test RPC timed out'))) + await once(socket, 'connect') + const reply = new Promise((resolve, reject) => { + let data = '' + socket.once('error', reject) + socket.once('close', () => reject(new Error('Runtime test RPC connection closed'))) + socket.on('data', (chunk) => { + data += chunk + if (data.includes('\n')) resolve(data.slice(0, data.indexOf('\n'))) + }) + }) + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method, ...(params ? { params } : {}) }) + '\n' + ) + try { + const value = JSON.parse(await reply) + if (value.error) + throw Object.assign(new Error(value.error.message), { + code: value.error.code, + data: value.error.data + }) + return value.result + } finally { + socket.destroy() + } +} +async function applyHook(root, input) { + return until(async () => { + let result + try { + result = await rpc(root, 'tempad/client-hook', input, 'run/mcp.sock') + } catch (error) { + // Installed hooks retry the same invocation; a future clock is not admission. + if (error.data?.code === 'MCP_HOOK_CLOCK_AHEAD') return false + throw error + } + assert.equal(result.receipt?.invocationId, input.invocationId) + return result.receipt.state === 'applied' && result + }, 'durable hook application') +} +function ownedProcesses(root) { + return fixtureProcesses(root, artifacts).map(({ pid }) => pid) +} +const session = { + sessionId: 'session', + fileKey: 'file', + fileName: 'Switch fixture', + pageId: '0:1', + tabId: 1, + documentId: 'document', + busy: false +} +const structure = { + roots: [{ id: '1:2', name: 'Fixture', type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }] +} +const written = { + rootNodeId: '1:2', + nodeIdsByKey: { root: '1:2' }, + createdNodeIds: [], + updatedNodeIds: ['1:2'], + removedNodeIds: [], + mutationCount: 1, + verification: { status: 'passed', nodesChecked: 1, referencesChecked: 0, warnings: [] } +} +const call = (client, name, args = {}) => + client.callTool({ name, arguments: args }, undefined, { timeout: 30000 }) + +async function scenario(a, b, mode, incompatible) { + const root = await fixtureRoot('tp-sw-') + const clients = [] + const edgePids = [] + const initializeMs = [] + const claudeStop = mode === 'claude-stop' || mode === 'claude-stop-rollback' + const claudeSession = randomUUID() + const claudeTurn = randomUUID() + const claudeHook = (event, extra = {}) => ({ + version: 2, + kind: 'claude', + sessionId: claudeSession, + turnId: claudeTurn, + event, + invocationId: randomUUID(), + profile: { kind: 'release' }, + issuedAt: Date.now(), + ...extra + }) + let passed = false + let extension, conflict + let stderr = '' + const command = (...args) => + exec(process.execPath, [a, '--root', root, ...args], { + timeout: 390000, + maxBuffer: 4 * 1024 * 1024 + }) + async function openClient(name) { + const transport = new StdioClientTransport({ + command: process.execPath, + args: fixtureNodeArguments([a, '--root', root]), + stderr: 'pipe', + env: { + ...fixtureEnvironment(root), + TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS: origin, + TEMPAD_MCP_AUTO_ACTIVATE_GRACE: '10' + } + }) + transport.stderr.on('data', (chunk) => { + stderr = (stderr + chunk).slice(-20000) + }) + const client = new Client({ name, version: '1' }) + clients.push(client) + const started = performance.now() + await client.connect(transport, { timeout: FIXTURE_INITIALIZE_TIMEOUT_MS }) + initializeMs.push(Math.round(performance.now() - started)) + assert.ok(transport.pid) + edgePids.push(transport.pid) + return client + } + try { + for (let index = 0; index < 2; index++) + await openClient(claudeStop && index === 0 ? 'claude-code' : `switch-${index}`) + const initial = await record(root, 'supervisor.json') + const endpoints = await record(root, 'endpoints.json') + const catalog = await clients[0].listTools() + let frames = [], + calls = [], + held, + assetUrl, + review + async function attach() { + frames = [] + let registered + extension = new WebSocket( + `ws://127.0.0.1:${endpoints.wsPort}`, + [TEMPAD_MCP_BRIDGE_SUBPROTOCOL], + { origin } + ) + extension.on('message', (bytes) => { + const message = JSON.parse(bytes) + frames.push(message) + if (message.type === 'designTaskState') review = message.task + if (message.type === 'registered') { + registered = message.id + extension.send( + JSON.stringify({ + type: 'runtimeHello', + extensionVersion: '0.22.0', + extensionRuntimeFingerprint: 'b'.repeat(64), + protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, + terminalReceipts: true, + durableTerminalReceipts: true + }) + ) + extension.send( + JSON.stringify({ + type: 'sessions', + browserId: 'browser', + activeSessionId: session.sessionId, + sessions: [session], + ...(review ? { reviews: [{ sessionId: session.sessionId, task: review }] } : {}) + }) + ) + extension.send(JSON.stringify({ type: 'activate' })) + } + if (message.type === 'state') assetUrl = message.assetServerUrl + if (message.type !== 'toolCall') return + calls.push(message) + if (message.payload.name === 'apply_canvas') { + held = message + return + } + extension.send( + JSON.stringify({ + type: 'toolResult', + id: message.id, + payload: message.payload.name === '__begin_design' ? message.payload.args : structure + }) + ) + }) + await once(extension, 'open') + await until( + () => + assetUrl && + frames.some((frame) => frame.type === 'state' && frame.activeId === registered), + 'extension activation' + ) + } + await attach() + if (claudeStop) { + await assert.rejects( + clients[0].request( + { method: 'tempad/client-hook', params: claudeHook('UserPromptSubmit') }, + ResultSchema + ), + /runtime gateway/ + ) + await applyHook(root, claudeHook('UserPromptSubmit')) + } + const task = await call(clients[0], 'manage_design_task', { + action: 'begin', + title: mode, + requestId: randomUUID(), + sessionId: session.sessionId + }) + assert.ok(!task.isError, JSON.stringify(task)) + const { taskId, epoch } = task.structuredContent + if (claudeStop) { + await applyHook(root, claudeHook('PostToolUse', { taskId })) + const bound = (await record(root, 'state/design-tasks.json')).records.find( + (value) => value.task.taskId === taskId + ) + assert.equal(bound.task.client.kind, 'claude') + assert.equal(bound.task.client.sessionId, claudeSession) + } + await call(clients[0], 'get_structure', { nodeId: '1:2', taskId, taskEpoch: epoch }) + const body = Buffer.from('persistent fixture asset') + const hash = createHash('sha256').update(body).digest('hex') + const url = `${assetUrl}/assets/${hash}.png` + assert.equal( + (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body })).status, + 201 + ) + const beforeFile = await readFile(join(root, 'assets', `${hash}.png`)) + if (mode === 'normal') { + await assert.rejects(command('--switch', a), /newer stable/) + await assert.rejects(command('--switch', incompatible), /frozen MCP contract/) + } + // Both real cold probes finish before admitting the work held across drain. + // Their startup/inspection/catalog/shutdown budgets must not consume that work's deadline. + await writeFile(join(root, 'test-hold-before-drain'), '') + const update = command('--switch', b).then( + (value) => JSON.parse(value.stdout), + (error) => ({ interrupted: error.message }) + ) + const updateBoundary = (check, label, timeout) => + Promise.race([ + until(check, label, timeout), + update.then((outcome) => { + throw new Error(`Switch ended before ${label}: ${JSON.stringify(outcome)}`) + }) + ]) + await updateBoundary( + () => record(root, 'test-before-drain.json'), + 'candidate and active verification', + 10_000 + 2 * (15_000 + 5000 + 10_000 + 6000) + ) + const write = call(clients[0], 'apply_canvas', { + mode: 'update', + targetNodeId: '1:2', + markup: '', + taskId, + taskEpoch: epoch + }) + const lost = write.catch((error) => ({ transportError: error.message })) + await until(() => held, 'one admitted write') + assert.ok(held.terminalReceipt) + let upload, uploaded + if (mode === 'normal') { + const content = Buffer.from('upload spanning drain') + const digest = createHash('sha256').update(content).digest('hex') + upload = request(`${assetUrl}/assets/${digest}.png`, { + method: 'POST', + headers: { 'Content-Type': 'image/png', 'Content-Length': content.length } + }) + uploaded = new Promise((resolve, reject) => { + upload.once('error', reject) + upload.once('response', (response) => { + response.resume() + resolve(response.statusCode) + }) + }) + void uploaded.catch(() => {}) + upload.write(content.subarray(0, 1)) + await until( + async () => (await rpc(root, 'tempad/runtime-status')).blockers.includes('asset transfers'), + 'upload admitted' + ) + } + if (mode === 'orphan') { + extension.terminate() + await lost + } + const hooks = [] + const stopDuringPause = mode !== 'normal' && mode !== 'orphan' + let portBarrier + let newEdge + if (mode === 'initial-attach') { + await writeFile(join(root, 'test-delay-hub'), '') + newEdge = (async () => { + await until( + async () => (await record(root, 'switch.json'))?.phase === 'old-stopped', + 'delayed replacement startup' + ) + const client = await openClient('during-handoff') + assert.deepEqual(await client.listTools(), catalog) + })() + void newEdge.catch(() => {}) + } + if ( + ['preparing', 'old-stopped', 'candidate-ready', 'committed', 'port-conflict'].includes(mode) + ) { + await writeFile( + join(root, 'test-fault.json'), + JSON.stringify({ + phase: mode === 'port-conflict' ? 'old-stopped' : mode, + action: mode === 'port-conflict' ? 'hold' : 'crash', + version: '0.11.1' + }) + ) + if (mode === 'port-conflict') { + portBarrier = (async () => { + await until( + () => record(root, 'test-fault-observed.json'), + 'port conflict boundary', + 90_000 + ) + conflict = createServer() + conflict.listen(endpoints.wsPort, '127.0.0.1') + await once(conflict, 'listening') + await writeFile(join(root, 'test-fault-release'), '') + // Candidate startup must fail first; leave fallback a free port. + setTimeout(() => conflict.close(), 5800) + })() + void portBarrier.catch(() => {}) + } + } + await writeFile(join(root, 'test-release-drain'), '') + if (mode !== 'orphan') { + await updateBoundary( + async () => + (await readdir(root)).filter((name) => name.startsWith('test-admission-paused-')) + .length === 2, + 'edge admission pause' + ) + const count = calls.length + await assert.rejects( + call(clients[1], 'get_structure', { nodeId: '1:2' }), + (error) => error.data?.dispatched === false + ) + assert.equal(calls.length, count) + const hook = { + version: 1, + kind: 'claude', + sessionId: 'no-bound-task', + event: 'Interrupt', + invocationId: randomUUID() + } + hooks.push(hook) + await rpc(root, 'tempad/client-hook', hook, 'run/mcp.sock') + if (stopDuringPause) { + const requestId = randomUUID() + extension.send( + JSON.stringify({ + type: 'designAction', + sessionId: session.sessionId, + action: { requestId, taskId, epoch, action: 'stop' } + }) + ) + await until( + () => + frames.some( + (frame) => + frame.type === 'designActionResult' && + frame.result.requestId === requestId && + frame.result.status === 'delivered' + ), + 'Stop during admission pause' + ) + } + extension.send(JSON.stringify({ type: 'toolResult', id: held.id, payload: written })) + assert.ok(!(await lost).isError) + if (upload) { + const pending = await updateBoundary(async () => { + const state = await rpc(root, 'tempad/runtime-status') + return state.gated && state + }, 'Hub drain after edge admission') + assert.ok(pending.gated && pending.blockers.includes('asset transfers')) + assert.equal(pending.hubPid, initial.hubPid) + upload.end(Buffer.from('upload spanning drain').subarray(1)) + assert.equal(await uploaded, 201) + } + } + const outcome = await update + await writeFile(join(root, 'test-switch-outcome.json'), JSON.stringify(outcome), { + mode: 0o600 + }) + await portBarrier + await newEdge + const settled = await until( + async () => { + try { + const status = await rpc(root, 'tempad/runtime-status') + return status.phase === 'committed' && !status.gated && status + } catch { + return false + } + }, + 'committed runtime and attached edges', + 90000 + ) + if (mode === 'normal' || mode === 'orphan' || mode === 'initial-attach' || claudeStop) + assert.equal(outcome.status, 'activated', JSON.stringify(outcome)) + if (mode === 'port-conflict') + assert.equal(outcome.status, 'rolled-back', JSON.stringify(outcome)) + if ( + ['preparing', 'old-stopped', 'candidate-ready', 'committed', 'port-conflict'].includes(mode) + ) + assert.deepEqual(await record(root, 'test-fault-observed.json'), { + phase: mode === 'port-conflict' ? 'old-stopped' : mode, + pid: initial.pid + }) + assert.deepEqual(await record(root, 'endpoints.json'), endpoints) + assert.deepEqual(await clients[0].listTools(), catalog) + assert.deepEqual(await clients[1].listTools(), catalog) + for (const client of clients) + await until(async () => { + try { + return !(await call(client, 'list_design_sessions')).isError + } catch { + return false + } + }, 'initialized edge data connection') + await clients[0].ping() + assert.equal(await (await fetch(url)).text(), body.toString()) + assert.deepEqual(await readFile(join(root, 'assets', `${hash}.png`)), beforeFile) + assert.equal( + (await fetch(url, { headers: { Origin: 'https://attacker.invalid' } })).status, + 403 + ) + extension?.terminate() + await attach() + if (mode === 'orphan') { + const blocked = JSON.parse((await command('--runtime-status')).stdout) + assert.ok(JSON.stringify(blocked).includes(held.id)) + const snapshot = await record(root, 'state/design-tasks.json') + assert.ok(snapshot.records.some((value) => value.execution?.id === held.id)) + extension.send( + JSON.stringify({ type: 'toolCompletion', id: held.id, ...held.terminalReceipt }) + ) + await until( + () => frames.some((frame) => frame.type === 'toolCompletionAck' && frame.id === held.id), + 'old-generation receipt accepted' + ) + assert.ok( + !(await record(root, 'state/design-tasks.json')).records.some( + (value) => value.execution?.id === held.id + ) + ) + } + for (const input of hooks) + assert.deepEqual(await rpc(root, 'tempad/client-hook', input, 'run/mcp.sock'), {}) + const saved = (await record(root, 'state/design-tasks.json')).records.find( + (value) => value.task.taskId === taskId + ) + assert.equal(saved.task.status, mode === 'orphan' || stopDuringPause ? 'cancelled' : 'paused') + if (mode === 'preparing') { + // The crash precedes shutdown admission. The successor retains the live + // A Hub and stages B, so the task lease must not change. + assert.equal(settled.hubId, initial.hubId) + assert.equal(settled.active.version, '0.11.0') + assert.equal(settled.candidate.version, '0.11.1') + assert.equal(saved.task.epoch, epoch) + } else assert.ok(saved.task.epoch > epoch) + if (mode === 'normal' || mode === 'claude-stop-rollback') { + assert.deepEqual(JSON.parse((await command('--recover-runtime')).stdout), { + status: 'recovered', + version: '0.11.1' + }) + assert.equal((await rpc(root, 'tempad/runtime-status')).hubId, settled.hubId) + // Both cancellation and a yet-undelivered Claude notice must survive rollback. + if (!claudeStop) + extension.send( + JSON.stringify({ + type: 'designAction', + sessionId: session.sessionId, + action: { requestId: randomUUID(), taskId, epoch: saved.task.epoch, action: 'stop' } + }) + ) + await until( + async () => + (await record(root, 'state/design-tasks.json')).records.find( + (value) => value.task.taskId === taskId + )?.task.status === 'cancelled', + 'post-activation Stop' + ) + assert.ok(ownedProcesses(root).includes(settled.hubPid)) + process.kill(settled.hubPid, 'SIGKILL') + const rollback = await until( + async () => { + try { + const status = await rpc(root, 'tempad/runtime-status') + return !status.gated && status.active.version === '0.11.0' && status + } catch { + return false + } + }, + 'rollback after active Hub failure', + 90000 + ) + assert.ok(rollback.failure) + for (const client of clients) + await until(async () => { + try { + return !(await call(client, 'list_design_sessions')).isError + } catch { + return false + } + }, 'edge data connection after rollback') + assert.equal( + (await record(root, 'state/design-tasks.json')).records.find( + (value) => value.task.taskId === taskId + ).task.status, + 'cancelled' + ) + assert.deepEqual(await clients[0].listTools(), catalog) + assert.equal(await (await fetch(url)).text(), body.toString()) + await assert.rejects(command('--switch', b), /quarantined/) + } + if (claudeStop) { + const input = claudeHook('PreToolUse') + const result = await applyHook(root, input) + assert.equal(result.output?.hookSpecificOutput?.hookEventName, 'PreToolUse') + assert.ok( + result.output.hookSpecificOutput.additionalContext.includes( + `The user stopped Figma design task ${taskId}.` + ) + ) + assert.deepEqual(await applyHook(root, input), result) + assert.equal((await applyHook(root, claudeHook('PostToolUse'))).output, undefined) + assert.equal( + (await record(root, 'state/design-tasks.json')).records.find( + (value) => value.task.taskId === taskId + ).task.status, + 'cancelled' + ) + } + assert.ok(edgePids.every(alive), 'The original initialized edges must survive handoff.') + assert.equal(calls.filter((value) => value.payload.name === 'apply_canvas').length, 1) + results.push({ + mode, + outcome, + active: settled.active.version, + initializedClients: clients.length, + initializeMs, + writes: 1 + }) + passed = true + } catch (error) { + await preserveFixtureEvidence(root, `${mode}: ${error.stack}\n${stderr}`) + throw new Error(`${mode}: ${error.message}\n${stderr}\nRoot: ${root}`, { cause: error }) + } finally { + conflict?.close() + extension?.terminate() + await Promise.allSettled(clients.map((client) => client.close())) + for (const signal of ['SIGTERM', 'SIGKILL']) { + for (const pid of ownedProcesses(root)) { + try { + process.kill(pid, signal) + } catch (error) { + if (error.code !== 'ESRCH') console.error(`Fixture cleanup failed: ${error.message}`) + } + } + await until(() => !ownedProcesses(root).some(alive), 'fixture cleanup', 5000).catch(() => {}) + } + assert.equal(ownedProcesses(root).length, 0) + if (passed && !process.env.TEMPAD_KEEP_PROTOTYPE) + await rm(root, { recursive: true, force: true }) + } +} + +async function coldAttachment(entry) { + const root = await fixtureRoot('tp-sw-cold-') + const clients = [] + let passed = false + let stderr = '' + async function openClient() { + const transport = new StdioClientTransport({ + command: process.execPath, + args: fixtureNodeArguments([entry, '--root', root]), + stderr: 'pipe', + env: fixtureEnvironment(root) + }) + transport.stderr.on('data', (chunk) => { + stderr = (stderr + chunk).slice(-20000) + }) + const client = new Client({ name: 'cold-start', version: '1' }) + clients.push(client) + await client.connect(transport, { timeout: FIXTURE_INITIALIZE_TIMEOUT_MS }) + return { client, pid: transport.pid } + } + try { + const first = await openClient() + const catalog = await first.client.listTools() + const initial = await record(root, 'supervisor.json') + // Kill the edge first so its established recovery cannot publish an endpoint. + // Leave real writer leases intact; a fresh edge must wait for cold recovery. + process.kill(first.pid, 'SIGKILL') + await first.client.close() + process.kill(initial.pid, 'SIGKILL') + process.kill(initial.hubPid, 'SIGKILL') + await until(() => !alive(initial.pid) && !alive(initial.hubPid), 'cold process exit') + const started = performance.now() + const second = await openClient() + const elapsed = performance.now() - started + assert.ok(elapsed > 5000, `Cold recovery did not exercise the endpoint wait: ${elapsed}ms`) + assert.deepEqual(await second.client.listTools(), catalog) + await second.client.ping() + results.push({ + mode: 'cold-attach', + elapsedMs: Math.round(elapsed), + initializedClients: 1, + writes: 0 + }) + passed = true + } catch (error) { + await preserveFixtureEvidence(root, `cold-attach: ${error.stack}\n${stderr}`) + throw new Error(`cold-attach: ${error.message}\n${stderr}\nRoot: ${root}`, { cause: error }) + } finally { + await Promise.allSettled(clients.map((client) => client.close())) + for (const pid of ownedProcesses(root)) { + try { + process.kill(pid, 'SIGKILL') + } catch (error) { + if (error.code !== 'ESRCH') console.error(`Fixture cleanup failed: ${error.message}`) + } + } + await until(() => !ownedProcesses(root).some(alive), 'cold fixture cleanup', 5000) + if (passed && !process.env.TEMPAD_KEEP_PROTOTYPE) + await rm(root, { recursive: true, force: true }) + } +} +try { + const a = await artifact('0.11.0') + const b = await artifact('0.11.1') + const incompatible = await artifact('0.11.2', true) + for (const mode of ( + process.env.TEMPAD_SWITCH_CASES ?? + 'normal,orphan,preparing,old-stopped,candidate-ready,committed,port-conflict,claude-stop,claude-stop-rollback,initial-attach,cold-attach' + ).split(',')) { + if (mode === 'cold-attach') await coldAttachment(a) + else await scenario(a, b, mode, incompatible) + console.log(`Runtime switch case passed: ${mode}`) + } + console.log(JSON.stringify({ valid: true, cases: results }, null, 2)) + completed = true +} finally { + // Failed probes retain their own root and executable evidence for diagnosis. + if (!process.env.TEMPAD_KEEP_PROTOTYPE && completed) + await rm(artifacts, { recursive: true, force: true }) +} diff --git a/packages/mcp-server/scripts/check-runtime-updates.mjs b/packages/mcp-server/scripts/check-runtime-updates.mjs new file mode 100644 index 00000000..cf1046f9 --- /dev/null +++ b/packages/mcp-server/scripts/check-runtime-updates.mjs @@ -0,0 +1,1123 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { execFile, spawn } from 'node:child_process' +import console from 'node:console' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { chmod, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { createServer as createSocketServer } from 'node:net' +import { dirname, join, resolve, sep } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' +import { fileURLToPath, pathToFileURL, URL } from 'node:url' +import { promisify } from 'node:util' +import { build } from 'tsdown' + +import { assertPrivatePath, runtimeEndpoint } from '../src/runtime-platform.ts' +import config from '../tsdown.config.ts' +import { runPackageManager } from './package-manager.mjs' +import { runtimeSourceHash } from './runtime-source-hash.mjs' +import { + FIXTURE_INITIALIZE_TIMEOUT_MS, + commandArguments, + fixtureEnvironment, + fixtureNodeArguments, + fixtureRoot, + preserveFixtureEvidence, + processInventory +} from './runtime-test-platform.mjs' + +if (process.env.TEMPAD_MCP_DEV_CHECKOUT) + throw new Error('Runtime acquisition acceptance requires an isolated release-test environment.') +const exec = promisify(execFile) +const source = fileURLToPath(new URL('..', import.meta.url)) +const repo = join(source, '../..') +const sourceHash = runtimeSourceHash(repo) +if (process.env.TEMPAD_RUNTIME_EVIDENCE_DIR) await import('./runtime-startup-trace.mjs') +const root = await fixtureRoot('tp-release-test-') +const packaged = join(root, 'source.tgz') +const clients = [] +const transports = [] +let control +let occupiedAssetPort +let occupiedWebSocketPort +let succeeded = false +let runtimePids = [] +const automaticPids = new Set() +const releases = new Map() +const requests = [] +// UpdateManager permits a 240 s worker and resumes a partial scan after 30 s. +// Observe those production bounds; do not truncate a valid Windows acquisition. +const acquisitionTimeout = 245_000 +const stagingTimeout = acquisitionTimeout + 30_000 +let online = true +let latestVersion +function metadata(release) { + return { + name: '@tempad-dev/mcp', + version: release.version, + dist: { integrity: release.integrity, tarball: release.url } + } +} +const registry = createServer((req, res) => { + requests.push(req.url) + if (!online) { + res.writeHead(503) + res.end() + return + } + if (req.url === '/@tempad-dev%2fmcp') { + res.setHeader('content-type', 'application/json') + res.end( + JSON.stringify({ + name: '@tempad-dev/mcp', + 'dist-tags': { latest: latestVersion }, + versions: Object.fromEntries( + [...releases].map(([version, release]) => [version, metadata(release)]) + ) + }) + ) + return + } + const selector = req.url?.startsWith('/@tempad-dev%2fmcp/') + ? req.url.slice('/@tempad-dev%2fmcp/'.length) + : undefined + const release = selector + ? releases.get(selector === 'latest' ? latestVersion : selector) + : [...releases.values()].find( + (item) => item.url === `http://127.0.0.1:${registry.address().port}${req.url}` + ) + if (!release) { + res.writeHead(404) + res.end() + return + } + if (selector) { + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(metadata(release))) + } else { + res.setHeader('content-type', 'application/octet-stream') + res.end(release.bytes) + } +}) +await new Promise((resolve) => registry.listen(0, '127.0.0.1', resolve)) +const registryUrl = `http://127.0.0.1:${registry.address().port}` +const isolationEnv = { + ...process.env, + ...fixtureEnvironment(join(root, 'home'), root), + TEMPAD_MCP_RUNTIME_DIR: join(root, 'run'), + TEMPAD_MCP_STATE_DIR: join(root, 'state'), + TEMPAD_MCP_ASSET_DIR: join(root, 'assets'), + TEMPAD_MCP_LOG_DIR: join(root, 'logs') +} +for (const key of [ + 'NODE_OPTIONS', + 'TEMPAD_MCP_DEV_CHECKOUT', + 'CODEX_HOME', + 'CODEX_SQLITE_HOME', + 'CODEX_THREAD_ID', + 'CODEX_SESSION_ID', + 'CODEX_APP_TOOLS_PIPE_PATH', + 'CLAUDECODE', + 'CLAUDE_CODE', + 'CLAUDE_CODE_SESSION_ID' +]) + delete isolationEnv[key] +for (const path of ['home', 'run', 'state', 'assets', 'logs']) + await mkdir(join(root, path), { mode: 0o700 }) +// Verifier imports initialize logging; keep that tooling process private too. +for (const key of [ + 'TEMPAD_MCP_RUNTIME_DIR', + 'TEMPAD_MCP_STATE_DIR', + 'TEMPAD_MCP_ASSET_DIR', + 'TEMPAD_MCP_LOG_DIR' +]) + process.env[key] = isolationEnv[key] +const verify = await import('../dist/runtime-verification.mjs') +const { ControlPeer, SocketTransport, RuntimeDataTransport, ResultSchema, connectSocket } = verify +const pkg = JSON.parse(await readFile(join(source, 'package.json'), 'utf8')) +const policy = JSON.parse(await readFile(join(source, 'runtime-policy.json'), 'utf8')) +const [major, minor, patch] = pkg.version.split('.').map(Number) +const versions = [0, 1, 2, 3].map((offset) => `${major}.${minor}.${patch + offset}`) +async function until(check, label, timeout = 60_000) { + const deadline = performance.now() + timeout + do { + const value = await check() + if (value) return value + await delay(50) + } while (performance.now() < deadline) + throw new Error(`Timed out: ${label}`) +} +async function makeArtifact(version, base, epoch) { + const directory = join(root, version) + await cp(base, directory, { recursive: true }) + await writeFile(join(directory, 'package.json'), JSON.stringify({ ...pkg, version })) + await build({ + ...config, + config: false, + cwd: source, + outDir: join(directory, 'dist'), + sourcemap: false, + plugins: [ + ...config.plugins, + { + name: 'isolated-release-fixture', + load(id) { + if (resolve(id) === join(source, 'package.json')) + return JSON.stringify({ ...pkg, version }) + }, + transform(code, id) { + if (process.platform === 'win32' && resolve(id) === join(source, 'src/shared.ts')) { + const boundary = 'export const SOCK_PATH = resolveSockPath()' + assert.ok(code.includes(boundary), 'fixture public endpoint boundary changed') + return code.replace( + boundary, + `import { runtimeEndpoint as fixtureRuntimeEndpoint } from './runtime-platform'\nexport const SOCK_PATH = fixtureRuntimeEndpoint(RUNTIME_DIR, 'mcp.sock')` + ) + } + if (resolve(id) === join(source, 'src/runtime-update/registry.ts')) { + assert.ok(code.includes("const REGISTRY = 'https://registry.npmjs.org'")) + return code.replace( + "const REGISTRY = 'https://registry.npmjs.org'", + `const REGISTRY = ${JSON.stringify(registryUrl)}` + ) + } + if (resolve(id) === join(source, 'src/runtime-update/cold-start.ts')) { + const candidates = 'getMcpServerConfig(RUNTIME_PROFILE).wsPortCandidates' + assert.ok(code.includes(candidates), 'fixture cold endpoint boundary changed') + return code.replace(candidates, '[0]') + } + if (resolve(id) === join(source, 'src/hub-runtime.ts')) { + const boundary = 'const runtimeDataSockets = new Map()' + assert.ok(code.includes(boundary)) + code = code.replace( + boundary, + `${boundary}\n if (!endpoints && options.prototype?.release) wsPortCandidates.splice(0, wsPortCandidates.length, 0)` + ) + if (version !== versions[0]) + code = code.replace( + "title: 'TemPad Dev'", + "title: 'TemPad Dev compatible correction'" + ) + const localRead = "case 'list_design_sessions':" + const readStart = code.indexOf(localRead) + assert.ok(readStart >= 0) + const handler = 'handler: async () => {' + const handlerStart = code.indexOf(handler, readStart) + handler.length + assert.ok(handlerStart >= readStart + handler.length) + // A real admitted local request stays pending long enough to observe + // drain status; no native host or Figma execution is simulated here. + code = `${code.slice(0, handlerStart)}\n await new Promise((resolve) => setTimeout(resolve, 150))${code.slice(handlerStart)}` + return code + } + } + } + ] + }) + await rm(join(directory, 'dist/runtime-manifest.json'), { force: true }) + const packed = JSON.parse( + ( + await runPackageManager( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', root], + { + cwd: directory, + maxBuffer: 8 * 1024 * 1024 + } + ) + ).stdout + )[0] + const files = verify.readArchive(await readFile(join(root, packed.filename))) + const probe = await verify.probeArtifact(join(directory, 'dist/managed-runtime.mjs')) + const preceding = [...releases.values()].at(-1) + const manifest = verify.ManifestSchema.parse({ + ...policy, + packageVersion: version, + guidanceEpoch: epoch, + guidanceReview: { + ...policy.guidanceReview, + version, + ...(preceding + ? { + decision: epoch === policy.guidanceEpoch ? 'compatible' : 'new-epoch', + baseline: { version: preceding.version, integrity: preceding.integrity } + } + : {}) + }, + name: pkg.name, + entry: 'dist/managed-runtime.mjs', + node: pkg.engines.node, + platforms: ['darwin', 'linux', 'win32'], + wireHash: probe.wireHash, + presentationHash: probe.presentationHash, + lockHash: 'a'.repeat(64), + files: Object.fromEntries([...files].map(([path, bytes]) => [path, verify.sha256(bytes)])) + }) + if (preceding) verify.validatePolicyBaseline(manifest, preceding.manifest) + await writeFile(join(directory, 'dist/runtime-manifest.json'), JSON.stringify(manifest)) + await rm(join(root, packed.filename)) + const final = JSON.parse( + ( + await runPackageManager( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', root], + { + cwd: directory, + maxBuffer: 8 * 1024 * 1024 + } + ) + ).stdout + )[0] + const bytes = await readFile(join(root, final.filename)) + verify.verifyArchive(bytes, version, final.integrity) + const release = { + version, + directory, + manifest, + bytes, + integrity: final.integrity, + url: `${registryUrl}/@tempad-dev/mcp/-/mcp-${version}.tgz` + } + releases.set(version, release) + latestVersion = version + return release +} +async function client(release, policy = 'off', env = isolationEnv, name = 'release-acceptance') { + const transport = new StdioClientTransport({ + command: process.execPath, + args: fixtureNodeArguments([ + join(release.directory, 'dist/cli.mjs'), + ...(policy !== 'default' ? [`--runtime-updates=${policy}`] : []) + ]), + env, + stderr: 'pipe' + }) + let diagnostic = '' + transport.stderr?.on('data', (bytes) => { + diagnostic = (diagnostic + bytes).slice(-10_000) + }) + transports.push(transport) + const client = new Client({ name, version: '1' }) + clients.push(client) + try { + // First enrollment can wait for another CLI to retain the verified package + // before the edge's separate attachment/recovery budget begins. + await client.connect(transport, { timeout: 2 * FIXTURE_INITIALIZE_TIMEOUT_MS }) + } catch (error) { + throw new Error(`${String(error)}\n${diagnostic}`, { cause: error }) + } + return client +} +function exited(pid) { + try { + process.kill(pid, 0) + return false + } catch (error) { + if (error.code === 'ESRCH') return true + throw error + } +} + +async function stopFixture(directory) { + const runtimeRoot = join(directory, 'state/runtime') + for (const [role, path] of [ + ['supervisor', join(runtimeRoot, 'supervisor.json')], + ['hub', join(directory, 'run/hub-runtime.json')] + ]) { + let record + try { + assertPrivatePath(path, 'file') + record = JSON.parse(await readFile(path, 'utf8')) + } catch (error) { + if (error.code === 'ENOENT') continue + throw error + } + const pid = role === 'supervisor' ? record.pid : record.processId + const entry = role === 'supervisor' ? record.entry : record.entryPath + assert.ok(Number.isInteger(pid) && pid > 0 && entry.startsWith(`${root}${sep}`)) + if (exited(pid)) continue + let command + try { + command = processInventory().find((value) => value.pid === pid)?.command + if (!command && exited(pid)) continue + assert.ok(command, 'fixture process command must be inspectable before cleanup') + } catch (error) { + if (exited(pid)) continue + throw error + } + assert.deepEqual(commandArguments(command).slice(-5), [ + entry, + '--root', + runtimeRoot, + '--role', + role + ]) + try { + process.kill(pid, 'SIGTERM') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } + await until(() => exited(pid), `${role} fixture cleanup`, 15_000) + } +} + +async function automaticAcceptance(a, b) { + const directory = join(root, 'auto') + const env = { ...isolationEnv } + for (const [key, name] of Object.entries({ + TEMPAD_MCP_RUNTIME_DIR: 'run', + TEMPAD_MCP_STATE_DIR: 'state', + TEMPAD_MCP_ASSET_DIR: 'assets', + TEMPAD_MCP_LOG_DIR: 'logs' + })) { + env[key] = join(directory, name) + await mkdir(env[key], { recursive: true, mode: 0o700 }) + } + // These are SDK clients with host identity fixtures, not installed-host + // acceptance. Hold publication until an explicit opt-out observer is attached. + const firstClient = clients.length + const firstTransport = transports.length + const published = latestVersion + latestVersion = a.version + const owner = new ControlPeer() + const controlPeers = [owner] + try { + const generic = await client(a, 'default', env) + const codexEnv = { ...env, CODEX_THREAD_ID: 'automatic-release-fixture' } + const codex = await client(a, 'default', codexEnv, 'codex-mcp-client') + const catalog = await codex.listTools() + const genericCatalog = await generic.listTools() + let observer = await client(a, 'off', env) + await owner.connect( + new SocketTransport( + await connectSocket(runtimeEndpoint(join(directory, 'run'), 'supervisor.sock')) + ) + ) + const status = async () => { + const state = await owner.request({ method: 'tempad/runtime-status' }, ResultSchema) + for (const pid of [state.supervisorPid, state.hubPid]) if (pid) automaticPids.add(pid) + return state + } + await until(async () => { + const state = await status() + return state.updates.lastAttempt && !state.updates.checking + }, 'initial default update check') + // Exercise the real CLI confirmation using controlled pipes. An exact version + // above latest can be downloaded, but cancelling must not enroll it for idle activation. + const confirmation = spawn( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + process.stdin.isTTY = true; + process.stdout.isTTY = true; + process.argv = [process.execPath, ${JSON.stringify(join(a.directory, 'dist/cli.mjs'))}, '--update-runtime', ${JSON.stringify(b.version)}]; + await import(${JSON.stringify(pathToFileURL(join(a.directory, 'dist/cli.mjs')).href)}); + ` + ], + { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } + ) + let prompt = '' + let confirmationError = '' + const confirmed = once(confirmation, 'exit', { + signal: globalThis.AbortSignal.timeout(acquisitionTimeout + 5000) + }) + confirmation.stdout.on('data', (bytes) => { + prompt = (prompt + bytes).slice(-10000) + if (prompt.includes('Type "update') && !confirmation.stdin.writableEnded) + confirmation.stdin.end('cancel\n') + }) + confirmation.stderr.on('data', (bytes) => { + confirmationError = (confirmationError + bytes).slice(-10000) + }) + try { + assert.notEqual((await confirmed)[0], 0) + } finally { + if (confirmation.exitCode === null) confirmation.kill() + } + assert.match(confirmationError, /activation cancelled/) + assert.equal((await status()).updates.manualCandidate.version, b.version) + await observer.close() + const manualWait = performance.now() + 8000 + await until(async () => { + assert.equal( + (await status()).active.version, + a.version, + 'cancelled exact release must never auto-activate' + ) + return performance.now() >= manualWait + }, 'manual staging remains inactive') + observer = await client(a, 'off', env) + latestVersion = published + const stagingStarted = performance.now() + await owner.request( + { method: 'tempad/runtime-check', params: { version: 'latest' } }, + ResultSchema, + { timeout: acquisitionTimeout } + ) + const staged = await until( + async () => { + const state = await status() + if (state.updates.error) throw new Error(state.updates.error) + return state.updates.candidate?.version === b.version && state + }, + 'automatic fixture staging', + stagingTimeout + ) + const stagingMs = Math.round(performance.now() - stagingStarted) + assert.equal(staged.active.version, a.version) + assert.equal(staged.updates.selection.latest, published) + assert.ok( + staged.updates.selection.latestReasons.some((reason) => reason.includes('Guidance epoch')) + ) + assert.equal(staged.updates.automaticActivation, false) + assert.deepEqual( + staged.autoBlockers, + [], + 'initialized generic clients support automatic switching' + ) + const oldPid = staged.hubPid + const remainsStaged = async (reason) => { + const deadline = performance.now() + 8000 + await until(async () => { + const state = await status() + assert.equal(state.hubPid, oldPid, reason) + return performance.now() >= deadline + }, reason) + } + // Keep the data socket alive independently of the supervisor connection. + // A disconnected opt-out edge must not disappear from update admission. + const detachedEdge = async (policy) => { + const registration = { + sessionId: randomUUID(), + token: randomUUID(), + versions: [2], + updates: { + policy, + version: a.version, + manifestHash: verify.sha256( + await readFile(join(a.directory, 'dist/runtime-manifest.json')) + ) + }, + context: { + version: 1, + home: directory, + tempDir: directory, + codexHome: directory, + codexSqliteHome: directory + } + } + let peer + let grant + const reconnect = async () => { + peer = new ControlPeer() + controlPeers.push(peer) + await peer.connect( + new SocketTransport( + await connectSocket(runtimeEndpoint(join(directory, 'run'), 'supervisor.sock')) + ) + ) + grant = await peer.request( + { method: 'tempad/runtime-hello', params: registration }, + ResultSchema + ) + await peer.request( + { + method: 'tempad/runtime-ready', + params: { hubId: grant.hubId } + }, + ResultSchema + ) + } + await reconnect() + const wire = new SocketTransport( + await connectSocket(runtimeEndpoint(join(directory, 'run'), 'runtime-hub.sock')) + ) + await new Promise((resolve, reject) => { + wire.onerror = reject + wire.onclose = () => reject(new Error('Data attachment closed')) + wire.onmessage = (message) => + 'result' in message ? resolve() : reject(new Error(JSON.stringify(message))) + void wire + .start() + .then(() => + wire.send({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/runtime-attach', + params: { sessionId: registration.sessionId, token: registration.token } + }) + ) + .catch(reject) + }) + const data = new Client({ name: 'detached-control-fixture', version: '1' }) + clients.push(data) + await data.connect( + new RuntimeDataTransport(wire, { + role: 'edge', + generation: () => grant.generation, + admit: () => true, + started: true + }) + ) + return { data, reconnect, disconnect: () => peer.close() } + } + const claude = await client(a, 'stable', { ...env, CLAUDECODE: '1' }, 'claude-code') + await observer.close() + const claudeBlocked = await until(async () => { + const state = await status() + return state.autoBlockers.some((reason) => reason.includes('Claude')) && state + }, 'unverified Claude hook blocker') + assert.equal(claudeBlocked.hubPid, oldPid) + await remainsStaged('unverified Claude bindings must keep the candidate staged') + const off = await client(a, 'off', codexEnv, 'codex-mcp-client') + const hook = async (version) => { + const params = { + version, + kind: 'claude', + sessionId: 'automatic-claude-fixture', + event: 'PreToolUse', + invocationId: randomUUID(), + ...(version === 2 ? { issuedAt: Date.now(), profile: { kind: 'release' } } : {}) + } + return until(async () => { + const peer = new ControlPeer() + try { + await peer.connect( + new SocketTransport( + await connectSocket(runtimeEndpoint(join(directory, 'run'), 'mcp.sock')) + ) + ) + const result = await peer.request({ method: 'tempad/client-hook', params }, ResultSchema) + if (version === 1) return result + assert.equal(result.receipt.invocationId, params.invocationId) + return result.receipt.state === 'applied' && result + } finally { + await peer.close() + } + }, 'Claude hook receipt applied') + } + await claude.notification({ + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: 'automatic-claude-fixture' } + }) + await hook(1) + await claude.callTool({ name: 'list_design_sessions', arguments: {} }) + assert.ok( + (await status()).blockers.includes('Claude binding without receipt/retry hooks'), + 'legacy hooks must still prevent handoff' + ) + const receipt = await hook(2) + assert.equal(receipt.receipt.version, 2) + await claude.callTool({ name: 'list_design_sessions', arguments: {} }) + const policyBlocked = await until(async () => { + const state = await status() + return state.autoBlockers.length === 0 && state + }, 'Claude binding has verified receipt/retry hooks') + assert.ok( + policyBlocked.updates.pendingReasons.some((reason) => reason.includes('not opted in')) + ) + assert.equal(policyBlocked.updates.automaticActivation, false) + assert.equal(policyBlocked.hubPid, oldPid) + await remainsStaged('a non-opt-in peer must keep the candidate staged') + + const detached = await detachedEdge('off') + const pinned = await detachedEdge('pinned') + await detached.disconnect() + await pinned.disconnect() + await off.close() + await remainsStaged('off/pinned data connections must survive control-only disconnects') + assert.ok( + (await status()).updates.automaticPendingReasons.some((reason) => + reason.includes('control connection') + ) + ) + await detached.data.ping() + await pinned.data.ping() + await detached.reconnect() + await remainsStaged('reconnected off policy must still block activation') + await detached.disconnect() + await detached.data.close() + await remainsStaged('remaining pinned data connection must still block activation') + await pinned.reconnect() + await remainsStaged('reconnected pinned policy must still block activation') + await pinned.disconnect() + + // Every completed call resets the quiet window. Keep sending actual tool + // calls longer than that window, including while the non-opt-in peer exits. + const workUntil = performance.now() + 6500 + let calls = 0 + let workError + const work = (async () => { + do { + const result = await codex.callTool({ name: 'list_design_sessions', arguments: {} }) + assert.notEqual(result.isError, true) + calls++ + } while (performance.now() < workUntil) + })().catch((error) => { + workError = error + }) + await until(async () => { + const state = await status() + return state.blockers.includes('admitted MCP calls') + }, 'actual admitted tool work') + await pinned.data.close() + while (performance.now() < workUntil) { + assert.equal((await status()).hubPid, oldPid, 'busy work must not be interrupted to update') + await delay(100) + } + await work + if (workError) throw workError + assert.ok(calls > 1) + + let gateStarted + let waitingCall + const changed = await until(async () => { + const state = await status() + if (state.gated && !gateStarted) { + gateStarted = performance.now() + waitingCall = codex.callTool({ name: 'list_design_sessions', arguments: {} }) + // Observe failures immediately even while the status loop is pending. + void waitingCall.catch(() => {}) + } + return state.active.version === b.version && !state.gated && state + }, 'automatic compatible activation') + assert.ok(gateStarted, 'the real admission pause must be observed') + const admissionPauseMs = Math.round(performance.now() - gateStarted) + assert.notEqual((await waitingCall).isError, true, 'a call arriving during handoff must wait') + assert.notEqual(changed.hubPid, oldPid) + assert.equal(changed.supervisorPid, staged.supervisorPid) + assert.deepEqual(await codex.listTools(), catalog, 'automatic switching preserves the catalog') + assert.deepEqual(await generic.listTools(), genericCatalog, 'generic session keeps its catalog') + await codex.ping() + await generic.ping() + await hook(2) + assert.notEqual( + (await claude.callTool({ name: 'list_design_sessions', arguments: {} })).isError, + true, + 'Claude keeps its MCP connection across automatic switching' + ) + assert.deepEqual((await status()).autoBlockers, []) + await claude.close() + await codex.close() + await generic.close() + await until(() => [...automaticPids].every(exited), 'automatic fixture shutdown', 15_000) + return { stagingMs, admissionPauseMs, uninterruptedCalls: calls } + } catch (error) { + const diagnostic = await owner + .request({ method: 'tempad/runtime-status' }, ResultSchema, { timeout: 5000 }) + .catch((inspectionError) => ({ error: String(inspectionError) })) + await writeFile( + join(directory, 'failure-status.json'), + JSON.stringify(diagnostic, null, 2) + ).catch((inspectionError) => console.error(`Could not save failure status: ${inspectionError}`)) + await preserveFixtureEvidence(directory, error.stack) + throw error + } finally { + latestVersion = published + await Promise.all(controlPeers.map((peer) => peer.close())) + await Promise.allSettled(clients.slice(firstClient).map((client) => client.close())) + await Promise.allSettled(transports.slice(firstTransport).map((transport) => transport.close())) + // A failed initialize can leave an owner before status ever records its PID. + // Only stop processes whose private records and live argv match this fixture. + await stopFixture(directory) + } +} +try { + await exec( + process.execPath, + [join(source, 'scripts/prepare-runtime-release.mjs'), '--output', packaged], + { maxBuffer: 8 * 1024 * 1024 } + ) + const base = join(root, 'base') + for (const [path, bytes] of verify.readArchive(await readFile(packaged))) { + const target = join(base, path) + await mkdir(dirname(target), { recursive: true, mode: 0o700 }) + await writeFile(target, bytes, { mode: 0o600 }) + } + const a = await makeArtifact(versions[0], base, policy.guidanceEpoch) + const b = await makeArtifact(versions[1], base, policy.guidanceEpoch) + const c = await makeArtifact(versions[2], base, policy.guidanceEpoch + 1) + // Stable selection must not fetch this untagged version. + const ahead = { + ...b, + version: versions[3], + bytes: Buffer.from('not a stable release'), + url: `${registryUrl}/@tempad-dev/mcp/-/mcp-${versions[3]}.tgz` + } + releases.set(ahead.version, ahead) + const defaultStatus = await exec( + process.execPath, + [join(a.directory, 'dist/cli.mjs'), '--runtime-update-status'], + { env: isolationEnv } + ) + assert.equal(JSON.parse(defaultStatus.stdout).managed, false) + assert.equal(requests.length, 0) + await assert.rejects(readFile(join(root, 'state/runtime/prototype.json'))) + const legacySockets = new Set() + let legacyBytes = 0 + const legacy = createSocketServer((socket) => { + legacySockets.add(socket) + socket.once('close', () => legacySockets.delete(socket)) + socket.on('data', (chunk) => { + legacyBytes += chunk.length + }) + }) + const legacyPath = runtimeEndpoint(join(root, 'run'), 'mcp.sock') + await new Promise((resolve) => legacy.listen(legacyPath, resolve)) + // Released Windows Hubs used the default pipe ACL; do not upgrade the fixture's ACL. + if (process.platform !== 'win32') await chmod(legacyPath, 0o600) + const before = requests.length + try { + await assert.rejects( + exec(process.execPath, [join(a.directory, 'dist/cli.mjs'), '--check-runtime-update'], { + env: isolationEnv + }), + process.platform === 'win32' ? /Close\/restart all TemPad MCP connections once/ : /legacy Hub/ + ) + assert.equal(legacyBytes, 0) + } finally { + for (const socket of legacySockets) socket.destroy() + await new Promise((resolve) => legacy.close(resolve)) + } + assert.equal(requests.length, before) + await assert.rejects(readFile(join(root, 'state/runtime/prototype.json'))) + + await writeFile(join(root, 'assets/preserved.txt'), 'pre-enrollment asset') + // A background metadata failure must disable checks, not kill the supervisor + // or start acquisition without a durable attempt. Exercise a real write error. + const storageFailureRoot = join(root, 'f') + const storageFailureEnv = { ...isolationEnv } + for (const [key, name] of Object.entries({ + TEMPAD_MCP_RUNTIME_DIR: 'run', + TEMPAD_MCP_STATE_DIR: 'state', + TEMPAD_MCP_ASSET_DIR: 'assets', + TEMPAD_MCP_LOG_DIR: 'logs' + })) { + storageFailureEnv[key] = join(storageFailureRoot, name) + await mkdir(storageFailureEnv[key], { recursive: true, mode: 0o700 }) + } + const metadataTemporary = join(storageFailureRoot, 'state/runtime/updates.json.tmp') + await mkdir(metadataTemporary, { recursive: true, mode: 0o700 }) + const storageFailureClient = await client(a, 'stable', storageFailureEnv) + control = new ControlPeer() + await control.connect( + new SocketTransport( + await connectSocket(runtimeEndpoint(join(storageFailureRoot, 'run'), 'supervisor.sock')) + ) + ) + const storageFailure = await until(async () => { + const value = await control.request({ method: 'tempad/runtime-status' }, ResultSchema) + return value.updates.error?.includes('could not be saved') && value + }, 'isolated update metadata failure') + runtimePids = [storageFailure.supervisorPid, storageFailure.hubPid] + assert.match(storageFailure.updates.error, /EISDIR|EPERM|EACCES/) + assert.equal(storageFailure.updates.checking, false) + assert.equal(requests.length, before, 'failed attempt must not query the registry') + await storageFailureClient.ping() + await storageFailureClient.listTools() + const stillServing = await control.request({ method: 'tempad/runtime-status' }, ResultSchema) + assert.equal(stillServing.supervisorPid, storageFailure.supervisorPid) + assert.equal(stillServing.hubPid, storageFailure.hubPid) + await control.close() + await storageFailureClient.close() + await until(() => runtimePids.every(exited), 'storage fault runtime shutdown', 15_000) + await rm(metadataTemporary, { recursive: true }) + latestVersion = a.version + const started = performance.now() + const [first, parallel] = await Promise.all([client(a, 'stable'), client(a, 'stable')]) + const firstInitializeMs = Math.round(performance.now() - started) + await parallel.listTools() + await parallel.close() + const catalog = await first.listTools() + const second = await client(a, 'off') + control = new ControlPeer() + await control.connect( + new SocketTransport(await connectSocket(runtimeEndpoint(join(root, 'run'), 'supervisor.sock'))) + ) + const request = (method, params) => + control.request({ method, ...(params ? { params } : {}) }, ResultSchema, { + timeout: method === 'tempad/runtime-check' ? acquisitionTimeout : 180_000 + }) + const status = () => request('tempad/runtime-status') + await until(async () => { + const state = await status() + return state.updates.lastAttempt && !state.updates.checking + }, 'initial check before candidate publication') + const beforeNoninteractive = requests.length + await assert.rejects( + exec(process.execPath, [join(a.directory, 'dist/cli.mjs'), '--update-runtime', ahead.version], { + env: isolationEnv + }), + /interactive confirmation/ + ) + assert.equal( + requests.length, + beforeNoninteractive, + 'noninteractive activation must not acquire a release' + ) + assert.equal((await status()).updates.manualCandidate, undefined) + latestVersion = c.version + const stagingStarted = performance.now() + await request('tempad/runtime-check', { version: 'latest' }) + const staged = await until( + async () => { + const value = await status() + if (value.updates?.error) throw new Error(value.updates.error) + return value.updates?.candidate?.version === b.version && value + }, + 'verified candidate staging', + stagingTimeout + ) + const stagingMs = Math.round(performance.now() - stagingStarted) + assert.equal(staged.active.version, a.version) + assert.equal(staged.updates.selection.latest, c.version) + assert.ok( + staged.updates.selection.latestReasons.some((reason) => reason.includes('Guidance epoch')) + ) + assert.equal(staged.updates.automaticActivation, false) + runtimePids = [staged.supervisorPid, staged.hubPid] + const oldPid = staged.hubPid + const endpointPath = join(root, 'state/runtime/endpoints.json') + const fixedEndpoints = JSON.parse(await readFile(endpointPath, 'utf8')) + assert.equal((await status()).hubPid, oldPid, 'mixed policies must share the Hub') + const count = requests.length + await delay(200) + assert.equal(requests.length, count, 'attachment must not multiply checks') + assert.match( + JSON.stringify( + await request('tempad/runtime-activate', { version: b.version, integrity: b.integrity }) + ), + /not opted in/ + ) + assert.equal((await status()).hubPid, oldPid) + // Leave a fresh quiet window for the explicit transaction to acquire ownership + // before the automatic scheduler can consider this same compatible candidate. + assert.notEqual( + (await first.callTool({ name: 'list_design_sessions', arguments: {} })).isError, + true + ) + await second.close() + await until( + async () => + !(await status()).updates.pendingReasons.some((value) => value.includes('not opted in')), + 'pinned edge departure' + ) + const activated = await request('tempad/runtime-activate', { + version: b.version, + integrity: b.integrity + }) + assert.equal(activated.status, 'activated') + const changed = await status() + runtimePids = [changed.supervisorPid, changed.hubPid] + assert.equal(changed.active.version, b.version) + assert.notEqual(changed.hubPid, oldPid) + assert.deepEqual( + JSON.parse(await readFile(endpointPath, 'utf8')), + fixedEndpoints, + 'a hot handoff must preserve asset ports and credentials' + ) + assert.deepEqual(await first.listTools(), catalog, 'the original session keeps its presentation') + await first.ping() + const unpinned = await client(a) + assert.equal(unpinned.getServerVersion().version, a.version, 'initialize identifies the edge') + assert.equal(unpinned.getServerVersion().title, 'TemPad Dev compatible correction') + await unpinned.close() + await assert.rejects(client(a, 'pinned'), /pinned client requests a different runtime/) + assert.equal(await readFile(join(root, 'assets/preserved.txt'), 'utf8'), 'pre-enrollment asset') + + const automatic = await automaticAcceptance(a, b) + + assert.ok(requests.includes('/@tempad-dev%2fmcp'), 'stable checks must read the release catalog') + assert.ok(!requests.some((path) => path?.endsWith(`/mcp-${ahead.version}.tgz`))) + latestVersion = a.version + const rolledBackTag = await request('tempad/runtime-check', { version: 'latest' }) + assert.equal(rolledBackTag.candidate, undefined, 'moving latest backwards must not downgrade') + assert.equal((await status()).active.version, b.version) + latestVersion = c.version + const olderExact = await request('tempad/runtime-check', { version: a.version }) + assert.equal( + olderExact.candidate, + undefined, + 'an exact request must not substitute another release' + ) + assert.equal((await status()).active.version, b.version) + const incompatible = await request('tempad/runtime-check', { version: c.version }) + assert.ok(incompatible.pendingReasons.some((value) => value.includes('Guidance epoch'))) + assert.equal( + (await request('tempad/runtime-activate', { version: c.version, integrity: c.integrity })) + .status, + 'staged' + ) + await assert.rejects( + request('tempad/runtime-activate', { version: c.version, integrity: b.integrity }), + /staged artifact changed/ + ) + assert.equal((await status()).hubPid, changed.hubPid) + online = false + await assert.rejects(request('tempad/runtime-check', { version: c.version }), /HTTP 503/) + await first.ping() + assert.equal((await status()).hubPid, changed.hubPid) + const blockedCount = requests.length + for (const bad of ['/missing/checkout', source]) { + await assert.rejects( + exec(process.execPath, [join(a.directory, 'dist/cli.mjs'), '--runtime-updates=stable'], { + env: { ...isolationEnv, TEMPAD_MCP_DEV_CHECKOUT: bad }, + timeout: 10_000 + }) + ) + await assert.rejects( + exec( + process.execPath, + [ + join(a.directory, 'dist/runtime-installer.mjs'), + '--cache', + join(root, 'bad-cache'), + '--version', + b.version, + '--current', + a.version + ], + { env: { ...isolationEnv, TEMPAD_MCP_DEV_CHECKOUT: bad }, timeout: 10_000 } + ) + ) + } + assert.equal(requests.length, blockedCount, 'checkout must never query the fixture registry') + const cache = incompatible.candidate.release.directory + await writeFile(join(cache, 'dist/managed-runtime.mjs'), 'corrupt cache') + online = true + const repaired = await request('tempad/runtime-check', { version: c.version }) + assert.equal(repaired.candidate.version, c.version) + assert.match((await status()).updates.cacheWarning, /hash mismatch/) + await first.ping() + + // A local restart is the escape hatch for a guidance/contract boundary. It must + // promote both control and Hub without a registry check or losing durable state. + await control.close() + await first.close() + await until(() => runtimePids.every(exited), 'all old runtime owners exited', 15_000) + occupiedAssetPort = createServer() + await once(occupiedAssetPort.listen(fixedEndpoints.httpPort, '127.0.0.1'), 'listening') + online = false + const networkBeforeRestart = requests.length + const [restarted, concurrent] = await Promise.all([client(c, 'pinned'), client(c, 'pinned')]) + control = new ControlPeer() + await control.connect( + new SocketTransport(await connectSocket(runtimeEndpoint(join(root, 'run'), 'supervisor.sock'))) + ) + const cold = await status() + runtimePids = [cold.supervisorPid, cold.hubPid] + assert.equal(cold.active.version, c.version) + assert.equal(cold.controlComponents.supervisor.version, c.version) + assert.equal(cold.controlComponents.restartRequired, false) + assert.equal(cold.updates.automaticActivation, false) + assert.equal(requests.length, networkBeforeRestart, 'cold adoption uses only installed bytes') + const coldEndpoints = JSON.parse(await readFile(endpointPath, 'utf8')) + assert.notEqual(coldEndpoints.httpPort, fixedEndpoints.httpPort) + assert.deepEqual( + { ...coldEndpoints, httpPort: fixedEndpoints.httpPort }, + fixedEndpoints, + 'cold HTTP reallocation preserves credentials and the rollback-compatible metadata format' + ) + await new Promise((resolve) => occupiedAssetPort.close(resolve)) + assert.equal(await readFile(join(root, 'assets/preserved.txt'), 'utf8'), 'pre-enrollment asset') + const savedControl = JSON.parse( + await readFile(join(root, 'state/runtime/prototype.json'), 'utf8') + ) + assert.equal(savedControl.release.manifestHash, verify.sha256(JSON.stringify(c.manifest))) + const coldJournal = JSON.parse(await readFile(join(root, 'state/runtime/switch.json'), 'utf8')) + assert.equal(coldJournal.previous.version, b.version) + assert.equal(coldJournal.coldStart, undefined) + await concurrent.close() + await restarted.ping() + await assert.rejects(client(a, 'pinned'), /pinned client requests a different runtime/) + assert.equal((await status()).hubPid, cold.hubPid, 'an old CLI cannot downgrade the owner') + await control.close() + await restarted.close() + await until(() => runtimePids.every(exited), 'cold HTTP fixture owners exited', 15_000) + occupiedWebSocketPort = createServer() + await once(occupiedWebSocketPort.listen(coldEndpoints.wsPort, '127.0.0.1'), 'listening') + const wsRestarted = await client(c, 'pinned') + control = new ControlPeer() + await control.connect( + new SocketTransport(await connectSocket(runtimeEndpoint(join(root, 'run'), 'supervisor.sock'))) + ) + const wsCold = await status() + runtimePids = [wsCold.supervisorPid, wsCold.hubPid] + const wsEndpoints = JSON.parse(await readFile(endpointPath, 'utf8')) + assert.notEqual(wsEndpoints.wsPort, coldEndpoints.wsPort) + assert.deepEqual({ ...wsEndpoints, wsPort: coldEndpoints.wsPort }, coldEndpoints) + await wsRestarted.ping() + await new Promise((resolve) => occupiedWebSocketPort.close(resolve)) + assert.equal(runtimeSourceHash(repo), sourceHash, 'Runtime sources changed during acceptance.') + succeeded = true + console.log( + JSON.stringify({ + node: process.version, + versions, + firstInitializeMs, + stagingMs, + automatic, + cases: [ + 'verified-download', + 'stage-only', + 'shared-release-owner', + 'concurrent-first-enrollment', + 'non-opt-in-policy-block', + 'explicit-pin-conflict', + 'explicit-activation', + 'frozen-presentation', + 'epoch-block', + 'offline-runtime', + 'checkout-zero-network', + 'corrupt-cache', + 'legacy-migration-guard', + 'background-metadata-failure-isolation', + 'cold-guidance-upgrade', + 'concurrent-cold-upgrade', + 'cold-upgrade-offline', + 'cold-occupied-http-port', + 'cold-occupied-websocket-port', + 'hot-endpoint-preservation', + 'old-cli-no-downgrade', + 'automatic-compatible-activation', + 'automatic-generic-host-activation', + 'automatic-claude-block', + 'automatic-non-opt-in-block', + 'automatic-busy-work-deferral', + 'automatic-handoff-request-wait', + 'default-release-enrollment', + 'newest-compatible-selection', + 'stable-tag-ceiling', + 'stable-tag-rollback-no-downgrade', + 'exact-version-no-substitution' + ] + }) + ) +} catch (error) { + await preserveFixtureEvidence(root, error.stack) + throw error +} finally { + if (occupiedAssetPort?.listening) await new Promise((resolve) => occupiedAssetPort.close(resolve)) + if (occupiedWebSocketPort?.listening) + await new Promise((resolve) => occupiedWebSocketPort.close(resolve)) + await control?.close() + await Promise.allSettled(clients.map((client) => client.close())) + await Promise.allSettled(transports.map((transport) => transport.close())) + registry.closeAllConnections() + await new Promise((resolve) => registry.close(resolve)) + verify.endLogging() + if (runtimePids.length) + await until(() => runtimePids.every(exited), 'release runtime shutdown', 15_000) + if (automaticPids.size) + await until(() => [...automaticPids].every(exited), 'automatic fixture shutdown', 15_000) + // The worker/probe must close its own processes. Keep failed evidence intact. + if (succeeded && !process.env.TEMPAD_KEEP_PROTOTYPE) + await rm(root, { recursive: true, force: true }) + else console.error(`Release acceptance evidence: ${root}`) +} diff --git a/packages/mcp-server/scripts/measure-hooks.mjs b/packages/mcp-server/scripts/measure-hooks.mjs new file mode 100644 index 00000000..49be27da --- /dev/null +++ b/packages/mcp-server/scripts/measure-hooks.mjs @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import console from 'node:console' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { chmod, mkdtemp, open, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:net' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import { setTimeout } from 'node:timers/promises' +import { parseArgs } from 'node:util' + +import { summarize } from './runtime-contract.mjs' + +const { values } = parseArgs({ + options: { + hook: { type: 'string' }, + output: { type: 'string' }, + runs: { type: 'string', default: '30' } + } +}) +const runs = Number(values.runs) +if (process.platform === 'win32') + throw new Error('Hook measurement requires isolated Unix sockets.') +if (!values.hook || !values.output || !Number.isInteger(runs) || runs < 1 || runs > 1000) + throw new Error('Usage: measure-hooks.mjs --hook --output [--runs 30]') +const hook = await realpath(resolve(values.hook)) + +async function invocation(fault) { + const root = await realpath(await mkdtemp('/tmp/tp-hm-')) + const sockets = new Set() + const pending = [] + const observations = { received: 0, commits: 0, output: '', exitCode: null } + const started = performance.now() + let child + let inputTimer + const server = createServer((socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.on('close', () => sockets.delete(socket)) + let buffer = '' + socket.on('data', (data) => { + buffer += data + const end = buffer.indexOf('\n') + if (end < 0) return + socket.removeAllListeners('data') + const work = (async () => { + const request = JSON.parse(buffer.slice(0, end)) + assert.equal(request.method, 'tempad/client-hook') + observations.received++ + observations.receivedMs = performance.now() - started + observations.request = request.params + if (fault === 'before-commit') { + socket.destroy() + return + } + if (fault === 'slow-commit') await setTimeout(900) + if (fault === 'commit-failed') { + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + error: { code: -32603, message: 'Injected storage failure' } + }) + '\n' + ) + return + } + const temporary = join(root, 'event.tmp') + const file = await open(temporary, 'wx', 0o600) + try { + await file.writeFile(JSON.stringify(request)) + await file.sync() + } finally { + await file.close() + } + await rename(temporary, join(root, 'event.json')) + const directory = await open(root, 'r') + try { + await directory.sync() + } finally { + await directory.close() + } + observations.commits++ + observations.committedMs = performance.now() - started + if (fault === 'lost-ack') return + if (!socket.destroyed) + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: request.id, + result: { + output: { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: 'isolated-hook-reply' + } + } + } + }) + '\n' + ) + })() + // Attach a rejection handler immediately; propagate it after the child has exited. + pending.push( + work.then( + () => undefined, + (error) => error + ) + ) + }) + }) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(join(root, 'mcp.sock'), resolve) + }) + await chmod(join(root, 'mcp.sock'), 0o600) + child = spawn(process.execPath, [hook, 'claude'], { + env: { PATH: process.env.PATH, HOME: root, TMPDIR: root, TEMPAD_MCP_RUNTIME_DIR: root }, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stderr = '' + child.stdout.on('data', (chunk) => { + observations.output += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.stdin.on('error', () => {}) + const exited = once(child, 'close') + const input = () => + child.stdin.end( + JSON.stringify({ + session_id: 'isolated-runtime-probe', + hook_event_name: 'PreToolUse', + turn_id: 'turn' + }) + ) + if (fault === 'before-arrival') inputTimer = globalThis.setTimeout(input, 900) + else input() + const watchdog = globalThis.setTimeout(() => child.kill('SIGKILL'), 5000) + try { + const [code, signal] = await exited + assert.equal(signal, null, stderr) + observations.exitCode = code + observations.exitedMs = performance.now() - started + } finally { + globalThis.clearTimeout(watchdog) + } + const errors = (await Promise.all(pending)).filter(Boolean) + if (errors.length) throw errors[0] + assert.equal(observations.exitCode, 0, stderr) + assert.equal(observations.received, fault === 'before-arrival' ? 0 : 1) + assert.equal( + observations.commits, + ['before-arrival', 'before-commit', 'commit-failed'].includes(fault) ? 0 : 1 + ) + assert.equal(Boolean(observations.output), fault === 'normal') + return observations + } finally { + globalThis.clearTimeout(inputTimer) + if (child && child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'close') + child.kill('SIGKILL') + await exited + } + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(resolve)) + await rm(root, { recursive: true, force: true }) + } +} + +const normal = [] +for (let run = 0; run < runs; run++) normal.push(await invocation('normal')) +const faults = {} +for (const fault of ['slow-commit', 'before-commit', 'lost-ack', 'commit-failed', 'before-arrival']) + faults[fault] = await invocation(fault) +await writeFile( + resolve(values.output), + JSON.stringify( + { + schemaVersion: 1, + measuredAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + hook, + hookSha256: createHash('sha256') + .update(await readFile(hook)) + .digest('hex'), + observedProtocol: { + version: normal[0].request.version, + invocationId: Boolean(normal[0].request.invocationId), + receiptRetryProven: false + }, + normal: Object.fromEntries( + ['receivedMs', 'committedMs', 'exitedMs'].map((key) => [ + key, + summarize(normal.map((sample) => sample[key])) + ]) + ), + faults, + limits: [ + 'Synthetic local receiver and fresh hook processes, not installed-host delivery or an implemented Hub inbox.', + 'File and directory fsync measured; this does not establish macOS F_FULLFSYNC latency or crash durability.', + 'Normal samples are idle-machine observations; injected delay is not a disk/memory-pressure benchmark.', + 'No population telemetry or hook-adoption claim; successful exit is not evidence of delivery.' + ] + }, + null, + 2 + ) + '\n', + { flag: 'wx' } +) +console.error( + `Measured ${runs} ordinary invocations and five failure boundaries; saved ${resolve(values.output)}.` +) diff --git a/packages/mcp-server/scripts/measure-runtime.mjs b/packages/mcp-server/scripts/measure-runtime.mjs new file mode 100644 index 00000000..6d6ec0fd --- /dev/null +++ b/packages/mcp-server/scripts/measure-runtime.mjs @@ -0,0 +1,324 @@ +import { execFile, spawn } from 'node:child_process' +import console from 'node:console' +import { createHash } from 'node:crypto' +import { once } from 'node:events' +import { mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { release } from 'node:os' +import { join, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' +import { createInterface } from 'node:readline' +import { setTimeout } from 'node:timers/promises' +import { fileURLToPath, URL } from 'node:url' +import { parseArgs, promisify } from 'node:util' + +import { + compareContracts, + compareReleaseWindow, + digest, + runtimeContract, + summarize +} from './runtime-contract.mjs' + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + output: { type: 'string' }, + runs: { type: 'string', default: '5' }, + lockfile: { type: 'string' } + } +}) +const runs = Number(values.runs) +if (process.platform === 'win32') + throw new Error('Runtime measurement requires isolated Unix sockets.') +if (!values.output || !positionals.length || !Number.isInteger(runs) || runs < 1 || runs > 100) + throw new Error( + 'Usage: measure-runtime.mjs --output [--runs 5] [--lockfile ] [...]' + ) + +const isolation = fileURLToPath(new URL('./runtime-probe-isolation.mjs', import.meta.url)) +const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') +const execute = promisify(execFile) + +async function artifactFiles(root, path = 'dist') { + const entries = await readdir(join(root, path), { withFileTypes: true }) + const files = [] + for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) { + const name = `${path}/${entry.name}` + if (entry.isDirectory()) files.push(...(await artifactFiles(root, name))) + else if (entry.isFile()) files.push([name, sha256(await readFile(join(root, name)))]) + else throw new Error(`Unexpected artifact entry: ${name}`) + } + return files +} + +function launch(path, env) { + const child = spawn(process.execPath, ['--import', isolation, path], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let diagnostic = '' + let failure + child.stderr.on('data', (data) => { + diagnostic = (diagnostic + data).slice(-64000) + }) + child.on('error', (error) => { + failure = error + }) + child.stdin.on('error', (error) => { + failure = error + }) + return { + child, + assertRunning() { + if (failure) throw failure + if (child.exitCode !== null || child.signalCode !== null) + throw new Error(`Measurement process exited: ${diagnostic}`) + } + } +} + +async function stop({ child }) { + if (child.exitCode !== null || child.signalCode !== null) return + const closed = once(child, 'close') + child.kill('SIGTERM') + const timer = globalThis.setTimeout(() => child.kill('SIGKILL'), 3000) + try { + await closed + } finally { + globalThis.clearTimeout(timer) + } +} + +function peer(process) { + const { child } = process + const pending = new Map() + let sequence = 0 + const lines = createInterface({ input: child.stdout }) + const fail = (error) => { + for (const item of pending.values()) item.reject(error) + pending.clear() + } + lines.on('line', (line) => { + try { + const message = JSON.parse(line) + const item = pending.get(message.id) + if (!item) return + pending.delete(message.id) + if (message.error) item.reject(new Error(JSON.stringify(message.error))) + else item.resolve(message.result) + } catch (error) { + fail(error) + } + }) + child.once('close', () => fail(new Error('CLI closed during measurement.'))) + const send = (message) => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n') + return { + notify(method) { + send({ method }) + }, + async request(method, params) { + process.assertRunning() + const id = ++sequence + let timer + try { + return await new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }) + timer = globalThis.setTimeout(() => { + pending.delete(id) + reject(new Error(`Timed out waiting for ${method}.`)) + }, 15000) + send({ id, method, ...(params ? { params } : {}) }) + }) + } finally { + globalThis.clearTimeout(timer) + } + } + } +} + +async function list(client, method, key) { + const entries = [] + const cursors = new Set() + let cursor + do { + const result = await client.request(method, cursor ? { cursor } : undefined) + if (!Array.isArray(result[key])) throw new Error(`Invalid ${method} response.`) + entries.push(...result[key]) + cursor = result.nextCursor + if ( + cursor !== undefined && + (typeof cursor !== 'string' || !cursor || cursors.has(cursor) || cursors.size >= 100) + ) + throw new Error(`Invalid or repeated ${method} cursor.`) + cursors.add(cursor) + } while (cursor !== undefined) + return entries +} + +async function initialize(client) { + const result = await client.request('initialize', { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'isolated-runtime-measurement', version: '1' } + }) + client.notify('notifications/initialized') + return result +} + +async function rss(processes) { + const { stdout } = await execute('ps', [ + '-o', + 'pid=,rss=', + '-p', + processes.map(({ child }) => child.pid).join(',') + ]) + const values = new Map( + stdout + .trim() + .split('\n') + .map((line) => line.trim().split(/\s+/).map(Number)) + ) + return processes.map(({ child }) => { + const value = values.get(child.pid) + if (!Number.isFinite(value) || value <= 0) throw new Error('Missing process RSS sample.') + return value + }) +} + +async function measure(root) { + const temporary = await realpath(await mkdtemp('/tmp/tp-m-')) + const env = { + PATH: process.env.PATH, + HOME: temporary, + TMPDIR: temporary, + TEMP: temporary, + TMP: temporary, + TEMPAD_PROBE_ROOT: temporary, + TEMPAD_MCP_RUNTIME_DIR: join(temporary, 'run'), + TEMPAD_MCP_LOG_DIR: join(temporary, 'log'), + TEMPAD_MCP_ASSET_DIR: join(temporary, 'assets'), + TEMPAD_MCP_STATE_DIR: join(temporary, 'state'), + TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS: 'chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + } + const processes = [] + const start = performance.now() + try { + // Direct launch makes cleanup ownership unambiguous; CLIs cannot spawn replacements. + const hub = launch(join(root, 'dist/hub.mjs'), env) + processes.push(hub) + const deadline = Date.now() + 15000 + while ( + !(await stat(join(temporary, 'run/mcp.sock')).catch((error) => { + if (error.code === 'ENOENT') return undefined + throw error + })) + ) { + hub.assertRunning() + if (Date.now() > deadline) throw new Error('The private Hub did not publish its socket.') + await setTimeout(10) + } + const hubReadyMs = performance.now() - start + const firstStarted = performance.now() + const first = launch(join(root, 'dist/cli.mjs'), env) + processes.push(first) + const client = peer(first) + const initialized = await initialize(client) + const firstInitializeMs = performance.now() - firstStarted + const snapshot = { initialize: initialized } + if (initialized.capabilities.tools) snapshot.tools = await list(client, 'tools/list', 'tools') + if (initialized.capabilities.resources) { + snapshot.resources = await list(client, 'resources/list', 'resources') + snapshot.resourceTemplates = await list( + client, + 'resources/templates/list', + 'resourceTemplates' + ) + } + if (initialized.capabilities.prompts) + snapshot.prompts = await list(client, 'prompts/list', 'prompts') + await setTimeout(250) + const [hubRssKiB, firstRssKiB] = await rss([hub, first]) + const attachedAt = performance.now() + const second = launch(join(root, 'dist/cli.mjs'), env) + processes.push(second) + const secondClient = peer(second) + await initialize(secondClient) + const warmAttachMs = performance.now() - attachedAt + await setTimeout(250) + const twoClientsRssKiB = (await rss(processes)).reduce((sum, value) => sum + value, 0) + return { + snapshot, + metrics: { + hubReadyMs, + firstInitializeMs, + warmAttachMs, + hubRssKiB, + firstRssKiB, + twoClientsRssKiB + } + } + } finally { + for (const child of processes.reverse()) await stop(child) + await rm(temporary, { recursive: true, force: true }) + } +} + +const results = [] +for (const input of positionals) { + const root = await realpath(resolve(input)) + const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) + if (manifest.name !== '@tempad-dev/mcp' || !/^\d+\.\d+\.\d+$/.test(manifest.version)) + throw new Error(`Not a stable TemPad MCP package: ${input}`) + const files = await artifactFiles(root) + const samples = [] + let contract + let snapshot + for (let run = 0; run < runs; run++) { + const measured = await measure(root) + const next = runtimeContract(measured.snapshot) + if (contract && compareContracts(contract, next) !== 'identical') + throw new Error(`Catalog changed between runs of ${manifest.version}.`) + contract = next + snapshot = measured.snapshot + samples.push(measured.metrics) + } + results.push({ + version: manifest.version, + root, + artifactHash: digest({ manifest, files }), + dependencies: manifest.dependencies, + snapshot, + wireHash: contract.wireHash, + presentationHash: contract.presentationHash, + metrics: Object.fromEntries( + Object.keys(samples[0]).map((key) => [key, summarize(samples.map((sample) => sample[key]))]) + ) + }) + console.error( + `Measured ${manifest.version}: ${snapshot.tools?.length ?? 0} tools, ${runs} fresh Hubs.` + ) +} +const report = { + schemaVersion: 1, + measuredAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + osRelease: release(), + dependencyLock: values.lockfile + ? { path: resolve(values.lockfile), sha256: sha256(await readFile(values.lockfile)) } + : null, + limits: [ + 'Operator-supplied local packages only; no downloads or installed-host connections.', + 'TCP listeners are remapped to ephemeral loopback ports; Unix listeners must remain under a private root.', + 'Fresh processes with warm filesystem caches, not cold-machine startup; RSS is not exclusive physical memory.', + 'Warm attach initializes another CLI; it does not demonstrate uninterrupted MCP sessions or supervisor recovery.', + 'Hashes screen structural compatibility only; behavior, guidance, extension and storage compatibility require review.', + 'Installed dependencies may differ from those used at publication; preserve the installation lockfile.' + ], + results, + comparisons: compareReleaseWindow(results) +} +await writeFile(resolve(values.output), JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }) +console.error(`Saved ${results.length} artifact measurements to ${resolve(values.output)}.`) diff --git a/packages/mcp-server/scripts/native-runtime-fixture.mjs b/packages/mcp-server/scripts/native-runtime-fixture.mjs new file mode 100644 index 00000000..752687d2 --- /dev/null +++ b/packages/mcp-server/scripts/native-runtime-fixture.mjs @@ -0,0 +1,441 @@ +// Test-only packaged runtimes for native-host acceptance. Never publish these archives. +import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' +import { execFile } from 'node:child_process' +import console from 'node:console' +import { appendFileSync } from 'node:fs' +import { + chmod, + cp, + lstat, + mkdir, + readFile, + readdir, + realpath, + rm, + writeFile +} from 'node:fs/promises' +import { createServer } from 'node:http' +import { createServer as createSocketServer } from 'node:net' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL, URL } from 'node:url' +import { parseArgs, promisify } from 'node:util' + +import { MCP_PORT_CANDIDATES } from '../../shared/src/mcp/constants.ts' +import { runtimeSourceHash } from './runtime-source-hash.mjs' + +const exec = promisify(execFile) +const checkout = fileURLToPath(new URL('../../..', import.meta.url)) +const source = join(checkout, 'packages/mcp-server') +const descriptorName = 'runtime-fixture.json' +const privateDirectories = { + TEMPAD_MCP_RUNTIME_DIR: 'run', + TEMPAD_MCP_STATE_DIR: 'state', + TEMPAD_MCP_ASSET_DIR: 'assets', + TEMPAD_MCP_LOG_DIR: 'logs' +} + +export function fixtureEnvironment(root) { + return Object.fromEntries( + Object.entries(privateDirectories).map(([key, directory]) => [key, join(root, directory)]) + ) +} + +export function validateFixture(fixture, root) { + assert.equal(fixture.version, 1) + assert.equal(fixture.kind, 'native-runtime-fixture') + assert.equal(fixture.testOnly, true) + assert.equal(fixture.root, root) + assert.ok(isAbsolute(fixture.checkout)) + assert.match(fixture.sourceHash, /^[a-f0-9]{64}$/) + assert.deepEqual(fixture.env, fixtureEnvironment(root)) + assert.deepEqual(fixture.releasePorts, MCP_PORT_CANDIDATES) + const registry = new URL(fixture.registryUrl) + assert.equal(registry.origin, fixture.registryUrl) + assert.equal(registry.protocol, 'http:') + assert.equal(registry.hostname, '127.0.0.1') + assert.ok(Number(registry.port) > 0) + assert.ok(!MCP_PORT_CANDIDATES.includes(Number(registry.port))) + assert.equal(registry.username + registry.password, '') + for (const release of [fixture.initial, fixture.candidate]) { + assert.match(release.version, /^\d+\.\d+\.\d+$/) + assert.equal(release.directory, join(root, 'packages', release.version)) + assert.equal(release.archive, join(root, 'archives', `mcp-${release.version}.tgz`)) + assert.match(release.integrity, /^sha512-[A-Za-z0-9+/]{86}==$/) + } + const [major, minor, patch] = fixture.initial.version.split('.').map(Number) + assert.equal(fixture.candidate.version, `${major}.${minor}.${patch + 1}`) + return fixture +} + +export function fixtureResponse(fixture, method, path, candidateAvailable = true) { + if (method !== 'GET') return { status: 405 } + const releases = candidateAvailable ? [fixture.initial, fixture.candidate] : [fixture.initial] + const latest = releases.at(-1) + const metadata = (release) => ({ + name: '@tempad-dev/mcp', + version: release.version, + dist: { + integrity: release.integrity, + tarball: `${fixture.registryUrl}/@tempad-dev/mcp/-/mcp-${release.version}.tgz` + } + }) + if (path === '/@tempad-dev%2fmcp') + return { + status: 200, + metadata: { + name: '@tempad-dev/mcp', + 'dist-tags': { latest: latest.version }, + versions: Object.fromEntries( + releases.map((release) => [release.version, metadata(release)]) + ) + } + } + for (const release of releases) { + const archivePath = `/@tempad-dev/mcp/-/mcp-${release.version}.tgz` + if (path === archivePath) return { status: 200, archive: release.archive } + if ( + path === `/@tempad-dev%2fmcp/${release.version}` || + (release === latest && path === '/@tempad-dev%2fmcp/latest') + ) + return { + status: 200, + metadata: metadata(release) + } + } + return { status: 404 } +} + +async function listen(server, port) { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen({ port, host: '127.0.0.1', exclusive: true }, resolve) + }) +} + +async function close(server) { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) +} + +export async function assertReleasePortsFree() { + const held = [] + try { + for (const port of MCP_PORT_CANDIDATES) { + const server = createSocketServer() + try { + await listen(server, port) + } catch (error) { + throw new Error( + `Release discovery port ${port} is occupied; stop its owner explicitly before preparing or serving this fixture.`, + { cause: error } + ) + } + held.push(server) + } + } finally { + await Promise.all(held.map(close)) + } +} + +async function immutable(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await immutable(path) + else { + assert.ok(entry.isFile(), 'Verified packages cannot contain links or special files.') + await chmod(path, 0o400) + } + } + await chmod(directory, 0o500) +} + +async function unpack(files, directory) { + for (const [path, bytes] of files) { + const target = join(directory, path) + await mkdir(dirname(target), { recursive: true, mode: 0o700 }) + await writeFile(target, bytes, { flag: 'wx', mode: 0o600 }) + } +} + +async function prepare(requestedRoot) { + await assertReleasePortsFree() + // mkdir is intentionally exclusive: a previous fixture or runtime state is never reused. + await mkdir(requestedRoot, { mode: 0o700 }) + const root = await realpath(requestedRoot) + if (Buffer.byteLength(join(root, 'run/supervisor.sock')) >= 100) + throw new Error('Use a short private root, for example /tmp/tp-native-.') + const env = fixtureEnvironment(root) + for (const directory of [...Object.values(env), join(root, 'archives'), join(root, 'packages')]) + await mkdir(directory, { mode: 0o700 }) + // Verifier imports create lock files and initialize logging; keep them outside native state. + Object.assign(process.env, fixtureEnvironment(join(root, 'tooling'))) + const childEnv = { + ...process.env, + COREPACK_ENABLE_NETWORK: '0', + PNPM_DISABLE_SELF_UPDATE_CHECK: 'true', + npm_config_offline: 'true', + npm_config_registry: 'http://127.0.0.1:9', + npm_config_audit: 'false', + npm_config_fund: 'false', + npm_config_update_notifier: 'false', + npm_config_cache: join(root, 'npm-cache') + } + for (const key of Object.keys(childEnv)) + if (key === 'NODE_OPTIONS' || /^(?:CODEX_|CLAUDE|TEMPAD_MCP_DEV_CHECKOUT)/.test(key)) + delete childEnv[key] + const sourceHash = runtimeSourceHash(checkout) + const pkg = JSON.parse(await readFile(join(source, 'package.json'), 'utf8')) + assert.match(pkg.version, /^\d+\.\d+\.\d+$/) + const [major, minor, patch] = pkg.version.split('.').map(Number) + const registryReservation = createSocketServer((socket) => socket.destroy()) + await listen(registryReservation, 0) + const registryUrl = `http://127.0.0.1:${registryReservation.address().port}` + let verifier + const stagingDirectories = [] + try { + const deploy = join(root, 'deploy') + await exec( + 'pnpm', + [ + '--filter', + '@tempad-dev/mcp', + 'deploy', + '--prod', + '--offline', + '--ignore-scripts', + '--frozen-lockfile', + '--config.node-linker=hoisted', + deploy + ], + { cwd: checkout, env: childEnv, maxBuffer: 8 * 1024 * 1024 } + ) + const { build } = await import('tsdown') + const { default: config } = await import('../tsdown.config.ts') + const policy = JSON.parse(await readFile(join(source, 'runtime-policy.json'), 'utf8')) + const releases = [] + for (const version of [pkg.version, `${major}.${minor}.${patch + 1}`]) { + const staging = join(root, `build-${version}`) + stagingDirectories.push(staging) + await cp(deploy, staging, { recursive: true }) + const fixturePackage = { + ...pkg, + version, + private: true, + publishConfig: { registry: registryUrl } + } + await writeFile(join(staging, 'package.json'), JSON.stringify(fixturePackage)) + await build({ + ...config, + config: false, + cwd: source, + outDir: join(staging, 'dist'), + plugins: [ + ...config.plugins, + { + name: 'native-release-fixture', + load(id) { + if (id === join(source, 'package.json')) return JSON.stringify(fixturePackage) + }, + transform(code, id) { + if (id !== join(source, 'src/runtime-update/registry.ts')) return + const declaration = "const REGISTRY = 'https://registry.npmjs.org'" + assert.ok(code.includes(declaration), 'Review the changed release registry source.') + return code.replace(declaration, `const REGISTRY = ${JSON.stringify(registryUrl)}`) + } + } + ] + }) + verifier ??= await import(pathToFileURL(join(staging, 'dist/runtime-verification.mjs')).href) + const pack = async () => { + const result = await exec( + 'npm', + ['pack', '--offline', '--ignore-scripts', '--json', '--pack-destination', root], + { cwd: staging, env: childEnv, maxBuffer: 8 * 1024 * 1024 } + ) + const [packed] = JSON.parse(result.stdout) + const path = join(root, packed.filename) + const bytes = await readFile(path) + await rm(path) + return { bytes, integrity: packed.integrity } + } + await rm(join(staging, verifier.MANIFEST_PATH), { force: true }) + const files = verifier.readArchive((await pack()).bytes) + const probeDirectory = join(root, `probe-${version}`) + await unpack(files, probeDirectory) + const probe = await verifier.probeArtifact(join(probeDirectory, 'dist/managed-runtime.mjs')) + assert.equal(probe.artifact.version, version) + const preceding = releases.at(-1) + const manifest = verifier.ManifestSchema.parse({ + ...policy, + guidanceReview: { + ...policy.guidanceReview, + version, + sourceHash, + ...(preceding + ? { + decision: 'compatible', + baseline: { version: preceding.version, integrity: preceding.integrity }, + rationale: + 'Test-only successor built from the same source bytes and release logic as the initial fixture; only its package version differs.' + } + : {}) + }, + name: pkg.name, + packageVersion: version, + entry: 'dist/managed-runtime.mjs', + node: pkg.engines.node, + platforms: ['darwin', 'linux'], + wireHash: probe.wireHash, + presentationHash: probe.presentationHash, + lockHash: verifier.sha256(await readFile(join(checkout, 'pnpm-lock.yaml'))), + files: Object.fromEntries([...files].map(([path, bytes]) => [path, verifier.sha256(bytes)])) + }) + if (preceding) verifier.validatePolicyBaseline(manifest, preceding.manifest) + await writeFile(join(staging, verifier.MANIFEST_PATH), JSON.stringify(manifest)) + const { bytes, integrity } = await pack() + const verified = verifier.verifyArchive(bytes, version, integrity) + const archive = join(root, 'archives', `mcp-${version}.tgz`) + const directory = join(root, 'packages', version) + await writeFile(archive, bytes, { flag: 'wx', mode: 0o400 }) + await unpack(verified.files, directory) + await immutable(directory) + releases.push({ version, directory, archive, integrity, manifest }) + await rm(probeDirectory, { recursive: true }) + } + assert.equal( + runtimeSourceHash(checkout), + sourceHash, + 'Sources changed during fixture preparation.' + ) + await assertReleasePortsFree() + await rm(deploy, { recursive: true }) + await chmod(join(root, 'archives'), 0o500) + await chmod(join(root, 'packages'), 0o500) + const [initial, candidate] = releases.map(({ manifest: _manifest, ...release }) => release) + const fixture = validateFixture( + { + version: 1, + kind: 'native-runtime-fixture', + testOnly: true, + checkout: await realpath(checkout), + root, + sourceHash, + registryUrl, + releasePorts: MCP_PORT_CANDIDATES, + env, + initial, + candidate + }, + root + ) + await writeFile(join(root, 'registry-requests.jsonl'), '', { flag: 'wx', mode: 0o600 }) + await writeFile(join(root, descriptorName), JSON.stringify(fixture, null, 2) + '\n', { + flag: 'wx', + mode: 0o400 + }) + console.log(JSON.stringify({ descriptor: join(root, descriptorName), ...fixture })) + } finally { + verifier?.endLogging() + await close(registryReservation) + for (const directory of stagingDirectories) + await rm(directory, { recursive: true, force: true }) + } +} + +async function serve(requestedRoot, holdCandidate) { + const root = await realpath(requestedRoot) + const stat = await lstat(root) + assert.equal(stat.mode & 0o077, 0, 'Fixture root must remain private.') + const fixture = validateFixture( + JSON.parse(await readFile(join(root, descriptorName), 'utf8')), + root + ) + Object.assign(process.env, fixtureEnvironment(join(root, 'tooling'))) + const verifier = await import( + pathToFileURL(join(fixture.initial.directory, 'dist/runtime-verification.mjs')).href + ) + const archives = new Map() + try { + for (const release of [fixture.initial, fixture.candidate]) { + const bytes = await readFile(release.archive) + verifier.verifyArchive(bytes, release.version, release.integrity) + archives.set(release.archive, bytes) + } + } finally { + verifier.endLogging() + } + await assertReleasePortsFree() + let candidateAvailable = !holdCandidate + const revealCandidate = () => { + if (candidateAvailable) return + candidateAvailable = true + console.log( + JSON.stringify({ event: 'candidate-available', version: fixture.candidate.version }) + ) + } + const server = createServer((req, res) => { + const result = fixtureResponse(fixture, req.method, req.url, candidateAvailable) + appendFileSync( + join(root, 'registry-requests.jsonl'), + JSON.stringify({ + at: new Date().toISOString(), + method: req.method, + path: req.url, + status: result.status + }) + '\n' + ) + res.writeHead(result.status, { + 'content-type': result.metadata ? 'application/json' : 'application/octet-stream', + 'cache-control': 'no-store' + }) + res.end(result.metadata ? JSON.stringify(result.metadata) : archives.get(result.archive)) + }) + await listen(server, Number(new URL(fixture.registryUrl).port)) + // SIGUSR1 is reserved for Node's debugger; this test-only signal exposes the + // already-verified candidate without restarting the registry or installed host. + if (holdCandidate) process.on('SIGUSR2', revealCandidate) + server.once('close', () => process.off('SIGUSR2', revealCandidate)) + console.log( + JSON.stringify({ + registryUrl: fixture.registryUrl, + descriptor: join(root, descriptorName), + pid: process.pid, + candidateAvailable, + ...(holdCandidate ? { revealSignal: 'SIGUSR2', heldVersion: fixture.candidate.version } : {}) + }) + ) + for (const signal of ['SIGINT', 'SIGTERM']) + process.once(signal, () => { + server.close() + server.closeIdleConnections() + }) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ + options: { + root: { type: 'string' }, + prepare: { type: 'boolean' }, + serve: { type: 'boolean' }, + 'hold-candidate': { type: 'boolean' } + } + }) + if ( + !values.root || + !isAbsolute(values.root) || + !!values.prepare === !!values.serve || + (values['hold-candidate'] && !values.serve) + ) + throw new Error( + 'Use --root --prepare or --serve [--hold-candidate]. Test-only; never publish.' + ) + if (process.platform === 'win32' || process.env.TEMPAD_MCP_DEV_CHECKOUT) + throw new Error( + 'Native fixtures require a Unix release environment without TEMPAD_MCP_DEV_CHECKOUT.' + ) + if (values.prepare) await prepare(values.root) + else await serve(values.root, values['hold-candidate']) +} diff --git a/packages/mcp-server/scripts/package-manager.mjs b/packages/mcp-server/scripts/package-manager.mjs new file mode 100644 index 00000000..047b7818 --- /dev/null +++ b/packages/mcp-server/scripts/package-manager.mjs @@ -0,0 +1,59 @@ +import { execFile, execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { basename, delimiter, dirname, join } from 'node:path' +import process from 'node:process' +import { promisify } from 'node:util' + +const exec = promisify(execFile) + +function pnpmPackageEntry(directory) { + try { + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.pnpm + return manifest.name === 'pnpm' && typeof bin === 'string' && /\.(?:exe|[cm]?js)$/i.test(bin) + ? join(directory, bin) + : undefined + } catch (error) { + if (error.code === 'ENOENT') return undefined + throw error + } +} + +function* windowsEntries(command) { + const path = (process.env.PATH ?? '').split(delimiter).filter(Boolean) + const inherited = process.env.npm_execpath + // pnpm's version manager prepends its selected tool to PATH but can leave + // npm_execpath pointing at the bootstrap version. Resolve each PATH entry first. + if (command === 'pnpm') { + for (const root of path) { + yield join(root, 'pnpm.exe') + yield pnpmPackageEntry(join(root, 'node_modules/pnpm')) + yield pnpmPackageEntry(join(dirname(root), 'node_modules/pnpm')) + yield join(root, 'node_modules/pnpm/bin/pnpm.cjs') + } + if (inherited && /^pnpm\.(?:c?js|mjs|exe)$/i.test(basename(inherited))) yield inherited + } else { + for (const root of [dirname(process.execPath), ...path]) { + yield join(root, 'node_modules/npm/bin/npm-cli.js') + yield join(root, 'npm.exe') + } + } +} + +function invocation(command, args) { + if (process.platform !== 'win32') return [command, args] + // Windows .cmd shims require a shell. Run the installed CLI with Node instead, + // keeping paths and arguments (including spaces and shell characters) intact. + for (const entry of windowsEntries(command)) + if (entry && existsSync(entry)) + return /\.exe$/i.test(entry) ? [entry, args] : [process.execPath, [entry, ...args]] + throw new Error(`Cannot locate the installed ${command} CLI without a shell.`) +} + +export function runPackageManagerSync(command, args, options) { + return execFileSync(...invocation(command, args), options) +} + +export function runPackageManager(command, args, options) { + return exec(...invocation(command, args), options) +} diff --git a/packages/mcp-server/scripts/prepare-runtime-dependencies.mjs b/packages/mcp-server/scripts/prepare-runtime-dependencies.mjs new file mode 100644 index 00000000..5d83ff6a --- /dev/null +++ b/packages/mcp-server/scripts/prepare-runtime-dependencies.mjs @@ -0,0 +1,18 @@ +import console from 'node:console' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, URL } from 'node:url' + +import { deployRuntimeDependencies } from './runtime-dependencies.mjs' + +const repo = fileURLToPath(new URL('../../..', import.meta.url)) +const root = realpathSync(mkdtempSync(join(tmpdir(), 'tp-dependencies-'))) +try { + // A workspace install can verify with online evidence that is absent from its + // metadata cache. Verify the exact reduced production graph before going offline. + deployRuntimeDependencies(repo, join(root, 'deploy'), { offline: false }) + console.log('Prepared frozen runtime dependencies for offline packaging; no artifact created.') +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/packages/mcp-server/scripts/prepare-runtime-release.mjs b/packages/mcp-server/scripts/prepare-runtime-release.mjs new file mode 100644 index 00000000..a9ca758d --- /dev/null +++ b/packages/mcp-server/scripts/prepare-runtime-release.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import console from 'node:console' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath, URL } from 'node:url' +import { parseArgs } from 'node:util' + +import { runPackageManagerSync } from './package-manager.mjs' +import { deployRuntimeDependencies } from './runtime-dependencies.mjs' +import { runtimeSourceHash } from './runtime-source-hash.mjs' + +const repo = fileURLToPath(new URL('../../..', import.meta.url)) +const source = join(repo, 'packages/mcp-server') +const { values } = parseArgs({ + options: { + output: { type: 'string' }, + 'source-hash': { type: 'boolean' }, + 'verify-baseline': { type: 'boolean' } + } +}) +const sourceHash = runtimeSourceHash(repo) +if (values['source-hash']) { + console.log(sourceHash) + process.exit(0) +} +if (process.env.TEMPAD_MCP_DEV_CHECKOUT) + throw new Error('Prepare release artifacts in a release environment, not a checkout profile.') +if (!values.output || !isAbsolute(values.output)) + throw new Error('Use --output [--verify-baseline].') + +const root = realpathSync(mkdtempSync(join(tmpdir(), 'tp-release-'))) +for (const [key, path] of Object.entries({ + TEMPAD_MCP_RUNTIME_DIR: 'run', + TEMPAD_MCP_STATE_DIR: 'state', + TEMPAD_MCP_ASSET_DIR: 'assets', + TEMPAD_MCP_LOG_DIR: 'logs' +})) + process.env[key] = join(root, path) +const verifier = await import('../dist/runtime-verification.mjs') +const { + PolicySchema, + ManifestSchema, + MANIFEST_PATH, + readArchive, + sha256, + verifyArchive, + probeArtifact, + validatePolicyBaseline, + resolveRelease, + downloadRelease +} = verifier +try { + const policy = PolicySchema.parse( + JSON.parse(readFileSync(join(source, 'runtime-policy.json'), 'utf8')) + ) + const pkg = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8')) + assert.deepEqual(policy.controlProtocols, [...verifier.CONTROL_VERSIONS]) + assert.deepEqual(policy.extension.protocols, [ + ...verifier.TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS + ]) + if ( + policy.guidanceReview.sourceHash !== sourceHash || + policy.guidanceReview.version !== pkg.version + ) + throw new Error('Revalidate the release guidance review and source hash before packaging.') + const deploy = join(root, 'deploy') + // A hoisted, offline deploy from the frozen workspace lock avoids pnpm store + // symlinks escaping the npm tarball. No registry queries or lifecycle scripts run. + deployRuntimeDependencies(repo, deploy, { offline: true }) + rmSync(join(deploy, MANIFEST_PATH), { force: true }) + const packed = JSON.parse( + runPackageManagerSync( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', root], + { cwd: deploy, encoding: 'utf8' } + ) + )[0] + const files = readArchive(readFileSync(join(root, packed.filename))) + const unpacked = join(root, 'unpacked') + for (const [path, bytes] of files) { + const target = join(unpacked, path) + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }) + writeFileSync(target, bytes, { flag: 'wx', mode: 0o600 }) + } + const probe = await probeArtifact(join(unpacked, 'dist/managed-runtime.mjs')) + if (probe.artifact.version !== pkg.version) + throw new Error('Built runtime version differs from the release package.') + const manifest = ManifestSchema.parse({ + ...policy, + name: pkg.name, + packageVersion: pkg.version, + entry: 'dist/managed-runtime.mjs', + node: pkg.engines.node, + platforms: ['darwin', 'linux', 'win32'], + wireHash: probe.wireHash, + presentationHash: probe.presentationHash, + lockHash: sha256(readFileSync(join(repo, 'pnpm-lock.yaml'))), + files: Object.fromEntries([...files].map(([path, bytes]) => [path, sha256(bytes)])) + }) + if (values['verify-baseline']) { + const latest = await resolveRelease('latest') + if (latest.version !== policy.guidanceReview.baseline.version) + throw new Error('Review guidance against the current stable release before publishing.') + const baseline = await resolveRelease(policy.guidanceReview.baseline.version) + if (baseline.integrity !== policy.guidanceReview.baseline.integrity) + throw new Error('Reviewed npm baseline integrity changed.') + const archive = await downloadRelease(baseline) + verifier.verifyIntegrity(archive, baseline.integrity) + const baselineFiles = readArchive(archive) + const historical = baselineFiles.get(MANIFEST_PATH) + validatePolicyBaseline( + manifest, + historical ? verifyArchive(archive, baseline.version, baseline.integrity).manifest : undefined + ) + } + writeFileSync(join(deploy, MANIFEST_PATH), JSON.stringify(manifest, null, 2) + '\n') + rmSync(join(root, packed.filename)) + const final = JSON.parse( + runPackageManagerSync( + 'npm', + ['pack', '--ignore-scripts', '--json', '--pack-destination', root], + { cwd: deploy, encoding: 'utf8' } + ) + )[0] + const bytes = readFileSync(join(root, final.filename)) + verifyArchive(bytes, pkg.version, final.integrity) + writeFileSync(values.output, bytes, { flag: 'wx', mode: 0o600 }) + writeFileSync( + `${values.output}.json`, + JSON.stringify({ version: pkg.version, integrity: final.integrity, manifest }, null, 2) + '\n', + { flag: 'wx', mode: 0o600 } + ) + console.log( + JSON.stringify({ + artifact: values.output, + version: pkg.version, + integrity: final.integrity, + files: files.size, + bytes: bytes.length, + baselineVerified: !!values['verify-baseline'] + }) + ) +} finally { + verifier.endLogging() + rmSync(root, { force: true, recursive: true }) +} diff --git a/packages/mcp-server/scripts/require-release-archive.mjs b/packages/mcp-server/scripts/require-release-archive.mjs new file mode 100644 index 00000000..4071f90f --- /dev/null +++ b/packages/mcp-server/scripts/require-release-archive.mjs @@ -0,0 +1,3 @@ +throw new Error( + 'Publish only the verified .tgz produced by mcp:prepare-release, with --ignore-scripts; see docs/releasing.md.' +) diff --git a/packages/mcp-server/scripts/runtime-contract.d.mts b/packages/mcp-server/scripts/runtime-contract.d.mts new file mode 100644 index 00000000..93511228 --- /dev/null +++ b/packages/mcp-server/scripts/runtime-contract.d.mts @@ -0,0 +1,25 @@ +export type RuntimeSnapshot = { + initialize: { + serverInfo: { version: string; [key: string]: unknown } + instructions?: string + [key: string]: unknown + } + tools?: Record[] + resources?: Record[] + resourceTemplates?: Record[] + prompts?: Record[] +} +export function canonicalJson(value: unknown): string +export function digest(value: unknown): string +export function runtimeContract(snapshot: RuntimeSnapshot): { + wire: unknown + presentation: unknown + wireHash: string + presentationHash: string +} +export function compareContracts( + before: { wireHash: string; presentationHash: string }, + after: { wireHash: string; presentationHash: string } +): 'wire-changed' | 'identical' | 'presentation-only' +export function compareReleaseWindow(results: unknown[]): unknown[] +export function summarize(values: number[]): Record diff --git a/packages/mcp-server/scripts/runtime-contract.mjs b/packages/mcp-server/scripts/runtime-contract.mjs new file mode 100644 index 00000000..f0ef28ac --- /dev/null +++ b/packages/mcp-server/scripts/runtime-contract.mjs @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto' + +export function canonicalJson(value) { + return JSON.stringify(value, (_key, item) => + item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [key, item[key]]) + ) + : item + ) +} + +export function digest(value) { + return createHash('sha256').update(canonicalJson(value)).digest('hex') +} + +const SCHEMA_MAPS = new Set([ + 'properties', + 'patternProperties', + '$defs', + 'definitions', + 'dependentSchemas' +]) +const SCHEMA_LISTS = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']) +const SCHEMA_VALUES = new Set([ + 'items', + 'additionalItems', + 'additionalProperties', + 'unevaluatedProperties', + 'unevaluatedItems', + 'contains', + 'propertyNames', + 'not', + 'if', + 'then', + 'else', + 'contentSchema' +]) + +function schemaContract(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema + return Object.fromEntries( + Object.entries(schema) + .filter(([key]) => !['description', 'title', '$comment'].includes(key)) + .map(([key, value]) => { + if (SCHEMA_MAPS.has(key)) + value = Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, schemaContract(child)]) + ) + else if (SCHEMA_LISTS.has(key) || (key === 'items' && Array.isArray(value))) + value = value.map(schemaContract) + else if (SCHEMA_VALUES.has(key)) value = schemaContract(value) + // Values under const, enum, default and extension keywords are data, not schemas. + return [key, value] + }) + ) +} + +function catalogContract(item) { + const { description: _description, title: _title, inputSchema, outputSchema, ...wire } = item + if (inputSchema !== undefined) wire.inputSchema = schemaContract(inputSchema) + if (outputSchema !== undefined) wire.outputSchema = schemaContract(outputSchema) + // Unknown fields and annotations stay wire-significant, even when only display-related. + return wire +} + +function ordered(items, key) { + const names = items.map((item) => item[key]) + if (names.some((name) => typeof name !== 'string') || new Set(names).size !== names.length) + throw new Error(`Catalog entries need unique ${key} values.`) + return [...items].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0)) +} + +/** A conservative structural screen, never an activation or guidance-compatibility decision. */ +export function runtimeContract(snapshot) { + const { serverInfo, instructions, ...initialization } = snapshot.initialize + const { version: _version, ...serverPresentation } = serverInfo + const catalogs = Object.fromEntries( + [ + ['tools', 'name'], + ['resources', 'uri'], + ['resourceTemplates', 'uriTemplate'], + ['prompts', 'name'] + ].map(([name, key]) => [name, ordered(snapshot[name] ?? [], key)]) + ) + const wire = { + initialize: initialization, + ...Object.fromEntries( + Object.entries(catalogs).map(([name, items]) => [name, items.map(catalogContract)]) + ) + } + // Keep the full advertised snapshot for review; wire changes can also change this hash. + const presentation = { serverInfo: serverPresentation, instructions, ...catalogs } + return { wire, presentation, wireHash: digest(wire), presentationHash: digest(presentation) } +} + +export function compareContracts(before, after) { + if (before.wireHash !== after.wireHash) return 'wire-changed' + return before.presentationHash === after.presentationHash ? 'identical' : 'presentation-only' +} + +export function compareReleaseWindow(results) { + return results.slice(0, -1).flatMap((before, start) => + results.slice(start + 1).map((after, offset) => { + const intermediate = results.slice(start, start + offset + 2) + const [fromMajor, fromMinor] = before.version.split('.').map(Number) + const [toMajor, toMinor] = after.version.split('.').map(Number) + return { + from: before.version, + to: after.version, + adjacent: offset === 0, + structural: compareContracts(before, after), + wireUnchangedThroughout: intermediate.every((item) => item.wireHash === before.wireHash), + breakingReleaseBoundary: + fromMajor !== toMajor || (fromMajor === 0 && fromMinor !== toMinor), + guidance: 'unreviewed', + activation: 'not-established' + } + }) + ) +} + +export function summarize(values) { + if (!values.length || values.some((value) => !Number.isFinite(value))) + throw new Error('Measurement samples must be nonempty and finite.') + const sorted = [...values].sort((a, b) => a - b) + const percentile = (p) => sorted[Math.ceil(sorted.length * p) - 1] + return { + samples: sorted.length, + p50: percentile(0.5), + p95: percentile(0.95), + p99: percentile(0.99), + max: sorted.at(-1) + } +} diff --git a/packages/mcp-server/scripts/runtime-dependencies.mjs b/packages/mcp-server/scripts/runtime-dependencies.mjs new file mode 100644 index 00000000..76194ecd --- /dev/null +++ b/packages/mcp-server/scripts/runtime-dependencies.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { runPackageManagerSync } from './package-manager.mjs' + +/** Keep cache preparation and offline packaging on the same frozen dependency graph. */ +export function deployRuntimeDependencies(repo, directory, { offline }) { + const { packageManager } = JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')) + const selectedPnpm = runPackageManagerSync('pnpm', ['--version'], { + cwd: repo, + encoding: 'utf8' + }).trim() + assert.equal( + `pnpm@${selectedPnpm}`, + packageManager, + 'Release packaging must use the pnpm version pinned by the workspace.' + ) + runPackageManagerSync( + 'pnpm', + [ + '--filter', + '@tempad-dev/mcp', + 'deploy', + '--prod', + ...(offline ? ['--offline'] : []), + '--ignore-scripts', + '--frozen-lockfile', + '--config.node-linker=hoisted', + // npm pack can stall on shared inodes from content-store hardlinks. + // Independent files also keep release artifacts free of tar hardlinks. + '--config.package-import-method=copy', + directory + ], + { cwd: repo, stdio: 'inherit' } + ) +} diff --git a/packages/mcp-server/scripts/runtime-probe-isolation.mjs b/packages/mcp-server/scripts/runtime-probe-isolation.mjs new file mode 100644 index 00000000..801103db --- /dev/null +++ b/packages/mcp-server/scripts/runtime-probe-isolation.mjs @@ -0,0 +1,40 @@ +// Loaded only by the explicit measurement harness, never by a distributed runtime. +import childProcess from 'node:child_process' +import { syncBuiltinESMExports } from 'node:module' +import { Server } from 'node:net' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import process from 'node:process' + +const root = process.env.TEMPAD_PROBE_ROOT +if (process.platform === 'win32' || !root || !isAbsolute(root)) + throw new Error('Runtime measurement requires an isolated Unix runtime root.') + +// A CLI must fail rather than spawn an uninstrumented replacement if its fixture Hub exits. +for (const method of ['spawn', 'fork', 'exec', 'execFile']) { + childProcess[method] = () => { + throw new Error('The measurement harness must own every child process.') + } +} +syncBuiltinESMExports() + +const listen = Server.prototype.listen +Server.prototype.listen = function (...args) { + const first = args[0] + const path = typeof first === 'string' ? first : first?.path + if (path) { + const local = relative(root, resolve(path)) + if (!local || local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) + throw new Error('Measurement refused an IPC listener outside its runtime root.') + return listen.apply(this, args) + } + if (typeof first !== 'number' && !(first && typeof first === 'object' && 'port' in first)) + throw new Error('Measurement refused an unrecognized listener.') + // Released artifacts otherwise compete for installed extensions' fixed discovery ports. + // Only listener allocation is changed; MCP catalogs come from the unmodified artifact. + const callback = args.findLast((value) => typeof value === 'function') + return listen.call( + this, + { ...(typeof first === 'object' ? first : {}), port: 0, host: '127.0.0.1' }, + ...(callback ? [callback] : []) + ) +} diff --git a/packages/mcp-server/scripts/runtime-source-hash.mjs b/packages/mcp-server/scripts/runtime-source-hash.mjs new file mode 100644 index 00000000..8cc2e85f --- /dev/null +++ b/packages/mcp-server/scripts/runtime-source-hash.mjs @@ -0,0 +1,31 @@ +import { createHash } from 'node:crypto' +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +/** Release review covers behavior, not only the visible instructions hash. */ +export function runtimeSourceHash(repo) { + const paths = [ + 'packages/mcp-server/package.json', + 'packages/mcp-server/tsdown.config.ts', + 'packages/mcp-server/scripts/runtime-contract.mjs', + 'pnpm-lock.yaml' + ] + function visit(path) { + for (const entry of readdirSync(join(repo, path), { withFileTypes: true })) { + const child = `${path}/${entry.name}` + if (entry.isDirectory()) visit(child) + else if (entry.isFile()) paths.push(child) + else throw new Error('Release sources may not contain symlinks.') + } + } + visit('packages/mcp-server/src') + visit('packages/shared/src') + const hash = createHash('sha256') + for (const path of paths.sort()) + hash + .update(path) + .update('\0') + .update(readFileSync(join(repo, path))) + .update('\0') + return hash.digest('hex') +} diff --git a/packages/mcp-server/scripts/runtime-startup-trace.mjs b/packages/mcp-server/scripts/runtime-startup-trace.mjs new file mode 100644 index 00000000..c3555295 --- /dev/null +++ b/packages/mcp-server/scripts/runtime-startup-trace.mjs @@ -0,0 +1,72 @@ +// Opt-in process-fixture diagnostics. Never loaded by a published runtime. +import { Buffer } from 'node:buffer' +import childProcess from 'node:child_process' +import { mkdirSync, writeSync } from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' +import { basename, join } from 'node:path' +import { performance } from 'node:perf_hooks' +import process from 'node:process' + +const started = performance.now() +function trace(event) { + writeSync( + 2, + `Runtime startup ${JSON.stringify({ pid: process.pid, ms: Math.round(performance.now() - started), ...event })}\n` + ) +} +trace({ phase: 'entry' }) +const evidence = process.env.TEMPAD_RUNTIME_EVIDENCE_DIR +if (evidence) mkdirSync(evidence, { recursive: true }) + +function traceSecurityHelper(args) { + const [commandArgs, options] = args + const index = Array.isArray(commandArgs) ? commandArgs.indexOf('-EncodedCommand') : -1 + if (!evidence || index < 0 || !options?.env?.TEMPAD_RUNTIME_ACL_REQUEST) return args + let script = Buffer.from(commandArgs[index + 1], 'base64').toString('utf16le') + const phases = [ + ["$ErrorActionPreference = 'Stop'", 'script-started'], + ['[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)', 'encoding-set'], + [ + 'Import-Module "$PSHOME\\Modules\\Microsoft.PowerShell.Utility\\Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop', + 'utility-imported' + ], + [ + 'Import-Module "$PSHOME\\Modules\\Microsoft.PowerShell.Security\\Microsoft.PowerShell.Security.psd1" -ErrorAction Stop', + 'security-imported' + ], + ['$request = $env:TEMPAD_RUNTIME_ACL_REQUEST | ConvertFrom-Json', 'request-parsed'], + ['$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User', 'identity-read'], + ['[void][IO.Directory]::CreateDirectory($request.path, $security)', 'directory-created'], + ['$acl = Get-Acl -LiteralPath $request.path', 'acl-read'] + ] + for (const [statement, phase] of phases) + script = script.replace( + statement, + () => + `${statement}\ntry { [IO.File]::AppendAllText($env:TEMPAD_RUNTIME_ACL_TRACE, ([DateTime]::UtcNow.ToString('o') + ' ${phase}' + [Environment]::NewLine)) } catch {}` + ) + const traced = [...commandArgs] + traced[index + 1] = Buffer.from(script, 'utf16le').toString('base64') + return [ + traced, + { + ...options, + env: { ...options.env, TEMPAD_RUNTIME_ACL_TRACE: join(evidence, `acl-${process.pid}.log`) } + } + ] +} + +const execute = childProcess.execFileSync +childProcess.execFileSync = function (file, ...args) { + const command = basename(file) + trace({ phase: 'helper-start', command }) + try { + const result = execute.call(this, file, ...traceSecurityHelper(args)) + trace({ phase: 'helper-end', command }) + return result + } catch (error) { + trace({ phase: 'helper-error', command, code: error.code }) + throw error + } +} +syncBuiltinESMExports() diff --git a/packages/mcp-server/scripts/runtime-test-platform.mjs b/packages/mcp-server/scripts/runtime-test-platform.mjs new file mode 100644 index 00000000..829828fa --- /dev/null +++ b/packages/mcp-server/scripts/runtime-test-platform.mjs @@ -0,0 +1,152 @@ +import { execFileSync } from 'node:child_process' +import console from 'node:console' +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, isAbsolute, join, sep } from 'node:path' +import process from 'node:process' +import { URL } from 'node:url' + +import { platformEnvironment } from '../src/process-environment.ts' +import { ensurePrivateDirectory, windowsPowerShellEnvironment } from '../src/runtime-platform.ts' + +// Initial attachment may use the edge's 90-second recovery budget. Allow five +// seconds for the SDK exchange without changing any business-request deadline. +export const FIXTURE_INITIALIZE_TIMEOUT_MS = 95_000 + +export async function fixtureRoot(prefix) { + if (process.platform === 'win32') { + const root = join(tmpdir(), `${prefix}${randomUUID()}`) + ensurePrivateDirectory(root) + return realpath(root) + } + // Short Unix paths keep nested socket names below sockaddr_un's limit. + return realpath(await mkdtemp(join('/tmp', prefix))) +} + +export function fixtureEnvironment(home, temp = home) { + return { + ...platformEnvironment(process.env), + HOME: home, + USERPROFILE: home, + TMPDIR: temp, + TEMP: temp, + TMP: temp + } +} + +export function fixtureNodeArguments(args) { + return process.env.TEMPAD_RUNTIME_EVIDENCE_DIR + ? ['--import', new URL('./runtime-startup-trace.mjs', import.meta.url).href, ...args] + : args +} + +/** Copy bounded fixture diagnostics before shutdown removes live ownership records. */ +export async function preserveFixtureEvidence(root, failure) { + const output = process.env.TEMPAD_RUNTIME_EVIDENCE_DIR + if (!output) return + try { + if (!isAbsolute(output)) throw new Error('Runtime evidence directory must be absolute.') + const destination = join(output, basename(root)) + await mkdir(destination, { recursive: true }) + await writeFile(join(destination, 'failure.txt'), `${String(failure)}\nRoot: ${root}\n`) + let remaining = 16 * 1024 * 1024 + async function copy(directory, relative = '') { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const target = join(destination, relative, entry.name) + if (entry.isDirectory()) { + // Runtime stores, retained probes and nested update fixtures contain evidence; + // packages, cached archives, images and executable bundles do not. + if ( + ['run', 'state', 'runtime', 'log', 'logs', 'auto', 'f'].includes(entry.name) || + /^tp-probe-[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/.test(entry.name) + ) + await copy(path, join(relative, entry.name)) + } else if (entry.isFile() && /\.(?:json|log|txt|lock)$/.test(entry.name)) { + try { + const { size } = await stat(path) + if (size > 2 * 1024 * 1024 || size > remaining) continue + const bytes = await readFile(path) + if (bytes.length > 2 * 1024 * 1024 || bytes.length > remaining) continue + remaining -= bytes.length + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, bytes) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + } + } + } + await copy(root) + console.error(`Copied runtime fixture diagnostics: ${destination}`) + } catch (error) { + // Diagnostics must never replace the test failure or prevent fixture shutdown. + console.error(`Could not copy runtime fixture diagnostics: ${error.message}`) + } +} + +function powershell(script) { + return execFileSync( + join(process.env.SystemRoot ?? 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe'), + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $ErrorActionPreference = 'Stop'; ${script}` + ], + { encoding: 'utf8', windowsHide: true, env: windowsPowerShellEnvironment() } + ).trim() +} + +export function processInventory() { + if (process.platform === 'win32') + return JSON.parse( + powershell( + '@(Get-CimInstance Win32_Process | Select-Object @{n="pid";e={$_.ProcessId}}, @{n="command";e={$_.CommandLine}}) | ConvertTo-Json -Compress' + ) + ).filter((entry) => entry.command) + return execFileSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8' }) + .trim() + .split('\n') + .map((line) => { + const match = line.trim().match(/^(\d+)\s+(.*)$/) + return { pid: Number(match[1]), command: match[2] } + }) +} + +export function commandArguments(command) { + // Fixture arguments contain neither embedded quotes nor escaped quote sequences. + // Windows quotes paths with spaces; ps prints the same paths without quotes on Unix. + return [...command.matchAll(/"([^"]*)"|(\S+)/g)].map((match) => match[1] ?? match[2]) +} + +export function fixtureProcesses(root, entry) { + return processInventory().filter(({ command }) => { + const args = commandArguments(command) + const index = args.indexOf('--root') + const executable = args[index - 1] + return ( + index > 0 && + args[index + 1] === root && + (executable === entry || executable.startsWith(`${entry}${sep}`)) + ) + }) +} + +export function processMemory(pids) { + if (process.platform === 'win32') + return JSON.parse( + powershell( + `@(Get-Process -Id ${pids.join(',')} | Select-Object @{n="pid";e={$_.Id}}, @{n="rssKiB";e={[math]::Ceiling($_.WorkingSet64 / 1024)}}) | ConvertTo-Json -Compress` + ) + ) + return execFileSync('ps', ['-o', 'pid=,rss=', '-p', pids.join(',')], { encoding: 'utf8' }) + .trim() + .split('\n') + .map((line) => { + const [pid, rssKiB] = line.trim().split(/\s+/).map(Number) + return { pid, rssKiB } + }) +} diff --git a/packages/mcp-server/src/agent-clients/codex-feedback.ts b/packages/mcp-server/src/agent-clients/codex-feedback.ts index 9ed7e72d..03615b0d 100644 --- a/packages/mcp-server/src/agent-clients/codex-feedback.ts +++ b/packages/mcp-server/src/agent-clients/codex-feedback.ts @@ -8,16 +8,26 @@ import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { promisify } from 'node:util' +import type { HostContext } from './host-context' import type { ClientBinding } from './types' import { HISTORY_RETENTION_MS } from '../history-retention' -import { CodexDiscoveryError, CodexIpc, CodexIpcError } from './codex-ipc' +import { hostEnvironment } from '../process-environment' +import { CodexDiscoveryError, CodexIpc, CodexIpcError, codexSocketPaths } from './codex-ipc' import { CodexNativeQueue, CodexQueueUnavailable, type CodexQueueBackend, type CodexQueueConnection } from './codex-queue' +import { + HostContextSchema, + codexStoreKey, + codexConversationKey, + hostContextKey, + hostConversationKey, + requireHostContext +} from './host-context' import { nativeFeedback } from './types' export const CODEX_APP_FEEDBACK: AgentCapabilities = { @@ -99,6 +109,7 @@ type Receipt = { acceptedAt?: number feedbackCreatedAt?: number queueCommitConfirmed?: boolean + hostContext?: HostContext } type QueueReceipt = Receipt & Required> @@ -129,7 +140,11 @@ function isQueueReceipt(receipt: Receipt): receipt is QueueReceipt { ) } -async function loadConversation(conversationId: string, signal: AbortSignal): Promise { +async function loadConversation( + conversationId: string, + signal: AbortSignal, + context?: HostContext +): Promise { if (!/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i.test(conversationId)) throw new Error('Open the original conversation in Codex before sending these comments.') const url = `codex://threads/${conversationId}` @@ -143,7 +158,8 @@ async function loadConversation(conversationId: string, signal: AbortSignal): Pr throw new Error('Open the original conversation in Codex before sending these comments.') await promisify(execFile)(command.file, command.args, { signal, - timeout: 5000 + timeout: 5000, + env: hostEnvironment(requireHostContext(context)) }) } @@ -151,32 +167,94 @@ function isUnloadedConversation(error: unknown): boolean { return error instanceof CodexIpcError && !error.uncertain && error.message === 'no-client-found' } +type FeedbackOptions = { + open?: (context?: HostContext) => Promise + retryMs?: number + load?: typeof loadConversation + nativeQueue?: CodexNativeQueue + taskContext?: (taskId: string) => HostContext | undefined +} + export class CodexAppFeedback { private readonly shutdown = new AbortController() private readonly cancelledTasks = new Set() private readonly pendingTasks = new Map>() - private readonly queues = new Map>() + private readonly queues = new Map; contexts: Set }>() private readonly availability = new Map() + private readonly hostQueues = new Map() private receiptScan?: AsyncGenerator private reconciliation?: Promise + private readonly open: NonNullable + private readonly retryMs: number + private readonly load: typeof loadConversation + private readonly nativeQueue?: CodexNativeQueue + private readonly taskContext: NonNullable constructor( private readonly directory: string, - private readonly open: () => Promise = () => CodexIpc.open(), - private readonly retryMs = 1000, - private readonly load: typeof loadConversation = loadConversation, - private readonly nativeQueue = new CodexNativeQueue() - ) {} + options: FeedbackOptions = {} + ) { + this.open = + options.open ?? ((context) => CodexIpc.open(codexSocketPaths(requireHostContext(context)))) + this.retryMs = options.retryMs ?? 1000 + this.load = options.load ?? loadConversation + this.nativeQueue = options.nativeQueue + this.taskContext = options.taskContext ?? (() => undefined) + } + + private queue(context?: HostContext): CodexNativeQueue { + if (this.nativeQueue) return this.nativeQueue + const host = requireHostContext(context) + const key = hostContextKey(host) + let queue = this.hostQueues.get(key) + if (!queue) { + if (this.hostQueues.size >= 128) throw new Error('Too many active Codex host contexts.') + queue = CodexNativeQueue.forContext(host) + this.hostQueues.set(key, queue) + } + return queue + } + + retainContexts(contexts: Iterable): void { + const retained = new Set([...contexts].map(hostContextKey)) + for (const { contexts } of this.queues.values()) for (const key of contexts) retained.add(key) + for (const [key, queue] of this.hostQueues) { + if (retained.has(key)) continue + queue.close() + this.hostQueues.delete(key) + } + } + + private receiptContext(receipt: Receipt): HostContext | undefined { + const current = receipt.taskId ? this.taskContext(receipt.taskId) : undefined + return current && + (!receipt.hostContext || codexStoreKey(current) === codexStoreKey(receipt.hostContext)) + ? current + : receipt.hostContext + } + + private async readQueuedReceipt(receipt: QueueReceipt) { + try { + return await this.queue(this.receiptContext(receipt)).readForReconciliation( + receipt.conversationId, + receipt.backend + ) + } catch { + // Missing host context and unreadable native state must both preserve the receipt. + return undefined + } + } async available(binding: ClientBinding): Promise { const { sessionId } = binding.client if (!sessionId || !nativeFeedback(binding.client) || this.shutdown.signal.aborted) return false - const cached = this.availability.get(sessionId) + const key = hostConversationKey(sessionId, binding.hostContext) + const cached = this.availability.get(key) if (cached && cached.expiresAt > Date.now()) return cached.available let connection: Connection | undefined let available = false try { - connection = await this.open() + connection = await this.open(binding.hostContext) await connection.owner(sessionId, undefined, 2000) available = true } catch { @@ -185,7 +263,7 @@ export class CodexAppFeedback { connection?.close() } if (this.availability.size >= 256) this.availability.clear() - this.availability.set(sessionId, { available, expiresAt: Date.now() + 10000 }) + this.availability.set(key, { available, expiresAt: Date.now() + 10000 }) return available } @@ -207,7 +285,8 @@ export class CodexAppFeedback { return Promise.reject(new Error('These design comments were cancelled.')) if (!conversationId || !['queue', 'steer'].includes(feedback.mode)) return Promise.reject(new Error('Native Codex delivery requires Queue or Steer comments.')) - if (feedback.mode === 'steer' && this.queues.has(conversationId)) + const key = codexConversationKey(conversationId, binding.hostContext) + if (feedback.mode === 'steer' && this.queues.has(key)) return Promise.reject( new Error( 'Codex already has a pending delivery. Comments are saved; retry when it finishes.' @@ -216,8 +295,16 @@ export class CodexAppFeedback { if (this.queues.size >= 128) return Promise.reject(new Error('Too many pending Codex conversations.')) const combined = AbortSignal.any([signal, this.shutdown.signal]) - const pending = this.schedule(conversationId, () => - this.deliver(conversationId, taskId, feedback, combined, beforeSend, onDispatch) + const pending = this.schedule(key, binding.hostContext, () => + this.deliver( + conversationId, + taskId, + feedback, + combined, + beforeSend, + onDispatch, + binding.hostContext + ) ) this.pendingTasks.set(taskId, pending) void pending @@ -228,13 +315,19 @@ export class CodexAppFeedback { return pending } - private schedule(conversationId: string, action: () => Promise): Promise { - const previous = this.queues.get(conversationId) ?? Promise.resolve() - const pending = previous.catch(() => {}).then(action) - this.queues.set(conversationId, pending) + private schedule( + key: string, + context: HostContext | undefined, + action: () => Promise + ): Promise { + const previous = this.queues.get(key) + const pending = (previous?.pending ?? Promise.resolve()).catch(() => {}).then(action) + const contexts = previous?.contexts ?? new Set() + contexts.add(hostContextKey(context)) + this.queues.set(key, { pending, contexts }) void pending .finally(() => { - if (this.queues.get(conversationId) === pending) this.queues.delete(conversationId) + if (this.queues.get(key)?.pending === pending) this.queues.delete(key) }) .catch(() => {}) return pending @@ -246,7 +339,8 @@ export class CodexAppFeedback { feedback: DesignFeedback, signal: AbortSignal, beforeSend: () => void, - onDispatch: () => void + onDispatch: () => void, + context?: HostContext ): Promise { const validate = () => { assertFreshFeedback(feedback) @@ -262,7 +356,8 @@ export class CodexAppFeedback { status: 'pending', conversationId, acceptedAt: Date.now(), - feedbackCreatedAt: feedback.createdAt + feedbackCreatedAt: feedback.createdAt, + ...(context ? { hostContext: context } : {}) } let loaded = false let nativeQueueUnavailable = false @@ -277,6 +372,8 @@ export class CodexAppFeedback { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } if (existing) { + if (existing.hostContext && codexStoreKey(existing.hostContext) !== codexStoreKey(context)) + throw new Error('This comment delivery belongs to a different Codex host context.') if (existing.signature !== signature || (existing.taskId && existing.taskId !== taskId)) throw new Error('This comment delivery ID already belongs to different content or task.') onDispatch() @@ -285,7 +382,7 @@ export class CodexAppFeedback { throw new Error('These queued comments were cancelled.') if ( existing.delivery === 'queue' && - (await this.nativeQueue.read(conversationId, existing.backend)).some( + (await this.queue(context).read(conversationId, existing.backend)).some( (message) => message.id === feedback.id ) ) { @@ -303,14 +400,14 @@ export class CodexAppFeedback { if ( feedback.mode === 'queue' && nativeQueueUnavailable && - !(await this.nativeQueue.areQueuesEmpty(conversationId)) + !(await this.queue(context).areQueuesEmpty(conversationId)) ) { if (Date.now() >= deadline) throw new Error('Codex still has queued messages. Comments are saved; retry later.') await delay(this.retryMs, undefined, { signal }) continue } - const connection = await this.open() + const connection = await this.open(context) try { let owner: string try { @@ -320,9 +417,9 @@ export class CodexAppFeedback { signal.throwIfAborted() validate() loaded = true - owner = await this.loadOwner(connection, conversationId, signal, validate) + owner = await this.loadOwner(connection, conversationId, signal, validate, context) } - this.availability.delete(conversationId) + this.availability.delete(hostConversationKey(conversationId, context)) signal.throwIfAborted() validate() // Reserve before writing to IPC so a Hub restart cannot duplicate an uncertain submission. @@ -347,7 +444,7 @@ export class CodexAppFeedback { } let attempted = false try { - await this.nativeQueue.admit( + await this.queue(context).admit( connection, owner, conversationId, @@ -380,14 +477,14 @@ export class CodexAppFeedback { nativeQueueUnavailable = true // Recheck both queues before the bounded idle-turn fallback. A pending // native queue must not be bypassed, and waiting reserves no receipt. - if (!(await this.nativeQueue.areQueuesEmpty(conversationId))) continue + if (!(await this.queue(context).areQueuesEmpty(conversationId))) continue await writeFile(path, JSON.stringify(pendingReceipt), { flag: 'wx', mode: 0o600 }) } else { onDispatch() - const admitted = await this.nativeQueue + const admitted = await this.queue(context) .read(conversationId, receipt.backend) .then((messages) => messages.some((message) => message.id === feedback.id)) .catch(() => false) @@ -521,30 +618,43 @@ export class CodexAppFeedback { const queues = new Map() for (const { receipt } of await this.queueReceipts()) { if (receipt.taskId === taskId) - queues.set(JSON.stringify([receipt.conversationId, receipt.backend ?? 'legacy']), receipt) + queues.set( + JSON.stringify([ + codexConversationKey(receipt.conversationId, this.receiptContext(receipt)), + receipt.backend ?? 'legacy' + ]), + receipt + ) } - for (const { conversationId, backend = 'legacy' } of queues.values()) { - await this.schedule(conversationId, async () => { + for (const group of queues.values()) { + const { conversationId, backend = 'legacy' } = group + const context = this.receiptContext(group) + await this.schedule(codexConversationKey(conversationId, context), context, async () => { const receipts = (await this.queueReceipts()).filter( ({ receipt }) => receipt.taskId === taskId && receipt.conversationId === conversationId && + codexStoreKey(this.receiptContext(receipt)) === codexStoreKey(context) && (receipt.backend ?? 'legacy') === backend && receipt.messageId ) if (!receipts.length) return const ids = new Set(receipts.map(({ receipt }) => receipt.messageId)) for (const { path, receipt } of receipts) { - if (receipt.status !== 'removed') + const recoveredContext = + context && hostContextKey(context) !== hostContextKey(receipt.hostContext) + if (recoveredContext) receipt.hostContext = context + // Persist refreshed discovery inputs before the supplying task can expire. + if (receipt.status !== 'removed' || recoveredContext) await this.saveReceipt(path, { ...receipt, - status: 'cancelled', + status: receipt.status === 'removed' ? 'removed' : 'cancelled', queueCommitConfirmed: receipt.queueCommitConfirmed === true || receipt.status === 'delivered' }) } const observed = new Set( - (await this.nativeQueue.read(conversationId, backend)).map((message) => message.id) + (await this.queue(context).read(conversationId, backend)).map((message) => message.id) ) const hasMessages = receipts.some(({ receipt }) => observed.has(receipt.messageId)) for (const { path, receipt } of receipts) { @@ -558,10 +668,10 @@ export class CodexAppFeedback { if (hasMessages) { let owner: string | undefined if (backend === 'legacy') { - connection = await this.open() + connection = await this.open(context) owner = await connection.owner(conversationId, this.shutdown.signal) } - await this.nativeQueue.remove( + await this.queue(context).remove( connection, owner, conversationId, @@ -613,12 +723,14 @@ export class CodexAppFeedback { continue } // Keep cancellation tombstones: a disconnected writer can commit after the first removal check. - const key = JSON.stringify([receipt.conversationId, receipt.backend ?? 'legacy']) + const context = this.receiptContext(receipt) + const key = JSON.stringify([ + codexConversationKey(receipt.conversationId, context), + receipt.backend ?? 'legacy' + ]) let ids = snapshots.get(key) if (!snapshots.has(key)) { - const messages = await this.nativeQueue - .readForReconciliation(receipt.conversationId, receipt.backend) - .catch(() => undefined) + const messages = await this.readQueuedReceipt(receipt) ids = messages ? new Set(messages.map((message) => message.id)) : undefined snapshots.set(key, ids) } @@ -631,10 +743,14 @@ export class CodexAppFeedback { !(entry.receipt.status === 'removed' && entry.receipt.queueCommitConfirmed === true) ) continue - if (entry.receipt.conversationId && this.queues.has(entry.receipt.conversationId)) continue + const context = this.receiptContext(entry.receipt) + const key = entry.receipt.conversationId + ? codexConversationKey(entry.receipt.conversationId, context) + : undefined + if (key && this.queues.has(key)) continue const prune = () => this.pruneReceipt(entry.path) // Serialize against admission and Stop, then re-read rather than pruning a scan snapshot. - if (entry.receipt.conversationId) await this.schedule(entry.receipt.conversationId, prune) + if (key) await this.schedule(key, context, prune) else await prune() } } @@ -664,9 +780,7 @@ export class CodexAppFeedback { ) + HISTORY_RETENTION_MS if (Date.now() < expiresAt) return if (isQueueReceipt(receipt)) { - const messages = await this.nativeQueue - .readForReconciliation(receipt.conversationId, receipt.backend) - .catch(() => undefined) + const messages = await this.readQueuedReceipt(receipt) if (!messages || messages.some((message) => message.id === receipt.messageId)) return } await unlink(path) @@ -706,6 +820,8 @@ export class CodexAppFeedback { const path = join(this.directory, file) try { const receipt = JSON.parse(await readFile(path, 'utf8')) as Receipt + if (receipt.hostContext) + receipt.hostContext = HostContextSchema.parse(receipt.hostContext) if (typeof receipt?.signature === 'string' && typeof receipt.status === 'string') entries.push({ path, receipt }) } catch { @@ -725,11 +841,12 @@ export class CodexAppFeedback { connection: Connection, conversationId: string, signal: AbortSignal, - beforeSend: () => void + beforeSend: () => void, + context?: HostContext ): Promise { try { // Only an explicit submission may load its original task. Polls never navigate. - await this.load(conversationId, signal) + await this.load(conversationId, signal, context) const deadline = Date.now() + 5000 while (true) { signal.throwIfAborted() @@ -764,6 +881,8 @@ export class CodexAppFeedback { this.shutdown.abort() void this.receiptScan?.return(undefined).catch(() => {}) this.receiptScan = undefined - this.nativeQueue.close() + this.nativeQueue?.close() + for (const queue of this.hostQueues.values()) queue.close() + this.hostQueues.clear() } } diff --git a/packages/mcp-server/src/agent-clients/codex-ipc.ts b/packages/mcp-server/src/agent-clients/codex-ipc.ts index 6ff23498..decec0be 100644 --- a/packages/mcp-server/src/agent-clients/codex-ipc.ts +++ b/packages/mcp-server/src/agent-clients/codex-ipc.ts @@ -1,9 +1,10 @@ import { randomUUID } from 'node:crypto' import { lstat } from 'node:fs/promises' import { connect, type Socket } from 'node:net' -import { homedir, tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import type { HostContext } from './host-context' + type Message = Record const WINDOWS_PIPE = String.raw`\\.\pipe\codex-ipc` export class CodexIpcError extends Error { @@ -23,13 +24,13 @@ export class CodexDiscoveryError extends CodexIpcError { } } -export function codexSocketPaths(env: NodeJS.ProcessEnv = process.env): string[] { +export function codexSocketPaths(context: HostContext): string[] { if (process.platform === 'win32') return [WINDOWS_PIPE] if (!process.getuid) return [] const uid = process.getuid() return [ - join(env.CODEX_HOME || join(homedir(), '.codex'), 'ipc', 'ipc.sock'), - join(tmpdir(), 'codex-ipc', uid ? `ipc-${uid}.sock` : 'ipc.sock') + join(context.codexHome, 'ipc', 'ipc.sock'), + join(context.tempDir, 'codex-ipc', uid ? `ipc-${uid}.sock` : 'ipc.sock') ] } @@ -127,7 +128,7 @@ export class CodexIpc { }) } - static async open(paths = codexSocketPaths()): Promise { + static async open(paths: string[]): Promise { for (const path of paths) { if (!(await trustedSocket(path))) continue let client: CodexIpc | undefined @@ -194,7 +195,9 @@ export class CodexIpc { typeof message.error === 'string' ? message.error : 'Codex IPC returned an incompatible response.', - message.resultType === 'success' + message.resultType !== 'error' || + typeof message.error !== 'string' || + message.error === 'request-timeout' ) ) } diff --git a/packages/mcp-server/src/agent-clients/codex-lifecycle.ts b/packages/mcp-server/src/agent-clients/codex-lifecycle.ts index afe152a9..0ce83c69 100644 --- a/packages/mcp-server/src/agent-clients/codex-lifecycle.ts +++ b/packages/mcp-server/src/agent-clients/codex-lifecycle.ts @@ -1,7 +1,10 @@ import { setTimeout as delay } from 'node:timers/promises' -import { CodexIpc } from './codex-ipc' +import type { HostContext } from './host-context' + +import { CodexIpc, codexSocketPaths } from './codex-ipc' import { CodexTurnState, type CodexLifecycleState } from './codex-turn-state' +import { hostConversationKey, requireHostContext } from './host-context' export type { CodexTurn, CodexLifecycleState } from './codex-turn-state' @@ -26,25 +29,28 @@ export class CodexAppLifecycle { constructor( private readonly changed: ( conversationId: string, - state: CodexLifecycleState | undefined + state: CodexLifecycleState | undefined, + context?: HostContext ) => void, - private readonly open: () => Promise = () => CodexIpc.open(), + private readonly open: (context?: HostContext) => Promise = (context) => + CodexIpc.open(codexSocketPaths(requireHostContext(context))), private readonly retryMs = 2000, private readonly snapshotTimeoutMs = 5000 ) {} - watch(conversationId: string): void { - if (this.closed || this.watches.has(conversationId) || this.watches.size >= 128) return + watch(conversationId: string, context?: HostContext): void { + const key = hostConversationKey(conversationId, context) + if (this.closed || this.watches.has(key) || this.watches.size >= 128) return const watch = { controller: new AbortController(), state: undefined as CodexLifecycleState | undefined } - this.watches.set(conversationId, watch) - void this.follow(conversationId, watch) + this.watches.set(key, watch) + void this.follow(conversationId, watch, context) } - state(conversationId: string): CodexLifecycleState | undefined { - return this.watches.get(conversationId)?.state + state(conversationId: string, context?: HostContext): CodexLifecycleState | undefined { + return this.watches.get(hostConversationKey(conversationId, context))?.state } retain(conversationIds: Set): void { @@ -55,8 +61,12 @@ export class CodexAppLifecycle { } } - async interrupt(conversationId: string, expectedTurnId: string): Promise { - const connection = await this.open() + async interrupt( + conversationId: string, + expectedTurnId: string, + context?: HostContext + ): Promise { + const connection = await this.open(context) try { const owner = await connection.owner(conversationId, undefined, 2000) const response = await connection.request( @@ -84,14 +94,15 @@ export class CodexAppLifecycle { private async follow( conversationId: string, - watch: { controller: AbortController; state?: CodexLifecycleState } + watch: { controller: AbortController; state?: CodexLifecycleState }, + context?: HostContext ): Promise { const signal = watch.controller.signal const publish = (state?: CodexLifecycleState) => { if (signal.aborted) return const changed = JSON.stringify(watch.state) !== JSON.stringify(state) watch.state = state - if (changed) this.changed(conversationId, state) + if (changed) this.changed(conversationId, state, context) } while (!signal.aborted) { let connection: Connection | undefined @@ -99,25 +110,29 @@ export class CodexAppLifecycle { let timer: ReturnType | undefined const cleanups: (() => void)[] = [] try { - connection = await this.open() + connection = await this.open(context) signal.throwIfAborted() owner = await connection.owner(conversationId, signal, 2000) signal.throwIfAborted() const ipc = connection const ownerId = owner - await new Promise((resolve, reject) => { + await new Promise((resolve) => { let revision: number | undefined let turns: CodexTurnState | undefined let awaitingSnapshot = false + let ended = false + const end = () => { + if (ended) return + ended = true + publish() + resolve() + } const requestSnapshot = () => { if (awaitingSnapshot) return awaitingSnapshot = true turns = undefined publish() - timer = setTimeout( - () => reject(new Error('Codex snapshot timed out.')), - this.snapshotTimeoutMs - ) + timer = setTimeout(end, this.snapshotTimeoutMs) ipc.broadcast( 'thread-stream-following-changed', 1, @@ -125,12 +140,13 @@ export class CodexAppLifecycle { [ownerId] ) } - cleanups.push(ipc.onDisconnect(resolve)) - const abort = () => resolve() - signal.addEventListener('abort', abort, { once: true }) - cleanups.push(() => signal.removeEventListener('abort', abort)) + cleanups.push(ipc.onDisconnect(end)) + signal.addEventListener('abort', end, { once: true }) + cleanups.push(() => signal.removeEventListener('abort', end)) cleanups.push( ipc.onBroadcast((message) => { + // One IPC read may contain trailing frames after this owner was lost. + if (ended) return try { const params = object(message.params) if ( @@ -139,7 +155,7 @@ export class CodexAppLifecycle { params.clientId === ownerId && params.status === 'disconnected') ) { - resolve() + end() return } if ( @@ -186,8 +202,8 @@ export class CodexAppLifecycle { requestSnapshot() } } else throw new Error('Unsupported Codex stream change.') - } catch (error) { - reject(error) + } catch { + end() } }) ) diff --git a/packages/mcp-server/src/agent-clients/codex-queue-rpc.ts b/packages/mcp-server/src/agent-clients/codex-queue-rpc.ts index 89632ad6..d452fbdc 100644 --- a/packages/mcp-server/src/agent-clients/codex-queue-rpc.ts +++ b/packages/mcp-server/src/agent-clients/codex-queue-rpc.ts @@ -2,6 +2,9 @@ import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child import { createInterface } from 'node:readline' import { promisify } from 'node:util' +import type { HostContext } from './host-context' + +import { hostEnvironment } from '../process-environment' import { CodexIpcError } from './codex-ipc' export type ServerQueuedMessage = { @@ -11,7 +14,8 @@ export type ServerQueuedMessage = { } /** Use the running desktop's bundled binary, never an unrelated CLI on PATH. */ -export async function codexQueueExecutable(): Promise { +export async function codexQueueExecutable(context: HostContext): Promise { + const options = { timeout: 3000, env: hostEnvironment(context) } const { stdout } = process.platform === 'win32' ? await promisify(execFile)( @@ -22,11 +26,9 @@ export async function codexQueueExecutable(): Promise { '-Command', '(Get-Process codex -ErrorAction Stop).Path' ], - { timeout: 3000 } + options ) - : await promisify(execFile)('ps', ['-U', String(process.getuid!()), '-o', 'comm='], { - timeout: 3000 - }) + : await promisify(execFile)('ps', ['-U', String(process.getuid!()), '-o', 'comm='], options) const paths = [...new Set(stdout.split(/\r?\n/).map((path) => path.trim()))].filter((path) => /[/\\]resources[/\\](?:codex-cli[/\\]CodexCLI\.app[/\\]Contents[/\\]MacOS[/\\])?codex(?:\.exe)?$/i.test( path @@ -52,11 +54,15 @@ export class CodexQueueRpc { } >() - constructor(private readonly executable = codexQueueExecutable) {} + constructor( + private readonly context: HostContext, + private readonly executable = codexQueueExecutable + ) {} async list(threadId: string, signal?: AbortSignal): Promise { await this.connect() const messages: ServerQueuedMessage[] = [] + const cursors = new Set() let cursor: string | null = null do { const page = (await this.request('thread/queue/list', { threadId, cursor }, signal)) as { @@ -78,10 +84,11 @@ export class CodexQueueRpc { messages.push(...page.data) if ( page.nextCursor != null && - (typeof page.nextCursor !== 'string' || page.nextCursor === cursor) + (typeof page.nextCursor !== 'string' || cursors.has(page.nextCursor)) ) throw new Error('Codex returned an incompatible queue cursor.') cursor = page.nextCursor ?? null + if (cursor !== null) cursors.add(cursor) } while (cursor !== null) return messages } @@ -135,9 +142,13 @@ export class CodexQueueRpc { } private async start(): Promise { - const executable = await this.executable() + const executable = await this.executable(this.context) if (this.closed) throw new Error('Codex queue connection is closed.') - const child = spawn(executable, ['app-server'], { stdio: 'pipe', windowsHide: true }) + const child = spawn(executable, ['app-server'], { + stdio: 'pipe', + windowsHide: true, + env: hostEnvironment(this.context) + }) this.child = child child.stderr.resume() const lines = createInterface({ input: child.stdout }) @@ -149,7 +160,14 @@ export class CodexQueueRpc { if (!pending) return if (response.error) pending.reject( - new CodexIpcError(String(response.error.message ?? 'Codex queue request failed.')) + new CodexIpcError( + typeof response.error.message === 'string' + ? response.error.message + : 'Codex queue request failed.', + !Number.isInteger(response.error.code) || + typeof response.error.message !== 'string' || + 'result' in response + ) ) else if ('result' in response) pending.resolve(response.result) else this.disconnect() diff --git a/packages/mcp-server/src/agent-clients/codex-queue.ts b/packages/mcp-server/src/agent-clients/codex-queue.ts index 41ef4124..1bde9e90 100644 --- a/packages/mcp-server/src/agent-clients/codex-queue.ts +++ b/packages/mcp-server/src/agent-clients/codex-queue.ts @@ -2,9 +2,10 @@ import type { DesignFeedback } from '@tempad-dev/shared' import { formatDesignFeedback } from '@tempad-dev/shared' import { readFile } from 'node:fs/promises' -import { homedir } from 'node:os' import { join } from 'node:path' +import type { HostContext } from './host-context' + import { CodexIpc, CodexIpcError } from './codex-ipc' import { CodexQueueRpc } from './codex-queue-rpc' import { readCodexServerQueue } from './codex-server-queue' @@ -26,8 +27,11 @@ function object(value: unknown): Value { export class CodexQueueUnavailable extends Error {} /** Read only. The native owner commits this file before acknowledging queue writes. */ -export async function readCodexQueue(conversationId: string): Promise { - const path = join(process.env.CODEX_HOME || join(homedir(), '.codex'), '.codex-global-state.json') +export async function readCodexQueue( + conversationId: string, + home: string +): Promise { + const path = join(home, '.codex-global-state.json') let state: Value try { state = object(JSON.parse(await readFile(path, 'utf8'))) @@ -139,13 +143,18 @@ async function conversationCwd( } export class CodexNativeQueue { + static forContext(context: HostContext): CodexNativeQueue { + return new CodexNativeQueue( + (id) => readCodexQueue(id, context.codexHome), + (id) => readCodexServerQueue(id, context.codexSqliteHome), + new CodexQueueRpc(context) + ) + } + constructor( - private readonly readLegacy: typeof readCodexQueue = readCodexQueue, - private readonly readServer: typeof readCodexServerQueue = readCodexServerQueue, - private readonly server: Pick< - CodexQueueRpc, - 'list' | 'add' | 'remove' | 'close' - > = new CodexQueueRpc() + private readonly readLegacy: (id: string) => ReturnType, + private readonly readServer: (id: string) => ReturnType, + private readonly server: Pick ) {} async read( diff --git a/packages/mcp-server/src/agent-clients/codex-server-queue.ts b/packages/mcp-server/src/agent-clients/codex-server-queue.ts index 71655c66..d5e949c7 100644 --- a/packages/mcp-server/src/agent-clients/codex-server-queue.ts +++ b/packages/mcp-server/src/agent-clients/codex-server-queue.ts @@ -1,15 +1,14 @@ import { stat } from 'node:fs/promises' -import { homedir } from 'node:os' import { join } from 'node:path' export type CodexServerQueueState = { hasMessages: boolean } | null /** A missing database identifies an older store; an unreadable store is never empty. */ -export async function readCodexServerQueue(conversationId: string): Promise { - const path = join( - process.env.CODEX_SQLITE_HOME || process.env.CODEX_HOME || join(homedir(), '.codex'), - 'queue_1.sqlite' - ) +export async function readCodexServerQueue( + conversationId: string, + home: string +): Promise { + const path = join(home, 'queue_1.sqlite') try { try { await stat(path) diff --git a/packages/mcp-server/src/agent-clients/hooks.ts b/packages/mcp-server/src/agent-clients/hooks.ts index b409448a..f808c5ea 100644 --- a/packages/mcp-server/src/agent-clients/hooks.ts +++ b/packages/mcp-server/src/agent-clients/hooks.ts @@ -1,29 +1,8 @@ -import { z } from 'zod' +import type { ClientHook } from '@tempad-dev/shared' +export { ClientHookRequestSchema, type ClientHook } from '@tempad-dev/shared' import type { ClientBinding } from './types' -export const ClientHookRequestSchema = z.object({ - method: z.literal('tempad/client-hook'), - params: z - .object({ - version: z.literal(1), - kind: z.enum(['codex', 'claude']), - sessionId: z.string().min(1).max(256), - event: z.enum([ - 'PreToolUse', - 'PostToolUse', - 'UserPromptSubmit', - 'Stop', - 'Interrupt', - 'SessionEnd' - ]), - invocationId: z.string().uuid(), - turnId: z.string().min(1).max(256).optional(), - taskId: z.string().min(1).max(128).optional() - }) - .strict() -}) -export type ClientHook = z.infer['params'] export type ClientHookResult = { output?: { hookSpecificOutput: { hookEventName: ClientHook['event']; additionalContext: string } @@ -31,8 +10,9 @@ export type ClientHookResult = { } type Session = { active: boolean + issuedAt?: number turnId?: string - stopNotice?: { taskId: string; turnId?: string } + receiptCapable?: boolean } function scope(kind: string, sessionId: string): string { @@ -43,60 +23,107 @@ function scope(kind: string, sessionId: string): string { export class ClientHooks { private readonly sessions = new Map() - isActive(kind: string, sessionId: string): boolean { - return this.sessions.get(scope(kind, sessionId))?.active === true + constructor( + private readonly takeStopNotice: (input: ClientHook) => { + taskId?: string + staleTurn?: boolean + } + ) {} + + receiptCapable(sessionId?: string): boolean { + return !!sessionId && this.sessions.get(scope('claude', sessionId))?.receiptCapable === true } - stop(binding: ClientBinding, taskId: string): void { - const session = this.session(binding) - if (session) session.stopNotice = { taskId, turnId: binding.turnId } + isActive(kind: string, sessionId: string): boolean { + return this.sessions.get(scope(kind, sessionId))?.active === true } disconnect(binding: ClientBinding): void { - const session = this.session(binding) + const { kind, sessionId } = binding.client + const session = sessionId && this.sessions.get(scope(kind, sessionId)) if (session) session.active = false } - private session(binding: ClientBinding): Session | undefined { - const { kind, sessionId } = binding.client - return sessionId ? this.sessions.get(scope(kind, sessionId)) : undefined + retireLegacy(): string[] { + const retired: string[] = [] + for (const [key, session] of this.sessions) { + if (session.receiptCapable) continue + const [kind, sessionId] = JSON.parse(key) as [string, string] + if (kind !== 'claude') continue + this.sessions.delete(key) + retired.push(sessionId) + } + return retired } - handle(input: ClientHook): ClientHookResult { + /** Expiry is loss of old evidence, never a replay of the historical event. */ + retire(input: ClientHook): boolean { + const key = scope(input.kind, input.sessionId) + const session = this.sessions.get(key) + if (input.version !== 2 || (session?.issuedAt ?? 0) > input.issuedAt) return false + this.sessions.delete(key) + return true + } + + /** Historical delivery can end old work, never establish liveness or consume a Stop notice. */ + reconcile(input: ClientHook): ClientHookResult | undefined { + if (!['Stop', 'Interrupt', 'SessionEnd'].includes(input.event)) return + const session = this.sessions.get(scope(input.kind, input.sessionId)) + if (session) { + if ( + input.version !== 2 || + (session.issuedAt ?? 0) >= input.issuedAt || + (input.turnId && session.turnId && input.turnId !== session.turnId) + ) + return + session.active = false + session.issuedAt = input.issuedAt + } + return {} + } + + /** Undefined rejects the event; an empty result accepts it without model-facing output. */ + handle(input: ClientHook): ClientHookResult | undefined { const key = scope(input.kind, input.sessionId) let session = this.sessions.get(key) if (!session) { if (this.sessions.size >= 256) { const inactive = [...this.sessions].find(([, value]) => !value.active) - if (!inactive) return {} + if (!inactive) return this.sessions.delete(inactive[0]) } - session = { active: false, turnId: input.turnId } + session = { active: false } this.sessions.set(key, session) } - if (input.event === 'UserPromptSubmit') { - session.turnId = input.turnId - session.stopNotice = undefined - } else if (input.turnId && session.turnId && input.turnId !== session.turnId) { - return {} - } else if (input.turnId) session.turnId = input.turnId + if ( + input.event !== 'UserPromptSubmit' && + input.turnId && + session.turnId && + input.turnId !== session.turnId + ) { + return + } + const notice = this.takeStopNotice(input) + if (notice.staleTurn) return + session.issuedAt = Math.max( + session.issuedAt ?? 0, + input.version === 2 ? input.issuedAt : Date.now() + ) + if (input.event === 'UserPromptSubmit' || input.turnId) session.turnId = input.turnId + session.receiptCapable = input.version === 2 const ended = ['Interrupt', 'SessionEnd'].includes(input.event) session.active = !ended && input.event !== 'Stop' - if (ended) return {} - const notice = session.stopNotice - if (notice && (!notice.turnId || !input.turnId || notice.turnId === input.turnId)) { - session.stopNotice = undefined - if (input.event === 'PreToolUse' || input.event === 'PostToolUse') - return { - output: { - hookSpecificOutput: { - hookEventName: input.event, - additionalContext: `The user stopped Figma design task ${notice.taskId}. That task is cancelled and cannot resume. Respect the stop and do not automatically replace it. If further design work is necessary or the user requests it, explicitly begin a new task.` - } + const { taskId } = notice + if (taskId && (input.event === 'PreToolUse' || input.event === 'PostToolUse')) + return { + output: { + hookSpecificOutput: { + hookEventName: input.event, + additionalContext: `The user stopped Figma design task ${taskId}. That task is cancelled and cannot resume. Respect the stop and do not automatically replace it. If further design work is necessary or the user requests it, explicitly begin a new task.` } } - } + } return {} } } diff --git a/packages/mcp-server/src/agent-clients/host-context.ts b/packages/mcp-server/src/agent-clients/host-context.ts new file mode 100644 index 00000000..313907ce --- /dev/null +++ b/packages/mcp-server/src/agent-clients/host-context.ts @@ -0,0 +1,74 @@ +import { homedir, tmpdir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import { z } from 'zod' + +const DirectorySchema = z + .string() + .min(1) + .max(4096) + .refine((value) => isAbsolute(value) && !value.includes('\0')) + +/** Private CLI-to-Hub metadata. These paths locate a host; they do not establish ownership. */ +export const HostContextSchema = z + .object({ + version: z.literal(1), + home: DirectorySchema, + tempDir: DirectorySchema, + codexHome: DirectorySchema, + codexSqliteHome: DirectorySchema + }) + .strict() +export type HostContext = z.infer + +export const MISSING_HOST_CONTEXT_MESSAGE = + 'Update TemPad Dev MCP and restart its MCP server in your host; reconnecting the existing process is not enough. Comments stay saved and are never delivered through hooks.' + +// Keep the existing identity notification unchanged so older Hubs still recognize new CLIs. +export const HostContextNotificationSchema = z.object({ + method: z.literal('notifications/tempad/host-context'), + params: HostContextSchema +}) + +/** Resolve defaults and relative host overrides in the originating CLI, never in the Hub. */ +export function captureHostContext(env: NodeJS.ProcessEnv): HostContext { + const home = resolve(env.HOME || env.USERPROFILE || homedir()) + const codexHome = resolve(env.CODEX_HOME || join(home, '.codex')) + return HostContextSchema.parse({ + version: 1, + home, + tempDir: resolve(env.TMPDIR || env.TEMP || env.TMP || tmpdir()), + codexHome, + codexSqliteHome: resolve(env.CODEX_SQLITE_HOME || codexHome) + }) +} + +export function requireHostContext(context?: HostContext): HostContext { + if (!context) throw new Error(MISSING_HOST_CONTEXT_MESSAGE) + return context +} + +/** Discovery inputs may change without changing the native conversation's storage. */ +export function codexStoreKey(context?: HostContext): string { + return context ? JSON.stringify([context.codexHome, context.codexSqliteHome]) : '' +} + +export function codexConversationKey(conversationId: string, context?: HostContext): string { + return JSON.stringify([codexStoreKey(context), conversationId]) +} + +/** Keep discovery caches and immutable connection registrations sensitive to every input. */ +export function hostContextKey(context?: HostContext): string { + return context + ? JSON.stringify([ + context.version, + context.home, + context.tempDir, + context.codexHome, + context.codexSqliteHome + ]) + : '' +} + +export function hostConversationKey(conversationId: string, context?: HostContext): string { + return JSON.stringify([hostContextKey(context), conversationId]) +} diff --git a/packages/mcp-server/src/agent-clients/registry.ts b/packages/mcp-server/src/agent-clients/registry.ts index 59ff9d77..b6566c67 100644 --- a/packages/mcp-server/src/agent-clients/registry.ts +++ b/packages/mcp-server/src/agent-clients/registry.ts @@ -2,6 +2,7 @@ import type { DesignAction, DesignActionResult } from '@tempad-dev/shared' import type { ClientBindingUpdate } from '../client-binding' import type { CodexAppLifecycle, CodexLifecycleState } from './codex-lifecycle' +import type { HostContext } from './host-context' import type { ClientBinding } from './types' import { getVerifiedTaskClient, type DesignTasks } from '../design-tasks' @@ -9,6 +10,13 @@ import { HISTORY_RETENTION_MS } from '../history-retention' import { log } from '../shared' import { CODEX_APP_FEEDBACK, type CodexAppFeedback } from './codex-feedback' import { ClientHooks, type ClientHook } from './hooks' +import { + HostContextSchema, + MISSING_HOST_CONTEXT_MESSAGE, + codexStoreKey, + hostContextKey, + hostConversationKey +} from './host-context' import { bindRequestMetadata, clientDescriptor, ClientEventSchema } from './identity' import { CANVAS_ONLY, @@ -25,8 +33,30 @@ function soleActiveTurn(state?: CodexLifecycleState): string | undefined { /** Conversation bindings and native delivery stay outside shared task lifecycle logic. */ export class AgentClients { - private readonly hooks = new ClientHooks() + needsClaudeHooks(connectionId: string, clientInfo?: { name: string }): boolean { + const pending = this.pendingClaudeBindings.has(connectionId) + const client = this.connections.get(connectionId)?.client + if (!pending && client?.kind !== 'claude' && clientInfo?.name !== 'claude-code') return false + // An initialized host or cached session hint cannot prove that installed hooks + // reach this Hub. Require the exact binding established by fresh lifecycle evidence. + return pending || client?.kind !== 'claude' || !this.hooks.receiptCapable(client.sessionId) + } + + get lastActivity(): number { + return this.activityAt + } + + private activityAt = performance.now() + + get hasLegacyHooks(): boolean { + return [...this.connections.values()].some( + (binding) => + binding.client.kind === 'claude' && !this.hooks.receiptCapable(binding.client.sessionId) + ) + } + private readonly hooks: ClientHooks private readonly connections = new Map() + private readonly contexts = new Map() private readonly pendingClaudeBindings = new Map< string, { binding: ClientBinding; needsInitialize: boolean } @@ -38,6 +68,7 @@ export class AgentClients { { signature: string; pending: Promise; settledAt?: number } >() private readonly feedbackSignals = new Map() + private readonly pendingRemovals = new Set>() private readonly lifecycle?: Pick< CodexAppLifecycle, 'watch' | 'state' | 'retain' | 'interrupt' | 'close' @@ -47,15 +78,50 @@ export class AgentClients { private readonly tasks: DesignTasks, private readonly codex?: CodexAppFeedback, lifecycle?: ( - changed: (conversationId: string, state: CodexLifecycleState | undefined) => void + changed: ( + conversationId: string, + state: CodexLifecycleState | undefined, + context?: HostContext + ) => void ) => NonNullable ) { - this.lifecycle = lifecycle?.((id, state) => this.onNativeState(id, state)) + this.hooks = new ClientHooks((input) => tasks.takeClaudeStopNotice(input)) + this.lifecycle = lifecycle?.((id, state, context) => this.onNativeState(id, state, context)) } - private onNativeState(conversationId: string, state?: CodexLifecycleState): void { + registerContext(connectionId: string, value: unknown): void { + const context = HostContextSchema.parse(value) + const previous = this.contexts.get(connectionId) + if (previous) { + if (hostContextKey(previous) !== hostContextKey(context)) + throw new Error('MCP host context cannot change on an existing connection.') + return + } + if ( + [...this.bindings.keys()].some( + (id) => id === connectionId || id.startsWith(`${connectionId}/`) + ) + ) + throw new Error('Register MCP host context before using tools.') + this.contexts.set(connectionId, context) + } + + get hasPendingActions(): boolean { + return ( + this.pendingRemovals.size > 0 || + [...this.actions.values()].some((action) => action.settledAt === undefined) + ) + } + + private onNativeState( + conversationId: string, + state?: CodexLifecycleState, + context?: HostContext + ): void { + this.activityAt = performance.now() for (const [ownerId, binding] of this.bindings) { if (nativeConversation(binding.client) !== conversationId) continue + if (hostContextKey(binding.hostContext) !== hostContextKey(context)) continue const previous = state?.turns.find((turn) => turn.turnId === binding.turnId) if (previous && ['completed', 'interrupted', 'failed'].includes(previous.status)) { for (const record of this.tasks.list()) { @@ -72,9 +138,11 @@ export class AgentClients { } } - clientHook(input: ClientHook) { + clientHook(input: ClientHook, fresh = true) { // Older installed Codex packages may still call hooks. Native state is authoritative. if (input.kind === 'codex') return {} + const result = fresh ? this.hooks.handle(input) : this.hooks.reconcile(input) + if (!result) return {} if (input.event === 'PostToolUse' && input.taskId) { this.event('hook', { method: 'notifications/tempad/client-event', @@ -87,7 +155,6 @@ export class AgentClients { } }) } - const result = this.hooks.handle(input) if (['UserPromptSubmit', 'Stop', 'Interrupt', 'SessionEnd'].includes(input.event)) { this.event('hook', { method: 'notifications/tempad/client-event', @@ -106,6 +173,24 @@ export class AgentClients { return result } + retireHook(input: ClientHook): void { + if (input.kind !== 'claude' || !this.hooks.retire(input)) return + // Require fresh lifecycle evidence to reclaim this session. SessionEnd's + // cleanup pauses tasks without removing Stop or pending-execution fences. + this.event('hook', { + method: 'notifications/tempad/client-event', + params: { event: 'SessionEnd', kind: input.kind, sessionId: input.sessionId } + }) + } + + retireLegacyHooks(): void { + for (const sessionId of this.hooks.retireLegacy()) + this.event('hook', { + method: 'notifications/tempad/client-event', + params: { event: 'SessionEnd', kind: 'claude', sessionId } + }) + } + event(connectionId: string, input: unknown): void { const { params } = ClientEventSchema.parse(input) if (params.event === 'connect') { @@ -127,7 +212,10 @@ export class AgentClients { const record = this.tasks.find(params.taskId) if ( !record || - (record.task.client?.sessionId && record.task.client.sessionId !== params.sessionId) + (record.task.client?.sessionId && record.task.client.sessionId !== params.sessionId) || + (record.claudeStopNotice?.turnId && + params.turnId && + record.claudeStopNotice.turnId !== params.turnId) ) return const connectionId = record.ownerId.split('/')[0]! @@ -148,9 +236,14 @@ export class AgentClients { this.bindings.set(ownerId, next) record.ownerId = ownerId record.task.client = next.client - this.tasks.attachClient(ownerId, next.client, { - ...(record.task.capabilities ?? CANVAS_ONLY) - }) + this.tasks.attachClient( + ownerId, + next.client, + { + ...(record.task.capabilities ?? CANVAS_ONLY) + }, + next.hostContext + ) return } for (const record of this.tasks.list()) { @@ -171,7 +264,7 @@ export class AgentClients { // A delayed Stop for an earlier turn cannot pause a later resumed turn. if (params.turnId && bound?.turnId && params.turnId !== bound.turnId) continue if (params.event === 'SessionEnd' && bound) - this.tasks.attachClient(record.ownerId, bound.client, CANVAS_ONLY) + this.tasks.attachClient(record.ownerId, bound.client, CANVAS_ONLY, bound.hostContext) else this.refresh(record.ownerId) if (['Interrupt', 'SessionEnd'].includes(params.event)) this.abortFeedback(record.task.taskId) if (['Interrupt', 'Stop', 'SessionEnd'].includes(params.event)) @@ -227,6 +320,8 @@ export class AgentClients { isClaude ? undefined : metadata ) if (needsMetadata && !binding.client.sessionId) binding = { client: fallback } + const context = this.contexts.get(connectionId) + if (context) binding = { ...binding, hostContext: context } const reportedName = clientInfo?.title?.trim() || clientInfo?.name.trim() if (reportedName) binding.client = { ...binding.client, name: reportedName.slice(0, 80) } const ownerId = binding.client.sessionId @@ -237,7 +332,17 @@ export class AgentClients { if (previous?.client.kind === binding.client.kind) binding.turnId ??= previous.turnId this.bindings.set(ownerId, binding) // Reconnect only after runtime metadata has supplied an exact client conversation. - this.tasks.attachClient(ownerId, binding.client) + const mismatchedTasks = this.tasks.attachClient( + ownerId, + binding.client, + undefined, + binding.hostContext + ) + if (mismatchedTasks.length) + log.warn( + { ownerId, taskIds: mismatchedTasks }, + 'Task reconnect refused: Codex storage differs or host context is missing. Update and restart the original MCP server with its original Codex configuration.' + ) return ownerId } @@ -266,12 +371,17 @@ export class AgentClients { private refresh(ownerId: string): void { const binding = this.bindings.get(ownerId) if (binding) { - this.tasks.attachClient(ownerId, binding.client, this.unavailable(binding)) + this.tasks.attachClient( + ownerId, + binding.client, + this.unavailable(binding), + binding.hostContext + ) if (this.codex) void this.describe(ownerId) .then(({ capabilities }) => { if (this.bindings.get(ownerId) === binding) - this.tasks.attachClient(ownerId, binding.client, capabilities) + this.tasks.attachClient(ownerId, binding.client, capabilities, binding.hostContext) }) .catch((error) => log.warn({ error }, 'Could not refresh client capabilities.')) } @@ -283,23 +393,27 @@ export class AgentClients { // Native App delivery belongs to the conversation, independently of an MCP connection. const record = this.tasks.list().find((record) => record.ownerId === ownerId) const client = record && getVerifiedTaskClient(record) - return client && nativeFeedback(client) ? { client } : undefined + return client && nativeFeedback(client) + ? { client, ...(record?.hostContext ? { hostContext: record.hostContext } : {}) } + : undefined } private interruptionTarget(binding?: ClientBinding) { const conversationId = nativeConversation(binding?.client) if (!this.lifecycle || !conversationId) return undefined // An exact binding wins. The owner guards against interrupting a later turn. - const turnId = binding?.turnId ?? soleActiveTurn(this.lifecycle.state(conversationId)) - return turnId ? { conversationId, turnId } : undefined + const turnId = + binding?.turnId ?? soleActiveTurn(this.lifecycle.state(conversationId, binding?.hostContext)) + return turnId ? { conversationId, turnId, context: binding?.hostContext } : undefined } async describe(ownerId: string) { const binding = this.binding(ownerId) const conversationId = nativeConversation(binding?.client) - if (conversationId) this.lifecycle?.watch(conversationId) + if (conversationId) this.lifecycle?.watch(conversationId, binding?.hostContext) return { client: binding?.client ?? clientDescriptor('other'), + ...(binding?.hostContext ? { hostContext: binding.hostContext } : {}), capabilities: binding && (await this.codex?.available(binding)) ? { ...CODEX_APP_FEEDBACK, interrupt: !!this.interruptionTarget(binding) } @@ -310,10 +424,14 @@ export class AgentClients { } private unavailable(binding: ClientBinding) { - return nativeFeedback(binding.client) ? CODEX_FEEDBACK_UNAVAILABLE : CANVAS_ONLY + if (!nativeFeedback(binding.client)) return CANVAS_ONLY + return binding.hostContext + ? CODEX_FEEDBACK_UNAVAILABLE + : { ...CANVAS_ONLY, reason: MISSING_HOST_CONTEXT_MESSAGE } } async action(action: DesignAction, onAccepted?: () => void): Promise { + this.activityAt = performance.now() this.pruneActions() const record = this.tasks.find(action.taskId) const client = record?.task.client @@ -323,6 +441,7 @@ export class AgentClients { action.feedback && record && client?.sessionId ? JSON.stringify([ 'feedback', + codexStoreKey(this.binding(record.ownerId)?.hostContext), client.kind, client.sessionId, record.task.target.fileKey, @@ -353,20 +472,24 @@ export class AgentClients { let dispatched = false const pending = this.perform(action, onAccepted, () => { dispatched = true - }).then((result) => { - const entry = this.actions.get(key) - if (entry) entry.settledAt = Date.now() - // A rejection before host dispatch is safe to retry with the same delivery - // identity. Queue retries re-enter the adapter's durable receipt reconciliation; - // they cannot replay an uncertain native write. - if ( - result.status === 'failed' && - (!dispatched || - (action.action === 'feedback' && action.feedback?.mode === 'queue' && this.codex)) - ) - this.actions.delete(key) - return result }) + .then((result) => { + const entry = this.actions.get(key) + if (entry) entry.settledAt = Date.now() + // A rejection before host dispatch is safe to retry with the same delivery + // identity. Queue retries re-enter the adapter's durable receipt reconciliation; + // they cannot replay an uncertain native write. + if ( + result.status === 'failed' && + (!dispatched || + (action.action === 'feedback' && action.feedback?.mode === 'queue' && this.codex)) + ) + this.actions.delete(key) + return result + }) + .finally(() => { + this.activityAt = performance.now() + }) this.actions.set(key, { signature, pending }) return pending } @@ -411,10 +534,12 @@ export class AgentClients { if (action.action === 'stop') { if (this.tasks.current(record.task.target.fileKey) !== record) throw new Error('This task has been replaced. Stop the current task instead.') - if (['completed', 'cancelled'].includes(record.task.status)) + if (record.task.status === 'cancelled') return result('delivered', 'This design task has already ended.') + const completed = record.task.status === 'completed' // Capture the target before cancellation callbacks can advance native state. - const target = this.interruptionTarget(binding) + // A completed review can still be cancelled, but no longer owns host work. + const target = completed ? undefined : this.interruptionTarget(binding) const diagnostic = { taskId: action.taskId, requestId: action.requestId, @@ -422,15 +547,23 @@ export class AgentClients { boundTurnId: binding?.turnId, expectedTurnId: target?.turnId } - if (binding?.client.kind === 'claude') this.hooks.stop(binding, action.taskId) + const stopNotice = + !completed && binding?.client.kind === 'claude' && binding.client.sessionId + ? { sessionId: binding.client.sessionId, turnId: binding.turnId } + : undefined // Stop's local fence and host interruption must not wait for a pending queue write. void this.cancelFeedback(action.taskId).catch(() => {}) - this.tasks.stop(action.taskId, 'cancelled') + this.tasks.stop(action.taskId, 'cancelled', stopNotice) onAccepted?.() + if (completed) return result('delivered', 'Design task stopped.') if (this.lifecycle && target) { log.info(diagnostic, 'Requesting native Codex Stop interruption.') try { - const interrupted = await this.lifecycle.interrupt(target.conversationId, target.turnId) + const interrupted = await this.lifecycle.interrupt( + target.conversationId, + target.turnId, + target.context + ) log.info({ ...diagnostic, interrupted }, 'Native Codex Stop interruption settled.') return result( 'delivered', @@ -466,7 +599,7 @@ export class AgentClients { throw new Error('The feedback belongs to a different Figma file.') if (!nativeFeedback(binding.client) || !this.codex || !binding.client.sessionId) { const capabilities = this.unavailable(binding) - this.tasks.attachClient(record.ownerId, binding.client, capabilities) + this.tasks.attachClient(record.ownerId, binding.client, capabilities, binding.hostContext) throw new Error(capabilities.reason) } let controller = this.feedbackSignals.get(action.taskId) @@ -490,7 +623,12 @@ export class AgentClients { ) } await this.codex.enqueue(binding, action.taskId, feedback, signal, beforeSend, onDispatch) - this.tasks.attachClient(record.ownerId, binding.client, CODEX_APP_FEEDBACK) + this.tasks.attachClient( + record.ownerId, + binding.client, + CODEX_APP_FEEDBACK, + binding.hostContext + ) return result('delivered', 'Feedback sent to the bound agent conversation.') } catch (error) { return result( @@ -528,7 +666,9 @@ export class AgentClients { new Set( reviews.flatMap((record) => { const conversationId = nativeConversation(getVerifiedTaskClient(record)) - return conversationId ? [conversationId] : [] + return conversationId + ? [hostConversationKey(conversationId, this.binding(record.ownerId)?.hostContext)] + : [] }) ) ) @@ -537,8 +677,12 @@ export class AgentClients { const binding = this.bindings.get(ownerId) const { client, capabilities } = await this.describe(ownerId) if (this.bindings.get(ownerId) === binding) - this.tasks.attachClient(ownerId, client, capabilities) + this.tasks.attachClient(ownerId, client, capabilities, this.binding(ownerId)?.hostContext) } + this.codex.retainContexts([ + ...this.contexts.values(), + ...this.tasks.list().flatMap((record) => (record.hostContext ? [record.hostContext] : [])) + ]) } close(): void { @@ -553,11 +697,18 @@ export class AgentClients { } cancelFeedback(taskId: string): Promise { + this.activityAt = performance.now() this.abortFeedback(taskId) - return this.codex?.cancelQueued(taskId) ?? Promise.resolve() + const pending = this.codex?.cancelQueued(taskId) ?? Promise.resolve() + this.pendingRemovals.add(pending) + return pending.finally(() => { + this.pendingRemovals.delete(pending) + this.activityAt = performance.now() + }) } disconnect(connectionId: string): void { + this.contexts.delete(connectionId) this.connections.delete(connectionId) this.pendingClaudeBindings.delete(connectionId) this.bindingListeners.delete(connectionId) diff --git a/packages/mcp-server/src/agent-clients/types.ts b/packages/mcp-server/src/agent-clients/types.ts index ddf1041f..76ffc753 100644 --- a/packages/mcp-server/src/agent-clients/types.ts +++ b/packages/mcp-server/src/agent-clients/types.ts @@ -2,9 +2,12 @@ import type { AgentCapabilities, AgentClient } from '@tempad-dev/shared' import { AGENT_CLIENTS } from '@tempad-dev/shared' +import type { HostContext } from './host-context' + export type ClientBinding = { client: AgentClient turnId?: string + hostContext?: HostContext } /** Only clients with a verified native conversation channel can carry comments or interruption. */ diff --git a/packages/mcp-server/src/asset-http-server.ts b/packages/mcp-server/src/asset-http-server.ts index 32f5fc1e..807801ef 100644 --- a/packages/mcp-server/src/asset-http-server.ts +++ b/packages/mcp-server/src/asset-http-server.ts @@ -40,16 +40,21 @@ const ASSET_MAX_CONCURRENT_DOWNLOADS = 128 export interface AssetHttpServerOptions { accessToken?: string + legacyAccessToken?: string + port?: number maxAssetSizeBytes?: number maxAssetStoreBytes?: number maxConcurrentUploads?: number authorizeExtensionOrigin?: (origin: string) => boolean + admit?: () => boolean + onActivity?: () => void } export interface AssetHttpServer { start(): Promise stop(): void getBaseUrl(legacy?: boolean): string + readonly pendingTransfers: number } export function createAssetHttpServer( @@ -60,7 +65,7 @@ export function createAssetHttpServer( const accessToken = options.accessToken ?? createCapabilityToken() // Only unversioned connections receive this separate capability. Both endpoints // share origin checks, concurrency reservations, quotas, and the asset store. - const legacyAccessToken = createCapabilityToken() + const legacyAccessToken = options.legacyAccessToken ?? createCapabilityToken() const maxAssetSizeBytes = options.maxAssetSizeBytes ?? config.maxAssetSizeBytes const maxAssetStoreBytes = options.maxAssetStoreBytes ?? config.maxAssetStoreBytes const maxConcurrentUploads = options.maxConcurrentUploads ?? config.maxConcurrentAssetUploads @@ -95,7 +100,7 @@ export function createAssetHttpServer( } server.once('error', onError) server.once('listening', onListening) - server.listen(0, LOOPBACK_HOST) + server.listen(options.port ?? 0, LOOPBACK_HOST) }) log.info({ port }, 'Asset HTTP server ready.') } @@ -125,12 +130,29 @@ export function createAssetHttpServer( ) }) + try { + routeRequest(req, res) + } catch (error) { + log.error({ error }, 'Failed to handle asset request.') + if (res.headersSent) res.destroy() + else sendError(res, 500, 'Internal Server Error') + } + } + + function routeRequest(req: IncomingMessage, res: ServerResponse): void { if (!req.url) { sendError(res, 400, 'Missing URL') return } - const url = new URL(req.url, getBaseUrl()) + let url: URL + const baseUrl = getBaseUrl() + try { + url = new URL(req.url, baseUrl) + } catch { + sendError(res, 400, 'Invalid URL') + return + } const segments = url.pathname.split('/').filter(Boolean) const legacy = secretsEqual(segments[0] ?? '', legacyAccessToken) if ( @@ -168,6 +190,11 @@ export function createAssetHttpServer( return } + if (options.admit && !options.admit()) { + sendError(res, 503, 'Runtime recovery is in progress; request was not admitted.') + return + } + const filename = segments[2]! const hash = getHashFromAssetFilename(filename) if (!hash) { @@ -221,11 +248,13 @@ export function createAssetHttpServer( } activeDownloads += 1 + options.onActivity?.() let released = false const release = () => { if (released) return released = true activeDownloads -= 1 + options.onActivity?.() } res.once('finish', release) res.once('close', release) @@ -241,19 +270,14 @@ export function createAssetHttpServer( 'X-Content-Type-Options': 'nosniff' }) + // Expiry cleanup can run before the asynchronous file open completes. + store.touch(hash) const stream = createReadStream(record.filePath) - stream.on('error', (error) => { - log.warn({ error, hash }, 'Failed to stream asset file.') - if (!res.headersSent) { - sendError(res, 500, 'Internal Server Error') - } else { - res.end() + pipeline(stream, res, (error) => { + if (error && (error as NodeJS.ErrnoException).code !== 'ERR_STREAM_PREMATURE_CLOSE') { + log.warn({ error, hash }, 'Failed to stream asset file.') } }) - stream.on('open', () => { - store.touch(hash) - }) - stream.pipe(res) } function handleUpload(req: IncomingMessage, res: ServerResponse, hash: string): void { @@ -282,6 +306,14 @@ export function createAssetHttpServer( if (themeable) metadata.themeable = true const assetMetadata = Object.keys(metadata).length ? metadata : undefined + const reuseExisting = (record: AssetRecord) => { + if (assetMetadata) record.metadata = { ...record.metadata, ...assetMetadata } + if (record.mimeType !== mimeType) record.mimeType = mimeType + record.lastAccess = Date.now() + store.upsert(record) + sendJson(res, 200, { message: 'Asset Already Exists' }) + } + const existing = store.get(hash) // A short hash is not sufficient evidence of identical contents. Always read // and verify legacy uploads, including retries and concurrent collisions. @@ -296,17 +328,7 @@ export function createAssetHttpServer( // Previously returned localPath values stay valid across MIME metadata updates. // Drain request to ensure connection is clean req.resume() - - if (assetMetadata) { - existing.metadata = { - ...existing.metadata, - ...assetMetadata - } - } - if (existing.mimeType !== mimeType) existing.mimeType = mimeType - existing.lastAccess = Date.now() - store.upsert(existing) - sendJson(res, 200, { message: 'Asset Already Exists' }) + reuseExisting(existing) return } } @@ -333,6 +355,7 @@ export function createAssetHttpServer( const hasher = createHash('sha256') let size = 0 activeUploads += 1 + options.onActivity?.() reservedUploadBytes += reservationBytes const cleanup = () => { @@ -359,6 +382,7 @@ export function createAssetHttpServer( pipeline(req, monitor, writeStream, (err) => { activeUploads -= 1 + options.onActivity?.() reservedUploadBytes -= reservationBytes if (err) { cleanup() @@ -390,25 +414,29 @@ export function createAssetHttpServer( } try { - if (hash.length !== MCP_HASH_HEX_LENGTH) { - const previous = store.get(hash) - if (previous && existsSync(previous.filePath)) { - const previousHash = createHash('sha256') - .update(readFileSync(previous.filePath)) - .digest('hex') + const previous = store.get(hash) + if (previous && existsSync(previous.filePath)) { + // Another upload may have committed this hash while this body streamed. + if (hash.length === MCP_HASH_HEX_LENGTH) { cleanup() - if (previousHash !== computedHash) { - sendError(res, 409, 'Legacy Asset Hash Collision; update the TemPad Dev extension') - return - } - store.upsert({ - ...previous, - metadata: { ...previous.metadata, ...assetMetadata }, - lastAccess: Date.now() - }) - sendJson(res, 200, { message: 'Asset Already Exists' }) + reuseExisting(previous) + return + } + const previousHash = createHash('sha256') + .update(readFileSync(previous.filePath)) + .digest('hex') + cleanup() + if (previousHash !== computedHash) { + sendError(res, 409, 'Legacy Asset Hash Collision; update the TemPad Dev extension') return } + store.upsert({ + ...previous, + metadata: { ...previous.metadata, ...assetMetadata }, + lastAccess: Date.now() + }) + sendJson(res, 200, { message: 'Asset Already Exists' }) + return } renameSync(tmpPath, filePath) } catch (error) { @@ -467,7 +495,10 @@ export function createAssetHttpServer( return { start, stop, - getBaseUrl + getBaseUrl, + get pendingTransfers() { + return activeDownloads + activeUploads + } } } diff --git a/packages/mcp-server/src/asset-store.ts b/packages/mcp-server/src/asset-store.ts index 280fc4ca..74eb6248 100644 --- a/packages/mcp-server/src/asset-store.ts +++ b/packages/mcp-server/src/asset-store.ts @@ -142,16 +142,17 @@ export function createAssetStore(options: AssetStoreOptions = {}): AssetStore { function remove(hash: string, { removeFile = true } = {}): void { const record = records.get(hash) if (!record) return - records.delete(hash) - persist() if (removeFile) { try { rmSync(record.filePath, { force: true }) } catch (error) { log.warn({ hash, error }, 'Failed to remove asset file on delete.') + return } } + records.delete(hash) + persist() } function reconcile(): void { diff --git a/packages/mcp-server/src/asset-utils.ts b/packages/mcp-server/src/asset-utils.ts index cd27401b..be9481f5 100644 --- a/packages/mcp-server/src/asset-utils.ts +++ b/packages/mcp-server/src/asset-utils.ts @@ -1,7 +1,7 @@ import { MCP_HASH_HEX_LENGTH, MCP_LEGACY_HASH_HEX_LENGTH } from '@tempad-dev/shared' const HASH_FILENAME_PATTERN = new RegExp( - `^([a-f0-9]{${MCP_HASH_HEX_LENGTH}}|[a-f0-9]{${MCP_LEGACY_HASH_HEX_LENGTH}})(?:\\.[a-z0-9]+)?$` + `^([a-f0-9]{${MCP_HASH_HEX_LENGTH}}|[a-f0-9]{${MCP_LEGACY_HASH_HEX_LENGTH}})(?:\\.[a-z0-9-]+)?$` ) const SAFE_IMAGE_EXTENSION_PATTERN = /^[a-z0-9-]+$/ diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index b0607556..dae84c6e 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -5,16 +5,19 @@ import type { Socket } from 'node:net' import { spawn } from 'node:child_process' import { connect } from 'node:net' -import { join } from 'node:path' +import { join, resolve } from 'node:path' +import { finished } from 'node:stream/promises' import { fileURLToPath } from 'node:url' import lockfile from 'proper-lockfile' +import { captureHostContext, HostContextNotificationSchema } from './agent-clients/host-context' import { runtimeIdentity } from './agent-clients/identity' import { ClientBindingFilter, ClientBindingSubscribeSchema, type ClaudeBinding } from './client-binding' +import { hubEnvironment } from './process-environment' import { HUB_BUSY_EXIT_CODE, HUB_LOCK_STALE_MS, @@ -23,8 +26,10 @@ import { LOCK_PATH, RUNTIME_DIR, SOCK_PATH, + endLogging, ensureDir, - assertTrustedSocket + assertTrustedSocket, + resolveStateDir } from './shared' let activeSocket: Socket | null = null @@ -55,15 +60,12 @@ function shutdownCli(reason: string) { process.exit(0) } -process.on('SIGINT', () => shutdownCli('SIGINT received.')) -process.on('SIGTERM', () => shutdownCli('SIGTERM received.')) -process.stdin.once('end', () => shutdownCli('Consumer stream ended.')) - const HUB_STARTUP_TIMEOUT = 5000 const CONNECT_RETRY_DELAY = 200 const FAILED_RESTART_DELAY = 5000 const HERE = fileURLToPath(new URL('.', import.meta.url)) const HUB_ENTRY = join(HERE, 'hub.mjs') +const hostContext = captureHostContext(process.env) ensureDir(RUNTIME_DIR) @@ -87,21 +89,38 @@ function bridge(socket: Socket): Promise { socket.destroy() }) - const onSocketClose = () => { + const onSocketClose = async () => { log.warn('Connection to Hub lost. Attempting to reconnect...') activeSocket = null process.stdin.unpipe(socket) socket.unpipe(output) + socket.removeAllListeners() + // Complete frames can still be buffered behind a slow consumer. Drain + // them before reconnecting so responses are neither lost nor reordered. + output.end() + try { + await finished(output, { cleanup: true }) + } catch (error) { + outputError ??= error instanceof Error ? error : new Error(String(error)) + } output.unpipe(process.stdout) output.destroy() - socket.removeAllListeners() if (outputError) reject(outputError) else resolve(receivedOutput) } - socket.once('close', onSocketClose) + socket.once('close', () => { + void onSocketClose().catch(reject) + }) socket.on('error', (err) => log.warn({ err }, 'Socket error occurred.')) const binding = claudeBinding ?? runtimeIdentity(process.env).client + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + method: HostContextNotificationSchema.shape.method.value, + params: hostContext + }) + '\n' + ) socket.write( JSON.stringify({ jsonrpc: '2.0', @@ -206,7 +225,8 @@ function startHub(): ChildProcess { log.info('Spawning new Hub process...') return spawn(process.execPath, [HUB_ENTRY], { detached: true, - stdio: 'ignore' + stdio: 'ignore', + env: hubEnvironment(process.env) }) } @@ -262,12 +282,69 @@ async function tryBecomeLeaderAndStartHub(): Promise { } async function main() { + try { + const { maybeManagedRuntime } = await import('./runtime-update/bootstrap') + if (await maybeManagedRuntime(process.argv.slice(2), join(HERE, '..'))) return + } catch (error) { + process.stderr.write(`TemPad runtime startup failed: ${String(error)}\n`) + process.exitCode = 1 + process.stdin.destroy() + endLogging() + return + } + process.on('SIGINT', () => shutdownCli('SIGINT received.')) + process.on('SIGTERM', () => shutdownCli('SIGTERM received.')) + process.stdin.once('end', () => shutdownCli('Consumer stream ended.')) + if ( + process.argv.includes('--runtime-status') || + process.argv.includes('--resolve-runtime-block') + ) { + try { + const { runRuntimeRecovery } = await import('./runtime-recovery') + await runRuntimeRecovery(process.argv.slice(2), () => + connectHub().catch((error: unknown) => { + if (!isHubUnavailable(error)) throw error + return tryBecomeLeaderAndStartHub() + }) + ) + } catch (error) { + process.stderr.write( + `Runtime recovery failed: ${error instanceof Error ? error.message : String(error)}\n` + ) + process.exitCode = 1 + } + process.stdin.destroy() + endLogging() + return + } + if (process.argv.includes('--recover-task-storage')) { + try { + const directory = resolve(resolveStateDir()) + process.stdout.write(`Task storage: ${join(directory, 'design-tasks.json')}\n`) + if (process.argv.length !== 3) + throw new Error('Use --recover-task-storage without other arguments.') + const { recoverDesignTaskStorage } = await import('./task-storage-recovery') + const result = await recoverDesignTaskStorage(directory) + process.stdout.write( + `Task storage recovered. Old tasks remain retired; begin a fresh task to write.\nOriginal files: ${result.backupDirectory}\n` + ) + } catch (error) { + process.stderr.write( + `Task storage recovery failed: ${error instanceof Error ? error.message : String(error)}\n` + ) + process.exitCode = 1 + } + process.stdin.destroy() + endLogging() + return + } log.info({ version: PACKAGE_VERSION }, 'TemPad MCP Client starting...') // A killed Hub can leave valid lock leases behind. Allow their full stale // interval and a further startup attempt without weakening lock ownership. const startupDeadline = Date.now() + HUB_LOCK_STALE_MS + 2 * HUB_STARTUP_TIMEOUT let connected = false let failedAttempts = 0 + let waitingReported = false while (true) { try { @@ -293,10 +370,15 @@ async function main() { process.stderr.write( `Unable to connect to TemPad Hub after ${failedAttempts} ${connected ? 'reconnect' : 'startup'} attempts: ${reason} Reconnect the MCP client after resolving the connection error.\n` ) - // No future bridge will consume stdin. Terminate even when a Node 22 - // stream or logging worker keeps the event loop alive after this failure. + // No future bridge will consume stdin; release its read handle before exiting. process.stdin.destroy() - process.exit(1) + process.exitCode = 1 + endLogging() + return + } + if (!connected && transient && !waitingReported) { + process.stderr.write('Waiting for TemPad Hub to become available. Retrying…\n') + waitingReported = true } log.error( { err }, diff --git a/packages/mcp-server/src/config.ts b/packages/mcp-server/src/config.ts index 9761c90b..d659bb62 100644 --- a/packages/mcp-server/src/config.ts +++ b/packages/mcp-server/src/config.ts @@ -7,7 +7,8 @@ import { MCP_MAX_CONCURRENT_ASSET_UPLOADS, MCP_MAX_EXTENSION_CONNECTIONS, MCP_MAX_PAYLOAD_BYTES, - MCP_PORT_CANDIDATES, + runtimePortCandidates, + type RuntimeProfile, MCP_GET_CODE_TIMEOUT_MS, MCP_GET_DESIGN_SYSTEM_TIMEOUT_MS, MCP_TOOL_TIMEOUT_MS @@ -61,9 +62,9 @@ function resolveMaxExtensionConnections(): number { ) } -export function getMcpServerConfig() { +export function getMcpServerConfig(profile?: RuntimeProfile) { return { - wsPortCandidates: [...MCP_PORT_CANDIDATES], + wsPortCandidates: [...runtimePortCandidates(profile)], toolTimeoutMs: resolveToolTimeoutMs(), getCodeTimeoutMs: resolveToolTimeoutMs( process.env.TEMPAD_MCP_GET_CODE_TIMEOUT, diff --git a/packages/mcp-server/src/consumer-transport.ts b/packages/mcp-server/src/consumer-transport.ts index ca43ae55..16ff4301 100644 --- a/packages/mcp-server/src/consumer-transport.ts +++ b/packages/mcp-server/src/consumer-transport.ts @@ -14,6 +14,22 @@ function decode(id: RequestId): RequestId { return typeof id === 'string' && id.startsWith(STRING_ID) ? id.slice(STRING_ID.length) : id } +export function encodeConsumerMessage(message: JSONRPCMessage): JSONRPCMessage { + if ('method' in message && 'id' in message) return { ...message, id: encode(message.id) } + if ('method' in message && message.method === 'notifications/cancelled') { + const id = message.params?.requestId + if (typeof id === 'string' || typeof id === 'number') + return { ...message, params: { ...message.params, requestId: encode(id) } } + } + return message +} + +export function decodeConsumerMessage(message: JSONRPCMessage): JSONRPCMessage { + return !('method' in message) && message.id !== undefined + ? { ...message, id: decode(message.id) } + : message +} + /** SDK 1.32 ignores cancellation of falsy request IDs. Keep wire IDs unchanged * while giving its existing cancellation machinery nonempty, collision-free IDs. */ export class ConsumerTransport extends StdioServerTransport { @@ -24,13 +40,7 @@ export class ConsumerTransport extends StdioServerTransport { this.started = true const receive = this.onmessage this.onmessage = (message) => { - if ('method' in message && 'id' in message) message = { ...message, id: encode(message.id) } - else if ('method' in message && message.method === 'notifications/cancelled') { - const id = message.params?.requestId - if (typeof id === 'string' || typeof id === 'number') - message = { ...message, params: { ...message.params, requestId: encode(id) } } - } - receive?.(message) + receive?.(encodeConsumerMessage(message)) } await super.start() } @@ -38,8 +48,6 @@ export class ConsumerTransport extends StdioServerTransport { override send(message: JSONRPCMessage): Promise { // Server-initiated requests and their incoming responses use the server's // independent ID space. Only replies to client requests need decoding. - if (!('method' in message) && message.id !== undefined) - message = { ...message, id: decode(message.id) } - return super.send(message) + return super.send(decodeConsumerMessage(message)) } } diff --git a/packages/mcp-server/src/design-task-store.ts b/packages/mcp-server/src/design-task-store.ts index 45965a31..2d20b99a 100644 --- a/packages/mcp-server/src/design-task-store.ts +++ b/packages/mcp-server/src/design-task-store.ts @@ -1,16 +1,31 @@ -import { DesignTaskSchema } from '@tempad-dev/shared' -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { DesignTaskSchema, stringRecord } from '@tempad-dev/shared' +import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs' import { dirname } from 'node:path' import { z } from 'zod' +import { HostContextSchema } from './agent-clients/host-context' +import { writeDurableJson } from './durable-json' +import { ExecutionMarkerSchema, type Execution } from './execution-state' +import { HookReceiptSchema } from './hook-receipts' + +const ClaudeStopNoticeSchema = z.object({ + sessionId: z.string().min(1), + turnId: z.string().optional() +}) +export type ClaudeStopNotice = z.infer + const SnapshotSchema = z.object({ revision: z.number().int().nonnegative(), - current: z.record(z.string(), z.string()), - fileFences: z.record(z.string(), z.boolean()).optional(), + hookReceipts: z.array(HookReceiptSchema).optional(), + hookReceiptCutoff: z.number().optional(), + current: stringRecord(z.string(), z.string()), + fileFences: stringRecord(z.string(), z.boolean()).optional(), records: z.array( z.object({ task: DesignTaskSchema.strip(), requestId: z.string(), + hostContext: HostContextSchema.optional(), + claudeStopNotice: ClaudeStopNoticeSchema.optional(), retiredAt: z.number().nonnegative().optional(), browser: z .object({ @@ -24,19 +39,32 @@ const SnapshotSchema = z.object({ }) ) }) -export type DesignTaskSnapshot = z.infer +const RecoveryMarkerSchema = z.object({ recoveryGeneration: z.string().uuid() }).strict() +const FileReadMarkerSchema = z + .object({ + readRequired: z.literal(true), + task: z.object({ target: z.object({ fileKey: z.string().min(1) }).strict() }).strict() + }) + .strict() +export type DesignTaskSnapshot = z.infer & { + recoveryGeneration?: string + filesNeedingRead?: string[] + executions?: Execution[] +} /** One lock-owning Hub writes a small atomic snapshot; transport and leases are never restored. */ export class DesignTaskStore { recoveryError?: string blockedFiles?: Set private incompatibleRecords: unknown[] = [] + private readonly incompatibleCurrent = new Map() constructor(private readonly path: string) {} load(): DesignTaskSnapshot | undefined { this.recoveryError = undefined this.blockedFiles = new Set() this.incompatibleRecords = [] + this.incompatibleCurrent.clear() try { if (!existsSync(this.path) && existsSync(`${this.path}.corrupt`)) throw new Error('A quarantined task snapshot requires recovery.') @@ -44,21 +72,61 @@ export class DesignTaskStore { JSON.parse(readFileSync(this.path, 'utf8')) ) const records: DesignTaskSnapshot['records'] = [] + const filesNeedingRead = new Set() + const executions: Execution[] = [] + let recoveryGeneration: string | undefined for (const item of raw.records) { + const execution = ExecutionMarkerSchema.safeParse(item) + if (execution.success) { + if (executions.some((value) => value.id === execution.data.execution.id)) + throw new Error('Duplicate execution identity in task storage.') + executions.push(execution.data.execution) + continue + } + const readMarker = FileReadMarkerSchema.safeParse(item) + if (readMarker.success) { + filesNeedingRead.add(readMarker.data.task.target.fileKey) + continue + } + const marker = RecoveryMarkerSchema.safeParse(item) + if (marker.success) { + if (recoveryGeneration) throw new Error('Multiple task recovery generations.') + recoveryGeneration = marker.data.recoveryGeneration + continue + } + if (item && typeof item === 'object' && 'recoveryGeneration' in item) + throw new Error('Invalid task recovery generation.') const parsed = SnapshotSchema.shape.records.element.safeParse(item) if (parsed.success) { records.push(parsed.data) continue } const target = z - .object({ task: z.object({ target: z.object({ fileKey: z.string().min(1) }) }) }) + .object({ + task: z.object({ + taskId: z.unknown().optional(), + target: z.object({ fileKey: z.string().min(1) }) + }) + }) .safeParse(item) if (!target.success) throw new Error('An incompatible task has no identifiable file fence.') - this.blockedFiles.add(target.data.task.target.fileKey) + const { + taskId, + target: { fileKey } + } = target.data.task + this.blockedFiles.add(fileKey) + if (typeof taskId === 'string' && raw.current[fileKey] === taskId) + this.incompatibleCurrent.set(fileKey, taskId) // Preserve incompatible records verbatim so later writes and restarts keep their fences. this.incompatibleRecords.push(item) } - return { ...raw, records } + return { + ...raw, + records, + ...(executions.length ? { executions } : {}), + ...(recoveryGeneration ? { recoveryGeneration } : {}), + ...(filesNeedingRead.size ? { filesNeedingRead: [...filesNeedingRead] } : {}) + } } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined // Keep a durable marker: restarting must never erase unknown Stop fences. @@ -69,7 +137,7 @@ export class DesignTaskStore { /* Keep the original evidence. */ } } - this.recoveryError = `Design task storage requires recovery at ${this.path}. Reads remain available; canvas writes are blocked to preserve saved Stop fences. Restore a verified complete snapshot including the latest Stop fences, then restart the Hub. Keep ${this.path}.corrupt; see Task storage recovery in the MCP README.` + this.recoveryError = `Design task storage requires recovery at ${this.path}. Reads remain available; canvas writes are blocked to preserve saved Stop fences. Restore a verified complete snapshot including the latest Stop fences, or disconnect all MCP clients and run npx -y @tempad-dev/mcp@latest --recover-task-storage to retire all old tasks. Keep ${this.path}.corrupt; see Task storage recovery in the MCP README.` return undefined } } @@ -77,12 +145,26 @@ export class DesignTaskStore { save(snapshot: DesignTaskSnapshot): void { if (this.recoveryError) throw new Error(this.recoveryError) mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }) - const temporary = `${this.path}.tmp` - writeFileSync( - temporary, - JSON.stringify({ ...snapshot, records: [...snapshot.records, ...this.incompatibleRecords] }), - { mode: 0o600, flush: true } - ) - renameSync(temporary, this.path) + const { recoveryGeneration, filesNeedingRead, executions, ...stored } = snapshot + writeDurableJson(this.path, { + ...stored, + current: { ...snapshot.current, ...Object.fromEntries(this.incompatibleCurrent) }, + // Old readers must fail closed rather than ignore a new top-level generation field. + records: [ + ...(executions ?? []).map((execution) => + ExecutionMarkerSchema.parse({ + execution, + task: { target: { fileKey: execution.session.fileKey } } + }) + ), + ...(recoveryGeneration ? [RecoveryMarkerSchema.parse({ recoveryGeneration })] : []), + // Old readers retain these as incompatible per-file fences. + ...(filesNeedingRead ?? []).map((fileKey) => + FileReadMarkerSchema.parse({ readRequired: true, task: { target: { fileKey } } }) + ), + ...snapshot.records, + ...this.incompatibleRecords + ] + }) } } diff --git a/packages/mcp-server/src/design-tasks.ts b/packages/mcp-server/src/design-tasks.ts index 5d253692..d03e9a97 100644 --- a/packages/mcp-server/src/design-tasks.ts +++ b/packages/mcp-server/src/design-tasks.ts @@ -1,19 +1,39 @@ import type { AgentClient, AgentCapabilities, + ClientHook, DesignTask, DesignTaskTarget, FigmaSession, TempadMcpErrorCode } from '@tempad-dev/shared' -import { MCP_DESIGN_TASK_LEASE_MS, RESUMABLE_DESIGN_TASK_STATUSES } from '@tempad-dev/shared' +import { + ClientHookParamsSchema, + MCP_DESIGN_TASK_LEASE_MS, + RESUMABLE_DESIGN_TASK_STATUSES +} from '@tempad-dev/shared' import { isDeepStrictEqual } from 'node:util' -import type { DesignTaskSnapshot, DesignTaskStore } from './design-task-store' +import type { HostContext } from './agent-clients/host-context' +import type { ClaudeStopNotice, DesignTaskSnapshot, DesignTaskStore } from './design-task-store' +import type { Execution } from './execution-state' import type { ExtensionConnection } from './types' +import { codexStoreKey } from './agent-clients/host-context' +import { nativeFeedback } from './agent-clients/types' import { HISTORY_RETENTION_MS } from './history-retention' +import { + EXPIRED_HOOK_RESULT, + ExpiredHookError, + HOOK_RECEIPT_LIMIT, + LegacyHookCapacityError, + hookCutoff, + hookPressureCutoff, + hookRetentionCutoff, + hookSignature, + type HookReceipt +} from './hook-receipts' import { extensionUpgradeRequired } from './legacy-extension' type TerminalStatus = Exclude @@ -32,6 +52,8 @@ export type DesignTaskRecord = { resultNodeIds?: string[] browser?: BrowserIdentity & { tabId: number; documentId: string } retiredAt?: number + hostContext?: HostContext + claudeStopNotice?: ClaudeStopNotice } /** Only a Hub begin request establishes a client binding; browser migration leaves it empty. */ @@ -119,6 +141,12 @@ export class DesignTasks { private readonly records = new Map() private readonly currentTaskIds = new Map() private readonly operations = new Map() + private readonly filesNeedingRead = new Map() + private executions: Execution[] = [] + private readonly hookReceipts = new Map() + private hookReceiptCutoff = 0 + private applyingHook = false + private readGeneration = 0 // Every retired file rejects stale unknown reviews; true also retains its Stop write fence. private readonly fileFences = new Map() private readonly expiredRequests = new Map>() @@ -126,6 +154,7 @@ export class DesignTasks { private readonly now: () => number private readonly leaseMs: number private revision = 0 + private recoveryGeneration?: string private persistenceError?: unknown constructor(private readonly options: Options) { @@ -133,7 +162,18 @@ export class DesignTasks { this.leaseMs = options.leaseMs ?? MCP_DESIGN_TASK_LEASE_MS } - retry(ownerId: string, requestId: string): DesignTaskRecord | undefined { + retry( + ownerId: string, + requestId: string, + recoveryGeneration?: string + ): DesignTaskRecord | undefined { + if (this.recoveryGeneration && recoveryGeneration !== this.recoveryGeneration) + throw Object.assign( + new Error( + `Task storage was recovered; old tasks cannot resume. Explicitly begin a fresh task with recoveryGeneration "${this.recoveryGeneration}" and a fresh requestId, then reread its target before writing.` + ), + { code: 'DESIGN_TASK_INACTIVE', recoveryGeneration: this.recoveryGeneration } + ) this.sweep() if (this.expiredRequests.get(ownerId)?.has(requestId)) fail( @@ -153,10 +193,12 @@ export class DesignTasks { requestId: string, client?: AgentClient, capabilities?: AgentCapabilities, - browser?: BrowserIdentity + browser?: BrowserIdentity, + recoveryGeneration?: string, + hostContext?: HostContext ): DesignTaskRecord { this.assertStorageWritable(session.fileKey) - const retry = this.retry(ownerId, requestId) + const retry = this.retry(ownerId, requestId, recoveryGeneration) if (retry) { if (retry.task.title !== title) { fail('REQUEST_ID_CONFLICT', 'This begin requestId was already used with another title.') @@ -172,6 +214,7 @@ export class DesignTasks { extensionId, requestId, ready: false, + ...(hostContext ? { hostContext } : {}), ...(browser && tabId !== undefined && documentId !== undefined ? { browser: { ...browser, tabId, documentId } } : {}), @@ -181,6 +224,7 @@ export class DesignTasks { target, status: 'active', epoch: 0, + ...(this.recoveryGeneration ? { needsRead: true } : {}), ...(client ? { client } : {}), ...(capabilities ? { capabilities } : {}), operation: null, @@ -242,26 +286,41 @@ export class DesignTasks { } /** Runtime identity may reconnect the same conversation, never a focused/last caller. */ - attachClient(ownerId: string, client: AgentClient, capabilities?: AgentCapabilities): void { - if (!client.sessionId || ['other', 'unknown'].includes(client.kind)) return + attachClient( + ownerId: string, + client: AgentClient, + capabilities?: AgentCapabilities, + hostContext?: HostContext + ): string[] { + const mismatched: string[] = [] + if (!client.sessionId || ['other', 'unknown'].includes(client.kind)) return mismatched for (const record of this.records.values()) { const verified = getVerifiedTaskClient(record) if (verified?.kind !== client.kind || verified.sessionId !== client.sessionId) continue + // Cancelled history remains readable; only an in-flight lease keeps its transport owner. + if (RUNNING_STATUSES.includes(record.task.status) && record.ownerId !== ownerId) continue if ( - !RESUMABLE_DESIGN_TASK_STATUSES.includes(record.task.status) && - record.ownerId !== ownerId - ) + nativeFeedback(client) && + record.hostContext && + codexStoreKey(record.hostContext) !== codexStoreKey(hostContext) + ) { + mismatched.push(record.task.taskId) continue + } const ownerChanged = record.ownerId !== ownerId const changed = - ownerChanged || (capabilities && !isDeepStrictEqual(record.task.capabilities, capabilities)) + ownerChanged || + (hostContext && !isDeepStrictEqual(record.hostContext, hostContext)) || + (capabilities && !isDeepStrictEqual(record.task.capabilities, capabilities)) record.ownerId = ownerId if (ownerChanged && !this.expiredRequests.has(ownerId)) this.expiredRequests.set(ownerId, new Set()) record.task.client = client + if (hostContext) record.hostContext = hostContext if (capabilities) record.task.capabilities = capabilities if (changed) this.publish(record) } + return mismatched } assertEpoch(taskId: string, ownerId: string, epoch?: number): DesignTaskRecord { @@ -323,6 +382,256 @@ export class DesignTasks { this.publish(record) } + requireFileRead(fileKey: string, requireDurable = false): number { + const previous = this.filesNeedingRead.get(fileKey) + const generation = ++this.readGeneration + this.filesNeedingRead.set(fileKey, generation) + this.persist() + if (requireDurable && this.persistenceError) { + if (previous === undefined) this.filesNeedingRead.delete(fileKey) + else this.filesNeedingRead.set(fileKey, previous) + fail( + 'DESIGN_TASK_INACTIVE', + 'Design task state could not be saved. No canvas write was dispatched. Restore storage before retrying.' + ) + } + return generation + } + + acknowledgeFileRead(fileKey: string, expectedGeneration?: number): void { + if ( + this.executions.some( + (value) => value.session.fileKey === fileKey && value.abandonedAt === undefined + ) + ) + return + const generation = this.filesNeedingRead.get(fileKey) + if ( + generation === undefined || + (expectedGeneration !== undefined && expectedGeneration !== generation) + ) + return + this.filesNeedingRead.delete(fileKey) + this.persist() + if (this.persistenceError) this.filesNeedingRead.set(fileKey, generation) + } + + needsFileRead(fileKey: string): boolean { + return this.filesNeedingRead.has(fileKey) + } + + executionState(): Execution[] { + return structuredClone(this.executions) + } + + saveExecutions(executions: Execution[]): void { + const previous = this.executions + const previousReads = new Map(this.filesNeedingRead) + const previousById = new Map(previous.map((execution) => [execution.id, execution])) + this.executions = executions + for (const execution of executions) { + const prior = previousById.get(execution.id) + // Exclusion and its eventual read fence are one atomic snapshot. + if ( + prior?.revision !== execution.revision && + !this.filesNeedingRead.has(execution.session.fileKey) + ) + this.filesNeedingRead.set(execution.session.fileKey, ++this.readGeneration) + if ( + execution.abandonedAt === undefined || + !execution.taskId || + prior?.abandonedAt !== undefined + ) + continue + const record = this.records.get(execution.taskId) + if (record && RUNNING_STATUSES.includes(record.task.status)) { + record.task.status = record.ending === 'cancelled' ? 'cancelled' : 'paused' + record.task.epoch = (record.task.epoch ?? 0) + 1 + record.task.needsRead = true + record.ready = false + record.ending = undefined + } + } + this.persist() + if (this.persistenceError) { + this.executions = previous + this.filesNeedingRead.clear() + for (const [file, generation] of previousReads) this.filesNeedingRead.set(file, generation) + fail( + 'DESIGN_TASK_INACTIVE', + 'Design task state could not be saved. No canvas write was dispatched or execution block released. Restore storage before retrying.' + ) + } + } + + isOperationPending(requestId: string): boolean { + return this.operations.has(requestId) + } + + get pendingOperationCount(): number { + return this.operations.size + } + + pendingOperations() { + return [...this.operations].map(([id, operation]) => ({ + id, + extensionId: operation.extensionId, + write: operation.write + })) + } + + flush(): void { + this.persist() + if (this.options.store?.recoveryError || this.persistenceError) + throw new Error('Task state cannot be flushed; runtime switch is blocked.') + } + + applyHook( + invocationId: string, + input: unknown, + apply: () => Record, + retireExpired?: () => void + ): Record { + this.compactHookReceipts(invocationId) + const signature = hookSignature(input) + const existing = this.hookReceipts.get(invocationId) + if (existing && existing.signature !== signature) + throw new Error('Lifecycle invocation ID was reused with different content.') + const parsed = ClientHookParamsSchema.safeParse(input) + let expired = false + if (parsed.success) { + try { + this.hookReceiptCutoff = hookCutoff(parsed.data, this.hookReceiptCutoff, this.now()) + } catch (error) { + if (!(error instanceof ExpiredHookError) || !retireExpired) throw error + this.hookReceiptCutoff = error.cutoff + expired = true + } + } + if (existing && (!expired || existing.retired)) { + this.flush() + if (existing.error && !existing.retired) + throw new Error(`Lifecycle effect remains uncertain: ${existing.error}`) + return existing.result + } + if (!existing && !expired && parsed.success && parsed.data.version === 2) { + this.compactHookReceipts(invocationId, true) + hookCutoff(parsed.data, this.hookReceiptCutoff, this.now()) + } + if ( + !existing && + parsed.success && + parsed.data.version === 1 && + [...this.hookReceipts.values()].filter((receipt) => receipt.issuedAt === undefined).length >= + HOOK_RECEIPT_LIMIT + ) + throw new LegacyHookCapacityError() + if ( + !existing && + !expired && + parsed.success && + parsed.data.version === 2 && + [...this.hookReceipts.values()].filter((receipt) => receipt.issuedAt !== undefined).length >= + HOOK_RECEIPT_LIMIT + ) + throw new Error('Hook result history is waiting for uncertain lifecycle effects.') + if ( + !existing && + !expired && + [...this.hookReceipts.values()].filter((receipt) => receipt.error && !receipt.retired) + .length >= HOOK_RECEIPT_LIMIT + ) + throw new Error( + 'Runtime hook receipt capacity reached. Preserve the runtime state for diagnosis.' + ) + if (this.applyingHook) throw new Error('A lifecycle transaction is already in progress.') + const receipt: HookReceipt = existing ?? { + invocationId, + signature, + result: {}, + recordedAt: this.now() + } + if (parsed.success && parsed.data.version === 2) receipt.issuedAt = parsed.data.issuedAt + this.applyingHook = true + try { + if (expired) { + // Retire uncertain lifecycle intent only with its session/task fences in + // the same snapshot. Never replay it or report that it was applied. + retireExpired!() + receipt.retired = 'expired' + receipt.result = EXPIRED_HOOK_RESULT + } else receipt.result = apply() + return receipt.result + } catch (error) { + receipt.error = String(error).slice(0, 1000) + throw error + } finally { + // A newly observed expired invocation is already fenced by the durable cutoff. + // Its idempotent retirement needs no new receipt slot, even at full capacity. + if (existing || !expired) this.hookReceipts.set(invocationId, receipt) + this.applyingHook = false + this.flush() + } + } + + private compactHookReceipts(preserveId?: string, reserveResult = false): void { + this.hookReceiptCutoff = hookRetentionCutoff(this.hookReceiptCutoff, this.now()) + const legacyCutoff = hookRetentionCutoff(0, this.now()) + if (reserveResult) { + const completed: number[] = [] + const unresolved: number[] = [] + for (const receipt of this.hookReceipts.values()) + if (receipt.issuedAt !== undefined) + (receipt.error && !receipt.retired ? unresolved : completed).push(receipt.issuedAt) + this.hookReceiptCutoff = hookPressureCutoff(this.hookReceiptCutoff, completed, unresolved) + } + for (const [id, receipt] of this.hookReceipts) { + if (receipt.error && !receipt.retired) continue + if (receipt.issuedAt !== undefined) { + if (id !== preserveId && receipt.issuedAt <= this.hookReceiptCutoff) + this.hookReceipts.delete(id) + } else if (!receipt.retired && receipt.recordedAt <= legacyCutoff) { + receipt.retired = 'expired' + receipt.result = EXPIRED_HOOK_RESULT + } + } + } + + /** Called only by a fresh lifecycle hook; never restore a session's liveness from disk. */ + takeClaudeStopNotice(input: ClientHook): { taskId?: string; staleTurn?: boolean } { + if (input.kind !== 'claude') return {} + let taskId: string | undefined + let changed = false + let staleTurn = false + for (const record of this.records.values()) { + const notice = record.claudeStopNotice + if (!notice || notice.sessionId !== input.sessionId) continue + if ( + input.event !== 'UserPromptSubmit' && + notice.turnId && + input.turnId && + notice.turnId !== input.turnId + ) { + staleTurn = true + continue + } + delete record.claudeStopNotice + changed = true + const client = getVerifiedTaskClient(record) + if ( + (input.event === 'PreToolUse' || input.event === 'PostToolUse') && + client?.kind === 'claude' && + client.sessionId === input.sessionId && + (record.task.status === 'cancelled' || + (record.task.status === 'stopping' && record.ending === 'cancelled')) + ) + taskId = record.task.taskId + } + // applyHook includes this retirement and its response receipt in the same durable save. + if (changed) this.persist() + return { ...(taskId ? { taskId } : {}), ...(!changed && staleTurn ? { staleTurn: true } : {}) } + } + find(taskId: string): DesignTaskRecord | undefined { this.sweep() return this.records.get(taskId) @@ -356,7 +665,17 @@ export class DesignTasks { return [...this.operations.values()].some((operation) => operation.target.fileKey === fileKey) } - private assertFileIdle(session: FigmaSession): void { + private assertFileIdle(session: FigmaSession, checkExecution = true): void { + const execution = + checkExecution && + this.executions.find( + (value) => value.session.fileKey === session.fileKey && value.abandonedAt === undefined + ) + if (execution) + fail( + 'DESIGN_TASK_BUSY', + `An earlier operation may still be executing in this file. Reconnect the original Figma tab, or use --runtime-status and interactive --resolve-runtime-block ${execution.id} after stopping that execution environment. Restarting the Hub or reading the file does not release this block.` + ) if (session.busy || this.hasPendingOperation(session.fileKey)) { fail( 'DESIGN_TASK_BUSY', @@ -368,15 +687,18 @@ export class DesignTasks { checkOperation(ownerId: string, session: FigmaSession, taskId?: string, write = false): void { this.sweep() if (write) this.assertStorageWritable(session.fileKey) - this.assertFileIdle(session) + this.assertFileIdle(session, write) const record = taskId ? this.owned(taskId, ownerId, true) : undefined if (record && (!record.ready || record.task.target.sessionId !== session.sessionId)) { fail('DESIGN_TARGET_CHANGED', 'The task is not bound to this Figma session.') } if (write && record?.task.needsRead) { + fail('DESIGN_TASK_INACTIVE', 'The resumed task has not confirmed its bound canvas state.') + } + if (write && this.filesNeedingRead.has(session.fileKey)) { fail( 'DESIGN_TASK_INACTIVE', - 'Read the bound canvas with get_structure or get_code after resuming before writing.' + 'The previous write did not confirm its final canvas state. Do not replay the write.' ) } const occupant = this.occupant(session.fileKey) @@ -384,12 +706,18 @@ export class DesignTasks { if ( write && !taskId && - (this.fileFences.get(session.fileKey) || + (this.recoveryGeneration || + this.fileFences.get(session.fileKey) || [...this.records.values()].some( ({ task }) => task.target.fileKey === session.fileKey && task.status === 'cancelled' )) ) - fail('DESIGN_TASK_INACTIVE', 'This file has a stopped task. Begin a new task before writing.') + fail( + 'DESIGN_TASK_INACTIVE', + this.recoveryGeneration + ? 'Task storage was recovered. Begin a new task before writing.' + : 'This file has a stopped task. Begin a new task before writing.' + ) } startOperation( @@ -542,9 +870,26 @@ export class DesignTasks { this.persist() } - stop(taskId: string, outcome: TerminalStatus): DesignTask | undefined { + stop( + taskId: string, + outcome: TerminalStatus, + claudeStopNotice?: ClaudeStopNotice + ): DesignTask | undefined { const record = this.records.get(taskId) - if (!record || ENDED_STATUSES.includes(record.task.status)) return record?.task + if (!record || record.task.status === 'cancelled' || record.task.status === outcome) + return record?.task + if ( + outcome === 'cancelled' && + claudeStopNotice && + getVerifiedTaskClient(record)?.kind === 'claude' && + record.task.client?.sessionId === claudeStopNotice.sessionId + ) { + for (const previous of this.records.values()) + if (previous.claudeStopNotice?.sessionId === claudeStopNotice.sessionId) + delete previous.claudeStopNotice + // The next publish saves the intent together with cancelled/stopping state. + record.claudeStopNotice = claudeStopNotice + } if (!RUNNING_STATUSES.includes(record.task.status)) { if (outcome === 'completed' || outcome === 'cancelled') this.finish(record, outcome) return record.task @@ -678,6 +1023,14 @@ export class DesignTasks { const snapshot = this.options.store?.load() if (!snapshot) return this.revision = snapshot.revision + this.recoveryGeneration = snapshot.recoveryGeneration + this.executions = snapshot.executions ?? [] + for (const receipt of snapshot.hookReceipts ?? []) + this.hookReceipts.set(receipt.invocationId, receipt) + this.hookReceiptCutoff = snapshot.hookReceiptCutoff ?? 0 + this.compactHookReceipts() + for (const fileKey of snapshot.filesNeedingRead ?? []) + this.filesNeedingRead.set(fileKey, ++this.readGeneration) for (const [fileKey, stopped] of Object.entries(snapshot.fileFences ?? {})) this.fileFences.set(fileKey, stopped) for (const saved of snapshot.records) { @@ -724,7 +1077,12 @@ export class DesignTasks { return if (!record) { // Migrate a tab saved before durable Hub records existed. Never replace a newer task. - if (this.current(session.fileKey) || this.fileFences.has(session.fileKey)) return + if ( + this.recoveryGeneration || + this.current(session.fileKey) || + this.fileFences.has(session.fileKey) + ) + return record = { task: { ...saved, @@ -815,18 +1173,35 @@ export class DesignTasks { } private persist(): void { - if (!this.options.store) return + if (!this.options.store || this.applyingHook) return const snapshot: DesignTaskSnapshot = { revision: this.revision, + ...(this.hookReceipts.size ? { hookReceipts: [...this.hookReceipts.values()] } : {}), + ...(this.hookReceiptCutoff ? { hookReceiptCutoff: this.hookReceiptCutoff } : {}), + ...(this.executions.length ? { executions: this.executions } : {}), + ...(this.recoveryGeneration ? { recoveryGeneration: this.recoveryGeneration } : {}), + ...(this.filesNeedingRead.size + ? { filesNeedingRead: [...this.filesNeedingRead.keys()] } + : {}), current: Object.fromEntries(this.currentTaskIds), ...(this.fileFences.size ? { fileFences: Object.fromEntries(this.fileFences) } : {}), records: [...this.records.values()].map( - ({ task, requestId, browser, resultNodeIds, retiredAt }) => ({ + ({ + task, + requestId, + browser, + resultNodeIds, + retiredAt, + hostContext, + claudeStopNotice + }) => ({ task: { ...task, capabilities: undefined, operation: null }, requestId, browser, resultNodeIds, - retiredAt + retiredAt, + hostContext, + claudeStopNotice }) ) } @@ -843,7 +1218,7 @@ export class DesignTasks { if (this.options.store?.blockedFiles?.has(fileKey)) fail( 'DESIGN_TASK_INACTIVE', - 'This Figma file has an incompatible saved task. Its canvas writes remain blocked until that task record is recovered; other files are unaffected.' + 'This Figma file has an incompatible saved task. If it was written by a newer Hub, upgrade TemPad Dev MCP and reconnect all clients. If the current version still rejects it, preserve the snapshot for diagnosis or restore a verified complete backup including the latest Stop fences. Do not reset readable task storage; other files are unaffected.' ) if (this.options.store?.recoveryError || this.persistenceError) fail( diff --git a/packages/mcp-server/src/durable-json.ts b/packages/mcp-server/src/durable-json.ts new file mode 100644 index 00000000..fb26dfa7 --- /dev/null +++ b/packages/mcp-server/src/durable-json.ts @@ -0,0 +1,16 @@ +import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +/** The caller owns the directory's writer lock. Never restore an old snapshot. */ +export function writeDurableJson(path: string, value: unknown): void { + const temporary = `${path}.tmp` + writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flush: true }) + renameSync(temporary, path) + if (process.platform === 'win32') return + const directory = openSync(dirname(path), 'r') + try { + fsyncSync(directory) + } finally { + closeSync(directory) + } +} diff --git a/packages/mcp-server/src/execution-state.ts b/packages/mcp-server/src/execution-state.ts new file mode 100644 index 00000000..90b62f5a --- /dev/null +++ b/packages/mcp-server/src/execution-state.ts @@ -0,0 +1,27 @@ +import { FigmaSessionSchema, TerminalReceiptSchema } from '@tempad-dev/shared' +import { z } from 'zod' + +export const ExecutionSchema = z.object({ + id: z.string().min(1), + revision: z.uuid(), + recordedAt: z.number().nonnegative(), + receipt: TerminalReceiptSchema, + extensionId: z.string(), + browserId: z.string(), + origin: z.string(), + session: FigmaSessionSchema, + taskId: z.string().optional(), + epoch: z.number().int().nonnegative().optional(), + handoff: z.boolean(), + abandonedAt: z.number().nonnegative().optional() +}) +export type Execution = z.infer + +// Older readers retain an incompatible per-file fence instead of forgetting +// that JavaScript may still be running in a disconnected browser. +export const ExecutionMarkerSchema = z + .object({ + execution: ExecutionSchema, + task: z.object({ target: z.object({ fileKey: z.string().min(1) }) }) + }) + .refine(({ execution, task }) => execution.session.fileKey === task.target.fileKey) diff --git a/packages/mcp-server/src/extension-socket.ts b/packages/mcp-server/src/extension-socket.ts index 1f1e81ae..a7836b8f 100644 --- a/packages/mcp-server/src/extension-socket.ts +++ b/packages/mcp-server/src/extension-socket.ts @@ -1,5 +1,4 @@ -import type { DesignAction, TempadMcpErrorPayload } from '@tempad-dev/shared' -import type { RawData, WebSocket } from 'ws' +import type { DesignAction, TempadMcpErrorPayload, ToolCompletionMessage } from '@tempad-dev/shared' import { MessageFromExtensionSchema, @@ -7,12 +6,17 @@ import { TEMPAD_MCP_BRIDGE_SUBPROTOCOL, TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS, + sameRuntimeProfile, + type RuntimeProfile, type RegisteredMessage } from '@tempad-dev/shared' +import { WebSocket, type RawData } from 'ws' import type { ExtensionRegistry } from './extension-registry' import type { ExtensionConnection } from './types' +const CHECKOUT_HANDSHAKE_TIMEOUT_MS = 5000 + type ExtensionProtocolWarning = | { extensionId: string; kind: 'binary' } | { error: unknown; extensionId: string; kind: 'json' | 'schema' } @@ -21,6 +25,9 @@ type AttachExtensionSocketOptions = { createId: () => string origin: string registry: ExtensionRegistry + runtimeProfile?: RuntimeProfile + terminalReceiptGeneration?: string + onToolCompletion?: (message: ToolCompletionMessage, extension: ExtensionConnection) => boolean onActivationRejected?: (extensionId: string, activeExtensionId: string) => void onActivated?: (extensionId: string) => void onAutoActivated?: (extensionId: string) => void @@ -47,20 +54,41 @@ export function attachExtensionSocket( legacy: ws.protocol !== TEMPAD_MCP_BRIDGE_SUBPROTOCOL, ws } - options.registry.add(extension) - options.onConnected?.(extension.id) + const checkout = options.runtimeProfile?.kind === 'checkout' + const handshakeTimer = checkout + ? setTimeout( + () => ws.close(1008, 'Checkout runtime handshake timed out.'), + CHECKOUT_HANDSHAKE_TIMEOUT_MS + ) + : undefined + handshakeTimer?.unref() + if (!checkout) { + options.registry.add(extension) + options.onConnected?.(extension.id) + } const registered: RegisteredMessage = { id: extension.id, protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, supportedProtocolVersions: [...TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS], + ...(checkout ? { runtimeProfile: options.runtimeProfile } : {}), + ...(options.terminalReceiptGeneration + ? { + terminalReceiptGeneration: options.terminalReceiptGeneration, + durableTerminalReceipts: true + } + : {}), type: 'registered' } ws.send(JSON.stringify(extension.legacy ? { type: 'registered', id: extension.id } : registered)) - options.onStateChange() - scheduleAutoActivation(options) + if (!checkout) { + options.onStateChange() + scheduleAutoActivation(options) + } ws.on('message', (raw: RawData, isBinary: boolean) => { + // Policy rejection starts a closing handshake; queued frames may still arrive. + if (ws.readyState !== WebSocket.OPEN) return if (isBinary) { options.onProtocolWarning?.({ extensionId: extension.id, kind: 'binary' }) return @@ -100,85 +128,139 @@ export function attachExtensionSocket( } const message = parseResult.data + if (checkout && !extension.runtime && message.type !== 'runtimeHello') { + ws.close(1008, 'Checkout runtime handshake required.') + return + } if ( !extension.legacy && !extension.runtime && - (message.type === 'sessions' || message.type === 'designAction') + (message.type === 'sessions' || + message.type === 'designAction' || + message.type === 'toolCompletion') ) { ws.close(1008, 'Runtime handshake required.') return } - switch (message.type) { - case 'sessions': - if ( - options.registry - .list() - .some( - (peer) => - peer.id !== extension.id && - peer.sessions?.sessions.some((session) => - message.sessions.some((incoming) => incoming.sessionId === session.sessionId) - ) + try { + switch (message.type) { + case 'toolCompletion': { + if (!extension.runtime?.terminalReceipts) { + ws.close(1008, 'Terminal receipts were not negotiated.') + return + } + const accepted = options.onToolCompletion?.(message, extension) ?? false + try { + ws.send(JSON.stringify({ ...message, type: 'toolCompletionAck', accepted })) + } catch (error) { + options.onSocketError?.( + extension.id, + error instanceof Error ? error : new Error(String(error)) ) - ) { - ws.close(1008, 'Figma session is already registered.') - return - } - extension.sessions = message - options.onSessions?.(extension) - break - case 'designAction': - options.onDesignAction?.(extension, message.sessionId, message.action) - break - case 'activate': - if (options.registry.activate(extension.id)) { - options.onActivated?.(extension.id) - options.onStateChange() - scheduleAutoActivation(options) - } else { - const activeExtensionId = options.registry.getActiveId() - if (activeExtensionId) { - options.onActivationRejected?.(extension.id, activeExtensionId) } - } - break - case 'toolResult': - if (message.error !== undefined) { - options.onToolError(message.id, extension.id, message.error) - } else { - options.onToolResult(message.id, extension.id, message.payload) - } - break - case 'runtimeHello': { - const protocolVersion = message.protocolVersion ?? 13 - if (!TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { - ws.close(1008, 'Unsupported extension bridge protocol.') break } - if ( - extension.runtime && - (extension.runtime.version !== message.extensionVersion || - extension.runtime.fingerprint !== message.extensionRuntimeFingerprint || - extension.runtime.protocolVersion !== protocolVersion) - ) { - options.onProtocolWarning?.({ - error: new Error('Extension changed runtime identity on an open connection.'), - extensionId: extension.id, - kind: 'schema' - }) - ws.close(1008, 'Extension changed runtime identity.') + case 'sessions': + if ( + options.registry + .list() + .some( + (peer) => + peer.id !== extension.id && + peer.sessions?.sessions.some((session) => + message.sessions.some((incoming) => incoming.sessionId === session.sessionId) + ) + ) + ) { + ws.close(1008, 'Figma session is already registered.') + return + } + extension.sessions = message + options.onSessions?.(extension) + break + case 'designAction': + options.onDesignAction?.(extension, message.sessionId, message.action) + break + case 'activate': + if (options.registry.activate(extension.id)) { + options.onActivated?.(extension.id) + options.onStateChange() + scheduleAutoActivation(options) + } else { + const activeExtensionId = options.registry.getActiveId() + if (activeExtensionId) { + options.onActivationRejected?.(extension.id, activeExtensionId) + } + } + break + case 'toolResult': + if (message.error !== undefined) { + options.onToolError(message.id, extension.id, message.error) + } else { + options.onToolResult(message.id, extension.id, message.payload) + } + break + case 'runtimeHello': { + if (!sameRuntimeProfile(options.runtimeProfile, message.runtimeProfile)) { + ws.close(1008, 'Runtime profile mismatch.') + return + } + if (message.terminalReceipts && !options.terminalReceiptGeneration) { + ws.close(1008, 'Terminal receipts are unavailable.') + return + } + const protocolVersion = message.protocolVersion ?? 13 + if (!TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + ws.close(1008, 'Unsupported extension bridge protocol.') + break + } + if ( + extension.runtime && + (extension.runtime.version !== message.extensionVersion || + extension.runtime.fingerprint !== message.extensionRuntimeFingerprint || + extension.runtime.protocolVersion !== protocolVersion || + extension.runtime.terminalReceipts !== message.terminalReceipts || + extension.runtime.durableTerminalReceipts !== message.durableTerminalReceipts) + ) { + options.onProtocolWarning?.({ + error: new Error('Extension changed runtime identity on an open connection.'), + extensionId: extension.id, + kind: 'schema' + }) + ws.close(1008, 'Extension changed runtime identity.') + break + } + const firstHello = !extension.runtime + clearTimeout(handshakeTimer) + extension.runtime = { + protocolVersion, + version: message.extensionVersion, + fingerprint: message.extensionRuntimeFingerprint, + ...(message.terminalReceipts ? { terminalReceipts: true as const } : {}), + ...(message.terminalReceipts && message.durableTerminalReceipts + ? { durableTerminalReceipts: true as const } + : {}) + } + if (checkout && firstHello) { + options.registry.add(extension) + options.onConnected?.(extension.id) + options.onStateChange() + scheduleAutoActivation(options) + } + options.onRuntimeHello?.(extension) break } - extension.runtime = { - protocolVersion, - version: message.extensionVersion, - fingerprint: message.extensionRuntimeFingerprint - } - options.onRuntimeHello?.(extension) - break + case 'ping': + break } - case 'ping': - break + } catch (error) { + // A failed durable admission/completion must not crash the Hub or ack a + // receipt. Reconnection can retry against the retained exclusion. + options.onSocketError?.( + extension.id, + error instanceof Error ? error : new Error(String(error)) + ) + ws.close(1011, 'Could not commit runtime state.') } }) @@ -187,6 +269,7 @@ export function attachExtensionSocket( }) ws.once('close', () => { + clearTimeout(handshakeTimer) const removed = options.registry.remove(extension.id) if (!removed) return options.onDisconnected?.(extension.id, removed.wasActive) @@ -194,6 +277,7 @@ export function attachExtensionSocket( scheduleAutoActivation(options) }) + if (checkout && extension.legacy) ws.close(1008, 'Checkout runtime identity required.') return extension } diff --git a/packages/mcp-server/src/hook-receipts.ts b/packages/mcp-server/src/hook-receipts.ts new file mode 100644 index 00000000..92b05989 --- /dev/null +++ b/packages/mcp-server/src/hook-receipts.ts @@ -0,0 +1,62 @@ +import { CLIENT_HOOK_RETENTION_MS, type ClientHook } from '@tempad-dev/shared' +import { createHash } from 'node:crypto' +import { z } from 'zod' + +export const HOOK_RECEIPT_LIMIT = 4096 + +export const HookReceiptSchema = z.object({ + invocationId: z.uuid(), + signature: z.string(), + result: z.record(z.string(), z.unknown()), + retired: z.literal('expired').optional(), + error: z.string().optional(), + recordedAt: z.number(), + issuedAt: z.number().optional() +}) +export type HookReceipt = z.infer + +export const EXPIRED_HOOK_RESULT = { runtimeHookDisposition: 'expired' } as const +export const LEGACY_HOOK_EXHAUSTED_RESULT = { + ...EXPIRED_HOOK_RESULT, + runtimeHookLegacyUpgradeRequired: true +} as const +export class LegacyHookCapacityError extends Error { + constructor() { + super( + 'Legacy lifecycle history is full. Update the TemPad Dev plugin to use receipt-capable hooks.' + ) + } +} +export function isExpiredHookResult(result: Record): boolean { + return result.runtimeHookDisposition === 'expired' +} + +export class ExpiredHookError extends Error { + constructor(readonly cutoff: number) { + super('Lifecycle invocation is expired and cannot be replayed. Send a fresh lifecycle event.') + } +} +export function hookSignature(input: unknown) { + return createHash('sha256').update(JSON.stringify(input)).digest('hex') +} + +export function hookRetentionCutoff(previous: number, now = Date.now()) { + return Math.max(previous, now - CLIENT_HOOK_RETENTION_MS) +} + +/** Make room for one completed result without expiring an unresolved effect. */ +export function hookPressureCutoff(cutoff: number, completed: number[], unresolved: number[]) { + const excess = completed.length + unresolved.length - HOOK_RECEIPT_LIMIT + if (excess < 0 || !completed.length) return cutoff + const oldest = completed.sort((a, b) => a - b)[Math.min(excess, completed.length - 1)]! + return Math.max(cutoff, Math.min(oldest, ...unresolved.map((issuedAt) => issuedAt - 1))) +} + +export function hookCutoff(input: ClientHook, previous: number, now = Date.now()) { + const cutoff = hookRetentionCutoff(previous, now) + if (input.version === 2) { + if (input.issuedAt <= cutoff) throw new ExpiredHookError(cutoff) + if (input.issuedAt > now + 60_000) throw new Error('Lifecycle invocation clock is invalid.') + } + return cutoff +} diff --git a/packages/mcp-server/src/hub-runtime.ts b/packages/mcp-server/src/hub-runtime.ts index fa779887..013ea861 100644 --- a/packages/mcp-server/src/hub-runtime.ts +++ b/packages/mcp-server/src/hub-runtime.ts @@ -25,6 +25,7 @@ import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/sdk/shared/stdio.js' import { + CanvasPageSnapshotSchema, DesignTaskSchema, MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES, MCP_NODE_READ_BATCH_WORK_BUDGET_MS, @@ -32,11 +33,12 @@ import { TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, TEMPAD_MCP_ERROR_CODES, measureCallToolResultBytes, + sameRuntimeProfile, utf8Bytes, type TempadMcpErrorCode } from '@tempad-dev/shared' import { randomUUID } from 'node:crypto' -import { existsSync, rmSync, chmodSync, mkdirSync, realpathSync } from 'node:fs' +import { existsSync, rmSync, mkdirSync, realpathSync } from 'node:fs' import { connect, createServer, type Socket } from 'node:net' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -47,7 +49,8 @@ import type { ExtensionConnection } from './types' import { CodexAppFeedback } from './agent-clients/codex-feedback' import { CodexAppLifecycle } from './agent-clients/codex-lifecycle' -import { ClientHookRequestSchema } from './agent-clients/hooks' +import { ClientHookRequestSchema, type ClientHook } from './agent-clients/hooks' +import { HostContextNotificationSchema } from './agent-clients/host-context' import { ClientEventSchema } from './agent-clients/identity' import { AgentClients } from './agent-clients/registry' import { decodeImageDataUrl } from './asset-data-url' @@ -61,6 +64,11 @@ import { DesignTaskStore } from './design-task-store' import { DesignTasks, resolveDesignTarget, type DesignTaskRecord } from './design-tasks' import { ExtensionRegistry } from './extension-registry' import { attachExtensionSocket } from './extension-socket' +import { + isExpiredHookResult, + LegacyHookCapacityError, + LEGACY_HOOK_EXHAUSTED_RESULT +} from './hook-receipts' import MCP_INSTRUCTIONS from './instructions.md?raw' import { extensionUpgradeRequired, legacyToolPayload } from './legacy-extension' import { register, resolve, reject, cleanupForExtension, cleanupAll } from './request' @@ -71,7 +79,19 @@ import { writeHubRuntimeIdentity, type HubRuntimeIdentity } from './runtime-identity' -import { createExtensionOriginPolicy } from './security' +import { HubControl } from './runtime-prototype/hub-control' +import { listenPrivate } from './runtime-prototype/processes' +import { + IdSchema, + RECOVER_MS, + RuntimeDataTransport, + SocketTransport, + unavailable, + type Registration +} from './runtime-prototype/protocol' +import { readEndpoints, writeEndpoints } from './runtime-prototype/state' +import { attachRuntimeRecovery, RECOVERY_METHODS } from './runtime-recovery' +import { createCapabilityToken, createExtensionOriginPolicy } from './security' import { ASSET_DIR, HUB_BUSY_EXIT_CODE, @@ -82,15 +102,16 @@ import { PACKAGE_VERSION, log, RUNTIME_DIR, - SOCK_PATH, + RUNTIME_PROFILE, + SOCK_PATH as DEFAULT_SOCK_PATH, ensureDir, assertTrustedSocket, resolveStateDir, getRecordProperty } from './shared' +import { TerminalReceipts } from './terminal-receipts' import { TOOL_DEFS, - coercePayloadToToolResponse, createDesignTaskToolResponse, createInlineBudgetExceededToolResponse, createToolErrorResponse, @@ -99,7 +120,40 @@ import { import { assertVersionedToolSupport, versionedReadNotice } from './versioned-extension' import { startExtensionWebSocketServer } from './websocket-server' -export async function startHub(): Promise { +function hasCanvasReadEvidence(tool: ToolName, payload: unknown): boolean { + if (tool === 'get_structure') { + const roots = getRecordProperty(payload, 'roots') + const page = getRecordProperty(payload, 'page') + return ( + (Array.isArray(roots) && + roots.some((root) => typeof getRecordProperty(root, 'id') === 'string')) || + CanvasPageSnapshotSchema.safeParse(page).success + ) + } + if (tool !== 'get_code') return false + if (typeof getRecordProperty(payload, 'code') === 'string') return true + const results = getRecordProperty(payload, 'results') + return ( + Array.isArray(results) && + results.some( + (entry) => typeof getRecordProperty(getRecordProperty(entry, 'result'), 'code') === 'string' + ) + ) +} + +export async function startHub( + options: { + entryPath?: string + prototype?: { + secret: string + root?: string + socketPath?: string + paused?: boolean + release?: boolean + } + } = {} +): Promise { + const SOCK_PATH = options.prototype?.socketPath ?? DEFAULT_SOCK_PATH const SHUTDOWN_TIMEOUT = 2000 const SOCKET_PROBE_TIMEOUT_MS = 300 const EXACT_SESSION_READ_TOOLS = new Set([ @@ -121,8 +175,28 @@ export async function startHub(): Promise { autoActivateGraceMs, assetTtlMs, allowedExtensionOrigins - } = getMcpServerConfig() + } = getMcpServerConfig(RUNTIME_PROFILE) const extensionOriginPolicy = createExtensionOriginPolicy(allowedExtensionOrigins) + const runtimeControl: HubControl | undefined = options.prototype + ? new HubControl(options.prototype.secret, undefined, { + paused: options.prototype.paused, + inspect: handoffStatus, + flush: flushHandoff, + stop: shutdownHandoff, + retireLegacyHooks: () => agentClients.retireLegacyHooks(), + hook: (input, fresh) => receiveHook(input, true, fresh) + }) + : undefined + const endpoints = options.prototype?.root ? readEndpoints(options.prototype.root) : undefined + const endpointTokens = options.prototype + ? { + accessToken: endpoints?.accessToken ?? createCapabilityToken(), + legacyAccessToken: endpoints?.legacyAccessToken ?? createCapabilityToken() + } + : undefined + const runtimeDataSockets = new Map() + if (endpoints || (options.prototype && !options.prototype.release)) + wsPortCandidates.splice(0, wsPortCandidates.length, endpoints?.wsPort ?? 0) log.info({ version: PACKAGE_VERSION }, 'TemPad MCP Hub starting...') @@ -155,9 +229,12 @@ export async function startHub(): Promise { }) const agentClients = new AgentClients( designTasks, - new CodexAppFeedback(join(taskStateDirectory, 'codex-feedback')), + new CodexAppFeedback(join(taskStateDirectory, 'codex-feedback'), { + taskContext: (taskId) => designTasks.find(taskId)?.hostContext + }), (changed) => new CodexAppLifecycle(changed) ) + const terminalReceipts = new TerminalReceipts((records) => designTasks.saveExecutions(records)) let refreshingCapabilities = false let storesOwned = false const storeReleases: Array<() => Promise> = [] @@ -172,19 +249,173 @@ export async function startHub(): Promise { }) }, 10000) const pendingDesignBindings = new Map>() + const activeCalls = new Map() + const localResults = new Set>() + let lastActivity = performance.now() + function noteActivity() { + lastActivity = performance.now() + } const designTaskTimer = setInterval(() => { if (storesOwned && !shuttingDown) designTasks.sweep() + if ( + runtimeControl && + storesOwned && + !shuttingDown && + consumerCount === 0 && + recoveryConnections === 0 && + !runtimeControl.keepAlive() && + runtimeControl.pendingConnections() === 0 && + (hadConsumer || performance.now() - prototypeStartedAt >= RECOVER_MS) && + designTasks.pendingOperationCount === 0 && + terminalReceipts.pendingCount === 0 && + !agentClients.hasPendingActions && + pendingDesignBindings.size === 0 && + assetHttpServer.pendingTransfers === 0 + ) + shutdown() }, 1000) designTaskTimer.unref() let consumerCount = 0 + let recoveryConnections = 0 + let hadConsumer = false + const prototypeStartedAt = performance.now() type TimeoutHandle = ReturnType let selectedWsPort = 0 let releaseHubLock: (() => Promise) | null = null let shuttingDown = false let wss: WebSocketServer | null = null let hubRuntimeIdentity: HubRuntimeIdentity | null = null - const consumerSessions = new Set() + const consumerSessions = new Map() const incomingSockets = new Set() + + function receiveHook(input: ClientHook, durable = false, fresh = true) { + if (runtimeControl && !durable) + throw new Error('Managed lifecycle hooks must enter through the runtime gateway.') + if (input.version === 2 && !sameRuntimeProfile(input.profile, RUNTIME_PROFILE)) + throw new Error('Lifecycle hook belongs to a different runtime profile.') + noteActivity() + let result: Record + try { + result = + durable || input.version === 2 + ? designTasks.applyHook( + input.invocationId, + input, + () => agentClients.clientHook(input, fresh), + durable ? () => agentClients.retireHook(input) : undefined + ) + : agentClients.clientHook(input) + } catch (error) { + if (!durable || !(error instanceof LegacyHookCapacityError)) throw error + agentClients.retireLegacyHooks() + designTasks.flush() + return LEGACY_HOOK_EXHAUSTED_RESULT + } + return input.version === 2 && !isExpiredHookResult(result) + ? { ...result, receipt: { version: 2, invocationId: input.invocationId, state: 'applied' } } + : result + } + + function handoffStatus() { + const blockers: string[] = [] + const connected = new Set(extensionRegistry.list().map((extension) => extension.id)) + const transferable = terminalReceipts.transferableIds() + if (activeCalls.size || localResults.size) blockers.push('admitted MCP calls') + if ( + designTasks + .pendingOperations() + .some( + (operation) => connected.has(operation.extensionId) || !transferable.has(operation.id) + ) + ) + blockers.push('Figma execution without transferable exclusion') + if (!terminalReceipts.canHandoff()) blockers.push('peer without durable terminal receipts') + if (agentClients.hasPendingActions) blockers.push('native feedback or queue removal') + if (agentClients.hasLegacyHooks) blockers.push('Claude binding without receipt/retry hooks') + if (pendingDesignBindings.size) blockers.push('design task binding') + if (assetHttpServer.pendingTransfers) blockers.push('asset transfers') + if (blockers.length) noteActivity() + const autoBlockers: string[] = [] + if ( + !consumerSessions.size || + [...consumerSessions.keys()].some((session) => !session.server.getClientVersion()) + ) + autoBlockers.push('Waiting for MCP clients to finish initialization') + if ( + [...consumerSessions].some(([session, connectionId]) => + agentClients.needsClaudeHooks(connectionId, session.server.getClientVersion()) + ) + ) + autoBlockers.push('Claude connection requires verified receipt/retry hooks') + return { + drained: blockers.length === 0, + blockers, + autoBlockers, + idleForMs: Math.max(0, performance.now() - Math.max(lastActivity, agentClients.lastActivity)), + version: PACKAGE_VERSION, + shutdown: { + storesOwned, + shuttingDown, + consumers: consumerCount, + recoveryConnections, + retained: runtimeControl?.keepAlive() ?? false, + pendingConnections: runtimeControl?.pendingConnections() ?? 0, + waitingForFirstConsumer: + !hadConsumer && performance.now() - prototypeStartedAt < RECOVER_MS, + pendingOperations: designTasks.pendingOperationCount, + pendingReceipts: terminalReceipts.pendingCount, + nativeActions: agentClients.hasPendingActions, + designBindings: pendingDesignBindings.size, + assetTransfers: assetHttpServer.pendingTransfers + }, + // Native adapters and downloads have no shared absolute deadline. Give + // them the bounded drain window; expiry defers switching, never kills work. + deadline: + Math.max( + Date.now(), + ...activeCalls.values(), + agentClients.hasPendingActions || assetHttpServer.pendingTransfers || localResults.size + ? Date.now() + 320_000 + : 0 + ) + 10_000 + } + } + + function flushHandoff() { + designTasks.flush() + assetStore.flush() + } + + async function shutdownHandoff() { + if (shuttingDown) return + shuttingDown = true + clearInterval(designTaskTimer) + clearInterval(clientCapabilityTimer) + clearInterval(assetCleanupTimer) + try { + agentClients.close() + await Promise.all([...consumerSessions.keys()].map((session) => session.close())) + // Socket close handlers persist the final paused owner state. Stop fences + // and unknown executions are in the same store, never restored from backup. + await new Promise((resolve) => setImmediate(resolve)) + flushHandoff() + assetHttpServer.stop() + for (const socket of incomingSockets) socket.destroy() + for (const socket of wss?.clients ?? []) socket.terminate() + await Promise.all([ + new Promise((resolve) => netServer.close(() => resolve())), + new Promise((resolve) => (wss ? wss.close(() => resolve()) : resolve())) + ]) + flushHandoff() + removeRuntimeIdentity() + await releasePartialStoreLocks() + await releaseHubLockIfNeeded() + process.exit(0) + } catch (error) { + log.error({ error }, 'Durable handoff shutdown failed.') + process.exit(1) + } + } type ToolResponse = CallToolResult type ToolRegistrationOptions = { annotations?: ToolAnnotations @@ -430,26 +661,6 @@ export async function startHub(): Promise { } } - function hasFormatter(tool: RegisteredToolDefinition): tool is ExtensionTool & { - format: (payload: unknown) => ToolResponse - } { - return tool.target === 'extension' && 'format' in tool - } - - type ToolDefinitionByName = { - [T in RegisteredToolDefinition as T['name']]: T - } - - const TOOL_BY_NAME: ToolDefinitionByName = Object.fromEntries( - TOOL_DEFINITIONS.map((tool) => [tool.name, tool] as const) - ) as ToolDefinitionByName - - function getToolDefinition( - name: Name - ): ToolDefinitionByName[Name] { - return TOOL_BY_NAME[name] - } - let assetStore: ReturnType let assetHttpServer: ReturnType let assetCleanupTimer: ReturnType | undefined @@ -527,7 +738,12 @@ export async function startHub(): Promise { switch (args.action) { case 'begin': return handleBeginDesign( - { title: args.title!, requestId: args.requestId!, sessionId: args.sessionId }, + { + title: args.title!, + requestId: args.requestId!, + sessionId: args.sessionId, + recoveryGeneration: args.recoveryGeneration + }, ownerId, signal ) @@ -552,7 +768,7 @@ export async function startHub(): Promise { ownerId: string, signal: AbortSignal ): Promise { - let record = designTasks.retry(ownerId, args.requestId) + let record = designTasks.retry(ownerId, args.requestId, args.recoveryGeneration) if (record) { if ( record.task.title !== args.title || @@ -571,7 +787,7 @@ export async function startHub(): Promise { if (issues.length) { throw createCodedError(TEMPAD_MCP_ERROR_CODES.RUNTIME_IDENTITY_MISMATCH, issues.join('; ')) } - const { client, capabilities } = await agentClients.describe(ownerId) + const { client, capabilities, hostContext } = await agentClients.describe(ownerId) signal.throwIfAborted() record = designTasks.begin( ownerId, @@ -583,7 +799,9 @@ export async function startHub(): Promise { capabilities, extension.sessions ? { browserId: extension.sessions.browserId, origin: extension.origin } - : undefined + : undefined, + args.recoveryGeneration, + hostContext ) const task = record.task const binding = bindDesignTask(record, extension, session) @@ -602,6 +820,7 @@ export async function startHub(): Promise { extension: ExtensionConnection, session: FigmaSession ): Promise { + noteActivity() const epoch = task.epoch ?? 0 const registration = register(extension.id, toolTimeoutMs) try { @@ -633,6 +852,8 @@ export async function startHub(): Promise { if ((task.epoch ?? 0) === epoch && task.target.sessionId === session.sessionId) designTasks.stop(task.taskId, 'interrupted') throw error + } finally { + noteActivity() } } @@ -642,9 +863,9 @@ export async function startHub(): Promise { signal: AbortSignal ): Promise { const { extension, session } = resolveDesignRoute(ownerId, args.taskId, false) - const { client, capabilities } = await agentClients.describe(ownerId) + const { client, capabilities, hostContext } = await agentClients.describe(ownerId) signal.throwIfAborted() - designTasks.attachClient(ownerId, client, capabilities) + designTasks.attachClient(ownerId, client, capabilities, hostContext) const record = designTasks.resume(args.taskId, ownerId, args.epoch, session) record.extensionId = extension.id if (!record.ready) @@ -678,6 +899,9 @@ export async function startHub(): Promise { mcp.server.setNotificationHandler(ClientEventSchema, (notification) => agentClients.event(ownerId, notification) ) + mcp.server.setNotificationHandler(HostContextNotificationSchema, (notification) => + agentClients.registerContext(ownerId, notification.params) + ) mcp.server.setNotificationHandler(ClientBindingSubscribeSchema, (notification) => { agentClients.subscribeBinding( ownerId, @@ -692,9 +916,7 @@ export async function startHub(): Promise { notification.params?.sessionId ) }) - mcp.server.setRequestHandler(ClientHookRequestSchema, (request) => - agentClients.clientHook(request.params) - ) + mcp.server.setRequestHandler(ClientHookRequestSchema, (request) => receiveHook(request.params)) const registered: string[] = [] for (const tool of TOOL_DEFINITIONS) { @@ -733,9 +955,26 @@ export async function startHub(): Promise { const schema = tool.parameters const connectionId = ownerId + const isCanvasWrite = tool.name === 'apply_canvas' const handler = async (args: unknown, extra: { _meta?: unknown; signal: AbortSignal }) => { + const activity = Symbol() let requestId: string | undefined + let readTarget: { fileKey: string; taskId?: string } | undefined try { + if (runtimeControl && !runtimeControl.admitting()) throw unavailable() + noteActivity() + activeCalls.set( + activity, + Date.now() + + Math.max( + toolTimeoutMs, + getCodeTimeoutMs, + getDesignSystemTimeoutMs, + pageStructureTimeoutMs, + applyCanvasTimeoutMs * 2 + ) + + MCP_NODE_READ_BATCH_WORK_BUDGET_MS + ) extra.signal.throwIfAborted() const ownerId = await agentClients.owner( connectionId, @@ -766,7 +1005,7 @@ export async function startHub(): Promise { (getRecordProperty(parsedArgs, 'pageId') || getRecordProperty(parsedArgs, 'pageKey')) ) { timeoutMs = pageStructureTimeoutMs - } else if (tool.name === 'apply_canvas') { + } else if (isCanvasWrite) { timeoutMs = applyCanvasTimeoutMs } if ( @@ -775,6 +1014,7 @@ export async function startHub(): Promise { ) { timeoutMs += MCP_NODE_READ_BATCH_WORK_BUDGET_MS } + activeCalls.set(activity, Date.now() + timeoutMs * (isCanvasWrite ? 2 : 1)) // Task-bound calls must always resolve their original target. Legacy reads are // available only when that unversioned connection is explicitly the active route. const legacy = !taskId && !explicitSessionId ? extensionRegistry.getActive() : undefined @@ -787,7 +1027,7 @@ export async function startHub(): Promise { } catch (error) { reject(requestId, legacy.id, coerceToolError(error)) } - return createToolResponse(tool.name, await registration.promise) + return createToolResponse(tool, await registration.promise) } const { extension: activeExt, session } = resolveDesignRoute( ownerId, @@ -795,6 +1035,7 @@ export async function startHub(): Promise { true, explicitSessionId ) + readTarget = { fileKey: session.fileKey, taskId } const runtimeIssues = getExtensionRuntimeIssues(activeExt.runtime) if (runtimeIssues.length) { throw createCodedError( @@ -803,8 +1044,7 @@ export async function startHub(): Promise { ) } - const runtime = - tool.name === 'apply_canvas' ? buildAuthoringRuntimeEvidence(activeExt) : undefined + const runtime = isCanvasWrite ? buildAuthoringRuntimeEvidence(activeExt) : undefined const protocolVersion = activeExt.runtime!.protocolVersion assertVersionedToolSupport(protocolVersion, tool.name, parsedArgs) if (runtime && utf8Bytes({ runtime }) > MCP_APPLY_CANVAS_RUNTIME_BUDGET_BYTES) { @@ -814,9 +1054,9 @@ export async function startHub(): Promise { ) } - designTasks.checkOperation(ownerId, session, taskId, tool.name === 'apply_canvas') + designTasks.checkOperation(ownerId, session, taskId, isCanvasWrite) const registration = register(activeExt.id, timeoutMs, { - waitForDefinitiveResult: tool.name === 'apply_canvas', + waitForDefinitiveResult: isCanvasWrite, signal: extra.signal }) requestId = registration.requestId @@ -834,6 +1074,8 @@ export async function startHub(): Promise { args: parsedArgs } } + let dispatched = false + let readGeneration: number | undefined try { designTasks.startOperation( requestId, @@ -842,10 +1084,30 @@ export async function startHub(): Promise { activeExt.sessions!.browserId, session, taskId, - tool.name === 'apply_canvas' + isCanvasWrite ) + message.terminalReceipt = terminalReceipts.reserve( + requestId, + activeExt, + session, + taskId, + isCanvasWrite, + taskEpoch + ) + // Save before dispatch: even a crash or a subsequent disk failure must + // leave the original file fenced until its state is read. + if (isCanvasWrite) readGeneration = designTasks.requireFileRead(session.fileKey, true) activeExt.ws.send(JSON.stringify(message)) + dispatched = true } catch (error) { + try { + terminalReceipts.settle(requestId, activeExt.id) + } catch (persistError) { + log.error( + { error: persistError }, + 'Could not retire undispatched execution; its file remains fenced.' + ) + } designTasks.finishOperation(requestId, activeExt.id) reject(requestId, activeExt.id, coerceToolError(error)) } @@ -856,11 +1118,24 @@ export async function startHub(): Promise { // Consumer cancellation and transport failures end only this wait. The // operation remains fenced until its definitive result or document loss. - const payload = await registration.promise - if (taskId && ['get_structure', 'get_code'].includes(tool.name)) { - designTasks.acknowledgeRead(taskId, ownerId, taskEpoch) - } - if (taskId && tool.name === 'apply_canvas') { + const payload = await registration.promise.catch((error: unknown) => { + const code = getRecordProperty(error, 'code') + // Only these outcomes prove that an older or newer peer did not write. + // Other codes (including legacy validation failures) can arrive after + // mutation; an absent/unknown code is not evidence of a safe failure. + const safeFailure = + code === TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED || + code === TEMPAD_MCP_ERROR_CODES.EXTENSION_DELIVERY_FAILED + const unknownOutcome = + dispatched && + (designTasks.isOperationPending(registration.requestId) || + getRecordProperty(error, 'name') === 'AbortError' || + !safeFailure) + if (readGeneration !== undefined && !unknownOutcome) + designTasks.acknowledgeFileRead(session.fileKey, readGeneration) + throw error + }) + if (taskId && isCanvasWrite) { const rootNodeId = getRecordProperty(payload, 'rootNodeId') if (typeof rootNodeId === 'string') designTasks.captureResult( @@ -874,10 +1149,18 @@ export async function startHub(): Promise { if (typeof pageId === 'string') designTasks.updatePage(taskId, pageId) } } - return createToolResponse(tool.name, payload, { + const readEvidence = hasCanvasReadEvidence(tool.name, payload) + const response = createToolResponse(tool, payload, { runtime, - compatibilityNotice: versionedReadNotice(protocolVersion, tool.name) + notice: versionedReadNotice(protocolVersion, tool.name) }) + if (!response.isError && readGeneration !== undefined) + designTasks.acknowledgeFileRead(session.fileKey, readGeneration) + if (!response.isError && readEvidence) { + if (taskId) designTasks.acknowledgeRead(taskId, ownerId, taskEpoch) + designTasks.acknowledgeFileRead(session.fileKey) + } + return withCanvasReadNotice(tool.name, response, readTarget) } catch (error) { const normalized = coerceToolError(error) log.error( @@ -889,7 +1172,14 @@ export async function startHub(): Promise { }, 'Tool invocation failed.' ) - return createToolErrorResponse(tool.name, normalized) + return withCanvasReadNotice( + tool.name, + createToolErrorResponse(tool.name, normalized), + readTarget + ) + } finally { + activeCalls.delete(activity) + noteActivity() } } @@ -904,6 +1194,29 @@ export async function startHub(): Promise { ) } + function withCanvasReadNotice( + tool: ToolName, + response: CallToolResult, + target?: { fileKey: string; taskId?: string } + ): CallToolResult { + const notice = + target && + (tool === 'apply_canvas' || tool === 'get_structure' || tool === 'get_code') && + (designTasks.needsFileRead(target.fileKey) || + (target.taskId && designTasks.find(target.taskId)?.task.needsRead)) + ? terminalReceipts.status().some((record) => record.fileKey === target.fileKey) + ? 'An earlier canvas operation may still be executing. Reads are provisional and cannot release its execution block. Reconnect the original Figma environment or inspect --runtime-status; do not replay the write.' + : 'Canvas writes still require readback. Read a surviving node with get_structure or get_code, or an exact page with get_structure. For a resumed task, include its taskId and current taskEpoch. Empty selections and failed node reads do not clear this requirement.' + : undefined + const prependNotice = (result: CallToolResult): CallToolResult => + notice ? { ...result, content: [{ type: 'text', text: notice }, ...result.content] } : result + const result = prependNotice(response) + const bytes = measureCallToolResultBytes(result) + return bytes <= MCP_TOOL_INLINE_BUDGET_BYTES + ? result + : prependNotice(createInlineBudgetExceededToolResponse(tool, bytes)) + } + function registerLocalTool(mcp: McpServer, tool: HubOnlyTool, ownerId: string): void { const schema = tool.parameters const handler = tool.handler as ( @@ -936,7 +1249,11 @@ export async function startHub(): Promise { args: unknown, extra: { _meta?: unknown; signal: AbortSignal } ) => { + const activity = Symbol() try { + if (runtimeControl && !runtimeControl.admitting()) throw unavailable() + noteActivity() + activeCalls.set(activity, Date.now() + toolTimeoutMs) extra.signal.throwIfAborted() const ownerId = await agentClients.owner( connectionId, @@ -963,6 +1280,9 @@ export async function startHub(): Promise { } catch (error) { log.error({ tool: tool.name, error }, 'Local tool invocation failed.') return createToolErrorResponse(tool.name, error) + } finally { + activeCalls.delete(activity) + noteActivity() } } @@ -973,6 +1293,7 @@ export async function startHub(): Promise { result: Promise, signal: AbortSignal ): Promise { + localResults.add(result) return new Promise((resolve, reject) => { const abort = () => { signal.removeEventListener('abort', abort) @@ -982,10 +1303,14 @@ export async function startHub(): Promise { if (signal.aborted) abort() void result.then( (value) => { + localResults.delete(result) + noteActivity() signal.removeEventListener('abort', abort) resolve(value) }, (error: unknown) => { + localResults.delete(result) + noteActivity() signal.removeEventListener('abort', abort) reject(error) } @@ -993,19 +1318,20 @@ export async function startHub(): Promise { }) } - function createToolResponse( - toolName: Name, - payload: ToolResultMap[Name], + function createToolResponse( + tool: T, + payload: ToolResultMap[T['name']], { runtime, includeLocalAssetPaths = true, - compatibilityNotice + notice }: { runtime?: AuthoringRuntimeEvidence includeLocalAssetPaths?: boolean - compatibilityNotice?: string + notice?: string } = {} ): ToolResponse { + const toolName = tool.name const enrichedPayload = (() => { if (includeLocalAssetPaths && (toolName === 'get_code' || toolName === 'get_screenshot')) { const read = payload as ToolResultMap['get_code' | 'get_screenshot'] @@ -1024,34 +1350,32 @@ export async function startHub(): Promise { return { ...apply, runtime } } return payload - })() as ToolResultMap[Name] + })() as ToolResultMap[T['name']] let rawResult: ToolResponse - const definition = getToolDefinition(toolName) - if (definition && hasFormatter(definition)) { - try { - const formatter = definition.format as (input: ToolResultMap[Name]) => ToolResponse - rawResult = formatter(enrichedPayload) - } catch (error) { - log.warn({ tool: toolName, error }, 'Failed to format tool result; returning raw payload.') - rawResult = coercePayloadToToolResponse(enrichedPayload) + try { + const formatter = tool.format as (input: ToolResultMap[T['name']]) => ToolResponse + rawResult = formatter(enrichedPayload) + } catch (error) { + log.warn({ tool: toolName, error }, 'Failed to format tool result; returning raw payload.') + rawResult = { + isError: true, + content: [{ type: 'text', text: JSON.stringify(enrichedPayload, null, 2) ?? 'undefined' }] } - } else { - rawResult = coercePayloadToToolResponse(enrichedPayload) } - if (compatibilityNotice) { + if (notice) { rawResult = { ...rawResult, - content: [{ type: 'text', text: compatibilityNotice }, ...rawResult.content] + content: [{ type: 'text', text: notice }, ...rawResult.content] } } const resultBytes = measureCallToolResultBytes(rawResult) if (resultBytes > MCP_TOOL_INLINE_BUDGET_BYTES) { if (includeLocalAssetPaths && (toolName === 'get_code' || toolName === 'get_screenshot')) { - return createToolResponse(toolName, payload, { + return createToolResponse(tool, payload, { runtime, - compatibilityNotice, + notice, includeLocalAssetPaths: false }) } @@ -1200,8 +1524,9 @@ export async function startHub(): Promise { clearInterval(clientCapabilityTimer) clearInterval(assetCleanupTimer) agentClients.close() + void runtimeControl?.close() log.info('Hub is shutting down...') - consumerSessions.forEach((session) => { + consumerSessions.forEach((_, session) => { session.close().catch((err) => { log.warn({ err }, 'Failed to close MCP session during shutdown.') }) @@ -1228,13 +1553,26 @@ export async function startHub(): Promise { unrefTimer(timer) } - function attachConsumer(sock: Socket): void { - const ownerId = randomUUID() + function attachConsumer(sock: Socket, registration?: Registration): void { + const ownerId = registration?.sessionId ?? randomUUID() + if (registration) { + runtimeDataSockets.set(ownerId, sock) + agentClients.registerContext(ownerId, registration.context) + } const mcp = createMcpServer(ownerId) - consumerSessions.add(mcp) + consumerSessions.set(mcp, ownerId) + noteActivity() consumerCount++ + hadConsumer = true log.info(`Consumer connected. Total: ${consumerCount}`) - const transport = new ConsumerTransport(sock, sock) + const transport = + registration && runtimeControl + ? new RuntimeDataTransport(new SocketTransport(sock), { + generation: () => runtimeControl.currentGeneration, + admit: (generation, method) => runtimeControl.admits(ownerId, generation, method), + role: 'hub' + }) + : new ConsumerTransport(sock, sock) mcp.connect(transport).catch((err) => { log.error({ err }, 'Failed to attach MCP transport.') consumerSessions.delete(mcp) @@ -1248,6 +1586,8 @@ export async function startHub(): Promise { }) const closeConsumer = async () => { agentClients.disconnect(ownerId) + runtimeDataSockets.delete(ownerId) + runtimeControl?.forget(ownerId) await transport .close() .catch((closeErr) => log.warn({ err: closeErr }, 'Transport close failed.')) @@ -1256,8 +1596,9 @@ export async function startHub(): Promise { .catch((closeErr) => log.warn({ err: closeErr }, 'MCP session close failed.')) consumerSessions.delete(mcp) consumerCount-- + noteActivity() log.info(`Consumer disconnected. Remaining: ${consumerCount}`) - if (consumerCount === 0) { + if (consumerCount === 0 && recoveryConnections === 0 && !runtimeControl) { log.info('Last consumer disconnected. Shutting down.') shutdown() } @@ -1271,7 +1612,8 @@ export async function startHub(): Promise { // Installed lifecycle hooks issue one request without initializing MCP. Keep // these short connections out of tool registration and consumer ownership. incomingSockets.add(sock) - let buffer = Buffer.alloc(0) + let chunks: Buffer[] = [] + let bufferedBytes = 0 const timeout = setTimeout(() => sock.destroy(), 5000) unrefTimer(timeout) const onError = (error: Error) => { @@ -1285,30 +1627,100 @@ export async function startHub(): Promise { }) const onData = (chunk: Buffer) => { try { - if (buffer.length + chunk.length > STDIO_DEFAULT_MAX_BUFFER_SIZE) + bufferedBytes += chunk.length + if (bufferedBytes > STDIO_DEFAULT_MAX_BUFFER_SIZE) throw new Error('Incoming Hub frame exceeded the MCP buffer limit.') - buffer = Buffer.concat([buffer, chunk]) - const newline = buffer.indexOf('\n') + chunks.push(chunk) + const newline = chunk.indexOf('\n') if (newline === -1) return + const frameEnd = bufferedBytes - chunk.length + newline + const buffer = Buffer.concat(chunks, bufferedBytes) + chunks = [] const message = deserializeMessage( - buffer.subarray(0, newline).toString('utf8').replace(/\r$/, '') + buffer.subarray(0, frameEnd).toString('utf8').replace(/\r$/, '') ) sock.pause() sock.off('data', onData) + if ('method' in message && RECOVERY_METHODS.includes(message.method)) { + recoveryConnections++ + sock.once('close', () => { + recoveryConnections-- + if (!runtimeControl && consumerCount === 0 && recoveryConnections === 0) shutdown() + }) + clearTimeout(timeout) + sock.unshift(buffer) + void attachRuntimeRecovery(sock, terminalReceipts, (record) => { + designTasks.finishOperation(record.id, record.extensionId) + reject( + record.id, + record.extensionId, + createCodedError( + TEMPAD_MCP_ERROR_CODES.EXTENSION_RESULT_LOST, + 'The operator retired this execution. Its result is unknown; reread before further work.' + ) + ) + }) + .then(() => sock.resume()) + .catch(onError) + return + } if ('method' in message && message.method === 'tempad/client-hook') { if (!('id' in message)) throw new Error('Lifecycle hooks require a request ID.') const request = ClientHookRequestSchema.safeParse(message) const reply = request.success - ? { result: agentClients.clientHook(request.data.params) } + ? { result: receiveHook(request.data.params) } : { error: { code: -32602, message: 'Invalid lifecycle hook parameters.' } } sock.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, ...reply }) + '\n') return } + if (runtimeControl) { + if ('method' in message && message.method === 'tempad/runtime-acquire') { + clearTimeout(timeout) + sock.unshift(buffer) + void runtimeControl + .attach(sock) + .then(() => sock.resume()) + .catch(onError) + return + } + if ( + 'method' in message && + 'id' in message && + message.method === 'tempad/runtime-attach' + ) { + const sessionId = IdSchema.parse(message.params?.sessionId) + const token = IdSchema.parse(message.params?.token) + const registration = runtimeControl.claimSession(sessionId, token) + if (runtimeDataSockets.has(sessionId)) + throw new Error('Runtime session already has a data connection.') + clearTimeout(timeout) + incomingSockets.delete(sock) + sock.off('error', onError) + sock.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: {} }) + '\n') + const remainder = buffer.subarray(frameEnd + 1) + if (remainder.length) sock.unshift(remainder) + attachConsumer(sock, registration) + sock.resume() + return + } + if ('id' in message) + sock.end( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32002, + message: 'MCP_RUNTIME_RESTART_REQUIRED: reconnect through the stable MCP edge.' + } + }) + '\n' + ) + else sock.destroy() + return + } clearTimeout(timeout) incomingSockets.delete(sock) sock.off('error', onError) sock.unshift(buffer) - buffer = Buffer.alloc(0) attachConsumer(sock) sock.resume() } catch (error) { @@ -1327,43 +1739,28 @@ export async function startHub(): Promise { await acquireStoreLocks() assetStore = createAssetStore() assetHttpServer = createAssetHttpServer(assetStore, { + onActivity: noteActivity, + ...(runtimeControl + ? { admit: () => runtimeControl.admitting(), ...endpointTokens, port: endpoints?.httpPort } + : {}), authorizeExtensionOrigin: (origin) => extensionRegistry.getActive()?.origin === origin.toLowerCase() }) designTasks.restore() + terminalReceipts.restore(designTasks.executionState()) await cleanSocketPath() - hubRuntimeIdentity = createHubRuntimeIdentity(fileURLToPath(import.meta.url), PACKAGE_VERSION) + hubRuntimeIdentity = createHubRuntimeIdentity( + options.entryPath ?? fileURLToPath(import.meta.url), + PACKAGE_VERSION + ) writeHubRuntimeIdentity(HUB_RUNTIME_IDENTITY_PATH, hubRuntimeIdentity) await assetHttpServer.start() scheduleAssetCleanup() } - function listenConsumerSocket(): Promise { - return new Promise((resolve, reject) => { - const cleanup = () => { - netServer.off('error', onError) - netServer.off('listening', onListening) - } - const onError = (err: Error) => { - cleanup() - reject(err) - } - const onListening = () => { - cleanup() - try { - if (process.platform !== 'win32') chmodSync(SOCK_PATH, 0o600) - } catch (err) { - netServer.close() - reject(err) - return - } - log.info({ sock: SOCK_PATH }, 'Hub socket ready.') - resolve() - } - netServer.once('error', onError) - netServer.once('listening', onListening) - netServer.listen(SOCK_PATH) - }) + async function listenConsumerSocket(): Promise { + await listenPrivate(netServer, SOCK_PATH) + log.info({ sock: SOCK_PATH }, 'Hub socket ready.') } async function startWebSocketServer(): Promise<{ server: WebSocketServer; port: number }> { @@ -1397,6 +1794,16 @@ export async function startHub(): Promise { const startedWebSocket = await startWebSocketServer() wss = startedWebSocket.server selectedWsPort = startedWebSocket.port + if (options.prototype?.root && !endpoints) + writeEndpoints(options.prototype.root, { + wsPort: selectedWsPort, + httpPort: Number(new URL(assetHttpServer.getBaseUrl()).port), + ...endpointTokens! + }) + if (hubRuntimeIdentity) { + hubRuntimeIdentity.wsPort = selectedWsPort + writeHubRuntimeIdentity(HUB_RUNTIME_IDENTITY_PATH, hubRuntimeIdentity) + } return startedWebSocket.server } @@ -1425,6 +1832,31 @@ export async function startHub(): Promise { activeWss.on('connection', (ws, request) => { attachExtensionSocket(ws, { + runtimeProfile: RUNTIME_PROFILE, + terminalReceiptGeneration: terminalReceipts.generation, + onToolCompletion: (message, extension) => { + const { accepted, recovered } = terminalReceipts.accept(message, extension) + if (recovered) { + if (recovered.write) { + designTasks.requireFileRead(recovered.session.fileKey) + if (recovered.taskId) { + const record = designTasks.find(recovered.taskId) + if (record && record.task.epoch === (recovered.epoch ?? 0)) + record.task.needsRead = true + } + } + designTasks.finishOperation(message.id, recovered.extensionId) + reject( + message.id, + recovered.extensionId, + createCodedError( + TEMPAD_MCP_ERROR_CODES.EXTENSION_RESULT_LOST, + 'The Figma call finished, but its result was lost. Read the original target to inspect its current state before further work. Do not replay the write.' + ) + ) + } + return accepted + }, createId: randomUUID, origin: request.headers.origin ?? '', registry: extensionRegistry, @@ -1449,6 +1881,7 @@ export async function startHub(): Promise { `Extension disconnected. Remaining: ${extensionRegistry.size}` ) designTasks.disconnectExtension(extensionId) + terminalReceipts.disconnect(extensionId) cleanupForExtension(extensionId) if (wasActive) log.warn({ id: extensionId }, 'Active extension disconnected.') }, @@ -1473,6 +1906,7 @@ export async function startHub(): Promise { ) }, onSessions: (extension) => { + terminalReceipts.reconcile(extension) const snapshot = extension.sessions! for (const review of snapshot.reviews ?? []) { const session = snapshot.sessions.find((value) => value.sessionId === review.sessionId) @@ -1551,6 +1985,19 @@ export async function startHub(): Promise { status: 'accepted', message: action.action === 'stop' ? 'Stopping design task…' : 'Sending feedback…' }) + if ( + runtimeControl && + !runtimeControl.admitting() && + !['stop', 'done'].includes(action.action) + ) { + reply({ + requestId: action.requestId, + taskId: action.taskId, + status: 'failed', + message: 'Runtime recovery is in progress; feedback was not dispatched.' + }) + return + } void agentClients .action(action, accepted) .then(reply) @@ -1568,11 +2015,10 @@ export async function startHub(): Promise { }, onStateChange: broadcastState, onToolError: (requestId, extensionId, error) => { - designTasks.finishOperation( - requestId, - extensionId, - error.code !== TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED - ) + // DELIVERY_FAILED is definitive; DISCONNECTED may follow an accepted page write. + const definitive = error.code !== TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED + terminalReceipts.settle(requestId, extensionId, definitive) + designTasks.finishOperation(requestId, extensionId, definitive) const normalized = coerceToolError(error) log.warn( { @@ -1586,6 +2032,7 @@ export async function startHub(): Promise { reject(requestId, extensionId, normalized) }, onToolResult: (requestId, extensionId, payload) => { + terminalReceipts.settle(requestId, extensionId) designTasks.finishOperation(requestId, extensionId) resolve(requestId, extensionId, payload) } diff --git a/packages/mcp-server/src/hub.ts b/packages/mcp-server/src/hub.ts index 78dd67ee..224ecc7c 100644 --- a/packages/mcp-server/src/hub.ts +++ b/packages/mcp-server/src/hub.ts @@ -1,7 +1,8 @@ import { startHub } from './hub-runtime' import { log } from './shared' -void startHub().catch((error) => { +void startHub({ entryPath: fileURLToPath(import.meta.url) }).catch((error) => { log.error({ error }, 'Hub startup failed.') process.exitCode = 1 }) +import { fileURLToPath } from 'node:url' diff --git a/packages/mcp-server/src/managed-runtime.ts b/packages/mcp-server/src/managed-runtime.ts new file mode 100644 index 00000000..8aac9d64 --- /dev/null +++ b/packages/mcp-server/src/managed-runtime.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'node:url' + +import { runRuntime } from './runtime-prototype/run' + +// Internal payload entry; the public CLI owns enrollment and migration. +await runRuntime(fileURLToPath(import.meta.url)) diff --git a/packages/mcp-server/src/process-environment.ts b/packages/mcp-server/src/process-environment.ts new file mode 100644 index 00000000..b53ef50e --- /dev/null +++ b/packages/mcp-server/src/process-environment.ts @@ -0,0 +1,108 @@ +import { win32 } from 'node:path' + +import type { HostContext } from './agent-clients/host-context' + +const PLATFORM_KEYS = [ + 'PATH', + 'Path', + 'PATHEXT', + 'SystemRoot', + 'SYSTEMROOT', + 'WINDIR', + 'ComSpec', + 'HOME', + 'USERPROFILE', + 'APPDATA', + 'LOCALAPPDATA', + 'TMPDIR', + 'TEMP', + 'TMP', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ' +] as const +const WINDOWS_KEYS = [ + 'SystemDrive', + 'ProgramFiles', + 'ProgramFiles(x86)', + 'ProgramW6432', + 'PROCESSOR_ARCHITECTURE', + 'USERNAME', + 'HOMEDRIVE', + 'HOMEPATH' +] as const +const HUB_KEYS = [ + 'DEBUG', + 'NODE_EXTRA_CA_CERTS', + 'TEMPAD_MCP_DEV_CHECKOUT', + 'TEMPAD_MCP_RUNTIME_DIR', + 'TEMPAD_MCP_LOG_DIR', + 'TEMPAD_MCP_ASSET_DIR', + 'TEMPAD_MCP_STATE_DIR', + 'TEMPAD_MCP_TOOL_TIMEOUT', + 'TEMPAD_MCP_AUTO_ACTIVATE_GRACE', + 'TEMPAD_MCP_MAX_ASSET_BYTES', + 'TEMPAD_MCP_ASSET_TTL_MS', + 'TEMPAD_MCP_MAX_ASSET_STORE_BYTES', + 'TEMPAD_MCP_MAX_CONCURRENT_ASSET_UPLOADS', + 'TEMPAD_MCP_MAX_EXTENSION_CONNECTIONS', + 'TEMPAD_MCP_GET_CODE_TIMEOUT', + 'TEMPAD_MCP_GET_DESIGN_SYSTEM_TIMEOUT', + 'TEMPAD_MCP_PAGE_STRUCTURE_TIMEOUT', + 'TEMPAD_MCP_APPLY_CANVAS_TIMEOUT', + 'TEMPAD_MCP_ALLOWED_EXTENSION_ORIGINS' +] as const + +function pick(env: NodeJS.ProcessEnv, keys: readonly string[]): NodeJS.ProcessEnv { + if (process.platform === 'win32') { + const allowed = new Map() + for (const key of keys) if (!allowed.has(key.toLowerCase())) allowed.set(key.toLowerCase(), key) + return Object.fromEntries( + Object.entries(env).flatMap(([key, value]) => { + const canonical = allowed.get(key.toLowerCase()) + return canonical && value !== undefined ? [[canonical, value]] : [] + }) + ) + } + return Object.fromEntries( + keys.flatMap((key) => (env[key] === undefined ? [] : [[key, env[key]]])) + ) +} + +/** Windows helpers need the OS directory and architecture variables even in a clean environment. */ +export function platformEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return pick( + env, + process.platform === 'win32' ? [...PLATFORM_KEYS, ...WINDOWS_KEYS] : PLATFORM_KEYS + ) +} + +/** Never inherit host identities, credentials, NODE_OPTIONS, or arbitrary runtime overrides. */ +export function hubEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...platformEnvironment(env), ...pick(env, HUB_KEYS) } +} + +export function hostEnvironment( + context: HostContext, + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + const home = win32.normalize(context.home) + const root = win32.parse(home).root + return { + ...platformEnvironment(env), + HOME: context.home, + USERPROFILE: context.home, + ...(process.platform === 'win32' + ? { + HOMEDRIVE: root.slice(0, -1), + HOMEPATH: `\\${home.slice(root.length)}` + } + : {}), + TMPDIR: context.tempDir, + TEMP: context.tempDir, + TMP: context.tempDir, + CODEX_HOME: context.codexHome, + CODEX_SQLITE_HOME: context.codexSqliteHome + } +} diff --git a/packages/mcp-server/src/request.ts b/packages/mcp-server/src/request.ts index 32323bae..b70c40eb 100644 --- a/packages/mcp-server/src/request.ts +++ b/packages/mcp-server/src/request.ts @@ -8,6 +8,8 @@ import type { PendingToolCall } from './types' import { log } from './shared' const pendingCalls = new Map() +const UNKNOWN_WRITE_OUTCOME = + 'The canvas operation outcome is unknown. Do not replay this write; inspect the canvas or reload the executing Figma tab before continuing.' type RegisterOptions = { waitForDefinitiveResult?: boolean @@ -53,10 +55,7 @@ export function register( if (call) call.timer = setTimeout(() => { call.reject( - createToolError( - TEMPAD_MCP_ERROR_CODES.EXTENSION_TIMEOUT, - 'The canvas operation outcome is unknown. Do not replay this write; inspect the canvas or reload the executing Figma tab before continuing.' - ) + createToolError(TEMPAD_MCP_ERROR_CODES.EXTENSION_TIMEOUT, UNKNOWN_WRITE_OUTCOME) ) }, timeout) return @@ -76,6 +75,17 @@ export function register( }, reject: (error) => { cleanup() + if ( + options.waitForDefinitiveResult && + error && + 'code' in error && + error.code === TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED + ) { + reject( + createToolError(TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED, UNKNOWN_WRITE_OUTCOME) + ) + return + } reject(error) }, timer, diff --git a/packages/mcp-server/src/runtime-identity.ts b/packages/mcp-server/src/runtime-identity.ts index ad9558f5..57a82368 100644 --- a/packages/mcp-server/src/runtime-identity.ts +++ b/packages/mcp-server/src/runtime-identity.ts @@ -10,6 +10,7 @@ export interface HubRuntimeIdentity { startedAt: string processId: number activeExtension: ActiveExtensionRuntimeIdentity | null + wsPort?: number } export interface ActiveExtensionRuntimeIdentity { @@ -61,6 +62,8 @@ export function parseHubRuntimeIdentity(value: unknown): HubRuntimeIdentity | nu !Number.isFinite(Date.parse(candidate.startedAt)) || typeof candidate.processId !== 'number' || !Number.isInteger(candidate.processId) || + (candidate.wsPort !== undefined && + (!Number.isInteger(candidate.wsPort) || candidate.wsPort < 1 || candidate.wsPort > 65535)) || activeExtension === undefined ) { return null diff --git a/packages/mcp-server/src/runtime-installer.ts b/packages/mcp-server/src/runtime-installer.ts new file mode 100644 index 00000000..9d539fce --- /dev/null +++ b/packages/mcp-server/src/runtime-installer.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { rmSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' + +import { createPrivateTempDirectory } from './runtime-platform' + +const [flag, cache, versionFlag, selector, contextFlag, context, cursorFlag, cursor] = + process.argv.slice(2) +if ( + process.env.TEMPAD_MCP_DEV_CHECKOUT || + flag !== '--cache' || + !cache || + !isAbsolute(cache) || + versionFlag !== '--version' || + !selector || + contextFlag !== '--context' || + !context || + (process.argv.length !== 8 && + !(process.argv.length === 10 && cursorFlag === '--cursor' && cursor)) +) + throw new Error( + 'Release acquisition requires an explicit private release cache and version; checkout acquisition is forbidden.' + ) +const isolation = createPrivateTempDirectory('tp-acquire-') +for (const [key, directory] of Object.entries({ + TEMPAD_MCP_RUNTIME_DIR: 'run', + TEMPAD_MCP_STATE_DIR: 'state', + TEMPAD_MCP_ASSET_DIR: 'assets', + TEMPAD_MCP_LOG_DIR: 'logs' +})) + process.env[key] = join(isolation, directory) +const { endLogging } = await import('./shared') +try { + const { AcquisitionContextSchema, SelectionCursorSchema, stageRelease } = + await import('./runtime-update/acquire') + const staged = await stageRelease( + cache, + selector, + AcquisitionContextSchema.parse(JSON.parse(context)), + cursor ? SelectionCursorSchema.parse(JSON.parse(cursor)) : undefined + ) + process.stdout.write(JSON.stringify(staged) + '\n') +} catch (error) { + process.stderr.write(`Runtime acquisition failed: ${String(error)}\n`) + process.exitCode = 1 +} finally { + endLogging() + rmSync(isolation, { recursive: true, force: true }) +} diff --git a/packages/mcp-server/src/runtime-platform.ts b/packages/mcp-server/src/runtime-platform.ts new file mode 100644 index 00000000..461ecc6c --- /dev/null +++ b/packages/mcp-server/src/runtime-platform.ts @@ -0,0 +1,380 @@ +import { execFile, execFileSync } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { lstatSync, mkdirSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir, userInfo } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' + +const PIPE_PREFIX = '\\\\.\\pipe\\' +const checkedPermissions = new Map>() + +/** Runtime overrides must isolate pipes as well as files, including on one Windows account. */ +export function runtimeEndpoint( + directory: string, + name: string, + platform: NodeJS.Platform = process.platform, + user = userInfo().username +) { + if (platform !== 'win32') return join(directory, name) + const identity = createHash('sha256') + .update(user) + .update('\0') + .update(directory.replaceAll('/', '\\').toLowerCase()) + .update('\0') + .update(name) + .digest('hex') + .slice(0, 32) + return `${PIPE_PREFIX}tempad-runtime-${identity}` +} + +export function isPipe(path: string) { + return path.startsWith(PIPE_PREFIX) +} + +function canonical(path: string) { + const actual = realpathSync(path) + return process.platform === 'win32' + ? actual.toLowerCase() === resolve(path).toLowerCase() + : actual === path +} + +/** Check real Windows ACLs; chmod and stat.mode do not establish Windows privacy. */ +function inspectPath(path: string, kind: 'file' | 'directory') { + if (!isAbsolute(path)) throw new Error('Runtime path must be absolute.') + const stat = lstatSync(path, { bigint: true }) + if ( + stat.isSymbolicLink() || + !(kind === 'file' ? stat.isFile() : stat.isDirectory()) || + !canonical(path) || + (process.platform !== 'win32' && (stat.uid !== BigInt(process.getuid!()) || stat.mode & 0o077n)) + ) + throw new Error(`Refusing untrusted runtime ${kind}: ${path}`) + return { + identity: `${stat.dev}:${stat.ino}:${stat.birthtimeNs}:${kind}`, + revision: stat.ctimeNs + } +} + +function rememberPermissions(path: string, state: ReturnType) { + if (checkedPermissions.size >= 1024) checkedPermissions.clear() + checkedPermissions.set(path, state) +} + +function uncheckedPath(path: string, kind: 'file' | 'directory') { + const state = inspectPath(path, kind) + if (process.platform !== 'win32') return + const cached = checkedPermissions.get(path) + if (cached?.identity === state.identity && cached.revision === state.revision) return + checkedPermissions.delete(path) + return state +} + +function checkedPath( + path: string, + kind: 'file' | 'directory', + state: ReturnType +) { + const next = inspectPath(path, kind) + if (next.identity !== state.identity) + throw Object.assign(new Error(`Runtime ${kind} changed during its permission check: ${path}`), { + code: 'ERUNTIMEPATHCHANGED' + }) + if (next.revision === state.revision) { + rememberPermissions(path, next) + return + } + return next +} + +export function assertPrivatePath(path: string, kind: 'file' | 'directory') { + let state = uncheckedPath(path, kind) + if (!state) return + for (let attempt = 0; attempt < 2; attempt++) { + windowsSecurity('inspect', path, kind) + // ChangeTime covers ACL edits and ordinary child creation. Recheck once when it + // changes, rejecting replacement but leaving an actively changing object uncached. + state = checkedPath(path, kind, state) + if (!state) return + } +} + +/** Serving processes must let control leases renew while Windows checks permissions. */ +export async function assertPrivatePathAsync(path: string, kind: 'file' | 'directory') { + let state = uncheckedPath(path, kind) + if (!state) return + for (let attempt = 0; attempt < 2; attempt++) { + await windowsSecurityAsync('inspect', path, kind) + state = checkedPath(path, kind, state) + if (!state) return + } +} + +export function ensurePrivateDirectory(path: string) { + if (!isAbsolute(path)) throw new Error('Runtime directory must be absolute.') + if (process.platform !== 'win32') { + mkdirSync(path, { recursive: true, mode: 0o700 }) + assertPrivatePath(path, 'directory') + return + } + try { + assertPrivatePath(path, 'directory') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + windowsSecurity('ensure', path, 'directory') + rememberPermissions(path, inspectPath(path, 'directory')) + } +} + +export async function ensurePrivateDirectoryAsync(path: string) { + if (process.platform !== 'win32') return ensurePrivateDirectory(path) + try { + await assertPrivatePathAsync(path, 'directory') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + await windowsSecurityAsync('ensure', path, 'directory') + rememberPermissions(path, inspectPath(path, 'directory')) + } +} + +export function createPrivateTempDirectory(prefix: string) { + // macOS's per-user temp path leaves too little room for Unix socket names. + const directory = process.platform === 'win32' ? tmpdir() : '/tmp' + const path = join(realpathSync(directory), `${prefix}${randomUUID()}`) + ensurePrivateDirectory(path) + return path +} + +export function cleanupEndpoint(path: string) { + if (!isPipe(path)) rmSync(path, { force: true }) +} + +export function assertPrivateEndpoint(path: string) { + if (!isPipe(path)) throw new Error('Expected a Windows runtime pipe.') + windowsSecurity('inspect', path, 'pipe') +} + +export function protectPipe(path: string) { + if (!isPipe(path)) throw new Error('Expected a Windows runtime pipe.') + windowsSecurity('protect', path, 'pipe') +} + +export async function assertPrivateEndpointAsync(path: string, signal?: AbortSignal) { + if (!isPipe(path)) throw new Error('Expected a Windows runtime pipe.') + await windowsSecurityAsync('inspect', path, 'pipe', signal) +} + +export async function protectPipeAsync(path: string, signal?: AbortSignal) { + if (!isPipe(path)) throw new Error('Expected a Windows runtime pipe.') + await windowsSecurityAsync('protect', path, 'pipe', signal) +} + +export function windowsPowerShellEnvironment(env: NodeJS.ProcessEnv = process.env) { + // PowerShell 7's module paths can load incompatible modules in Windows PowerShell 5. + return Object.fromEntries( + Object.entries(env).filter(([key]) => key.toLowerCase() !== 'psmodulepath') + ) +} + +// Built-in Windows PowerShell supplies the platform ACL API without an npm/native dependency. +// JSON stays in a child-only environment value, never executable source or shell arguments. +// Helper input is independent of host stdio and the Windows console's input code page. +function securityCommand(action: 'ensure' | 'inspect' | 'protect', path: string, kind: string) { + const systemRoot = process.env.SystemRoot + if (!systemRoot || !isAbsolute(systemRoot)) + throw new Error('Windows SystemRoot is required to verify private runtime storage.') + return { + file: join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + args: [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(SECURITY_SCRIPT, 'utf16le').toString('base64') + ], + options: { + encoding: 'utf8' as const, + timeout: 15_000, + maxBuffer: 64 * 1024, + windowsHide: true, + env: { + ...windowsPowerShellEnvironment(), + TEMPAD_RUNTIME_ACL_REQUEST: JSON.stringify({ action, path, kind }) + } + } + } +} + +function windowsSecurity(action: 'ensure' | 'inspect' | 'protect', path: string, kind: string) { + const { file, args, options } = securityCommand(action, path, kind) + try { + execFileSync(file, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }) + } catch (cause) { + throw securityFailure(cause, path, kind) + } +} + +async function windowsSecurityAsync( + action: 'ensure' | 'inspect' | 'protect', + path: string, + kind: string, + signal?: AbortSignal +) { + const { file, args, options } = securityCommand(action, path, kind) + await new Promise((resolve, reject) => { + const child = execFile(file, args, { ...options, signal }, (error, stdout, stderr) => { + if (error) reject(securityFailure(Object.assign(error, { stdout, stderr }), path, kind)) + else resolve() + }) + child.stdin?.end() + }) +} + +function securityFailure(cause: unknown, path: string, kind: string) { + const error = cause as { stdout?: string; stderr?: string } + const detail = [error.stdout, error.stderr].filter(Boolean).join('\n').trim() + let code: number | string | undefined + let reportedFailure = false + try { + const report = JSON.parse(error.stderr ?? '{}') + code = report.code + reportedFailure = typeof report.message === 'string' + } catch { + // Startup and policy failures need not be JSON; they still fail closed. + } + const failure = new Error( + `Refusing untrusted Windows runtime ${kind}: ${path}${detail ? ` (${detail})` : ''}`, + { + cause + } + ) as NodeJS.ErrnoException + if (code === 2 || code === 3) failure.code = 'ENOENT' + if (code === 231) failure.code = 'EBUSY' + if (code === 'EPIPEUNPROTECTED') failure.code = code + if (!failure.code && kind === 'pipe' && reportedFailure) failure.code = 'EPIPEUNTRUSTED' + return failure +} + +const SECURITY_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +# Module autoload can stall with a filtered host environment. Use the trusted +# built-in modules directly, without scanning host/user module locations. +Import-Module "$PSHOME\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1" -ErrorAction Stop +Import-Module "$PSHOME\Modules\Microsoft.PowerShell.Security\Microsoft.PowerShell.Security.psd1" -ErrorAction Stop +$PSModuleAutoloadingPreference = 'None' +$failureCode = $null +try { + $request = $env:TEMPAD_RUNTIME_ACL_REQUEST | ConvertFrom-Json + $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $allowed = @($user.Value, 'S-1-5-18', 'S-1-5-32-544') + if ($request.kind -eq 'pipe') { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class TempadPipeSecurity { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + [DllImport("advapi32.dll", SetLastError=true)] + static extern uint GetSecurityInfo(SafeFileHandle handle, uint type, uint info, out IntPtr owner, out IntPtr group, out IntPtr dacl, out IntPtr sacl, out IntPtr descriptor); + [DllImport("advapi32.dll", SetLastError=true)] + static extern uint SetSecurityInfo(SafeFileHandle handle, uint type, uint info, IntPtr owner, IntPtr group, IntPtr dacl, IntPtr sacl); + [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string value, uint revision, out IntPtr descriptor, out uint length); + [DllImport("advapi32.dll", SetLastError=true)] + static extern bool GetSecurityDescriptorDacl(IntPtr descriptor, out bool present, out IntPtr dacl, out bool defaulted); + [DllImport("advapi32.dll")] + static extern uint GetSecurityDescriptorLength(IntPtr descriptor); + [DllImport("kernel32.dll")] + static extern IntPtr LocalFree(IntPtr memory); + public static byte[] Inspect(string path, string sddl) { + // PowerShell converts a null string argument to an empty string. + bool protect = !String.IsNullOrEmpty(sddl); + SafeFileHandle opened = CreateFile(path, 0x20000u | (protect ? 0x40000u : 0u), 7, IntPtr.Zero, 3, 0x40000000, IntPtr.Zero); + int openError = Marshal.GetLastWin32Error(); + using (SafeFileHandle handle = opened) { + if (handle.IsInvalid) throw new Win32Exception(openError, "CreateFile failed for the runtime pipe."); + if (protect) { + IntPtr replacement; uint length; + if (!ConvertStringSecurityDescriptorToSecurityDescriptor(sddl, 1, out replacement, out length)) throw new Win32Exception(Marshal.GetLastWin32Error(), "ConvertStringSecurityDescriptorToSecurityDescriptor failed."); + try { + bool present, defaulted; IntPtr acl; + if (!GetSecurityDescriptorDacl(replacement, out present, out acl, out defaulted)) throw new Win32Exception(Marshal.GetLastWin32Error(), "GetSecurityDescriptorDacl failed."); + if (!present || acl == IntPtr.Zero) throw new InvalidOperationException("The replacement pipe descriptor has no discretionary ACL."); + uint changed = SetSecurityInfo(handle, 6, 4u | 0x80000000u, IntPtr.Zero, IntPtr.Zero, acl, IntPtr.Zero); + if (changed != 0) throw new Win32Exception((int)changed, "SetSecurityInfo failed for the runtime pipe."); + } finally { LocalFree(replacement); } + } + IntPtr owner, group, dacl, sacl, descriptor; + uint result = GetSecurityInfo(handle, 6, 5, out owner, out group, out dacl, out sacl, out descriptor); + if (result != 0) throw new Win32Exception((int)result, "GetSecurityInfo failed for the runtime pipe."); + try { + byte[] bytes = new byte[GetSecurityDescriptorLength(descriptor)]; + Marshal.Copy(descriptor, bytes, 0, bytes.Length); + return bytes; + } finally { LocalFree(descriptor); } + } + } +} +'@ + $sddl = $null + if ($request.action -eq 'protect') { + $sddl = 'D:P(D;;GA;;;NU)(A;;GA;;;' + $user.Value + ')(A;;GA;;;SY)(A;;GA;;;BA)' + } + $bytes = [TempadPipeSecurity]::Inspect($request.path, $sddl) + $descriptor = New-Object System.Security.AccessControl.RawSecurityDescriptor($bytes, 0) + } else { + if ($request.action -eq 'ensure' -and ![IO.Directory]::Exists($request.path)) { + $security = New-Object System.Security.AccessControl.DirectorySecurity + $security.SetOwner($user) + $security.SetAccessRuleProtection($true, $false) + foreach ($sid in $allowed) { + $principal = New-Object System.Security.Principal.SecurityIdentifier($sid) + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule($principal, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $security.AddAccessRule($rule) + } + [void][IO.Directory]::CreateDirectory($request.path, $security) + } + try { + $acl = Get-Acl -LiteralPath $request.path + } catch [System.Management.Automation.ItemNotFoundException], [IO.FileNotFoundException], [IO.DirectoryNotFoundException] { + $failureCode = 2 + throw + } + $descriptor = New-Object System.Security.AccessControl.RawSecurityDescriptor($acl.Sddl) + } + if ($allowed -notcontains $descriptor.Owner.Value -or $null -eq $descriptor.DiscretionaryAcl) { + throw 'Unexpected owner or absent discretionary ACL.' + } + $ownAccess = $false + $foreignAccess = $false + $pendingProtection = $request.kind -eq 'pipe' -and $request.action -eq 'inspect' + foreach ($ace in $descriptor.DiscretionaryAcl) { + if ($ace.AceType -eq 'AccessAllowed') { + if ($allowed -notcontains $ace.SecurityIdentifier.Value) { + $foreignAccess = $true + # Windows' initial pipe descriptor can be owned by Administrators and omit + # the user's ACE. Only its default reader grants are retryable, never trusted. + $defaultReader = @('S-1-1-0', 'S-1-5-7') -contains $ace.SecurityIdentifier.Value + $readOnly = ($ace.AccessMask -band 0x7fedff76) -eq 0 + $pendingProtection = $pendingProtection -and $defaultReader -and $readOnly + } + if ($ace.SecurityIdentifier.Value -eq $user.Value) { $ownAccess = $true } + } elseif ($ace.AceType -ne 'AccessDenied') { + throw 'Unsupported ACL entry.' + } + } + if ($foreignAccess -and $pendingProtection) { $failureCode = 'EPIPEUNPROTECTED' } + if (!$ownAccess) { throw 'ACL does not explicitly grant the current user access.' } + if ($foreignAccess) { + throw 'ACL grants another account access.' + } +} catch { + $failure = $_.Exception + while ($failure.InnerException) { $failure = $failure.InnerException } + if (!$failureCode) { $failureCode = $failure.NativeErrorCode } + [Console]::Error.WriteLine((@{ message = $failure.Message; code = $failureCode } | ConvertTo-Json -Compress)) + exit 1 +} +` diff --git a/packages/mcp-server/src/runtime-prototype.ts b/packages/mcp-server/src/runtime-prototype.ts new file mode 100644 index 00000000..d1cd06ae --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'node:url' + +import { runRuntime } from './runtime-prototype/run' + +await runRuntime(fileURLToPath(import.meta.url)) diff --git a/packages/mcp-server/src/runtime-prototype/edge.ts b/packages/mcp-server/src/runtime-prototype/edge.ts new file mode 100644 index 00000000..406dbc53 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/edge.ts @@ -0,0 +1,403 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' + +import { McpError, ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' + +import { captureHostContext, HostContextNotificationSchema } from '../agent-clients/host-context' +import { runtimeIdentity } from '../agent-clients/identity' +import { ClientBindingSubscribeSchema, type ClaudeBinding } from '../client-binding' +import { getMcpServerConfig } from '../config' +import { ConsumerTransport } from '../consumer-transport' +import { releaseManifest } from '../runtime-update/artifact' +import { endLogging, PACKAGE_VERSION } from '../shared' +import { contractHashes, startStableEdge } from '../stable-edge' +import { + connectSocket, + hubSocketPath, + isSocketPending, + supervisorConnection, + unavailableSocket, + type PrototypeConfig +} from './processes' +import { + ControlPeer, + isRuntimeUnavailable, + CONTROL_VERSIONS, + GrantSchema, + RECOVER_MS, + REQUEST_TIMEOUT_MS, + RuntimeDataTransport, + SocketTransport, + unavailable, + type Grant, + type Registration +} from './protocol' +import { readJournalAsync } from './state' + +export async function startPrototypeEdge( + config: PrototypeConfig, + updates?: Registration['updates'] +) { + const registration: Registration = { + sessionId: randomUUID(), + token: randomUUID(), + context: captureHostContext(process.env), + versions: [...CONTROL_VERSIONS], + ...(updates ? { updates } : {}) + } + let control: ControlPeer | undefined + let grant: Grant | undefined + let ready = false + let recovering = false + let lifetime: AbortSignal + let edge: Awaited> | undefined = undefined + let backendHubId: string | undefined + let backendLost = false + let replacing = false + let attaching: Promise | undefined + let openingHubId: string | undefined + let frozenEpoch: number | undefined + let claudeBinding: ClaudeBinding | undefined + let claudeHintRetired = false + let pausedBy: { peer: ControlPeer; hubId: string } | undefined + const runtimeConfig = getMcpServerConfig() + const requestTimeoutMs = Math.max( + REQUEST_TIMEOUT_MS, + ...[ + runtimeConfig.toolTimeoutMs, + runtimeConfig.applyCanvasTimeoutMs, + runtimeConfig.getCodeTimeoutMs, + runtimeConfig.getDesignSystemTimeoutMs, + runtimeConfig.pageStructureTimeoutMs + ].map((value) => value + 10_000) + ) + + function attachControl(signal = lifetime): Promise { + // A lost Hub can close data and control at the same time. Both recovery + // paths must share registration, or a duplicate control peer pins updates. + attaching ??= openControl(signal).finally(() => { + attaching = undefined + }) + return attaching + } + + async function openControl(signal: AbortSignal) { + const socket = await supervisorConnection(config, signal) + const peer = new ControlPeer() + peer.onerror = () => void peer.close() + try { + await peer.connect(new SocketTransport(socket)) + const next = await peer.request( + { method: 'tempad/runtime-hello', params: registration }, + GrantSchema, + { + timeout: 5000, + signal + } + ) + if (!peer.transport) throw new Error('Supervisor closed during registration.') + signal.throwIfAborted() + grant = next + const previous = control + control = peer + ready = true + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-admission'), + params: z.object({ hubId: z.uuid(), paused: z.boolean() }) + }), + async ({ params }, extra) => { + if (control !== peer || params.hubId !== grant?.hubId || !edge) + throw unavailable('Stale runtime admission barrier.') + if (params.paused) { + pausedBy = { peer, hubId: params.hubId } + await edge.pause(extra.signal) + } else { + pausedBy = undefined + edge.resume() + } + if (control !== peer || params.hubId !== grant?.hubId) + throw unavailable('Runtime admission owner changed.') + return {} + } + ) + peer.setNotificationHandler( + z.object({ + method: z.literal('notifications/tempad/runtime-replaced'), + params: GrantSchema + }), + async ({ params }) => { + grant = params + ready = true + if (backendHubId && (backendLost || backendHubId !== params.hubId)) void replaceBackend() + else if (backendHubId) { + await acknowledgeReady() + resumeVerified() + } + } + ) + peer.onclose = () => { + if (control !== peer) return + ready = false + if (!lifetime.aborted) void recover() + } + await previous?.close() + if (backendHubId && (backendLost || backendHubId !== next.hubId)) void replaceBackend() + else if (backendHubId) { + await acknowledgeReady() + resumeVerified() + } + } catch (error) { + await peer.close() + throw error + } + } + + function resumeVerified() { + // Reconnecting data to the same owner must not undo its active barrier. + if (pausedBy && pausedBy.peer === control && pausedBy.hubId === backendHubId) return + pausedBy = undefined + edge?.resume() + } + + async function acknowledgeReady(error?: string) { + await control!.request( + { + method: 'tempad/runtime-ready', + params: { + hubId: grant!.hubId, + ...(error ? { error } : {}) + } + }, + ResultSchema, + { timeout: 5000, signal: lifetime } + ) + } + + async function replaceBackend() { + if (replacing || !edge || lifetime.aborted) return + replacing = true + const deadline = performance.now() + RECOVER_MS + try { + while (!lifetime.aborted && performance.now() < deadline) { + try { + await edge.replaceBackend() + return + } catch (error) { + if (error instanceof McpError && error.code === -32002) + await acknowledgeReady(error.message).catch(() => {}) + if (!lifetime.aborted) await delay(100, undefined, { signal: lifetime }) + } + } + if (!lifetime.aborted) + process.stderr.write( + 'Runtime backend recovery timed out; the MCP session remains open and no request was replayed.\n' + ) + } catch { + // Closing the host cancels connection recovery, never the remote write. + } finally { + replacing = false + } + } + + async function recover() { + if (recovering || lifetime.aborted) return + recovering = true + const deadline = performance.now() + RECOVER_MS + try { + do { + try { + await attachControl() + return + } catch { + if (lifetime.aborted) return + await delay(200, undefined, { signal: lifetime }) + } + } while (performance.now() < deadline) + process.stderr.write( + 'Runtime supervisor recovery exhausted. The MCP session remains open; writes are unavailable and were not replayed.\n' + ) + } catch { + // Host closure ends recovery without creating another process. + } finally { + recovering = false + } + } + + async function openTransport(signal: AbortSignal) { + if (!control?.transport || !ready) await attachControl(signal) + else + grant = await control.request({ method: 'tempad/runtime-refresh' }, GrantSchema, { + timeout: 5000, + signal + }) + let socket + try { + socket = await connectSocket(hubSocketPath(config.root, config.directories?.runtime), signal) + } catch (error) { + if (unavailableSocket(error) || isSocketPending(error)) + throw unavailable('Runtime data endpoint is unavailable.') + throw error + } + openingHubId = grant!.hubId + const wire = new SocketTransport(socket) + try { + await new Promise((resolve, reject) => { + const id = randomUUID() + const timeout = setTimeout( + () => finish(new Error('Runtime data attachment timed out.')), + 5000 + ) + const abort = () => finish(new Error('Runtime data attachment cancelled.')) + const finish = (error?: Error) => { + clearTimeout(timeout) + signal.removeEventListener('abort', abort) + if (error) reject(error) + else resolve() + } + wire.onclose = () => finish(new Error('Runtime data socket closed.')) + wire.onerror = finish + wire.onmessage = (message: JSONRPCMessage) => { + if (!('method' in message) && message.id === id) + finish( + 'error' in message + ? new McpError(message.error.code, message.error.message, message.error.data) + : undefined + ) + else finish(new Error('Unexpected runtime attachment response.')) + } + signal.addEventListener('abort', abort, { once: true }) + if (signal.aborted) { + abort() + return + } + void wire + .start() + .then(() => + wire.send({ + jsonrpc: '2.0', + id, + method: 'tempad/runtime-attach', + params: { sessionId: registration.sessionId, token: registration.token } + }) + ) + .catch(finish) + }) + return new RuntimeDataTransport(wire, { + generation: () => grant!.generation, + admit: (generation) => ready && grant?.generation === generation, + role: 'edge', + timeoutMs: requestTimeoutMs, + started: true + }) + } catch (error) { + await wire.close() + throw error + } + } + + const host = new ConsumerTransport() + edge = await startStableEdge(host, { + ...(config.release + ? { + validateContract: async (observed, version, previous) => { + const journal = await readJournalAsync(config.root) + const artifact = [journal?.active, journal?.candidate, journal?.previous].find( + (value) => value?.version === version + ) + if (!artifact) throw new Error('The backend is not a retained release artifact.') + const manifest = releaseManifest(artifact) + const hashes = contractHashes(observed) + if ( + hashes.wireHash !== manifest.wireHash || + hashes.presentationHash !== manifest.presentationHash || + (previous && + (manifest.guidanceEpoch !== frozenEpoch || + hashes.wireHash !== contractHashes(previous).wireHash)) + ) + throw new McpError( + -32002, + 'MCP_RUNTIME_RESTART_REQUIRED: runtime contract or guidance changed.' + ) + frozenEpoch ??= manifest.guidanceEpoch + } + } + : {}), + version: PACKAGE_VERSION, + requestTimeoutMs, + runtimeWaitMs: 10_000, + onError: (error) => process.stderr.write(`Runtime edge: ${error.message}\n`), + onBinding: (binding) => { + claudeHintRetired = true + if ('kind' in binding) claudeBinding = binding + }, + onBackendClose: () => { + backendLost = true + void replaceBackend() + }, + onBackendReady: async () => { + if (openingHubId !== grant?.hubId) + throw new Error('Runtime changed during backend initialization.') + backendHubId = openingHubId + backendLost = false + await acknowledgeReady() + resumeVerified() + }, + open: async (_initialize, hostSignal) => { + lifetime = hostSignal + const startup = backendHubId ? undefined : AbortSignal.timeout(RECOVER_MS) + const signal = startup ? AbortSignal.any([hostSignal, startup]) : hostSignal + // Retry only pre-MCP admission. Initialization and business requests are + // never replayed, and permanent compatibility/ownership failures escape. + for (;;) { + try { + return await openTransport(signal) + } catch (error) { + hostSignal.throwIfAborted() + if (!startup || (!startup.aborted && !isRuntimeUnavailable(error))) throw error + if (!startup.aborted) await delay(200, undefined, { signal }).catch(() => {}) + hostSignal.throwIfAborted() + if (startup.aborted) + throw unavailable('Runtime startup recovery timed out; retry the MCP connection.') + } + } + }, + register: async (client) => { + await client.notification({ + method: HostContextNotificationSchema.shape.method.value, + params: registration.context + }) + // As in the byte bridge, a remembered session is only a reconnect claim; + // the replacement Hub still requires a fresh matching Claude hook. + const identity = claudeBinding ?? runtimeIdentity(process.env).client + await client.notification({ + method: 'notifications/tempad/client-event', + params: { + event: 'connect', + kind: identity.kind, + sessionId: identity.sessionId + } + }) + const sessionId = process.env.CLAUDE_CODE_SESSION_ID + await client.notification({ + method: ClientBindingSubscribeSchema.shape.method.value, + ...(!claudeHintRetired && sessionId && sessionId.length <= 256 + ? { params: { sessionId } } + : {}) + }) + } + }) + const close = () => void edge!.close() + process.once('SIGINT', close) + process.once('SIGTERM', close) + process.stdin.once('end', close) + if (await edge.closed) process.exitCode = 1 + process.off('SIGINT', close) + process.off('SIGTERM', close) + process.stdin.off('end', close) + await control?.close() + process.stdin.destroy() + endLogging() +} diff --git a/packages/mcp-server/src/runtime-prototype/hooks.ts b/packages/mcp-server/src/runtime-prototype/hooks.ts new file mode 100644 index 00000000..3853e465 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/hooks.ts @@ -0,0 +1,383 @@ +import { + deserializeMessage, + STDIO_DEFAULT_MAX_BUFFER_SIZE +} from '@modelcontextprotocol/sdk/shared/stdio.js' +import { createServer } from 'node:net' +import { join } from 'node:path' +import { z } from 'zod' + +import { ClientHookRequestSchema, type ClientHook } from '../agent-clients/hooks' +import { writeDurableJson } from '../durable-json' +import { + ExpiredHookError, + HOOK_RECEIPT_LIMIT, + LEGACY_HOOK_EXHAUSTED_RESULT, + LegacyHookCapacityError, + hookCutoff, + hookPressureCutoff, + hookRetentionCutoff, + hookSignature, + isExpiredHookResult +} from '../hook-receipts' +import { cleanupEndpoint, runtimeEndpoint } from '../runtime-platform' +import { listenPrivate } from './processes' +import { readPrivateJsonAsync } from './state' + +const InboxSchema = z.array( + z.object({ + input: ClientHookRequestSchema.shape.params, + admittedHubId: z.uuid().optional(), + recordedAt: z.number().optional(), + retired: z.literal('expired').optional(), + result: z.record(z.string(), z.unknown()).optional() + }) +) +const ReceiverSchema = z.object({ hubId: z.uuid(), freshAfter: z.number().optional() }) +class FutureHookError extends Error {} + +/** Keep the installed hook path alive while the data-plane Hub is stopped. */ +export async function startHookGateway( + root: string, + deliver: (input: ClientHook, admittedHubId?: string) => Promise>, + onFault: (error?: unknown) => void = () => {}, + socketPath = runtimeEndpoint(join(root, 'run'), 'mcp.sock'), + retireLegacy: () => Promise = async () => { + throw new Error('Legacy lifecycle retirement requires a connected Hub.') + }, + signal?: AbortSignal +) { + const path = join(root, 'hooks.json') + const stored = await readPrivateJsonAsync( + path, + z.object({ + version: z.literal(2), + cutoff: z.number(), + entries: InboxSchema, + receiver: ReceiverSchema.optional() + }) + ) + signal?.throwIfAborted() + let entries = stored?.entries ?? [] + let cutoff = stored?.cutoff ?? 0 + let receiver = stored?.receiver + let flushing: Promise | undefined + let legacyRetiredFor: string | undefined + let closed = false + const sockets = new Set() + const pendingEntry = (entry: (typeof entries)[number]) => !entry.result && !entry.retired + const capacityFailure = () => + entries.filter(pendingEntry).length >= HOOK_RECEIPT_LIMIT + ? new Error('Hook inbox capacity reached.') + : undefined + const pending = () => entries.some(pendingEntry) + const legacyExhausted = () => + entries.some((entry) => entry.result?.runtimeHookLegacyUpgradeRequired === true) || + entries.filter((entry) => entry.input.version === 1).length >= HOOK_RECEIPT_LIMIT + const evidenceHub = (input: ClientHook) => + receiver && + (input.version !== 2 || + receiver.freshAfter === undefined || + input.issuedAt > receiver.freshAfter) + ? receiver.hubId + : undefined + function save(next: typeof entries, nextReceiver = receiver, nextCutoff = cutoff) { + if (closed) throw new Error('The hook inbox owner has closed.') + try { + writeDurableJson(path, { + version: 2, + cutoff: nextCutoff, + entries: next, + receiver: nextReceiver + }) + entries = next + receiver = nextReceiver + cutoff = nextCutoff + } catch (error) { + onFault(error) + throw error + } + } + function compact(reserveResult = false) { + let nextCutoff = hookRetentionCutoff(cutoff) + const legacyCutoff = hookRetentionCutoff(0) + if (reserveResult) { + const completed: number[] = [] + const unresolved: number[] = [] + for (const entry of entries) + if (entry.input.version === 2) + (pendingEntry(entry) ? unresolved : completed).push(entry.input.issuedAt) + nextCutoff = hookPressureCutoff(nextCutoff, completed, unresolved) + } + const next = entries.filter( + (entry) => + pendingEntry(entry) || entry.input.version !== 2 || entry.input.issuedAt > nextCutoff + ) + const compacted = next.map((entry) => { + if ( + entry.input.version === 1 && + entry.result && + !entry.retired && + (entry.recordedAt === undefined || entry.recordedAt <= legacyCutoff) + ) { + // Legacy senders have no timestamp. Keep their identity as an old-reader-safe + // tombstone: forgetting it would let a retry become fresh lifecycle evidence. + return { + input: entry.input, + retired: 'expired' as const, + ...(entry.result.runtimeHookLegacyUpgradeRequired + ? { result: LEGACY_HOOK_EXHAUSTED_RESULT } + : {}) + } + } + return entry + }) + if ( + nextCutoff !== cutoff || + compacted.length !== entries.length || + compacted.some((entry, index) => entry !== entries[index]) + ) + save(compacted, receiver, nextCutoff) + } + async function flush() { + if (flushing) return flushing + const run = async () => { + compact() + while (true) { + const entry = entries.find(pendingEntry) + if (!entry) { + // A later Stop (including one admitted during this drain) must commit + // before an earlier delivery fault can reopen the data plane. + const receiverId = receiver?.hubId ?? '' + if (legacyExhausted() && legacyRetiredFor !== receiverId) { + try { + await retireLegacy() + legacyRetiredFor = receiverId + } catch (error) { + onFault(error) + throw error + } + } + if (pending() || receiverId !== (receiver?.hubId ?? '')) continue + onFault(undefined) + return + } + const result = await deliver( + entry.input, + entry.admittedHubId === evidenceHub(entry.input) ? entry.admittedHubId : undefined + ).catch((error: unknown) => { + onFault(error) + throw error + }) + const retired = isExpiredHookResult(result) + const outcome = retired ? { retired: 'expired' as const, result } : { result } + save( + entries.map((value) => + value === entry ? { ...value, ...outcome, recordedAt: Date.now() } : value + ), + receiver, + retired && entry.input.version === 2 ? Math.max(cutoff, entry.input.issuedAt) : cutoff + ) + } + } + flushing = run() + try { + await flushing + } finally { + flushing = undefined + } + } + const server = createServer((socket) => { + if (sockets.size >= 128) { + socket.destroy() + return + } + sockets.add(socket) + socket.setEncoding('utf8') + let buffer = '' + let notifications = 0 + const timeout = setTimeout(() => socket.destroy(), 5000) + socket.on('error', () => socket.destroy()) + socket.on('close', () => { + clearTimeout(timeout) + sockets.delete(socket) + }) + socket.on('data', (chunk) => { + buffer += chunk + if (Buffer.byteLength(buffer) > STDIO_DEFAULT_MAX_BUFFER_SIZE) { + socket.destroy() + return + } + let newline + while ((newline = buffer.indexOf('\n')) >= 0) { + let message + try { + message = deserializeMessage(buffer.slice(0, newline)) + } catch { + socket.destroy() + return + } + buffer = buffer.slice(newline + 1) + // Old CLIs send identity notifications before initialize. Consume that + // bounded prefix so the host receives an actionable initialize error. + if (!('id' in message)) { + if (++notifications > 16) { + socket.destroy() + return + } + continue + } + socket.removeAllListeners('data') + void (async () => { + const parsed = ClientHookRequestSchema.safeParse(message) + if (!parsed.success) { + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32002, + message: 'MCP_RUNTIME_RESTART_REQUIRED: connect through the stable MCP edge.' + } + }) + '\n' + ) + return + } + const input = parsed.data.params + if (input.version === 2 && input.profile.kind !== 'release') + throw new Error('Lifecycle hook belongs to a different runtime profile.') + const admittedAt = Date.now() + const known = entries.some((entry) => entry.input.invocationId === input.invocationId) + compact() + hookCutoff(input, cutoff, admittedAt) + if (input.version === 2 && !known) { + // A retryable clock skew must not evict completed results before admission. + if (input.issuedAt > admittedAt) + throw new FutureHookError( + 'Lifecycle invocation is ahead of the runtime clock; retry the same invocation.' + ) + compact(true) + hookCutoff(input, cutoff, admittedAt) + } + const existing = entries.find((entry) => entry.input.invocationId === input.invocationId) + if (existing && hookSignature(existing.input) !== hookSignature(input)) + throw new Error('Lifecycle invocation identity changed.') + if (existing?.retired) throw new ExpiredHookError(cutoff) + if (!existing) { + if (input.version === 1 && legacyExhausted()) { + await flush() + throw new LegacyHookCapacityError() + } + const error = capacityFailure() + const historyFull = + input.version === 2 && + entries.filter((entry) => entry.input.version === 2).length >= HOOK_RECEIPT_LIMIT + if (error || historyFull) { + const fault = + error ?? + new Error('Hook result history is waiting for unresolved lifecycle effects.') + onFault(fault) + throw fault + } + save([...entries, { input, admittedHubId: evidenceHub(input) }]) + if (legacyExhausted()) onFault(new LegacyHookCapacityError()) + } + if (input.version === 2) { + void flush().catch(() => {}) + } else await flush() + const result = entries.find( + (entry) => entry.input.invocationId === input.invocationId + )?.result + if (result?.runtimeHookLegacyUpgradeRequired) throw new LegacyHookCapacityError() + if (input.version === 1 && !result) + throw new Error('Hook remains queued during runtime handoff.') + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: + input.version === 2 + ? { + ...result, + receipt: { + version: 2, + invocationId: input.invocationId, + state: result ? 'applied' : 'accepted' + } + } + : result + }) + '\n' + ) + })().catch((error: unknown) => { + if ( + error instanceof ExpiredHookError || + error instanceof FutureHookError || + error instanceof LegacyHookCapacityError + ) + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32001, + message: error.message, + ...(error instanceof FutureHookError || error instanceof LegacyHookCapacityError + ? { + data: { + code: + error instanceof FutureHookError + ? 'MCP_HOOK_CLOCK_AHEAD' + : 'MCP_HOOK_LEGACY_UPGRADE_REQUIRED' + } + } + : {}) + } + }) + '\n' + ) + else socket.destroy() + }) + return + } + }) + }) + compact() + cleanupEndpoint(socketPath) + await listenPrivate(server, socketPath, signal) + onFault( + capacityFailure() ?? + (pending() + ? new Error('Lifecycle hooks remain queued.') + : legacyExhausted() + ? new LegacyHookCapacityError() + : undefined) + ) + return { + flush, + setReceiver(hubId: string) { + if (receiver?.hubId === hubId) return + // A real Hub replacement requires new evidence. The first receiver still + // accepts hooks issued while it was starting; reconnecting the same Hub + // preserves its boundary and each invocation's original admission. + save( + entries, + ReceiverSchema.parse({ + hubId, + ...(receiver || stored !== undefined + ? { freshAfter: Math.max(receiver?.freshAfter ?? 0, Date.now()) } + : {}) + }) + ) + }, + get pending() { + return pending() + }, + get legacyExhausted() { + return legacyExhausted() + }, + close: async () => { + closed = true + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + cleanupEndpoint(socketPath) + } + } +} diff --git a/packages/mcp-server/src/runtime-prototype/hub-control.ts b/packages/mcp-server/src/runtime-prototype/hub-control.ts new file mode 100644 index 00000000..1d53f928 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/hub-control.ts @@ -0,0 +1,328 @@ +import type { Socket } from 'node:net' + +import { ResultSchema, type RequestId } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { finished } from 'node:stream/promises' +import { z } from 'zod' + +import { ClientHookRequestSchema } from '../agent-clients/hooks' +import { + ControlPeer, + LEASE_MS, + negotiate, + RegistrationSchema, + RECOVER_MS, + SocketTransport, + type Registration, + unavailable +} from './protocol' + +export const AUTOMATIC_IDLE_MS = 5000 + +export class HubControl { + readonly hubId = randomUUID() + private generation = 0 + private peer?: ControlPeer + private expires = 0 + private gated: boolean + private stopping = false + private retained = false + private readonly sessions = new Map< + string, + Registration & { generation: number; attached: boolean; expires: number } + >() + + constructor( + private readonly secret: string, + private readonly now = () => performance.now(), + private readonly handoff?: { + paused?: boolean + inspect: () => { + drained: boolean + deadline: number + blockers: string[] + version: string + autoBlockers?: string[] + idleForMs?: number + } + flush: () => void + stop: () => Promise + retireLegacyHooks?: () => void + hook: ( + input: import('../agent-clients/hooks').ClientHook, + fresh: boolean + ) => Record + } + ) { + this.gated = handoff?.paused ?? false + } + + admitting() { + return this.available() && !this.gated + } + + keepAlive() { + return this.available() && this.retained + } + + available(generation = this.generation) { + return !!this.peer && generation === this.generation && this.now() < this.expires + } + + get currentGeneration() { + return this.generation + } + + pendingConnections() { + let pending = 0 + for (const [id, session] of this.sessions) { + if (session.attached) continue + if (session.generation !== this.generation || session.expires <= this.now()) + this.sessions.delete(id) + else pending++ + } + return pending + } + + private attachedSessions() { + return [...this.sessions].filter(([, session]) => session.attached).map(([id]) => id) + } + + async attach(socket: Socket) { + const peer = new ControlPeer() + let acquired = false + let pendingStop: { id: RequestId; signal: AbortSignal } | undefined + const cancelStop = () => { + if (!pendingStop) return + pendingStop.signal.removeEventListener('abort', cancelStop) + pendingStop = undefined + this.stopping = false + } + const transport = new SocketTransport(socket, async (message) => { + if (!pendingStop || !('result' in message) || message.id !== pendingStop.id) return + // The SDK schedules response writes after the handler returns. Commit the + // shutdown only after its reply was written, then finish the pipe gracefully. + pendingStop.signal.removeEventListener('abort', cancelStop) + pendingStop = undefined + try { + const ended = finished(socket, { + readable: false, + cleanup: true, + signal: AbortSignal.timeout(5000) + }) + socket.end() + await ended + } finally { + // The flushed acknowledgement commits the stop. A later pipe failure + // cannot revoke the exit already promised to the supervisor. + // Partial shutdown also stays fenced until the process exits. + await this.handoff!.stop() + } + }) + peer.onerror = () => { + cancelStop() + void peer.close() + } + peer.onclose = () => { + cancelStop() + if (this.peer === peer) { + this.peer = undefined + this.retained = false + } + } + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-acquire'), + params: z + .object({ + secret: z.string(), + versions: z.array(z.number().int().positive()).max(16) + }) + .strict() + }), + ({ params }) => { + const version = negotiate(params.versions) + if (acquired) throw unavailable('Control lease acquisition is single-use.') + if (params.secret !== this.secret || (this.peer && this.peer !== peer && this.available())) + throw unavailable('Runtime control is already owned or its capability is invalid.') + acquired = true + this.generation++ + void this.peer?.close() + this.peer = peer + this.expires = this.now() + LEASE_MS + return { version, generation: this.generation, hubId: this.hubId, hubPid: process.pid } + } + ) + const requireLease = () => { + if (this.peer !== peer || !this.available()) throw unavailable() + } + peer.setRequestHandler( + z.object({ method: z.literal('tempad/runtime-retire-legacy-hooks') }), + () => { + requireLease() + if (!this.handoff?.retireLegacyHooks) + throw unavailable('This Hub cannot retire legacy lifecycle evidence.') + this.handoff.retireLegacyHooks() + this.handoff.flush() + return {} + } + ) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-renew') }), () => { + requireLease() + this.expires = this.now() + LEASE_MS + return {} + }) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-hook'), + params: z + .object({ + input: ClientHookRequestSchema.shape.params, + admittedHubId: z.uuid().optional() + }) + .strict() + }), + ({ params }) => { + requireLease() + if (!this.handoff) throw unavailable('Durable hook admission is unavailable.') + return this.handoff.hook(params.input, params.admittedHubId === this.hubId) + } + ) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-retain'), + params: z.object({ retained: z.boolean() }) + }), + ({ params }) => { + requireLease() + this.retained = params.retained + return {} + } + ) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-inspect') }), () => { + requireLease() + if (!this.handoff) throw unavailable('This Hub does not support durable handoff.') + return { + ...this.handoff.inspect(), + attachedSessions: this.attachedSessions(), + gated: this.gated, + handoffVersion: 1 + } + }) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-gate'), + params: z.object({ closed: z.boolean() }).strict() + }), + ({ params }) => { + requireLease() + if (!this.handoff || this.stopping) + throw unavailable('This Hub cannot change its admission gate.') + if (!params.closed) this.handoff.flush() + this.gated = params.closed + return { + ...this.handoff.inspect(), + attachedSessions: this.attachedSessions(), + gated: this.gated, + handoffVersion: 1 + } + } + ) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-stop'), + params: z + .object({ + pausedSessions: z.array(z.uuid()).max(128), + automatic: z.literal(true).optional() + }) + .strict() + }), + ({ params }, extra) => { + requireLease() + const handoff = this.handoff + const state = handoff?.inspect() + if (this.stopping) throw unavailable('Runtime shutdown has already been admitted.') + if (!this.gated || !handoff || !state?.drained) + throw unavailable('Runtime has not drained; shutdown was not admitted.') + if (this.attachedSessions().some((id) => !params.pausedSessions.includes(id))) + throw unavailable('An MCP data session has no acknowledged admission barrier.') + if ( + params.automatic && + (!state.autoBlockers || + state.autoBlockers.length || + (state.idleForMs ?? 0) < AUTOMATIC_IDLE_MS) + ) + throw unavailable('Runtime is no longer idle or eligible for automatic replacement.') + handoff.flush() + this.stopping = true + pendingStop = { id: extra.requestId, signal: extra.signal } + extra.signal.addEventListener('abort', cancelStop, { once: true }) + if (extra.signal.aborted) cancelStop() + return { stopping: this.hubId } + } + ) + peer.setRequestHandler( + z.object({ method: z.literal('tempad/runtime-register'), params: RegistrationSchema }), + ({ params }) => { + requireLease() + negotiate(params.versions) + // Expire abandoned registrations before applying the capacity limit. + this.pendingConnections() + const existing = this.sessions.get(params.sessionId) + if ( + existing && + (existing.token !== params.token || + JSON.stringify(existing.context) !== JSON.stringify(params.context)) + ) + throw unavailable('Runtime session identity changed; reconnect the MCP host.') + if (!existing && this.sessions.size >= 128) throw unavailable('Too many runtime sessions.') + this.sessions.set(params.sessionId, { + ...params, + generation: this.generation, + attached: existing?.attached ?? false, + expires: this.now() + RECOVER_MS + }) + return { generation: this.generation } + } + ) + await peer.connect(transport) + return peer + } + + claimSession(sessionId: string, token: string) { + const session = this.sessions.get(sessionId) + if ( + !session || + this.stopping || + session.token !== token || + !this.available(session.generation) || + (!session.attached && session.expires <= this.now()) + ) + throw unavailable('Runtime session has no current supervisor grant.') + session.attached = true + return session + } + + admits(sessionId: string, generation: number, method?: string) { + return ( + this.available(generation) && + this.sessions.get(sessionId)?.generation === generation && + (!this.gated || ['initialize', 'tools/list', 'ping'].includes(method ?? '')) + ) + } + + forget(sessionId: string) { + this.sessions.delete(sessionId) + } + close() { + return this.peer?.close() + } +} + +export const LeaseSchema = ResultSchema.extend({ + version: z.number().int().positive(), + generation: z.number().int().positive(), + hubId: z.uuid(), + hubPid: z.number().int().positive() +}) diff --git a/packages/mcp-server/src/runtime-prototype/probe.ts b/packages/mcp-server/src/runtime-prototype/probe.ts new file mode 100644 index 00000000..2f7e78a2 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/probe.ts @@ -0,0 +1,135 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { createHash } from 'node:crypto' +import { realpathSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' + +import { hubEnvironment } from '../process-environment' +import { createPrivateTempDirectory, runtimeEndpoint } from '../runtime-platform' +import { contractHashes, readRuntimeContract } from '../stable-edge' +import { connectSocket, fingerprintBundle } from './processes' +import { ControlPeer, RECOVER_MS, SocketTransport } from './protocol' +import { ArtifactSchema } from './state' + +/** Run only an explicitly offered local entry, with private stores and no user host identity. */ +export async function probeArtifact(entry: string) { + entry = realpathSync(entry) + const bundle = fingerprintBundle(entry) + const root = createPrivateTempDirectory('tp-probe-') + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entry, '--root', root], + stderr: 'pipe', + env: Object.fromEntries( + Object.entries(hubEnvironment(process.env)).filter( + (value): value is [string, string] => typeof value[1] === 'string' + ) + ) + }) + let stderr = Buffer.alloc(0) + transport.stderr?.on('data', (chunk: Buffer) => { + stderr = Buffer.concat([stderr, chunk.subarray(-16_384)]).subarray(-16_384) + }) + const client = new Client({ name: 'tempad-artifact-probe', version: '1' }) + const control = new ControlPeer() + let pids: number[] | undefined + let result: + | { + artifact: z.infer + contract: string + wireHash: string + presentationHash: string + } + | undefined + let failure: unknown + let phase = 'initialize' + const started = performance.now() + try { + // Initial attachment can use the edge's full recovery budget. Allow the + // following SDK exchange without extending any business-request deadline. + await client.connect(transport, { timeout: RECOVER_MS + 5000 }) + phase = 'runtime-status' + await control.connect( + new SocketTransport(await connectSocket(runtimeEndpoint(root, 'supervisor.sock'))) + ) + const state = await control.request( + { method: 'tempad/runtime-status' }, + ResultSchema.extend({ + handoffVersion: z.literal(1), + hubPid: z.number().int().positive(), + supervisorPid: z.number().int().positive() + }), + { timeout: 5000 } + ) + pids = [state.hubPid, state.supervisorPid] + phase = 'contract' + const contract = await readRuntimeContract(client, AbortSignal.timeout(10_000), 5000) + const artifact = ArtifactSchema.parse({ + entry, + bundle, + version: client.getServerVersion()!.version + }) + if (fingerprintBundle(entry) !== bundle) + throw new Error('Local runtime changed during verification.') + result = { + artifact, + ...contractHashes(contract), + contract: createHash('sha256') + .update(JSON.stringify({ ...contract, pages: [...contract.pages] })) + .digest('hex') + } + phase = 'shutdown' + } catch (error) { + failure = error + } finally { + await control.close() + await client.close() + await transport.close() + } + // No forced PID kill: an uncertain probe retains its private evidence and + // cannot authorize activation. The prototype's idle-owner rule stops children. + let exited = false + for (let attempt = 0; pids && attempt < 60; attempt++) { + exited = pids.every((pid) => { + try { + process.kill(pid, 0) + return false + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return true + throw error + } + }) + if (exited) break + await delay(100) + } + if (!exited) { + const diagnostic = { + phase, + elapsedMs: Math.round(performance.now() - started), + pids, + failure: + failure instanceof Error ? (failure.stack ?? failure.message).slice(-16_384) : undefined, + stderr: stderr.toString('utf8') + } + try { + writeFileSync(join(root, 'probe-failure.json'), JSON.stringify(diagnostic), { mode: 0o600 }) + } catch { + // Evidence collection must not replace the original failure or permit cleanup. + } + const reason = pids ? 'did not stop' : `ownership is unknown after ${phase} failed` + // Release preparation runs this probe in a subprocess. Include its bounded + // private diagnostic so an uncaught failure retains the evidence in stderr. + throw Object.assign( + new Error(`Local runtime probe ${reason}; preserve ${root} for diagnosis.`, { + cause: failure + }), + { diagnostic } + ) + } + rmSync(root, { recursive: true, force: true }) + if (failure) throw failure + return result! +} diff --git a/packages/mcp-server/src/runtime-prototype/processes.ts b/packages/mcp-server/src/runtime-prototype/processes.ts new file mode 100644 index 00000000..a0c5e7f9 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/processes.ts @@ -0,0 +1,399 @@ +import type { Socket } from 'node:net' + +import { spawn, type ChildProcess } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, writeFileSync, realpathSync } from 'node:fs' +import { connect, type Server } from 'node:net' +import { dirname, join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import lockfile from 'proper-lockfile' + +import { hubEnvironment } from '../process-environment' +import { + assertPrivateEndpointAsync, + assertPrivatePath, + ensurePrivateDirectory, + isPipe, + protectPipeAsync, + runtimeEndpoint +} from '../runtime-platform' +import { verifyReleaseArtifact } from '../runtime-update/artifact' +import { assertTrustedSocket, ensureFile } from '../shared' +import { isRuntimeUnavailable, RECOVER_MS, unavailable } from './protocol' +import { ConfigSchema, readJournal, type Artifact, type PrototypeConfig } from './state' +export type { PrototypeConfig } from './state' + +export function fingerprintBundle(entry: string): string { + const directory = dirname(entry) + const hash = createHash('sha256') + for (const name of readdirSync(directory) + .filter((name) => name.endsWith('.mjs')) + .sort()) + hash + .update(name) + .update('\0') + .update(readFileSync(join(directory, name))) + return hash.digest('hex') +} + +export function runtimePaths(config: PrototypeConfig) { + return ( + config.directories ?? { + runtime: join(config.root, 'run'), + state: join(config.root, 'state'), + assets: join(config.root, 'assets'), + log: join(config.root, 'log') + } + ) +} + +export function hubSocketPath(root: string, runtime = join(root, 'run')) { + return runtimeEndpoint(runtime, 'runtime-hub.sock') +} + +export function supervisorSocketPath(config: PrototypeConfig) { + return runtimeEndpoint(config.directories?.runtime ?? config.root, 'supervisor.sock') +} + +export function prototypeConfig(root: string, entry: string, role = 'edge'): PrototypeConfig { + root = realpathSync(root) + ensurePrivateDirectory(root) + const bundle = fingerprintBundle(entry) + const path = join(root, 'prototype.json') + try { + writeFileSync(path, JSON.stringify({ version: 1, entry, bundle, secret: randomUUID() }), { + flag: 'wx', + mode: 0o600 + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + assertPrivatePath(path, 'file') + const config = ConfigSchema.parse(JSON.parse(readFileSync(path, 'utf8'))) + if (config.entry !== entry || config.bundle !== bundle) { + const journal = readJournal(root) + const known = [journal?.active, journal?.candidate, journal?.previous].some( + (artifact) => artifact?.entry === entry && artifact.bundle === bundle + ) + if (role === 'supervisor' || !known) + throw new Error( + 'Runtime prototype bundle changed. Stop the isolated run and use a fresh root.' + ) + } + return { ...config, root } +} + +export function launch(config: PrototypeConfig, role: 'supervisor' | 'hub', artifact?: Artifact) { + const entry = artifact?.entry ?? config.entry + if (artifact?.release) verifyReleaseArtifact(artifact) + else if (config.release && !artifact) + verifyReleaseArtifact({ + entry, + bundle: config.bundle, + version: JSON.parse(readFileSync(join(config.release.directory, 'package.json'), 'utf8')) + .version, + release: config.release + }) + if (fingerprintBundle(entry) !== (artifact?.bundle ?? config.bundle)) + throw new Error('Verified runtime bundle changed before launch.') + const child = spawn(process.execPath, [entry, '--root', config.root, '--role', role], { + detached: true, + windowsHide: true, + stdio: 'ignore', + env: hubEnvironment(process.env) + }) + child.on('error', () => {}) + child.unref() + return child +} + +export function connectSocket(path: string, signal?: AbortSignal): Promise { + if (!isPipe(path)) assertTrustedSocket(path) + return new Promise((resolve, reject) => { + const socket = connect(path) + let settled = false + let deadline = performance.now() + 1000 + const inspection = new AbortController() + const inspectionSignal = signal + ? AbortSignal.any([signal, inspection.signal]) + : inspection.signal + let pendingInspection: Error | undefined + const cleanup = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', abort) + socket.off('error', fail) + socket.off('close', closed) + socket.off('connect', connected) + } + const fail = (error: Error) => { + if (settled) return + settled = true + cleanup() + inspection.abort() + socket.destroy() + reject(error) + } + const abort = () => fail(new Error('Runtime connection cancelled.')) + const closed = () => + fail( + Object.assign(new Error('Runtime connection closed before permission verification.'), { + code: 'ECONNRESET' + }) + ) + const timedOut = () => + fail( + pendingInspection ?? + Object.assign(new Error('Runtime connection timed out.'), { code: 'ETIMEDOUT' }) + ) + let timer = setTimeout(timedOut, 1000) + const verify = async () => { + while (!settled && !socket.destroyed) { + try { + if (isPipe(path)) await assertPrivateEndpointAsync(path, inspectionSignal) + return + } catch (error) { + if ( + !['EPIPEUNPROTECTED', 'EBUSY'].includes((error as NodeJS.ErrnoException).code ?? '') || + performance.now() >= deadline + ) + throw error + pendingInspection = error instanceof Error ? error : new Error(String(error)) + await delay(25, undefined, { signal: inspectionSignal }) + } + } + } + const connected = () => { + if (settled) return + if (isPipe(path)) { + // Connection acquisition and ACL inspection have separate bounds. The + // helper may take 15s, but must not block the Hub's 10s control lease. + clearTimeout(timer) + deadline = performance.now() + 15_000 + timer = setTimeout(timedOut, 15_000) + } + // Keep the connection open while inspecting: a connected pipe cannot be replaced by + // a different owner between this check and the caller's first authenticated frame. + void verify().then( + () => { + if (settled) return + if (socket.destroyed) return closed() + settled = true + cleanup() + resolve(socket) + }, + (error) => fail(error instanceof Error ? error : new Error(String(error))) + ) + } + socket.once('error', fail) + socket.once('close', closed) + socket.once('connect', connected) + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) abort() + }) +} + +export async function waitForSocket( + path: string, + signal?: AbortSignal, + timeoutMs = 5000, + processId?: number +) { + if (timeoutMs <= 0) throw unavailable('Runtime process has not published its endpoint yet.') + const until = performance.now() + timeoutMs + const deadline = AbortSignal.timeout(Math.ceil(timeoutMs)) + const bounded = signal ? AbortSignal.any([signal, deadline]) : deadline + try { + do { + if (processId !== undefined) { + try { + process.kill(processId, 0) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error + throw new Error('Runtime process exited before endpoint publication.', { cause: error }) + } + } + try { + const socket = await connectSocket(path, bounded) + if (bounded.aborted) { + socket.destroy() + bounded.throwIfAborted() + } + return socket + } catch (error) { + bounded.throwIfAborted() + if (!unavailableSocket(error) && !isSocketPending(error)) throw error + await delay(100, undefined, { signal: bounded }) + } + } while (performance.now() < until) + } catch (error) { + signal?.throwIfAborted() + if (!deadline.aborted) throw error + } + throw unavailable('Runtime process has not published its endpoint yet.') +} + +export async function waitForLaunchedSocket( + path: string, + child: ChildProcess, + signal: AbortSignal, + timeoutMs = RECOVER_MS +) { + const ended = new AbortController() + const failed = (error: Error) => ended.abort(error) + const exited = (code: number | null, exitSignal: NodeJS.Signals | null) => + failed(new Error(`Runtime process exited before endpoint publication (${exitSignal ?? code}).`)) + child.once('error', failed) + child.once('exit', exited) + if (child.exitCode != null || child.signalCode != null) exited(child.exitCode, child.signalCode) + try { + return await waitForSocket(path, AbortSignal.any([signal, ended.signal]), timeoutMs) + } catch (error) { + signal.throwIfAborted() + ended.signal.throwIfAborted() + throw error + } finally { + child.off('error', failed) + child.off('exit', exited) + } +} + +export function unavailableSocket(error: unknown) { + return ['ENOENT', 'ECONNREFUSED', 'EBUSY'].includes((error as NodeJS.ErrnoException).code ?? '') +} + +export function isSocketPending(error: unknown) { + // A protecting listener rejects early connections. Closure or slow acquisition + // cannot prove that its owner is absent and authorize a competing launch. + return ['EPIPEUNPROTECTED', 'ETIMEDOUT', 'ECONNRESET', 'EPIPE'].includes( + (error as NodeJS.ErrnoException).code ?? '' + ) +} + +export async function supervisorConnection(config: PrototypeConfig, signal: AbortSignal) { + const deadline = AbortSignal.timeout(RECOVER_MS) + const attemptSignal = AbortSignal.any([signal, deadline]) + for (;;) { + signal.throwIfAborted() + if (deadline.aborted) throw unavailable('Runtime supervisor startup timed out.') + try { + return await supervisorConnectionAttempt(config, attemptSignal) + } catch (error) { + signal.throwIfAborted() + if (!deadline.aborted && !isRuntimeUnavailable(error) && !isSocketPending(error)) throw error + await delay(200, undefined, { signal: attemptSignal }).catch(() => {}) + } + } +} + +// Retry only endpoint acquisition, before a control request can be dispatched. +async function supervisorConnectionAttempt(config: PrototypeConfig, signal: AbortSignal) { + const path = supervisorSocketPath(config) + try { + return await connectSocket(path, signal) + } catch (error) { + if (!unavailableSocket(error)) throw error + } + try { + return await withStartupLock(config.root, async () => { + try { + return await connectSocket(path, signal) + } catch (error) { + if (!unavailableSocket(error)) throw error + } + signal.throwIfAborted() + // Another cold launch may have promoted the retained control artifact while + // this edge waited. Never launch the stale snapshot supplied by the caller. + if (config.release) { + const { resumeColdStart } = await import('../runtime-update/cold-start') + config = await resumeColdStart(config) + } + const owner = join(config.root, 'supervisor.lock') + // A supervisor may be waiting for a crashed Hub's writer lease before it can + // publish its endpoint. Repeated edge retries must not spawn losing owners. + if (!existsSync(owner) || !(await lockfile.check(owner, { stale: 5000 }))) + launch(config, 'supervisor') + return await waitForSocket(path, signal) + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ELOCKED') throw error + return waitForSocket(path, signal) + } +} + +export async function withStartupLock(root: string, action: () => Promise, wait = false) { + const path = join(root, 'startup.lock') + ensureFile(path) + const unlock = await lockfile.lock(path, { + retries: wait ? { retries: 900, factor: 1, minTimeout: 100, maxTimeout: 100 } : 0, + stale: 10_000 + }) + try { + return await action() + } finally { + await unlock() + } +} + +export async function listenPrivate(server: Server, path: string, signal?: AbortSignal) { + signal?.throwIfAborted() + const connectionListeners = isPipe(path) ? server.rawListeners('connection') : undefined + const rejectUnprotected = (socket: Socket) => socket.destroy() + if (connectionListeners) { + server.removeAllListeners('connection') + server.on('connection', rejectUnprotected) + } + await new Promise((resolve, reject) => { + const inspection = new AbortController() + let settled = false + const cleanup = () => { + clearTimeout(timer) + server.off('error', fail) + server.off('close', closed) + signal?.removeEventListener('abort', abort) + } + const fail = (error: Error) => { + if (settled) return + settled = true + inspection.abort() + cleanup() + server.close() + reject(error) + } + const closed = () => fail(new Error('Runtime listener closed during startup.')) + const abort = () => fail(signal?.reason ?? new Error('Runtime listener startup cancelled.')) + let timer = setTimeout(() => fail(new Error('Runtime listener startup timed out.')), 5000) + server.once('error', fail) + server.once('close', closed) + signal?.addEventListener('abort', abort, { once: true }) + const mask = process.umask(0o177) + try { + server.listen(path, () => { + clearTimeout(timer) + timer = setTimeout(() => fail(new Error('Runtime listener protection timed out.')), 15_000) + void (async () => { + // Reject connections until the real ACL is installed and verified. + if (isPipe(path)) await protectPipeAsync(path, inspection.signal) + if (settled) return + if (connectionListeners) { + server.off('connection', rejectUnprotected) + for (const listener of connectionListeners) + server.on('connection', listener as (socket: Socket) => void) + } + cleanup() + settled = true + resolve() + })().catch((error: unknown) => { + fail(error instanceof Error ? error : new Error(String(error))) + }) + }) + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))) + } finally { + process.umask(mask) + } + }) + if (signal?.aborted) { + server.close() + signal.throwIfAborted() + } +} diff --git a/packages/mcp-server/src/runtime-prototype/protocol.ts b/packages/mcp-server/src/runtime-prototype/protocol.ts new file mode 100644 index 00000000..52d20498 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/protocol.ts @@ -0,0 +1,238 @@ +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { + JSONRPCMessage, + Request, + Notification, + Result +} from '@modelcontextprotocol/sdk/types.js' +import type { Socket } from 'node:net' + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { Protocol } from '@modelcontextprotocol/sdk/shared/protocol.js' +import { + serializeMessage, + STDIO_DEFAULT_MAX_BUFFER_SIZE +} from '@modelcontextprotocol/sdk/shared/stdio.js' +import { + isJSONRPCRequest, + JSONRPCMessageSchema, + McpError +} from '@modelcontextprotocol/sdk/types.js' +import { z } from 'zod' + +import { HostContextSchema } from '../agent-clients/host-context' +import { decodeConsumerMessage, encodeConsumerMessage } from '../consumer-transport' + +// Version 2 includes replacement, bounded waiting and admission barriers. +// Version 1 was an unpublished prototype without hook admission provenance. +export const CONTROL_VERSIONS = [2] as const +export const LEASE_MS = 10_000 +export const RENEW_MS = 2_000 +export const RECOVER_MS = 90_000 +export const REQUEST_TIMEOUT_MS = 330_000 + +export const IdSchema = z.uuid() +export const RegistrationSchema = z + .object({ + sessionId: IdSchema, + token: IdSchema, + context: HostContextSchema, + updates: z + .object({ + policy: z.enum(['off', 'stable', 'pinned']), + version: z.string(), + manifestHash: z.string().regex(/^[a-f0-9]{64}$/) + }) + .strict() + .optional(), + versions: z.array(z.number().int().positive()).min(1).max(16) + }) + .strict() +export type Registration = z.infer +export const GrantSchema = z + .object({ + version: z.number().int().positive(), + generation: z.number().int().positive(), + hubId: IdSchema, + hubPid: z.number().int().positive(), + supervisorPid: z.number().int().positive() + }) + .strict() +export type Grant = z.infer +export const FrameSchema = z.object({ + method: z.literal('tempad/runtime-frame'), + params: z + .object({ + generation: z.number().int().positive(), + deadline: z.number().finite().positive().optional(), + message: JSONRPCMessageSchema + }) + .strict() +}) + +export function unavailable( + message = 'Runtime recovery is in progress; this call was not dispatched.' +) { + return new McpError(-32001, message, { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false }) +} + +export function isRuntimeUnavailable(error: unknown): boolean { + return ( + error instanceof McpError && + error.code === -32001 && + (error.data as { code?: unknown } | undefined)?.code === 'MCP_RUNTIME_UNAVAILABLE' && + (error.data as { dispatched?: unknown }).dispatched === false + ) +} + +export function negotiate( + versions: readonly number[], + supported: readonly number[] = CONTROL_VERSIONS +) { + const version = Math.max(...supported.filter((value) => versions.includes(value))) + if (Number.isFinite(version)) return version + throw new McpError( + -32002, + 'MCP_RUNTIME_RESTART_REQUIRED: restart MCP with a compatible runtime.', + { + offered: versions, + supported + } + ) +} + +/** Private control RPC uses the SDK's IDs, errors and cancellation, without MCP initialization. */ +export class ControlPeer extends Protocol { + protected assertCapabilityForMethod() {} + protected assertNotificationCapability() {} + protected assertRequestHandlerCapability() {} + protected assertTaskCapability() {} + protected assertTaskHandlerCapability() {} +} + +/** Socket ownership and write callbacks also settle pending sends on close/error. */ +export class SocketTransport extends StdioServerTransport { + private closed = false + private pendingBytes = 0 + + constructor( + private readonly socket: Socket, + private readonly afterSend?: (message: JSONRPCMessage) => Promise + ) { + super(socket, socket) + socket.once('close', () => void this.close()) + } + + override async start() { + if (this.closed || this.socket.destroyed) throw new Error('Runtime socket is closed.') + const error = this.onerror + this.onerror = (value) => { + error?.(value) + void this.close() + } + await super.start() + } + + override async send(message: JSONRPCMessage) { + const data = serializeMessage(message) + const size = Buffer.byteLength(data) + if (this.closed || this.pendingBytes + size > STDIO_DEFAULT_MAX_BUFFER_SIZE) + throw new Error('Runtime socket is closed or its output buffer is full.') + this.pendingBytes += size + try { + await new Promise((resolve, reject) => { + const closed = () => reject(new Error('Runtime socket closed during send.')) + this.socket.once('close', closed) + this.socket.write(data, (error) => { + this.socket.off('close', closed) + if (error) reject(error) + else resolve() + }) + }) + } finally { + this.pendingBytes -= size + } + await this.afterSend?.(message) + } + + override async close() { + if (this.closed) return + this.closed = true + this.socket.destroy() + await super.close() + } +} + +/** Direct edge/Hub data survives a supervisor crash; the lease still gates new requests. */ +export class RuntimeDataTransport implements Transport { + onmessage?: Transport['onmessage'] + onclose?: Transport['onclose'] + onerror?: Transport['onerror'] + + constructor( + private readonly wire: Transport, + private readonly options: { + generation: () => number + admit: (generation: number, method?: string) => boolean + role: 'edge' | 'hub' + timeoutMs?: number + started?: boolean + } + ) {} + + async start() { + this.wire.onclose = () => this.onclose?.() + this.wire.onerror = (error) => this.onerror?.(error) + this.wire.onmessage = (value) => { + try { + const { params } = FrameSchema.parse(value) + const message = + this.options.role === 'hub' ? encodeConsumerMessage(params.message) : params.message + if ( + isJSONRPCRequest(message) && + (!this.options.admit(params.generation, message.method) || + !params.deadline || + params.deadline <= Date.now()) + ) { + void this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32001, + message: 'Runtime unavailable; request was not dispatched.', + data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } + } + }).catch((error: Error) => this.onerror?.(error)) + return + } + this.onmessage?.(message) + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))) + void this.close() + } + } + if (!this.options.started) await this.wire.start() + } + + async send(value: JSONRPCMessage) { + const generation = this.options.generation() + const message = this.options.role === 'hub' ? decodeConsumerMessage(value) : value + const request = 'method' in message && 'id' in message + if (request && !this.options.admit(generation, message.method)) throw unavailable() + await this.wire.send({ + jsonrpc: '2.0', + method: 'tempad/runtime-frame', + params: { + generation, + message, + ...(request + ? { deadline: Date.now() + (this.options.timeoutMs ?? REQUEST_TIMEOUT_MS) } + : {}) + } + }) + } + + close() { + return this.wire.close() + } +} diff --git a/packages/mcp-server/src/runtime-prototype/run.ts b/packages/mcp-server/src/runtime-prototype/run.ts new file mode 100644 index 00000000..f9ca703e --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/run.ts @@ -0,0 +1,105 @@ +import { isAbsolute, join } from 'node:path' + +import { assertPrivatePath } from '../runtime-platform' +import { ConfigSchema, readPrivateJson } from './state' + +export async function runRuntime(entry: string) { + const args = process.argv.slice(2) + const root = args[1] + const role = args[2] === '--role' ? args[3] : 'edge' + const command = args[2] !== '--role' ? args[2] : undefined + if ( + args[0] !== '--root' || + !root || + !isAbsolute(root) || + (args.length !== 2 && + !( + (args.length === 4 && args[2] === '--role') || + (args.length === 4 && command === '--switch') || + (args.length === 3 && + ['--status', '--runtime-status', '--recover-runtime'].includes(command ?? '')) || + (args.length === 4 && command === '--resolve-runtime-block') + )) || + !['edge', 'supervisor', 'hub'].includes(role ?? '') || + process.env.TEMPAD_MCP_DEV_CHECKOUT + ) + throw new Error( + 'Use runtime-prototype.mjs --root without checkout overrides.' + ) + assertPrivatePath(root, 'directory') + const saved = readPrivateJson(join(root, 'prototype.json'), ConfigSchema) + for (const [key, directory] of Object.entries({ + TEMPAD_MCP_RUNTIME_DIR: saved?.directories?.runtime ?? join(root, 'run'), + TEMPAD_MCP_STATE_DIR: saved?.directories?.state ?? join(root, 'state'), + TEMPAD_MCP_LOG_DIR: saved?.directories?.log ?? join(root, 'log'), + TEMPAD_MCP_ASSET_DIR: saved?.directories?.assets ?? join(root, 'assets') + })) + process.env[key] = directory + + const { prototypeConfig, hubSocketPath } = await import('./processes') + const { readJournal } = await import('./state') + const { endLogging } = await import('../shared') + const config = prototypeConfig(root, entry, role) + try { + if (command) { + const { supervisorConnection, connectSocket } = await import('./processes') + const { ControlPeer, SocketTransport } = await import('./protocol') + const { ResultSchema } = await import('@modelcontextprotocol/sdk/types.js') + const peer = new ControlPeer() + try { + await peer.connect( + new SocketTransport(await supervisorConnection(config, AbortSignal.timeout(10_000))) + ) + if (command === '--runtime-status' || command === '--resolve-runtime-block') { + const { runRuntimeRecovery } = await import('../runtime-recovery') + await runRuntimeRecovery(args.slice(2), () => + connectSocket(hubSocketPath(root, config.directories?.runtime)) + ) + } else { + const result = await peer.request( + command === '--switch' + ? { method: 'tempad/runtime-update', params: { entry: args[3]! } } + : { + method: + command === '--recover-runtime' + ? 'tempad/runtime-recover' + : 'tempad/runtime-status' + }, + ResultSchema, + { timeout: 500_000 } + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + } + } finally { + await peer.close() + process.stdin.destroy() + endLogging() + } + } else if (role === 'hub') { + const { startHub } = await import('../hub-runtime') + await startHub({ + entryPath: entry, + prototype: { + secret: config.secret, + root, + socketPath: hubSocketPath(root, config.directories?.runtime), + release: !!config.release, + paused: readJournal(root)?.phase !== 'committed' + } + }) + } else if (role === 'supervisor') { + const { startSupervisor } = await import('./supervisor') + await startSupervisor(config) + } else { + const { startPrototypeEdge } = await import('./edge') + await startPrototypeEdge(config) + } + } catch (error) { + process.stderr.write( + `Runtime prototype failed: ${error instanceof Error ? error.message : String(error)}\n` + ) + process.exitCode = 1 + process.stdin.destroy() + endLogging() + } +} diff --git a/packages/mcp-server/src/runtime-prototype/state.ts b/packages/mcp-server/src/runtime-prototype/state.ts new file mode 100644 index 00000000..00c27d80 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/state.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { z } from 'zod' + +import { writeDurableJson } from '../durable-json' +import { assertPrivatePath, assertPrivatePathAsync } from '../runtime-platform' + +export const ArtifactSchema = z + .object({ + entry: z.string().refine(isAbsolute), + bundle: z.string().regex(/^[a-f0-9]{64}$/), + version: z.string().regex(/^\d+\.\d+\.\d+$/), + release: z + .object({ + directory: z.string().refine(isAbsolute), + manifestHash: z.string().regex(/^[a-f0-9]{64}$/), + integrity: z.string().optional() + }) + .strict() + .optional() + }) + .strict() +export type Artifact = z.infer +export const ConfigSchema = z + .object({ + version: z.literal(1), + entry: z.string().refine(isAbsolute), + bundle: z.string().regex(/^[a-f0-9]{64}$/), + secret: z.uuid(), + directories: z + .object({ + runtime: z.string().refine(isAbsolute), + state: z.string().refine(isAbsolute), + assets: z.string().refine(isAbsolute), + log: z.string().refine(isAbsolute) + }) + .strict() + .optional(), + release: ArtifactSchema.shape.release + }) + .strict() +export type PrototypeConfig = z.infer & { root: string } +export const EndpointsSchema = z + .object({ + wsPort: z.number().int().min(1).max(65535), + httpPort: z.number().int().min(1).max(65535), + accessToken: z.string().min(32), + legacyAccessToken: z.string().min(32) + }) + .strict() +export type Endpoints = z.infer +export const JournalSchema = z + .object({ + version: z.literal(1), + revision: z.number().int().nonnegative(), + phase: z.enum(['committed', 'preparing', 'old-stopped', 'candidate-ready']), + coldStart: z.literal(true).optional(), + active: ArtifactSchema, + candidate: ArtifactSchema.optional(), + previous: ArtifactSchema.optional(), + oldHub: z.object({ id: z.uuid(), pid: z.number().int().positive() }).optional(), + failedBundles: z.array(z.string()), + failure: z.string().optional() + }) + .strict() +export type Journal = z.infer + +export function readPrivateJson( + path: string, + schema: T +): z.output | undefined { + for (let attempt = 0; ; attempt++) { + try { + assertPrivatePath(path, 'file') + return schema.parse(JSON.parse(readFileSync(path, 'utf8'))) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return + // A writer can atomically replace a snapshot during Windows ACL inspection. + // Retry once from a fresh trust check, never accept the unchecked replacement. + if (attempt === 0 && code === 'ERUNTIMEPATHCHANGED') continue + throw error + } + } +} + +export function readEndpoints(root: string) { + return readPrivateJson(join(root, 'endpoints.json'), EndpointsSchema) +} + +export async function readPrivateJsonAsync( + path: string, + schema: T +): Promise | undefined> { + for (let attempt = 0; ; attempt++) { + try { + await assertPrivatePathAsync(path, 'file') + return schema.parse(JSON.parse(readFileSync(path, 'utf8'))) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') return + if (attempt === 0 && code === 'ERUNTIMEPATHCHANGED') continue + throw error + } + } +} + +export function readJournalAsync(root: string) { + return readPrivateJsonAsync(join(root, 'switch.json'), JournalSchema) +} +export function writeEndpoints(root: string, endpoints: Endpoints) { + writeDurableJson(join(root, 'endpoints.json'), EndpointsSchema.parse(endpoints)) +} +export function readJournal(root: string) { + return readPrivateJson(join(root, 'switch.json'), JournalSchema) +} +export function writeJournal(root: string, journal: Journal) { + writeDurableJson(join(root, 'switch.json'), JournalSchema.parse(journal)) +} + +export function newerVersion(candidate: string, active: string) { + const next = candidate.split('.').map(Number) + const prior = active.split('.').map(Number) + for (let index = 0; index < 3; index++) { + if (next[index] !== prior[index]) return next[index]! > prior[index]! + } + return false +} diff --git a/packages/mcp-server/src/runtime-prototype/status.ts b/packages/mcp-server/src/runtime-prototype/status.ts new file mode 100644 index 00000000..f0d5fe3e --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/status.ts @@ -0,0 +1,33 @@ +import type { Registration } from './protocol' + +import { VersionSchema } from '../runtime-update/manifest' +import { newerVersion } from './state' + +/** Project version evidence only; registrations also contain private host context and capabilities. */ +export function controlComponentStatus( + supervisorVersion: string, + registrations: Registration[], + availableVersions: string[] +) { + const latest = availableVersions.reduce( + (latest, version) => (newerVersion(version, latest) ? version : latest), + supervisorVersion + ) + const component = (value?: string) => { + const version = VersionSchema.safeParse(value).data ?? null + const pendingVersion = version && newerVersion(latest, version) ? latest : null + return { version, pendingVersion, restartRequired: version ? pendingVersion !== null : null } + } + const supervisor = component(supervisorVersion) + const edges = registrations.map((registration) => component(registration.updates?.version)) + const components = [supervisor, ...edges] + return { + supervisor, + edges, + restartRequired: components.some((value) => value.restartRequired === true) + ? true + : components.some((value) => value.restartRequired === null) + ? null + : false + } +} diff --git a/packages/mcp-server/src/runtime-prototype/supervisor.ts b/packages/mcp-server/src/runtime-prototype/supervisor.ts new file mode 100644 index 00000000..b3eeaca5 --- /dev/null +++ b/packages/mcp-server/src/runtime-prototype/supervisor.ts @@ -0,0 +1,1097 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:net' +import { dirname, join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import lockfile from 'proper-lockfile' +import { z } from 'zod' + +import { parseHubRuntimeIdentity } from '../runtime-identity' +import { cleanupEndpoint, runtimeEndpoint } from '../runtime-platform' +import { releaseManifest, verifyReleaseArtifact } from '../runtime-update/artifact' +import { pruneCache } from '../runtime-update/cache' +import { acquireRelease, ReleaseUpdates } from '../runtime-update/manager' +import { + automaticCompatibilityReasons, + coldCompatibilityReasons, + compatibilityReasons +} from '../runtime-update/manifest' +import { ensureDir, ensureFile, HUB_LOCK_STALE_MS, PACKAGE_VERSION, SOCK_PATH } from '../shared' +import { startHookGateway } from './hooks' +import { AUTOMATIC_IDLE_MS, LeaseSchema } from './hub-control' +import { probeArtifact } from './probe' +import { + connectSocket, + fingerprintBundle, + hubSocketPath, + isSocketPending, + runtimePaths, + supervisorSocketPath, + launch, + listenPrivate, + waitForLaunchedSocket, + waitForSocket, + unavailableSocket, + type PrototypeConfig +} from './processes' +import { + ControlPeer, + CONTROL_VERSIONS, + isRuntimeUnavailable, + negotiate, + RECOVER_MS, + RegistrationSchema, + RENEW_MS, + SocketTransport, + unavailable, + type Registration +} from './protocol' +import { + newerVersion, + readJournal, + readJournalAsync, + writeJournal, + type Artifact, + type Journal +} from './state' +import { controlComponentStatus } from './status' + +const InspectionSchema = ResultSchema.extend({ + drained: z.boolean(), + deadline: z.number(), + blockers: z.array(z.string()), + version: z.string(), + gated: z.boolean(), + handoffVersion: z.literal(1), + attachedSessions: z.array(z.uuid()).max(128), + autoBlockers: z.array(z.string()).optional(), + idleForMs: z.number().nonnegative().optional() +}) +const AUTOMATIC_RETRY_MS = 60_000 +type Lease = z.infer +type Edge = { + registration: Registration + ready: boolean + error?: string +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +export async function startSupervisor(config: PrototypeConfig) { + const lockPath = join(config.root, 'supervisor.lock') + ensureFile(lockPath) + const unlock = await lockfile.lock(lockPath, { retries: 0, stale: 5000, update: 1000 }) + const socketPath = supervisorSocketPath(config) + const paths = runtimePaths(config) + const control: Artifact = { + entry: config.entry, + bundle: config.bundle, + version: PACKAGE_VERSION, + release: config.release + } + const peers = new Set() + const edges = new Map() + const pausedEdges = new Map() + let hub: ControlPeer | undefined + let lease: Lease | undefined + let pendingHub: + | { artifact: Artifact; child: ReturnType; exited: boolean } + | undefined + let hooks: Awaited> | undefined + let journal: Journal = readJournal(config.root) ?? { + version: 1, + revision: 0, + phase: 'committed', + active: control, + failedBundles: [] + } + let closing = false + let transitioning = false + let renewing = false + let automaticChecking = false + let automaticRetryAt = 0 + let automaticError: string | undefined + let hookFault: string | undefined + let timer: ReturnType | undefined + const lifetime = new AbortController() + const pause = (milliseconds: number) => + delay(milliseconds, undefined, { signal: lifetime.signal }) + const stop = () => void close() + const updates: ReleaseUpdates | undefined = config.release + ? new ReleaseUpdates({ + root: config.root, + enrolled: () => + !transitioning && + [...edges.values()].some((edge) => edge.registration.updates?.policy === 'stable'), + acquire: async (version, signal, cursor) => { + await requireHub().request( + { method: 'tempad/runtime-retain', params: { retained: true } }, + ResultSchema + ) + try { + const result = await acquireRelease( + join(dirname(config.entry), 'runtime-installer.mjs'), + join(config.root, 'cache'), + version, + { + active: journal.active, + control, + previous: journal.previous, + excludedBundles: journal.failedBundles + }, + signal, + cursor + ) + await pruneCache( + join(config.root, 'cache'), + [ + control, + journal.active, + journal.previous, + journal.candidate, + result.candidate, + updates?.status.candidate, + updates?.status.manualCandidate + ], + signal + ) + return result + } finally { + await hub + ?.request( + { method: 'tempad/runtime-retain', params: { retained: false } }, + ResultSchema + ) + .catch(() => {}) + } + } + }) + : undefined + + function activationBlockers(candidate: Artifact, automatic = false) { + let reasons: string[] + try { + const compare = automatic ? automaticCompatibilityReasons : compatibilityReasons + const next = releaseManifest(candidate) + reasons = [ + ...new Set([ + ...compare(releaseManifest(journal.active), next), + ...compatibilityReasons(releaseManifest(control), next) + ]) + ] + } catch (error) { + return [`Retained release verification failed: ${String(error)}`] + } + if (!newerVersion(candidate.version, journal.active.version)) + reasons.push('Candidate is not newer than the active runtime') + if (journal.failedBundles.includes(candidate.bundle)) + reasons.push('Candidate was quarantined after a failed activation') + if ([...edges.values()].some((edge) => edge.registration.updates?.policy !== 'stable')) + reasons.push( + 'An attached MCP client has not opted in; restart it with --runtime-updates=stable or close it' + ) + if ([...edges.values()].some((edge) => !edge.ready)) + reasons.push('An attached MCP client has not finished initialization') + return reasons + } + + function automaticEnabled() { + return ( + !!updates && + !updates.status.disabled && + edges.size > 0 && + [...edges.values()].every( + (edge) => edge.registration.updates?.policy === 'stable' && edge.ready + ) + ) + } + + function automaticBlockers(state: z.infer) { + const reasons = [...state.blockers, ...connectionBlockers(state)] + if (!automaticEnabled()) + reasons.push('All connected clients must opt in and support waiting through runtime switches') + if (hookFault || hooks?.pending) reasons.push('Lifecycle events are awaiting durable delivery') + if (!state.autoBlockers) reasons.push('Hub cannot verify automatic switch readiness') + else reasons.push(...state.autoBlockers) + if (state.idleForMs === undefined || state.idleForMs < AUTOMATIC_IDLE_MS) + reasons.push('Waiting for five seconds without active work') + return reasons + } + + function connectionBlockers(state: z.infer) { + const controlled = new Set([...edges.values()].map((edge) => edge.registration.sessionId)) + return state.attachedSessions.some((id) => !controlled.has(id)) + ? ['An MCP data session is waiting for its control connection to reconnect'] + : [] + } + + // Reuse the existing lease heartbeat; do no artifact or readiness work without + // a staged candidate. A busy Hub is never gated while waiting for an idle gap. + async function maybeActivateAutomatically() { + if ( + automaticChecking || + closing || + transitioning || + !automaticEnabled() || + updates?.status.checking || + journal.phase !== 'committed' || + performance.now() < automaticRetryAt + ) + return + const candidate = updates?.status.candidate + if ( + !candidate || + !newerVersion(candidate.version, journal.active.version) || + journal.failedBundles.includes(candidate.bundle) + ) + return + automaticChecking = true + try { + if (activationBlockers(candidate, true).length) return + const state = await inspect() + if (state.gated || automaticBlockers(state).length) return + automaticRetryAt = performance.now() + AUTOMATIC_RETRY_MS + const result = await update(candidate.entry, candidate, true) + automaticError = result.status === 'activated' ? undefined : result.reason + } catch (error) { + automaticRetryAt = performance.now() + AUTOMATIC_RETRY_MS + automaticError = String(error).slice(0, 2000) + } finally { + automaticChecking = false + } + } + + function persist(next: Journal) { + lifetime.signal.throwIfAborted() + const value = { ...next, revision: journal.revision + 1 } + writeJournal(config.root, value) + journal = value + } + function requireHub() { + if (closing || !hub || !lease) + throw unavailable('The Hub is switching or recovering; no call was dispatched.') + return hub + } + function inspect(timeout = 5000) { + return requireHub().request({ method: 'tempad/runtime-inspect' }, InspectionSchema, { + timeout + }) + } + async function gate(closed: boolean, timeout = 5000) { + if (!closed && (hookFault || hooks?.pending)) + throw unavailable( + `Lifecycle delivery remains uncertain: ${hookFault ?? 'hooks remain queued'}` + ) + return requireHub().request( + { method: 'tempad/runtime-gate', params: { closed } }, + InspectionSchema, + { timeout } + ) + } + function publish() { + writeFileSync( + join(config.root, 'supervisor.json'), + JSON.stringify({ pid: process.pid, entry: config.entry, bundle: config.bundle, ...lease }), + { mode: 0o600 } + ) + } + async function ensureHooks() { + hooks ??= await startHookGateway( + config.root, + (input, admittedHubId) => + requireHub().request( + { method: 'tempad/runtime-hook', params: { input, admittedHubId } }, + ResultSchema, + { + timeout: 5000 + } + ), + (error) => { + const recovering = !!hookFault + hookFault = error === undefined ? undefined : String(error) + if (hookFault || (recovering && !transitioning)) void gate(!!hookFault).catch(() => {}) + }, + config.directories ? SOCK_PATH : runtimeEndpoint(paths.runtime, 'mcp.sock'), + async () => { + await requireHub().request({ method: 'tempad/runtime-retire-legacy-hooks' }, ResultSchema, { + timeout: 5000, + signal: lifetime.signal + }) + }, + lifetime.signal + ) + hooks.setReceiver(lease!.hubId) + } + + async function waitForExit(pid: number, timeout = 15_000) { + const deadline = performance.now() + timeout + while (alive(pid)) { + if (performance.now() >= deadline) + throw new Error('The old Hub is still alive. Its writer ownership will not be replaced.') + await pause(50) + } + } + async function waitForWriters() { + const deadline = performance.now() + HUB_LOCK_STALE_MS + 5000 + const writerLocks = [ + [join(paths.runtime, 'hub.lock'), undefined], + [paths.state, join(paths.state, '.tempad-hub-writer.lock')], + [paths.assets, join(paths.assets, '.tempad-hub-writer.lock')] + ] as const + while (true) { + let locked = false + for (const [path, lockfilePath] of writerLocks) { + if ( + existsSync(path) && + (await lockfile.check(path, { + stale: HUB_LOCK_STALE_MS, + ...(lockfilePath ? { lockfilePath } : {}) + })) + ) + locked = true + } + if (!locked) return + if (performance.now() >= deadline) + throw new Error('Hub writer locks remain owned; runtime recovery is deferred.') + await pause(200) + } + } + + async function connectHub( + artifact: Artifact, + allowLaunch = true, + deadline = performance.now() + RECOVER_MS + ) { + if (closing) throw unavailable('Runtime supervisor is closing.') + let socket + try { + socket = await connectSocket(hubSocketPath(config.root, paths.runtime), lifetime.signal) + } catch (error) { + if (!isSocketPending(error) && !unavailableSocket(error)) throw error + const presentEndpoint = + isSocketPending(error) || (error as NodeJS.ErrnoException).code === 'EBUSY' + const identityPath = join(paths.runtime, 'hub-runtime.json') + const identity = existsSync(identityPath) + ? parseHubRuntimeIdentity(JSON.parse(readFileSync(identityPath, 'utf8'))) + : undefined + const liveIdentity = identity && alive(identity.processId) + if ( + identity === null || + (liveIdentity && + (identity.packageVersion !== artifact.version || + dirname(identity.entryPath) !== dirname(artifact.entry))) || + (pendingHub && !pendingHub.exited && pendingHub.artifact.bundle !== artifact.bundle) + ) + throw new Error('Hub process ownership is uncertain; no replacement was started.', { + cause: error + }) + if (!liveIdentity && !presentEndpoint && (!pendingHub || pendingHub.exited)) { + if (!allowLaunch) throw error + await waitForWriters() + lifetime.signal.throwIfAborted() + const pending = { artifact, child: launch(config, 'hub', artifact), exited: false } + pendingHub = pending + const exited = () => { + pending.exited = true + } + pending.child.once('exit', exited) + pending.child.once('error', exited) + } + const path = hubSocketPath(config.root, paths.runtime) + const remaining = deadline - performance.now() + socket = + pendingHub && !pendingHub.exited + ? await waitForLaunchedSocket(path, pendingHub.child, lifetime.signal, remaining) + : await waitForSocket( + path, + lifetime.signal, + remaining, + liveIdentity ? identity.processId : undefined + ) + } + const peer = new ControlPeer() + peer.onerror = () => void peer.close() + try { + await peer.connect(new SocketTransport(socket)) + const acquired = await peer.request( + { + method: 'tempad/runtime-acquire', + params: { secret: config.secret, versions: [...CONTROL_VERSIONS] } + }, + LeaseSchema, + { timeout: 5000 } + ) + const observed = await peer.request({ method: 'tempad/runtime-inspect' }, InspectionSchema, { + timeout: 5000 + }) + const identity = parseHubRuntimeIdentity( + JSON.parse(readFileSync(join(paths.runtime, 'hub-runtime.json'), 'utf8')) + ) + if ( + !identity || + identity.processId !== acquired.hubPid || + dirname(identity.entryPath) !== dirname(artifact.entry) || + observed.version !== artifact.version || + fingerprintBundle(artifact.entry) !== artifact.bundle + ) + throw new Error('The running Hub does not match the verified local artifact.') + lifetime.signal.throwIfAborted() + pendingHub = undefined + hub = peer + lease = acquired + startRenewal() + await ensureHooks() + publish() + peer.onclose = () => { + if (hub !== peer) return + hub = undefined + if (closing || transitioning) return + if (!edges.size) void close() + else + void recover().catch((error) => { + process.stderr.write(`Runtime recovery failed: ${String(error)}\n`) + void close() + }) + } + } catch (error) { + await peer.close() + // A failed startup can close its control pipe before its process exits + // (for example, after WebSocket binding fails). Keep ownership until the + // launched child has exited, within the original startup recovery budget. + const starting = pendingHub + while ( + starting?.artifact.bundle === artifact.bundle && + !starting.exited && + performance.now() < deadline + ) { + if (starting.child.exitCode != null || starting.child.signalCode != null) + starting.exited = true + else await pause(50) + } + throw error + } + } + + function startRenewal() { + if (timer) return + timer = setInterval(() => { + if (renewing || !hub) return + renewing = true + const connection = hub + void connection + .request({ method: 'tempad/runtime-renew' }, ResultSchema, { timeout: RENEW_MS }) + .catch(() => connection.close()) + .finally(() => { + renewing = false + void maybeActivateAutomatically() + }) + }, RENEW_MS) + } + + async function register(edge: Edge) { + const connection = requireHub() + const current = lease! + await connection.request( + { method: 'tempad/runtime-register', params: edge.registration }, + ResultSchema, + { timeout: 5000 } + ) + if (connection !== hub) throw unavailable() + return { + ...current, + version: negotiate(edge.registration.versions), + supervisorPid: process.pid + } + } + async function reattach() { + await Promise.all( + [...edges].map(async ([peer, edge]) => { + edge.ready = false + edge.error = undefined + try { + const grant = await register(edge) + await peer.notification({ + method: 'notifications/tempad/runtime-replaced', + params: grant + }) + } catch (error) { + if (edges.has(peer)) throw error + } + }) + ) + const deadline = performance.now() + 15_000 + while ([...edges.values()].some((edge) => !edge.ready)) { + if (closing) throw unavailable('Runtime supervisor is closing.') + const failure = [...edges.values()].find((edge) => edge.error)?.error + if (failure) throw new Error(failure) + if (performance.now() >= deadline) + throw new Error('An edge could not verify the replacement runtime.') + await pause(25) + } + } + async function drain(automatic = false) { + const observed = await inspect() + const disconnected = connectionBlockers(observed) + if (disconnected.length) throw new Error(disconnected.join('; ')) + const deadline = Math.min(Date.now() + 330_000, observed.deadline) + const remaining = () => { + const value = deadline - Date.now() + if (value <= 0) throw unavailable('Runtime drain deadline expired; switching is deferred.') + return Math.min(5000, value) + } + await pauseEdges(deadline) + const state = await gate(true, remaining()) + remaining() + const unpaused = connectionBlockers(state) + if (unpaused.length) throw new Error(unpaused.join('; ')) + if (automatic) { + const reasons = automaticBlockers(state) + if (!state.drained || reasons.length) + throw new Error(`Automatic runtime switch deferred: ${reasons.join(', ')}.`) + return + } + let current = state + while (!current.drained) { + if ( + current.blockers.includes('Claude binding without receipt/retry hooks') || + Date.now() >= deadline + ) + throw new Error(`Runtime switch deferred: ${current.blockers.join(', ')}.`) + await pause(50) + current = await inspect(remaining()) + } + remaining() + } + + async function pauseEdges(deadline: number) { + const failed = new AbortController() + const signal = AbortSignal.any([lifetime.signal, failed.signal]) + await Promise.allSettled( + [...edges.keys()].map(async (peer) => { + const hubId = lease!.hubId + pausedEdges.set(peer, hubId) + try { + const remaining = deadline - Date.now() + if (remaining <= 0) throw unavailable('Runtime admission barrier deadline expired.') + await peer.request( + { method: 'tempad/runtime-admission', params: { hubId, paused: true } }, + ResultSchema, + { timeout: remaining, signal } + ) + } catch (error) { + if (edges.has(peer)) failed.abort(error) + } + }) + ) + lifetime.signal.throwIfAborted() + failed.signal.throwIfAborted() + } + + async function resumeEdges() { + const pending = [...pausedEdges] + pausedEdges.clear() + await Promise.all( + pending.map(async ([peer, hubId]) => { + // A verified replacement or a new control registration releases its own + // pause. Only a still-connected old backend needs this abort-path resume. + if (!edges.has(peer) || hubId !== lease?.hubId) return + try { + await peer.request( + { method: 'tempad/runtime-admission', params: { hubId, paused: false } }, + ResultSchema, + { timeout: 5000, signal: lifetime.signal } + ) + } catch { + // A lost resume reply must recover through a fresh verified registration. + await peer.close() + } + }) + ) + } + async function stopHub(automatic = false) { + const current = lease! + await requireHub().request( + { + method: 'tempad/runtime-stop', + params: { + pausedSessions: [...pausedEdges].flatMap(([peer, hubId]) => { + const edge = edges.get(peer) + return edge && hubId === current.hubId ? [edge.registration.sessionId] : [] + }), + ...(automatic ? { automatic: true } : {}) + } + }, + ResultSchema, + { timeout: 5000 } + ) + await waitForExit(current.hubPid) + hub = undefined + await waitForWriters() + } + + async function recoverHub(artifact: Artifact) { + const deadline = performance.now() + RECOVER_MS + while (true) { + try { + await connectHub(artifact, true, deadline) + return + } catch (error) { + const identityPath = join(paths.runtime, 'hub-runtime.json') + const identity = existsSync(identityPath) + ? parseHubRuntimeIdentity(JSON.parse(readFileSync(identityPath, 'utf8'))) + : undefined + // Retry a failed startup only after its process has exited. Never race + // an unknown writer, change ports, or execute a changed local artifact. + if ( + closing || + performance.now() >= deadline || + (existsSync(identityPath) && (!identity || alive(identity.processId))) || + fingerprintBundle(artifact.entry) !== artifact.bundle + ) + throw error + await pause(200) + } + } + } + + async function activate(candidate: Artifact, automatic = false) { + if (candidate.release) { + const blockers = activationBlockers(candidate, automatic) + if (blockers.length) throw new Error(blockers.join('; ')) + } + await drain(automatic) + persist({ + ...journal, + phase: 'preparing', + candidate, + failure: undefined, + oldHub: { id: lease!.hubId, pid: lease!.hubPid } + }) + await stopHub(automatic) + persist({ ...journal, phase: 'old-stopped' }) + await connectHub(candidate) + persist({ ...journal, phase: 'candidate-ready' }) + await finishHandoff() + } + + async function finishHandoff() { + await reattach() + await hooks?.flush() + if (journal.candidate?.release && journal.phase !== 'committed') { + const blockers = journal.coldStart + ? coldCompatibilityReasons( + releaseManifest(journal.active), + releaseManifest(journal.candidate) + ) + : activationBlockers(journal.candidate) + if (blockers.length) throw new Error(blockers.join('; ')) + } + if (journal.phase !== 'committed') + persist({ + ...journal, + phase: 'committed', + coldStart: undefined, + ...(journal.candidate + ? { + previous: + journal.candidate.bundle === journal.active.bundle + ? journal.previous + : journal.active, + active: journal.candidate, + candidate: undefined, + failure: undefined + } + : {}), + oldHub: undefined + }) + await gate(false) + await resumeEdges() + } + + async function rollback(error: unknown) { + journal = (await readJournalAsync(config.root)) ?? journal + const failed = journal.candidate ?? journal.active + const fallback = + journal.phase === 'committed' || journal.candidate?.bundle === journal.active.bundle + ? journal.previous + : journal.active + if (!fallback) throw error + if (hub && lease && alive(lease.hubPid)) { + await drain() + await stopHub() + } + const ownershipFailure = (cause = error) => + new Error('Hub process ownership is uncertain; the interrupted handoff remains gated.', { + cause + }) + if ((pendingHub && !pendingHub.exited) || (lease && alive(lease.hubPid))) + throw ownershipFailure() + const identityPath = join(paths.runtime, 'hub-runtime.json') + const identity = existsSync(identityPath) + ? parseHubRuntimeIdentity(JSON.parse(readFileSync(identityPath, 'utf8'))) + : undefined + if (identity === null || (identity && alive(identity.processId))) throw ownershipFailure() + let present + try { + present = await connectSocket(hubSocketPath(config.root, paths.runtime), lifetime.signal) + } catch (cause) { + if (!['ENOENT', 'ECONNREFUSED'].includes((cause as NodeJS.ErrnoException).code ?? '')) + throw ownershipFailure(cause) + } + if (present) { + present.destroy() + throw ownershipFailure() + } + persist({ + ...journal, + phase: 'old-stopped', + coldStart: undefined, + active: fallback, + candidate: undefined, + previous: undefined, + oldHub: undefined, + failedBundles: [...new Set([...journal.failedBundles, failed.bundle])], + failure: String(error) + }) + await recoverHub(fallback) + await finishHandoff() + } + + async function recover() { + if (transitioning) return + transitioning = true + let observedPid: number | undefined + try { + const identityPath = join(paths.runtime, 'hub-runtime.json') + const identity = existsSync(identityPath) + ? parseHubRuntimeIdentity(JSON.parse(readFileSync(identityPath, 'utf8'))) + : undefined + if (identity && alive(identity.processId)) { + observedPid = identity.processId + const candidates = [journal.active, journal.candidate, journal.previous].filter( + (value): value is Artifact => !!value + ) + const artifact = candidates.find( + (value) => + value.version === identity.packageVersion && + dirname(value.entry) === dirname(identity.entryPath) + ) + if (!artifact) throw new Error('An unknown Hub still owns this runtime.') + // Socket closure can precede process exit. Reattaching must never + // silently relaunch that artifact and bypass the crash rollback path. + if (!hub?.transport || lease?.hubPid !== identity.processId) + await connectHub(artifact, false) + if (journal.phase === 'preparing' && artifact.bundle === journal.active.bundle) { + persist({ + ...journal, + phase: 'committed', + candidate: updates ? undefined : journal.candidate, + oldHub: undefined, + failure: + 'The supervisor stopped before the old Hub exited; the active runtime was retained.' + }) + } else if (artifact.bundle !== journal.active.bundle) { + persist({ ...journal, phase: 'candidate-ready' }) + } + } else { + if (journal.phase === 'committed' && identity && journal.previous) { + await rollback(new Error('The active Hub exited unexpectedly.')) + return + } + const candidate = + journal.candidate && + !journal.failedBundles.includes(journal.candidate.bundle) && + journal.phase !== 'committed' + ? journal.candidate + : journal.active + if (journal.phase !== 'committed') persist({ ...journal, phase: 'old-stopped' }) + await recoverHub(candidate) + if (journal.phase !== 'committed') persist({ ...journal, phase: 'candidate-ready' }) + } + await finishHandoff() + } catch (error) { + if (journal.phase !== 'committed' && journal.candidate) await rollback(error) + else if (observedPid && journal.previous) { + await waitForExit(observedPid) + await rollback(error) + } else throw error + } finally { + await resumeEdges() + transitioning = false + updates?.schedule() + } + } + + async function update(entry: string, released?: Artifact, automatic = false) { + if (transitioning) throw unavailable('Another runtime transition is in progress.') + if (updates?.status.checking) + throw unavailable('Wait for release acquisition to finish before activating.') + if (journal.phase !== 'committed') + throw unavailable( + 'An interrupted handoff must finish through --recover-runtime before offering another version.' + ) + transitioning = true + updates?.schedule() + try { + await requireHub().request( + { method: 'tempad/runtime-retain', params: { retained: true } }, + ResultSchema + ) + if ([...edges.values()].some((edge) => !edge.ready)) + throw new Error( + 'An attached MCP client has not finished initialization; wait or reconnect it before switching.' + ) + if (config.release && !released) + throw new Error('Managed releases accept only a verified staged npm artifact.') + if (released) verifyReleaseArtifact(released) + const candidate = released + ? { artifact: released, contract: undefined } + : await probeArtifact(entry) + if (!newerVersion(candidate.artifact.version, journal.active.version)) + throw new Error('Only a newer stable local runtime can be offered.') + if (journal.failedBundles.includes(candidate.artifact.bundle)) + throw new Error('This runtime bundle is quarantined after a failed activation.') + if (!released) { + const active = await probeArtifact(journal.active.entry) + if (candidate.contract !== active.contract) + throw new Error( + 'The local candidate changes the frozen MCP contract; restart MCP instead.' + ) + } + if (!updates) persist({ ...journal, candidate: candidate.artifact, failure: undefined }) + try { + await activate(candidate.artifact, automatic) + } catch (error) { + journal = (await readJournalAsync(config.root)) ?? journal + // A definite pre-shutdown refusal leaves the original writer intact. + // Reopen it instead of draining newly arrived work for a rollback. + const originalRetained = + journal.phase === 'preparing' && + isRuntimeUnavailable(error) && + journal.oldHub?.id === lease?.hubId && + hub?.transport + if ( + (journal.phase === 'committed' && journal.active.bundle !== candidate.artifact.bundle) || + originalRetained + ) { + persist({ + ...journal, + phase: 'committed', + candidate: updates ? undefined : journal.candidate, + oldHub: undefined, + failure: String(error) + }) + await gate(false) + return { status: 'staged', reason: String(error) } + } + await rollback(error) + return { status: 'rolled-back', reason: String(error) } + } + return { status: 'activated', version: journal.active.version } + } finally { + await resumeEdges() + transitioning = false + await hub + ?.request({ method: 'tempad/runtime-retain', params: { retained: false } }, ResultSchema) + .catch(() => {}) + updates?.schedule() + } + } + + const server = createServer((socket) => { + if (peers.size >= 128) { + socket.destroy() + return + } + const peer = new ControlPeer() + peers.add(peer) + const timeout = setTimeout(() => void peer.close(), 5000) + peer.onerror = () => void peer.close() + peer.onclose = () => { + clearTimeout(timeout) + peers.delete(peer) + edges.delete(peer) + updates?.schedule() + } + peer.setRequestHandler( + z.object({ method: z.literal('tempad/runtime-hello'), params: RegistrationSchema }), + async ({ params }) => { + if (edges.has(peer)) throw new Error('A runtime connection can register only once.') + if (transitioning) throw unavailable('Runtime handoff in progress; retry attachment.') + if ( + config.release && + params.updates?.policy === 'pinned' && + (params.updates?.version !== journal.active.version || + params.updates?.manifestHash !== journal.active.release?.manifestHash) + ) + throw new Error( + 'MCP_RUNTIME_RESTART_REQUIRED: this pinned client requests a different runtime; update/restart MCP or explicitly enroll it.' + ) + const edge = { registration: params, ready: false } + edges.set(peer, edge) + const grant = await register(edge) + clearTimeout(timeout) + updates?.schedule() + return grant + } + ) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-ready'), + params: z.object({ + hubId: z.uuid(), + error: z.string().optional() + }) + }), + ({ params }) => { + const edge = edges.get(peer) + if (!edge || params.hubId !== lease?.hubId) + throw unavailable('Stale runtime readiness acknowledgment.') + edge.error = params.error + edge.ready = !params.error + return {} + } + ) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-refresh') }), async () => { + const edge = edges.get(peer) + if (!edge) throw unavailable('Runtime session is not registered.') + return register(edge) + }) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-status') }), async () => { + clearTimeout(timeout) + const state = hub ? await inspect().catch(() => undefined) : undefined + return { + ...(state ?? { gated: true, unavailable: 'Hub recovery has not completed.' }), + ...lease, + supervisorPid: process.pid, + phase: journal.phase, + active: journal.active, + candidate: journal.candidate, + failure: journal.failure, + hookFault, + legacyHookWarning: hooks?.legacyExhausted + ? 'Legacy lifecycle history is full. Update the TemPad Dev plugin to restore Claude lifecycle support.' + : undefined, + controlComponents: controlComponentStatus( + PACKAGE_VERSION, + [...edges.values()].map((edge) => edge.registration), + [journal.active, journal.candidate, updates?.status.candidate] + .filter( + (artifact): artifact is Artifact => + !!artifact && !journal.failedBundles.includes(artifact.bundle) + ) + .map((artifact) => artifact.version) + ), + updates: updates + ? { + ...updates.status, + automaticActivation: automaticEnabled(), + automaticError, + automaticPendingReasons: updates.status.candidate + ? [ + ...activationBlockers(updates.status.candidate, true), + ...(state ? automaticBlockers(state) : ['Hub recovery has not completed.']) + ] + : [], + pendingReasons: updates.status.candidate + ? activationBlockers(updates.status.candidate) + : [] + } + : undefined + } + }) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-check'), + params: z.object({ version: z.string() }).strict() + }), + async ({ params }) => { + if (!updates) + throw new Error('Release acquisition is unavailable in isolated/local runtimes.') + if (transitioning) throw unavailable('Runtime handoff in progress.') + clearTimeout(timeout) + const candidate = await updates.check(params.version, true) + return { + candidate, + selection: updates.status.selection, + automaticActivation: automaticEnabled(), + pendingReasons: candidate ? activationBlockers(candidate) : [] + } + } + ) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-activate'), + params: z.object({ version: z.string(), integrity: z.string() }).strict() + }), + async ({ params }) => { + const candidate = [updates?.status.manualCandidate, updates?.status.candidate].find( + (value) => + value?.version === params.version && value.release?.integrity === params.integrity + ) + if (!candidate) + throw new Error( + 'The staged artifact changed; check and confirm its version and integrity again.' + ) + clearTimeout(timeout) + const blockers = activationBlockers(candidate) + if (blockers.length) return { status: 'staged', reasons: blockers } + return update(candidate.entry, candidate) + } + ) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-recover') }), async () => { + if (transitioning) throw unavailable('Another runtime transition is in progress.') + clearTimeout(timeout) + await recover() + return { status: 'recovered', version: journal.active.version } + }) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-update'), + params: z.object({ entry: z.string() }).strict() + }), + async ({ params }) => { + clearTimeout(timeout) + return update(params.entry) + } + ) + void peer.connect(new SocketTransport(socket)).catch(() => void peer.close()) + }) + async function close() { + if (closing) return + closing = true + updates?.close() + lifetime.abort() + process.off('SIGTERM', stop) + process.off('SIGINT', stop) + clearInterval(timer) + server.close() + await Promise.allSettled([...peers].map((peer) => peer.close())) + await hooks?.close() + await hub?.close() + cleanupEndpoint(socketPath) + rmSync(join(config.root, 'supervisor.json'), { force: true }) + await unlock() + } + try { + persist(journal) + ensureDir(paths.runtime) + await recover() + cleanupEndpoint(socketPath) + await listenPrivate(server, socketPath, lifetime.signal) + publish() + process.once('SIGTERM', stop) + process.once('SIGINT', stop) + } catch (error) { + await close() + throw error + } +} diff --git a/packages/mcp-server/src/runtime-recovery.ts b/packages/mcp-server/src/runtime-recovery.ts new file mode 100644 index 00000000..84dc7b35 --- /dev/null +++ b/packages/mcp-server/src/runtime-recovery.ts @@ -0,0 +1,138 @@ +import type { Socket } from 'node:net' + +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { createInterface } from 'node:readline/promises' +import { z } from 'zod' + +import type { TerminalReceipts } from './terminal-receipts' + +import { ControlPeer, SocketTransport } from './runtime-prototype/protocol' + +const BlockSchema = z.object({ + id: z.string(), + revision: z.uuid(), + fileKey: z.string(), + browserId: z.string(), + tabId: z.number().optional(), + documentId: z.string().optional(), + recordedAt: z.number() +}) +const StatusSchema = ResultSchema.extend({ blocks: z.array(BlockSchema) }) +const PreparedSchema = ResultSchema.extend({ block: BlockSchema, challenge: z.uuid() }) +export const RECOVERY_METHODS = ['tempad/runtime-status', 'tempad/runtime-prepare-resolution'] + +/** Private same-user socket only; never registered in the MCP tool catalog. */ +export async function attachRuntimeRecovery( + socket: Socket, + receipts: TerminalReceipts, + resolved: (record: NonNullable>) => void +) { + const peer = new ControlPeer() + let confirmation: { id: string; revision: string; challenge: string; expires: number } | undefined + const timeout = setTimeout(() => void peer.close(), 120_000) + timeout.unref() + peer.onclose = () => { + clearTimeout(timeout) + confirmation = undefined + } + peer.onerror = () => void peer.close() + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-status') }), () => ({ + blocks: receipts.status() + })) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-prepare-resolution'), + params: z.object({ id: z.string() }).strict() + }), + ({ params }) => { + const block = receipts.status().find((record) => record.id === params.id) + if (!block) + throw new Error('No unresolved execution with this ID. Inspect --runtime-status again.') + confirmation = { + id: block.id, + revision: block.revision, + challenge: randomUUID(), + expires: performance.now() + 120_000 + } + return { block, challenge: confirmation.challenge } + } + ) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-resolve-block'), + params: z.object({ challenge: z.uuid(), stopped: z.literal(true) }).strict() + }), + ({ params }) => { + const selected = confirmation + confirmation = undefined + if ( + !selected || + selected.challenge !== params.challenge || + performance.now() >= selected.expires + ) + throw new Error('Recovery confirmation expired or was already used.') + const record = receipts.abandon(selected.id, selected.revision) + if (record) resolved(record) + return { resolved: selected.id, readRequired: true } + } + ) + await peer.connect(new SocketTransport(socket)) + return peer +} + +export async function runRuntimeRecovery( + args: string[], + connect: () => Promise, + io = { + interactive: !!process.stdin.isTTY && !!process.stdout.isTTY, + write: (value: string) => process.stdout.write(value), + confirm: async (prompt: string) => { + const terminal = createInterface({ input: process.stdin, output: process.stdout }) + try { + return await terminal.question(prompt) + } finally { + terminal.close() + } + } + } +): Promise { + const status = args.length === 1 && args[0] === '--runtime-status' + const id = args.length === 2 && args[0] === '--resolve-runtime-block' ? args[1] : undefined + if (!status && !id) throw new Error('Use --runtime-status or --resolve-runtime-block .') + if (id && !io.interactive) + throw new Error( + 'Runtime recovery requires an interactive terminal and confirmation that the original execution environment has stopped.' + ) + const peer = new ControlPeer() + peer.onerror = () => void peer.close() + try { + await peer.connect(new SocketTransport(await connect())) + if (status) { + const result = await peer.request({ method: 'tempad/runtime-status' }, StatusSchema) + io.write(JSON.stringify(result, null, 2) + '\n') + return + } + const prepared = await peer.request( + { method: 'tempad/runtime-prepare-resolution', params: { id } }, + PreparedSchema + ) + io.write(JSON.stringify(prepared.block, null, 2) + '\n') + const answer = await io.confirm( + 'This cannot stop remote code. Close the original Figma page/browser first. Type the exact block ID to attest that its execution environment has stopped: ' + ) + if (answer !== id) throw new Error('Recovery cancelled; execution remains blocked.') + await peer.request( + { + method: 'tempad/runtime-resolve-block', + params: { challenge: prepared.challenge, stopped: true } + }, + ResultSchema + ) + io.write( + 'Execution retired. Stop fences remain; explicitly resume an eligible task and reread the original target before writing.\n' + ) + } finally { + await peer.close() + } +} diff --git a/packages/mcp-server/src/runtime-update/acquire.ts b/packages/mcp-server/src/runtime-update/acquire.ts new file mode 100644 index 00000000..b6acbbe2 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/acquire.ts @@ -0,0 +1,224 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { z } from 'zod' + +import { probeArtifact } from '../runtime-prototype/probe' +import { fingerprintBundle } from '../runtime-prototype/processes' +import { ArtifactSchema } from '../runtime-prototype/state' +import { readArchive, verifyIntegrity } from './archive' +import { releaseManifest } from './artifact' +import { discardUnusedCache, installArchive, verifyCached } from './cache' +import { + assertRunnable, + automaticCompatibilityReasons, + compareVersions, + compatibilityReasons, + crossesBreakingBoundary, + MANIFEST_PATH, + ManifestSchema, + PACKAGE_NAME, + VersionSchema, + type RuntimeManifest +} from './manifest' +import { sha256 } from './package' +import { downloadRelease, releaseCatalog, resolveRelease, type Release } from './registry' + +const HashSchema = z.string().regex(/^[a-f0-9]{64}$/) +const ReasonsSchema = z.array(z.string().max(2000)).max(32) +export const AcquisitionContextSchema = z + .object({ + active: ArtifactSchema, + control: ArtifactSchema, + previous: ArtifactSchema.optional(), + excludedBundles: z.array(HashSchema).optional() + }) + .strict() +export type AcquisitionContext = z.infer +export const SelectionCursorSchema = z + .object({ + fingerprint: HashSchema, + before: VersionSchema, + latestReasons: ReasonsSchema + }) + .strict() +export type SelectionCursor = z.infer +export const SelectionSchema = z + .object({ + latest: VersionSchema, + latestReasons: ReasonsSchema, + inspected: z.number().int().nonnegative(), + remaining: z.number().int().nonnegative(), + cursor: SelectionCursorSchema.optional() + }) + .strict() +export const AcquisitionResultSchema = z + .object({ + candidate: ArtifactSchema.optional(), + selection: SelectionSchema.optional(), + cacheWarning: z.string().max(2000).optional() + }) + .strict() +export type AcquisitionResult = z.infer +const SEARCH_BATCH_SIZE = 16 + +/** Acquisition validates bytes and behavior; it never changes the active runtime. */ +export async function stageRelease( + cache: string, + selector: string, + context: AcquisitionContext, + cursor?: SelectionCursor +): Promise { + if (process.env.TEMPAD_MCP_DEV_CHECKOUT) + throw new Error('Checkout runtimes cannot acquire releases.') + const active = releaseManifest(context.active) + const control = releaseManifest(context.control) + const excluded = new Set(context.excludedBundles) + let downloaded = false + let inspected = 0 + let cacheWarning: string | undefined + const reasonsFor = (manifest: RuntimeManifest) => [ + ...new Set([ + ...automaticCompatibilityReasons(active, manifest), + ...compatibilityReasons(control, manifest) + ]) + ] + + async function inspectRelease(release: Release, selectCompatible: boolean) { + let installed + try { + installed = verifyCached(cache, release.version, release.integrity) + } catch (error) { + if ( + discardUnusedCache(cache, release.integrity, [ + context.active, + context.control, + context.previous + ]) + ) + cacheWarning = `Discarded unused cache for ${release.version}: ${String(error)}`.slice( + 0, + 2000 + ) + } + if (!installed) { + // Commit completed cached inspections before starting a network archive; + // the next worker gets the entire download/probe budget for this version. + if (selectCompatible && inspected > 0) return { deferred: true, reasons: [] } + downloaded = true + const bytes = await downloadRelease(release) + if (selectCompatible) { + verifyIntegrity(bytes, release.integrity) + const files = readArchive(bytes) + const declared = files.get(MANIFEST_PATH) + if (!declared) return { reasons: ['Release has no managed runtime manifest'] } + if (declared.length > 4 * 1024 * 1024) throw new Error('Oversized runtime manifest.') + const value: unknown = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(declared) + ) + const identity = z + .object({ + version: z.number().int().positive(), + name: z.literal(PACKAGE_NAME), + packageVersion: VersionSchema + }) + .parse(value) + if (identity.packageVersion !== release.version) + throw new Error('Runtime package identity disagrees with registry metadata.') + if (identity.version !== 1) + return { reasons: ['Release uses an unsupported runtime manifest version'] } + const manifest = ManifestSchema.parse(value) + const reasons = reasonsFor(manifest) + // No package code executes and incompatible archives never enter the cache. + if (reasons.length) return { reasons } + } + installed = await installArchive(cache, release.version, release.integrity, bytes) + } + if (selectCompatible) { + const reasons = reasonsFor(installed.manifest) + if (reasons.length) return { reasons } + if (excluded.has(fingerprintBundle(join(installed.directory, installed.manifest.entry)))) + return { reasons: ['Candidate was quarantined after a failed activation'] } + } + assertRunnable(installed.manifest) + const probe = await probeArtifact(join(installed.directory, installed.manifest.entry)) + if ( + probe.artifact.version !== installed.manifest.packageVersion || + probe.wireHash !== installed.manifest.wireHash || + probe.presentationHash !== installed.manifest.presentationHash + ) + throw new Error('The isolated runtime does not match its declared contract.') + return { + candidate: { + ...probe.artifact, + release: { + directory: installed.directory, + integrity: release.integrity, + manifestHash: sha256(readFileSync(join(installed.directory, MANIFEST_PATH))) + } + }, + reasons: [] + } + } + + if (selector !== 'latest') { + const release = await resolveRelease(selector) + if (compareVersions(release.version, active.packageVersion) <= 0) return {} + const { candidate } = await inspectRelease(release, false) + return { candidate, cacheWarning } + } + + const catalog = await releaseCatalog(active.packageVersion) + const fingerprint = sha256( + JSON.stringify({ + catalog: catalog.fingerprint, + active: context.active.release!.manifestHash, + control: context.control.release!.manifestHash, + excluded: [...excluded].sort() + }) + ) + const continuing = cursor?.fingerprint === fingerprint ? cursor : undefined + let latestReasons = continuing?.latestReasons ?? [] + if (crossesBreakingBoundary(active.packageVersion, catalog.latest)) + latestReasons = ['Breaking release boundary; explicit update required'] + const deprecatedLatest = catalog.releases.find( + (release) => release.version === catalog.latest + )?.deprecated + if (deprecatedLatest) + latestReasons = [`Release is deprecated: ${deprecatedLatest.slice(0, 1900)}`] + const releases = catalog.releases.filter( + (release) => + !release.deprecated && + !crossesBreakingBoundary(active.packageVersion, release.version) && + (!continuing || compareVersions(release.version, continuing.before) < 0) + ) + let before: string | undefined + const deadline = performance.now() + 30_000 + for (const release of releases.slice(0, SEARCH_BATCH_SIZE)) { + const result = await inspectRelease(release, true) + if (result.deferred) break + inspected++ + before = release.version + if (release.version === catalog.latest) latestReasons = result.reasons + if (result.candidate) + return { + candidate: result.candidate, + cacheWarning, + selection: { latest: catalog.latest, latestReasons, inspected, remaining: 0 } + } + // One network archive retains the existing 120-second download allowance + // without losing an entire history batch to the worker's time bound. + // Cached inspections also checkpoint between candidates after 30 seconds. + if (downloaded || performance.now() >= deadline) break + } + const remaining = releases.length - inspected + return { + cacheWarning, + selection: { + latest: catalog.latest, + latestReasons, + inspected, + remaining, + ...(remaining && before ? { cursor: { fingerprint, before, latestReasons } } : {}) + } + } +} diff --git a/packages/mcp-server/src/runtime-update/archive.ts b/packages/mcp-server/src/runtime-update/archive.ts new file mode 100644 index 00000000..4e4c3167 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/archive.ts @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto' +import { gunzipSync } from 'node:zlib' + +import { IntegritySchema, safePath } from './manifest' + +export const MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 +export const MAX_EXTRACTED_BYTES = 256 * 1024 * 1024 +const MAX_FILE_BYTES = 16 * 1024 * 1024 +const MAX_FILES = 20_000 + +export function verifyIntegrity(bytes: Uint8Array, integrity: string) { + IntegritySchema.parse(integrity) + if ( + bytes.byteLength > MAX_ARCHIVE_BYTES || + `sha512-${createHash('sha512').update(bytes).digest('base64')}` !== integrity + ) + throw new Error('Runtime archive integrity or size check failed.') +} + +function field(bytes: Buffer) { + const end = bytes.indexOf(0) + return new TextDecoder('utf-8', { fatal: true }).decode( + bytes.subarray(0, end < 0 ? bytes.length : end) + ) +} +function octal(bytes: Buffer) { + const text = field(bytes).trim() + if (!/^[0-7]+$/.test(text)) throw new Error('Invalid tar numeric field.') + const number = Number.parseInt(text, 8) + if (!Number.isSafeInteger(number)) throw new Error('Oversized tar numeric field.') + return number +} +function pax(bytes: Buffer) { + const result: Record = Object.create(null) + let offset = 0 + while (offset < bytes.length) { + const space = bytes.indexOf(32, offset) + const length = Number(bytes.subarray(offset, space).toString('ascii')) + if ( + space < offset || + !Number.isSafeInteger(length) || + length <= space - offset + 1 || + offset + length > bytes.length || + bytes[offset + length - 1] !== 10 + ) + throw new Error('Invalid tar extended header.') + const record = new TextDecoder('utf-8', { fatal: true }).decode( + bytes.subarray(space + 1, offset + length - 1) + ) + const equals = record.indexOf('=') + if (equals < 1) throw new Error('Invalid tar extended field.') + const key = record.slice(0, equals) + if (key in result || key === 'linkpath' || key.startsWith('GNU.sparse')) + throw new Error('Unsafe tar extended field.') + result[key] = record.slice(equals + 1) + offset += length + } + return result +} + +/** Parse before writing anything. Links, devices, traversal and duplicate files are forbidden. */ +export function readArchive(compressed: Buffer): Map { + if (compressed.length > MAX_ARCHIVE_BYTES) throw new Error('Runtime archive is too large.') + const bytes = gunzipSync(compressed, { maxOutputLength: MAX_EXTRACTED_BYTES }) + const files = new Map() + const directories = new Set() + let extended: Record | undefined + let offset = 0 + let ended = false + while (offset + 512 <= bytes.length) { + const header = bytes.subarray(offset, offset + 512) + offset += 512 + if (header.every((value) => value === 0)) { + if (extended || bytes.subarray(offset).some((value) => value !== 0)) + throw new Error('Trailing tar content.') + ended = true + break + } + const checksum = header.reduce( + (sum, byte, index) => sum + (index >= 148 && index < 156 ? 32 : byte), + 0 + ) + if (octal(header.subarray(148, 156)) !== checksum) throw new Error('Invalid tar checksum.') + const prefix = field(header.subarray(345, 500)) + const name = (prefix ? `${prefix}/` : '') + field(header.subarray(0, 100)) + const size = octal(header.subarray(124, 136)) + const type = String.fromCharCode(header[156]!) + if (size > MAX_FILE_BYTES || offset + size > bytes.length) + throw new Error('Invalid tar file size.') + const data = bytes.subarray(offset, offset + size) + offset += Math.ceil(size / 512) * 512 + if (type === 'x') { + if (extended) throw new Error('Repeated tar extended header.') + extended = pax(data) + continue + } + const path = extended?.path ?? name + if (extended?.size !== undefined && Number(extended.size) !== size) + throw new Error('Conflicting tar sizes.') + extended = undefined + if (!['0', '\0', '5'].includes(type)) + throw new Error('Runtime archives may contain only regular files and directories.') + if (!path.startsWith('package/')) throw new Error('Unexpected package archive root.') + if (type !== '5' && path.endsWith('/')) throw new Error('Invalid regular file path.') + const relative = path.slice(8).replace(/\/$/, '') + if (type === '5' && !relative && size === 0) continue + if (!safePath(relative) || files.has(relative)) + throw new Error('Unsafe or duplicate package path.') + if (type === '5') { + if (size !== 0) throw new Error('Nonempty tar directory.') + directories.add(relative) + } else { + if (directories.has(relative)) throw new Error('Package file conflicts with directory.') + files.set(relative, data) + } + if (files.size + directories.size > MAX_FILES) throw new Error('Too many package entries.') + } + if (!ended || !files.size) throw new Error('Incomplete package archive.') + for (const path of [...files.keys(), ...directories]) { + const parts = path.split('/') + parts.pop() + while (parts.length) { + if (files.has(parts.join('/'))) + throw new Error('Package file is an ancestor of another entry.') + parts.pop() + } + } + return files +} diff --git a/packages/mcp-server/src/runtime-update/artifact.ts b/packages/mcp-server/src/runtime-update/artifact.ts new file mode 100644 index 00000000..7c3fa64e --- /dev/null +++ b/packages/mcp-server/src/runtime-update/artifact.ts @@ -0,0 +1,80 @@ +import { lstatSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { join } from 'node:path' + +import type { Artifact } from '../runtime-prototype/state' + +import { privateDirectory, publishDirectory, writePackageFiles } from './cache' +import { assertRunnable, MANIFEST_PATH, ManifestSchema } from './manifest' +import { readInstalledPackage, sha256, verifyPackage } from './package' + +export function releaseManifest(artifact: Artifact) { + if (!artifact.release) throw new Error('The runtime has no verified release payload.') + const bytes = readFileSync(join(artifact.release.directory, MANIFEST_PATH)) + if (bytes.length > 4 * 1024 * 1024 || sha256(bytes) !== artifact.release.manifestHash) + throw new Error('Runtime manifest changed.') + const manifest = ManifestSchema.parse(JSON.parse(bytes.toString('utf8'))) + if ( + manifest.packageVersion !== artifact.version || + artifact.entry !== join(artifact.release.directory, manifest.entry) + ) + throw new Error('Runtime release identity changed.') + return manifest +} + +export function verifyReleaseArtifact(artifact: Artifact) { + if (!artifact.release) throw new Error('The runtime has no verified release payload.') + const { directory, manifestHash } = artifact.release + const files = readInstalledPackage(directory) + if (sha256(files.get(MANIFEST_PATH) ?? '') !== manifestHash) + throw new Error('Runtime manifest changed.') + const manifest = verifyPackage(files, artifact.version) + assertRunnable(manifest) + if (artifact.entry !== join(directory, manifest.entry)) + throw new Error('Runtime entry differs from its manifest.') + return manifest +} + +/** The installed bootstrap is already trusted. Copy only its inventoried closure + * so npm cache cleanup and subsequent npm installs cannot change the running owner. */ +export async function retainBundledPackage(source: string, cache: string) { + source = realpathSync(source) + const manifestBytes = readFileSync(join(source, MANIFEST_PATH)) + const manifest = ManifestSchema.parse(JSON.parse(manifestBytes.toString('utf8'))) + const manifestHash = sha256(manifestBytes) + const files = new Map([[MANIFEST_PATH, manifestBytes]]) + for (const path of Object.keys(manifest.files)) { + const fullPath = join(source, path) + const stat = lstatSync(fullPath) + if (!stat.isFile() || realpathSync(fullPath) !== fullPath || stat.size > 16 * 1024 * 1024) + throw new Error(`Invalid bundled runtime file: ${path}`) + files.set(path, readFileSync(fullPath)) + } + verifyPackage(files, manifest.packageVersion) + assertRunnable(manifest) + privateDirectory(cache) + const directory = join(cache, `bundled-${manifestHash}`) + let exists = false + try { + lstatSync(directory) + exists = true + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (!exists) { + const staging = mkdtempSync(join(cache, '.bundled-')) + try { + await writePackageFiles(staging, files) + publishDirectory(staging, directory) + } finally { + rmSync(staging, { force: true, recursive: true }) + } + } + const artifact = { + entry: join(directory, manifest.entry), + version: manifest.packageVersion, + bundle: '', + release: { directory, manifestHash } + } + verifyReleaseArtifact(artifact) + return artifact +} diff --git a/packages/mcp-server/src/runtime-update/bootstrap.ts b/packages/mcp-server/src/runtime-update/bootstrap.ts new file mode 100644 index 00000000..200eae08 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/bootstrap.ts @@ -0,0 +1,273 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { createInterface } from 'node:readline/promises' +import { setTimeout as delay } from 'node:timers/promises' +import lockfile from 'proper-lockfile' + +import { writeDurableJson } from '../durable-json' +import { startPrototypeEdge } from '../runtime-prototype/edge' +import { + connectSocket, + fingerprintBundle, + hubSocketPath, + supervisorConnection, + unavailableSocket +} from '../runtime-prototype/processes' +import { ControlPeer, RECOVER_MS, SocketTransport } from '../runtime-prototype/protocol' +import { ConfigSchema, readPrivateJson, type PrototypeConfig } from '../runtime-prototype/state' +import { + ASSET_DIR, + HUB_LOCK_PATH, + HUB_LOCK_STALE_MS, + LOCK_PATH, + LOG_DIR, + RUNTIME_DIR, + RUNTIME_PROFILE, + SOCK_PATH, + endLogging, + ensureFile, + resolveStateDir +} from '../shared' +import { retainBundledPackage } from './artifact' +import { privateDirectory } from './cache' +import { prepareManagedEdge } from './cold-start' +import { MANIFEST_PATH, ManifestSchema, VersionSchema } from './manifest' +import { sha256 } from './package' + +export function updateIntent( + args: string[], + checkout: boolean, + defaultPolicy: 'stable' | 'off' = 'off' +) { + const flags = args.filter((arg) => arg.startsWith('--runtime-updates')) + if ( + flags.length > 1 || + flags.some( + (flag) => !['stable', 'off', 'pinned'].some((value) => flag === `--runtime-updates=${value}`) + ) + ) + throw new Error('Use one --runtime-updates=stable, off or pinned option.') + const command = args.find((arg) => + ['--check-runtime-update', '--update-runtime', '--runtime-update-status'].includes(arg) + ) + if (checkout && (command || flags.length)) + throw new Error('Checkout runtimes cannot enroll, check or download release updates.') + return { + command, + policy: + flags[0] === '--runtime-updates=stable' + ? ('stable' as const) + : flags[0] === '--runtime-updates=pinned' + ? ('pinned' as const) + : flags[0] === '--runtime-updates=off' + ? ('off' as const) + : defaultPolicy + } +} + +async function acquireEnrollmentLock() { + // The owner may still be flushing its verified package. Give contenders the + // startup recovery budget while preserving the owner's ordinary heartbeat. + const deadline = performance.now() + RECOVER_MS + for (;;) { + try { + return await lockfile.lock(LOCK_PATH, { retries: 0, stale: 10_000 }) + } catch (error) { + const remaining = deadline - performance.now() + if ((error as NodeJS.ErrnoException).code !== 'ELOCKED' || remaining <= 0) throw error + await delay(Math.min(250, remaining)) + } + } +} + +/** Enrollment changes policy, never the release namespace or the task/asset store. */ +export async function maybeManagedRuntime(args: string[], packageDir: string): Promise { + const manifestPath = join(packageDir, MANIFEST_PATH) + const intent = updateIntent( + args, + RUNTIME_PROFILE.kind === 'checkout', + existsSync(manifestPath) ? 'stable' : 'off' + ) + if (RUNTIME_PROFILE.kind === 'checkout') return false + if (intent.command === '--update-runtime' && (!process.stdin.isTTY || !process.stdout.isTTY)) + throw new Error( + 'Runtime activation requires interactive confirmation of the exact staged artifact.' + ) + if (args.includes('--recover-task-storage')) return false + const root = join(resolveStateDir(), 'runtime') + const path = join(root, 'prototype.json') + if (!existsSync(path) && intent.command === '--runtime-update-status') { + process.stdout.write(JSON.stringify({ managed: false, automaticActivation: false }) + '\n') + process.stdin.destroy() + endLogging() + return true + } + if (!existsSync(path)) { + if (args.includes('--runtime-status') || args.includes('--resolve-runtime-block')) return false + if (args.includes('--recover-runtime')) throw new Error('No managed runtime exists to recover.') + } + if (!existsSync(path) && intent.policy === 'off' && !intent.command) return false + if (!existsSync(manifestPath)) + throw new Error( + 'This build is not a verified release. Use a checkout profile for development; no online fallback is allowed.' + ) + const bytes = readFileSync(manifestPath) + const installed = ManifestSchema.parse(JSON.parse(bytes.toString('utf8'))) + const manifestHash = sha256(bytes) + privateDirectory(root) + let saved = readPrivateJson(path, ConfigSchema) + if (!saved) { + ensureFile(LOCK_PATH) + const unlock = await acquireEnrollmentLock() + try { + saved = readPrivateJson(path, ConfigSchema) + if (!saved) { + let legacy + try { + legacy = await connectSocket(SOCK_PATH) + } catch (error) { + if ( + ['EPIPEUNPROTECTED', 'EPIPEUNTRUSTED'].includes( + (error as NodeJS.ErrnoException).code ?? '' + ) + ) + throw new Error( + `An existing Windows Hub could not be verified. Close/restart all TemPad MCP connections once to replace a legacy Hub; runtime enrollment was not started. ${String(error)}`, + { cause: error } + ) + if (!unavailableSocket(error)) throw error + } + if (legacy) { + legacy.destroy() + if (intent.policy === 'stable' && !intent.command) { + process.stderr.write( + 'TemPad: using the existing Hub. Automatic updates will start after all TemPad MCP clients restart once.\n' + ) + return false + } + throw new Error( + 'A legacy Hub is running. Close/restart all TemPad MCP connections once to enroll; it will not be terminated automatically.' + ) + } + if ( + existsSync(HUB_LOCK_PATH) && + (await lockfile.check(HUB_LOCK_PATH, { stale: HUB_LOCK_STALE_MS })) + ) + throw new Error('The prior Hub still owns its writer lease; wait before enrolling.') + const artifact = await retainBundledPackage(packageDir, join(root, 'cache')) + artifact.bundle = fingerprintBundle(artifact.entry) + saved = ConfigSchema.parse({ + version: 1, + entry: artifact.entry, + bundle: artifact.bundle, + secret: randomUUID(), + release: artifact.release, + directories: { + runtime: RUNTIME_DIR, + state: resolveStateDir(), + assets: ASSET_DIR, + log: LOG_DIR + } + }) + writeDurableJson(path, saved) + } + } finally { + await unlock() + } + } + if ( + !saved.release || + !saved.directories || + saved.directories.runtime !== RUNTIME_DIR || + saved.directories.state !== resolveStateDir() || + saved.directories.assets !== ASSET_DIR + ) + throw new Error( + 'Managed runtime directories differ from this launch. Keep one release namespace; stop its clients before changing directory overrides.' + ) + const config: PrototypeConfig = { ...saved, root } + const command = + intent.command ?? + args.find((arg) => + ['--runtime-status', '--resolve-runtime-block', '--recover-runtime'].includes(arg) + ) + if (command) { + const peer = new ControlPeer() + try { + await peer.connect( + new SocketTransport(await supervisorConnection(config, AbortSignal.timeout(90_000))) + ) + const request = (method: string, params?: Record) => + peer.request({ method, ...(params ? { params } : {}) }, ResultSchema, { timeout: 500_000 }) + if (command === '--runtime-status' || command === '--resolve-runtime-block') { + const { runRuntimeRecovery } = await import('../runtime-recovery') + await runRuntimeRecovery( + args.filter((arg) => !arg.startsWith('--runtime-updates=')), + () => connectSocket(hubSocketPath(root, config.directories?.runtime)) + ) + if (command === '--runtime-status') + process.stdout.write( + JSON.stringify(await request('tempad/runtime-status'), null, 2) + '\n' + ) + } else if (command === '--recover-runtime') { + process.stdout.write( + JSON.stringify(await request('tempad/runtime-recover'), null, 2) + '\n' + ) + } else if (command === '--runtime-update-status') { + process.stdout.write(JSON.stringify(await request('tempad/runtime-status'), null, 2) + '\n') + } else { + const version = + command === '--update-runtime' + ? VersionSchema.parse(args[args.indexOf(command) + 1]) + : 'latest' + const checked = await request('tempad/runtime-check', { version }) + process.stdout.write(JSON.stringify(checked, null, 2) + '\n') + if (command === '--update-runtime') { + const candidate = ( + checked as { candidate?: { version?: string; release?: { integrity?: string } } } + ).candidate + if (candidate?.version !== version || !candidate.release?.integrity) + throw new Error('The requested release is not staged.') + const input = createInterface({ input: process.stdin, output: process.stdout }) + let answer + try { + answer = await input.question( + `Activate ${version} (${candidate.release.integrity})? Type "update ${version}": ` + ) + } finally { + input.close() + } + if (answer !== `update ${version}`) throw new Error('Runtime activation cancelled.') + process.stdout.write( + JSON.stringify( + await request('tempad/runtime-activate', { + version, + integrity: candidate.release.integrity + }), + null, + 2 + ) + '\n' + ) + } + } + } finally { + await peer.close() + process.stdin.destroy() + endLogging() + } + } else { + const prepared = await prepareManagedEdge(config, packageDir, installed) + try { + await startPrototypeEdge(prepared.config, { + policy: intent.policy, + version: installed.packageVersion, + manifestHash + }) + } finally { + prepared.close() + } + } + return true +} diff --git a/packages/mcp-server/src/runtime-update/cache.ts b/packages/mcp-server/src/runtime-update/cache.ts new file mode 100644 index 00000000..1f210a01 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/cache.ts @@ -0,0 +1,163 @@ +import { + closeSync, + fsyncSync, + lstatSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, join, sep } from 'node:path' + +import type { Artifact } from '../runtime-prototype/state' + +import { ensurePrivateDirectory, ensurePrivateDirectoryAsync } from '../runtime-platform' +import { IntegritySchema, MANIFEST_PATH } from './manifest' +import { readInstalledPackage, sha256, verifyArchive, verifyPackage } from './package' + +export function privateDirectory(path: string) { + ensurePrivateDirectory(path) +} + +export function cacheDirectory(cache: string, integrity: string) { + IntegritySchema.parse(integrity) + return join(cache, sha256(integrity)) +} + +function retainedDirectory(path: string, retained: (Artifact | undefined)[]) { + return retained.some( + (artifact) => + artifact?.release && + (artifact.release.directory === path || artifact.release.directory.startsWith(path + sep)) + ) +} + +/** Download caches are replaceable; the running, control and rollback closures are not. */ +export function discardUnusedCache( + cache: string, + integrity: string, + retained: (Artifact | undefined)[] +) { + privateDirectory(cache) + const directory = cacheDirectory(cache, integrity) + if (retainedDirectory(directory, retained)) + throw new Error('A retained runtime cache is damaged; automatic replacement was refused.') + try { + lstatSync(directory) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } + privateDirectory(directory) + rmSync(directory, { recursive: true }) + return true +} + +/** Called by the sole owner after an acquisition worker has exited. */ +export async function pruneCache( + cache: string, + retained: (Artifact | undefined)[], + signal?: AbortSignal +) { + await ensurePrivateDirectoryAsync(cache) + signal?.throwIfAborted() + for (const entry of readdirSync(cache, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const path = join(cache, entry.name) + if (/^(?:bundled-)?[a-f0-9]{64}$/.test(entry.name)) { + if (retainedDirectory(path, retained)) continue + rmSync(path, { recursive: true, force: true }) + } else if ( + /^\.(?:install|bundled)-/.test(entry.name) && + Date.now() - lstatSync(path).mtimeMs > 24 * 60 * 60 * 1000 + ) { + rmSync(path, { recursive: true, force: true }) + } + } +} + +export function verifyCached(cache: string, version: string, integrity: string) { + privateDirectory(cache) + const directory = cacheDirectory(cache, integrity) + lstatSync(directory) + privateDirectory(directory) + const archivePath = join(directory, 'archive.tgz') + const stat = lstatSync(archivePath) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 32 * 1024 * 1024) + throw new Error('Invalid cached archive.') + const expected = verifyArchive(readFileSync(archivePath), version, integrity) + return verifyInstalled(join(directory, 'package'), version, expected.files.get(MANIFEST_PATH)!) +} + +function verifyInstalled(directory: string, version: string, expectedManifest: Buffer) { + const installed = readInstalledPackage(directory) + const manifest = verifyPackage(installed, version) + if (!installed.get(MANIFEST_PATH)!.equals(expectedManifest)) + throw new Error('Cached manifest differs from the verified release.') + return { directory, manifest } +} + +/** Bound file-system concurrency while durably writing every verified payload file. */ +export async function writePackageFiles(directory: string, files: ReadonlyMap) { + const entries = [...files] + let next = 0 + const results = await Promise.allSettled( + Array.from({ length: 8 }, async () => { + while (next < entries.length) { + const [path, data] = entries[next++]! + const target = join(directory, path) + await mkdir(dirname(target), { recursive: true, mode: 0o700 }) + await writeFile(target, data, { flag: 'wx', mode: 0o600, flush: true }) + } + }) + ) + for (const result of results) if (result.status === 'rejected') throw result.reason +} + +export function publishDirectory(staging: string, destination: string) { + renameSync(staging, destination) + // Windows cannot open directories for fsync; payload files were flushed above, + // and every startup revalidates the complete published closure. + if (process.platform === 'win32') return + const fd = openSync(dirname(destination), 'r') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } +} + +export async function installArchive( + cache: string, + version: string, + integrity: string, + bytes: Buffer +) { + const { files } = verifyArchive(bytes, version, integrity) + privateDirectory(cache) + const destination = cacheDirectory(cache, integrity) + try { + lstatSync(destination) + return verifyCached(cache, version, integrity) + } catch (error) { + // A corrupt existing cache is evidence, never silently replaced or executed. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + const staging = mkdtempSync(join(cache, '.install-')) + try { + writeFileSync(join(staging, 'archive.tgz'), bytes, { flag: 'wx', mode: 0o600, flush: true }) + await writePackageFiles(join(staging, 'package'), files) + // Publish only a complete closure. A crash before rename leaves an unused + // staging directory; a crash afterward is caught by full revalidation. + publishDirectory(staging, destination) + // The archive was verified above; recheck the installed bytes before probing + // without decompressing that same immutable download a second time. + return verifyInstalled(join(destination, 'package'), version, files.get(MANIFEST_PATH)!) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} diff --git a/packages/mcp-server/src/runtime-update/cold-start.ts b/packages/mcp-server/src/runtime-update/cold-start.ts new file mode 100644 index 00000000..7249ff61 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/cold-start.ts @@ -0,0 +1,274 @@ +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { createServer, type AddressInfo, type Server } from 'node:net' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import lockfile from 'proper-lockfile' +import { z } from 'zod' + +import { getMcpServerConfig } from '../config' +import { writeDurableJson } from '../durable-json' +import { readHubRuntimeIdentity } from '../runtime-identity' +import { + connectSocket, + fingerprintBundle, + hubSocketPath, + isSocketPending, + runtimePaths, + supervisorSocketPath, + unavailableSocket, + withStartupLock +} from '../runtime-prototype/processes' +import { + ConfigSchema, + JournalSchema, + readEndpoints, + readJournal, + readPrivateJson, + writeJournal, + writeEndpoints, + type PrototypeConfig +} from '../runtime-prototype/state' +import { HUB_LOCK_STALE_MS, RUNTIME_PROFILE } from '../shared' +import { releaseManifest, retainBundledPackage, verifyReleaseArtifact } from './artifact' +import { privateDirectory } from './cache' +import { coldCompatibilityReasons, compareVersions, type RuntimeManifest } from './manifest' + +const SnapshotSchema = z.object({ config: ConfigSchema, journal: JournalSchema }).strict() +const IntentSchema = SnapshotSchema.extend({ before: SnapshotSchema }).strict() +const EdgeSchema = z.object({ pid: z.number().int().positive() }).strict() + +function controlVersion(config: PrototypeConfig) { + return releaseManifest({ + entry: config.entry, + bundle: config.bundle, + version: JSON.parse(readFileSync(join(config.release!.directory, 'package.json'), 'utf8')) + .version, + release: config.release + }).packageVersion +} + +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false + throw error + } +} + +/** Called only with startup.lock held, before any control owner is launched. */ +export async function resumeColdStart(config: PrototypeConfig): Promise { + const path = join(config.root, 'cold-start.json') + const intent = readPrivateJson(path, IntentSchema) + if (intent) { + const candidate = intent.journal.candidate + const saved = readPrivateJson(join(config.root, 'prototype.json'), ConfigSchema) + const journal = readJournal(config.root) + const matches = (actual: unknown, before: unknown, after: unknown) => + [before, after].some((value) => JSON.stringify(actual) === JSON.stringify(value)) + if ( + intent.config.secret !== config.secret || + JSON.stringify(intent.config.directories) !== JSON.stringify(config.directories) || + !intent.journal.coldStart || + !candidate || + candidate.entry !== intent.config.entry || + candidate.bundle !== intent.config.bundle || + !matches(saved, intent.before.config, intent.config) || + !matches(journal, intent.before.journal, intent.journal) + ) + throw new Error('Cold runtime upgrade intent does not match this namespace.') + if (hasLiveEdges(config.root) || (await hasOwner(config))) + throw new Error('Cold runtime upgrade cannot resume while a prior owner is still alive.') + verifyReleaseArtifact(candidate) + // The intent remains until both metadata files are durable. A crash between + // writes resumes this same decision; task/receipt snapshots are never restored. + writeJournal(config.root, intent.journal) + writeDurableJson(join(config.root, 'prototype.json'), intent.config) + rmSync(path) + } + return { + ...ConfigSchema.parse(readPrivateJson(join(config.root, 'prototype.json'), ConfigSchema)), + root: config.root + } +} + +function hasLiveEdges(root: string) { + const directory = join(root, 'edges') + privateDirectory(directory) + let live = false + for (const name of readdirSync(directory).filter((name) => name.endsWith('.json'))) { + const path = join(directory, name) + const edge = readPrivateJson(path, EdgeSchema) + if (!edge) continue + // PID reuse conservatively postpones migration, never expires a living edge. + if (alive(edge.pid)) live = true + else rmSync(path, { force: true }) + } + return live +} + +async function hasOwner(config: PrototypeConfig) { + const paths = runtimePaths(config) + for (const path of [supervisorSocketPath(config), hubSocketPath(config.root, paths.runtime)]) { + try { + const socket = await connectSocket(path) + socket.destroy() + return true + } catch (error) { + if (isSocketPending(error)) return true + if (!unavailableSocket(error)) throw error + } + } + const identityPath = join(paths.runtime, 'hub-runtime.json') + if (existsSync(identityPath)) { + const identity = readHubRuntimeIdentity(identityPath) + if (!identity || alive(identity.processId)) return true + } + const supervisor = readPrivateJson( + join(config.root, 'supervisor.json'), + z.object({ pid: z.number().int().positive() }).passthrough() + ) + if (supervisor && alive(supervisor.pid)) return true + const deadline = performance.now() + HUB_LOCK_STALE_MS + 5000 + do { + const locks = await Promise.all( + ( + [ + [join(config.root, 'supervisor.lock'), undefined, 5000], + [join(paths.runtime, 'hub.lock'), undefined, HUB_LOCK_STALE_MS], + [paths.state, join(paths.state, '.tempad-hub-writer.lock'), HUB_LOCK_STALE_MS], + [paths.assets, join(paths.assets, '.tempad-hub-writer.lock'), HUB_LOCK_STALE_MS] + ] as const + ).map( + async ([path, lockfilePath, stale]) => + existsSync(path) && + lockfile.check(path, { + stale, + ...(lockfilePath ? { lockfilePath } : {}) + }) + ) + ) + if (locks.every((held) => !held)) return false + await delay(100) + } while (performance.now() < deadline) + return true +} + +async function reservePort(candidates: number[]) { + const reservation = createServer((socket) => socket.destroy()) + let failure: unknown + for (const port of new Set(candidates)) { + try { + await once(reservation.listen(port, '127.0.0.1'), 'listening') + return reservation + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EADDRINUSE') throw error + failure = error + } + } + throw failure ?? new Error('No runtime endpoint port candidates.') +} + +/** Only called under startup.lock after all prior edges and writers have stopped. */ +async function refreshEndpoints(root: string) { + const endpoints = readEndpoints(root) + if (!endpoints) return + const candidates = getMcpServerConfig(RUNTIME_PROFILE).wsPortCandidates + const wsCandidates = + candidates.includes(endpoints.wsPort) || candidates.includes(0) + ? [endpoints.wsPort, ...candidates] + : candidates + const reservations: Server[] = [] + try { + const ws = await reservePort(wsCandidates) + reservations.push(ws) + const http = await reservePort([endpoints.httpPort, 0]) + reservations.push(http) + const wsPort = (ws.address() as AddressInfo).port + const httpPort = (http.address() as AddressInfo).port + if (wsPort !== endpoints.wsPort || httpPort !== endpoints.httpPort) + writeEndpoints(root, { ...endpoints, wsPort, httpPort }) + } finally { + await Promise.all( + reservations.map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) + } +} + +/** An installed CLI may advance a fully stopped runtime, never an attached session. */ +export async function prepareManagedEdge( + original: PrototypeConfig, + packageDir: string, + installed: RuntimeManifest +) { + return withStartupLock( + original.root, + async () => { + let config = await resumeColdStart(original) + const journal = readJournal(config.root) + const cold = + journal && + (journal.phase === 'committed' || journal.coldStart) && + !hasLiveEdges(config.root) && + !(await hasOwner(config)) + if (cold) await refreshEndpoints(config.root) + if ( + cold && + journal.phase === 'committed' && + compareVersions(installed.packageVersion, controlVersion(config)) > 0 && + compareVersions(installed.packageVersion, journal.active.version) >= 0 + ) { + const reasons = coldCompatibilityReasons(releaseManifest(journal.active), installed) + if (reasons.length) throw new Error(`Cold runtime upgrade refused: ${reasons.join('; ')}.`) + const candidate = await retainBundledPackage(packageDir, join(config.root, 'cache')) + candidate.bundle = fingerprintBundle(candidate.entry) + if ( + candidate.version === journal.active.version && + candidate.release?.manifestHash !== journal.active.release?.manifestHash + ) + throw new Error( + 'An installed version cannot replace different bytes of the same release.' + ) + if (journal.failedBundles.includes(candidate.bundle)) + throw new Error('The installed runtime was quarantined after an earlier failure.') + const { root, ...saved } = config + writeDurableJson(join(root, 'cold-start.json'), { + before: { config: saved, journal }, + config: { + ...saved, + entry: candidate.entry, + bundle: candidate.bundle, + release: candidate.release + }, + journal: { + ...journal, + revision: journal.revision + 1, + phase: 'old-stopped', + candidate, + coldStart: true, + oldHub: undefined, + failure: undefined + } + }) + config = await resumeColdStart(config) + } + const directory = join(config.root, 'edges') + privateDirectory(directory) + const path = join(directory, `${randomUUID()}.json`) + writeDurableJson(path, { pid: process.pid }) + const close = () => rmSync(path, { force: true }) + process.once('exit', close) + return { + config, + close: () => { + process.off('exit', close) + close() + } + } + }, + true + ) +} diff --git a/packages/mcp-server/src/runtime-update/manager.ts b/packages/mcp-server/src/runtime-update/manager.ts new file mode 100644 index 00000000..cd6483c0 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/manager.ts @@ -0,0 +1,247 @@ +import { execFile } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { z } from 'zod' + +import { writeDurableJson } from '../durable-json' +import { hubEnvironment } from '../process-environment' +import { assertPrivatePath } from '../runtime-platform' +import { ArtifactSchema, readPrivateJson, type Artifact } from '../runtime-prototype/state' +import { + AcquisitionResultSchema, + SelectionSchema, + type AcquisitionContext, + type AcquisitionResult, + type SelectionCursor +} from './acquire' +import { VersionSchema } from './manifest' + +const StateSchema = z + .object({ + version: z.literal(2), + nextCheck: z.number(), + failures: z.number().int().nonnegative(), + lastAttempt: z.number().optional(), + candidate: ArtifactSchema.optional(), + manualCandidate: ArtifactSchema.optional(), + cacheWarning: z.string().max(2000).optional(), + selection: SelectionSchema.optional(), + error: z.string().optional() + }) + .strict() +const INTERVAL = 6 * 60 * 60 * 1000 + +/** Acquisition owns downloads; only the supervisor can activate a committed candidate. */ +export class ReleaseUpdates { + private state: z.infer + private timer?: ReturnType + private pending?: { selector: string; result: Promise } + private abort?: AbortController + private closed = false + private explicit = false + private disabled = false + + constructor( + private readonly options: { + root: string + enrolled: () => boolean + acquire: ( + selector: string, + signal: AbortSignal, + cursor?: SelectionCursor + ) => Promise + now?: () => number + jitter?: () => number + } + ) { + if (process.env.TEMPAD_MCP_DEV_CHECKOUT) + throw new Error('Checkout runtimes cannot acquire releases.') + this.state = { version: 2, nextCheck: 0, failures: 0 } + try { + this.state = readPrivateJson(join(options.root, 'updates.json'), StateSchema) ?? this.state + } catch (error) { + try { + // Only this derived download/check state is reconstructible. Preserve + // its bytes before replacing it; trust and I/O failures remain fatal. + if (!(error instanceof SyntaxError || error instanceof z.ZodError)) throw error + const path = join(options.root, 'updates.json') + assertPrivatePath(path, 'file') + const backup = join(options.root, `updates.invalid-${randomUUID()}.json`) + writeFileSync(backup, readFileSync(path), { flag: 'wx', mode: 0o600, flush: true }) + this.save({ + version: 2, + nextCheck: this.now() + 5 * 60 * 1000, + failures: 1, + error: `Update metadata was rebuilt; diagnostic copy: ${backup}. ${String(error)}`.slice( + 0, + 2000 + ) + }) + } catch (failure) { + this.disabled = true + this.state.error = + `Update metadata is unreadable; checks are disabled: ${String(failure)}`.slice(0, 2000) + } + } + } + + get status() { + return { ...this.state, checking: !!this.pending, disabled: this.disabled } + } + private now() { + return this.options.now?.() ?? Date.now() + } + private save(next: z.infer) { + try { + writeDurableJson(join(this.options.root, 'updates.json'), StateSchema.parse(next)) + } catch (error) { + this.disabled = true + clearTimeout(this.timer) + this.state.error = ( + `Update metadata could not be saved; checks are disabled until MCP restarts: ${String(error)}` + + (next.error ? ` (check error: ${next.error})` : '') + ).slice(0, 2000) + throw new Error(this.state.error, { cause: error }) + } + // A downloaded candidate is visible to activation only after its metadata + // commits. A failed fsync must not publish it or restore an older disk file. + this.state = next + } + + schedule() { + clearTimeout(this.timer) + if (this.closed || this.disabled) return + if (!this.options.enrolled()) { + if (!this.explicit) this.abort?.abort() + return + } + if (this.pending) return + this.timer = setTimeout( + () => void this.check().catch(() => {}), + Math.min(0x7fffffff, Math.max(0, this.state.nextCheck - this.now())) + ) + this.timer.unref() + } + + async check(selector = 'latest', explicit = false): Promise { + if (this.disabled) throw new Error(this.state.error) + if (selector !== 'latest') VersionSchema.parse(selector) + if (this.closed || (!explicit && !this.options.enrolled())) return + if (this.pending) { + if (selector !== this.pending.selector) + throw new Error('Another release selector is being checked; retry after it finishes.') + return this.pending.result + } + clearTimeout(this.timer) + // Persist the attempt before the worker starts. Crash/reconnect storms cannot + // create an unbounded series of startup checks. + const now = this.now() + const stable = selector === 'latest' + this.save({ + ...this.state, + lastAttempt: now, + ...(stable ? { nextCheck: now + 5 * 60 * 1000 } : {}) + }) + this.abort = new AbortController() + this.explicit = explicit + const signal = this.abort.signal + const result = Promise.resolve() + .then(() => + this.options.acquire( + selector, + signal, + selector === 'latest' ? this.state.selection?.cursor : undefined + ) + ) + .then((value) => { + if (this.closed) return + const { candidate, selection, cacheWarning } = AcquisitionResultSchema.parse(value) + this.save({ + ...this.state, + // A successful scan also revokes old staging when the stable tag rewinds. + ...(stable + ? { + candidate, + selection, + failures: 0, + nextCheck: + this.now() + + (selection?.cursor + ? 30_000 + : INTERVAL + + Math.floor(((this.options.jitter?.() ?? Math.random()) * INTERVAL) / 10)) + } + : { manualCandidate: candidate }), + cacheWarning, + error: undefined + }) + return candidate + }) + .catch((error: unknown) => { + if (!this.closed && !this.disabled) { + const failures = Math.min(this.state.failures + 1, 10) + this.save({ + ...this.state, + ...(stable + ? { + failures, + nextCheck: this.now() + Math.min(INTERVAL, 5 * 60 * 1000 * 2 ** (failures - 1)) + } + : {}), + error: String(error).slice(0, 2000) + }) + } + throw error + }) + .finally(() => { + this.pending = undefined + this.abort = undefined + this.explicit = false + this.schedule() + }) + this.pending = { selector, result } + return result + } + + close() { + this.closed = true + clearTimeout(this.timer) + this.abort?.abort() + } +} + +export async function acquireRelease( + entry: string, + cache: string, + selector: string, + context: AcquisitionContext, + signal: AbortSignal, + cursor?: SelectionCursor +) { + if (process.env.TEMPAD_MCP_DEV_CHECKOUT) + throw new Error('Checkout runtimes cannot acquire releases.') + const { stdout } = await promisify(execFile)( + process.execPath, + [ + entry, + '--cache', + cache, + '--version', + selector, + '--context', + JSON.stringify(context), + ...(cursor ? ['--cursor', JSON.stringify(cursor)] : []) + ], + { + env: hubEnvironment(process.env), + windowsHide: true, + signal, + timeout: 240_000, + maxBuffer: 1024 * 1024 + } + ) + const result: unknown = JSON.parse(stdout) + return AcquisitionResultSchema.parse(result) +} diff --git a/packages/mcp-server/src/runtime-update/manifest.ts b/packages/mcp-server/src/runtime-update/manifest.ts new file mode 100644 index 00000000..14717861 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/manifest.ts @@ -0,0 +1,180 @@ +import { z } from 'zod' + +export const PACKAGE_NAME = '@tempad-dev/mcp' +export const MANIFEST_PATH = 'dist/runtime-manifest.json' +export const VersionSchema = z + .string() + .regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/) + .refine((value) => value.split('.').every((part) => Number.isSafeInteger(Number(part)))) +const HashSchema = z.string().regex(/^[a-f0-9]{64}$/) +export const IntegritySchema = z.string().regex(/^sha512-[A-Za-z0-9+/]{86}==$/) + +export function safePath(path: string): boolean { + return ( + path.length > 0 && + path.length <= 1024 && + !/[\\:*?"<>|]/.test(path) && + [...path].every((character) => character.charCodeAt(0) >= 32 && character !== '\x7f') && + path + .split('/') + .every( + (part) => + part.length > 0 && + !/[. ]$/.test(part) && + !/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(part) + ) + ) +} + +export const PolicySchema = z + .object({ + version: z.literal(1), + guidanceEpoch: z.number().int().positive(), + guidanceReview: z + .object({ + version: VersionSchema, + sourceHash: HashSchema, + decision: z.enum(['initial', 'compatible', 'new-epoch']), + baseline: z.object({ version: VersionSchema, integrity: IntegritySchema }), + rationale: z.string().min(24).max(4000) + }) + .strict(), + controlProtocols: z.array(z.number().int().positive()).min(1).max(16), + state: z + .object({ + family: z.string().min(1), + reads: z.array(z.number().int().positive()).min(1), + writes: z.number().int().positive() + }) + .strict(), + extension: z + .object({ protocols: z.array(z.number().int().positive()).min(1), legacy: z.boolean() }) + .strict(), + hookProtocol: z.literal(2) + }) + .strict() +export type RuntimePolicy = z.infer + +export const ManifestSchema = PolicySchema.extend({ + name: z.literal(PACKAGE_NAME), + packageVersion: VersionSchema, + entry: z.literal('dist/managed-runtime.mjs'), + node: z.string().min(1), + platforms: z.array(z.enum(['darwin', 'linux', 'win32'])).min(1), + wireHash: HashSchema, + presentationHash: HashSchema, + lockHash: HashSchema, + files: z.record(z.string().refine(safePath), HashSchema) +}).refine( + (value) => value.packageVersion === value.guidanceReview.version, + 'Guidance review belongs to another release.' +) +export type RuntimeManifest = z.infer + +export function compareVersions(a: string, b: string) { + const left = VersionSchema.parse(a).split('.').map(Number) + const right = VersionSchema.parse(b).split('.').map(Number) + for (let index = 0; index < 3; index++) { + if (left[index] !== right[index]) return Math.sign(left[index]! - right[index]!) + } + return 0 +} + +/** Fail closed on an engine expression this release's checker cannot interpret. */ +export function supportsNode(range: string, version = process.versions.node) { + const [major] = VersionSchema.parse(version).split('.').map(Number) + const clauses = range.split('||').map((value) => value.trim()) + if (!clauses.every((value) => /^(?:\^|>=)(?:0|[1-9]\d*)$/.test(value))) + throw new Error('Unsupported Node engine policy; update the release checker first.') + return clauses.some((value) => + value.startsWith('^') ? major === Number(value.slice(1)) : major! >= Number(value.slice(2)) + ) +} + +function runtimeCompatibilityReasons(next: RuntimeManifest): string[] { + const reasons: string[] = [] + try { + if (!supportsNode(next.node)) reasons.push('Node restart/upgrade required') + } catch { + reasons.push('Node engine policy requires a newer runtime checker') + } + if (!next.platforms.includes(process.platform as 'darwin' | 'linux' | 'win32')) + reasons.push('Unsupported platform') + return reasons +} + +/** Integrity alone never authorizes execution on an incompatible launcher. */ +export function assertRunnable(manifest: RuntimeManifest) { + const reasons = runtimeCompatibilityReasons(manifest) + if (reasons.length) throw new Error(`This runtime cannot run: ${reasons.join('; ')}.`) +} + +function stateCompatibilityReasons(active: RuntimeManifest, next: RuntimeManifest) { + const reasons: string[] = [] + if ( + active.state.family !== next.state.family || + !next.state.reads.includes(active.state.writes) || + !active.state.reads.includes(next.state.writes) + ) + reasons.push('State rollback compatibility is missing') + if ( + (active.extension.legacy && !next.extension.legacy) || + !active.extension.protocols.every((value) => next.extension.protocols.includes(value)) + ) + reasons.push('Released extension compatibility is missing') + return reasons +} + +export function compatibilityReasons(active: RuntimeManifest, next: RuntimeManifest): string[] { + const reasons = runtimeCompatibilityReasons(next) + if (active.wireHash !== next.wireHash) reasons.push('MCP contract changed; restart MCP') + if (active.guidanceEpoch !== next.guidanceEpoch) + reasons.push('Guidance epoch changed; restart MCP') + if (!active.controlProtocols.every((value) => next.controlProtocols.includes(value))) + reasons.push('Control component restart required') + return [...reasons, ...stateCompatibilityReasons(active, next)] +} + +/** Automatic consent never crosses a breaking-release boundary. */ +export function crossesBreakingBoundary(active: string, next: string) { + const [major, minor] = VersionSchema.parse(active).split('.').map(Number) + const [nextMajor, nextMinor] = VersionSchema.parse(next).split('.').map(Number) + return major !== nextMajor || (major === 0 && minor !== nextMinor) +} + +export function automaticCompatibilityReasons(active: RuntimeManifest, next: RuntimeManifest) { + const reasons = compatibilityReasons(active, next) + if (crossesBreakingBoundary(active.packageVersion, next.packageVersion)) + reasons.push('Breaking release boundary; explicit update required') + return reasons +} + +/** Cold replacement has no frozen MCP sessions, but must retain safe state/rollback access. */ +export function coldCompatibilityReasons(active: RuntimeManifest, next: RuntimeManifest) { + const reasons = [...runtimeCompatibilityReasons(next), ...stateCompatibilityReasons(active, next)] + if (!active.controlProtocols.some((value) => next.controlProtocols.includes(value))) + reasons.push('No common control protocol for binary rollback') + return reasons +} + +export function validatePolicyBaseline(current: RuntimeManifest, baseline?: RuntimeManifest) { + const review = current.guidanceReview + if (compareVersions(current.packageVersion, review.baseline.version) <= 0) + throw new Error('A release must be newer than its reviewed baseline.') + if (!baseline) { + if (review.decision !== 'initial' || current.guidanceEpoch !== 1) + throw new Error('Missing historical guidance evidence.') + return + } + if (baseline.packageVersion !== review.baseline.version) + throw new Error('Wrong release baseline.') + if (current.guidanceEpoch < baseline.guidanceEpoch) + throw new Error('Guidance epochs cannot decrease.') + if ( + review.decision === 'initial' || + (review.decision === 'compatible') !== (current.guidanceEpoch === baseline.guidanceEpoch) + ) + throw new Error('Guidance decision and epoch disagree.') + if (!baseline.controlProtocols.every((value) => current.controlProtocols.includes(value))) + throw new Error('Control protocol retirement requires a separate migration review.') +} diff --git a/packages/mcp-server/src/runtime-update/package.ts b/packages/mcp-server/src/runtime-update/package.ts new file mode 100644 index 00000000..3b5dc9c5 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/package.ts @@ -0,0 +1,115 @@ +import { createHash } from 'node:crypto' +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs' +import { join, posix, relative, resolve } from 'node:path' +import { z } from 'zod' + +import { readArchive, verifyIntegrity } from './archive' +import { MANIFEST_PATH, ManifestSchema, PACKAGE_NAME, VersionSchema } from './manifest' + +const PackageSchema = z.object({ + name: z.literal(PACKAGE_NAME), + version: VersionSchema, + bin: z.literal('dist/cli.mjs'), + engines: z.object({ node: z.string() }), + dependencies: z.record(z.string(), z.string()), + bundleDependencies: z.array(z.string()) +}) + +export const sha256 = (bytes: Uint8Array | string) => + createHash('sha256').update(bytes).digest('hex') + +/** Validate identity and complete payload integrity independently of this launcher. */ +export function verifyPackage(files: ReadonlyMap, expectedVersion: string) { + const json = (path: string) => { + const bytes = files.get(path) + if (!bytes || bytes.length > 4 * 1024 * 1024) throw new Error(`Missing or oversized ${path}.`) + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown + } + const pkg = PackageSchema.parse(json('package.json')) + const manifest = ManifestSchema.parse(json(MANIFEST_PATH)) + if ( + pkg.version !== expectedVersion || + manifest.packageVersion !== expectedVersion || + manifest.node !== pkg.engines.node + ) + throw new Error('Runtime package identity disagrees with its manifest.') + if (Object.keys(manifest.files).length !== files.size - 1 || manifest.files[MANIFEST_PATH]) + throw new Error('The manifest must inventory every payload file exactly once.') + for (const [path, bytes] of files) { + if (path !== MANIFEST_PATH && manifest.files[path] !== sha256(bytes)) + throw new Error(`Runtime payload hash mismatch: ${path}`) + } + if (!files.has(manifest.entry) || !files.has(pkg.bin) || !files.has('dist/log-transport.mjs')) + throw new Error('The runtime payload is incomplete.') + if (Object.keys(pkg.dependencies).some((name) => !pkg.bundleDependencies.includes(name))) + throw new Error('All runtime dependencies must be bundled.') + + // Node resolution must terminate inside this package, including transitive + // worker dependencies. The release build supplies them from the frozen lock. + for (const path of files.keys()) { + if ( + path !== 'package.json' && + !/^node_modules\/(?:@[^/]+\/)?[^/]+\/(?:node_modules\/(?:@[^/]+\/)?[^/]+\/)*package\.json$/.test( + path + ) + ) + continue + const dependencyPackage = z + .object({ + dependencies: z.record(z.string(), z.string()).optional(), + optionalDependencies: z.record(z.string(), z.string()).optional() + }) + .parse(json(path)) + for (const name of Object.keys(dependencyPackage.dependencies ?? {})) { + if (Object.hasOwn(dependencyPackage.optionalDependencies ?? {}, name)) continue + if (!/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/i.test(name)) + throw new Error('Invalid dependency name.') + let directory = posix.dirname(path) + let found = false + while (true) { + const candidate = + directory === '.' + ? `node_modules/${name}/package.json` + : `${directory}/node_modules/${name}/package.json` + if (files.has(candidate)) { + found = true + break + } + if (directory === '.') break + directory = posix.dirname(directory) + } + if (!found) throw new Error(`Unbundled runtime dependency ${name} from ${path}.`) + } + } + return manifest +} + +export function verifyArchive(bytes: Buffer, version: string, integrity: string) { + verifyIntegrity(bytes, integrity) + const files = readArchive(bytes) + return { files, manifest: verifyPackage(files, version) } +} + +/** Revalidate cached bytes, never trust a marker left by an earlier installer. */ +export function readInstalledPackage(directory: string) { + directory = resolve(directory) + if (realpathSync(directory) !== directory) + throw new Error('Runtime package path must be canonical.') + const files = new Map() + let size = 0 + function walk(path: string) { + const stat = lstatSync(path) + if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) + throw new Error('Runtime packages cannot contain links or special files.') + if (stat.isDirectory()) { + for (const name of readdirSync(path)) walk(join(path, name)) + } else { + size += stat.size + if (size > 256 * 1024 * 1024 || files.size >= 20_000) + throw new Error('Runtime package exceeds its limits.') + files.set(relative(directory, path).split('\\').join('/'), readFileSync(path)) + } + } + walk(directory) + return files +} diff --git a/packages/mcp-server/src/runtime-update/registry.ts b/packages/mcp-server/src/runtime-update/registry.ts new file mode 100644 index 00000000..8d9d7fb3 --- /dev/null +++ b/packages/mcp-server/src/runtime-update/registry.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' + +import { MAX_ARCHIVE_BYTES } from './archive' +import { compareVersions, IntegritySchema, PACKAGE_NAME, VersionSchema } from './manifest' +import { sha256 } from './package' + +const REGISTRY = 'https://registry.npmjs.org' +const MetadataSchema = z.object({ + name: z.literal(PACKAGE_NAME), + version: VersionSchema, + deprecated: z.string().optional(), + dist: z.object({ integrity: IntegritySchema, tarball: z.string() }) +}) +export type Release = { version: string; integrity: string; url: string; deprecated?: string } + +function releaseMetadata(value: unknown, version?: string): Release { + const metadata = MetadataSchema.parse(value) + const url = `${REGISTRY}/@tempad-dev/mcp/-/mcp-${metadata.version}.tgz` + if (metadata.dist.tarball !== url || (version && version !== metadata.version)) + throw new Error('Registry metadata does not identify the requested official release.') + return { + version: metadata.version, + integrity: metadata.dist.integrity, + url, + ...(metadata.deprecated ? { deprecated: metadata.deprecated } : {}) + } +} + +async function bytes(url: string, limit: number, timeout: number, fetcher: typeof fetch) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout) + let reader: ReadableStreamDefaultReader | undefined + try { + const response = await fetcher(url, { + signal: controller.signal, + redirect: 'error', + credentials: 'omit', + headers: { accept: 'application/json, application/octet-stream' } + }) + if (!response.ok || !response.body) + throw new Error(`Runtime registry returned HTTP ${response.status}.`) + reader = response.body.getReader() + const chunks: Buffer[] = [] + let size = 0 + while (true) { + const next = await reader.read() + if (next.done) break + size += next.value.byteLength + if (size > limit) throw new Error('Runtime registry response exceeds its byte limit.') + chunks.push(Buffer.from(next.value)) + } + return Buffer.concat(chunks) + } finally { + clearTimeout(timer) + await reader?.cancel().catch(() => {}) + controller.abort() + } +} + +export async function resolveRelease( + selector: string, + fetcher: typeof fetch = fetch +): Promise { + if (selector !== 'latest') VersionSchema.parse(selector) + const body = await bytes( + `${REGISTRY}/@tempad-dev%2fmcp/${selector}`, + 2 * 1024 * 1024, + 10_000, + fetcher + ) + return releaseMetadata( + JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)), + selector === 'latest' ? undefined : selector + ) +} + +/** The stable tag is an upper bound, including when its publisher rolls it back. */ +export async function releaseCatalog(current: string, fetcher: typeof fetch = fetch) { + VersionSchema.parse(current) + const body = await bytes(`${REGISTRY}/@tempad-dev%2fmcp`, 8 * 1024 * 1024, 10_000, fetcher) + const catalog = z + .object({ + name: z.literal(PACKAGE_NAME), + 'dist-tags': z.object({ latest: VersionSchema }), + versions: z.record(z.string(), z.unknown()) + }) + .parse(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body))) + const latest = catalog['dist-tags'].latest + releaseMetadata(catalog.versions[latest], latest) + const releases = Object.keys(catalog.versions) + .filter( + (version) => + VersionSchema.safeParse(version).success && + compareVersions(version, current) > 0 && + compareVersions(version, latest) <= 0 + ) + .sort((a, b) => compareVersions(b, a)) + .map((version) => releaseMetadata(catalog.versions[version], version)) + return { latest, releases, fingerprint: sha256(JSON.stringify({ latest, releases })) } +} + +export function downloadRelease(release: Release, fetcher: typeof fetch = fetch) { + VersionSchema.parse(release.version) + IntegritySchema.parse(release.integrity) + if (release.url !== `${REGISTRY}/@tempad-dev/mcp/-/mcp-${release.version}.tgz`) + throw new Error('Runtime download must use the official npm artifact URL.') + return bytes(release.url, MAX_ARCHIVE_BYTES, 120_000, fetcher) +} diff --git a/packages/mcp-server/src/runtime-verification.ts b/packages/mcp-server/src/runtime-verification.ts new file mode 100644 index 00000000..52fbf8e8 --- /dev/null +++ b/packages/mcp-server/src/runtime-verification.ts @@ -0,0 +1,21 @@ +// Build/release tools share the exact verifier used before runtime acquisition. +export { readArchive, verifyIntegrity } from './runtime-update/archive' +export { + ManifestSchema, + PolicySchema, + MANIFEST_PATH, + validatePolicyBaseline +} from './runtime-update/manifest' +export { sha256, verifyArchive, verifyPackage } from './runtime-update/package' +export { resolveRelease, downloadRelease } from './runtime-update/registry' +export { probeArtifact } from './runtime-prototype/probe' +export { endLogging } from './shared' +export { + ControlPeer, + SocketTransport, + RuntimeDataTransport, + CONTROL_VERSIONS +} from './runtime-prototype/protocol' +export { connectSocket } from './runtime-prototype/processes' +export { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +export { TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS } from '@tempad-dev/shared' diff --git a/packages/mcp-server/src/shared.ts b/packages/mcp-server/src/shared.ts index 179cb354..a719f011 100644 --- a/packages/mcp-server/src/shared.ts +++ b/packages/mcp-server/src/shared.ts @@ -1,15 +1,17 @@ import { closeSync, lstatSync, mkdirSync, openSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import pino from 'pino' import { - prepareDefaultRoot, - resolveDefaultRoot -} from '../../../agent-plugin/src/clients/shared/runtime-paths.mjs' + resolveRuntimeProfile, + runtimeDirectories, + runtimeSocketPath +} from '../../../agent-plugin/src/clients/shared/runtime-profile.mjs' import packageJson from '../package.json' with { type: 'json' } import { pruneExpiredLogs } from './log-retention' +import { ensurePrivateDirectory } from './runtime-platform' export { assertTrustedSocket, @@ -21,12 +23,17 @@ export function normalizePackageVersion(version: unknown): string { } export function ensureDir(dirPath: string): void { + if (process.platform === 'win32') { + ensurePrivateDirectory(dirPath) + return + } mkdirSync(dirPath, { recursive: true, mode: 0o700 }) const stat = lstatSync(dirPath) if ( !stat.isDirectory() || stat.isSymbolicLink() || - (process.platform !== 'win32' && (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0)) + stat.uid !== process.getuid?.() || + (stat.mode & 0o077) !== 0 ) throw new Error(`Refusing untrusted runtime directory: ${dirPath}`) } @@ -39,38 +46,31 @@ export function getRecordProperty(record: unknown, key: string): unknown { } export const PACKAGE_VERSION = normalizePackageVersion(getRecordProperty(packageJson, 'version')) +export const RUNTIME_PROFILE = resolveRuntimeProfile(process.env, import.meta.url) export function resolveRuntimeDir( env: NodeJS.ProcessEnv = process.env, systemTmpDir: string = tmpdir() ): string { - if (env.TEMPAD_MCP_RUNTIME_DIR) return env.TEMPAD_MCP_RUNTIME_DIR - return join(resolveDefaultRoot(systemTmpDir), 'run') + return runtimeDirectories(env, undefined, { tmpdir: systemTmpDir }).runtime } export function resolveLogDir( env: NodeJS.ProcessEnv = process.env, systemTmpDir: string = tmpdir() ): string { - if (env.TEMPAD_MCP_LOG_DIR) return env.TEMPAD_MCP_LOG_DIR - return join(resolveDefaultRoot(systemTmpDir), 'log') + return runtimeDirectories(env, undefined, { tmpdir: systemTmpDir }).log } export function resolveAssetDir( env: NodeJS.ProcessEnv = process.env, systemTmpDir: string = tmpdir() ): string { - if (env.TEMPAD_MCP_ASSET_DIR) return env.TEMPAD_MCP_ASSET_DIR - return join(resolveDefaultRoot(systemTmpDir), 'assets') + return runtimeDirectories(env, undefined, { tmpdir: systemTmpDir }).assets } export function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string { - return ( - env.TEMPAD_MCP_STATE_DIR ?? - (env.TEMPAD_MCP_RUNTIME_DIR - ? join(env.TEMPAD_MCP_RUNTIME_DIR, 'state') - : join(homedir(), '.tempad-dev', 'state')) - ) + return runtimeDirectories(env).state } export function resolveLogLevel( @@ -83,19 +83,15 @@ export function resolveSockPath( platform: NodeJS.Platform = process.platform, runtimeDir: string = RUNTIME_DIR ): string { - return platform === 'win32' ? '\\\\.\\pipe\\tempad-mcp' : join(runtimeDir, 'mcp.sock') + return runtimeSocketPath(runtimeDir, RUNTIME_PROFILE, platform) } -const needsDefaultRoot = - !process.env.TEMPAD_MCP_RUNTIME_DIR || - !process.env.TEMPAD_MCP_LOG_DIR || - !process.env.TEMPAD_MCP_ASSET_DIR -const defaultRoot = needsDefaultRoot ? prepareDefaultRoot() : '' -export const RUNTIME_DIR = process.env.TEMPAD_MCP_RUNTIME_DIR || join(defaultRoot, 'run') -export const LOG_DIR = process.env.TEMPAD_MCP_LOG_DIR || join(defaultRoot, 'log') -export const ASSET_DIR = process.env.TEMPAD_MCP_ASSET_DIR || join(defaultRoot, 'assets') +const directories = runtimeDirectories(process.env, RUNTIME_PROFILE, { prepare: true }) +export const RUNTIME_DIR = directories.runtime +export const LOG_DIR = directories.log +export const ASSET_DIR = directories.assets -if (needsDefaultRoot) ensureDir(defaultRoot) +if (directories.root) ensureDir(directories.root) ensureDir(RUNTIME_DIR) ensureDir(LOG_DIR) ensureDir(ASSET_DIR) @@ -141,4 +137,9 @@ export const log = pino( prettyTransport ) +export function endLogging(): void { + // Shutdown must drain the worker even when its ready event is still pending. + prettyTransport.end() +} + export const SOCK_PATH = resolveSockPath() diff --git a/packages/mcp-server/src/stable-edge.ts b/packages/mcp-server/src/stable-edge.ts new file mode 100644 index 00000000..65176a3d --- /dev/null +++ b/packages/mcp-server/src/stable-edge.ts @@ -0,0 +1,384 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { + InitializeRequest, + JSONRPCMessage, + Notification, + Request +} from '@modelcontextprotocol/sdk/types.js' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/sdk/shared/stdio.js' +import { + ErrorCode, + InitializeRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + McpError, + ResultSchema +} from '@modelcontextprotocol/sdk/types.js' +import { setTimeout as delay } from 'node:timers/promises' +import { isDeepStrictEqual } from 'node:util' + +import { runtimeContract } from '../scripts/runtime-contract.mjs' +import { ClientBindingUpdateSchema, type ClientBindingUpdate } from './client-binding' +import { isRuntimeUnavailable, unavailable } from './runtime-prototype/protocol' + +export interface StableEdgeOptions { + version: string + requestTimeoutMs: number + /** Retain only requests that the runtime has explicitly not dispatched. */ + runtimeWaitMs?: number + open: (initialize: InitializeRequest, signal: AbortSignal) => Promise + register?: (client: Client) => Promise + onBinding?: (binding: ClientBindingUpdate) => void + onError?: (error: Error) => void + onBackendClose?: () => void + onBackendReady?: () => Promise + validateContract?: ( + contract: RuntimeContract, + version: string, + previous?: RuntimeContract + ) => void | Promise +} +type RuntimeContract = Awaited> + +export function contractHashes(contract: RuntimeContract) { + return runtimeContract({ + initialize: { + serverInfo: { ...contract.info, version: '0' }, + capabilities: contract.capabilities, + instructions: contract.instructions + }, + tools: [...contract.pages.values()].flatMap((page) => page.tools) + }) +} + +/** Capture the observable contract before admitting a backend into a host session. */ +export async function readRuntimeContract(client: Client, signal: AbortSignal, timeout: number) { + const pages = new Map>>() + let cursor: string | undefined + let bytes = 0 + do { + if (pages.has(cursor ?? '') || pages.size >= 64) + throw new Error('Runtime returned a cyclic or oversized tool catalog.') + const page = await client.request( + { method: 'tools/list', ...(cursor === undefined ? {} : { params: { cursor } }) }, + ListToolsResultSchema, + { signal, timeout } + ) + bytes += Buffer.byteLength(JSON.stringify(page)) + if (bytes > STDIO_DEFAULT_MAX_BUFFER_SIZE) + throw new Error('Runtime tool catalog exceeded the MCP buffer limit.') + pages.set(cursor ?? '', page) + cursor = page.nextCursor + } while (cursor !== undefined) + const { version: _version, ...info } = client.getServerVersion()! + return { + pages, + info, + capabilities: client.getServerCapabilities(), + instructions: client.getInstructions() + } +} + +/** Own the host's MCP session without carrying any tool implementation or schema. */ +export async function startStableEdge(host: Transport, options: StableEdgeOptions) { + const lifetime = new AbortController() + let backend: Client | undefined + let initializedRequest: InitializeRequest | undefined + let contract: Awaited> | undefined + let replacement: Promise | undefined + let server: Server | undefined + let opening = false + let openingId: string | number | undefined + let failure: Error | undefined + let bufferedBytes = 0 + const buffered: JSONRPCMessage[] = [] + let waitingRequests = 0 + let waitingBytes = 0 + let paused = false + const forwarded = new Set>() + let resolveClosed: (error?: Error) => void + const closed = new Promise((resolve) => (resolveClosed = resolve)) + + async function close() { + if (lifetime.signal.aborted) return + lifetime.abort() + buffered.length = 0 + await Promise.allSettled([backend?.close(), server?.close(), host.close()]) + resolveClosed(failure) + } + + async function pause(signal: AbortSignal) { + signal = AbortSignal.any([signal, lifetime.signal]) + signal.throwIfAborted() + paused = true + // Only attempts already sent to the backend participate. Locally queued + // calls must remain queued until this barrier is released. + await new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + void Promise.allSettled([...forwarded]).then(() => { + signal.removeEventListener('abort', abort) + resolve() + }) + if (signal.aborted) abort() + }) + } + + function resume() { + paused = false + } + + function forward( + target: Client | Server, + request: Request, + extra: RequestHandlerExtra, + timeout = options.requestTimeoutMs + ) { + const progressToken = request.params?._meta?.progressToken + return target.request(request, ResultSchema, { + signal: extra.signal, + timeout, + ...(progressToken !== undefined + ? { + onprogress: (progress) => { + void extra + .sendNotification({ + method: 'notifications/progress', + params: { ...progress, progressToken } + }) + .catch(options.onError ?? (() => {})) + } + } + : {}) + }) + } + + async function forwardToRuntime( + request: Request, + extra: RequestHandlerExtra + ) { + const started = performance.now() + const deadline = started + options.requestTimeoutMs + const waitUntil = Math.min(deadline, started + (options.runtimeWaitMs ?? 0)) + const signal = AbortSignal.any([extra.signal, lifetime.signal]) + let retainedBytes = 0 + let rejection: unknown = unavailable() + try { + for (;;) { + signal.throwIfAborted() + if (performance.now() >= deadline || (retainedBytes && performance.now() >= waitUntil)) + throw rejection + const target = backend + if (target && !paused) { + try { + const attempt = forward( + target, + request, + extra, + Math.max(1, deadline - performance.now()) + ) + forwarded.add(attempt) + try { + return await attempt + } finally { + forwarded.delete(attempt) + } + } catch (error) { + // A dropped connection or any uncertain result may follow a write. + // Only the transport's explicit pre-dispatch rejection permits retry. + if (!isRuntimeUnavailable(error)) throw error + rejection = error + } + } + const remaining = waitUntil - performance.now() + if (remaining <= 0) throw rejection + if (!retainedBytes) { + const bytes = Buffer.byteLength(JSON.stringify(request)) + if (waitingRequests >= 64 || waitingBytes + bytes > STDIO_DEFAULT_MAX_BUFFER_SIZE) + throw unavailable('Runtime wait buffer is full; this call was not dispatched.') + waitingRequests++ + waitingBytes += bytes + retainedBytes = bytes + } + await delay(Math.min(50, remaining), undefined, { signal }) + } + } finally { + if (retainedBytes) { + waitingRequests-- + waitingBytes -= retainedBytes + } + } + } + + async function openBackend(request: InitializeRequest) { + const client = new Client(request.params.clientInfo, { + capabilities: request.params.capabilities + }) + client.onerror = options.onError + client.onclose = () => { + if (backend !== client) return + backend = undefined + if (!lifetime.signal.aborted) options.onBackendClose?.() + } + client.fallbackRequestHandler = (request, extra) => { + if (!server) throw new McpError(ErrorCode.InvalidRequest, 'Host is not initialized.') + return forward(server, request, extra) + } + client.fallbackNotificationHandler = async (notification) => { + if (notification.method === ClientBindingUpdateSchema.shape.method.value) + options.onBinding?.(ClientBindingUpdateSchema.parse(notification).params) + else if (notification.method !== 'notifications/tools/list_changed') + await server?.notification(notification) + } + try { + const transport = await options.open(request, lifetime.signal) + if (lifetime.signal.aborted) { + await transport.close() + lifetime.signal.throwIfAborted() + } + await client.connect(transport, { + signal: lifetime.signal, + timeout: options.requestTimeoutMs + }) + await options.register?.(client) + const observed = await readRuntimeContract(client, lifetime.signal, options.requestTimeoutMs) + await options.validateContract?.(observed, client.getServerVersion()!.version, contract) + lifetime.signal.throwIfAborted() + if (!client.transport) throw unavailable() + return { client, observed } + } catch (error) { + await client.close() + throw error + } + } + + async function replaceBackend(): Promise { + if (!initializedRequest || !server || !contract || lifetime.signal.aborted) throw unavailable() + if (replacement) return replacement + const replace = async () => { + const previous = backend + backend = undefined + await previous?.close() + const { client, observed } = await openBackend(initializedRequest!) + if (!options.validateContract && !isDeepStrictEqual(contract, observed)) { + await client.close() + throw new McpError( + -32002, + 'MCP_RUNTIME_RESTART_REQUIRED: replacement runtime changed this session’s frozen contract.' + ) + } + backend = client + await options.onBackendReady?.() + } + replacement = replace() + try { + await replacement + } finally { + replacement = undefined + } + } + + async function initialize(message: JSONRPCMessage) { + if (!('method' in message) || message.method !== 'initialize' || !('id' in message)) { + if ('id' in message) + await host.send({ + jsonrpc: '2.0', + id: message.id, + error: { code: ErrorCode.InvalidRequest, message: 'Initialize the MCP session first.' } + }) + return + } + opening = true + openingId = message.id + try { + const request = InitializeRequestSchema.parse(message) + const { client, observed } = await openBackend(request) + backend = client + initializedRequest = request + contract = observed + await options.onBackendReady?.() + const runtimeInfo = backend.getServerVersion()! + server = new Server( + { ...runtimeInfo, version: options.version }, + { + capabilities: contract.capabilities, + instructions: contract.instructions + } + ) + server.onerror = options.onError + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => { + const page = contract!.pages.get(params?.cursor ?? '') + if (!page) throw new McpError(ErrorCode.InvalidParams, 'Unknown tool catalog cursor.') + return structuredClone(page) + }) + server.fallbackRequestHandler = forwardToRuntime + server.fallbackNotificationHandler = async (notification) => { + if (!backend) throw unavailable() + await backend.notification(notification) + } + // The host transport already started while the runtime was being opened. + const session: Transport = { + start: async () => {}, + send: (value, sendOptions) => host.send(value, sendOptions), + close: async () => { + session.onclose?.() + } + } + await server.connect(session) + host.onmessage = (value, extra) => session.onmessage?.(value, extra) + host.onmessage(message) + for (const value of buffered.splice(0)) host.onmessage(value) + bufferedBytes = 0 + } catch (error) { + if (!lifetime.signal.aborted) { + failure = error instanceof Error ? error : new Error(String(error)) + options.onError?.(failure) + await host.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: error instanceof McpError ? error.code : ErrorCode.InternalError, + message: error instanceof Error ? error.message : String(error), + ...(error instanceof McpError && error.data !== undefined ? { data: error.data } : {}) + } + }) + } + await close() + } + } + + host.onerror = options.onError + host.onclose = () => void close() + host.onmessage = (message) => { + if (lifetime.signal.aborted) return + if ( + opening && + 'method' in message && + message.method === 'notifications/cancelled' && + message.params?.requestId === openingId + ) { + void close() + return + } + if (!opening) { + void initialize(message).catch((error: Error) => { + failure = error + options.onError?.(error) + void close() + }) + return + } + bufferedBytes += Buffer.byteLength(JSON.stringify(message)) + if (buffered.length >= 64 || bufferedBytes > STDIO_DEFAULT_MAX_BUFFER_SIZE) { + failure = new Error('Host initialization buffer exceeded the MCP buffer limit.') + options.onError?.(failure) + void close() + } else buffered.push(message) + } + await host.start() + return { close, closed, replaceBackend, pause, resume } +} diff --git a/packages/mcp-server/src/task-storage-recovery.ts b/packages/mcp-server/src/task-storage-recovery.ts new file mode 100644 index 00000000..b6cfb22b --- /dev/null +++ b/packages/mcp-server/src/task-storage-recovery.ts @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + renameSync, + writeFileSync +} from 'node:fs' +import { basename, join } from 'node:path' +import lockfile from 'proper-lockfile' + +import { DesignTaskStore } from './design-task-store' +import { HUB_LOCK_STALE_MS, HUB_LOCK_UPDATE_MS } from './shared' + +/** Explicit offline recovery retires every unknown old task; it never reconstructs Stop history. */ +export async function recoverDesignTaskStorage(directory: string): Promise<{ + generation: string + backupDirectory: string +}> { + const canonical = realpathSync(directory) + const release = await lockfile.lock(canonical, { + lockfilePath: join(canonical, '.tempad-hub-writer.lock'), + retries: 0, + stale: HUB_LOCK_STALE_MS, + update: HUB_LOCK_UPDATE_MS + }) + try { + const path = join(canonical, 'design-tasks.json') + const store = new DesignTaskStore(path) + store.load() + if (!store.recoveryError) + throw new Error('Task storage is readable. No recovery was performed.') + + const generation = randomUUID() + const backupDirectory = join(canonical, `design-tasks.recovery-${generation}`) + mkdirSync(backupDirectory, { mode: 0o700 }) + for (const suffix of ['', '.corrupt', '.tmp']) { + const source = `${path}${suffix}` + if (existsSync(source)) + writeFileSync(join(backupDirectory, basename(source)), readFileSync(source), { + flag: 'wx', + mode: 0o600, + flush: true + }) + } + + // This marker is intentionally not a task record: older readers must block all writes. + const snapshot = { revision: 0, current: {}, records: [{ recoveryGeneration: generation }] } + const temporary = `${path}.recovery-${generation}.tmp` + writeFileSync(temporary, JSON.stringify(snapshot), { flag: 'wx', mode: 0o600, flush: true }) + renameSync(temporary, path) + return { generation, backupDirectory } + } finally { + await release() + } +} diff --git a/packages/mcp-server/src/terminal-receipts.ts b/packages/mcp-server/src/terminal-receipts.ts new file mode 100644 index 00000000..99852d98 --- /dev/null +++ b/packages/mcp-server/src/terminal-receipts.ts @@ -0,0 +1,190 @@ +import type { FigmaSession, TerminalReceipt, ToolCompletionMessage } from '@tempad-dev/shared' + +import { randomUUID } from 'node:crypto' + +import type { Execution } from './execution-state' +import type { ExtensionConnection } from './types' + +const MAX_RECEIPTS = 128 + +type ReceiptRecord = Execution & { write: boolean } + +/** Persist write exclusion, never requests to replay or authority to resume a task. */ +export class TerminalReceipts { + readonly generation = randomUUID() + private readonly pending = new Map() + private readonly completed = new Map() + private abandoned: Execution[] = [] + + constructor(private readonly persist?: (records: Execution[]) => void) {} + + restore(records: Execution[]): void { + this.pending.clear() + this.abandoned = records.filter((record) => record.abandonedAt !== undefined) + for (const record of records) { + if (record.abandonedAt === undefined) this.pending.set(record.id, { ...record, write: true }) + } + } + + private save(pending = this.pending, abandoned = this.abandoned): void { + this.persist?.( + [...pending.values()] + .filter((record) => record.write) + .map(({ write: _write, ...record }) => record) + .concat(abandoned) + ) + } + + status() { + return [...this.pending.values()] + .filter((record) => record.write) + .map((record) => ({ + id: record.id, + revision: record.revision, + fileKey: record.session.fileKey, + browserId: record.browserId, + tabId: record.session.tabId, + documentId: record.session.documentId, + recordedAt: record.recordedAt + })) + } + + abandon(id: string, revision: string): ReceiptRecord | undefined { + const record = this.pending.get(id) + if (!record) { + if (this.abandoned.some((value) => value.id === id && value.revision === revision)) return + throw new Error('The execution block changed. Inspect --runtime-status again.') + } + if (!record.write || record.revision !== revision) + throw new Error('The execution block changed. Inspect --runtime-status again.') + const pending = new Map(this.pending) + pending.delete(id) + const { write: _write, ...execution } = record + const abandoned = [...this.abandoned, { ...execution, abandonedAt: Date.now() }] + this.save(pending, abandoned) + this.pending.delete(id) + this.abandoned = abandoned + return record + } + + canHandoff(): boolean { + return [...this.pending.values()].every((record) => record.write && record.handoff) + } + + transferableIds(): Set { + return new Set( + [...this.pending.values()] + .filter((record) => record.write && record.handoff) + .map((record) => record.id) + ) + } + + disconnect(extensionId: string): void { + for (const record of this.pending.values()) { + if (record.extensionId === extensionId && !record.write) this.remove(record) + } + } + + get pendingCount(): number { + return this.pending.size + } + + reserve( + requestId: string, + extension: ExtensionConnection, + session: FigmaSession, + taskId: string | undefined, + write: boolean, + epoch?: number + ): TerminalReceipt | undefined { + const supported = + !!extension.runtime?.terminalReceipts && + session.documentId !== undefined && + session.tabId !== undefined + if (!supported && !write) return + if (this.pending.size >= MAX_RECEIPTS) + throw Object.assign( + new Error( + 'Too many calls await a terminal receipt. Wait for completion acknowledgments or reload the executing Figma tabs before continuing.' + ), + { code: 'DESIGN_TASK_BUSY' } + ) + const receipt = { generation: this.generation, receiptId: randomUUID() } + const record: ReceiptRecord = { + id: requestId, + revision: randomUUID(), + recordedAt: Date.now(), + receipt, + extensionId: extension.id, + browserId: extension.sessions?.browserId ?? '', + origin: extension.origin, + session: { ...session }, + taskId, + epoch, + write, + handoff: supported && !!extension.runtime?.durableTerminalReceipts + } + const pending = new Map(this.pending).set(requestId, record) + if (write) this.save(pending) + this.pending.set(requestId, record) + return supported ? receipt : undefined + } + + settle(requestId: string, extensionId: string, definitive = true): void { + const record = this.pending.get(requestId) + if (!record || record.extensionId !== extensionId || (!definitive && record.write)) return + this.remove(record) + this.completed.set(requestId, record) + if (this.completed.size > MAX_RECEIPTS) + this.completed.delete(this.completed.keys().next().value!) + } + + private remove(record: ReceiptRecord): void { + const pending = new Map(this.pending) + pending.delete(record.id) + if (record.write) this.save(pending) + this.pending.delete(record.id) + } + + accept( + message: ToolCompletionMessage, + extension: ExtensionConnection + ): { accepted: boolean; recovered?: ReceiptRecord } { + const record = this.pending.get(message.id) ?? this.completed.get(message.id) + const session = extension.sessions?.sessions.find( + (value) => value.sessionId === record?.session.sessionId + ) + if ( + !record || + !extension.runtime?.terminalReceipts || + message.generation !== record.receipt.generation || + message.receiptId !== record.receipt.receiptId || + extension.origin !== record.origin || + extension.sessions?.browserId !== record.browserId || + session?.fileKey !== record.session.fileKey || + session.documentId !== record.session.documentId || + session.tabId !== record.session.tabId + ) + return { accepted: false } + if (!this.pending.has(message.id)) return { accepted: true } + this.settle(message.id, record.extensionId) + return { accepted: true, recovered: record } + } + + reconcile(extension: ExtensionConnection): void { + const snapshot = extension.sessions + if (!snapshot) return + for (const record of this.pending.values()) { + if (record.browserId !== snapshot.browserId || record.origin !== extension.origin) continue + const closed = + snapshot.openTabIds !== undefined && !snapshot.openTabIds.includes(record.session.tabId!) + const replaced = snapshot.sessions.some( + (session) => + session.tabId === record.session.tabId && + session.documentId !== undefined && + session.documentId !== record.session.documentId + ) + if (closed || replaced) this.remove(record) + } + } +} diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 67fa38ea..27009969 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -68,7 +68,6 @@ export type { type BaseToolMetadata = ToolSchema & { annotations: ToolAnnotations parameters: Schema - format?: (payload: ToolResultMap[Name]) => CallToolResult } type ExtensionToolMetadata = BaseToolMetadata< @@ -76,6 +75,7 @@ type ExtensionToolMetadata = Base Schema > & { target: 'extension' + format: (payload: ToolResultMap[Name]) => CallToolResult } type HubToolMetadata = BaseToolMetadata< @@ -131,7 +131,7 @@ const CONNECTIVITY_TROUBLESHOOTING_LINES = [ const MULTI_SELECTION_TROUBLESHOOTING_LINE = 'Tip: Select one or more visible nodes, or pass nodeIds (nodeId for a single node).' const VERIFICATION_TROUBLESHOOTING_LINE = - 'Tip: TemPad rolls verification failures back. Correct the reported desired-state mismatch and retry the affected root while preserving unrelated design intent.' + 'Tip: TemPad attempts to roll verification failures back. Check the reported rollback outcome and satisfy any readback requirement. Inspect the affected nodes and resources before choosing a bounded correction that preserves unrelated design intent. Do not repeat the failed write unchanged or use Undo without confirming which edit it would reverse.' function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) @@ -239,8 +239,7 @@ export const TOOL_DEFS = [ annotations: ASSET_WRITE_ANNOTATIONS, parameters: UploadAssetParametersSchema, target: 'hub', - outputSchema: UploadAssetResultSchema, - format: createUploadAssetToolResponse + outputSchema: UploadAssetResultSchema }) ] as const @@ -270,9 +269,18 @@ function createToolErrorResponse(toolName: string, error: unknown): CallToolResu const code = extractToolErrorCode(error) const codeLabel = code ? ` [${code}]` : '' const troubleshooting = buildTroubleshootingText(code, message) + const recovery = + toolName === 'manage_design_task' && code === TEMPAD_MCP_ERROR_CODES.DESIGN_TASK_INACTIVE + ? ManageDesignTaskParametersSchema.shape.recoveryGeneration.safeParse( + getRecordProperty(error, 'recoveryGeneration') + ) + : undefined return { isError: true, + ...(recovery?.success && recovery.data + ? { _meta: { code, message, recoveryGeneration: recovery.data } } + : {}), content: [ { type: 'text' as const, @@ -293,7 +301,12 @@ function buildTroubleshootingText(code: TempadMcpErrorCode | undefined, message: help.push(MULTI_SELECTION_TROUBLESHOOTING_LINE) } - if (code === TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC && /verification failed/i.test(message)) { + if ( + (code === TEMPAD_MCP_ERROR_CODES.INVALID_CANVAS_SPEC || + code === TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED || + code === TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED) && + /verification failed/i.test(message) + ) { help.push(VERIFICATION_TROUBLESHOOTING_LINE) } @@ -431,21 +444,6 @@ function isStructureResult(payload: unknown): payload is ToolResultMap['get_stru return isRecord(payload) && Array.isArray(payload.roots) } -export function coercePayloadToToolResponse(payload: unknown): CallToolResult { - if (isRecord(payload) && Array.isArray(payload.content)) { - return payload as CallToolResult - } - - return { - content: [ - { - type: 'text' as const, - text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) - } - ] - } -} - export function createUploadAssetToolResponse(payload: UploadAssetResult): CallToolResult { return toCallToolResult(buildUploadAssetToolResult(payload)) } @@ -490,7 +488,7 @@ function getBudgetRetryGuidance(toolName: ToolName): string { case 'set_design_anchor': return 'Retry the same task lifecycle request without changing its identity.' case 'apply_canvas': - return 'Submit a smaller desired subtree and retry.' + return 'The write may have completed. Read the original target before continuing; do not replay the write.' case 'get_code': return 'Reduce selection size or request a smaller nodeId subtree and retry.' case 'get_design_system': diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index 2129ce99..b89d25e0 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -13,6 +13,8 @@ export interface ExtensionConnection { protocolVersion: number version: string fingerprint: string + terminalReceipts?: true + durableTerminalReceipts?: true } } diff --git a/packages/mcp-server/tests/agent-clients.test.ts b/packages/mcp-server/tests/agent-clients.test.ts index d1f6e6e1..87c5c8db 100644 --- a/packages/mcp-server/tests/agent-clients.test.ts +++ b/packages/mcp-server/tests/agent-clients.test.ts @@ -6,6 +6,7 @@ import type { ClientHook } from '../src/agent-clients/hooks' import type { DesignTaskSnapshot } from '../src/design-task-store' import { CodexAppFeedback } from '../src/agent-clients/codex-feedback' +import { MISSING_HOST_CONTEXT_MESSAGE } from '../src/agent-clients/host-context' import { bindRequestMetadata, ClientEventSchema, @@ -88,6 +89,102 @@ async function fixture(connected = true) { } describe('client identity and capabilities', () => { + it('keeps unbound and pending Claude connections visible to automatic-update admission', () => { + const clients = new AgentClients(new DesignTasks({ createId: () => 'unused' })) + expect(clients.needsClaudeHooks('codex')).toBe(false) + clients.event('codex', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'codex-app' } + }) + expect(clients.needsClaudeHooks('codex')).toBe(false) + expect(clients.needsClaudeHooks('native', { name: 'claude-code' })).toBe(true) + clients.subscribeBinding('pending', () => {}, 'session-hint') + expect(clients.needsClaudeHooks('pending')).toBe(true) + clients.disconnect('pending') + expect(clients.needsClaudeHooks('pending')).toBe(false) + clients.event('claude', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude' } + }) + expect(clients.needsClaudeHooks('claude')).toBe(true) + clients.disconnect('claude') + expect(clients.needsClaudeHooks('claude')).toBe(false) + clients.close() + }) + + it('requires fresh receipt-capable hooks for the exact Claude connection binding', async () => { + const clients = new AgentClients(new DesignTasks({ createId: () => 'unused' })) + const info = { name: 'claude-code' } + const legacy = { + version: 1 as const, + kind: 'claude' as const, + sessionId: 'session', + event: 'UserPromptSubmit' as const, + invocationId: '00000000-0000-4000-8000-000000000001' + } + const current = { + ...legacy, + version: 2 as const, + issuedAt: Date.now(), + profile: { kind: 'release' as const } + } + try { + clients.event('edge', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: 'session' } + }) + clients.clientHook({ ...current, sessionId: 'other-session' }) + await clients.owner('edge', undefined, info) + expect(clients.needsClaudeHooks('edge', info)).toBe(true) + + clients.clientHook(legacy) + await clients.owner('edge', undefined, info) + expect(clients.needsClaudeHooks('edge', info)).toBe(true) + clients.clientHook(current) + expect(clients.needsClaudeHooks('edge', info)).toBe(false) + + clients.subscribeBinding('reconnected', () => {}, 'session') + expect(clients.needsClaudeHooks('reconnected', info)).toBe(true) + await clients.owner('reconnected', undefined, info) + expect(clients.needsClaudeHooks('reconnected', info)).toBe(false) + + clients.clientHook({ ...current, event: 'SessionEnd' }) + expect(clients.needsClaudeHooks('edge', info)).toBe(true) + expect(clients.needsClaudeHooks('reconnected', info)).toBe(true) + } finally { + clients.close() + } + }) + + it('records the end of native queue work even when it completes between inspections', async () => { + let now = 100 + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now) + const native = new CodexAppFeedback('/unused') + let finish!: () => void + const cancel = vi.spyOn(native, 'cancelQueued').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const clients = new AgentClients(new DesignTasks({ createId: () => 'unused' }), native) + try { + now = 200 + const pending = clients.cancelFeedback('task') + expect(clients.hasPendingActions).toBe(true) + expect(clients.lastActivity).toBe(200) + now = 300 + finish() + await pending + expect(clients.hasPendingActions).toBe(false) + expect(clients.lastActivity).toBe(300) + } finally { + clients.close() + cancel.mockRestore() + clock.mockRestore() + } + }) + it.each([false, true])( 'does not trust a browser-only review destination after restart=%s or an ordinary owner read', async (restart) => { @@ -218,14 +315,16 @@ describe('client identity and capabilities', () => { async (inheritedSession, hookTurn, initialized) => { const tasks = new DesignTasks({ createId: () => 'claude-task', now: () => 1000 }) const nativeOwner = vi.fn().mockResolvedValue('codex-owner') - const native = new CodexAppFeedback('/unused', async () => ({ - owner: nativeOwner, - close: vi.fn(), - request: vi.fn(), - broadcast: vi.fn(), - onBroadcast: vi.fn(() => () => {}), - onDisconnect: vi.fn(() => () => {}) - })) + const native = new CodexAppFeedback('/unused', { + open: async () => ({ + owner: nativeOwner, + close: vi.fn(), + request: vi.fn(), + broadcast: vi.fn(), + onBroadcast: vi.fn(() => () => {}), + onDisconnect: vi.fn(() => () => {}) + }) + }) const watch = vi.fn() const interrupt = vi.fn().mockResolvedValue(true) const clients = new AgentClients(tasks, native, () => ({ @@ -330,7 +429,7 @@ describe('client identity and capabilities', () => { expect(codex.client).toMatchObject({ kind: 'codex-app', sessionId: 'actual-thread' }) expect(codex.capabilities).toMatchObject({ interrupt: true, queue: true, steer: true }) expect(nativeOwner).toHaveBeenCalledWith('actual-thread', undefined, 2000) - expect(watch).toHaveBeenCalledWith('actual-thread') + expect(watch).toHaveBeenCalledWith('actual-thread', undefined) } } finally { clients.close() @@ -547,7 +646,10 @@ describe('client identity and capabilities', () => { it('keeps local stop available without claiming reverse controls', async () => { const f = await fixture(false) - expect(f.record.task.capabilities).toEqual(CODEX_FEEDBACK_UNAVAILABLE) + expect(f.record.task.capabilities).toEqual({ + ...CODEX_FEEDBACK_UNAVAILABLE, + reason: MISSING_HOST_CONTEXT_MESSAGE + }) expect((await f.clients.action(f.action({ action: 'stop', feedback: undefined }))).status).toBe( 'delivered' ) @@ -556,6 +658,36 @@ describe('client identity and capabilities', () => { }) describe('bound client actions', () => { + it.each([false, true])( + 'keeps native queue removal pending until it settles (failure=%s)', + async (failure) => { + const tasks = new DesignTasks({ createId: () => 'unused' }) + const codex = new CodexAppFeedback('/unused') + let resolve!: () => void + let reject!: (error: Error) => void + vi.spyOn(codex, 'cancelQueued') + .mockResolvedValue(undefined) + .mockReturnValueOnce( + new Promise((yes, no) => { + resolve = yes + reject = no + }) + ) + const clients = new AgentClients(tasks, codex) + expect(clients.hasPendingActions).toBe(false) + const cancelled = clients.cancelFeedback('task') + const settled = Promise.allSettled([cancelled]) + expect(clients.hasPendingActions).toBe(true) + if (failure) reject(new Error('Native queue unavailable')) + else resolve() + expect((await settled)[0]!.status).toBe(failure ? 'rejected' : 'fulfilled') + expect(clients.hasPendingActions).toBe(false) + await clients.cancelFeedback('other') + expect(clients.hasPendingActions).toBe(false) + clients.close() + } + ) + it.each(['queue', 'steer', 'continue'] as const)( 'rejects %s comments without native delivery even when hooks are active', async (mode) => { @@ -650,8 +782,10 @@ describe('bound client actions', () => { true ) const pending = f.clients.action(f.action({ action: 'stop', feedback: undefined })) + expect(f.clients.hasPendingActions).toBe(true) expect(f.record.task.status).toBe('stopping') expect((await pending).status).toBe('delivered') + expect(f.clients.hasPendingActions).toBe(false) f.tasks.finishOperation('write', 'extension-a') expect(f.record.task.status).toBe('cancelled') expect(() => f.tasks.resume(f.record.task.taskId, f.owner, 0, session)).toThrow( @@ -659,6 +793,49 @@ describe('bound client actions', () => { ) }) + it('cancels a completed Claude review without sending a Stop notice to its next turn', async () => { + const tasks = new DesignTasks({ createId: () => 'task', now: () => 1000 }) + const clients = new AgentClients(tasks) + let invocation = 0 + const hook = (event: ClientHook['event'], turnId: string) => + clients.clientHook({ + version: 1, + kind: 'claude', + sessionId: 'claude-session', + turnId, + event, + invocationId: `00000000-0000-4000-8000-${String(++invocation).padStart(12, '0')}` + }) + clients.event('connection', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: 'claude-session' } + }) + hook('UserPromptSubmit', 'turn-a') + const owner = await clients.owner('connection', undefined) + const record = tasks.begin( + owner, + 'extension', + session, + 'Review', + 'begin', + (await clients.describe(owner)).client + ) + tasks.stop(record.task.taskId, 'completed') + hook('UserPromptSubmit', 'turn-b') + + const result = await clients.action({ + requestId: 'stop', + taskId: record.task.taskId, + epoch: 0, + action: 'stop' + }) + expect(result.status).toBe('delivered') + expect(record.task.status).toBe('cancelled') + expect(hook('PreToolUse', 'turn-b')).toEqual({}) + expect(() => tasks.resume(record.task.taskId, owner, 0, session)).toThrow('cannot resume') + clients.close() + }) + it('does not stop a new task for a delayed Stop from the old task', async () => { const f = await fixture() f.tasks.stop(f.record.task.taskId, 'cancelled') diff --git a/packages/mcp-server/tests/asset-download-lifecycle.test.ts b/packages/mcp-server/tests/asset-download-lifecycle.test.ts new file mode 100644 index 00000000..88d15ee0 --- /dev/null +++ b/packages/mcp-server/tests/asset-download-lifecycle.test.ts @@ -0,0 +1,57 @@ +import type { IncomingMessage } from 'node:http' + +import { createHash } from 'node:crypto' +import { createReadStream, rmSync, writeFileSync } from 'node:fs' +import { get } from 'node:http' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' + +import type { AssetStore } from '../src/asset-store' + +import { createAssetHttpServer } from '../src/asset-http-server' +import { ASSET_DIR } from '../src/shared' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createReadStream: vi.fn(actual.createReadStream) } +}) + +it('closes source files after cancelled downloads and still serves a complete download', async () => { + const hash = 'a'.repeat(64) + const filePath = join(ASSET_DIR, `${hash}.png`) + const content = Buffer.alloc(8 * 1024 * 1024, 42) + writeFileSync(filePath, content) + const store = { + get: () => ({ hash, filePath, mimeType: 'image/png' }), + touch: vi.fn() + } as unknown as AssetStore + const server = createAssetHttpServer(store) + const streams = vi.mocked(createReadStream) + await server.start() + const url = `${server.getBaseUrl()}/assets/${hash}.png` + try { + for (let attempt = 0; attempt < 5; attempt++) { + const response = await new Promise((resolve, reject) => { + get(url, (res) => { + res.pause() + resolve(res) + }).on('error', reject) + }) + const source = streams.mock.results.at(-1)!.value as ReturnType + response.destroy() + await expect.poll(() => source.closed).toBe(true) + expect(source.bytesRead).toBeLessThan(content.length) + } + const response = await fetch(url) + const downloaded = Buffer.from(await response.arrayBuffer()) + expect(downloaded.length).toBe(content.length) + expect(createHash('sha256').update(downloaded).digest('hex')).toBe( + createHash('sha256').update(content).digest('hex') + ) + await expect.poll(() => streams.mock.results.every(({ value }) => value.closed)).toBe(true) + } finally { + for (const { value } of streams.mock.results) value.destroy() + server.stop() + rmSync(filePath, { force: true }) + } +}) diff --git a/packages/mcp-server/tests/asset-http-server.test.ts b/packages/mcp-server/tests/asset-http-server.test.ts index afb3e690..d1c4e0d6 100644 --- a/packages/mcp-server/tests/asset-http-server.test.ts +++ b/packages/mcp-server/tests/asset-http-server.test.ts @@ -1,11 +1,13 @@ import { createHash } from 'node:crypto' -import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { request } from 'node:http' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AssetStore } from '../src/asset-store' import { createAssetHttpServer } from '../src/asset-http-server' +import { buildAssetFilename } from '../src/asset-utils' import { ASSET_DIR } from '../src/shared' type StoreMock = { @@ -46,6 +48,108 @@ afterEach(() => { }) describe('asset-http-server', () => { + it('reuses the exact port and both capabilities, and refuses a conflicting listener', async () => { + const store = createStoreMock() + const options = { accessToken: 'a'.repeat(64), legacyAccessToken: 'b'.repeat(64) } + const first = createAssetHttpServer(store, options) + await first.start() + const base = first.getBaseUrl() + const legacy = first.getBaseUrl(true) + const port = Number(new URL(base).port) + const conflict = createAssetHttpServer(store, { ...options, port }) + await expect(conflict.start()).rejects.toMatchObject({ code: 'EADDRINUSE' }) + first.stop() + const second = createAssetHttpServer(store, { ...options, port }) + await second.start() + try { + expect(second.getBaseUrl()).toBe(base) + expect(second.getBaseUrl(true)).toBe(legacy) + expect((await fetch(assetUrl(base))).status).toBe(404) + expect( + (await fetch(assetUrl(base), { headers: { Origin: 'https://other.invalid' } })).status + ).toBe(403) + } finally { + second.stop() + } + }) + + it('fences new transfers during recovery without affecting authentication or admitted uploads', async () => { + const store = createStoreMock() + let admit = true + const body = Buffer.from('admitted asset') + const hash = createHash('sha256').update(body).digest('hex') + const filename = `${hash}.png` + const server = createAssetHttpServer(store, { admit: () => admit }) + await server.start() + createdPaths.push(join(ASSET_DIR, filename)) + const url = `${server.getBaseUrl()}/assets/${filename}` + const upload = request(url, { + method: 'POST', + headers: { 'Content-Type': 'image/png', 'Content-Length': body.length } + }) + const uploaded = new Promise((resolve, reject) => { + upload.once('error', reject) + upload.once('response', (response) => { + response.resume() + resolve(response.statusCode) + }) + }) + void uploaded.catch(() => {}) + try { + expect(server.pendingTransfers).toBe(0) + upload.write(body.subarray(0, 1)) + await vi.waitFor(() => expect(server.pendingTransfers).toBe(1)) + store.get.mockClear() + admit = false + expect((await fetch(url)).status).toBe(503) + expect( + (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'image/png' }, body })) + .status + ).toBe(503) + expect(store.get).not.toHaveBeenCalled() + upload.end(body.subarray(1)) + expect(await uploaded).toBe(201) + await vi.waitFor(() => expect(server.pendingTransfers).toBe(0)) + admit = true + expect((await fetch(url)).status).toBe(404) + expect(store.get).toHaveBeenCalledWith(hash) + } finally { + upload.destroy() + server.stop() + } + }) + + it('uploads and downloads an image subtype with a hyphen through its generated filename', async () => { + const store = createStoreMock() + const records = new Map>() + store.get.mockImplementation((hash) => records.get(hash)) + store.upsert.mockImplementation((record) => records.set(record.hash, record)) + const body = Buffer.from('icon bytes') + const hash = createHash('sha256').update(body).digest('hex') + const filename = buildAssetFilename(hash, 'image/x-icon') + const activity = vi.fn() + const server = createAssetHttpServer(store, { onActivity: activity }) + await server.start() + const url = `${server.getBaseUrl()}/assets/${filename}` + createdPaths.push(join(ASSET_DIR, filename)) + try { + const uploaded = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'image/x-icon' }, + body + }) + expect(uploaded.status).toBe(201) + await vi.waitFor(() => expect(activity).toHaveBeenCalledTimes(2)) + const downloaded = await fetch(url) + expect(downloaded.status).toBe(200) + expect(downloaded.headers.get('content-type')).toBe('image/x-icon') + expect(Buffer.from(await downloaded.arrayBuffer())).toEqual(body) + await vi.waitFor(() => expect(activity).toHaveBeenCalledTimes(4)) + } finally { + server.stop() + } + }) + it('accepts short hashes only on the legacy capability and refuses full-content collisions', async () => { const store = createStoreMock() const records = new Map>() @@ -289,6 +393,55 @@ describe('asset-http-server', () => { server.stop() }) + it('preserves one asset path when concurrent uploads finish with different MIME types', async () => { + const store = createStoreMock() + const records = new Map>() + store.get.mockImplementation((hash) => records.get(hash)) + store.upsert.mockImplementation((record) => records.set(record.hash, record)) + const body = Buffer.from('concurrent image bytes') + const hash = createHash('sha256').update(body).digest('hex') + const firstPath = join(ASSET_DIR, `${hash}.png`) + createdPaths.push(firstPath, join(ASSET_DIR, `${hash}.jpg`)) + const server = createAssetHttpServer(store) + await server.start() + const upload = (mimeType: string) => { + const uploading = request(`${server.getBaseUrl()}/assets/${hash}`, { + method: 'POST', + headers: { 'Content-Type': mimeType, 'Content-Length': body.length } + }) + const completed = new Promise((resolve, reject) => { + uploading.on('error', reject) + uploading.on('response', (response) => { + response.resume() + response.on('end', () => resolve(response.statusCode)) + }) + }) + uploading.flushHeaders() + return { uploading, completed } + } + const first = upload('image/png') + const second = upload('image/jpeg') + try { + // Both bodies reserve a new upload before either asset exists. + await vi.waitFor(() => expect(store.get).toHaveBeenCalledTimes(2)) + first.uploading.end(body) + expect(await first.completed).toBe(201) + second.uploading.end(body) + expect.soft(await second.completed).toBe(200) + expect.soft(records.get(hash)?.filePath).toBe(firstPath) + expect.soft(records.get(hash)?.mimeType).toBe('image/jpeg') + expect(readdirSync(ASSET_DIR).filter((name) => name.startsWith(hash))).toEqual([ + `${hash}.png` + ]) + expect(readFileSync(firstPath)).toEqual(body) + } finally { + first.uploading.destroy() + second.uploading.destroy() + await Promise.allSettled([first.completed, second.completed]) + server.stop() + } + }) + it('returns hash mismatch and handles successful uploads', async () => { const store = createStoreMock() const server = createAssetHttpServer(store) diff --git a/packages/mcp-server/tests/asset-http-server.unit.test.ts b/packages/mcp-server/tests/asset-http-server.unit.test.ts index 8fb56b17..8555cfbc 100644 --- a/packages/mcp-server/tests/asset-http-server.unit.test.ts +++ b/packages/mcp-server/tests/asset-http-server.unit.test.ts @@ -10,7 +10,7 @@ type MockState = { listenError?: Error pipelineError?: Error & { code?: string } digest: string - readMode: 'normal' | 'error-before' | 'error-after' + readMode: 'normal' | 'error' exists: Map statError?: Error & { code?: string } statSize: number @@ -142,7 +142,7 @@ function createRequest(input: { } as IncomingMessage & { resume: ReturnType } } -function createResponse(input?: { headersSent?: boolean; keepHeadersSentOnWriteHead?: boolean }) { +function createResponse(input?: { headersSent?: boolean }) { const callbacks = new Map void>>() const addCallback = (event: string, cb: () => void) => { const registered = callbacks.get(event) ?? [] @@ -170,9 +170,7 @@ function createResponse(input?: { headersSent?: boolean; keepHeadersSentOnWriteH }), writeHead: vi.fn((status: number, headers?: Record) => { res.statusCode = status - if (!input?.keepHeadersSentOnWriteHead) { - res.headersSent = true - } + res.headersSent = true if (headers) Object.assign(res.headers, headers) }), end: vi.fn((body?: string) => { @@ -225,20 +223,7 @@ beforeEach(() => { }) mocks.createReadStream.mockImplementation(() => { - const stream = new EventEmitter() as EventEmitter & { pipe: (target: unknown) => void } - stream.pipe = (target: unknown) => { - if (state.readMode === 'error-before') { - stream.emit('error', new Error('stream failed')) - return - } - if (state.readMode === 'error-after') { - ;(target as { headersSent: boolean }).headersSent = true - stream.emit('error', new Error('stream failed')) - return - } - stream.emit('open') - } - return stream + return new EventEmitter() }) mocks.createWriteStream.mockImplementation(() => ({})) @@ -255,8 +240,20 @@ beforeEach(() => { }) mocks.pipeline.mockImplementation( - (_req: unknown, _monitor: unknown, _writeStream: unknown, done: (err?: Error) => void) => { - done(state.pipelineError) + ( + source: EventEmitter, + destination: ReturnType, + third: unknown, + done?: (err?: Error) => void + ) => { + if (done) { + done(state.pipelineError) + } else if (state.readMode === 'error') { + destination.destroy() + ;(third as (err: Error) => void)(new Error('stream failed')) + } else { + source.emit('open') + } } ) @@ -276,6 +273,48 @@ afterEach(() => { }) describe('asset-http-server unit branches', () => { + it('rejects malformed URLs and continues serving later requests', async () => { + const store = createStoreMock() + await createAssetHttpServer(store).start() + + const invalid = createResponse() + expect(() => state.requestHandler?.(createRequest({ url: 'http://[' }), invalid)).not.toThrow() + expect(invalid.statusCode).toBe(400) + expect(readJson(invalid)).toEqual({ error: 'Invalid URL' }) + expect(store.get).not.toHaveBeenCalled() + + const next = createResponse() + state.requestHandler?.(createRequest({}), next) + expect(next.statusCode).toBe(404) + expect(readJson(next)).toEqual({ error: 'Asset Not Found' }) + }) + + it('contains unexpected routing failures within the current response', async () => { + const store = createStoreMock() + store.get.mockImplementationOnce(() => { + throw new Error('store unavailable') + }) + await createAssetHttpServer(store).start() + + const failed = createResponse() + expect(() => state.requestHandler?.(createRequest({}), failed)).not.toThrow() + expect(failed.statusCode).toBe(500) + expect(readJson(failed)).toEqual({ error: 'Internal Server Error' }) + + const next = createResponse() + state.requestHandler?.(createRequest({}), next) + expect(next.statusCode).toBe(404) + + mocks.createReadStream.mockImplementationOnce(() => { + throw new Error('cannot open stream') + }) + store.get.mockReturnValue({ filePath: ASSET_FILE_PATH, mimeType: 'image/png' }) + const started = createResponse() + expect(() => state.requestHandler?.(createRequest({}), started)).not.toThrow() + expect(started.destroy).toHaveBeenCalledOnce() + expect(started.end).not.toHaveBeenCalled() + }) + it('treats stop as a no-op before start', () => { const store = createStoreMock() const server = createAssetHttpServer(store) @@ -413,17 +452,16 @@ describe('asset-http-server unit branches', () => { expect(readJson(statErrorRes)).toEqual({ error: 'Internal Server Error' }) state.statError = undefined - state.readMode = 'error-before' - const notSentRes = createResponse({ keepHeadersSentOnWriteHead: true }) - state.requestHandler?.(createRequest({ url: `/assets/${ASSET_FILENAME}` }), notSentRes) - expect(notSentRes.statusCode).toBe(500) - expect(readJson(notSentRes)).toEqual({ error: 'Internal Server Error' }) - - state.readMode = 'error-after' + state.readMode = 'error' const sentRes = createResponse() state.requestHandler?.(createRequest({ url: `/assets/${ASSET_FILENAME}` }), sentRes) expect(sentRes.statusCode).toBe(200) - expect(sentRes.end).toHaveBeenCalled() + expect(sentRes.destroy).toHaveBeenCalledOnce() + expect(sentRes.end).not.toHaveBeenCalled() + expect(mocks.log.warn).toHaveBeenCalledWith( + { error: expect.any(Error), hash: ASSET_HASH }, + 'Failed to stream asset file.' + ) }) it('bounds concurrent downloads and times out stalled responses', async () => { @@ -462,6 +500,39 @@ describe('asset-http-server unit branches', () => { expect(recoveredResponse.statusCode).toBe(200) }) + it('protects an accepted download from expiry before its file stream opens', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1000) + try { + const store = createStoreMock() + const record = { + hash: ASSET_HASH, + filePath: ASSET_FILE_PATH, + mimeType: 'image/png', + size: 10, + uploadedAt: 100, + lastAccess: 100 + } + store.get.mockReturnValue(record) + store.touch.mockImplementation(() => { + record.lastAccess = Date.now() + return record + }) + // Delay opening the source until after a cleanup tick could run. + mocks.pipeline.mockImplementationOnce(() => {}) + await createAssetHttpServer(store).start() + const response = createResponse() + state.requestHandler?.(createRequest({}), response) + if (Date.now() - record.lastAccess > 500) store.remove(record.hash) + expect(store.remove).not.toHaveBeenCalled() + expect(response.statusCode).toBe(200) + const source = mocks.createReadStream.mock.results[0]!.value as EventEmitter + source.emit('open') + expect(store.touch).toHaveBeenCalledExactlyOnceWith(ASSET_HASH) + } finally { + now.mockRestore() + } + }) + it('preserves existing upload paths and handles headers-sent responses', async () => { const store = createStoreMock() const existing = { diff --git a/packages/mcp-server/tests/asset-store.test.ts b/packages/mcp-server/tests/asset-store.test.ts index 7132c168..0fd283ac 100644 --- a/packages/mcp-server/tests/asset-store.test.ts +++ b/packages/mcp-server/tests/asset-store.test.ts @@ -218,7 +218,7 @@ describe('asset-store', () => { expect(writeFileSync).toHaveBeenCalled() }) - it('logs warning when removing file fails', () => { + it('retains failed deletions in the catalog and retries removing the file', () => { const indexPath = '/mock/remove-index.json' fsState.existing.add(indexPath) fsState.contentByPath.set( @@ -244,6 +244,20 @@ describe('asset-store', () => { { hash: 'x1', error: expect.any(Error) }, 'Failed to remove asset file on delete.' ) + expect(store.list().map(({ hash, size }) => ({ hash, size }))).toEqual([ + { hash: 'x1', size: 1 } + ]) + store.flush() + expect(JSON.parse(fsState.contentByPath.get(indexPath)!)).toEqual([ + expect.objectContaining({ hash: 'x1', size: 1 }) + ]) + + fsState.rmErrorByPath.delete('/mock/assets/x1.png') + store.remove('x1') + expect(fsState.existing.has('/mock/assets/x1.png')).toBe(false) + expect(store.has('x1')).toBe(false) + store.flush() + expect(JSON.parse(fsState.contentByPath.get(indexPath)!)).toEqual([]) }) it('reconciles stale records, temp files, and orphan files', () => { diff --git a/packages/mcp-server/tests/asset-utils.test.ts b/packages/mcp-server/tests/asset-utils.test.ts index 75a0d08e..3f029c61 100644 --- a/packages/mcp-server/tests/asset-utils.test.ts +++ b/packages/mcp-server/tests/asset-utils.test.ts @@ -43,4 +43,13 @@ describe('asset-utils', () => { expect(getHashFromAssetFilename('too-short.png')).toBeNull() expect(getHashFromAssetFilename(`${hash}.bad-ext!`)).toBeNull() }) + + it.each(['a1b2c3d4', 'a1b2c3d4'.repeat(8)])( + 'reads a generated hyphenated image filename for hash %s', + (hash) => { + const filename = buildAssetFilename(hash, 'image/x-icon') + expect(filename).toBe(`${hash}.x-icon`) + expect(getHashFromAssetFilename(filename)).toBe(hash) + } + ) }) diff --git a/packages/mcp-server/tests/claude-stop-notice.test.ts b/packages/mcp-server/tests/claude-stop-notice.test.ts new file mode 100644 index 00000000..f680a1bc --- /dev/null +++ b/packages/mcp-server/tests/claude-stop-notice.test.ts @@ -0,0 +1,286 @@ +import type { ClientHook } from '@tempad-dev/shared' + +import { randomUUID } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { AgentClients } from '../src/agent-clients/registry' +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' + +const cleanups: (() => void)[] = [] +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) cleanup() +}) + +async function fixture(turnId: string | null = 'turn-a') { + const directory = mkdtempSync(join(tmpdir(), 'tp-stop-notice-')) + const store = new DesignTaskStore(join(directory, 'tasks.json')) + let id = 0 + let tasks = new DesignTasks({ + createId: () => (++id === 1 ? 'task' : `task-${id}`), + now: () => 1000, + store + }) + let clients = new AgentClients(tasks) + cleanups.push(() => { + clients.close() + rmSync(directory, { recursive: true, force: true }) + }) + const input = ( + event: ClientHook['event'], + extra: Partial> = {} + ): ClientHook => ({ + version: 2, + profile: { kind: 'release' }, + issuedAt: 1000, + kind: 'claude', + sessionId: 'claude-session', + ...(turnId ? { turnId } : {}), + event, + invocationId: randomUUID(), + ...extra + }) + const hook = (event: ClientHook) => + tasks.applyHook(event.invocationId, event, () => clients.clientHook(event)) + const connect = (sessionId?: string) => + clients.event('connection', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', ...(sessionId ? { sessionId } : {}) } + }) + const owner = () => clients.owner('connection', undefined, { name: 'claude-code' }) + connect() + hook(input('UserPromptSubmit')) + const initialOwner = await owner() + const session = { + sessionId: 'figma', + fileKey: 'file', + fileName: 'Fixture', + pageId: 'page', + busy: false + } + tasks.begin(initialOwner, 'extension', session, 'Stop probe', 'begin') + tasks.confirm('task', initialOwner) + hook(input('PostToolUse', { taskId: 'task' })) + await owner() + return { + get tasks() { + return tasks + }, + get clients() { + return clients + }, + store, + input, + hook, + owner, + session, + stop: () => + clients.action({ + requestId: randomUUID(), + taskId: 'task', + epoch: tasks.find('task')!.task.epoch!, + action: 'stop' + }), + restart: async () => { + clients.close() + tasks = new DesignTasks({ createId: () => 'unused', now: () => 1000, store }) + tasks.restore() + clients = new AgentClients(tasks) + connect('claude-session') + await owner() + } + } +} + +describe('durable Claude Stop notices', () => { + it.each([false, true])( + 'keeps cancellation and notice in one snapshot while executing=%s', + async (executing) => { + const f = await fixture() + if (executing) + f.tasks.startOperation( + 'write', + await f.owner(), + 'extension', + 'browser', + f.session, + 'task', + true + ) + const save = vi.spyOn(f.store, 'save') + expect(await f.stop()).toMatchObject({ status: 'delivered' }) + for (const [snapshot] of save.mock.calls) { + const record = snapshot.records.find(({ task }) => task.taskId === 'task')! + expect(record.claudeStopNotice).toEqual({ sessionId: 'claude-session', turnId: 'turn-a' }) + expect(record.task.status).toBe(executing ? 'stopping' : 'cancelled') + } + expect(save).toHaveBeenCalled() + await f.restart() + expect(f.tasks.find('task')?.task.status).toBe('cancelled') + expect(f.clients.hasLegacyHooks).toBe(true) + expect(f.hook(f.input('PreToolUse'))).toMatchObject({ + output: { hookSpecificOutput: { additionalContext: expect.stringContaining('task task.') } } + }) + await f.owner() + expect(f.clients.hasLegacyHooks).toBe(false) + expect(f.tasks.find('task')?.task.status).toBe('cancelled') + } + ) + + it.each([true, false])( + 'deduplicates the consumed notice across another restart with turn ID=%s', + async (withTurn) => { + const f = await fixture(withTurn ? 'turn-a' : null) + await f.stop() + await f.restart() + const event = f.input('PostToolUse') + const result = f.hook(event) + expect(result).toMatchObject({ + output: { hookSpecificOutput: { additionalContext: expect.stringContaining('task task.') } } + }) + const saved = f.store.load()! + expect(saved.records[0]?.claudeStopNotice).toBeUndefined() + expect( + saved.hookReceipts?.find(({ invocationId }) => invocationId === event.invocationId)?.result + ).toEqual(result) + await f.restart() + expect(f.hook(event)).toEqual(result) + expect(f.hook(f.input('PreToolUse'))).toEqual({}) + expect(f.tasks.find('task')?.task.status).toBe('cancelled') + } + ) + + it('does not consume or adopt a stale session/turn before the correct fresh hook', async () => { + const f = await fixture() + await f.stop() + await f.restart() + expect(f.hook(f.input('PreToolUse', { sessionId: 'different-session' }))).toEqual({}) + expect(f.hook(f.input('PreToolUse', { turnId: 'old-turn' }))).toEqual({}) + expect(f.clients.hasLegacyHooks).toBe(true) + expect(f.store.load()?.records[0]?.claudeStopNotice).toBeDefined() + expect(f.hook(f.input('PreToolUse'))).toMatchObject({ output: expect.any(Object) }) + }) + + it.each([false, true])( + 'keeps the original owner after a stale SessionEnd with restart=%s', + async (restart) => { + const f = await fixture() + const originalOwner = await f.owner() + if (restart) { + await f.stop() + await f.restart() + } + const published = vi.fn() + f.clients.subscribeBinding('connection', published) + expect(f.hook(f.input('SessionEnd', { turnId: 'old-turn' }))).toEqual({}) + expect(published).not.toHaveBeenCalled() + expect(f.tasks.find('task')?.task.status).toBe(restart ? 'cancelled' : 'active') + expect(f.hook(f.input('PreToolUse'))).toEqual(restart ? { output: expect.any(Object) } : {}) + const owner = await f.owner() + expect(owner).toBe(originalOwner) + expect(() => f.tasks.owned('task', owner)).not.toThrow() + + expect(f.hook(f.input('SessionEnd'))).toEqual({}) + expect(published).toHaveBeenCalledWith({ kind: 'claude' }) + expect(await f.owner()).not.toBe(originalOwner) + } + ) + + it('keeps legacy hooks silent while rejecting stale lifecycle and task binding effects', async () => { + const f = await fixture() + const owner = await f.owner() + const hook = (event: ClientHook['event'], turnId: string, taskId?: string) => + f.clients.clientHook({ + version: 1, + kind: 'claude', + sessionId: 'claude-session', + turnId, + event, + invocationId: randomUUID(), + ...(taskId ? { taskId } : {}) + }) + const published = vi.fn() + f.clients.subscribeBinding('connection', published) + expect(hook('PostToolUse', 'old-turn', 'task')).toEqual({}) + expect(hook('SessionEnd', 'old-turn')).toEqual({}) + expect(published).not.toHaveBeenCalled() + expect(await f.owner()).toBe(owner) + expect(f.tasks.find('task')?.task.status).toBe('active') + expect(hook('PreToolUse', 'turn-a')).toEqual({}) + expect(hook('SessionEnd', 'turn-a')).toEqual({}) + expect(published).toHaveBeenCalledWith({ kind: 'claude' }) + expect(f.tasks.find('task')?.task.status).toBe('paused') + }) + + it('does not replace the authoritative turn through a stale task-bound hook', async () => { + const f = await fixture() + await f.stop() + expect(f.hook(f.input('PostToolUse', { taskId: 'task', turnId: 'old-turn' }))).toEqual({}) + const owner = await f.owner() + const description = await f.clients.describe(owner) + const next = f.tasks.begin( + owner, + 'extension', + f.session, + 'Explicit next probe', + 'next', + description.client + ) + f.tasks.confirm(next.task.taskId, owner) + await f.clients.action({ + requestId: randomUUID(), + taskId: next.task.taskId, + epoch: 0, + action: 'stop' + }) + expect( + f.store.load()?.records.find(({ task }) => task.taskId === next.task.taskId)?.claudeStopNotice + ).toEqual({ + sessionId: 'claude-session', + turnId: 'turn-a' + }) + }) + + it.each(['UserPromptSubmit', 'Stop', 'SessionEnd', 'Interrupt'] as const)( + 'retires a pending notice on %s even without a turn ID', + async (event) => { + const f = await fixture() + await f.stop() + await f.restart() + expect(f.hook(f.input(event, { turnId: undefined }))).toEqual({}) + expect(f.store.load()?.records[0]?.claudeStopNotice).toBeUndefined() + await f.restart() + expect(f.hook(f.input('PreToolUse', { turnId: undefined }))).toEqual({}) + } + ) + + it('does not invent a notice from an ordinary cancelled task or restore old active capability', async () => { + const f = await fixture() + f.tasks.stop('task', 'cancelled') + await f.restart() + expect(f.clients.hasLegacyHooks).toBe(true) + expect(f.hook(f.input('PreToolUse'))).toEqual({}) + expect(f.tasks.find('task')?.task.status).toBe('cancelled') + }) + + it('retries failed notice persistence using its original durable receipt', async () => { + const f = await fixture() + await f.stop() + await f.restart() + const event = f.input('PreToolUse') + vi.spyOn(f.store, 'save').mockImplementationOnce(() => { + throw new Error('disk unavailable') + }) + expect(() => f.hook(event)).toThrow('cannot be flushed') + expect(f.store.load()?.records[0]?.claudeStopNotice).toBeDefined() + const result = f.hook(event) + expect(result).toMatchObject({ output: expect.any(Object) }) + expect(f.store.load()?.records[0]?.claudeStopNotice).toBeUndefined() + await f.restart() + expect(f.hook(event)).toEqual(result) + expect(f.hook(f.input('PreToolUse'))).toEqual({}) + }) +}) diff --git a/packages/mcp-server/tests/client-hook.integration.test.ts b/packages/mcp-server/tests/client-hook.integration.test.ts index a4ea0e8b..231d3c82 100644 --- a/packages/mcp-server/tests/client-hook.integration.test.ts +++ b/packages/mcp-server/tests/client-hook.integration.test.ts @@ -9,8 +9,13 @@ import { PassThrough } from 'node:stream' import { fileURLToPath } from 'node:url' import { describe, expect, it, vi } from 'vitest' +import { + checkoutProfile, + runtimeDirectories +} from '../../../agent-plugin/src/clients/shared/runtime-profile.mjs' import { ClientHookRequestSchema, ClientHooks, type ClientHook } from '../src/agent-clients/hooks' import { ClientEventSchema } from '../src/agent-clients/identity' +import { DesignTasks } from '../src/design-tasks' const hook = fileURLToPath( new URL('../../../agent-plugin/src/clients/shared/lifecycle.mjs', import.meta.url) @@ -21,7 +26,8 @@ async function withHookHub( directory: string, hooks: ClientHooks, received: ClientHook[], - root: string + root: string, + tasks: DesignTasks ) => Promise, linuxRoot?: 'private' | 'unsafe' ) { @@ -36,7 +42,8 @@ async function withHookHub( ) await mkdir(directory, { recursive: true, mode: 0o700 }) } - const hooks = new ClientHooks() + const tasks = new DesignTasks({ createId: () => 'task-a' }) + const hooks = new ClientHooks((input) => tasks.takeClaudeStopNotice(input)) const received: ClientHook[] = [] const clients = new Set() const server = createServer((socket) => { @@ -44,7 +51,12 @@ async function withHookHub( clients.add(mcp) mcp.server.setRequestHandler(ClientHookRequestSchema, (request) => { received.push(request.params) - return hooks.handle(request.params) + return { + ...hooks.handle(request.params), + ...(request.params.version === 2 + ? { receipt: { version: 2, invocationId: request.params.invocationId, state: 'applied' } } + : {}) + } }) void mcp.connect(new StdioServerTransport(socket, socket)) socket.once('close', () => { @@ -55,7 +67,7 @@ async function withHookHub( try { await new Promise((resolve) => server.listen(join(directory, 'mcp.sock'), resolve)) if (process.platform !== 'win32') await chmod(join(directory, 'mcp.sock'), 0o600) - await run(directory, hooks, received, root) + await run(directory, hooks, received, root, tasks) } finally { await Promise.all([...clients].map((client) => client.close())) await new Promise((resolve) => server.close(() => resolve())) @@ -88,22 +100,307 @@ async function runHook( }) let stdout = '', stderr = '' - child.stdout.on('data', (data) => { - stdout += String(data) + child.stdout.setEncoding('utf8').on('data', (data) => { + stdout += data }) - child.stderr.on('data', (data) => { - stderr += String(data) + child.stderr.setEncoding('utf8').on('data', (data) => { + stderr += data }) child.stdin.end( JSON.stringify({ hook_event_name: event, session_id: 'thread-a', turn_id: 'turn-a', ...extra }) ) - const code = await new Promise((resolve) => child.once('exit', resolve)) + const code = await new Promise((resolve) => child.once('close', resolve)) return { code, stdout, stderr } } // The installed Windows hook ignores runtime-directory overrides for its fixed pipe. // Only the in-memory protocol test is safe there; never probe the user's real Hub. describe('plugin hook transport', () => { + it.skipIf(process.platform === 'win32').each(['lost reply', 'accepted', 'legacy'] as const)( + 'retries one invocation after %s without repeating its effect', + async (mode) => { + const directory = await mkdtemp(join(tmpdir(), 'th-retry-')) + const received: ClientHook[] = [] + const applied = new Set() + const server = createServer((socket) => { + socket.setEncoding('utf8') + let buffer = '' + socket.on('data', (chunk) => { + buffer += chunk + if (!buffer.endsWith('\n')) return + const input = ClientHookRequestSchema.parse(JSON.parse(buffer)).params + received.push(input) + if (mode === 'legacy' && input.version === 2) { + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + error: { code: -32602, message: 'Invalid lifecycle hook parameters.' } + }) + '\n' + ) + return + } + applied.add(input.invocationId) + if (mode === 'lost reply' && received.length === 1) { + socket.destroy() + return + } + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: + input.version === 2 + ? { + receipt: { + version: 2, + invocationId: input.invocationId, + state: mode === 'accepted' && received.length === 1 ? 'accepted' : 'applied' + } + } + : {} + }) + '\n' + ) + }) + }) + try { + await new Promise((resolve) => server.listen(join(directory, 'mcp.sock'), resolve)) + await chmod(join(directory, 'mcp.sock'), 0o600) + expect(await runHook(directory, 'Stop', {}, 'claude')).toEqual({ + code: 0, + stdout: '', + stderr: '' + }) + expect(received).toHaveLength(2) + expect(new Set(received.map((input) => input.invocationId)).size).toBe(1) + expect(applied.size).toBe(1) + expect(received[0]).toMatchObject({ version: 2, profile: { kind: 'release' } }) + expect(received[1]?.version).toBe(mode === 'legacy' ? 1 : 2) + } finally { + await new Promise((resolve) => server.close(() => resolve())) + await rm(directory, { recursive: true, force: true }) + } + } + ) + it.skipIf(process.platform === 'win32')( + 'uses the generated checkout and configured directory without MCP environment inheritance', + async () => { + const root = await mkdtemp('/tmp/th-profile-') + const checkout = join(root, 'checkout') + const fallbackDir = join(root, 'fallback') + const pluginRoot = join(root, 'plugin') + const descriptor = join(pluginRoot, 'clients/shared/runtime-profile.json') + const serverName = 'renamed-checkout-server' + const received: string[] = [] + const servers = [createServer(), createServer()] + try { + await mkdir(join(checkout, 'packages/mcp-server/dist'), { recursive: true }) + for (const name of ['cli.mjs', 'hub.mjs']) + await writeFile(join(checkout, 'packages/mcp-server/dist', name), '') + const profile = checkoutProfile(checkout) + await mkdir(join(pluginRoot, 'clients/shared'), { recursive: true }) + await writeFile(descriptor, JSON.stringify({ checkout, id: profile.id, serverName })) + const config = { + mcpServers: { + [serverName]: { + command: 'node', + args: [join(checkout, 'packages/mcp-server/dist/cli.mjs')], + env: { TEMPAD_MCP_DEV_CHECKOUT: checkout, TEMPAD_MCP_RUNTIME_DIR: root } + } + } + } + const configPath = join(pluginRoot, '.mcp.json') + await writeFile(configPath, JSON.stringify(config)) + const devDir = runtimeDirectories({ + TEMPAD_MCP_RUNTIME_DIR: root, + TEMPAD_MCP_DEV_CHECKOUT: checkout + }).runtime + await mkdir(fallbackDir, { mode: 0o700 }) + await mkdir(devDir, { mode: 0o700 }) + for (const [index, directory] of [fallbackDir, devDir].entries()) { + const server = servers[index]! + server.on('connection', (socket) => { + socket.setEncoding('utf8') + let text = '' + socket.on('data', (data) => { + text += data + if (!text.endsWith('\n')) return + received.push(index ? 'checkout' : 'release') + socket.end( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + receipt: { + version: 2, + invocationId: JSON.parse(text).params.invocationId, + state: 'applied' + } + } + }) + '\n' + ) + }) + }) + await new Promise((resolve) => server.listen(join(directory, 'mcp.sock'), resolve)) + await chmod(join(directory, 'mcp.sock'), 0o600) + } + const invoke = async (path: string) => { + const child = spawn(process.execPath, [hook, 'claude', '--runtime-profile', path], { + env: { + ...process.env, + TEMPAD_MCP_DEV_CHECKOUT: undefined, + TEMPAD_MCP_RUNTIME_DIR: fallbackDir + }, + stdio: ['pipe', 'ignore', 'pipe'] + }) + let stderr = '' + child.stderr.setEncoding('utf8').on('data', (data) => { + stderr += data + }) + child.stdin.end(JSON.stringify({ hook_event_name: 'Stop', session_id: 'profile-test' })) + expect(await new Promise((resolve) => child.once('close', resolve))).toBe(0) + expect(stderr).toBe('') + } + await invoke(descriptor) + expect(received).toEqual(['checkout']) + config.mcpServers[serverName].env.TEMPAD_MCP_RUNTIME_DIR = '..' + await writeFile(configPath, JSON.stringify(config)) + await invoke(descriptor) + expect(received).toEqual(['checkout']) + config.mcpServers[serverName].env.TEMPAD_MCP_RUNTIME_DIR = root + await writeFile(configPath, JSON.stringify(config)) + await invoke(join(root, 'missing.json')) + await writeFile(descriptor, JSON.stringify({ checkout, id: '0'.repeat(16), serverName })) + await invoke(descriptor) + expect(received).toEqual(['checkout']) + await writeFile(descriptor, JSON.stringify({ checkout, id: profile.id })) + await invoke(descriptor) + expect(received).toEqual(['checkout']) + await writeFile(descriptor, JSON.stringify({ checkout, id: profile.id, serverName })) + config.mcpServers[serverName].env.TEMPAD_MCP_DEV_CHECKOUT = root + await writeFile(configPath, JSON.stringify(config)) + await invoke(descriptor) + await rm(configPath) + await invoke(descriptor) + expect(received).toEqual(['checkout']) + } finally { + await Promise.all( + servers.map((server) => new Promise((resolve) => server.close(() => resolve()))) + ) + await rm(root, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32').each([ + { name: 'a normal reply', prefix: '' }, + { + name: 'unrelated frames before the reply', + prefix: 'not JSON\nnull\n42\n{}\n{"id":2,"error":{"message":"Other request"}}\n' + } + ])('preserves split UTF-8 with $name', async ({ prefix }) => { + const directory = await mkdtemp(join(tmpdir(), 'th-')) + const input = { + hook_event_name: 'PostToolUse', + session_id: 'thread-会话', + tool_name: 'mcp__tempad-dev__manage_design_task', + tool_input: { action: 'begin' }, + tool_response: { structuredContent: { taskId: 'task-設計' } } + } + const output = { + hookSpecificOutput: { + hookEventName: input.hook_event_name, + additionalContext: 'Stop · 已停止 🛑' + } + } + const requestBytes = Buffer.from(JSON.stringify(input)) + const requestSplit = requestBytes.indexOf(Buffer.from('会')) + 1 + let received: ClientHook | undefined + let finishResponse: (() => void) | undefined + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + let buffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk) => { + buffer += chunk + if (!buffer.endsWith('\n')) return + const message = JSON.parse(buffer) + const request = ClientHookRequestSchema.parse(message) + received = request.params + const bytes = Buffer.from( + prefix + + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + output, + receipt: { version: 2, invocationId: request.params.invocationId, state: 'applied' } + } + }) + + '\n' + ) + const split = bytes.indexOf(Buffer.from('🛑')) + 1 + finishResponse = () => socket.end(bytes.subarray(split)) + socket.write(bytes.subarray(0, split)) + }) + socket.once('close', () => sockets.delete(socket)) + }) + let child: ReturnType | undefined + try { + await new Promise((resolve) => server.listen(join(directory, 'mcp.sock'), resolve)) + await chmod(join(directory, 'mcp.sock'), 0o600) + // Observe actual stream delivery before sending the rest. Separate writes alone can + // coalesce, hiding the split character; no timing delay or fake decoder is involved. + const observer = ` + import { Socket } from 'node:net' + const emit = Socket.prototype.emit + Socket.prototype.emit = function(event, ...args) { + const result = Reflect.apply(emit, this, [event, ...args]) + if (event === 'data') process.send(this === process.stdin ? 'stdin' : 'socket') + return result + } + process.channel.unref() + ` + const hookProcess = spawn( + process.execPath, + ['--import', `data:text/javascript,${encodeURIComponent(observer)}`, hook, 'claude'], + { + env: { ...process.env, TEMPAD_MCP_RUNTIME_DIR: directory }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'] + } + ) + child = hookProcess + let stdout = '', + stderr = '' + hookProcess.stdout!.setEncoding('utf8').on('data', (chunk) => (stdout += chunk)) + hookProcess.stderr!.setEncoding('utf8').on('data', (chunk) => (stderr += chunk)) + const chunks = new Set() + hookProcess.on('message', (source) => { + if (chunks.has(source)) return + chunks.add(source) + if (source === 'stdin') hookProcess.stdin!.end(requestBytes.subarray(requestSplit)) + if (source === 'socket') finishResponse?.() + }) + const closed = new Promise((resolve) => hookProcess.once('close', resolve)) + hookProcess.stdin!.write(requestBytes.subarray(0, requestSplit)) + expect(await closed).toBe(0) + expect(stderr).toBe('') + expect(chunks).toContain('stdin') + expect(chunks).toContain('socket') + expect.soft(received).toMatchObject({ + sessionId: input.session_id, + taskId: input.tool_response.structuredContent.taskId + }) + expect(JSON.parse(stdout)).toEqual(output) + } finally { + child?.kill() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + await rm(directory, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'keeps the isolated Hub after legacy root changes and refuses ambiguous roots', async () => { @@ -176,7 +473,7 @@ describe('plugin hook transport', () => { expect(await runHook(directory, 'Interrupt')).toEqual({ code: 0, stdout: '', stderr: '' }) expect(received).toEqual([ expect.objectContaining({ - version: 1, + version: 2, event: 'Interrupt', kind: 'codex', sessionId: 'thread-a', @@ -189,18 +486,27 @@ describe('plugin hook transport', () => { } ) - it.skipIf(process.platform === 'win32').each(['codex', 'claude'] as const)( - 'reports %s task binding and emits only the one-time Figma Stop notice', - async (kind) => { - await withHookHub(async (directory, hooks, received) => { + it.skipIf(process.platform === 'win32')( + 'reports Claude task binding and emits only the one-time Figma Stop notice', + async () => { + const kind = 'claude' + await withHookHub(async (directory, _hooks, received, _root, tasks) => { await runHook(directory, 'UserPromptSubmit', {}, kind) - hooks.stop( + tasks.begin( + 'owner', + 'extension', { - client: { kind, name: kind, sessionId: 'thread-a' }, - turnId: 'turn-a' + sessionId: 'figma', + fileKey: 'file', + fileName: 'Fixture', + pageId: 'page', + busy: false }, - 'task-a' + 'Fixture', + 'begin', + { kind, name: 'Claude Code', sessionId: 'thread-a' } ) + tasks.stop('task-a', 'cancelled', { sessionId: 'thread-a', turnId: 'turn-a' }) const result = await runHook( directory, 'PostToolUse', @@ -379,6 +685,18 @@ describe('plugin hook transport', () => { } ) + it.skipIf(process.platform === 'win32')( + 'exits quietly when the Hub rejects the hook', + async () => { + await withHookHub(async (directory, hooks) => { + vi.spyOn(hooks, 'handle').mockImplementation(() => { + throw new Error('Unsupported hook') + }) + expect(await runHook(directory, 'PreToolUse')).toEqual({ code: 0, stdout: '', stderr: '' }) + }) + } + ) + it.skipIf(process.platform === 'win32')( 'exits quietly when no Hub is running instead of launching a helper', async () => { diff --git a/packages/mcp-server/tests/client-hooks.test.ts b/packages/mcp-server/tests/client-hooks.test.ts index ac3b1d17..1abe78ce 100644 --- a/packages/mcp-server/tests/client-hooks.test.ts +++ b/packages/mcp-server/tests/client-hooks.test.ts @@ -1,9 +1,34 @@ import { describe, expect, it } from 'vitest' import { ClientHooks, type ClientHook } from '../src/agent-clients/hooks' +import { DesignTasks } from '../src/design-tasks' + +function fixture() { + const tasks = new DesignTasks({ createId: () => 'task-a' }) + tasks.begin( + 'owner', + 'extension', + { + sessionId: 'figma', + fileKey: 'file', + fileName: 'Fixture', + pageId: 'page', + busy: false + }, + 'Fixture', + 'begin', + { kind: 'claude', name: 'Claude Code', sessionId: 'thread-a' } + ) + const hooks = new ClientHooks((input) => tasks.takeClaudeStopNotice(input)) + const stop = () => tasks.stop('task-a', 'cancelled', { sessionId: 'thread-a', turnId: 'turn-a' }) + return { hooks, stop } +} let sequence = 0 -function event(name: ClientHook['event'], extra: Partial = {}): ClientHook { +function event( + name: ClientHook['event'], + extra: Partial> = {} +): ClientHook { return { version: 1, kind: 'codex', @@ -19,7 +44,7 @@ describe('client lifecycle hooks', () => { it.each(['codex', 'claude'] as const)( 'keeps %s tool and turn boundaries silent without a Stop notice', (kind) => { - const hooks = new ClientHooks() + const { hooks } = fixture() for (const name of [ 'UserPromptSubmit', 'PreToolUse', @@ -32,44 +57,32 @@ describe('client lifecycle hooks', () => { } ) - it.each(['codex', 'claude'] as const)( - 'conveys a %s Figma Stop exactly once to the matching turn and conversation', - (kind) => { - const hooks = new ClientHooks() - hooks.handle(event('UserPromptSubmit', { kind })) - hooks.stop( - { - client: { kind, name: kind, sessionId: 'thread-a' }, - turnId: 'turn-a' - }, - 'task-a' - ) - expect(hooks.handle(event('PreToolUse', { kind, sessionId: 'thread-b' }))).toEqual({}) - expect(hooks.handle(event('PreToolUse', { kind, turnId: 'old-turn' }))).toEqual({}) - expect(hooks.handle(event('PreToolUse', { kind }))).toEqual({ - output: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: expect.stringContaining('The user stopped Figma design task task-a.') - } + it('conveys a Claude Figma Stop exactly once to the matching turn and conversation', () => { + const kind = 'claude' + const { hooks, stop } = fixture() + hooks.handle(event('UserPromptSubmit', { kind })) + stop() + expect(hooks.handle(event('PreToolUse', { kind, sessionId: 'thread-b' }))).toEqual({}) + expect(hooks.handle(event('PreToolUse', { kind, turnId: 'old-turn' }))).toBeUndefined() + expect(hooks.handle(event('PreToolUse', { kind }))).toEqual({ + output: { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + additionalContext: expect.stringContaining('The user stopped Figma design task task-a.') } - }) - expect(hooks.handle(event('PostToolUse', { kind }))).toEqual({}) - expect(hooks.handle(event('Stop', { kind }))).toEqual({}) - } - ) + } + }) + expect(hooks.handle(event('PostToolUse', { kind }))).toEqual({}) + expect(hooks.handle(event('Stop', { kind }))).toEqual({}) + }) it('does not replay an old Stop notice into a new user turn', () => { - const hooks = new ClientHooks() - hooks.handle(event('UserPromptSubmit')) - hooks.stop( - { - client: { kind: 'codex-app', name: 'Codex App', sessionId: 'thread-a' }, - turnId: 'turn-a' - }, - 'task-a' + const { hooks, stop } = fixture() + hooks.handle(event('UserPromptSubmit', { kind: 'claude' })) + stop() + expect(hooks.handle(event('UserPromptSubmit', { kind: 'claude', turnId: 'turn-b' }))).toEqual( + {} ) - expect(hooks.handle(event('UserPromptSubmit', { turnId: 'turn-b' }))).toEqual({}) - expect(hooks.handle(event('PreToolUse', { turnId: 'turn-b' }))).toEqual({}) + expect(hooks.handle(event('PreToolUse', { kind: 'claude', turnId: 'turn-b' }))).toEqual({}) }) }) diff --git a/packages/mcp-server/tests/codex-feedback.test.ts b/packages/mcp-server/tests/codex-feedback.test.ts index 6e2bfc13..be0bad84 100644 --- a/packages/mcp-server/tests/codex-feedback.test.ts +++ b/packages/mcp-server/tests/codex-feedback.test.ts @@ -70,11 +70,12 @@ async function fixture() { })) const queue = new CodexNativeQueue( async () => [], - async () => null + async () => null, + { list: vi.fn(), add: vi.fn(), remove: vi.fn(), close: vi.fn() } ) vi.spyOn(queue, 'admit').mockRejectedValue(new CodexQueueUnavailable('Queue unavailable')) const load = vi.fn<(id: string, signal: AbortSignal) => Promise>().mockResolvedValue() - const native = new CodexAppFeedback(dir, open, 1, load, queue) + const native = new CodexAppFeedback(dir, { open, retryMs: 1, load, nativeQueue: queue }) cleanups.push(async () => native.close()) const controller = new AbortController() const validate = vi.fn() @@ -151,7 +152,12 @@ describe('Codex feedback delivery', () => { feedbackCreatedAt: value.createdAt }) f.native.close() - const restarted = new CodexAppFeedback(f.dir, f.open, 1, f.load, f.queue) + const restarted = new CodexAppFeedback(f.dir, { + open: f.open, + retryMs: 1, + load: f.load, + nativeQueue: f.queue + }) cleanups.push(async () => restarted.close()) now += HISTORY_RETENTION_MS await restarted.reconcileQueued(() => false) @@ -295,7 +301,12 @@ describe('Codex feedback delivery', () => { f.native.close() // A successful read after restart proves the observed commit is no longer queued. read.mockResolvedValue([]) - const restarted = new CodexAppFeedback(f.dir, f.open, 1, f.load, f.queue) + const restarted = new CodexAppFeedback(f.dir, { + open: f.open, + retryMs: 1, + load: f.load, + nativeQueue: f.queue + }) cleanups.push(async () => restarted.close()) await restarted.reconcileQueued(() => false) await restarted.reconcileQueued(() => false) @@ -646,7 +657,7 @@ describe('Codex feedback delivery', () => { clients.close() const restarted = new DesignTasks({ createId: () => 'unused', now: () => 3000, store }) restarted.restore() - const native = new CodexAppFeedback(f.dir, f.open, undefined, undefined, f.queue) + const native = new CodexAppFeedback(f.dir, { open: f.open, nativeQueue: f.queue }) const retried = new AgentClients(restarted, native) cleanups.push(async () => retried.close()) expect( @@ -751,7 +762,7 @@ describe('Codex feedback delivery', () => { .mockRejectedValueOnce(new CodexIpcError('Connection lost', true)) await expect(f.send(batch)).rejects.toThrow('Steer could not be confirmed') expect(f.dispatched).toHaveBeenCalledOnce() - const restarted = new CodexAppFeedback(f.dir, f.open, undefined, undefined, f.queue) + const restarted = new CodexAppFeedback(f.dir, { open: f.open, nativeQueue: f.queue }) cleanups.push(async () => restarted.close()) await expect( restarted.enqueue(binding, 'task-a', batch, f.controller.signal, f.validate, f.dispatched) @@ -840,7 +851,7 @@ describe('Codex feedback delivery', () => { f.owner.mockResolvedValue('owner-a') }) await f.send() - expect(f.load).toHaveBeenCalledExactlyOnceWith('thread-a', expect.any(AbortSignal)) + expect(f.load).toHaveBeenCalledExactlyOnceWith('thread-a', expect.any(AbortSignal), undefined) expect(f.request).toHaveBeenCalledOnce() expect(await f.native.available(binding)).toBe(true) }) @@ -851,7 +862,7 @@ describe('Codex feedback delivery', () => { .mockRejectedValueOnce(new CodexIpcError('no-client-found')) .mockRejectedValueOnce(new CodexDiscoveryError()) await f.send() - expect(f.load).toHaveBeenCalledExactlyOnceWith('thread-a', expect.any(AbortSignal)) + expect(f.load).toHaveBeenCalledExactlyOnceWith('thread-a', expect.any(AbortSignal), undefined) expect(f.owner).toHaveBeenCalledTimes(3) for (const [, , timeoutMs] of f.owner.mock.calls.slice(1)) { expect(timeoutMs).toBeGreaterThan(0) @@ -957,7 +968,7 @@ describe('Codex feedback delivery', () => { f.request.mockRejectedValueOnce(new CodexIpcError('Disconnected', true)) await expect(f.send()).rejects.toThrow('could not be confirmed') } else await f.send() - const restarted = new CodexAppFeedback(f.dir, f.open, undefined, undefined, f.queue) + const restarted = new CodexAppFeedback(f.dir, { open: f.open, nativeQueue: f.queue }) const retry = restarted.enqueue( binding, 'new-lease-task', diff --git a/packages/mcp-server/tests/codex-lifecycle.test.ts b/packages/mcp-server/tests/codex-lifecycle.test.ts index ecd5804b..a134ffe2 100644 --- a/packages/mcp-server/tests/codex-lifecycle.test.ts +++ b/packages/mcp-server/tests/codex-lifecycle.test.ts @@ -217,7 +217,7 @@ describe('Codex native lifecycle', () => { } ] }) - expect(f.changed.mock.calls).toEqual([['thread-a', undefined]]) + expect(f.changed.mock.calls).toEqual([['thread-a', undefined, undefined]]) expect(f.connection.broadcast).toHaveBeenCalledTimes(2) f.emit({ type: 'patches', baseRevision: 2, revision: 3, patches: [] }) expect(f.connection.broadcast).toHaveBeenCalledTimes(2) @@ -243,6 +243,29 @@ describe('Codex native lifecycle', () => { } ) + it.each(['disconnect', 'owner loss', 'version'] as const)( + 'ignores trailing frames in the same batch after %s invalidates the subscription', + async (reason) => { + const f = await fixture() + f.snapshot() + f.changed.mockClear() + if (reason === 'disconnect') for (const listener of f.disconnects) listener() + else if (reason === 'owner loss') + f.emit(undefined, { + method: 'client-status-changed', + params: { clientId: 'owner', status: 'disconnected' } + }) + else f.emit({}, { version: 999 }) + f.snapshot(2, 'completed') + + expect(f.lifecycle.state('thread-a')).toBeUndefined() + expect(f.changed.mock.calls).toEqual([['thread-a', undefined, undefined]]) + await vi.waitFor(() => expect(f.open.mock.calls.length).toBeGreaterThan(1)) + f.snapshot() + expect(f.lifecycle.state('thread-a')?.turns[0]?.status).toBe('inProgress') + } + ) + it('interrupts only the expected turn through its current owner and validates acknowledgement', async () => { const f = await fixture() f.snapshot() @@ -279,7 +302,15 @@ describe('hook-free task lifecycle', () => { let taskCount = 0 const tasks = new DesignTasks({ createId: () => `task-${taskCount++ === 0 ? 'a' : 'b'}`, - now: () => 1000 + now: () => 1000, + onReviewStop: ({ task }) => { + void clients.action({ + requestId: 'restored-stop', + taskId: task.taskId, + epoch: task.epoch ?? 0, + action: 'stop' + }) + } }) const feedback = { available: vi.fn().mockResolvedValue(true), @@ -317,7 +348,7 @@ describe('hook-free task lifecycle', () => { it('binds through MCP metadata, ignores old hooks, and pauses only the matching finished turn', async () => { const f = await taskFixture() - expect(f.native.watch).toHaveBeenCalledWith('thread-a') + expect(f.native.watch).toHaveBeenCalledWith('thread-a', undefined) f.clients.clientHook({ version: 1, kind: 'codex', @@ -352,7 +383,7 @@ describe('hook-free task lifecycle', () => { action: 'stop' }) expect(f.record.task.status).toBe('cancelled') - expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-a') + expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-a', undefined) settle() const result = await pending expect(result.status).toBe('delivered') @@ -361,6 +392,26 @@ describe('hook-free task lifecycle', () => { } ) + it('interrupts the native turn once when a replacement worker restores Stop before Done', async () => { + const f = await taskFixture() + const stopped = { ...f.record.task, status: 'cancelled' as const, reviewClosed: true } + f.tasks.disconnectExtension('extension') + const replacement = { + id: 'replacement-worker', + origin: 'chrome-extension://tempad', + sessions: { browserId: 'browser', sessions: [f.session] } + } as Parameters[1] + + f.tasks.restoreReview(stopped, replacement, f.session) + await vi.waitFor(() => + expect(f.native.interrupt).toHaveBeenCalledExactlyOnceWith('thread-a', 'turn-a', undefined) + ) + expect(f.record.task).toMatchObject({ status: 'cancelled', reviewClosed: true }) + expect(() => f.tasks.resume('task-a', f.owner, 0, f.session)).toThrow('closed') + f.tasks.restoreReview(stopped, replacement, f.session) + expect(f.native.interrupt).toHaveBeenCalledTimes(1) + }) + it('pauses the old lease at a turn boundary and tracks the next turn for Stop', async () => { const f = await taskFixture() f.update([ @@ -369,7 +420,30 @@ describe('hook-free task lifecycle', () => { ]) expect(f.record.task.status).toBe('paused') await f.clients.action({ requestId: 'stop', taskId: 'task-a', epoch: 0, action: 'stop' }) - expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b') + expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b', undefined) + }) + + it('permanently cancels a completed review without interrupting the next native turn', async () => { + const f = await taskFixture() + f.tasks.stop('task-a', 'completed') + f.update([ + { turnId: 'turn-a', status: 'completed' }, + { turnId: 'turn-b', status: 'inProgress' } + ]) + const cancelFeedback = vi.spyOn(f.clients, 'cancelFeedback') + const accepted = vi.fn() + const action = { requestId: 'stop', taskId: 'task-a', epoch: 0, action: 'stop' as const } + + const pending = f.clients.action(action, accepted) + expect(f.record.task.status).toBe('cancelled') + expect(accepted).toHaveBeenCalledOnce() + expect(cancelFeedback).toHaveBeenCalledExactlyOnceWith('task-a') + expect((await pending).status).toBe('delivered') + expect(() => f.tasks.resume('task-a', f.owner, 0, f.session)).toThrow('cannot resume') + await f.clients.action({ ...action, requestId: 'repeat-stop' }, accepted) + expect(cancelFeedback).toHaveBeenCalledOnce() + expect(accepted).toHaveBeenCalledOnce() + expect(f.native.interrupt).not.toHaveBeenCalled() }) it('retains the native turn identity when a later MCP request only identifies the conversation', async () => { @@ -382,7 +456,7 @@ describe('hook-free task lifecycle', () => { 'x-codex-turn-metadata': { thread_id: 'thread-a' } }) await f.clients.action({ requestId: 'stop', taskId: 'task-a', epoch: 0, action: 'stop' }) - expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b') + expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b', undefined) }) it('recovers the current native turn when the conversation reconnects without turn metadata', async () => { @@ -397,7 +471,7 @@ describe('hook-free task lifecycle', () => { 'x-codex-turn-metadata': { thread_id: 'thread-a' } }) await f.clients.action({ requestId: 'stop', taskId: 'task-a', epoch: 0, action: 'stop' }) - expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b') + expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b', undefined) }) it.each([ @@ -447,7 +521,7 @@ describe('hook-free task lifecycle', () => { epoch: 0, action: 'stop' }) - expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-a') + expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-a', undefined) expect(result.message).toContain('Codex turn interrupted') expect(f.record.task.status).toBe('cancelled') }) @@ -462,7 +536,7 @@ describe('hook-free task lifecycle', () => { f.native.state.mockReturnValue({ turns: [{ turnId: 'turn-b', status: 'inProgress' }] }) expect((await f.clients.describe(f.record.ownerId)).capabilities.interrupt).toBe(true) await f.clients.action({ requestId: 'stop', taskId: 'task-a', epoch: 0, action: 'stop' }) - expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b') + expect(f.native.interrupt).toHaveBeenCalledWith('thread-a', 'turn-b', undefined) }) it('keeps an exact metadata target across a state gap and never chases another turn', async () => { @@ -476,7 +550,7 @@ describe('hook-free task lifecycle', () => { epoch: 0, action: 'stop' }) - expect(f.native.interrupt).toHaveBeenCalledExactlyOnceWith('thread-a', 'turn-a') + expect(f.native.interrupt).toHaveBeenCalledExactlyOnceWith('thread-a', 'turn-a', undefined) expect(result.message).toContain('did not interrupt the expected turn') expect(log.info).toHaveBeenCalledWith( expect.objectContaining({ expectedTurnId: 'turn-a', interrupted: false }), @@ -492,7 +566,7 @@ describe('hook-free task lifecycle', () => { { turnId: 'turn-b', status: 'inProgress' } ]) ) - expect(f.native.interrupt).toHaveBeenCalledExactlyOnceWith('thread-a', 'turn-a') + expect(f.native.interrupt).toHaveBeenCalledExactlyOnceWith('thread-a', 'turn-a', undefined) expect(f.record.task.status).toBe('cancelled') }) diff --git a/packages/mcp-server/tests/codex-platform.test.ts b/packages/mcp-server/tests/codex-platform.test.ts index 54ffa903..8e4c3908 100644 --- a/packages/mcp-server/tests/codex-platform.test.ts +++ b/packages/mcp-server/tests/codex-platform.test.ts @@ -9,6 +9,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CodexAppFeedback } from '../src/agent-clients/codex-feedback' import { CodexIpc, CodexIpcError, codexSocketPaths } from '../src/agent-clients/codex-ipc' import { CodexNativeQueue, CodexQueueUnavailable } from '../src/agent-clients/codex-queue' +import { captureHostContext } from '../src/agent-clients/host-context' +import { hostEnvironment } from '../src/process-environment' + +const context = captureHostContext({ HOME: '/fixture', TMPDIR: '/fixture/tmp' }) vi.mock('node:net', async (original) => ({ ...(await original()), @@ -36,44 +40,67 @@ function windows() { process.getuid = undefined as unknown as typeof process.getuid } +function pipe(response?: Record) { + const messages: Record[] = [] + class Pipe extends EventEmitter { + destroyed = false + write(bytes: Buffer) { + const message = JSON.parse(bytes.subarray(4).toString()) + messages.push(message) + const body = Buffer.from( + JSON.stringify({ + type: 'response', + requestId: message.requestId, + method: message.method, + resultType: 'success', + handledByClientId: 'owner', + result: + message.method === 'initialize' + ? { clientId: 'tempad-client' } + : { supportsUntrustedAppInput: true }, + ...(message.method === 'initialize' ? {} : response) + }) + ) + const header = Buffer.alloc(4) + header.writeUInt32LE(body.length) + queueMicrotask(() => this.emit('data', Buffer.concat([header, body]))) + } + destroy() { + this.destroyed = true + this.emit('close') + } + } + vi.mocked(connect).mockImplementation(() => { + const pipe = new Pipe() + queueMicrotask(() => pipe.emit('connect')) + return pipe as unknown as ReturnType + }) + return messages +} + describe('Codex Windows integration', () => { + it.each([ + ['C:\\Users\\another', 'C:', '\\Users\\another'], + ['\\\\server\\users\\another', '\\\\server\\users', '\\another'] + ])('keeps Windows home variables consistent with %s', (home, drive, path) => { + windows() + expect( + hostEnvironment( + { ...context, home }, + { + HOMEDRIVE: 'Z:', + HOMEPATH: '\\first-launcher', + USERPROFILE: 'Z:\\first-launcher' + } + ) + ).toMatchObject({ HOME: home, USERPROFILE: home, HOMEDRIVE: drive, HOMEPATH: path }) + }) + it('connects and initializes the fixed local pipe without Unix inode checks', async () => { windows() - const messages: Record[] = [] - class Pipe extends EventEmitter { - destroyed = false - write(bytes: Buffer) { - const message = JSON.parse(bytes.subarray(4).toString()) - messages.push(message) - const body = Buffer.from( - JSON.stringify({ - type: 'response', - requestId: message.requestId, - method: message.method, - resultType: 'success', - handledByClientId: 'owner', - result: - message.method === 'initialize' - ? { clientId: 'tempad-client' } - : { supportsUntrustedAppInput: true } - }) - ) - const header = Buffer.alloc(4) - header.writeUInt32LE(body.length) - queueMicrotask(() => this.emit('data', Buffer.concat([header, body]))) - } - destroy() { - this.destroyed = true - this.emit('close') - } - } - vi.mocked(connect).mockImplementation(() => { - const pipe = new Pipe() - queueMicrotask(() => pipe.emit('connect')) - return pipe as unknown as ReturnType - }) - expect(codexSocketPaths()).toEqual([String.raw`\\.\pipe\codex-ipc`]) - const ipc = await CodexIpc.open() + const messages = pipe() + expect(codexSocketPaths(context)).toEqual([String.raw`\\.\pipe\codex-ipc`]) + const ipc = await CodexIpc.open(codexSocketPaths(context)) try { expect(await ipc.owner('thread-a')).toBe('owner') expect(connect).toHaveBeenCalledExactlyOnceWith(String.raw`\\.\pipe\codex-ipc`) @@ -87,6 +114,38 @@ describe('Codex Windows integration', () => { } }) + it.each([ + { response: { resultType: 'error', error: 'request-timeout' }, uncertain: true }, + { response: { resultType: 'future-result', error: 'Unknown outcome' }, uncertain: true }, + { response: { resultType: 'error', error: {} }, uncertain: true }, + { response: { resultType: 'error', error: 'no-client-found' }, uncertain: false } + ])('preserves delivery certainty for $response', async ({ response, uncertain }) => { + windows() + pipe(response) + const ipc = await CodexIpc.open(codexSocketPaths(context)) + try { + await expect(ipc.request('thread-follower-start-turn', 2, {}, 'owner')).rejects.toMatchObject( + { uncertain } + ) + } finally { + ipc.close() + } + }) + + it('reports a host discovery timeout as unsent input', async () => { + windows() + pipe({ resultType: 'error', error: 'request-timeout' }) + const ipc = await CodexIpc.open(codexSocketPaths(context)) + try { + await expect(ipc.owner('thread-a')).rejects.toMatchObject({ + message: expect.stringContaining('No comments were sent.'), + uncertain: false + }) + } finally { + ipc.close() + } + }) + it.each([String.raw`\\remote\pipe\codex-ipc`, String.raw`\\.\pipe\other`, 'C:\\ipc.sock'])( 'rejects an unexpected Windows endpoint %s before connecting', async (path) => { @@ -110,12 +169,12 @@ describe('Codex Windows integration', () => { }) const queue = new CodexNativeQueue( async () => [], - async () => null + async () => null, + { list: vi.fn(), add: vi.fn(), remove: vi.fn(), close: vi.fn() } ) vi.spyOn(queue, 'admit').mockRejectedValue(new CodexQueueUnavailable('Queue unavailable')) - const native = new CodexAppFeedback( - directory, - async () => ({ + const native = new CodexAppFeedback(directory, { + open: async () => ({ owner, request, close: vi.fn(), @@ -123,11 +182,13 @@ describe('Codex Windows integration', () => { onBroadcast: vi.fn(() => () => {}), onDisconnect: vi.fn(() => () => {}) }), - 1, - undefined, - queue - ) - const binding = { client: { kind: 'codex-app' as const, name: 'Codex', sessionId: id } } + retryMs: 1, + nativeQueue: queue + }) + const binding = { + client: { kind: 'codex-app' as const, name: 'Codex', sessionId: id }, + hostContext: context + } vi.mocked(execFile).mockImplementation(((...args: unknown[]) => { owner.mockResolvedValue('owner') const callback = args.at(-1) as (error: null, stdout: string, stderr: string) => void diff --git a/packages/mcp-server/tests/codex-queue-rpc.test.ts b/packages/mcp-server/tests/codex-queue-rpc.test.ts index 2cdc80c9..a95ae419 100644 --- a/packages/mcp-server/tests/codex-queue-rpc.test.ts +++ b/packages/mcp-server/tests/codex-queue-rpc.test.ts @@ -6,6 +6,9 @@ const mocks = vi.hoisted(() => ({ spawn: vi.fn(), execFile: vi.fn() })) vi.mock('node:child_process', () => mocks) import { CodexQueueRpc, codexQueueExecutable } from '../src/agent-clients/codex-queue-rpc' +import { captureHostContext } from '../src/agent-clients/host-context' + +const context = captureHostContext({ HOME: '/fixture', TMPDIR: '/fixture/tmp' }) type Request = { id?: number; method: string; params?: Record } const clients: CodexQueueRpc[] = [] @@ -42,12 +45,44 @@ function peer(handle: (request: Request) => unknown = () => ({ data: [], nextCur } function client(executable = vi.fn(async () => '/host/resources/codex')) { - const rpc = new CodexQueueRpc(executable) + const rpc = new CodexQueueRpc(context, executable) clients.push(rpc) return rpc } describe('queue-only native RPC', () => { + it('starts each helper with its originating host paths and without inherited credentials', async () => { + vi.stubEnv('OPENAI_API_KEY', 'not-for-helpers') + vi.stubEnv('CODEX_HOME', '/first-launcher') + vi.stubEnv('CODEX_THREAD_ID', 'first-thread') + vi.stubEnv('NODE_OPTIONS', '--require /unrelated.js') + try { + peer() + peer() + const first = client() + const otherContext = { + ...context, + codexHome: '/second-home', + codexSqliteHome: '/second-sqlite' + } + const second = new CodexQueueRpc(otherContext, async () => '/host/resources/codex') + clients.push(second) + await Promise.all([first.list('same-thread'), second.list('same-thread')]) + expect(mocks.spawn.mock.calls.map((call) => call[2].env.CODEX_HOME)).toEqual([ + context.codexHome, + otherContext.codexHome + ]) + for (const [, , options] of mocks.spawn.mock.calls) { + expect(options.env).not.toHaveProperty('OPENAI_API_KEY') + expect(options.env).not.toHaveProperty('CODEX_THREAD_ID') + expect(options.env).not.toHaveProperty('NODE_OPTIONS') + } + expect(mocks.spawn.mock.calls[1]![2].env.CODEX_SQLITE_HOME).toBe('/second-sqlite') + } finally { + vi.unstubAllEnvs() + } + }) + it('releases an idle queue process and reconnects on demand', async () => { vi.useFakeTimers() const first = peer() @@ -147,6 +182,23 @@ describe('queue-only native RPC', () => { await rejected }) + it.each([ + { error: 'Rejected' }, + { error: { code: -32603 } }, + { error: { code: -32603, message: {} } }, + { error: { code: 'unknown', message: 'Rejected' } }, + { error: { code: -32603, message: 'Rejected' }, result: {} } + ])('does not treat an incompatible rejection as definite: %j', async (response) => { + const f = peer(() => undefined) + const rejected = expect( + client().add('thread-a', 'feedback-a', 'Comment', new AbortController().signal) + ).rejects.toMatchObject({ uncertain: true }) + await vi.waitFor(() => expect(f.requests.at(-1)?.method).toBe('thread/queue/add')) + f.reply({ id: f.requests.at(-1)!.id, ...response }) + await rejected + expect(f.requests.filter(({ method }) => method === 'thread/queue/add')).toHaveLength(1) + }) + it('rejects an incompatible admission acknowledgement', async () => { peer(() => ({ queuedSubmission: { id: 'one', clientUserMessageId: 'other' } })) await expect( @@ -159,6 +211,17 @@ describe('queue-only native RPC', () => { await expect(client().list('thread-a')).rejects.toThrow('incompatible server queue') }) + it('rejects a repeated pagination cursor before requesting the same page again', async () => { + let pages = 0 + const f = peer(() => ({ + data: [], + // A final null bounds the broken implementation without an endless test. + nextCursor: ['page-a', 'page-b', 'page-a', null][pages++] + })) + await expect(client().list('thread-a')).rejects.toThrow('incompatible queue cursor') + expect(f.requests.filter(({ method }) => method === 'thread/queue/list')).toHaveLength(3) + }) + it('does not spawn after shutdown during executable discovery', async () => { let finish!: (path: string) => void const rpc = client( @@ -188,32 +251,34 @@ describe('desktop executable discovery', () => { processes( '/usr/local/bin/codex\n/Applications/Codex.app/Contents/Resources/codex\n/Applications/Codex.app/Contents/Resources/codex\n' ) - expect(await codexQueueExecutable()).toBe('/Applications/Codex.app/Contents/Resources/codex') + expect(await codexQueueExecutable(context)).toBe( + '/Applications/Codex.app/Contents/Resources/codex' + ) }) it('does not guess between different running desktop installations', async () => { processes( '/Applications/Codex.app/Contents/Resources/codex\n/Applications/ChatGPT.app/Contents/Resources/codex\n' ) - await expect(codexQueueExecutable()).rejects.toThrow('could not be identified') + await expect(codexQueueExecutable(context)).rejects.toThrow('could not be identified') }) it('discovers the nested desktop CLI and deduplicates its running processes', async () => { const executable = '/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex' processes(`/usr/local/bin/codex\n${executable}\n${executable}\n`) - expect(await codexQueueExecutable()).toBe(executable) + expect(await codexQueueExecutable(context)).toBe(executable) }) it('rejects ambiguity between nested and legacy desktop installations', async () => { processes( '/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex\n/Applications/Codex.app/Contents/Resources/codex\n' ) - await expect(codexQueueExecutable()).rejects.toThrow('could not be identified') + await expect(codexQueueExecutable(context)).rejects.toThrow('could not be identified') }) it('does not use a standalone CLI when no desktop binary is running', async () => { processes('/usr/local/bin/codex\n') - await expect(codexQueueExecutable()).rejects.toThrow('could not be identified') + await expect(codexQueueExecutable(context)).rejects.toThrow('could not be identified') }) }) diff --git a/packages/mcp-server/tests/codex-queue.test.ts b/packages/mcp-server/tests/codex-queue.test.ts index 4373e03c..de496e26 100644 --- a/packages/mcp-server/tests/codex-queue.test.ts +++ b/packages/mcp-server/tests/codex-queue.test.ts @@ -90,13 +90,12 @@ async function fixture(existing?: string) { }, onDisconnect: () => () => {} } - const native = new CodexAppFeedback( - directory, - async () => connection, - 1, - vi.fn(), - new CodexNativeQueue(read, readServer, server) - ) + const native = new CodexAppFeedback(directory, { + open: async () => connection, + retryMs: 1, + load: vi.fn(), + nativeQueue: new CodexNativeQueue(read, readServer, server) + }) cleanups.push(() => native.close()) const controller = new AbortController() const dispatched = vi.fn() @@ -126,6 +125,28 @@ async function fixture(existing?: string) { } } +async function seedReceipts(f: Awaited>, count: number) { + // Use the current admission format, without repeating IPC and receipt rewrites for each item. + await f.send() + const [file] = await readdir(f.directory) + const template = JSON.parse(await readFile(join(f.directory, file!), 'utf8')) + expect(template).toMatchObject({ status: 'delivered', acceptedAt: expect.any(Number) }) + await rm(join(f.directory, file!)) + f.setMessages([]) + f.request.mockClear() + const entries = Array.from({ length: count }, (_, index) => ({ + path: join(f.directory, `${index.toString(16).padStart(64, '0')}.json`), + receipt: { ...template, taskId: `task-${index}`, messageId: `message-${index}` } + })) + for (let index = 0; index < entries.length; index += 32) + await Promise.all( + entries + .slice(index, index + 32) + .map(({ path, receipt }) => writeFile(path, JSON.stringify(receipt))) + ) + return entries +} + describe('native Codex queue admission', () => { it('appends to the server queue without replacing existing native messages', async () => { const f = await fixture() @@ -288,19 +309,7 @@ describe('native Codex queue admission', () => { it('bounds background receipt reads while eventually revisiting every retained identity', async () => { const f = await fixture() - for (let index = 0; index < 260; index++) { - await writeFile( - join(f.directory, `${index.toString(16).padStart(64, '0')}.json`), - JSON.stringify({ - delivery: 'queue', - signature: 'signature', - status: 'delivered', - conversationId: 'thread-a', - taskId: `task-${index}`, - messageId: `message-${index}` - }) - ) - } + await seedReceipts(f, 260) const isCancelled = vi.fn<(taskId: string) => boolean>(() => false) await Promise.all([ f.native.reconcileQueued(isCancelled), @@ -349,25 +358,21 @@ describe('native Codex queue admission', () => { it('scans every receipt on explicit Stop even when background reconciliation is batched', async () => { const f = await fixture() - for (let index = 0; index < 129; index++) { - await writeFile( - join(f.directory, `${index.toString(16).padStart(64, '0')}.json`), - JSON.stringify({ - delivery: 'queue', - signature: 'signature', - status: 'pending', - conversationId: 'thread-a', - taskId: 'task-a', - messageId: `message-${index}` - }) - ) - } - f.setMessages([{ id: 'message-128' }, { id: 'user-message' }]) - await f.native.cancelQueued('task-a') + const entries = await seedReceipts(f, 129) + const isCancelled = vi.fn<(taskId: string) => boolean>(() => false) + await f.native.reconcileQueued(isCancelled) + expect(isCancelled).toHaveBeenCalledTimes(128) + const scanned = new Set(isCancelled.mock.calls.map(([taskId]) => taskId)) + const target = entries.find(({ receipt }) => !scanned.has(receipt.taskId))! + f.setMessages([{ id: target.receipt.messageId }, { id: 'user-message' }]) + await f.native.cancelQueued(target.receipt.taskId) expect(f.messages()).toEqual([{ id: 'user-message' }]) - for (const file of await readdir(f.directory)) { - expect(JSON.parse(await readFile(join(f.directory, file), 'utf8')).status).toBe('removed') - } + await Promise.all( + entries.map(async ({ path, receipt }) => { + const saved = JSON.parse(await readFile(path, 'utf8')) + expect(saved).toEqual(path === target.path ? { ...receipt, status: 'removed' } : receipt) + }) + ) expect(await readdir(f.directory)).toHaveLength(129) }) @@ -738,25 +743,25 @@ describe('native Codex queue admission', () => { it('reads current persisted queue state without modifying the host file', async () => { const f = await fixture() - vi.stubEnv('CODEX_HOME', f.directory) + vi.stubEnv('CODEX_HOME', join(f.directory, 'wrong-host')) const path = join(f.directory, '.codex-global-state.json') const original = JSON.stringify({ 'queued-follow-ups': { 'thread-a': [{ id: 'one', extra: 42 }] }, unrelated: 'keep' }) await writeFile(path, original) - expect(await readCodexQueue('thread-a')).toEqual([{ id: 'one', extra: 42 }]) - expect(await readCodexQueue('other')).toEqual([]) + expect(await readCodexQueue('thread-a', f.directory)).toEqual([{ id: 'one', extra: 42 }]) + expect(await readCodexQueue('other', f.directory)).toEqual([]) expect(await readFile(path, 'utf8')).toBe(original) await writeFile(path, JSON.stringify({ 'queued-follow-ups': { 'thread-a': [{ id: 'two' }] } })) - expect(await readCodexQueue('thread-a')).toEqual([{ id: 'two' }]) + expect(await readCodexQueue('thread-a', f.directory)).toEqual([{ id: 'two' }]) await writeFile(path, JSON.stringify({ 'queued-follow-ups': { 'thread-a': null } })) // A null entry is the native representation of no stored queue. - expect(await readCodexQueue('thread-a')).toEqual([]) + expect(await readCodexQueue('thread-a', f.directory)).toEqual([]) await writeFile( path, JSON.stringify({ 'queued-follow-ups': { 'thread-a': [{ id: 'x' }, { id: 'x' }] } }) ) - await expect(readCodexQueue('thread-a')).rejects.toThrow('duplicate') + await expect(readCodexQueue('thread-a', f.directory)).rejects.toThrow('duplicate') }) }) diff --git a/packages/mcp-server/tests/codex-server-queue.test.ts b/packages/mcp-server/tests/codex-server-queue.test.ts index 64e1dfb7..ae067b8e 100644 --- a/packages/mcp-server/tests/codex-server-queue.test.ts +++ b/packages/mcp-server/tests/codex-server-queue.test.ts @@ -10,7 +10,8 @@ let directory: string let database: DatabaseSync | undefined beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'tempad-server-queue-')) - vi.stubEnv('CODEX_HOME', directory) + vi.stubEnv('CODEX_HOME', join(directory, 'wrong-host')) + vi.stubEnv('CODEX_SQLITE_HOME', join(directory, 'wrong-store')) }) afterEach(async () => { database?.close() @@ -31,15 +32,14 @@ function createDatabase() { describe('Codex server queue observation', () => { it('distinguishes an absent database without creating one', async () => { - expect(await readCodexServerQueue('thread-a')).toBeNull() + expect(await readCodexServerQueue('thread-a', directory)).toBeNull() expect(await readdir(directory)).toEqual([]) }) it('preserves inspection errors instead of treating an invalid home as an absent database', async () => { const path = join(directory, 'not-a-directory') await writeFile(path, 'preserved') - vi.stubEnv('CODEX_HOME', path) - await expect(readCodexServerQueue('thread-a')).rejects.toMatchObject({ + await expect(readCodexServerQueue('thread-a', path)).rejects.toMatchObject({ message: 'The Codex server queue could not be checked. Comments remain saved.', cause: { code: 'ENOTDIR' } }) @@ -49,37 +49,37 @@ describe('Codex server queue observation', () => { it('reads committed WAL changes for only the requested conversation and observes consumption', async () => { const writer = createDatabase() writer.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run('one', 'thread-a', '{}') - expect(await readCodexServerQueue('thread-a')).toEqual({ hasMessages: true }) - expect(await readCodexServerQueue('thread-b')).toEqual({ hasMessages: false }) + expect(await readCodexServerQueue('thread-a', directory)).toEqual({ hasMessages: true }) + expect(await readCodexServerQueue('thread-b', directory)).toEqual({ hasMessages: false }) expect(writer.prepare('SELECT count(*) AS count FROM queued_items').get()).toMatchObject({ count: 1 }) writer.prepare('DELETE FROM queued_items WHERE thread_id = ?').run('thread-a') - expect(await readCodexServerQueue('thread-a')).toEqual({ hasMessages: false }) + expect(await readCodexServerQueue('thread-a', directory)).toEqual({ hasMessages: false }) }) it('does not observe an uncommitted admission', async () => { const writer = createDatabase() writer.exec('BEGIN IMMEDIATE') writer.prepare('INSERT INTO queued_items VALUES (?, ?, ?)').run('one', 'thread-a', '{}') - expect(await readCodexServerQueue('thread-a')).toEqual({ hasMessages: false }) + expect(await readCodexServerQueue('thread-a', directory)).toEqual({ hasMessages: false }) writer.exec('COMMIT') - expect(await readCodexServerQueue('thread-a')).toEqual({ hasMessages: true }) + expect(await readCodexServerQueue('thread-a', directory)).toEqual({ hasMessages: true }) }) it('honors the native SQLite directory override', async () => { createDatabase() .prepare('INSERT INTO queued_items VALUES (?, ?, ?)') .run('one', 'thread-a', '{}') - vi.stubEnv('CODEX_SQLITE_HOME', directory) - vi.stubEnv('CODEX_HOME', join(directory, 'unused-home')) - expect(await readCodexServerQueue('thread-a')).toEqual({ hasMessages: true }) + expect(await readCodexServerQueue('thread-a', directory)).toEqual({ hasMessages: true }) }) it('does not treat an unknown schema as an empty queue or create a replacement table', async () => { database = new DatabaseSync(join(directory, 'queue_1.sqlite')) database.exec('CREATE TABLE future_queue (id TEXT)') - await expect(readCodexServerQueue('thread-a')).rejects.toThrow('could not be checked') + await expect(readCodexServerQueue('thread-a', directory)).rejects.toThrow( + 'could not be checked' + ) expect(database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()).toEqual([ { name: 'future_queue' } ]) @@ -88,7 +88,9 @@ describe('Codex server queue observation', () => { it('preserves an unreadable database and reports unknown state', async () => { const path = join(directory, 'queue_1.sqlite') await writeFile(path, 'invalid database') - await expect(readCodexServerQueue('thread-a')).rejects.toThrow('could not be checked') + await expect(readCodexServerQueue('thread-a', directory)).rejects.toThrow( + 'could not be checked' + ) expect(await readFile(path, 'utf8')).toBe('invalid database') }) }) diff --git a/packages/mcp-server/tests/design-task-generation.test.ts b/packages/mcp-server/tests/design-task-generation.test.ts new file mode 100644 index 00000000..dd58319f --- /dev/null +++ b/packages/mcp-server/tests/design-task-generation.test.ts @@ -0,0 +1,148 @@ +import type { DesignTask, FigmaSession } from '@tempad-dev/shared' + +import { describe, expect, it } from 'vitest' + +import type { DesignTaskSnapshot } from '../src/design-task-store' +import type { ExtensionConnection } from '../src/types' + +import { DesignTasks } from '../src/design-tasks' +import { HISTORY_RETENTION_MS } from '../src/history-retention' + +const generation = '00000000-0000-4000-8000-000000000001' +const session: FigmaSession = { + fileKey: 'unrecorded-file', + fileName: 'File', + pageId: 'page', + sessionId: 'session', + tabId: 1, + documentId: 'document', + busy: false +} + +function fixture() { + let snapshot: DesignTaskSnapshot = { + revision: 0, + current: {}, + records: [], + recoveryGeneration: generation + } + let now = 1000 + let id = 0 + const store = { + load: () => structuredClone(snapshot), + save: (value: DesignTaskSnapshot) => { + snapshot = structuredClone(value) + } + } + const restart = () => { + const tasks = new DesignTasks({ createId: () => `new-${++id}`, now: () => now, store }) + tasks.restore() + return tasks + } + return { + restart, + store, + advance: () => { + now += HISTORY_RETENTION_MS + 1 + } + } +} + +function begin(tasks: DesignTasks, acknowledged?: string, target = session) { + return tasks.begin( + 'owner', + 'extension', + target, + 'New task', + 'fresh-request', + undefined, + undefined, + undefined, + acknowledged + ) +} + +describe('recovered storage generations', () => { + it('rejects queued old begins and taskless writes, requiring acknowledgment and a fresh read', () => { + const tasks = fixture().restart() + expect(() => begin(tasks)).toThrow(generation) + expect(() => begin(tasks, '00000000-0000-4000-8000-000000000002')).toThrow(generation) + expect(() => tasks.checkOperation('owner', session, undefined, true)).toThrow('new task') + expect(() => tasks.checkOperation('owner', session)).not.toThrow() + expect(() => begin(tasks, generation, { ...session, busy: true })).toThrow('not finished') + const record = begin(tasks, generation) + expect(() => begin(tasks)).toThrow(generation) + expect(() => begin(tasks, '00000000-0000-4000-8000-000000000002')).toThrow(generation) + expect(begin(tasks, generation)).toBe(record) + tasks.confirm(record.task.taskId, 'owner') + expect(() => tasks.checkOperation('owner', session, record.task.taskId, true)).toThrow( + 'The resumed task has not confirmed its bound canvas state.' + ) + tasks.acknowledgeRead(record.task.taskId, 'owner', 0) + expect(() => tasks.checkOperation('owner', session, record.task.taskId, true)).not.toThrow() + tasks.stop(record.task.taskId, 'cancelled') + expect(record.task.status).toBe('cancelled') + expect(() => tasks.resume(record.task.taskId, 'owner', 0, session)).toThrow('cannot resume') + }) + + it.each(['active', 'completed', 'cancelled'] as const)( + 'never imports an unknown old %s review after recovery or restart', + (status) => { + const f = fixture() + const old: DesignTask = { + taskId: 'unknown-old', + title: 'Old', + target: session, + status, + operation: null, + expiresAt: 1000, + revision: 5 + } + const extension = { + id: 'extension', + origin: 'chrome-extension://test', + sessions: { browserId: 'browser', sessions: [session] } + } as ExtensionConnection + for (let restart = 0; restart < 2; restart++) { + const tasks = f.restart() + tasks.restoreReview(old, extension, session) + expect(tasks.find(old.taskId)).toBeUndefined() + expect(tasks.current(session.fileKey)).toBeUndefined() + expect(() => tasks.resume(old.taskId, 'owner', 0, session)).toThrow('Unknown') + expect(() => tasks.checkOperation('owner', session, undefined, true)).toThrow('new task') + } + } + ) + + it('keeps the generation after history pruning and honors Stop recovered for a new task', () => { + const f = fixture() + const tasks = f.restart() + const record = begin(tasks, generation) + const stopped = { ...record.task, status: 'cancelled' as const } + tasks.disconnectExtension('extension') + tasks.restoreReview( + stopped, + { + id: 'reconnected', + origin: 'chrome-extension://test', + sessions: { browserId: 'browser', sessions: [session] } + } as ExtensionConnection, + session + ) + expect(record.task.status).toBe('cancelled') + f.advance() + tasks.sweep() + expect(tasks.find(record.task.taskId)).toBeUndefined() + expect(f.store.load().recoveryGeneration).toBe(generation) + const restarted = f.restart() + expect(() => begin(restarted)).toThrow(generation) + expect(() => + restarted.checkOperation( + 'owner', + { ...session, fileKey: 'another-unknown-file' }, + undefined, + true + ) + ).toThrow('new task') + }) +}) diff --git a/packages/mcp-server/tests/design-task-recovery.test.ts b/packages/mcp-server/tests/design-task-recovery.test.ts index d9f564d5..f85c7a22 100644 --- a/packages/mcp-server/tests/design-task-recovery.test.ts +++ b/packages/mcp-server/tests/design-task-recovery.test.ts @@ -57,6 +57,67 @@ function fixture() { } describe('durable design reviews', () => { + it('retains taskless read requirements across restart until a successful read', () => { + const f = fixture() + f.tasks.requireFileRead(session.fileKey) + const restored = f.restart() + expect(() => restored.checkOperation('new-owner', session, undefined, true)).toThrow( + 'previous write did not confirm' + ) + restored.acknowledgeFileRead(session.fileKey) + expect(() => f.restart().checkOperation('new-owner', session, undefined, true)).not.toThrow() + }) + + it('retains a read requirement in memory and storage when clearing it cannot be saved', () => { + const f = fixture() + f.tasks.requireFileRead(session.fileKey) + const save = f.store.save + f.store.save = () => { + throw new Error('Disk full') + } + f.tasks.acknowledgeFileRead(session.fileKey) + f.store.save = save + expect(() => f.tasks.checkOperation('owner', session, undefined, true)).toThrow( + 'previous write did not confirm' + ) + expect(() => f.restart().checkOperation('owner', session, undefined, true)).toThrow( + 'previous write did not confirm' + ) + }) + + it('does not let an old completion clear a newer read requirement', () => { + const f = fixture() + const generation = f.tasks.requireFileRead(session.fileKey) + f.tasks.requireFileRead(session.fileKey) + f.tasks.acknowledgeFileRead(session.fileKey, generation) + expect(() => f.restart().checkOperation('owner', session, undefined, true)).toThrow( + 'previous write did not confirm' + ) + f.tasks.acknowledgeFileRead(session.fileKey) + expect(() => f.restart().checkOperation('owner', session, undefined, true)).not.toThrow() + }) + + it.each([false, true])( + 'restores the previous marker after a rejected save: existing=%s', + (existing) => { + const f = fixture() + const generation = existing ? f.tasks.requireFileRead(session.fileKey) : undefined + vi.spyOn(f.store, 'save').mockImplementationOnce(() => { + throw new Error('Disk full') + }) + expect(() => f.tasks.requireFileRead(session.fileKey, true)).toThrow( + 'No canvas write was dispatched' + ) + // The next sweep retries storage. A rejected dispatch must not create a + // phantom read requirement or replace the generation of an existing one. + f.tasks.sweep() + expect(f.store.load()?.filesNeedingRead ?? []).toEqual(existing ? [session.fileKey] : []) + if (existing) f.tasks.acknowledgeFileRead(session.fileKey, generation) + expect(() => f.tasks.checkOperation('owner', session, undefined, true)).not.toThrow() + expect(() => f.restart().checkOperation('owner', session, undefined, true)).not.toThrow() + } + ) + it('writes result roots only when their ordered last-20 snapshot changes', () => { const f = fixture() const record = f.begin() @@ -267,17 +328,79 @@ describe('durable design reviews', () => { 'another MCP caller' ) restored.attachClient('fresh-transport', client) - if (status !== 'cancelled') { - expect(() => restored.assertEpoch(saved.task.taskId, 'fresh-transport', 0)).toThrow( - 'Stale task lease' - ) - expect(() => - restored.checkOperation('fresh-transport', session, saved.task.taskId, true) - ).toThrow() - } + expect(() => restored.assertEpoch(saved.task.taskId, 'fresh-transport', 0)).toThrow( + 'Stale task lease' + ) + expect(() => + restored.checkOperation('fresh-transport', session, saved.task.taskId, true) + ).toThrow() } ) + it.each(['codex-app', 'claude'] as const)( + 'lets the verified %s conversation read cancelled history after restart without reviving it', + (kind) => { + const f = fixture() + const record = f.begin() + const original = { ...client, kind } + record.task.client = original + f.tasks.stop(record.task.taskId, 'cancelled') + + const restored = f.restart() + restored.attachClient('fresh-transport', original) + const saved = restored.owned(record.task.taskId, 'fresh-transport') + expect(saved.task).toMatchObject({ status: 'cancelled', epoch: 1, operation: null }) + expect(restored.retry('fresh-transport', record.requestId)).toBe(saved) + expect(() => restored.resume(saved.task.taskId, 'fresh-transport', 1, session)).toThrow( + 'cannot resume' + ) + expect(() => + restored.checkOperation('fresh-transport', session, saved.task.taskId, true) + ).toThrow('cancelled') + expect(() => restored.checkOperation('fresh-transport', session, undefined, true)).toThrow( + 'stopped task' + ) + expect(f.restart().find(saved.task.taskId)?.task.status).toBe('cancelled') + } + ) + + it('keeps cancelled history isolated by conversation, Codex storage, and verified Hub binding', () => { + const f = fixture() + const record = f.begin() + const context = { + version: 1 as const, + home: '/home', + tempDir: '/tmp', + codexHome: '/home/.codex', + codexSqliteHome: '/home/.codex' + } + record.hostContext = context + f.tasks.stop(record.task.taskId, 'cancelled') + const restored = f.restart() + restored.attachClient( + 'wrong-conversation', + { ...client, sessionId: 'other' }, + undefined, + context + ) + for (const foreignContext of [undefined, { ...context, codexSqliteHome: '/other' }]) { + expect(restored.attachClient('wrong-store', client, undefined, foreignContext)).toEqual([ + record.task.taskId + ]) + expect(() => restored.owned(record.task.taskId, 'wrong-store')).toThrow('another MCP caller') + } + expect(() => restored.owned(record.task.taskId, 'wrong-conversation')).toThrow( + 'another MCP caller' + ) + restored.attachClient('original-store', client, undefined, { ...context, tempDir: '/new-tmp' }) + expect(restored.owned(record.task.taskId, 'original-store').task.status).toBe('cancelled') + + const migrated = new DesignTasks({ createId: () => 'unused', now: () => 1000 }) + migrated.restoreReview(record.task, extension(), session) + migrated.attachClient('unverified', client, undefined, context) + expect(() => migrated.owned(record.task.taskId, 'unverified')).toThrow('another MCP caller') + }) + it('keeps ordinary exact-tab refresh recovery and its read fence', () => { const f = fixture() const record = f.begin() @@ -328,7 +451,7 @@ describe('durable design reviews', () => { tasks.confirm(record.task.taskId, 'fresh-transport') expect(() => tasks.checkOperation('fresh-transport', refreshed, record.task.taskId, true) - ).toThrow('Read the bound canvas') + ).toThrow('The resumed task has not confirmed') tasks.checkOperation('fresh-transport', refreshed, record.task.taskId, false) tasks.acknowledgeRead(record.task.taskId, 'fresh-transport', epoch + 1) expect(() => @@ -404,6 +527,25 @@ describe('durable design reviews', () => { expect(f2.restart().find(stopped.task.taskId)?.task.status).toBe('cancelled') }) + it('persists an offline Stop even if completion reached the Hub first', () => { + const f = fixture() + const record = f.begin() + const offlineStop = { ...structuredClone(record.task), status: 'cancelled' as const } + f.tasks.stop(record.task.taskId, 'completed') + f.tasks.disconnectExtension('original-extension') + f.tasks.restoreReview(offlineStop, extension(), session) + + const restarted = f.restart() + const restored = restarted.find(record.task.taskId)! + expect(restored.task.status).toBe('cancelled') + expect(() => + restarted.resume(restored.task.taskId, restored.ownerId, restored.task.epoch!, session) + ).toThrow('cannot resume') + expect(() => restarted.checkOperation('new-owner', session, undefined, true)).toThrow( + 'stopped task' + ) + }) + it('persists a legacy offline Done before the first Hub restart', () => { const f = fixture() const closed: DesignTask = { diff --git a/packages/mcp-server/tests/design-task-store.test.ts b/packages/mcp-server/tests/design-task-store.test.ts index 4f93332b..a7e113f4 100644 --- a/packages/mcp-server/tests/design-task-store.test.ts +++ b/packages/mcp-server/tests/design-task-store.test.ts @@ -4,6 +4,9 @@ const files = vi.hoisted(() => new Map()) vi.mock('node:fs', () => ({ existsSync: (path: string) => files.has(path), mkdirSync: () => {}, + openSync: () => 1, + fsyncSync: () => {}, + closeSync: () => {}, readFileSync: (path: string) => { if (!files.has(path)) throw Object.assign(new Error('Missing'), { code: 'ENOENT' }) return files.get(path) @@ -16,10 +19,138 @@ vi.mock('node:fs', () => ({ })) import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { HISTORY_RETENTION_MS } from '../src/history-retention' beforeEach(() => files.clear()) describe('task snapshot recovery', () => { + it.each(['__proto__', 'constructor', 'toString', 'ordinary'])( + 'preserves the current review and compacted Stop fence for file %s', + (fileKey) => { + const path = '/state/tasks.json' + let time = 1000 + const target = { + fileKey, + fileName: 'Design', + sessionId: 'session', + pageId: 'page', + busy: false + } + const tasks = new DesignTasks({ + store: new DesignTaskStore(path), + createId: () => 'task', + now: () => time + }) + const record = tasks.begin('owner', 'extension', target, 'Design', 'request') + const restart = () => { + const restored = new DesignTasks({ + store: new DesignTaskStore(path), + createId: () => 'unused', + now: () => time + }) + restored.restore() + return restored + } + expect.soft(restart().current(fileKey)?.task.status).toBe('paused') + tasks.stop(record.task.taskId, 'cancelled') + expect.soft(restart().current(fileKey)?.task.status).toBe('cancelled') + time += HISTORY_RETENTION_MS + tasks.sweep() + const snapshot = new DesignTaskStore(path).load()! + expect(snapshot.records).toEqual([]) + expect.soft(Object.hasOwn(snapshot.fileFences!, fileKey)).toBe(true) + expect(Object.getPrototypeOf(snapshot.fileFences)).toBe(Object.prototype) + expect(() => restart().checkOperation('owner', target, undefined, true)).toThrow( + 'stopped task' + ) + } + ) + + it.each(['current', 'fileFences'])( + 'fences an invalid __proto__ value in %s instead of silently dropping it', + (field) => { + files.set( + '/state/tasks.json', + JSON.stringify({ + revision: 1, + current: {}, + records: [], + [field]: { ['__proto__']: 42 } + }) + ) + const store = new DesignTaskStore('/state/tasks.json') + expect(store.load()).toBeUndefined() + expect(store.recoveryError).toContain('Stop fences') + } + ) + + it('retains only matching incompatible current pointers through task restore and save', () => { + const future = { + task: { + taskId: 'future', + title: 'Future', + target: { fileKey: 'blocked-file', fileName: 'File', sessionId: 'session', pageId: 'page' }, + status: 'future-status', + operation: null, + expiresAt: 1000, + revision: 1 + }, + requestId: 'future-request' + } + files.set( + '/state/tasks.json', + JSON.stringify({ + revision: 1, + current: { 'blocked-file': 'future', 'mismatched-file': 'future', dangling: 'missing' }, + records: [future] + }) + ) + const store = new DesignTaskStore('/state/tasks.json') + const tasks = new DesignTasks({ store, createId: () => 'new', now: () => 1000 }) + tasks.restore() + expect(tasks.current('blocked-file')).toBeUndefined() + expect(store.blockedFiles).toEqual(new Set(['blocked-file'])) + const other = { + sessionId: 'other', + fileKey: 'other', + fileName: 'Other', + pageId: 'page', + busy: false + } + expect(() => + tasks.begin('owner', 'extension', { ...other, fileKey: 'blocked-file' }, 'New', 'r') + ).toThrow(/upgrade TemPad Dev MCP/i) + tasks.begin('owner', 'extension', other, 'Other', 'request') + const persisted = JSON.parse(files.get('/state/tasks.json')!) + expect(persisted.current).toEqual({ 'blocked-file': 'future', other: 'new' }) + expect(persisted.records).toContainEqual(future) + + // Simulate the newer reader recognizing this status, without inventing a current pointer. + persisted.records.find( + (record: { task: { taskId: string } }) => record.task.taskId === 'future' + ).task.status = 'paused' + files.set('/state/tasks.json', JSON.stringify(persisted)) + const upgraded = new DesignTasks({ + store: new DesignTaskStore('/state/tasks.json'), + createId: () => 'unused', + now: () => 1000 + }) + upgraded.restore() + expect(upgraded.current('blocked-file')?.task).toMatchObject({ + taskId: 'future', + status: 'paused' + }) + expect(upgraded.current('mismatched-file')).toBeUndefined() + expect(upgraded.current('dangling')).toBeUndefined() + expect( + upgraded.resume('future', 'restored:future', 1, { + ...future.task.target, + busy: false + }).task.status + ).toBe('active') + }) + it('isolates an incompatible record to its file and preserves its fence when saving valid tasks', () => { const incompatible = { task: { taskId: 'future', target: { fileKey: 'blocked-file' }, status: 'future-status' } @@ -110,4 +241,54 @@ describe('task snapshot recovery', () => { store.save(snapshot) expect(new DesignTaskStore('/state/tasks.json').load()).toEqual(snapshot) }) + + it('round-trips read requirements as identifiable file fences for older readers', () => { + const store = new DesignTaskStore('/state/tasks.json') + const snapshot = { + revision: 2, + current: {}, + records: [], + filesNeedingRead: ['interrupted-file'] + } + store.save(snapshot) + expect(JSON.parse(files.get('/state/tasks.json')!).records).toContainEqual({ + readRequired: true, + task: { target: { fileKey: 'interrupted-file' } } + }) + expect(new DesignTaskStore('/state/tasks.json').load()).toEqual(snapshot) + }) + + it('preserves future read markers while loading known markers and recovery generations', () => { + const future = { + readRequired: true, + task: { target: { fileKey: 'future-file' } }, + futureRequirement: { nodeId: '1:1' } + } + const generation = '00000000-0000-4000-8000-000000000001' + files.set( + '/state/tasks.json', + JSON.stringify({ + revision: 1, + current: {}, + records: [ + { recoveryGeneration: generation }, + { readRequired: true, task: { target: { fileKey: 'known-file' } } }, + future + ] + }) + ) + const store = new DesignTaskStore('/state/tasks.json') + const snapshot = store.load()! + expect(snapshot).toMatchObject({ + filesNeedingRead: ['known-file'], + recoveryGeneration: generation, + records: [] + }) + expect(store.blockedFiles).toEqual(new Set(['future-file'])) + store.save(snapshot) + expect(JSON.parse(files.get('/state/tasks.json')!).records).toContainEqual(future) + const restarted = new DesignTaskStore('/state/tasks.json') + expect(restarted.load()).toEqual(snapshot) + expect(restarted.blockedFiles).toEqual(new Set(['future-file'])) + }) }) diff --git a/packages/mcp-server/tests/design-tasks.test.ts b/packages/mcp-server/tests/design-tasks.test.ts index d1da171a..e49362c3 100644 --- a/packages/mcp-server/tests/design-tasks.test.ts +++ b/packages/mcp-server/tests/design-tasks.test.ts @@ -93,7 +93,7 @@ describe('design task leases', () => { ) f.tasks.confirm(task.taskId, 'caller-a') expect(() => f.tasks.checkOperation('caller-a', refreshedSession, task.taskId, true)).toThrow( - 'Read the bound canvas' + 'The resumed task has not confirmed' ) f.tasks.checkOperation('caller-a', refreshedSession, task.taskId) f.tasks.acknowledgeRead(task.taskId, 'caller-a', 1) @@ -250,6 +250,23 @@ describe('design task leases', () => { expect(() => f.tasks.owned(task.taskId, 'different-owner')).toThrow('another MCP caller') }) + it.each(['active', 'stopping'] as const)( + 'does not transfer a %s task to another transport for the same conversation', + (status) => { + const f = fixture() + const task = f.begin() + const client = { kind: 'codex' as const, name: 'Codex', sessionId: 'thread-a' } + task.client = client + if (status === 'stopping') { + f.call('pending-write', task.taskId) + f.tasks.stop(task.taskId, 'cancelled') + } + f.tasks.attachClient('different-owner', client) + expect(f.tasks.owned(task.taskId, 'caller-a').task.status).toBe(status) + expect(() => f.tasks.owned(task.taskId, 'different-owner')).toThrow('another MCP caller') + } + ) + it.each(['paused', 'completed'] as const)( 'resumes the same %s task with a new lease and requires a fresh canvas read', (status) => { @@ -263,7 +280,7 @@ describe('design task leases', () => { expect(record.task.epoch).toBe(1) expect(() => f.tasks.assertEpoch(task.taskId, 'caller-a', 0)).toThrow('Stale task lease') f.tasks.confirm(task.taskId, 'caller-a') - expect(() => f.call('old-write', task.taskId)).toThrow('Read the bound canvas') + expect(() => f.call('old-write', task.taskId)).toThrow('The resumed task has not confirmed') f.call('read', task.taskId, false) f.tasks.finishOperation('read', 'extension-a') f.tasks.acknowledgeRead(task.taskId, 'caller-a', 1) @@ -298,7 +315,7 @@ describe('design task leases', () => { expect(resumed.task.epoch).toBe(epoch + 1) expect(() => f.tasks.assertEpoch(task.taskId, 'caller-a', epoch)).toThrow('Stale task lease') f.tasks.confirm(task.taskId, 'caller-a') - expect(() => f.call('write', task.taskId)).toThrow('Read the bound canvas') + expect(() => f.call('write', task.taskId)).toThrow('The resumed task has not confirmed') f.call('read', task.taskId, false) f.tasks.finishOperation('read', 'extension-a') f.tasks.acknowledgeRead(task.taskId, 'caller-a', epoch + 1) @@ -622,6 +639,24 @@ describe('design task leases', () => { expect(() => f.call('late-write', task.taskId)).toThrow('cancelled') }) + it('honors an offline Stop that arrives after the Hub completed the design pass', () => { + const f = fixture() + const task = f.begin('caller-a', originalDocument) + const offlineStop = { ...structuredClone(task), status: 'cancelled' as const } + f.tasks.stop(task.taskId, 'completed') + f.tasks.disconnectExtension('extension-a') + + f.tasks.restoreReview(offlineStop, refreshedExtension(originalDocument), originalDocument) + + expect(task.status).toBe('cancelled') + expect(() => f.tasks.resume(task.taskId, 'caller-a', 0, originalDocument)).toThrow( + 'cannot resume' + ) + expect(() => f.call('untagged-write', undefined, true, originalDocument)).toThrow( + 'stopped task' + ) + }) + it('lets explicit Stop end a lifecycle pause that is already draining', () => { const f = fixture() const task = f.begin() @@ -667,6 +702,23 @@ describe('design task leases', () => { expect(f.begin('caller-b').status).toBe('active') }) + it('keeps a lost taskless write unread across a fresh task without weakening Stop', () => { + const f = fixture() + f.tasks.requireFileRead(session.fileKey) + const task = f.begin() + expect(() => f.tasks.checkOperation('caller-a', session, task.taskId, true)).toThrow( + 'previous write did not confirm' + ) + f.tasks.checkOperation('caller-a', session, task.taskId) + f.tasks.acknowledgeFileRead(session.fileKey) + f.tasks.checkOperation('caller-a', session, task.taskId, true) + f.tasks.stop(task.taskId, 'cancelled') + f.tasks.acknowledgeFileRead(session.fileKey) + expect(() => f.tasks.checkOperation('caller-a', session, undefined, true)).toThrow( + 'stopped task' + ) + }) + it('keeps disconnected execution fenced until a result or proven tab closure', () => { const f = fixture() const task = f.begin() diff --git a/packages/mcp-server/tests/durable-json.test.ts b/packages/mcp-server/tests/durable-json.test.ts new file mode 100644 index 00000000..e8a7e1f2 --- /dev/null +++ b/packages/mcp-server/tests/durable-json.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const io = vi.hoisted(() => ({ + writeFileSync: vi.fn(), + renameSync: vi.fn(), + openSync: vi.fn(() => 7), + fsyncSync: vi.fn(), + closeSync: vi.fn() +})) +vi.mock('node:fs', () => io) +import { writeDurableJson } from '../src/durable-json' +beforeEach(() => vi.resetAllMocks()) + +describe('durable snapshots', () => { + it('flushes the file, renames, then syncs the directory', () => { + writeDurableJson('/private/state.json', { revision: 2 }) + expect(io.writeFileSync).toHaveBeenCalledWith('/private/state.json.tmp', '{"revision":2}', { + mode: 0o600, + flush: true + }) + expect(io.renameSync).toHaveBeenCalledWith('/private/state.json.tmp', '/private/state.json') + if (process.platform !== 'win32') { + expect(io.openSync).toHaveBeenCalledWith('/private', 'r') + expect(io.fsyncSync.mock.invocationCallOrder[0]).toBeGreaterThan( + io.renameSync.mock.invocationCallOrder[0]! + ) + expect(io.closeSync).toHaveBeenCalledWith(7) + } + }) + it('propagates write and post-rename failures without restoring stale state', () => { + io.writeFileSync.mockImplementationOnce(() => { + throw new Error('ENOSPC') + }) + expect(() => writeDurableJson('/state.json', {})).toThrow('ENOSPC') + expect(io.renameSync).not.toHaveBeenCalled() + if (process.platform !== 'win32') { + io.fsyncSync.mockImplementationOnce(() => { + throw new Error('EIO') + }) + expect(() => writeDurableJson('/state.json', {})).toThrow('EIO') + expect(io.renameSync).toHaveBeenCalledOnce() + expect(io.closeSync).toHaveBeenCalledWith(7) + } + }) +}) diff --git a/packages/mcp-server/tests/execution-recovery.test.ts b/packages/mcp-server/tests/execution-recovery.test.ts new file mode 100644 index 00000000..f2f78b82 --- /dev/null +++ b/packages/mcp-server/tests/execution-recovery.test.ts @@ -0,0 +1,226 @@ +import type { WebSocket } from 'ws' + +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { mkdtempSync, rmSync } from 'node:fs' +import { createServer, type Server } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +import type { ExtensionConnection } from '../src/types' + +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { connectSocket, listenPrivate } from '../src/runtime-prototype/processes' +import { ControlPeer, SocketTransport } from '../src/runtime-prototype/protocol' +import { attachRuntimeRecovery, runRuntimeRecovery } from '../src/runtime-recovery' +import { TerminalReceipts } from '../src/terminal-receipts' + +const session = { + sessionId: 'page', + fileKey: 'file', + fileName: 'File', + pageId: '1:1', + busy: false, + tabId: 1, + documentId: 'document' +} +const extension: ExtensionConnection = { + id: 'original', + ws: {} as WebSocket, + origin: 'chrome-extension://owner', + connectedAt: '', + runtime: { + protocolVersion: 15, + version: '1', + fingerprint: 'a'.repeat(64), + terminalReceipts: true, + durableTerminalReceipts: true + }, + sessions: { type: 'sessions', browserId: 'browser', activeSessionId: 'page', sessions: [session] } +} +let root: string +let server: Server | undefined +const peers: ControlPeer[] = [] +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'tpd-execution-')) +}) +afterEach(async () => { + await Promise.all(peers.splice(0).map((peer) => peer.close())) + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())) + server = undefined + rmSync(root, { recursive: true, force: true }) +}) +function runtime() { + const tasks = new DesignTasks({ + createId: () => 'task', + store: new DesignTaskStore(join(root, 'tasks.json')) + }) + tasks.restore() + const receipts = new TerminalReceipts((records) => tasks.saveExecutions(records)) + receipts.restore(tasks.executionState()) + return { tasks, receipts } +} +function reserve(receipts: TerminalReceipts) { + return { + type: 'toolCompletion' as const, + id: 'call', + ...receipts.reserve('call', extension, session, 'task', true)! + } +} +async function listen(receipts: TerminalReceipts) { + const path = join(root, 'recover.sock') + server = createServer((socket) => { + void attachRuntimeRecovery(socket, receipts, () => {}).then((peer) => peers.push(peer)) + }) + await listenPrivate(server, path) + return () => connectSocket(path) +} + +describe('durable execution exclusion and operator recovery', () => { + it('retires a disconnected read but retains an unknown write for handoff', () => { + const { receipts } = runtime() + receipts.reserve('read', extension, session, undefined, false) + reserve(receipts) + expect(receipts.canHandoff()).toBe(false) + receipts.disconnect(extension.id) + expect(receipts.canHandoff()).toBe(true) + expect([...receipts.transferableIds()]).toEqual(['call']) + expect(runtime().receipts.pendingCount).toBe(1) + }) + + it('survives cold restart and reads, accepts only the original receipt, then requires readback', () => { + const original = runtime() + const completion = reserve(original.receipts) + const next = runtime() + expect(next.receipts.generation).not.toBe(completion.generation) + expect(() => next.tasks.checkOperation('owner', session, undefined, false)).not.toThrow() + next.tasks.acknowledgeFileRead('file') + expect(() => next.tasks.checkOperation('owner', session, undefined, true)).toThrow( + 'earlier operation' + ) + expect( + next.receipts.accept(completion, { + ...extension, + sessions: { ...extension.sessions!, browserId: 'different' } + }) + ).toEqual({ accepted: false }) + expect(next.receipts.accept(completion, { ...extension, id: 'new-connection' })).toMatchObject({ + accepted: true, + recovered: { id: 'call' } + }) + expect(() => next.tasks.checkOperation('owner', session, undefined, true)).toThrow( + 'previous write' + ) + const restarted = runtime() + expect(restarted.receipts.status()).toEqual([]) + restarted.tasks.acknowledgeFileRead('file') + expect(() => restarted.tasks.checkOperation('owner', session, undefined, true)).not.toThrow() + }) + + it('keeps old/unknown peers blocked but does not claim their execution can cross a planned switch', () => { + const { receipts } = runtime() + expect( + receipts.reserve('call', { ...extension, runtime: undefined }, session, undefined, true) + ).toBeUndefined() + expect(receipts.canHandoff()).toBe(false) + const next = runtime() + expect(next.receipts.status()).toHaveLength(1) + next.receipts.reconcile({ + ...extension, + sessions: { ...extension.sessions!, browserId: 'other', openTabIds: [] } + }) + expect(next.receipts.pendingCount).toBe(1) + next.receipts.reconcile({ ...extension, sessions: { ...extension.sessions!, openTabIds: [] } }) + expect(runtime().receipts.pendingCount).toBe(0) + }) + + it('persists abandonment with Stop and read fences, rejects stale confirmations and late receipts', () => { + const { tasks, receipts } = runtime() + tasks.begin('owner', extension.id, session, 'Work', 'begin') + tasks.stop('task', 'cancelled') + const completion = reserve(receipts) + const block = receipts.status()[0]! + expect(() => receipts.abandon('call', crypto.randomUUID())).toThrow('changed') + expect(receipts.abandon('call', block.revision)).toMatchObject({ id: 'call' }) + expect(receipts.abandon('call', block.revision)).toBeUndefined() + expect(receipts.accept(completion, extension)).toEqual({ accepted: false }) + const next = runtime() + expect(next.tasks.find('task')?.task.status).toBe('cancelled') + expect(next.tasks.needsFileRead('file')).toBe(true) + expect(next.receipts.abandon('call', block.revision)).toBeUndefined() + next.tasks.acknowledgeFileRead('file') + next.receipts.reserve('read', extension, session, undefined, false) + next.receipts.settle('read', extension.id) + expect(next.tasks.needsFileRead('file')).toBe(false) + expect(() => next.tasks.checkOperation('owner', session, undefined, true)).toThrow( + 'stopped task' + ) + }) + + it('does not dispatch or release a block when persistence fails', () => { + const save = vi.fn(() => {}) + const receipts = new TerminalReceipts(save) + save.mockImplementationOnce(() => { + throw new Error('ENOSPC') + }) + expect(() => reserve(receipts)).toThrow('ENOSPC') + expect(receipts.status()).toEqual([]) + const completion = reserve(receipts) + const block = receipts.status()[0]! + for (const operation of [ + () => receipts.abandon('call', block.revision), + () => receipts.accept(completion, extension) + ]) { + save.mockImplementationOnce(() => { + throw new Error('ENOSPC') + }) + expect(operation).toThrow('ENOSPC') + expect(receipts.status()).toEqual([block]) + } + }) + + it('offers redacted status but refuses noninteractive or declined recovery', async () => { + const { receipts } = runtime() + const completion = reserve(receipts) + const connect = await listen(receipts) + const io = { interactive: false, write: vi.fn(), confirm: vi.fn(async () => 'no') } + await runRuntimeRecovery(['--runtime-status'], connect, io) + expect(io.write.mock.calls[0]![0]).toContain('document') + expect(io.write.mock.calls[0]![0]).not.toContain(completion.receiptId) + await expect( + runRuntimeRecovery(['--resolve-runtime-block', 'call'], connect, io) + ).rejects.toThrow('interactive') + io.interactive = true + await expect( + runRuntimeRecovery(['--resolve-runtime-block', 'call'], connect, io) + ).rejects.toThrow('cancelled') + expect(receipts.pendingCount).toBe(1) + io.confirm.mockResolvedValue('call') + await runRuntimeRecovery(['--resolve-runtime-block', 'call'], connect, io) + expect(receipts.pendingCount).toBe(0) + }) + + it('binds confirmation to one connection, revision and use', async () => { + const { receipts } = runtime() + reserve(receipts) + const connect = await listen(receipts) + const peer = new ControlPeer() + peers.push(peer) + await peer.connect(new SocketTransport(await connect())) + const prepared = await peer.request( + { method: 'tempad/runtime-prepare-resolution', params: { id: 'call' } }, + ResultSchema.extend({ challenge: z.string() }) + ) + receipts.settle('call', extension.id) + reserve(receipts) + const request = { + method: 'tempad/runtime-resolve-block', + params: { challenge: prepared.challenge, stopped: true } + } + await expect(peer.request(request, ResultSchema)).rejects.toThrow('changed') + await expect(peer.request(request, ResultSchema)).rejects.toThrow('already used') + expect(receipts.pendingCount).toBe(1) + }) +}) diff --git a/packages/mcp-server/tests/extension-socket.test.ts b/packages/mcp-server/tests/extension-socket.test.ts index 82405ad6..8a5dc272 100644 --- a/packages/mcp-server/tests/extension-socket.test.ts +++ b/packages/mcp-server/tests/extension-socket.test.ts @@ -3,7 +3,7 @@ import { TEMPAD_MCP_BRIDGE_SUBPROTOCOL, TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS } from '@tempad-dev/shared' -import { EventEmitter } from 'node:events' +import { EventEmitter, once } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocket, WebSocketServer } from 'ws' @@ -17,11 +17,279 @@ const servers: WebSocketServer[] = [] const registries: ExtensionRegistry[] = [] afterEach(async () => { + vi.useRealTimers() registries.splice(0).forEach((registry) => registry.dispose()) await Promise.all(servers.splice(0).map(closeServer)) }) describe('extension socket lifecycle', () => { + it.each(['checkout', 'release'] as const)( + 'bounds only checkout handshakes and releases connection capacity (%s)', + async (kind) => { + const profile = kind === 'checkout' ? { kind, id: 'a'.repeat(16) } : { kind } + const registry = new ExtensionRegistry(10000) + registries.push(registry) + const started = await startExtensionWebSocketServer({ + maxConnections: 1, + maxPayloadBytes: 4096, + originPolicy: createExtensionOriginPolicy(STORE_ORIGIN), + portCandidates: [0] + }) + servers.push(started.server) + started.server.on('connection', (socket) => + attachExtensionSocket(socket, { + createId: () => 'peer', + origin: STORE_ORIGIN, + registry, + runtimeProfile: profile, + onStateChange: () => {}, + onToolResult: () => {}, + onToolError: () => {} + }) + ) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const connect = async () => { + const connected = once(started.server, 'connection') + const client = new WebSocket( + `ws://127.0.0.1:${started.port}`, + TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + { + origin: STORE_ORIGIN + } + ) + await once(client, 'message') + const [serverSocket] = await connected + return { client, serverSocket: serverSocket as WebSocket } + } + const first = await connect() + expect(started.server.clients.size).toBe(1) + if (kind === 'release') { + await vi.advanceTimersByTimeAsync(5000) + expect(first.client.readyState).toBe(WebSocket.OPEN) + expect(registry.size).toBe(1) + return + } + const clientClosed = once(first.client, 'close') + const serverClosed = once(first.serverSocket, 'close') + await vi.advanceTimersByTimeAsync(5000) + expect((await clientClosed)[0]).toBe(1008) + await serverClosed + expect(started.server.clients.size).toBe(0) + expect(registry.size).toBe(0) + + const second = await connect() + const received = once(second.serverSocket, 'message') + second.client.send( + JSON.stringify({ + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64), + runtimeProfile: profile + }) + ) + await received + await vi.advanceTimersByTimeAsync(5000) + expect(second.client.readyState).toBe(WebSocket.OPEN) + expect(registry.size).toBe(1) + } + ) + + it.each(['matching', 'foreign', 'missing', 'legacy', 'premature'] as const)( + 'gates checkout routes and assets until the %s peer is verified', + async (kind) => { + const profile = { kind: 'checkout' as const, id: 'a'.repeat(16) } + const registry = new ExtensionRegistry(1) + registries.push(registry) + const started = await startExtensionWebSocketServer({ + maxConnections: 1, + maxPayloadBytes: 4096, + originPolicy: createExtensionOriginPolicy(STORE_ORIGIN), + portCandidates: [0] + }) + servers.push(started.server) + const onConnected = vi.fn() + started.server.on('connection', (socket) => + attachExtensionSocket(socket, { + createId: () => 'checkout', + origin: STORE_ORIGIN, + registry, + runtimeProfile: profile, + onConnected, + onStateChange: () => + registry.list().forEach(({ ws }) => + ws.send( + JSON.stringify({ + type: 'state', + activeId: registry.getActiveId(), + assetServerUrl: 'http://127.0.0.1:1234/private' + }) + ) + ), + onToolResult: () => {}, + onToolError: () => {} + }) + ) + const client = new WebSocket( + `ws://127.0.0.1:${started.port}`, + kind === 'legacy' ? [] : TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + { origin: STORE_ORIGIN } + ) + const closed = waitForClose(client) + const frames: Record[] = [] + client.on('message', (raw) => frames.push(JSON.parse(raw.toString()))) + await waitForOpen(client) + await waitUntil(() => frames.length > 0) + expect(onConnected).not.toHaveBeenCalled() + expect(registry.getActive()).toBeUndefined() + expect(frames.some((frame) => frame.type === 'state')).toBe(false) + if (kind === 'premature') client.send(JSON.stringify({ type: 'activate' })) + else if (kind !== 'legacy') + client.send( + JSON.stringify({ + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64), + ...(kind === 'missing' + ? {} + : { + runtimeProfile: { ...profile, id: (kind === 'matching' ? 'a' : 'b').repeat(16) } + }) + }) + ) + if (kind === 'matching') { + await waitUntil(() => frames.some((frame) => frame.type === 'state')) + expect(onConnected).toHaveBeenCalledOnce() + expect(registry.size).toBe(1) + client.close() + } + await closed + await waitUntil(() => registry.size === 0) + } + ) + + it('ignores queued messages after rejecting the runtime handshake', async () => { + const registry = new ExtensionRegistry(10_000) + registries.push(registry) + const onActivated = vi.fn() + const onRuntimeHello = vi.fn() + const started = await startExtensionWebSocketServer({ + maxConnections: 1, + maxPayloadBytes: 4096, + originPolicy: createExtensionOriginPolicy(STORE_ORIGIN), + portCandidates: [0] + }) + servers.push(started.server) + started.server.on('connection', (socket) => + attachExtensionSocket(socket, { + createId: () => 'rejected', + origin: STORE_ORIGIN, + registry, + onActivated, + onRuntimeHello, + onStateChange: () => {}, + onToolResult: () => {}, + onToolError: () => {} + }) + ) + const client = new WebSocket(`ws://127.0.0.1:${started.port}/`, TEMPAD_MCP_BRIDGE_SUBPROTOCOL, { + origin: STORE_ORIGIN + }) + await waitForOpen(client) + const closed = waitForClose(client) + const hello = { + type: 'runtimeHello', + extensionVersion: '1.0.0', + extensionRuntimeFingerprint: 'a'.repeat(64) + } + client.send( + JSON.stringify({ + ...hello, + protocolVersion: Math.max(...TEMPAD_MCP_BRIDGE_SUPPORTED_PROTOCOL_VERSIONS) + 1 + }) + ) + client.send(JSON.stringify({ ...hello, protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION })) + client.send(JSON.stringify({ type: 'activate' })) + + await closed + expect(onRuntimeHello).not.toHaveBeenCalled() + expect(onActivated).not.toHaveBeenCalled() + }) + + it('requires negotiated receipts and allows a failed acknowledgment to be retried', () => { + const registry = new ExtensionRegistry(10000) + registries.push(registry) + const socket = Object.assign(new EventEmitter(), { + protocol: TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + readyState: WebSocket.OPEN, + send: vi.fn<(data: string) => void>(), + close: vi.fn() + }) + const onToolCompletion = vi.fn().mockReturnValue(true) + const onSocketError = vi.fn() + attachExtensionSocket(socket as unknown as WebSocket, { + createId: () => 'extension', + origin: STORE_ORIGIN, + registry, + terminalReceiptGeneration: 'hub', + onToolCompletion, + onSocketError, + onStateChange: () => {}, + onToolResult: () => {}, + onToolError: () => {} + }) + const send = (message: unknown) => socket.emit('message', JSON.stringify(message), false) + const hello = { + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64) + } + send(hello) + const completion = { + type: 'toolCompletion', + id: 'call', + generation: 'hub', + receiptId: 'receipt' + } + send(completion) + expect(onToolCompletion).not.toHaveBeenCalled() + expect(socket.close).toHaveBeenCalledWith(1008, 'Terminal receipts were not negotiated.') + // A separate negotiated connection must survive losing the acknowledgment. + const negotiated = Object.assign(new EventEmitter(), { + protocol: TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + readyState: WebSocket.OPEN, + send: vi.fn<(data: string) => void>(), + close: vi.fn() + }) + attachExtensionSocket(negotiated as unknown as WebSocket, { + createId: () => 'negotiated', + origin: STORE_ORIGIN, + registry, + terminalReceiptGeneration: 'hub', + onToolCompletion, + onSocketError, + onStateChange: () => {}, + onToolResult: () => {}, + onToolError: () => {} + }) + negotiated.emit('message', JSON.stringify({ ...hello, terminalReceipts: true }), false) + negotiated.send.mockImplementationOnce(() => { + throw new Error('ack lost') + }) + expect(() => negotiated.emit('message', JSON.stringify(completion), false)).not.toThrow() + expect(onSocketError).toHaveBeenCalledWith( + 'negotiated', + expect.objectContaining({ message: 'ack lost' }) + ) + negotiated.emit('message', JSON.stringify(completion), false) + expect(JSON.parse(negotiated.send.mock.calls.at(-1)![0])).toEqual({ + ...completion, + type: 'toolCompletionAck', + accepted: true + }) + }) it('requires runtime identity and rejects session IDs already held by another connection', () => { const registry = new ExtensionRegistry(10000) registries.push(registry) @@ -29,6 +297,7 @@ describe('extension socket lifecycle', () => { const peer = (id: string) => { const socket = Object.assign(new EventEmitter(), { protocol: TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + readyState: WebSocket.OPEN, send: vi.fn(), close: vi.fn() }) diff --git a/packages/mcp-server/tests/fixtures/runtime-ca-key.pem b/packages/mcp-server/tests/fixtures/runtime-ca-key.pem new file mode 100644 index 00000000..f2128ce3 --- /dev/null +++ b/packages/mcp-server/tests/fixtures/runtime-ca-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDOzNqqjjgoMmFp +BGTQBKpZxHUjokLbp4K4Kn33aIyVre1tsYVMeNeRMysf7jAzMXn8eHo/o6ZZZnTs +ku0egIlpIhBQzwKX3p3BEeyqLIoipDwTc343aBEhdP+nF6WqS7Ts/M5JbGG4Ejlb +23u7794wNND4EMkg3BUIpTf323F2Ttrklxyv9RqfCp8z+K0olnvijgyUends3b29 +8XNIqVOCI6Ba/96JHg1hjwuzxKadF0gr8lkUQP7JJx75iUcJLDt3Th/+0VkEzJzp +MkHY+X/dCQuuyqyyX1VaxAynl3Fvq0dSGUVsChM9bXhf02aqQSI0IcsLtORkjaYX +T007X8VFAgMBAAECggEAHXH+uqU4asTNqOnWvuXDKbf24+WBNoxx6O4n9Hfe+JsE +wQczTzGvo/2QqOG93DQxxdiTs4cGMxmLEXpUJ/EuX5lZkGABYwVwGeMG+y4PVyJi +aXSa5qG7OrPRiQWtcYOJFetjJgI4IcD6iNFGG3rqfJuwm3msYbDkj9ASmYiV7b3I +DUaudExK5H6DJ4WSbJwxeNxIoCSEgyKfbHx5o1LulJZFH9mozfRKUhaJT6u8/SOf +BUTwAC+Yko8WzahwPZManGjLUn4tMunQCKc4nqksfcE5NTQp000oLvY4ArGMudNe +t3eEHV2h81DXijInpf4esbzu+niHiZrxJyTVHleaoQKBgQDugh8eGctQX+SHpnxl +dgVgbjIZZBmf+RTrFPPRmt/SJwyRmchRjkYT4GdfqnkCvVjAFOoa8uksTY8Xh4PH +B3zTAOxwyHtuDknbKbBTjxX2AMJIlPM/kYpyh8Fh+zcnwOYAAD4TSX7JlsnGL8Ni +1GUzhn4Jt+y4Q24rzx2duCtccQKBgQDd923BWsMO7vZBvsOtraOi+hC2c8QQ5RG9 +oQYh9y9NN5EzmGMxoqeS1hSpvWlqUieSZPkliEXKfskVtEuYuWal6gSwzyuU+4Dh +CZYBRrLMRC043PuY32buXuYb074BIxMC9XPIAokcmxTrgo0XNqOF4mtKR7z6pgb/ +3ovalDwwFQKBgA+XOemBziSG4C1t2JvZRyiyi1vVi0dFxPRI/lcpRJA8NTofT1Tl +c/dAqAE854QiVHbZ5yOz1UIsH7puBVMxThuM5+fHcyoSjQlihEAyDkFyc0DDW9M/ +9EjgTufUwBo9JcvCOEySdVNAJBhsbt06/RKNLBf+id7RqZX7h/gTXeJBAoGAcOs6 +zq6ZpLM0W+XsLiKzqGmGrwrdp6qhB9MvB4B0c9fc6l6Pt+SDU1PNVzygsBHzR6pO +hSV6O7WcHoqGGr/W1Hjkwf3isfvwaIsz4JpSw0+7ob3p0K918bYdYfKqKnHpbgvo +RsvjbMYIMndLx49oSOgSpMsx/GESFoDkrOHUbLECgYAZTXQ9GkDaKm2OwZPubfFE +wEOTeuJtLXBcZZ0yHLZw7RrRc0k+52SoA2SJ8MeyKWQ7Rs2MdqRchDbEEI2T5O6S +teWNo6mnL4jhxCdbX4fJbR02zd9JoGGGpD3HzpdU6ZZaeEQDoLgq2A3qLvC3638m +DUd6cR281qt/Q+Hychm3tw== +-----END PRIVATE KEY----- diff --git a/packages/mcp-server/tests/fixtures/runtime-ca.pem b/packages/mcp-server/tests/fixtures/runtime-ca.pem new file mode 100644 index 00000000..4f2232e4 --- /dev/null +++ b/packages/mcp-server/tests/fixtures/runtime-ca.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFTCCAf2gAwIBAgIUS8v/m1QFbyqN9t3/GujUGMcCAKcwDQYJKoZIhvcNAQEL +BQAwITEfMB0GA1UEAwwWVGVtUGFkIFJ1bnRpbWUgVGVzdCBDQTAgFw0yNjEwMjgw +MzUxMzlaGA8yMTI2MTAwNDAzNTEzOVowITEfMB0GA1UEAwwWVGVtUGFkIFJ1bnRp +bWUgVGVzdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM7M2qqO +OCgyYWkEZNAEqlnEdSOiQtungrgqffdojJWt7W2xhUx415EzKx/uMDMxefx4ej+j +pllmdOyS7R6AiWkiEFDPApfencER7KosiiKkPBNzfjdoESF0/6cXpapLtOz8zkls +YbgSOVvbe7vv3jA00PgQySDcFQilN/fbcXZO2uSXHK/1Gp8KnzP4rSiWe+KODJR6 +d2zdvb3xc0ipU4IjoFr/3okeDWGPC7PEpp0XSCvyWRRA/sknHvmJRwksO3dOH/7R +WQTMnOkyQdj5f90JC67KrLJfVVrEDKeXcW+rR1IZRWwKEz1teF/TZqpBIjQhywu0 +5GSNphdPTTtfxUUCAwEAAaNDMEEwHQYDVR0OBBYEFCCZxvzwKIljdEP3l6E/yPNp +Iw/bMA8GA1UdEwEB/wQFMAMBAf8wDwYDVR0RBAgwBocEfwAAATANBgkqhkiG9w0B +AQsFAAOCAQEAm/7ER9s8WeChTscMBcvYAwx+6JI7JJsf6AzhL1yvySNw08OJUKc2 +Bn/Q08XquDWXBPzVMg5H+G8L1dHZ+WZSvJ9rODix9EP8tc8qTrUFZSlFoUz9soqA +dfxiex7DiVncAn1LobCZlgND5DqGfHZptF643L8Rre+WlHtxWSJo5zLTQRpkjI2V +7aDrpBUnhVbsc0BPfKwfQFLie60dNHzjZnJ3Gu0LDDF463kXvBzh22XxT9RMxQrL +/qaK2aDq+NRzaSUpozSmwodb6wOf8fhR/B2049SvEpmVO8f8yQYwtRnvAnWUnX4e +E9XvFmIde/2GjHDDxPeR6PZMtujCdtrRiA== +-----END CERTIFICATE----- diff --git a/packages/mcp-server/tests/global-setup.ts b/packages/mcp-server/tests/global-setup.ts index 5de43bea..8f458d26 100644 --- a/packages/mcp-server/tests/global-setup.ts +++ b/packages/mcp-server/tests/global-setup.ts @@ -1,8 +1,8 @@ import type { TestProject } from 'vitest/node' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { rmSync } from 'node:fs' + +import { createPrivateTempDirectory } from '../src/runtime-platform' declare module 'vitest' { export interface ProvidedContext { @@ -10,8 +10,12 @@ declare module 'vitest' { } } -export default function setup(project: TestProject) { - const directory = mkdtempSync(join(tmpdir(), 'tempad-mcp-tests-')) +export default async function setup(project: TestProject) { + if (process.env.TEMPAD_RUNTIME_EVIDENCE_DIR) { + const diagnostics = '../scripts/runtime-startup-trace.mjs' + await import(diagnostics) + } + const directory = createPrivateTempDirectory('tempad-mcp-tests-') project.provide('mcpTestDirectory', directory) return () => rmSync(directory, { recursive: true, force: true }) } diff --git a/packages/mcp-server/tests/history-retention.integration.test.ts b/packages/mcp-server/tests/history-retention.integration.test.ts index 536fd690..e34327ab 100644 --- a/packages/mcp-server/tests/history-retention.integration.test.ts +++ b/packages/mcp-server/tests/history-retention.integration.test.ts @@ -51,14 +51,14 @@ describe('history retention across task and host adapters', () => { let messages = [{ id: 'feedback-a' }] const queue = new CodexNativeQueue( async () => messages, - async () => null + async () => null, + { list: vi.fn(), add: vi.fn(), remove: vi.fn(), close: vi.fn() } ) const remove = vi.spyOn(queue, 'remove').mockImplementation(async () => { messages = [] }) - const native = new CodexAppFeedback( - directory, - async () => ({ + const native = new CodexAppFeedback(directory, { + open: async () => ({ owner: vi.fn().mockResolvedValue('host-a'), close: vi.fn(), request: vi.fn(), @@ -66,10 +66,10 @@ describe('history retention across task and host adapters', () => { onBroadcast: vi.fn(() => () => {}), onDisconnect: vi.fn(() => () => {}) }), - 1, - vi.fn(), - queue - ) + retryMs: 1, + load: vi.fn(), + nativeQueue: queue + }) const clients = new AgentClients(tasks, native) try { await writeFile( diff --git a/packages/mcp-server/tests/host-context.test.ts b/packages/mcp-server/tests/host-context.test.ts new file mode 100644 index 00000000..b11fa4bb --- /dev/null +++ b/packages/mcp-server/tests/host-context.test.ts @@ -0,0 +1,621 @@ +import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { CodexAppLifecycle } from '../src/agent-clients/codex-lifecycle' +import type { HostContext } from '../src/agent-clients/host-context' + +import { CodexAppFeedback } from '../src/agent-clients/codex-feedback' +import { codexSocketPaths } from '../src/agent-clients/codex-ipc' +import { CodexNativeQueue } from '../src/agent-clients/codex-queue' +import { + captureHostContext, + HostContextSchema, + hostContextKey, + hostConversationKey, + requireHostContext +} from '../src/agent-clients/host-context' +import { AgentClients } from '../src/agent-clients/registry' +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { HISTORY_RETENTION_MS } from '../src/history-retention' +import { hostEnvironment, hubEnvironment } from '../src/process-environment' +import { log } from '../src/shared' + +const contextA = captureHostContext({ HOME: resolve('/host-a'), TMPDIR: resolve('/tmp-a') }) +const contextB = captureHostContext({ HOME: resolve('/host-b'), TMPDIR: resolve('/tmp-b') }) +const session = { + sessionId: 'figma', + fileKey: 'file-a', + fileName: 'File', + pageId: 'page', + busy: false +} +const cleanups: (() => unknown)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) +async function directory() { + const path = await mkdtemp(join(tmpdir(), 'tempad-host-context-')) + cleanups.push(() => rm(path, { recursive: true, force: true })) + return path +} + +describe('CLI host context', () => { + it('resolves only bounded discovery paths, including defaults and relative overrides', () => { + const context = captureHostContext({ + HOME: resolve('/home'), + CODEX_HOME: './codex', + CODEX_SQLITE_HOME: './sqlite', + TEMP: resolve('/temp'), + API_KEY: 'secret', + CODEX_THREAD_ID: 'parent' + }) + expect(context).toEqual({ + version: 1, + home: resolve('/home'), + tempDir: resolve('/temp'), + codexHome: resolve('codex'), + codexSqliteHome: resolve('sqlite') + }) + expect( + captureHostContext({ USERPROFILE: resolve('/user'), TMP: resolve('/temp') }) + ).toMatchObject({ + home: resolve('/user'), + codexHome: resolve('/user/.codex'), + tempDir: resolve('/temp') + }) + expect(HostContextSchema.safeParse(captureHostContext({})).success).toBe(true) + for (const invalid of [ + { ...context, version: 2 }, + { ...context, home: 'relative' }, + { ...context, tempDir: '/x\0y' }, + { ...context, codexHome: '/'.repeat(4097) }, + { ...context, executable: '/arbitrary/program' }, + { ...context, credentials: 'secret' } + ]) + expect(HostContextSchema.safeParse(invalid).success).toBe(false) + expect(requireHostContext(context)).toBe(context) + expect(() => requireHostContext()).toThrow('Update TemPad Dev MCP and restart') + expect(hostContextKey(contextA)).not.toBe(hostContextKey(contextB)) + expect(hostConversationKey('same-thread', contextA)).not.toBe( + hostConversationKey('same-thread', contextB) + ) + expect(hostContextKey()).toBe('') + }) + + it('launches Hub without inherited identities, secrets or Node injection flags', () => { + const env = { + PATH: '/bin', + HOME: '/os-home', + TMPDIR: '/tmp', + SystemRoot: 'C:\\Windows', + TEMPAD_MCP_RUNTIME_DIR: '/run', + TEMPAD_MCP_DEV_CHECKOUT: '/checkout', + TEMPAD_MCP_APPLY_CANVAS_TIMEOUT: '300000', + CODEX_HOME: '/first-codex', + CODEX_SQLITE_HOME: '/first-sqlite', + CODEX_THREAD_ID: 'parent', + CODEX_APP_TOOLS_PIPE_PATH: '/parent.pipe', + CLAUDECODE: '1', + CLAUDE_CODE_SESSION_ID: 'parent-session', + OPENAI_API_KEY: 'secret', + NODE_OPTIONS: '--require /injected.js', + TEMPAD_MCP_UNKNOWN: 'not-config' + } + expect(hubEnvironment(env)).toEqual({ + PATH: '/bin', + HOME: '/os-home', + TMPDIR: '/tmp', + SystemRoot: 'C:\\Windows', + TEMPAD_MCP_RUNTIME_DIR: '/run', + TEMPAD_MCP_DEV_CHECKOUT: '/checkout', + TEMPAD_MCP_APPLY_CANVAS_TIMEOUT: '300000' + }) + const helper = hostEnvironment(contextB, env) + expect(helper).toMatchObject({ + HOME: contextB.home, + USERPROFILE: contextB.home, + CODEX_HOME: contextB.codexHome, + CODEX_SQLITE_HOME: contextB.codexSqliteHome, + TMPDIR: contextB.tempDir, + TEMP: contextB.tempDir, + TMP: contextB.tempDir + }) + for (const key of [ + 'OPENAI_API_KEY', + 'CODEX_THREAD_ID', + 'CLAUDECODE', + 'NODE_OPTIONS', + 'TEMPAD_MCP_RUNTIME_DIR' + ]) + expect(helper).not.toHaveProperty(key) + expect(env.CODEX_HOME).toBe('/first-codex') + }) + + it('reads each host queue without consulting the Hub environment', async () => { + const root = await directory() + const contexts = ['a', 'b'].map((name) => + captureHostContext({ HOME: root, CODEX_HOME: join(root, name), TMPDIR: root }) + ) + vi.stubEnv('CODEX_HOME', '/wrong-home') + vi.stubEnv('CODEX_SQLITE_HOME', '/wrong-sqlite') + for (const [index, context] of contexts.entries()) { + await mkdir(context.codexHome) + await writeFile( + join(context.codexHome, '.codex-global-state.json'), + JSON.stringify({ 'queued-follow-ups': { same: [{ id: String(index) }] } }) + ) + } + const queues = contexts.map(CodexNativeQueue.forContext) + cleanups.push(() => queues.forEach((queue) => queue.close())) + expect(await Promise.all(queues.map((queue) => queue.read('same')))).toEqual([ + [{ id: '0' }], + [{ id: '1' }] + ]) + expect(await queues[0]!.areQueuesEmpty('unused')).toBe(true) + expect(await queues[1]!.areQueuesEmpty('same')).toBe(false) + if (process.platform !== 'win32') + expect(codexSocketPaths(contexts[0]!)[0]).toBe(join(contexts[0]!.codexHome, 'ipc/ipc.sock')) + }) +}) + +describe('connection-scoped native routing', () => { + it('does not share availability caches or lifecycle events between identical thread IDs in different homes', async () => { + const open = vi.fn(async (context) => ({ + owner: async () => { + if (hostContextKey(context) === hostContextKey(contextB)) throw new Error('Unavailable') + return 'owner' + }, + close: vi.fn(), + request: vi.fn(), + broadcast: vi.fn(), + onBroadcast: () => () => {}, + onDisconnect: () => () => {} + })) + const feedback = new CodexAppFeedback(await directory(), { open }) + let changed!: ConstructorParameters[0] + const tasks = new DesignTasks({ + createId: (() => { + let count = 0 + return () => `task-${++count}` + })() + }) + const clients = new AgentClients(tasks, feedback, (callback) => { + changed = callback + return { + watch: vi.fn(), + state: () => undefined, + retain: vi.fn(), + interrupt: vi.fn(), + close: vi.fn() + } + }) + cleanups.push(() => clients.close()) + for (const [id, context] of [ + ['a', contextA], + ['b', contextB] + ] as const) { + clients.registerContext(id, context) + const owner = await clients.owner(id, { + 'x-codex-turn-metadata': { thread_id: 'same', turn_id: 'turn' } + }) + const description = await clients.describe(owner) + expect(description.capabilities.queue).toBe(id === 'a') + tasks.begin( + owner, + 'extension', + { ...session, fileKey: id }, + id, + id, + description.client, + description.capabilities, + undefined, + undefined, + context + ) + await clients.describe(owner) + } + expect(open).toHaveBeenCalledTimes(2) + changed('same', { turns: [{ turnId: 'turn', status: 'completed' }] }, contextA) + expect(tasks.find('task-1')!.task.status).toBe('paused') + expect(tasks.find('task-2')!.task.status).toBe('active') + clients.disconnect('a') + const owner = await clients.owner('b', { + 'x-codex-turn-metadata': { thread_id: 'same', turn_id: 'next' } + }) + expect((await clients.describe(owner)).capabilities.queue).toBe(false) + }) + + it('retains receipt context across restart and removes only the original host queue entry', async () => { + const root = await directory() + let releaseAdmission!: () => void + const admission = new Promise((resolve) => { + releaseAdmission = resolve + }) + const messages = new Map([ + [contextA.codexHome, [] as { id: string }[]], + [contextB.codexHome, [] as { id: string }[]] + ]) + const factory = vi.spyOn(CodexNativeQueue, 'forContext').mockImplementation((context) => { + const queue = new CodexNativeQueue( + async () => messages.get(context.codexHome)!, + async () => null, + { list: vi.fn(), add: vi.fn(), remove: vi.fn(), close: vi.fn() } + ) + vi.spyOn(queue, 'admit').mockImplementation( + async (_ipc, _owner, _conversation, _task, feedback, _signal, beforeWrite, onDispatch) => { + await beforeWrite('legacy') + onDispatch() + await admission + messages.get(context.codexHome)!.push({ id: feedback.id }) + } + ) + vi.spyOn(queue, 'remove').mockImplementation(async (_ipc, _owner, _conversation, ids) => { + messages.set( + context.codexHome, + messages.get(context.codexHome)!.filter(({ id }) => !ids.has(id)) + ) + }) + return queue + }) + const open = vi.fn(async () => ({ + owner: async () => 'owner', + close: vi.fn(), + request: vi.fn(), + broadcast: vi.fn(), + onBroadcast: () => () => {}, + onDisconnect: () => () => {} + })) + let native = new CodexAppFeedback(root, { open }) + const feedback = { + id: 'one', + mode: 'queue' as const, + fileKey: 'file', + items: [], + comment: 'Comment', + createdAt: Date.now() + } + const binding = { + client: { kind: 'codex-app' as const, name: 'Codex', sessionId: 'same' }, + hostContext: contextA + } + const pending = native.enqueue( + binding, + 'task-a', + feedback, + new AbortController().signal, + () => {}, + () => {} + ) + try { + await vi.waitFor(() => expect(factory).toHaveBeenCalledTimes(1)) + const queue = factory.mock.results[0]!.value as CodexNativeQueue + const close = vi.spyOn(queue, 'close') + native.retainContexts([]) + expect(close).not.toHaveBeenCalled() + await expect( + native.enqueue( + { ...binding, hostContext: { ...contextA, tempDir: contextB.tempDir } }, + 'task-a', + { ...feedback, mode: 'steer' }, + new AbortController().signal, + () => {}, + () => {} + ) + ).rejects.toThrow('pending delivery') + } finally { + releaseAdmission() + } + await pending + await native.enqueue( + { ...binding, hostContext: { ...contextA, home: contextB.home, tempDir: contextB.tempDir } }, + 'task-a', + feedback, + new AbortController().signal, + () => {}, + () => {} + ) + await expect( + native.enqueue( + { ...binding, hostContext: contextB }, + 'task-a', + feedback, + new AbortController().signal, + () => {}, + () => {} + ) + ).rejects.toThrow('different Codex host context') + await native.enqueue( + { ...binding, hostContext: contextB }, + 'task-b', + { ...feedback, id: 'two' }, + new AbortController().signal, + () => {}, + () => {} + ) + expect(factory).toHaveBeenCalledTimes(2) + const stored = await Promise.all( + (await readdir(root)).map(async (name) => + JSON.parse(await readFile(join(root, name), 'utf8')) + ) + ) + expect(stored.map((receipt) => receipt.hostContext.codexHome).sort()).toEqual( + [contextA.codexHome, contextB.codexHome].sort() + ) + native.close() + const moved = { ...contextA, home: contextB.home, tempDir: contextB.tempDir } + let taskContext: HostContext | undefined = moved + native = new CodexAppFeedback(root, { + open, + taskContext: (taskId) => (taskId === 'task-a' ? taskContext : undefined) + }) + cleanups.push(() => native.close()) + open.mockClear() + await native.cancelQueued('task-a') + expect(open).toHaveBeenCalledExactlyOnceWith(moved) + expect(messages.get(contextA.codexHome)).toEqual([]) + expect(messages.get(contextB.codexHome)).toEqual([{ id: 'two' }]) + // Even expired task metadata is unnecessary for late cancellation reconciliation. + taskContext = undefined + messages.set(contextA.codexHome, [{ id: 'one' }, { id: 'user-message' }]) + await native.reconcileQueued(() => false) + expect(open).toHaveBeenLastCalledWith(moved) + expect(messages.get(contextA.codexHome)).toEqual([{ id: 'user-message' }]) + native.retainContexts([]) + await native.cancelQueued('task-b') + expect(messages.get(contextB.codexHome)).toEqual([]) + }) + + it('recovers a legacy receipt only through its task context and persists that routing', async () => { + const root = await directory() + const path = join(root, `${'a'.repeat(64)}.json`) + await writeFile( + path, + JSON.stringify({ + signature: 'legacy', + status: 'delivered', + delivery: 'queue', + backend: 'server', + conversationId: 'thread', + taskId: 'task', + messageId: 'comment', + acceptedAt: Date.now() - HISTORY_RETENTION_MS - 10 * 60000 + }) + ) + let messages = [{ id: 'native-id', clientUserMessageId: 'comment', input: [] }] + const queue = new CodexNativeQueue( + async () => [], + async () => null, + { + list: async () => messages, + add: vi.fn(), + remove: async (_thread, id) => { + messages = messages.filter((message) => message.id !== id) + }, + close: vi.fn() + } + ) + const factory = vi.spyOn(CodexNativeQueue, 'forContext').mockReturnValue(queue) + let recovered: HostContext | undefined + const feedback = new CodexAppFeedback(root, { + taskContext: (id) => (id === 'task' ? recovered : undefined) + }) + cleanups.push(() => feedback.close()) + vi.stubEnv('CODEX_HOME', contextB.codexHome) + + await feedback.reconcileQueued(() => false) + expect(JSON.parse(await readFile(path, 'utf8')).status).toBe('delivered') + expect(factory).not.toHaveBeenCalled() + await expect(feedback.cancelQueued('task')).rejects.toThrow('Update TemPad Dev MCP and restart') + expect(factory).not.toHaveBeenCalled() + expect(JSON.parse(await readFile(path, 'utf8')).status).toBe('cancelled') + expect(messages).toHaveLength(1) + + recovered = contextA + await feedback.cancelQueued('task') + expect(factory).toHaveBeenCalledExactlyOnceWith(contextA) + expect(messages).toEqual([]) + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ + status: 'removed', + hostContext: contextA + }) + recovered = undefined + messages = [{ id: 'late-native-id', clientUserMessageId: 'comment', input: [] }] + await feedback.reconcileQueued(() => false) + expect(messages).toEqual([]) + }) + + it.each(['claude', 'codex-app'] as const)( + 'keeps contexts independent when %s connects first', + async (first) => { + const tasks = new DesignTasks({ createId: () => 'task-a' }) + const open = vi.fn(async () => ({ + owner: vi.fn(async () => 'owner'), + close: vi.fn(), + request: vi.fn(), + broadcast: vi.fn(), + onBroadcast: () => () => {}, + onDisconnect: () => () => {} + })) + const feedback = new CodexAppFeedback(await directory(), { open }) + const clients = new AgentClients(tasks, feedback) + cleanups.push(() => clients.close()) + for (const kind of [first, first === 'claude' ? 'codex-app' : 'claude'] as const) { + const context = kind === 'claude' ? contextA : contextB + clients.registerContext(kind, context) + clients.event(kind, { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind } + }) + const owner = await clients.owner( + kind, + kind === 'codex-app' ? { 'x-codex-turn-metadata': { thread_id: 'thread' } } : undefined, + { name: kind === 'claude' ? 'claude-code' : 'codex' } + ) + const description = await clients.describe(owner) + expect(description.hostContext).toEqual(context) + expect(description.capabilities.queue).toBe(kind === 'codex-app') + } + expect(open).toHaveBeenCalledExactlyOnceWith(contextB) + clients.disconnect('claude') + const owner = await clients.owner('codex-app', { + 'x-codex-turn-metadata': { thread_id: 'thread' } + }) + expect((await clients.describe(owner)).hostContext).toEqual(contextB) + expect((await clients.describe(owner)).capabilities.queue).toBe(true) + } + ) + + it('freezes a registration and rejects invalid, changed or late contexts', async () => { + const clients = new AgentClients(new DesignTasks({ createId: () => 'unused' })) + cleanups.push(() => clients.close()) + const input = structuredClone(contextA) + clients.registerContext('a', input) + input.home = contextB.home + clients.registerContext('a', contextA) + expect(() => clients.registerContext('a', contextB)).toThrow('cannot change') + expect(() => clients.registerContext('bad', { ...contextA, version: 9 })).toThrow() + const owner = await clients.owner('a', undefined) + expect((await clients.describe(owner)).hostContext).toEqual(contextA) + await clients.owner('late', undefined) + expect(() => clients.registerContext('late', contextA)).toThrow('before using tools') + clients.disconnect('a') + clients.registerContext('a', contextB) + expect((await clients.describe(await clients.owner('a', undefined))).hostContext).toEqual( + contextB + ) + }) + + it('preserves context through disconnect and restart, and rejects adoption from another home', async () => { + const root = await directory() + const store = new DesignTaskStore(join(root, 'tasks.json')) + let tasks = new DesignTasks({ createId: () => 'task-a', store }) + const feedback = new CodexAppFeedback(root) + const available = vi.spyOn(feedback, 'available').mockResolvedValue(true) + const interrupt = vi.fn(async () => true) + const native = { + watch: vi.fn(), + state: () => ({ turns: [{ turnId: 'turn', status: 'inProgress' as const }] }), + retain: vi.fn(), + interrupt, + close: vi.fn() + } + let clients = new AgentClients(tasks, feedback, () => native) + const metadata = { 'x-codex-turn-metadata': { thread_id: 'same-thread', turn_id: 'turn' } } + clients.registerContext('a', contextA) + clients.event('a', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'codex-app' } + }) + const owner = await clients.owner('a', metadata) + const description = await clients.describe(owner) + tasks.begin( + owner, + 'extension', + session, + 'Review', + 'request', + description.client, + description.capabilities, + undefined, + undefined, + description.hostContext + ) + clients.disconnect('a') + expect((await clients.describe(owner)).hostContext).toEqual(contextA) + clients.close() + tasks = new DesignTasks({ createId: () => 'unused', store }) + tasks.restore() + clients = new AgentClients(tasks, feedback, () => native) + cleanups.push(() => clients.close()) + const record = tasks.find('task-a')! + const restoredOwner = record.ownerId + expect(record.hostContext).toEqual(contextA) + await clients.describe(restoredOwner) + expect(available).toHaveBeenLastCalledWith({ + client: description.client, + hostContext: contextA + }) + clients.registerContext('b', contextB) + clients.event('b', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'codex-app' } + }) + const warn = vi.spyOn(log, 'warn') + await clients.owner('b', metadata) + expect(warn).toHaveBeenCalledWith( + expect.objectContaining({ taskIds: ['task-a'] }), + expect.stringContaining('Codex storage differs') + ) + expect(record.ownerId).toBe(restoredOwner) + expect(record.hostContext).toEqual(contextA) + const moved = { ...contextA, home: contextB.home, tempDir: contextB.tempDir } + clients.registerContext('c', moved) + clients.event('c', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'codex-app' } + }) + const nextOwner = await clients.owner('c', metadata) + expect(record.ownerId).toBe(nextOwner) + expect(record.hostContext).toEqual(moved) + expect(store.load()?.records[0]?.hostContext).toEqual(moved) + await clients.action({ + requestId: 'stop', + taskId: 'task-a', + epoch: record.task.epoch!, + action: 'stop' + }) + expect(interrupt).toHaveBeenCalledWith('same-thread', 'turn', moved) + }) + + it('does not require Codex storage or discovery paths to reconnect a Claude task', () => { + const tasks = new DesignTasks({ createId: () => 'claude-task' }) + const client = { kind: 'claude' as const, name: 'Claude', sessionId: 'resumed-session' } + const record = tasks.begin( + 'old', + 'extension', + session, + 'Review', + 'request', + client, + undefined, + undefined, + undefined, + contextA + ) + tasks.disconnectOwner('old') + tasks.attachClient('new', client, undefined, contextB) + expect(record.ownerId).toBe('new') + expect(record.hostContext).toEqual(contextB) + }) + + it('does not substitute the Hub environment for missing legacy client context', async () => { + vi.stubEnv('CODEX_HOME', '/first-launcher') + const feedback = new CodexAppFeedback(await directory()) + cleanups.push(() => feedback.close()) + expect( + await feedback.available({ + client: { kind: 'codex-app', name: 'Codex', sessionId: 'thread' } + }) + ).toBe(false) + const clients = new AgentClients(new DesignTasks({ createId: () => 'unused' }), feedback) + cleanups.push(() => clients.close()) + clients.event('legacy', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'codex-app' } + }) + const owner = await clients.owner('legacy', { + 'x-codex-turn-metadata': { thread_id: 'thread' } + }) + expect((await clients.describe(owner)).capabilities).toMatchObject({ + queue: false, + steer: false, + interrupt: false, + reason: expect.stringContaining('Update TemPad Dev MCP and restart') + }) + }) +}) diff --git a/packages/mcp-server/tests/hub-runtime.test.ts b/packages/mcp-server/tests/hub-runtime.test.ts index 153917be..e1d35784 100644 --- a/packages/mcp-server/tests/hub-runtime.test.ts +++ b/packages/mcp-server/tests/hub-runtime.test.ts @@ -1,22 +1,25 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { CallToolResultSchema, ResultSchema } from '@modelcontextprotocol/sdk/types.js' import { TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, TEMPAD_MCP_BRIDGE_SUBPROTOCOL, type DesignTask, type ToolCallMessage } from '@tempad-dev/shared' +import { randomUUID } from 'node:crypto' import { EventEmitter, once } from 'node:events' -import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer as createHttpServer } from 'node:http' import { connect, createServer, type Server, type Socket } from 'node:net' import lockfile from 'proper-lockfile' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { WebSocketServer } from 'ws' +import { WebSocket, WebSocketServer } from 'ws' const fixture = await vi.hoisted(async () => { - const { mkdtempSync } = await import('node:fs') + const { mkdtempSync, realpathSync } = await import('node:fs') const { tmpdir } = await import('node:os') const { basename, join } = await import('node:path') - const directory = mkdtempSync(join(tmpdir(), 'tempad-hub-test-')) + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'tempad-hub-test-'))) return { directory, socketPath: @@ -37,6 +40,7 @@ vi.mock('../src/shared', async () => { return { ASSET_DIR: `${fixture.directory}/assets`, RUNTIME_DIR: fixture.directory, + RUNTIME_PROFILE: { kind: 'release' }, SOCK_PATH: fixture.socketPath, HUB_LOCK_PATH: `${fixture.directory}/hub.lock`, HUB_LOCK_STALE_MS: 10000, @@ -69,11 +73,23 @@ vi.mock('../src/asset-store', async (load) => { }) import { CodexAppFeedback } from '../src/agent-clients/codex-feedback' +import { CodexAppLifecycle } from '../src/agent-clients/codex-lifecycle' +import { captureHostContext, type HostContext } from '../src/agent-clients/host-context' import { AgentClients } from '../src/agent-clients/registry' import { createAssetHttpServer } from '../src/asset-http-server' import { createAssetStore } from '../src/asset-store' +import { DesignTaskStore } from '../src/design-task-store' import { DesignTasks } from '../src/design-tasks' +import { writeDurableJson } from '../src/durable-json' import { removeHubRuntimeIdentityIfOwned, writeHubRuntimeIdentity } from '../src/runtime-identity' +import { LeaseSchema } from '../src/runtime-prototype/hub-control' +import { + ControlPeer, + RuntimeDataTransport, + SocketTransport +} from '../src/runtime-prototype/protocol' +import { readEndpoints } from '../src/runtime-prototype/state' +import { runRuntimeRecovery } from '../src/runtime-recovery' import { log, resolveStateDir } from '../src/shared' import { startExtensionWebSocketServer } from '../src/websocket-server' @@ -84,8 +100,15 @@ const assetStart = vi.fn<() => Promise>() const assetStop = vi.fn() let websocket: WebSocketServer const consumerSockets: Socket[] = [] +const structure = { + roots: [{ id: '1:1', name: 'Root', type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }] +} -async function connectConsumer(clientEventFirst = false, clientName = 'isolated-test') { +async function connectConsumer( + clientEventFirst = false, + clientName = 'isolated-test', + context?: HostContext +) { const socket = connect(fixture.socketPath) consumerSockets.push(socket) await once(socket, 'connect') @@ -115,6 +138,7 @@ async function connectConsumer(clientEventFirst = false, clientName = 'isolated- await vi.waitFor(() => expect(replies.has(id)).toBe(true), { interval: 1 }) return replies.get(id)! } + if (context) send({ method: 'notifications/tempad/host-context', params: context }) if (clientEventFirst) send({ method: 'notifications/tempad/client-event', @@ -134,15 +158,21 @@ async function connectConsumer(clientEventFirst = false, clientName = 'isolated- return { socket, send, reply, replies, bindingUpdates } } -function connectExtension() { +function connectExtension(terminalReceipts = false) { const calls: ToolCallMessage[] = [] const peer = Object.assign(new EventEmitter(), { protocol: TEMPAD_MCP_BRIDGE_SUBPROTOCOL, + readyState: WebSocket.OPEN as number, send: vi.fn((data: string) => { const message = JSON.parse(data) if (message.type === 'toolCall') calls.push(message) }), - close: vi.fn() + close: vi.fn(() => { + peer.readyState = WebSocket.CLOSING + }) + }) + peer.once('close', () => { + peer.readyState = WebSocket.CLOSED }) const send = (message: object) => peer.emit('message', Buffer.from(JSON.stringify(message)), false) @@ -151,7 +181,8 @@ function connectExtension() { type: 'runtimeHello', protocolVersion: TEMPAD_MCP_BRIDGE_PROTOCOL_VERSION, extensionVersion: '0.23.4', - extensionRuntimeFingerprint: 'a'.repeat(64) + extensionRuntimeFingerprint: 'a'.repeat(64), + ...(terminalReceipts ? { terminalReceipts: true } : {}) }) const publishSession = (sessionId = 'session', documentId = 'document') => send({ @@ -183,7 +214,7 @@ function connectExtension() { await vi.waitFor(() => expect(calls.length).toBeGreaterThan(index), { interval: 1 }) return calls[index]! } - return { calls, call, send, publishSession } + return { calls, call, send, publishSession, peer } } async function loadRuntime() { @@ -197,6 +228,248 @@ async function loadRuntime() { return runtime } +it('keeps a hot handoff pinned when an unrelated listener occupies its HTTP port', async () => { + const other = createHttpServer() + other.listen(0, '127.0.0.1') + await once(other, 'listening') + const endpoints = { + wsPort: 6220, + httpPort: (other.address() as { port: number }).port, + accessToken: 'a'.repeat(32), + legacyAccessToken: 'b'.repeat(32) + } + const path = `${fixture.directory}/endpoints.json` + writeDurableJson(path, endpoints) + const actual = await vi.importActual( + '../src/asset-http-server' + ) + vi.mocked(createAssetHttpServer).mockImplementation(actual.createAssetHttpServer) + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('Startup stopped') + }) + try { + const { startHub } = await loadRuntime() + await expect( + startHub({ prototype: { root: fixture.directory, secret: 'isolated', paused: true } }) + ).rejects.toThrow('Startup stopped') + expect(createAssetHttpServer).toHaveBeenCalledOnce() + expect(readEndpoints(fixture.directory)).toEqual(endpoints) + expect(startExtensionWebSocketServer).not.toHaveBeenCalled() + } finally { + await stopRuntime() + await new Promise((resolve) => other.close(() => resolve())) + rmSync(path, { force: true }) + } +}) + +it('includes pending native actions and transfers in the runtime drain boundary', async () => { + const native = vi.spyOn(AgentClients.prototype, 'hasPendingActions', 'get').mockReturnValue(false) + const { startHub } = await loadRuntime() + await startHub({ prototype: { secret: 'isolated' } }) + const socket = connect(fixture.socketPath) + consumerSockets.push(socket) + await once(socket, 'connect') + const peer = new ControlPeer() + await peer.connect(new SocketTransport(socket)) + try { + await peer.request( + { method: 'tempad/runtime-acquire', params: { secret: 'isolated', versions: [2] } }, + LeaseSchema + ) + const inspect = () => peer.request({ method: 'tempad/runtime-inspect' }, ResultSchema) + expect(await inspect()).toMatchObject({ + drained: true, + blockers: [], + shutdown: { + storesOwned: true, + shuttingDown: false, + consumers: 0, + recoveryConnections: 0, + retained: false, + pendingConnections: 0, + waitingForFirstConsumer: true, + pendingOperations: 0, + pendingReceipts: 0, + nativeActions: false, + designBindings: 0, + assetTransfers: 0 + } + }) + native.mockReturnValue(true) + const pending = await inspect() + expect(pending).toMatchObject({ + drained: false, + blockers: ['native feedback or queue removal'], + shutdown: { nativeActions: true } + }) + expect(Number(pending.deadline)).toBeGreaterThan(Date.now() + 300000) + await peer.request({ method: 'tempad/runtime-gate', params: { closed: true } }, ResultSchema) + await expect( + peer.request({ method: 'tempad/runtime-stop', params: { pausedSessions: [] } }, ResultSchema) + ).rejects.toThrow('not drained') + native.mockReturnValue(false) + const assets = vi.mocked(createAssetHttpServer).mock.results.at(-1)!.value + Object.assign(assets, { pendingTransfers: 1 }) + expect(await inspect()).toMatchObject({ + drained: false, + blockers: ['asset transfers'], + shutdown: { assetTransfers: 1 } + }) + Object.assign(assets, { pendingTransfers: 0 }) + expect(await inspect()).toMatchObject({ drained: true }) + await peer.request( + { method: 'tempad/runtime-retain', params: { retained: true } }, + ResultSchema + ) + expect(await inspect()).toMatchObject({ shutdown: { retained: true } }) + await peer.request( + { method: 'tempad/runtime-retain', params: { retained: false } }, + ResultSchema + ) + } finally { + await peer.close() + } +}) + +it('requires initialized clients for automatic switches and records activity between inspections', async () => { + let now = 0 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const { startHub } = await loadRuntime() + await startHub({ prototype: { secret: 'isolated' } }) + const socket = connect(fixture.socketPath) + consumerSockets.push(socket) + await once(socket, 'connect') + const peer = new ControlPeer() + await peer.connect(new SocketTransport(socket)) + const clients: Client[] = [] + try { + const grant = await peer.request( + { method: 'tempad/runtime-acquire', params: { secret: 'isolated', versions: [2] } }, + LeaseSchema + ) + const inspect = () => peer.request({ method: 'tempad/runtime-inspect' }, ResultSchema) + const attach = async (name: string) => { + const sessionId = randomUUID() + const token = randomUUID() + await peer.request( + { + method: 'tempad/runtime-register', + params: { sessionId, token, context: captureHostContext({}), versions: [2] } + }, + ResultSchema + ) + expect(await inspect()).toMatchObject({ shutdown: { pendingConnections: 1 } }) + const data = connect(fixture.socketPath) + consumerSockets.push(data) + await once(data, 'connect') + const attached = once(data, 'data') + data.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/runtime-attach', + params: { sessionId, token } + }) + '\n' + ) + await attached + expect(await inspect()).toMatchObject({ + shutdown: { + pendingConnections: 0, + consumers: clients.filter((client) => client.transport).length + 1, + waitingForFirstConsumer: false + } + }) + expect((await inspect()).autoBlockers).not.toEqual([]) + const client = new Client({ name, version: '1' }) + clients.push(client) + await client.connect( + new RuntimeDataTransport(new SocketTransport(data), { + generation: () => grant.generation, + admit: () => true, + role: 'edge' + }) + ) + return client + } + expect((await inspect()).autoBlockers).not.toEqual([]) + const codex = await attach('codex-mcp-client') + now = 6000 + expect(await inspect()).toMatchObject({ drained: true, autoBlockers: [], idleForMs: 6000 }) + // A complete call between two inspections still restarts the quiet interval. + await codex.callTool({ name: 'list_design_sessions', arguments: {} }) + expect(await inspect()).toMatchObject({ drained: true, autoBlockers: [], idleForMs: 0 }) + now = 7000 + const assetOptions = vi.mocked(createAssetHttpServer).mock.calls.at(-1)![1]! + assetOptions.onActivity?.() + now = 7500 + expect((await inspect()).idleForMs).toBe(500) + // Reading status and changing the gate do not themselves count as activity. + await peer.request({ method: 'tempad/runtime-gate', params: { closed: true } }, ResultSchema) + expect((await inspect()).idleForMs).toBe(500) + await peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + + const unknown = await attach('unknown-host') + expect((await inspect()).autoBlockers).toEqual([]) + await unknown.close() + await vi.waitFor(async () => expect((await inspect()).autoBlockers).toEqual([])) + const claude = await attach('claude-code') + expect(await inspect()).toMatchObject({ drained: true }) + expect((await inspect()).autoBlockers).not.toEqual([]) + await claude.notification({ + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: 'verified-session' } + }) + const hook = { + version: 1, + kind: 'claude', + sessionId: 'verified-session', + event: 'PreToolUse', + invocationId: randomUUID() + } + await peer.request( + { + method: 'tempad/runtime-hook', + params: { input: hook, admittedHubId: grant.hubId } + }, + ResultSchema + ) + await claude.callTool({ name: 'list_design_sessions', arguments: {} }) + expect((await inspect()).blockers).toContain('Claude binding without receipt/retry hooks') + expect((await inspect()).autoBlockers).not.toEqual([]) + await peer.request( + { + method: 'tempad/runtime-hook', + params: { + input: { + ...hook, + version: 2, + invocationId: randomUUID(), + issuedAt: Date.now(), + profile: { kind: 'release' } + }, + admittedHubId: grant.hubId + } + }, + ResultSchema + ) + expect(await inspect()).toMatchObject({ drained: true, autoBlockers: [] }) + await claude.close() + await vi.waitFor(async () => expect((await inspect()).autoBlockers).toEqual([])) + // An inherited Codex environment cannot overrule a pending Claude identity. + await codex.notification({ + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: 'unverified-session' } + }) + await codex.ping() + expect((await inspect()).autoBlockers).toContain( + 'Claude connection requires verified receipt/retry hooks' + ) + } finally { + await Promise.all(clients.map((client) => client.close())) + await peer.close() + } +}) + beforeEach(() => { vi.clearAllMocks() vi.mocked(resolveStateDir).mockReturnValue(`${fixture.directory}/state`) @@ -213,6 +486,7 @@ beforeEach(() => { vi.mocked(createAssetHttpServer).mockReturnValue({ start: assetStart, stop: assetStop, + pendingTransfers: 0, getBaseUrl: () => 'http://127.0.0.1:0' } as ReturnType) websocket = new WebSocketServer({ noServer: true }) @@ -223,7 +497,7 @@ beforeEach(() => { }) }) -afterEach(async () => { +async function stopRuntime() { for (const socket of consumerSockets.splice(0)) socket.destroy() // Also stop a successfully started Hub when an assertion fails before the test's // explicit shutdown, so lock ownership and listeners cannot leak into the next test. @@ -250,12 +524,15 @@ afterEach(async () => { if (!previousSignals.get(signal)?.includes(listener)) process.removeListener(signal, listener) } } - netServer ??= vi.mocked(createServer).mock.results.find((result) => result.type === 'return') - ?.value as Server | undefined + netServer ??= vi.mocked(createServer).mock.results.at(-1)?.value as Server | undefined if (netServer?.listening) await new Promise((resolve) => netServer!.close(() => resolve())) netServer = undefined if (!vi.mocked(websocket.close).mock.calls.length) websocket.close() vi.clearAllTimers() +} + +afterEach(async () => { + await stopRuntime() vi.useRealTimers() vi.unstubAllEnvs() vi.restoreAllMocks() @@ -264,6 +541,92 @@ afterEach(async () => { afterAll(() => rmSync(fixture.directory, { recursive: true, force: true })) describe('Hub runtime module', () => { + it('rejects raw hooks on the managed Hub endpoint before lifecycle effects', async () => { + const { startHub } = await loadRuntime() + await startHub({ prototype: { secret: 'isolated' } }) + const hook = vi.spyOn(AgentClients.prototype, 'clientHook') + const socket = connect(fixture.socketPath) + consumerSockets.push(socket) + await once(socket, 'connect') + const closed = once(socket, 'close') + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/client-hook', + params: { + version: 1, + kind: 'claude', + sessionId: 'session', + event: 'UserPromptSubmit', + invocationId: '00000000-0000-4000-8000-000000000001' + } + }) + '\n' + ) + await closed + expect(hook).not.toHaveBeenCalled() + expect(assetStop).not.toHaveBeenCalled() + }) + + it('receives private CLI contexts before initialize and persists only the task owner context', async () => { + const context = captureHostContext({ + HOME: `${fixture.directory}/host-a`, + TMPDIR: fixture.directory + }) + const other = captureHostContext({ + HOME: `${fixture.directory}/host-b`, + TMPDIR: fixture.directory + }) + const available = vi.spyOn(CodexAppFeedback.prototype, 'available').mockResolvedValue(true) + vi.spyOn(CodexAppLifecycle.prototype, 'watch').mockImplementation(() => {}) + const attach = vi.spyOn(DesignTasks.prototype, 'attachClient') + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const first = await connectConsumer(true, 'codex', context) + const second = await connectConsumer(true, 'codex', other) + const _meta = { 'x-codex-turn-metadata': { thread_id: 'same-thread', turn_id: 'turn' } } + first.send({ + id: 1, + method: 'tools/call', + params: { + name: 'manage_design_task', + arguments: { + action: 'begin', + title: 'Context', + requestId: '00000000-0000-4000-8000-000000000004' + }, + _meta + } + }) + const binding = await extension.call(0) + extension.send({ type: 'toolResult', id: binding.id, payload: binding.payload.args }) + const result = await first.reply(1) + expect(result.result?.isError).not.toBe(true) + expect(available).toHaveBeenCalledWith(expect.objectContaining({ hostContext: context })) + // Host filesystem paths never cross the public MCP or extension contract. + expect(JSON.stringify(result)).not.toContain(context.home) + expect(JSON.stringify(binding)).not.toContain(context.home) + first.socket.destroy() + await once(first.socket, 'close') + second.send({ + id: 1, + method: 'tools/call', + params: { name: 'list_design_sessions', arguments: {}, _meta } + }) + expect((await second.reply(1)).result?.isError).not.toBe(true) + expect(attach).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ sessionId: 'same-thread' }), + undefined, + other + ) + expect( + new DesignTaskStore(`${fixture.directory}/state/design-tasks.json`).load()?.records[0] + ?.hostContext + ).toEqual(context) + }) + it('deduplicates a shared state and asset directory', async () => { vi.mocked(resolveStateDir).mockReturnValue(`${fixture.directory}/assets`) const { startHub } = await loadRuntime() @@ -377,6 +740,64 @@ describe('Hub runtime module', () => { } ) + it('buffers a fragmented first frame once and preserves split UTF-8 and following frames', async () => { + const { startHub } = await loadRuntime() + await startHub() + const server = vi.mocked(createServer).mock.results.at(-1)!.value as Server + const accepted = once(server, 'connection') + const socket = connect(fixture.socketPath) + consumerSockets.push(socket) + const [incoming] = (await accepted) as [Socket] + const replies: { id: number; result?: unknown }[] = [] + let received = '' + socket.on('data', (chunk) => { + received += String(chunk) + let newline: number + while ((newline = received.indexOf('\n')) !== -1) { + replies.push(JSON.parse(received.slice(0, newline))) + received = received.slice(newline + 1) + } + }) + const frames = Buffer.from( + [ + { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: '审阅', version: '1' } + } + }, + { jsonrpc: '2.0', id: 1, method: 'ping' } + ] + .map((frame) => JSON.stringify(frame) + '\n') + .join('') + ) + const split = frames.indexOf('审') + 1 + const fragments = [ + ...Array.from({ length: 64 }, () => Buffer.alloc(1024, 32)), + frames.subarray(0, split), + frames.subarray(split) + ] + const concat = vi.spyOn(Buffer, 'concat') + // Deliver deterministic fragments at the real socket boundary; TCP/pipe + // coalescing must not decide whether repeated-copy regressions are covered. + for (const fragment of fragments) incoming.emit('data', fragment) + const copied = concat.mock.calls.reduce( + (sum, [buffers]) => sum + buffers.reduce((bytes, buffer) => bytes + buffer.length, 0), + 0 + ) + concat.mockRestore() + expect(copied).toBeLessThanOrEqual( + fragments.reduce((bytes, buffer) => bytes + buffer.length, 0) + ) + await vi.waitFor(() => expect(replies.map(({ id }) => id)).toEqual([0, 1])) + expect(replies.every((reply) => reply.result !== undefined)).toBe(true) + expect(log.info).toHaveBeenCalledWith('Consumer connected. Total: 1') + }) + it('rejects invalid hook parameters before lifecycle effects and does not allow hook transport promotion', async () => { const { startHub } = await loadRuntime() await startHub() @@ -558,6 +979,73 @@ describe('Hub runtime module', () => { } ) + it('requires a recovered generation acknowledgment through the public begin tool', async () => { + const generation = '00000000-0000-4000-8000-000000000001' + mkdirSync(`${fixture.directory}/state`, { recursive: true }) + writeFileSync( + `${fixture.directory}/state/design-tasks.json`, + JSON.stringify({ + revision: 0, + current: {}, + records: [{ recoveryGeneration: generation }] + }) + ) + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + const args = { + action: 'begin', + title: 'Fresh after recovery', + requestId: '00000000-0000-4000-8000-000000000002' + } + const send = (id: number, arguments_: object) => + consumer.send({ + id, + method: 'tools/call', + params: { name: 'manage_design_task', arguments: arguments_ } + }) + send(1, args) + expect(await consumer.reply(1)).toMatchObject({ + result: { + isError: true, + content: [{ text: expect.stringContaining(generation) }], + _meta: { code: 'DESIGN_TASK_INACTIVE', recoveryGeneration: generation } + } + }) + expect(extension.calls).toHaveLength(0) + const fresh = { + ...args, + requestId: '00000000-0000-4000-8000-000000000003', + recoveryGeneration: generation + } + send(2, fresh) + await vi.waitFor(() => expect(extension.calls.length || consumer.replies.has(2)).toBeTruthy(), { + interval: 1 + }) + expect(extension.calls).toHaveLength(1) + const binding = extension.calls[0]! + expect(binding.payload.args).toMatchObject({ status: 'active', needsRead: true }) + extension.send({ type: 'toolResult', id: binding.id, payload: binding.payload.args }) + expect((await consumer.reply(2)).result?.isError).not.toBe(true) + for (const [id, recoveryGeneration] of [ + [3, undefined], + [4, '00000000-0000-4000-8000-000000000004'] + ] as const) { + send(id, { ...fresh, recoveryGeneration }) + expect(await consumer.reply(id)).toMatchObject({ + result: { + isError: true, + content: [{ text: expect.stringContaining(generation) }], + _meta: { recoveryGeneration: generation } + } + }) + } + send(5, fresh) + expect((await consumer.reply(5)).result).toEqual((await consumer.reply(2)).result) + expect(extension.calls).toHaveLength(1) + }) + it('distinguishes begin content conflicts without changing the original task or its Stop fence', async () => { const { startHub } = await loadRuntime() await startHub() @@ -651,14 +1139,27 @@ describe('Hub runtime module', () => { send(4, 'apply_canvas', write) const blocked = await consumer.reply(4) expect(blocked.result?.isError).toBe(true) - expect(JSON.stringify(blocked)).toContain('Read the bound canvas') + expect(JSON.stringify(blocked)).toContain('The resumed task has not confirmed') + expect(JSON.stringify(blocked).match(/get_structure/g)).toHaveLength(2) + expect(JSON.stringify(blocked).match(/Canvas writes still require readback/g)).toHaveLength(1) expect(extension.calls).toHaveLength(2) + for (const [index, outcome] of [ + { payload: { roots: [] } }, + { error: { code: 'NODE_NOT_VISIBLE', message: 'Node was removed.' } } + ].entries()) { + send(50 + index, 'get_structure', { taskId: task.taskId, taskEpoch: 1 }) + const emptyRead = await extension.call(2 + index) + extension.send({ type: 'toolResult', id: emptyRead.id, ...outcome }) + expect(JSON.stringify(await consumer.reply(50 + index))).toContain( + 'Canvas writes still require readback' + ) + } send(5, 'get_structure', { taskId: task.taskId, taskEpoch: 1 }) - const read = await extension.call(2) - extension.send({ type: 'toolResult', id: read.id, payload: { roots: [] } }) + const read = await extension.call(4) + extension.send({ type: 'toolResult', id: read.id, payload: structure }) expect((await consumer.reply(5)).result?.isError).not.toBe(true) send(6, 'apply_canvas', write) - const update = await extension.call(3) + const update = await extension.call(5) extension.send({ type: 'toolResult', id: update.id, @@ -752,6 +1253,7 @@ describe('Hub runtime module', () => { vi.mocked(createAssetHttpServer).mockReturnValue({ start: assetStart, stop: assetStop, + pendingTransfers: 0, getBaseUrl: () => `http://127.0.0.1:${address.port}` } as ReturnType) const { startHub } = await loadRuntime() @@ -1095,7 +1597,20 @@ describe('Hub runtime module', () => { } ) - it('keeps a dispatched cancelled write fenced until its late definitive result arrives', async () => { + it.each([ + 'success', + undefined, + 'UNKNOWN_NATIVE_ERROR', + 'INVALID_CANVAS_SPEC', + 'CANVAS_READ_ONLY', + 'CANVAS_APPLY_ABORTED', + 'CANVAS_APPLY_FAILED', + 'EXTENSION_RESULT_LOST', + 'EXTENSION_DELIVERY_FAILED', + 'EXTENSION_DISCONNECTED' + ])('requires readback only for an unknown write outcome after %s', async (code) => { + const readRequired = + code !== 'success' && code !== 'CANVAS_APPLY_ABORTED' && code !== 'EXTENSION_DELIVERY_FAILED' const { startHub } = await loadRuntime() await startHub() const extension = connectExtension() @@ -1106,43 +1621,737 @@ describe('Hub runtime module', () => { } consumer.send({ id: 1, method: 'tools/call', params: write }) const first = await extension.call(0) - expect(first.route).toMatchObject({ sessionId: 'session', fileKey: 'file' }) + extension.send({ + type: 'toolResult', + id: first.id, + ...(code === 'success' + ? { + payload: { + rootNodeId: 'root', + nodeIdsByKey: {}, + createdNodeIds: [], + updatedNodeIds: [], + removedNodeIds: [], + mutationCount: 0, + verification: { + status: 'passed', + nodesChecked: 1, + referencesChecked: 0, + warnings: [] + } + } + } + : { error: { code, message: 'Native error' } }) + }) + const result = await consumer.reply(1) + if (code === 'success') expect(result.result?.isError).not.toBe(true) + else + expect(JSON.stringify(result)).toContain( + code === 'EXTENSION_DISCONNECTED' ? 'outcome is unknown' : 'Native error' + ) + consumer.send({ id: 2, method: 'tools/call', params: write }) + if (code === 'EXTENSION_DISCONNECTED') { + expect(JSON.stringify(await consumer.reply(2))).toContain('DESIGN_TASK_BUSY') + expect(extension.calls).toHaveLength(1) + extension.send({ + type: 'toolResult', + id: first.id, + error: { message: 'Definitive completion' } + }) + } + if (readRequired) { + const blockedId = code === 'EXTENSION_DISCONNECTED' ? 3 : 2 + if (blockedId === 3) consumer.send({ id: blockedId, method: 'tools/call', params: write }) + const blocked = JSON.stringify(await consumer.reply(blockedId)) + expect(blocked).toContain('previous write did not confirm') + expect(blocked.match(/get_structure/g)).toHaveLength(2) + expect(blocked.match(/Canvas writes still require readback/g)).toHaveLength(1) + consumer.send({ + id: 4, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const read = await extension.call(1) + extension.send({ type: 'toolResult', id: read.id, payload: structure }) + await consumer.reply(4) + consumer.send({ id: 5, method: 'tools/call', params: write }) + } + const next = await extension.call(readRequired ? 2 : 1) + extension.send({ + type: 'toolResult', + id: next.id, + error: { message: 'Next write completed' } + }) + expect(JSON.stringify(await consumer.reply(readRequired ? 5 : 2))).toContain( + 'Next write completed' + ) + }) + + it('does not require readback when the transport rejects a write before dispatch', async () => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + extension.peer.send.mockImplementationOnce(() => { + throw new Error('Socket is closed') + }) + consumer.send({ id: 1, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(1))).toContain('Socket is closed') + expect(extension.calls).toHaveLength(0) + consumer.send({ id: 2, method: 'tools/call', params: write }) + const next = await extension.call(0) + extension.send({ type: 'toolResult', id: next.id, error: { message: 'Native result' } }) + expect(JSON.stringify(await consumer.reply(2))).toContain('Native result') + }) + + it('serves operator recovery without exposing a tool or keeping an otherwise idle Hub alive', async () => { + const { startHub } = await loadRuntime() + await startHub() + const connectRecovery = async () => { + const socket = connect(fixture.socketPath) + consumerSockets.push(socket) + await once(socket, 'connect') + return socket + } + const io = { interactive: false, write: vi.fn(), confirm: vi.fn(async () => '') } + await runRuntimeRecovery(['--runtime-status'], connectRecovery, io) + expect(io.write.mock.calls[0]?.[0]).toContain('"blocks": []') + await vi.waitFor(() => expect(websocket.close).toHaveBeenCalled()) + }) + + it('does not dispatch a write when its read requirement cannot be saved', async () => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + vi.spyOn(DesignTaskStore.prototype, 'save').mockImplementationOnce(() => { + throw new Error('Disk full') + }) + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + consumer.send({ id: 1, method: 'tools/call', params: write }) + const failure = JSON.stringify(await consumer.reply(1)) + expect(failure).toContain('DESIGN_TASK_INACTIVE') + expect(failure).toContain('No canvas write was dispatched') + expect(extension.calls).toHaveLength(0) + consumer.send({ id: 2, method: 'tools/call', params: write }) + const next = await extension.call(0) + extension.send({ type: 'toolResult', id: next.id, error: { message: 'Native result' } }) + expect(JSON.stringify(await consumer.reply(2))).toContain('Native result') + }) + + it.each(['in-flight', 'cancelled', 'rollback-failed'] as const)( + 'persists a %s write before dispatch and requires readback after Hub restart', + async (state) => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + const readMarker = { readRequired: true, task: { target: { fileKey: 'file' } } } + const snapshot = () => + JSON.parse(readFileSync(`${fixture.directory}/state/design-tasks.json`, 'utf8')) + consumer.send({ id: 1, method: 'tools/call', params: write }) + const dispatched = await extension.call(0) + expect(snapshot().records).toContainEqual(readMarker) + const reader = await connectConsumer(false, 'another-reader') + reader.send({ + id: 6, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + expect(JSON.stringify(await reader.reply(6))).toContain('DESIGN_TASK_BUSY') + expect(extension.calls).toHaveLength(1) + expect(snapshot().records).toContainEqual(readMarker) + if (state === 'cancelled') { + consumer.send({ method: 'notifications/cancelled', params: { requestId: 1 } }) + consumer.send({ id: 2, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(2))).toContain('DESIGN_TASK_BUSY') + extension.send({ + type: 'toolResult', + id: dispatched.id, + error: { message: 'Late definitive completion' } + }) + } + if (state === 'rollback-failed') { + extension.send({ + type: 'toolResult', + id: dispatched.id, + error: { + code: 'CANVAS_APPLY_FAILED', + message: 'Canvas apply failed; automatic rollback was not available.' + } + }) + expect(JSON.stringify(await consumer.reply(1))).toContain('CANVAS_APPLY_FAILED') + expect(snapshot().records).toContainEqual(readMarker) + consumer.send({ id: 2, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(2))).toContain('previous write did not confirm') + expect(extension.calls).toHaveLength(1) + } + await stopRuntime() + expect(snapshot().records).toContainEqual(readMarker) + + websocket = new WebSocketServer({ noServer: true }) + vi.spyOn(websocket, 'close') + vi.mocked(startExtensionWebSocketServer).mockResolvedValue({ server: websocket, port: 6220 }) + await startHub() + const replacement = connectExtension() + const nextOwner = await connectConsumer() + nextOwner.send({ id: 3, method: 'tools/call', params: write }) + expect(JSON.stringify(await nextOwner.reply(3))).toContain( + state === 'in-flight' ? 'earlier operation' : 'previous write did not confirm' + ) + expect(replacement.calls).toHaveLength(0) + nextOwner.send({ + id: 4, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const read = await replacement.call(0) + replacement.send({ type: 'toolResult', id: read.id, payload: structure }) + expect((await nextOwner.reply(4)).result?.isError).not.toBe(true) + if (state === 'in-flight') { + expect(snapshot().records).toContainEqual(readMarker) + nextOwner.send({ id: 40, method: 'tools/call', params: write }) + expect(JSON.stringify(await nextOwner.reply(40))).toContain('earlier operation') + replacement.publishSession('session', 'replacement-document') + nextOwner.send({ + id: 41, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const reread = await replacement.call(1) + replacement.send({ type: 'toolResult', id: reread.id, payload: structure }) + expect((await nextOwner.reply(41)).result?.isError).not.toBe(true) + } + expect(snapshot().records).not.toContainEqual(readMarker) + nextOwner.send({ id: 5, method: 'tools/call', params: write }) + const next = await replacement.call(state === 'in-flight' ? 2 : 1) + replacement.send({ + type: 'toolResult', + id: next.id, + error: { code: 'CANVAS_APPLY_ABORTED', message: 'Definitive preflight rejection' } + }) + expect(JSON.stringify(await nextOwner.reply(5))).toContain('Definitive preflight rejection') + expect(snapshot().records).not.toContainEqual(readMarker) + } + ) + + it.each([ + { name: 'empty selection', tool: 'get_structure', payload: { roots: [] } }, + { + name: 'failed roots with continuation', + tool: 'get_structure', + payload: { + roots: [], + errors: [{ nodeId: 'gone', error: { message: 'Missing node' } }], + remainingNodeIds: ['remaining'] + } + }, + { + name: 'failed code with continuation', + tool: 'get_code', + payload: { + results: [{ nodeId: 'gone', error: { message: 'Missing node' } }], + remainingNodeIds: ['remaining'] + } + }, + { + name: 'thrown node read', + tool: 'get_structure', + error: { code: 'NODE_NOT_VISIBLE', message: 'Node was removed.' } + }, + { + name: 'oversized thrown node read', + tool: 'get_code', + error: { message: 'x'.repeat(64 * 1024) } + }, + { + name: 'oversized code result', + tool: 'get_code', + responseError: true, + payload: { code: 'x'.repeat(64 * 1024), lang: 'jsx', nodeId: 'root' } + }, + { + name: 'oversized failed-node result', + tool: 'get_code', + responseError: true, + payload: { results: [{ nodeId: 'gone', error: { message: 'x'.repeat(64 * 1024) } }] } + }, + { + name: 'unformattable code result', + tool: 'get_code', + responseError: true, + payload: { code: '
', lang: 42 } + } + ])('keeps readback required after $name', async ({ tool, payload, error, responseError }) => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + consumer.send({ id: 1, method: 'tools/call', params: write }) + const first = await extension.call(0) + extension.send({ type: 'toolResult', id: first.id, payload: { rootNodeId: 'unusable' } }) + expect((await consumer.reply(1)).result?.isError).toBe(true) consumer.send({ - method: 'notifications/cancelled', - params: { requestId: 1, reason: 'User cancelled' } + id: 2, + method: 'tools/call', + params: { name: tool, arguments: {} } }) - await vi.waitFor( - () => - expect(log.error).toHaveBeenCalledWith( - expect.objectContaining({ req: first.id, message: expect.stringContaining('cancelled') }), - 'Tool invocation failed.' - ), - { interval: 1 } + const emptyRead = await extension.call(1) + extension.send({ type: 'toolResult', id: emptyRead.id, ...(error ? { error } : { payload }) }) + const response = await consumer.reply(2) + expect(!!response.result?.isError).toBe(!!error || !!responseError) + expect(JSON.stringify(response)).toContain('Canvas writes still require readback') + expect(Buffer.byteLength(JSON.stringify(response.result))).toBeLessThan(64 * 1024) + consumer.send({ id: 3, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(3))).toContain('previous write did not confirm') + expect(extension.calls).toHaveLength(2) + + // An exact empty page is valid evidence, unlike an empty selection. + consumer.send({ + id: 4, + method: 'tools/call', + params: { name: 'get_structure', arguments: { pageId: 'page' } } + }) + const pageRead = await extension.call(2) + extension.send({ + type: 'toolResult', + id: pageRead.id, + payload: { + roots: [], + page: { + id: 'page', + name: 'Empty page', + index: 0, + active: true, + childCount: 0, + selectionCount: 0 + } + } + }) + expect(JSON.stringify(await consumer.reply(4))).not.toContain( + 'Canvas writes still require readback' ) + consumer.send({ id: 5, method: 'tools/call', params: write }) + const next = await extension.call(3) + extension.send({ type: 'toolResult', id: next.id, error: { message: 'Write completed' } }) + expect(JSON.stringify(await consumer.reply(5))).toContain('Write completed') + }) + + it('keeps empty reads quiet when no canvas readback is required', async () => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + consumer.send({ + id: 1, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const read = await extension.call(0) + extension.send({ type: 'toolResult', id: read.id, payload: { roots: [] } }) + const response = await consumer.reply(1) + expect(response.result?.isError).not.toBe(true) + expect(JSON.stringify(response)).not.toContain('Canvas writes still require readback') + }) + + it.each(['malformed', 'oversized'])( + 'requires readback after a completed write returns an unusable result (%s)', + async (kind) => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + consumer.send({ id: 1, method: 'tools/call', params: write }) + const first = await extension.call(0) + extension.send({ + type: 'toolResult', + id: first.id, + payload: + kind === 'malformed' + ? { rootNodeId: 'created' } + : { + rootNodeId: 'created', + nodeIdsByKey: {}, + createdNodeIds: ['created'], + updatedNodeIds: [], + removedNodeIds: [], + mutationCount: 1, + verification: { + status: 'warning', + nodesChecked: 1, + referencesChecked: 0, + warnings: [{ code: 'large-warning', message: 'x'.repeat(70_000) }] + } + } + }) + const response = (await consumer.reply(1)).result + expect(CallToolResultSchema.parse(response)).toMatchObject({ isError: true }) + expect(response).not.toHaveProperty('structuredContent') + if (kind === 'oversized') { + expect(JSON.stringify(response)).toContain('Read the original target') + expect(JSON.stringify(response)).toContain('do not replay the write') + } + consumer.send({ id: 2, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(2))).toContain('previous write did not confirm') + expect(extension.calls).toHaveLength(1) + consumer.send({ + id: 3, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const read = await extension.call(1) + extension.send({ type: 'toolResult', id: read.id, payload: structure }) + await consumer.reply(3) + consumer.send({ id: 4, method: 'tools/call', params: write }) + const next = await extension.call(2) + extension.send({ type: 'toolResult', id: next.id, error: { message: 'Native result' } }) + expect(JSON.stringify(await consumer.reply(4))).toContain('Native result') + } + ) + + it.each([false, true])( + 'recovers a lost write result without reviving Stop (%s)', + async (stopped) => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension(true) + const consumer = await connectConsumer() + const send = (id: number, name: string, args: object) => + consumer.send({ id, method: 'tools/call', params: { name, arguments: args } }) + send(1, 'manage_design_task', { + action: 'begin', + title: 'Receipt fixture', + requestId: '00000000-0000-4000-8000-000000000017' + }) + const binding = await extension.call(0) + const task = binding.payload.args as DesignTask + extension.send({ type: 'toolResult', id: binding.id, payload: task }) + await consumer.reply(1) + const args = { taskId: task.taskId, taskEpoch: task.epoch } + const write = { ...args, mode: 'create', markup: '
' } + send(2, 'apply_canvas', write) + const call = await extension.call(1) + expect(call.terminalReceipt).toBeDefined() + extension.publishSession() // Idle alone must not release this write. + send(3, 'apply_canvas', write) + expect(JSON.stringify(await consumer.reply(3))).toContain('DESIGN_TASK_BUSY') + if (stopped) { + send(7, 'manage_design_task', { action: 'cancel', taskId: task.taskId, epoch: task.epoch }) + expect(await consumer.reply(7)).toMatchObject({ + result: { structuredContent: { status: 'stopping' } } + }) + } + extension.send({ type: 'toolCompletion', id: call.id, ...call.terminalReceipt }) + expect(JSON.stringify(await consumer.reply(2))).toContain('EXTENSION_RESULT_LOST') + if (stopped) { + send(8, 'get_design_task', { taskId: task.taskId }) + expect(await consumer.reply(8)).toMatchObject({ + result: { structuredContent: { status: 'cancelled', operation: null } } + }) + send(9, 'apply_canvas', write) + expect(JSON.stringify(await consumer.reply(9))).toContain('DESIGN_TASK_INACTIVE') + expect(extension.calls).toHaveLength(2) + + send(10, 'manage_design_task', { + action: 'begin', + title: 'Fresh after Stop', + requestId: '00000000-0000-4000-8000-000000000018' + }) + const newBinding = await extension.call(2) + const fresh = newBinding.payload.args as DesignTask + extension.send({ type: 'toolResult', id: newBinding.id, payload: fresh }) + await consumer.reply(10) + const freshArgs = { taskId: fresh.taskId, taskEpoch: fresh.epoch } + send(11, 'apply_canvas', { ...write, ...freshArgs }) + expect(JSON.stringify(await consumer.reply(11))).toContain('previous write did not confirm') + expect(extension.calls).toHaveLength(3) + send(12, 'get_structure', freshArgs) + const read = await extension.call(3) + extension.send({ type: 'toolResult', id: read.id, payload: structure }) + await consumer.reply(12) + send(13, 'apply_canvas', { ...write, ...freshArgs }) + const next = await extension.call(4) + extension.send({ + type: 'toolResult', + id: next.id, + error: { message: 'Fresh write completed' } + }) + expect(JSON.stringify(await consumer.reply(13))).toContain('Fresh write completed') + return + } + send(4, 'apply_canvas', write) + expect(JSON.stringify(await consumer.reply(4))).toContain( + 'The resumed task has not confirmed' + ) + // The duplicate receipt must not clear a fresh read or re-arm needsRead. + send(5, 'get_structure', args) + const read = await extension.call(2) + extension.send({ type: 'toolResult', id: read.id, payload: structure }) + await consumer.reply(5) + extension.send({ type: 'toolCompletion', id: call.id, ...call.terminalReceipt }) + send(6, 'apply_canvas', write) + const next = await extension.call(3) + extension.send({ type: 'toolResult', id: next.id, error: { message: 'Finished new write' } }) + expect(JSON.stringify(await consumer.reply(6))).toContain('Finished new write') + } + ) + + it('does not exhaust receipt capacity after repeated disconnected reads in one document', async () => { + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension(true) + const consumer = await connectConsumer() + for (let id = 1; id <= 129; id++) { + consumer.send({ + id, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + await vi.waitFor( + () => expect(extension.calls.length >= id || consumer.replies.has(id)).toBe(true), + { + interval: 1 + } + ) + expect(extension.calls).toHaveLength(id) + const call = extension.calls[id - 1]! + extension.send({ + type: 'toolResult', + id: call.id, + error: { code: 'EXTENSION_DISCONNECTED', message: 'Page read disconnected.' } + }) + expect(JSON.stringify(await consumer.reply(id))).toContain('EXTENSION_DISCONNECTED') + } + consumer.send({ + id: 130, + method: 'tools/call', + params: { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + }) + const write = await extension.call(129) + extension.send({ type: 'toolResult', id: write.id, error: { message: 'Write finished' } }) + expect(JSON.stringify(await consumer.reply(130))).toContain('Write finished') + }) + + it.each(['transport', 'malformed', 'roots', 'code'])( + 'keeps a recovered write unread after a %s read failure', + async (failure) => { + const { startHub } = await loadRuntime() + await startHub() + const original = connectExtension(true) + const consumer = await connectConsumer() + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + consumer.send({ id: 1, method: 'tools/call', params: write }) + const call = await original.call(0) + original.peer.emit('close') + expect(JSON.stringify(await consumer.reply(1))).toContain('EXTENSION_DISCONNECTED') + const replacement = connectExtension(true) + replacement.send({ + type: 'toolCompletion', + id: call.id, + ...call.terminalReceipt, + receiptId: 'wrong' + }) + consumer.send({ id: 2, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(2))).toContain('DESIGN_TASK_BUSY') + replacement.send({ type: 'toolCompletion', id: call.id, ...call.terminalReceipt }) + consumer.send({ id: 3, method: 'tools/call', params: write }) + await vi.waitFor( + () => expect(replacement.calls.length || consumer.replies.has(3)).toBeTruthy(), + { + interval: 1 + } + ) + expect(replacement.calls).toHaveLength(0) + expect(JSON.stringify(await consumer.reply(3))).toContain('get_structure or get_code') + + consumer.send({ + id: 4, + method: 'tools/call', + params: { name: 'get_structure', arguments: { sessionId: 'other-session' } } + }) + const otherFile = await replacement.call(0) + replacement.send({ type: 'toolResult', id: otherFile.id, payload: { roots: [] } }) + await consumer.reply(4) + consumer.send({ id: 5, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(5))).toContain('get_structure or get_code') + + consumer.send({ + id: 6, + method: 'tools/call', + params: { name: failure === 'code' ? 'get_code' : 'get_structure', arguments: {} } + }) + const failedRead = await replacement.call(1) + const error = { message: 'Read failed' } + replacement.send({ + type: 'toolResult', + id: failedRead.id, + ...(failure === 'transport' + ? { error } + : { + payload: + failure === 'malformed' + ? { + roots: 'Invalid roots', + content: [{ type: 'text', text: 'Invalid structured result' }], + structuredContent: { invalid: true } + } + : failure === 'roots' + ? { roots: [], errors: [{ nodeId: 'root', error }] } + : { results: [{ nodeId: 'root', error }] } + }) + }) + const failedResponse = await consumer.reply(6) + expect(failedResponse).toMatchObject({ result: { isError: true } }) + if (failure === 'malformed') { + expect(failedResponse.result).not.toHaveProperty('structuredContent') + expect(JSON.stringify(failedResponse)).toContain('Invalid roots') + } + consumer.send({ id: 7, method: 'tools/call', params: write }) + expect(JSON.stringify(await consumer.reply(7))).toContain('get_structure or get_code') + + consumer.send({ + id: 8, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const read = await replacement.call(2) + replacement.send({ type: 'toolResult', id: read.id, payload: structure }) + await consumer.reply(8) + // Repeated completion cannot re-arm the read requirement after a successful read. + replacement.send({ type: 'toolCompletion', id: call.id, ...call.terminalReceipt }) + consumer.send({ id: 9, method: 'tools/call', params: write }) + const next = await replacement.call(3) + replacement.send({ + type: 'toolResult', + id: next.id, + error: { message: 'New call completed' } + }) + expect(JSON.stringify(await consumer.reply(9))).toContain('New call completed') + replacement.send({ type: 'toolCompletion', id: call.id, ...call.terminalReceipt }) + expect(JSON.parse(replacement.peer.send.mock.calls.at(-1)![0])).toEqual({ + type: 'toolCompletionAck', + id: call.id, + ...call.terminalReceipt, + accepted: true + }) + } + ) + + it.each([ + ['cancel', 'error'], + ['cancel', 'success'], + ['timeout', 'error'], + ['timeout', 'success'] + ] as const)('requires a read after %s and a late definite %s result', async (ending, outcome) => { + vi.stubEnv('TEMPAD_MCP_APPLY_CANVAS_TIMEOUT', '100') + const { startHub } = await loadRuntime() + await startHub() + const extension = connectExtension() + const consumer = await connectConsumer() + const write = { + name: 'apply_canvas', + arguments: { mode: 'create', markup: '
' } + } + consumer.send({ id: 1, method: 'tools/call', params: write }) + const first = await extension.call(0) + expect(first.route).toMatchObject({ sessionId: 'session', fileKey: 'file' }) + if (ending === 'cancel') { + consumer.send({ + method: 'notifications/cancelled', + params: { requestId: 1, reason: 'User cancelled' } + }) + await vi.waitFor( + () => + expect(log.error).toHaveBeenCalledWith( + expect.objectContaining({ + req: first.id, + message: expect.stringContaining('cancelled') + }), + 'Tool invocation failed.' + ), + { interval: 1 } + ) + expect(consumer.replies.has(1)).toBe(false) + } else { + await vi.advanceTimersByTimeAsync(200) + expect(JSON.stringify(await consumer.reply(1))).toContain('EXTENSION_TIMEOUT') + } extension.publishSession() consumer.send({ id: 2, method: 'tools/call', params: write }) const blocked = await consumer.reply(2) expect(blocked.result?.isError).toBe(true) expect(JSON.stringify(blocked)).toContain('DESIGN_TASK_BUSY') expect(extension.calls).toHaveLength(1) - expect(consumer.replies.has(1)).toBe(false) extension.send({ type: 'toolResult', id: first.id, - error: { message: 'Native operation definitively failed' } + ...(outcome === 'error' + ? { error: { message: 'Native operation definitively failed' } } + : { + payload: { + rootNodeId: 'created', + nodeIdsByKey: {}, + createdNodeIds: ['created'], + updatedNodeIds: [], + removedNodeIds: [], + mutationCount: 1, + verification: { + status: 'passed', + nodesChecked: 1, + referencesChecked: 0, + warnings: [] + } + } + }) }) expect(log.warn).toHaveBeenCalledWith( { reqId: first.id }, - 'Received error for unknown/timed-out call.' + `Received ${outcome === 'error' ? 'error' : 'result'} for unknown/timed-out call.` ) consumer.send({ id: 3, method: 'tools/call', params: write }) - const next = await extension.call(1) + expect(JSON.stringify(await consumer.reply(3))).toContain('previous write did not confirm') + expect(extension.calls).toHaveLength(1) + consumer.send({ + id: 4, + method: 'tools/call', + params: { name: 'get_structure', arguments: {} } + }) + const read = await extension.call(1) + extension.send({ type: 'toolResult', id: read.id, payload: structure }) + await consumer.reply(4) + consumer.send({ id: 5, method: 'tools/call', params: write }) + const next = await extension.call(2) extension.send({ type: 'toolResult', id: next.id, error: { message: 'Definitive second result' } }) - expect(JSON.stringify(await consumer.reply(3))).toContain('Definitive second result') + expect(JSON.stringify(await consumer.reply(5))).toContain('Definitive second result') }) it('releases a cancelled operation after its native document is replaced', async () => { diff --git a/packages/mcp-server/tests/native-runtime-fixture.test.ts b/packages/mcp-server/tests/native-runtime-fixture.test.ts new file mode 100644 index 00000000..f196123f --- /dev/null +++ b/packages/mcp-server/tests/native-runtime-fixture.test.ts @@ -0,0 +1,206 @@ +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { MCP_PORT_CANDIDATES } from '../../shared/src/mcp/constants' + +const sockets = vi.hoisted(() => ({ + occupied: 0, + opened: [] as number[], + closed: [] as number[] +})) +vi.mock('node:net', () => ({ + createServer: () => { + let onError: (error: Error) => void + let port: number + return { + once: (_event: string, callback: (error: Error) => void) => { + onError = callback + }, + listen: ( + options: { port: number; host: string; exclusive: boolean }, + callback: () => void + ) => { + expect(options.host).toBe('127.0.0.1') + expect(options.exclusive).toBe(true) + port = options.port + if (port === sockets.occupied) onError(new Error('EADDRINUSE')) + else { + sockets.opened.push(port) + callback() + } + }, + close: (callback: () => void) => { + sockets.closed.push(port) + callback() + } + } + } +})) + +const modulePath = '../scripts/native-runtime-fixture.mjs' +const { fixtureEnvironment, validateFixture, fixtureResponse, assertReleasePortsFree } = + await import(modulePath) + +function descriptor() { + const root = '/tmp/tp-native-fixture-test' + const release = (version: string) => ({ + version, + directory: join(root, 'packages', version), + archive: join(root, 'archives', `mcp-${version}.tgz`), + integrity: `sha512-${'A'.repeat(86)}==` + }) + return { + version: 1, + kind: 'native-runtime-fixture', + testOnly: true, + checkout: '/checkout', + root, + sourceHash: 'a'.repeat(64), + registryUrl: 'http://127.0.0.1:48273', + releasePorts: MCP_PORT_CANDIDATES, + env: fixtureEnvironment(root), + initial: release('0.12.0'), + candidate: release('0.12.1') + } +} + +describe('native runtime fixture isolation', () => { + beforeEach(() => { + sockets.occupied = 0 + sockets.opened = [] + sockets.closed = [] + }) + + it('keeps all runtime directories private without supplying host identity', () => { + const fixture = descriptor() + expect(validateFixture(fixture, fixture.root)).toBe(fixture) + expect(fixture.env).toEqual({ + TEMPAD_MCP_RUNTIME_DIR: `${fixture.root}/run`, + TEMPAD_MCP_STATE_DIR: `${fixture.root}/state`, + TEMPAD_MCP_ASSET_DIR: `${fixture.root}/assets`, + TEMPAD_MCP_LOG_DIR: `${fixture.root}/logs` + }) + }) + + it.each([ + 'https://registry.npmjs.org', + 'http://localhost:48273', + 'http://127.0.0.1:6220', + 'http://127.0.0.1:48273/path', + 'http://user@127.0.0.1:48273' + ])('rejects a registry outside its dedicated loopback origin: %s', (registryUrl) => { + const fixture = { ...descriptor(), registryUrl } + expect(() => validateFixture(fixture, fixture.root)).toThrow() + }) + + it('rejects escaped artifact paths, state directories, host identity, or changed release ports', () => { + const cases = [ + (value: ReturnType) => { + value.initial.directory = '/outside' + }, + (value: ReturnType) => { + value.candidate.archive = '/outside.tgz' + }, + (value: ReturnType) => { + value.env.TEMPAD_MCP_STATE_DIR = '/outside' + }, + (value: ReturnType) => { + value.env.CODEX_THREAD_ID = 'spoofed' + }, + (value: ReturnType) => { + value.releasePorts = [0] + }, + (value: ReturnType) => { + value.testOnly = false + } + ] + for (const change of cases) { + const fixture = descriptor() + change(fixture) + expect(() => validateFixture(fixture, fixture.root)).toThrow() + } + }) + + it('serves only the exact fixture versions and publishes the candidate as latest', () => { + const fixture = descriptor() + const catalog = fixtureResponse(fixture, 'GET', '/@tempad-dev%2fmcp') + expect(catalog).toEqual({ + status: 200, + metadata: { + name: '@tempad-dev/mcp', + 'dist-tags': { latest: fixture.candidate.version }, + versions: Object.fromEntries( + [fixture.initial, fixture.candidate].map((release) => [ + release.version, + fixtureResponse(fixture, 'GET', `/@tempad-dev%2fmcp/${release.version}`).metadata + ]) + ) + } + }) + expect(fixtureResponse(fixture, 'GET', '/@tempad-dev%2fmcp/latest')).toEqual({ + status: 200, + metadata: { + name: '@tempad-dev/mcp', + version: '0.12.1', + dist: { + integrity: fixture.candidate.integrity, + tarball: `${fixture.registryUrl}/@tempad-dev/mcp/-/mcp-0.12.1.tgz` + } + } + }) + expect(fixtureResponse(fixture, 'GET', '/@tempad-dev%2fmcp/0.12.0')).toMatchObject({ + status: 200, + metadata: { version: '0.12.0' } + }) + expect(fixtureResponse(fixture, 'GET', '/@tempad-dev/mcp/-/mcp-0.12.1.tgz')).toEqual({ + status: 200, + archive: fixture.candidate.archive + }) + expect(fixtureResponse(fixture, 'GET', '/@tempad-dev%2fmcp/0.12.2')).toEqual({ status: 404 }) + expect(fixtureResponse(fixture, 'POST', '/@tempad-dev%2fmcp/latest')).toEqual({ status: 405 }) + }) + + it('can hold the candidate until the native baseline has been observed', () => { + const fixture = descriptor() + expect(fixtureResponse(fixture, 'GET', '/@tempad-dev%2fmcp', false)).toEqual({ + status: 200, + metadata: { + name: '@tempad-dev/mcp', + 'dist-tags': { latest: fixture.initial.version }, + versions: { + [fixture.initial.version]: fixtureResponse( + fixture, + 'GET', + `/@tempad-dev%2fmcp/${fixture.initial.version}` + ).metadata + } + } + }) + expect(fixtureResponse(fixture, 'GET', '/@tempad-dev%2fmcp/latest', false)).toMatchObject({ + status: 200, + metadata: { version: fixture.initial.version } + }) + for (const path of [ + `/@tempad-dev%2fmcp/${fixture.candidate.version}`, + `/@tempad-dev/mcp/-/mcp-${fixture.candidate.version}.tgz` + ]) { + expect(fixtureResponse(fixture, 'GET', path, false)).toEqual({ status: 404 }) + expect(fixtureResponse(fixture, 'GET', path, true)).toMatchObject({ status: 200 }) + } + }) + + it('checks and releases the entire normal release discovery group', async () => { + await assertReleasePortsFree() + expect(sockets.opened).toEqual(MCP_PORT_CANDIDATES) + expect(sockets.closed).toEqual(MCP_PORT_CANDIDATES) + }) + + it('refuses an occupied release port and releases only its own earlier reservations', async () => { + sockets.occupied = MCP_PORT_CANDIDATES[1]! + await expect(assertReleasePortsFree()).rejects.toThrow( + 'Release discovery port 7431 is occupied' + ) + expect(sockets.opened).toEqual([MCP_PORT_CANDIDATES[0]]) + expect(sockets.closed).toEqual(sockets.opened) + }) +}) diff --git a/packages/mcp-server/tests/protocol-13.test.ts b/packages/mcp-server/tests/protocol-13.test.ts new file mode 100644 index 00000000..9fe44e55 --- /dev/null +++ b/packages/mcp-server/tests/protocol-13.test.ts @@ -0,0 +1,62 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' + +afterEach(() => vi.restoreAllMocks()) + +it.each([ + { + name: 'unnegotiated terminal receipt', + raw: JSON.stringify({ + type: 'toolCall', + id: 'call', + payload: { name: 'get_code', args: {} }, + terminalReceipt: { token: 'unexpected' } + }) + }, + { + name: 'unsupported completion acknowledgment', + raw: JSON.stringify({ type: 'toolCompletionAck' }) + }, + { name: 'malformed JSON', raw: '{' } +])('reports $name through the harness failure path', async ({ raw }) => { + const module = '../scripts/check-protocol-13.mjs' + const { checkProtocol13 } = await import(module) + const socket = new EventEmitter() + const failures: unknown[] = [] + const upload = Promise.withResolvers() + const fetch = vi.spyOn(globalThis, 'fetch').mockReturnValue(upload.promise) + const pending = checkProtocol13({ + port: 1234, + origin: 'chrome-extension://fixture', + peer: async (_port: number, _legacy: boolean, schema: { parse(value: unknown): unknown }) => { + // Match peer()'s guarded receive boundary; all failures must stay inside it. + socket.on('message', (raw: Buffer) => { + try { + schema.parse(JSON.parse(raw.toString())) + } catch (error) { + failures.push(error) + } + }) + return { + socket, + frames: [{ supportedProtocolVersions: [13] }], + assetServerUrl: 'http://fixture.invalid' + } + }, + send: vi.fn(), + activate: vi.fn(), + call: async () => { + throw failures[0] ?? new Error('Unexpected MCP request') + } + }).catch((error: unknown) => error) + + try { + await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce()) + expect(() => socket.emit('message', Buffer.from(raw))).not.toThrow() + expect(failures).toHaveLength(1) + } finally { + upload.resolve(new Response(null, { status: 201 })) + await pending + } + expect(await pending).toBe(failures[0]) +}) diff --git a/packages/mcp-server/tests/request.test.ts b/packages/mcp-server/tests/request.test.ts index dc2d0758..fafca63f 100644 --- a/packages/mcp-server/tests/request.test.ts +++ b/packages/mcp-server/tests/request.test.ts @@ -89,6 +89,28 @@ describe('mcp-server/request', () => { await vi.advanceTimersByTimeAsync(2000) await result }) + + it.each(['socket', 'page'] as const)( + 'warns against replaying a write after a %s disconnect', + async (source) => { + vi.mocked(nanoid).mockReturnValue('disconnected-write') + const { promise, requestId } = register('ext', 1000, { waitForDefinitiveResult: true }) + if (source === 'socket') cleanupForExtension('ext') + else + reject( + requestId, + 'ext', + Object.assign(new Error('The page port disconnected.'), { + code: TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED + }) + ) + await expect(promise).rejects.toMatchObject({ + code: TEMPAD_MCP_ERROR_CODES.EXTENSION_DISCONNECTED, + message: expect.stringMatching(/outcome is unknown.*Do not replay/) + }) + } + ) + it('registers and resolves a pending call', async () => { vi.mocked(nanoid).mockReturnValue('req-1') diff --git a/packages/mcp-server/tests/runtime-acl-heartbeat.test.ts b/packages/mcp-server/tests/runtime-acl-heartbeat.test.ts new file mode 100644 index 00000000..d9137a33 --- /dev/null +++ b/packages/mcp-server/tests/runtime-acl-heartbeat.test.ts @@ -0,0 +1,341 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { execFile, execFileSync, type ChildProcess, type ExecFileOptions } from 'node:child_process' +import { EventEmitter, once } from 'node:events' +import { lstatSync, readFileSync, realpathSync } from 'node:fs' +import { connect, createServer, type Server, type Socket } from 'node:net' +import { resolve } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { z } from 'zod' + +vi.mock('node:child_process', async (original) => { + const childProcess = await original() + return { + ...childProcess, + // Let shared.ts create real private directories before beforeEach installs serving-only fakes. + execFile: vi.fn(childProcess.execFile), + execFileSync: vi.fn(childProcess.execFileSync) + } +}) +vi.mock('node:net', async (original) => ({ + ...(await original()), + connect: vi.fn() +})) +vi.mock('node:fs', async (original) => { + const fs = await original() + return { + ...fs, + lstatSync: vi.fn(fs.lstatSync), + readFileSync: vi.fn(fs.readFileSync), + realpathSync: vi.fn(fs.realpathSync) + } +}) + +import { assertPrivatePathAsync } from '../src/runtime-platform' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { + connectSocket, + listenPrivate, + unavailableSocket, + waitForSocket +} from '../src/runtime-prototype/processes' +import { ControlPeer, LEASE_MS, RENEW_MS, SocketTransport } from '../src/runtime-prototype/protocol' +import { readPrivateJsonAsync } from '../src/runtime-prototype/state' + +const actualNet = await vi.importActual('node:net') +const cleanup: (() => Promise)[] = [] +let finishHelper: (error?: Error, stderr?: string) => void +let helperSignal: AbortSignal | undefined +let revision = 0n +let inode = 1n +beforeEach(() => { + vi.resetAllMocks() + revision++ + inode = 1n + const env = { ...process.env, SystemRoot: resolve('Windows') } + vi.stubGlobal( + 'process', + new Proxy(process, { + get(target, key, receiver) { + if (key === 'platform') return 'win32' + if (key === 'env') return env + return Reflect.get(target, key, receiver) + } + }) + ) + vi.mocked(connect).mockImplementation(actualNet.connect) + vi.mocked(realpathSync).mockImplementation((path) => resolve(String(path))) + vi.mocked(lstatSync).mockImplementation( + () => + ({ + dev: 1n, + ino: inode, + birthtimeNs: 1n, + ctimeNs: revision, + isSymbolicLink: () => false, + isFile: () => true, + isDirectory: () => false + }) as ReturnType + ) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Serving code used a blocking ACL helper') + }) + vi.mocked(execFile).mockImplementation((...args) => { + const callback = args.at(-1) as (error: Error | null, stdout: string, stderr: string) => void + helperSignal = (args[2] as ExecFileOptions).signal + finishHelper = (error, stderr = '') => callback(error ?? null, '', stderr) + helperSignal?.addEventListener( + 'abort', + () => callback(new Error('ACL helper cancelled'), '', ''), + { once: true } + ) + return { stdin: { end: vi.fn() } } as unknown as ChildProcess + }) +}) +afterEach(async () => { + vi.useRealTimers() + for (const close of cleanup.splice(0).reverse()) await close() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +async function controlLease() { + const control = new HubControl('secret', () => Date.now()) + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + void control.attach(socket) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + cleanup.push(async () => { + await control.close() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + const peer = new ControlPeer() + await peer.connect( + new SocketTransport(connect((server.address() as { port: number }).port, '127.0.0.1')) + ) + cleanup.push(() => peer.close()) + await peer.request( + { method: 'tempad/runtime-acquire', params: { secret: 'secret', versions: [2] } }, + LeaseSchema + ) + return { control, peer } +} + +function pipeSocket() { + const socket = Object.assign(new EventEmitter(), { + destroyed: false, + write: vi.fn(), + destroy: vi.fn(() => { + socket.destroyed = true + return socket + }) + }) + return socket as unknown as Socket +} + +function pipeListener() { + const admitted = vi.fn() + const server = Object.assign(new EventEmitter(), { + listen: vi.fn((_path, ready: () => void) => { + ready() + return server + }), + close: vi.fn(() => { + server.emit('close') + return server + }) + }) as unknown as Server + server.on('connection', admitted) + return { server, admitted } +} + +it('does not cache an asynchronous ACL denial as trusted', async () => { + const path = resolve('private-denied-fixture.json') + const rejected = expect(assertPrivatePathAsync(path, 'file')).rejects.toThrow('foreign account') + finishHelper(new Error('Access denied'), JSON.stringify({ code: 5, message: 'foreign account' })) + await rejected + const recheck = assertPrivatePathAsync(path, 'file') + expect(execFile).toHaveBeenCalledTimes(2) + finishHelper() + await recheck + await assertPrivatePathAsync(path, 'file') + expect(execFile).toHaveBeenCalledTimes(2) + expect(readFileSync).not.toHaveBeenCalled() +}) + +it('reads an atomically replaced snapshot only after its asynchronous ACL recheck succeeds', async () => { + vi.mocked(readFileSync).mockReturnValue('{"revision":2}') + const reading = readPrivateJsonAsync( + resolve('private-snapshot-fixture.json'), + z.object({ revision: z.number() }) + ) + inode++ + finishHelper() + await vi.waitFor(() => expect(execFile).toHaveBeenCalledTimes(2)) + expect(readFileSync).not.toHaveBeenCalled() + finishHelper() + expect(await reading).toEqual({ revision: 2 }) + expect(readFileSync).toHaveBeenCalledOnce() + expect(execFile).toHaveBeenCalledTimes(2) +}) + +it.each(['replacement', 'foreign ACL'] as const)( + 'does not read a snapshot after its second asynchronous check finds %s', + async (failure) => { + const reading = readPrivateJsonAsync( + resolve('private-invalid-snapshot-fixture.json'), + z.unknown() + ) + const rejected = expect(reading).rejects.toThrow( + failure === 'replacement' ? 'changed during its permission check' : 'foreign account' + ) + inode++ + finishHelper() + await vi.waitFor(() => expect(execFile).toHaveBeenCalledTimes(2)) + if (failure === 'replacement') { + inode++ + finishHelper() + } else + finishHelper( + new Error('Access denied'), + JSON.stringify({ code: 5, message: 'foreign account' }) + ) + await rejected + expect(execFile).toHaveBeenCalledTimes(2) + expect(readFileSync).not.toHaveBeenCalled() + } +) + +it('renews the actual control lease while a Windows ACL helper takes longer than that lease', async () => { + const { control, peer } = await controlLease() + vi.useFakeTimers() + const renewed: Promise[] = [] + const timer = setInterval(() => { + renewed.push(peer.request({ method: 'tempad/runtime-renew' }, ResultSchema)) + }, RENEW_MS) + let validated = false + const validation = assertPrivatePathAsync(resolve('private-lease-fixture.json'), 'file').then( + () => { + validated = true + } + ) + setTimeout(() => finishHelper(), LEASE_MS + RENEW_MS) + try { + for (let elapsed = RENEW_MS; elapsed <= LEASE_MS + RENEW_MS; elapsed += RENEW_MS) { + await vi.advanceTimersByTimeAsync(RENEW_MS) + await Promise.all(renewed) + expect(control.available()).toBe(true) + if (elapsed <= LEASE_MS) expect(validated).toBe(false) + } + await validation + expect(renewed).toHaveLength(6) + expect(execFile).toHaveBeenCalledOnce() + expect(execFileSync).not.toHaveBeenCalled() + } finally { + clearInterval(timer) + } +}) + +it.each([false, true])( + 'never sends pipe capabilities before ACL verification (cancelled: %s)', + async (cancelled) => { + vi.useFakeTimers() + const socket = pipeSocket() + vi.mocked(connect).mockReturnValue(socket) + const cancellation = new AbortController() + const connected = connectSocket('\\\\.\\pipe\\acl-delay', cancellation.signal).then((value) => { + value.write('private capability') + return value + }) + const rejected = cancelled ? expect(connected).rejects.toThrow('cancelled') : undefined + socket.emit('connect') + await vi.advanceTimersByTimeAsync(12_000) + expect(socket.write).not.toHaveBeenCalled() + expect(socket.destroyed).toBe(false) + if (cancelled) { + cancellation.abort() + await rejected + expect(helperSignal?.aborted).toBe(true) + finishHelper() + await Promise.resolve() + expect(socket.destroyed).toBe(true) + expect(socket.write).not.toHaveBeenCalled() + } else { + finishHelper() + expect(await connected).toBe(socket) + expect(socket.write).toHaveBeenCalledExactlyOnceWith('private capability') + socket.destroy() + } + } +) + +it('rejects incoming connections until delayed pipe protection completes', async () => { + vi.useFakeTimers() + const { server, admitted } = pipeListener() + const listening = listenPrivate(server, '\\\\.\\pipe\\protect-delay') + const early = pipeSocket() + server.emit('connection', early) + expect(early.destroyed).toBe(true) + expect(admitted).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(12_000) + finishHelper() + await listening + const trusted = pipeSocket() + server.emit('connection', trusted) + expect(trusted.destroyed).toBe(false) + expect(admitted).toHaveBeenCalledExactlyOnceWith(trusted) + expect(execFileSync).not.toHaveBeenCalled() +}) + +it('does not publish a listener for an owner that already closed', async () => { + const { server } = pipeListener() + const owner = new AbortController() + owner.abort(new Error('Owner closed')) + await expect(listenPrivate(server, '\\\\.\\pipe\\closed-owner', owner.signal)).rejects.toThrow() + expect(server.listen).not.toHaveBeenCalled() + expect(execFile).not.toHaveBeenCalled() +}) + +it('keeps a cancelled listener closed after delayed protection finishes', async () => { + vi.useFakeTimers() + const { server, admitted } = pipeListener() + const owner = new AbortController() + const rejected = expect( + listenPrivate(server, '\\\\.\\pipe\\closing-owner', owner.signal) + ).rejects.toThrow() + await vi.advanceTimersByTimeAsync(12_000) + expect(admitted).not.toHaveBeenCalled() + owner.abort(new Error('Owner closed')) + await rejected + expect(server.close).toHaveBeenCalledOnce() + expect(helperSignal?.aborted).toBe(true) + finishHelper() + await Promise.resolve() + server.emit('connection', pipeSocket()) + expect(admitted).not.toHaveBeenCalled() + expect(server.rawListeners('connection')).not.toContain(admitted) +}) + +it('bounds pending ACL inspection by the enclosing endpoint deadline without treating it as absent', async () => { + const socket = pipeSocket() + vi.mocked(connect).mockReturnValue(socket) + const waiting = waitForSocket('\\\\.\\pipe\\short-deadline', undefined, 100).then((value) => { + value.write('private capability') + return value + }) + const failed = waiting.catch((error: unknown) => error) + socket.emit('connect') + const error = await failed + expect(error).toMatchObject({ data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } }) + expect(unavailableSocket(error)).toBe(false) + expect(helperSignal?.aborted).toBe(true) + expect(socket.destroyed).toBe(true) + expect(connect).toHaveBeenCalledOnce() + finishHelper() + await Promise.resolve() + expect(socket.write).not.toHaveBeenCalled() +}) diff --git a/packages/mcp-server/tests/runtime-acquire-cache.test.ts b/packages/mcp-server/tests/runtime-acquire-cache.test.ts new file mode 100644 index 00000000..ea925881 --- /dev/null +++ b/packages/mcp-server/tests/runtime-acquire-cache.test.ts @@ -0,0 +1,217 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { gzipSync } from 'node:zlib' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import type { Artifact } from '../src/runtime-prototype/state' +import type { RuntimeManifest } from '../src/runtime-update/manifest' + +const fake = vi.hoisted(() => ({ + catalog: vi.fn(), + resolve: vi.fn(), + download: vi.fn(), + probe: vi.fn() +})) +vi.mock('../src/runtime-update/registry', () => ({ + releaseCatalog: fake.catalog, + resolveRelease: fake.resolve, + downloadRelease: fake.download +})) +vi.mock('../src/runtime-prototype/probe', () => ({ probeArtifact: fake.probe })) + +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { fingerprintBundle } from '../src/runtime-prototype/processes' +import { stageRelease } from '../src/runtime-update/acquire' +import { verifyReleaseArtifact } from '../src/runtime-update/artifact' +import { installArchive, verifyCached } from '../src/runtime-update/cache' +import { sha256 } from '../src/runtime-update/package' + +function archive(version: string, node: string) { + const files = new Map([ + [ + 'package.json', + JSON.stringify({ + name: '@tempad-dev/mcp', + version, + bin: 'dist/cli.mjs', + engines: { node }, + dependencies: {}, + bundleDependencies: [] + }) + ], + ['dist/cli.mjs', ''], + ['dist/log-transport.mjs', ''], + ['dist/managed-runtime.mjs', `// ${version}`] + ]) + const manifest: RuntimeManifest = { + version: 1, + name: '@tempad-dev/mcp', + packageVersion: version, + entry: 'dist/managed-runtime.mjs', + node, + platforms: ['darwin', 'linux', 'win32'], + guidanceEpoch: 1, + guidanceReview: { + version, + sourceHash: 'a'.repeat(64), + decision: 'compatible', + baseline: { version: '0.11.0', integrity: `sha512-${'a'.repeat(86)}==` }, + rationale: 'Compatible runtime for isolated cache selection tests.' + }, + controlProtocols: [2], + state: { family: 'tempad', reads: [1], writes: 1 }, + extension: { protocols: [1], legacy: true }, + hookProtocol: 2, + wireHash: 'b'.repeat(64), + presentationHash: 'c'.repeat(64), + lockHash: 'd'.repeat(64), + files: Object.fromEntries([...files].map(([path, bytes]) => [path, sha256(bytes)])) + } + files.set('dist/runtime-manifest.json', JSON.stringify(manifest)) + const blocks: Buffer[] = [] + for (const [path, value] of files) { + const data = Buffer.from(value) + const header = Buffer.alloc(512) + header.write(`package/${path}`) + header.write(data.length.toString(8).padStart(11, '0') + '\0', 124) + header.write(' ', 148) + header.write('0', 156) + header.write( + header + .reduce((sum, value) => sum + value, 0) + .toString(8) + .padStart(6, '0') + '\0 ', + 148 + ) + blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512)) + } + const bytes = gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])) + return { + bytes, + version, + integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + url: `https://registry.npmjs.org/@tempad-dev/mcp/-/mcp-${version}.tgz` + } +} + +let root: string +const nodeVersion = Object.getOwnPropertyDescriptor(process.versions, 'node')! +const node = (version: string) => + Object.defineProperty(process.versions, 'node', { value: version, configurable: true }) +const releases = [archive('0.12.0', '>=22'), archive('0.12.1', '>=22'), archive('0.12.2', '^24')] +function artifact(version: string): Artifact { + const release = releases.find((release) => release.version === version)! + const installed = verifyCached(root, version, release.integrity) + const entry = join(installed.directory, installed.manifest.entry) + return { + version, + entry, + bundle: fingerprintBundle(entry), + release: { + directory: installed.directory, + integrity: release.integrity, + manifestHash: sha256(readFileSync(join(installed.directory, 'dist/runtime-manifest.json'))) + } + } +} +beforeEach(async () => { + vi.resetAllMocks() + root = createPrivateTempDirectory('tp-cache-selection-') + node('24.0.0') + for (const release of releases) + await installArchive(root, release.version, release.integrity, release.bytes) + fake.catalog.mockResolvedValue({ + latest: '0.12.2', + releases: releases.slice(1).reverse(), + fingerprint: sha256('catalog') + }) + fake.resolve.mockResolvedValue(releases[2]) + fake.download.mockImplementation(async (release) => release.bytes) + fake.probe.mockImplementation(async (entry: string) => { + const manifest: RuntimeManifest = JSON.parse( + readFileSync(join(dirname(entry), 'runtime-manifest.json'), 'utf8') + ) + return { + artifact: artifact(manifest.packageVersion), + wireHash: manifest.wireHash, + presentationHash: manifest.presentationHash + } + }) +}) +afterEach(() => { + Object.defineProperty(process.versions, 'node', nodeVersion) + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +it('selects a compatible verified cache after the launcher changes from Node 24 to 22', async () => { + const current = artifact('0.12.0') + node('22.0.0') + expect(await stageRelease(root, 'latest', { active: current, control: current })).toMatchObject({ + candidate: { version: '0.12.1' }, + selection: { inspected: 2, latestReasons: ['Node restart/upgrade required'] } + }) + expect(fake.probe).toHaveBeenCalledExactlyOnceWith(artifact('0.12.1').entry) + expect(fake.download).not.toHaveBeenCalled() +}) + +it('still refuses to execute an incompatible cached runtime through an exact request or activation', async () => { + const current = artifact('0.12.0') + const next = artifact('0.12.2') + node('22.0.0') + await expect(stageRelease(root, '0.12.2', { active: current, control: current })).rejects.toThrow( + /Node/ + ) + expect(() => verifyReleaseArtifact(next)).toThrow(/Node/) + expect(fake.probe).not.toHaveBeenCalled() +}) + +it('discards an unused corrupt cache and continues compatible selection without executing it', async () => { + const current = artifact('0.12.0') + const next = artifact('0.12.2') + writeFileSync(next.entry, 'tampered') + node('22.0.0') + const context = { active: current, control: current } + const first = await stageRelease(root, 'latest', context) + expect(first).toMatchObject({ + cacheWarning: expect.stringContaining('hash mismatch'), + selection: { inspected: 1, remaining: 1 } + }) + expect(existsSync(next.entry)).toBe(false) + expect(fake.probe).not.toHaveBeenCalled() + expect(fake.download).toHaveBeenCalledOnce() + expect(await stageRelease(root, 'latest', context, first.selection?.cursor)).toMatchObject({ + candidate: { version: '0.12.1' } + }) + expect(fake.probe).toHaveBeenCalledExactlyOnceWith(artifact('0.12.1').entry) +}) + +it('repairs an unused compatible cache before probing its installed bytes', async () => { + const current = artifact('0.12.0') + const next = artifact('0.12.1') + writeFileSync(next.entry, 'tampered') + fake.resolve.mockResolvedValue(releases[1]) + const result = await stageRelease(root, '0.12.1', { active: current, control: current }) + expect(result).toMatchObject({ + candidate: next, + cacheWarning: expect.stringContaining('hash mismatch') + }) + expect(readFileSync(next.entry, 'utf8')).toBe('// 0.12.1') + expect(() => verifyReleaseArtifact(result.candidate!)).not.toThrow() +}) + +it.each(['control', 'previous'] as const)( + 'retains a damaged %s cache for explicit recovery', + async (role) => { + const current = artifact('0.12.0') + const next = artifact('0.12.2') + writeFileSync(next.entry, 'tampered') + await expect( + stageRelease(root, 'latest', { active: current, control: current, [role]: next }) + ).rejects.toThrow('retained runtime cache') + expect(readFileSync(next.entry, 'utf8')).toBe('tampered') + expect(fake.probe).not.toHaveBeenCalled() + expect(fake.download).not.toHaveBeenCalled() + } +) diff --git a/packages/mcp-server/tests/runtime-acquire.test.ts b/packages/mcp-server/tests/runtime-acquire.test.ts new file mode 100644 index 00000000..26680ed9 --- /dev/null +++ b/packages/mcp-server/tests/runtime-acquire.test.ts @@ -0,0 +1,354 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { basename, dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import type { Artifact } from '../src/runtime-prototype/state' +import type { AcquisitionContext } from '../src/runtime-update/acquire' +import type { RuntimeManifest } from '../src/runtime-update/manifest' + +const fake = vi.hoisted(() => ({ + resolve: vi.fn(), + catalog: vi.fn(), + download: vi.fn(), + cached: vi.fn(), + discard: vi.fn(), + install: vi.fn(), + probe: vi.fn(), + manifest: vi.fn(), + fingerprint: vi.fn(), + integrity: vi.fn(), + archive: vi.fn() +})) +vi.mock('../src/runtime-update/registry', () => ({ + resolveRelease: fake.resolve, + releaseCatalog: fake.catalog, + downloadRelease: fake.download +})) +vi.mock('../src/runtime-update/cache', () => ({ + verifyCached: fake.cached, + discardUnusedCache: fake.discard, + installArchive: fake.install +})) +vi.mock('../src/runtime-update/archive', () => ({ + verifyIntegrity: fake.integrity, + readArchive: fake.archive +})) +vi.mock('../src/runtime-update/artifact', () => ({ releaseManifest: fake.manifest })) +vi.mock('../src/runtime-prototype/probe', () => ({ probeArtifact: fake.probe })) +vi.mock('../src/runtime-prototype/processes', () => ({ fingerprintBundle: fake.fingerprint })) +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { stageRelease } from '../src/runtime-update/acquire' +import { sha256 } from '../src/runtime-update/package' + +let root: string +let context: AcquisitionContext +const manifests = new Map() +function artifact(version: string): Artifact { + return { + version, + entry: join(root, version, 'dist/managed-runtime.mjs'), + bundle: sha256(version), + release: { directory: join(root, version), manifestHash: sha256(version) } + } +} +function manifest(version: string, overrides: Partial = {}) { + const value: RuntimeManifest = { + version: 1, + name: '@tempad-dev/mcp', + packageVersion: version, + entry: 'dist/managed-runtime.mjs', + node: '>=22', + platforms: ['darwin', 'linux', 'win32'], + guidanceEpoch: 1, + guidanceReview: { + version, + sourceHash: 'a'.repeat(64), + decision: 'compatible', + baseline: { version: '0.11.0', integrity: 'sha512-' + 'a'.repeat(86) + '==' }, + rationale: 'Compatible runtime for isolated selection tests.' + }, + controlProtocols: [2], + state: { family: 'tempad', reads: [1], writes: 1 }, + extension: { protocols: [1], legacy: true }, + hookProtocol: 2, + wireHash: 'b'.repeat(64), + presentationHash: 'c'.repeat(64), + lockHash: 'd'.repeat(64), + files: {}, + ...overrides + } + manifests.set(version, value) + mkdirSync(join(root, version, 'dist'), { recursive: true }) + writeFileSync(join(root, version, 'dist/runtime-manifest.json'), JSON.stringify(value)) + return value +} +function catalog(versions: string[], latest = versions[0]!) { + const releases = versions.map((version) => ({ version, integrity: 'sri', url: 'official' })) + fake.catalog.mockResolvedValue({ + latest, + releases, + fingerprint: sha256(JSON.stringify({ latest, releases })) + }) +} +const absent = () => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) +} +beforeEach(() => { + vi.resetAllMocks() + manifests.clear() + root = createPrivateTempDirectory('tp-acquire-') + manifest('0.12.0') + manifest('0.12.1') + context = { active: artifact('0.12.0'), control: artifact('0.12.0') } + fake.manifest.mockImplementation((value: Artifact) => manifests.get(value.version)) + fake.cached.mockImplementation((_cache, version) => ({ + directory: join(root, version), + manifest: manifests.get(version) + })) + fake.install.mockImplementation((_cache, version) => ({ + directory: join(root, version), + manifest: manifests.get(version) + })) + fake.resolve.mockResolvedValue({ version: '0.12.1', integrity: 'sri', url: 'official' }) + catalog(['0.12.1']) + fake.download.mockImplementation(async (release) => Buffer.from(release.version)) + fake.archive.mockImplementation( + (bytes: Buffer) => + new Map([ + ['dist/runtime-manifest.json', Buffer.from(JSON.stringify(manifests.get(bytes.toString())))] + ]) + ) + const entryVersion = (entry: string) => basename(dirname(dirname(entry))) + fake.fingerprint.mockImplementation((entry) => sha256(entryVersion(entry))) + fake.probe.mockImplementation(async (entry) => { + const version = entryVersion(entry) + const value = manifests.get(version)! + return { + artifact: artifact(version), + wireHash: value.wireHash, + presentationHash: value.presentationHash + } + }) +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('does not fetch archives for current/older tags, or query at all for a checkout', async () => { + catalog([], '0.12.0') + expect(await stageRelease(root, 'latest', context)).toMatchObject({ + selection: { inspected: 0, remaining: 0 } + }) + expect(fake.download).not.toHaveBeenCalled() + fake.catalog.mockClear() + vi.stubEnv('TEMPAD_MCP_DEV_CHECKOUT', '/missing') + await expect(stageRelease(root, 'latest', context)).rejects.toThrow('Checkout') + expect(fake.catalog).not.toHaveBeenCalled() +}) + +it('keeps exact-version staging unchanged and only downloads absent caches', async () => { + manifest('0.12.1', { guidanceEpoch: 2 }) + const result = await stageRelease(root, '0.12.1', context) + expect(result.candidate?.version).toBe('0.12.1') + expect(result.selection).toBeUndefined() + expect(fake.catalog).not.toHaveBeenCalled() + expect(fake.download).not.toHaveBeenCalled() + fake.cached.mockImplementation(absent) + await stageRelease(root, '0.12.1', context) + expect(fake.download).toHaveBeenCalledOnce() + expect(fake.install).toHaveBeenCalledOnce() +}) + +it('selects the newest compatible release without probing incompatible candidates', async () => { + manifest('0.12.3', { guidanceEpoch: 2 }) + manifest('0.12.2') + catalog(['0.12.3', '0.12.2', '0.12.1']) + expect(await stageRelease(root, 'latest', context)).toMatchObject({ + candidate: { version: '0.12.2' }, + selection: { + latest: '0.12.3', + latestReasons: ['Guidance epoch changed; restart MCP'], + inspected: 2, + remaining: 0 + } + }) + expect(fake.probe).toHaveBeenCalledExactlyOnceWith(artifact('0.12.2').entry) +}) + +it('continues below a future Node engine policy without executing that package', async () => { + manifest('0.12.2', { node: '^26.1' }) + catalog(['0.12.2', '0.12.1']) + expect(await stageRelease(root, 'latest', context)).toMatchObject({ + candidate: { version: '0.12.1' }, + selection: { latestReasons: ['Node engine policy requires a newer runtime checker'] } + }) + expect(fake.probe).toHaveBeenCalledExactlyOnceWith(artifact('0.12.1').entry) +}) + +it('preserves frozen control compatibility and skips known failed bundles', async () => { + manifest('0.12.0', { state: { family: 'tempad', reads: [1], writes: 1 } }) + manifest('0.12.1', { state: { family: 'tempad', reads: [1, 2], writes: 1 } }) + context.active = artifact('0.12.1') + manifest('0.12.4', { state: { family: 'tempad', reads: [1, 2], writes: 2 } }) + manifest('0.12.3') + manifest('0.12.2') + context.excludedBundles = [artifact('0.12.3').bundle] + catalog(['0.12.4', '0.12.3', '0.12.2']) + expect(await stageRelease(root, 'latest', context)).toMatchObject({ + candidate: { version: '0.12.2' }, + selection: { latestReasons: ['State rollback compatibility is missing'] } + }) + expect(fake.probe).toHaveBeenCalledExactlyOnceWith(artifact('0.12.2').entry) +}) + +it('skips breaking branches and deprecated releases without downloading them', async () => { + manifest('0.13.0') + manifest('0.12.2') + const releases = [ + { version: '0.13.0', integrity: 'sri', url: 'official' }, + { version: '0.12.2', integrity: 'sri', url: 'official', deprecated: 'bad release' }, + { version: '0.12.1', integrity: 'sri', url: 'official' } + ] + fake.catalog.mockResolvedValue({ latest: '0.13.0', fingerprint: 'catalog', releases }) + expect(await stageRelease(root, 'latest', context)).toMatchObject({ + candidate: { version: '0.12.1' }, + selection: { latestReasons: ['Breaking release boundary; explicit update required'] } + }) + expect(fake.cached).toHaveBeenCalledExactlyOnceWith(root, '0.12.1', 'sri') +}) + +it('resumes bounded search beyond sixteen rejected releases and resets changed evidence', async () => { + const versions = Array.from({ length: 18 }, (_, index) => `0.12.${18 - index}`) + for (const version of versions) + manifest(version, version === '0.12.1' ? {} : { guidanceEpoch: 2 }) + catalog(versions) + const first = await stageRelease(root, 'latest', context) + expect(first.selection).toMatchObject({ + inspected: 16, + remaining: 2, + cursor: { before: '0.12.3' } + }) + const second = await stageRelease(root, 'latest', context, first.selection?.cursor) + expect(second).toMatchObject({ + candidate: { version: '0.12.1' }, + selection: { inspected: 2, remaining: 0 } + }) + fake.cached.mockClear() + await stageRelease( + root, + 'latest', + { + ...context, + control: { + ...context.control, + release: { ...context.control.release!, manifestHash: 'f'.repeat(64) } + } + }, + first.selection?.cursor + ) + expect(fake.cached.mock.calls[0]?.[1]).toBe('0.12.18') + fake.cached.mockClear() + catalog(versions.slice(1)) + await stageRelease(root, 'latest', context, first.selection?.cursor) + expect(fake.cached.mock.calls[0]?.[1]).toBe('0.12.17') +}) + +it('checkpoints slow cached inspections before the worker deadline', async () => { + let now = 0 + vi.spyOn(performance, 'now').mockImplementation(() => now) + manifest('0.12.2', { guidanceEpoch: 2 }) + catalog(['0.12.2', '0.12.1']) + fake.cached.mockImplementation((_cache, version) => { + now += 31000 + return { directory: join(root, version), manifest: manifests.get(version) } + }) + const first = await stageRelease(root, 'latest', context) + expect(first.selection).toMatchObject({ inspected: 1, remaining: 1 }) + expect(await stageRelease(root, 'latest', context, first.selection?.cursor)).toMatchObject({ + candidate: { version: '0.12.1' } + }) +}) + +it('checkpoints before a network archive and after one download without caching incompatible bytes', async () => { + manifest('0.12.3', { guidanceEpoch: 2 }) + manifest('0.12.2', { guidanceEpoch: 2 }) + catalog(['0.12.3', '0.12.2', '0.12.1']) + fake.cached.mockImplementation((_cache, version) => { + if (version !== '0.12.3') absent() + return { directory: join(root, version), manifest: manifests.get(version) } + }) + const first = await stageRelease(root, 'latest', context) + expect(first.selection).toMatchObject({ inspected: 1, remaining: 2 }) + expect(fake.download).not.toHaveBeenCalled() + const second = await stageRelease(root, 'latest', context, first.selection?.cursor) + expect(second.selection).toMatchObject({ inspected: 1, remaining: 1 }) + expect(fake.download).toHaveBeenCalledOnce() + expect(fake.integrity).toHaveBeenCalledOnce() + expect(fake.install).not.toHaveBeenCalled() + expect(await stageRelease(root, 'latest', context, second.selection?.cursor)).toMatchObject({ + candidate: { version: '0.12.1' } + }) + expect(fake.download).toHaveBeenCalledTimes(2) + expect(fake.install).toHaveBeenCalledOnce() +}) + +it('skips an integrity-verified future manifest without executing it', async () => { + manifest('0.12.2') + catalog(['0.12.2', '0.12.1']) + fake.cached.mockImplementation(absent) + fake.archive.mockReturnValueOnce( + new Map([ + [ + 'dist/runtime-manifest.json', + Buffer.from(JSON.stringify({ ...manifests.get('0.12.2'), version: 2 })) + ] + ]) + ) + const first = await stageRelease(root, 'latest', context) + expect(first.selection?.latestReasons).toEqual([ + 'Release uses an unsupported runtime manifest version' + ]) + expect(fake.probe).not.toHaveBeenCalled() + expect(await stageRelease(root, 'latest', context, first.selection?.cursor)).toMatchObject({ + candidate: { version: '0.12.1' } + }) +}) + +it('refetches unused corrupt cache without probing damaged bytes', async () => { + fake.cached.mockImplementationOnce(() => { + throw new Error('hash mismatch') + }) + fake.discard.mockReturnValue(true) + expect(await stageRelease(root, 'latest', context)).toMatchObject({ + candidate: { version: '0.12.1' }, + cacheWarning: expect.stringContaining('hash mismatch') + }) + expect(fake.download).toHaveBeenCalledOnce() + expect(fake.install).toHaveBeenCalledOnce() + expect(fake.probe).toHaveBeenCalledOnce() +}) + +it('does not discard retained artifacts or accept a false contract declaration', async () => { + context.previous = artifact('0.12.1') + fake.cached.mockImplementationOnce(() => { + throw new Error('hash mismatch') + }) + fake.discard.mockImplementationOnce(() => { + throw new Error('retained runtime cache') + }) + await expect(stageRelease(root, 'latest', context)).rejects.toThrow('retained runtime cache') + expect(fake.discard).toHaveBeenCalledWith(root, 'sri', [ + context.active, + context.control, + context.previous + ]) + expect(fake.download).not.toHaveBeenCalled() + fake.probe.mockResolvedValue({ + artifact: artifact('0.12.1'), + wireHash: 'different', + presentationHash: 'text' + }) + await expect(stageRelease(root, 'latest', context)).rejects.toThrow('declared contract') +}) diff --git a/packages/mcp-server/tests/runtime-bootstrap.test.ts b/packages/mcp-server/tests/runtime-bootstrap.test.ts new file mode 100644 index 00000000..e803187d --- /dev/null +++ b/packages/mcp-server/tests/runtime-bootstrap.test.ts @@ -0,0 +1,326 @@ +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import lockfile from 'proper-lockfile' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const fake = vi.hoisted(() => ({ + edge: vi.fn(), + connect: vi.fn(), + request: vi.fn(), + retain: vi.fn(), + question: vi.fn(), + close: vi.fn(), + recovery: vi.fn(), + profile: { kind: 'release' } +})) +vi.mock('../src/runtime-prototype/edge', () => ({ startPrototypeEdge: fake.edge })) +vi.mock('../src/runtime-update/artifact', async (original) => ({ + ...(await original()), + retainBundledPackage: fake.retain +})) +vi.mock('../src/runtime-prototype/processes', async (original) => ({ + ...(await original()), + connectSocket: fake.connect, + supervisorConnection: async () => ({}), + fingerprintBundle: () => 'a'.repeat(64) +})) +vi.mock('../src/runtime-prototype/protocol', async (original) => ({ + ...(await original()), + ControlPeer: class { + connect = async () => {} + request = fake.request + close = fake.close + }, + SocketTransport: class {} +})) +vi.mock('../src/shared', async (original) => ({ + ...(await original()), + RUNTIME_PROFILE: fake.profile, + resolveStateDir: () => { + const directory = process.env.TEMPAD_MCP_STATE_DIR! + mkdirSync(directory, { recursive: true, mode: 0o700 }) + return realpathSync(directory) + }, + endLogging: () => {} +})) +vi.mock('node:readline/promises', () => ({ + createInterface: () => ({ question: fake.question, close: fake.close }) +})) +vi.mock('../src/runtime-recovery', () => ({ runRuntimeRecovery: fake.recovery })) + +import { RECOVER_MS } from '../src/runtime-prototype/protocol' +import { maybeManagedRuntime } from '../src/runtime-update/bootstrap' +import { ASSET_DIR, LOCK_PATH, LOG_DIR, RUNTIME_DIR, resolveStateDir } from '../src/shared' + +const root = join(resolveStateDir(), 'runtime') +const packageDir = join(resolveStateDir(), 'package') +const configPath = join(root, 'prototype.json') +const manifest = { + version: 1, + guidanceEpoch: 1, + guidanceReview: { + version: '0.12.0', + sourceHash: 'a'.repeat(64), + decision: 'initial', + baseline: { version: '0.11.0', integrity: `sha512-${'a'.repeat(86)}==` }, + rationale: 'First release with a reviewed guidance epoch.' + }, + controlProtocols: [1], + state: { family: 'tempad', reads: [1], writes: 1 }, + extension: { protocols: [13, 15], legacy: true }, + hookProtocol: 2, + name: '@tempad-dev/mcp', + packageVersion: '0.12.0', + entry: 'dist/managed-runtime.mjs', + node: '^22 || ^24 || >=26', + platforms: ['darwin', 'linux', 'win32'], + wireHash: 'b'.repeat(64), + presentationHash: 'c'.repeat(64), + lockHash: 'd'.repeat(64), + files: {} +} +function bundledArtifact() { + return { + entry: join(packageDir, manifest.entry), + version: '0.12.0', + bundle: '', + release: { directory: packageDir, manifestHash: 'a'.repeat(64) } + } +} +beforeEach(() => { + vi.clearAllMocks() + fake.profile.kind = 'release' + fake.connect.mockRejectedValue(Object.assign(new Error('absent'), { code: 'ENOENT' })) + fake.retain.mockReturnValue(bundledArtifact()) + fake.request.mockResolvedValue({}) + mkdirSync(join(packageDir, 'dist'), { recursive: true, mode: 0o700 }) + writeFileSync(join(packageDir, 'dist/runtime-manifest.json'), JSON.stringify(manifest)) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + vi.spyOn(process.stdin, 'destroy').mockReturnValue(process.stdin) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + rmSync(root, { recursive: true, force: true }) + rmSync(packageDir, { recursive: true, force: true }) +}) + +it('keeps status, opt-out and checkout launches from enrolling', async () => { + expect(await maybeManagedRuntime(['--runtime-update-status'], packageDir)).toBe(true) + expect(await maybeManagedRuntime(['--runtime-status'], packageDir)).toBe(false) + expect(await maybeManagedRuntime(['--resolve-runtime-block'], packageDir)).toBe(false) + await expect(maybeManagedRuntime(['--recover-runtime'], packageDir)).rejects.toThrow( + 'No managed runtime' + ) + expect(await maybeManagedRuntime(['--runtime-updates=off'], packageDir)).toBe(false) + expect(existsSync(configPath)).toBe(false) + expect(fake.retain).not.toHaveBeenCalled() + fake.profile.kind = 'checkout' + expect(await maybeManagedRuntime([], packageDir)).toBe(false) + await expect(maybeManagedRuntime(['--check-runtime-update'], packageDir)).rejects.toThrow( + 'Checkout' + ) +}) + +it('automatically enrolls verified releases but never guesses an unverified local build', async () => { + const path = join(packageDir, 'dist/runtime-manifest.json') + rmSync(path) + expect(await maybeManagedRuntime([], packageDir)).toBe(false) + expect(fake.connect).not.toHaveBeenCalled() + expect(fake.retain).not.toHaveBeenCalled() + writeFileSync(path, JSON.stringify(manifest)) + expect(await maybeManagedRuntime([], packageDir)).toBe(true) + expect(fake.edge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ policy: 'stable' }) + ) +}) + +it('enrolls once and gives every subsequent policy the same directories and owner', async () => { + expect(await maybeManagedRuntime(['--runtime-updates=stable'], packageDir)).toBe(true) + const saved = JSON.parse(readFileSync(configPath, 'utf8')) + expect(saved.directories).toEqual({ + runtime: RUNTIME_DIR, + state: resolveStateDir(), + assets: ASSET_DIR, + log: LOG_DIR + }) + await maybeManagedRuntime([], packageDir) + await maybeManagedRuntime(['--runtime-updates=off'], packageDir) + await maybeManagedRuntime(['--runtime-updates=pinned'], packageDir) + expect(fake.retain).toHaveBeenCalledTimes(1) + expect(fake.edge.mock.calls.map((call) => call[1].policy)).toEqual([ + 'stable', + 'stable', + 'off', + 'pinned' + ]) + expect(fake.edge.mock.calls.every((call) => call[0].secret === saved.secret)).toBe(true) +}) + +it('shares one retained package and owner across concurrent enrollment', async () => { + let release!: () => void + let retaining!: () => void + const held = new Promise((resolve) => (release = resolve)) + const started = new Promise((resolve) => (retaining = resolve)) + fake.retain.mockImplementationOnce(async () => { + retaining() + await held + return bundledArtifact() + }) + const locks = vi.spyOn(lockfile, 'lock') + const first = maybeManagedRuntime(['--runtime-updates=stable'], packageDir) + await started + const second = maybeManagedRuntime(['--runtime-updates=stable'], packageDir) + try { + await vi.waitFor(() => + expect(locks.mock.calls.filter(([path]) => path === LOCK_PATH).length).toBeGreaterThan(1) + ) + expect(fake.retain).toHaveBeenCalledOnce() + expect(existsSync(configPath)).toBe(false) + } finally { + release() + await Promise.all([first, second]) + } + expect(fake.retain).toHaveBeenCalledOnce() + const owners = fake.edge.mock.calls.map(([config]) => config.secret) + expect(owners).toHaveLength(2) + expect(new Set(owners).size).toBe(1) +}) + +it('waits for a busy enrollment through the bounded startup recovery budget', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + const busy = Object.assign(new Error('another enrollment is flushing its package'), { + code: 'ELOCKED' + }) + const acquire = vi.spyOn(lockfile, 'lock').mockRejectedValue(busy) + let settled = false + const outcome = maybeManagedRuntime(['--runtime-updates=stable'], packageDir).catch((error) => { + settled = true + return error + }) + await vi.advanceTimersByTimeAsync(RECOVER_MS - 1) + expect(settled).toBe(false) + expect(fake.retain).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(await outcome).toBe(busy) + expect(acquire).toHaveBeenCalledWith(LOCK_PATH, { retries: 0, stale: 10_000 }) +}) + +it.each(['EACCES', 'EIO'])('does not retry enrollment lock failures with %s', async (code) => { + const failure = Object.assign(new Error('runtime storage unavailable'), { code }) + const acquire = vi.spyOn(lockfile, 'lock').mockRejectedValue(failure) + await expect(maybeManagedRuntime(['--runtime-updates=stable'], packageDir)).rejects.toBe(failure) + expect(acquire).toHaveBeenCalledOnce() + expect(fake.retain).not.toHaveBeenCalled() +}) + +it('never replaces an existing legacy owner or guesses a missing release build', async () => { + const destroy = vi.fn() + fake.connect.mockResolvedValue({ destroy }) + expect(await maybeManagedRuntime([], packageDir)).toBe(false) + expect(await maybeManagedRuntime(['--runtime-updates=stable'], packageDir)).toBe(false) + await expect(maybeManagedRuntime(['--check-runtime-update'], packageDir)).rejects.toThrow( + 'legacy Hub' + ) + await expect(maybeManagedRuntime(['--runtime-updates=pinned'], packageDir)).rejects.toThrow( + 'legacy Hub' + ) + expect(destroy).toHaveBeenCalledTimes(4) + expect(existsSync(configPath)).toBe(false) + rmSync(join(packageDir, 'dist/runtime-manifest.json')) + await expect(maybeManagedRuntime(['--runtime-updates=stable'], packageDir)).rejects.toThrow( + 'no online fallback' + ) +}) + +it.each(['EPIPEUNPROTECTED', 'EPIPEUNTRUSTED'])( + 'explains the one-time Windows legacy restart for %s without trusting the pipe', + async (code) => { + const failure = Object.assign(new Error('ACL does not grant private user access'), { code }) + fake.connect.mockRejectedValue(failure) + for (const args of [[], ['--check-runtime-update']]) { + const attempt = maybeManagedRuntime(args, packageDir) + await expect(attempt).rejects.toThrow( + 'Close/restart all TemPad MCP connections once to replace a legacy Hub' + ) + await expect(attempt).rejects.toMatchObject({ cause: failure }) + } + expect(existsSync(configPath)).toBe(false) + expect(fake.retain).not.toHaveBeenCalled() + expect(fake.edge).not.toHaveBeenCalled() + } +) + +it('preserves corrupt metadata and refuses changed directories rather than starting a second owner', async () => { + await maybeManagedRuntime(['--runtime-updates=stable'], packageDir) + const config = JSON.parse(readFileSync(configPath, 'utf8')) + writeFileSync( + configPath, + JSON.stringify({ ...config, directories: { ...config.directories, assets: '/different' } }) + ) + await expect(maybeManagedRuntime([], packageDir)).rejects.toThrow('directories differ') + writeFileSync(configPath, 'broken') + await expect(maybeManagedRuntime([], packageDir)).rejects.toThrow() + expect(readFileSync(configPath, 'utf8')).toBe('broken') + expect(fake.retain).toHaveBeenCalledOnce() +}) + +it('checks without activation and requires a terminal confirmation of the staged integrity', async () => { + await maybeManagedRuntime(['--check-runtime-update'], packageDir) + expect(fake.request).toHaveBeenCalledWith( + { method: 'tempad/runtime-check', params: { version: 'latest' } }, + expect.anything(), + expect.anything() + ) + const candidate = { version: '0.12.1', release: { integrity: 'exact-integrity' } } + fake.request.mockResolvedValue({ candidate }) + await expect(maybeManagedRuntime(['--update-runtime', '0.12.1'], packageDir)).rejects.toThrow( + 'interactive' + ) + vi.stubGlobal('process', { + ...process, + stdin: { isTTY: true, destroy: vi.fn() }, + stdout: { isTTY: true, write: vi.fn() } + }) + fake.question.mockResolvedValue('no') + await expect(maybeManagedRuntime(['--update-runtime', '0.12.1'], packageDir)).rejects.toThrow( + 'cancelled' + ) + expect(fake.request.mock.calls.some((call) => call[0].method === 'tempad/runtime-activate')).toBe( + false + ) + fake.question.mockResolvedValue('update 0.12.1') + await maybeManagedRuntime(['--update-runtime', '0.12.1'], packageDir) + expect(fake.request).toHaveBeenLastCalledWith( + { + method: 'tempad/runtime-activate', + params: { version: '0.12.1', integrity: 'exact-integrity' } + }, + expect.anything(), + expect.anything() + ) + expect(fake.question).toHaveBeenCalledWith(expect.stringContaining('exact-integrity')) + fake.request.mockResolvedValue({}) + await expect(maybeManagedRuntime(['--update-runtime', '0.12.1'], packageDir)).rejects.toThrow( + 'not staged' + ) +}) + +it('routes status and recovery through the existing owner without downloading', async () => { + await maybeManagedRuntime(['--runtime-updates=stable'], packageDir) + for (const command of [ + '--runtime-update-status', + '--runtime-status', + '--resolve-runtime-block', + '--recover-runtime' + ]) + await maybeManagedRuntime([command], packageDir) + expect(fake.recovery).toHaveBeenCalledTimes(2) + expect(fake.request.mock.calls.some((call) => call[0].method === 'tempad/runtime-check')).toBe( + false + ) + expect(await maybeManagedRuntime(['--recover-task-storage'], packageDir)).toBe(false) +}) diff --git a/packages/mcp-server/tests/runtime-cold-start.test.ts b/packages/mcp-server/tests/runtime-cold-start.test.ts new file mode 100644 index 00000000..ad0e833f --- /dev/null +++ b/packages/mcp-server/tests/runtime-cold-start.test.ts @@ -0,0 +1,518 @@ +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { join } from 'node:path' +import lockfile from 'proper-lockfile' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const fake = vi.hoisted(() => ({ + retain: vi.fn(), + verify: vi.fn(), + connect: vi.fn(), + ports: [0] +})) +vi.mock('../src/config', async (original) => { + const config = await original() + return { + ...config, + getMcpServerConfig: (...args: Parameters) => ({ + ...config.getMcpServerConfig(...args), + wsPortCandidates: fake.ports + }) + } +}) +vi.mock('../src/runtime-update/artifact', async (original) => ({ + ...(await original()), + retainBundledPackage: fake.retain, + verifyReleaseArtifact: fake.verify +})) +vi.mock('../src/runtime-prototype/processes', async (original) => ({ + ...(await original()), + connectSocket: fake.connect +})) + +import { writeDurableJson } from '../src/durable-json' +import * as durable from '../src/durable-json' +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { fingerprintBundle, withStartupLock } from '../src/runtime-prototype/processes' +import * as state from '../src/runtime-prototype/state' +import { + ConfigSchema, + readEndpoints, + readJournal, + readPrivateJson, + writeJournal, + writeEndpoints, + type Artifact, + type Journal, + type PrototypeConfig +} from '../src/runtime-prototype/state' +import { privateDirectory } from '../src/runtime-update/cache' +import { prepareManagedEdge, resumeColdStart } from '../src/runtime-update/cold-start' +import { type RuntimeManifest } from '../src/runtime-update/manifest' +import { sha256 } from '../src/runtime-update/package' + +let root: string +let config: PrototypeConfig +let journal: Journal +let candidate: ReturnType +const cleanup: (() => unknown | Promise)[] = [] +const endpoints = { + wsPort: 6220, + httpPort: 12345, + accessToken: 'a'.repeat(32), + legacyAccessToken: 'b'.repeat(32) +} + +async function httpListener(port = 0) { + const server = createServer() + await once(server.listen(port, '127.0.0.1'), 'listening') + cleanup.push(async () => { + if (server.listening) await new Promise((resolve) => server.close(() => resolve())) + }) + return server +} + +async function saveHttpEndpoint(occupied = true) { + const server = await httpListener() + const saved = { ...endpoints, httpPort: (server.address() as { port: number }).port } + writeEndpoints(root, saved) + if (!occupied) await new Promise((resolve) => server.close(() => resolve())) + return saved +} + +function release(version: string, name: string, overrides: Partial = {}) { + const directory = join(root, 'packages', name) + privateDirectory(join(directory, 'dist')) + const manifest: RuntimeManifest = { + version: 1, + name: '@tempad-dev/mcp', + packageVersion: version, + entry: 'dist/managed-runtime.mjs', + guidanceEpoch: 1, + guidanceReview: { + version, + sourceHash: 'a'.repeat(64), + decision: 'compatible', + baseline: { version: '0.11.0', integrity: `sha512-${'a'.repeat(86)}==` }, + rationale: 'A locally prepared release fixture for cold runtime admission.' + }, + controlProtocols: [1, 2], + state: { family: 'tempad', reads: [1], writes: 1 }, + extension: { protocols: [13, 15], legacy: true }, + hookProtocol: 2, + node: '^22 || ^24 || >=26', + platforms: ['darwin', 'linux', 'win32'], + wireHash: 'b'.repeat(64), + presentationHash: 'c'.repeat(64), + lockHash: 'd'.repeat(64), + files: {}, + ...overrides + } + const entry = join(directory, manifest.entry) + writeFileSync(entry, `// release ${name}\n`) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ version })) + const bytes = JSON.stringify(manifest) + writeFileSync(join(directory, 'dist/runtime-manifest.json'), bytes) + const artifact: Artifact = { + entry, + bundle: fingerprintBundle(entry), + version, + release: { directory, manifestHash: sha256(Buffer.from(bytes)) } + } + return { directory, manifest, artifact } +} + +function saveConfig(value: PrototypeConfig) { + const { root: _root, ...saved } = value + writeDurableJson(join(root, 'prototype.json'), saved) + return saved +} + +async function prepare(value = candidate) { + const edge = await prepareManagedEdge(config, value.directory, value.manifest) + cleanup.push(edge.close) + return edge +} + +function intent() { + const { root: _root, ...saved } = config + const value = { + before: { config: saved, journal }, + config: { + ...saved, + entry: candidate.artifact.entry, + bundle: candidate.artifact.bundle, + release: candidate.artifact.release + }, + journal: { + ...journal, + revision: journal.revision + 1, + phase: 'old-stopped' as const, + candidate: candidate.artifact, + coldStart: true as const + } + } + writeDurableJson(join(root, 'cold-start.json'), value) + return value +} + +beforeEach(async () => { + vi.clearAllMocks() + fake.ports = [0] + const ws = await httpListener() + endpoints.wsPort = (ws.address() as { port: number }).port + await new Promise((resolve) => ws.close(() => resolve())) + root = createPrivateTempDirectory('tp-cold-') + const control = release('0.12.0', 'a') + const active = release('0.12.1', 'b') + candidate = release('0.12.2', 'c', { + wireHash: 'e'.repeat(64), + guidanceEpoch: 2, + controlProtocols: [2] + }) + config = { + root, + version: 1, + entry: control.artifact.entry, + bundle: control.artifact.bundle, + release: control.artifact.release, + secret: randomUUID(), + directories: { + runtime: join(root, 'run'), + state: join(root, 'state'), + assets: join(root, 'assets'), + log: join(root, 'log') + } + } + for (const directory of Object.values(config.directories!)) privateDirectory(directory) + journal = { + version: 1, + revision: 4, + phase: 'committed', + active: active.artifact, + previous: control.artifact, + failedBundles: ['f'.repeat(64)] + } + saveConfig(config) + writeJournal(root, journal) + fake.connect.mockRejectedValue(Object.assign(new Error('absent'), { code: 'ENOENT' })) + fake.retain.mockImplementation(async () => ({ ...candidate.artifact })) + fake.verify.mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) +}) + +it('admits an installed newer contract only on cold startup and preserves prior state', async () => { + const snapshot = join(config.directories!.state, 'tasks.json') + const data = { stopped: ['task'], executionBlocks: ['file'], needsRead: ['file'] } + writeDurableJson(snapshot, data) + const edge = await prepare() + expect(edge.config).toMatchObject({ + entry: candidate.artifact.entry, + bundle: candidate.artifact.bundle, + secret: config.secret, + directories: config.directories + }) + expect(readJournal(root)).toEqual({ + ...journal, + revision: 5, + phase: 'old-stopped', + coldStart: true, + candidate: candidate.artifact + }) + expect(JSON.parse(readFileSync(snapshot, 'utf8'))).toEqual(data) + expect(fake.verify).toHaveBeenCalledWith(candidate.artifact) + expect(existsSync(join(root, 'cold-start.json'))).toBe(false) + expect(readdirSync(join(root, 'edges'))).toHaveLength(1) + edge.close() + expect(readdirSync(join(root, 'edges'))).toEqual([]) +}) + +it.each(['edge', 'supervisor', 'hub', 'socket', 'unprotected-pipe', 'timed-out-socket'] as const)( + 'retains the running generation when a %s survives', + async (owner) => { + writeEndpoints(root, endpoints) + if (owner === 'edge') { + privateDirectory(join(root, 'edges')) + writeDurableJson(join(root, 'edges/prior.json'), { pid: process.pid }) + } else if (owner === 'supervisor') { + writeDurableJson(join(root, 'supervisor.json'), { pid: process.pid }) + } else if (owner === 'hub') { + writeDurableJson(join(config.directories!.runtime, 'hub-runtime.json'), { + schemaVersion: 1, + packageVersion: journal.active.version, + runtimeFingerprint: journal.active.bundle, + entryPath: journal.active.entry, + startedAt: new Date().toISOString(), + processId: process.pid, + activeExtension: null + }) + } else if (owner === 'unprotected-pipe' || owner === 'timed-out-socket') { + fake.connect.mockRejectedValue( + Object.assign(new Error('Endpoint acquisition pending'), { + code: owner === 'unprotected-pipe' ? 'EPIPEUNPROTECTED' : 'ETIMEDOUT' + }) + ) + } else { + fake.connect.mockResolvedValue({ destroy: vi.fn() }) + } + expect((await prepare()).config).toEqual(config) + expect(readJournal(root)).toEqual(journal) + expect(fake.retain).not.toHaveBeenCalled() + expect(readEndpoints(root)).toEqual(endpoints) + } +) + +it.each([false, true])( + 'refreshes only an occupied cold HTTP port (occupied=%s), preserving the old metadata format', + async (occupied) => { + const saved = await saveHttpEndpoint(occupied) + const installed = release('0.12.0', 'installed') + expect((await prepare(installed)).config).toEqual(config) + const next = readEndpoints(root)! + if (occupied) expect(next.httpPort).not.toBe(saved.httpPort) + else expect(next.httpPort).toBe(saved.httpPort) + expect({ ...next, httpPort: saved.httpPort }).toEqual(saved) + // Frozen pre-fix schema: rollback must remain readable by an older release. + expect( + z + .object({ + wsPort: z.number().int().min(1).max(65535), + httpPort: z.number().int().min(1).max(65535), + accessToken: z.string().min(32), + legacyAccessToken: z.string().min(32) + }) + .strict() + .parse(next) + ).toEqual(next) + expect(readJournal(root)).toEqual(journal) + } +) + +it.each(['preparing', 'old-stopped', 'candidate-ready'] as const)( + 'keeps endpoints fixed during an unfinished %s transaction', + async (phase) => { + writeEndpoints(root, endpoints) + writeJournal(root, { ...journal, phase }) + await prepare() + expect(readEndpoints(root)).toEqual(endpoints) + } +) + +it('reassigns an occupied cold WebSocket port only to another discovery candidate', async () => { + const occupied = await httpListener() + endpoints.wsPort = (occupied.address() as { port: number }).port + const available = await httpListener() + const replacement = (available.address() as { port: number }).port + fake.ports = [endpoints.wsPort, replacement] + const saved = await saveHttpEndpoint(false) + await new Promise((resolve) => available.close(() => resolve())) + await prepare() + expect(readEndpoints(root)).toEqual({ ...saved, wsPort: replacement }) +}) + +it('does not keep a cold WebSocket port outside the current discovery candidates', async () => { + const available = await httpListener() + const replacement = (available.address() as { port: number }).port + fake.ports = [replacement] + const saved = await saveHttpEndpoint(false) + await new Promise((resolve) => available.close(() => resolve())) + await prepare() + expect(readEndpoints(root)).toEqual({ ...saved, wsPort: replacement }) +}) + +it('does not use an arbitrary WebSocket port when every discovery candidate is occupied', async () => { + const occupied = await Promise.all([httpListener(), httpListener()]) + fake.ports = occupied.map((server) => (server.address() as { port: number }).port) + endpoints.wsPort = fake.ports[0]! + const saved = await saveHttpEndpoint(false) + await expect(prepare()).rejects.toMatchObject({ code: 'EADDRINUSE' }) + expect(readEndpoints(root)).toEqual(saved) + expect(readdirSync(join(root, 'edges'))).toEqual([]) + expect(fake.retain).not.toHaveBeenCalled() +}) + +it('can reallocate HTTP after resuming an interrupted verified cold upgrade', async () => { + const saved = await saveHttpEndpoint() + const pending = intent() + expect((await prepare()).config.entry).toBe(candidate.artifact.entry) + expect(readEndpoints(root)?.httpPort).not.toBe(saved.httpPort) + expect(readJournal(root)).toEqual(pending.journal) + expect(fake.verify).toHaveBeenCalledOnce() + expect(fake.retain).not.toHaveBeenCalled() +}) + +it('does not admit an edge when its cold HTTP allocation cannot be saved', async () => { + const saved = await saveHttpEndpoint() + let reservedPort: number | undefined + const write = durable.writeDurableJson + vi.spyOn(durable, 'writeDurableJson').mockImplementation((path, value) => { + if (path === join(root, 'endpoints.json')) { + reservedPort = (value as { httpPort: number }).httpPort + throw new Error('Endpoint write failed') + } + return write(path, value) + }) + await expect(prepare()).rejects.toThrow('Endpoint write failed') + expect(readEndpoints(root)).toEqual(saved) + expect(readdirSync(join(root, 'edges'))).toEqual([]) + expect(fake.retain).not.toHaveBeenCalled() + expect(reservedPort).toBeTypeOf('number') + await httpListener(reservedPort) + await httpListener(saved.wsPort) +}) + +it('waits for the real state writer lease instead of treating its lock directory as a file', async () => { + const saved = await saveHttpEndpoint() + const state = config.directories!.state + const unlock = await lockfile.lock(state, { + lockfilePath: join(state, '.tempad-hub-writer.lock') + }) + let locked = true + cleanup.push(async () => { + if (locked) await unlock() + }) + const result = prepare() + await vi.waitFor(() => expect(fake.connect).toHaveBeenCalledTimes(2)) + expect(fake.retain).not.toHaveBeenCalled() + expect(readEndpoints(root)).toEqual(saved) + await unlock() + locked = false + expect((await result).config.entry).toBe(candidate.artifact.entry) + expect(readEndpoints(root)?.httpPort).not.toBe(saved.httpPort) +}) + +it('removes only dead edge records before admitting a cold upgrade', async () => { + privateDirectory(join(root, 'edges')) + const stale = join(root, 'edges/dead.json') + writeDurableJson(stale, { pid: 123456789 }) + const original = process.kill.bind(process) + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === 123456789) throw Object.assign(new Error('dead'), { code: 'ESRCH' }) + return original(pid, signal) + }) + expect((await prepare()).config.entry).toBe(candidate.artifact.entry) + expect(existsSync(stale)).toBe(false) +}) + +it('ignores a prior edge registration removed by its exiting CLI during the scan', async () => { + privateDirectory(join(root, 'edges')) + const path = join(root, 'edges/prior.json') + writeDurableJson(path, { pid: process.pid }) + const read = state.readPrivateJson + vi.spyOn(state, 'readPrivateJson').mockImplementation((file, schema) => { + if (file === path) rmSync(path, { force: true }) + return read(file, schema) + }) + expect((await prepare()).config.entry).toBe(candidate.artifact.entry) + expect(readJournal(root)?.coldStart).toBe(true) +}) + +it('rejects an invalid prior edge registration instead of treating it as absent', async () => { + privateDirectory(join(root, 'edges')) + writeDurableJson(join(root, 'edges/prior.json'), { pid: 'invalid' }) + await expect(prepare()).rejects.toThrow('expected number') + expect(fake.retain).not.toHaveBeenCalled() + expect(readJournal(root)).toEqual(journal) +}) + +it('serializes competing cold launches before either edge can initialize', async () => { + const [first, second] = await Promise.all([prepare(), prepare()]) + expect(first.config.entry).toBe(candidate.artifact.entry) + expect(second.config).toEqual(first.config) + expect(fake.retain).toHaveBeenCalledOnce() + expect(readJournal(root)?.revision).toBe(5) + expect(readdirSync(join(root, 'edges'))).toHaveLength(2) +}) + +it.each(['0.11.0', '0.12.0'])('does not downgrade the active runtime from %s', async (version) => { + const older = release(version, 'older') + expect((await prepare(older)).config).toEqual(config) + expect(fake.retain).not.toHaveBeenCalled() + expect(readJournal(root)).toEqual(journal) +}) + +it('rejects different bytes with the same active release version', async () => { + candidate = release(journal.active.version, 'different-bytes', { guidanceEpoch: 2 }) + await expect(prepare()).rejects.toThrow('different bytes of the same release') + expect(readJournal(root)).toEqual(journal) +}) + +it.each<[string, Partial, string]>([ + ['state', { state: { family: 'other', reads: [1], writes: 1 } }, 'State rollback'], + ['extension', { extension: { protocols: [15], legacy: true } }, 'Released extension'], + ['control', { controlProtocols: [3] }, 'No common control'] +])('keeps the %s compatibility boundary on cold startup', async (_name, overrides, reason) => { + candidate = release('0.12.2', 'c', overrides) + await expect(prepare()).rejects.toThrow(reason) + expect(fake.retain).not.toHaveBeenCalled() + expect(readJournal(root)).toEqual(journal) +}) + +it('preserves quarantined bundles and unfinished handoffs', async () => { + journal.failedBundles.push(candidate.artifact.bundle) + writeJournal(root, journal) + await expect(prepare()).rejects.toThrow('quarantined') + expect(readJournal(root)).toEqual(journal) + journal.phase = 'preparing' + writeJournal(root, journal) + fake.retain.mockClear() + expect((await prepare()).config).toEqual(config) + expect(fake.retain).not.toHaveBeenCalled() +}) + +it.each(['neither', 'journal', 'both'] as const)( + 'resumes the exact durable decision after %s metadata writes', + async (written) => { + const pending = intent() + if (written !== 'neither') writeJournal(root, pending.journal) + if (written === 'both') writeDurableJson(join(root, 'prototype.json'), pending.config) + const next = await withStartupLock(root, () => resumeColdStart(config)) + expect(next).toEqual({ ...pending.config, root }) + expect(readJournal(root)).toEqual(pending.journal) + expect(readPrivateJson(join(root, 'prototype.json'), ConfigSchema)).toEqual(pending.config) + expect(existsSync(join(root, 'cold-start.json'))).toBe(false) + expect(fake.verify).toHaveBeenCalledOnce() + } +) + +it('does not replay an old intent over a newer journal revision', async () => { + intent() + writeJournal(root, { ...journal, revision: 10 }) + await expect(withStartupLock(root, () => resumeColdStart(config))).rejects.toThrow( + 'does not match' + ) + expect(readJournal(root)?.revision).toBe(10) + expect(existsSync(join(root, 'cold-start.json'))).toBe(true) + expect(fake.verify).not.toHaveBeenCalled() +}) + +it('does not finish a pending intent while an earlier edge still lives', async () => { + intent() + privateDirectory(join(root, 'edges')) + writeDurableJson(join(root, 'edges/prior.json'), { pid: process.pid }) + await expect(withStartupLock(root, () => resumeColdStart(config))).rejects.toThrow( + 'prior owner is still alive' + ) + expect(readJournal(root)).toEqual(journal) + expect(fake.verify).not.toHaveBeenCalled() +}) + +it('revalidates retained bytes before finishing an interrupted upgrade', async () => { + intent() + fake.verify.mockImplementation(() => { + throw new Error('retained bytes changed') + }) + await expect(withStartupLock(root, () => resumeColdStart(config))).rejects.toThrow( + 'retained bytes changed' + ) + expect(readJournal(root)).toEqual(journal) + expect(readPrivateJson(join(root, 'prototype.json'), ConfigSchema)?.entry).toBe(config.entry) +}) diff --git a/packages/mcp-server/tests/runtime-contract.test.ts b/packages/mcp-server/tests/runtime-contract.test.ts new file mode 100644 index 00000000..05e9e21b --- /dev/null +++ b/packages/mcp-server/tests/runtime-contract.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' + +const modulePath = '../scripts/runtime-contract.mjs' +const { canonicalJson, runtimeContract, compareContracts, compareReleaseWindow, summarize } = + await import(modulePath) + +function snapshot() { + return { + initialize: { + protocolVersion: '2025-03-26', + capabilities: { tools: {} }, + serverInfo: { name: 'tempad', version: '1' }, + instructions: 'Read first.' + }, + tools: [ + { + name: 'read', + description: 'Read nodes.', + annotations: { readOnlyHint: true }, + inputSchema: { + type: 'object', + properties: { description: { type: 'string', description: 'Node description.' } }, + required: ['description'] + } + } + ] + } +} + +describe('released runtime contract screening', () => { + it('ignores object and catalog ordering and package version, without mutating snapshots', () => { + const before = snapshot() + const after = JSON.parse(JSON.stringify(before)) + after.initialize.serverInfo.version = '2' + after.tools[0].inputSchema = { + required: ['description'], + properties: { description: { description: 'Node description.', type: 'string' } }, + type: 'object' + } + const saved = JSON.stringify(before) + expect(compareContracts(runtimeContract(before), runtimeContract(after))).toBe('identical') + expect(JSON.stringify(before)).toBe(saved) + expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}') + expect(canonicalJson(JSON.parse('{"__proto__":{"value":1}}'))).toBe('{"__proto__":{"value":1}}') + }) + + it('separates actual schema documentation and instructions from executable contracts', () => { + const before = snapshot() + const after = snapshot() + after.initialize.instructions = 'Read before writing.' + after.tools[0]!.description = 'Inspect nodes.' + after.tools[0]!.inputSchema.properties.description.description = 'Updated explanation.' + expect(compareContracts(runtimeContract(before), runtimeContract(after))).toBe( + 'presentation-only' + ) + }) + + it.each(['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas'])( + 'does not erase a literal description property in %s', + (key) => { + const before = snapshot() + const after = snapshot() + Object.assign(before.tools[0]!.inputSchema, { [key]: { description: { const: 'a' } } }) + Object.assign(after.tools[0]!.inputSchema, { [key]: { description: { const: 'b' } } }) + expect(compareContracts(runtimeContract(before), runtimeContract(after))).toBe('wire-changed') + } + ) + + it.each(['const', 'default', 'enum', 'x-custom'])( + 'preserves descriptions inside %s data', + (key) => { + const before = snapshot() + const after = snapshot() + Object.assign(before.tools[0]!.inputSchema, { [key]: { description: 'a' } }) + Object.assign(after.tools[0]!.inputSchema, { [key]: { description: 'b' } }) + expect(compareContracts(runtimeContract(before), runtimeContract(after))).toBe('wire-changed') + } + ) + + it('keeps annotations, output schemas, capabilities and schema array ordering significant', () => { + for (const change of [ + (value: ReturnType) => { + value.tools[0]!.annotations.readOnlyHint = false + }, + (value: ReturnType) => { + Object.assign(value.tools[0]!, { outputSchema: { type: 'string' } }) + }, + (value: ReturnType) => { + Object.assign(value.initialize.capabilities, { resources: {} }) + }, + (value: ReturnType) => { + value.tools[0]!.inputSchema.required = ['other', 'description'] + } + ]) { + const before = snapshot() + const after = snapshot() + change(after) + expect(compareContracts(runtimeContract(before), runtimeContract(after))).toBe('wire-changed') + } + }) + + it('sorts catalogs and rejects duplicate identifiers', () => { + const before = snapshot() + before.tools.push({ ...before.tools[0]!, name: 'write' }) + const after = { ...before, tools: [...before.tools].reverse() } + expect(compareContracts(runtimeContract(before), runtimeContract(after))).toBe('identical') + expect(() => runtimeContract({ ...before, tools: [before.tools[0], before.tools[0]] })).toThrow( + 'unique name' + ) + }) + + it('reports observed nearest-rank percentiles without inventing small-sample precision', () => { + expect(summarize([30, 10, 20])).toEqual({ samples: 3, p50: 20, p95: 30, p99: 30, max: 30 }) + expect(() => summarize([])).toThrow('nonempty') + expect(() => summarize([NaN])).toThrow('finite') + }) + + it('does not infer cumulative compatibility from reverted endpoints or matching wire hashes', () => { + const results = [ + { version: '0.5.0', wireHash: 'a', presentationHash: 'a' }, + { version: '0.5.1', wireHash: 'b', presentationHash: 'b' }, + { version: '0.6.0', wireHash: 'a', presentationHash: 'a' } + ] + expect(compareReleaseWindow(results)).toContainEqual({ + from: '0.5.0', + to: '0.6.0', + adjacent: false, + structural: 'identical', + wireUnchangedThroughout: false, + breakingReleaseBoundary: true, + guidance: 'unreviewed', + activation: 'not-established' + }) + results[1]!.wireHash = 'a' + const adjacent = compareReleaseWindow(results).find( + (item: { to: string }) => item.to === '0.5.1' + ) + expect(adjacent).toMatchObject({ + structural: 'presentation-only', + breakingReleaseBoundary: false, + activation: 'not-established' + }) + }) +}) diff --git a/packages/mcp-server/tests/runtime-control-status.test.ts b/packages/mcp-server/tests/runtime-control-status.test.ts new file mode 100644 index 00000000..2dd25aef --- /dev/null +++ b/packages/mcp-server/tests/runtime-control-status.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import type { Registration } from '../src/runtime-prototype/protocol' + +import { captureHostContext } from '../src/agent-clients/host-context' +import { controlComponentStatus } from '../src/runtime-prototype/status' + +function registration(version?: string): Registration { + return { + sessionId: '11111111-1111-4111-8111-111111111111', + token: '22222222-2222-4222-8222-222222222222', + context: captureHostContext({ HOME: '/private/host-home', TMPDIR: '/private/host-tmp' }), + versions: [1], + ...(version + ? { updates: { policy: 'stable' as const, version, manifestHash: 'a'.repeat(64) } } + : {}) + } +} + +describe('control component update status', () => { + it('keeps original control versions visible after a Hub update without leaking registration data', () => { + expect(controlComponentStatus('0.12.0', [registration('0.12.0')], ['0.12.1'])).toEqual({ + supervisor: { version: '0.12.0', pendingVersion: '0.12.1', restartRequired: true }, + edges: [{ version: '0.12.0', pendingVersion: '0.12.1', restartRequired: true }], + restartRequired: true + }) + }) + + it('reports the newest available version while avoiding downgrades for newer edges', () => { + expect( + controlComponentStatus( + '0.12.0', + [registration('0.12.1'), registration('0.13.0')], + ['0.12.0', '0.12.2', '0.12.1'] + ) + ).toEqual({ + supervisor: { version: '0.12.0', pendingVersion: '0.12.2', restartRequired: true }, + edges: [ + { version: '0.12.1', pendingVersion: '0.12.2', restartRequired: true }, + { version: '0.13.0', pendingVersion: null, restartRequired: false } + ], + restartRequired: true + }) + }) + + it('does not invent versions or claim unknown older registrations have current code', () => { + expect(controlComponentStatus('0.12.0', [registration(), registration('unknown')], [])).toEqual( + { + supervisor: { version: '0.12.0', pendingVersion: null, restartRequired: false }, + edges: [ + { version: null, pendingVersion: null, restartRequired: null }, + { version: null, pendingVersion: null, restartRequired: null } + ], + restartRequired: null + } + ) + }) +}) diff --git a/packages/mcp-server/tests/runtime-dependencies.test.ts b/packages/mcp-server/tests/runtime-dependencies.test.ts new file mode 100644 index 00000000..f5d0c586 --- /dev/null +++ b/packages/mcp-server/tests/runtime-dependencies.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ run: vi.fn() })) +vi.mock('node:fs', () => ({ + readFileSync: () => JSON.stringify({ packageManager: 'pnpm@12.8.2' }) +})) +vi.mock('../scripts/package-manager.mjs', () => ({ runPackageManagerSync: fixture.run })) +const modulePath = '../scripts/runtime-dependencies.mjs' +const { deployRuntimeDependencies } = await import(modulePath) + +beforeEach(() => { + fixture.run.mockReset().mockReturnValue('12.8.2\n') +}) + +it.each([false, true])('keeps the production dependency graph frozen (offline: %s)', (offline) => { + deployRuntimeDependencies('/workspace', '/temporary/deploy', { offline }) + expect(fixture.run).toHaveBeenNthCalledWith(1, 'pnpm', ['--version'], { + cwd: '/workspace', + encoding: 'utf8' + }) + expect(fixture.run).toHaveBeenNthCalledWith( + 2, + 'pnpm', + [ + '--filter', + '@tempad-dev/mcp', + 'deploy', + '--prod', + ...(offline ? ['--offline'] : []), + '--ignore-scripts', + '--frozen-lockfile', + '--config.node-linker=hoisted', + '--config.package-import-method=copy', + '/temporary/deploy' + ], + { cwd: '/workspace', stdio: 'inherit' } + ) +}) + +it('rejects a different package manager before deploying dependencies', () => { + fixture.run.mockReturnValue('11.19.0\n') + expect(() => deployRuntimeDependencies('/workspace', '/deploy', { offline: false })).toThrow( + 'pnpm version pinned by the workspace' + ) + expect(fixture.run).toHaveBeenCalledTimes(1) +}) diff --git a/packages/mcp-server/tests/runtime-environment.test.ts b/packages/mcp-server/tests/runtime-environment.test.ts new file mode 100644 index 00000000..40413dbb --- /dev/null +++ b/packages/mcp-server/tests/runtime-environment.test.ts @@ -0,0 +1,154 @@ +import { execFile } from 'node:child_process' +import { once } from 'node:events' +import { readFileSync } from 'node:fs' +import { createServer } from 'node:https' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, expect, it } from 'vitest' + +import { hostEnvironment, hubEnvironment, platformEnvironment } from '../src/process-environment' + +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +afterEach(() => Object.defineProperty(process, 'platform', platform)) + +it.each(['canonical', 'uppercase'])('preserves Windows OS variables with %s names', (casing) => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const expected = { + PATH: 'C:\\Windows\\System32', + SystemRoot: 'C:\\Windows', + SystemDrive: 'C:', + ProgramFiles: 'C:\\Program Files', + 'ProgramFiles(x86)': 'C:\\Program Files (x86)', + ProgramW6432: 'C:\\Program Files', + PROCESSOR_ARCHITECTURE: 'AMD64', + USERNAME: 'runner', + APPDATA: 'C:\\Users\\runner\\AppData\\Roaming', + LOCALAPPDATA: 'C:\\Users\\runner\\AppData\\Local', + HOMEDRIVE: 'C:', + HOMEPATH: '\\Users\\runner' + } + const env = { + ...Object.fromEntries( + Object.entries(expected).map(([key, value]) => [ + casing === 'uppercase' ? key.toUpperCase() : key, + value + ]) + ), + CODEX_THREAD_ID: 'parent-task', + CLAUDE_CODE_SESSION_ID: 'parent-session', + OPENAI_API_KEY: 'secret', + NODE_OPTIONS: '--require injected.js', + NODE_EXTRA_CA_CERTS: 'C:\\Company\\ca.pem', + PSModulePath: 'C:\\PowerShell7\\Modules', + TEMPAD_MCP_UNKNOWN: 'not-config', + tempad_mcp_tool_timeout: '1000' + } + expect(platformEnvironment(env)).toEqual(expected) + expect(hubEnvironment(env)).toEqual({ + ...expected, + TEMPAD_MCP_TOOL_TIMEOUT: '1000', + NODE_EXTRA_CA_CERTS: 'C:\\Company\\ca.pem' + }) +}) + +it('replaces all Windows home and temporary aliases with the selected host context', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const context = { + version: 1 as const, + home: 'D:\\Host B', + tempDir: 'D:\\Host B\\Temp', + codexHome: 'D:\\Host B\\.codex', + codexSqliteHome: 'D:\\Host B\\sqlite' + } + expect( + hostEnvironment(context, { + home: 'C:\\Previous', + UserProfile: 'C:\\Previous', + HomeDrive: 'C:', + HomePath: '\\Previous', + TmpDir: 'C:\\Previous\\Temp', + Temp: 'C:\\Previous\\Temp', + Tmp: 'C:\\Previous\\Temp', + PROGRAMFILES: 'C:\\Program Files', + CODEX_THREAD_ID: 'previous-task' + }) + ).toEqual({ + HOME: context.home, + USERPROFILE: context.home, + HOMEDRIVE: 'D:', + HOMEPATH: '\\Host B', + TMPDIR: context.tempDir, + TEMP: context.tempDir, + TMP: context.tempDir, + ProgramFiles: 'C:\\Program Files', + CODEX_HOME: context.codexHome, + CODEX_SQLITE_HOME: context.codexSqliteHome + }) +}) + +it('retains exact-key filtering on Unix', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }) + expect( + hubEnvironment({ + PATH: '/bin', + path: '/wrong', + HOME: '/home', + home: '/wrong', + ProgramFiles: '/not-a-unix-platform-variable', + tempad_mcp_tool_timeout: 'wrong', + TEMPAD_MCP_TOOL_TIMEOUT: '1000' + }) + ).toEqual({ PATH: '/bin', HOME: '/home', TEMPAD_MCP_TOOL_TIMEOUT: '1000' }) +}) + +it('preserves an explicit TLS CA through launcher and installer environments without weakening verification', async () => { + // Public test-only key/certificate; no external trust store is modified. + const certificate = new URL('./fixtures/runtime-ca.pem', import.meta.url) + const server = createServer( + { + cert: readFileSync(certificate), + key: readFileSync(new URL('./fixtures/runtime-ca-key.pem', import.meta.url)) + }, + (_request, response) => { + response.setHeader('connection', 'close') + response.end('verified TLS') + } + ) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const url = `https://127.0.0.1:${(server.address() as { port: number }).port}` + const worker = ` + for (const key of ['NODE_OPTIONS', 'NODE_TLS_REJECT_UNAUTHORIZED', 'CODEX_THREAD_ID', 'NPM_TOKEN']) + if (process.env[key]) throw new Error('Inherited forbidden variable: ' + key); + process.stdout.write(await (await fetch(${JSON.stringify(url)})).text()); + ` + const launcher = ` + import { execFile } from 'node:child_process'; + import { promisify } from 'node:util'; + import { hubEnvironment } from ${JSON.stringify(new URL('../src/process-environment.ts', import.meta.url).href)}; + const { stdout } = await promisify(execFile)(process.execPath, ['--input-type=module', '--eval', ${JSON.stringify(worker)}], { env: hubEnvironment(process.env) }); + process.stdout.write(stdout); + ` + const env = hubEnvironment({ + ...process.env, + NODE_EXTRA_CA_CERTS: undefined, + NODE_OPTIONS: '--require=must-not-run', + NODE_TLS_REJECT_UNAUTHORIZED: '0', + CODEX_THREAD_ID: 'unrelated', + NPM_TOKEN: 'must-not-inherit' + }) + const run = (env: NodeJS.ProcessEnv) => + promisify(execFile)(process.execPath, ['--input-type=module', '--eval', launcher], { + env, + timeout: 10000 + }) + try { + await expect(run(env)).rejects.toThrow('fetch failed') + await expect( + run({ ...env, NODE_EXTRA_CA_CERTS: fileURLToPath(certificate) }) + ).resolves.toHaveProperty('stdout', 'verified TLS') + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } +}) diff --git a/packages/mcp-server/tests/runtime-handoff.test.ts b/packages/mcp-server/tests/runtime-handoff.test.ts new file mode 100644 index 00000000..632802f8 --- /dev/null +++ b/packages/mcp-server/tests/runtime-handoff.test.ts @@ -0,0 +1,530 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { CLIENT_HOOK_RETENTION_MS, type ClientHook } from '@tempad-dev/shared' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { chmodSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { connect, createServer, type Socket } from 'node:net' +import { join } from 'node:path' +import { setTimeout as delay } from 'node:timers/promises' +import { afterEach, expect, it, vi } from 'vitest' + +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { writeDurableJson } from '../src/durable-json' +import { HOOK_RECEIPT_LIMIT } from '../src/hook-receipts' +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { startHookGateway } from '../src/runtime-prototype/hooks' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { connectSocket, listenPrivate } from '../src/runtime-prototype/processes' +import { ControlPeer, SocketTransport } from '../src/runtime-prototype/protocol' +import { + newerVersion, + readJournal, + writeJournal, + readEndpoints, + writeEndpoints +} from '../src/runtime-prototype/state' + +const cleanup: (() => unknown | Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() +}) +function directory() { + const root = createPrivateTempDirectory('tp-handoff-') + mkdirSync(join(root, 'run'), { mode: 0o700 }) + cleanup.push(() => rmSync(root, { recursive: true, force: true })) + return root +} +function tasks(root: string) { + const store = new DesignTaskStore(join(root, 'tasks.json')) + const tasks = new DesignTasks({ store, createId: () => 'task' }) + tasks.restore() + return { tasks, store } +} + +it('expires receipt-capable retries with a durable cutoff, even after the clock moves backward', () => { + const root = directory() + const store = new DesignTaskStore(join(root, 'tasks.json')) + let now = Date.now() + const current = new DesignTasks({ store, now: () => now, createId: randomUUID }) + current.restore() + const input: ClientHook = { + version: 2, + kind: 'claude', + sessionId: 'session', + event: 'Stop', + invocationId: randomUUID(), + profile: { kind: 'release' }, + issuedAt: now + } + const apply = vi.fn(() => ({})) + current.applyHook(input.invocationId, input, apply) + current.applyHook(input.invocationId, input, apply) + expect(apply).toHaveBeenCalledOnce() + now += CLIENT_HOOK_RETENTION_MS + 1 + const next = { ...input, issuedAt: now, invocationId: randomUUID() } + current.applyHook(next.invocationId, next, apply) + expect(store.load()?.hookReceipts).toHaveLength(1) + now = input.issuedAt + const restored = new DesignTasks({ store, now: () => now, createId: randomUUID }) + restored.restore() + expect(() => restored.applyHook(input.invocationId, input, apply)).toThrow('expired') + expect(apply).toHaveBeenCalledTimes(2) +}) + +it.skipIf(process.platform === 'win32')( + 'distinguishes durable hook admission from application and deduplicates retries', + async () => { + const root = directory() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const deliver = vi.fn(async () => { + await gate + return { notice: 'Stop' } + }) + const gateway = await startHookGateway(root, deliver) + cleanup.push(async () => { + release() + await gateway.close() + }) + const input: ClientHook = { + version: 2, + kind: 'claude', + sessionId: 'session', + event: 'Stop', + invocationId: randomUUID(), + profile: { kind: 'release' }, + issuedAt: Date.now() + } + const request = async () => { + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params: input }) + + '\n' + ) + const result = JSON.parse(String((await reply)[0])).result + socket.destroy() + return result + } + expect(await request()).toEqual({ + receipt: { version: 2, invocationId: input.invocationId, state: 'accepted' } + }) + expect(await request()).toHaveProperty('receipt.state', 'accepted') + release() + await gateway.flush() + expect(await request()).toEqual({ + notice: 'Stop', + receipt: { version: 2, invocationId: input.invocationId, state: 'applied' } + }) + expect(deliver).toHaveBeenCalledOnce() + } +) + +it.skipIf(process.platform === 'win32')( + 'returns a restart error to an old CLI after its identity prefix', + async () => { + const root = directory() + const gateway = await startHookGateway(root, async () => ({})) + cleanup.push(() => gateway.close()) + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + const reply = once(socket, 'data') + socket.write( + '{"jsonrpc":"2.0","method":"notifications/tempad/host-context","params":{}}\n{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n' + ) + expect(JSON.parse(String((await reply)[0])).error.message).toContain( + 'MCP_RUNTIME_RESTART_REQUIRED' + ) + socket.destroy() + } +) + +it('records a lifecycle effect and receipt in the same durable task snapshot', () => { + const root = directory() + const current = tasks(root) + current.tasks.begin( + 'owner', + 'extension', + { + sessionId: 'page', + fileKey: 'file', + fileName: 'File', + pageId: '0:1', + busy: false + }, + 'Work', + 'begin' + ) + const id = randomUUID() + const apply = vi.fn(() => { + current.tasks.stop('task', 'cancelled') + return { applied: true } + }) + const save = vi.spyOn(current.store, 'save') + expect(current.tasks.applyHook(id, { event: 'Stop' }, apply)).toEqual({ applied: true }) + expect(save).toHaveBeenCalledOnce() + const restored = tasks(root).tasks + expect(restored.find('task')?.task.status).toBe('cancelled') + expect(restored.applyHook(id, { event: 'Stop' }, apply)).toEqual({ applied: true }) + expect(apply).toHaveBeenCalledOnce() + expect(() => restored.applyHook(id, { event: 'Start' }, apply)).toThrow('different content') +}) + +it('does not acknowledge a failed receipt commit or repeat its in-memory effect on retry', () => { + const { tasks: current, store } = tasks(directory()) + const save = vi.spyOn(store, 'save').mockImplementationOnce(() => { + throw new Error('ENOSPC') + }) + const apply = vi.fn(() => ({ notice: 'Stop' })) + const id = randomUUID() + expect(() => current.applyHook(id, {}, apply)).toThrow('cannot be flushed') + expect(current.applyHook(id, {}, apply)).toEqual({ notice: 'Stop' }) + expect(apply).toHaveBeenCalledOnce() + expect(save).toHaveBeenCalledTimes(2) +}) + +it('keeps version, endpoint capabilities and journal ownership across restart', () => { + const root = directory() + const journal = { + version: 1 as const, + revision: 1, + phase: 'preparing' as const, + active: { entry: join(root, 'a/runtime.mjs'), bundle: 'a'.repeat(64), version: '0.11.0' }, + candidate: { entry: join(root, 'b/runtime.mjs'), bundle: 'b'.repeat(64), version: '0.11.1' }, + failedBundles: [] + } + writeJournal(root, journal) + expect(readJournal(root)).toEqual(journal) + const endpoints = { + wsPort: 12345, + httpPort: 12346, + accessToken: 'a'.repeat(64), + legacyAccessToken: 'b'.repeat(64) + } + writeEndpoints(root, endpoints) + expect(readEndpoints(root)).toEqual(endpoints) + expect(newerVersion('0.11.1', '0.11.0')).toBe(true) + expect(newerVersion('0.10.9', '0.11.0')).toBe(false) + expect(newerVersion('0.11.0', '0.11.0')).toBe(false) + if (process.platform !== 'win32') { + chmodSync(join(root, 'switch.json'), 0o644) + expect(() => readJournal(root)).toThrow('untrusted') + } +}) + +it('never repeats a lifecycle effect that threw after changing state', () => { + const root = directory() + const id = randomUUID() + const apply = vi.fn(() => { + throw new Error('after effect') + }) + expect(() => tasks(root).tasks.applyHook(id, {}, apply)).toThrow('after effect') + expect(() => tasks(root).tasks.applyHook(id, {}, apply)).toThrow('remains uncertain') + expect(apply).toHaveBeenCalledOnce() +}) + +it.skipIf(process.platform === 'win32')( + 'drains before stopping and keeps metadata available behind the gate', + async () => { + const root = directory() + let drained = false + let autoBlockers: string[] | undefined + let idleForMs = 0 + const flush = vi.fn() + const stop = vi.fn(async () => {}) + const control = new HubControl('secret', undefined, { + inspect: () => ({ + drained, + deadline: Date.now() + 10000, + blockers: drained ? [] : ['work'], + version: '1', + autoBlockers, + idleForMs + }), + flush, + stop, + hook: () => ({}) + }) + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + void control.attach(socket) + }) + await listenPrivate(server, join(root, 'control.sock')) + cleanup.push(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + const peer = new ControlPeer() + await peer.connect(new SocketTransport(await connectSocket(join(root, 'control.sock')))) + cleanup.push(() => peer.close()) + const lease = await peer.request( + { method: 'tempad/runtime-acquire', params: { secret: 'secret', versions: [2] } }, + LeaseSchema + ) + const registration = { + sessionId: randomUUID(), + token: randomUUID(), + versions: [2], + context: { version: 1, home: root, tempDir: root, codexHome: root, codexSqliteHome: root } + } + await peer.request({ method: 'tempad/runtime-register', params: registration }, ResultSchema) + await expect( + peer.request({ method: 'tempad/runtime-stop', params: { pausedSessions: [] } }, ResultSchema) + ).rejects.toThrow('not drained') + await peer.request({ method: 'tempad/runtime-gate', params: { closed: true } }, ResultSchema) + expect(control.admits(registration.sessionId, lease.generation, 'tools/call')).toBe(false) + expect(control.admits(registration.sessionId, lease.generation, 'tools/list')).toBe(true) + await expect( + peer.request({ method: 'tempad/runtime-stop', params: { pausedSessions: [] } }, ResultSchema) + ).rejects.toThrow('not drained') + drained = true + const automaticStop = () => + peer.request( + { method: 'tempad/runtime-stop', params: { pausedSessions: [], automatic: true } }, + ResultSchema + ) + await expect(automaticStop()).rejects.toThrow('no longer idle or eligible') + autoBlockers = ['Claude acceptance pending'] + idleForMs = 5000 + await expect(automaticStop()).rejects.toThrow('no longer idle or eligible') + autoBlockers = [] + idleForMs = 4999 + await expect(automaticStop()).rejects.toThrow('no longer idle or eligible') + expect(stop).not.toHaveBeenCalled() + flush.mockImplementationOnce(() => { + throw new Error('ENOSPC') + }) + await expect( + peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + ).rejects.toThrow('ENOSPC') + expect(control.admitting()).toBe(false) + idleForMs = 5000 + await automaticStop() + await vi.waitFor(() => expect(stop).toHaveBeenCalledOnce()) + await expect( + peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + ).rejects.toThrow('Connection closed') + } +) + +it.skipIf(process.platform === 'win32')( + 'retains hooks across a slow receiver, lost reply and gateway restart', + async () => { + const root = directory() + let available = false + const input = { + version: 1, + kind: 'claude', + sessionId: 'session', + event: 'Interrupt', + invocationId: randomUUID() + } + const applied = new Map>() + const deliver = vi.fn(async (value) => { + if (!available) throw new Error('switching') + const prior = applied.get(value.invocationId) + if (prior) return prior + const result = { notice: 'stopped' } + applied.set(value.invocationId, result) + return result + }) + let gateway = await startHookGateway(root, deliver) + cleanup.push(() => gateway.close()) + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + const closed = once(socket, 'close') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params: input }) + '\n' + ) + await closed + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries[0].input).toEqual( + input + ) + await gateway.close() + available = true + gateway = await startHookGateway(root, deliver) + await gateway.flush() + const retry = connect(join(root, 'run/mcp.sock')) + await once(retry, 'connect') + const reply = once(retry, 'data') + retry.write( + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tempad/client-hook', params: input }) + '\n' + ) + expect(JSON.parse(String((await reply)[0])).result).toEqual({ notice: 'stopped' }) + retry.destroy() + expect(deliver).toHaveBeenCalledTimes(2) + expect(applied.size).toBe(1) + } +) + +it.skipIf(process.platform === 'win32')( + 'keeps a delivery fault fenced through the whole backlog, including concurrent admissions', + async () => { + const root = directory() + const input = (event: ClientHook['event']): ClientHook => ({ + version: 2, + kind: 'claude', + sessionId: 'session', + event, + turnId: 'turn', + invocationId: randomUUID(), + profile: { kind: 'release' }, + issuedAt: Date.now() + }) + const first = input('UserPromptSubmit') + const second = input('Stop') + const third = input('SessionEnd') + writeDurableJson(join(root, 'hooks.json'), { + version: 2, + cutoff: 0, + entries: [{ input: first }, { input: second }] + }) + const secondGate = Promise.withResolvers() + const thirdGate = Promise.withResolvers() + let failing = true + const fault = vi.fn() + const deliver = vi.fn(async (hook: ClientHook) => { + if (failing) throw new Error('Hub temporarily unavailable') + if (hook.invocationId === second.invocationId) await secondGate.promise + if (hook.invocationId === third.invocationId) await thirdGate.promise + return {} + }) + const gateway = await startHookGateway(root, deliver, fault) + cleanup.push(() => gateway.close()) + await expect(gateway.flush()).rejects.toThrow('temporarily unavailable') + failing = false + const retry = gateway.flush() + try { + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(second, undefined)) + expect(fault).toHaveBeenLastCalledWith(expect.any(Error)) + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params: third }) + + '\n' + ) + try { + expect(JSON.parse(String((await reply)[0])).result.receipt.state).toBe('accepted') + } finally { + socket.destroy() + } + secondGate.resolve() + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(third, undefined)) + expect(fault).toHaveBeenLastCalledWith(expect.any(Error)) + } finally { + secondGate.resolve() + thirdGate.resolve() + await retry + } + expect(fault).toHaveBeenLastCalledWith(undefined) + expect(deliver.mock.calls.map(([hook]) => hook.invocationId)).toEqual([ + first.invocationId, + first.invocationId, + second.invocationId, + third.invocationId + ]) + } +) + +it.skipIf(process.platform === 'win32')( + 'retires legacy sessions after draining a full restored legacy history without permanently fencing MCP', + async () => { + const root = directory() + const entries = Array.from({ length: HOOK_RECEIPT_LIMIT }, (_, index) => ({ + input: { + version: 1, + kind: 'claude', + sessionId: 'session', + event: 'SessionEnd', + invocationId: randomUUID() + }, + ...(index < HOOK_RECEIPT_LIMIT - 1 ? { result: {} } : {}) + })) + writeDurableJson(join(root, 'hooks.json'), { version: 2, cutoff: 0, entries }) + const fault = vi.fn() + const deliver = vi.fn(async () => ({})) + const retire = vi.fn(async () => {}) + const gateway = await startHookGateway(root, deliver, fault, undefined, retire) + cleanup.push(() => gateway.close()) + expect(fault).toHaveBeenLastCalledWith( + expect.objectContaining({ message: 'Lifecycle hooks remain queued.' }) + ) + await gateway.flush() + expect(deliver).toHaveBeenCalledOnce() + expect(fault).toHaveBeenLastCalledWith(undefined) + expect(retire).toHaveBeenCalledOnce() + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/client-hook', + params: { ...entries[0]!.input, invocationId: randomUUID() } + }) + '\n' + ) + expect(JSON.parse(String((await reply)[0])).error.data.code).toBe( + 'MCP_HOOK_LEGACY_UPGRADE_REQUIRED' + ) + socket.destroy() + expect(deliver).toHaveBeenCalledOnce() + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries).toHaveLength( + HOOK_RECEIPT_LIMIT + ) + } +) + +it.skipIf(process.platform === 'win32')( + 'keeps a received event when a legacy hook exits at its deadline', + async () => { + const root = directory() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const deliver = vi.fn(async () => { + await gate + return { applied: true } + }) + const gateway = await startHookGateway(root, deliver) + cleanup.push(async () => { + release() + await gateway.close() + }) + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + socket.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tempad/client-hook', + params: { + version: 1, + kind: 'claude', + sessionId: 'session', + event: 'SessionEnd', + invocationId: randomUUID() + } + }) + '\n' + ) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledOnce()) + await delay(800) + const closed = once(socket, 'close') + socket.destroy() + await closed + release() + await gateway.flush() + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries[0].result).toEqual({ + applied: true + }) + expect(deliver).toHaveBeenCalledOnce() + } +) diff --git a/packages/mcp-server/tests/runtime-hook-clock.test.ts b/packages/mcp-server/tests/runtime-hook-clock.test.ts new file mode 100644 index 00000000..6182ba25 --- /dev/null +++ b/packages/mcp-server/tests/runtime-hook-clock.test.ts @@ -0,0 +1,140 @@ +import type { ClientHook } from '@tempad-dev/shared' + +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +import { writeDurableJson } from '../src/durable-json' +import { ensurePrivateDirectory, runtimeEndpoint } from '../src/runtime-platform' +import { startHookGateway } from '../src/runtime-prototype/hooks' +import { connectSocket } from '../src/runtime-prototype/processes' + +const platformModule = '../scripts/runtime-test-platform.mjs' +const { fixtureRoot } = await import(platformModule) +const cleanup: (() => unknown | Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() +}) + +function hook(issuedAt: number): Extract { + return { + version: 2, + kind: 'claude', + sessionId: 'session', + event: 'PreToolUse', + invocationId: randomUUID(), + profile: { kind: 'release' }, + issuedAt + } +} + +async function directory() { + const root = await fixtureRoot('tp-hook-clock-') + ensurePrivateDirectory(join(root, 'run')) + cleanup.push(() => rmSync(root, { recursive: true, force: true })) + return root as string +} + +async function request(root: string, input: ClientHook) { + const socket = await connectSocket(runtimeEndpoint(join(root, 'run'), 'mcp.sock')) + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params: input }) + '\n' + ) + try { + return JSON.parse(String((await reply)[0])) + } finally { + socket.destroy() + } +} + +it('retries a one-millisecond future invocation without losing its Stop output', async () => { + const root = await directory() + const hubId = randomUUID() + const output = { + hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: 'The task was stopped.' } + } + const deliver = vi.fn(async (_input: ClientHook, admittedHubId?: string) => + admittedHubId === hubId ? { output } : {} + ) + const gateway = await startHookGateway(root, deliver) + cleanup.push(() => gateway.close()) + gateway.setReceiver(hubId) + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const input = hook(now + 1) + + expect((await request(root, input)).error).toMatchObject({ + code: -32001, + data: { code: 'MCP_HOOK_CLOCK_AHEAD' }, + message: expect.stringContaining('retry the same invocation') + }) + expect(deliver).not.toHaveBeenCalled() + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries).toEqual([]) + + now++ + await request(root, input) + await gateway.flush() + const applied = await request(root, input) + expect(applied.result).toEqual({ + output, + receipt: { version: 2, invocationId: input.invocationId, state: 'applied' } + }) + now-- + expect(await request(root, input)).toEqual(applied) + expect(deliver).toHaveBeenCalledExactlyOnceWith(input, hubId) +}) + +it('preserves valid admission evidence when the clock moves backward before delivery', async () => { + const root = await directory() + const hubId = randomUUID() + let now = Date.now() + const first = hook(now) + const second = hook(now) + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const deliver = vi.fn(async (input: ClientHook, admittedHubId?: string) => { + if (input.invocationId === first.invocationId) await barrier + return { admittedHubId } + }) + const gateway = await startHookGateway(root, deliver) + cleanup.push(async () => { + release() + await gateway.flush() + await gateway.close() + }) + gateway.setReceiver(hubId) + vi.spyOn(Date, 'now').mockImplementation(() => now) + expect((await request(root, first)).result.receipt.state).toBe('accepted') + expect((await request(root, second)).result.receipt.state).toBe('accepted') + + now-- + release() + await gateway.flush() + expect(deliver).toHaveBeenNthCalledWith(2, second, hubId) +}) + +it('never promotes a previously accepted hook without admission evidence', async () => { + const root = await directory() + const hubId = randomUUID() + const input = hook(Date.now() + 1000) + writeDurableJson(join(root, 'hooks.json'), { + version: 2, + cutoff: 0, + receiver: { hubId }, + entries: [{ input }] + }) + const deliver = vi.fn(async () => ({})) + const gateway = await startHookGateway(root, deliver) + cleanup.push(() => gateway.close()) + gateway.setReceiver(hubId) + vi.spyOn(Date, 'now').mockReturnValue(input.issuedAt + 1) + await gateway.flush() + await request(root, input) + expect(deliver).toHaveBeenCalledExactlyOnceWith(input, undefined) +}) diff --git a/packages/mcp-server/tests/runtime-hook-evidence.test.ts b/packages/mcp-server/tests/runtime-hook-evidence.test.ts new file mode 100644 index 00000000..a536eb24 --- /dev/null +++ b/packages/mcp-server/tests/runtime-hook-evidence.test.ts @@ -0,0 +1,442 @@ +import type { ClientHook } from '@tempad-dev/shared' + +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { createServer, type Socket } from 'node:net' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +import { AgentClients } from '../src/agent-clients/registry' +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { writeDurableJson } from '../src/durable-json' +import { startHookGateway } from '../src/runtime-prototype/hooks' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { connectSocket, listenPrivate } from '../src/runtime-prototype/processes' +import { ControlPeer, SocketTransport } from '../src/runtime-prototype/protocol' + +const cleanup: (() => unknown | Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() +}) +const session = { + sessionId: 'figma', + fileKey: 'file', + fileName: 'File', + pageId: '0:1', + busy: false +} +function directory() { + const root = realpathSync(mkdtempSync('/tmp/tp-hook-evidence-')) + mkdirSync(join(root, 'run'), { mode: 0o700 }) + cleanup.push(() => rmSync(root, { recursive: true, force: true })) + return root +} +function input( + event: ClientHook['event'] = 'PreToolUse', + issuedAt = Date.now(), + turnId = 'turn' +): Extract { + return { + version: 2, + kind: 'claude', + sessionId: 'session', + event, + turnId, + issuedAt, + profile: { kind: 'release' }, + invocationId: randomUUID() + } +} +async function hub(root: string, versions = [2]) { + const store = new DesignTaskStore(join(root, 'tasks.json')) + const tasks = new DesignTasks({ store, createId: randomUUID }) + tasks.restore() + const clients = new AgentClients(tasks) + const effect = vi.fn((hook: ClientHook, fresh: boolean) => clients.clientHook(hook, fresh)) + const secret = randomUUID() + const control = new HubControl(secret, () => 0, { + inspect: () => ({ + drained: true, + deadline: Date.now() + 1000, + blockers: [], + version: '0.12.0' + }), + flush: () => tasks.flush(), + stop: async () => {}, + hook: (hook, fresh) => + tasks.applyHook( + hook.invocationId, + hook, + () => effect(hook, fresh), + () => clients.retireHook(hook) + ) + }) + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + void control.attach(socket) + }) + const path = join(root, `${control.hubId}.sock`) + await listenPrivate(server, path) + const peer = new ControlPeer() + await peer.connect(new SocketTransport(await connectSocket(path))) + cleanup.push(async () => { + clients.close() + await peer.close() + await control.close() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + await peer.request( + { method: 'tempad/runtime-acquire', params: { secret, versions } }, + LeaseSchema + ) + const deliver = (hook: ClientHook, admittedHubId?: string) => + peer.request( + { + method: 'tempad/runtime-hook', + params: { input: hook, ...(admittedHubId ? { admittedHubId } : {}) } + }, + ResultSchema + ) + const connect = (id = 'edge', sessionId = 'session') => + clients.event(id, { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId } + }) + const owner = (id = 'edge') => clients.owner(id, undefined, { name: 'claude-code' }) + return { clients, tasks, control, effect, deliver, connect, owner, peer } +} +async function request(root: string, params: ClientHook) { + const socket = await connectSocket(join(root, 'run/mcp.sock')) + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params }) + '\n' + ) + try { + return JSON.parse(String((await reply)[0])) + } finally { + socket.destroy() + } +} + +it.skipIf(process.platform === 'win32').each(['without-receiver', 'previous-hub'])( + 'does not reclaim a session from persisted %s hook evidence', + async (format) => { + const root = directory() + const oldHubId = randomUUID() + const queued = input('PreToolUse', Date.now() - 5000, 'old-turn') + const entries = [ + { input: queued, ...(format === 'previous-hub' ? { admittedHubId: oldHubId } : {}) } + ] + writeDurableJson(join(root, 'hooks.json'), { + version: 2, + cutoff: 0, + ...(format === 'previous-hub' ? { receiver: { hubId: oldHubId } } : {}), + entries + }) + const current = await hub(root) + current.connect() + const gateway = await startHookGateway(root, current.deliver) + cleanup.push(() => gateway.close()) + gateway.setReceiver(current.control.hubId) + expect(await current.owner()).toBe('edge') + await gateway.flush() + expect(current.effect).toHaveBeenCalledExactlyOnceWith(queued, false) + expect(await current.owner()).toBe('edge') + expect(current.clients.hasLegacyHooks).toBe(true) + } +) + +it.skipIf(process.platform === 'win32')( + 'accepts the initiating hook before first Hub startup, including legacy hooks', + async () => { + const root = directory() + const first = input('UserPromptSubmit', Date.now() - 5000) + const current = await hub(root) + current.connect() + const gateway = await startHookGateway(root, current.deliver) + cleanup.push(() => gateway.close()) + gateway.setReceiver(current.control.hubId) + await request(root, first) + await gateway.flush() + expect(current.effect).toHaveBeenCalledExactlyOnceWith(first, true) + expect(await current.owner()).toBe('edge/claude/session') + expect(current.clients.hasLegacyHooks).toBe(false) + const legacy: ClientHook = { + version: 1, + kind: 'claude', + sessionId: 'legacy-session', + event: 'PreToolUse', + invocationId: randomUUID() + } + await request(root, legacy) + expect(current.effect).toHaveBeenLastCalledWith(legacy, true) + } +) + +it.skipIf(process.platform === 'win32')( + 'keeps first-admission provenance across retries and supervisor restart against the same Hub', + async () => { + const root = directory() + const current = await hub(root) + current.connect() + const first = input('UserPromptSubmit', Date.now() - 5000) + const failed = await startHookGateway(root, async () => { + throw new Error('transport unavailable') + }) + failed.setReceiver(current.control.hubId) + expect((await request(root, first)).result.receipt.state).toBe('accepted') + await expect(failed.flush()).rejects.toThrow('transport unavailable') + await failed.close() + const gateway = await startHookGateway(root, current.deliver) + cleanup.push(() => gateway.close()) + gateway.setReceiver(current.control.hubId) + await request(root, first) + await gateway.flush() + expect(current.effect).toHaveBeenCalledExactlyOnceWith(first, true) + expect(await current.owner()).toBe('edge/claude/session') + const stored = JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')) + expect(stored.receiver).toEqual({ hubId: current.control.hubId }) + expect(stored.entries[0].admittedHubId).toBe(current.control.hubId) + } +) + +it.skipIf(process.platform === 'win32')( + 'cannot promote old retries or delayed first receipts after true Hub replacement', + async () => { + const root = directory() + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const old = input('UserPromptSubmit') + let current: Awaited> | null = null + const gateway = await startHookGateway(root, (hook, admittedHubId) => { + if (!current) throw new Error('old Hub crashed') + return current.deliver(hook, admittedHubId) + }) + cleanup.push(() => gateway.close()) + const oldHubId = randomUUID() + gateway.setReceiver(oldHubId) + await request(root, old) + await expect(gateway.flush()).rejects.toThrow('old Hub crashed') + now += 5000 + current = await hub(root) + current.connect() + gateway.setReceiver(current.control.hubId) + await request(root, old) + await gateway.flush() + expect(current.effect).toHaveBeenLastCalledWith(old, false) + expect( + JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries[0].admittedHubId + ).toBe(oldHubId) + const delayed = { ...old, invocationId: randomUUID() } + await request(root, delayed) + await gateway.flush() + expect(current.effect).toHaveBeenLastCalledWith(delayed, false) + expect(await current.owner()).toBe('edge') + now++ + const fresh = input('UserPromptSubmit') + await request(root, fresh) + await gateway.flush() + expect(current.effect).toHaveBeenLastCalledWith(fresh, true) + expect(await current.owner()).toBe('edge/claude/session') + } +) + +it.skipIf(process.platform === 'win32')( + 'authenticates the private hook envelope and rejects legacy raw messages without effects', + async () => { + const current = await hub(directory()) + current.connect() + const historical = input() + await current.deliver(historical, randomUUID()) + expect(current.effect).toHaveBeenLastCalledWith(historical, false) + expect(await current.owner()).toBe('edge') + const raw = input() + await expect( + current.peer.request({ method: 'tempad/runtime-hook', params: raw }, ResultSchema) + ).rejects.toThrow() + expect(current.effect).toHaveBeenCalledExactlyOnceWith(historical, false) + expect(await current.owner()).toBe('edge') + const fresh = input() + await current.deliver(fresh, current.control.hubId) + expect(current.effect).toHaveBeenLastCalledWith(fresh, true) + expect(await current.owner()).toBe('edge/claude/session') + } +) + +it.skipIf(process.platform === 'win32')( + 'rejects control v1 before acquiring a provenance-capable Hub lease', + async () => { + await expect(hub(directory(), [1])).rejects.toThrow('MCP_RUNTIME_RESTART_REQUIRED') + } +) + +it.skipIf(process.platform === 'win32')( + 'does not admit a future-issued hook behind a blocked delivery', + async () => { + const root = directory() + const current = await hub(root) + current.connect() + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const blocker = { ...input(), sessionId: 'other-session' } + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + const gateway = await startHookGateway(root, async (hook, admittedHubId) => { + if (hook.invocationId === blocker.invocationId) await barrier + return current.deliver(hook, admittedHubId) + }) + cleanup.push(async () => { + release() + await gateway.flush() + await gateway.close() + }) + gateway.setReceiver(current.control.hubId) + expect((await request(root, blocker)).result.receipt.state).toBe('accepted') + const future = input('UserPromptSubmit', now + 1000) + expect((await request(root, future)).error).toMatchObject({ code: -32001 }) + expect(current.effect).not.toHaveBeenCalled() + now += 2000 + release() + await gateway.flush() + expect(current.effect).toHaveBeenCalledExactlyOnceWith(blocker, true) + expect(await current.owner()).toBe('edge') + await request(root, future) + await gateway.flush() + expect(current.effect).toHaveBeenCalledTimes(2) + expect(current.effect).toHaveBeenLastCalledWith(future, true) + expect(await current.owner()).toBe('edge/claude/session') + } +) + +it.skipIf(process.platform === 'win32')( + 'keeps the replacement freshness boundary across same-Hub gateway restart', + async () => { + const root = directory() + const current = await hub(root) + current.connect() + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const before = await startHookGateway(root, current.deliver) + before.setReceiver(randomUUID()) + before.setReceiver(current.control.hubId) + await before.close() + const gateway = await startHookGateway(root, current.deliver) + cleanup.push(() => gateway.close()) + now += 1000 + gateway.setReceiver(current.control.hubId) + const delayed = input('UserPromptSubmit', now - 1000) + await request(root, delayed) + await gateway.flush() + expect(current.effect).toHaveBeenLastCalledWith(delayed, false) + expect(await current.owner()).toBe('edge') + const fresh = input('UserPromptSubmit') + await request(root, fresh) + await gateway.flush() + expect(current.effect).toHaveBeenLastCalledWith(fresh, true) + expect(await current.owner()).toBe('edge/claude/session') + } +) + +it + .skipIf(process.platform === 'win32') + .each(['UserPromptSubmit', 'PreToolUse', 'PostToolUse'] as const)( + 'historical %s neither binds a task nor consumes a pending Stop notice', + async (event) => { + const current = await hub(directory()) + current.connect() + const owner = await current.owner() + const record = current.tasks.begin(owner, 'extension', session, 'Stopped work', 'begin', { + kind: 'claude', + name: 'Claude', + sessionId: 'session' + }) + current.tasks.stop(record.task.taskId, 'cancelled', { sessionId: 'session', turnId: 'turn' }) + current.tasks.requireFileRead(session.fileKey, true) + current.tasks.saveExecutions([ + { + id: 'uncertain-write', + revision: randomUUID(), + recordedAt: Date.now(), + receipt: { generation: 'generation', receiptId: 'receipt' }, + extensionId: 'extension', + browserId: 'browser', + origin: 'chrome-extension://owner', + session, + taskId: record.task.taskId, + handoff: true + } + ]) + const historical = { ...input(event), taskId: record.task.taskId } + expect(await current.deliver(historical)).toEqual({}) + expect(record.ownerId).toBe(owner) + expect(await current.owner()).toBe('edge') + expect(record.claudeStopNotice).toEqual({ sessionId: 'session', turnId: 'turn' }) + expect(record.task.status).toBe('cancelled') + expect(current.tasks.needsFileRead(session.fileKey)).toBe(true) + expect(current.tasks.executionState()).toHaveLength(1) + const result = await current.deliver(input('PreToolUse'), current.control.hubId) + expect(result).toMatchObject({ + output: { + hookSpecificOutput: { additionalContext: expect.stringContaining(record.task.taskId) } + } + }) + expect(record.task.status).toBe('cancelled') + } +) + +it.skipIf(process.platform === 'win32').each(['Stop', 'Interrupt', 'SessionEnd'] as const)( + 'historical %s pauses an old task but cannot stop a newer live turn', + async (event) => { + const current = await hub(directory()) + current.connect() + const old = input(event, Date.now() - 1000, 'old-turn') + const owner = await current.owner() + const prior = current.tasks.begin(owner, 'extension', session, 'Old work', 'old-begin', { + kind: 'claude', + name: 'Claude', + sessionId: 'session' + }) + current.tasks.confirm(prior.task.taskId, owner) + await current.deliver(old) + expect(prior.task.status).toBe('paused') + expect(await current.owner()).toBe('edge') + current.clients.disconnect('edge') + + await current.deliver(input('UserPromptSubmit', Date.now(), 'new-turn'), current.control.hubId) + current.connect('new-edge') + const nextOwner = await current.owner('new-edge') + const next = current.tasks.begin(nextOwner, 'extension', session, 'New work', 'new-begin', { + kind: 'claude', + name: 'Claude', + sessionId: 'session' + }) + current.tasks.confirm(next.task.taskId, nextOwner) + const delayed: ClientHook[] = [ + { ...old, turnId: 'new-turn', invocationId: randomUUID() }, + { ...old, issuedAt: Date.now() + 1, invocationId: randomUUID() }, + { + version: 1, + kind: 'claude', + sessionId: old.sessionId, + event, + turnId: 'new-turn', + invocationId: randomUUID() + } + ] + for (const historical of delayed) { + await current.deliver(historical) + expect(next.task.status).toBe('active') + expect(await current.owner('new-edge')).toBe(nextOwner) + } + expect(current.clients.hasLegacyHooks).toBe(false) + } +) diff --git a/packages/mcp-server/tests/runtime-hook-expiry.test.ts b/packages/mcp-server/tests/runtime-hook-expiry.test.ts new file mode 100644 index 00000000..0d526a38 --- /dev/null +++ b/packages/mcp-server/tests/runtime-hook-expiry.test.ts @@ -0,0 +1,251 @@ +import { CLIENT_HOOK_RETENTION_MS, type ClientHook } from '@tempad-dev/shared' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { mkdirSync, readFileSync, rmSync } from 'node:fs' +import { connect } from 'node:net' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +import { AgentClients } from '../src/agent-clients/registry' +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { writeDurableJson } from '../src/durable-json' +import { EXPIRED_HOOK_RESULT, HOOK_RECEIPT_LIMIT } from '../src/hook-receipts' +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { startHookGateway } from '../src/runtime-prototype/hooks' + +const cleanup: (() => unknown | Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() +}) +const session = { + sessionId: 'figma', + fileKey: 'file', + fileName: 'File', + pageId: '0:1', + busy: false +} +function directory() { + const root = createPrivateTempDirectory('tp-hook-expiry-') + mkdirSync(join(root, 'run'), { mode: 0o700 }) + cleanup.push(() => rmSync(root, { force: true, recursive: true })) + return root +} +function hook( + event: ClientHook['event'] = 'Stop', + issuedAt = Date.now() +): Extract { + return { + version: 2, + kind: 'claude', + sessionId: 'old-session', + event, + invocationId: randomUUID(), + profile: { kind: 'release' }, + issuedAt + } +} +function runtime(root: string) { + const store = new DesignTaskStore(join(root, 'tasks.json')) + const tasks = new DesignTasks({ + store, + createId: randomUUID, + now: () => Date.now(), + leaseMs: 2 * CLIENT_HOOK_RETENTION_MS + }) + tasks.restore() + const clients = new AgentClients(tasks) + cleanup.push(() => clients.close()) + const apply = vi.fn((input: ClientHook) => clients.clientHook(input)) + const deliver = async (input: ClientHook) => + tasks.applyHook( + input.invocationId, + input, + () => apply(input), + () => clients.retireHook(input) + ) + return { store, tasks, clients, apply, deliver } +} +async function request(root: string, input: ClientHook) { + const socket = connect(join(root, 'run/mcp.sock')) + await once(socket, 'connect') + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params: input }) + '\n' + ) + try { + return JSON.parse(String((await reply)[0])) + } finally { + socket.destroy() + } +} + +it.skipIf(process.platform === 'win32')( + 'retires a legitimately admitted hook after offline expiry, then admits fresh sessions across restarts', + async () => { + const root = directory() + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const input = hook() + const before = runtime(root) + const task = before.tasks.begin('owner', 'extension', session, 'Work', 'begin', { + kind: 'claude', + name: 'Claude', + sessionId: input.sessionId + }) + before.tasks.stop(task.task.taskId, 'cancelled', { sessionId: input.sessionId }) + before.tasks.requireFileRead(session.fileKey, true) + before.tasks.saveExecutions([ + { + id: 'uncertain-write', + revision: randomUUID(), + recordedAt: now, + receipt: { generation: 'generation', receiptId: 'receipt' }, + extensionId: 'extension', + browserId: 'browser', + origin: 'chrome-extension://owner', + session, + taskId: task.task.taskId, + handoff: true + } + ]) + + const failed = await startHookGateway(root, async () => { + throw new Error('Hub crashed') + }) + expect((await request(root, input)).result.receipt.state).toBe('accepted') + await expect(failed.flush()).rejects.toThrow('Hub crashed') + await failed.close() + now += CLIENT_HOOK_RETENTION_MS + 1 + + const after = runtime(root) + const fault = vi.fn() + const recovered = await startHookGateway(root, after.deliver, fault) + const fresh = { ...hook('UserPromptSubmit'), sessionId: 'fresh-session' } + expect((await request(root, fresh)).result.receipt.state).toBe('accepted') + await recovered.flush() + expect(after.apply.mock.calls.map(([event]) => event.invocationId)).toEqual([ + fresh.invocationId + ]) + expect(fault).toHaveBeenLastCalledWith(undefined) + const stored = JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')) + expect(stored.cutoff).toBeGreaterThanOrEqual(input.issuedAt) + expect( + stored.entries.some( + (entry: { input: ClientHook }) => entry.input.invocationId === input.invocationId + ) + ).toBe(false) + expect((await request(root, input)).error.message).toContain('cannot be replayed') + expect(after.tasks.find(task.task.taskId)).toMatchObject({ + task: { status: 'cancelled' }, + claudeStopNotice: { sessionId: input.sessionId } + }) + expect(after.tasks.needsFileRead(session.fileKey)).toBe(true) + expect(() => after.tasks.checkOperation('fresh-owner', session, undefined, true)).toThrow( + 'earlier operation' + ) + await recovered.close() + + const restarted = runtime(root) + const gateway = await startHookGateway(root, restarted.deliver) + cleanup.push(() => gateway.close()) + await gateway.flush() + expect(restarted.apply).not.toHaveBeenCalled() + expect((await request(root, input)).error.message).toContain('expired') + expect(restarted.tasks.executionState()).toHaveLength(1) + expect(restarted.tasks.find(task.task.taskId)?.task.status).toBe('cancelled') + } +) + +it('does not acknowledge retirement before its task fences are durable, and does not repeat an uncertain effect', async () => { + const current = runtime(directory()) + const issuedAt = Date.now() + const input = hook('Interrupt', issuedAt) + const record = current.tasks.begin('owner', 'extension', session, 'Work', 'begin', { + kind: 'claude', + name: 'Claude', + sessionId: input.sessionId + }) + const apply = vi.fn(() => { + current.tasks.requireFileRead(session.fileKey) + throw new Error('lost lifecycle outcome') + }) + expect(() => current.tasks.applyHook(input.invocationId, input, apply)).toThrow( + 'lost lifecycle outcome' + ) + vi.spyOn(Date, 'now').mockReturnValue(issuedAt + CLIENT_HOOK_RETENTION_MS + 1) + vi.spyOn(current.store, 'save').mockImplementationOnce(() => { + throw new Error('ENOSPC') + }) + await expect(current.deliver(input)).rejects.toThrow('cannot be flushed') + expect( + current.store + .load() + ?.hookReceipts?.find((receipt) => receipt.invocationId === input.invocationId)?.retired + ).toBeUndefined() + expect(await current.deliver(input)).toEqual(EXPIRED_HOOK_RESULT) + expect(current.tasks.find(record.task.taskId)?.task.status).toBe('paused') + expect(current.tasks.needsFileRead(session.fileKey)).toBe(true) + expect(current.store.load()?.hookReceipts).toEqual([ + expect.objectContaining({ + retired: 'expired', + error: 'Error: lost lifecycle outcome', + result: EXPIRED_HOOK_RESULT + }) + ]) + expect(apply).toHaveBeenCalledOnce() + expect(current.apply).not.toHaveBeenCalled() +}) + +it.skipIf(process.platform === 'win32')( + 'does not retain a capacity fault after durable retirement', + async () => { + const root = directory() + const input = hook('Stop', Date.now() - CLIENT_HOOK_RETENTION_MS - 1) + writeDurableJson(join(root, 'hooks.json'), { + version: 2, + cutoff: 0, + entries: Array.from({ length: HOOK_RECEIPT_LIMIT }, (_, index) => ({ + input: { ...input, invocationId: randomUUID() }, + ...(index ? { result: {} } : {}) + })) + }) + const fault = vi.fn() + const gateway = await startHookGateway(root, async () => EXPIRED_HOOK_RESULT, fault) + cleanup.push(() => gateway.close()) + expect(fault).toHaveBeenLastCalledWith(expect.any(Error)) + await gateway.flush() + expect(fault).toHaveBeenLastCalledWith(undefined) + } +) + +it.each([false, true])( + 'retirement preserves newer lifecycle evidence=%s without reviving the old event', + async (freshEvidence) => { + const current = runtime(directory()) + const old = hook('SessionEnd', Date.now() - CLIENT_HOOK_RETENTION_MS - 1) + current.clients.event('connection', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: old.sessionId } + }) + // Model a live receiver whose earlier binding predates the queued event. + current.clients.clientHook({ ...old, event: 'UserPromptSubmit', issuedAt: old.issuedAt - 1 }) + const owner = await current.clients.owner('connection', undefined, { name: 'claude-code' }) + const record = current.tasks.begin(owner, 'extension', session, 'Work', 'begin', { + kind: 'claude', + name: 'Claude', + sessionId: old.sessionId + }) + current.tasks.confirm(record.task.taskId, owner) + if (freshEvidence) + current.clients.clientHook({ ...hook('UserPromptSubmit'), sessionId: old.sessionId }) + expect(await current.deliver(old)).toEqual(EXPIRED_HOOK_RESULT) + expect(record.task.status).toBe(freshEvidence ? 'active' : 'paused') + const nextOwner = await current.clients.owner('connection', undefined, { name: 'claude-code' }) + expect((await current.clients.describe(nextOwner)).client.sessionId).toBe( + freshEvidence ? old.sessionId : undefined + ) + expect(current.apply).not.toHaveBeenCalled() + } +) diff --git a/packages/mcp-server/tests/runtime-hook-retention.test.ts b/packages/mcp-server/tests/runtime-hook-retention.test.ts new file mode 100644 index 00000000..9b1bd0ae --- /dev/null +++ b/packages/mcp-server/tests/runtime-hook-retention.test.ts @@ -0,0 +1,559 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { CLIENT_HOOK_RETENTION_MS, type ClientHook } from '@tempad-dev/shared' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { readFileSync, rmSync } from 'node:fs' +import { createServer, type Socket } from 'node:net' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +import { AgentClients } from '../src/agent-clients/registry' +import { DesignTaskStore, type DesignTaskSnapshot } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { writeDurableJson } from '../src/durable-json' +import { + EXPIRED_HOOK_RESULT, + HOOK_RECEIPT_LIMIT, + hookSignature, + hookPressureCutoff, + LEGACY_HOOK_EXHAUSTED_RESULT +} from '../src/hook-receipts' +import { ensurePrivateDirectory, runtimeEndpoint } from '../src/runtime-platform' +import { startHookGateway } from '../src/runtime-prototype/hooks' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { connectSocket, listenPrivate } from '../src/runtime-prototype/processes' +import { ControlPeer, SocketTransport } from '../src/runtime-prototype/protocol' + +const platformModule = '../scripts/runtime-test-platform.mjs' +const { fixtureRoot } = await import(platformModule) +const cleanup: (() => unknown | Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() +}) + +async function directory() { + const root = (await fixtureRoot('tp-hook-history-')) as string + ensurePrivateDirectory(join(root, 'run')) + cleanup.push(() => rmSync(root, { recursive: true, force: true })) + return root +} + +function hook(version: 1 | 2, issuedAt = Date.now()): ClientHook { + const input = { + kind: 'claude' as const, + sessionId: 'session', + event: 'UserPromptSubmit' as const, + invocationId: randomUUID() + } + return version === 1 + ? { ...input, version } + : { ...input, version, issuedAt, profile: { kind: 'release' } } +} + +async function request(root: string, input: ClientHook) { + const socket = await connectSocket(runtimeEndpoint(join(root, 'run'), 'mcp.sock')) + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params: input }) + '\n' + ) + try { + return JSON.parse(String((await reply)[0])) + } finally { + socket.destroy() + } +} + +it.each([1, 2] as const)( + 'reclaims full completed v%s history before opening the gateway and never reauthorizes its replay', + async (version) => { + const root = await directory() + const now = Date.now() + const issuedAt = now - CLIENT_HOOK_RETENTION_MS - 1 + const entries = Array.from({ length: HOOK_RECEIPT_LIMIT }, () => ({ + input: hook(version, issuedAt), + result: { output: 'old result' } + })) + writeDurableJson(join(root, 'hooks.json'), { version: 2, cutoff: 0, entries }) + const deliver = vi.fn(async () => ({})) + const fault = vi.fn() + const retire = vi.fn(async () => {}) + const gateway = await startHookGateway(root, deliver, fault, undefined, retire) + cleanup.push(() => gateway.close()) + expect(gateway.pending).toBe(false) + await gateway.flush() + expect(fault).toHaveBeenLastCalledWith(undefined) + expect(deliver).not.toHaveBeenCalled() + expect(retire).toHaveBeenCalledTimes(version === 1 ? 1 : 0) + const stored = JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')) + expect(stored.cutoff).toBeGreaterThanOrEqual(issuedAt) + if (version === 2) expect(stored.entries).toEqual([]) + else { + expect(stored.entries).toHaveLength(HOOK_RECEIPT_LIMIT) + expect( + stored.entries.every( + (entry: { retired?: string; result?: unknown }) => + entry.retired === 'expired' && entry.result === undefined + ) + ).toBe(true) + } + await gateway.close() + vi.spyOn(Date, 'now').mockReturnValue(issuedAt) + const restarted = await startHookGateway(root, deliver, fault, undefined, retire) + cleanup.push(() => restarted.close()) + await restarted.flush() + expect((await request(root, entries[0]!.input)).error.message).toContain('expired') + expect(fault).toHaveBeenLastCalledWith(undefined) + expect(deliver).not.toHaveBeenCalled() + } +) + +it('bounds a full recent v2 response cache with a durable pressure cutoff, without replaying old results', async () => { + const root = await directory() + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const entries = Array.from({ length: HOOK_RECEIPT_LIMIT }, (_, index) => ({ + input: hook(2, now - HOOK_RECEIPT_LIMIT + index), + result: { output: 'retained Stop output' } + })) + writeDurableJson(join(root, 'hooks.json'), { version: 2, cutoff: 0, entries }) + const fault = vi.fn() + const deliver = vi.fn(async () => ({ output: 'new Stop output' })) + const gateway = await startHookGateway(root, deliver, fault) + cleanup.push(() => gateway.close()) + await gateway.flush() + expect((await request(root, hook(2, now + 1))).error.data.code).toBe('MCP_HOOK_CLOCK_AHEAD') + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries).toEqual(entries) + const input = hook(2) + await request(root, input) + await gateway.flush() + expect((await request(root, input)).result.output).toBe('new Stop output') + expect((await request(root, entries[0]!.input)).error.message).toContain('expired') + expect((await request(root, entries[1]!.input)).result.output).toBe('retained Stop output') + const stored = JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')) + expect(stored.entries).toHaveLength(HOOK_RECEIPT_LIMIT) + now -= HOOK_RECEIPT_LIMIT + expect((await request(root, entries[0]!.input)).error.message).toContain('expired') + expect(deliver).toHaveBeenCalledOnce() + expect(fault).toHaveBeenLastCalledWith(undefined) +}) + +it('keeps a full pending inbox and its fault even when the events are old', async () => { + const root = await directory() + const entries = Array.from({ length: HOOK_RECEIPT_LIMIT }, () => ({ + input: hook(2, Date.now() - CLIENT_HOOK_RETENTION_MS - 1) + })) + writeDurableJson(join(root, 'hooks.json'), { version: 2, cutoff: 0, entries }) + const fault = vi.fn() + const deliver = vi.fn(async () => ({})) + const gateway = await startHookGateway(root, deliver, fault) + cleanup.push(() => gateway.close()) + expect(gateway.pending).toBe(true) + expect(fault).toHaveBeenLastCalledWith( + expect.objectContaining({ message: 'Hook inbox capacity reached.' }) + ) + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries).toEqual(entries) + expect(deliver).not.toHaveBeenCalled() +}) + +it.each([1, 2] as const)( + 'reclaims completed v%s task receipts across restart without clearing an uncertain receipt', + async (version) => { + const root = await directory() + const path = join(root, 'tasks.json') + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const old = now - CLIENT_HOOK_RETENTION_MS - 1 + const inputs = Array.from({ length: HOOK_RECEIPT_LIMIT }, () => hook(version, old)) + const uncertain = hook(1) + writeDurableJson(path, { + revision: 0, + current: {}, + records: [], + hookReceiptCutoff: 0, + hookReceipts: [ + ...inputs.map((input) => ({ + invocationId: input.invocationId, + signature: hookSignature(input), + recordedAt: old, + ...(input.version === 2 ? { issuedAt: input.issuedAt } : {}), + result: { output: 'old result' } + })), + { + invocationId: uncertain.invocationId, + signature: hookSignature(uncertain), + recordedAt: old, + result: {}, + error: 'uncertain effect' + } + ] + }) + const store = new DesignTaskStore(path) + const tasks = new DesignTasks({ store, createId: randomUUID }) + tasks.restore() + const input = hook(version) + const apply = vi.fn(() => ({ output: 'Stop' })) + if (version === 1) + expect(() => tasks.applyHook(input.invocationId, input, apply)).toThrow( + 'Update the TemPad Dev plugin' + ) + else expect(tasks.applyHook(input.invocationId, input, apply)).toEqual({ output: 'Stop' }) + expect(() => tasks.applyHook(uncertain.invocationId, uncertain, apply)).toThrow( + 'remains uncertain' + ) + const saved = store.load()! + expect(saved.hookReceiptCutoff).toBeGreaterThanOrEqual(old) + if (version === 1) { + expect(saved.hookReceipts?.filter((receipt) => receipt.retired)).toHaveLength( + HOOK_RECEIPT_LIMIT + ) + expect( + saved.hookReceipts?.find((receipt) => receipt.invocationId === inputs[0]!.invocationId) + ?.result + ).toEqual(EXPIRED_HOOK_RESULT) + } else expect(saved.hookReceipts).toHaveLength(2) + now = old + const restarted = new DesignTasks({ store, createId: randomUUID }) + restarted.restore() + if (version === 1) + expect(restarted.applyHook(inputs[0]!.invocationId, inputs[0], apply)).toEqual( + EXPIRED_HOOK_RESULT + ) + else + expect(() => restarted.applyHook(inputs[0]!.invocationId, inputs[0], apply)).toThrow( + 'expired' + ) + expect(apply).toHaveBeenCalledTimes(version === 1 ? 0 : 1) + } +) + +it('retains a full legacy fence until scoped retirement is durable, then permits v2 without growing legacy history', async () => { + const root = await directory() + const entries = Array.from({ length: HOOK_RECEIPT_LIMIT }, () => ({ input: hook(1), result: {} })) + writeDurableJson(join(root, 'hooks.json'), { version: 2, cutoff: 0, entries }) + const store = new DesignTaskStore(join(root, 'tasks.json')) + const tasks = new DesignTasks({ store, createId: randomUUID }) + const clients = new AgentClients(tasks) + cleanup.push(() => clients.close()) + const legacy = { ...hook(1), sessionId: 'legacy' } + const modern = { ...hook(2), sessionId: 'modern' } + for (const input of [legacy, modern]) clients.clientHook(input) + const session = { + sessionId: 'figma', + fileKey: 'file', + fileName: 'File', + pageId: '0:1', + busy: false + } + const legacyTask = tasks.begin('legacy-owner', 'extension', session, 'Legacy', 'legacy-begin', { + kind: 'claude', + name: 'Claude', + sessionId: legacy.sessionId + }) + const modernTask = tasks.begin( + 'modern-owner', + 'extension', + { ...session, fileKey: 'modern-file' }, + 'Modern', + 'modern-begin', + { kind: 'claude', name: 'Claude', sessionId: modern.sessionId } + ) + const stopped = tasks.begin( + 'stopped-owner', + 'extension', + { ...session, fileKey: 'stopped-file' }, + 'Stopped', + 'stop-begin', + { kind: 'claude', name: 'Claude', sessionId: legacy.sessionId } + ) + tasks.stop(stopped.task.taskId, 'cancelled', { sessionId: legacy.sessionId }) + const fault = vi.fn() + const retire = vi.fn(async () => { + clients.retireLegacyHooks() + tasks.flush() + }) + const deliver = vi.fn(async (input: ClientHook) => clients.clientHook(input)) + const gateway = await startHookGateway(root, deliver, fault, undefined, retire) + cleanup.push(() => gateway.close()) + const saving = vi.spyOn(store, 'save').mockImplementation(() => { + throw new Error('ENOSPC') + }) + await expect(gateway.flush()).rejects.toThrow('cannot be flushed') + expect(fault).toHaveBeenLastCalledWith(expect.any(Error)) + saving.mockRestore() + await gateway.flush() + expect(fault).toHaveBeenLastCalledWith(undefined) + expect(retire).toHaveBeenCalledTimes(2) + expect(legacyTask.task.status).toBe('paused') + expect(modernTask.task.status).toBe('active') + expect(stopped).toMatchObject({ + task: { status: 'cancelled' }, + claudeStopNotice: { sessionId: legacy.sessionId } + }) + expect( + store.load()?.records.find((record) => record.task.taskId === legacyTask.task.taskId)?.task + .status + ).toBe('paused') + expect((await request(root, hook(1))).error.data.code).toBe('MCP_HOOK_LEGACY_UPGRADE_REQUIRED') + expect(deliver).not.toHaveBeenCalled() + await request(root, { ...hook(2), sessionId: 'legacy' }) + await gateway.flush() + expect(deliver).toHaveBeenCalledOnce() + expect(retire).toHaveBeenCalledTimes(2) + clients.event('edge', { + method: 'notifications/tempad/client-event', + params: { event: 'connect', kind: 'claude', sessionId: legacy.sessionId } + }) + expect(await clients.owner('edge', undefined, { name: 'claude-code' })).toBe('edge/claude/legacy') + gateway.setReceiver(randomUUID()) + await gateway.flush() + expect(retire).toHaveBeenCalledTimes(3) + expect( + JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries.filter( + (entry: { input: ClientHook }) => entry.input.version === 1 + ) + ).toHaveLength(HOOK_RECEIPT_LIMIT) +}) + +it('drains events received while legacy retirement is awaiting durability', async () => { + const root = await directory() + writeDurableJson(join(root, 'hooks.json'), { + version: 2, + cutoff: 0, + entries: Array.from({ length: HOOK_RECEIPT_LIMIT }, () => ({ + input: hook(1), + retired: 'expired' + })) + }) + const waiting = Promise.withResolvers() + const retire = vi.fn(() => waiting.promise) + const deliver = vi.fn(async () => ({})) + const fault = vi.fn() + const gateway = await startHookGateway(root, deliver, fault, undefined, retire) + cleanup.push(() => gateway.close()) + const flush = gateway.flush() + await vi.waitFor(() => expect(retire).toHaveBeenCalledOnce()) + const pendingStop = { ...hook(2), event: 'Stop' as const } + expect((await request(root, pendingStop)).result.receipt.state).toBe('accepted') + waiting.resolve() + await flush + expect(deliver).toHaveBeenCalledExactlyOnceWith(pendingStop, undefined) + expect(gateway.pending).toBe(false) + expect(fault).toHaveBeenLastCalledWith(undefined) +}) + +it('preserves Hub-reported legacy exhaustion when its gateway history was shorter', async () => { + const root = await directory() + const deliver = vi.fn(async () => LEGACY_HOOK_EXHAUSTED_RESULT) + const retire = vi.fn(async () => {}) + const gateway = await startHookGateway(root, deliver, undefined, undefined, retire) + cleanup.push(() => gateway.close()) + expect((await request(root, hook(1))).error.data.code).toBe('MCP_HOOK_LEGACY_UPGRADE_REQUIRED') + expect(gateway.legacyExhausted).toBe(true) + await gateway.close() + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + CLIENT_HOOK_RETENTION_MS + 1) + const restarted = await startHookGateway(root, deliver, undefined, undefined, retire) + cleanup.push(() => restarted.close()) + await restarted.flush() + expect(restarted.legacyExhausted).toBe(true) + expect((await request(root, hook(1))).error.data.code).toBe('MCP_HOOK_LEGACY_UPGRADE_REQUIRED') + expect(deliver).toHaveBeenCalledOnce() +}) + +it('processes more than 4096 v2 effects with bounded receipts and no replay under pressure', async () => { + const root = await directory() + let now = Date.now() + let saved: DesignTaskSnapshot | undefined + const store = new DesignTaskStore(join(root, 'tasks.json')) + vi.spyOn(store, 'save').mockImplementation((snapshot) => { + saved = snapshot + }) + const tasks = new DesignTasks({ store, now: () => now, createId: randomUUID }) + const first = hook(2, now) + let last = first + const apply = vi.fn(() => ({ output: 'Stop' })) + for (let index = 0; index <= HOOK_RECEIPT_LIMIT; index++) { + last = index ? hook(2, ++now) : first + expect(tasks.applyHook(last.invocationId, last, apply)).toEqual({ output: 'Stop' }) + } + expect(saved?.hookReceipts).toHaveLength(HOOK_RECEIPT_LIMIT) + expect(() => tasks.applyHook(first.invocationId, first, apply)).toThrow('expired') + expect(tasks.applyHook(last.invocationId, last, apply)).toEqual({ output: 'Stop' }) + expect(apply).toHaveBeenCalledTimes(HOOK_RECEIPT_LIMIT + 1) +}) + +it('does not pressure-expire an uncertain effect to make space for a new hook', async () => { + const root = await directory() + const now = Date.now() + const uncertain = hook(2, now - 1000) + const completed = Array.from({ length: HOOK_RECEIPT_LIMIT - 1 }, () => hook(2, now - 500)) + const path = join(root, 'tasks.json') + const hookReceipts = [uncertain, ...completed].map((input, index) => ({ + invocationId: input.invocationId, + signature: hookSignature(input), + issuedAt: input.version === 2 ? input.issuedAt : undefined, + recordedAt: now, + result: {}, + ...(index ? {} : { error: 'unknown effect' }) + })) + writeDurableJson(path, { + revision: 0, + current: {}, + records: [], + hookReceiptCutoff: 0, + hookReceipts + }) + const store = new DesignTaskStore(path) + const tasks = new DesignTasks({ store, now: () => now, createId: randomUUID }) + tasks.restore() + const apply = vi.fn(() => ({})) + const fresh = hook(2, now) + expect(() => tasks.applyHook(fresh.invocationId, fresh, apply)).toThrow( + 'uncertain lifecycle effects' + ) + expect(() => tasks.applyHook(uncertain.invocationId, uncertain, apply)).toThrow( + 'remains uncertain' + ) + expect(store.load()?.hookReceipts).toEqual(hookReceipts) + expect(apply).not.toHaveBeenCalled() +}) + +it('authenticates scoped legacy retirement and acknowledges only after its fences flush', async () => { + const root = await directory() + const secret = randomUUID() + const retire = vi.fn() + const flush = vi.fn().mockImplementationOnce(() => { + throw new Error('fsync failed') + }) + const control = new HubControl(secret, undefined, { + inspect: () => ({ + drained: true, + blockers: [], + deadline: Date.now() + 1000, + version: '0.12.0' + }), + stop: async () => {}, + hook: () => ({}), + retireLegacyHooks: retire, + flush + }) + const sockets = new Set() + const path = runtimeEndpoint(join(root, 'run'), 'control.sock') + const server = createServer((socket) => { + sockets.add(socket) + void control.attach(socket) + }) + await listenPrivate(server, path) + const owner = new ControlPeer() + const stranger = new ControlPeer() + cleanup.push(async () => { + await owner.close() + await stranger.close() + await control.close() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + await owner.connect(new SocketTransport(await connectSocket(path))) + await stranger.connect(new SocketTransport(await connectSocket(path))) + const request = { method: 'tempad/runtime-retire-legacy-hooks' } + await expect(stranger.request(request, ResultSchema)).rejects.toThrow() + expect(retire).not.toHaveBeenCalled() + await owner.request( + { method: 'tempad/runtime-acquire', params: { secret, versions: [2] } }, + LeaseSchema + ) + await expect(owner.request(request, ResultSchema)).rejects.toThrow('fsync failed') + expect(await owner.request(request, ResultSchema)).toEqual({}) + expect(retire).toHaveBeenCalledTimes(2) + expect(flush).toHaveBeenCalledTimes(2) +}) + +it('retires an expired pending event even when recent results occupy every receipt slot', async () => { + const root = await directory() + const now = Date.now() + const path = join(root, 'tasks.json') + const hookReceipts = Array.from({ length: HOOK_RECEIPT_LIMIT }, () => { + const input = hook(2, now) + return { + invocationId: input.invocationId, + signature: hookSignature(input), + issuedAt: now, + recordedAt: now, + result: {} + } + }) + writeDurableJson(path, { + revision: 0, + current: {}, + records: [], + hookReceiptCutoff: 0, + hookReceipts + }) + const store = new DesignTaskStore(path) + const tasks = new DesignTasks({ store, now: () => now, createId: randomUUID }) + tasks.restore() + const expired = { ...hook(2, now - CLIENT_HOOK_RETENTION_MS - 1), event: 'Stop' as const } + const apply = vi.fn(() => ({})) + expect( + tasks.applyHook(expired.invocationId, expired, apply, () => tasks.requireFileRead('file')) + ).toEqual(EXPIRED_HOOK_RESULT) + expect(store.load()?.hookReceipts).toEqual(hookReceipts) + const restarted = new DesignTasks({ + store, + now: () => now - CLIENT_HOOK_RETENTION_MS, + createId: randomUUID + }) + restarted.restore() + expect(restarted.needsFileRead('file')).toBe(true) + expect(() => restarted.applyHook(expired.invocationId, expired, apply)).toThrow('expired') + expect(apply).not.toHaveBeenCalled() +}) + +it('expires a complete timestamp cohort only when no member is unresolved', () => { + const issuedAt = Date.now() + expect(hookPressureCutoff(0, Array(HOOK_RECEIPT_LIMIT).fill(issuedAt), [])).toBe(issuedAt) + expect(hookPressureCutoff(0, Array(HOOK_RECEIPT_LIMIT - 1).fill(issuedAt), [issuedAt])).toBe( + issuedAt - 1 + ) +}) + +it('keeps an expired event pending through a partial retirement and a failed flush, without claiming application', async () => { + const root = await directory() + const old = { ...hook(2, Date.now() - CLIENT_HOOK_RETENTION_MS - 1), event: 'Stop' as const } + writeDurableJson(join(root, 'hooks.json'), { version: 2, cutoff: 0, entries: [{ input: old }] }) + const store = new DesignTaskStore(join(root, 'tasks.json')) + const tasks = new DesignTasks({ store, createId: randomUUID }) + const apply = vi.fn(() => ({})) + const retire = vi.fn(() => { + tasks.requireFileRead('file') + if (retire.mock.calls.length === 1) throw new Error('retirement interrupted after mutation') + }) + const fault = vi.fn() + const gateway = await startHookGateway( + root, + async (input) => tasks.applyHook(input.invocationId, input, apply, retire), + fault + ) + cleanup.push(() => gateway.close()) + await expect(gateway.flush()).rejects.toThrow('retirement interrupted') + expect(gateway.pending).toBe(true) + expect(fault).toHaveBeenLastCalledWith(expect.any(Error)) + const saving = vi.spyOn(store, 'save').mockImplementation(() => { + throw new Error('ENOSPC') + }) + await expect(gateway.flush()).rejects.toThrow('cannot be flushed') + expect(gateway.pending).toBe(true) + expect(JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')).entries).toEqual([ + { input: old } + ]) + saving.mockRestore() + await gateway.flush() + expect(gateway.pending).toBe(false) + expect(fault).toHaveBeenLastCalledWith(undefined) + expect(store.load()?.filesNeedingRead).toContain('file') + expect(store.load()?.hookReceipts).toBeUndefined() + expect(retire).toHaveBeenCalledTimes(3) + expect(apply).not.toHaveBeenCalled() + const stored = JSON.parse(readFileSync(join(root, 'hooks.json'), 'utf8')) + expect(stored.entries[0]).toMatchObject({ retired: 'expired', result: EXPIRED_HOOK_RESULT }) +}) diff --git a/packages/mcp-server/tests/runtime-identity.test.ts b/packages/mcp-server/tests/runtime-identity.test.ts index 764376ba..3451a57c 100644 --- a/packages/mcp-server/tests/runtime-identity.test.ts +++ b/packages/mcp-server/tests/runtime-identity.test.ts @@ -19,14 +19,16 @@ import { describe, expect, it, vi } from 'vitest' import { getExtensionRuntimeIssues, createHubRuntimeIdentity, + parseHubRuntimeIdentity, readHubRuntimeIdentity, removeHubRuntimeIdentityIfOwned, writeHubRuntimeIdentity } from '../src/runtime-identity' describe('runtime identity', () => { - it('publishes, reads, and owner-removes an atomic Hub identity', () => { + it('publishes, reads, and owner-removes an atomic Hub identity', ({ onTestFinished }) => { const directory = mkdtempSync(join(tmpdir(), 'tempad-runtime-identity-')) + onTestFinished(() => rmSync(directory, { recursive: true, force: true })) const entryPath = join(directory, 'hub.mjs') const identityPath = join(directory, 'hub-runtime.json') writeFileSync(entryPath, 'export const cohort = 1\n') @@ -39,6 +41,9 @@ describe('runtime identity', () => { writeHubRuntimeIdentity(identityPath, identity) expect(readHubRuntimeIdentity(identityPath)).toEqual(identity) expect(JSON.parse(readFileSync(identityPath, 'utf8'))).toEqual(identity) + expect(parseHubRuntimeIdentity({ ...identity, wsPort: 6220 })?.wsPort).toBe(6220) + for (const wsPort of [0, 65536, 1.5, '6220']) + expect(parseHubRuntimeIdentity({ ...identity, wsPort })).toBeNull() const legacyIdentity = JSON.parse(JSON.stringify(identity)) as Record delete legacyIdentity.activeExtension @@ -132,6 +137,7 @@ it.skipIf(process.platform === 'win32').each([ format: ['esm'], platform: 'node', target: 'node22', + inputOptions: { resolve: { conditionNames: ['source'] } }, dts: false, logLevel: 'silent' }) diff --git a/packages/mcp-server/tests/runtime-package-manager.test.ts b/packages/mcp-server/tests/runtime-package-manager.test.ts new file mode 100644 index 00000000..28dc7dba --- /dev/null +++ b/packages/mcp-server/tests/runtime-package-manager.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, expect, it, vi } from 'vitest' + +const fixture = vi.hoisted(() => ({ + process: { + platform: 'win32', + execPath: 'C:\\Node\\node.exe', + env: { PATH: 'C:\\Tools;C:\\Node', npm_execpath: '' } + }, + files: new Set(), + manifests: new Map(), + execute: vi.fn() +})) +vi.mock('node:process', () => ({ default: fixture.process })) +vi.mock('node:fs', () => ({ + existsSync: (path: string) => fixture.files.has(path), + readFileSync: (path: string) => { + if (fixture.manifests.has(path)) return JSON.stringify(fixture.manifests.get(path)) + throw Object.assign(new Error('missing fixture'), { code: 'ENOENT' }) + } +})) +vi.mock('node:path', async (original) => (await original()).win32) +vi.mock('node:child_process', async (original) => ({ + ...(await original()), + execFileSync: fixture.execute +})) +const modulePath = '../scripts/package-manager.mjs' +const { runPackageManagerSync } = await import(modulePath) + +beforeEach(() => { + fixture.process.platform = 'win32' + fixture.process.env.PATH = 'C:\\Tools;C:\\Node' + fixture.process.env.npm_execpath = '' + fixture.files.clear() + fixture.manifests.clear() + fixture.execute.mockReset() +}) + +it('runs the Windows npm CLI without interpreting shell characters in paths', () => { + const cli = 'C:\\Node\\node_modules\\npm\\bin\\npm-cli.js' + fixture.files.add(cli) + const args = ['pack', '--pack-destination', 'C:\\Build output & evidence'] + const options = { encoding: 'utf8', cwd: 'C:\\Package with spaces' } + runPackageManagerSync('npm', args, options) + expect(fixture.execute).toHaveBeenCalledWith(fixture.process.execPath, [cli, ...args], options) +}) + +it('uses the pnpm script supplied by its package-script environment', () => { + const cli = 'C:\\Tools\\pnpm\\bin\\pnpm.cjs' + fixture.process.env.npm_execpath = cli + fixture.files.add(cli) + runPackageManagerSync('pnpm', ['deploy'], {}) + expect(fixture.execute).toHaveBeenCalledWith(fixture.process.execPath, [cli, 'deploy'], {}) +}) + +it('supports standalone pnpm executables without falling back to a cmd shim', () => { + fixture.process.env.npm_execpath = 'C:\\Tools\\pnpm.cmd' + fixture.files.add(fixture.process.env.npm_execpath) + fixture.files.add('C:\\Tools\\pnpm.exe') + runPackageManagerSync('pnpm', ['deploy'], {}) + expect(fixture.execute).toHaveBeenCalledWith('C:\\Tools\\pnpm.exe', ['deploy'], {}) +}) + +it.each(['pnpm.exe', 'bin/pnpm.cjs'])( + 'prefers the PATH-selected package %s over a stale bootstrap npm_execpath', + (bin) => { + fixture.process.env.PATH = 'C:\\Pinned\\bin;C:\\Tools' + fixture.process.env.npm_execpath = 'C:\\Bootstrap\\pnpm.exe' + const directory = 'C:\\Pinned\\node_modules\\pnpm' + const entry = `${directory}\\${bin.replaceAll('/', '\\')}` + fixture.manifests.set(`${directory}\\package.json`, { name: 'pnpm', bin: { pnpm: bin } }) + fixture.files.add(fixture.process.env.npm_execpath) + fixture.files.add(entry) + runPackageManagerSync('pnpm', ['deploy', '--offline', '--frozen-lockfile'], {}) + expect(fixture.execute).toHaveBeenCalledWith( + bin.endsWith('.exe') ? entry : fixture.process.execPath, + [...(bin.endsWith('.exe') ? [] : [entry]), 'deploy', '--offline', '--frozen-lockfile'], + {} + ) + } +) + +it('fails explicitly when only a Windows cmd shim is available', () => { + fixture.files.add('C:\\Tools\\pnpm.cmd') + expect(() => runPackageManagerSync('pnpm', ['deploy'], {})).toThrow('without a shell') + expect(fixture.execute).not.toHaveBeenCalled() +}) + +it('preserves existing Unix command resolution', () => { + fixture.process.platform = 'linux' + runPackageManagerSync('pnpm', ['deploy'], {}) + expect(fixture.execute).toHaveBeenCalledWith('pnpm', ['deploy'], {}) +}) diff --git a/packages/mcp-server/tests/runtime-pipe-connection.test.ts b/packages/mcp-server/tests/runtime-pipe-connection.test.ts new file mode 100644 index 00000000..26253213 --- /dev/null +++ b/packages/mcp-server/tests/runtime-pipe-connection.test.ts @@ -0,0 +1,348 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { connect, type Socket } from 'node:net' +import { setTimeout as delay } from 'node:timers/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +vi.mock('node:net', async (original) => ({ + ...(await original()), + connect: vi.fn() +})) +vi.mock('node:child_process', async (original) => ({ + ...(await original()), + spawn: vi.fn() +})) +vi.mock('../src/runtime-platform', async (original) => ({ + ...(await original()), + assertPrivateEndpointAsync: vi.fn(), + runtimeEndpoint: () => '\\\\.\\pipe\\test' +})) + +import { assertPrivateEndpointAsync as assertPrivateEndpoint } from '../src/runtime-platform' +import { + connectSocket, + isSocketPending, + supervisorConnection, + unavailableSocket, + waitForLaunchedSocket, + waitForSocket, + type PrototypeConfig +} from '../src/runtime-prototype/processes' +import { RECOVER_MS } from '../src/runtime-prototype/protocol' + +function createSocket() { + const value = Object.assign(new EventEmitter(), { + destroyed: false, + write: vi.fn(), + destroy: vi.fn(() => { + Object.assign(value, { destroyed: true }) + return value + }) + }) as unknown as Socket + return value +} + +let socket: Socket +beforeEach(() => { + vi.resetAllMocks() + socket = createSocket() + vi.mocked(connect).mockReturnValue(socket) +}) +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +it('waits past the endpoint probe window for its own living launch', async () => { + const child = new EventEmitter() as ChildProcess + const started = performance.now() + vi.mocked(connect).mockImplementation(() => { + const next = createSocket() + queueMicrotask(() => { + if (performance.now() - started < 5200) + next.emit('error', Object.assign(new Error('starting'), { code: 'ENOENT' })) + else next.emit('connect') + }) + return next + }) + const connected = await waitForLaunchedSocket( + '\\\\.\\pipe\\test', + child, + new AbortController().signal + ) + expect(connected.destroyed).toBe(false) + expect(spawn).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('exit')).toBe(0) + connected.destroy() +}, 12000) + +it.each(['exit', 'error'] as const)( + 'stops waiting promptly when its launch reports %s', + async (event) => { + const child = new EventEmitter() as ChildProcess + const pending = waitForLaunchedSocket('\\\\.\\pipe\\test', child, new AbortController().signal) + const rejected = expect(pending).rejects.toThrow(event === 'exit' ? 'exited' : 'spawn failed') + if (event === 'exit') child.emit('exit', 1, null) + else child.emit('error', new Error('spawn failed')) + await rejected + expect(socket.destroyed).toBe(true) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('exit')).toBe(0) + } +) + +it('bounds an unresponsive launch without terminating it', async () => { + const child = Object.assign(new EventEmitter(), { kill: vi.fn() }) as unknown as ChildProcess + vi.spyOn(performance, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(RECOVER_MS + 1) + const rejected = expect( + waitForLaunchedSocket('\\\\.\\pipe\\test', child, new AbortController().signal) + ).rejects.toMatchObject({ data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } }) + socket.emit('error', Object.assign(new Error('starting'), { code: 'ENOENT' })) + await rejected + expect(child.kill).not.toHaveBeenCalled() +}) + +it.each(['ESRCH', 'EPERM'])( + 'stops endpoint discovery when an existing owner reports %s', + async (code) => { + const failed = Object.assign(new Error('owner probe failed'), { code }) + const probe = vi + .spyOn(process, 'kill') + .mockReturnValueOnce(true) + .mockImplementation(() => { + throw failed + }) + const pending = waitForSocket( + '\\\\.\\pipe\\test', + new AbortController().signal, + RECOVER_MS, + 12345 + ) + const rejected = expect(pending).rejects.toThrow( + code === 'ESRCH' ? 'process exited' : 'owner probe failed' + ) + socket.emit('error', Object.assign(new Error('not ready'), { code: 'ENOENT' })) + await rejected + expect(probe).toHaveBeenCalledTimes(2) + expect(probe).toHaveBeenCalledWith(12345, 0) + expect(connect).toHaveBeenCalledOnce() + expect(socket.write).not.toHaveBeenCalled() + } +) + +it.each(['EPIPEUNPROTECTED', 'EBUSY'])( + 'keeps the same connection while %s resolves, without sending control data', + async (code) => { + vi.mocked(assertPrivateEndpoint).mockImplementationOnce(() => { + throw Object.assign(new Error('not ready yet'), { code }) + }) + const ready = vi.fn() + const connection = connectSocket('\\\\.\\pipe\\test').then((connected) => { + ready() + connected.write('control secret') + return connected + }) + socket.emit('connect') + expect(ready).not.toHaveBeenCalled() + expect(socket.write).not.toHaveBeenCalled() + await expect(connection).resolves.toBe(socket) + expect(connect).toHaveBeenCalledOnce() + expect(assertPrivateEndpoint).toHaveBeenCalledTimes(2) + expect(socket.write).toHaveBeenCalledWith('control secret') + } +) + +it.each(['EPIPEUNPROTECTED', 'EBUSY'])( + 'preserves %s when inspection cannot finish within its bounded verification deadline', + async (code) => { + vi.useFakeTimers() + const error = Object.assign(new Error('not ready yet'), { code }) + vi.mocked(assertPrivateEndpoint).mockImplementation(() => { + throw error + }) + const rejected = expect(connectSocket('\\\\.\\pipe\\test')).rejects.toBe(error) + socket.emit('connect') + await vi.advanceTimersByTimeAsync(15_000) + await rejected + expect(assertPrivateEndpoint).toHaveBeenCalled() + expect(socket.destroy).toHaveBeenCalled() + expect(socket.write).not.toHaveBeenCalled() + } +) + +it('times out when no pipe connection was established', async () => { + const rejected = expect(connectSocket('\\\\.\\pipe\\test')).rejects.toMatchObject({ + message: 'Runtime connection timed out.', + code: 'ETIMEDOUT' + }) + await rejected + expect(assertPrivateEndpoint).not.toHaveBeenCalled() + expect(socket.destroy).toHaveBeenCalled() + expect(socket.write).not.toHaveBeenCalled() +}) + +it('preserves cancellation while waiting for pipe protection', async () => { + vi.mocked(assertPrivateEndpoint).mockImplementation(() => { + throw Object.assign(new Error('not protected yet'), { code: 'EPIPEUNPROTECTED' }) + }) + const controller = new AbortController() + const rejected = expect(connectSocket('\\\\.\\pipe\\test', controller.signal)).rejects.toThrow( + 'cancelled' + ) + socket.emit('connect') + await delay(1) + expect(assertPrivateEndpoint).toHaveBeenCalled() + controller.abort() + await rejected + expect(socket.destroy).toHaveBeenCalled() + expect(socket.write).not.toHaveBeenCalled() +}) + +it.each(['close', 'ECONNRESET', 'EPIPE'])( + 'settles a connection lost through %s during ACL inspection without waiting for its deadline', + async (event) => { + vi.useFakeTimers() + const inspection = Promise.withResolvers() + let signal: AbortSignal | undefined + vi.mocked(assertPrivateEndpoint).mockImplementation((_path, value) => { + signal = value + return inspection.promise + }) + const observed = vi.fn() + const pending = connectSocket('\\\\.\\pipe\\test').then( + (connected) => observed({ connected }), + (error: unknown) => { + observed(error) + return error + } + ) + socket.emit('connect') + if (event === 'close') { + socket.destroy() + socket.emit('close') + } else socket.emit('error', Object.assign(new Error('Pipe was closed'), { code: event })) + await vi.advanceTimersByTimeAsync(0) + expect(observed).toHaveBeenCalledOnce() + const error = await pending + expect(isSocketPending(error)).toBe(true) + expect(unavailableSocket(error)).toBe(false) + expect(signal?.aborted).toBe(true) + expect(socket.destroyed).toBe(true) + expect(vi.getTimerCount()).toBe(0) + inspection.resolve() + await vi.advanceTimersByTimeAsync(0) + expect(observed).toHaveBeenCalledOnce() + expect(socket.write).not.toHaveBeenCalled() + } +) + +it.each(['endpoint', 'supervisor'] as const)( + 'retries an early protected-listener close promptly through %s without launching an owner', + async (kind) => { + const sockets: Socket[] = [] + const inspection = Promise.withResolvers() + vi.mocked(connect).mockImplementation(() => { + const next = createSocket() + sockets.push(next) + queueMicrotask(() => next.emit('connect')) + return next + }) + vi.mocked(assertPrivateEndpoint).mockImplementation(() => { + if (sockets.length !== 1) return Promise.resolve() + queueMicrotask(() => { + sockets[0]!.destroy() + sockets[0]!.emit('close') + }) + return inspection.promise + }) + const ready = vi.fn() + const pending = ( + kind === 'endpoint' + ? waitForSocket('\\\\.\\pipe\\test', undefined, RECOVER_MS) + : supervisorConnection( + { root: '/unused' } as PrototypeConfig, + AbortSignal.timeout(RECOVER_MS) + ) + ).then((connected) => { + ready(connected) + return connected + }) + await vi.waitFor(() => expect(ready).toHaveBeenCalledExactlyOnceWith(sockets[1])) + expect(await pending).toBe(sockets[1]) + expect(sockets).toHaveLength(2) + expect(spawn).not.toHaveBeenCalled() + for (const value of sockets) expect(value.write).not.toHaveBeenCalled() + inspection.resolve() + sockets[1]!.destroy() + } +) + +it.each([undefined, 'EPIPEUNTRUSTED'])( + 'does not retry foreign owners or unsupported security helpers (%s)', + async (code) => { + vi.mocked(assertPrivateEndpoint).mockImplementation(() => { + throw Object.assign(new Error('Unexpected owner'), { code }) + }) + const rejected = expect(connectSocket('\\\\.\\pipe\\test')).rejects.toThrow('Unexpected owner') + socket.emit('connect') + await rejected + expect(assertPrivateEndpoint).toHaveBeenCalledOnce() + expect(socket.write).not.toHaveBeenCalled() + } +) + +it.each([ + ['endpoint', 'EPIPEUNPROTECTED'], + ['endpoint', 'ETIMEDOUT'], + ['supervisor', 'EPIPEUNPROTECTED'], + ['supervisor', 'ETIMEDOUT'] +] as const)( + 'waits for %s %s across connection attempts without launching another owner', + async (kind, code) => { + vi.useFakeTimers() + const pending = Object.assign(new Error('Endpoint acquisition pending'), { code }) + expect(unavailableSocket(pending)).toBe(false) + const sockets: Socket[] = [] + vi.mocked(connect).mockImplementation(() => { + const next = createSocket() + sockets.push(next) + if (code !== 'ETIMEDOUT' || sockets.length > 1) queueMicrotask(() => next.emit('connect')) + return next + }) + vi.mocked(assertPrivateEndpoint).mockImplementation(async () => { + if (sockets.length === 1) throw pending + }) + const connection = + kind === 'endpoint' + ? waitForSocket('\\\\.\\pipe\\test', undefined, RECOVER_MS) + : supervisorConnection( + { root: '/unused' } as PrototypeConfig, + AbortSignal.timeout(RECOVER_MS) + ) + await vi.advanceTimersByTimeAsync(16_000) + const connected = await connection + expect(sockets).toHaveLength(2) + expect(connected).toBe(sockets[1]) + expect(sockets[0]!.destroyed).toBe(true) + expect(spawn).not.toHaveBeenCalled() + for (const value of sockets) expect(value.write).not.toHaveBeenCalled() + connected.destroy() + } +) + +it('keeps ownership uncertain when supervisor acquisition exhausts its recovery deadline', async () => { + const timeout = AbortSignal.timeout.bind(AbortSignal) + vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => timeout(ms === RECOVER_MS ? 30 : ms)) + await expect( + supervisorConnection({ root: '/unused' } as PrototypeConfig, new AbortController().signal) + ).rejects.toMatchObject({ + message: expect.stringContaining('Runtime supervisor startup timed out'), + data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } + }) + expect(spawn).not.toHaveBeenCalled() + expect(socket.destroyed).toBe(true) + expect(socket.write).not.toHaveBeenCalled() +}) diff --git a/packages/mcp-server/tests/runtime-platform-failures.test.ts b/packages/mcp-server/tests/runtime-platform-failures.test.ts new file mode 100644 index 00000000..4f5cd039 --- /dev/null +++ b/packages/mcp-server/tests/runtime-platform-failures.test.ts @@ -0,0 +1,251 @@ +import { execFileSync } from 'node:child_process' +import { lstatSync, readFileSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { z } from 'zod' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) +vi.mock('node:fs', () => ({ + lstatSync: vi.fn(), + readFileSync: vi.fn(), + realpathSync: vi.fn(), + mkdirSync: vi.fn(), + rmSync: vi.fn() +})) + +import { + assertPrivateEndpoint, + assertPrivatePath, + ensurePrivateDirectory, + protectPipe +} from '../src/runtime-platform' +import { readPrivateJson } from '../src/runtime-prototype/state' + +let revision = 0n +let inode = 1n +let kind: 'file' | 'directory' = 'directory' +const path = resolve('private-runtime-fixture') +beforeEach(() => { + vi.resetAllMocks() + revision++ + inode = 1n + kind = 'directory' + vi.stubGlobal('process', { + ...process, + platform: 'win32', + env: { ...process.env, SystemRoot: resolve('Windows') } + }) + vi.mocked(realpathSync).mockImplementation((path) => resolve(String(path))) + vi.mocked(lstatSync).mockImplementation( + () => + ({ + dev: 1n, + ino: inode, + birthtimeNs: 1n, + ctimeNs: revision, + isSymbolicLink: () => false, + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file' + }) as ReturnType + ) +}) +afterEach(() => vi.unstubAllGlobals()) + +it('caches unchanged ACLs but rechecks a changed security descriptor', () => { + assertPrivatePath(path, 'directory') + assertPrivatePath(path, 'directory') + expect(execFileSync).toHaveBeenCalledOnce() + revision++ + assertPrivatePath(path, 'directory') + expect(execFileSync).toHaveBeenCalledTimes(2) + expect(vi.mocked(execFileSync).mock.calls[0]?.[2]).toMatchObject({ + env: { + TEMPAD_RUNTIME_ACL_REQUEST: JSON.stringify({ action: 'inspect', path, kind: 'directory' }) + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 15_000, + windowsHide: true + }) +}) + +it.each(['PSModulePath', 'PSMODULEPATH', 'psmodulepath'])( + 'does not inherit %s from another PowerShell edition into ACL helpers', + (key) => { + process.env[key] = 'PowerShell7/modules' + process.env.TEMP = resolve('temporary') + assertPrivatePath(path, 'directory') + const options = vi.mocked(execFileSync).mock.calls[0]?.[2] + expect(options?.env).not.toHaveProperty(key) + expect(options?.env).toMatchObject({ + SystemRoot: process.env.SystemRoot, + TEMP: process.env.TEMP + }) + expect(process.env[key]).toBe('PowerShell7/modules') + } +) + +it('creates missing directories through the ACL helper, without repairing unsafe ones', () => { + vi.mocked(lstatSync).mockImplementationOnce(() => { + throw Object.assign(new Error('missing'), { code: 'ENOENT' }) + }) + ensurePrivateDirectory(path) + expect(vi.mocked(execFileSync).mock.calls[0]?.[2]).toMatchObject({ + env: { + TEMPAD_RUNTIME_ACL_REQUEST: JSON.stringify({ action: 'ensure', path, kind: 'directory' }) + } + }) + revision++ + vi.mocked(execFileSync).mockImplementation(() => { + throw { stderr: JSON.stringify({ message: 'Unexpected owner', code: 5 }) } + }) + expect(() => ensurePrivateDirectory(path)).toThrow('Unexpected owner') + expect(execFileSync).toHaveBeenCalledTimes(2) +}) + +it('rechecks directory changes and caches only a stable private revision', () => { + vi.mocked(execFileSync).mockImplementationOnce(() => { + revision++ + return '' + }) + expect(() => assertPrivatePath(path, 'directory')).not.toThrow() + expect(execFileSync).toHaveBeenCalledTimes(2) + assertPrivatePath(path, 'directory') + expect(execFileSync).toHaveBeenCalledTimes(2) +}) + +it('allows concurrent directory content changes without caching an unstable ACL check', () => { + vi.mocked(execFileSync).mockImplementation(() => { + revision++ + return '' + }) + expect(() => assertPrivatePath(path, 'directory')).not.toThrow() + expect(execFileSync).toHaveBeenCalledTimes(2) + expect(() => assertPrivatePath(path, 'directory')).not.toThrow() + expect(execFileSync).toHaveBeenCalledTimes(4) +}) + +it.each([1, 2])('rejects paths replaced during ACL inspection %s', (replacementCheck) => { + let checks = 0 + vi.mocked(execFileSync).mockImplementation(() => { + revision++ + if (++checks === replacementCheck) inode++ + return '' + }) + expect(() => assertPrivatePath(path, 'directory')).toThrow('changed during its permission check') + expect(execFileSync).toHaveBeenCalledTimes(replacementCheck) +}) + +it('validates an atomically replaced snapshot before reading its new contents', () => { + kind = 'file' + vi.mocked(execFileSync).mockImplementationOnce(() => { + inode++ + revision++ + return '' + }) + vi.mocked(readFileSync).mockReturnValue('{"revision":2}') + expect(readPrivateJson(path, z.object({ revision: z.number() }))).toEqual({ revision: 2 }) + expect(execFileSync).toHaveBeenCalledTimes(2) + expect(readFileSync).toHaveBeenCalledOnce() +}) + +it('rejects a replacement snapshot whose ACL is unsafe', () => { + kind = 'file' + vi.mocked(execFileSync) + .mockImplementationOnce(() => { + inode++ + return '' + }) + .mockImplementation(() => { + throw { stderr: JSON.stringify({ message: 'ACL grants another account access.', code: 5 }) } + }) + expect(() => readPrivateJson(path, z.unknown())).toThrow('another account') + expect(execFileSync).toHaveBeenCalledTimes(2) + expect(readFileSync).not.toHaveBeenCalled() +}) + +it('bounds snapshot retries without reading an unstable replacement', () => { + kind = 'file' + vi.mocked(execFileSync).mockImplementation(() => { + inode++ + return '' + }) + expect(() => readPrivateJson(path, z.unknown())).toThrowError( + expect.objectContaining({ code: 'ERUNTIMEPATHCHANGED' }) + ) + expect(execFileSync).toHaveBeenCalledTimes(2) + expect(readFileSync).not.toHaveBeenCalled() +}) + +it.each(['not JSON', '{"revision":"invalid"}'])( + 'does not retry invalid snapshot data: %s', + (data) => { + kind = 'file' + vi.mocked(readFileSync).mockReturnValue(data) + expect(() => readPrivateJson(path, z.object({ revision: z.number() }))).toThrow() + expect(execFileSync).toHaveBeenCalledOnce() + expect(readFileSync).toHaveBeenCalledOnce() + } +) + +it('rejects an unsafe ACL discovered after the revision changes', () => { + vi.mocked(execFileSync) + .mockImplementationOnce(() => { + revision++ + return '' + }) + .mockImplementation(() => { + throw { stderr: JSON.stringify({ message: 'ACL grants another account access.', code: 5 }) } + }) + expect(() => assertPrivatePath(path, 'directory')).toThrow('another account') + expect(execFileSync).toHaveBeenCalledTimes(2) + expect(() => assertPrivatePath(path, 'directory')).toThrow('another account') + expect(execFileSync).toHaveBeenCalledTimes(3) +}) + +it.each([ + [2, 'ENOENT'], + [3, 'ENOENT'], + [231, 'EBUSY'], + ['EPIPEUNPROTECTED', 'EPIPEUNPROTECTED'], + [5, 'EPIPEUNTRUSTED'] +])('classifies Windows pipe failure %s without accepting it', (nativeCode, code) => { + vi.mocked(execFileSync).mockImplementation(() => { + throw { stderr: JSON.stringify({ message: 'denied', code: nativeCode }) } + }) + expect(() => assertPrivateEndpoint('\\\\.\\pipe\\test')).toThrow('denied') + try { + assertPrivateEndpoint('\\\\.\\pipe\\test') + } catch (error) { + expect((error as NodeJS.ErrnoException).code).toBe(code) + } +}) + +it.each([2, 3])('preserves missing storage error %s without accepting or caching it', (code) => { + vi.mocked(execFileSync).mockImplementation(() => { + throw { stderr: JSON.stringify({ message: 'Path disappeared', code }) } + }) + for (let attempt = 0; attempt < 2; attempt++) + expect(() => assertPrivatePath(path, 'directory')).toThrowError( + expect.objectContaining({ code: 'ENOENT' }) + ) + expect(execFileSync).toHaveBeenCalledTimes(2) +}) + +it('does not classify filesystem denial as a missing path', () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw { stderr: JSON.stringify({ message: 'Access denied', code: 5 }) } + }) + expect(() => assertPrivatePath(path, 'directory')).toThrowError( + expect.not.objectContaining({ code: 'ENOENT' }) + ) +}) + +it('fails closed for helper startup, timeout and configuration failures', () => { + vi.mocked(execFileSync).mockImplementation(() => { + throw Object.assign(new Error('timed out'), { stderr: 'helper timed out' }) + }) + expect(() => protectPipe('\\\\.\\pipe\\test')).toThrow('helper timed out') + expect(() => protectPipe(path)).toThrow('Expected a Windows runtime pipe') + process.env.SystemRoot = '' + expect(() => assertPrivatePath(path, 'directory')).toThrow('SystemRoot') +}) diff --git a/packages/mcp-server/tests/runtime-platform.test.ts b/packages/mcp-server/tests/runtime-platform.test.ts new file mode 100644 index 00000000..54db077e --- /dev/null +++ b/packages/mcp-server/tests/runtime-platform.test.ts @@ -0,0 +1,361 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { execFileSync, spawn } from 'node:child_process' +import * as childProcess from 'node:child_process' +import { once } from 'node:events' +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { createServer, createConnection } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +// Keep real child processes while allowing the disappearance test to intercept +// the helper launch; native ESM module namespace properties are not configurable. +vi.mock('node:child_process', async (original) => ({ + ...(await original()) +})) + +import { + assertPrivateEndpoint, + assertPrivatePath, + assertPrivatePathAsync, + cleanupEndpoint, + createPrivateTempDirectory, + ensurePrivateDirectory, + isPipe, + protectPipe, + runtimeEndpoint, + windowsPowerShellEnvironment +} from '../src/runtime-platform' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { ControlPeer, LEASE_MS, RENEW_MS, SocketTransport } from '../src/runtime-prototype/protocol' +import { readPrivateJson } from '../src/runtime-prototype/state' + +const roots: string[] = [] +function fixture() { + const parent = realpathSync(mkdtempSync(join(tmpdir(), 'tp-platform-'))) + roots.push(parent) + const root = join(parent, 'private') + ensurePrivateDirectory(root) + return root +} +afterEach(() => { + vi.unstubAllEnvs() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +it('keeps Unix endpoints and isolates Windows pipes by directory, role and user', () => { + expect(runtimeEndpoint('/private/run', 'supervisor.sock', 'linux', 'a')).toBe( + join('/private/run', 'supervisor.sock') + ) + const pipe = runtimeEndpoint('C:\\private\\run', 'supervisor.sock', 'win32', 'a') + expect(pipe).toMatch(/^\\\\\.\\pipe\\tempad-runtime-[a-f0-9]{32}$/) + expect(runtimeEndpoint('c:/private/run', 'supervisor.sock', 'win32', 'a')).toBe(pipe) + expect(runtimeEndpoint('C:\\private\\run', 'hub.sock', 'win32', 'a')).not.toBe(pipe) + expect(runtimeEndpoint('C:\\private\\other', 'supervisor.sock', 'win32', 'a')).not.toBe(pipe) + expect(runtimeEndpoint('C:\\private\\run', 'supervisor.sock', 'win32', 'b')).not.toBe(pipe) +}) + +it('creates temporary storage with private permissions from its first creation', () => { + const root = createPrivateTempDirectory('tp-private-') + roots.push(root) + expect(() => assertPrivatePath(root, 'directory')).not.toThrow() +}) + +it('requires canonical private storage and regular files', () => { + const root = fixture() + expect(() => ensurePrivateDirectory(root)).not.toThrow() + expect(() => ensurePrivateDirectory('relative')).toThrow('absolute') + const path = join(root, 'state.json') + writeFileSync(path, '{}', { mode: 0o600 }) + expect(() => assertPrivatePath(path, 'file')).not.toThrow() + expect(() => assertPrivatePath(root, 'file')).toThrow('untrusted') + expect(() => assertPrivatePath(path, 'directory')).toThrow('untrusted') + cleanupEndpoint(path) + expect(() => readFileSync(path)).toThrow() + expect(isPipe(runtimeEndpoint('C:\\run', 'mcp.sock', 'win32'))).toBe(true) + expect(() => cleanupEndpoint('\\\\.\\pipe\\tempad-not-a-file')).not.toThrow() +}) + +describe.skipIf(process.platform === 'win32')('Unix storage', () => { + it('rejects exposed permissions and symlink aliases', () => { + const root = fixture() + const link = join(root, 'link') + symlinkSync(root, link) + expect(() => assertPrivatePath(link, 'directory')).toThrow('untrusted') + chmodSync(root, 0o755) + expect(() => assertPrivatePath(root, 'directory')).toThrow('untrusted') + }) +}) + +describe.skipIf(process.platform !== 'win32')('Windows ACLs', () => { + it('keeps a real control lease alive during a native ACL check longer than the lease', async () => { + const path = join(fixture(), 'heartbeat.json') + writeFileSync(path, '{}') + const control = new HubControl('secret') + const server = createServer((socket) => void control.attach(socket)) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const peer = new ControlPeer() + const socket = createConnection((server.address() as { port: number }).port, '127.0.0.1') + const execute = childProcess.execFile + const helper = vi.spyOn(childProcess, 'execFile').mockImplementationOnce((...args) => { + const command = [...(args[1] as string[])] + const index = command.indexOf('-EncodedCommand') + 1 + const script = Buffer.from(command[index]!, 'base64').toString('utf16le') + command[index] = Buffer.from( + script.replace('$request = ', `[Threading.Thread]::Sleep(${LEASE_MS + 500}); $request = `), + 'utf16le' + ).toString('base64') + return Reflect.apply(execute, childProcess, [args[0], command, ...args.slice(2)]) + }) + let timer: ReturnType | undefined + const renewals: Promise[] = [] + try { + await peer.connect(new SocketTransport(socket)) + await peer.request( + { method: 'tempad/runtime-acquire', params: { secret: 'secret', versions: [2] } }, + LeaseSchema + ) + timer = setInterval(() => { + const renewal = peer.request({ method: 'tempad/runtime-renew' }, ResultSchema) + void renewal.catch(() => {}) + renewals.push(renewal) + }, RENEW_MS) + await assertPrivatePathAsync(path, 'file') + await Promise.all(renewals) + expect(renewals.length).toBeGreaterThanOrEqual(5) + expect(control.available()).toBe(true) + expect(helper).toHaveBeenCalledOnce() + } finally { + clearInterval(timer) + helper.mockRestore() + await peer.close() + await control.close() + socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + } + }, 30_000) + + it('revalidates a snapshot replaced after the native ACL helper returns', () => { + const path = join(fixture(), 'switch.json') + writeFileSync(path, '{"revision":1}') + const execute = childProcess.execFileSync + const helper = vi.spyOn(childProcess, 'execFileSync').mockImplementationOnce((...args) => { + const result = Reflect.apply(execute, childProcess, args) + writeFileSync(`${path}.tmp`, '{"revision":2}') + renameSync(`${path}.tmp`, path) + return result + }) + try { + expect(readPrivateJson(path, z.object({ revision: z.number() }))).toEqual({ revision: 2 }) + expect(helper).toHaveBeenCalledTimes(2) + } finally { + helper.mockRestore() + } + }, 30_000) + + it.each(['file', 'directory'] as const)( + 'preserves ENOENT when a %s disappears before its ACL inspection', + (kind) => { + const path = join(fixture(), 'vanishing') + if (kind === 'file') writeFileSync(path, '{}') + else mkdirSync(path) + const execute = childProcess.execFileSync + const helper = vi.spyOn(childProcess, 'execFileSync').mockImplementationOnce((...args) => { + rmSync(path, { recursive: true }) + return Reflect.apply(execute, childProcess, args) + }) + try { + expect(() => assertPrivatePath(path, kind)).toThrowError( + expect.objectContaining({ code: 'ENOENT' }) + ) + expect(helper).toHaveBeenCalledOnce() + } finally { + helper.mockRestore() + } + }, + 30_000 + ) + + it.each([ + ['original full environment', true, false, false], + ['SDK and platform environment', false, false, false], + ['isolated home only', false, true, false], + ['isolated temp only', false, false, true], + ['isolated home and temp', false, true, true] + ] as const)( + 'verifies Unicode storage with open MCP stdin: %s', + async (name, inheritAll, isolateHome, isolateTemp) => { + const root = fixture() + const { getDefaultEnvironment } = await import('@modelcontextprotocol/sdk/client/stdio.js') + const helperPath = '../scripts/runtime-test-platform.mjs' + const { fixtureEnvironment } = await import(helperPath) + const home = join(root, 'home-设计-é') + ensurePrivateDirectory(home) + const isolated = fixtureEnvironment(home, root) + const inheritedPlatform = Object.fromEntries( + Object.keys(isolated).flatMap((key) => + process.env[key] === undefined ? [] : [[key, process.env[key]]] + ) + ) + const env = inheritAll + ? { ...process.env } + : { ...getDefaultEnvironment(), ...inheritedPlatform } + if (isolateHome) Object.assign(env, { HOME: home, USERPROFILE: home }) + if (isolateTemp) Object.assign(env, { TMPDIR: root, TEMP: root, TMP: root }) + const source = ` + process.stderr.write('permission probe: started\\n'); + const { assertPrivatePath } = await import(${JSON.stringify(new URL('../src/runtime-platform.ts', import.meta.url).href)}); + process.stderr.write('permission probe: imported\\n'); + assertPrivatePath(process.argv[1], 'directory'); + process.stdout.write('verified'); + ` + const child = spawn(process.execPath, ['--input-type=module', '-e', source, home], { + env, + timeout: 25_000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + try { + // A real host keeps this pipe open for future JSON-RPC messages. + child.stdin.write('{"jsonrpc":"2.0","method":"initialize","id":1}\n') + const [code] = await once(child, 'close') + expect(code, `${name}: ${stderr}`).toBe(0) + expect(stdout).toBe('verified') + } finally { + child.stdin.destroy() + if (child.exitCode === null) child.kill() + } + }, + 30_000 + ) + + function powershell(source: string, input: unknown) { + return execFileSync( + join(process.env.SystemRoot!, 'System32/WindowsPowerShell/v1.0/powershell.exe'), + [ + '-NoProfile', + '-NonInteractive', + '-EncodedCommand', + Buffer.from(source, 'utf16le').toString('base64') + ], + { + input: JSON.stringify(input), + encoding: 'utf8', + windowsHide: true, + env: windowsPowerShellEnvironment() + } + ) + } + + it('rejects permissive ACLs instead of treating chmod as protection', () => { + const root = fixture() + powershell( + ` + $path = [Console]::In.ReadToEnd() | ConvertFrom-Json + $acl = Get-Acl -LiteralPath $path + $everyone = New-Object System.Security.Principal.SecurityIdentifier('S-1-1-0') + $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($everyone, 'Read', 'Allow'))) + Set-Acl -LiteralPath $path -AclObject $acl + `, + root + ) + expect(() => assertPrivatePath(root, 'directory')).toThrow('another account') + expect(() => ensurePrivateDirectory(root)).toThrow('another account') + }, 30_000) + + it('protects and verifies live pipe ACLs, including after later accepts', async () => { + const path = runtimeEndpoint(fixture(), 'control.sock') + const server = createServer((socket) => socket.end()) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(path, resolve) + }) + // The default descriptor grants Everyone read, so unprotected pipes are untrusted. + // Each ACL helper opens an instance. Let Node service its close before the next check. + const inspect = async (check: () => void) => { + const closed = new Promise((resolve) => { + server.once('connection', (socket) => socket.once('close', resolve)) + }) + check() + await closed + } + await inspect(() => + expect(() => assertPrivateEndpoint(path)).toThrowError( + expect.objectContaining({ code: 'EPIPEUNPROTECTED' }) + ) + ) + await inspect(() => protectPipe(path)) + await inspect(() => assertPrivateEndpoint(path)) + await new Promise((resolve, reject) => { + createConnection(path) + .once('error', reject) + .once('close', () => resolve()) + }) + await inspect(() => assertPrivateEndpoint(path)) + for (const [extraRule, includeUser] of [ + ['(A;;GW;;;WD)', true], + ['(A;;GR;;;AU)', true], + ['', false] + ] as const) { + await inspect(() => + powershell( + ` + $request = [Console]::In.ReadToEnd() | ConvertFrom-Json + $user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + $rights = [System.IO.Pipes.PipeAccessRights]'ReadData,ReadPermissions,ChangePermissions' + $pipe = [System.IO.Pipes.NamedPipeClientStream]::new('.', $request.name, $rights, + [System.IO.Pipes.PipeOptions]::None, [System.Security.Principal.TokenImpersonationLevel]::Anonymous, + [System.IO.HandleInheritability]::None) + try { + $pipe.Connect(1000) + $security = $pipe.GetAccessControl() + $sddl = 'D:P(A;;GA;;;SY)(A;;GA;;;BA)' + if ($request.includeUser) { $sddl += '(A;;GA;;;' + $user + ')' } + $security.SetSecurityDescriptorSddlForm($sddl + $request.extraRule, + [System.Security.AccessControl.AccessControlSections]::Access) + $pipe.SetAccessControl($security) + } finally { $pipe.Dispose() } + `, + { name: path.slice('\\\\.\\pipe\\'.length), extraRule, includeUser } + ) + ) + await inspect(() => + expect(() => assertPrivateEndpoint(path)).toThrowError( + expect.objectContaining({ code: 'EPIPEUNTRUSTED' }) + ) + ) + } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } + }, 30_000) + + it('fails closed without the trusted Windows ACL tool', () => { + const root = fixture() + const file = join(root, 'unchecked.json') + writeFileSync(file, '{}') + vi.stubEnv('SystemRoot', '') + expect(() => assertPrivatePath(file, 'file')).toThrow('SystemRoot') + }, 30_000) +}) diff --git a/packages/mcp-server/tests/runtime-probe-isolation.test.ts b/packages/mcp-server/tests/runtime-probe-isolation.test.ts new file mode 100644 index 00000000..39abc749 --- /dev/null +++ b/packages/mcp-server/tests/runtime-probe-isolation.test.ts @@ -0,0 +1,69 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' + +const preload = new URL('../scripts/runtime-probe-isolation.mjs', import.meta.url).href +const roots: string[] = [] +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function probe(code: string, root?: string) { + return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn( + process.execPath, + ['--import', preload, '--input-type=module', '--eval', code], + { + env: { PATH: process.env.PATH, ...(root ? { TEMPAD_PROBE_ROOT: root } : {}) }, + stdio: ['ignore', 'pipe', 'pipe'] + } + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.once('error', reject) + child.once('close', (code) => resolve({ code, stdout, stderr })) + }) +} + +it('refuses to run without an explicit absolute Unix measurement root', async () => { + const result = await probe('throw new Error("should not run")') + expect(result.code).not.toBe(0) + expect(result.stderr).toContain('isolated Unix runtime root') +}) + +it.skipIf(process.platform === 'win32')( + 'remaps HTTP/WS TCP ports and retains only private IPC listeners', + async () => { + const root = await realpath(await mkdtemp('/tmp/tp-i-')) + roots.push(root) + const result = await probe( + ` + import assert from 'node:assert/strict'; + import { spawn } from 'node:child_process'; + import { createServer } from 'node:net'; + import { createServer as httpServer } from 'node:http'; + for (const server of [createServer(), httpServer()]) { + await new Promise(resolve => server.listen({ port: 6220, host: '0.0.0.0' }, resolve)); + assert.notEqual(server.address().port, 6220); + assert.equal(server.address().address, '127.0.0.1'); + await new Promise(resolve => server.close(resolve)); + } + const local = createServer(); + await new Promise(resolve => local.listen(${JSON.stringify(join(root, 'mcp.sock'))}, resolve)); + await new Promise(resolve => local.close(resolve)); + assert.throws(() => createServer().listen('/tmp/outside-tempad-probe.sock'), /outside/); + assert.throws(() => createServer().listen({ fd: 4 }), /unrecognized/); + assert.throws(() => spawn(process.execPath, ['--version']), /own every child process/); + `, + root + ) + expect(result, result.stderr).toMatchObject({ code: 0 }) + } +) diff --git a/packages/mcp-server/tests/runtime-probe.test.ts b/packages/mcp-server/tests/runtime-probe.test.ts new file mode 100644 index 00000000..4ad827f9 --- /dev/null +++ b/packages/mcp-server/tests/runtime-probe.test.ts @@ -0,0 +1,168 @@ +import { rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +const fake = vi.hoisted(() => ({ + roots: [] as string[], + connect: vi.fn(), + close: vi.fn(), + request: vi.fn(), + fingerprint: vi.fn(), + contract: vi.fn(), + stderr: vi.fn(), + delay: vi.fn(), + version: '0.11.1' +})) +vi.mock('node:timers/promises', () => ({ setTimeout: fake.delay })) +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: class { + connect = fake.connect + close = fake.close + getServerVersion = () => ({ name: 'fixture', version: fake.version }) + } +})) +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: class { + constructor(options: { args: string[] }) { + fake.roots.push(options.args[2]!) + } + stderr = { on: fake.stderr } + close = fake.close + } +})) +vi.mock('../src/runtime-prototype/protocol', async (importOriginal) => ({ + ...(await importOriginal()), + ControlPeer: class { + connect = fake.connect + close = fake.close + request = fake.request + }, + SocketTransport: class {} +})) +vi.mock('../src/runtime-prototype/processes', () => ({ + connectSocket: async () => ({}), + fingerprintBundle: fake.fingerprint +})) +vi.mock('../src/stable-edge', async (importOriginal) => ({ + ...(await importOriginal()), + readRuntimeContract: fake.contract +})) +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { probeArtifact } from '../src/runtime-prototype/probe' + +let root: string +let entry: string +beforeEach(() => { + vi.clearAllMocks() + root = createPrivateTempDirectory('tp-artifact-') + entry = join(root, 'runtime.mjs') + writeFileSync(entry, 'export {}') + fake.version = '0.11.1' + fake.fingerprint.mockReturnValue('a'.repeat(64)) + fake.connect.mockResolvedValue(undefined) + fake.delay.mockResolvedValue(undefined) + fake.request.mockResolvedValue({ handoffVersion: 1, hubPid: 900001, supervisorPid: 900002 }) + fake.contract.mockResolvedValue({ pages: new Map([['', { tools: [] }]]), instructions: 'stable' }) + const originalKill = process.kill.bind(process) + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === 900001 || pid === 900002) + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + return originalKill(pid, signal) + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + for (const path of [root, ...fake.roots.splice(0)]) rmSync(path, { recursive: true, force: true }) +}) +it('verifies the local contract and bundle and removes only a fully stopped probe', async () => { + const result = await probeArtifact(entry) + expect(result).toMatchObject({ artifact: { version: '0.11.1', bundle: 'a'.repeat(64) } }) + expect(result.contract).toMatch(/^[a-f0-9]{64}$/) + expect(fake.close).toHaveBeenCalledTimes(3) + expect(existsSync(fake.roots[0]!)).toBe(false) +}) +it('allows initial attachment beyond 15 seconds within the production recovery budget', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + fake.connect.mockImplementationOnce( + (_transport, { timeout }: { timeout: number }) => + new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error('Initialize timed out')), timeout) + setTimeout(() => { + clearTimeout(deadline) + resolve() + }, 16_000) + }) + ) + const probe = probeArtifact(entry) + const completed = expect(probe).resolves.toMatchObject({ artifact: { version: '0.11.1' } }) + await vi.advanceTimersByTimeAsync(16_000) + await completed + expect(existsSync(fake.roots[0]!)).toBe(false) +}) +it('keeps an unresponsive initialization bounded and forwards its private diagnostics', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) + const failure = new Error('Initialize timed out') + fake.connect.mockImplementationOnce( + (_transport, { timeout }: { timeout: number }) => + new Promise((_resolve, reject) => { + fake.stderr.mock.calls[0]![1](Buffer.from('private probe startup')) + setTimeout(() => reject(failure), timeout) + }) + ) + const probe = probeArtifact(entry) + const failed = expect(probe).rejects.toMatchObject({ + cause: failure, + diagnostic: { + phase: 'initialize', + elapsedMs: 95_000, + stderr: 'private probe startup' + } + }) + await vi.advanceTimersByTimeAsync(95_000) + await failed + expect(fake.request).not.toHaveBeenCalled() + expect(existsSync(join(fake.roots[0]!, 'probe-failure.json'))).toBe(true) + expect(fake.close).toHaveBeenCalledTimes(3) +}) +it('rejects a bundle changed during probing', async () => { + fake.fingerprint.mockReturnValueOnce('a'.repeat(64)).mockReturnValueOnce('b'.repeat(64)) + await expect(probeArtifact(entry)).rejects.toThrow('changed during verification') + expect(existsSync(fake.roots[0]!)).toBe(false) +}) +it('preserves evidence and the original error when child ownership is unknown', async () => { + const failure = new Error('Initialization failed') + fake.connect.mockImplementationOnce(async () => { + const receive = fake.stderr.mock.calls[0]![1] + receive(Buffer.alloc(20_000, 'x')) + receive(Buffer.from('final stderr')) + throw failure + }) + await expect(probeArtifact(entry)).rejects.toMatchObject({ + cause: failure, + message: expect.stringContaining('ownership is unknown after initialize failed'), + diagnostic: { + phase: 'initialize', + stderr: expect.stringContaining('final stderr') + } + }) + expect(existsSync(fake.roots[0]!)).toBe(true) + const diagnostic = JSON.parse(readFileSync(join(fake.roots[0]!, 'probe-failure.json'), 'utf8')) + expect(diagnostic).toMatchObject({ + phase: 'initialize', + failure: expect.stringContaining('Initialization failed'), + stderr: expect.stringContaining('final stderr') + }) + expect(Buffer.byteLength(diagnostic.stderr)).toBeLessThanOrEqual(16_384) + expect(diagnostic).not.toHaveProperty('pids') + expect(fake.delay).not.toHaveBeenCalled() +}) +it('distinguishes known processes that remain alive from startup ownership failures', async () => { + vi.mocked(process.kill).mockReturnValue(true) + await expect(probeArtifact(entry)).rejects.toThrow('did not stop') + expect(existsSync(fake.roots[0]!)).toBe(true) + const diagnostic = JSON.parse(readFileSync(join(fake.roots[0]!, 'probe-failure.json'), 'utf8')) + expect(diagnostic).toMatchObject({ phase: 'shutdown', pids: [900001, 900002], stderr: '' }) + expect(fake.delay).toHaveBeenCalledTimes(60) + expect(fake.delay).toHaveBeenCalledWith(100) +}) diff --git a/packages/mcp-server/tests/runtime-profile.test.ts b/packages/mcp-server/tests/runtime-profile.test.ts new file mode 100644 index 00000000..70fce41c --- /dev/null +++ b/packages/mcp-server/tests/runtime-profile.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' + +import { + checkoutProfile, + resolveRuntimeProfile, + runtimeDirectories, + runtimeSocketPath +} from '../../../agent-plugin/src/clients/shared/runtime-profile.mjs' + +const root = realpathSync(mkdtempSync(join(tmpdir(), 'tp-profile-'))) +function checkout(name: string) { + const path = join(root, name) + mkdirSync(join(path, 'packages/mcp-server/dist'), { recursive: true }) + for (const file of ['cli.mjs', 'hub.mjs']) + writeFileSync(join(path, 'packages/mcp-server/dist', file), '') + return path +} +const first = checkout('first') +const second = checkout('second') +afterAll(() => rmSync(root, { recursive: true, force: true })) + +describe('runtime profiles', () => { + it('keeps all release launches in one namespace regardless of launch update policy', () => { + expect(resolveRuntimeProfile({})).toEqual({ kind: 'release' }) + const paths = runtimeDirectories({}, undefined, { tmpdir: root, homedir: root }) + expect(paths.runtime).toBe(join(root, 'tempad-dev/run')) + expect(paths.state).toBe(join(root, '.tempad-dev/state')) + expect(runtimeSocketPath(paths.runtime, undefined, 'win32')).toBe('\\\\.\\pipe\\tempad-mcp') + }) + + it('isolates two checkouts and all explicit directory overrides from release', () => { + const overrides = Object.fromEntries( + ['RUNTIME', 'LOG', 'ASSET', 'STATE'].map((key) => [`TEMPAD_MCP_${key}_DIR`, join(root, key)]) + ) + const release = runtimeDirectories(overrides) + const a = runtimeDirectories({ ...overrides, TEMPAD_MCP_DEV_CHECKOUT: first }) + const b = runtimeDirectories({ ...overrides, TEMPAD_MCP_DEV_CHECKOUT: second }) + for (const key of ['runtime', 'log', 'assets', 'state'] as const) { + expect(new Set([release[key], a[key], b[key]]).size).toBe(3) + expect(a[key]).toBe(join(release[key], `checkout-${checkoutProfile(first).id}`)) + } + expect(a.root).toBeUndefined() + }) + + it('treats an empty checkout state override as unset without changing release behavior', () => { + const env = { + TEMPAD_MCP_RUNTIME_DIR: join(root, 'run'), + TEMPAD_MCP_LOG_DIR: join(root, 'log'), + TEMPAD_MCP_ASSET_DIR: join(root, 'assets'), + TEMPAD_MCP_STATE_DIR: '' + } + expect(runtimeDirectories(env).state).toBe('') + const paths = runtimeDirectories({ ...env, TEMPAD_MCP_DEV_CHECKOUT: first }) + expect(paths.state).toBe(join(paths.runtime, 'state')) + expect( + runtimeDirectories({ TEMPAD_MCP_DEV_CHECKOUT: first, TEMPAD_MCP_STATE_DIR: '' }, undefined, { + homedir: root + }).state + ).toBe(join(root, '.tempad-dev/checkouts', checkoutProfile(first).id, 'state')) + }) + + it('keeps default checkout stores durable and runtime roots independent of the legacy root', () => { + const env = { TEMPAD_MCP_DEV_CHECKOUT: first } + const profile = checkoutProfile(first) + const paths = runtimeDirectories(env, profile, { tmpdir: root, homedir: root, uid: 17 }) + expect(paths.runtime).toBe(join(root, `tpd-17-${profile.id}/run`)) + expect(paths.state).toBe(join(root, `.tempad-dev/checkouts/${profile.id}/state`)) + expect(runtimeDirectories({ ...env, TEMPAD_MCP_RUNTIME_DIR: root }).state).toBe( + join(root, `checkout-${profile.id}/state`) + ) + expect(runtimeSocketPath('/tmp/short', profile, 'darwin')).toBe(join('/tmp/short', 'mcp.sock')) + expect(() => runtimeSocketPath('/' + '界'.repeat(40), profile, 'darwin')).toThrow( + 'shorter TEMPAD_MCP_RUNTIME_DIR' + ) + }) + + it('names Windows checkout pipes by user and checkout, not directory overrides', () => { + const profiles = [checkoutProfile(first), checkoutProfile(second)] + const homes = ['C:\\Users\\alice', 'C:\\Users\\bob'] + const pipes = profiles.flatMap((profile) => + homes.map((home) => runtimeSocketPath('C:\\runtime-a', profile, 'win32', home)) + ) + expect(new Set(pipes).size).toBe(4) + for (const pipe of pipes) expect(pipe).toMatch(/tempad-mcp-[a-f0-9]{16}-[a-f0-9]{16}$/) + for (const home of homes) { + expect(runtimeSocketPath('C:\\runtime-a', undefined, 'win32', home)).toBe( + '\\\\.\\pipe\\tempad-mcp' + ) + expect(runtimeSocketPath('C:\\runtime-b', profiles[0], 'win32', home)).toBe( + runtimeSocketPath('C:\\runtime-a', profiles[0], 'win32', home) + ) + } + }) + + it('uses the real checkout identity across symlink launches', () => { + const link = join(root, 'alias') + symlinkSync(first, link, 'junction') + expect(checkoutProfile(link)).toEqual(checkoutProfile(first)) + const entry = pathToFileURL(join(link, 'packages/mcp-server/dist/cli.mjs')) + expect(resolveRuntimeProfile({ TEMPAD_MCP_DEV_CHECKOUT: link }, entry)).toEqual( + checkoutProfile(first) + ) + }) + + it('rejects relative, empty, missing, incomplete and conflicting checkout inputs', () => { + for (const path of ['', '.', join(root, 'missing')]) + expect(() => checkoutProfile(path)).toThrow() + expect(() => checkoutProfile(join(first, 'packages/mcp-server/dist/cli.mjs'))).toThrow( + 'directory' + ) + const entry = pathToFileURL(join(first, 'packages/mcp-server/dist/cli.mjs')) + expect(() => resolveRuntimeProfile({ TEMPAD_MCP_DEV_CHECKOUT: second }, entry)).toThrow( + 'does not belong' + ) + const incomplete = join(root, 'incomplete') + mkdirSync(incomplete) + expect(() => resolveRuntimeProfile({ TEMPAD_MCP_DEV_CHECKOUT: incomplete }, entry)).toThrow() + }) + + it.each(['directory', 'symlink'] as const)( + 'rejects a %s in place of the checkout Hub bundle', + (kind) => { + const path = checkout(kind) + const hub = join(path, 'packages/mcp-server/dist/hub.mjs') + rmSync(hub) + if (kind === 'directory') mkdirSync(hub) + else symlinkSync(join(first, 'packages/mcp-server/dist/hub.mjs'), hub) + expect(() => + resolveRuntimeProfile( + { TEMPAD_MCP_DEV_CHECKOUT: path }, + pathToFileURL(join(path, 'packages/mcp-server/dist/cli.mjs')) + ) + ).toThrow('Invalid checkout runtime') + } + ) +}) diff --git a/packages/mcp-server/tests/runtime-prototype-edge.test.ts b/packages/mcp-server/tests/runtime-prototype-edge.test.ts new file mode 100644 index 00000000..ecb74d65 --- /dev/null +++ b/packages/mcp-server/tests/runtime-prototype-edge.test.ts @@ -0,0 +1,488 @@ +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { Notification } from '@modelcontextprotocol/sdk/types.js' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { ListToolsRequestSchema, McpError, ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { createServer, type Socket } from 'node:net' +import { afterEach, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const state = vi.hoisted(() => ({ + host: undefined as Transport | undefined, + path: '', + generation: 0, + hubId: '' +})) +vi.mock('../src/consumer-transport', async (load) => ({ + ...(await load()), + ConsumerTransport: class { + constructor() { + return state.host! + } + } +})) +vi.mock('../src/runtime-prototype/processes', async (load) => { + const actual = await load() + return { + ...actual, + supervisorConnection: (_config: unknown, signal: AbortSignal) => + actual.connectSocket(state.path, signal) + } +}) + +import type { ClientBindingUpdate } from '../src/client-binding' + +import { startPrototypeEdge } from '../src/runtime-prototype/edge' +import * as processes from '../src/runtime-prototype/processes' +import { + hubSocketPath, + listenPrivate, + type PrototypeConfig +} from '../src/runtime-prototype/processes' +import { + ControlPeer, + CONTROL_VERSIONS, + RECOVER_MS, + RuntimeDataTransport, + SocketTransport, + unavailable +} from '../src/runtime-prototype/protocol' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +async function fixture( + options: { hello?: () => void; attach?: () => void; delayData?: boolean } = {} +) { + const exitCode = process.exitCode + vi.spyOn(process.stdin, 'destroy').mockImplementation(() => process.stdin) + cleanup.push(async () => { + process.exitCode = exitCode + }) + const [clientTransport, host] = InMemoryTransport.createLinkedPair() + state.host = host + const root = await mkdtemp('/tmp/tp-edge-') + await mkdir(`${root}/run`, { mode: 0o700 }) + cleanup.push(() => rm(root, { recursive: true, force: true })) + state.path = `${root}/control.sock` + state.generation = 0 + state.hubId = randomUUID() + const registrations: unknown[] = [] + const controls: ControlPeer[] = [] + const runtimes: Server[] = [] + let ready = 0 + const sockets = new Set() + const controlServer = createServer((socket) => { + sockets.add(socket) + const peer = new ControlPeer() + controls.push(peer) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-ready'), + params: z.object({ hubId: z.uuid() }) + }), + () => { + ready++ + return {} + } + ) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-refresh') }), () => ({ + version: CONTROL_VERSIONS[0], + generation: state.generation, + hubId: state.hubId, + hubPid: process.pid, + supervisorPid: process.pid + })) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-hello'), + params: z.record(z.string(), z.unknown()) + }), + ({ params }) => { + registrations.push(params) + options.hello?.() + return { + version: CONTROL_VERSIONS[0], + generation: ++state.generation, + hubId: state.hubId, + hubPid: process.pid, + supervisorPid: process.pid + } + } + ) + void peer.connect(new SocketTransport(socket)) + }) + const notifications: Notification[] = [] + const calls = vi.fn(async () => ({ content: [], structuredContent: { received: true } })) + let initialized = 0 + const dataServer = createServer((socket) => { + sockets.add(socket) + const wire = new SocketTransport(socket) + wire.onmessage = async (message) => { + if (!('id' in message) || message.id === undefined) + throw new Error('Expected attachment request') + try { + options.attach?.() + } catch (error) { + if (!(error instanceof McpError)) throw error + await wire.send({ + jsonrpc: '2.0', + id: message.id, + error: { code: error.code, message: error.message, data: error.data } + }) + return + } + await wire.send({ jsonrpc: '2.0', id: message.id, result: {} }) + const runtime = new Server( + { name: 'fixture', version: '1' }, + { capabilities: { tools: {} }, instructions: 'Frozen' } + ) + runtimes.push(runtime) + runtime.oninitialized = () => initialized++ + runtime.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] })) + runtime.fallbackRequestHandler = calls + runtime.fallbackNotificationHandler = async (notification) => { + notifications.push(notification) + } + await runtime.connect( + new RuntimeDataTransport(wire, { + generation: () => state.generation, + admit: (generation) => generation === state.generation, + role: 'hub', + timeoutMs: 5000, + started: true + }) + ) + } + void wire.start() + }) + cleanup.push(async () => { + for (const socket of sockets) socket.destroy() + await Promise.all( + [controlServer, dataServer].map( + (server) => new Promise((resolve) => server.close(() => resolve())) + ) + ) + }) + await listenPrivate(controlServer, state.path) + const publishData = () => listenPrivate(dataServer, hubSocketPath(root)) + if (!options.delayData) await publishData() + const edge = startPrototypeEdge({ root } as PrototypeConfig) + const client = new Client({ name: 'fixture host', version: '1' }) + cleanup.push(async () => { + await client.close() + await edge + }) + return { + client, + connect: () => client.connect(clientTransport), + edge, + registrations, + controls, + calls, + notifications, + publishData, + disconnectData: () => runtimes.at(-1)!.close(), + publishBinding: async (binding: ClientBindingUpdate) => { + const runtime = runtimes.at(-1)! + await runtime.notification({ method: 'notifications/tempad/client-binding', params: binding }) + // The ordered reply proves the private notification reached the edge + // before the control socket announces a replacement. + await runtime.request({ method: 'ping' }, ResultSchema) + }, + replaceHub: async () => { + const previous = ready + state.hubId = randomUUID() + await controls.at(-1)!.notification({ + method: 'notifications/tempad/runtime-replaced', + params: { + version: CONTROL_VERSIONS[0], + generation: ++state.generation, + hubId: state.hubId, + hubPid: process.pid, + supervisorPid: process.pid + } + }) + await vi.waitFor(() => expect(ready).toBeGreaterThan(previous)) + }, + initialized: () => initialized, + exitCode + } +} + +it.skipIf(process.platform === 'win32')( + 'keeps its admission barrier across same-Hub data reconnection until its owner resumes it', + async () => { + const { client, connect, controls, calls, disconnectData, initialized } = await fixture() + await connect() + const owner = controls.at(-1)! + await owner.request( + { method: 'tempad/runtime-admission', params: { hubId: state.hubId, paused: true } }, + ResultSchema + ) + const pending = client.request({ method: 'fixture/write' }, ResultSchema) + await disconnectData() + await vi.waitFor(() => expect(initialized()).toBe(2)) + await expect( + owner.request( + { method: 'tempad/runtime-admission', params: { hubId: randomUUID(), paused: false } }, + ResultSchema + ) + ).rejects.toMatchObject({ data: { dispatched: false } }) + expect(calls).not.toHaveBeenCalled() + await owner.request( + { method: 'tempad/runtime-admission', params: { hubId: state.hubId, paused: false } }, + ResultSchema + ) + expect(await pending).toMatchObject({ structuredContent: { received: true } }) + expect(calls).toHaveBeenCalledOnce() + } +) + +it.skipIf(process.platform === 'win32').each(['control', 'backend'] as const)( + 'releases an old admission barrier after a verified %s replacement', + async (kind) => { + const { client, connect, controls, calls, registrations, replaceHub } = await fixture() + await connect() + await controls + .at(-1)! + .request( + { method: 'tempad/runtime-admission', params: { hubId: state.hubId, paused: true } }, + ResultSchema + ) + const pending = client.request({ method: 'fixture/write' }, ResultSchema) + if (kind === 'control') { + await controls.at(-1)!.close() + await vi.waitFor(() => expect(registrations).toHaveLength(2)) + } else await replaceHub() + expect(await pending).toMatchObject({ structuredContent: { received: true } }) + expect(calls).toHaveBeenCalledOnce() + } +) + +it.skipIf(process.platform === 'win32')( + 'reattaches control without reinitializing MCP, inheriting another host or replaying tools', + async () => { + const { + client, + connect, + edge, + registrations, + controls, + calls, + notifications, + initialized, + exitCode + } = await fixture() + await connect() + expect(client.getInstructions()).toBe('Frozen') + expect(initialized()).toBe(1) + await client.request({ method: 'tools/call', params: { name: 'write' } }, ResultSchema) + expect(calls).toHaveBeenCalledOnce() + await controls[0]!.close() + await vi.waitFor(() => expect(registrations).toHaveLength(2)) + expect(registrations[1]).toEqual(registrations[0]) + await client.request({ method: 'tools/call', params: { name: 'read' } }, ResultSchema) + expect(calls).toHaveBeenCalledTimes(2) + expect(initialized()).toBe(1) + expect(notifications.map((value) => value.method)).toEqual([ + 'notifications/tempad/host-context', + 'notifications/tempad/client-event', + 'notifications/tempad/subscribe-client-binding' + ]) + await client.close() + await edge + expect(process.exitCode).toBe(exitCode) + } +) + +function claudeEnvironment() { + for (const name of ['CODEX_APP_TOOLS_PIPE_PATH', 'CODEX_THREAD_ID', 'CODEX_SESSION_ID']) + vi.stubEnv(name, undefined) + vi.stubEnv('CLAUDECODE', '1') + vi.stubEnv('CLAUDE_CODE_SESSION_ID', 'birth-hint') +} + +it.skipIf(process.platform === 'win32')( + 'preserves verified Claude reconnect claims, rotation and clearing across Hub replacements', + async () => { + claudeEnvironment() + const { client, connect, notifications, publishBinding, replaceHub, initialized } = + await fixture() + const publicNotifications = vi.fn() + client.fallbackNotificationHandler = publicNotifications + await connect() + for (const sessionId of ['verified-a', 'verified-b', undefined]) { + await publishBinding({ kind: 'claude', ...(sessionId ? { sessionId } : {}) }) + // Retiring only a birth hint must not erase an already verified claim. + await publishBinding({ clearSessionHint: true }) + await replaceHub() + const event = notifications.findLast( + (value) => value.method === 'notifications/tempad/client-event' + ) + const subscription = notifications.findLast( + (value) => value.method === 'notifications/tempad/subscribe-client-binding' + ) + expect(event?.params).toEqual({ + event: 'connect', + kind: 'claude', + ...(sessionId ? { sessionId } : {}) + }) + expect(subscription?.params).toBeUndefined() + } + expect(initialized()).toBe(4) + expect(publicNotifications).not.toHaveBeenCalled() + } +) + +it.skipIf(process.platform === 'win32').each([false, true])( + 'replays a Claude birth hint only until retired (clearSessionHint=%s)', + async (clear) => { + claudeEnvironment() + const { connect, notifications, publishBinding, replaceHub } = await fixture() + await connect() + expect(notifications.at(-1)?.params).toEqual({ sessionId: 'birth-hint' }) + if (clear) await publishBinding({ clearSessionHint: true }) + await replaceHub() + const event = notifications.findLast( + (value) => value.method === 'notifications/tempad/client-event' + ) + const subscription = notifications.findLast( + (value) => value.method === 'notifications/tempad/subscribe-client-binding' + ) + expect(event?.params).toEqual({ event: 'connect', kind: 'claude' }) + expect(subscription?.params).toEqual(clear ? undefined : { sessionId: 'birth-hint' }) + } +) + +it.skipIf(process.platform === 'win32').each(['hello', 'attach'] as const)( + 'waits for initial %s admission without replaying MCP initialization or tools', + async (stage) => { + let attempts = 0 + const { client, connect, initialized, calls } = await fixture({ + [stage]: () => { + if (++attempts < 3) throw unavailable('Runtime handoff in progress; retry attachment.') + } + }) + await connect() + expect(attempts).toBe(3) + expect(initialized()).toBe(1) + await client.request({ method: 'tools/call', params: { name: 'write' } }, ResultSchema) + expect(calls).toHaveBeenCalledOnce() + } +) + +it.skipIf(process.platform === 'win32')( + 'retries only the unavailable data socket before initialization', + async () => { + const connectSocket = vi.spyOn(processes, 'connectSocket') + const { connect, publishData, initialized } = await fixture({ delayData: true }) + const connecting = connect() + void connecting.catch(() => {}) + await vi.waitFor(() => expect(connectSocket.mock.calls.length).toBeGreaterThan(1)) + await publishData() + await connecting + expect(initialized()).toBe(1) + } +) + +it.skipIf(process.platform === 'win32').each(['EPIPEUNPROTECTED', 'ETIMEDOUT'])( + 'waits for data endpoint %s without replaying initialization or tools', + async (code) => { + const connectSocket = vi + .spyOn(processes, 'connectSocket') + .mockRejectedValueOnce(Object.assign(new Error('Endpoint acquisition pending'), { code })) + const { connect, initialized, client, calls } = await fixture() + await connect() + expect(connectSocket).toHaveBeenCalledTimes(2) + expect(initialized()).toBe(1) + await client.request({ method: 'tools/call', params: { name: 'write' } }, ResultSchema) + expect(calls).toHaveBeenCalledOnce() + } +) + +it.skipIf(process.platform === 'win32')( + 'does not retry filesystem ENOENT from supervisor startup', + async () => { + const error = Object.assign(new Error('Missing verified release artifact'), { code: 'ENOENT' }) + const supervisor = vi.spyOn(processes, 'supervisorConnection').mockRejectedValue(error) + const timeout = AbortSignal.timeout.bind(AbortSignal) + vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => + timeout(milliseconds === RECOVER_MS ? 500 : milliseconds) + ) + const { connect, initialized } = await fixture() + await expect(connect()).rejects.toThrow(error.message) + expect(supervisor).toHaveBeenCalledOnce() + expect(initialized()).toBe(0) + } +) + +it + .skipIf(process.platform === 'win32') + .each([ + new McpError(-32002, 'MCP_RUNTIME_RESTART_REQUIRED: incompatible protocol.'), + new Error('MCP_RUNTIME_RESTART_REQUIRED: pinned client requests a different runtime.'), + new McpError(-32001, 'Unclassified request timeout.') + ])('rejects permanent registration failures without retrying: %s', async (error) => { + const { connect, registrations, initialized } = await fixture({ + hello: () => { + throw error + } + }) + await expect(connect()).rejects.toThrow(error.message) + expect(registrations).toHaveLength(1) + expect(initialized()).toBe(0) +}) + +it.skipIf(process.platform === 'win32')( + 'bounds initial recovery when admission never becomes available', + async () => { + const deadline = new AbortController() + const timeout = AbortSignal.timeout.bind(AbortSignal) + vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => + milliseconds === RECOVER_MS ? deadline.signal : timeout(milliseconds) + ) + const { connect, registrations, initialized } = await fixture({ + hello: () => { + throw unavailable() + } + }) + const connecting = expect(connect()).rejects.toThrow('Runtime startup recovery timed out') + await vi.waitFor(() => expect(registrations.length).toBeGreaterThan(1)) + expect(AbortSignal.timeout).toHaveBeenCalledWith(RECOVER_MS) + deadline.abort() + await connecting + const attempts = registrations.length + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(registrations).toHaveLength(attempts) + expect(initialized()).toBe(0) + } +) + +it.skipIf(process.platform === 'win32')( + 'cancels initial recovery when the host closes', + async () => { + const { client, connect, edge, registrations, initialized } = await fixture({ + hello: () => { + throw unavailable() + } + }) + const connecting = expect(connect()).rejects.toThrow('Connection closed') + await vi.waitFor(() => expect(registrations.length).toBeGreaterThan(0)) + await client.close() + await connecting + await edge + const attempts = registrations.length + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(registrations).toHaveLength(attempts) + expect(initialized()).toBe(0) + } +) diff --git a/packages/mcp-server/tests/runtime-prototype-processes.test.ts b/packages/mcp-server/tests/runtime-prototype-processes.test.ts new file mode 100644 index 00000000..1bf76764 --- /dev/null +++ b/packages/mcp-server/tests/runtime-prototype-processes.test.ts @@ -0,0 +1,311 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { EventEmitter } from 'node:events' +import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { createServer, type Socket } from 'node:net' +import { join } from 'node:path' +import lockfile from 'proper-lockfile' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +vi.mock('node:child_process', async (original) => ({ + ...(await original()), + spawn: vi.fn() +})) + +import { captureHostContext } from '../src/agent-clients/host-context' +import { createHubRuntimeIdentity } from '../src/runtime-identity' +import { HubControl } from '../src/runtime-prototype/hub-control' +import { + connectSocket, + hubSocketPath, + launch, + listenPrivate, + prototypeConfig, + supervisorConnection, + waitForSocket, + type PrototypeConfig +} from '../src/runtime-prototype/processes' +import { + ControlPeer, + GrantSchema, + RECOVER_MS, + SocketTransport, + unavailable +} from '../src/runtime-prototype/protocol' +import { startSupervisor } from '../src/runtime-prototype/supervisor' +import { PACKAGE_VERSION } from '../src/shared' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() + vi.mocked(spawn).mockReset() +}) + +async function fixture() { + const root = await realpath(await mkdtemp('/tmp/tp-process-')) + cleanup.push(() => rm(root, { recursive: true, force: true })) + const entry = join(root, 'runtime-prototype.mjs') + await writeFile(entry, 'export const fixture = true') + return prototypeConfig(root, entry) +} + +async function listener(path: string, accept: (socket: Socket) => void = () => {}) { + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + accept(socket) + }) + cleanup.push(async () => { + for (const socket of sockets) socket.destroy() + if (server.listening) await new Promise((resolve) => server.close(() => resolve())) + }) + await listenPrivate(server, path) + return server +} + +describe.skipIf(process.platform === 'win32')('isolated prototype processes', () => { + it('pins the complete local bundle and refuses mutations or an exposed configuration', async () => { + const config = await fixture() + expect(prototypeConfig(config.root, config.entry)).toEqual(config) + await writeFile(join(config.root, 'imported.mjs'), 'export const value = 2') + expect(() => prototypeConfig(config.root, config.entry)).toThrow('bundle changed') + await rm(join(config.root, 'imported.mjs')) + await chmod(join(config.root, 'prototype.json'), 0o644) + expect(() => prototypeConfig(config.root, config.entry)).toThrow('untrusted') + }) + + it('starts only its pinned entry with a sanitized environment', async () => { + const config = await fixture() + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }) as unknown as ChildProcess + vi.mocked(spawn).mockReturnValue(child) + vi.stubEnv('CODEX_THREAD_ID', 'must-not-inherit') + vi.stubEnv('NODE_OPTIONS', '--require=must-not-run') + try { + expect(launch(config, 'hub')).toBe(child) + expect(spawn).toHaveBeenCalledWith( + process.execPath, + [config.entry, '--root', config.root, '--role', 'hub'], + { + detached: true, + windowsHide: true, + stdio: 'ignore', + env: expect.not.objectContaining({ + CODEX_THREAD_ID: expect.anything(), + NODE_OPTIONS: expect.anything() + }) + } + ) + expect(child.unref).toHaveBeenCalledOnce() + expect(vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env).not.toHaveProperty('NODE_OPTIONS') + expect(vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env).not.toHaveProperty('CODEX_THREAD_ID') + child.emit('error', new Error('Start failed')) + } finally { + vi.unstubAllEnvs() + } + }) + + it('elects one startup owner and reuses an existing private endpoint', async () => { + const config = await fixture() + const path = join(config.root, 'supervisor.sock') + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }) as unknown as ChildProcess + let listening: Promise | undefined + vi.mocked(spawn).mockImplementation(() => { + listening = listener(path) + return child + }) + const signal = new AbortController().signal + const sockets = await Promise.all([ + supervisorConnection(config, signal), + supervisorConnection(config, signal) + ]) + await listening + expect(spawn).toHaveBeenCalledOnce() + sockets.push(await supervisorConnection(config, signal)) + sockets.push(await waitForSocket(path)) + expect(spawn).toHaveBeenCalledOnce() + sockets.forEach((socket) => socket.destroy()) + const abort = new AbortController() + abort.abort() + await expect(connectSocket(path, abort.signal)).rejects.toThrow('cancelled') + await expect(waitForSocket(join(config.root, 'absent'), abort.signal)).rejects.toThrow() + await chmod(path, 0o666) + expect(() => connectSocket(path)).toThrow('untrusted') + }) + + it('marks only endpoint availability exhaustion as retryable startup failure', async () => { + const config = await fixture() + vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValue(5001) + await expect(waitForSocket(join(config.root, 'absent'))).rejects.toMatchObject({ + code: -32001, + data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } + }) + }) + + it('waits past one connection attempt for an existing owner without replaying a command', async () => { + const config = await fixture() + const owner = join(config.root, 'supervisor.lock') + await writeFile(owner, '') + const unlock = await lockfile.lock(owner, { stale: 5000, update: 1000 }) + cleanup.push(unlock) + const execute = vi.fn(() => { + throw unavailable('Command was not admitted.') + }) + let published: Promise | undefined + const publication = setTimeout(() => { + published = listener(join(config.root, 'supervisor.sock'), (socket) => { + const peer = new ControlPeer() + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-recover') }), execute) + void peer.connect(new SocketTransport(socket)) + }) + }, 6500) + try { + const peer = new ControlPeer() + await peer.connect( + new SocketTransport(await supervisorConnection(config, AbortSignal.timeout(9000))) + ) + cleanup.push(() => peer.close()) + expect(spawn).not.toHaveBeenCalled() + await expect( + peer.request({ method: 'tempad/runtime-recover' }, ResultSchema) + ).rejects.toThrow('not admitted') + expect(execute).toHaveBeenCalledOnce() + } finally { + clearTimeout(publication) + await published + } + }, 10000) + + it.each(['caller', 'recovery'] as const)( + 'bounds supervisor discovery by the %s deadline', + async (source) => { + const config = await fixture() + const owner = join(config.root, 'supervisor.lock') + await writeFile(owner, '') + cleanup.push(await lockfile.lock(owner, { stale: 5000, update: 1000 })) + const deadline = new AbortController() + const error = new Error('Caller cancelled command') + if (source === 'recovery') vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal) + const connecting = supervisorConnection( + config, + source === 'caller' ? deadline.signal : new AbortController().signal + ) + const rejected = expect(connecting).rejects.toThrow( + source === 'caller' ? error.message : 'supervisor startup timed out' + ) + deadline.abort(error) + await rejected + if (source === 'recovery') expect(AbortSignal.timeout).toHaveBeenCalledWith(RECOVER_MS) + expect(spawn).not.toHaveBeenCalled() + } + ) + + it('rejects untrusted supervisor endpoints without retry or replacement', async () => { + const config = await fixture() + const path = join(config.root, 'supervisor.sock') + await listener(path) + await chmod(path, 0o666) + await expect(supervisorConnection(config, AbortSignal.timeout(500))).rejects.toThrow( + 'untrusted' + ) + expect(spawn).not.toHaveBeenCalled() + }) + + it.each(['release', 'local'] as const)( + 'does not retry a missing %s artifact as a missing socket', + async (kind) => { + const config = await fixture() + const directory = join(config.root, 'missing-artifact') + config.entry = join(directory, 'managed-runtime.mjs') + if (kind === 'release') { + config.release = { directory, manifestHash: 'a'.repeat(64) } + const { root, ...saved } = config + await writeFile(join(root, 'prototype.json'), JSON.stringify(saved), { mode: 0o600 }) + } + await expect(supervisorConnection(config, AbortSignal.timeout(500))).rejects.toMatchObject({ + code: 'ENOENT', + path: kind === 'release' ? join(directory, 'package.json') : directory + }) + expect(spawn).not.toHaveBeenCalled() + } + ) + + it('renews one Hub lease, negotiates registrations and closes with its Hub', async () => { + const config = await fixture() + let now = 0 + const control = new HubControl(config.secret, () => now, { + inspect: () => ({ + drained: true, + deadline: Date.now() + 10000, + blockers: [], + version: PACKAGE_VERSION + }), + flush: () => {}, + stop: async () => {}, + hook: () => ({}) + }) + await mkdir(join(config.root, 'run'), { mode: 0o700 }) + await writeFile( + join(config.root, 'run/hub-runtime.json'), + JSON.stringify(createHubRuntimeIdentity(config.entry, PACKAGE_VERSION)) + ) + await listener(hubSocketPath(config.root), (socket) => { + void control.attach(socket) + }) + cleanup.push(async () => { + await control.close() + }) + await startSupervisor(config) + const path = join(config.root, 'supervisor.sock') + const peer = new ControlPeer() + await peer.connect(new SocketTransport(await connectSocket(path))) + cleanup.push(() => peer.close()) + const params = { + sessionId: randomUUID(), + token: randomUUID(), + context: captureHostContext({ HOME: config.root, TMPDIR: config.root }), + versions: [1, 2] + } + const first = peer.request({ method: 'tempad/runtime-hello', params }, GrantSchema) + const duplicate = expect( + peer.request({ method: 'tempad/runtime-hello', params }, GrantSchema) + ).rejects.toThrow('only once') + const grant = await first + await duplicate + expect(grant).toMatchObject({ generation: 1, hubId: control.hubId, version: 2 }) + const incompatible = new ControlPeer() + await incompatible.connect(new SocketTransport(await connectSocket(path))) + cleanup.push(() => incompatible.close()) + await expect( + incompatible.request( + { method: 'tempad/runtime-hello', params: { ...params, versions: [1] } }, + ResultSchema + ) + ).rejects.toThrow('MCP_RUNTIME_RESTART_REQUIRED') + // A real renewal crosses the interval; shutdown must also remove its endpoint and lock. + now = 9000 + await new Promise((resolve) => setTimeout(resolve, 2200)) + now = 11000 + expect(control.available()).toBe(true) + await peer.close() + await incompatible.close() + await new Promise((resolve) => setTimeout(resolve, 25)) + await control.close() + await vi.waitFor(async () => + expect( + await readFile(join(config.root, 'supervisor.json')).catch(() => undefined) + ).toBeUndefined() + ) + }) + + it('does not replace a Hub whose ownership record cannot be verified', async () => { + const config: PrototypeConfig = await fixture() + await mkdir(join(config.root, 'run'), { mode: 0o700 }) + await writeFile(join(config.root, 'run/hub-runtime.json'), '{}') + await expect(startSupervisor(config)).rejects.toThrow('ownership is uncertain') + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/mcp-server/tests/runtime-prototype.test.ts b/packages/mcp-server/tests/runtime-prototype.test.ts new file mode 100644 index 00000000..af7a4d41 --- /dev/null +++ b/packages/mcp-server/tests/runtime-prototype.test.ts @@ -0,0 +1,234 @@ +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/sdk/shared/stdio.js' +import { ResultSchema, type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { rm } from 'node:fs/promises' +import { connect, createServer, type Socket } from 'node:net' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { captureHostContext } from '../src/agent-clients/host-context' +import { runtimeEndpoint } from '../src/runtime-platform' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { listenPrivate } from '../src/runtime-prototype/processes' +import { + ControlPeer, + LEASE_MS, + negotiate, + RECOVER_MS, + RuntimeDataTransport, + SocketTransport +} from '../src/runtime-prototype/protocol' + +const platformModule = '../scripts/runtime-test-platform.mjs' +const { fixtureRoot } = await import(platformModule) + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() +}) + +describe('runtime control negotiation and admission', () => { + it('negotiates downward and rejects incompatible peers explicitly', () => { + expect(negotiate([1, 2, 3], [1, 2])).toBe(2) + expect(negotiate([1], [1, 2])).toBe(1) + expect(() => negotiate([2], [1])).toThrow('MCP_RUNTIME_RESTART_REQUIRED') + expect(() => negotiate([])).toThrow('MCP_RUNTIME_RESTART_REQUIRED') + expect(() => negotiate([1])).toThrow('MCP_RUNTIME_RESTART_REQUIRED') + }) + + it('fences expired and replaced leases and preserves per-edge context', async () => { + const root = await fixtureRoot('tp-control-') + const path = runtimeEndpoint(root, 'control.sock') + const sockets = new Set() + let now = 0 + const control = new HubControl('secret', () => now) + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + void control.attach(socket) + }) + await listenPrivate(server, path) + cleanup.push(async () => { + await control.close() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + await rm(root, { recursive: true, force: true }) + }) + const peer = async () => { + const socket = connect(path) + await once(socket, 'connect') + const rpc = new ControlPeer() + await rpc.connect(new SocketTransport(socket)) + cleanup.push(() => rpc.close()) + return rpc + } + const first = await peer() + const acquire = (rpc: ControlPeer, secret = 'secret') => + rpc.request( + { + method: 'tempad/runtime-acquire', + params: { secret, versions: [1, 2] } + }, + LeaseSchema + ) + await expect(acquire(first, 'wrong')).rejects.toThrow('invalid') + const lease = await acquire(first) + expect(lease.generation).toBe(1) + const registration = { + sessionId: randomUUID(), + token: randomUUID(), + context: captureHostContext({ HOME: root, TMPDIR: root }), + versions: [2] + } + await first.request({ method: 'tempad/runtime-register', params: registration }, ResultSchema) + expect(control.pendingConnections()).toBe(1) + expect(control.claimSession(registration.sessionId, registration.token).context).toEqual( + registration.context + ) + expect(control.pendingConnections()).toBe(0) + expect(control.admits(registration.sessionId, lease.generation)).toBe(true) + const obsolete = { ...registration, sessionId: randomUUID(), token: randomUUID() } + await first.request({ method: 'tempad/runtime-register', params: obsolete }, ResultSchema) + const second = await peer() + await expect(acquire(second)).rejects.toThrow('already owned') + now = LEASE_MS + expect(control.available()).toBe(false) + // Lease expiry alone does not discard grants within the same generation. + expect(control.pendingConnections()).toBe(1) + await expect(first.request({ method: 'tempad/runtime-renew' }, ResultSchema)).rejects.toThrow( + 'not dispatched' + ) + const replacement = await acquire(second) + expect(replacement.generation).toBe(2) + expect(replacement.hubId).toBe(lease.hubId) + expect(control.pendingConnections()).toBe(0) + expect(() => control.claimSession(obsolete.sessionId, obsolete.token)).toThrow('no current') + expect(control.admits(registration.sessionId, 2)).toBe(false) + await second.request({ method: 'tempad/runtime-register', params: registration }, ResultSchema) + expect(control.admits(registration.sessionId, 2)).toBe(true) + expect(control.admits(registration.sessionId, 1)).toBe(false) + const abandoned = { ...registration, sessionId: randomUUID(), token: randomUUID() } + await second.request({ method: 'tempad/runtime-register', params: abandoned }, ResultSchema) + expect(control.pendingConnections()).toBe(1) + expect(() => control.claimSession(abandoned.sessionId, registration.token)).toThrow( + 'no current' + ) + now += RECOVER_MS + expect(control.pendingConnections()).toBe(0) + expect(() => control.claimSession(abandoned.sessionId, abandoned.token)).toThrow('no current') + const third = await peer() + await acquire(third) + await expect( + third.request( + { + method: 'tempad/runtime-register', + params: { + ...registration, + context: { ...registration.context, codexHome: join(root, 'other') } + } + }, + ResultSchema + ) + ).rejects.toThrow('identity changed') + control.forget(registration.sessionId) + expect(() => control.claimSession(registration.sessionId, registration.token)).toThrow( + 'no current' + ) + await third.close() + await vi.waitFor(() => expect(control.available()).toBe(false)) + }) + + it('rejects stale/expired calls while allowing cancellation and accepted replies to settle', async () => { + const [client, wire] = InMemoryTransport.createLinkedPair() + let available = true + const transport = new RuntimeDataTransport(wire, { + generation: () => 2, + admit: (generation) => available && generation === 2, + role: 'hub' + }) + const delivered: JSONRPCMessage[] = [] + const output: JSONRPCMessage[] = [] + transport.onmessage = (message) => delivered.push(message) + client.onmessage = (message) => output.push(message) + await transport.start() + cleanup.push(() => transport.close()) + const frame = (message: JSONRPCMessage, generation = 2, deadline = Date.now() + 10000) => + client.send({ + jsonrpc: '2.0', + method: 'tempad/runtime-frame', + params: { generation, deadline, message } + }) + await frame({ + jsonrpc: '2.0', + id: 0, + method: 'tools/call', + params: { _meta: { trace: 'one' } } + }) + expect(delivered).toHaveLength(1) + expect(delivered[0]).toMatchObject({ + id: 'tempad:request:zero', + params: { _meta: { trace: 'one' } } + }) + await frame({ jsonrpc: '2.0', id: 1, method: 'tools/call' }, 1) + await frame({ jsonrpc: '2.0', id: 2, method: 'tools/call' }, 2, Date.now() - 1) + expect(delivered).toHaveLength(1) + expect(output).toHaveLength(2) + expect(output[0]).toMatchObject({ + params: { message: { id: 1, error: { data: { dispatched: false } } } } + }) + for (const id of [0, '', 'tempad:request:zero', 'tempad:request:string:caller']) { + await frame({ jsonrpc: '2.0', id, method: 'tools/call' }, 1) + expect(output.at(-1)).toMatchObject({ + params: { message: { id, error: { data: { dispatched: false } } } } + }) + } + available = false + await frame({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 0 } }, 1) + expect(delivered[1]).toMatchObject({ params: { requestId: 'tempad:request:zero' } }) + await transport.send({ jsonrpc: '2.0', id: 'tempad:request:zero', result: { complete: true } }) + expect(output.at(-1)).toMatchObject({ + params: { message: { id: 0, result: { complete: true } } } + }) + await expect( + transport.send({ jsonrpc: '2.0', id: 3, method: 'roots/list' }) + ).rejects.toMatchObject({ data: { dispatched: false } }) + }) + + it('bounds socket output and settles backpressure when the peer closes', async () => { + const sockets = new Set() + const server = createServer((socket) => { + socket.pause() + sockets.add(socket) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Expected ephemeral port') + const socket = connect(address.port, '127.0.0.1') + await once(socket, 'connect') + const transport = new SocketTransport(socket) + await transport.start() + cleanup.push(async () => { + await transport.close() + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + await expect( + transport.send({ + jsonrpc: '2.0', + id: 1, + result: { text: 'x'.repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE) } + }) + ).rejects.toThrow('buffer is full') + const sends = Array.from({ length: 8 }, (_, id) => + transport.send({ jsonrpc: '2.0', id, result: { text: 'x'.repeat(1024 * 1024) } }) + ) + const settled = Promise.allSettled(sends) + await transport.close() + const results = await settled + expect(results.some((result) => result.status === 'rejected')).toBe(true) + await expect(transport.send({ jsonrpc: '2.0', id: 9, result: {} })).rejects.toThrow('closed') + }) +}) diff --git a/packages/mcp-server/tests/runtime-release-edge.test.ts b/packages/mcp-server/tests/runtime-release-edge.test.ts new file mode 100644 index 00000000..635ffba6 --- /dev/null +++ b/packages/mcp-server/tests/runtime-release-edge.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, it, vi } from 'vitest' + +import type { PrototypeConfig } from '../src/runtime-prototype/state' +import type { StableEdgeOptions } from '../src/stable-edge' + +const fake = vi.hoisted(() => ({ + options: undefined as StableEdgeOptions | undefined, + journal: undefined as unknown, + manifest: {} as Record +})) +vi.mock('../src/stable-edge', async (original) => ({ + ...(await original()), + startStableEdge: async (_host: unknown, options: StableEdgeOptions) => { + fake.options = options + return { closed: Promise.resolve(false), close: vi.fn() } + } +})) +vi.mock('../src/runtime-prototype/state', async (original) => ({ + ...(await original()), + readJournalAsync: async () => fake.journal +})) +vi.mock('../src/runtime-update/artifact', () => ({ releaseManifest: () => fake.manifest })) +vi.mock('../src/shared', async (original) => ({ + ...(await original()), + endLogging: () => {} +})) + +import { startPrototypeEdge } from '../src/runtime-prototype/edge' +import { contractHashes } from '../src/stable-edge' + +afterEach(() => vi.restoreAllMocks()) +it('requires retained identity and exact wire/guidance while allowing reviewed presentation corrections', async () => { + vi.spyOn(process.stdin, 'destroy').mockReturnValue(process.stdin) + await startPrototypeEdge({ + root: '/fixture', + release: { directory: '/fixture', manifestHash: 'a'.repeat(64) } + } as PrototypeConfig) + const validate = fake.options!.validateContract! + const original = { + info: { name: 'fixture' }, + instructions: 'Original guidance', + capabilities: { tools: {} }, + pages: new Map([['', { tools: [] }]]) + } + await expect(validate(original, '0.12.0')).rejects.toThrow('retained release') + fake.journal = { active: { version: '0.12.0' } } + fake.manifest = { ...contractHashes(original), guidanceEpoch: 1 } + await validate(original, '0.12.0') + const correction = { ...original, instructions: 'Reviewed typo correction' } + fake.manifest = { ...contractHashes(correction), guidanceEpoch: 1 } + await validate(correction, '0.12.0', original) + fake.manifest.guidanceEpoch = 2 + await expect(validate(correction, '0.12.0', original)).rejects.toThrow('RESTART_REQUIRED') + fake.manifest.guidanceEpoch = 1 + fake.manifest.wireHash = 'incorrect' + await expect(validate(correction, '0.12.0', original)).rejects.toThrow('RESTART_REQUIRED') +}) diff --git a/packages/mcp-server/tests/runtime-run.test.ts b/packages/mcp-server/tests/runtime-run.test.ts new file mode 100644 index 00000000..6d6f338f --- /dev/null +++ b/packages/mcp-server/tests/runtime-run.test.ts @@ -0,0 +1,125 @@ +import { randomUUID } from 'node:crypto' +import { chmodSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' + +import { createPrivateTempDirectory } from '../src/runtime-platform' + +const fake = vi.hoisted(() => ({ + hub: vi.fn(), + supervisor: vi.fn(), + edge: vi.fn(), + request: vi.fn(), + recovery: vi.fn(), + config: {} as Record +})) +vi.mock('../src/hub-runtime', () => ({ startHub: fake.hub })) +vi.mock('../src/runtime-prototype/supervisor', () => ({ startSupervisor: fake.supervisor })) +vi.mock('../src/runtime-prototype/edge', () => ({ startPrototypeEdge: fake.edge })) +vi.mock('../src/shared', () => ({ endLogging: () => {} })) +vi.mock('../src/runtime-prototype/processes', () => ({ + prototypeConfig: (root: string) => ({ ...fake.config, root }), + hubSocketPath: (root: string, runtime = join(root, 'run')) => join(runtime, 'runtime-hub.sock'), + supervisorConnection: async () => ({}), + connectSocket: async () => ({}) +})) +vi.mock('../src/runtime-prototype/protocol', () => ({ + ControlPeer: class { + connect = async () => {} + request = fake.request + close = async () => {} + }, + SocketTransport: class {} +})) +vi.mock('../src/runtime-recovery', () => ({ runRuntimeRecovery: fake.recovery })) +import { runRuntime } from '../src/runtime-prototype/run' + +let root: string +beforeEach(() => { + vi.clearAllMocks() + root = createPrivateTempDirectory('tp-run-') + fake.config = { version: 1, entry: '/fixture.mjs', bundle: 'a'.repeat(64), secret: randomUUID() } + fake.request.mockResolvedValue({ status: 'okay' }) + fake.hub.mockResolvedValue(undefined) + vi.stubGlobal('process', { + ...process, + argv: ['node', '/fixture.mjs', '--root', root], + env: { ...process.env, SystemRoot: process.env.SystemRoot }, + stdin: { destroy: vi.fn() }, + stdout: { write: vi.fn() }, + stderr: { write: vi.fn() } + }) +}) +afterEach(() => { + vi.unstubAllGlobals() + rmSync(root, { recursive: true, force: true }) +}) +it('confines standalone probes and dispatches only supported roles', async () => { + await runRuntime('/fixture.mjs') + expect(fake.edge).toHaveBeenCalledOnce() + expect(process.env.TEMPAD_MCP_STATE_DIR).toBe(join(root, 'state')) + process.argv.push('--role', 'supervisor') + await runRuntime('/fixture.mjs') + expect(fake.supervisor).toHaveBeenCalledWith(expect.objectContaining({ root })) + process.argv[5] = 'unexpected' + await expect(runRuntime('/fixture.mjs')).rejects.toThrow('Use runtime-prototype') +}) +it('restores release directories before starting a paused replacement Hub', async () => { + const directories = { + runtime: '/fixture/run', + state: '/fixture/state', + assets: '/fixture/assets', + log: '/fixture/log' + } + fake.config = { + ...fake.config, + directories, + release: { directory: '/fixture', manifestHash: 'b'.repeat(64) } + } + writeFileSync(join(root, 'prototype.json'), JSON.stringify(fake.config), { mode: 0o600 }) + process.argv.push('--role', 'hub') + await runRuntime('/fixture.mjs') + expect(process.env.TEMPAD_MCP_ASSET_DIR).toBe(directories.assets) + expect(fake.hub).toHaveBeenCalledWith({ + entryPath: '/fixture.mjs', + prototype: { + root, + secret: fake.config.secret, + socketPath: join(directories.runtime, 'runtime-hub.sock'), + paused: true, + release: true + } + }) + fake.hub.mockRejectedValue(new Error('listener failed')) + await runRuntime('/fixture.mjs') + expect(process.exitCode).toBe(1) + expect(process.stderr.write).toHaveBeenCalledWith(expect.stringContaining('listener failed')) +}) +it('rejects checkout and untrusted roots before starting a child', async () => { + process.env.TEMPAD_MCP_DEV_CHECKOUT = '/checkout' + await expect(runRuntime('/fixture.mjs')).rejects.toThrow('without checkout') + delete process.env.TEMPAD_MCP_DEV_CHECKOUT + if (process.platform !== 'win32') { + chmodSync(root, 0o755) + await expect(runRuntime('/fixture.mjs')).rejects.toThrow('untrusted') + } + expect(fake.edge).not.toHaveBeenCalled() +}) +it('routes local switch, diagnostics and execution recovery through private controls', async () => { + for (const args of [ + ['--status'], + ['--recover-runtime'], + ['--switch', '/next.mjs'], + ['--runtime-status'], + ['--resolve-runtime-block', 'id'] + ]) { + process.argv = ['node', '/fixture.mjs', '--root', root, ...args] + await runRuntime('/fixture.mjs') + } + expect(fake.request.mock.calls.map((call) => call[0].method)).toEqual([ + 'tempad/runtime-status', + 'tempad/runtime-recover', + 'tempad/runtime-update' + ]) + expect(fake.recovery).toHaveBeenCalledTimes(2) +}) diff --git a/packages/mcp-server/tests/runtime-stop-response.test.ts b/packages/mcp-server/tests/runtime-stop-response.test.ts new file mode 100644 index 00000000..36c28aab --- /dev/null +++ b/packages/mcp-server/tests/runtime-stop-response.test.ts @@ -0,0 +1,252 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { connect, createServer, type Socket } from 'node:net' +import { setTimeout as delay } from 'node:timers/promises' +import { afterEach, expect, it, vi } from 'vitest' + +import { captureHostContext } from '../src/agent-clients/host-context' +import { HubControl, LeaseSchema } from '../src/runtime-prototype/hub-control' +import { ControlPeer, SocketTransport } from '../src/runtime-prototype/protocol' + +const cleanup: (() => Promise)[] = [] +const stopRequest = { method: 'tempad/runtime-stop', params: { pausedSessions: [] } } +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close() + vi.restoreAllMocks() +}) + +async function fixture() { + const stop = vi.fn(async () => {}) + const control = new HubControl('secret', undefined, { + inspect: () => ({ drained: true, deadline: Date.now(), blockers: [], version: '1' }), + flush: () => {}, + stop, + hook: () => ({}) + }) + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + void control.attach(socket) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + cleanup.push(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + }) + async function attach(gate = true) { + const accepted = once(server, 'connection') + const socket = connect((server.address() as { port: number }).port, '127.0.0.1') + const peer = new ControlPeer() + peer.onerror = () => {} + await peer.connect(new SocketTransport(socket)) + cleanup.push(() => peer.close()) + await peer.request( + { method: 'tempad/runtime-acquire', params: { secret: 'secret', versions: [2] } }, + LeaseSchema + ) + if (gate) + await peer.request({ method: 'tempad/runtime-gate', params: { closed: true } }, ResultSchema) + return { peer, socket: (await accepted)[0] as Socket } + } + return { control, stop, attach, ...(await attach()) } +} + +function holdStopReply(socket: Socket) { + const write = socket.write.bind(socket) + let release: (() => void) | undefined + let fail: (() => void) | undefined + vi.spyOn(socket, 'write').mockImplementation((chunk, ...args) => { + if (String(chunk).includes('"stopping":')) { + release = () => Reflect.apply(write, socket, [chunk, ...args]) + fail = () => { + const callback = args.find((value) => typeof value === 'function') + callback?.(new Error('Reply write failed')) + } + return true + } + return Reflect.apply(write, socket, [chunk, ...args]) + }) + return { + held: () => !!release, + release: () => release!(), + fail: () => fail!() + } +} + +it('requires a pause for every attached data session and releases detached sessions', async () => { + const f = await fixture() + const registration = { + sessionId: randomUUID(), + token: randomUUID(), + versions: [2], + context: captureHostContext(process.env) + } + await f.peer.request({ method: 'tempad/runtime-register', params: registration }, ResultSchema) + f.control.claimSession(registration.sessionId, registration.token) + expect(await f.peer.request({ method: 'tempad/runtime-inspect' }, ResultSchema)).toHaveProperty( + 'attachedSessions', + [registration.sessionId] + ) + await expect(f.peer.request(stopRequest, ResultSchema)).rejects.toThrow( + 'no acknowledged admission barrier' + ) + expect(f.stop).not.toHaveBeenCalled() + f.control.forget(registration.sessionId) + await f.peer.request(stopRequest, ResultSchema) + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) +}) + +it('admits paused sessions but prevents a data connection racing acknowledged shutdown', async () => { + const f = await fixture() + const registration = { + sessionId: randomUUID(), + token: randomUUID(), + versions: [2], + context: captureHostContext(process.env) + } + const late = { ...registration, sessionId: randomUUID(), token: randomUUID() } + for (const params of [registration, late]) + await f.peer.request({ method: 'tempad/runtime-register', params }, ResultSchema) + f.control.claimSession(registration.sessionId, registration.token) + const held = holdStopReply(f.socket) + const stopping = f.peer.request( + { + method: 'tempad/runtime-stop', + params: { pausedSessions: [registration.sessionId] } + }, + ResultSchema + ) + await vi.waitFor(() => expect(held.held()).toBe(true)) + expect(() => f.control.claimSession(late.sessionId, late.token)).toThrow() + held.release() + await stopping + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) +}) + +it('waits for the actual stop reply and graceful socket finish before shutdown', async () => { + const f = await fixture() + const held = holdStopReply(f.socket) + f.stop.mockImplementation(async () => { + expect(f.socket.writableFinished).toBe(true) + }) + const reply = f.peer.request(stopRequest, ResultSchema) + await vi.waitFor(() => expect(held.held()).toBe(true)) + await delay(60) + expect(f.stop).not.toHaveBeenCalled() + await expect(f.peer.request(stopRequest, ResultSchema)).rejects.toThrow('already been admitted') + held.release() + await expect(reply).resolves.toEqual({ stopping: f.control.hubId }) + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) +}) + +it('does not stop after a failed reply and releases the reservation for a successor controller', async () => { + const f = await fixture() + const held = holdStopReply(f.socket) + let finishClose!: () => void + const closing = new Promise((resolve) => { + finishClose = resolve + }) + cleanup.push(async () => finishClose()) + const close = ControlPeer.prototype.close + const delayedClose = vi + .spyOn(ControlPeer.prototype, 'close') + .mockImplementationOnce(async function (this: ControlPeer) { + await closing + await close.call(this) + }) + const reply = expect(f.peer.request(stopRequest, ResultSchema)).rejects.toThrow( + 'Connection closed' + ) + await vi.waitFor(() => expect(held.held()).toBe(true)) + held.fail() + await vi.waitFor(() => expect(delayedClose).toHaveBeenCalled()) + // The SDK has finished the failed send, but closure has not aborted the handler. + // The error itself must release the reservation so controlled recovery can proceed. + await f.peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + await f.peer.request({ method: 'tempad/runtime-gate', params: { closed: true } }, ResultSchema) + finishClose() + await reply + expect(f.stop).not.toHaveBeenCalled() + expect(f.control.admitting()).toBe(false) + const next = await f.attach() + await next.peer.request(stopRequest, ResultSchema) + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) +}) + +it('cancels an unflushed stop without letting its eventual reply shut down the Hub', async () => { + const f = await fixture() + const held = holdStopReply(f.socket) + const abort = new AbortController() + const reply = expect( + f.peer.request(stopRequest, ResultSchema, { signal: abort.signal }) + ).rejects.toThrow() + await vi.waitFor(() => expect(held.held()).toBe(true)) + abort.abort() + await reply + await f.peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + held.release() + await delay(60) + expect(f.stop).not.toHaveBeenCalled() + expect(f.control.admitting()).toBe(true) +}) + +it('commits shutdown if the control pipe fails after the reply was flushed', async () => { + const f = await fixture() + vi.spyOn(f.socket, 'end').mockImplementation(() => { + f.socket.destroy(new Error('Pipe finish failed')) + return f.socket + }) + await f.peer.request(stopRequest, ResultSchema).catch(() => {}) + await vi.waitFor(() => expect(f.socket.destroyed).toBe(true)) + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) + const next = await f.attach(false) + await expect( + next.peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + ).rejects.toThrow('cannot change') + expect(f.control.admitting()).toBe(false) +}) + +it.each(['tempad/runtime-renew', 'tempad/runtime-inspect'])( + 'finishes an acknowledged shutdown when %s arrives while the pipe ends', + async (method) => { + const f = await fixture() + const final = f.socket._final.bind(f.socket) + let finish: (() => void) | undefined + const release = () => { + const complete = finish + finish = undefined + complete?.() + } + vi.spyOn(f.socket, '_final').mockImplementation((callback) => { + finish = () => final(callback) + }) + await f.peer.request(stopRequest, ResultSchema) + expect(f.socket.writableEnded).toBe(true) + try { + // The rejected response proves the late write reached the ending socket; + // releasing _final earlier would merely test ordinary graceful shutdown. + await expect(f.peer.request({ method }, ResultSchema, { timeout: 1000 })).rejects.toThrow( + 'Connection closed' + ) + release() + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) + } finally { + release() + } + } +) + +it('keeps admission fenced if shutdown fails after the acknowledgement', async () => { + const f = await fixture() + f.stop.mockRejectedValueOnce(new Error('Partial shutdown failed')) + await f.peer.request(stopRequest, ResultSchema) + await vi.waitFor(() => expect(f.stop).toHaveBeenCalledOnce()) + const next = await f.attach(false) + await expect( + next.peer.request({ method: 'tempad/runtime-gate', params: { closed: false } }, ResultSchema) + ).rejects.toThrow('cannot change') + expect(f.control.admitting()).toBe(false) +}) diff --git a/packages/mcp-server/tests/runtime-switch.test.ts b/packages/mcp-server/tests/runtime-switch.test.ts new file mode 100644 index 00000000..e34f5b0a --- /dev/null +++ b/packages/mcp-server/tests/runtime-switch.test.ts @@ -0,0 +1,992 @@ +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { + CLIENT_HOOK_RETENTION_MS, + ClientHookParamsSchema, + type ClientHook +} from '@tempad-dev/shared' +import { randomUUID } from 'node:crypto' +import { EventEmitter, once } from 'node:events' +import { mkdir, writeFile, rm } from 'node:fs/promises' +import { connect, createServer, type Socket } from 'node:net' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { z } from 'zod' + +import type { RuntimeManifest } from '../src/runtime-update/manifest' + +const fake = vi.hoisted(() => ({ launch: vi.fn(), probe: vi.fn() })) +vi.mock('../src/runtime-prototype/probe', () => ({ probeArtifact: fake.probe })) +vi.mock('../src/runtime-prototype/processes', async (load) => ({ + ...(await load()), + launch: fake.launch +})) +import { captureHostContext } from '../src/agent-clients/host-context' +import { AgentClients } from '../src/agent-clients/registry' +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { writeDurableJson } from '../src/durable-json' +import { HOOK_RECEIPT_LIMIT } from '../src/hook-receipts' +import { createHubRuntimeIdentity, writeHubRuntimeIdentity } from '../src/runtime-identity' +import { cleanupEndpoint, runtimeEndpoint } from '../src/runtime-platform' +import * as processes from '../src/runtime-prototype/processes' +import { + connectSocket, + fingerprintBundle, + hubSocketPath, + listenPrivate, + prototypeConfig, + supervisorSocketPath +} from '../src/runtime-prototype/processes' +import { + ControlPeer, + GrantSchema, + RECOVER_MS, + SocketTransport, + unavailable +} from '../src/runtime-prototype/protocol' +import { readJournal, type Artifact } from '../src/runtime-prototype/state' +import * as journalState from '../src/runtime-prototype/state' +import { startSupervisor } from '../src/runtime-prototype/supervisor' +import * as releaseArtifacts from '../src/runtime-update/artifact' +import * as releaseAcquisition from '../src/runtime-update/manager' +import { PACKAGE_VERSION } from '../src/shared' + +const platformModule = '../scripts/runtime-test-platform.mjs' +const { fixtureRoot } = await import(platformModule) +// Real Windows pipe/ACL helpers run throughout each handoff. Unlike the former +// synchronous helpers, they let SDK timers fire, so observe the runtime's recovery +// budget rather than the short Unix fixture budget. Product drain limits stay intact. +const transitionTimeout = process.platform === 'win32' ? RECOVER_MS : 10_000 +const recoveryWait = process.platform === 'win32' ? RECOVER_MS : 5000 +if (process.platform === 'win32') vi.setConfig({ testTimeout: 2 * RECOVER_MS }) + +it.each(['EPIPEUNPROTECTED', 'ETIMEDOUT', 'EBUSY', 'ENOENT'])( + 'waits for an existing Hub after %s instead of launching a replacement', + async (code) => { + vi.spyOn(processes, 'connectSocket').mockRejectedValueOnce( + Object.assign(new Error('Endpoint not ready'), { code }) + ) + await fixture() + expect(fake.launch).not.toHaveBeenCalled() + } +) + +function endpointClosed(path: string) { + return new Promise((resolve, reject) => { + const socket = connect(path) + let closed = false + let failure: Error | undefined + socket.once('connect', () => socket.destroy()) + socket.once('error', (error: NodeJS.ErrnoException) => { + if (['ENOENT', 'ECONNREFUSED'].includes(error.code ?? '')) closed = true + else if (error.code !== 'EBUSY') failure = error + }) + socket.once('close', () => (failure ? reject(failure) : resolve(closed))) + }) +} + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + const failures: unknown[] = [] + for (const cleanup of cleanups.splice(0).reverse()) { + try { + await cleanup() + } catch (error) { + failures.push(error) + } + } + vi.restoreAllMocks() + vi.clearAllMocks() + if (failures.length) throw new AggregateError(failures, 'Runtime fixture cleanup failed.') +}) + +async function fixture( + hook?: (input: ClientHook, fresh: boolean) => Record, + automatic = false, + initial?: { phase: 'committed' | 'preparing'; staged?: boolean }, + beforeStart?: (root: string) => void +) { + const root = await fixtureRoot('tp-switch-test-') + await mkdir(join(root, 'run'), { mode: 0o700 }) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const artifact = async (version: string): Promise => { + const directory = join(root, version) + await mkdir(directory) + const entry = join(directory, 'runtime.mjs') + await writeFile(entry, `export const version = '${version}'`) + return { version, entry, bundle: fingerprintBundle(entry) } + } + const a = await artifact(PACKAGE_VERSION) + const [major, minor, patch] = PACKAGE_VERSION.split('.').map(Number) + const b = await artifact(`${major}.${minor}.${patch! + 1}`) + const config = prototypeConfig(root, a.entry) + if (automatic) { + a.release = { directory: root, manifestHash: 'a'.repeat(64) } + b.release = { directory: root, manifestHash: 'b'.repeat(64), integrity: 'verified' } + config.release = a.release + vi.spyOn(releaseAcquisition, 'acquireRelease').mockResolvedValue({ candidate: b }) + vi.spyOn(releaseArtifacts, 'verifyReleaseArtifact').mockImplementation((artifact) => + releaseArtifacts.releaseManifest(artifact) + ) + vi.spyOn(releaseArtifacts, 'releaseManifest').mockImplementation( + (artifact) => + ({ + packageVersion: artifact.version, + wireHash: 'a'.repeat(64), + guidanceEpoch: 1, + node: '>=22', + platforms: ['darwin', 'linux', 'win32'], + controlProtocols: [2], + state: { family: 'test', reads: [1], writes: 1 }, + extension: { legacy: true, protocols: [1] } + }) as RuntimeManifest + ) + } + const live = new Set() + const exiting = new Set() + const originalKill = process.kill.bind(process) + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid >= 900001 && pid <= 900100) { + if (exiting.delete(pid)) return true + if (live.has(pid)) return true + throw Object.assign(new Error('exited'), { code: 'ESRCH' }) + } + return originalKill(pid, signal) + }) + let nextPid = 900001 + let drained = true + let blocker = 'work' + let peerFailure = false + let autoBlockers: string[] | undefined = ['Acceptance not ready'] + let idleForMs = 5000 + let busyOnGate = false + let refuseStop = false + let beforeStop: (() => Promise) | undefined + let attachedSessions: string[] = [] + let refuseCandidateOpen = false + let onPause: ((signal: AbortSignal) => Promise) | undefined + const admissions: boolean[] = [] + const retireLegacy = vi.fn(() => ({})) + const hubs: { pid: number; stop: (exiting?: boolean) => Promise; artifact: Artifact }[] = [] + const launches = new Set>() + const launchFailures: unknown[] = [] + let shutdown: (signal: NodeJS.Signals) => void = () => {} + cleanups.push(async () => { + shutdown('SIGTERM') + while (launches.size) await Promise.all(launches) + await Promise.all(hubs.map((hub) => hub.stop())) + expect(launchFailures).toEqual([]) + }) + async function start(artifact: Artifact) { + const pid = nextPid++ + const id = randomUUID() + let gated = false + live.add(pid) + cleanupEndpoint(hubSocketPath(root)) + writeHubRuntimeIdentity( + join(root, 'run/hub-runtime.json'), + createHubRuntimeIdentity(artifact.entry, artifact.version, { processId: pid }) + ) + const sockets = new Set() + let stopping: Promise | undefined + const stop = (socketClosesFirst = false) => + (stopping ??= (async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve) => server.close(() => resolve())) + // A real exited process cannot still own its listening pipe. + live.delete(pid) + if (socketClosesFirst) exiting.add(pid) + })()) + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + const peer = new ControlPeer() + peer.onerror = () => {} + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-acquire') }), () => ({ + version: 2, + generation: 1, + hubId: id, + hubPid: pid + })) + const status = () => ({ + drained, + deadline: Date.now() + (drained ? 10000 : -1), + blockers: drained ? [] : [blocker], + version: artifact.version, + gated, + handoffVersion: 1, + attachedSessions, + ...(automatic ? { autoBlockers, idleForMs } : {}) + }) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-inspect') }), () => { + return status() + }) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-gate'), + params: z.object({ closed: z.boolean() }) + }), + ({ params }) => { + if (!params.closed && refuseCandidateOpen && artifact.bundle === b.bundle) + throw unavailable('Candidate could not reopen after commit.') + gated = params.closed + if (gated && busyOnGate) drained = false + return status() + } + ) + peer.setRequestHandler(z.object({ method: z.literal('tempad/runtime-stop') }), async () => { + await beforeStop?.() + if (refuseStop) throw unavailable('Late activity; shutdown was not admitted.') + setTimeout(() => void stop(), 5) + return {} + }) + peer.setRequestHandler( + z.object({ method: z.literal('tempad/runtime-retire-legacy-hooks') }), + retireLegacy + ) + if (hook) + peer.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-hook'), + params: z.object({ input: ClientHookParamsSchema, admittedHubId: z.uuid().optional() }) + }), + ({ params }) => hook(params.input, params.admittedHubId === id) + ) + peer.fallbackRequestHandler = async () => ({}) + void peer.connect(new SocketTransport(socket)) + }) + try { + await listenPrivate(server, hubSocketPath(root)) + } catch (error) { + await stop() + throw error + } + hubs.push({ pid, stop, artifact }) + } + fake.launch.mockImplementation((_config, role, value) => { + if (role !== 'hub') throw new Error('Unexpected supervisor spawn') + const pending = start(value) + .catch((error: unknown) => { + launchFailures.push(error) + }) + .finally(() => launches.delete(pending)) + launches.add(pending) + return Object.assign(new EventEmitter(), { unref: () => {} }) + }) + fake.probe.mockImplementation(async (entry) => ({ + artifact: entry === a.entry ? a : b, + contract: 'identical' + })) + await start(a) + if (initial) { + journalState.writeJournal(root, { + version: 1, + revision: 1, + phase: initial.phase, + active: a, + candidate: initial.phase === 'preparing' || !automatic ? b : undefined, + failedBundles: [] + }) + if (automatic) + writeDurableJson(join(root, 'updates.json'), { + version: 2, + nextCheck: Date.now() + 60_000, + failures: 0, + ...(initial.staged ? { candidate: b } : {}) + }) + } + beforeStart?.(root) + const stops = new Set(process.listeners('SIGTERM')) + await startSupervisor(config) + shutdown = process.listeners('SIGTERM').find((listener) => !stops.has(listener))! + const control = new ControlPeer() + const supervisorPath = supervisorSocketPath(config) + await control.connect(new SocketTransport(await connectSocket(supervisorPath))) + cleanups.push(async () => { + shutdown('SIGTERM') + await control.close() + await vi.waitFor(async () => expect(await endpointClosed(supervisorPath)).toBe(true), { + timeout: 5000 + }) + }) + const edge = new ControlPeer() + await edge.connect(new SocketTransport(await connectSocket(supervisorPath))) + cleanups.push(() => edge.close()) + const ready = (hubId: string) => + edge.request( + { + method: 'tempad/runtime-ready', + params: { + hubId, + ...(peerFailure ? { error: 'Frozen contract mismatch' } : {}) + } + }, + ResultSchema + ) + edge.setNotificationHandler( + z.object({ method: z.literal('notifications/tempad/runtime-replaced'), params: GrantSchema }), + async ({ params }) => { + await ready(params.hubId) + peerFailure = false + } + ) + edge.setRequestHandler( + z.object({ + method: z.literal('tempad/runtime-admission'), + params: z.object({ paused: z.boolean() }) + }), + async ({ params }, extra) => { + admissions.push(params.paused) + if (params.paused) await onPause?.(extra.signal) + return {} + } + ) + const sessionId = randomUUID() + const grant = await edge.request( + { + method: 'tempad/runtime-hello', + params: { + sessionId, + token: randomUUID(), + context: captureHostContext({ HOME: root, TMPDIR: root }), + updates: { policy: 'stable', version: a.version, manifestHash: 'a'.repeat(64) }, + versions: [2] + } + }, + GrantSchema + ) + await ready(grant.hubId) + if (automatic && !initial) + await vi.waitFor( + async () => { + const state = await control.request({ method: 'tempad/runtime-status' }, ResultSchema) + expect(state.updates).toMatchObject({ lastAttempt: expect.any(Number), checking: false }) + }, + { timeout: recoveryWait } + ) + return { + root, + config, + shutdown: () => shutdown('SIGTERM'), + a, + b, + hubs, + control, + admissions, + retireLegacy, + disconnectBeforeStop: () => { + beforeStop = async () => { + // The data connection survived while the paused control peer left during drain. + attachedSessions = [sessionId] + await edge.close() + await control.request({ method: 'tempad/runtime-status' }, ResultSchema) + refuseStop = true + } + }, + pauseWith: (handler: (signal: AbortSignal) => Promise) => { + onPause = handler + }, + update: () => + control.request( + { method: 'tempad/runtime-update', params: { entry: b.entry } }, + ResultSchema, + { timeout: transitionTimeout } + ), + status: () => control.request({ method: 'tempad/runtime-status' }, ResultSchema), + defer: (reason = 'work') => { + drained = false + blocker = reason + }, + rejectCandidate: () => { + peerFailure = true + }, + rejectCandidateOpen: () => { + refuseCandidateOpen = true + }, + auto: { + allow: () => { + autoBlockers = [] + }, + legacyHub: () => { + autoBlockers = undefined + }, + recentWork: () => { + idleForMs = 0 + }, + raceGate: () => { + busyOnGate = true + }, + refuseStop: () => { + refuseStop = true + }, + unready: async () => { + peerFailure = true + await ready(grant.hubId) + } + } + } +} + +it('waits for every edge admission barrier before gating or retiring the old Hub', async () => { + const f = await fixture() + const barrier = Promise.withResolvers() + f.pauseWith(() => barrier.promise) + const update = f.update() + await vi.waitFor(() => expect(f.admissions).toEqual([true])) + expect(await f.status()).toMatchObject({ phase: 'committed', active: f.a, gated: false }) + expect(f.hubs).toHaveLength(1) + barrier.resolve() + expect(await update).toMatchObject({ status: 'activated', version: f.b.version }) +}) + +it('releases paused edges and keeps the old writer when admission fails', async () => { + const f = await fixture() + f.pauseWith(async () => { + throw new Error('Pause could not be established') + }) + expect(await f.update()).toMatchObject({ status: 'staged' }) + expect(f.admissions).toEqual([true, false]) + expect(f.hubs).toHaveLength(1) + expect(await f.status()).toMatchObject({ active: f.a, gated: false }) +}) + +it('does not restart the drain budget after an admission barrier uses it up', async () => { + const f = await fixture() + const now = Date.now() + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + f.pauseWith(async () => { + clock.mockReturnValue(now + 10_001) + }) + expect(await f.update()).toMatchObject({ + status: 'staged', + reason: expect.stringContaining('drain deadline expired') + }) + clock.mockRestore() + expect(f.admissions).toEqual([true, false]) + expect(f.hubs).toHaveLength(1) + expect(await f.status()).toMatchObject({ active: f.a, gated: false }) +}) + +it('requires successful initialization before switching an attached edge', async () => { + const f = await fixture() + await f.auto.unready() + await expect(f.update()).rejects.toThrow('not finished initialization') + expect(f.admissions).toEqual([]) + expect(f.hubs).toHaveLength(1) + expect(fake.probe).not.toHaveBeenCalled() +}) + +it('reopens an explicitly drained Hub if its data session outlives the paused control peer', async () => { + const f = await fixture() + f.disconnectBeforeStop() + await expect(f.update()).resolves.toMatchObject({ + status: 'staged', + reason: expect.stringContaining('shutdown was not admitted') + }) + expect(await f.status()).toMatchObject({ active: f.a, phase: 'committed', gated: false }) + expect(f.hubs).toHaveLength(1) + expect(fake.launch).not.toHaveBeenCalled() + expect(readJournal(f.root)).toMatchObject({ phase: 'committed', failedBundles: [] }) +}) + +it('retires exhausted legacy lifecycle evidence before reopening admission and reports the upgrade boundary', async () => { + const f = await fixture(undefined, false, undefined, (root) => { + writeDurableJson(join(root, 'hooks.json'), { + version: 2, + cutoff: 0, + entries: Array.from({ length: HOOK_RECEIPT_LIMIT }, () => ({ + input: { + version: 1, + invocationId: randomUUID(), + kind: 'claude', + sessionId: 'legacy', + event: 'UserPromptSubmit' + }, + result: {} + })) + }) + }) + expect(f.retireLegacy).toHaveBeenCalledOnce() + expect(await f.status()).toMatchObject({ + gated: false, + legacyHookWarning: expect.stringContaining('Update the TemPad Dev plugin') + }) + expect(await f.status()).not.toHaveProperty('hookFault') +}) + +it('automatically replaces a compatible idle runtime once', async () => { + const f = await fixture(undefined, true) + const writes = vi.spyOn(journalState, 'writeJournal') + f.auto.allow() + await vi.waitFor( + async () => expect(await f.status()).toMatchObject({ active: f.b, gated: false }), + { timeout: recoveryWait } + ) + expect(f.hubs.map((hub) => hub.artifact)).toEqual([f.a, f.b]) + expect(readJournal(f.root)).toMatchObject({ phase: 'committed', previous: f.a }) + expect( + writes.mock.calls.every(([, journal]) => journal.phase !== 'committed' || !journal.candidate) + ).toBe(true) +}) + +it('keeps an exact download manual until that artifact is explicitly activated', async () => { + const f = await fixture(undefined, true, { phase: 'committed' }) + await f.control.request( + { method: 'tempad/runtime-check', params: { version: f.b.version } }, + ResultSchema + ) + f.auto.allow() + await new Promise((resolve) => setTimeout(resolve, 2200)) + expect(await f.status()).toMatchObject({ active: f.a, updates: { manualCandidate: f.b } }) + expect((await f.status()).updates).not.toHaveProperty('candidate') + expect( + await f.control.request( + { + method: 'tempad/runtime-activate', + params: { version: f.b.version, integrity: f.b.release!.integrity } + }, + ResultSchema, + { timeout: transitionTimeout } + ) + ).toMatchObject({ status: 'activated', version: f.b.version }) +}) + +it('keeps managed staging out of the switch journal until the old Hub drains', async () => { + const f = await fixture(undefined, true) + await f.control.request( + { method: 'tempad/runtime-check', params: { version: f.b.version } }, + ResultSchema + ) + const writes = vi.spyOn(journalState, 'writeJournal') + f.defer() + expect( + await f.control.request( + { + method: 'tempad/runtime-activate', + params: { version: f.b.version, integrity: f.b.release!.integrity } + }, + ResultSchema, + { timeout: transitionTimeout } + ) + ).toMatchObject({ status: 'staged' }) + expect(await f.status()).toMatchObject({ + active: f.a, + gated: false, + updates: { manualCandidate: f.b } + }) + expect(readJournal(f.root)?.candidate).toBeUndefined() + expect(writes.mock.calls.every(([, journal]) => !journal.candidate)).toBe(true) + expect(f.hubs).toHaveLength(1) +}) + +it('abandons a prepared managed switch if the original Hub is still running', async () => { + const f = await fixture(undefined, true, { phase: 'preparing' }) + expect(readJournal(f.root)).toMatchObject({ phase: 'committed', active: f.a }) + expect(readJournal(f.root)?.candidate).toBeUndefined() + expect(await f.status()).toMatchObject({ active: f.a, gated: false }) + expect((await f.status()).updates).not.toHaveProperty('candidate') + expect(f.hubs).toHaveLength(1) + expect(releaseAcquisition.acquireRelease).not.toHaveBeenCalled() +}) + +it.each([ + { phase: 'committed' as const, managed: false }, + { phase: 'preparing' as const, managed: false }, + { phase: 'committed' as const, managed: true }, + { phase: 'preparing' as const, managed: true } +])( + 'recovers $phase staging in its owning store (managed: $managed)', + async ({ phase, managed }) => { + const f = await fixture(undefined, managed, { phase, staged: true }) + expect(readJournal(f.root)).toMatchObject({ phase: 'committed', active: f.a }) + expect(readJournal(f.root)?.candidate).toEqual(managed ? undefined : f.b) + expect(await f.status()).toMatchObject({ + active: f.a, + gated: false, + ...(managed ? { updates: { candidate: f.b } } : { candidate: f.b }) + }) + expect(f.hubs).toHaveLength(1) + } +) + +it('retains a living unverified candidate for explicit recovery instead of launching fallback', async () => { + const f = await fixture() + vi.spyOn(processes, 'waitForLaunchedSocket').mockRejectedValueOnce( + unavailable('Runtime process has not published its endpoint yet.') + ) + await expect(f.update()).rejects.toThrow('interrupted handoff remains gated') + expect(fake.launch).toHaveBeenCalledOnce() + expect(readJournal(f.root)).toMatchObject({ + phase: 'old-stopped', + active: f.a, + candidate: f.b, + failedBundles: [] + }) + await vi.waitFor(() => expect(f.hubs).toHaveLength(2), { timeout: recoveryWait }) + await expect( + f.control.request({ method: 'tempad/runtime-recover' }, ResultSchema, { + timeout: transitionTimeout + }) + ).resolves.toMatchObject({ + status: 'recovered', + version: f.b.version + }) + expect(fake.launch).toHaveBeenCalledOnce() + expect(await f.status()).toMatchObject({ phase: 'committed', active: f.b, gated: false }) +}) + +it('rolls back promptly after its candidate process exits before publishing', async () => { + const f = await fixture() + fake.launch.mockImplementationOnce(() => { + const child = new EventEmitter() + queueMicrotask(() => child.emit('exit', 1, null)) + return child + }) + expect(await f.update()).toMatchObject({ + status: 'rolled-back', + reason: expect.stringContaining('exited') + }) + expect(fake.launch).toHaveBeenCalledTimes(2) + expect(await f.status()).toMatchObject({ phase: 'committed', active: f.a, gated: false }) + expect(readJournal(f.root)?.failedBundles).toEqual([f.b.bundle]) +}) + +it.each(['event', 'exitCode'] as const)( + 'waits for its failed startup to exit after pipe closure (%s) before rolling back', + async (evidence) => { + const f = await fixture() + const launch = fake.launch.getMockImplementation()! + let child!: EventEmitter & { exitCode?: number } + fake.launch.mockImplementationOnce((...args) => (child = launch(...args))) + const wait = processes.waitForLaunchedSocket + vi.spyOn(processes, 'waitForLaunchedSocket').mockImplementationOnce(async (...args) => { + const socket = await wait(...args) + socket.destroy() + setTimeout(() => { + void f.hubs[1]!.stop().then(() => { + expect(fake.launch).toHaveBeenCalledOnce() + if (evidence === 'event') child.emit('exit', 1, null) + else child.exitCode = 1 + }) + }, 50) + return socket + }) + expect(await f.update()).toMatchObject({ + status: 'rolled-back', + reason: expect.stringContaining('Runtime socket is closed') + }) + expect(fake.launch).toHaveBeenCalledTimes(2) + expect(await f.status()).toMatchObject({ phase: 'committed', active: f.a, gated: false }) + expect(readJournal(f.root)?.failedBundles).toEqual([f.b.bundle]) + } +) + +it.each(['unready', 'legacyHub', 'recentWork'] as const)( + 'keeps serving when automatic readiness is missing: %s', + async (scenario) => { + const f = await fixture(undefined, true) + f.auto.allow() + await f.auto[scenario]() + await new Promise((resolve) => setTimeout(resolve, 2200)) + expect(await f.status()).toMatchObject({ active: f.a, gated: false }) + expect(f.hubs).toHaveLength(1) + } +) + +it.each(['raceGate', 'refuseStop'] as const)( + 'reopens the original Hub when work races automatic shutdown: %s', + async (scenario) => { + const f = await fixture(undefined, true) + f.auto.allow() + f.auto[scenario]() + await vi.waitFor( + async () => + expect(await f.status()).toMatchObject({ + active: f.a, + gated: false, + phase: 'committed', + failure: expect.any(String) + }), + { timeout: recoveryWait } + ) + expect(f.hubs).toHaveLength(1) + expect(readJournal(f.root)?.failedBundles).toEqual([]) + expect(readJournal(f.root)?.candidate).toBeUndefined() + expect(await f.status()).toHaveProperty('updates.candidate', f.b) + } +) + +it('isolates and backs off automatic verification failures', async () => { + const f = await fixture(undefined, true) + const verify = vi.spyOn(releaseArtifacts, 'verifyReleaseArtifact').mockImplementation(() => { + throw new Error('damaged archive') + }) + f.auto.allow() + await vi.waitFor( + async () => + expect(await f.status()).toMatchObject({ + active: f.a, + gated: false, + updates: { automaticError: expect.stringContaining('damaged archive') } + }), + { timeout: 5000 } + ) + await new Promise((resolve) => setTimeout(resolve, 2200)) + expect(verify).toHaveBeenCalledOnce() + expect(f.hubs).toHaveLength(1) +}) + +it('recovers a supervisor with an expired admitted hook without replay or loss of Stop', async () => { + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + let available = false + let tasks!: DesignTasks + const applied = vi.fn((input: ClientHook, fresh: boolean) => clients.clientHook(input, fresh)) + const f = await fixture((input, fresh) => { + if (!available) throw new Error('Hub outcome unavailable after crash') + return tasks.applyHook( + input.invocationId, + input, + () => applied(input, fresh), + () => clients.retireHook(input) + ) + }) + const store = new DesignTaskStore(join(f.root, 'tasks.json')) + tasks = new DesignTasks({ store, createId: randomUUID, now: () => now }) + const session = { + sessionId: 'figma', + fileKey: 'file', + fileName: 'File', + pageId: '0:1', + busy: false + } + const task = tasks.begin('owner', 'extension', session, 'Work', 'begin', { + kind: 'claude', + name: 'Claude', + sessionId: 'old-session' + }) + tasks.stop(task.task.taskId, 'cancelled', { sessionId: 'old-session' }) + const input: ClientHook = { + version: 2, + kind: 'claude', + sessionId: 'old-session', + event: 'Stop', + invocationId: randomUUID(), + issuedAt: now, + profile: { kind: 'release' } + } + const request = async (params: ClientHook) => { + const socket = await connectSocket(runtimeEndpoint(join(f.root, 'run'), 'mcp.sock')) + const reply = once(socket, 'data') + socket.write( + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tempad/client-hook', params }) + '\n' + ) + try { + return JSON.parse(String((await reply)[0])) + } finally { + socket.destroy() + } + } + expect((await request(input)).result.receipt.state).toBe('accepted') + await vi.waitFor(async () => + expect(await f.status()).toMatchObject({ + gated: true, + hookFault: expect.stringContaining('after crash') + }) + ) + f.shutdown() + await vi.waitFor(async () => + expect(await endpointClosed(supervisorSocketPath(f.config))).toBe(true) + ) + + now += CLIENT_HOOK_RETENTION_MS + 1 + tasks = new DesignTasks({ store, createId: randomUUID, now: () => now }) + tasks.restore() + const clients = new AgentClients(tasks) + available = true + const stops = new Set(process.listeners('SIGTERM')) + await startSupervisor(f.config) + const stop = process.listeners('SIGTERM').find((listener) => !stops.has(listener))! + cleanups.push(async () => { + clients.close() + stop('SIGTERM') + await vi.waitFor(async () => + expect(await endpointClosed(supervisorSocketPath(f.config))).toBe(true) + ) + }) + const control = new ControlPeer() + await control.connect(new SocketTransport(await connectSocket(supervisorSocketPath(f.config)))) + cleanups.push(() => control.close()) + expect(await control.request({ method: 'tempad/runtime-status' }, ResultSchema)).toMatchObject({ + gated: false, + phase: 'committed' + }) + expect(applied).not.toHaveBeenCalled() + const fresh = { + ...input, + sessionId: 'fresh-session', + invocationId: randomUUID(), + issuedAt: now, + event: 'UserPromptSubmit' as const + } + expect((await request(fresh)).result.receipt.state).toBe('accepted') + await vi.waitFor(() => expect(applied).toHaveBeenCalledExactlyOnceWith(fresh, true)) + expect(tasks.find(task.task.taskId)).toMatchObject({ + task: { status: 'cancelled' }, + claudeStopNotice: { sessionId: 'old-session' } + }) +}) + +it.each([false, true])( + 'rolls back an active failure using current data (socket closes before exit: %s)', + async (socketClosesFirst) => { + const f = await fixture() + const currentControls = { + supervisor: { version: f.a.version, pendingVersion: null, restartRequired: false }, + edges: [{ version: f.a.version, pendingVersion: null, restartRequired: false }], + restartRequired: false + } + expect(await f.status()).toMatchObject({ controlComponents: currentControls }) + expect(await f.update()).toMatchObject({ status: 'activated', version: f.b.version }) + expect(await f.status()).toMatchObject({ + controlComponents: { + supervisor: { version: f.a.version, pendingVersion: f.b.version, restartRequired: true }, + edges: [{ version: f.a.version, pendingVersion: f.b.version, restartRequired: true }], + restartRequired: true + } + }) + expect(readJournal(f.root)).toMatchObject({ phase: 'committed', active: f.b, previous: f.a }) + await writeFile(join(f.root, 'state-evidence'), 'Stop written by B') + await f.hubs.at(-1)!.stop(socketClosesFirst) + await vi.waitFor( + async () => + expect(await f.status()).toMatchObject({ + phase: 'committed', + active: f.a, + gated: false, + controlComponents: currentControls + }), + { timeout: recoveryWait } + ) + expect(readJournal(f.root)?.failedBundles).toContain(f.b.bundle) + expect(f.hubs.map((hub) => hub.artifact)).toEqual([f.a, f.b, f.a]) + expect( + await import('node:fs/promises').then((fs) => + fs.readFile(join(f.root, 'state-evidence'), 'utf8') + ) + ).toBe('Stop written by B') + await expect(f.update()).rejects.toThrow('quarantined') + } +) + +it.each(['work', 'Claude binding without receipt/retry hooks'])( + 'defers %s without killing the current runtime', + async (reason) => { + const f = await fixture() + f.defer(reason) + expect(await f.update()).toMatchObject({ status: 'staged' }) + expect(f.hubs).toHaveLength(1) + expect(await f.status()).toMatchObject({ + active: f.a, + candidate: f.b, + gated: false, + controlComponents: { + supervisor: { version: f.a.version, pendingVersion: f.b.version, restartRequired: true }, + restartRequired: true + } + }) + } +) + +it('allows repeated operator recovery without reacquiring its own live lease', async () => { + const f = await fixture() + await f.update() + const before = await f.status() + for (let attempt = 0; attempt < 2; attempt++) + expect( + await f.control.request({ method: 'tempad/runtime-recover' }, ResultSchema, { + timeout: transitionTimeout + }) + ).toMatchObject({ status: 'recovered', version: f.b.version }) + expect(await f.status()).toMatchObject({ + active: f.b, + hubId: before.hubId, + gated: false + }) + expect(f.hubs.map((hub) => hub.artifact)).toEqual([f.a, f.b]) +}) + +it('recovers a journal commit reported as failed after rename without restoring old data', async () => { + const f = await fixture() + const write = journalState.writeJournal + let failed = false + vi.spyOn(journalState, 'writeJournal').mockImplementation((root, journal) => { + write(root, journal) + if (!failed && journal.phase === 'preparing') { + failed = true + throw new Error('EIO after rename') + } + }) + expect(await f.update()).toMatchObject({ status: 'rolled-back' }) + expect(readJournal(f.root)).toMatchObject({ + active: f.a, + phase: 'committed', + failedBundles: [f.b.bundle] + }) +}) + +it('rolls back a candidate refused by an initialized edge', async () => { + const f = await fixture() + f.rejectCandidate() + expect(await f.update()).toMatchObject({ status: 'rolled-back' }) + expect(await f.status()).toMatchObject({ active: f.a, gated: false }) + expect(readJournal(f.root)?.failedBundles).toContain(f.b.bundle) +}) + +it.each([false, true])( + 'rolls back when the committed Hub cannot reopen (managed: %s)', + async (managed) => { + const f = await fixture(undefined, managed) + f.rejectCandidateOpen() + if (managed) + await f.control.request( + { method: 'tempad/runtime-check', params: { version: f.b.version } }, + ResultSchema + ) + const result = managed + ? await f.control.request( + { + method: 'tempad/runtime-activate', + params: { version: f.b.version, integrity: f.b.release!.integrity } + }, + ResultSchema, + { timeout: transitionTimeout } + ) + : await f.update() + expect(result).toMatchObject({ status: 'rolled-back' }) + expect(await f.status()).toMatchObject({ active: f.a, phase: 'committed', gated: false }) + expect(readJournal(f.root)?.failedBundles).toEqual([f.b.bundle]) + expect(f.hubs.map((hub) => hub.artifact)).toEqual([f.a, f.b, f.a]) + } +) + +it('cannot finish a pending update after releasing supervisor ownership', async () => { + const f = await fixture() + const before = readJournal(f.root) + let release!: () => void + fake.probe.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve({ artifact: f.b, contract: 'identical' }) + }) + ) + const update = f.update() + const rejected = expect(update).rejects.toThrow('Connection closed') + await vi.waitFor(() => expect(release).toBeTypeOf('function')) + f.shutdown() + await rejected + release() + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(readJournal(f.root)).toEqual(before) + expect(fake.launch).not.toHaveBeenCalled() +}) diff --git a/packages/mcp-server/tests/runtime-test-platform.test.ts b/packages/mcp-server/tests/runtime-test-platform.test.ts new file mode 100644 index 00000000..a21a0500 --- /dev/null +++ b/packages/mcp-server/tests/runtime-test-platform.test.ts @@ -0,0 +1,137 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +const modulePath = '../scripts/runtime-test-platform.mjs' +const { + commandArguments, + fixtureEnvironment, + fixtureProcesses, + preserveFixtureEvidence, + processInventory +} = await import(modulePath) +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +const temporaryDirectories: string[] = [] + +function windowsEnvironment(env: NodeJS.ProcessEnv) { + return Object.fromEntries(Object.entries(env).map(([key, value]) => [key.toUpperCase(), value])) +} + +afterEach(async () => { + Object.defineProperty(process, 'platform', platform) + vi.unstubAllEnvs() + vi.clearAllMocks() + for (const directory of temporaryDirectories.splice(0)) + await rm(directory, { recursive: true, force: true }) +}) + +describe('runtime process fixture platform', () => { + it('copies bounded runtime records before cleanup without bundling installed packages', async () => { + const root = await mkdtemp(join(tmpdir(), 'tp-evidence-')) + temporaryDirectories.push(root) + const fixture = join(root, 'fixture') + const evidence = join(root, 'evidence') + const records = { + 'supervisor.json': '{"pid":42}', + 'state/runtime/switch.json': '{"phase":"preparing"}', + 'run/hub-runtime.json': '{"processId":43}', + 'log/hub.log': 'starting', + 'auto/state/design-tasks.json': '{}', + 'tp-probe-12345678-1234-1234-1234-123456789abc/probe-failure.json': '{"phase":"initialize"}', + 'tp-probe-12345678-1234-1234-1234-123456789abc/run/hub-runtime.json': '{"processId":44}', + 'tp-probe-12345678-1234-1234-1234-123456789abc/node_modules/package/package.json': '{}', + 'node_modules/package/package.json': '{}', + 'state/runtime/cache/package.json': '{}' + } + for (const [path, content] of Object.entries(records)) { + const target = join(fixture, path) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, content) + } + await writeFile(join(fixture, 'log/large.log'), Buffer.alloc(2 * 1024 * 1024 + 1)) + vi.stubEnv('TEMPAD_RUNTIME_EVIDENCE_DIR', evidence) + await preserveFixtureEvidence(fixture, 'request timed out') + const copied = join(evidence, basename(fixture)) + expect(await readFile(join(copied, 'failure.txt'), 'utf8')).toContain('request timed out') + for (const [path, content] of Object.entries(records)) { + const actual = readFile(join(copied, path), 'utf8') + if (path.includes('node_modules') || path.includes('cache')) + await expect(actual).rejects.toThrow() + else expect(await actual).toBe(content) + } + await expect(readFile(join(copied, 'log/large.log'))).rejects.toThrow() + }) + + it.each(['PSModulePath', 'psmodulepath', 'PsMoDuLePaTh'])( + 'lets Windows PowerShell choose its own modules instead of inheriting %s', + (key) => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + vi.stubEnv('SystemRoot', 'C:\\Windows') + vi.stubEnv('TEMP', 'C:\\fixture-temp') + vi.stubEnv(key, 'C:\\PowerShell7\\Modules') + vi.mocked(execFileSync).mockReturnValue('[]') + expect(processInventory()).toEqual([]) + const options = vi.mocked(execFileSync).mock.calls[0]![2]! + expect(windowsEnvironment(options.env!)).toMatchObject({ + SYSTEMROOT: 'C:\\Windows', + TEMP: 'C:\\fixture-temp' + }) + expect(Object.keys(options.env!).some((name) => name.toLowerCase() === 'psmodulepath')).toBe( + false + ) + } + ) + + it('retains platform discovery variables without inheriting host identity', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + vi.stubEnv('SystemRoot', 'C:\\Windows') + vi.stubEnv('PROGRAMFILES', 'C:\\Program Files') + vi.stubEnv('ProgramFiles(x86)', 'C:\\Program Files (x86)') + vi.stubEnv('SystemDrive', 'C:') + vi.stubEnv('CODEX_THREAD_ID', 'unrelated-task') + vi.stubEnv('TEMPAD_MCP_DEV_CHECKOUT', '/unrelated-checkout') + const env = fixtureEnvironment('/private-home', '/private-temp') + expect(windowsEnvironment(env)).toMatchObject({ + SYSTEMROOT: 'C:\\Windows', + PROGRAMFILES: 'C:\\Program Files', + 'PROGRAMFILES(X86)': 'C:\\Program Files (x86)', + SYSTEMDRIVE: 'C:', + HOME: '/private-home', + USERPROFILE: '/private-home', + TMPDIR: '/private-temp', + TEMP: '/private-temp', + TMP: '/private-temp' + }) + expect(env).not.toHaveProperty('CODEX_THREAD_ID') + expect(env).not.toHaveProperty('TEMPAD_MCP_DEV_CHECKOUT') + }) + + it('matches the exact fixture root and executable argument with Windows quoting', () => { + const root = 'C:\\fixture root\\run' + const entry = 'C:\\fixture root\\runtime.mjs' + const commands = [ + { pid: 41, command: `"C:\\Program Files\\node.exe" "${entry}" --root "${root}" --role hub` }, + { pid: 42, command: `node "${entry}" --root "${root}-other" --role hub` }, + { pid: 43, command: `node C:\\other.mjs --root "${root}" --role hub` }, + { pid: 44, command: `node "${entry}-other" --root "${root}" --role hub` } + ] + vi.mocked(execFileSync).mockReturnValue( + process.platform === 'win32' + ? JSON.stringify(commands) + : commands.map(({ pid, command }) => `${pid} ${command}`).join('\n') + ) + expect(commandArguments(commands[0]!.command)).toEqual([ + 'C:\\Program Files\\node.exe', + entry, + '--root', + root, + '--role', + 'hub' + ]) + expect(fixtureProcesses(root, entry)).toEqual([commands[0]]) + }) +}) diff --git a/packages/mcp-server/tests/runtime-update-manager.test.ts b/packages/mcp-server/tests/runtime-update-manager.test.ts new file mode 100644 index 00000000..363beb51 --- /dev/null +++ b/packages/mcp-server/tests/runtime-update-manager.test.ts @@ -0,0 +1,399 @@ +import { existsSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' + +import type { Artifact } from '../src/runtime-prototype/state' +import type { AcquisitionResult } from '../src/runtime-update/acquire' + +import * as durable from '../src/durable-json' +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { updateIntent } from '../src/runtime-update/bootstrap' +import { ReleaseUpdates } from '../src/runtime-update/manager' + +const roots: string[] = [] +const owners: ReleaseUpdates[] = [] +const candidate: Artifact = { + entry: '/verified/dist/managed-runtime.mjs', + bundle: 'a'.repeat(64), + version: '0.12.1' +} +function root() { + const value = createPrivateTempDirectory('tp-updates-') + roots.push(value) + return value +} +function owner(options: ConstructorParameters[0]) { + const value = new ReleaseUpdates(options) + owners.push(value) + return value +} +afterEach(() => { + for (const value of owners.splice(0)) value.close() + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }) + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +it.each([ + ['attempt', 'ENOSPC'], + ['success', 'EIO'], + ['failure', 'EACCES'] +])('isolates %s persistence failure from the scheduler (%s)', async (stage, code) => { + vi.useFakeTimers() + const directory = root() + const acquire = vi.fn(async () => { + if (stage === 'failure') throw new Error('registry offline') + return { candidate } + }) + const updates = owner({ root: directory, enrolled: () => true, acquire }) + const write = durable.writeDurableJson + const persist = vi.spyOn(durable, 'writeDurableJson').mockImplementation((path, state) => { + if (stage === 'attempt' || persist.mock.calls.length === 2) + throw Object.assign(new Error(code), { code }) + write(path, state) + }) + updates.schedule() + await vi.advanceTimersByTimeAsync(0) + expect(acquire).toHaveBeenCalledTimes(stage === 'attempt' ? 0 : 1) + expect(updates.status).toMatchObject({ + checking: false, + disabled: true, + error: expect.stringContaining(code) + }) + expect(updates.status.error).toContain('disabled') + if (stage === 'failure') expect(updates.status.error).toContain('registry offline') + expect(updates.status.candidate).toBeUndefined() + const writes = persist.mock.calls.length + updates.schedule() + await vi.advanceTimersByTimeAsync(48 * 60 * 60 * 1000) + expect(persist).toHaveBeenCalledTimes(writes) + await expect(updates.check('latest', true)).rejects.toThrow(code) + expect(acquire).toHaveBeenCalledTimes(stage === 'attempt' ? 0 : 1) + expect(persist).toHaveBeenCalledTimes(writes) + updates.close() + // A fresh owner can resume once storage is repaired, using the persisted + // attempt backoff rather than repeatedly crashing or skipping admission. + persist.mockRestore() + const restored = owner({ + root: directory, + enrolled: () => true, + acquire: async () => ({ candidate }) + }) + await expect(restored.check('latest', true)).resolves.toEqual(candidate) +}) + +it.each([false, true])( + 'retains the committed candidate after a failed save (renamed: %s)', + async (renamed) => { + const directory = root() + const acquire = vi.fn(async () => ({ candidate })) + const updates = owner({ root: directory, enrolled: () => true, acquire }) + await updates.check('latest', true) + const later = { ...candidate, version: '0.12.2', bundle: 'b'.repeat(64) } + acquire.mockResolvedValue({ candidate: later }) + const write = durable.writeDurableJson + vi.spyOn(durable, 'writeDurableJson') + .mockImplementationOnce(write) + .mockImplementation((path, value) => { + if (renamed) write(path, value) + throw new Error('EIO') + }) + await expect(updates.check('latest', true)).rejects.toThrow('EIO') + expect(updates.status).toMatchObject({ candidate, error: expect.stringContaining('EIO') }) + expect(JSON.parse(readFileSync(join(directory, 'updates.json'), 'utf8')).candidate).toEqual( + renamed ? later : candidate + ) + } +) + +it('does no background work without an enrolled edge and never activates', async () => { + vi.useFakeTimers() + const acquire = vi.fn(async () => ({ candidate })) + const directory = root() + const updates = owner({ root: directory, enrolled: () => false, acquire }) + updates.schedule() + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000) + await updates.check() + expect(acquire).not.toHaveBeenCalled() + await updates.check('0.12.1', true) + expect(acquire).toHaveBeenCalledOnce() + expect(updates.status).toMatchObject({ disabled: false, manualCandidate: candidate }) + expect(updates.status.candidate).toBeUndefined() + expect(existsSync(join(directory, 'switch.json'))).toBe(false) +}) + +it('shares concurrent checks and preserves the schedule across an owner restart', async () => { + vi.useFakeTimers() + const directory = root() + const acquire = vi.fn(async () => ({ candidate })) + const options = { root: directory, enrolled: () => true, acquire, jitter: () => 0 } + const updates = owner(options) + const [first, second] = await Promise.all([updates.check(), updates.check()]) + expect(first).toEqual(second) + expect(acquire).toHaveBeenCalledOnce() + updates.close() + const next = owner(options) + next.schedule() + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000 - 1) + expect(acquire).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(acquire).toHaveBeenCalledTimes(2) +}) + +it('keeps exact staging separate from stable selection across restarts', async () => { + const manual = { ...candidate, version: '0.12.99', bundle: 'c'.repeat(64) } + const acquire = vi.fn(async (selector: string) => ({ + candidate: selector === 'latest' ? candidate : manual + })) + const options = { root: root(), enrolled: () => true, acquire } + const updates = owner(options) + await updates.check('latest', true) + await updates.check(manual.version, true) + expect(updates.status).toMatchObject({ candidate, manualCandidate: manual }) + updates.close() + expect(owner(options).status).toMatchObject({ candidate, manualCandidate: manual }) +}) + +it.each(['latest', '0.12.1'])( + 'only shares concurrent acquisitions for the same selector (%s)', + async (selector) => { + const completion = Promise.withResolvers() + const acquire = vi.fn(() => completion.promise) + const updates = owner({ root: root(), enrolled: () => true, acquire }) + const first = updates.check(selector, true) + const same = updates.check(selector, true) + await expect(updates.check(selector === 'latest' ? '0.12.1' : 'latest', true)).rejects.toThrow( + 'Another release selector' + ) + completion.resolve({ candidate }) + expect(await first).toEqual(await same) + expect(acquire).toHaveBeenCalledOnce() + expect(updates.status.candidate).toEqual(selector === 'latest' ? candidate : undefined) + } +) + +it('does not automatically reuse old staging without provenance', () => { + const directory = root() + writeFileSync( + join(directory, 'updates.json'), + JSON.stringify({ version: 1, candidate, nextCheck: 0, failures: 0 }), + { mode: 0o600 } + ) + const updates = owner({ root: directory, enrolled: () => true, acquire: vi.fn() }) + expect(updates.status.candidate).toBeUndefined() + expect(updates.status.error).toContain('rebuilt') +}) + +it('backs off offline failures while retaining a verified candidate', async () => { + vi.useFakeTimers() + const acquire = vi + .fn<() => Promise>() + .mockResolvedValueOnce({ candidate }) + .mockRejectedValue(new Error('offline')) + const updates = owner({ root: root(), enrolled: () => true, acquire, jitter: () => 0 }) + await updates.check() + await expect(updates.check()).rejects.toThrow('offline') + expect(updates.status).toMatchObject({ + candidate, + failures: 1, + error: expect.stringContaining('offline') + }) + await vi.advanceTimersByTimeAsync(5 * 60 * 1000) + expect(acquire).toHaveBeenCalledTimes(3) + expect(updates.status.failures).toBe(2) +}) + +it('cancels background acquisition after the last opt-in edge leaves', async () => { + let enrolled = true + const acquire = vi.fn( + (_selector, signal: AbortSignal) => + new Promise((_resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + ) + ) + const updates = owner({ root: root(), enrolled: () => enrolled, acquire }) + const checking = updates.check() + await Promise.resolve() + enrolled = false + updates.schedule() + await expect(checking).rejects.toThrow('aborted') +}) + +it.each(['broken', JSON.stringify({ version: 1, nextCheck: 'invalid' })])( + 'rebuilds corrupt scheduling metadata after preserving %s and backs off across restarts', + async (corrupt) => { + vi.useFakeTimers() + const directory = root() + writeFileSync(join(directory, 'updates.json'), corrupt, { mode: 0o600 }) + for (const name of ['switch.json', 'design-tasks.json', 'hooks.json']) + writeFileSync(join(directory, name), `authoritative ${name}`, { mode: 0o600 }) + const acquire = vi.fn(async () => ({ candidate })) + const updates = owner({ root: directory, enrolled: () => true, acquire }) + const backup = readdirSync(directory).find((name) => name.startsWith('updates.invalid-'))! + expect(readFileSync(join(directory, backup), 'utf8')).toBe(corrupt) + expect(updates.status).toMatchObject({ + disabled: false, + failures: 1, + error: expect.stringContaining(backup) + }) + expect(updates.status.candidate).toBeUndefined() + updates.schedule() + await vi.advanceTimersByTimeAsync(0) + expect(acquire).not.toHaveBeenCalled() + updates.close() + const restarted = owner({ root: directory, enrolled: () => true, acquire }) + restarted.schedule() + await vi.advanceTimersByTimeAsync(5 * 60 * 1000 - 1) + expect(acquire).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(acquire).toHaveBeenCalledOnce() + expect(restarted.status.candidate).toEqual(candidate) + expect(readFileSync(join(directory, backup), 'utf8')).toBe(corrupt) + for (const name of ['switch.json', 'design-tasks.json', 'hooks.json']) + expect(readFileSync(join(directory, name), 'utf8')).toBe(`authoritative ${name}`) + } +) + +it('allows an explicit check after rebuilding derived metadata', async () => { + const directory = root() + writeFileSync(join(directory, 'updates.json'), 'broken', { mode: 0o600 }) + const acquire = vi.fn(async () => ({ candidate })) + const updates = owner({ root: directory, enrolled: () => true, acquire }) + await expect(updates.check('latest', true)).resolves.toEqual(candidate) + expect(acquire).toHaveBeenCalledOnce() +}) + +it('does not replace corrupt metadata if the repaired snapshot cannot commit', async () => { + const directory = root() + const path = join(directory, 'updates.json') + writeFileSync(path, 'broken', { mode: 0o600 }) + vi.spyOn(durable, 'writeDurableJson').mockImplementation(() => { + throw new Error('ENOSPC') + }) + const acquire = vi.fn() + const updates = owner({ root: directory, enrolled: () => true, acquire }) + expect(updates.status).toMatchObject({ disabled: true, error: expect.stringContaining('ENOSPC') }) + expect(readFileSync(path, 'utf8')).toBe('broken') + await expect(updates.check('latest', true)).rejects.toThrow('ENOSPC') + expect(acquire).not.toHaveBeenCalled() +}) + +it.skipIf(process.platform === 'win32')( + 'does not quarantine or replace untrusted metadata', + async () => { + const directory = root() + const original = join(directory, 'original.json') + writeFileSync(original, 'broken', { mode: 0o600 }) + symlinkSync(original, join(directory, 'updates.json')) + const acquire = vi.fn() + const updates = owner({ root: directory, enrolled: () => true, acquire }) + expect(updates.status).toMatchObject({ + disabled: true, + error: expect.stringContaining('untrusted') + }) + expect(readdirSync(directory).some((name) => name.startsWith('updates.invalid-'))).toBe(false) + expect(readFileSync(original, 'utf8')).toBe('broken') + await expect(updates.check('latest', true)).rejects.toThrow('untrusted') + expect(acquire).not.toHaveBeenCalled() + } +) + +it('rejects contradictory checkout enrollment before constructing an updater', () => { + expect(updateIntent([], true)).toEqual({ command: undefined, policy: 'off' }) + for (const flag of [ + '--runtime-updates=stable', + '--runtime-updates=off', + '--runtime-updates=pinned', + '--check-runtime-update', + '--update-runtime' + ]) + expect(() => updateIntent([flag], true)).toThrow('Checkout') + expect(() => updateIntent(['--runtime-updates=canary'], false)).toThrow('Use one') + expect(updateIntent(['--runtime-updates=pinned'], false).policy).toBe('pinned') + vi.stubEnv('TEMPAD_MCP_DEV_CHECKOUT', '/missing/checkout') + expect(() => owner({ root: root(), enrolled: () => true, acquire: vi.fn() })).toThrow('Checkout') +}) + +it('persists search progress and resumes promptly after an owner restart', async () => { + vi.useFakeTimers() + const directory = root() + const cursor = { + fingerprint: 'b'.repeat(64), + before: '0.12.3', + latestReasons: ['Guidance epoch changed; restart MCP'] + } + const selection = { + latest: '0.12.19', + latestReasons: cursor.latestReasons, + inspected: 16, + remaining: 2, + cursor + } + const acquire = vi + .fn[0]['acquire']>() + .mockResolvedValueOnce({ selection }) + .mockResolvedValue({ + candidate, + selection: { ...selection, inspected: 2, remaining: 0, cursor: undefined } + }) + const options = { root: directory, enrolled: () => true, acquire, jitter: () => 0 } + const first = owner(options) + await first.check() + expect(first.status.candidate).toBeUndefined() + expect(first.status.selection?.cursor).toEqual(cursor) + first.close() + const next = owner(options) + next.schedule() + await vi.advanceTimersByTimeAsync(29999) + expect(acquire).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(acquire).toHaveBeenLastCalledWith('latest', expect.any(AbortSignal), cursor) + expect(next.status.candidate).toEqual(candidate) + expect(next.status.selection?.cursor).toBeUndefined() +}) + +it.each([true, false])( + 'does not postpone stable search progress after an exact check (success: %s)', + async (success) => { + vi.useFakeTimers() + const cursor = { fingerprint: 'b'.repeat(64), before: '0.12.3', latestReasons: [] } + const selection = { latest: '0.12.4', latestReasons: [], inspected: 2, remaining: 2, cursor } + const acquire = vi + .fn[0]['acquire']>() + .mockResolvedValueOnce({ selection }) + .mockImplementationOnce(async () => { + if (!success) throw new Error('exact unavailable') + return { candidate } + }) + .mockResolvedValue({ candidate }) + const updates = owner({ root: root(), enrolled: () => true, acquire }) + await updates.check() + const due = updates.status.nextCheck + await vi.advanceTimersByTimeAsync(10000) + if (success) await updates.check(candidate.version, true) + else await expect(updates.check(candidate.version, true)).rejects.toThrow('exact unavailable') + expect(updates.status).toMatchObject({ nextCheck: due, failures: 0, selection }) + await vi.advanceTimersByTimeAsync(19999) + expect(acquire).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(acquire).toHaveBeenLastCalledWith('latest', expect.any(AbortSignal), cursor) + } +) + +it('clears staging after a successful tag rollback but retains it when the registry is unavailable', async () => { + const acquire = vi + .fn[0]['acquire']>() + .mockResolvedValueOnce({ candidate }) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue({ + selection: { latest: '0.12.0', latestReasons: [], inspected: 0, remaining: 0 } + }) + const updates = owner({ root: root(), enrolled: () => true, acquire }) + await updates.check() + await expect(updates.check()).rejects.toThrow('offline') + expect(updates.status.candidate).toEqual(candidate) + await updates.check() + expect(updates.status.candidate).toBeUndefined() + expect(updates.status.selection?.latest).toBe('0.12.0') +}) diff --git a/packages/mcp-server/tests/runtime-update.test.ts b/packages/mcp-server/tests/runtime-update.test.ts new file mode 100644 index 00000000..0fbbf867 --- /dev/null +++ b/packages/mcp-server/tests/runtime-update.test.ts @@ -0,0 +1,398 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { gzipSync } from 'node:zlib' +import { describe, expect, it, vi } from 'vitest' + +import { createPrivateTempDirectory } from '../src/runtime-platform' +import { readArchive, verifyIntegrity } from '../src/runtime-update/archive' +import { retainBundledPackage, verifyReleaseArtifact } from '../src/runtime-update/artifact' +import { + cacheDirectory, + discardUnusedCache, + installArchive, + pruneCache, + verifyCached +} from '../src/runtime-update/cache' +import { + automaticCompatibilityReasons, + compatibilityReasons, + supportsNode, + validatePolicyBaseline, + type RuntimeManifest +} from '../src/runtime-update/manifest' +import { sha256, verifyPackage } from '../src/runtime-update/package' +import { downloadRelease, releaseCatalog, resolveRelease } from '../src/runtime-update/registry' + +function tar(entries: { path: string; data?: string; type?: string }[]) { + const blocks: Buffer[] = [] + for (const entry of entries) { + const header = Buffer.alloc(512) + const data = Buffer.from(entry.data ?? '') + header.write(entry.path) + header.write(data.length.toString(8).padStart(11, '0') + '\0', 124) + header.write(' ', 148) + header.write(entry.type ?? '0', 156) + const checksum = header.reduce((sum, value) => sum + value, 0) + header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148) + blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512)) + } + return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])) +} +const integrity = (bytes: Buffer) => `sha512-${createHash('sha512').update(bytes).digest('base64')}` +const baselineIntegrity = integrity(Buffer.from('baseline')) +function fixture() { + const files = new Map( + Object.entries({ + 'package.json': JSON.stringify({ + name: '@tempad-dev/mcp', + version: '0.12.0', + bin: 'dist/cli.mjs', + engines: { node: '^22 || ^24 || >=26' }, + dependencies: { test: '1.0.0' }, + bundleDependencies: ['test'] + }), + 'dist/cli.mjs': '', + 'dist/managed-runtime.mjs': '', + 'dist/log-transport.mjs': '', + 'node_modules/test/package.json': JSON.stringify({ name: 'test', version: '1.0.0' }) + }).map(([key, value]) => [key, Buffer.from(value)]) + ) + const manifest: RuntimeManifest = { + version: 1, + name: '@tempad-dev/mcp', + packageVersion: '0.12.0', + entry: 'dist/managed-runtime.mjs', + node: '^22 || ^24 || >=26', + platforms: ['darwin', 'linux', 'win32'], + guidanceEpoch: 1, + guidanceReview: { + version: '0.12.0', + sourceHash: 'a'.repeat(64), + decision: 'initial', + baseline: { version: '0.11.0', integrity: baselineIntegrity }, + rationale: 'First release with reviewed runtime guidance.' + }, + wireHash: 'b'.repeat(64), + presentationHash: 'c'.repeat(64), + lockHash: 'd'.repeat(64), + controlProtocols: [1], + state: { family: 'tempad', reads: [1], writes: 1 }, + extension: { protocols: [1], legacy: true }, + hookProtocol: 2, + files: Object.fromEntries([...files].map(([key, value]) => [key, sha256(value)])) + } + files.set('dist/runtime-manifest.json', Buffer.from(JSON.stringify(manifest))) + return { files, manifest } +} + +describe('release archives', () => { + it('checks integrity before accepting regular package files', () => { + const bytes = tar([{ path: 'package/dist/cli.mjs', data: 'ok' }]) + verifyIntegrity(bytes, integrity(bytes)) + expect(readArchive(bytes).get('dist/cli.mjs')?.toString()).toBe('ok') + expect(() => verifyIntegrity(bytes, baselineIntegrity)).toThrow('integrity') + expect(() => readArchive(bytes.subarray(0, bytes.length - 6))).toThrow() + }) + it.each([ + '../escape', + 'a/../../escape', + '/absolute', + 'a\\b', + 'C:/outside', + 'a//b', + 'a/./b', + 'a/NUL.txt', + 'COM1', + 'lpt².txt', + 'a/file.', + 'a/file ', + 'a/file:stream', + 'a/*.mjs' + ])('rejects path %s', (path) => { + expect(() => readArchive(tar([{ path: `package/${path}`, data: 'x' }]))).toThrow() + }) + it.each(['1', '2', '3', '4', '6', 'g'])('rejects non-file tar type %s', (type) => { + expect(() => readArchive(tar([{ path: 'package/link', type }]))).toThrow() + }) + it('rejects duplicates and file ancestors', () => { + expect(() => readArchive(tar([{ path: 'package/a' }, { path: 'package/a' }]))).toThrow() + expect(() => readArchive(tar([{ path: 'package/a/b' }, { path: 'package/a' }]))).toThrow() + }) + it('checks checksums and empty packages', () => { + expect(() => readArchive(tar([]))).toThrow() + const bytes = Buffer.alloc(1024, 1) + expect(() => readArchive(gzipSync(bytes))).toThrow() + }) +}) + +describe('release manifest and dependency closure', () => { + it.each([ + ['0.12.0', '0.12.9', false], + ['0.12.0', '0.13.0', true], + ['0.12.0', '1.0.0', true], + ['1.0.0', '1.8.0', false], + ['1.8.0', '2.0.0', true] + ])('checks automatic release boundaries %s → %s', (active, next, blocked) => { + const { manifest } = fixture() + const before = { ...manifest, packageVersion: active } + const after = { ...manifest, packageVersion: next } + expect(compatibilityReasons(before, after)).toEqual([]) + expect(automaticCompatibilityReasons(before, after)).toEqual( + blocked ? ['Breaking release boundary; explicit update required'] : [] + ) + expect(automaticCompatibilityReasons(before, { ...after, guidanceEpoch: 2 })).toContain( + 'Guidance epoch changed; restart MCP' + ) + }) + it('accepts the exact inventoried package', () => { + const { files, manifest } = fixture() + expect(verifyPackage(files, '0.12.0')).toEqual(manifest) + validatePolicyBaseline(manifest) + }) + it('rejects modified, missing, extra and misidentified files', () => { + const { files } = fixture() + expect(() => verifyPackage(files, '0.12.1')).toThrow('identity') + files.set('dist/cli.mjs', Buffer.from('modified')) + expect(() => verifyPackage(files, '0.12.0')).toThrow('hash') + files.delete('dist/cli.mjs') + expect(() => verifyPackage(files, '0.12.0')).toThrow('inventory') + }) + it('rejects incomplete bundled transitive dependencies', () => { + const { files, manifest } = fixture() + const path = 'node_modules/test/package.json' + files.set(path, Buffer.from(JSON.stringify({ dependencies: { absent: '1.0.0' } }))) + manifest.files[path] = sha256(files.get(path)!) + files.set('dist/runtime-manifest.json', Buffer.from(JSON.stringify(manifest))) + expect(() => verifyPackage(files, '0.12.0')).toThrow('Unbundled runtime dependency absent') + }) + it('resolves nested archive dependencies with portable slash-separated keys', () => { + const { files, manifest } = fixture() + files.set( + 'node_modules/test/package.json', + Buffer.from(JSON.stringify({ dependencies: { nested: '1.0.0' } })) + ) + files.set( + 'node_modules/test/node_modules/nested/package.json', + Buffer.from(JSON.stringify({ dependencies: { test: '1.0.0' } })) + ) + manifest.files = Object.fromEntries( + [...files] + .filter(([path]) => path !== 'dist/runtime-manifest.json') + .map(([path, bytes]) => [path, sha256(bytes)]) + ) + files.set('dist/runtime-manifest.json', Buffer.from(JSON.stringify(manifest))) + expect(verifyPackage(files, '0.12.0')).toEqual(manifest) + }) + it('allows a presentation correction but blocks reverted text across epochs', () => { + const { manifest: first } = fixture() + const third = { + ...first, + packageVersion: '0.12.2', + presentationHash: first.presentationHash, + guidanceEpoch: 2 + } + expect(compatibilityReasons(first, { ...first, presentationHash: 'e'.repeat(64) })).toEqual([]) + expect(compatibilityReasons(first, third)).toContain('Guidance epoch changed; restart MCP') + }) + it('requires state rollback, control, extension and wire compatibility', () => { + const { manifest } = fixture() + expect( + compatibilityReasons(manifest, { + ...manifest, + wireHash: 'e'.repeat(64), + controlProtocols: [2], + state: { ...manifest.state, reads: [2] }, + extension: { protocols: [2], legacy: false } + }) + ).toHaveLength(4) + }) + it('requires fresh monotonic guidance decisions', () => { + const { manifest: prior } = fixture() + const next = { + ...prior, + packageVersion: '0.12.1', + guidanceReview: { + ...prior.guidanceReview, + version: '0.12.1', + decision: 'compatible' as const, + baseline: { version: prior.packageVersion, integrity: baselineIntegrity } + } + } + validatePolicyBaseline(next, prior) + expect(() => validatePolicyBaseline(next)).toThrow('Missing historical') + expect(() => validatePolicyBaseline({ ...next, guidanceEpoch: 2 }, prior)).toThrow('disagree') + expect(() => validatePolicyBaseline({ ...next, guidanceEpoch: 0 }, prior)).toThrow('decrease') + }) + it('only interprets explicitly supported Node engine policies', () => { + expect(supportsNode('^22 || ^24 || >=26', '24.1.0')).toBe(true) + expect(supportsNode('^22 || ^24 || >=26', '23.1.0')).toBe(false) + expect(() => supportsNode('>=22.0.0')).toThrow('Unsupported') + }) +}) + +describe('private release cache', () => { + it('retains an immutable bootstrap closure independently of npm cache cleanup', async () => { + const root = createPrivateTempDirectory('tp-cache-') + try { + const source = join(root, 'source') + const { files } = fixture() + for (const [path, bytes] of files) { + const target = join(source, path) + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }) + writeFileSync(target, bytes) + } + const cache = join(root, 'cache') + const artifact = await retainBundledPackage(source, cache) + expect(await retainBundledPackage(source, cache)).toEqual(artifact) + rmSync(source, { recursive: true }) + expect(verifyReleaseArtifact(artifact).packageVersion).toBe('0.12.0') + const unused = join(cache, 'b'.repeat(64)) + mkdirSync(unused) + symlinkSync(root, join(cache, 'c'.repeat(64)), 'junction') + const owner = new AbortController() + const interrupted = pruneCache(cache, [artifact], owner.signal) + owner.abort(new Error('Cache owner retired')) + await expect(interrupted).rejects.toThrow('Cache owner retired') + expect(existsSync(unused)).toBe(true) + await pruneCache(cache, [artifact]) + expect(existsSync(unused)).toBe(false) + expect(existsSync(artifact.entry)).toBe(true) + expect(existsSync(join(cache, 'c'.repeat(64)))).toBe(true) + writeFileSync(artifact.entry, 'modified') + expect(() => verifyReleaseArtifact(artifact)).toThrow('hash mismatch') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('publishes a complete package and rechecks corrupted cache bytes', async () => { + const root = createPrivateTempDirectory('tp-cache-') + try { + const { files } = fixture() + const archive = tar( + [...files].map(([path, data]) => ({ path: `package/${path}`, data: data.toString() })) + ) + const sri = integrity(archive) + const installed = await installArchive(root, '0.12.0', sri, archive) + expect( + readFileSync(join(installed.directory, 'node_modules/test/package.json'), 'utf8') + ).toContain('1.0.0') + expect(await installArchive(root, '0.12.0', sri, archive)).toEqual(installed) + writeFileSync(join(installed.directory, 'dist/cli.mjs'), 'tampered') + expect(() => verifyCached(root, '0.12.0', sri)).toThrow('hash mismatch') + await expect(installArchive(root, '0.12.0', sri, archive)).rejects.toThrow('hash mismatch') + const retained = { + version: '0.12.0', + entry: join(installed.directory, 'dist/managed-runtime.mjs'), + bundle: 'a'.repeat(64), + release: { directory: installed.directory, manifestHash: 'b'.repeat(64) } + } + expect(() => discardUnusedCache(root, sri, [undefined, retained])).toThrow( + 'retained runtime cache' + ) + expect(readFileSync(join(installed.directory, 'dist/cli.mjs'), 'utf8')).toBe('tampered') + expect(discardUnusedCache(root, sri, [])).toBe(true) + expect(await installArchive(root, '0.12.0', sri, archive)).toEqual(installed) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('never follows a cached package symlink', () => { + const root = createPrivateTempDirectory('tp-cache-') + try { + symlinkSync(root, cacheDirectory(root, baselineIntegrity), 'junction') + expect(() => verifyCached(root, '0.12.0', baselineIntegrity)).toThrow('untrusted runtime') + expect(() => discardUnusedCache(root, baselineIntegrity, [])).toThrow('untrusted runtime') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + +describe('official release acquisition', () => { + const metadata = { + name: '@tempad-dev/mcp', + version: '0.12.0', + dist: { + integrity: baselineIntegrity, + tarball: 'https://registry.npmjs.org/@tempad-dev/mcp/-/mcp-0.12.0.tgz' + } + } + it('orders stable history below the current latest tag and fingerprints deprecation', async () => { + const version = (value: string, extra = {}) => ({ + ...metadata, + version: value, + dist: { + ...metadata.dist, + tarball: `https://registry.npmjs.org/@tempad-dev/mcp/-/mcp-${value}.tgz` + }, + ...extra + }) + const payload = { + name: metadata.name, + 'dist-tags': { latest: '0.12.3' }, + versions: { + '0.12.0': metadata, + '0.12.1': version('0.12.1'), + '0.12.2': version('0.12.2', { deprecated: 'Withdrawn' }), + '0.12.3': version('0.12.3'), + '0.12.4': { untrusted: 'above stable ceiling' }, + '0.13.0-alpha': { untrusted: 'prerelease' } + } + } + const fetcher = vi.fn().mockResolvedValue(Response.json(payload)) + const first = await releaseCatalog('0.12.0', fetcher) + expect(first.releases.map((release) => release.version)).toEqual(['0.12.3', '0.12.2', '0.12.1']) + expect(first.releases[1]?.deprecated).toBe('Withdrawn') + expect(fetcher.mock.calls[0]?.[0]).toBe('https://registry.npmjs.org/@tempad-dev%2fmcp') + payload.versions['0.12.2'] = version('0.12.2') + fetcher.mockResolvedValue(Response.json(payload)) + expect((await releaseCatalog('0.12.0', fetcher)).fingerprint).not.toBe(first.fingerprint) + payload['dist-tags'].latest = '0.12.0' + fetcher.mockResolvedValue(Response.json(payload)) + expect((await releaseCatalog('0.12.2', fetcher)).releases).toEqual([]) + }) + it('rejects oversized, mismatched, or missing stable history metadata', async () => { + const payload = { + name: metadata.name, + 'dist-tags': { latest: '0.12.1' }, + versions: { '0.12.1': metadata } + } + const fetcher = vi.fn().mockResolvedValue(Response.json(payload)) + await expect(releaseCatalog('0.12.0', fetcher)).rejects.toThrow('requested official release') + fetcher.mockResolvedValueOnce(Response.json({ ...payload, versions: {} })) + await expect(releaseCatalog('0.12.0', fetcher)).rejects.toThrow() + fetcher.mockResolvedValueOnce(new Response('x'.repeat(8 * 1024 * 1024 + 1))) + await expect(releaseCatalog('0.12.0', fetcher)).rejects.toThrow('byte limit') + }) + it('pins exact metadata and refuses redirects or alternate tarball hosts', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json(metadata)) + expect(await resolveRelease('latest', fetcher)).toEqual({ + version: '0.12.0', + integrity: baselineIntegrity, + url: metadata.dist.tarball + }) + expect(fetcher.mock.calls[0]![1]).toMatchObject({ redirect: 'error', credentials: 'omit' }) + fetcher.mockResolvedValueOnce( + Response.json({ ...metadata, dist: { ...metadata.dist, tarball: 'https://evil.test/a.tgz' } }) + ) + await expect(resolveRelease('0.12.0', fetcher)).rejects.toThrow('official release') + expect(() => + downloadRelease( + { version: '0.12.0', integrity: baselineIntegrity, url: 'http://localhost/a' }, + fetcher + ) + ).toThrow('official npm') + }) + it('rejects prereleases, wrong versions, errors and oversized metadata', async () => { + const fetcher = vi.fn() + await expect(resolveRelease('0.12.0-alpha', fetcher)).rejects.toThrow() + expect(fetcher).not.toHaveBeenCalled() + fetcher.mockResolvedValueOnce(Response.json(metadata)) + await expect(resolveRelease('0.12.1', fetcher)).rejects.toThrow('requested') + fetcher.mockResolvedValueOnce(new Response('', { status: 503 })) + await expect(resolveRelease('latest', fetcher)).rejects.toThrow('HTTP 503') + fetcher.mockResolvedValueOnce(new Response('x'.repeat(2 * 1024 * 1024 + 1))) + await expect(resolveRelease('latest', fetcher)).rejects.toThrow('byte limit') + }) +}) diff --git a/packages/mcp-server/tests/setup.ts b/packages/mcp-server/tests/setup.ts index 5b347f20..5495ff97 100644 --- a/packages/mcp-server/tests/setup.ts +++ b/packages/mcp-server/tests/setup.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { inject } from 'vitest' // Isolate each test file before runtime imports can create files or prune logs. +delete process.env.TEMPAD_MCP_DEV_CHECKOUT const directory = mkdtempSync(join(inject('mcpTestDirectory'), 'worker-')) process.env.TEMPAD_MCP_RUNTIME_DIR = join(directory, 'runtime') process.env.TEMPAD_MCP_STATE_DIR = join(directory, 'state') diff --git a/packages/mcp-server/tests/shared.test.ts b/packages/mcp-server/tests/shared.test.ts index 2c850191..b779ab68 100644 --- a/packages/mcp-server/tests/shared.test.ts +++ b/packages/mcp-server/tests/shared.test.ts @@ -22,13 +22,14 @@ const osMocks = vi.hoisted(() => ({ })) const pinoMocks = vi.hoisted(() => { - const transport = vi.fn(() => ({ mockTransport: true })) + const end = vi.fn() + const transport = vi.fn(() => ({ mockTransport: true, end })) const logger = { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() } const pino = Object.assign( vi.fn(() => logger), { transport } ) - return { pino, transport, logger } + return { pino, transport, logger, end } }) const pruneExpiredLogs = vi.hoisted(() => vi.fn()) @@ -157,6 +158,9 @@ describe('mcp-server/shared', () => { }) }) ) + expect(pinoMocks.end).not.toHaveBeenCalled() + shared.endLogging() + expect(pinoMocks.end).toHaveBeenCalledOnce() }) it('falls back to tmpdir defaults and info level when env is unset', async () => { diff --git a/packages/mcp-server/tests/stable-edge.test.ts b/packages/mcp-server/tests/stable-edge.test.ts new file mode 100644 index 00000000..766453c8 --- /dev/null +++ b/packages/mcp-server/tests/stable-edge.test.ts @@ -0,0 +1,614 @@ +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { STDIO_DEFAULT_MAX_BUFFER_SIZE } from '@modelcontextprotocol/sdk/shared/stdio.js' +import { + ErrorCode, + ListToolsRequestSchema, + McpError, + ResultSchema +} from '@modelcontextprotocol/sdk/types.js' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RuntimeDataTransport } from '../src/runtime-prototype/protocol' +import { startStableEdge, type StableEdgeOptions } from '../src/stable-edge' + +const cleanup: (() => Promise)[] = [] +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((close) => close())) + vi.restoreAllMocks() +}) + +async function fixture( + overrides: Partial = {}, + admit?: (generation: number, method?: string) => boolean +) { + const [clientTransport, hostTransport] = InMemoryTransport.createLinkedPair() + const [edgeTransport, runtimeTransport] = InMemoryTransport.createLinkedPair() + const runtime = new Server( + { name: 'runtime', version: '9', title: 'Runtime title' }, + { capabilities: { tools: { listChanged: true } }, instructions: 'Original guidance.' } + ) + let description = 'Original tool.' + const list = vi.fn(() => ({ + tools: [ + { + name: 'fixture', + description, + inputSchema: { type: 'object' as const }, + annotations: { readOnlyHint: true } + } + ] + })) + runtime.setRequestHandler(ListToolsRequestSchema, list) + await runtime.connect( + admit + ? new RuntimeDataTransport(runtimeTransport, { generation: () => 1, role: 'hub', admit }) + : runtimeTransport + ) + const error = vi.fn() + const open = vi.fn(async () => + admit + ? new RuntimeDataTransport(edgeTransport, { + generation: () => 1, + role: 'edge', + admit: () => true + }) + : edgeTransport + ) + const binding = vi.fn() + const edge = await startStableEdge(hostTransport, { + version: '1', + requestTimeoutMs: 5000, + open, + onError: error, + onBinding: binding, + ...overrides + }) + const client = new Client({ name: 'host', version: '2' }, { capabilities: { roots: {} } }) + cleanup.push(async () => { + await edge.close() + await client.close() + await runtime.close() + }) + return { + client, + clientTransport, + hostTransport, + runtime, + runtimeTransport, + edgeTransport, + edge, + error, + open, + list, + binding, + changeDescription: (value: string) => (description = value), + connect: () => client.connect(clientTransport) + } +} + +describe('stable MCP edge', () => { + async function replacement( + f: Awaited>, + version = '10', + instructions = 'Original guidance.' + ) { + const [transport, endpoint] = InMemoryTransport.createLinkedPair() + const runtime = new Server( + { name: 'runtime', version, title: 'Runtime title' }, + { capabilities: { tools: { listChanged: true } }, instructions } + ) + runtime.setRequestHandler(ListToolsRequestSchema, () => f.list()) + runtime.fallbackRequestHandler = async () => ({ runtime: version }) + await runtime.connect(endpoint) + cleanup.push(() => runtime.close()) + f.open.mockResolvedValue(transport) + return runtime + } + + it('switches two initialized clients without replacing frozen metadata or replaying requests', async () => { + const register = vi.fn(async () => {}) + const clients = await Promise.all([fixture({ register }), fixture({ register })]) + for (const f of clients) { + await f.connect() + const catalog = await f.client.listTools() + const runtime = await replacement(f) + await f.edge.replaceBackend() + expect(runtime.getClientVersion()).toEqual({ name: 'host', version: '2' }) + expect(await f.client.request({ method: 'fixture/version' }, ResultSchema)).toEqual({ + runtime: '10' + }) + expect(await f.client.listTools()).toEqual(catalog) + expect(f.client.getServerVersion()?.version).toBe('1') + expect(f.client.getInstructions()).toBe('Original guidance.') + } + expect(register).toHaveBeenCalledTimes(4) + }) + + it('waits through a short backend replacement while keeping the catalog available', async () => { + const f = await fixture({ runtimeWaitMs: 1000 }) + await f.connect() + const catalog = await f.client.listTools() + await f.runtime.close() + const pending = f.client.request({ method: 'fixture/write' }, ResultSchema) + const writes = vi.fn(async () => ({ completed: true })) + const next = await replacement(f) + next.fallbackRequestHandler = writes + expect(await f.client.listTools()).toEqual(catalog) + await f.edge.replaceBackend() + expect(await pending).toEqual({ completed: true }) + expect(writes).toHaveBeenCalledOnce() + }) + + it('waits only after transport-level rejection and dispatches a queued write once', async () => { + let gated = false + const f = await fixture({ runtimeWaitMs: 1000 }, () => !gated) + const writes = vi.fn(async () => ({ completed: true })) + f.runtime.fallbackRequestHandler = writes + await f.connect() + gated = true + const pending = f.client.request({ method: 'fixture/write' }, ResultSchema) + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(writes).not.toHaveBeenCalled() + gated = false + expect(await pending).toEqual({ completed: true }) + expect(writes).toHaveBeenCalledOnce() + }) + + it('acknowledges an admission pause only after forwarded attempts settle, excluding queued calls', async () => { + const f = await fixture({ runtimeWaitMs: 2000 }) + const result = Promise.withResolvers<{ completed: boolean }>() + const original = vi.fn(() => result.promise) + f.runtime.fallbackRequestHandler = original + await f.connect() + const sent = f.client.request({ method: 'fixture/write' }, ResultSchema) + await vi.waitFor(() => expect(original).toHaveBeenCalledOnce()) + const acknowledged = vi.fn() + const barrier = f.edge.pause(new AbortController().signal).then(acknowledged) + const queued = f.client.request({ method: 'fixture/write' }, ResultSchema) + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(acknowledged).not.toHaveBeenCalled() + expect(original).toHaveBeenCalledOnce() + result.resolve({ completed: true }) + expect(await sent).toEqual({ completed: true }) + await barrier + expect(acknowledged).toHaveBeenCalledOnce() + const next = await replacement(f) + const writes = vi.fn(async () => ({ replaced: true })) + next.fallbackRequestHandler = writes + await f.edge.replaceBackend() + expect(writes).not.toHaveBeenCalled() + f.edge.resume() + expect(await queued).toEqual({ replaced: true }) + expect(writes).toHaveBeenCalledOnce() + }) + + it('waits for a gated rejection reply before allowing the old endpoint to close', async () => { + let gated = false + const f = await fixture({ runtimeWaitMs: 2000 }, () => !gated) + const writes = vi.fn(async () => ({ original: true })) + f.runtime.fallbackRequestHandler = writes + await f.connect() + const reply = Promise.withResolvers() + const held = Promise.withResolvers() + const send = f.runtimeTransport.send.bind(f.runtimeTransport) + vi.spyOn(f.runtimeTransport, 'send').mockImplementation(async (frame, options) => { + if ('params' in frame && (frame.params?.message as { error?: unknown })?.error) { + held.resolve() + await reply.promise + } + await send(frame, options) + }) + gated = true + const pending = f.client.request({ method: 'fixture/write' }, ResultSchema) + await held.promise + const acknowledged = vi.fn() + const barrier = f.edge.pause(new AbortController().signal).then(acknowledged) + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(acknowledged).not.toHaveBeenCalled() + expect(writes).not.toHaveBeenCalled() + reply.resolve() + await barrier + await f.runtime.close() + const next = await replacement(f) + const replacementWrites = vi.fn(async () => ({ replaced: true })) + next.fallbackRequestHandler = replacementWrites + await f.edge.replaceBackend() + f.edge.resume() + expect(await pending).toEqual({ replaced: true }) + expect(writes).not.toHaveBeenCalled() + expect(replacementWrites).toHaveBeenCalledOnce() + }) + + it('cancels barrier waiting without cancelling an already forwarded write or reviving a cancelled queued call', async () => { + const f = await fixture({ runtimeWaitMs: 2000 }) + const result = Promise.withResolvers>() + const writes = vi.fn(() => result.promise) + f.runtime.fallbackRequestHandler = writes + await f.connect() + const sent = f.client.request({ method: 'fixture/write' }, ResultSchema) + await vi.waitFor(() => expect(writes).toHaveBeenCalledOnce()) + const cancellation = new AbortController() + const barrier = f.edge.pause(cancellation.signal) + const rejected = expect(barrier).rejects.toThrow('cancel barrier') + cancellation.abort(new Error('cancel barrier')) + await rejected + const queuedCancellation = new AbortController() + const queued = f.client.request({ method: 'fixture/write' }, ResultSchema, { + signal: queuedCancellation.signal + }) + const queuedRejected = expect(queued).rejects.toThrow() + queuedCancellation.abort() + await queuedRejected + result.resolve({ completed: true }) + expect(await sent).toEqual({ completed: true }) + f.edge.resume() + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(writes).toHaveBeenCalledOnce() + }) + + it.each(['cancel', 'close'] as const)( + 'never dispatches a waiting request after host %s', + async (reason) => { + const f = await fixture({ runtimeWaitMs: 1000 }) + await f.connect() + await f.runtime.close() + const abort = new AbortController() + const pending = f.client.request({ method: 'fixture/write' }, ResultSchema, { + signal: abort.signal + }) + const rejected = expect(pending).rejects.toThrow() + if (reason === 'cancel') abort.abort() + else await f.client.close() + await rejected + if (reason === 'cancel') { + const next = await replacement(f) + const writes = vi.fn(async () => ({})) + next.fallbackRequestHandler = writes + await f.edge.replaceBackend() + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(writes).not.toHaveBeenCalled() + } + } + ) + + it.each([ + { runtimeWaitMs: 80, requestTimeoutMs: 1000 }, + { runtimeWaitMs: 1000, requestTimeoutMs: 80 } + ])('bounds waiting by both recovery and request deadlines: %o', async (options) => { + const f = await fixture(options) + await f.connect() + await f.runtime.close() + const clock = vi.spyOn(performance, 'now').mockReturnValue(0) + const pending = f.client.request({ method: 'fixture/write' }, ResultSchema) + const rejected = expect(pending).rejects.toMatchObject({ + data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } + }) + await vi.waitFor(() => expect(clock).toHaveBeenCalled()) + clock.mockReturnValue(80) + await rejected + clock.mockRestore() + const next = await replacement(f) + const writes = vi.fn(async () => ({})) + next.fallbackRequestHandler = writes + await f.edge.replaceBackend() + expect(writes).not.toHaveBeenCalled() + }) + + it.each(['error', 'closed'] as const)( + 'never retries an uncertain dispatched result: %s', + async (result) => { + const f = await fixture({ runtimeWaitMs: 1000 }) + const writes = vi.fn(async () => { + if (result === 'closed') await f.runtime.close() + throw new McpError(-32001, 'Uncertain result.', { + code: 'MCP_RUNTIME_UNAVAILABLE', + dispatched: true + }) + }) + f.runtime.fallbackRequestHandler = writes + await f.connect() + await expect(f.client.request({ method: 'fixture/write' }, ResultSchema)).rejects.toThrow() + expect(writes).toHaveBeenCalledOnce() + expect(f.open).toHaveBeenCalledOnce() + } + ) + + it('bounds retained request count and releases cancelled waiters', async () => { + const f = await fixture({ runtimeWaitMs: 1000 }) + await f.connect() + await f.runtime.close() + const abort = new AbortController() + const pending = Array.from({ length: 64 }, () => + f.client.request({ method: 'fixture/write' }, ResultSchema, { signal: abort.signal }) + ) + const settled = Promise.allSettled(pending) + await expect(f.client.request({ method: 'fixture/overflow' }, ResultSchema)).rejects.toThrow( + 'Runtime wait buffer is full' + ) + abort.abort() + await settled + const next = await replacement(f) + const writes = vi.fn(async () => ({})) + next.fallbackRequestHandler = writes + const retry = f.client.request({ method: 'fixture/write' }, ResultSchema) + await f.edge.replaceBackend() + await retry + expect(writes).toHaveBeenCalledOnce() + }) + + it('bounds retained request bytes independently of request count', async () => { + const f = await fixture({ runtimeWaitMs: 1000 }) + await f.connect() + await f.runtime.close() + await expect( + f.client.request( + { method: 'fixture/write', params: { text: 'x'.repeat(STDIO_DEFAULT_MAX_BUFFER_SIZE) } }, + ResultSchema + ) + ).rejects.toThrow('Runtime wait buffer is full') + }) + + it('fails lost writes once, rejects calls during replacement, and preserves the session after a rejected candidate', async () => { + const closed = vi.fn() + const f = await fixture({ onBackendClose: closed }) + const written = vi.fn( + async (_request, extra) => + new Promise>((resolve) => + extra.signal.addEventListener('abort', () => resolve({})) + ) + ) + f.runtime.fallbackRequestHandler = written + await expect(f.edge.replaceBackend()).rejects.toMatchObject({ data: { dispatched: false } }) + await f.connect() + const pending = f.client.request({ method: 'fixture/write' }, ResultSchema) + const lost = expect(pending).rejects.toThrow() + await vi.waitFor(() => expect(written).toHaveBeenCalledOnce()) + await f.runtime.close() + await lost + expect(closed).toHaveBeenCalledOnce() + await expect(f.client.request({ method: 'fixture/write' }, ResultSchema)).rejects.toMatchObject( + { data: { code: 'MCP_RUNTIME_UNAVAILABLE', dispatched: false } } + ) + expect(await f.client.ping()).toEqual({}) + const catalog = await f.client.listTools() + await replacement(f, '10', 'Incompatible guidance.') + await expect(f.edge.replaceBackend()).rejects.toThrow('MCP_RUNTIME_RESTART_REQUIRED') + expect(await f.client.listTools()).toEqual(catalog) + await replacement(f, '9') + await f.edge.replaceBackend() + expect(await f.client.request({ method: 'fixture/version' }, ResultSchema)).toEqual({ + runtime: '9' + }) + expect(written).toHaveBeenCalledOnce() + }) + + it('freezes runtime guidance and catalog while identifying the edge version', async () => { + const f = await fixture() + await f.connect() + expect(f.client.getServerVersion()).toEqual({ + name: 'runtime', + title: 'Runtime title', + version: '1' + }) + expect(f.runtime.getClientVersion()).toEqual({ name: 'host', version: '2' }) + expect(f.runtime.getClientCapabilities()).toEqual({ roots: {} }) + expect(f.client.getInstructions()).toBe('Original guidance.') + const catalog = await f.client.listTools() + f.changeDescription('New guidance.') + await f.runtime.notification({ method: 'notifications/tools/list_changed' }) + expect(await f.client.listTools()).toEqual(catalog) + expect(f.list).toHaveBeenCalledTimes(1) + await f.runtime.close() + expect(await f.client.listTools()).toEqual(catalog) + await expect(f.client.ping()).resolves.toEqual({}) + }) + + it('preserves metadata, structured results, errors and progress in both directions', async () => { + const f = await fixture() + const received = vi.fn() + f.runtime.fallbackRequestHandler = async (request, extra) => { + received(request) + if (request.method === 'fixture/error') + throw new McpError(-32019, 'Rejected by runtime.', { reason: 'fixture', retryable: false }) + await extra.sendNotification({ + method: 'notifications/progress', + params: { progressToken: request.params!._meta!.progressToken!, progress: 3, total: 5 } + }) + return { structuredContent: { exact: ['a', 1] }, _meta: { generation: 7 }, content: [] } + } + const hostReceived = vi.fn(async () => ({ roots: [{ uri: 'file:///fixture' }] })) + f.client.fallbackRequestHandler = hostReceived + await f.connect() + const progress = vi.fn() + const result = await f.client.request( + { + method: 'fixture/result', + params: { _meta: { 'codex/thread-id': 'thread', nested: { preserve: true } } } + }, + ResultSchema, + { onprogress: progress } + ) + expect(result).toEqual({ + structuredContent: { exact: ['a', 1] }, + _meta: { generation: 7 }, + content: [] + }) + expect(received.mock.calls[0]![0].params._meta).toMatchObject({ + 'codex/thread-id': 'thread', + nested: { preserve: true } + }) + expect(progress).toHaveBeenCalledWith(expect.objectContaining({ progress: 3, total: 5 })) + await expect(f.client.request({ method: 'fixture/error' }, ResultSchema)).rejects.toMatchObject( + { + code: -32019, + data: { reason: 'fixture', retryable: false } + } + ) + expect(await f.runtime.request({ method: 'roots/list' }, ResultSchema)).toEqual({ + roots: [{ uri: 'file:///fixture' }] + }) + expect(hostReceived).toHaveBeenCalledTimes(1) + }) + + it('cancels only the corresponding runtime request and never retries a lost write', async () => { + const f = await fixture() + const signals: AbortSignal[] = [] + const finish: (() => void)[] = [] + f.runtime.fallbackRequestHandler = async (_request, extra) => { + signals.push(extra.signal) + await new Promise((resolve) => finish.push(resolve)) + return {} + } + await f.connect() + const abort = new AbortController() + const first = f.client.request({ method: 'fixture/write' }, ResultSchema, { + signal: abort.signal + }) + const second = f.client.request({ method: 'fixture/write' }, ResultSchema) + const settled = Promise.allSettled([first, second]) + await vi.waitFor(() => expect(signals).toHaveLength(2)) + abort.abort() + await vi.waitFor(() => expect(signals[0]!.aborted).toBe(true)) + expect(signals[1]!.aborted).toBe(false) + await f.runtimeTransport.close() + expect((await settled).map((result) => result.status)).toEqual(['rejected', 'rejected']) + expect(f.open).toHaveBeenCalledTimes(1) + expect(signals).toHaveLength(2) + for (const resolve of finish) resolve() + }) + + it('keeps private binding notifications away from the host and registers before tools', async () => { + const order: string[] = [] + const f = await fixture({ register: async () => void order.push('register') }) + const list = f.list.getMockImplementation()! + f.list.mockImplementation(() => { + order.push('tools') + return list() + }) + const hostNotifications = vi.fn() + f.client.fallbackNotificationHandler = hostNotifications + await f.connect() + await f.runtime.notification({ + method: 'notifications/tempad/client-binding', + params: { kind: 'claude', sessionId: 'new-session' } + }) + expect(order).toEqual(['register', 'tools']) + expect(f.binding).toHaveBeenCalledWith({ kind: 'claude', sessionId: 'new-session' }) + expect(hostNotifications).not.toHaveBeenCalled() + }) + + it('freezes all catalog pages and rejects unknown or cyclic cursors', async () => { + const f = await fixture() + f.runtime.setRequestHandler(ListToolsRequestSchema, ({ params }) => + params?.cursor ? { tools: [], _meta: { page: 2 } } : { tools: [], nextCursor: 'second' } + ) + await f.connect() + expect(await f.client.listTools()).toEqual({ tools: [], nextCursor: 'second' }) + expect(await f.client.listTools({ cursor: 'second' })).toEqual({ + tools: [], + _meta: { page: 2 } + }) + await expect(f.client.listTools({ cursor: 'unknown' })).rejects.toMatchObject({ + code: ErrorCode.InvalidParams + }) + const cycle = await fixture() + cycle.runtime.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [], + nextCursor: 'again' + })) + await expect(cycle.connect()).rejects.toThrow('cyclic or oversized') + await cycle.edge.closed + }) + + it('returns an explicit initialization error when runtime negotiation fails', async () => { + const f = await fixture({ + open: async () => { + throw new McpError(-32001, 'MCP_RUNTIME_RESTART_REQUIRED: incompatible control protocol.', { + supported: [1] + }) + } + }) + await expect(f.connect()).rejects.toMatchObject({ + code: -32001, + data: { supported: [1] } + }) + expect(f.list).not.toHaveBeenCalled() + await f.edge.closed + }) + + it('closes a runtime that finishes opening after the host has already left', async () => { + let opened: (transport: Transport) => void + const f = await fixture({ open: () => new Promise((resolve) => (opened = resolve)) }) + const connecting = f.connect().catch(() => {}) + await vi.waitFor(() => expect(opened).toBeDefined()) + await f.clientTransport.close() + const close = vi.spyOn(f.edgeTransport, 'close') + opened!(f.edgeTransport) + await connecting + await vi.waitFor(() => expect(close).toHaveBeenCalled()) + await f.edge.closed + }) + + it('rejects pre-initialize calls without opening a runtime', async () => { + const f = await fixture() + const messages: JSONRPCMessage[] = [] + f.clientTransport.onmessage = (message) => messages.push(message) + await f.clientTransport.send({ jsonrpc: '2.0', id: 0, method: 'tools/list' }) + expect(messages).toEqual([ + { + jsonrpc: '2.0', + id: 0, + error: { code: ErrorCode.InvalidRequest, message: 'Initialize the MCP session first.' } + } + ]) + expect(f.open).not.toHaveBeenCalled() + }) + + it.each(['cancel', 'overflow'] as const)( + 'releases pending initialization on %s', + async (reason) => { + let signal: AbortSignal | undefined + const f = await fixture({ + open: (_request, lifetime) => { + signal = lifetime + return new Promise((_resolve, reject) => + lifetime.addEventListener('abort', () => reject(new Error('closed')), { once: true }) + ) + } + }) + await f.clientTransport.send({ + jsonrpc: '2.0', + id: 'initializing', + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + clientInfo: { name: 'fixture', version: '1' }, + capabilities: {} + } + }) + expect(signal?.aborted).toBe(false) + if (reason === 'cancel') + await f.clientTransport.send({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'initializing' } + }) + else + for (let i = 0; i < 65; i++) + await f.clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }) + const failure = await f.edge.closed + expect(signal?.aborted).toBe(true) + expect(failure?.message).toBe( + reason === 'overflow' + ? 'Host initialization buffer exceeded the MCP buffer limit.' + : undefined + ) + expect(f.list).not.toHaveBeenCalled() + } + ) +}) diff --git a/packages/mcp-server/tests/startup.integration.test.ts b/packages/mcp-server/tests/startup.integration.test.ts index 8e30e1fc..daa91404 100644 --- a/packages/mcp-server/tests/startup.integration.test.ts +++ b/packages/mcp-server/tests/startup.integration.test.ts @@ -33,9 +33,16 @@ beforeAll(async () => { const config = join(bundle, 'config.mjs') const hub = join(bundle, 'hub-entry.mts') const assets = join(bundle, 'asset-listener.mjs') + // Normal shutdown tests must not race a cold logging worker against the real watchdog. writeFileSync( hub, - `import {startHub} from ${JSON.stringify(join(packageRoot, 'src/hub-runtime.ts'))}; await startHub(); process.stdout.write('ready\\n');` + `import {once} from 'node:events'; import {symbols} from 'pino'; + import {startHub} from ${JSON.stringify(join(packageRoot, 'src/hub-runtime.ts'))}; + import {log} from ${JSON.stringify(join(packageRoot, 'src/shared.ts'))}; + await startHub(); + const transport = log[symbols.streamSym]; + if (!transport.ready) await once(transport, 'ready'); + process.stdout.write('ready\\n');` ) writeFileSync( config, @@ -59,6 +66,7 @@ beforeAll(async () => { format: ['esm'], platform: 'node', target: 'node22', + inputOptions: { resolve: { conditionNames: ['source'] } }, dts: false, logLevel: 'silent', plugins: [ @@ -86,10 +94,16 @@ afterAll(async () => { rmSync(bundle, { recursive: true, force: true }) }) -function launch(entry: string, runtime: string, preload?: string, stores = directory) { +function launch( + entry: string, + runtime: string, + preload?: string, + stores = directory, + args: string[] = [] +) { const child = spawn( process.execPath, - [...(preload ? ['--import', preload] : []), join(bundle, entry)], + [...(preload ? ['--import', preload] : []), join(bundle, entry), ...args], { env: { ...process.env, @@ -115,6 +129,47 @@ function launch(entry: string, runtime: string, preload?: string, stores = direc return { child, exited, stderr: () => stderr, stdout: () => stdout } } +it('recovers quarantined task storage through the CLI without spawning a Hub', async () => { + const runtime = join(directory, 'recovery-runtime') + const stores = join(directory, 'recovery-stores') + const state = join(stores, 'state') + mkdirSync(state, { recursive: true }) + const path = join(state, 'design-tasks.json') + writeFileSync(path, '{unreadable Stop history') + const preload = join(bundle, 'recovery-no-spawn.mjs') + writeFileSync( + preload, + `import childProcess from 'node:child_process'; import {syncBuiltinESMExports} from 'node:module'; + childProcess.spawn = () => { throw new Error('Recovery must not spawn a Hub'); }; syncBuiltinESMExports();` + ) + const recovering = launch('cli.mjs', runtime, preload, stores, ['--recover-task-storage']) + expect(await recovering.exited).toEqual([0, null]) + expect(recovering.stderr()).toBe('') + expect(recovering.stdout()).toContain(`Task storage: ${path}\n`) + expect(recovering.stdout()).toContain('Old tasks remain retired') + expect(existsSync(join(runtime, 'mcp.sock'))).toBe(false) + const saved = readFileSync(path, 'utf8') + expect(JSON.parse(saved)).toMatchObject({ + revision: 0, + current: {}, + records: [{ recoveryGeneration: expect.any(String) }] + }) + expect(readFileSync(`${path}.corrupt`, 'utf8')).toBe('{unreadable Stop history') + const repeated = launch('cli.mjs', runtime, preload, stores, ['--recover-task-storage']) + expect(await repeated.exited).toEqual([1, null]) + expect(repeated.stdout()).toContain(`Task storage: ${path}\n`) + expect(repeated.stderr()).toContain('readable') + expect(readFileSync(path, 'utf8')).toBe(saved) + const extraArgument = launch('cli.mjs', runtime, preload, stores, [ + '--recover-task-storage', + '--force' + ]) + expect(await extraArgument.exited).toEqual([1, null]) + expect(extraArgument.stdout()).toContain(`Task storage: ${path}\n`) + expect(extraArgument.stderr()).toContain('without other arguments') + expect(readFileSync(path, 'utf8')).toBe(saved) +}) + async function pingHub(socketPath: string): Promise { const socket = connect(socketPath) try { @@ -225,6 +280,54 @@ it.skipIf(process.platform === 'win32')( 10000 ) +it.skipIf(process.platform === 'win32').each(['success', 'throw', 'error'] as const)( + 'creates a private Hub socket and restores the process mask on %s', + async (outcome) => { + const runtime = join(directory, `bind-${outcome}`) + const socketPath = join(runtime, 'mcp.sock') + const probe = join(directory, `bind-${outcome}.jsonl`) + const preload = join(bundle, `bind-${outcome}.mjs`) + writeFileSync( + preload, + `import {Server} from 'node:net'; import {appendFileSync, lstatSync} from 'node:fs'; + process.umask(0o022); + const record = value => appendFileSync(${JSON.stringify(probe)}, JSON.stringify(value) + '\\n'); + process.once('exit', () => record({restoredMask: process.umask()})); + const listen = Server.prototype.listen; + Server.prototype.listen = function(...args) { + if (args[0] !== ${JSON.stringify(socketPath)}) return listen.apply(this, args); + if (${JSON.stringify(outcome)} === 'throw') throw new Error('Fixture bind failed'); + if (${JSON.stringify(outcome)} === 'error') { + queueMicrotask(() => this.emit('error', new Error('Fixture bind failed'))); + return this; + } + const result = listen.apply(this, args); + // Observe the inode before the later listening callback can chmod it. + record({mode: lstatSync(${JSON.stringify(socketPath)}).mode & 0o777}); + return result; + };` + ) + const hub = launch('hub.mjs', runtime, preload, join(directory, `bind-stores-${outcome}`)) + if (outcome === 'success') { + await vi.waitFor(() => expect(hub.stdout(), hub.stderr()).toContain('ready'), { + timeout: 5000 + }) + await pingHub(socketPath) + } + await vi.waitFor(() => expect(hub.child.exitCode, hub.stderr()).not.toBeNull(), { + timeout: 5000 + }) + expect((await hub.exited)[0], hub.stderr()).toBe(outcome === 'success' ? 0 : 1) + const records = readFileSync(probe, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + if (outcome === 'success') expect(records[0]).toEqual({ mode: 0o600 }) + else expect(hub.stderr()).toContain('Fixture bind failed') + expect(records.at(-1)).toEqual({ restoredMask: 0o022 }) + } +) + it.skipIf(process.platform === 'win32')( 'recovers through the CLI after orphaned lifecycle and writer locks expire', async () => { @@ -251,6 +354,10 @@ it.skipIf(process.platform === 'win32')( const cli = launch('cli.mjs', runtime, undefined, stores) cli.child.stdin!.write('{"jsonrpc":"2.0","id":1,"method":"ping"}\n') try { + await vi.waitFor( + () => expect(cli.stderr()).toContain('Waiting for TemPad Hub to become available.'), + { timeout: 15000 } + ) await vi.waitFor( () => { expect(cli.child.exitCode, cli.stderr()).toBeNull() @@ -265,6 +372,7 @@ it.skipIf(process.platform === 'win32')( } expect(readFileSync(identityPath, 'utf8')).not.toBe(oldIdentity) expect(JSON.parse(cli.stdout().trim())).toEqual({ jsonrpc: '2.0', id: 1, result: {} }) + expect(cli.stderr().match(/Waiting for TemPad Hub/g)).toHaveLength(1) } finally { cli.child.stdin!.end() await cli.exited @@ -455,7 +563,21 @@ it.skipIf(process.platform === 'win32')( expect(existsSync(join(directory, 'state/.tempad-hub-writer.lock'))).toBe(false) expect(existsSync(join(directory, 'assets/.tempad-hub-writer.lock'))).toBe(false) const nextRuntime = join(directory, 'run-three') - const next = launch('hub.mjs', nextRuntime) + const delayedLogger = join(bundle, 'delayed-log-ready.mjs') + // Reproduce a worker starting later than the shutdown watchdog allows. + writeFileSync( + delayedLogger, + `import {Worker} from 'node:worker_threads'; + const emit = Worker.prototype.emit; + Worker.prototype.emit = function(event, ...args) { + if (event === 'message' && args[0]?.code === 'READY') { + setTimeout(() => emit.call(this, event, ...args), 2500); + return true; + } + return emit.call(this, event, ...args); + };` + ) + const next = launch('hub.mjs', nextRuntime, delayedLogger) await vi.waitFor(() => expect(next.stdout(), next.stderr()).toContain('ready'), { timeout: 5000 }) @@ -502,6 +624,10 @@ it.each(['denied', 'stalled', 'closed'])( `Unable to connect to TemPad Hub after ${attempts} startup attempts` ) expect(attempt.stderr().match(/fixture-connect/g)).toHaveLength(attempts) + expect(attempt.stderr().match(/Waiting for TemPad Hub/g) ?? []).toHaveLength( + failure === 'stalled' ? 1 : 0 + ) + expect(attempt.stdout()).toBe('') }, 15000 ) @@ -568,7 +694,7 @@ it.skipIf(process.platform === 'win32')( it .skipIf(process.platform === 'win32') - .each(['closed', 'invalid', 'invalid-with-stdin', 'denied-after-binding'])( + .each(['closed', 'invalid', 'invalid-with-stdin', 'invalid-log-wait', 'denied-after-binding'])( 'bounds an accepted CLI bridge that is %s', async (failure) => { const runtime = join(directory, failure === 'denied-after-binding' ? 'bridge-denied' : failure) @@ -593,6 +719,18 @@ it writeFileSync( preload, `import net from 'node:net'; import {syncBuiltinESMExports} from 'node:module'; + import {Worker} from 'node:worker_threads'; + if (${JSON.stringify(failure)} === 'invalid-log-wait') { + // Keep the transport pending readiness while its worker continues writing logs. + const emit = Worker.prototype.emit; + Worker.prototype.emit = function(event, ...args) { + if (event === 'message' && args[0]?.code === 'READY') { + process.stderr.write('fixture-log-ready-held\\n'); + return false; + } + return emit.call(this, event, ...args); + }; + } const timeout = globalThis.setTimeout, connect = net.connect; globalThis.setTimeout = (fn, ms, ...args) => timeout(fn, ms >= 1000 ? 20 : ms, ...args); let calls = 0; @@ -629,6 +767,16 @@ it expect(connections).toBe(failure === 'denied-after-binding' ? 1 : 3) expect(attempt.stdout()).toBe('') expect(existsSync(join(runtime, 'hub-runtime.json'))).toBe(false) + if (failure === 'invalid-log-wait') { + expect(attempt.stderr()).toContain('fixture-log-ready-held') + const logs = join(directory, 'logs') + const pid = attempt.child.pid + const filename = readdirSync(logs).find((name) => name.endsWith(`-${pid}.log`)) + expect(filename).toBeDefined() + const contents = readFileSync(join(logs, filename!), 'utf8') + expect(contents.match(/Invalid Hub output\. Reconnecting\./g)).toHaveLength(3) + expect(contents.match(/Connection attempt failed\. Retrying/g)).toHaveLength(2) + } } finally { // Stop the CLI before removing its listener, even when an assertion fails. if (attempt && attempt.child.exitCode === null && attempt.child.signalCode === null) { @@ -725,3 +873,53 @@ it.skipIf(process.platform === 'win32')( }, 5000 ) + +it.skipIf(process.platform === 'win32')( + 'drains complete Hub output before reconnecting when the consumer is backpressured', + async () => { + const runtime = join(directory, 'bridge-output') + mkdirSync(runtime, { mode: 0o700 }) + const messages = [1, 2, 3].map( + (id) => JSON.stringify({ jsonrpc: '2.0', id, result: { text: 'x'.repeat(100_000) } }) + '\n' + ) + const sockets = new Set() + let connections = 0 + let notifyClosed!: () => void + const firstClosed = new Promise((resolve) => { + notifyClosed = resolve + }) + const server = createServer((socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + socket.on('error', () => {}) + socket.resume() + if (++connections !== 1) return + socket.on('close', notifyClosed) + socket.end(messages.join('')) + }) + let attempt: ReturnType | undefined + try { + server.listen(join(runtime, 'mcp.sock')) + await once(server, 'listening') + chmodSync(join(runtime, 'mcp.sock'), 0o600) + attempt = launch('cli.mjs', runtime) + attempt.child.stdout!.pause() + await firstClosed + attempt.child.stdout!.resume() + await vi.waitFor(() => expect(attempt!.stdout().length).toBe(messages.join('').length), { + timeout: 3000 + }) + expect(attempt.stdout()).toBe(messages.join('')) + attempt.child.stdin!.end() + expect((await attempt.exited)[0], attempt.stderr()).toBe(0) + } finally { + if (attempt && attempt.child.exitCode === null && attempt.child.signalCode === null) { + attempt.child.kill() + await attempt.exited + } + for (const socket of sockets) socket.destroy() + if (server.listening) await new Promise((resolve) => server.close(() => resolve())) + } + }, + 10000 +) diff --git a/packages/mcp-server/tests/task-storage-recovery.test.ts b/packages/mcp-server/tests/task-storage-recovery.test.ts new file mode 100644 index 00000000..32d651c4 --- /dev/null +++ b/packages/mcp-server/tests/task-storage-recovery.test.ts @@ -0,0 +1,202 @@ +import type * as Fs from 'node:fs' + +import { join } from 'node:path' +import lockfile from 'proper-lockfile' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' + +const faults = vi.hoisted(() => ({ write: '', replace: false })) +vi.mock('node:fs', async (load) => { + const fs = await load() + return { + ...fs, + writeFileSync: (...args: Parameters) => { + const path = String(args[0]) + if ( + faults.write && + (faults.write === 'backup' + ? path.includes('design-tasks.recovery-') + : path.includes('.json.recovery-')) + ) + throw new Error('Simulated write failure') + return fs.writeFileSync(...args) + }, + renameSync: (...args: Parameters) => { + if (faults.replace && String(args[0]).includes('.json.recovery-')) + throw new Error('Simulated replacement failure') + return fs.renameSync(...args) + } + } +}) + +import { mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' + +import { DesignTaskStore } from '../src/design-task-store' +import { DesignTasks } from '../src/design-tasks' +import { recoverDesignTaskStorage } from '../src/task-storage-recovery' + +afterEach(() => { + faults.write = '' + faults.replace = false +}) + +function fixture() { + // The package setup points this parent at its disposable per-worker runtime. + const directory = mkdtempSync(join(process.env.TEMPAD_MCP_RUNTIME_DIR!, 'recovery-')) + const path = join(directory, 'design-tasks.json') + writeFileSync(path, '{unknown Stop history') + writeFileSync(`${path}.corrupt`, '{earlier unknown Stop history') + writeFileSync(`${path}.tmp`, 'unverified older snapshot') + return { directory, path } +} + +describe('explicit offline task storage recovery', () => { + it('preserves all original bytes and atomically installs a durable generation', async () => { + const { directory, path } = fixture() + const result = await recoverDesignTaskStorage(directory) + expect(readFileSync(join(result.backupDirectory, 'design-tasks.json'), 'utf8')).toBe( + '{unknown Stop history' + ) + expect(readFileSync(join(result.backupDirectory, 'design-tasks.json.corrupt'), 'utf8')).toBe( + '{earlier unknown Stop history' + ) + expect(readFileSync(join(result.backupDirectory, 'design-tasks.json.tmp'), 'utf8')).toBe( + 'unverified older snapshot' + ) + expect(readFileSync(`${path}.corrupt`, 'utf8')).toBe('{earlier unknown Stop history') + if (process.platform !== 'win32') { + expect(statSync(result.backupDirectory).mode & 0o777).toBe(0o700) + expect(statSync(path).mode & 0o777).toBe(0o600) + } + const store = new DesignTaskStore(path) + const snapshot = store.load()! + expect(snapshot).toEqual({ + revision: 0, + current: {}, + records: [], + recoveryGeneration: result.generation + }) + store.save(snapshot) + expect(new DesignTaskStore(path).load()).toEqual(snapshot) + expect(JSON.parse(readFileSync(path, 'utf8')).records).toEqual([ + { recoveryGeneration: result.generation } + ]) + // Freeze the pre-recovery reader's file-isolation fallback (c1bfaf9e). + // A record with no identifiable file globally fences that reader, not just one file. + const oldFileFence = z.object({ + task: z.object({ target: z.object({ fileKey: z.string().min(1) }) }) + }) + expect(oldFileFence.safeParse(JSON.parse(readFileSync(path, 'utf8')).records[0]).success).toBe( + false + ) + + const tasks = new DesignTasks({ store, createId: () => 'fresh-task', now: () => 1000 }) + tasks.restore() + const target = { + fileKey: 'unknown', + fileName: 'Unknown', + sessionId: 'session', + pageId: 'page', + busy: false + } + expect(() => tasks.checkOperation('owner', target, undefined, true)).toThrow('new task') + expect(() => tasks.begin('owner', 'extension', target, 'Fresh', 'request')).toThrow( + result.generation + ) + const record = tasks.begin( + 'owner', + 'extension', + target, + 'Fresh', + 'request', + undefined, + undefined, + undefined, + result.generation + ) + tasks.stop(record.task.taskId, 'cancelled') + const restarted = new DesignTasks({ + store: new DesignTaskStore(path), + createId: () => 'unused', + now: () => 1000 + }) + restarted.restore() + expect(restarted.find(record.task.taskId)?.task.status).toBe('cancelled') + expect(() => + restarted.checkOperation('owner', { ...target, fileKey: 'other-unknown' }, undefined, true) + ).toThrow('new task') + await expect(recoverDesignTaskStorage(directory)).rejects.toThrow('readable') + expect( + readdirSync(directory).filter((name) => name.startsWith('design-tasks.recovery-')) + ).toHaveLength(1) + }) + + it('cannot run while another Hub or recovery owns the state writer lock', async () => { + const { directory, path } = fixture() + const release = await lockfile.lock(directory, { + lockfilePath: join(directory, '.tempad-hub-writer.lock'), + retries: 0 + }) + try { + await expect(recoverDesignTaskStorage(directory)).rejects.toMatchObject({ code: 'ELOCKED' }) + expect(readFileSync(path, 'utf8')).toBe('{unknown Stop history') + expect(readdirSync(directory).some((name) => name.startsWith('design-tasks.recovery-'))).toBe( + false + ) + } finally { + await release() + } + await expect(recoverDesignTaskStorage(directory)).resolves.toHaveProperty('generation') + }) + + it.each(['backup', 'temporary', 'replace'] as const)( + 'keeps the recovery fence on %s failure', + async (stage) => { + const { directory, path } = fixture() + if (stage === 'replace') faults.replace = true + else faults.write = stage + await expect(recoverDesignTaskStorage(directory)).rejects.toThrow('Simulated') + expect(readFileSync(path, 'utf8')).toBe('{unknown Stop history') + expect(readFileSync(`${path}.corrupt`, 'utf8')).toBe('{earlier unknown Stop history') + const failed = new DesignTaskStore(path) + expect(failed.load()).toBeUndefined() + expect(failed.recoveryError).toContain('Stop fences') + faults.write = '' + faults.replace = false + await expect(recoverDesignTaskStorage(directory)).resolves.toHaveProperty('generation') + } + ) + + it('retires the preceding generation when a recovered snapshot is damaged again', async () => { + const { directory, path } = fixture() + const first = await recoverDesignTaskStorage(directory) + writeFileSync(path, '{damaged new generation') + const second = await recoverDesignTaskStorage(directory) + expect(second.generation).not.toBe(first.generation) + expect(readFileSync(join(first.backupDirectory, 'design-tasks.json'), 'utf8')).toBe( + '{unknown Stop history' + ) + expect(readFileSync(join(second.backupDirectory, 'design-tasks.json'), 'utf8')).toBe( + '{damaged new generation' + ) + expect(new DesignTaskStore(path).load()?.recoveryGeneration).toBe(second.generation) + }) + + it.each([ + { + records: [{ recoveryGeneration: 'not-a-uuid', task: { target: { fileKey: 'misleading' } } }] + }, + { + records: [ + { recoveryGeneration: '00000000-0000-4000-8000-000000000001' }, + { recoveryGeneration: '00000000-0000-4000-8000-000000000002' } + ] + } + ])('fails closed for malformed or ambiguous recovery markers', ({ records }) => { + const { path } = fixture() + writeFileSync(path, JSON.stringify({ revision: 0, current: {}, records })) + const store = new DesignTaskStore(path) + expect(store.load()).toBeUndefined() + expect(store.recoveryError).toContain('Stop fences') + }) +}) diff --git a/packages/mcp-server/tests/terminal-receipts.test.ts b/packages/mcp-server/tests/terminal-receipts.test.ts new file mode 100644 index 00000000..a96866d4 --- /dev/null +++ b/packages/mcp-server/tests/terminal-receipts.test.ts @@ -0,0 +1,152 @@ +import type { FigmaSession, ToolCompletionMessage } from '@tempad-dev/shared' +import type { WebSocket } from 'ws' + +import { describe, expect, it } from 'vitest' + +import type { ExtensionConnection } from '../src/types' + +import { TerminalReceipts } from '../src/terminal-receipts' + +const session: FigmaSession = { + sessionId: 'page', + fileKey: 'file', + fileName: 'File', + pageId: '1:1', + busy: false, + tabId: 1, + documentId: 'document' +} +function peer(id = 'original'): ExtensionConnection { + return { + id, + ws: {} as WebSocket, + origin: 'chrome-extension://owner', + connectedAt: '', + runtime: { + protocolVersion: 15, + version: '1', + fingerprint: 'a'.repeat(64), + terminalReceipts: true + }, + sessions: { + type: 'sessions', + browserId: 'browser', + activeSessionId: 'page', + sessions: [{ ...session }] + } + } +} +function dispatched(registry: TerminalReceipts, id = 'call'): ToolCompletionMessage { + return { type: 'toolCompletion', id, ...registry.reserve(id, peer(), session, 'task', true)! } +} + +describe('terminal receipts', () => { + it('recovers only once across a same-browser reconnect and preserves the original operation identity', () => { + const registry = new TerminalReceipts() + const completion = dispatched(registry) + expect(registry.accept(completion, peer('replacement'))).toMatchObject({ + accepted: true, + recovered: { extensionId: 'original', taskId: 'task', write: true } + }) + expect(registry.accept(completion, peer('replacement'))).toEqual({ accepted: true }) + }) + + it.each([ + 'generation', + 'receiptId', + 'id', + 'origin', + 'browser', + 'document', + 'tab', + 'session', + 'file', + 'capability' + ])('does not finish the operation for a mismatched %s', (field) => { + const registry = new TerminalReceipts() + const completion = dispatched(registry) + const originalCompletion = { ...completion } + const replacement = peer('replacement') + if (field === 'generation' || field === 'receiptId' || field === 'id') + completion[field] = 'wrong' + if (field === 'origin') replacement.origin = 'chrome-extension://other' + if (field === 'browser') replacement.sessions!.browserId = 'other' + if (field === 'document') replacement.sessions!.sessions[0]!.documentId = 'new-document' + if (field === 'tab') replacement.sessions!.sessions[0]!.tabId = 2 + if (field === 'session') replacement.sessions!.sessions[0]!.sessionId = 'other' + if (field === 'file') replacement.sessions!.sessions[0]!.fileKey = 'other' + if (field === 'capability') delete replacement.runtime!.terminalReceipts + expect(registry.accept(completion, replacement)).toEqual({ accepted: false }) + // Rejection must not consume the original authority. + expect(registry.accept(originalCompletion, peer())).toMatchObject({ + accepted: true, + recovered: { extensionId: 'original' } + }) + }) + + it('normal results settle before the completion arrives, and wrong connections cannot settle', () => { + const registry = new TerminalReceipts() + const completion = dispatched(registry) + registry.settle('call', 'other') + registry.settle('call', 'original') + expect(registry.accept(completion, peer())).toEqual({ accepted: true }) + }) + + it('retires disconnected reads without losing an unresolved write receipt', () => { + const registry = new TerminalReceipts() + const write = dispatched(registry) + registry.settle(write.id, 'original', false) + for (let index = 0; index < 256; index++) { + const id = `read-${index}` + registry.reserve(id, peer(), session, undefined, false) + registry.settle(id, 'original', false) + } + expect(registry.accept(write, peer('replacement'))).toMatchObject({ + accepted: true, + recovered: { write: true, extensionId: 'original' } + }) + }) + + it('bounds pending calls without eviction, retires replaced documents, and bounds completed tombstones', () => { + const registry = new TerminalReceipts() + const first = dispatched(registry, '0') + for (let index = 1; index < 128; index++) dispatched(registry, String(index)) + expect(() => dispatched(registry, 'overflow')).toThrow(/Too many calls/) + registry.reconcile(peer()) // An idle snapshot is not completion. + expect(() => dispatched(registry, 'overflow')).toThrow(/Too many calls/) + const otherBrowser = peer() + otherBrowser.sessions!.browserId = 'other' + otherBrowser.sessions!.openTabIds = [] + registry.reconcile(otherBrowser) + expect(() => dispatched(registry, 'overflow')).toThrow(/Too many calls/) + const replacement = peer() + replacement.sessions!.sessions[0]!.documentId = 'replacement' + registry.reconcile(replacement) + expect(registry.accept(first, peer())).toEqual({ accepted: false }) + let oldest: ToolCompletionMessage | undefined + let newest: ToolCompletionMessage | undefined + for (let index = 0; index < 129; index++) { + newest = dispatched(registry, String(index)) + oldest ??= newest + registry.settle(String(index), 'original') + } + expect(registry.accept(oldest!, peer())).toEqual({ accepted: false }) + expect(registry.accept(newest!, peer())).toEqual({ accepted: true }) + }) + + it('keeps old peers and sessions without browser document identity on the existing path', () => { + const registry = new TerminalReceipts() + const old = peer() + delete old.runtime!.terminalReceipts + expect(registry.reserve('old', old, session, undefined, false)).toBeUndefined() + expect( + registry.reserve( + 'unknown-document', + peer(), + { ...session, documentId: undefined }, + undefined, + false + ) + ).toBeUndefined() + }) +}) diff --git a/packages/mcp-server/tests/tools-client.test.ts b/packages/mcp-server/tests/tools-client.test.ts new file mode 100644 index 00000000..ca8ea300 --- /dev/null +++ b/packages/mcp-server/tests/tools-client.test.ts @@ -0,0 +1,128 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { expect, it } from 'vitest' + +import { + TOOL_DEFS, + createApplyCanvasToolResponse, + createDesignTaskToolResponse, + createToolErrorResponse +} from '../src/tools' + +it('preserves canvas identities through SDK input parsing and formatted results', async () => { + const definition = TOOL_DEFS.find((tool) => tool.name === 'apply_canvas')! + const server = new McpServer({ name: 'canvas-fixture', version: '1' }) + const client = new Client({ name: 'sdk-fixture', version: '1' }) + const native = Object.fromEntries([ + ['__proto__', { figma: { locked: true } }], + ['constructor', { figma: { locked: false } }] + ]) + const received: unknown[] = [] + server.registerTool(definition.name, { inputSchema: definition.parameters }, (args) => { + // The Hub parses once more after the SDK validates tool arguments. + const parsed = definition.parameters.parse(args) + received.push(parsed.native) + return createApplyCanvasToolResponse({ + rootNodeId: '1:1', + nodeIdsByKey: Object.fromEntries(Object.keys(parsed.native ?? {}).map((key) => [key, '1:1'])), + createdNodeIds: ['1:1'], + updatedNodeIds: [], + removedNodeIds: [], + mutationCount: 1, + verification: { status: 'passed', nodesChecked: 1, referencesChecked: 0, warnings: [] } + }) + }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + const { tools } = await client.listTools() + expect(tools[0]?.inputSchema.properties?.native).toMatchObject({ + type: 'object', + propertyNames: { minLength: 1, maxLength: 128, pattern: '^[\\w./:-]+$' } + }) + const args = { + mode: 'create', + markup: '
', + native + } + const result = await client.callTool({ name: definition.name, arguments: args }) + expect(result.isError).not.toBe(true) + expect(received).toEqual([native]) + expect(result.structuredContent).toMatchObject({ + nodeIdsByKey: Object.fromEntries([ + ['__proto__', '1:1'], + ['constructor', '1:1'] + ]) + }) + + const rejected = await client.callTool({ + name: definition.name, + arguments: { ...args, native: Object.fromEntries([['__proto__', { component: { id: 1 } }]]) } + }) + expect(rejected.isError).toBe(true) + expect(received).toHaveLength(1) + } finally { + await client.close() + await server.close() + } +}) + +it('returns recovery guidance through an SDK client without changing successful task output', async () => { + const recoveryGeneration = '00000000-0000-4000-8000-000000000001' + const definition = TOOL_DEFS.find((tool) => tool.name === 'manage_design_task')! + const server = new McpServer({ name: 'recovery-fixture', version: '1' }) + const client = new Client({ name: 'sdk-fixture', version: '1' }) + const task = { + taskId: 'task-a', + title: 'Recovered task', + status: 'active' as const, + operation: null, + target: { sessionId: 'tab-a', fileKey: 'file-a', fileName: 'Design', pageId: 'page-a' }, + expiresAt: 1000, + revision: 1, + needsRead: true + } + server.registerTool( + definition.name, + { inputSchema: definition.parameters, outputSchema: definition.outputSchema }, + (args) => + args.recoveryGeneration === recoveryGeneration + ? createDesignTaskToolResponse(task) + : createToolErrorResponse( + definition.name, + Object.assign( + new Error(`Begin a fresh task with recoveryGeneration "${recoveryGeneration}".`), + { code: 'DESIGN_TASK_INACTIVE', recoveryGeneration } + ) + ) + ) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + await client.listTools() + const args = { + action: 'begin', + title: task.title, + requestId: '00000000-0000-4000-8000-000000000002' + } + const rejected = await client.callTool({ name: definition.name, arguments: args }) + expect(rejected).toMatchObject({ + isError: true, + _meta: { code: 'DESIGN_TASK_INACTIVE', recoveryGeneration }, + content: [{ type: 'text', text: expect.stringContaining(recoveryGeneration) }] + }) + expect(rejected.structuredContent).toBeUndefined() + + const accepted = await client.callTool({ + name: definition.name, + arguments: { ...args, recoveryGeneration } + }) + expect(accepted.isError).not.toBe(true) + expect(accepted.structuredContent).toEqual(task) + expect(definition.outputSchema?.parse(accepted.structuredContent)).toEqual(task) + } finally { + await client.close() + await server.close() + } +}) diff --git a/packages/mcp-server/tests/tools.test.ts b/packages/mcp-server/tests/tools.test.ts index 3388e1ca..0d10ee80 100644 --- a/packages/mcp-server/tests/tools.test.ts +++ b/packages/mcp-server/tests/tools.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest' import { TOOL_DEFS, - coercePayloadToToolResponse, createApplyCanvasToolResponse, createCodeToolResponse, createDesignSystemToolResponse, @@ -164,12 +163,34 @@ describe('tools response helpers', () => { expect(getStructure?.description).toContain('exact page id/key') expect(getStructure?.description).toContain('page-query roots are page-relative') expect(designSystem?.parameters.parse({})).toEqual({}) - expect( - applyCanvas?.parameters.parse({ - mode: 'create', - markup: '
' - }) - ).not.toHaveProperty('catalogId') + const input = { + mode: 'create', + markup: '
', + native: { + root: { + figma: { + effects: [ + { + type: 'NOISE', + noiseType: 'MONOTONE', + color: { r: 0, g: 0, b: 0, a: 1 }, + noiseSize: 2, + noiseSizeVector: { x: 2, y: 3 }, + density: 0.5 + }, + { + type: 'TEXTURE', + noiseSize: 1, + noiseSizeVector: { x: 1, y: 2 }, + radius: 4, + clipToShape: true + } + ] + } + } + } + } + expect(applyCanvas?.parameters.parse(input)).toEqual(input) }) it('declares read and write behavior for every tool', () => { @@ -436,37 +457,22 @@ describe('tools response helpers', () => { expect(result.content).toHaveLength(2) }) - it('formats inline budget errors with retry guidance', () => { + it('formats inline budget errors with recovery guidance', () => { const result = createInlineBudgetExceededToolResponse('get_token_defs', 70000) expect(result.isError).toBe(true) expect(textContent(result.content[0])).toContain('64 KiB inline budget') expect(textContent(result.content[0])).toContain('split them into smaller batches') - expect( - textContent(createInlineBudgetExceededToolResponse('apply_canvas', 70000).content[0]) - ).toContain('smaller desired subtree') + const writeError = textContent( + createInlineBudgetExceededToolResponse('apply_canvas', 70000).content[0] + ) + expect(writeError).toContain('Read the original target') + expect(writeError).toContain('do not replay the write') expect( textContent(createInlineBudgetExceededToolResponse('get_design_system', 70000).content[0]) ).toContain('catalog cursor') }) - it('coerces payloads to MCP CallToolResult', () => { - const passthrough = { content: [{ type: 'text' as const, text: 'ok' }] } - expect(coercePayloadToToolResponse(passthrough)).toBe(passthrough) - - expect(coercePayloadToToolResponse('plain')).toEqual({ - content: [{ type: 'text', text: 'plain' }] - }) - - expect(coercePayloadToToolResponse({ a: 1 })).toEqual({ - content: [{ type: 'text', text: '{\n "a": 1\n}' }] - }) - - expect(coercePayloadToToolResponse(undefined)).toEqual({ - content: [{ type: 'text', text: undefined }] - }) - }) - it('formats tool errors with troubleshooting hints', () => { const connectivityError = createToolErrorResponse('get_code', { code: TEMPAD_MCP_ERROR_CODES.NO_ACTIVE_EXTENSION, @@ -523,11 +529,26 @@ describe('tools response helpers', () => { message: 'Verification failed for "root": direct effect 0 does not match.' }) expect(textContent(verificationError.content[0])).toContain( - 'TemPad rolls verification failures back' + 'TemPad attempts to roll verification failures back' ) + expect(textContent(verificationError.content[0])).toContain('preserves unrelated design intent') expect(textContent(verificationError.content[0])).toContain( - 'preserving unrelated design intent' + 'Do not repeat the failed write unchanged' ) + expect(textContent(verificationError.content[0])).not.toContain('before retrying') + for (const code of [ + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_ABORTED, + TEMPAD_MCP_ERROR_CODES.CANVAS_APPLY_FAILED + ]) { + expect( + textContent( + createToolErrorResponse('apply_canvas', { + code, + message: 'Verification failed: geometry does not match.' + }).content[0] + ) + ).toContain('Check the reported rollback outcome') + } const unknownError = createToolErrorResponse('get_assets', 42) expect(unknownError.isError).toBe(true) @@ -570,6 +591,32 @@ describe('tools response helpers', () => { expect(textContent(stringError.content[0])).toContain('Troubleshooting:') }) + it('exposes only valid recovery generation metadata on task recovery errors', () => { + const recoveryGeneration = '00000000-0000-4000-8000-000000000001' + const error = { + code: TEMPAD_MCP_ERROR_CODES.DESIGN_TASK_INACTIVE, + message: 'Task storage was recovered.', + recoveryGeneration + } + expect(createToolErrorResponse('manage_design_task', error)).toMatchObject({ + isError: true, + _meta: error + }) + expect(createToolErrorResponse('manage_design_task', error).structuredContent).toBeUndefined() + for (const value of [undefined, 'not-a-uuid', 42]) { + expect( + createToolErrorResponse('manage_design_task', { ...error, recoveryGeneration: value })._meta + ).toBeUndefined() + } + expect(createToolErrorResponse('get_code', error)._meta).toBeUndefined() + expect( + createToolErrorResponse('manage_design_task', { + ...error, + code: TEMPAD_MCP_ERROR_CODES.DESIGN_TASK_BUSY + })._meta + ).toBeUndefined() + }) + it('throws when payload shape is invalid', () => { expect(() => createCodeToolResponse({} as ToolResultMap['get_code'])).toThrow( /Invalid get_code payload/ diff --git a/packages/mcp-server/tsdown.config.ts b/packages/mcp-server/tsdown.config.ts index a95afaa9..1251ca9a 100644 --- a/packages/mcp-server/tsdown.config.ts +++ b/packages/mcp-server/tsdown.config.ts @@ -2,8 +2,16 @@ import { defineConfig } from 'tsdown' import raw from 'unplugin-raw/rolldown' export default defineConfig({ - entry: ['src/cli.ts', 'src/hub.ts', 'src/log-transport.ts'], + entry: [ + 'src/cli.ts', + 'src/hub.ts', + 'src/log-transport.ts', + 'src/managed-runtime.ts', + 'src/runtime-verification.ts', + 'src/runtime-installer.ts' + ], plugins: [raw()], + inputOptions: { resolve: { conditionNames: ['source'] } }, format: ['esm'], platform: 'node', target: 'node22', diff --git a/packages/mcp-server/tsdown.prototype.config.ts b/packages/mcp-server/tsdown.prototype.config.ts new file mode 100644 index 00000000..158ac60a --- /dev/null +++ b/packages/mcp-server/tsdown.prototype.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsdown' + +import config from './tsdown.config.ts' + +// Keep the local-only diagnostic entry separate from the verified release payload. +export default defineConfig({ + ...config, + entry: ['src/runtime-prototype.ts', 'src/log-transport.ts'], + outDir: 'dist/prototype' +}) diff --git a/packages/mcp-server/vitest.config.ts b/packages/mcp-server/vitest.config.ts index a67c246c..350bee63 100644 --- a/packages/mcp-server/vitest.config.ts +++ b/packages/mcp-server/vitest.config.ts @@ -6,7 +6,7 @@ import { coverageThresholds, MCP_CORE_COVERAGE_FILES, MCP_COVERAGE_FILES -} from '../../vitest.coverage' +} from '../../vitest.coverage.ts' const agentRuntimeFiles = AGENT_RUNTIME_FILES.map((file) => `../../${file}`) diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md index 8946c010..c57a12be 100644 --- a/packages/plugins/CHANGELOG.md +++ b/packages/plugins/CHANGELOG.md @@ -1,10 +1,13 @@ # Changelog -## 0.6.3 - -- Fixed `h()` treating props with a `name` field as a child component. Missing or null props - now become an empty object. -- Clarified predicate examples for `queryAll` and `queryOne`. +## 1.0.0 + +- Fixed `findAll()` failing on large subtrees and avoided repeatedly copying accumulated results. +- Fixed `h()` misreading props with a `name` field or discarding explicit children when props + resemble a component. Missing or null props now become an empty object. +- Made queries with global or sticky regular expressions consistent across nodes and repeated calls, + without changing the caller's regular-expression state. +- Corrected predicate examples for `queryAll` and `queryOne`. ## 0.6.2 diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 1d65f7ad..03f36629 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -142,8 +142,9 @@ export default definePlugin({ title: 'Component', lang: 'tsx', transformComponent({ component }) { + const title = component.properties.title return h('Card', { variant: component.properties.variant }, [ - h('Heading', { level: 2 }, [component.properties.title]), + h('Heading', { level: 2 }, typeof title === 'string' ? title : ''), h('Button', { intent: 'primary' }, ['Submit']) ]) } @@ -154,6 +155,16 @@ export default definePlugin({ TemPad Dev will serialize the returned `DevComponent` into JSX/TSX for display. +The three-argument form always treats the second argument as props. Use it when your props +also contain `name`, `props`, and `children` fields; the two-argument form recognizes that +shape as a child component. Pass `[]` as the third argument when there are no children. + +For MCP exports, token collection and rewriting apply to `style`, `class`/`className`, and +SVG presentation attributes. Text children and other props remain literal: a custom +`color: 'var(--brand, #fff)'` prop retains its fallback and name and does not add a token entry. +Use `style: { color: 'var(--brand, #fff)' }` when the value represents CSS. Returned strings +and `raw()` content opt into code-level token processing instead. + ### Navigating design nodes Plugins often need to search through the inspected node tree. The following helpers are exported: diff --git a/packages/plugins/README.zh-Hans.md b/packages/plugins/README.zh-Hans.md index d29aef36..922c4a86 100644 --- a/packages/plugins/README.zh-Hans.md +++ b/packages/plugins/README.zh-Hans.md @@ -136,8 +136,9 @@ export default definePlugin({ title: 'Component', lang: 'tsx', transformComponent({ component }) { + const title = component.properties.title return h('Card', { variant: component.properties.variant }, [ - h('Heading', { level: 2 }, [component.properties.title]), + h('Heading', { level: 2 }, typeof title === 'string' ? title : ''), h('Button', { intent: 'primary' }, ['Submit']) ]) } @@ -148,6 +149,15 @@ export default definePlugin({ TemPad Dev 会将返回的 `DevComponent` 序列化为 JSX/TSX 并展示出来。 +三个参数的形式始终将第二个参数作为 props。如果你的 props 也包含 `name`、`props` 和 +`children` 字段,请使用这一形式;两个参数的形式会将这种结构识别为子组件。没有子节点时, +第三个参数传入 `[]` 即可。 + +MCP 导出只对 `style`、`class`/`className` 和 SVG 表现属性收集、改写 token。 +文本子节点和其他 prop 保持字面值:例如自定义 `color: 'var(--brand, #fff)'` prop +会保留 fallback 和变量名,也不会添加 token 条目。如果值表示 CSS,请使用 +`style: { color: 'var(--brand, #fff)' }`。返回字符串或 `raw()` 内容则会启用代码级 token 处理。 + ### 遍历设计节点 插件经常需要在被检查的节点树中查找内容。我们导出了以下辅助函数: diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 7e589d9c..e81f4a9b 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@tempad-dev/plugins", - "version": "0.6.3", + "version": "1.0.0", "description": "Plugin utils for TemPad Dev.", "license": "MIT", "repository": { diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 5f7c3bb2..597b8ea0 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -403,7 +403,8 @@ export function h>( propsOrChildren?: T | (DevComponent | string)[] | DevComponent | string | null, childrenOrSingle?: (DevComponent | string)[] | DevComponent | string ): DevComponent { - const childrenOnly = propsOrChildren == null || isChildrenArgument(propsOrChildren) + const childrenOnly = + propsOrChildren == null || (arguments.length < 3 && isChildrenArgument(propsOrChildren)) const props = childrenOnly ? ({} as T) : (propsOrChildren as T) const childSource = childrenOnly ? (propsOrChildren ?? childrenOrSingle) : childrenOrSingle @@ -454,7 +455,8 @@ function matchProperty( } if (condition instanceof RegExp) { - return condition.test(value) + // Queries match independently of earlier nodes and the caller's lastIndex. + return new RegExp(condition.source, condition.flags).test(value) } } @@ -563,14 +565,19 @@ export function findAll( query: NodeQuery ): T[] { const result: DesignNode[] = [] - for (const child of node.children) { - if (matchNode(child, query)) { - result.push(child) - } - if ('children' in child) { - result.push(...findAll(child, query)) + + function visit(container: ContainerNode): void { + for (const child of container.children) { + if (matchNode(child, query)) { + result.push(child) + } + if ('children' in child) { + visit(child) + } } } + + visit(node) return result as T[] } diff --git a/packages/plugins/tests/index.test.ts b/packages/plugins/tests/index.test.ts index e4743287..389447a8 100644 --- a/packages/plugins/tests/index.test.ts +++ b/packages/plugins/tests/index.test.ts @@ -117,6 +117,19 @@ describe('plugins sdk helpers', () => { expect(h('Box', null)).toEqual({ name: 'Box', props: {}, children: [] }) }) + it('treats the second argument as props when children are supplied explicitly', () => { + const componentProps = { name: 'nested', props: { role: 'presentation' }, children: [] } + + for (const props of [componentProps, ['value']]) { + expect(h('Widget', props, 'body')).toEqual({ name: 'Widget', props, children: ['body'] }) + expect(h('Widget', props, undefined)).toEqual({ name: 'Widget', props, children: [] }) + } + for (const props of [null, undefined]) { + expect(h('Widget', props, 'body')).toEqual({ name: 'Widget', props: {}, children: ['body'] }) + } + expect(h('Widget', componentProps).children).toEqual([componentProps]) + }) + it('finds direct children with property and predicate queries', () => { const node = frame('Root', [text('Title', 'Hello'), vector('Icon', false), vector('Icon 2')]) @@ -142,6 +155,67 @@ describe('plugins sdk helpers', () => { expect(findAll(root, (node) => node.type === 'VECTOR').map((n) => n.name)).toEqual(['Arrow']) }) + it('collects all matches from a broad nested subtree', () => { + const children = Array.from({ length: 150_000 }, (_, index) => text(`Label ${index}`, 'x')) + const root = frame('Root', [group('Labels', children)]) + const result = findAll(root, { type: 'TEXT' }) + + expect(result).toHaveLength(children.length) + expect(result[0]).toBe(children[0]) + expect(result.at(-1)).toBe(children.at(-1)) + }) + + it('visits descendants in order without pruning unmatched containers or deduplicating', () => { + const first = text('First', 'a') + const duplicate = text('Repeated', 'b') + const skipped = group('Skipped', [duplicate], false) + const nested = vector('Nested') + const inner = frame('Inner', [nested, duplicate]) + const last = text('Last', 'c') + const root = frame('Root', [first, skipped, inner, last]) + const visited: DesignNode[] = [] + + expect( + findAll(root, (node) => { + visited.push(node) + return node !== skipped + }) + ).toEqual([first, duplicate, inner, nested, duplicate, last]) + expect(visited).toEqual([first, skipped, duplicate, inner, nested, duplicate, last]) + expect(findAll(root, { type: 'TEXT' })).toEqual([first, duplicate, duplicate, last]) + + const failure = new Error('Query failed') + visited.length = 0 + expect(() => + findAll(root, (node) => { + visited.push(node) + if (node === duplicate) throw failure + return true + }) + ).toThrow(failure) + expect(visited).toEqual([first, skipped, duplicate]) + }) + + it.each(['g', 'y', 'gy'])('matches every node independently with a /%s query', (flags) => { + const titles = [text('Title', 'A'), text('Title', 'B'), text('Title', 'C')] + const direct = frame('Root', [...titles, text('Subtitle Title', 'D')]) + const deep = frame('Root', [group('Group', direct.children)]) + const name = new RegExp('Title', flags) + name.lastIndex = 2 + Object.freeze(name) + const expected = flags.includes('y') ? titles : direct.children + + for (let repeat = 0; repeat < 2; repeat++) { + expect(findChildren(direct, { name })).toEqual(expected) + expect(findChild(direct, { name })).toBe(titles[0]) + expect(findAll(deep, { name })).toEqual(expected) + expect(findOne(deep, { name })).toBe(titles[0]) + expect(queryAll(deep, [{ query: 'all', name }])).toEqual(expected) + expect(queryOne(deep, [{ query: 'one', name }])).toBe(titles[0]) + expect(name.lastIndex).toBe(2) + } + }) + it('runs chained queryAll/queryOne pipelines and deduplicates', () => { const duplicate = text('Duplicated Label', 'Same') const panel = group('Panel', [duplicate, duplicate, text('Hint', 'x', false)]) diff --git a/packages/plugins/vitest.config.ts b/packages/plugins/vitest.config.ts index 7f5c7b42..06673425 100644 --- a/packages/plugins/vitest.config.ts +++ b/packages/plugins/vitest.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vitest/config' +import { FULL_COVERAGE_THRESHOLDS } from '../../vitest.coverage' + export default defineConfig({ test: { name: 'plugins', @@ -9,13 +11,7 @@ export default defineConfig({ provider: 'istanbul', reporter: ['text', 'html'], include: ['src/index.ts'], - thresholds: { - lines: 100, - functions: 100, - branches: 100, - statements: 100, - perFile: true - } + thresholds: FULL_COVERAGE_THRESHOLDS } } }) diff --git a/packages/shared/AGENTS.md b/packages/shared/AGENTS.md index 0c55685f..27c0339b 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -98,13 +98,8 @@ const GetCodeResultSchema = z.object({ ## Testing notes - Follow repo-wide testing workflow in `TESTING.md`. -- Coverage architecture and strict pure inventory are documented in `docs/testing/architecture.md`. +- Coverage architecture is documented in [`docs/testing/architecture.md`](../../docs/testing/architecture.md). - Keep pure-function tests under `tests/**/*.test.ts`. -- Strict pure coverage in this package targets: - - `src/index.ts` - - `src/mcp/constants.ts` - - `src/mcp/errors.ts` - - `src/mcp/index.ts` - - `src/mcp/protocol.ts` - - `src/mcp/tools.ts` +- Coverage scope and thresholds come from [`vitest.coverage.ts`](../../vitest.coverage.ts) + and are applied by this package's [`vitest.config.ts`](vitest.config.ts). - Validate schema changes downstream in this order: `shared` -> `mcp-server` -> `extension`. diff --git a/packages/shared/src/mcp/canvas.ts b/packages/shared/src/mcp/canvas.ts index 46ba0fde..fd4fc5ee 100644 --- a/packages/shared/src/mcp/canvas.ts +++ b/packages/shared/src/mcp/canvas.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { MCP_HASH_PATTERN } from './constants' import { DesignTaskParameterSchema, DesignTaskEpochSchema } from './design-task' +import { stringRecord } from './string-record' export const CanvasStableKeySchema = z .string() @@ -405,7 +406,7 @@ export const CanvasFigmaPaintSchema = z.union([ .object({ type: z.literal('SHADER'), id: z.string().min(1), - properties: z.record(z.string().min(1), CanvasFigmaShaderPropertyValueSchema).optional(), + properties: stringRecord(z.string().min(1), CanvasFigmaShaderPropertyValueSchema).optional(), ...CanvasFigmaPaintFields }) .strict() @@ -457,10 +458,8 @@ function noiseSizeMatches(effect: { noiseSize: number noiseSizeVector?: { x: number; y: number } }): boolean { - return ( - effect.noiseSizeVector === undefined || - (effect.noiseSizeVector.x === effect.noiseSize && effect.noiseSizeVector.y === effect.noiseSize) - ) + // The scalar mirrors x; the native vector's y dimension is independent. + return effect.noiseSizeVector === undefined || effect.noiseSizeVector.x === effect.noiseSize } const CanvasFigmaNoiseEffectSchema = z @@ -487,7 +486,7 @@ const CanvasFigmaNoiseEffectSchema = z .strict() ]) .refine(noiseSizeMatches, { - message: 'noiseSizeVector.x and noiseSizeVector.y must equal noiseSize.', + message: 'noiseSizeVector.x must equal noiseSize.', path: ['noiseSizeVector'] }) @@ -534,7 +533,7 @@ export const CanvasFigmaEffectSchema = z.union([ }) .strict() .refine(noiseSizeMatches, { - message: 'noiseSizeVector.x and noiseSizeVector.y must equal noiseSize.', + message: 'noiseSizeVector.x must equal noiseSize.', path: ['noiseSizeVector'] }), z @@ -554,7 +553,7 @@ export const CanvasFigmaEffectSchema = z.union([ type: z.literal('SHADER'), id: z.string().min(1), visible: z.boolean().optional(), - properties: z.record(z.string().min(1), CanvasFigmaShaderPropertyValueSchema).optional() + properties: stringRecord(z.string().min(1), CanvasFigmaShaderPropertyValueSchema).optional() }) .strict() ]) @@ -608,9 +607,10 @@ export const CanvasVariableBindingsSchema = z export type CanvasVariableBindings = z.infer -export const CanvasVariableModesSchema = z - .record(z.string().min(1), z.string().min(1).nullable()) - .refine(hasFields, 'Variable mode overrides cannot be empty.') +export const CanvasVariableModesSchema = stringRecord( + z.string().min(1), + z.string().min(1).nullable() +).refine(hasFields, 'Variable mode overrides cannot be empty.') export type CanvasVariableModes = z.infer @@ -702,8 +702,7 @@ const CanvasVariableResourceSchema = z }) .optional(), codeSyntax: CanvasVariableCodeSyntaxSchema.optional(), - values: z - .record(z.string().min(1), CanvasVariableValueSchema) + values: stringRecord(z.string().min(1), CanvasVariableValueSchema) .refine(hasFields, 'Variable values cannot be empty.') .optional() }) @@ -715,8 +714,7 @@ const CanvasExtendedVariableOverrideSchema = z variable: CanvasVariableReferenceSchema.describe( 'Inherited variable to override in this extended collection.' ), - values: z - .record(z.string().min(1), CanvasVariableValueSchema.nullable()) + values: stringRecord(z.string().min(1), CanvasVariableValueSchema.nullable()) .refine(hasFields, 'Extended variable override values cannot be empty.') .describe( 'Extended-mode IDs, inherited parent-mode IDs, or parent authoring keys to override values; null removes an existing override.' @@ -732,8 +730,7 @@ const CanvasVariableCollectionResourceSchema = z extends: CanvasVariableCollectionReferenceSchema.describe( 'Existing local or published parent collection for a new extended collection.' ).optional(), - modes: z - .record(CanvasStableKeySchema, CanvasVariableModeResourceSchema.nullable()) + modes: stringRecord(CanvasStableKeySchema, CanvasVariableModeResourceSchema.nullable()) .refine(hasFields, 'Variable collection modes cannot be empty.') .optional(), overrides: z @@ -741,8 +738,7 @@ const CanvasVariableCollectionResourceSchema = z .min(1) .describe('Explicit inherited-variable overrides for an extended collection.') .optional(), - variables: z - .record(CanvasStableKeySchema, CanvasVariableResourceSchema.nullable()) + variables: stringRecord(CanvasStableKeySchema, CanvasVariableResourceSchema.nullable()) .refine(hasFields, 'Variable collection variables cannot be empty.') .optional() }) @@ -751,8 +747,10 @@ const CanvasVariableCollectionResourceSchema = z export const MAX_CANVAS_VARIABLE_RESOURCES = 100 -export const CanvasVariableCollectionsSchema = z - .record(CanvasStableKeySchema, CanvasVariableCollectionResourceSchema.nullable()) +export const CanvasVariableCollectionsSchema = stringRecord( + CanvasStableKeySchema, + CanvasVariableCollectionResourceSchema.nullable() +) .refine(hasFields, 'Variable collections cannot be empty.') .superRefine((collections, context) => { const variableKeys = new Set() @@ -1297,8 +1295,10 @@ const CanvasStyleResourceSchema = z.discriminatedUnion('type', [ export const MAX_CANVAS_STYLE_RESOURCES = 100 -export const CanvasStylesSchema = z - .record(CanvasStableKeySchema, CanvasStyleResourceSchema.nullable()) +export const CanvasStylesSchema = stringRecord( + CanvasStableKeySchema, + CanvasStyleResourceSchema.nullable() +) .refine(hasFields, 'Styles cannot be empty.') .refine((styles) => Object.keys(styles).length <= MAX_CANVAS_STYLE_RESOURCES, { message: `Styles may describe at most ${MAX_CANVAS_STYLE_RESOURCES} resources.` @@ -1414,9 +1414,10 @@ const CanvasFigmaComponentPropertyDefinitionSchema = z.discriminatedUnion('type' .strict() ]) -const CanvasFigmaComponentPropertiesSchema = z - .record(z.string().min(1).max(256), CanvasFigmaComponentPropertyDefinitionSchema.nullable()) - .refine(hasFields, 'Component property patches cannot be empty.') +const CanvasFigmaComponentPropertiesSchema = stringRecord( + z.string().min(1).max(256), + CanvasFigmaComponentPropertyDefinitionSchema.nullable() +).refine(hasFields, 'Component property patches cannot be empty.') const CanvasFigmaAuthoredComponentSchema = z .object({ @@ -1611,8 +1612,7 @@ export const CanvasBindingSchema = z component: CanvasDesignReferenceSchema.describe( 'Design-system component used by the childless div with this data-key.' ).optional(), - componentProperties: z - .record(z.string().min(1), CanvasComponentPropertyValueSchema) + componentProperties: stringRecord(z.string().min(1), CanvasComponentPropertyValueSchema) .describe( 'Exposed component values. Direct values remove a variable alias, variable references bind one, and omission preserves the property.' ) @@ -1674,11 +1674,12 @@ export const MAX_CANVAS_NODES = 160 export const MAX_CANVAS_DEPTH = 12 export const MAX_CANVAS_MARKUP_LENGTH = 200_000 -const CanvasBindingsSchema = z - .record(CanvasStableKeySchema, CanvasBindingSchema) - .refine((bindings) => Object.keys(bindings).length <= MAX_CANVAS_NODES, { +const CanvasBindingsSchema = stringRecord(CanvasStableKeySchema, CanvasBindingSchema).refine( + (bindings) => Object.keys(bindings).length <= MAX_CANVAS_NODES, + { message: `Canvas bindings may contain at most ${MAX_CANVAS_NODES} entries.` - }) + } +) const CanvasRemoveKeysSchema = z .array(CanvasStableKeySchema) @@ -1701,8 +1702,7 @@ const CanvasAssetSchema = z.union([ z.object({ type: z.literal('SVG'), assetHash: CanvasAssetHashSchema }).strict(), z.object({ type: z.literal('IMAGE'), assetHash: CanvasAssetHashSchema }).strict() ]) -export const CanvasAssetsSchema = z - .record(CanvasStableKeySchema, CanvasAssetSchema) +export const CanvasAssetsSchema = stringRecord(CanvasStableKeySchema, CanvasAssetSchema) .refine((assets) => Object.keys(assets).length <= 32, { message: 'Canvas assets may contain at most 32 entries.' }) @@ -1730,16 +1730,16 @@ const CanvasNativeBindingSchema = z .strict() .describe('Exact live component or component-set id from prior canvas work.') .optional(), - componentProperties: z - .record(z.string().min(1), CanvasComponentPropertyValueSchema) + componentProperties: stringRecord(z.string().min(1), CanvasComponentPropertyValueSchema) .describe('Values keyed by an exact Figma property name or a TemPad-authored stable key.') .optional(), - variables: z - .record(z.string(), z.object({ variableKey: CanvasStableKeySchema }).strict().nullable()) + variables: stringRecord( + z.string(), + z.object({ variableKey: CanvasStableKeySchema }).strict().nullable() + ) .describe('Local authored-variable bindings and explicit null removals.') .optional(), - variableModes: z - .record(z.string(), z.string().nullable()) + variableModes: stringRecord(z.string(), z.string().nullable()) .describe('Explicit collection modes; catalog kN/mN_M refs require catalogId.') .optional(), styles: z @@ -1753,8 +1753,7 @@ const CanvasNativeBindingSchema = z .strict() .describe('Local authored-style bindings and explicit null removals.') .optional(), - figma: z - .record(z.string(), z.unknown()) + figma: stringRecord(z.string(), z.unknown()) .describe( 'Strict desired state for the selected Figma-native capability. Use the matching progressive reference from the design-in-figma skill for its exact shape.' ) @@ -1764,24 +1763,20 @@ const CanvasNativeBindingSchema = z export const CanvasThemeSchema = z .object({ - variables: z - .record( - z.string().regex(/^--[a-zA-Z0-9_-]+$/), - z.union([ - z.object({ ref: z.string().min(1) }).strict(), - z.object({ variableKey: CanvasStableKeySchema }).strict() - ]) - ) - .optional(), - textStyles: z - .record( - z.string().regex(/^type-[a-zA-Z0-9_-]+$/), - z.union([ - z.object({ ref: z.string().min(1) }).strict(), - z.object({ styleKey: CanvasStableKeySchema }).strict() - ]) - ) - .optional() + variables: stringRecord( + z.string().regex(/^--[a-zA-Z0-9_-]+$/), + z.union([ + z.object({ ref: z.string().min(1) }).strict(), + z.object({ variableKey: CanvasStableKeySchema }).strict() + ]) + ).optional(), + textStyles: stringRecord( + z.string().regex(/^type-[a-zA-Z0-9_-]+$/), + z.union([ + z.object({ ref: z.string().min(1) }).strict(), + z.object({ styleKey: CanvasStableKeySchema }).strict() + ]) + ).optional() }) .strict() @@ -1936,33 +1931,28 @@ export const ApplyCanvasParametersSchema = z `Canvas HTML serialization of the desired managed Figma layer tree, with at most ${MAX_CANVAS_NODES} elements and ${MAX_CANVAS_DEPTH} levels. Conflicting classes are rejected, not resolved by order: replace default font sizes, colors, or line heights instead of appending overrides. When catalogId is supplied, use its component tags and bind its variable or style refs with data-var-="vN" or data-style-="sN"; use "none" to unlink.` ) .optional(), - native: z - .record(CanvasStableKeySchema, CanvasNativeBindingSchema) + native: stringRecord(CanvasStableKeySchema, CanvasNativeBindingSchema) .describe( 'Desired native Figma state and bindings for selected capabilities. With markup, every native key must match a data-key in that supplied tree, even for existing nodes. Without markup, update may bind existing stable keys inside targetNodeId. For a mixed structural/native edit, include the bound nodes in the supplied structure or use a separate native-only update.' ) .optional(), - variableCollections: z - .record(CanvasStableKeySchema, z.unknown()) + variableCollections: stringRecord(CanvasStableKeySchema, z.unknown()) .describe( 'Optional local variable collections, modes, and variables keyed by file-wide authoring identities. This does not require catalogId; use the design-in-figma variables reference for the exact shape.' ) .optional(), - styles: z - .record(CanvasStableKeySchema, z.unknown()) + styles: stringRecord(CanvasStableKeySchema, z.unknown()) .describe( 'Optional local Paint, Text, Effect, and Grid styles keyed by file-wide authoring identities. This does not require catalogId; use the design-in-figma local-styles reference for the exact shape.' ) .optional(), - assets: z - .record(CanvasStableKeySchema, z.unknown()) + assets: stringRecord(CanvasStableKeySchema, z.unknown()) .describe( 'Optional call-scoped inline SVG or content-addressed Hub assets referenced by native desired state.' ) .optional(), removeKeys: CanvasRemoveKeysSchema.optional(), - page: z - .record(z.string(), z.unknown()) + page: stringRecord(z.string(), z.unknown()) .describe( 'Optional local page identity and desired state. Use the design-in-figma document-geometry reference for the exact shape.' ) @@ -2058,7 +2048,7 @@ export const ApplyCanvasResultSchema = z .object({ rootNodeId: z.string().min(1).optional(), rootRemoved: z.literal(true).optional(), - nodeIdsByKey: z.record(z.string(), z.string().min(1)), + nodeIdsByKey: stringRecord(z.string(), z.string().min(1)), createdNodeIds: z.array(z.string().min(1)), updatedNodeIds: z.array(z.string().min(1)), removedNodeIds: z.array(z.string().min(1)), diff --git a/packages/shared/src/mcp/client-hook.ts b/packages/shared/src/mcp/client-hook.ts new file mode 100644 index 00000000..9e6a8ff0 --- /dev/null +++ b/packages/shared/src/mcp/client-hook.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' + +import { RuntimeProfileSchema } from './protocol' + +const LifecycleSchema = z.object({ + kind: z.enum(['codex', 'claude']), + sessionId: z.string().min(1).max(256), + event: z.enum([ + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'Stop', + 'Interrupt', + 'SessionEnd' + ]), + invocationId: z.uuid(), + turnId: z.string().min(1).max(256).optional(), + taskId: z.string().min(1).max(128).optional() +}) + +export const ClientHookParamsSchema = z.discriminatedUnion('version', [ + LifecycleSchema.extend({ version: z.literal(1) }).strict(), + LifecycleSchema.extend({ + version: z.literal(2), + profile: RuntimeProfileSchema, + issuedAt: z.number().int().positive() + }).strict() +]) +export const ClientHookRequestSchema = z.object({ + method: z.literal('tempad/client-hook'), + params: ClientHookParamsSchema +}) +export type ClientHook = z.infer + +/** A durable admission receipt does not claim that the lifecycle effect ran. */ +export const ClientHookReceiptSchema = z + .object({ + version: z.literal(2), + invocationId: z.uuid(), + state: z.enum(['accepted', 'applied']) + }) + .strict() +export const CLIENT_HOOK_RETENTION_MS = 24 * 60 * 60 * 1000 diff --git a/packages/shared/src/mcp/constants.ts b/packages/shared/src/mcp/constants.ts index 97010c03..fd646a71 100644 --- a/packages/shared/src/mcp/constants.ts +++ b/packages/shared/src/mcp/constants.ts @@ -1,4 +1,5 @@ export const MCP_PORT_CANDIDATES = [6220, 7431, 8127] +export const MCP_CHECKOUT_PORT_CANDIDATES = [16220, 17431, 18127] // Select versioned registration before the first frame. Extension 0.20.0 sends no // subprotocol and strictly rejects version fields; it uses the legacy read-only wire. diff --git a/packages/shared/src/mcp/design-task.ts b/packages/shared/src/mcp/design-task.ts index e7e25aae..e291a3e1 100644 --- a/packages/shared/src/mcp/design-task.ts +++ b/packages/shared/src/mcp/design-task.ts @@ -249,6 +249,13 @@ export const BeginDesignParametersSchema = z .uuid() .describe( 'Generate a fresh UUID for this task. Reuse it only to retry the same begin request.' + ), + recoveryGeneration: z + .string() + .uuid() + .optional() + .describe( + 'Required on every begin after storage recovery: use the recoveryGeneration returned in a rejected begin response. This requirement persists across sessions and restarts.' ) }) .strict() @@ -317,6 +324,7 @@ export const ManageDesignTaskParametersSchema = z title: BeginDesignParametersSchema.shape.title.optional().describe('Required for begin.'), sessionId: BeginDesignParametersSchema.shape.sessionId, requestId: BeginDesignParametersSchema.shape.requestId.optional(), + recoveryGeneration: BeginDesignParametersSchema.shape.recoveryGeneration, taskId: DesignTaskIdSchema.optional().describe('Required except for begin.'), epoch: ResumeDesignParametersSchema.shape.epoch .optional() diff --git a/packages/shared/src/mcp/errors.ts b/packages/shared/src/mcp/errors.ts index a157b354..324e85f4 100644 --- a/packages/shared/src/mcp/errors.ts +++ b/packages/shared/src/mcp/errors.ts @@ -4,6 +4,9 @@ export const TEMPAD_MCP_ERROR_CODES = { NO_ACTIVE_EXTENSION: 'NO_ACTIVE_EXTENSION', EXTENSION_TIMEOUT: 'EXTENSION_TIMEOUT', EXTENSION_DISCONNECTED: 'EXTENSION_DISCONNECTED', + // The call was not delivered to the page; no native operation could have started. + EXTENSION_DELIVERY_FAILED: 'EXTENSION_DELIVERY_FAILED', + EXTENSION_RESULT_LOST: 'EXTENSION_RESULT_LOST', EXTENSION_UPGRADE_REQUIRED: 'EXTENSION_UPGRADE_REQUIRED', RUNTIME_IDENTITY_MISMATCH: 'RUNTIME_IDENTITY_MISMATCH', INVALID_SELECTION: 'INVALID_SELECTION', @@ -18,7 +21,10 @@ export const TEMPAD_MCP_ERROR_CODES = { DESIGN_TARGET_CHANGED: 'DESIGN_TARGET_CHANGED', INVALID_CANVAS_SCOPE: 'INVALID_CANVAS_SCOPE', INVALID_CANVAS_SPEC: 'INVALID_CANVAS_SPEC', + // Execution/internal-state failure. Inspect the message and rollback outcome before retrying. CANVAS_APPLY_FAILED: 'CANVAS_APPLY_FAILED', + // Preparation failed before any canvas write started. + CANVAS_APPLY_ABORTED: 'CANVAS_APPLY_ABORTED', ASSET_SERVER_NOT_CONFIGURED: 'ASSET_SERVER_NOT_CONFIGURED', ASSET_NOT_FOUND: 'ASSET_NOT_FOUND', ASSET_HASH_MISMATCH: 'ASSET_HASH_MISMATCH', diff --git a/packages/shared/src/mcp/index.ts b/packages/shared/src/mcp/index.ts index 8e734ad8..b99eb00e 100644 --- a/packages/shared/src/mcp/index.ts +++ b/packages/shared/src/mcp/index.ts @@ -6,4 +6,6 @@ export * from './install' export * from './legacy' export * from './protocol' export * from './responses' +export * from './string-record' export * from './tools' +export * from './client-hook' diff --git a/packages/shared/src/mcp/install.ts b/packages/shared/src/mcp/install.ts index 1a97709f..28d67193 100644 --- a/packages/shared/src/mcp/install.ts +++ b/packages/shared/src/mcp/install.ts @@ -1,6 +1,6 @@ const SERVER_NAME = 'tempad-dev' const SERVER_COMMAND = 'npx' -const SERVER_ARGS = ['-y', '@tempad-dev/mcp@latest'] as const +const SERVER_ARGS = ['-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] as const const REPOSITORY = 'ecomfe/tempad-dev' const PLUGIN_INSTALL_COMMAND = `npx plugins add ${REPOSITORY}` diff --git a/packages/shared/src/mcp/protocol.ts b/packages/shared/src/mcp/protocol.ts index e02e0a94..0b25da1e 100644 --- a/packages/shared/src/mcp/protocol.ts +++ b/packages/shared/src/mcp/protocol.ts @@ -2,6 +2,7 @@ import type { ZodType } from 'zod' import { z } from 'zod' +import { MCP_CHECKOUT_PORT_CANDIDATES, MCP_PORT_CANDIDATES } from './constants' import { DesignTaskSchema, DesignToolRouteSchema, @@ -12,6 +13,21 @@ import { import { TempadMcpErrorPayloadSchema } from './errors' import { hasToolResultOutcome, TOOL_RESULT_OUTCOME_ERROR } from './tool-result' +export const RuntimeProfileSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('release') }).strict(), + z.object({ kind: z.literal('checkout'), id: z.string().regex(/^[a-f0-9]{16}$/) }).strict() +]) +export type RuntimeProfile = z.infer + +export function sameRuntimeProfile(left?: RuntimeProfile, right?: RuntimeProfile): boolean { + if (left?.kind === 'checkout') return right?.kind === 'checkout' && left.id === right.id + return right?.kind !== 'checkout' +} + +export function runtimePortCandidates(profile?: RuntimeProfile): readonly number[] { + return profile?.kind === 'checkout' ? MCP_CHECKOUT_PORT_CANDIDATES : MCP_PORT_CANDIDATES +} + // Messages from hub to extension. These envelopes stay open to unknown keys: a newer Hub reaches an // older installed extension, and rejecting an added envelope field would drop the whole message. // The extension decides compatibility from the announced versions, not from the envelope shape. @@ -21,7 +37,25 @@ export const RegisteredMessageSchema = z.object({ type: z.literal('registered'), id: z.string().min(1), protocolVersion: z.number().int().positive(), - supportedProtocolVersions: z.array(z.number().int().positive()).min(1).optional() + supportedProtocolVersions: z.array(z.number().int().positive()).min(1).optional(), + runtimeProfile: RuntimeProfileSchema.optional(), + terminalReceiptGeneration: z.string().min(1).max(128).optional(), + durableTerminalReceipts: z.literal(true).optional() +}) + +export const TerminalReceiptSchema = z + .object({ generation: z.string().min(1).max(128), receiptId: z.string().min(1).max(128) }) + .strict() + +export const ToolCompletionMessageSchema = TerminalReceiptSchema.extend({ + type: z.literal('toolCompletion'), + id: z.string().min(1).max(128) +}).strict() + +export const ToolCompletionAckMessageSchema = z.object({ + ...ToolCompletionMessageSchema.shape, + type: z.literal('toolCompletionAck'), + accepted: z.boolean() }) export const StateMessageSchema = z.object({ @@ -39,6 +73,7 @@ export const ToolCallMessageSchema = z.object({ type: z.literal('toolCall'), id: z.string().min(1), route: DesignToolRouteSchema.optional(), + terminalReceipt: TerminalReceiptSchema.optional(), payload: ToolCallPayloadSchema }) @@ -62,6 +97,7 @@ export const DesignActionMessageSchema = z .strict() export const MessageToExtensionSchema = z.discriminatedUnion('type', [ + ToolCompletionAckMessageSchema, DesignActionResultMessageSchema, RegisteredMessageSchema, StateMessageSchema, @@ -96,8 +132,11 @@ export const PingMessageSchema = z export const RuntimeHelloMessageSchema = z .object({ type: z.literal('runtimeHello'), + runtimeProfile: RuntimeProfileSchema.optional(), // Released protocol 13 peers omit this field. The Hub treats omission conservatively as 13. protocolVersion: z.number().int().positive().optional(), + terminalReceipts: z.literal(true).optional(), + durableTerminalReceipts: z.literal(true).optional(), extensionVersion: z.string().min(1), extensionRuntimeFingerprint: z.string().regex(/^[a-f0-9]{64}$/) }) @@ -118,6 +157,7 @@ export const FigmaSessionsMessageSchema = z .strict() export const MessageFromExtensionSchema = z.union([ + ToolCompletionMessageSchema, DesignActionMessageSchema, ActivateMessageSchema, ToolResultMessageSchema, @@ -134,6 +174,9 @@ export const LegacyMessageFromExtensionSchema = z.union([ ]) export type RegisteredMessage = z.infer +export type TerminalReceipt = z.infer +export type ToolCompletionMessage = z.infer +export type ToolCompletionAckMessage = z.infer export type StateMessage = z.infer export type ToolCallPayload = z.infer export type ToolCallMessage = z.infer diff --git a/packages/shared/src/mcp/string-record.ts b/packages/shared/src/mcp/string-record.ts new file mode 100644 index 00000000..9a478341 --- /dev/null +++ b/packages/shared/src/mcp/string-record.ts @@ -0,0 +1,72 @@ +import { core, z } from 'zod' + +// Zod strips __proto__ before validating record entries. Keep its parser and +// JSON Schema projection, restoring that own key without invoking its setter. +const StringRecord = core.$constructor>( + 'StringRecord', + (schema, definition) => { + z.ZodRecord.init(schema, definition) + const parse = schema._zod.parse + schema._zod.parse = (payload, context) => { + const input = payload.value + const result = parse(payload, context) + if ( + !core.util.isPlainObject(input) || + !Object.prototype.propertyIsEnumerable.call(input, '__proto__') + ) + return result + + const key = definition.keyType._zod.run({ value: '__proto__', issues: [] }, context) + if (key instanceof Promise) + throw new Error('Async schemas not supported in object keys currently') + if (key.issues.length) { + payload.issues.push({ + code: 'invalid_key', + origin: 'record', + issues: key.issues.map((issue) => core.util.finalizeIssue(issue, context, core.config())), + input: '__proto__', + path: ['__proto__'], + inst: schema + }) + return result + } + + const value = definition.valueType._zod.run({ value: input.__proto__, issues: [] }, context) + const restore = (parsed: core.ParsePayload) => { + payload.issues.push(...core.util.prefixIssues('__proto__', parsed.issues)) + Object.defineProperty(payload.value, '__proto__', { + value: parsed.value, + enumerable: true, + configurable: true, + writable: true + }) + // Declaration order chooses defaults such as the first variable mode. + // Move following entries behind the restored key without replacing the + // object retained by Zod's recursive-parser memoization. + const keys = Object.keys(input) + for (const key of keys.slice(keys.indexOf('__proto__') + 1)) { + const property = Object.getOwnPropertyDescriptor(payload.value, key) + if (!property) continue + delete payload.value[key] + Object.defineProperty(payload.value, key, property) + } + return payload + } + return result instanceof Promise || value instanceof Promise + ? Promise.all([result, value]).then(([, parsed]) => restore(parsed)) + : restore(value) + } + } +) + +/** Internal dictionary schema for unmodified string keys (length/pattern checks only). + * Keep key normalization and enumerated-key semantics out of this helper. */ +export function stringRecord( + keyType: Key, + valueType: Value +): z.ZodRecord { + return new StringRecord({ type: 'record', keyType, valueType }) as unknown as z.ZodRecord< + Key, + Value + > +} diff --git a/packages/shared/src/mcp/tools.ts b/packages/shared/src/mcp/tools.ts index ef4acf20..aa10237e 100644 --- a/packages/shared/src/mcp/tools.ts +++ b/packages/shared/src/mcp/tools.ts @@ -22,6 +22,7 @@ import { type DesignTask, type FigmaSession } from './design-task' +import { stringRecord } from './string-record' const NodeIdParameterSchema = z .string() @@ -370,7 +371,7 @@ const DesignSystemCatalogComponentSchema = z }) .strict() .optional(), - props: z.record(z.string(), DesignSystemCatalogPropertySchema), + props: stringRecord(z.string(), DesignSystemCatalogPropertySchema), omittedProps: z.number().int().nonnegative().optional() }) .strict() @@ -447,7 +448,7 @@ export const DesignSystemResourcesResultSchema = z .strict() .optional(), nextCursor: z.number().int().nonnegative().optional(), - omitted: z.record(z.string(), z.number().int().nonnegative()).optional(), + omitted: stringRecord(z.string(), z.number().int().nonnegative()).optional(), warnings: z.array(z.string()).optional() }) .strict() diff --git a/packages/shared/tests/client-hook.test.ts b/packages/shared/tests/client-hook.test.ts new file mode 100644 index 00000000..b4c07a2f --- /dev/null +++ b/packages/shared/tests/client-hook.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { ClientHookParamsSchema, ClientHookReceiptSchema } from '../src/mcp/client-hook' + +const legacy = { + version: 1, + kind: 'claude', + sessionId: 'session', + event: 'Stop', + invocationId: '6b6caf70-9f7e-4cea-8abd-443750e03310' +} +describe('installed lifecycle hook contract', () => { + it('retains the strict legacy shape', () => { + expect(ClientHookParamsSchema.parse(legacy)).toEqual(legacy) + expect( + ClientHookParamsSchema.safeParse({ ...legacy, profile: { kind: 'release' } }).success + ).toBe(false) + }) + it('requires explicit profile and time for bounded receipt-capable retries', () => { + const current = { + ...legacy, + version: 2, + profile: { kind: 'checkout', id: 'a'.repeat(16) }, + issuedAt: 1 + } + expect(ClientHookParamsSchema.parse(current)).toEqual(current) + expect(ClientHookParamsSchema.safeParse({ ...legacy, version: 2 }).success).toBe(false) + expect( + ClientHookParamsSchema.safeParse({ ...current, profile: { kind: 'checkout' } }).success + ).toBe(false) + expect(ClientHookParamsSchema.safeParse({ ...current, issuedAt: -1 }).success).toBe(false) + }) + it('distinguishes admission from application, never a boolean success guess', () => { + for (const state of ['accepted', 'applied']) + expect( + ClientHookReceiptSchema.parse({ version: 2, invocationId: legacy.invocationId, state }) + ).toHaveProperty('state', state) + expect( + ClientHookReceiptSchema.safeParse({ + version: 2, + invocationId: legacy.invocationId, + state: 'success' + }).success + ).toBe(false) + }) +}) diff --git a/packages/shared/tests/mcp/constants-errors.test.ts b/packages/shared/tests/mcp/constants-errors.test.ts index f3e194e1..e0b723fc 100644 --- a/packages/shared/tests/mcp/constants-errors.test.ts +++ b/packages/shared/tests/mcp/constants-errors.test.ts @@ -52,6 +52,8 @@ describe('mcp/errors', () => { NO_ACTIVE_EXTENSION: 'NO_ACTIVE_EXTENSION', EXTENSION_TIMEOUT: 'EXTENSION_TIMEOUT', EXTENSION_DISCONNECTED: 'EXTENSION_DISCONNECTED', + EXTENSION_DELIVERY_FAILED: 'EXTENSION_DELIVERY_FAILED', + EXTENSION_RESULT_LOST: 'EXTENSION_RESULT_LOST', EXTENSION_UPGRADE_REQUIRED: 'EXTENSION_UPGRADE_REQUIRED', RUNTIME_IDENTITY_MISMATCH: 'RUNTIME_IDENTITY_MISMATCH', INVALID_SELECTION: 'INVALID_SELECTION', @@ -67,6 +69,7 @@ describe('mcp/errors', () => { INVALID_CANVAS_SCOPE: 'INVALID_CANVAS_SCOPE', INVALID_CANVAS_SPEC: 'INVALID_CANVAS_SPEC', CANVAS_APPLY_FAILED: 'CANVAS_APPLY_FAILED', + CANVAS_APPLY_ABORTED: 'CANVAS_APPLY_ABORTED', ASSET_SERVER_NOT_CONFIGURED: 'ASSET_SERVER_NOT_CONFIGURED', ASSET_NOT_FOUND: 'ASSET_NOT_FOUND', ASSET_HASH_MISMATCH: 'ASSET_HASH_MISMATCH', diff --git a/packages/shared/tests/mcp/design-task.test.ts b/packages/shared/tests/mcp/design-task.test.ts index 21aa61dc..57f281d9 100644 --- a/packages/shared/tests/mcp/design-task.test.ts +++ b/packages/shared/tests/mcp/design-task.test.ts @@ -466,6 +466,8 @@ describe('design task contracts', () => { requestId: '00000000-0000-4000-8000-000000000001' } expect(ManageDesignTaskParametersSchema.parse(begin)).toEqual(begin) + const recovered = { ...begin, recoveryGeneration: '00000000-0000-4000-8000-000000000002' } + expect(ManageDesignTaskParametersSchema.parse(recovered)).toEqual(recovered) expect(ManageDesignTaskParametersSchema.parse({ ...begin, title: ' Settings ' }).title).toBe( 'Settings' ) @@ -480,6 +482,7 @@ describe('design task contracts', () => { { ...input, title: 'Another task' }, { ...input, sessionId: 'another-session' }, { ...input, requestId: begin.requestId }, + { ...input, recoveryGeneration: recovered.recoveryGeneration }, { ...input, taskEpoch: 2 }, { ...input, progress: 'Working' } ]) { @@ -496,6 +499,7 @@ describe('design task contracts', () => { for (const invalid of [ { ...begin, requestId: undefined }, { ...begin, requestId: 'not-a-uuid' }, + { ...begin, recoveryGeneration: 'not-a-uuid' }, { ...begin, title: ' ' }, { ...begin, title: 'x'.repeat(121) }, { ...begin, taskId: 'task-1' }, diff --git a/packages/shared/tests/mcp/dictionary-boundaries.test.ts b/packages/shared/tests/mcp/dictionary-boundaries.test.ts new file mode 100644 index 00000000..a75252b3 --- /dev/null +++ b/packages/shared/tests/mcp/dictionary-boundaries.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { + ApplyCanvasParametersSchema, + ApplyCanvasResultSchema, + CanvasResolvedApplyParametersSchema, + CanvasThemeSchema, + CanvasVariableCollectionsSchema, + DesignSystemResourcesResultSchema +} from '../../src/mcp/tools' + +const dictionary = (value: Value) => Object.fromEntries([['__proto__', value]]) + +describe('dictionary keys across public schemas', () => { + it('retains prototype-like native, resource, and asset keys in public canvas requests', () => { + const input = { + mode: 'create', + markup: '
', + native: dictionary({ figma: { locked: true } }), + variableCollections: dictionary(null), + styles: dictionary(null), + assets: dictionary({ type: 'SVG', svg: '' }) + } + expect(ApplyCanvasParametersSchema.parse(input)).toEqual(input) + }) + + it('retains nested collection, mode, variable, and binding keys after resolution', () => { + const input = { + mode: 'create', + markup: '
', + bindings: dictionary({ figma: { locked: true } }), + variableCollections: dictionary({ + modes: dictionary({ name: 'Dark' }), + variables: dictionary({ type: 'FLOAT', values: dictionary(8) }) + }), + styles: dictionary(null), + assets: dictionary({ type: 'SVG', svg: '' }) + } + expect(CanvasResolvedApplyParametersSchema.parse(input)).toEqual(input) + expect( + CanvasVariableCollectionsSchema.safeParse( + dictionary({ variables: dictionary({ type: 'FLOAT', values: dictionary([]) }) }) + ).success + ).toBe(false) + }) + + it('validates a prototype-like key instead of dropping it before key validation', () => { + const result = CanvasThemeSchema.safeParse({ + variables: dictionary({ variableKey: 'color' }) + }) + expect(result.success).toBe(false) + if (!result.success) expect(result.error.issues[0]?.path).toEqual(['variables', '__proto__']) + }) + + it('retains node identity keys and rejects invalid node ids in canvas results', () => { + const input = { + rootNodeId: '1:1', + nodeIdsByKey: dictionary('1:1'), + createdNodeIds: ['1:1'], + updatedNodeIds: [], + removedNodeIds: [], + mutationCount: 1, + verification: { status: 'passed', nodesChecked: 1, referencesChecked: 0, warnings: [] } + } + expect(ApplyCanvasResultSchema.parse(input)).toEqual(input) + expect( + ApplyCanvasResultSchema.safeParse({ ...input, nodeIdsByKey: dictionary('') }).success + ).toBe(false) + }) + + it('retains component property names in catalog output', () => { + const input = { + catalogId: 'catalog', + components: [ + { + ref: 'c1', + tag: 'Card', + name: 'Card', + props: dictionary({ type: 'text', default: 'Text' }) + } + ], + variables: [], + collections: [], + styles: [], + omitted: dictionary(1) + } + expect(DesignSystemResourcesResultSchema.parse(input)).toEqual(input) + }) +}) diff --git a/packages/shared/tests/mcp/install.test.ts b/packages/shared/tests/mcp/install.test.ts index f83807b6..99a9c045 100644 --- a/packages/shared/tests/mcp/install.test.ts +++ b/packages/shared/tests/mcp/install.test.ts @@ -56,7 +56,7 @@ describe('shared/mcp/install', () => { expect(mcp.MCP_SERVER).toEqual({ name: 'tempad-dev', command: 'npx', - args: ['-y', '@tempad-dev/mcp@latest'] + args: ['-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] }) expect(mcp.MCP_DEFAULT_CONFIG_SNIPPET).toContain('"tempad-dev"') @@ -64,7 +64,7 @@ describe('shared/mcp/install', () => { mcpServers: { 'tempad-dev': { command: 'npx', - args: ['-y', '@tempad-dev/mcp@latest'] + args: ['-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] } } }) @@ -80,7 +80,7 @@ describe('shared/mcp/install', () => { name: 'tempad-dev', type: 'stdio', command: 'npx', - args: ['-y', '@tempad-dev/mcp@latest'] + args: ['-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] }) const cursorDeepLink = mcp.MCP_CLIENTS_BY_ID.cursor.deepLink @@ -96,7 +96,7 @@ describe('shared/mcp/install', () => { ).toString('utf8') expect(JSON.parse(decodedCursorConfigJson)).toEqual({ command: 'npx', - args: ['-y', '@tempad-dev/mcp@latest'] + args: ['-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] }) expect(mcp.MCP_CLIENTS_BY_ID.trae.deepLink).toContain('trae://trae.ai-ide/mcp-import') @@ -110,17 +110,17 @@ describe('shared/mcp/install', () => { expect(mcp.MCP_CLIENTS_BY_ID.codex.copyText).toContain('codex mcp add "tempad-dev"') expect(mcp.MCP_CLIENTS_BY_ID.codex.alternateCopyKind).toBe('config') expect(mcp.MCP_CLIENTS_BY_ID.codex.alternateCopyText).toBe( - '[mcp_servers.tempad-dev]\ncommand = "npx"\nargs = ["-y", "@tempad-dev/mcp@latest"]\ntool_timeout_sec = 300' + '[mcp_servers.tempad-dev]\ncommand = "npx"\nargs = ["-y", "@tempad-dev/mcp@latest", "--runtime-updates=stable"]\ntool_timeout_sec = 300' ) expect(mcp.MCP_CLIENTS_BY_ID.gemini.copyText).toBe( - 'gemini mcp add --scope user "tempad-dev" npx -y @tempad-dev/mcp@latest' + 'gemini mcp add --scope user "tempad-dev" npx -y @tempad-dev/mcp@latest --runtime-updates=stable' ) expect(JSON.parse(mcp.MCP_CLIENTS_BY_ID.opencode.copyText ?? '')).toEqual({ $schema: 'https://opencode.ai/config.json', mcp: { 'tempad-dev': { type: 'local', - command: ['npx', '-y', '@tempad-dev/mcp@latest'] + command: ['npx', '-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] } } }) @@ -267,6 +267,7 @@ describe('shared/mcp/install', () => { ]) expect(integration.actions[0]?.value).toBe(client.copyText) expect(integration.actions[0]?.value).toContain('@tempad-dev/mcp@latest') + expect(integration.actions[0]?.value).toContain('--runtime-updates=stable') if (client.copyKind === 'config') expect(integration.actions[0]?.hint).toBeTruthy() expect(integration.actions[1]?.value).toBe(`${SKILLS_INSTALL_COMMAND} --global --agent ${id}`) } @@ -285,7 +286,7 @@ describe('shared/mcp/install', () => { ' serverName: tempad-dev', ' transport: stdio', ' command: npx', - ' args: ["-y","@tempad-dev/mcp@latest"]' + ' args: ["-y","@tempad-dev/mcp@latest","--runtime-updates=stable"]' ].join('\n') ) expect(clients.deepseek.copyKind).toBe('config') @@ -298,32 +299,46 @@ describe('shared/mcp/install', () => { it('uses the native CLI syntax for each harness', async () => { const { MCP_CLIENTS_BY_ID: clients, AGENT_INTEGRATIONS_BY_ID: integrations } = await importInstall() - expect(clients.amp.copyText).toBe('amp mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest') - expect(clients.grok.copyText).toBe('grok mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest') + expect(clients.amp.copyText).toBe( + 'amp mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest --runtime-updates=stable' + ) + expect(clients.grok.copyText).toBe( + 'grok mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest --runtime-updates=stable' + ) expect(clients['github-copilot'].copyText).toBe( - 'copilot mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest' + 'copilot mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest --runtime-updates=stable' ) expect(clients.droid.copyText).toBe( - 'droid mcp add tempad-dev "npx -y @tempad-dev/mcp@latest" --type stdio' + 'droid mcp add tempad-dev "npx -y @tempad-dev/mcp@latest --runtime-updates=stable" --type stdio' ) expect(clients['kimi-code-cli'].copyText).toBe( - 'kimi mcp add --transport stdio tempad-dev -- npx -y @tempad-dev/mcp@latest' + 'kimi mcp add --transport stdio tempad-dev -- npx -y @tempad-dev/mcp@latest --runtime-updates=stable' ) expect(clients['qwen-code'].copyText).toBe( - 'qwen mcp add --scope user tempad-dev npx -y @tempad-dev/mcp@latest' + 'qwen mcp add --scope user tempad-dev npx -y @tempad-dev/mcp@latest --runtime-updates=stable' + ) + expect(clients.pi.copyText).toBe( + 'pi mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest --runtime-updates=stable' ) - expect(clients.pi.copyText).toBe('pi mcp add tempad-dev -- npx -y @tempad-dev/mcp@latest') expect(integrations.pi.actions[0]?.hint).toContain('Pi 0.99') }) it('keeps host-specific configuration formats instead of assuming mcpServers everywhere', async () => { const { MCP_CLIENTS_BY_ID: clients } = await importInstall() - const command = { command: 'npx', args: ['-y', '@tempad-dev/mcp@latest'] } + const command = { + command: 'npx', + args: ['-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] + } for (const id of ['antigravity', 'augment', 'cline', 'junie', 'kiro-cli', 'qoder'] as const) { expect(JSON.parse(clients[id].copyText!)).toEqual({ mcpServers: { 'tempad-dev': command } }) } expect(JSON.parse(clients.kilo.copyText!)).toEqual({ - mcp: { 'tempad-dev': { type: 'local', command: ['npx', '-y', '@tempad-dev/mcp@latest'] } } + mcp: { + 'tempad-dev': { + type: 'local', + command: ['npx', '-y', '@tempad-dev/mcp@latest', '--runtime-updates=stable'] + } + } }) expect(JSON.parse(clients.codebuddy.copyText!)).toEqual({ mcpServers: { 'tempad-dev': { type: 'stdio', ...command } } @@ -335,7 +350,7 @@ describe('shared/mcp/install', () => { mcp: { servers: { 'tempad-dev': command } } }) expect(clients['hermes-agent'].copyText).toBe( - 'mcp_servers:\n tempad-dev:\n command: npx\n args: ["-y","@tempad-dev/mcp@latest"]' + 'mcp_servers:\n tempad-dev:\n command: npx\n args: ["-y","@tempad-dev/mcp@latest","--runtime-updates=stable"]' ) }) diff --git a/packages/shared/tests/mcp/protocol.test.ts b/packages/shared/tests/mcp/protocol.test.ts index 503137bb..018037e0 100644 --- a/packages/shared/tests/mcp/protocol.test.ts +++ b/packages/shared/tests/mcp/protocol.test.ts @@ -8,6 +8,25 @@ import { } from '../../src/mcp/protocol' describe('mcp/protocol', () => { + it('validates the optional terminal receipt negotiation and dedicated completion envelopes', () => { + const receipt = { generation: 'hub', receiptId: 'receipt' } + const completion = { type: 'toolCompletion', id: 'request', ...receipt } + expect(parseMessageFromExtension(JSON.stringify(completion))).toEqual(completion) + expect(parseMessageFromExtension(JSON.stringify({ ...completion, payload: {} }))).toBeNull() + expect(parseMessageFromExtension(JSON.stringify({ ...completion, receiptId: '' }))).toBeNull() + const ack = { ...completion, type: 'toolCompletionAck', accepted: true } + expect(parseMessageToExtension(JSON.stringify(ack))).toEqual(ack) + const hello = { + type: 'runtimeHello', + extensionVersion: '0.23.0', + extensionRuntimeFingerprint: 'a'.repeat(64), + terminalReceipts: true + } + expect(parseMessageFromExtension(JSON.stringify(hello))).toEqual(hello) + expect( + parseMessageFromExtension(JSON.stringify({ ...hello, terminalReceipts: false })) + ).toBeNull() + }) it('accepts an explicit extension protocol and preserves omission from released peers', () => { const hello = { type: 'runtimeHello', diff --git a/packages/shared/tests/mcp/runtime-profile.test.ts b/packages/shared/tests/mcp/runtime-profile.test.ts new file mode 100644 index 00000000..9cbeb90c --- /dev/null +++ b/packages/shared/tests/mcp/runtime-profile.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' + +import { + RuntimeProfileSchema, + RegisteredMessageSchema, + RuntimeHelloMessageSchema, + runtimePortCandidates, + sameRuntimeProfile +} from '../../src/mcp/protocol' + +describe('runtime profile contract', () => { + const checkout = { kind: 'checkout' as const, id: 'a'.repeat(16) } + it('keeps missing identity as release and separates checkout pools', () => { + expect(sameRuntimeProfile(undefined, { kind: 'release' })).toBe(true) + expect(sameRuntimeProfile(checkout, undefined)).toBe(false) + expect(sameRuntimeProfile(undefined, checkout)).toBe(false) + expect(sameRuntimeProfile(checkout, { ...checkout })).toBe(true) + expect(sameRuntimeProfile(checkout, { ...checkout, id: 'b'.repeat(16) })).toBe(false) + expect(runtimePortCandidates()).toEqual([6220, 7431, 8127]) + expect( + runtimePortCandidates(checkout).filter((port) => runtimePortCandidates().includes(port)) + ).toEqual([]) + }) + + it.each([ + { kind: 'pinned' }, + { kind: 'checkout', id: '../release' }, + { kind: 'release', id: 'x' } + ])('rejects invalid profile %j', (profile) => { + expect(RuntimeProfileSchema.safeParse(profile).success).toBe(false) + }) + + it('extends only the optional envelope identity and keeps released messages valid', () => { + const registration = { type: 'registered', id: 'hub', protocolVersion: 15 } + const hello = { + type: 'runtimeHello', + protocolVersion: 15, + extensionVersion: '1', + extensionRuntimeFingerprint: 'a'.repeat(64) + } + for (const [schema, message] of [ + [RegisteredMessageSchema, registration], + [RuntimeHelloMessageSchema, hello] + ] as const) { + expect(schema.parse(message)).toEqual(message) + expect(schema.parse({ ...message, runtimeProfile: checkout })).toEqual({ + ...message, + runtimeProfile: checkout + }) + expect(schema.safeParse({ ...message, runtimeProfile: { kind: 'unknown' } }).success).toBe( + false + ) + } + }) +}) diff --git a/packages/shared/tests/mcp/string-record.test.ts b/packages/shared/tests/mcp/string-record.test.ts new file mode 100644 index 00000000..7986d198 --- /dev/null +++ b/packages/shared/tests/mcp/string-record.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { z } from 'zod' + +import { stringRecord } from '../../src/mcp/string-record' + +const dictionary = (value: Value) => Object.fromEntries([['__proto__', value]]) + +describe('string dictionaries', () => { + it('retains declaration order when the prototype-like key is first, middle, or last', () => { + const schema = stringRecord(z.string(), z.string()) + for (const keys of [ + ['__proto__', 'first', 'second'], + ['first', '__proto__', 'second'], + ['first', 'second', '__proto__'] + ]) { + const input = Object.fromEntries(keys.map((key) => [key, key])) + const parsed = schema.parse(input) + expect(Object.keys(parsed)).toEqual(keys) + expect(parsed).toEqual(input) + } + }) + + it('validates own enumerable keys without changing prototypes or the input', () => { + const input = JSON.parse( + '{"__proto__":{"polluted":true},"constructor":{"polluted":false},"prototype":{"polluted":true}}' + ) + const parsed = stringRecord(z.string(), z.object({ polluted: z.boolean() })).parse(input) + expect(parsed).toEqual(input) + expect(parsed).not.toBe(input) + expect(parsed.__proto__).not.toBe(input.__proto__) + expect(Object.hasOwn(parsed, '__proto__')).toBe(true) + expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype) + expect(Object.getPrototypeOf(input)).toBe(Object.prototype) + expect(Object.hasOwn(Object.prototype, 'polluted')).toBe(false) + + const hidden = Object.defineProperty({ normal: true }, '__proto__', { value: false }) + expect(stringRecord(z.string(), z.boolean()).parse(hidden)).toEqual({ normal: true }) + }) + + it('preserves optional, cloned, described, and refined schemas and their value types', () => { + const transform = vi.fn((value: string) => value.length) + const schema = stringRecord(z.string(), z.string().transform(transform)) + .clone() + .describe('Dictionary') + .refine((value) => Object.keys(value).length === 1) + .optional() + expect(schema.parse(undefined)).toBeUndefined() + expect(schema.parse(dictionary('text'))).toEqual(dictionary(4)) + expect(schema.parse({ normal: 'plain' })).toEqual({ normal: 5 }) + expect(schema.safeParse({}).success).toBe(false) + expect(transform.mock.calls.map(([value]) => value)).toEqual(['text', 'plain']) + expectTypeOf>().toEqualTypeOf | undefined>() + expectTypeOf>().toEqualTypeOf | undefined>() + }) + + it('reports invalid inputs and values at their original dictionary paths', () => { + const schema = z.object({ data: stringRecord(z.string(), z.number()) }) + expect(schema.safeParse({ data: null }).success).toBe(false) + const result = schema.safeParse({ data: dictionary('invalid') }) + expect(result.success).toBe(false) + if (!result.success) + expect(result.error.issues).toEqual([ + expect.objectContaining({ code: 'invalid_type', path: ['data', '__proto__'] }) + ]) + const constrainedKeys = stringRecord(z.string().regex(/^__proto__$/), z.number()) + const invalidKey = constrainedKeys.safeParse({ ...dictionary(1), invalid: 2 }) + expect(invalidKey.success).toBe(false) + if (!invalidKey.success) + expect(invalidKey.error.issues).toEqual([ + expect.objectContaining({ code: 'invalid_key', path: ['invalid'] }) + ]) + }) + + it('supports asynchronous value validation for prototype-like and ordinary keys', async () => { + const schema = stringRecord( + z.string(), + z.string().transform(async (value) => value.length) + ) + expect(await schema.parseAsync(dictionary('text'))).toEqual(dictionary(4)) + const parsed = await schema.parseAsync({ ...dictionary('text'), normal: 'normal' }) + expect(parsed).toEqual({ ...dictionary(4), normal: 6 }) + expect(Object.keys(parsed)).toEqual(['__proto__', 'normal']) + expect(() => schema.parse(dictionary('text'))).toThrow(/Promise/) + }) + + it('rejects asynchronous key validation consistently with Zod records', async () => { + const schema = stringRecord( + z.string().refine(async () => true), + z.string() + ) + await expect(schema.parseAsync(dictionary('text'))).rejects.toThrow( + 'Async schemas not supported in object keys currently' + ) + expect(() => schema.parse(dictionary('text'))).toThrow(/Promise/) + }) + + it.each(['draft-7', 'draft-2020-12'] as const)( + 'preserves %s input/output projections, including tuple values', + (target) => { + const key = z + .string() + .min(1) + .max(128) + .regex(/^[\w./:-]+$/) + const value = z.tuple([z.string(), z.number()]) + for (const io of ['input', 'output'] as const) + expect(z.toJSONSchema(stringRecord(key, value), { target, io })).toEqual( + z.toJSONSchema(z.record(key, value), { target, io }) + ) + } + ) +}) diff --git a/packages/shared/tests/mcp/tools.test.ts b/packages/shared/tests/mcp/tools.test.ts index ad53aba7..8e2b4e46 100644 --- a/packages/shared/tests/mcp/tools.test.ts +++ b/packages/shared/tests/mcp/tools.test.ts @@ -1758,7 +1758,7 @@ describe('mcp/tools canvas authoring schemas', () => { color: { r: 0, g: 0, b: 0, a: 1 }, secondaryColor: { r: 1, g: 1, b: 1, a: 1 }, noiseSize: 2, - noiseSizeVector: { x: 2, y: 2 }, + noiseSizeVector: { x: 2, y: 3 }, density: 0.5 }, { @@ -1772,7 +1772,7 @@ describe('mcp/tools canvas authoring schemas', () => { { type: 'TEXTURE', noiseSize: 1, - noiseSizeVector: { x: 1, y: 1 }, + noiseSizeVector: { x: 1, y: 2 }, radius: 4, clipToShape: true }, @@ -1855,7 +1855,7 @@ describe('mcp/tools canvas authoring schemas', () => { color: { r: 0, g: 0, b: 0, a: 1 }, secondaryColor: { r: 1, g: 1, b: 1, a: 1 }, noiseSize: 2, - noiseSizeVector: { x: 2, y: 3 }, + noiseSizeVector: { x: 2, y: Infinity }, density: 1 } ], diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 4cf6c27d..990abee2 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -3,6 +3,7 @@ "include": ["**/*"], "compilerOptions": { "declaration": true, + "allowImportingTsExtensions": true, "noEmit": true, "types": [] } diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 30b13b29..02fa43ad 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -1,6 +1,10 @@ import { defineConfig } from 'vitest/config' -import { SHARED_COVERAGE_FILES, SHARED_SCHEMA_COVERAGE_FILES } from '../../vitest.coverage' +import { + FULL_COVERAGE_THRESHOLDS, + SHARED_COVERAGE_FILES, + SHARED_SCHEMA_COVERAGE_FILES +} from '../../vitest.coverage.ts' export default defineConfig({ test: { @@ -12,13 +16,7 @@ export default defineConfig({ reporter: ['text', 'html'], include: [...SHARED_COVERAGE_FILES], thresholds: { - [`{${SHARED_SCHEMA_COVERAGE_FILES.join(',')}}`]: { - lines: 100, - functions: 100, - branches: 100, - statements: 100, - perFile: true - } + [`{${SHARED_SCHEMA_COVERAGE_FILES.join(',')}}`]: FULL_COVERAGE_THRESHOLDS } } } diff --git a/packages/site/scripts/skill-preview.ts b/packages/site/scripts/skill-preview.ts index 5ae9245d..47bc3520 100644 --- a/packages/site/scripts/skill-preview.ts +++ b/packages/site/scripts/skill-preview.ts @@ -107,7 +107,7 @@ function extractHeadingText(tokens: InlineTokens): string { return normalizeHeadingText(tocTextParser.parseInline(tokens, tocTextRenderer)) } -function slugifyHeading(value: string, slugCounts: Map): string { +function slugifyHeading(value: string, usedIds: Set): string { const base = value .toLowerCase() @@ -118,10 +118,10 @@ function slugifyHeading(value: string, slugCounts: Map): string .replace(/\s+/g, '-') .replace(/-+/g, '-') || 'section' - const count = slugCounts.get(base) ?? 0 - slugCounts.set(base, count + 1) - - return count === 0 ? base : `${base}-${count}` + let id = base + for (let suffix = 1; usedIds.has(id); suffix++) id = `${base}-${suffix}` + usedIds.add(id) + return id } function renderHeadingHtml(depth: number, content: string, id?: string): string { @@ -145,7 +145,7 @@ export function renderSkillPreview( sourceUrl: string, files: ReadonlyMap = new Map() ): { html: string; toc: readonly TocEntry[] } { - const slugCounts = new Map() + const usedIds = new Set() const toc: TocEntry[] = [] const renderer = new marked.Renderer() const renderLink = renderer.link.bind(renderer) @@ -157,7 +157,7 @@ export function renderSkillPreview( if (!text) return renderHeadingHtml(token.depth, content) - const id = slugifyHeading(text, slugCounts) + const id = slugifyHeading(text, usedIds) const entry = { depth: token.depth, id, text } if (token.depth === 2) toc.push(entry) diff --git a/packages/site/src/composables/useSiteColorMode.ts b/packages/site/src/composables/useSiteColorMode.ts index 5db64084..3ae1affc 100644 --- a/packages/site/src/composables/useSiteColorMode.ts +++ b/packages/site/src/composables/useSiteColorMode.ts @@ -1,4 +1,4 @@ -import { useColorMode } from '@vueuse/core' +import { useColorMode, useStorage } from '@vueuse/core' import { computed, type ComputedRef } from 'vue' export type SiteColorMode = 'auto' | 'light' | 'dark' @@ -13,12 +13,17 @@ type UseSiteColorModeResult = { const STORAGE_KEY = 'tempad-dev-site-color-mode' export function useSiteColorMode(): UseSiteColorModeResult { + const store = useStorage(STORAGE_KEY, 'auto', undefined, { + serializer: { + read: (value) => (value === 'light' || value === 'dark' ? value : 'auto'), + write: (value) => value + } + }) const colorMode = useColorMode({ - initialValue: 'auto', - storageKey: STORAGE_KEY + storageRef: store }) - const selectedColorMode = computed(() => colorMode.store.value as SiteColorMode) + const selectedColorMode = computed(() => colorMode.store.value) const resolvedColorMode = computed(() => colorMode.value === 'dark' ? 'dark' : 'light' ) diff --git a/packages/site/src/sections/ConnectSection.vue b/packages/site/src/sections/ConnectSection.vue index 0acd852b..05eed548 100644 --- a/packages/site/src/sections/ConnectSection.vue +++ b/packages/site/src/sections/ConnectSection.vue @@ -106,6 +106,9 @@ const setupGroups = computed(() => { }) const { resolvedColorMode } = useSiteColorMode() let feedbackTimer: number | undefined +let cancelDeepLink: (() => void) | undefined + +watch(selectedAgentId, () => cancelDeepLink?.()) function showFeedback(text: string, kind: FeedbackKind = 'success'): void { if (feedbackTimer) { @@ -124,34 +127,37 @@ async function writeClipboard(text: string, successMessage: string): Promise { - if (cleaned) return - cleaned = true window.clearTimeout(timeoutId) window.removeEventListener('blur', cleanup) window.removeEventListener('pagehide', cleanup) document.removeEventListener('visibilitychange', handleVisibilityChange) + if (cancelDeepLink === cleanup) cancelDeepLink = undefined } const handleVisibilityChange = (): void => { @@ -176,10 +182,12 @@ function openDeepLink(action: AgentIntegrationAction, agent: SetupTarget): void window.addEventListener('pagehide', cleanup, { once: true }) document.addEventListener('visibilitychange', handleVisibilityChange) + cancelDeepLink = cleanup window.location.href = action.value } function handleAgentAction(action: AgentIntegrationAction, agent: SetupTarget): void { + cancelDeepLink?.() if (action.kind === 'deep-link') { openDeepLink(action, agent) return @@ -244,6 +252,7 @@ function getCopyHint(action: AgentIntegrationAction, index: number): string { } onBeforeUnmount(() => { + cancelDeepLink?.() if (feedbackTimer) window.clearTimeout(feedbackTimer) listAnimation?.cancel() }) diff --git a/packages/site/src/styles.css b/packages/site/src/styles.css index b3f18662..bc9123c3 100644 --- a/packages/site/src/styles.css +++ b/packages/site/src/styles.css @@ -24,13 +24,6 @@ --site-toggle-border: rgb(15 23 42 / 10%); --site-header-height: 64px; --site-toggle-button-hover: rgb(15 23 42 / 3%); - --site-hero-shot-border: rgb(255 255 255 / 58%); - --site-hero-shot-overlay-top: rgb(255 255 255 / 76%); - --site-hero-shot-overlay-bottom: rgb(255 255 255 / 12%); - --site-hero-shot-grid-start: rgb(255 255 255 / 72%); - --site-hero-shot-grid-end: rgb(247 248 250 / 76%); - --site-hero-shot-grid-line: rgb(15 23 42 / 2%); - --site-hero-shot-inset: rgb(15 23 42 / 5%); --site-client-button-border-hover: rgb(15 23 42 / 16%); } @@ -59,13 +52,6 @@ html.dark { --site-toggle-bg: rgb(255 255 255 / 6%); --site-toggle-border: rgb(255 255 255 / 10%); --site-toggle-button-hover: rgb(255 255 255 / 7%); - --site-hero-shot-border: rgb(255 255 255 / 10%); - --site-hero-shot-overlay-top: rgb(255 255 255 / 12%); - --site-hero-shot-overlay-bottom: rgb(255 255 255 / 4%); - --site-hero-shot-grid-start: rgb(21 28 36 / 84%); - --site-hero-shot-grid-end: rgb(15 20 27 / 92%); - --site-hero-shot-grid-line: rgb(255 255 255 / 4%); - --site-hero-shot-inset: rgb(255 255 255 / 5%); --site-client-button-border-hover: rgb(255 255 255 / 16%); } @@ -380,146 +366,6 @@ html.dark .site-button.is-primary { margin-top: 28px; } -.site-hero-figure { - display: flex; - align-items: center; - position: relative; - margin: 0; - min-width: 0; - width: 100%; - justify-self: stretch; - align-self: center; - height: 100%; - z-index: 1; -} - -.site-hero-shot-frame { - position: relative; - display: flex; - align-items: center; - width: 100%; - aspect-ratio: 3 / 2; - margin-block: auto; - overflow: visible; - isolation: isolate; -} - -.site-hero-shot-layer { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - transform-origin: 56% 48%; - z-index: 1; - transition: - transform 1320ms cubic-bezier(0.2, 0.85, 0.22, 1), - opacity 1320ms cubic-bezier(0.2, 0.85, 0.22, 1), - filter 1320ms cubic-bezier(0.2, 0.85, 0.22, 1); -} - -.site-hero-shot-layer.is-incoming { - z-index: 2; - opacity: 0; - transform: translate3d(26%, 0, 0) scale(0.88); - filter: saturate(0.8) brightness(1.05); -} - -.site-hero-shot-layer.is-outgoing { - z-index: 3; - opacity: 1; - transform: translate3d(0, 0, 0) scale(1); - filter: saturate(1) brightness(1); - pointer-events: none; -} - -.site-hero-shot-layer.is-incoming.is-resting, -.site-hero-shot-layer.is-incoming.is-animating { - opacity: 1; - transform: translate3d(0, 0, 0) scale(1); - filter: saturate(1) brightness(1); -} - -.site-hero-shot-layer.is-outgoing.is-animating { - opacity: 0; - transform: translate3d(-26%, 0, 0) scale(0.88); - filter: saturate(0.84) brightness(0.99); -} - -.site-hero-shot-glows { - position: absolute; - inset: -48% -42%; - z-index: -1; - pointer-events: none; -} - -.site-hero-glow { - position: absolute; - border-radius: 999px; - mix-blend-mode: screen; - will-change: transform, opacity, filter; - transform: translate3d(-50%, -50%, 0); - opacity: 0.9; -} - -.site-hero-shot-media { - position: relative; - z-index: 1; - width: 100%; - overflow: hidden; - border: 1px solid var(--site-hero-shot-border); - border-radius: 10px; - background: - linear-gradient(180deg, var(--site-hero-shot-grid-start), var(--site-hero-shot-grid-end)), - linear-gradient(var(--site-hero-shot-grid-line) 1px, transparent 1px), - linear-gradient(90deg, var(--site-hero-shot-grid-line) 1px, transparent 1px); - background-size: - auto, - 32px 32px, - 32px 32px; - box-shadow: inset 0 0 0 1px var(--site-hero-shot-inset); - backdrop-filter: blur(8px) saturate(132%); -} - -.site-hero-shot-media::after { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - box-shadow: - inset 0 1px 0 var(--site-hero-shot-overlay-top), - inset 0 -40px 80px var(--site-hero-shot-overlay-bottom); - pointer-events: none; -} - -.site-hero-shot-image { - width: 100%; - height: auto; - object-fit: contain; - object-position: center center; -} - -.site-hero-shot-caption { - position: absolute; - left: 18px; - bottom: 10px; - margin: 0; - padding: 4px 14px 4px 0; - color: var(--site-text-muted); - font-family: var(--site-font-sans); - font-size: 0.75rem; - font-weight: 500; - pointer-events: none; -} - -@media (prefers-reduced-motion: reduce) { - .site-hero-shot-layer, - .site-hero-glow { - transition: none; - animation: none; - } -} - .site-section { margin-top: 104px; padding: 0; @@ -772,11 +618,6 @@ html.dark .site-button.is-primary { .site-hero-layout { grid-template-columns: 1fr; } - - .site-hero-figure { - width: 100%; - justify-self: stretch; - } } @media (max-width: 640px) { @@ -793,10 +634,6 @@ html.dark .site-button.is-primary { padding: 14px 0; } - .site-hero-shot-frame { - width: 100%; - } - .site-section { margin-top: 88px; } @@ -858,10 +695,6 @@ button.site-text-link { gap: 20px; min-width: 0; } -.site-inspect-layout .site-hero-shot-glows { - inset: -15%; - opacity: 0.35; -} .site-connect-intro { display: grid; justify-items: start; @@ -1793,12 +1626,6 @@ html.dark { .site-code-mobile { display: none; } -.site-workflow-caption { - margin-top: 16px; - color: var(--site-text-soft); - font-size: 10px; - text-align: center; -} .site-design-layer-label, .site-design-dimension { color: #7f61e4; @@ -1822,14 +1649,6 @@ html.dark .site-code-illustration .is-value { outline: 2px solid var(--site-accent); outline-offset: 4px; } -.site-sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} @media (max-width: 900px) { .site-hero { padding-block: 48px; @@ -1916,10 +1735,6 @@ html.dark .site-code-illustration .is-value { font-size: 11px; padding: 8px 12px; } - .site-workflow-caption { - font-size: 9px; - margin-top: 12px; - } } /* Quiet hierarchy, shared control sizes, and predictable alignment. */ diff --git a/packages/site/tests/color-mode.browser.test.ts b/packages/site/tests/color-mode.browser.test.ts new file mode 100644 index 00000000..650401ba --- /dev/null +++ b/packages/site/tests/color-mode.browser.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { createApp, nextTick } from 'vue' + +import App from '@/App.vue' +import '@/styles.css' + +const key = 'tempad-dev-site-color-mode' +let app: ReturnType | undefined +let host: HTMLElement | undefined +let previousValue: string | null +let previousClass: string + +beforeEach(() => { + previousValue = localStorage.getItem(key) + previousClass = document.documentElement.className +}) + +afterEach(() => { + app?.unmount() + host?.remove() + if (previousValue === null) localStorage.removeItem(key) + else localStorage.setItem(key, previousValue) + document.documentElement.className = previousClass +}) + +async function mount() { + const errors: unknown[] = [] + host = document.createElement('div') + document.body.append(host) + app = createApp(App) + app.config.errorHandler = (error) => errors.push(error) + app.mount(host) + await nextTick() + return errors +} + +function expectAutoMode() { + expect(host?.querySelector('.site-mode-button')?.getAttribute('aria-label')).toBe('Auto mode') + const dark = window.matchMedia('(prefers-color-scheme: dark)').matches + expect(document.documentElement.classList.contains(dark ? 'dark' : 'light')).toBe(true) +} + +it.each(['obsolete-mode', 'constructor'])( + 'renders the site using system appearance with stored value %j', + async (value) => { + localStorage.setItem(key, value) + const errors = await mount() + expect.soft(errors).toEqual([]) + expectAutoMode() + const button = host!.querySelector('.site-mode-button')! + for (let step = 0; step < 3; step++) { + button.click() + await nextTick() + } + expectAutoMode() + expect(localStorage.getItem(key)).toBe('auto') + } +) + +it('normalizes unsupported values arriving from another tab', async () => { + localStorage.setItem(key, 'dark') + const errors = await mount() + expect(host?.querySelector('.site-mode-button')?.getAttribute('aria-label')).toBe('Dark mode') + localStorage.setItem(key, 'unsupported') + window.dispatchEvent( + new StorageEvent('storage', { + key, + oldValue: 'dark', + newValue: 'unsupported', + storageArea: localStorage, + url: location.href + }) + ) + await nextTick() + expect.soft(errors).toEqual([]) + expectAutoMode() +}) diff --git a/packages/site/tests/connect.browser.test.ts b/packages/site/tests/connect.browser.test.ts index 7214f07a..17c01437 100644 --- a/packages/site/tests/connect.browser.test.ts +++ b/packages/site/tests/connect.browser.test.ts @@ -17,9 +17,62 @@ let host: HTMLElement | undefined afterEach(() => { app?.unmount() host?.remove() + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }) vi.restoreAllMocks() }) +it.each(['copied', 'rejected', 'throws'] as const)( + 'reports a legacy clipboard result of %s and restores the copy button focus', + async (result) => { + vi.spyOn(navigator, 'clipboard', 'get').mockReturnValue(undefined as unknown as Clipboard) + let input: HTMLTextAreaElement | undefined + const copy = vi.spyOn(document, 'execCommand').mockImplementation(() => { + input = document.activeElement as HTMLTextAreaElement + expect(input.value).toBe(AGENT_INTEGRATIONS_BY_ID.codex.actions[0]!.value) + if (result === 'throws') throw new Error('Clipboard denied') + return result === 'copied' + }) + host = document.createElement('div') + document.body.append(host) + app = createApp(ConnectSection) + app.mount(host) + const button = host.querySelector('.site-command-copy')! + button.focus() + try { + button.click() + await nextTick() + expect(copy).toHaveBeenCalledWith('copy') + expect.soft(input?.isConnected).toBe(false) + expect.soft(document.activeElement).toBe(button) + expect(host.querySelector('.site-feedback')?.textContent?.trim()).toBe( + result === 'copied' + ? 'Copied setup command.' + : 'Clipboard access failed. Please copy it manually.' + ) + expect(button.title === 'Copied').toBe(result === 'copied') + } finally { + input?.remove() + } + } +) + +it('clears the previous success indicator when a later copy fails', async () => { + const copy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + host = document.createElement('div') + document.body.append(host) + app = createApp(ConnectSection) + app.mount(host) + const button = host.querySelector('.site-command-copy')! + button.click() + await expect.poll(() => button.title).toBe('Copied') + copy.mockRejectedValue(new Error('Clipboard denied')) + button.click() + await expect + .poll(() => host?.querySelector('.site-feedback')?.textContent?.trim()) + .toBe('Clipboard access failed. Please copy it manually.') + expect(button.title).not.toBe('Copied') +}) + it.each(['codex', 'kiro-cli', 'codebuddy', 'deepseek'] as const)( 'keeps the %s artwork identical across selection and themes', async (id) => { diff --git a/packages/site/tests/deep-link.browser.test.ts b/packages/site/tests/deep-link.browser.test.ts new file mode 100644 index 00000000..6c320cd5 --- /dev/null +++ b/packages/site/tests/deep-link.browser.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { createApp, nextTick } from 'vue' + +import ConnectSection from '@/sections/ConnectSection.vue' + +// Exercise navigation ownership without ever launching an installed agent. +vi.mock('@tempad-dev/shared', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + AGENT_INTEGRATIONS: actual.AGENT_INTEGRATIONS.map((agent) => ({ + ...agent, + actions: agent.actions.map((action) => + action.kind === 'deep-link' + ? { ...action, value: '#test-install', fallbackValue: '#test-fallback' } + : action + ) + })) + } +}) + +let app: ReturnType | undefined +let host: HTMLElement | undefined +const originalUrl = location.href + +afterEach(() => { + app?.unmount() + host?.remove() + vi.useRealTimers() + vi.restoreAllMocks() + history.replaceState(null, '', originalUrl) +}) + +async function startAttempt() { + host = document.createElement('div') + document.body.append(host) + app = createApp(ConnectSection) + app.mount(host) + host.querySelector('#site-agent-trae')!.click() + await nextTick() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const button = host.querySelector('.site-connect-action-button')! + button.click() + expect(location.hash).toBe('#test-install') + return button +} + +it.each(['agent switch', 'another action', 'unmount', 'blur', 'pagehide'])( + 'cancels the pending fallback after %s', + async (event) => { + await startAttempt() + if (event === 'agent switch') + host!.querySelector('#site-agent-codex')!.click() + else if (event === 'another action') { + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue() + host!.querySelector('.site-command-copy')!.click() + } else if (event === 'unmount') { + app!.unmount() + app = undefined + } else window.dispatchEvent(new Event(event)) + await nextTick() + await vi.advanceTimersByTimeAsync(850) + expect(location.hash).toBe('#test-install') + expect(host?.querySelector('.site-feedback')?.textContent ?? '').not.toContain( + 'fallback install' + ) + } +) + +it('only runs the most recent attempt fallback', async () => { + const button = await startAttempt() + await vi.advanceTimersByTimeAsync(400) + button.click() + await vi.advanceTimersByTimeAsync(450) + expect(location.hash).toBe('#test-install') + await vi.advanceTimersByTimeAsync(400) + expect(location.hash).toBe('#test-fallback') +}) diff --git a/packages/site/tests/skill-preview.test.ts b/packages/site/tests/skill-preview.test.ts index bf5f4677..02bad6ce 100644 --- a/packages/site/tests/skill-preview.test.ts +++ b/packages/site/tests/skill-preview.test.ts @@ -56,6 +56,17 @@ describe('multi-file skill previews', () => { expect(preview.html).toContain('https://github.com/ecomfe/tempad-dev/blob/main/README.md') }) + it('keeps explicit numbered headings distinct from generated duplicate suffixes', () => { + const preview = renderSkillPreview( + '## Tokens\n\n## Tokens\n\n## Tokens-1\n\n## Tokens\n\n## Tokens-1', + `${root}SKILL.md`, + files + ) + const ids = preview.toc.map(({ id }) => id) + expect(ids).toEqual(['tokens', 'tokens-1', 'tokens-1-1', 'tokens-2', 'tokens-1-2']) + expect(Array.from(preview.html.matchAll(/ id="([^"]+)"/g), (match) => match[1])).toEqual(ids) + }) + it('leaves code examples as code rather than navigable links', () => { const preview = renderSkillPreview( '```md\n[Assets](references/assets.md)\n```', diff --git a/packages/site/tests/workspace-resolution.test.ts b/packages/site/tests/workspace-resolution.test.ts new file mode 100644 index 00000000..d5f7a996 --- /dev/null +++ b/packages/site/tests/workspace-resolution.test.ts @@ -0,0 +1,33 @@ +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import { expect, it } from 'vitest' + +it.each([ + ['vite.config.ts', 'client'], + ['vitest.config.ts', 'ssr'] +] as const)( + 'resolves shared source through %s without reading a previous build', + async (config, environment) => { + const server = await createServer({ + configFile: fileURLToPath(new URL(`../${config}`, import.meta.url)), + root: fileURLToPath(new URL('../', import.meta.url)), + appType: 'custom', + logLevel: 'silent', + optimizeDeps: { noDiscovery: true }, + server: { middlewareMode: true, watch: null, hmr: false } + }) + + try { + const resolver = server.environments[environment]!.pluginContainer + const importer = fileURLToPath(new URL('../src/content/landing.ts', import.meta.url)) + expect((await resolver.resolveId('@tempad-dev/shared', importer))?.id).toBe( + fileURLToPath(new URL('../../shared/src/index.ts', import.meta.url)) + ) + expect((await resolver.resolveId('@tempad-dev/shared/ui', importer))?.id).toBe( + fileURLToPath(new URL('../../shared/src/ui/index.ts', import.meta.url)) + ) + } finally { + await server.close() + } + } +) diff --git a/packages/site/tsconfig.node.json b/packages/site/tsconfig.node.json index 4f1063c7..569b86ea 100644 --- a/packages/site/tsconfig.node.json +++ b/packages/site/tsconfig.node.json @@ -1,14 +1,7 @@ // TSConfig for modules that run in Node.js environment via either transpilation or type-stripping. { "extends": "@tsconfig/node24/tsconfig.json", - "include": [ - "vite.config.*", - "vitest.config.*", - "cypress.config.*", - "nightwatch.conf.*", - "playwright.config.*", - "eslint.config.*" - ], + "include": ["*.config.*", "scripts/**/*.ts"], "compilerOptions": { // Most tools use transpilation instead of Node.js's native type-stripping. // Bundler mode provides a smoother developer experience. diff --git a/packages/site/vite.config.ts b/packages/site/vite.config.ts index da154b7a..bfdf3ae6 100644 --- a/packages/site/vite.config.ts +++ b/packages/site/vite.config.ts @@ -1,6 +1,6 @@ import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' -import { defineConfig, type Plugin } from 'vite' +import { defaultClientConditions, defineConfig, type Plugin } from 'vite' import { loadSkillPreview } from './scripts/skill-preview.ts' @@ -20,6 +20,7 @@ export function skillPreviewPlugin(): Plugin { export default defineConfig({ plugins: [vue(), skillPreviewPlugin()], resolve: { + conditions: ['source', ...defaultClientConditions], alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } diff --git a/packages/site/vitest.browser.config.ts b/packages/site/vitest.browser.config.ts index 51e5f928..2556d91a 100644 --- a/packages/site/vitest.browser.config.ts +++ b/packages/site/vitest.browser.config.ts @@ -1,7 +1,7 @@ import { playwright } from '@vitest/browser-playwright' import { defineConfig, mergeConfig } from 'vitest/config' -import viteConfig from './vite.config' +import viteConfig from './vite.config.ts' export default mergeConfig( viteConfig, diff --git a/packages/site/vitest.config.ts b/packages/site/vitest.config.ts index 62dddac6..477c426c 100644 --- a/packages/site/vitest.config.ts +++ b/packages/site/vitest.config.ts @@ -1,6 +1,16 @@ +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vitest/config' export default defineConfig({ + resolve: { + alias: [ + // Match only the package entry; /ui subpaths keep their own exports. + { + find: /^@tempad-dev\/shared$/, + replacement: fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)) + } + ] + }, test: { name: 'site', environment: 'node', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93be895e..339f0885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,7 +220,7 @@ importers: packages/extension: dependencies: '@tempad-dev/plugins': - specifier: workspace:^0.6.2 + specifier: workspace:^1.0.0 version: link:../plugins '@tempad-dev/shared': specifier: workspace:^0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7ddcfcbf..458df5f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,9 +10,6 @@ overrides: 'web-ext-run>tmp': 0.2.7 vite: 8.3.4 -minimumReleaseAgeExclude: - - tmp@0.2.6 - allowBuilds: esbuild: true lefthook: true diff --git a/scripts/build-agent-plugin.mjs b/scripts/build-agent-plugin.mjs index dbe959ea..9db7bd20 100644 --- a/scripts/build-agent-plugin.mjs +++ b/scripts/build-agent-plugin.mjs @@ -1,6 +1,22 @@ -import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + cpSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' import { dirname, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +import { checkoutProfile } from '../agent-plugin/src/clients/shared/runtime-profile.mjs' +import { + nativeFixtureServer, + readNativeRuntimeFixture, + shellQuote +} from './native-runtime-fixture.mjs' const root = fileURLToPath(new URL('..', import.meta.url)) const srcRoot = join(root, 'agent-plugin/src') @@ -10,6 +26,9 @@ const devName = 'tempad-dev-dev' const devMaxAssetStoreBytes = 1024 * 1024 * 1024 const pluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' const mcpSchema = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json' +const { values } = parseArgs({ options: { 'runtime-fixture': { type: 'string' } } }) +const fixturePath = values['runtime-fixture'] +const fixture = fixturePath ? readNativeRuntimeFixture(fixturePath, root) : undefined const manifest = readJson(join(srcRoot, 'plugin.json')) const portableMcp = readJson(join(srcRoot, 'mcp.json')) @@ -123,19 +142,44 @@ function buildDev(marketplaces) { }, { mcpServers: { - [devName]: { - command: 'node', - args: [join(root, 'packages/mcp-server/dist/cli.mjs')], - tool_timeout_sec: codexMcpSettings.tool_timeout_sec, - env: { - TEMPAD_MCP_DEV_CHECKOUT: root, - TEMPAD_MCP_MAX_ASSET_STORE_BYTES: String(devMaxAssetStoreBytes) - } - } + [devName]: fixture + ? nativeFixtureServer(fixture, codexMcpSettings.tool_timeout_sec) + : { + command: 'node', + args: [join(root, 'packages/mcp-server/dist/cli.mjs')], + tool_timeout_sec: codexMcpSettings.tool_timeout_sec, + env: { + TEMPAD_MCP_DEV_CHECKOUT: root, + TEMPAD_MCP_MAX_ASSET_STORE_BYTES: String(devMaxAssetStoreBytes) + } + } } } ) + if (fixture) writeJson(join(devRoot, 'runtime-fixture.json'), { descriptor: fixturePath }) + else { + const checkout = realpathSync(root) + writeJson(join(devRoot, 'clients/shared/runtime-profile.json'), { + checkout, + id: checkoutProfile(checkout).id, + serverName: devName + }) + } + const hooks = readJson(join(devRoot, 'clients/claude/hooks.json')) + for (const entries of Object.values(hooks.hooks)) { + for (const entry of entries) { + for (const hook of entry.hooks) { + if (fixture) + hook.command = `TEMPAD_MCP_RUNTIME_DIR=${shellQuote(fixture.env.TEMPAD_MCP_RUNTIME_DIR)} ${hook.command}` + else + hook.command += + ' --runtime-profile "${CLAUDE_PLUGIN_ROOT}/clients/shared/runtime-profile.json"' + } + } + } + writeJson(join(devRoot, 'clients/claude/hooks.json'), hooks) + const codexMarketplace = structuredClone(marketplaces.codex) codexMarketplace.name = devName codexMarketplace.interface.displayName = 'TemPad Dev (Dev)' diff --git a/scripts/check-agent-plugin-version.mjs b/scripts/check-agent-plugin-version.mjs new file mode 100644 index 00000000..4b76b484 --- /dev/null +++ b/scripts/check-agent-plugin-version.mjs @@ -0,0 +1,85 @@ +import { execFileSync } from 'node:child_process' + +const source = 'agent-plugin/src' +const targets = 'agent-plugin/targets' +const manifest = `${source}/plugin.json` + +function git(...args) { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trimEnd() +} + +function isPayload(path) { + return !/^agent-plugin\/(?:src|targets\/[^/]+)\/(?:README(?:\.zh-Hans)?|CHANGELOG)\.md$/.test( + path + ) +} + +function versionAt(revision) { + const { version } = JSON.parse(git('show', `${revision}:${manifest}`)) + if (typeof version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + throw new Error(`${revision}:${manifest} must use a stable x.y.z release version.`) + } + return { value: version, parts: version.split('.').map(BigInt) } +} + +function check() { + const reference = process.argv[2] ?? process.env.PLUGIN_VERSION_BASE + if (!reference) throw new Error('Provide a Git baseline: pnpm agent-plugin:check-version .') + if (git('rev-parse', '--is-shallow-repository') === 'true') { + throw new Error( + 'Cannot check agent plugin versions in a shallow checkout. Fetch full history with git fetch --unshallow, then rerun pnpm agent-plugin:check-version .' + ) + } + const head = git('rev-parse', '--verify', 'HEAD^{commit}') + let base + if (/^0+$/.test(reference)) { + if (git('rev-list', '--parents', '-n', '1', head).split(' ').length !== 1) { + throw new Error( + 'An all-zero baseline is valid only for the initial commit; provide a readable Git baseline.' + ) + } + } else { + try { + base = git('rev-parse', '--verify', '--end-of-options', `${reference}^{commit}`) + } catch { + throw new Error( + `Git baseline "${reference}" is not available in this checkout. Fetch the missing commit or rerun pnpm agent-plugin:check-version with an explicit readable baseline.` + ) + } + } + const paths = base + ? git('diff', '--name-only', '-z', '--no-renames', base, head, '--', source, targets) + : git('ls-tree', '-r', '--name-only', '-z', head, '--', source, targets) + if (!paths.split('\0').filter(Boolean).some(isPayload)) { + console.log('Agent plugin payload is unchanged; no version increase required.') + return + } + const current = versionAt(head) + const previousFiles = base + ? git('ls-tree', '-r', '--name-only', '-z', base, '--', source, targets) + .split('\0') + .filter(Boolean) + : [] + if (!previousFiles.some(isPayload)) { + console.log(`Agent plugin initialized at ${current.value}.`) + return + } + const previous = versionAt(base) + const differing = current.parts.findIndex((part, index) => part !== previous.parts[index]) + if (differing < 0 || current.parts[differing] < previous.parts[differing]) { + throw new Error( + `Agent plugin payload changed without a version increase (${previous.value} → ${current.value}). Bump ${manifest} and regenerate the targets.` + ) + } + console.log(`Agent plugin payload version advanced: ${previous.value} → ${current.value}.`) +} + +try { + check() +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +} diff --git a/scripts/check-commit-message.mjs b/scripts/check-commit-message.mjs index 82b22cc4..208ff987 100644 --- a/scripts/check-commit-message.mjs +++ b/scripts/check-commit-message.mjs @@ -1,10 +1,14 @@ +import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' const file = process.argv[2] if (!file) throw new Error('Expected the commit message file path.') -const subject = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0] +const subject = execFileSync('git', ['stripspace', '--strip-comments'], { + input: readFileSync(file, 'utf8'), + encoding: 'utf8' +}).split('\n', 1)[0] // Git preserves the target's original subject in integration and autosquash commits. -const generated = /^(?:Merge |Revert "|(?:fixup|squash|amend)! \S)/.test(subject) +const generated = /^(?:Merge |(?:Revert|Reapply) "|(?:fixup|squash|amend)! \S)/.test(subject) if (!generated && !/^[a-z]+(?:\([^()\r\n]+\))?!?: \S.*$/.test(subject)) { console.error( 'Use a Conventional Commit subject, for example: fix(extension): preserve SVG placement' diff --git a/scripts/extension-runtime-fingerprint.mjs b/scripts/extension-runtime-fingerprint.mjs index b6be7286..1b9f57f7 100644 --- a/scripts/extension-runtime-fingerprint.mjs +++ b/scripts/extension-runtime-fingerprint.mjs @@ -20,11 +20,21 @@ const EXTENSION_RUNTIME_ENTRIES = [ 'packages/extension/utils', 'packages/extension/worker', 'packages/extension/package.json', + 'packages/extension/tsconfig.json', 'packages/extension/types.d.ts', 'packages/extension/wxt.config.ts', + // Workspace dependencies resolve to authored sources rather than generated dist files. + 'packages/plugins/package.json', + 'packages/plugins/src', + 'packages/plugins/tsconfig.json', 'packages/shared/package.json', 'packages/shared/src', - 'scripts/extension-runtime-fingerprint.mjs' + 'packages/shared/tsconfig.json', + 'tsconfig.base.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'scripts/extension-runtime-fingerprint.mjs', + 'agent-plugin/src/clients/shared/runtime-profile.mjs' ] export function listExtensionRuntimeSources(repositoryRoot) { diff --git a/scripts/native-runtime-fixture.d.mts b/scripts/native-runtime-fixture.d.mts new file mode 100644 index 00000000..5d742404 --- /dev/null +++ b/scripts/native-runtime-fixture.d.mts @@ -0,0 +1,35 @@ +export interface NativeRuntimeFixture { + version: 1 + kind: 'native-runtime-fixture' + testOnly: true + checkout: string + sourceHash: string + root: string + registryUrl: string + env: { + TEMPAD_MCP_RUNTIME_DIR: string + TEMPAD_MCP_STATE_DIR: string + TEMPAD_MCP_ASSET_DIR: string + TEMPAD_MCP_LOG_DIR: string + } + initial: { version: string; directory: string; integrity: string } + candidate: { version: string; directory: string; integrity: string } +} + +export function readNativeRuntimeFixture( + path: string, + checkout: string, + options?: { allowStaleSource?: boolean } +): NativeRuntimeFixture +export function nativeFixtureServer( + fixture: NativeRuntimeFixture, + toolTimeout: unknown +): { + command: string + args: string[] + tool_timeout_sec: unknown + env: NativeRuntimeFixture['env'] +} +export function nativeFixtureHubEntry(fixture: NativeRuntimeFixture): string +export function nativeFixtureRuntimeEntries(fixture: NativeRuntimeFixture): string[] +export function shellQuote(value: string): string diff --git a/scripts/native-runtime-fixture.mjs b/scripts/native-runtime-fixture.mjs new file mode 100644 index 00000000..ad517255 --- /dev/null +++ b/scripts/native-runtime-fixture.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs' +import { isAbsolute, join, relative } from 'node:path' +import process from 'node:process' + +import { runtimeSourceHash } from '../packages/mcp-server/scripts/runtime-source-hash.mjs' + +/** Only the ignored native acceptance plugin may consume this local fixture. */ +export function readNativeRuntimeFixture(path, checkout, { allowStaleSource = false } = {}) { + assert.ok(isAbsolute(path), 'The native fixture descriptor must be absolute.') + const file = lstatSync(path) + assert.ok(file.isFile() && !file.isSymbolicLink() && file.uid === process.getuid?.()) + assert.equal(file.mode & 0o077, 0, 'The fixture descriptor must be private.') + const fixture = JSON.parse(readFileSync(path, 'utf8')) + assert.equal(fixture.version, 1) + assert.equal(fixture.kind, 'native-runtime-fixture') + assert.equal(fixture.testOnly, true) + assert.equal(fixture.checkout, realpathSync(checkout)) + assert.match(fixture.sourceHash, /^[a-f0-9]{64}$/) + // Teardown must still find an old fixture after the checkout has changed. + if (!allowStaleSource) + assert.equal( + fixture.sourceHash, + runtimeSourceHash(checkout), + 'Native fixture sources changed; prepare a new fixture before installation or acceptance.' + ) + assert.ok(isAbsolute(fixture.root)) + assert.equal(fixture.root, realpathSync(fixture.root)) + const root = lstatSync(fixture.root) + assert.ok(root.isDirectory() && root.uid === process.getuid?.()) + assert.equal(root.mode & 0o077, 0, 'The fixture root must be private.') + assert.equal(path, join(fixture.root, 'runtime-fixture.json')) + const registry = new URL(fixture.registryUrl) + assert.equal(registry.origin, fixture.registryUrl) + assert.equal(registry.protocol, 'http:') + assert.equal(registry.hostname, '127.0.0.1') + assert.ok(Number(registry.port) > 1024, 'The fixture registry must use a local test port.') + assert.deepEqual(fixture.env, { + TEMPAD_MCP_RUNTIME_DIR: join(fixture.root, 'run'), + TEMPAD_MCP_STATE_DIR: join(fixture.root, 'state'), + TEMPAD_MCP_ASSET_DIR: join(fixture.root, 'assets'), + TEMPAD_MCP_LOG_DIR: join(fixture.root, 'logs') + }) + for (const artifact of [fixture.initial, fixture.candidate]) { + assert.match(artifact.version, /^\d+\.\d+\.\d+$/) + assert.match(artifact.integrity, /^sha512-[A-Za-z0-9+/]{86}==$/) + assert.equal(artifact.directory, realpathSync(artifact.directory)) + const inside = relative(fixture.root, artifact.directory) + assert.ok(inside && !inside.startsWith('..') && !isAbsolute(inside)) + const pkg = JSON.parse(readFileSync(join(artifact.directory, 'package.json'), 'utf8')) + assert.equal(pkg.name, '@tempad-dev/mcp') + assert.equal(pkg.version, artifact.version) + assert.equal(pkg.private, true, 'Native fixtures must not be publishable.') + assert.equal(pkg.publishConfig.registry, fixture.registryUrl) + } + return fixture +} + +export function nativeFixtureServer(fixture, toolTimeout) { + return { + command: 'node', + args: [join(fixture.initial.directory, 'dist/cli.mjs'), '--runtime-updates=stable'], + tool_timeout_sec: toolTimeout, + env: fixture.env + } +} + +export function nativeFixtureHubEntry(fixture) { + const path = join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/switch.json') + if (!existsSync(path)) return join(fixture.initial.directory, 'dist/managed-runtime.mjs') + const stat = lstatSync(path) + assert.ok(stat.isFile() && !stat.isSymbolicLink() && stat.uid === process.getuid?.()) + assert.equal(stat.mode & 0o077, 0) + const { active } = JSON.parse(readFileSync(path, 'utf8')) + const expected = [fixture.initial, fixture.candidate].find( + (artifact) => artifact.version === active.version + ) + assert.ok(expected, 'The active native fixture version is unknown.') + const cache = join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/cache') + const location = relative(cache, active.release.directory) + assert.ok(location && !location.startsWith('..') && !isAbsolute(location)) + assert.equal(active.entry, join(active.release.directory, 'dist/managed-runtime.mjs')) + assert.equal(active.entry, realpathSync(active.entry)) + const manifest = readFileSync(join(expected.directory, 'dist/runtime-manifest.json')) + assert.equal(createHash('sha256').update(manifest).digest('hex'), active.release.manifestHash) + assert.deepEqual( + readFileSync(join(active.release.directory, 'dist/runtime-manifest.json')), + manifest + ) + return active.entry +} + +export function nativeFixtureRuntimeEntries(fixture) { + const entries = [fixture.initial, fixture.candidate].map((artifact) => + join(artifact.directory, 'dist/cli.mjs') + ) + entries.push(nativeFixtureHubEntry(fixture)) + const config = join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/prototype.json') + if (existsSync(config)) { + const { entry } = JSON.parse(readFileSync(config, 'utf8')) + const inside = relative(join(fixture.env.TEMPAD_MCP_STATE_DIR, 'runtime/cache'), entry) + assert.ok(inside && !inside.startsWith('..') && !isAbsolute(inside)) + assert.equal(entry, realpathSync(entry)) + entries.push(entry) + } + return [...new Set(entries)] +} + +export function shellQuote(value) { + return `'${value.replaceAll("'", "'\\''")}'` +} diff --git a/tsconfig.configs.json b/tsconfig.configs.json index b654c5a7..9db3c80e 100644 --- a/tsconfig.configs.json +++ b/tsconfig.configs.json @@ -3,6 +3,7 @@ "include": ["eslint.config.ts", "eslint.shared.ts", "vitest.config.ts", "vitest.coverage.ts"], "compilerOptions": { "types": ["node"], + "allowImportingTsExtensions": true, "noEmit": true } } diff --git a/vitest.config.ts b/vitest.config.ts index 10e62fa1..be09fbd3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,8 +6,10 @@ import { COVERAGE_BASELINE_FILES, EXTENSION_CORE_COVERAGE_FILES, MCP_CORE_COVERAGE_FILES, - EXTENSION_SOURCE_FILES -} from './vitest.coverage' + EXTENSION_SOURCE_FILES, + FULL_COVERAGE_THRESHOLDS, + SHARED_SCHEMA_COVERAGE_FILES +} from './vitest.coverage.ts' export default defineConfig({ test: { @@ -28,13 +30,19 @@ export default defineConfig({ 'packages/{mcp-server,shared,plugins,site}/src/**/*.ts' ], exclude: ['**/dist/**', '**/.output/**', '**/*.d.ts'], - thresholds: coverageThresholds( - [...COVERAGE_BASELINE_FILES, ...AGENT_RUNTIME_FILES], - [ - ...EXTENSION_CORE_COVERAGE_FILES.map((file) => `packages/extension/${file}`), - ...MCP_CORE_COVERAGE_FILES.map((file) => `packages/mcp-server/${file}`) - ] - ) + thresholds: { + ...coverageThresholds( + [...COVERAGE_BASELINE_FILES, ...AGENT_RUNTIME_FILES], + [ + ...EXTENSION_CORE_COVERAGE_FILES.map((file) => `packages/extension/${file}`), + ...MCP_CORE_COVERAGE_FILES.map((file) => `packages/mcp-server/${file}`) + ] + ), + [`{${[ + ...SHARED_SCHEMA_COVERAGE_FILES.map((file) => `packages/shared/${file}`), + 'packages/plugins/src/index.ts' + ].join(',')}}`]: FULL_COVERAGE_THRESHOLDS + } } } }) diff --git a/vitest.coverage.ts b/vitest.coverage.ts index e91fcad3..32339a32 100644 --- a/vitest.coverage.ts +++ b/vitest.coverage.ts @@ -6,6 +6,14 @@ export const AGGREGATE_COVERAGE_THRESHOLDS = { perFile: false } as const +export const FULL_COVERAGE_THRESHOLDS = { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + perFile: true +} as const + // A per-file floor prevents a new or neglected core module from disappearing // into the aggregate. It does not replace the stronger existing baseline. export const CORE_COVERAGE_THRESHOLDS = { @@ -17,11 +25,17 @@ export const CORE_COVERAGE_THRESHOLDS = { } as const export const EXTENSION_CORE_COVERAGE_FILES = ['mcp/**/*.ts'] as const -// CLI and Hub launchers are exercised in child processes, outside instrumentation. +// CLI/Hub/acquisition launchers run in child-process acceptance harnesses. // Include every other top-level module and every nested runtime module. -export const MCP_CORE_COVERAGE_FILES = ['src/!(cli.ts|hub.ts)', 'src/*/**/*.ts'] as const +export const MCP_CORE_COVERAGE_FILES = [ + 'src/!(cli.ts|hub.ts|runtime-prototype.ts|managed-runtime.ts|runtime-installer.ts)', + 'src/*/**/*.ts' +] as const -export const AGENT_RUNTIME_FILES = ['agent-plugin/src/clients/shared/runtime-paths.mjs'] +export const AGENT_RUNTIME_FILES = [ + 'agent-plugin/src/clients/shared/runtime-paths.mjs', + 'agent-plugin/src/clients/shared/runtime-profile.mjs' +] export const EXTENSION_COVERAGE_FILES = [ 'scripts/inspect-agent-run-identity.ts', @@ -98,7 +112,6 @@ export const EXTENSION_COVERAGE_FILES = [ 'mcp/tools/code/messages.ts', 'mcp/tools/code/render/plugin.ts', 'mcp/tools/code/render/props.ts', - 'mcp/tools/code/variables.ts', 'mcp/tools/code/sanitize/negative-gap.ts', 'mcp/tools/code/sanitize/relative-parent.ts', 'mcp/tools/code/sanitize/stacking.ts', @@ -128,7 +141,6 @@ export const EXTENSION_COVERAGE_FILES = [ 'mcp/tools/token/candidates.ts', 'mcp/tools/token/cache.ts', 'mcp/tools/token/indexer.ts', - 'mcp/tools/token/mapping.ts', 'mcp/tools/token/index.ts', 'mcp/tools/token/value.ts' ] as const @@ -139,12 +151,14 @@ export const MCP_COVERAGE_FILES = [ 'src/config.ts', 'src/consumer-transport.ts', 'src/client-binding.ts', + 'src/process-environment.ts', 'src/log-retention.ts', 'src/log-destination.ts', 'src/log-transport.ts', 'src/request.ts', 'src/design-tasks.ts', 'src/agent-clients/identity.ts', + 'src/agent-clients/host-context.ts', 'src/agent-clients/registry.ts', 'src/agent-clients/hooks.ts', 'src/agent-clients/codex-ipc.ts', @@ -167,6 +181,7 @@ export const SHARED_SCHEMA_COVERAGE_FILES = [ 'src/index.ts', 'src/mcp/browser-gateway.ts', 'src/mcp/constants.ts', + 'src/mcp/string-record.ts', 'src/mcp/design-task.ts', 'src/mcp/errors.ts', 'src/mcp/index.ts',