diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 4f704dcd13..2f982d533d 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -87,7 +87,7 @@ jobs: run: cd backend && npm ci --include=dev - name: Install frontend dependencies - run: cd frontend && npm install --legacy-peer-deps + run: cd frontend && OPENUI_TELEMETRY_DISABLED=1 npm install --legacy-peer-deps - name: Install Playwright browsers run: npx playwright install chromium --with-deps diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a32c39cec4..15a14358e1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -129,7 +129,7 @@ jobs: frontend/package-lock.json # Frontend typecheck follows shared imports into backend TypeScript. - run: cd backend && npm ci --include=dev - - run: cd frontend && npm install --legacy-peer-deps + - run: cd frontend && OPENUI_TELEMETRY_DISABLED=1 npm install --legacy-peer-deps - run: cd frontend && npm run typecheck - name: Run frontend tests (coverage on the nightly schedule only) run: | @@ -139,7 +139,7 @@ jobs: else npx jest --watchAll=false --forceExit fi - - run: cd frontend && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs + - run: cd frontend && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/ui-card-catalogue.test.mjs tool-tests: name: CLI, MCP and hosted runtime tests diff --git a/commonly-mcp/__tests__/tools.test.mjs b/commonly-mcp/__tests__/tools.test.mjs index 918f23a1b8..22a67c5e77 100644 --- a/commonly-mcp/__tests__/tools.test.mjs +++ b/commonly-mcp/__tests__/tools.test.mjs @@ -5,6 +5,7 @@ * (via the same _fetchImpl seam in client.js) and assert the wire shape. */ +import { readFileSync } from 'node:fs'; import { jest } from '@jest/globals'; import { buildTools } from '../src/tools.js'; import { HttpError } from '../src/client.js'; @@ -690,3 +691,10 @@ describe('per-event inbox checkpoint', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); + +test('post schema publishes the current catalogue tuple without a private manifest lookup', () => { + const revision = JSON.parse(readFileSync(new URL('../../backend/config/uiCardCatalogueRevision.json', import.meta.url), 'utf8')); + const description = byName.commonly_post_message.inputSchema.properties.payload.description; + for (const value of Object.values(revision)) expect(description).toContain(String(value)); + expect(description).not.toContain('published catalogue revision manifest'); +}); diff --git a/docs/audits/task-498/README.md b/docs/audits/task-498/README.md new file mode 100644 index 0000000000..df432dfd12 --- /dev/null +++ b/docs/audits/task-498/README.md @@ -0,0 +1,31 @@ +# TASK-498 renderer proof + +Artboard: TASK-495, expanded to text in PR #2675 comment 6395684963. + +The screenshots render the actual V2UICard and OpenUI 1.0.1 runtime against local fixtures. No production API, agent or grant was used. The preview is `frontend/design-system/preview/ui-card.html` compiled by Vite in production mode and served by its preview server. Browser API routes supplied the board task and the trace receipt. Both widths have seven cards, including malformed, expired and unsupported revision states. + +- 1440: no horizontal overflow, ordinary primary link 32px tall. +- 390: no horizontal overflow, footer link 44px tall and full width; the three-column table becomes records. +- No page errors or OpenUI inspector auto-mount at either width. +- Static real-package render passes on the lockfile's React 18.3.1. + +`catalogue-1440.png` and `catalogue-390.png` capture the actual component specimens. Ego's seat wrapper denied access, so these were measured with an isolated headless Chrome profile using Playwright, without any signed-in browser session. + +The renderer accepts only the exact commonly.ui revision-1 ID/revision/hash tuple; missing or unsupported tuples retain the content with an unsupported-version note. Its hostname allowlist is exact: commonly.me, docs.commonly.me, github.com, HTTPS only, without credentials or custom ports. Other links are visible but unavailable. Legacy gig cards retain their narrower GitHub-only rule. Card source is never HTML and receives no tool provider. + +TaskStatus reads the current pod's board. RunTrace reads the pod-scoped TASK-499 endpoint from the server-stamped run ID; it uses only name, outcome and duration. Endpoint routing/provenance is supplied by #2686. No raw arguments or results enter the preview. + +The canonical grammar is owned by TASK-497 in `backend/config/uiCardCatalogue.json`. Run `node frontend/scripts/sync-ui-card-catalogue.mjs` to update the frontend copy; the byte-for-byte tests preserve positional prop order and the revision manifest. + +## Repeat the production check + +From the repository root with root and frontend dependencies installed: + +```sh +OPENUI_TELEMETRY_DISABLED=1 npm ci --prefix frontend --legacy-peer-deps +node scripts/check-ui-card-production.mjs +``` + +The check compiles the real component specimen with the production Vite configuration, checks emitted JavaScript for the devtools CDN/mount code, opens the production assets in isolated Playwright Chromium, records outgoing requests and measures both widths. Set `CHROME_EXECUTABLE` to use an existing Chrome binary. It does not call a live API. `production-proof.json` records zero jsDelivr/PostHog requests and page errors. This is local production-build evidence, not a deployed walk. + +Source parsing rejects dormant capabilities and bounds reference expansion before materialization (4,096 AST nodes across statements), plus a separate parsed-tree bound. Tests cover an exponential repeated-reference graph and its valid small control. The renderer has no A2UI interoperability claim. diff --git a/docs/audits/task-498/catalogue-1440.png b/docs/audits/task-498/catalogue-1440.png new file mode 100644 index 0000000000..4ffed14570 Binary files /dev/null and b/docs/audits/task-498/catalogue-1440.png differ diff --git a/docs/audits/task-498/catalogue-390.png b/docs/audits/task-498/catalogue-390.png new file mode 100644 index 0000000000..4b26fd6c7f Binary files /dev/null and b/docs/audits/task-498/catalogue-390.png differ diff --git a/docs/audits/task-498/production-proof.json b/docs/audits/task-498/production-proof.json new file mode 100644 index 0000000000..bf27b9071f --- /dev/null +++ b/docs/audits/task-498/production-proof.json @@ -0,0 +1,21 @@ +{ + "mode": "production", + "api": "controlled fixtures; no deployed walk", + "measurements": [ + { + "width": 1440, + "overflow": false, + "buttonHeight": 32, + "devtools": 0 + }, + { + "width": 390, + "overflow": false, + "buttonHeight": 44, + "devtools": 0 + } + ], + "jsdelivrRequests": 0, + "posthogRequests": 0, + "pageErrors": [] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c2efbe8715..0cbee95f0d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY package*.json ./ +ENV OPENUI_TELEMETRY_DISABLED=1 RUN npm install COPY . . diff --git a/frontend/design-system/preview/ui-card.html b/frontend/design-system/preview/ui-card.html new file mode 100644 index 0000000000..09fe38502d --- /dev/null +++ b/frontend/design-system/preview/ui-card.html @@ -0,0 +1 @@ +
diff --git a/frontend/design-system/preview/ui-card.tsx b/frontend/design-system/preview/ui-card.tsx new file mode 100644 index 0000000000..7d07efc07c --- /dev/null +++ b/frontend/design-system/preview/ui-card.tsx @@ -0,0 +1,20 @@ +import revision from '../../src/v2/cards/uiCardCatalogueRevision.json'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { SocketProvider } from '../../src/context/SocketContext'; +import { AuthContext } from '../../src/context/AuthContext'; +import V2UICard from '../../src/v2/cards/V2UICard'; +import '../../src/v2/v2.css'; +import '@fontsource/ibm-plex-sans/400.css'; +import '@fontsource/ibm-plex-sans/600.css'; +import '@fontsource/ibm-plex-mono/500.css'; +const sources = [ + 'root = Card("Release assessment", [Text("The change preserves identity and existing grants."), Steps([Step("Read the patch", "done"), Step("Check the release", "active", "CI is running"), Step("Publish", "pending")]), DiffSummary([File("frontend/src/v2/cards/V2UICard.tsx", 84, 12), File("backend/config/uiCardCatalogue.json", 30, 2)]), Link("Open pull request", "https://github.com/Team-Commonly/commonly/pull/2675", "primary")], "4 minutes ago", "The catalogue uses the same Signal tokens as the shell.")', + 'root = Card("Project state", [TaskStatus("TASK-498", "Agent note: ready for review"), KeyValue([Pair("Repository", "Team-Commonly/commonly"), Pair("Tests", 71)]), Badge("Review pending", "pending"), List(["The component API is fixed", "The trace persists"], true)])', + 'root = Card("Verification", [Table([Col("Surface"), Col("Width", "number"), Col("Result")], [["Pod chat", 1440, "Ready"], ["Pod chat", 390, "Ready"]]), RunTrace(3)])', + 'root = Card("Links", [Text(["Source: ", Link("GitHub", "https://github.com/Team-Commonly/commonly", "text")]), Link("Unapproved host", "https://github.com.evil.test/", "button"), Badge("Done", "done")])', + 'root = Text(@Run("unsafe"))', +]; +const auth = { token:null, currentUser:null, user:null, loading:false, error:null, isAuthenticated:false } as any; +createRoot(document.getElementById('root')!).render(
{sources.map((source, i) => )}
); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 34c22cdbc4..cef586344f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,7 +18,10 @@ "@fontsource/ibm-plex-sans": "^5.3.0", "@mui/icons-material": "^5.11.16", "@mui/material": "^5.12.1", + "@openuidev/lang-core": "1.0.1", + "@openuidev/react-lang": "1.0.1", "@sentry/react": "^10.65.0", + "ajv": "8.20.0", "axios": "^1.3.4", "chart.js": "^4.5.0", "d3": "^7.9.0", @@ -35,7 +38,8 @@ "react-i18next": "^17.0.10", "react-markdown": "^10.1.0", "react-router-dom": "^6.11.0", - "socket.io-client": "^4.7.2" + "socket.io-client": "^4.7.2", + "zod": "4.6.7" }, "devDependencies": { "@babel/core": "^7.24.0", @@ -2718,6 +2722,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -2733,6 +2754,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -4419,6 +4447,91 @@ "node": ">= 8" } }, + "node_modules/@openuidev/devtools": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@openuidev/devtools/-/devtools-0.3.0.tgz", + "integrity": "sha512-eT6+IUWNBL4k5JANAgKC03tQwutafBdTrAwTfVhhTherllk0R+5W/E4j64j1oBnNSlReRDwETyrGwyhHAwiMfA==", + "license": "MIT", + "dependencies": { + "lucide-react": "^0.575.0" + }, + "peerDependencies": { + "@openuidev/observability": ">=0.0.4 <0.1.0", + "@openuidev/react-lang": ">=1.0.0 <2.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@openuidev/react-lang": { + "optional": true + } + } + }, + "node_modules/@openuidev/lang-core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@openuidev/lang-core/-/lang-core-1.0.1.tgz", + "integrity": "sha512-4vBa7FRM6C4v4aCvBAmAtTgFWiEQiNTazG9h4Xy9fC6P/p6+9kZu4RdTyLToBEvdxrYn/gDy/PakEH8PU3lUcw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@openuidev/lang-core/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@openuidev/observability": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@openuidev/observability/-/observability-0.0.4.tgz", + "integrity": "sha512-4fjIrWu3TqTlfDE/m7cG10CH1vV7wQk5GPwMOmXeF9vJtkg92dg4Bdk3AjKgud7H7Xypwwjgt52VcLGZCE2CJQ==", + "license": "MIT" + }, + "node_modules/@openuidev/react-lang": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@openuidev/react-lang/-/react-lang-1.0.1.tgz", + "integrity": "sha512-jt5AKWyKe73BjD+bRC2kKerHCeAurI/D9KMQvbi736963D2lehpPGAvVGoxAN4MG6sM/7OOlfWvErQBPPPx30A==", + "license": "MIT", + "dependencies": { + "@openuidev/devtools": "^0.3.0", + "@openuidev/lang-core": "^1.0.1", + "@openuidev/observability": "^0.0.4" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0", + "react": "*", + "react-dom": "*", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -5859,15 +5972,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -8377,6 +8490,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -8392,6 +8522,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -8610,8 +8747,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -8653,6 +8789,22 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -12304,10 +12456,10 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -12500,6 +12652,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", + "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -14297,6 +14458,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requireindex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz", @@ -15561,6 +15731,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -16094,6 +16265,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4e8ddb2deb..dcd27991b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,10 @@ "@fontsource/ibm-plex-sans": "^5.3.0", "@mui/icons-material": "^5.11.16", "@mui/material": "^5.12.1", + "@openuidev/lang-core": "1.0.1", + "@openuidev/react-lang": "1.0.1", "@sentry/react": "^10.65.0", + "ajv": "8.20.0", "axios": "^1.3.4", "chart.js": "^4.5.0", "d3": "^7.9.0", @@ -30,17 +33,19 @@ "react-i18next": "^17.0.10", "react-markdown": "^10.1.0", "react-router-dom": "^6.11.0", - "socket.io-client": "^4.7.2" + "socket.io-client": "^4.7.2", + "zod": "4.6.7" }, "scripts": { "start": "vite", "build": "vite build && node scripts/generate-seo-pages.mjs", "preview": "vite preview", - "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs", + "test": "jest --watchAll=false --forceExit && node --test scripts/generate-seo-pages.test.mjs scripts/nginx-routing.test.mjs scripts/ui-card-catalogue.test.mjs", "test:coverage": "jest --coverage --watchAll=false --forceExit", "typecheck": "tsc --noEmit", "lint": "eslint src --ext .js,.jsx,.ts,.tsx", - "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix" + "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix", + "sync:ui-catalogue": "node scripts/sync-ui-card-catalogue.mjs" }, "devDependencies": { "@babel/core": "^7.24.0", diff --git a/frontend/scripts/sync-ui-card-catalogue.mjs b/frontend/scripts/sync-ui-card-catalogue.mjs new file mode 100644 index 0000000000..6db49196b7 --- /dev/null +++ b/frontend/scripts/sync-ui-card-catalogue.mjs @@ -0,0 +1,10 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +// Each Docker context ships its own byte-identical positional grammar and pin. +for (const file of ['uiCardCatalogue.json', 'uiCardCatalogueRevision.json']) { + const source = readFileSync(new URL(`../../backend/config/${file}`, import.meta.url)); + const generated = new URL(`../src/v2/cards/${file}`, import.meta.url); + if (process.argv.includes('--check')) { + if (!source.equals(readFileSync(generated))) throw new Error(`UI card ${file} differs. Run node frontend/scripts/sync-ui-card-catalogue.mjs`); + } else writeFileSync(generated, source); +} diff --git a/frontend/scripts/ui-card-catalogue.test.mjs b/frontend/scripts/ui-card-catalogue.test.mjs new file mode 100644 index 0000000000..33f99f87e2 --- /dev/null +++ b/frontend/scripts/ui-card-catalogue.test.mjs @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +test('frontend card grammar is byte-identical to the server grammar', () => { + const canonical = readFileSync(new URL('../../backend/config/uiCardCatalogue.json', import.meta.url)); + const generated = readFileSync(new URL('../src/v2/cards/uiCardCatalogue.json', import.meta.url)); + assert.deepEqual(generated, canonical, 'Run node frontend/scripts/sync-ui-card-catalogue.mjs'); +}); + +test('frontend revision pin matches the server and ordered grammar hash', async () => { + const { createHash } = await import('node:crypto'); + const canonical = readFileSync(new URL('../../backend/config/uiCardCatalogueRevision.json', import.meta.url)); + const generated = readFileSync(new URL('../src/v2/cards/uiCardCatalogueRevision.json', import.meta.url)); + assert.deepEqual(generated, canonical); + const grammar = JSON.parse(readFileSync(new URL('../src/v2/cards/uiCardCatalogue.json', import.meta.url))); + assert.equal(JSON.parse(generated).catalogHash, `sha256:${createHash('sha256').update(JSON.stringify(grammar)).digest('hex')}`); +}); + +test('sync check refuses drift and write mode restores both copies', () => { + const root = mkdtempSync(join(tmpdir(), 'card-catalogue-sync-')); + try { + for (const dir of ['frontend/scripts','frontend/src/v2/cards','backend/config']) mkdirSync(join(root,dir),{recursive:true}); + const script = join(root,'frontend/scripts/sync-ui-card-catalogue.mjs'); + copyFileSync(new URL('./sync-ui-card-catalogue.mjs',import.meta.url),script); + for (const file of ['uiCardCatalogue.json','uiCardCatalogueRevision.json']) { + copyFileSync(new URL(`../../backend/config/${file}`,import.meta.url),join(root,'backend/config',file)); + writeFileSync(join(root,'frontend/src/v2/cards',file),'{}'); + } + const check = spawnSync(process.execPath,[script,'--check']); + assert.notEqual(check.status,0); assert.match(check.stderr.toString(),/differs/); + assert.equal(spawnSync(process.execPath,[script]).status,0); + for(const file of ['uiCardCatalogue.json','uiCardCatalogueRevision.json']) assert.deepEqual(readFileSync(join(root,'frontend/src/v2/cards',file)),readFileSync(join(root,'backend/config',file))); + assert.equal(spawnSync(process.execPath,[script,'--check']).status,0); + } finally { rmSync(root,{recursive:true,force:true}); } +}); diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index b8e4f6afbf..1e37d8708f 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -1,4 +1,7 @@ +import uiCardRevision from '../cards/uiCardCatalogueRevision.json'; import React from 'react'; +import { TextEncoder } from 'util'; +Object.assign(global, { TextEncoder }); import { act, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import i18n from '../../i18n'; @@ -305,3 +308,18 @@ describe('V2MessageRow', () => { expect(screen.getByText('Pixel and codex started a DM').closest('.v2-syscard')).toHaveClass('v2-syscard--action'); }); }); + + +// Two lazy boundaries load the real parser/runtime; cold Jest transforms can exceed 1s. +test('dispatches a kind ui message into the real catalogue renderer', async () => { + render(); + expect(await screen.findByText('Actual rendered card', {}, { timeout: 15000 })).toBeInTheDocument(); + const card = screen.getByTestId('ui-card'); + expect(card).toHaveAttribute('id', 'message-ui-dispatch'); + card.focus(); + expect(card).toHaveFocus(); +}, 20000); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 975c0bbe0a..a5d8a47e59 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -4980,3 +4980,14 @@ test('TASK-435 runtime columns can shrink on phones without dropping the observe expect(ruleBody(css, '.v2-runtime-controls__observed')).toContain('overflow-wrap: anywhere'); expect(css).toContain('content: attr(data-label)'); }); + + +test('OpenUI cards keep the approved phone controls and table records', () => { + const css = read('../cards/uiCard.css'); + expect(css).toContain('@media (max-width:760px)'); + expect(css).toMatch(/\.ui-ft \.ub\{height:44px/); + expect(css).toContain('.ui-table--records .ui-tw { display:none; }'); + expect(css).toContain('.ui-table--records .ui-recs { display:grid; }'); + expect(css).toContain('.v2-root .v2-msg.v2-ui-card { display:flex; flex-direction:column;'); + expect(css).toContain('.ui.gone{border-style:dashed'); +}); diff --git a/frontend/src/v2/cards/CardRenderBoundary.tsx b/frontend/src/v2/cards/CardRenderBoundary.tsx new file mode 100644 index 0000000000..4bbba81824 --- /dev/null +++ b/frontend/src/v2/cards/CardRenderBoundary.tsx @@ -0,0 +1,8 @@ +import React, { Component } from 'react'; + +/** Keep a failed renderer or lazy chunk local to its message. */ +export default class CardRenderBoundary extends Component<{ children: React.ReactNode; fallback: React.ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { return { failed: true }; } + render() { return this.state.failed ? this.props.fallback : this.props.children; } +} diff --git a/frontend/src/v2/cards/V2UICard.tsx b/frontend/src/v2/cards/V2UICard.tsx new file mode 100644 index 0000000000..43fa1d6204 --- /dev/null +++ b/frontend/src/v2/cards/V2UICard.tsx @@ -0,0 +1,38 @@ +import React, { lazy, Suspense, useEffect, useState } from 'react'; +import type { V2Message } from '../hooks/useV2PodDetail'; +import { parseCard } from './uiCardSource'; +import { CardContext, TraceLink } from './cardViews'; +import './uiCard.css'; +import CardRenderBoundary from './CardRenderBoundary'; +import revision from './uiCardCatalogueRevision.json'; + +const Runtime = lazy(() => { + // OpenUI's development entry auto-mounts an inspector by default. Commonly + // intentionally uses only the headless renderer in every environment. + (globalThis as Record)[Symbol.for('openui.devtools.autoMount')] = true; + return import('./cardRuntime'); +}); +function CardBody({ message }: { message: V2Message }) { + const [failed, setFailed] = useState(false); + const [now, setNow] = useState(Date.now); + useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); return () => clearInterval(timer); }, []); + const payload = message.payload; + const supported = payload?.catalogId === revision.catalogId && payload?.catalogRev === revision.catalogRev + && payload?.catalogHash === revision.catalogHash; + const valid = supported && payload?.v === 1 && payload?.lang === 'openui@1' && !!parseCard(payload?.source); + const expired = now >= new Date(message.created_at || message.createdAt || '').getTime() + 30 * 86400_000; + const fallback = <>
{message.content}

