diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 49dd9706..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: 2 -updates: - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - commit-message: - prefix: fix - prefix-development: chore - include: scope - - - package-ecosystem: npm - directory: /.github/release - schedule: - interval: weekly - commit-message: - prefix: ci - include: scope - - - package-ecosystem: github-actions - directory: / - schedule: - interval: weekly - commit-message: - prefix: ci - include: scope diff --git a/.github/release/package-lock.json b/.github/release/package-lock.json index 0a8889a8..30ea22b1 100644 --- a/.github/release/package-lock.json +++ b/.github/release/package-lock.json @@ -40,9 +40,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", - "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -1543,12 +1543,12 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -2182,9 +2182,9 @@ } }, "node_modules/npm": { - "version": "11.21.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.21.0.tgz", - "integrity": "sha512-Zov8KhamNneiLdELtj5YALtNmJW4L4fCLTzjfpzXG2w6MSHcf0UxgdlK5uloCuksWT+7mGUU7wi79cO6RqivPg==", + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.21.2.tgz", + "integrity": "sha512-7HPApk+AflMUavSseL1Pm6CnYqMIHk53x+KmuhqmTDqPkjPMgxIpTPcTqsE91EKNw4gFBJV9fnXiSdGm1DXq9A==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -2262,13 +2262,13 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.9.2", + "@npmcli/arborist": "^9.9.3", "@npmcli/config": "^10.14.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", "@npmcli/package-json": "^7.0.5", - "@npmcli/promise-spawn": "^9.0.1", + "@npmcli/promise-spawn": "^9.0.2", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.4", "@sigstore/tuf": "^4.0.2", @@ -2287,11 +2287,11 @@ "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.13", - "libnpmexec": "^10.3.3", - "libnpmfund": "^7.0.27", + "libnpmdiff": "^8.1.14", + "libnpmexec": "^10.3.5", + "libnpmfund": "^7.0.28", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.14", + "libnpmpack": "^9.1.15", "libnpmpublish": "^11.2.1", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -2311,12 +2311,12 @@ "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.5.1", + "pacote": "^21.5.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.8.5", + "semver": "^7.8.6", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", @@ -2403,7 +2403,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.9.2", + "version": "9.9.3", "inBundle": true, "license": "ISC", "dependencies": { @@ -2574,7 +2574,7 @@ } }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", + "version": "9.0.2", "inBundle": true, "license": "ISC", "dependencies": { @@ -2741,7 +2741,7 @@ } }, "node_modules/npm/node_modules/bin-links": { - "version": "6.0.2", + "version": "6.0.3", "inBundle": true, "license": "ISC", "dependencies": { @@ -2767,7 +2767,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.9", + "version": "5.0.12", "inBundle": true, "license": "MIT", "dependencies": { @@ -2954,7 +2954,7 @@ } }, "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.2.0", + "version": "4.3.0", "inBundle": true, "license": "BSD-2-Clause" }, @@ -3034,7 +3034,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.5.0", + "version": "10.7.5", "inBundle": true, "license": "MIT", "engines": { @@ -3107,11 +3107,11 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.13", + "version": "8.1.14", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.2", + "@npmcli/arborist": "^9.9.3", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -3125,12 +3125,12 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.3.3", + "version": "10.3.5", "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.9.2", + "@npmcli/arborist": "^9.9.3", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -3147,11 +3147,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.27", + "version": "7.0.28", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.2" + "@npmcli/arborist": "^9.9.3" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -3170,11 +3170,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.14", + "version": "9.1.15", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.9.2", + "@npmcli/arborist": "^9.9.3", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -3564,7 +3564,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.5.1", + "version": "21.5.2", "inBundle": true, "license": "ISC", "dependencies": { @@ -3622,7 +3622,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.4", + "version": "7.1.6", "inBundle": true, "license": "MIT", "dependencies": { @@ -3709,7 +3709,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.8.5", + "version": "7.8.6", "inBundle": true, "license": "ISC", "bin": { @@ -3911,7 +3911,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.28.0", + "version": "6.29.1", "inBundle": true, "license": "MIT", "engines": { @@ -5033,9 +5033,9 @@ } }, "node_modules/undici": { - "version": "7.30.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", - "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "version": "7.30.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.2.tgz", + "integrity": "sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/docs/api.md b/docs/api.md index bfa255f4..f3664074 100644 --- a/docs/api.md +++ b/docs/api.md @@ -638,7 +638,7 @@ All paths are under `/api/v1`. | GET | `/remote-access` | | `{ remoteAccess }`: `revision`, `mode`, `busy`, `pending`, `problem` and connection details. `url` is the active address. `candidate` is the address enabling would use with Tailscale's current settings, `{ url, existing, remembered, standardUrl? }`: `remembered` when it is the address used before, and `standardUrl` when `standard: true` would choose another. | | POST | `/remote-access/check` | | `{ remoteAccess }` after refreshing Tailscale's status. | | PUT | `/remote-access` | `{ revision, action, adopt?, hosts?, origins? }` | `202`. `action` is `enable` (`adopt: true` manages a matching existing route; `standard: true`, only while remote access is off, skips the remembered address and takes port 443, else the next free port), `disable`, `custom` (with `hosts` and `origins`) or `forget` (leave an inactive route awaiting cleanup in Tailscale). Poll until `busy` is null and read `problem`. 409 `stale_settings` for an old `revision`. Needs the API token. Revoked WebSockets close with code 4403; their terminals keep running. | -| GET | `/folders?path=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. | +| GET | `/folders?path=&files=` | | `{ path, parent, home, segments, roots, entries, truncated, note }`: the subfolders of `path` (blank or `~` for the home folder). `entries` are `{ name, path, hidden }`. A missing `path` lists the nearest folder above it and names the requested path in `note`. `files=agworld` adds `files`, the world files in that folder, each `{ name, path }`. | | POST | `/folders` | `{ path, name }` | 201 with the `GET /folders` listing of the new folder `name`, made inside `path`. | | GET | `/autostart` | | `{ autostart: { available, enabled, reason, note?, lastRun?, log? } }`: whether the manager starts when the user signs in. `reason` says why it is unavailable. `lastRun` is null or `{ at, outcome }`. On Linux outside WSL it also has `mode` (`off`, `sign-in`, `boot` or `both`) and `boot`, the systemd service's `{ available, enabled, reason, note, user, linger, state, commands }`. | | PUT | `/autostart` | `{ enabled }` or `{ mode }` | `{ autostart }` as above. `mode` (`off`, `sign-in` or `boot`) makes that the one way the manager starts; `boot` is offered only on Linux with systemd. | @@ -666,6 +666,9 @@ All paths are under `/api/v1`. | GET | `/yard-art` | | `{ yardArt }`: the Yard's art. `state` is `ready`, `missing`, `downloading` (with `received` bytes), `installing`, `failed` or `unavailable` (each with `error`), or `cancelled`; `size` is the pack's size in bytes. A checkout serves `web/yard/assets` and is always `ready`. `/yard/assets/` answers 404 until the art is `ready`. | | POST | `/yard-art/download` | | `202 { yardArt }`: starts the one download of the art pack named by the package, unless it is running or installed. Progress and the outcome arrive as `yard-art.updated`. | | POST | `/yard-art/cancel` | | `{ yardArt }`: stops the download for every client; the state becomes `cancelled`. | +| GET | `/yard-worlds` | | `{ yardWorlds }`: the world replacing each Yard, by skin. A world is `{ title, revision, lights }`, or `{ title, problem }` when it cannot be used, in which case the Yard shows its original. A Yard with no entry shows its original. | +| PUT | `/yard-worlds/:skin` | `{ path }` | `{ yardWorlds }`: checks the `.agworld` file at `path` on the manager's computer and makes it this Yard's world, replacing any before it. 400 `world_invalid` says what is wrong and leaves the Yard unchanged; 404 `unknown_yard`; 409 `world_busy` while another install runs; 409 `worlds_unsupported` without a data folder; 500 `world_unwritable`. Its plates and skies are served at `/yard/worlds/:skin/:file`. | +| DELETE | `/yard-worlds/:skin` | | `{ yardWorlds }`: deletes this Yard's world, so it shows its original. | | POST | `/companion/install` | | `201 { session }`: starts a session that downloads the latest Grove Companion, checks its signature, installs it and starts it. 400 `companion_unsupported` unless the manager runs on Windows or macOS; 409 `install_in_progress` while one runs. | | POST | `/yard/fireworks` | | `{ ok, pages }`: fireworks over the Yard of every open page, sent as `yard.fireworks`. `pages` is how many pages were listening. | | GET | `/changelog` | | `{ refreshing, okAt, error, releases }`: Agent Guild's own releases, newest first, each `{ version, url, publishedAt, sections: [{ title, changes }] }`, where `title` may be null and a change is a list of text runs. A `changelog.updated` event follows a refresh. | @@ -743,7 +746,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`. | Message | Meaning | | --- | --- | -| `{ type: "hello", version, pid, platform, startedAt, launcher, folderOpener, remoteAccess, upgrade, yardArt, sessions, heartbeat, notesRevision, notesUnreadable }` | Sent first: the manager (as in `/info`), the full session list and the notepad's revision. | +| `{ type: "hello", version, pid, platform, startedAt, launcher, folderOpener, remoteAccess, upgrade, yardArt, yardWorlds, sessions, heartbeat, notesRevision, notesUnreadable }` | Sent first: the manager (as in `/info`), the full session list and the notepad's revision. | | `{ type: "session.created", session }` | A session was started by any client. | | `{ type: "session.updated", session }` | Status, activity, agents, name or size changed. | | `{ type: "session.removed", sessionId }` | A session was removed. | @@ -751,6 +754,7 @@ but `{ type: "ping" }`, which the manager answers with `{ type: "pong" }`. | `{ type: "environment.updated", environment }` | An environment check started or finished. | | `{ type: "news.updated" }` | Fetch `/news` again. | | `{ type: "yard-art.updated", yardArt }` | The Yard's art changed state or made download progress, as in `/yard-art`. | +| `{ type: "yard-worlds.updated", yardWorlds }` | A Yard's world was replaced or deleted, as in `/yard-worlds`. | | `{ type: "yard.fireworks" }` | Fireworks over the Yard, when it is on show. | | `{ type: "changelog.updated" }` | Fetch `/changelog` again. | | `{ type: "github.updated" }` | Fetch `/github` again. | diff --git a/docs/yard/fixture.mjs b/docs/yard/fixture.mjs index 73a3b0df..3a070bc4 100644 --- a/docs/yard/fixture.mjs +++ b/docs/yard/fixture.mjs @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { createManagerServer } from '../../src/manager/server.mjs'; -export async function startFixture({port=0,yardArt}={}) { +export async function startFixture({port=0,yardArt,yardWorlds}={}) { const calls=[],data=new Map(),terminals=new Map(); let next=16; const now=()=>new Date().toISOString(); @@ -96,7 +96,7 @@ export async function startFixture({port=0,yardArt}={}) { agents:i===0?[{id:'a1',name:'Explorer',status:'working',detail:'Reading the project'},{id:'a2',name:'Reviewer',status:'waiting',detail:'Waiting for input'}]:i===1?[{id:'a3',name:'Designer',status:'working'}]:[], shells:i===4?[{id:'sh1'}]:[]},false)); const token=randomBytes(24).toString('hex'); - const api=createManagerServer({manager,registry,usage,history,modelStats,news,changelog,github,selfUpdate,token,port,webDir:fileURLToPath(new URL('../../web',import.meta.url)),version:'1.0.0',...(yardArt?{yardArt}:{}), + const api=createManagerServer({manager,registry,usage,history,modelStats,news,changelog,github,selfUpdate,token,port,webDir:fileURLToPath(new URL('../../web',import.meta.url)),version:'1.0.0',...(yardArt?{yardArt}:{}),...(yardWorlds?{yardWorlds}:{}), onShutdownRequest:({restart})=>{calls.push(['shutdown',restart]);manager.emit('event',{type:'manager.stopped',remaining:0,restart});if(restart){data.clear();manager.closing=false;setTimeout(()=>manager.emit('event',{type:'hello',version:'1.1.0',pid:process.pid,sessions:[],upgrade:null}),100);}} }); await api.listen(); diff --git a/package-lock.json b/package-lock.json index da23ef93..c160661c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,11 +28,11 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/headless": "^6.0.0", "@xterm/xterm": "^6.0.0", - "markdown-it": "^15.0.2", + "markdown-it": "^15.0.3", "node-pty": "^1.2.0-beta.15", "qrcode-generator": "2.0.4", "smol-toml": "1.9.1", - "ws": "^8.22.0" + "ws": "^8.22.1" }, "bin": { "agent-guild": "bin/agent-guild.mjs", @@ -43,7 +43,7 @@ "@gltf-transform/extensions": "4.5.1", "@gltf-transform/functions": "4.5.1", "esbuild": "0.28.2", - "three": "0.186.1" + "three": "0.187.0" }, "engines": { "node": ">=22" @@ -1188,9 +1188,9 @@ ] }, "node_modules/argparse": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.2.tgz", - "integrity": "sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.4.tgz", + "integrity": "sha512-fwWK3Zq22l4XJJjx5mcBHGikIk+hJqO+32dPm0Jur7+rtG5x1scOjSfdT9ZKeWHPpsya/l0HHK4tDImBkTrtvA==", "funding": [ { "type": "github", @@ -1321,9 +1321,9 @@ } }, "node_modules/markdown-it": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", - "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", "funding": [ { "type": "github", @@ -1337,9 +1337,9 @@ "inBundle": true, "license": "MIT", "dependencies": { - "argparse": "^3.0.0", - "entities": "^8.0.0", - "linkify-it": "^6.0.0", + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" @@ -1519,9 +1519,9 @@ } }, "node_modules/three": { - "version": "0.186.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz", - "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==", + "version": "0.187.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.187.0.tgz", + "integrity": "sha512-Qiu7R+Cmh8CstZsY1uJOSWnVlfo7ZtNLV7mF8Jltk3LB3AtDu9cRZlcnRKkAk8Y4z6KKw35z7Y7Urd7jWY8etA==", "dev": true, "license": "MIT" }, @@ -1548,9 +1548,9 @@ "license": "MIT" }, "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "version": "8.22.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", + "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", "inBundle": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 6c5df805..447aae75 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,11 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/headless": "^6.0.0", "@xterm/xterm": "^6.0.0", - "markdown-it": "^15.0.2", + "markdown-it": "^15.0.3", "node-pty": "^1.2.0-beta.15", "qrcode-generator": "2.0.4", "smol-toml": "1.9.1", - "ws": "^8.22.0" + "ws": "^8.22.1" }, "bundleDependencies": true, "publishConfig": { @@ -74,6 +74,6 @@ "@gltf-transform/extensions": "4.5.1", "@gltf-transform/functions": "4.5.1", "esbuild": "0.28.2", - "three": "0.186.1" + "three": "0.187.0" } } diff --git a/src/manager/config.mjs b/src/manager/config.mjs index e1fafdba..bdfd7fde 100644 --- a/src/manager/config.mjs +++ b/src/manager/config.mjs @@ -56,6 +56,7 @@ export const paths = { get multiplexers() { return path.join(dataDir(), 'multiplexers.json'); }, /** The Yard's art packs, one folder per content id. */ get yardArt() { return path.join(dataDir(), 'yard-art'); }, + get yardWorlds() { return path.join(dataDir(), 'yard-worlds'); }, }; function writePrivate(file, contents) { diff --git a/src/manager/folder-browser.mjs b/src/manager/folder-browser.mjs index 228e46ab..8b0ec91d 100644 --- a/src/manager/folder-browser.mjs +++ b/src/manager/folder-browser.mjs @@ -73,6 +73,12 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom return folders.filter(Boolean).sort(byName); } + async function filesIn(dir, extension) { + const listed = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []); + return listed.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(extension)) + .map((entry) => ({ name: entry.name, path: paths.join(dir, entry.name) })).sort(byName); + } + const browser = { async create(rawParent, rawName) { if (typeof rawParent !== 'string' || !rawParent.trim() || rawParent.includes('\0')) throw failure(400, 'bad_path', 'The folder must be a path.'); @@ -97,7 +103,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom } return browser.list(dir); }, - async list(raw) { + async list(raw, { extension = null } = {}) { if (raw !== undefined && raw !== null && (typeof raw !== 'string' || raw.includes('\0'))) { throw failure(400, 'bad_path', 'The folder must be a path.'); } @@ -105,7 +111,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom if (asked === '~' || asked.startsWith('~/') || asked.startsWith('~\\')) asked = paths.join(home, asked.slice(1)); const wanted = asked ? paths.resolve(asked) : home; const dir = await nearestFolder(wanted); - const [folders, drives] = await Promise.all([entries(dir), roots()]); + const [folders, drives, files] = await Promise.all([entries(dir), roots(), extension ? filesIn(dir, extension) : null]); const parent = paths.dirname(dir); return { path: dir, @@ -116,6 +122,7 @@ export function createFolderBrowser({ platform = process.platform, home = os.hom entries: folders.slice(0, limit), truncated: folders.length > limit, note: dir === wanted ? null : wanted, + ...(files ? { files: files.slice(0, limit) } : {}), }; }, }; diff --git a/src/manager/main.mjs b/src/manager/main.mjs index 18983520..d1d30bbb 100644 --- a/src/manager/main.mjs +++ b/src/manager/main.mjs @@ -17,6 +17,7 @@ import { Changelog } from './changelog.mjs'; import { GitHub } from './github.mjs'; import { createManagerServer } from './server.mjs'; import { YardArt } from './yard-art.mjs'; +import { YardWorlds } from './yard-worlds.mjs'; import { loadPty } from './pty.mjs'; import { createNotesStore } from './notes.mjs'; import { createCardNames } from './card-names.mjs'; @@ -186,6 +187,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST, notes: createNotesStore(paths.notes), launcher: launcherPath(), yardArt: new YardArt({ webDir, dir: paths.yardArt }), + yardWorlds: new YardWorlds({ dir: paths.yardWorlds }), mcpConnect, onShutdownRequest: ({ restart = false } = {}) => shutdown('requested via API', { restart }).then((code) => process.exit(code)), }); diff --git a/src/manager/server.mjs b/src/manager/server.mjs index 8b663b95..d916cfba 100644 --- a/src/manager/server.mjs +++ b/src/manager/server.mjs @@ -19,6 +19,7 @@ import { createOptionalCards } from './optional-cards.mjs'; import { MODES as AUTOSTART_MODES } from './autostart.mjs'; import { ptyRestartProblem } from './pty.mjs'; import { YardArt } from './yard-art.mjs'; +import { YardWorlds } from './yard-worlds.mjs'; import { spokenText } from './spoken-text.mjs'; import { MCP_ACTIONS } from './mcp-connect.mjs'; @@ -185,6 +186,7 @@ export function createManagerServer({ nextManagerProblem = ptyRestartProblem, /** The Yard's art; without a data folder, only a checkout's. */ yardArt = new YardArt({ webDir }), + yardWorlds = new YardWorlds(), mcpConnect = null, heartbeatMs = 30000, }) { @@ -292,6 +294,12 @@ export function createManagerServer({ throw new HttpError(400, 'malformed path', 'bad_path'); } if (rel.includes('\0')) throw new HttpError(404, 'not found', 'not_found'); + if (rel.startsWith('yard/worlds/')) { + const [skin, name, ...rest] = rel.slice('yard/worlds/'.length).split('/'); + const world = rest.length ? null : yardWorlds.file(skin, name); + if (!world) throw new HttpError(404, 'not found', 'not_found'); + return serveFile(req, res, world); + } let root = webDir; if (rel.startsWith('yard/assets/')) { root = yardArt.assetsDir(); @@ -408,6 +416,10 @@ export function createManagerServer({ if (route === '/yard-art' && method === 'GET') return sendJson(res, 200, { yardArt: yardArt.snapshot() }); if (route === '/yard-art/download' && method === 'POST') return sendJson(res, 202, { yardArt: yardArt.start() }); if (route === '/yard-art/cancel' && method === 'POST') return sendJson(res, 200, { yardArt: yardArt.cancel() }); + if (route === '/yard-worlds' && method === 'GET') return sendJson(res, 200, { yardWorlds: yardWorlds.snapshot() }); + const yardWorld = route.match(/^\/yard-worlds\/([a-z]+)$/); + if (yardWorld && method === 'PUT') return sendJson(res, 200, { yardWorlds: await yardWorlds.replace(yardWorld[1], (await readJsonBody(req)).path) }); + if (yardWorld && method === 'DELETE') return sendJson(res, 200, { yardWorlds: await yardWorlds.restore(yardWorld[1]) }); if (route === '/yard/fireworks' && method === 'POST') { broadcast({ type: 'yard.fireworks' }); return sendJson(res, 200, { ok: true, pages: [...eventClients].filter((ws) => !ws.revoked && ws.readyState === ws.OPEN).length }); @@ -435,7 +447,7 @@ export function createManagerServer({ return sendJson(res, 200, { ok: true }); } if (route === '/folders' && method === 'GET') { - return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined)); + return sendJson(res, 200, await folderBrowser.list(url.searchParams.get('path') ?? undefined, { extension: url.searchParams.get('files') === 'agworld' ? '.agworld' : null })); } if (route === '/folders' && method === 'POST') { const { path: parent, name } = await readJsonBody(req); @@ -771,10 +783,11 @@ export function createManagerServer({ remoteAccess?.on('updated', () => broadcast({ type: 'remote-access.updated' })); deviceSignIn.on('updated', () => broadcast({ type: 'sign-in.updated' })); yardArt.on('updated', (status) => broadcast({ type: 'yard-art.updated', yardArt: status })); + yardWorlds.on('updated', (worlds) => broadcast({ type: 'yard-worlds.updated', yardWorlds: worlds })); function handleEvents(ws, req) { eventClients.add(ws); - const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), yardArt: yardArt.snapshot(), sessions: manager.list(), heartbeat: true }; + const hello = { type: 'hello', version, pid: process.pid, platform: process.platform, startedAt, launcher, folderOpener: folderOpenerFor(req), remoteAccess: remoteAccess ? { available: true } : null, upgrade: upgradeInfo(), yardArt: yardArt.snapshot(), yardWorlds: yardWorlds.snapshot(), sessions: manager.list(), heartbeat: true }; const notesHello = notes.helloRevision(); if (notesHello.known) hello.notesRevision = notesHello.revision; else hello.notesUnreadable = true; diff --git a/src/manager/yard-art.mjs b/src/manager/yard-art.mjs index 0857194b..fdfc7bb1 100644 --- a/src/manager/yard-art.mjs +++ b/src/manager/yard-art.mjs @@ -283,7 +283,7 @@ export class YardArt extends EventEmitter { } // Windows can hold a just-written folder briefly (indexing, antivirus). -async function renameRetrying(from, to) { +export async function renameRetrying(from, to) { for (let attempt = 0; ; attempt++) { try { return await fsp.rename(from, to); } catch (err) { if (attempt >= 20 || !['EPERM', 'EBUSY', 'EACCES'].includes(err.code)) throw err; diff --git a/src/manager/yard-worlds.mjs b/src/manager/yard-worlds.mjs new file mode 100644 index 00000000..a8ce0959 --- /dev/null +++ b/src/manager/yard-worlds.mjs @@ -0,0 +1,214 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import { promisify } from 'node:util'; +import { EventEmitter } from 'node:events'; +import { WORLDS, CAMERA, SUN, STAGING } from '../../web/yard/model.mjs'; +import { extractArchive, renameRetrying } from './yard-art.mjs'; + +const gunzip = promisify(zlib.gunzip); +const MANIFEST = 'world.json'; +const INSTALL = 'install-id'; +const FORMAT = 1; +const KIND = 'yard-environment'; +const MAX_PACKAGE = 64 * 1024 * 1024; +const MAX_UNPACKED = 64 * 1024 * 1024; +const MAX_ENTRY = 16 * 1024 * 1024; +const MAX_RATIO = 20; +const RATIO_FROM = 8 * 1024 * 1024; +const MAX_SIDE = 4096; +const SKY_HEADER = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 256 +X 512\n'; +const SKY_SIZE = SKY_HEADER.length + 512 * 256 * 4; +const THEMES = ['light', 'dark']; +const TILES = ['base-0-0', 'base-1-0', 'detail-0-0', 'detail-1-0', 'close-0-0']; +const REQUIRED = [...THEMES.flatMap((theme) => TILES.map((tile) => `web/${theme}-${tile}.webp`)), 'web/sky-light.hdr', 'web/sky-dark.hdr']; +const ALLOWED = new Set([...REQUIRED, 'preview.jpg']); +const SERVED = new Set(REQUIRED.map((name) => name.slice('web/'.length))); + +const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex'); +const failure = (status, code, message) => Object.assign(new Error(message), { status, code }); +const invalid = (message) => failure(400, 'world_invalid', message); + +export function environmentId() { + return sha256(JSON.stringify([CAMERA, SUN, STAGING.plaza])).slice(0, 16); +} + +function checkManifest(meta) { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) throw invalid('This world’s details can’t be read.'); + if (!Number.isInteger(meta.format) || meta.format < 1) throw invalid('This world’s format is missing.'); + if (meta.format > FORMAT) throw invalid('This world needs a newer Agent Guild.'); + if (meta.kind !== KIND) throw invalid('This isn’t a Yard environment.'); + const title = typeof meta.title === 'string' ? meta.title.trim() : ''; + if (!title || title.length > 128 || /\p{Cc}/u.test(title)) throw invalid('This world’s title is missing or too long.'); + if (meta.environment !== environmentId()) throw invalid('This world was made for a different Yard layout, so it wouldn’t line up.'); + if (!meta.files || typeof meta.files !== 'object' || Array.isArray(meta.files)) throw invalid('This world’s list of files is missing.'); + let total = 0; + for (const [name, file] of Object.entries(meta.files)) { + if (!ALLOWED.has(name)) throw invalid(`${name} doesn’t belong in a world file.`); + if (!Number.isSafeInteger(file?.size) || file.size < 0 || file.size > MAX_ENTRY || !/^[0-9a-f]{64}$/.test(file.sha256)) throw invalid(`${name} has invalid details.`); + total += file.size; + } + if (total > MAX_UNPACKED) throw invalid('This world unpacks to more than a world may.'); + for (const name of REQUIRED) if (!Object.hasOwn(meta.files, name)) throw invalid(`${name} is missing.`); + if (meta.lights !== undefined) { + const colour = (value) => Number.isInteger(value) && value >= 0 && value <= 0xffffff; + if (!THEMES.every((theme) => colour(meta.lights?.[theme]?.sun) && colour(meta.lights?.[theme]?.hemi))) throw invalid('This world’s light colours aren’t valid.'); + } + return { title, lights: meta.lights ?? null }; +} + +function webpSize(b) { + const chunk = b.toString('latin1', 12, 16); + if (chunk === 'VP8 ' && b.length >= 30) return [b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff]; + if (chunk === 'VP8L' && b.length >= 25) { + const v = b.readUInt32LE(21); + return [(v & 0x3fff) + 1, ((v >>> 14) & 0x3fff) + 1]; + } + if (chunk === 'VP8X' && b.length >= 30) return [b.readUIntLE(24, 3) + 1, b.readUIntLE(27, 3) + 1]; + return [0, 0]; +} + +function checkContent(name, b) { + if (name.endsWith('.webp')) { + if (b.toString('latin1', 0, 4) !== 'RIFF' || b.toString('latin1', 8, 12) !== 'WEBP') throw invalid(`${name} isn’t a WebP image.`); + if (webpSize(b).some((side) => side <= 0 || side > MAX_SIDE)) throw invalid(`${name} has an unsafe image size.`); + } else if (name.endsWith('.hdr')) { + if (b.length !== SKY_SIZE || b.toString('latin1', 0, SKY_HEADER.length) !== SKY_HEADER) throw invalid(`${name} isn’t a sky image.`); + } else if (b[0] !== 0xff || b[1] !== 0xd8 || b[2] !== 0xff) throw invalid(`${name} isn’t a JPEG image.`); +} + +async function readPackage(file) { + if (typeof file !== 'string' || !path.isAbsolute(file) || file.includes('\0') || !file.toLowerCase().endsWith('.agworld')) throw invalid('Choose an .agworld file.'); + let raw; + try { + const stat = await fsp.stat(file); + if (!stat.isFile()) throw invalid('Choose an .agworld file.'); + if (stat.size > MAX_PACKAGE) throw invalid('This file is larger than a world can be.'); + raw = await fsp.readFile(file); + } catch (err) { + if (err.status) throw err; + throw invalid('This file can’t be read.'); + } + let tar; + try { tar = await gunzip(raw, { maxOutputLength: MAX_UNPACKED + 1 }); } catch (err) { + throw invalid(err.code === 'ERR_BUFFER_TOO_LARGE' ? 'This world unpacks to more than a world may.' : 'This isn’t a world file.'); + } + if (tar.length > MAX_UNPACKED) throw invalid('This world unpacks to more than a world may.'); + if (tar.length > RATIO_FROM && tar.length / raw.length > MAX_RATIO) throw invalid('This file expands far more than a world should.'); + const name = tar.toString('utf8', 0, 100).replace(/\0.*$/s, ''); + const size = parseInt(tar.toString('latin1', 124, 136).replace(/\0.*$/s, '').trim(), 8); + const type = tar[156] === 0 ? '0' : String.fromCharCode(tar[156]); + if (name !== MANIFEST || type !== '0' || !Number.isSafeInteger(size) || size > MAX_ENTRY || 512 + size > tar.length) throw invalid('This isn’t a world file.'); + const bytes = tar.subarray(512, 512 + size); + let meta; + try { meta = JSON.parse(bytes.toString('utf8')); } catch { throw invalid('This world’s details can’t be read.'); } + checkManifest(meta); + const files = [{ path: MANIFEST, size, sha256: sha256(bytes) }, ...Object.entries(meta.files).map(([entry, details]) => ({ path: entry, size: details.size, sha256: details.sha256 }))]; + return { files, meta }; +} + +export class YardWorlds extends EventEmitter { + constructor({ dir = null, remove = (folder) => fsp.rm(folder, { recursive: true, force: true, maxRetries: 5 }) } = {}) { + super(); + this.dir = dir; + this.worlds = {}; + this.busy = false; + this.remove = remove; + this.swept = Promise.resolve(); + if (!dir) return; + for (const skin of Object.keys(WORLDS)) this.read(skin); + this.swept = this.sweep(); + } + + snapshot() { return this.worlds; } + + discard(folder) { + return this.remove(folder).catch(() => {}); + } + + async sweep() { + const names = await fsp.readdir(this.dir).catch(() => []); + await Promise.all(names.filter((name) => name.startsWith('.')).map((name) => this.discard(path.join(this.dir, name)))); + } + + aside(skin, kind) { + return path.join(this.dir, `.${skin}.${crypto.randomUUID()}.${kind}`); + } + + file(skin, name) { + return this.dir && Object.hasOwn(WORLDS, skin) && SERVED.has(name) ? path.join(this.dir, skin, 'web', name) : null; + } + + read(skin) { + const folder = path.join(this.dir, skin); + if (!fs.existsSync(folder)) { delete this.worlds[skin]; return; } + let title; + try { + const bytes = fs.readFileSync(path.join(folder, MANIFEST)); + const meta = JSON.parse(bytes.toString('utf8')); + const checked = checkManifest(meta); + title = checked.title; + for (const [name, details] of Object.entries(meta.files)) { + let content; + try { content = fs.readFileSync(path.join(folder, ...name.split('/'))); } catch { content = null; } + if (!content || content.length !== details.size || sha256(content) !== details.sha256) throw invalid(`${title} is damaged: ${name} is missing or changed.`); + } + const install = fs.readFileSync(path.join(folder, INSTALL)); + this.worlds[skin] = { title, revision: sha256(Buffer.concat([bytes, install])).slice(0, 16), lights: checked.lights }; + } catch (err) { + this.worlds[skin] = { title: title ?? null, problem: err.status ? err.message : `${title ?? 'This Yard’s world'} is damaged.` }; + } + } + + async exclusive(skin, work) { + if (!Object.hasOwn(WORLDS, skin)) throw failure(404, 'unknown_yard', 'There is no such Yard.'); + if (!this.dir) throw failure(409, 'worlds_unsupported', 'This manager has no data folder to keep worlds in.'); + if (this.busy) throw failure(409, 'world_busy', 'Another world is being installed. Try again when it finishes.'); + this.busy = true; + const before = JSON.stringify(this.worlds[skin] ?? null); + try { + await work(); + } catch (err) { + if (err.status) throw err; + throw failure(500, 'world_unwritable', `Agent Guild cannot write to its data folder: ${err.message}`); + } finally { + this.read(skin); + this.busy = false; + if (JSON.stringify(this.worlds[skin] ?? null) !== before) this.emit('updated', this.snapshot()); + } + return this.snapshot(); + } + + replace(skin, file) { + return this.exclusive(skin, async () => { + const { files } = await readPackage(file); + const target = path.join(this.dir, skin), staging = this.aside(skin, 'staging'), previous = this.aside(skin, 'old'); + await fsp.mkdir(this.dir, { recursive: true }); + try { + try { await extractArchive(file, files, staging); } catch (err) { + if (['ENOSPC', 'EACCES', 'EPERM', 'EBUSY', 'EROFS'].includes(err.code)) throw err; + throw invalid(`This world file is damaged: ${err.message}.`); + } + for (const { path: name } of files) if (name !== MANIFEST) checkContent(name, await fsp.readFile(path.join(staging, ...name.split('/')))); + await fsp.writeFile(path.join(staging, INSTALL), crypto.randomUUID()); + const had = fs.existsSync(target); + if (had) await renameRetrying(target, previous); + try { await renameRetrying(staging, target); } catch (err) { + if (had) await renameRetrying(previous, target); + throw err; + } + } finally { await this.discard(staging); } + await this.sweep(); + }); + } + + restore(skin) { + return this.exclusive(skin, async () => { + const target = path.join(this.dir, skin); + if (fs.existsSync(target)) await renameRetrying(target, this.aside(skin, 'old')); + await this.sweep(); + }); + } +} diff --git a/tests/browser/README.md b/tests/browser/README.md index 63fc270d..8b20e061 100644 --- a/tests/browser/README.md +++ b/tests/browser/README.md @@ -92,7 +92,7 @@ message preview using fixed demo responses. Parser, paging, account isolation and stale-response tests run under `npm test` with synthetic files and explicitly released promises; they do not call a provider or depend on timing delays. -To also run WebKit (as CI does on macOS, where it needs no system packages), +To run the WebKit pass instead of Chrome (as CI does on macOS, where it needs no system packages), install the test tools separately from the application's dependencies: ```sh diff --git a/tests/browser/chrome.mjs b/tests/browser/chrome.mjs index 79e79ef9..e528bea9 100644 --- a/tests/browser/chrome.mjs +++ b/tests/browser/chrome.mjs @@ -151,10 +151,7 @@ export async function withDialogClose(evaluate, selector, action) { } } -export async function withPage({ name, instrumentation = '', headers = () => ({}), chromeArgs = [] }, run) { - const chromePath = [process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/chromium', - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((file) => file && fs.existsSync(file)); - assert.ok(chromePath, 'Chrome is required; set CHROME_PATH to its executable'); +export async function serve({ instrumentation = '', headers = () => ({}) }, run) { const server = http.createServer((req, res) => { const route = new URL(req.url, 'http://localhost').pathname; const file = vendor[route] ? path.join(repo, 'node_modules', vendor[route]) @@ -173,65 +170,76 @@ export async function withPage({ name, instrumentation = '', headers = () => ({} }); server.listen(0, '127.0.0.1'); await new Promise((resolve) => server.once('listening', resolve)); - const origin = `http://127.0.0.1:${server.address().port}`; - let launched, socket; - let checks = 0; - const pass = (label) => { checks++; console.log(`ok ${checks} - ${label}`); }; try { - launched = await launchChrome({ chromePath, name, chromeArgs }); - const { port } = launched; - const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); - socket = new WebSocket(tabs.find((tab) => tab.type === 'page').webSocketDebuggerUrl); - await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); - let nextId = 0; - const pending = new Map(), errors = [], listeners = new Set(); - const send = (method, params = {}) => new Promise((resolve, reject) => { - const id = ++nextId; - const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 15000); - pending.set(id, { method, resolve, reject, timer }); - socket.send(JSON.stringify({ id, method, params })); - }); - socket.onmessage = ({ data }) => { - const message = JSON.parse(data); - if (message.method === 'Page.javascriptDialogOpening') { - // Only unsaved notes may guard navigation. - if (message.params.type !== 'beforeunload') errors.push(`Unexpected ${message.params.type} dialog`); - send('Page.handleJavaScriptDialog', { accept: true }).catch((error) => errors.push(error.message)); - } - if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); - if (message.method) for (const listener of listeners) listener(message); - const request = pending.get(message.id); - if (!request) return; - pending.delete(message.id); - clearTimeout(request.timer); - if (message.error) request.reject(new Error(`${request.method}: ${message.error.message}`)); - else request.resolve(message.result); - }; - const evaluate = async (expression) => { - const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); - if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); - return result.result.value; - }; - const layoutReady = () => evaluate('document.fonts.ready.then(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))'); - await send('Page.enable'); - await send('Runtime.enable'); - await send('Page.setLifecycleEventsEnabled', { enabled: true }); - // A headless page starts without window focus, and then focus() fires no focus event until the page gains focus, - // whenever that is. Emulated focus makes every focus() and blur() fire at once, as in a focused window. - await send('Emulation.setFocusEmulationEnabled', { enabled: true }); - if (!(await evaluate('document.hasFocus()'))) throw new Error('the page does not have focus'); - const { frameTree } = await send('Page.getFrameTree'); - const listen = (listener) => { listeners.add(listener); return () => listeners.delete(listener); }; - const { navigate, reload } = pageLoads(send, listen, frameTree.frame.id); - const pageSend = (method, params) => (method === 'Page.navigate' || method === 'Page.reload' - ? Promise.reject(new Error(`${method}: use navigate() or reload(), which wait for the new document`)) : send(method, params)); - await run({ origin, send: pageSend, evaluate, layoutReady, pass, errors, navigate, reload }); - return checks; + return await run(`http://127.0.0.1:${server.address().port}`); } finally { - socket?.close(); - if (launched) await discard(launched.chrome, launched.profile); - // Likewise close the page's keep-alive connections rather than waiting for them to idle out. + // Close the page's keep-alive connections rather than waiting for them to idle out. server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); } } + +export async function withPage({ name, instrumentation, headers, chromeArgs = [] }, run) { + const chromePath = [process.env.CHROME_PATH, '/usr/bin/google-chrome', '/usr/bin/chromium', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'].find((file) => file && fs.existsSync(file)); + assert.ok(chromePath, 'Chrome is required; set CHROME_PATH to its executable'); + return serve({ instrumentation, headers }, async (origin) => { + let launched, socket; + let checks = 0; + const pass = (label) => { checks++; console.log(`ok ${checks} - ${label}`); }; + try { + launched = await launchChrome({ chromePath, name, chromeArgs }); + const { port } = launched; + const tabs = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + socket = new WebSocket(tabs.find((tab) => tab.type === 'page').webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); + let nextId = 0; + const pending = new Map(), errors = [], listeners = new Set(); + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++nextId; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 15000); + pending.set(id, { method, resolve, reject, timer }); + socket.send(JSON.stringify({ id, method, params })); + }); + socket.onmessage = ({ data }) => { + const message = JSON.parse(data); + if (message.method === 'Page.javascriptDialogOpening') { + // Only unsaved notes may guard navigation. + if (message.params.type !== 'beforeunload') errors.push(`Unexpected ${message.params.type} dialog`); + send('Page.handleJavaScriptDialog', { accept: true }).catch((error) => errors.push(error.message)); + } + if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text); + if (message.method) for (const listener of listeners) listener(message); + const request = pending.get(message.id); + if (!request) return; + pending.delete(message.id); + clearTimeout(request.timer); + if (message.error) request.reject(new Error(`${request.method}: ${message.error.message}`)); + else request.resolve(message.result); + }; + const evaluate = async (expression) => { + const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text); + return result.result.value; + }; + const layoutReady = () => evaluate('document.fonts.ready.then(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))'); + await send('Page.enable'); + await send('Runtime.enable'); + await send('Page.setLifecycleEventsEnabled', { enabled: true }); + // A headless page starts without window focus, and then focus() fires no focus event until the page gains focus, + // whenever that is. Emulated focus makes every focus() and blur() fire at once, as in a focused window. + await send('Emulation.setFocusEmulationEnabled', { enabled: true }); + if (!(await evaluate('document.hasFocus()'))) throw new Error('the page does not have focus'); + const { frameTree } = await send('Page.getFrameTree'); + const listen = (listener) => { listeners.add(listener); return () => listeners.delete(listener); }; + const { navigate, reload } = pageLoads(send, listen, frameTree.frame.id); + const pageSend = (method, params) => (method === 'Page.navigate' || method === 'Page.reload' + ? Promise.reject(new Error(`${method}: use navigate() or reload(), which wait for the new document`)) : send(method, params)); + await run({ origin, send: pageSend, evaluate, layoutReady, pass, errors, navigate, reload }); + return checks; + } finally { + socket?.close(); + if (launched) await discard(launched.chrome, launched.profile); + } + }); +} diff --git a/tests/browser/companion.mjs b/tests/browser/companion.mjs index 504a709c..398468ae 100644 --- a/tests/browser/companion.mjs +++ b/tests/browser/companion.mjs @@ -53,7 +53,6 @@ await withPage({ name: 'companion', instrumentation }, async ({ origin, navigate stage('opening the Grove yard'); await navigate(origin); await until('connected install button', () => evaluate('document.querySelector("#yard-install").checkVisibility() && !document.querySelector("#yard-install").disabled')); - await evaluate('Promise.all([...document.querySelectorAll("#yard-install img")].map(img => { img.loading="eager"; return img.decode(); }))'); await layoutReady(); const point = () => evaluate(`(() => { @@ -74,6 +73,40 @@ await withPage({ name: 'companion', instrumentation }, async ({ origin, navigate assert.equal((await point()).hit, true, theme); } + stage('checking the button is live text that leaves the title room'); + const placed = () => evaluate(`(() => { + const parts = ['.yard-play-chev', '.yard-play-sub', '.yard-play-now', '.yard-play-text']; + const root = document.querySelector('.yard-play-grove'), heading = document.querySelector('.yard-heading'); + const chip = root.querySelector('.yard-play-chip').getBoundingClientRect(), stage = document.querySelector('#yard-stage').getBoundingClientRect(); + const needed = Math.max(...['.yard-eyebrow', '#yard-title', '#yard-subtitle'].map((s) => { + const line = document.querySelector(s); + line.style.whiteSpace = 'nowrap'; + const width = line.scrollWidth; + line.style.whiteSpace = ''; + return width; + })); + return { + stage: stage.width, height: chip.height, needed, onStage: chip.left >= stage.left && chip.right <= stage.right, + room: chip.left - parseFloat(getComputedStyle(heading).columnGap) - heading.firstElementChild.getBoundingClientRect().left, + shown: parts.filter((s) => root.querySelector(s).checkVisibility()).length, + }; + })()`); + assert.deepEqual(await evaluate(`(() => { + const size = (s) => getComputedStyle(document.querySelector(s)).fontSize; + return [document.querySelector('#yard-install img'), size('.yard-play-grove .yard-play-title') === size('.yard-play-solo .yard-play-title'), size('.yard-play-grove .yard-play-sub') === size('.yard-play-solo .yard-play-sub')]; + })()`), [null, true, true]); + for (const width of [1440, 800]) { + await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: false }); + await layoutReady(); + const at = await placed(); + assert.ok(at.onStage && at.height >= 44, `${width}: ${JSON.stringify(at)}`); + assert.ok(at.room >= at.needed, `${width}: ${JSON.stringify(at)}`); + assert.equal(at.shown, at.stage > 660 ? 4 : at.stage > 480 ? 3 : at.stage > 440 ? 1 : 0, `${width}: ${JSON.stringify(at)}`); + } + await send('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }); + await layoutReady(); + pass('the button is live text at the Play buttons\' sizes and leaves the title unwrapped'); + stage('clicking Install'); await click(); assert.equal(await evaluate('installRequests.length'), 1); diff --git a/tests/browser/dialogs.mjs b/tests/browser/dialogs.mjs index 05277900..6263cae8 100644 --- a/tests/browser/dialogs.mjs +++ b/tests/browser/dialogs.mjs @@ -1,10 +1,9 @@ -// Shared dialog layout in Chrome and, when PLAYWRIGHT_MODULE is set, WebKit. import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { describeCatalog, indexCatalog, parseCatalog } from '../../src/manager/model-stats.mjs'; -import { until, withDialogClose, withPage } from './chrome.mjs'; +import { serve, until, withDialogClose, withPage } from './chrome.mjs'; const instrumentation = ``; -const checks = await withPage({ name: 'dialogs', instrumentation }, async ({ origin, send, evaluate, layoutReady, pass, errors, navigate, reload }) => { - const checkDialogs = async (evaluate, resize, settle, engine) => { - const closeDialog = (selector, button) => withDialogClose(evaluate, selector, () => evaluate( - button ? `document.querySelector(${JSON.stringify(button)}).click()` : `document.querySelector(${JSON.stringify(selector)}).close()`)); - // Frames and animations settle geometry only, never close or refresh completion. - const settled = async () => { - await settle(); - await evaluate('Promise.allSettled([...document.querySelectorAll("dialog[open]")].flatMap(d=>d.getAnimations({subtree:true})).filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished))'); - await settle(); - }; - // Cards show before the events link opens, with their manager buttons disabled until it does. - await until(`${engine} provider cards connected`, () => evaluate('document.querySelector(".provider[data-id=anthropic] .existing")?.disabled===false')); - const geometry = (selector) => evaluate(`(()=>{ - const d=document.querySelector(${JSON.stringify(selector)}),b=d.querySelector('.models-body'); - const r=d.getBoundingClientRect(),body=b.getBoundingClientRect(),footer=d.lastElementChild.getBoundingClientRect(); - return {height:r.height,body:body.height,scrollable:b.scrollHeight>b.clientHeight,inside:r.top>=-1&&r.bottom<=innerHeight+1, - footer:footer.bottom<=r.bottom+1,bodyBottom:body.bottom<=r.bottom+1}; - })()`); - const limitNote = 'Showing 200 of 334 sessions. Narrow the filter to find older conversations.'; - for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { - await resize(width, height); - await evaluate('document.querySelector(".provider[data-id=anthropic] .existing").click()'); - await until(`${engine} history rows`, () => evaluate('document.querySelectorAll("#history-list .history-row").length===200')); - assert.equal(await evaluate('document.querySelector("#history-note").textContent'), limitNote); - assert.equal(await evaluate('document.querySelector("#history-preview").checkVisibility()'), true, 'every built-in history has a preview'); - await settled(); - let g = await geometry('#history'); - assert.ok(g.body > 120 && g.scrollable && g.inside && g.footer, `${engine} ${width}: ${JSON.stringify(g)}`); - assert.ok(await evaluate('(()=>{const b=document.querySelector("#history .models-body");b.scrollTop=b.scrollHeight;return b.scrollTop})()') > 0); - await evaluate('document.querySelector("#history-filter").value="Saved session 333";document.querySelector("#history-filter").dispatchEvent(new Event("input"))'); - await until(`${engine} searched beyond the loaded rows`, () => evaluate('document.querySelectorAll("#history-list .history-row").length===1')); - assert.equal(await evaluate('document.querySelector("#history-list .history-title").textContent'), 'Saved session 333'); - assert.equal(await evaluate('document.querySelector("#history-note").hidden'), true); - g = await geometry('#history'); - assert.ok(g.body > 30 && g.inside && g.footer, `${engine} filtered: ${JSON.stringify(g)}`); - await evaluate('document.querySelector("#history-filter").value="Missing conversation";document.querySelector("#history-filter").dispatchEvent(new Event("input"))'); - await until(`${engine} no match`, () => evaluate('document.querySelectorAll("#history-list .history-row").length===0')); - assert.equal(await evaluate('document.querySelector("#history-note").checkVisibility()'), true); - assert.equal(await evaluate('document.querySelector("#history-note").textContent'), 'No session matches the filter.'); - assert.ok((await geometry('#history')).body > 20, 'the empty state remains visible'); - await closeDialog('#history', '#history-close'); - } - pass(`${engine}: 200 history rows scroll, and filtered/empty history remains usable on desktop, tablet and phone`); +const checkDialogs = async (evaluate, resize, settle, engine, pass) => { + const closeDialog = (selector, button) => withDialogClose(evaluate, selector, () => evaluate( + button ? `document.querySelector(${JSON.stringify(button)}).click()` : `document.querySelector(${JSON.stringify(selector)}).close()`)); + // Frames and animations settle geometry only, never close or refresh completion. + const settled = async () => { + await settle(); + await evaluate('Promise.allSettled([...document.querySelectorAll("dialog[open]")].flatMap(d=>d.getAnimations({subtree:true})).filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished))'); + await settle(); + }; + // Cards show before the events link opens, with their manager buttons disabled until it does. + await until(`${engine} provider cards connected`, () => evaluate('document.querySelector(".provider[data-id=anthropic] .existing")?.disabled===false')); + const geometry = (selector) => evaluate(`(()=>{ + const d=document.querySelector(${JSON.stringify(selector)}),b=d.querySelector('.models-body'); + const r=d.getBoundingClientRect(),body=b.getBoundingClientRect(),footer=d.lastElementChild.getBoundingClientRect(); + return {height:r.height,body:body.height,scrollable:b.scrollHeight>b.clientHeight,inside:r.top>=-1&&r.bottom<=innerHeight+1, + footer:footer.bottom<=r.bottom+1,bodyBottom:body.bottom<=r.bottom+1}; + })()`); + const limitNote = 'Showing 200 of 334 sessions. Narrow the filter to find older conversations.'; + for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { + await resize(width, height); + await evaluate('document.querySelector(".provider[data-id=anthropic] .existing").click()'); + await until(`${engine} history rows`, () => evaluate('document.querySelectorAll("#history-list .history-row").length===200')); + assert.equal(await evaluate('document.querySelector("#history-note").textContent'), limitNote); + assert.equal(await evaluate('document.querySelector("#history-preview").checkVisibility()'), true, 'every built-in history has a preview'); + await settled(); + let g = await geometry('#history'); + assert.ok(g.body > 120 && g.scrollable && g.inside && g.footer, `${engine} ${width}: ${JSON.stringify(g)}`); + assert.ok(await evaluate('(()=>{const b=document.querySelector("#history .models-body");b.scrollTop=b.scrollHeight;return b.scrollTop})()') > 0); + await evaluate('document.querySelector("#history-filter").value="Saved session 333";document.querySelector("#history-filter").dispatchEvent(new Event("input"))'); + await until(`${engine} searched beyond the loaded rows`, () => evaluate('document.querySelectorAll("#history-list .history-row").length===1')); + assert.equal(await evaluate('document.querySelector("#history-list .history-title").textContent'), 'Saved session 333'); + assert.equal(await evaluate('document.querySelector("#history-note").hidden'), true); + g = await geometry('#history'); + assert.ok(g.body > 30 && g.inside && g.footer, `${engine} filtered: ${JSON.stringify(g)}`); + await evaluate('document.querySelector("#history-filter").value="Missing conversation";document.querySelector("#history-filter").dispatchEvent(new Event("input"))'); + await until(`${engine} no match`, () => evaluate('document.querySelectorAll("#history-list .history-row").length===0')); + assert.equal(await evaluate('document.querySelector("#history-note").checkVisibility()'), true); + assert.equal(await evaluate('document.querySelector("#history-note").textContent'), 'No session matches the filter.'); + assert.ok((await geometry('#history')).body > 20, 'the empty state remains visible'); + await closeDialog('#history', '#history-close'); + } + pass(`${engine}: 200 history rows scroll, and filtered/empty history remains usable on desktop, tablet and phone`); - // Static demo data only. Completion is the visible response, never an elapsed delay. - for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { - await resize(width, height); - await evaluate('document.querySelector("#cwd").value="/work/storefront";document.querySelector(".provider[data-id=google] .memory-link").click()'); - await until(`${engine} Google history`, () => evaluate('document.querySelectorAll("#history-list .history-preview-open").length===1')); - assert.equal(await evaluate('document.querySelector(".provider[data-id=google] .memory-link").textContent'), 'History'); - await evaluate('document.querySelector("#history-list .history-preview-open").click()'); - await until(`${engine} Google saved reply`, () => evaluate('document.querySelector("#history-messages").textContent.includes("The checkout guide covers")')); - await settled(); - const g = await evaluate(`(() => { - const d=document.querySelector('#history'),r=d.getBoundingClientRect(),p=document.querySelector('#history-preview').getBoundingClientRect(),m=document.querySelector('#history-messages').getBoundingClientRect(); - return {inside:r.top>=-1&&r.bottom<=innerHeight+1&&r.right<=innerWidth+1,preview:p.height,messages:m.height,overflow:d.scrollWidth>d.clientWidth}; - })()`); - assert.ok(g.inside && g.preview > 100 && g.messages > 60 && !g.overflow, `${engine} history preview ${width}: ${JSON.stringify(g)}`); - assert.equal(await evaluate('document.querySelector("#history-list .history-preview-open").getAttribute("aria-current")'), 'true'); - await closeDialog('#history', '#history-close'); - assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=google] .memory-link")'), true); - } - pass(`${engine}: Google History reads saved messages for the working folder on desktop, tablet and phone`); + // Static demo data only. Completion is the visible response, never an elapsed delay. + for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { + await resize(width, height); + await evaluate('document.querySelector("#cwd").value="/work/storefront";document.querySelector(".provider[data-id=google] .memory-link").click()'); + await until(`${engine} Google history`, () => evaluate('document.querySelectorAll("#history-list .history-preview-open").length===1')); + assert.equal(await evaluate('document.querySelector(".provider[data-id=google] .memory-link").textContent'), 'History'); + await evaluate('document.querySelector("#history-list .history-preview-open").click()'); + await until(`${engine} Google saved reply`, () => evaluate('document.querySelector("#history-messages").textContent.includes("The checkout guide covers")')); + await settled(); + const g = await evaluate(`(() => { + const d=document.querySelector('#history'),r=d.getBoundingClientRect(),p=document.querySelector('#history-preview').getBoundingClientRect(),m=document.querySelector('#history-messages').getBoundingClientRect(); + return {inside:r.top>=-1&&r.bottom<=innerHeight+1&&r.right<=innerWidth+1,preview:p.height,messages:m.height,overflow:d.scrollWidth>d.clientWidth}; + })()`); + assert.ok(g.inside && g.preview > 100 && g.messages > 60 && !g.overflow, `${engine} history preview ${width}: ${JSON.stringify(g)}`); + assert.equal(await evaluate('document.querySelector("#history-list .history-preview-open").getAttribute("aria-current")'), 'true'); + await closeDialog('#history', '#history-close'); + assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=google] .memory-link")'), true); + } + pass(`${engine}: Google History reads saved messages for the working folder on desktop, tablet and phone`); - // The first file's reply is held back until a newer file has been chosen and shown. The page - // handles the released reply in the same microtask run that sets the flag, so once the flag - // reads true the page has already kept or replaced the text. - await evaluate('window.memoryGate=new Promise(resolve=>{window.releaseMemory=resolve});window.memoryStaleDelivered=false;document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); - await until(`${engine} memory files`, () => evaluate('document.querySelectorAll("#memory-list .memory-item").length===2')); - await evaluate('document.querySelectorAll("#memory-list .memory-item")[1].click()'); - await until(`${engine} chosen memory file`, () => evaluate('document.querySelector("#memory-text").textContent.includes("## Commands")')); - await evaluate('window.releaseMemory();window.memoryGate=null'); - await until(`${engine} earlier memory reply`, () => evaluate('window.memoryStaleDelivered')); - assert.match(await evaluate('document.querySelector("#memory-text").textContent'), /## Commands/, 'an earlier, slower reply does not replace the chosen file'); - assert.equal(await evaluate('document.querySelector("#memory-list [aria-current=true]").dataset.path'), 'topics/testing.md'); - assert.equal(await evaluate('document.querySelector("#memory-file").textContent+"|"+document.querySelector("#memory-copy").hidden+"|"+document.querySelector("#memory-open").hidden'), - '/demo/memory/storefront/topics/testing.md|false|false', 'the full path shows with Copy and Open folder'); - await evaluate('document.querySelector("#memory-open").click()'); - await until(`${engine} memory folder opened`, () => evaluate('openedFolders[0]==="/demo/memory/storefront/topics"')); - await closeDialog('#memory', '#memory-close'); - // A path segment with no break opportunity must wrap inside the heading, never under its buttons. - await evaluate('document.querySelector("#cwd").value="/Users/someone/Projects/AgentGuildWorkspaces/storefront/packages/checkout/src/payments"'); - for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { - await resize(width, height); - await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); - await until(`${engine} memory text at ${width}`, () => evaluate('document.querySelector("#memory-text").textContent.length>0')); - await settled(); - const g = await evaluate(`(()=>{ - const d=document.querySelector('#memory'),r=d.getBoundingClientRect(),t=document.querySelector('#memory-text').getBoundingClientRect(),l=document.querySelector('#memory-list').getBoundingClientRect(); - const s=document.querySelector('#memory-sub'),h=document.querySelector('#memory .models-heading').getBoundingClientRect(),b=document.querySelector('#memory-refresh').getBoundingClientRect(); - return {inside:r.top>=-1&&r.bottom<=innerHeight+1&&r.left>=-1&&r.right<=innerWidth+1,text:t.height,list:l.height,textBottom:t.bottom<=r.bottom+1,overflow:d.scrollWidth>d.clientWidth, - heading:s.scrollWidth<=s.clientWidth&&h.right<=b.left}; - })()`); - assert.ok(g.inside && g.text > 80 && g.list > 60 && g.textBottom && !g.overflow && g.heading, `${engine} memory ${width}: ${JSON.stringify(g)}`); - await closeDialog('#memory', '#memory-close'); - assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=anthropic] .memory-link")'), true, `${engine} memory focus restored`); - } - await evaluate('document.querySelector("#cwd").value="/work/storefront"'); - // close() queues the dialog's close event, so another card can open it again before that event runs. + // The first file's reply is held back until a newer file has been chosen and shown. The page + // handles the released reply in the same microtask run that sets the flag, so once the flag + // reads true the page has already kept or replaced the text. + await evaluate('window.memoryGate=new Promise(resolve=>{window.releaseMemory=resolve});window.memoryStaleDelivered=false;document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); + await until(`${engine} memory files`, () => evaluate('document.querySelectorAll("#memory-list .memory-item").length===2')); + await evaluate('document.querySelectorAll("#memory-list .memory-item")[1].click()'); + await until(`${engine} chosen memory file`, () => evaluate('document.querySelector("#memory-text").textContent.includes("## Commands")')); + await evaluate('window.releaseMemory();window.memoryGate=null'); + await until(`${engine} earlier memory reply`, () => evaluate('window.memoryStaleDelivered')); + assert.match(await evaluate('document.querySelector("#memory-text").textContent'), /## Commands/, 'an earlier, slower reply does not replace the chosen file'); + assert.equal(await evaluate('document.querySelector("#memory-list [aria-current=true]").dataset.path'), 'topics/testing.md'); + assert.equal(await evaluate('document.querySelector("#memory-file").textContent+"|"+document.querySelector("#memory-copy").hidden+"|"+document.querySelector("#memory-open").hidden'), + '/demo/memory/storefront/topics/testing.md|false|false', 'the full path shows with Copy and Open folder'); + await evaluate('document.querySelector("#memory-open").click()'); + await until(`${engine} memory folder opened`, () => evaluate('openedFolders[0]==="/demo/memory/storefront/topics"')); + await closeDialog('#memory', '#memory-close'); + // A path segment with no break opportunity must wrap inside the heading, never under its buttons. + await evaluate('document.querySelector("#cwd").value="/Users/someone/Projects/AgentGuildWorkspaces/storefront/packages/checkout/src/payments"'); + for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { + await resize(width, height); await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); - await until(`${engine} memory before reopening`, () => evaluate('document.querySelectorAll("#memory-list .memory-item").length===2')); - await evaluate('document.querySelector("#memory-close").click();document.querySelector(".provider[data-id=xai] .memory-link").click()'); - await until(`${engine} memory reopened from another card`, () => evaluate('document.querySelector("#memory-title").textContent==="Grok Build memory"&&document.querySelectorAll("#memory-list .memory-item").length===2')); + await until(`${engine} memory text at ${width}`, () => evaluate('document.querySelector("#memory-text").textContent.length>0')); + await settled(); + const g = await evaluate(`(()=>{ + const d=document.querySelector('#memory'),r=d.getBoundingClientRect(),t=document.querySelector('#memory-text').getBoundingClientRect(),l=document.querySelector('#memory-list').getBoundingClientRect(); + const s=document.querySelector('#memory-sub'),h=document.querySelector('#memory .models-heading').getBoundingClientRect(),b=document.querySelector('#memory-refresh').getBoundingClientRect(); + return {inside:r.top>=-1&&r.bottom<=innerHeight+1&&r.left>=-1&&r.right<=innerWidth+1,text:t.height,list:l.height,textBottom:t.bottom<=r.bottom+1,overflow:d.scrollWidth>d.clientWidth, + heading:s.scrollWidth<=s.clientWidth&&h.right<=b.left}; + })()`); + assert.ok(g.inside && g.text > 80 && g.list > 60 && g.textBottom && !g.overflow && g.heading, `${engine} memory ${width}: ${JSON.stringify(g)}`); await closeDialog('#memory', '#memory-close'); - pass(`${engine}: memory lists files, keeps the newest choice over a slower reply, reopens from another card, and fits desktop, tablet and phone`); + assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=anthropic] .memory-link")'), true, `${engine} memory focus restored`); + } + await evaluate('document.querySelector("#cwd").value="/work/storefront"'); + // close() queues the dialog's close event, so another card can open it again before that event runs. + await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); + await until(`${engine} memory before reopening`, () => evaluate('document.querySelectorAll("#memory-list .memory-item").length===2')); + await evaluate('document.querySelector("#memory-close").click();document.querySelector(".provider[data-id=xai] .memory-link").click()'); + await until(`${engine} memory reopened from another card`, () => evaluate('document.querySelector("#memory-title").textContent==="Grok Build memory"&&document.querySelectorAll("#memory-list .memory-item").length===2')); + await closeDialog('#memory', '#memory-close'); + pass(`${engine}: memory lists files, keeps the newest choice over a slower reply, reopens from another card, and fits desktop, tablet and phone`); - await until(`${engine} instruction count on the card`, () => evaluate('document.querySelector(".provider[data-id=google] .instructions-link .link-count")?.textContent==="2"')); - await evaluate('document.querySelector(".provider[data-id=google] .instructions-link").click()'); - await until(`${engine} instruction files`, () => evaluate('document.querySelectorAll("#memory-list .memory-item").length===3')); - assert.equal(await evaluate('document.querySelector("#memory-sub").textContent.split(" · ")[0]'), 'Reads 2 files when a session starts'); - assert.equal(await evaluate('document.querySelector(".instructions-skipped").open'), false, 'files that do not load start folded away'); - await evaluate('document.querySelector(".instructions-skipped > summary").click()'); - await evaluate('document.querySelector(".instructions-skipped .memory-item").click()'); - await until(`${engine} skipped instruction file`, () => evaluate('document.querySelector("#memory-text").textContent.includes("Shared notes for every coding tool")')); - assert.equal(await evaluate('document.querySelector(".instructions-skipped .memory-reason").textContent'), 'Claude Code reads CLAUDE.md instead.'); - assert.equal(await evaluate('document.querySelector("#memory-file").textContent+"|"+document.querySelector("#memory-copy").hidden'), '/work/storefront/AGENTS.md|false'); - await closeDialog('#memory', '#memory-close'); - assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=google] .instructions-link")'), true); - pass(`${engine}: Instructions shows its count on the card, folds away files that do not load with the reason, and opens them`); + await until(`${engine} instruction count on the card`, () => evaluate('document.querySelector(".provider[data-id=google] .instructions-link .link-count")?.textContent==="2"')); + await evaluate('document.querySelector(".provider[data-id=google] .instructions-link").click()'); + await until(`${engine} instruction files`, () => evaluate('document.querySelectorAll("#memory-list .memory-item").length===3')); + assert.equal(await evaluate('document.querySelector("#memory-sub").textContent.split(" · ")[0]'), 'Reads 2 files when a session starts'); + assert.equal(await evaluate('document.querySelector(".instructions-skipped").open'), false, 'files that do not load start folded away'); + await evaluate('document.querySelector(".instructions-skipped > summary").click()'); + await evaluate('document.querySelector(".instructions-skipped .memory-item").click()'); + await until(`${engine} skipped instruction file`, () => evaluate('document.querySelector("#memory-text").textContent.includes("Shared notes for every coding tool")')); + assert.equal(await evaluate('document.querySelector(".instructions-skipped .memory-reason").textContent'), 'Claude Code reads CLAUDE.md instead.'); + assert.equal(await evaluate('document.querySelector("#memory-file").textContent+"|"+document.querySelector("#memory-copy").hidden'), '/work/storefront/AGENTS.md|false'); + await closeDialog('#memory', '#memory-close'); + assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=google] .instructions-link")'), true); + pass(`${engine}: Instructions shows its count on the card, folds away files that do not load with the reason, and opens them`); - // A toast raised inside a modal dialog is not inert: it is the topmost thing at its own centre and its - // text can be tapped away. On a phone it arrives under the top bar, clear of the browser's bottom toolbar. - // Open folder is refused here, which raises the toast inside the dialog. - await evaluate('window.openFolderError="The folder could not be opened."'); - for (const [width, height] of [[1440, 900], [390, 844]]) { - await resize(width, height); - await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); - await until(`${engine} memory folder button at ${width}`, () => evaluate('(b=>!b.hidden&&!b.disabled)(document.querySelector("#memory-open"))')); - await evaluate('document.querySelector("#memory-open").click()'); - await until(`${engine} toast at ${width}`, () => evaluate('!document.querySelector("#toast").hidden')); - await settled(); - const t = await evaluate(`(()=>{ - const el=document.querySelector('#toast'),r=el.getBoundingClientRect(),hit=document.elementFromPoint(r.left+r.width/2,r.top+r.height/2); - return {text:el.textContent,open:el.matches(':popover-open'),inDialog:el.parentElement.id,top:hit===el||el.contains(hit), - y:r.top,bottom:r.bottom,bar:document.querySelector('.topbar').getBoundingClientRect().bottom,inside:r.left>=0&&r.right<=innerWidth}; - })()`); - assert.equal(t.text, 'The folder could not be opened.'); - assert.ok(t.open && t.inDialog === 'memory' && t.top && t.inside, `${engine} toast over the dialog at ${width}: ${JSON.stringify(t)}`); - if (width <= 640) assert.ok(t.y >= t.bar && t.y < height / 3, `${engine} phone toast under the top bar: ${JSON.stringify(t)}`); - else assert.ok(t.bottom > height * 2 / 3, `${engine} desktop toast at the bottom: ${JSON.stringify(t)}`); - await closeDialog('#memory', '#memory-close'); - assert.deepEqual(await evaluate(`(()=>{const el=document.querySelector('#toast');return [el.parentElement===document.body,el.matches(':popover-open')]})()`), [true, true], `${engine} the toast outlives the dialog`); - await evaluate('document.querySelector("#toast").click()'); - assert.deepEqual(await evaluate(`(()=>{const el=document.querySelector('#toast');return [el.hidden,el.matches(':popover-open')]})()`), [true, false], `${engine} a tap dismisses the toast`); - } - // A toast dismissed inside the dialog stays there; the next one raised after the dialog reopens still outlives it. - const raiseInMemory = async (label) => { - await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); - await until(`${engine} memory folder button ${label}`, () => evaluate('(b=>!b.hidden&&!b.disabled)(document.querySelector("#memory-open"))')); - await evaluate('document.querySelector("#memory-open").click()'); - await until(`${engine} toast ${label}`, () => evaluate('!document.querySelector("#toast").hidden')); - }; - await raiseInMemory('before reopening'); - await evaluate('document.querySelector("#toast").click()'); - await closeDialog('#memory', '#memory-close'); - await raiseInMemory('after reopening'); + // A toast raised inside a modal dialog is not inert: it is the topmost thing at its own centre and its + // text can be tapped away. On a phone it arrives under the top bar, clear of the browser's bottom toolbar. + // Open folder is refused here, which raises the toast inside the dialog. + await evaluate('window.openFolderError="The folder could not be opened."'); + for (const [width, height] of [[1440, 900], [390, 844]]) { + await resize(width, height); + await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); + await until(`${engine} memory folder button at ${width}`, () => evaluate('(b=>!b.hidden&&!b.disabled)(document.querySelector("#memory-open"))')); + await evaluate('document.querySelector("#memory-open").click()'); + await until(`${engine} toast at ${width}`, () => evaluate('!document.querySelector("#toast").hidden')); + await settled(); + const t = await evaluate(`(()=>{ + const el=document.querySelector('#toast'),r=el.getBoundingClientRect(),hit=document.elementFromPoint(r.left+r.width/2,r.top+r.height/2); + return {text:el.textContent,open:el.matches(':popover-open'),inDialog:el.parentElement.id,top:hit===el||el.contains(hit), + y:r.top,bottom:r.bottom,bar:document.querySelector('.topbar').getBoundingClientRect().bottom,inside:r.left>=0&&r.right<=innerWidth}; + })()`); + assert.equal(t.text, 'The folder could not be opened.'); + assert.ok(t.open && t.inDialog === 'memory' && t.top && t.inside, `${engine} toast over the dialog at ${width}: ${JSON.stringify(t)}`); + if (width <= 640) assert.ok(t.y >= t.bar && t.y < height / 3, `${engine} phone toast under the top bar: ${JSON.stringify(t)}`); + else assert.ok(t.bottom > height * 2 / 3, `${engine} desktop toast at the bottom: ${JSON.stringify(t)}`); await closeDialog('#memory', '#memory-close'); - assert.deepEqual(await evaluate(`(()=>{const el=document.querySelector('#toast');return [el.parentElement===document.body,el.matches(':popover-open'),el.checkVisibility()]})()`), [true, true, true], `${engine} a toast raised in a reopened dialog outlives it`); + assert.deepEqual(await evaluate(`(()=>{const el=document.querySelector('#toast');return [el.parentElement===document.body,el.matches(':popover-open')]})()`), [true, true], `${engine} the toast outlives the dialog`); await evaluate('document.querySelector("#toast").click()'); - await evaluate('window.openFolderError=null'); - pass(`${engine}: a toast shows above an open dialog, under the top bar on a phone, outlives the dialog and is dismissed by a tap`); + assert.deepEqual(await evaluate(`(()=>{const el=document.querySelector('#toast');return [el.hidden,el.matches(':popover-open')]})()`), [true, false], `${engine} a tap dismisses the toast`); + } + // A toast dismissed inside the dialog stays there; the next one raised after the dialog reopens still outlives it. + const raiseInMemory = async (label) => { + await evaluate('document.querySelector(".provider[data-id=anthropic] .memory-link").click()'); + await until(`${engine} memory folder button ${label}`, () => evaluate('(b=>!b.hidden&&!b.disabled)(document.querySelector("#memory-open"))')); + await evaluate('document.querySelector("#memory-open").click()'); + await until(`${engine} toast ${label}`, () => evaluate('!document.querySelector("#toast").hidden')); + }; + await raiseInMemory('before reopening'); + await evaluate('document.querySelector("#toast").click()'); + await closeDialog('#memory', '#memory-close'); + await raiseInMemory('after reopening'); + await closeDialog('#memory', '#memory-close'); + assert.deepEqual(await evaluate(`(()=>{const el=document.querySelector('#toast');return [el.parentElement===document.body,el.matches(':popover-open'),el.checkVisibility()]})()`), [true, true, true], `${engine} a toast raised in a reopened dialog outlives it`); + await evaluate('document.querySelector("#toast").click()'); + await evaluate('window.openFolderError=null'); + pass(`${engine}: a toast shows above an open dialog, under the top bar on a phone, outlives the dialog and is dismissed by a tap`); - await until(`${engine} Videos button`, () => evaluate('document.querySelector("#videos-open").checkVisibility()')); - for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { - await resize(width, height); - await evaluate('youtubeFrames.length=0;document.querySelector("#videos-open").click()'); - await until(`${engine} video poster at ${width}`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="ready"')); - await settled(); - const g = await evaluate(`(()=>{ - const d=document.querySelector('#videos'),r=d.getBoundingClientRect(),s=document.querySelector('#videos-screen').getBoundingClientRect(),items=[...document.querySelectorAll('#videos-list .videos-item')]; - const last=items.at(-1).getBoundingClientRect(),nav=document.querySelector('.videos-nav'); - return {inside:r.top>=-1&&r.bottom<=innerHeight+1&&r.left>=-1&&r.right<=innerWidth+1,ratio:Math.round(s.width/s.height*100),screen:Math.round(s.width), - overflow:d.scrollWidth>d.clientWidth||document.documentElement.scrollWidth>innerWidth,listReachable:last.bottom<=r.bottom+1||nav.scrollHeight>nav.clientHeight, - first:items[0].getAttribute('aria-current'),frames:youtubeFrames.length,kinds:items.map((i)=>i.querySelector('.news-meta').textContent.split(' · ')[0]), - groups:[...document.querySelectorAll('#videos-list .videos-group-title')].map((h)=>h.textContent),lengths:items.every((i)=>/^\\d+:\\d\\d$/.test(i.querySelector('.videos-length')?.textContent))}; - })()`); - assert.ok(g.inside && g.ratio === 178 && g.screen >= Math.min(width - 32, 340) && !g.overflow && g.listReachable && g.first === 'true' && g.frames === 0 - && g.kinds[0] === 'Tutorial' && g.kinds.lastIndexOf('Tutorial') < g.kinds.indexOf('Trailer') && g.kinds.lastIndexOf('Trailer') < g.kinds.indexOf('World') - && g.groups.join() === 'Tutorials,Trailers,World' && g.lengths, - `${engine} videos ${width}: ${JSON.stringify(g)}`); - // A video plays only when chosen, and closing the dialog removes the player so the sound stops. - const id = await evaluate('(()=>{const item=[...document.querySelectorAll("#videos-list .videos-item")].at(-1);item.click();return item.dataset.id})()'); - await until(`${engine} player at ${width}`, () => evaluate('Boolean(document.querySelector("#videos-screen iframe"))')); - assert.deepEqual(await evaluate('[youtubeFrames,document.querySelector("#videos-screen iframe").referrerPolicy,document.querySelector("#videos-list [aria-current=true]").dataset.id]'), - [[`https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0&playsinline=1`], 'strict-origin-when-cross-origin', id]); - await closeDialog('#videos', '#videos-close'); - assert.deepEqual(await evaluate('[document.querySelectorAll("#videos iframe").length,document.activeElement===document.querySelector("#videos-open")]'), [0, true], `${engine} player removed and focus restored`); - } - // A landscape phone keeps the list beside the player, so whole items stay in view. - await resize(844, 390); - await evaluate('document.querySelector("#videos-open").click()'); - await until(`${engine} video poster landscape`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="ready"')); + await until(`${engine} Videos button`, () => evaluate('document.querySelector("#videos-open").checkVisibility()')); + for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { + await resize(width, height); + await evaluate('youtubeFrames.length=0;document.querySelector("#videos-open").click()'); + await until(`${engine} video poster at ${width}`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="ready"')); await settled(); - const land = await evaluate(`(()=>{const d=document.querySelector('#videos').getBoundingClientRect(),n=document.querySelector('.videos-nav').getBoundingClientRect(),s=document.querySelector('#videos-screen').getBoundingClientRect(); - return {items:[...document.querySelectorAll('#videos-list .videos-item')].filter((i)=>{const r=i.getBoundingClientRect();return r.top>=n.top-1&&r.bottom<=n.bottom+1}).length, - screen:s.top>=d.top&&s.bottom<=d.bottom&&s.width>=200}})()`); - assert.ok(land.items >= 2 && land.screen, `${engine} videos landscape: ${JSON.stringify(land)}`); - await closeDialog('#videos', '#videos-close'); - // Without YouTube the dialog says so and plays nothing; coming back online loads the poster. - await evaluate('youtubeOffline=true;youtubeFrames.length=0;document.querySelector("#videos-open").click()'); - await until(`${engine} videos offline`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="offline"')); - assert.deepEqual(await evaluate('[document.querySelector("#videos-offline").checkVisibility(),document.querySelector("#videos-play").disabled]'), [true, true]); - await evaluate('document.querySelectorAll("#videos-list .videos-item")[1].click()'); - await until(`${engine} videos still offline`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="offline"')); - assert.equal(await evaluate('youtubeFrames.length'), 0, 'nothing plays while YouTube is unreachable'); - await evaluate('youtubeOffline=false;document.querySelector("#videos-retry").focus();dispatchEvent(new Event("online"))'); - await until(`${engine} videos back online`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="ready"')); - assert.deepEqual(await evaluate('[document.querySelector("#videos-offline").checkVisibility(),document.activeElement.dataset.id===document.querySelectorAll("#videos-list .videos-item")[1].dataset.id]'), [false, true], - 'the poster replaces the message and focus moves to the chosen video'); + const g = await evaluate(`(()=>{ + const d=document.querySelector('#videos'),r=d.getBoundingClientRect(),s=document.querySelector('#videos-screen').getBoundingClientRect(),items=[...document.querySelectorAll('#videos-list .videos-item')]; + const last=items.at(-1).getBoundingClientRect(),nav=document.querySelector('.videos-nav'); + return {inside:r.top>=-1&&r.bottom<=innerHeight+1&&r.left>=-1&&r.right<=innerWidth+1,ratio:Math.round(s.width/s.height*100),screen:Math.round(s.width), + overflow:d.scrollWidth>d.clientWidth||document.documentElement.scrollWidth>innerWidth,listReachable:last.bottom<=r.bottom+1||nav.scrollHeight>nav.clientHeight, + first:items[0].getAttribute('aria-current'),frames:youtubeFrames.length,kinds:items.map((i)=>i.querySelector('.news-meta').textContent.split(' · ')[0]), + groups:[...document.querySelectorAll('#videos-list .videos-group-title')].map((h)=>h.textContent),lengths:items.every((i)=>/^\\d+:\\d\\d$/.test(i.querySelector('.videos-length')?.textContent))}; + })()`); + assert.ok(g.inside && g.ratio === 178 && g.screen >= Math.min(width - 32, 340) && !g.overflow && g.listReachable && g.first === 'true' && g.frames === 0 + && g.kinds[0] === 'Tutorial' && g.kinds.lastIndexOf('Tutorial') < g.kinds.indexOf('Trailer') && g.kinds.lastIndexOf('Trailer') < g.kinds.indexOf('World') + && g.groups.join() === 'Tutorials,Trailers,World' && g.lengths, + `${engine} videos ${width}: ${JSON.stringify(g)}`); + // A video plays only when chosen, and closing the dialog removes the player so the sound stops. + const id = await evaluate('(()=>{const item=[...document.querySelectorAll("#videos-list .videos-item")].at(-1);item.click();return item.dataset.id})()'); + await until(`${engine} player at ${width}`, () => evaluate('Boolean(document.querySelector("#videos-screen iframe"))')); + assert.deepEqual(await evaluate('[youtubeFrames,document.querySelector("#videos-screen iframe").referrerPolicy,document.querySelector("#videos-list [aria-current=true]").dataset.id]'), + [[`https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0&playsinline=1`], 'strict-origin-when-cross-origin', id]); await closeDialog('#videos', '#videos-close'); - pass(`${engine}: Videos groups and plays the videos, stops one on close, and says when YouTube is unreachable, on desktop, tablet, phone and landscape phone`); + assert.deepEqual(await evaluate('[document.querySelectorAll("#videos iframe").length,document.activeElement===document.querySelector("#videos-open")]'), [0, true], `${engine} player removed and focus restored`); + } + // A landscape phone keeps the list beside the player, so whole items stay in view. + await resize(844, 390); + await evaluate('document.querySelector("#videos-open").click()'); + await until(`${engine} video poster landscape`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="ready"')); + await settled(); + const land = await evaluate(`(()=>{const d=document.querySelector('#videos').getBoundingClientRect(),n=document.querySelector('.videos-nav').getBoundingClientRect(),s=document.querySelector('#videos-screen').getBoundingClientRect(); + return {items:[...document.querySelectorAll('#videos-list .videos-item')].filter((i)=>{const r=i.getBoundingClientRect();return r.top>=n.top-1&&r.bottom<=n.bottom+1}).length, + screen:s.top>=d.top&&s.bottom<=d.bottom&&s.width>=200}})()`); + assert.ok(land.items >= 2 && land.screen, `${engine} videos landscape: ${JSON.stringify(land)}`); + await closeDialog('#videos', '#videos-close'); + // Without YouTube the dialog says so and plays nothing; coming back online loads the poster. + await evaluate('youtubeOffline=true;youtubeFrames.length=0;document.querySelector("#videos-open").click()'); + await until(`${engine} videos offline`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="offline"')); + assert.deepEqual(await evaluate('[document.querySelector("#videos-offline").checkVisibility(),document.querySelector("#videos-play").disabled]'), [true, true]); + await evaluate('document.querySelectorAll("#videos-list .videos-item")[1].click()'); + await until(`${engine} videos still offline`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="offline"')); + assert.equal(await evaluate('youtubeFrames.length'), 0, 'nothing plays while YouTube is unreachable'); + await evaluate('youtubeOffline=false;document.querySelector("#videos-retry").focus();dispatchEvent(new Event("online"))'); + await until(`${engine} videos back online`, () => evaluate('document.querySelector("#videos-screen").dataset.state==="ready"')); + assert.deepEqual(await evaluate('[document.querySelector("#videos-offline").checkVisibility(),document.activeElement.dataset.id===document.querySelectorAll("#videos-list .videos-item")[1].dataset.id]'), [false, true], + 'the poster replaces the message and focus moves to the chosen video'); + await closeDialog('#videos', '#videos-close'); + pass(`${engine}: Videos groups and plays the videos, stops one on close, and says when YouTube is unreachable, on desktop, tablet, phone and landscape phone`); - await until(`${engine} environment summary`, () => evaluate('document.querySelectorAll(".provider[data-id=shell] .environment-values dd").length===4')); - for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { - await resize(width, height); - for (const skin of ['guild', 'professional', 'orbital', 'grove', 'gnomeland', 'goblinville']) { - for (const theme of ['light', 'dark']) { - await evaluate(`document.documentElement.dataset.skin='${skin}';document.documentElement.dataset.theme='${theme}';document.querySelector('.provider[data-id=shell] .environment-open').click()`); - await settled(); - const g = await geometry('#environment'); - assert.ok(g.body > 120 && g.inside && g.bodyBottom, `${engine} environment ${skin} ${theme} ${width}: ${JSON.stringify(g)}`); - assert.equal(await evaluate('document.querySelectorAll("#environment-runtimes .environment-row").length'), 6); - assert.ok(await evaluate('document.documentElement.scrollWidth<=innerWidth'), 'no horizontal page overflow'); - assert.ok(await evaluate('document.querySelector("#environment").scrollWidth<=document.querySelector("#environment").clientWidth'), 'no horizontal dialog overflow'); - await closeDialog('#environment', '#environment-close'); - assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=shell] .environment-open")'), true, `${engine} environment focus restored`); - } + await until(`${engine} environment summary`, () => evaluate('document.querySelectorAll(".provider[data-id=shell] .environment-values dd").length===4')); + for (const [width, height] of [[1440, 900], [768, 1024], [390, 844]]) { + await resize(width, height); + for (const skin of ['guild', 'professional', 'orbital', 'grove', 'gnomeland', 'goblinville']) { + for (const theme of ['light', 'dark']) { + await evaluate(`document.documentElement.dataset.skin='${skin}';document.documentElement.dataset.theme='${theme}';document.querySelector('.provider[data-id=shell] .environment-open').click()`); + await settled(); + const g = await geometry('#environment'); + assert.ok(g.body > 120 && g.inside && g.bodyBottom, `${engine} environment ${skin} ${theme} ${width}: ${JSON.stringify(g)}`); + assert.equal(await evaluate('document.querySelectorAll("#environment-runtimes .environment-row").length'), 6); + assert.ok(await evaluate('document.documentElement.scrollWidth<=innerWidth'), 'no horizontal page overflow'); + assert.ok(await evaluate('document.querySelector("#environment").scrollWidth<=document.querySelector("#environment").clientWidth'), 'no horizontal dialog overflow'); + await closeDialog('#environment', '#environment-close'); + assert.equal(await evaluate('document.activeElement===document.querySelector(".provider[data-id=shell] .environment-open")'), true, `${engine} environment focus restored`); } } - await evaluate(`new Promise(resolve => { - document.querySelector('.provider[data-id=shell] .environment-open').click(); - const refresh = document.querySelector('#environment-refresh'); - const observer = new MutationObserver(() => { - if (!refresh.disabled) { observer.disconnect(); resolve(); } - }); - observer.observe(refresh, { attributes: true, attributeFilter: ['disabled'] }); - refresh.click(); - })`); - assert.equal(await evaluate('document.querySelector("#environment").open'), true); - // Observe the actual card replacement before closing; rendering frames do - // not indicate that the asynchronous provider refresh has finished. - await evaluate(`new Promise(resolve => { - const selector = '.provider[data-id=shell] .environment-open'; - const original = document.querySelector(selector); - const observer = new MutationObserver(() => { - const replacement = document.querySelector(selector); - if (!original.isConnected && replacement?.isConnected && replacement !== original) { - observer.disconnect(); - window.testEnvironmentReplacement = replacement; - resolve(); - } - }); - observer.observe(document.querySelector('#providers'), { childList: true, subtree: true }); - document.querySelector('.provider[data-id=shell] .multiplexer-refresh').click(); - })`); - assert.equal(await evaluate('document.querySelector("#environment").open'), true); - await closeDialog('#environment', '#environment-close'); - assert.equal(await evaluate('testEnvironmentReplacement.isConnected && document.activeElement===testEnvironmentReplacement'), true, `${engine} replacement opener receives focus`); - await evaluate('delete window.testEnvironmentReplacement'); - pass(`${engine}: manager environment, refresh and focus restoration work in all skins and themes at desktop, tablet and phone widths`); - - // The other dialogs use exactly the same sizing rule. Exercise overflow and - // footer reachability without depending on provider accounts or Tailscale. - for (const [selector, width, height] of [ - ['#models', 1024, 768], ['#remote-access', 1024, 768], - ['#folder-browser', 1024, 768], ['#folder-browser', 768, 1024], ['#folder-browser', 390, 844], - ]) { - await resize(width, height); - await evaluate(`{const d=document.querySelector('${selector}');const b=d.querySelector('.models-body');window.testDialogContent=b.innerHTML;b.replaceChildren(...Array.from({length:100},(_,i)=>{const p=document.createElement('p');p.textContent='Dialog item '+i;return p}));d.showModal()}`); - await settled(); - const g = await geometry(selector); - assert.ok(g.body > 120 && g.scrollable && g.inside && g.bodyBottom && g.footer, `${engine} ${selector} ${width}: ${JSON.stringify(g)}`); - // Close handlers need the real controls, not the temporary overflow content. - await evaluate(`document.querySelector('${selector} .models-body').innerHTML=window.testDialogContent`); - await closeDialog(selector); - } - pass(`${engine}: model, remote-access and folder dialogs retain a visible, scrollable body and footer`); + } + await evaluate(`new Promise(resolve => { + document.querySelector('.provider[data-id=shell] .environment-open').click(); + const refresh = document.querySelector('#environment-refresh'); + const observer = new MutationObserver(() => { + if (!refresh.disabled) { observer.disconnect(); resolve(); } + }); + observer.observe(refresh, { attributes: true, attributeFilter: ['disabled'] }); + refresh.click(); + })`); + assert.equal(await evaluate('document.querySelector("#environment").open'), true); + // Observe the actual card replacement before closing; rendering frames do + // not indicate that the asynchronous provider refresh has finished. + await evaluate(`new Promise(resolve => { + const selector = '.provider[data-id=shell] .environment-open'; + const original = document.querySelector(selector); + const observer = new MutationObserver(() => { + const replacement = document.querySelector(selector); + if (!original.isConnected && replacement?.isConnected && replacement !== original) { + observer.disconnect(); + window.testEnvironmentReplacement = replacement; + resolve(); + } + }); + observer.observe(document.querySelector('#providers'), { childList: true, subtree: true }); + document.querySelector('.provider[data-id=shell] .multiplexer-refresh').click(); + })`); + assert.equal(await evaluate('document.querySelector("#environment").open'), true); + await closeDialog('#environment', '#environment-close'); + assert.equal(await evaluate('testEnvironmentReplacement.isConnected && document.activeElement===testEnvironmentReplacement'), true, `${engine} replacement opener receives focus`); + await evaluate('delete window.testEnvironmentReplacement'); + pass(`${engine}: manager environment, refresh and focus restoration work in all skins and themes at desktop, tablet and phone widths`); - // Questions are asked in the page: a framed page's confirm() returns false without showing anything. - await resize(390, 844); - // The last running session: later checks use the first card's terminal. - const id = await evaluate('[...document.querySelectorAll("#sessions .session-card")].findLast((c) => !c.querySelector(".stop").hidden)?.dataset.id'); - assert.ok(id, `${engine}: a running demo session`); - const stop = `document.querySelector('#sessions .session-card[data-id="${id}"] .stop')`; - await evaluate(`${stop}.click()`); - await until(`${engine} stop question`, () => evaluate('document.querySelector("#ask").open')); + // The other dialogs use exactly the same sizing rule. Exercise overflow and + // footer reachability without depending on provider accounts or Tailscale. + for (const [selector, width, height] of [ + ['#models', 1024, 768], ['#remote-access', 1024, 768], + ['#folder-browser', 1024, 768], ['#folder-browser', 768, 1024], ['#folder-browser', 390, 844], + ]) { + await resize(width, height); + await evaluate(`{const d=document.querySelector('${selector}');const b=d.querySelector('.models-body');window.testDialogContent=b.innerHTML;b.replaceChildren(...Array.from({length:100},(_,i)=>{const p=document.createElement('p');p.textContent='Dialog item '+i;return p}));d.showModal()}`); await settled(); - const question = await evaluate(`(() => { - const r=document.querySelector('#ask').getBoundingClientRect(); - return {message:document.querySelector('#ask-message').textContent,ok:document.querySelector('#ask-ok').textContent, - inside:r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight,focused:document.activeElement.id}; - })()`); - assert.match(question.message, /^Stop ".+"\? /); - assert.deepEqual([question.ok, question.inside, question.focused], ['Stop', true, 'ask-ok']); - await closeDialog('#ask', '#ask-cancel'); - assert.equal(await evaluate(`${stop}.hidden`), false, `${engine}: Cancel leaves the session running`); - await evaluate(`${stop}.click()`); - await until(`${engine} stop question again`, () => evaluate('document.querySelector("#ask").open')); - await closeDialog('#ask', '#ask-ok'); - await until(`${engine} session stopped`, () => evaluate(`${stop}.hidden`)); - pass(`${engine}: Stop asks inside the page; Cancel keeps the session and Stop ends it`); - }; + const g = await geometry(selector); + assert.ok(g.body > 120 && g.scrollable && g.inside && g.bodyBottom && g.footer, `${engine} ${selector} ${width}: ${JSON.stringify(g)}`); + // Close handlers need the real controls, not the temporary overflow content. + await evaluate(`document.querySelector('${selector} .models-body').innerHTML=window.testDialogContent`); + await closeDialog(selector); + } + pass(`${engine}: model, remote-access and folder dialogs retain a visible, scrollable body and footer`); + // Questions are asked in the page: a framed page's confirm() returns false without showing anything. + await resize(390, 844); + // The last running session: later checks use the first card's terminal. + const id = await evaluate('[...document.querySelectorAll("#sessions .session-card")].findLast((c) => !c.querySelector(".stop").hidden)?.dataset.id'); + assert.ok(id, `${engine}: a running demo session`); + const stop = `document.querySelector('#sessions .session-card[data-id="${id}"] .stop')`; + await evaluate(`${stop}.click()`); + await until(`${engine} stop question`, () => evaluate('document.querySelector("#ask").open')); + await settled(); + const question = await evaluate(`(() => { + const r=document.querySelector('#ask').getBoundingClientRect(); + return {message:document.querySelector('#ask-message').textContent,ok:document.querySelector('#ask-ok').textContent, + inside:r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight,focused:document.activeElement.id}; + })()`); + assert.match(question.message, /^Stop ".+"\? /); + assert.deepEqual([question.ok, question.inside, question.focused], ['Stop', true, 'ask-ok']); + await closeDialog('#ask', '#ask-cancel'); + assert.equal(await evaluate(`${stop}.hidden`), false, `${engine}: Cancel leaves the session running`); + await evaluate(`${stop}.click()`); + await until(`${engine} stop question again`, () => evaluate('document.querySelector("#ask").open')); + await closeDialog('#ask', '#ask-ok'); + await until(`${engine} session stopped`, () => evaluate(`${stop}.hidden`)); + pass(`${engine}: Stop asks inside the page; Cancel keeps the session and Stop ends it`); +}; + +const checks = process.env.PLAYWRIGHT_MODULE ? await serve({ instrumentation }, async (origin) => { + let checks = 0; + const pass = (label) => { checks++; console.log(`ok ${checks} - ${label}`); }; + const { webkit } = await import(pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href); + const browser = await webkit.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, hasTouch: true }); + const webkitErrors = []; + page.on('pageerror', (error) => webkitErrors.push(error.message)); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto(origin); + const run = (expression) => page.evaluate(expression); + const settle = () => run('document.fonts.ready.then(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))'); + await checkDialogs(run, (width, height) => page.setViewportSize({ width, height }), settle, `WebKit ${browser.version()}`, pass); + await page.setViewportSize({ width: 390, height: 844 }); + await run('document.querySelector("#sessions .session-card .open").click()'); + await until('WebKit touch keys ready', () => run('!document.querySelector("#terminal-controls [data-key=Enter]").disabled')); + await run('document.querySelector(".xterm-helper-textarea").focus();testInputs.length=0'); + for (const key of ['ArrowLeft', 'ArrowUp', 'ArrowDown', 'ArrowRight', 'Enter', 'Escape']) await page.tap(`#terminal-controls [data-key=${key}]`); + assert.deepEqual(await run('testInputs'), ['\x1b[D', '\x1b[A', '\x1b[B', '\x1b[C', '\r', '\x1b']); + assert.equal(await run('document.activeElement===document.querySelector(".xterm-helper-textarea")'), true); + await run('document.activeElement.blur();testInputs.length=0'); + await page.tap('#terminal-controls [data-key=ArrowUp]'); + assert.deepEqual(await run('testInputs'), ['\x1b[A']); + assert.equal(await run('document.activeElement===document.querySelector(".xterm-helper-textarea")'), false); + assert.deepEqual(webkitErrors, []); + pass('WebKit trusted touch sends each terminal key once and preserves focused/unfocused input'); + } finally { + await browser.close(); + } + return checks; +}) : await withPage({ name: 'dialogs', instrumentation }, async ({ origin, send, evaluate, layoutReady, pass, errors, navigate, reload }) => { await navigate(origin); - await checkDialogs(evaluate, (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }), layoutReady, 'Chrome'); + await checkDialogs(evaluate, (width, height) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }), layoutReady, 'Chrome', pass); assert.deepEqual(errors, []); const { providers } = JSON.parse(fs.readFileSync(new URL('../../config/providers.default.json', import.meta.url), 'utf8')); @@ -399,35 +429,5 @@ const checks = await withPage({ name: 'dialogs', instrumentation }, async ({ ori } await evaluate(`sessionStorage.removeItem('testModelStats')`); pass('Chrome: cards, the Models dialog and session tooltips show benchmarks when Rating is missing or null'); - - if (process.env.PLAYWRIGHT_MODULE) { - const { webkit } = await import(pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href); - const browser = await webkit.launch({ headless: true }); - try { - const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, hasTouch: true }); - const webkitErrors = []; - page.on('pageerror', (error) => webkitErrors.push(error.message)); - page.on('dialog', (dialog) => dialog.accept()); - await page.goto(origin); - const run = (expression) => page.evaluate(expression); - const settle = () => run('document.fonts.ready.then(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))'); - await checkDialogs(run, (width, height) => page.setViewportSize({ width, height }), settle, `WebKit ${browser.version()}`); - await page.setViewportSize({ width: 390, height: 844 }); - await run('document.querySelector("#sessions .session-card .open").click()'); - await until('WebKit touch keys ready', () => run('!document.querySelector("#terminal-controls [data-key=Enter]").disabled')); - await run('document.querySelector(".xterm-helper-textarea").focus();testInputs.length=0'); - for (const key of ['ArrowLeft', 'ArrowUp', 'ArrowDown', 'ArrowRight', 'Enter', 'Escape']) await page.tap(`#terminal-controls [data-key=${key}]`); - assert.deepEqual(await run('testInputs'), ['\x1b[D', '\x1b[A', '\x1b[B', '\x1b[C', '\r', '\x1b']); - assert.equal(await run('document.activeElement===document.querySelector(".xterm-helper-textarea")'), true); - await run('document.activeElement.blur();testInputs.length=0'); - await page.tap('#terminal-controls [data-key=ArrowUp]'); - assert.deepEqual(await run('testInputs'), ['\x1b[A']); - assert.equal(await run('document.activeElement===document.querySelector(".xterm-helper-textarea")'), false); - assert.deepEqual(webkitErrors, []); - pass('WebKit trusted touch sends each terminal key once and preserves focused/unfocused input'); - } finally { - await browser.close(); - } - } }); -console.log(`${checks} dialog and WebKit checks passed.`); +console.log(`${checks} dialog checks passed.`); diff --git a/tests/browser/layout.mjs b/tests/browser/layout.mjs index 56578d6e..ccfb4e86 100644 --- a/tests/browser/layout.mjs +++ b/tests/browser/layout.mjs @@ -6,7 +6,7 @@ import { until, withPage } from './chrome.mjs'; const instrumentation = `