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 = `