{supported ? 'This card couldn’t be displayed.' : 'Unsupported card version.'}

; + if (expired) return

{message.content}

Card expires after 30 days. Trace persists.

; + if (!valid || failed) return
{fallback}
; + return {message.content}}> + setFailed(true)} /> + ; +} +export default function V2UICard({ message, authorLabel, time }: { message: V2Message; authorLabel: string; time?: string }) { + return
+
{authorLabel}{time && <> · }
+ + + +
; +} diff --git a/frontend/src/v2/cards/__tests__/CardRenderBoundary.test.tsx b/frontend/src/v2/cards/__tests__/CardRenderBoundary.test.tsx new file mode 100644 index 0000000000..4166d1e9e2 --- /dev/null +++ b/frontend/src/v2/cards/__tests__/CardRenderBoundary.test.tsx @@ -0,0 +1,15 @@ +import React, { lazy, Suspense } from 'react'; +import { render, screen } from '@testing-library/react'; +import CardRenderBoundary from '../CardRenderBoundary'; + +test('a rejected lazy card chunk leaves its message and sibling readable', async () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + const Broken = lazy(() => Promise.reject(new Error('chunk failed'))); + render(<>Other messageOriginal message

}> + +
); + expect(await screen.findByText('Original message')).toBeInTheDocument(); + expect(screen.getByText('Other message')).toBeInTheDocument(); + } finally { error.mockRestore(); } +}); diff --git a/frontend/src/v2/cards/__tests__/V2UICard.test.tsx b/frontend/src/v2/cards/__tests__/V2UICard.test.tsx new file mode 100644 index 0000000000..9584c04d4f --- /dev/null +++ b/frontend/src/v2/cards/__tests__/V2UICard.test.tsx @@ -0,0 +1,51 @@ +import revision from '../uiCardCatalogueRevision.json'; +import React from 'react'; +import { TextEncoder } from 'util'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import V2UICard from '../V2UICard'; +import type { V2Message } from '../../hooks/useV2PodDetail'; +Object.assign(global, { TextEncoder }); +const message = (source: string, created_at = new Date().toISOString()): V2Message => ({ + id: 'm1', pod_id: '111111111111111111111111', user_id: 'u1', message_type: 'card', created_at, + content: 'Plain fallback', payload: { ...revision, kind: 'ui', v: 1, lang: 'openui@1', source }, +}); +const show = (m: V2Message) => render(); +test('renders the real runtime and keeps the agent attribution', async () => { + show(message('root = Card("Result", [Text("Literal