mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-01 13:07:45 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68bedd8362 | ||
|
|
0a6f380db7 | ||
|
|
44336e8b84 | ||
|
|
20cf09752d | ||
|
|
966e718b31 | ||
|
|
cf0c9fb914 | ||
|
|
bebc3640f4 | ||
|
|
aa6a4f93bd | ||
|
|
8433dd732f | ||
|
|
c34d09fe97 | ||
|
|
2fb7985bb7 | ||
|
|
a931e8a9fd | ||
|
|
149acd7141 | ||
|
|
adf4b023d8 | ||
|
|
f406d93a66 | ||
|
|
9faf99a4ab | ||
|
|
19f203837e | ||
|
|
f0f4c94c0a | ||
|
|
9c4c5870b6 | ||
|
|
924c12b291 | ||
|
|
155bc7df75 | ||
|
|
d280eaf961 | ||
|
|
5556ad563d | ||
|
|
629e7aaa31 | ||
|
|
dd2af0e5fc | ||
|
|
a923596ee7 | ||
|
|
91b9bc52a4 | ||
|
|
5c1e7ca00f |
No files matched your search
@@ -15,7 +15,7 @@ jobs:
|
||||
close-non-compliant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 2 hours
|
||||
- name: Close non-compliant issues and PRs after 72 hours
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
const seventyTwoHours = 72 * 60 * 60 * 1000;
|
||||
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
|
||||
const agentLogin = 'opencode-agent[bot]';
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
@@ -87,14 +87,14 @@ jobs:
|
||||
if (!complianceComment) continue;
|
||||
|
||||
const commentAge = now - new Date(complianceComment.created_at).getTime();
|
||||
if (commentAge < twoHours) {
|
||||
core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
|
||||
if (commentAge < seventyTwoHours) {
|
||||
core.info(`${kind} #${item.number} still within 72-hour window (${Math.round(commentAge / 60000)}m elapsed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMessage = isPR
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 72-hour window.\n\nFeel free to open a new pull request that follows our guidelines.'
|
||||
: 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 72-hour window.\n\nFeel free to open a new issue that follows our issue templates.';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
@@ -129,5 +129,5 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`);
|
||||
core.info(`Closed non-compliant ${kind} #${item.number} after 72-hour window`);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with:
|
||||
<!-- issue-compliance -->
|
||||
Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
Then explain what needs to be fixed and that they have 72 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance
|
||||
|
||||
If duplicates were found, include a section about potential duplicates with links.
|
||||
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
**What needs to be fixed:**
|
||||
- [specific reasons]
|
||||
|
||||
Please edit this issue to address the above within **2 hours**, or it will be automatically closed.
|
||||
Please edit this issue to address the above within **72 hours**, or it will be automatically closed.
|
||||
|
||||
[If duplicates found, add:]
|
||||
---
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
echo "=== Flake structure ==="
|
||||
nix flake show --all-systems
|
||||
|
||||
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
|
||||
SYSTEMS="x86_64-linux aarch64-linux aarch64-darwin"
|
||||
PACKAGES="opencode"
|
||||
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
|
||||
OPTIONAL_PACKAGES="desktop"
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- system: aarch64-linux
|
||||
runner: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- system: x86_64-darwin
|
||||
runner: macos-15-intel
|
||||
- system: aarch64-darwin
|
||||
runner: macos-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -126,7 +124,7 @@ jobs:
|
||||
|
||||
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
|
||||
|
||||
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
|
||||
for SYSTEM in x86_64-linux aarch64-linux aarch64-darwin; do
|
||||
FILE="hashes/hash-${SYSTEM}/hash.txt"
|
||||
if [ -f "$FILE" ]; then
|
||||
HASH="$(tr -d '[:space:]' < "$FILE")"
|
||||
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
**What needs to be fixed:**
|
||||
${issues.map(i => `- ${i}`).join('\n')}
|
||||
|
||||
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
|
||||
Please edit this PR description to address the above within **72 hours**, or it will be automatically closed.
|
||||
|
||||
If you believe this was flagged incorrectly, please let a maintainer know.`;
|
||||
|
||||
|
||||
@@ -251,6 +251,13 @@ jobs:
|
||||
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Run session UI component tests
|
||||
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
|
||||
run: bun --cwd packages/session-ui test:components
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always() && env.E2E_ENABLED == 'true'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -261,3 +268,5 @@ jobs:
|
||||
path: |
|
||||
packages/app/e2e/test-results
|
||||
packages/app/e2e/playwright-report
|
||||
packages/session-ui/component-tests/test-results
|
||||
packages/session-ui/component-tests/playwright-report
|
||||
+94
-1
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"jsPlugins": [
|
||||
{ "name": "anti-slop", "specifier": "./script/oxlint/anti-slop/index.ts" },
|
||||
{ "name": "anti-slop-effect", "specifier": "./script/oxlint/anti-slop/effect/index.ts" }
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
@@ -18,5 +22,94 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"packages/app/**",
|
||||
"packages/desktop/**",
|
||||
"packages/gui-extensions/**",
|
||||
"packages/ui/**",
|
||||
"packages/session-ui/**"
|
||||
],
|
||||
"rules": {
|
||||
"oxc/no-accumulating-spread": "warn",
|
||||
"anti-slop/no-array-filter-map": "warn",
|
||||
"anti-slop/no-reduce-accumulator-copy": "warn",
|
||||
"anti-slop/no-chained-type-assertions": "warn",
|
||||
"anti-slop/no-conditional-empty-object-spread": "warn",
|
||||
"anti-slop/no-known-value-widening": "warn",
|
||||
"anti-slop/no-module-mocking": "warn",
|
||||
"anti-slop/no-object-parameters": "warn",
|
||||
"anti-slop/no-reflect-apply": "warn",
|
||||
"anti-slop/no-reflect-get": "warn",
|
||||
"anti-slop/no-runtime-typeof": "warn",
|
||||
"anti-slop/no-shape-in-symbol-names": "warn",
|
||||
"anti-slop/no-unknown-parameters": "warn",
|
||||
"anti-slop/no-unknown-returns": "warn",
|
||||
"anti-slop/no-unknown-type-aliases": "warn",
|
||||
"anti-slop/no-unsafe-dictionary-type": "warn",
|
||||
"anti-slop/no-widen-then-assert": "warn",
|
||||
"anti-slop/require-readable-spacing": "warn",
|
||||
"anti-slop/require-safety-comment-for-type-assertion": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/**", "packages/desktop/**", "packages/gui-extensions/**", "packages/session-ui/**"],
|
||||
"rules": {
|
||||
"anti-slop-effect/no-manual-effect-error-tag": "warn",
|
||||
"anti-slop-effect/no-manual-tag-comparison": "warn",
|
||||
"anti-slop-effect/no-manual-tagged-construction": "warn",
|
||||
"anti-slop-effect/no-service-constructor-imports": "warn",
|
||||
"anti-slop-effect/prefer-effect-match": "warn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/gui-extensions/src/*/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "^@opencode/(app|desktop)(/|$)",
|
||||
"message": "GUI extensions never import the app or desktop packages. Use the SDK."
|
||||
},
|
||||
{
|
||||
"regex": "^@/",
|
||||
"message": "GUI extensions never import app internals. Use the SDK."
|
||||
},
|
||||
{
|
||||
"group": ["../*/*", "!../*/contract", "!../sdk/*"],
|
||||
"message": "Import another extension only through its contract.ts."
|
||||
},
|
||||
{
|
||||
"regex": "\\.css$",
|
||||
"message": "Import CSS with ?inline and contribute it with ctx.add(Style, css)."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/.build",
|
||||
"**/.sst",
|
||||
"**/*.d.ts",
|
||||
"**/sdk.gen.ts",
|
||||
".agent/**",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
".codex/**",
|
||||
".continue/**",
|
||||
".cursor/**",
|
||||
".gemini/**",
|
||||
".opencode/**",
|
||||
".pi/**",
|
||||
".roo/**",
|
||||
".windsurf/**",
|
||||
"script/oxlint/anti-slop/**"
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@oxlint/plugins": "1.60.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/react": "19.2.17",
|
||||
@@ -32,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -64,7 +65,7 @@
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/gui-extensions": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
@@ -86,13 +87,11 @@
|
||||
"core-js": "3.50.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -103,6 +102,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"vite": "8.2.2",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -178,7 +178,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -204,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -424,26 +424,22 @@
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode/app": "workspace:*",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/gui-extensions": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "44.4.5",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
"solid-js": "catalog:",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "8.2.2",
|
||||
@@ -459,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -496,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -510,9 +506,54 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.20",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/plugin-browser": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/session-ui": "workspace:*",
|
||||
"@opencode/ui": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@solid-primitives/event-listener": "catalog:",
|
||||
"@solid-primitives/keyed": "1.5.3",
|
||||
"@solid-primitives/media": "catalog:",
|
||||
"@solid-primitives/resize-observer": "catalog:",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"devtools-protocol": "0.0.1687809",
|
||||
"electron": "44.4.5",
|
||||
"electron-updater": "6.8.9",
|
||||
"lighthouse": "13.4.1",
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -531,7 +572,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -544,7 +585,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -558,7 +599,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -573,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -612,7 +653,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -642,7 +683,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -657,7 +698,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -681,7 +722,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -702,7 +743,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -724,7 +765,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -759,7 +800,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -779,7 +820,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -813,7 +854,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -832,7 +873,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -878,7 +919,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -892,7 +933,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -926,7 +967,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -961,7 +1002,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -999,7 +1040,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1040,7 +1081,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -2175,6 +2216,8 @@
|
||||
|
||||
"@opencode/function": ["@opencode/function@workspace:packages/function"],
|
||||
|
||||
"@opencode/gui-extensions": ["@opencode/gui-extensions@workspace:packages/gui-extensions"],
|
||||
|
||||
"@opencode/http-recorder": ["@opencode/http-recorder@workspace:packages/http-recorder"],
|
||||
|
||||
"@opencode/httpapi-codegen": ["@opencode/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
@@ -2477,6 +2520,8 @@
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="],
|
||||
|
||||
"@oxlint/plugins": ["@oxlint/plugins@1.60.0", "", {}, "sha512-wxEoVVAS5FQAXiA8jPY+NWTOTKTzbSViMfW7yU5OQ7+f34W0KYfy2iuSLC9o1fiYn5xt3LQLxZBREFJtDlQLRw=="],
|
||||
|
||||
"@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ=="],
|
||||
|
||||
"@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw=="],
|
||||
@@ -6199,6 +6244,8 @@
|
||||
|
||||
"@opencode/files/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opencode/gui-extensions/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@opencode/posts/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="],
|
||||
|
||||
"@opencode/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
forEachSystem = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||
rev = self.shortRev or self.dirtyShortRev or "dirty";
|
||||
@@ -22,7 +21,7 @@
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
bun
|
||||
nodejs_20
|
||||
nodejs
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
|
||||
@@ -7,7 +7,6 @@ in
|
||||
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
|
||||
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
|
||||
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
|
||||
x86_64-darwin = "4489aba55477a0082266cb690db1c829503ba3338048599d8fd243953df37dab";
|
||||
# fetchzip hashes the unpacked headers, not the release tarball.
|
||||
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
|
||||
}
|
||||
+3
-4
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
|
||||
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
|
||||
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
|
||||
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
|
||||
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
|
||||
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
|
||||
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,5 @@ stdenvNoCC.mkDerivation {
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
"aarch64-darwin"
|
||||
"x86_64-darwin"
|
||||
];
|
||||
}
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -18,7 +18,7 @@
|
||||
"dev:www": "bun run --cwd services/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint",
|
||||
"lint": "oxlint && ast-grep scan -c script/ast-grep/gui-extensions/sgconfig.yml",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"lint:effect-simplifications": "ast-grep scan -c script/ast-grep/effect-simplifications/sgconfig.yml --off=unused-suppression packages",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
@@ -117,10 +117,11 @@
|
||||
"@actions/artifact": "5.0.1",
|
||||
"@ast-grep/cli": "0.44.0",
|
||||
"@opencode/client": "workspace:*",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@oxlint/plugins": "1.60.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.21",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -143,14 +143,15 @@ const adapter = {
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
// GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
|
||||
// `-pro` slugs), and never alongside automatic `context_management` compaction.
|
||||
// GPT-6 and later default to `configuration_update` support, except in `reasoning.mode: "pro"`
|
||||
// or alongside automatic `context_management` compaction.
|
||||
const supportsEffortUpdates = (request: LLMRequest) => {
|
||||
if (request.providerOptions?.contextManagement !== undefined) return false
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
|
||||
const match = /(?:^|\/)gpt-(\d+)(?:\.\d+)?(?:-|$)/i.exec(request.model.id)
|
||||
return match !== null && Number(match[1]) >= 6
|
||||
}
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
|
||||
@@ -41,16 +41,20 @@ const Body = Schema.Struct({
|
||||
user_id: Options.fields.userID,
|
||||
})
|
||||
|
||||
// Tool streaming was introduced in GLM-4.6; later versions inherit support.
|
||||
const supportsToolStreaming = (modelID: string) => {
|
||||
const match = /(?:^|\/)glm-(\d+)(?:\.(\d+))?(?:-|$)/i.exec(modelID)
|
||||
return match !== null && (Number(match[1]) > 4 || (Number(match[1]) === 4 && Number(match[2] ?? 0) >= 6))
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("ZAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
|
||||
const body = yield* OpenAIChat.protocol.body.from(request)
|
||||
return {
|
||||
...body,
|
||||
thinking: options.thinking,
|
||||
// Tool streaming was introduced in GLM-4.6; older models must not receive the opt-in.
|
||||
tool_stream:
|
||||
options.toolStream ??
|
||||
(body.tools?.length && /^glm-(?:4\.[67]|5(?:[.-]|$))/i.test(request.model.id) ? true : undefined),
|
||||
options.toolStream ?? (body.tools?.length && supportsToolStreaming(request.model.id) ? true : undefined),
|
||||
do_sample: options.doSample,
|
||||
response_format: options.responseFormat,
|
||||
request_id: options.requestID,
|
||||
|
||||
@@ -26,9 +26,12 @@ const patterns = [
|
||||
/tokens in request more than max tokens allowed/i,
|
||||
/maximum prompt length is \d+/i,
|
||||
/reduce the length of the messages/i,
|
||||
// DeepInfra
|
||||
/requested input length \d+ exceeds maximum input length/i,
|
||||
/maximum context length is \d+ tokens/i,
|
||||
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
|
||||
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
|
||||
// Novita omits the token counts.
|
||||
/input(?: \(\d+ tokens\))? is longer than the model'?s context length/i,
|
||||
/exceeds the limit of \d+/i,
|
||||
/exceeds the available context size/i,
|
||||
/greater than the context length/i,
|
||||
@@ -37,7 +40,8 @@ const patterns = [
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
// Z.ai code 1261 arrives as `Prompt too long` or `Prompt 超长`.
|
||||
/prompt (?:too long|超长)/i,
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
@@ -114,7 +118,8 @@ const QUOTA_CODES = new Set([
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
|
||||
// Z.ai's Responses API reports account and plan rejections mid-stream as `permission_denied`.
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error", "permission_denied", "api_key_invalid"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
@@ -128,6 +133,7 @@ const SERVER_CODES = new Set([
|
||||
])
|
||||
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
|
||||
const INVALID_REQUEST_CODES = new Set([
|
||||
"model_not_found",
|
||||
"invalid_prompt",
|
||||
"invalid_request",
|
||||
"invalid_request_error",
|
||||
@@ -147,15 +153,19 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// OpenCode Zen replaces upstream codes outside its allow-list but keeps the original
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
// xAI reports an invalid API key as HTTP 400 with the generic `invalid-argument` code.
|
||||
const AUTH_TEXT = /incorrect api key provided/i
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Z.ai reports balance, plan expiry, plan limits, and plan model access on 429.
|
||||
const QUOTA_TEXT =
|
||||
/insufficient[-_\s]?(?:quota|balance)|quota[-_\s]?exceeded|budget exceeded|usage limit|limit exhausted|package has expired|plan does not yet include/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
// under `invalid_request_error`.
|
||||
const CONTENT_POLICY_TEXT =
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system/i
|
||||
/violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system|detected potentially unsafe or sensitive content/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
@@ -246,7 +256,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
if (
|
||||
input.status === 401 ||
|
||||
input.status === 403 ||
|
||||
codes.some((code) => AUTH_CODES.has(code)) ||
|
||||
(input.status === 400 && AUTH_TEXT.test(text))
|
||||
)
|
||||
return new AuthenticationError(details)
|
||||
if (
|
||||
input.status === 429 ||
|
||||
|
||||
@@ -6,10 +6,17 @@ const MISSING_TOOL_RESULT = "Tool result missing"
|
||||
export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
const normalized: Message[] = []
|
||||
const pending = new Map<string, ToolCallPart>()
|
||||
// System updates cannot sit between a tool call and its results, so they wait until every pending call is answered.
|
||||
const held: Message[] = []
|
||||
const releaseHeld = () => {
|
||||
if (pending.size > 0) return
|
||||
normalized.push(...held)
|
||||
held.length = 0
|
||||
}
|
||||
const appendMissingResults = () => {
|
||||
if (pending.size === 0) return
|
||||
normalized.push(missingToolResults(pending.values()))
|
||||
if (pending.size > 0) normalized.push(missingToolResults(pending.values()))
|
||||
pending.clear()
|
||||
releaseHeld()
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -18,6 +25,12 @@ export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
if (message.role === "tool") {
|
||||
const tool = normalizeToolMessage(message, pending)
|
||||
if (tool) normalized.push(tool)
|
||||
releaseHeld()
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "system" && pending.size > 0) {
|
||||
held.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -27,6 +40,7 @@ export function normalizeToolHistory(messages: ReadonlyArray<Message>) {
|
||||
if (part.type === "tool-call" && part.providerExecuted !== true) pending.set(part.id, part)
|
||||
}
|
||||
}
|
||||
appendMissingResults()
|
||||
|
||||
return normalized.length === messages.length && normalized.every((message, index) => message === messages[index])
|
||||
? messages
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts a marker between a tool call and its result", () =>
|
||||
it.effect("moves a marker between a tool call and its result after the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -166,8 +166,8 @@ describe("Anthropic Messages effort updates", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
|
||||
{ role: "system", content: [], output_config: { effort: "low" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -375,11 +375,24 @@ describe("OpenAI Responses effort updates", () => {
|
||||
["openai/gpt-6-sol", true],
|
||||
["gpt-6-luna", true],
|
||||
["openai/gpt-6-luna", true],
|
||||
["gpt-6-astra-2026-09-01", false],
|
||||
["gpt-6-sol-pro", false],
|
||||
["gpt-6-luna-pro", false],
|
||||
["gpt-6-sol-fast", false],
|
||||
["gpt-6", true],
|
||||
["gpt-6.1-sol", true],
|
||||
["openai/gpt-6.1-sol", true],
|
||||
["GPT-6.1-SOL", true],
|
||||
["gpt-6-astra-2026-09-01", true],
|
||||
["gpt-6-sol-pro", true],
|
||||
["gpt-6-luna-pro", true],
|
||||
["gpt-6-sol-fast", true],
|
||||
["gpt-7", true],
|
||||
["openai/gpt-7.2-new-family", true],
|
||||
["gpt-10.1", true],
|
||||
["gpt-5", false],
|
||||
["gpt-5.6-sol", false],
|
||||
["gpt-5.10", false],
|
||||
["not-gpt-6-sol", false],
|
||||
["gpt-6foo", false],
|
||||
["gpt-6.x-sol", false],
|
||||
["future-model", false],
|
||||
] as const) {
|
||||
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -7,9 +7,11 @@ describe("provider error classification", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Requested input length 600010 exceeds maximum input length 131071",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
||||
"The input is longer than the model's context length trace_id: 39d8c3a5c6c91dbc8dd9055f0b37e084",
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
"Range of input length should be [1, 129024]",
|
||||
"Too many tokens",
|
||||
@@ -282,6 +284,82 @@ describe("provider error classification", () => {
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai plan and balance limits as quota rather than throttling", () => {
|
||||
const zai = (code: string, message: string) => ({ error: { code, message } })
|
||||
const cases = [
|
||||
zai("1113", "Insufficient balance or no resource package. Please recharge."),
|
||||
zai("1308", "Usage limit reached for 5 hours. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai(
|
||||
"1309",
|
||||
"Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.",
|
||||
),
|
||||
zai("1310", "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-10-01 00:00:00"),
|
||||
zai("1311", "Your current subscription plan does not yet include access to glm-5"),
|
||||
zai("1314", "Your enterprise package has expired. Please contact your enterprise administrator."),
|
||||
// Z.ai's Anthropic-compatible endpoint wraps the code and request ID into the message.
|
||||
{
|
||||
type: "error",
|
||||
error: {
|
||||
type: "rate_limit_error",
|
||||
code: "1309",
|
||||
message:
|
||||
"[1309][Your GLM Coding Plan package has expired and is temporarily unavailable. You can resume using it after renewing the subscription on the official website.][20260929132151e73af01340d54b58]",
|
||||
},
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(cases.length).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("classifies Z.ai prompt length rejections as context overflow", () => {
|
||||
const cases = [
|
||||
{ error: { code: "1261", message: "Prompt 超长" } },
|
||||
{ error: { code: "1261", message: "Prompt too long" } },
|
||||
{
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "1261", message: "[1261][Prompt too long][2026092913]" },
|
||||
},
|
||||
]
|
||||
expect(
|
||||
cases.map((body) => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: body.error.message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify(body),
|
||||
})
|
||||
return reason._tag === "InvalidRequest" ? reason.classification : reason._tag
|
||||
}),
|
||||
).toEqual(["context-overflow", "context-overflow", "context-overflow"])
|
||||
})
|
||||
|
||||
test("classifies Z.ai sensitive content rejections as content policy", () => {
|
||||
const message =
|
||||
"System detected potentially unsafe or sensitive content in input or generation. Please avoid using prompts that may generate sensitive content. Thank you for your cooperation."
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message,
|
||||
status: 400,
|
||||
rawBody: JSON.stringify({ error: { code: "1301", message } }),
|
||||
})._tag,
|
||||
).toBe("ContentPolicy")
|
||||
})
|
||||
|
||||
test("keeps Z.ai throttling and overload retryable", () => {
|
||||
expect(
|
||||
[
|
||||
{ error: { code: "1302", message: "Rate limit reached for requests" } },
|
||||
{ error: { code: "1305", message: "The service may be temporarily overloaded, please try again later" } },
|
||||
].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 429, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["RateLimit", "RateLimit"])
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
@@ -383,7 +461,7 @@ describe("provider error rawBody classification", () => {
|
||||
expect(reason._tag === "InvalidRequest" ? reason.classification : reason._tag).toBe("context-overflow")
|
||||
})
|
||||
|
||||
test("classifies Google invalid API keys as authentication failures", () => {
|
||||
test("classifies invalid API keys reported as HTTP 400 as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
code: 400,
|
||||
@@ -407,6 +485,14 @@ describe("provider error rawBody classification", () => {
|
||||
classifyProviderFailure({ message: "API key not valid. Please pass a valid API key.", status: 400, rawBody })
|
||||
._tag,
|
||||
).toBe("Authentication")
|
||||
// xAI
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message: "Incorrect API key provided. You can obtain an API key from https://console.x.ai.",
|
||||
status: 400,
|
||||
rawBody: '{"code":"invalid-argument","error":"Incorrect API key provided."}',
|
||||
})._tag,
|
||||
).toBe("Authentication")
|
||||
})
|
||||
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
@@ -422,6 +508,19 @@ describe("provider error rawBody classification", () => {
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
|
||||
).toBe("QuotaExceeded")
|
||||
// Z.ai Responses stream rejections
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message: "Unknown Model, please check the model code.",
|
||||
rawBody: '{"type":"response.failed","response":{"error":{"code":"model_not_found"}}}',
|
||||
})._tag,
|
||||
).toBe("InvalidRequest")
|
||||
expect(
|
||||
classifyProviderFailure({
|
||||
message: "Your GLM Coding Plan package has expired and is temporarily unavailable.",
|
||||
rawBody: '{"type":"response.failed","response":{"error":{"code":"permission_denied"}}}',
|
||||
})._tag,
|
||||
).toBe("Authentication")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -588,9 +588,9 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
it.effect("moves a system update between a local tool call and its result after the result", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -601,9 +601,13 @@ describe("Anthropic Messages route", () => {
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
)
|
||||
|
||||
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
|
||||
expect(prepared.body.messages.slice(1)).toEqual([
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '"Done."' }] },
|
||||
{ role: "system", content: [{ type: "text", text: "Too early.", cache_control: undefined }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -501,25 +501,23 @@ describe("Bedrock Converse route", () => {
|
||||
LLM.request({ model, messages: [Message.assistant([call])], cache: "none" }),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
toolUse: {
|
||||
toolUseId: "tool_1",
|
||||
name: "edit",
|
||||
input: {
|
||||
path: "file.ts",
|
||||
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
|
||||
nested: { empty: {}, onlyEmpty: {} },
|
||||
" ": "preserve whitespace key",
|
||||
},
|
||||
expect(prepared.body.messages[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
toolUse: {
|
||||
toolUseId: "tool_1",
|
||||
name: "edit",
|
||||
input: {
|
||||
path: "file.ts",
|
||||
edits: [{ oldText: "a", newText: "b" }, null, true, 7, "text", ["kept", { nested: { value: "ok" } }]],
|
||||
nested: { empty: {}, onlyEmpty: {} },
|
||||
" ": "preserve whitespace key",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(input).toEqual(original)
|
||||
expect(call.input).toBe(input)
|
||||
}),
|
||||
@@ -540,15 +538,13 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
|
||||
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(prepared.body.messages[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_empty_key", name: "first", input: {} } },
|
||||
{ toolUse: { toolUseId: "tool_empty_object", name: "second", input: {} } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1099,6 +1095,12 @@ describe("Bedrock Converse route", () => {
|
||||
{ toolUse: { toolUseId: "call_1", name: "lookup", input: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ toolResult: { toolUseId: "call_1", content: [{ text: "Tool result missing" }], status: "error" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1285,25 +1285,23 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(prepared.body.contents[0]).toEqual({
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1321,21 +1319,19 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(prepared.body.contents[0]).toEqual({
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -215,13 +215,11 @@ describe("Mistral Chat", () => {
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
|
||||
},
|
||||
])
|
||||
expect(prepared.body.messages[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{ id: "Ab12Cd34E", type: "function", function: { name: "lookup", arguments: "{}" } }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -315,7 +313,9 @@ describe("Mistral Chat", () => {
|
||||
expect(response.reasoning).toBe("Consider")
|
||||
expect(response.text).toBe("Answer")
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
|
||||
expect(
|
||||
response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata]),
|
||||
).toEqual([
|
||||
["Con", undefined],
|
||||
["sider", undefined],
|
||||
])
|
||||
|
||||
@@ -1257,21 +1257,19 @@ describe("OpenAI Chat route", () => {
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: "thinking",
|
||||
reasoning_details: details,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replay.body.messages[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: "thinking",
|
||||
reasoning_details: details,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1374,18 +1372,16 @@ describe("OpenAI Chat route", () => {
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
|
||||
reasoning_content: "Let me think",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "Plan tools" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(replay.body.messages[0]).toEqual({
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
|
||||
reasoning_content: "Let me think",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "Plan tools" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3804,6 +3804,8 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
arguments: '{"query":"news"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_1", output: "Tool result missing" },
|
||||
{ type: "function_call_output", call_id: "call_2", output: "Tool result missing" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -4098,15 +4100,13 @@ describe("OpenAI Responses route", () => {
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
])
|
||||
expect(prepared.body.input[0]).toEqual({
|
||||
type: "function_call",
|
||||
id: "fc_item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4998,11 +4998,11 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
|
||||
const body = JSON.stringify({
|
||||
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
|
||||
})
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
|
||||
),
|
||||
Effect.provide(fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } })),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,16 +54,28 @@ it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to
|
||||
baseURL: "https://gateway.example/custom",
|
||||
auth: Auth.header("x-test-key", "fixture"),
|
||||
})
|
||||
for (const modelID of [
|
||||
"glm-4.5",
|
||||
"glm-4.5-air",
|
||||
"glm-4.5-x",
|
||||
"glm-4.6",
|
||||
"glm-4.7-flash",
|
||||
"glm-5.3",
|
||||
"glm-5.3-flash",
|
||||
"future-model",
|
||||
]) {
|
||||
for (const [modelID, supported] of [
|
||||
["glm-3.9", false],
|
||||
["glm-4", false],
|
||||
["glm-4.5", false],
|
||||
["glm-4.5-air", false],
|
||||
["glm-4.5-x", false],
|
||||
["glm-4.6", true],
|
||||
["glm-4.7-flash", true],
|
||||
["glm-4.8", true],
|
||||
["glm-4.10", true],
|
||||
["glm-5", true],
|
||||
["glm-5.3", true],
|
||||
["glm-5.3-flash", true],
|
||||
["glm-6", true],
|
||||
["glm-10.1-new-family", true],
|
||||
["zai/glm-6.1", true],
|
||||
["GLM-4.6", true],
|
||||
["not-glm-6", false],
|
||||
["glm-4.60foo", false],
|
||||
["glm-6.x", false],
|
||||
["future-model", false],
|
||||
] as const) {
|
||||
const result = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: provider.chat(modelID),
|
||||
@@ -78,9 +90,7 @@ it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to
|
||||
max_tokens: 123,
|
||||
thinking: { type: "enabled", clear_thinking: false },
|
||||
})
|
||||
expect(result.body.tool_stream).toBe(
|
||||
["glm-4.6", "glm-4.7-flash", "glm-5.3", "glm-5.3-flash"].includes(modelID) ? true : undefined,
|
||||
)
|
||||
expect(result.body.tool_stream).toBe(supported ? true : undefined)
|
||||
expect(result.body.max_completion_tokens).toBeUndefined()
|
||||
expect(result.body.store).toBeUndefined()
|
||||
expect(result.body.tools).toEqual([
|
||||
@@ -120,6 +130,30 @@ it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ZAI tool streaming defaults require tools and preserve explicit overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const configure of [ZAI.configure, ZAICodingPlan.configure]) {
|
||||
const provider = configure({ apiKey: "fixture" })
|
||||
for (const item of [
|
||||
{ id: "glm-6.1", tools: [], toolStream: undefined, expected: undefined },
|
||||
{ id: "glm-4.5", tools: [tool], toolStream: true, expected: true },
|
||||
{ id: "glm-6.1", tools: [tool], toolStream: false, expected: false },
|
||||
{ id: "future-model", tools: [tool], toolStream: true, expected: true },
|
||||
]) {
|
||||
const result = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: provider.chat(item.id),
|
||||
prompt: "Hello",
|
||||
tools: item.tools,
|
||||
providerOptions: { toolStream: item.toolStream },
|
||||
}),
|
||||
)
|
||||
expect(result.body.tool_stream).toBe(item.expected)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ZAI lowers effort using the selected native API without inventing thinking settings", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "future-effort"]) {
|
||||
|
||||
@@ -15,12 +15,47 @@ describe("tool history normalization", () => {
|
||||
Message.assistant(toolCall("trailing")),
|
||||
])
|
||||
|
||||
expect(normalized.map((message) => message.role)).toEqual(["assistant", "tool", "tool", "user", "assistant"])
|
||||
expect(normalized.map((message) => message.role)).toEqual([
|
||||
"assistant",
|
||||
"tool",
|
||||
"tool",
|
||||
"user",
|
||||
"assistant",
|
||||
"tool",
|
||||
])
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ type: "tool-result", id: "first", name: "first" })
|
||||
expect(normalized[2]?.content).toEqual([
|
||||
{ type: "tool-result", id: "second", name: "second", result: { type: "error", value: "Tool result missing" } },
|
||||
])
|
||||
expect(normalized[4]?.content).toEqual([toolCall("trailing")])
|
||||
expect(normalized[5]?.content).toEqual([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "trailing",
|
||||
name: "trailing",
|
||||
result: { type: "error", value: "Tool result missing" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("fills missing results for trailing calls without replacing available results", () => {
|
||||
const calls = Message.assistant([toolCall("answered"), toolCall("unanswered")])
|
||||
const answered = toolResult("answered", "done", "answered", "text")
|
||||
const hosted = ToolCallPart.make({ id: "hosted", name: "web_search", input: {}, providerExecuted: true })
|
||||
|
||||
expect(normalizeToolHistory([calls, answered])).toEqual([
|
||||
calls,
|
||||
answered,
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "unanswered",
|
||||
name: "unanswered",
|
||||
result: "Tool result missing",
|
||||
resultType: "error",
|
||||
}),
|
||||
),
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(hosted)])).toEqual([Message.assistant(hosted)])
|
||||
})
|
||||
|
||||
test("normalizes empty results without changing whitespace or media", () => {
|
||||
@@ -77,4 +112,51 @@ describe("tool history normalization", () => {
|
||||
|
||||
expect(normalized[1]?.content[0]).toMatchObject({ name: "lookup", namespace: undefined })
|
||||
})
|
||||
|
||||
test("moves system updates after the results of pending calls", () => {
|
||||
const calls = Message.assistant([toolCall("first"), toolCall("second")])
|
||||
const first = toolResult("first", "one", "first", "text")
|
||||
const second = toolResult("second", "two", "second", "text")
|
||||
const update = Message.system("First update.")
|
||||
const later = Message.system("Second update.")
|
||||
const user = Message.user("Continue.")
|
||||
const missing = (id: string) =>
|
||||
Message.tool(ToolResultPart.make({ id, name: id, result: "Tool result missing", resultType: "error" }))
|
||||
|
||||
expect(normalizeToolHistory([calls, first, update, later, second, user])).toEqual([
|
||||
calls,
|
||||
first,
|
||||
second,
|
||||
update,
|
||||
later,
|
||||
user,
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update, user])).toEqual([
|
||||
Message.assistant(toolCall("first")),
|
||||
missing("first"),
|
||||
update,
|
||||
user,
|
||||
])
|
||||
expect(normalizeToolHistory([Message.assistant(toolCall("first")), update])).toEqual([
|
||||
Message.assistant(toolCall("first")),
|
||||
missing("first"),
|
||||
update,
|
||||
])
|
||||
})
|
||||
|
||||
test("moves effort updates after the results of pending calls", () => {
|
||||
const call = Message.assistant(toolCall("first"))
|
||||
const result = toolResult("first", "one", "first", "text")
|
||||
const effort = Message.effort({ effort: "low", previous: "high" })
|
||||
|
||||
expect(normalizeToolHistory([call, effort, result])).toEqual([call, result, effort])
|
||||
})
|
||||
|
||||
test("keeps system updates in place when no call is pending", () => {
|
||||
const history = [Message.assistant(toolCall("first")), toolResult("first", "one", "first", "text")]
|
||||
const update = Message.system("Update.")
|
||||
const input = [Message.user("Start."), update, ...history, update, Message.user("Continue.")]
|
||||
|
||||
expect(normalizeToolHistory(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,18 @@
|
||||
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
|
||||
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
|
||||
|
||||
## Tests
|
||||
|
||||
A test must pay for its upkeep. Before adding one, answer three questions: which observable contract does it protect, which credible regression makes it fail, and why does no existing test already catch that regression. If any answer is missing, do not add the test.
|
||||
|
||||
- One contract, one owner. Test each behavior once, at the strongest boundary that observes it: the area's e2e keeper suite, or the owning module's unit test. Extend an existing table or keeper case before creating a file.
|
||||
- Do not unit-test what an e2e keeper already proves, what a dependency does (for example `@pierre/trees`), config that passes values through, or source text (import or string greps). Source text is fair game only when that text is the contract, such as a persisted key.
|
||||
- Never add production seams for tests: no test-only exports, parameters such as `now`, `delay` or `budget`, flags, globals, or `data-*` attributes. Control time with `setSystemTime` from `bun:test` and drive the real entry point.
|
||||
- A test must be able to fail. Do not assert values the code under test produced, mock the behavior you assert, use soft assertions for the order the test guards, or leave `rejects`/`resolves` un-awaited. A regression test must fail on the code before the fix.
|
||||
- Do not repeat a case per theme, text direction, viewport or channel. Use one table, and only when the behavior differs.
|
||||
- When you delete or move coverage, name the test that still proves the contract. When you move a contract, mutate its owner once and confirm the new test fails.
|
||||
- A contract proven by a `packages/session-ui/component-tests` test does not also get an app e2e copy.
|
||||
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal, render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneEvent, BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
import { SessionBrowserPane, type SessionBrowserComment } from "../src/session/browser/pane"
|
||||
|
||||
export function mountBrowserPane() {
|
||||
const host = document.createElement("main")
|
||||
host.dataset.testid = "browser-pane-fixture"
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
|
||||
document.body.appendChild(host)
|
||||
|
||||
function Fixture() {
|
||||
const [store, setStore] = createStore({
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
url: undefined as string | undefined,
|
||||
loading: false,
|
||||
generation: 0,
|
||||
delayNavigation: false,
|
||||
pendingURL: undefined as string | undefined,
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
covered: false,
|
||||
captures: 0,
|
||||
holdCapture: false,
|
||||
picker: {} as Record<string, boolean | undefined>,
|
||||
highlights: [] as string[],
|
||||
comments: [] as SessionBrowserComment[],
|
||||
})
|
||||
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
|
||||
const held: (() => void)[] = []
|
||||
const inspectors = new Set<(event: Extract<BrowserPaneEvent, { type: "inspect" }>) => void>()
|
||||
const emitInspect = (event: Extract<BrowserPaneEvent, { type: "inspect" }>) =>
|
||||
inspectors.forEach((listener) => listener(event))
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
url: `https://${name.toLowerCase()}.example/`,
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 0,
|
||||
}))
|
||||
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
|
||||
const registrations = new Map<string, BrowserPaneRegistration>(
|
||||
tabs.map((tab) => [
|
||||
tab.title,
|
||||
{
|
||||
setLayout: (layout) => setStore("layouts", tab.title, layout),
|
||||
command: async () => undefined,
|
||||
capture: async () => {
|
||||
setStore("captures", (count) => count + 1)
|
||||
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
|
||||
const canvas = new OffscreenCanvas(4, 4)
|
||||
const paint = canvas.getContext("2d")
|
||||
if (paint) {
|
||||
paint.fillStyle = "#3b82f6"
|
||||
paint.fillRect(0, 0, 4, 4)
|
||||
}
|
||||
return canvas.convertToBlob()
|
||||
},
|
||||
// The desktop confirms each picker change, as the page does once inspect mode is armed.
|
||||
inspect: (tabID, enabled) => {
|
||||
setStore("picker", tab.title, enabled)
|
||||
emitInspect({ type: "inspect", tabID, active: enabled })
|
||||
},
|
||||
highlight: (_tabID, ref) => setStore("highlights", (items) => [...items, ref ?? "clear"]),
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
const browser: ReturnType<typeof createSessionBrowser> = {
|
||||
available: () => true,
|
||||
attached: () => !!registrations.get(store.session),
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => {
|
||||
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
|
||||
return {
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[store.session],
|
||||
}
|
||||
},
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: (command) => {
|
||||
setStore("error", undefined)
|
||||
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
|
||||
if (command.type === "navigate") {
|
||||
if (store.delayNavigation) {
|
||||
setStore("pendingURL", command.url)
|
||||
return
|
||||
}
|
||||
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
|
||||
}
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
},
|
||||
onInspect: (listener) => {
|
||||
inspectors.add(listener)
|
||||
return () => inspectors.delete(listener)
|
||||
},
|
||||
reveal: () => undefined,
|
||||
}
|
||||
const pick = () =>
|
||||
emitInspect({
|
||||
type: "inspect",
|
||||
tabID: browser.active().id,
|
||||
active: false,
|
||||
element: {
|
||||
ref: Browser.Ref.make("e7"),
|
||||
selector: "main > button.primary",
|
||||
label: "button.primary",
|
||||
role: "button",
|
||||
name: "Save changes",
|
||||
rect: { x: 48, y: 40, width: 160, height: 36 },
|
||||
},
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
|
||||
<p>Selected session: {store.session}</p>
|
||||
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
|
||||
<For each={["Alpha", "Beta", "Empty"]}>
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
|
||||
}
|
||||
>
|
||||
Load current page
|
||||
</button>
|
||||
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
|
||||
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
|
||||
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
|
||||
Block navigation
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({
|
||||
url: store.pendingURL,
|
||||
pendingURL: undefined,
|
||||
loading: false,
|
||||
generation: store.generation + 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
|
||||
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
|
||||
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
|
||||
<button onClick={pick}>Pick element</button>
|
||||
</nav>
|
||||
<p>Captures: {store.captures}</p>
|
||||
<p>Picker: {store.picker[store.session] ? "on" : "off"}</p>
|
||||
<p>Highlights: {store.highlights.join(",")}</p>
|
||||
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane
|
||||
browser={browser}
|
||||
visible={store.visible}
|
||||
onComment={(comment) => setStore("comments", (items) => [...items, comment])}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<ul data-testid="fixture-comments">
|
||||
<For each={store.comments}>
|
||||
{(comment) => (
|
||||
<li>
|
||||
{comment.element.label} {comment.element.ref ? `@${comment.element.ref}` : "(no ref)"}:{" "}
|
||||
{comment.comment}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<Show when={store.covered}>
|
||||
{/* Floating content portals into <body> like a menu or hover card over the page. */}
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
data-popper-positioner
|
||||
data-testid="fixture-popover"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "320px",
|
||||
height: "480px",
|
||||
"z-index": "1001",
|
||||
"pointer-events": "none",
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
</Show>
|
||||
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
|
||||
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<div
|
||||
data-testid={`native-${tab.title}`}
|
||||
data-visible={!!store.layouts[tab.title]?.visible}
|
||||
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
|
||||
>
|
||||
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<UiI18nBridge>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
</DialogProvider>
|
||||
</UiI18nBridge>
|
||||
</LanguageProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
}
|
||||
@@ -1,46 +1,49 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
|
||||
const modules = {
|
||||
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
|
||||
surface: source("../src/runtime/extension/surface.tsx"),
|
||||
language: source("../src/runtime/i18n/language.tsx"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountBrowserPane } = await import(fixture)
|
||||
mountBrowserPane()
|
||||
}, fixture)
|
||||
// Any story loads the app styles; the fixture mounts the pane beside it on the real host surface.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserPane }, { createSurfaces }, language] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.surface),
|
||||
import(modules.language),
|
||||
])
|
||||
mountBrowserPane({
|
||||
createSurfaces,
|
||||
LanguageProvider: language.LanguageProvider,
|
||||
UiI18nBridge: language.UiI18nBridge,
|
||||
useLanguage: language.useLanguage,
|
||||
})
|
||||
}, modules)
|
||||
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
|
||||
story("hides a native page when another takes the pane, the pane hides, or it unmounts", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const alpha = root.getByTestId("native-Alpha")
|
||||
const beta = root.getByTestId("native-Beta")
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await expect(beta).toHaveAttribute("data-visible", "true")
|
||||
await expect(alpha).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Empty", exact: true }).click()
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(0)
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(beta).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await expect(alpha).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(alpha).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(alpha).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(alpha).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("hides the native view immediately while the pane stays mounted", async ({ page }) => {
|
||||
@@ -58,7 +61,7 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
|
||||
story("keeps a still of the page under floating content that covers it", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const still = root.locator("#browser-panel img")
|
||||
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
|
||||
@@ -71,7 +74,6 @@ story("keeps a still of the page under floating content that covers it", async (
|
||||
await root.getByRole("button", { name: "Release capture", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(still).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("covered.png") })
|
||||
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
@@ -79,22 +81,26 @@ story("keeps a still of the page under floating content that covers it", async (
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("comments on a picked element over a still of the page", async ({ page }, testInfo) => {
|
||||
story("comments on a picked element over a still of the page", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
|
||||
await picker.click()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("status")).toHaveText(
|
||||
"Click an element in the page to comment on it. Press Escape to cancel.",
|
||||
)
|
||||
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "false")
|
||||
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary")
|
||||
// The spotlight frames the picked element in surface pixels.
|
||||
await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "48px")
|
||||
// The native page hides behind its still so the editor can float over it.
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.locator("#browser-panel img")).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("comment.png") })
|
||||
|
||||
await editor.fill("Make this the primary colour")
|
||||
await editor.press("Enter")
|
||||
@@ -146,9 +152,14 @@ story("keeps the comment editor and its actions inside the page", async ({ page
|
||||
await editor.locator("textarea").evaluate((element) => {
|
||||
element.style.height = "900px"
|
||||
})
|
||||
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
|
||||
const box = await editor.boundingBox()
|
||||
expect(surface && box && box.y + box.height <= surface.y + surface.height).toBe(true)
|
||||
// The editor moves once it has measured its new height.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const surface = await root.locator('[data-component="browser-comment"]').boundingBox()
|
||||
const box = await editor.boundingBox()
|
||||
return !!surface && !!box && box.y + box.height <= surface.y + surface.height
|
||||
})
|
||||
.toBe(true)
|
||||
const submit = editor.getByRole("button", { name: "Comment", exact: true })
|
||||
await submit.scrollIntoViewIfNeeded()
|
||||
await expect(submit).toBeInViewport()
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import type { Setup } from "@opencode/gui-extensions/sdk"
|
||||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { ExtensionHostProvider, useExtensionHost } from "../src/runtime/extension/host"
|
||||
import { LanguageProvider } from "../src/runtime/i18n/language"
|
||||
|
||||
/** Mounts the real extension host with one extension whose every renderer load settles when the test says so. */
|
||||
export function mountExtensionHost() {
|
||||
const loads: PromiseWithResolvers<{ default: Setup }>[] = []
|
||||
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set())
|
||||
const state = { host: undefined as ReturnType<typeof useExtensionHost> | undefined }
|
||||
const container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
function Capture() {
|
||||
state.host = useExtensionHost()
|
||||
return null
|
||||
}
|
||||
const dispose = render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<DialogProvider>
|
||||
<ExtensionHostProvider
|
||||
definitions={[
|
||||
{
|
||||
id: "fixture",
|
||||
renderer: () => {
|
||||
const load = Promise.withResolvers<{ default: Setup }>()
|
||||
loads.push(load)
|
||||
return load.promise
|
||||
},
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
services={[]}
|
||||
>
|
||||
<Capture />
|
||||
</ExtensionHostProvider>
|
||||
</DialogProvider>
|
||||
</LanguageProvider>
|
||||
),
|
||||
container,
|
||||
)
|
||||
return {
|
||||
unmount: () => {
|
||||
dispose()
|
||||
container.remove()
|
||||
},
|
||||
/** Resolves the nth renderer load (the first by default) with this setup. */
|
||||
load: (setup: Setup, index = 0) => loads[index].resolve({ default: setup }),
|
||||
/** Rejects the nth renderer load. */
|
||||
fail: (index: number, error: unknown) => loads[index].reject(error),
|
||||
/** Renderer loads requested so far. */
|
||||
count: () => loads.length,
|
||||
reload: () => state.host?.reload("fixture"),
|
||||
disable: () => setDisabled(new Set(["fixture"])),
|
||||
enable: () => setDisabled(new Set<string>()),
|
||||
status: () => state.host?.state.status.fixture,
|
||||
/** Contributions the host holds for a point; readable after the host unmounts. */
|
||||
entries: (point: string) => state.host?.state.entries[point]?.length ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Context, Dialogs } from "@opencode/gui-extensions/sdk"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./extension-host.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
// Any story loads the app; the fixture mounts the real host beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
})
|
||||
|
||||
story("an extension that finishes loading after the host unmounts is never set up", async ({ page }) => {
|
||||
const setups = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const state = { setups: 0 }
|
||||
host.unmount()
|
||||
host.load(() => void state.setups++)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
return state.setups
|
||||
}, fixture)
|
||||
expect(setups).toBe(0)
|
||||
})
|
||||
|
||||
story("an async setup that resolves after the host unmounts releases everything it registers", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const started = Promise.withResolvers<void>()
|
||||
const resume = Promise.withResolvers<void>()
|
||||
const cleaned: string[] = []
|
||||
const point = { kind: "point" as const, id: "fixture-point" }
|
||||
host.load(async (ctx: Context) => {
|
||||
ctx.add(point, "before")
|
||||
started.resolve()
|
||||
await resume.promise
|
||||
ctx.add(point, "after")
|
||||
ctx.cleanup(() => void cleaned.push("registered"))
|
||||
return () => void cleaned.push("returned")
|
||||
})
|
||||
await started.promise
|
||||
const before = host.entries(point.id)
|
||||
host.unmount()
|
||||
resume.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
return { before, after: host.entries(point.id), cleaned }
|
||||
}, fixture)
|
||||
expect(result).toEqual({ before: 1, after: 0, cleaned: ["registered", "returned"] })
|
||||
})
|
||||
|
||||
story("older loads neither set up nor fail over the replacement after reloads", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const setups: string[] = []
|
||||
host.reload()
|
||||
host.reload()
|
||||
host.load(() => void setups.push("first"), 0)
|
||||
host.fail(1, new Error("second"))
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
host.load(() => void setups.push("third"), 2)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const outcome = { setups, status: host.status() }
|
||||
host.unmount()
|
||||
return outcome
|
||||
}, fixture)
|
||||
expect(result).toEqual({ setups: ["third"], status: "active" })
|
||||
})
|
||||
|
||||
story("a dialog service kept from before a reload opens and closes nothing under the replacement", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const services: Dialogs[] = []
|
||||
const setup = (ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs)
|
||||
const text = (value: string) => () => Object.assign(document.createElement("p"), { textContent: value })
|
||||
const shown = (value: string) => !!document.body.textContent?.includes(value)
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
host.load(setup, 0)
|
||||
await wait(20)
|
||||
host.reload()
|
||||
host.load(setup, 1)
|
||||
await wait(20)
|
||||
const [stale, fresh] = services
|
||||
stale.push(text("stale dialog"))
|
||||
fresh.push(text("fresh dialog"))
|
||||
await wait(50)
|
||||
stale.close()
|
||||
await wait(300)
|
||||
const outcome = { stale: shown("stale dialog"), fresh: shown("fresh dialog") }
|
||||
host.unmount()
|
||||
return outcome
|
||||
}, fixture)
|
||||
expect(result).toEqual({ stale: false, fresh: true })
|
||||
})
|
||||
|
||||
story("a dialog pushed in the same tick as a reload never mounts", async ({ page }) => {
|
||||
const shown = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const services: Dialogs[] = []
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
host.load((ctx: Context) => void services.push(ctx.use({ kind: "host" as const, id: "dialog" }) as Dialogs), 0)
|
||||
await wait(20)
|
||||
services[0].push(() => Object.assign(document.createElement("p"), { textContent: "same tick dialog" }))
|
||||
host.reload()
|
||||
await wait(300)
|
||||
const outcome = !!document.body.textContent?.includes("same tick dialog")
|
||||
host.unmount()
|
||||
return outcome
|
||||
}, fixture)
|
||||
expect(shown).toBe(false)
|
||||
})
|
||||
|
||||
story("an extension reloaded while disabled starts when it is enabled again", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { mountExtensionHost } = await import(fixture)
|
||||
const host = mountExtensionHost()
|
||||
const setups: string[] = []
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
host.load(() => void setups.push("first"), 0)
|
||||
await wait(20)
|
||||
host.disable()
|
||||
await wait(20)
|
||||
host.reload()
|
||||
const afterReload = host.status()
|
||||
// A load the reload started finishes while the extension is still disabled.
|
||||
if (host.count() > 1) host.load(() => void setups.push("while disabled"), 1)
|
||||
await wait(20)
|
||||
host.enable()
|
||||
await wait(20)
|
||||
host.load(() => void setups.push("enabled"), host.count() - 1)
|
||||
await wait(50)
|
||||
const outcome = { afterReload, setups, status: host.status() }
|
||||
host.unmount()
|
||||
return outcome
|
||||
}, fixture)
|
||||
expect(result).toEqual({ afterReload: "disabled", setups: ["first", "enabled"], status: "active" })
|
||||
})
|
||||
@@ -1,515 +0,0 @@
|
||||
# CreateEffect Simplification Implementation Spec
|
||||
|
||||
Reduce reactive misuse across `packages/app`.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
|
||||
|
||||
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
|
||||
|
||||
Key issues from the audit:
|
||||
|
||||
- Derived state is being written through effects instead of computed directly
|
||||
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
|
||||
- User-driven actions are hidden inside reactive effects
|
||||
- Context layers mirror and hydrate child stores with multiple sync effects
|
||||
- Several areas repeat the same imperative trigger pattern in multiple effects
|
||||
|
||||
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
|
||||
|
||||
## Goals
|
||||
|
||||
- Cut high-churn `createEffect` usage in the hottest files first
|
||||
- Replace effect-driven derived state with reactive derivation
|
||||
- Replace reset-on-key effects with keyed ownership boundaries
|
||||
- Move event-driven work to direct actions and write paths
|
||||
- Remove mirrored store hydration where a single source of truth can exist
|
||||
- Leave necessary external sync effects in place, but make them narrower and clearer
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not rewrite unrelated component structure just to reduce the count
|
||||
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
|
||||
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
|
||||
- Do not attempt a repo-wide cleanup outside `packages/app`
|
||||
|
||||
## Effect Taxonomy And Replacement Rules
|
||||
|
||||
Use these rules during implementation.
|
||||
|
||||
### Prefer `createMemo`
|
||||
|
||||
Use `createMemo` when the target value is pure derived state from other signals or stores.
|
||||
|
||||
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:141`
|
||||
- `packages/app/src/pages/layout.tsx:557`
|
||||
- `packages/app/src/components/terminal.tsx:261`
|
||||
- `packages/app/src/components/session/session-header.tsx:309`
|
||||
|
||||
Rules:
|
||||
|
||||
- If no external system is touched, do not use `createEffect`
|
||||
- Derive once, then read the memo where needed
|
||||
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
|
||||
|
||||
### Prefer Keyed Remounts
|
||||
|
||||
Use keyed remounts when local UI state should reset because an identity changed.
|
||||
|
||||
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:325`
|
||||
- `packages/app/src/pages/session.tsx:336`
|
||||
- `packages/app/src/pages/session.tsx:477`
|
||||
- `packages/app/src/pages/session.tsx:869`
|
||||
- `packages/app/src/pages/session.tsx:963`
|
||||
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||
- `packages/app/src/context/file.tsx:100`
|
||||
|
||||
Rules:
|
||||
|
||||
- If the desired behavior is "new identity, fresh local state," key the owner subtree
|
||||
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
|
||||
|
||||
### Prefer Event Handlers And Actions
|
||||
|
||||
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
|
||||
|
||||
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
|
||||
|
||||
Apply this to:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:484`
|
||||
- `packages/app/src/pages/layout.tsx:652`
|
||||
- `packages/app/src/pages/layout.tsx:776`
|
||||
- `packages/app/src/pages/layout.tsx:1489`
|
||||
- `packages/app/src/pages/layout.tsx:1519`
|
||||
- `packages/app/src/components/file-tree.tsx:328`
|
||||
- `packages/app/src/pages/session/terminal-panel.tsx:55`
|
||||
- `packages/app/src/context/global-sync.tsx:148`
|
||||
- Duplicated trigger sets in:
|
||||
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||
|
||||
Rules:
|
||||
|
||||
- If the trigger is user intent, call the action at the source of that intent
|
||||
- If the same imperative work is triggered from multiple places, extract one function and call it directly
|
||||
|
||||
### Prefer `onMount` And `onCleanup`
|
||||
|
||||
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
|
||||
|
||||
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
|
||||
|
||||
Use this when:
|
||||
|
||||
- Setup should happen once per owner lifecycle
|
||||
- Cleanup should always pair with teardown
|
||||
- The work is not conceptually derived state
|
||||
|
||||
### Keep `createEffect` When It Is A Real Bridge
|
||||
|
||||
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
|
||||
|
||||
Examples that should remain, though they may be narrowed or split:
|
||||
|
||||
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
|
||||
- Scroll sync in `packages/app/src/pages/session.tsx:685`
|
||||
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- External sync in:
|
||||
- `packages/app/src/context/language.tsx:207`
|
||||
- `packages/app/src/context/settings.tsx:110`
|
||||
- `packages/app/src/context/sdk.tsx:26`
|
||||
- Polling in:
|
||||
- `packages/app/src/components/status-popover.tsx:59`
|
||||
- `packages/app/src/components/dialog-select-server.tsx:273`
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep the effect single-purpose
|
||||
- Make dependencies explicit and narrow
|
||||
- Avoid writing back into the same reactive graph unless absolutely required
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0: Classification Pass
|
||||
|
||||
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
|
||||
- Shared helpers to be introduced are identified up front to avoid repeating patterns
|
||||
|
||||
### Phase 1: Derived-State Cleanup
|
||||
|
||||
Tackle highest-value, lowest-risk derived-state cleanup first.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
|
||||
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
|
||||
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
|
||||
- Replace other obvious derived-state effects in terminal and session header
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
|
||||
- Targeted derived-state effects are deleted, not just moved
|
||||
|
||||
### Phase 2: Keyed Reset Cleanup
|
||||
|
||||
Replace reset-on-key effects with keyed ownership boundaries.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Key session-scoped UI and state by `sessionKey`
|
||||
- Key file-scoped state by `scope()`
|
||||
- Remove manual clear-and-reseed effects in session and file context
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Switching session or file scope recreates the intended local state cleanly
|
||||
- No stale state leaks across session or scope changes
|
||||
- Target reset effects are deleted
|
||||
|
||||
### Phase 3: Event-Driven Work Extraction
|
||||
|
||||
Move event-driven work out of reactive effects.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Replace `globalStore.reload` effect dispatching with direct calls
|
||||
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
|
||||
- Collapse duplicated imperative trigger triplets into single functions
|
||||
- Move file-tree and terminal-panel imperative work to explicit handlers
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- User-triggered behavior still fires exactly once per intended action
|
||||
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
|
||||
|
||||
### Phase 4: Context Ownership Cleanup
|
||||
|
||||
Remove mirrored child-store hydration patterns.
|
||||
|
||||
Priority items:
|
||||
|
||||
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
|
||||
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
|
||||
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- There is one clear source of truth for each synced value
|
||||
- Child stores no longer need effect-based hydration to stay consistent
|
||||
- Initialization and updates both work without manual mirror effects
|
||||
|
||||
### Phase 5: Cleanup And Keeper Review
|
||||
|
||||
Clean up remaining targeted hotspots and narrow the effects that should stay.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
|
||||
- Mixed-responsibility effects are split into smaller units where still needed
|
||||
|
||||
## Detailed Work Items By Area
|
||||
|
||||
### 1. Normalize Tab State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:141`
|
||||
|
||||
Work:
|
||||
|
||||
- Move tab normalization into the functions that create, load, or update tab state
|
||||
- Make readers consume already-normalized tab data
|
||||
- Remove the effect that rewrites derived tab state after the fact
|
||||
|
||||
Rationale:
|
||||
|
||||
- Tabs should become valid when written, not be repaired later
|
||||
- This removes a feedback loop and makes state easier to trust
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/session.tsx:141` is removed
|
||||
- Newly created and restored tabs are normalized before they enter local state
|
||||
- Tab rendering still matches current behavior for valid and edge-case inputs
|
||||
|
||||
### 2. Key Session-Owned State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session.tsx:325`
|
||||
- `packages/app/src/pages/session.tsx:336`
|
||||
- `packages/app/src/pages/session.tsx:477`
|
||||
- `packages/app/src/pages/session.tsx:869`
|
||||
- `packages/app/src/pages/session.tsx:963`
|
||||
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||
|
||||
Work:
|
||||
|
||||
- Identify state that should reset when `sessionKey` changes
|
||||
- Move that state under a keyed subtree or keyed owner boundary
|
||||
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
|
||||
|
||||
Rationale:
|
||||
|
||||
- Session identity already defines the lifetime of this UI state
|
||||
- Keyed ownership makes reset behavior automatic and easier to reason about
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The targeted reset effects are removed
|
||||
- Changing sessions resets only the intended session-local state
|
||||
- Scroll and editor state that should persist are not accidentally reset
|
||||
|
||||
### 3. Derive Workspace Order
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:557`
|
||||
|
||||
Work:
|
||||
|
||||
- Stop writing `workspaceOrder` from live workspace data in an effect
|
||||
- Represent user overrides separately from live workspace data
|
||||
- Compute effective order from current data plus overrides with a memo or pure helper
|
||||
|
||||
Rationale:
|
||||
|
||||
- Persisted user intent and live source data should not mirror each other through an effect
|
||||
- A computed effective order avoids drift and racey resync behavior
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
|
||||
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
|
||||
- User overrides persist without requiring a sync-back effect
|
||||
|
||||
### 4. Remove Child-Store Mirrors
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/global-sync.tsx:130`
|
||||
- `packages/app/src/context/global-sync.tsx:138`
|
||||
- `packages/app/src/context/global-sync.tsx:148`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:184`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:190`
|
||||
- `packages/app/src/context/global-sync/child-store.ts:193`
|
||||
- `packages/app/src/context/layout.tsx:424`
|
||||
|
||||
Work:
|
||||
|
||||
- Trace the actual ownership of global and child store values
|
||||
- Replace hydration and mirror effects with explicit initialization and direct updates
|
||||
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
|
||||
|
||||
Rationale:
|
||||
|
||||
- Mirrors make it hard to tell which state is authoritative
|
||||
- Event-bus style state toggles hide control flow and create accidental reruns
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Child store hydration no longer depends on effect-based copying
|
||||
- Reload work can be followed from the event source to the handler without a reactive relay
|
||||
- State remains correct on first load, child creation, and subsequent updates
|
||||
|
||||
### 5. Key File-Scoped State
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/context/file.tsx:100`
|
||||
|
||||
Work:
|
||||
|
||||
- Move file-scoped local state under a boundary keyed by `scope()`
|
||||
- Remove any effect that watches `scope()` only to reset file-local state
|
||||
|
||||
Rationale:
|
||||
|
||||
- File scope changes are identity changes
|
||||
- Keyed ownership gives a cleaner reset than manual clear logic
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/context/file.tsx:100` is removed
|
||||
- Switching scopes resets only scope-local state
|
||||
- No previous-scope data appears after a scope change
|
||||
|
||||
### 6. Split Layout Side Effects
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/layout.tsx:1489`
|
||||
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
|
||||
|
||||
Work:
|
||||
|
||||
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
|
||||
- Move user-triggered branches into the actual command or handler that causes them
|
||||
- Remove any branch that only exists because one effect is handling unrelated concerns
|
||||
|
||||
Rationale:
|
||||
|
||||
- Mixed effects hide cause and make reruns hard to predict
|
||||
- Smaller units reduce accidental coupling and make future cleanup safer
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
|
||||
- Event-driven branches execute from direct handlers
|
||||
- Remaining effects in this area each have one clear external sync purpose
|
||||
|
||||
### 7. Remove Duplicate Triggers
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||
|
||||
Work:
|
||||
|
||||
- Extract one explicit imperative function per behavior
|
||||
- Call that function from each source event instead of replicating the same effect pattern multiple times
|
||||
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
|
||||
|
||||
Rationale:
|
||||
|
||||
- Duplicate triggers make it easy to miss a case or fire twice
|
||||
- One named action is easier to test and reason about
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Repeated imperative effect triplets are collapsed into shared functions
|
||||
- Scroll behavior still works, including hash-based navigation
|
||||
- No duplicate firing is introduced
|
||||
|
||||
### 8. Make Prompt Filtering Reactive
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/components/prompt-input.tsx:652`
|
||||
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
|
||||
|
||||
Work:
|
||||
|
||||
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
|
||||
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
|
||||
|
||||
Rationale:
|
||||
|
||||
- Filtering is classic derived state
|
||||
- It should not need an effect if it can be computed from current inputs
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
|
||||
- Filtered slash-command results update correctly as the input changes
|
||||
- The editor sync effect at `:690` still behaves correctly
|
||||
|
||||
### 9. Clean Up Smaller Derived-State Cases
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/app/src/components/terminal.tsx:261`
|
||||
- `packages/app/src/components/session/session-header.tsx:309`
|
||||
|
||||
Work:
|
||||
|
||||
- Replace effect-written local state with memos or inline derivation
|
||||
- Remove intermediate setters when the value can be computed directly
|
||||
|
||||
Rationale:
|
||||
|
||||
- These are low-risk wins that reinforce the same pattern
|
||||
- They also help keep follow-up cleanup consistent
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Targeted effects are removed
|
||||
- UI output remains unchanged under the same inputs
|
||||
|
||||
## Verification And Regression Checks
|
||||
|
||||
Run focused checks after each phase, not only at the end.
|
||||
|
||||
### Suggested Verification
|
||||
|
||||
- Switch between sessions rapidly and confirm local session UI resets only where intended
|
||||
- Open, close, and reorder tabs and confirm order and normalization remain stable
|
||||
- Change workspaces, reload workspace data, and verify effective ordering is correct
|
||||
- Change file scope and confirm stale file state does not bleed across scopes
|
||||
- Trigger layout actions that previously depended on effects and confirm they still fire once
|
||||
- Use slash commands in the prompt and verify filtering updates as you type
|
||||
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
|
||||
- Verify global sync initialization, reload, and child-store creation paths
|
||||
|
||||
### Regression Checks
|
||||
|
||||
- No accidental infinite reruns
|
||||
- No double-firing network or command actions
|
||||
- No lost cleanup for listeners, timers, or scroll handlers
|
||||
- No preserved stale state after identity changes
|
||||
- No removed effect that was actually bridging to DOM or an external API
|
||||
|
||||
If available, add or update tests around pure helpers introduced during this cleanup.
|
||||
|
||||
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
This work is done when all of the following are true:
|
||||
|
||||
- The highest-leverage targets in this spec are implemented
|
||||
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
|
||||
- The "should remain" effects still exist only where they serve a real external sync purpose
|
||||
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
|
||||
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
|
||||
- No behavior regressions are found in the targeted areas
|
||||
|
||||
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
|
||||
|
||||
The main success metric is clearer ownership and fewer effect-driven state repairs.
|
||||
|
||||
## Risks And Rollout Notes
|
||||
|
||||
Main risks:
|
||||
|
||||
- Keyed remounts can reset too much if state boundaries are drawn too high
|
||||
- Store mirror removal can break initialization order if ownership is not mapped first
|
||||
- Moving event work out of effects can accidentally skip triggers that were previously implicit
|
||||
|
||||
Rollout notes:
|
||||
|
||||
- Land in small phases, with each phase keeping the app behaviorally stable
|
||||
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
|
||||
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
|
||||
@@ -14,3 +14,11 @@
|
||||
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
|
||||
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
|
||||
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
|
||||
|
||||
## Test Ownership
|
||||
|
||||
- Each product area has one keeper suite in `regression/` (for example tabs, settings, review, terminal, timeline history). A new regression is a new case in that suite, not a new spec file.
|
||||
- Build pages with the shared harness in `utils/` (`app.ts`, `workspace.ts`, `mock-server.ts` with `mockServers` and the mock PTY). Do not hand-write routing, storage seeding, or fixtures inside a spec. If the harness lacks something, add it to `utils/` once rather than copying setup.
|
||||
- Files in `utils/` must not end in `.spec.ts` or `.test.ts`, or Playwright runs them as tests.
|
||||
- Assert what the user sees and can do. Assert exact pixels or CSS values only when that measurement is the contract, and then only once, in the owning keeper.
|
||||
- `performance/` holds benchmarks that CI does not run. Never prove correctness with a benchmark, and never copy benchmark code into a regression spec; import it from `utils/`.
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
import { fixture, installStressSessionTabs, installTimelineSettings } from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { completedAnswer } from "../../../../session-ui/performance/markdown-lifetime/answer"
|
||||
import { installMarkdownGate } from "./probe"
|
||||
|
||||
@@ -54,22 +54,22 @@ for (const size of ["typical", "large"]) {
|
||||
await installStressSessionTabs(page)
|
||||
const targetPart = `msg_2_${fixture.targetID}_assistant:text:0`
|
||||
const sourcePart = `msg_2_${fixture.sourceID}_assistant:text:0`
|
||||
await installMarkdownGate(page, { answer, targetPart, sourcePart, href: stressSessionHref(fixture.sourceID) })
|
||||
await installMarkdownGate(page, { answer, targetPart, sourcePart, href: sessionHref(fixture.sourceID) })
|
||||
const prefetched = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await prefetched
|
||||
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(fixture.targetID)}"]`).click()
|
||||
await page.waitForFunction(() => window.markdownGate.held)
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => window.markdownGate.arm())
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
import { createMockServerHandler } from "../utils/mock-server"
|
||||
import { fixture } from "./timeline/session-timeline-stress.fixture"
|
||||
import { messages } from "./timeline/session-tab-switch.fixture"
|
||||
import { fixture } from "../utils/session-fixture"
|
||||
import { messages } from "../utils/markdown-sessions"
|
||||
import { createReviewDiffs } from "./timeline/timeline-test-helpers"
|
||||
|
||||
const args = parseArgs({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Terminal } from "ghostty-web"
|
||||
import { SerializeAddon } from "../../../src/session/terminal/serialize"
|
||||
import { SerializeAddon } from "../../../../gui-extensions/src/terminal/serialize"
|
||||
|
||||
export type TerminalProbe = {
|
||||
term?: Terminal
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test.describe("timeline adverse visual stability", () => {
|
||||
test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
// Fractional scaling exercises different browser rounding than the baseline.
|
||||
for (const deviceScaleFactor of [1, 1.25]) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
|
||||
const patchID = "prt_incremental_01_patch"
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
validateTimelineEvent,
|
||||
validateTimelineMessages,
|
||||
type PartSeed,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
describe("timeline fixture validation", () => {
|
||||
test("accepts a valid timeline", () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_interaction_01_shell"
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test.describe("timeline visual lifecycle stability", () => {
|
||||
test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "../../utils/timeline"
|
||||
|
||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_ancestor_opacity"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test("follows an expanded patch that arrives as the user reaches the bottom", async ({ page }) => {
|
||||
const toolID = "prt_bottom_follow_patch"
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test("adds a subagent child-session link without replacing the row", async ({ page }, testInfo) => {
|
||||
const taskID = "prt_subagent_link"
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test.describe("timeline tool state stability", () => {
|
||||
test("moves lightweight tools through streaming, running, and completed without replacing rows", async ({
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
} from "../../utils/timeline"
|
||||
|
||||
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||
const textID = "prt_text_reconcile"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { base64Encode } from "@opencode/util/encode"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { fixture } from "../../utils/session-fixture"
|
||||
|
||||
const sessionID = "ses_composer_write_batch"
|
||||
const title = "Composer persistence workload"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureFirstNavigation } from "./first-navigation-probe"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
fixture,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressDraftHref,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
} from "../../utils/session-fixture"
|
||||
import { draftHref, sessionHref } from "../../utils/app"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const contentSelector = '[data-message-id], [data-component="composer-editor"]'
|
||||
@@ -17,7 +16,7 @@ const draftID = "draft_first_navigation"
|
||||
benchmark.describe("performance: first navigation paint", () => {
|
||||
benchmark("opens an unvisited session tab without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const href = sessionHref(fixture.targetID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
@@ -36,7 +35,7 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
|
||||
benchmark("opens the new session page before its lazy module is used", async ({ page, report }) => {
|
||||
await setup(page, draftID)
|
||||
const href = stressDraftHref(draftID)
|
||||
const href = draftHref(draftID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
@@ -59,13 +58,13 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
await installStressSessionTabs(page, { draftID })
|
||||
await page.goto("/")
|
||||
|
||||
const draftHref = stressDraftHref(draftID)
|
||||
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
|
||||
const draftLink = draftHref(draftID)
|
||||
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftLink}"]`)
|
||||
await expect(draftTab).toHaveCount(1)
|
||||
await draftTab.click()
|
||||
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const href = sessionHref(fixture.targetID)
|
||||
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
|
||||
await expect(sessionTab).toHaveCount(1)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
@@ -86,7 +85,7 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
const href = sessionHref(fixture.childID)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
@@ -108,7 +107,7 @@ async function setup(page: Parameters<typeof mockStressTimeline>[0], draft?: str
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, draft ? { draftID: draft } : undefined)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { CDPSession, Page } from "@playwright/test"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { APP_READY_TIMEOUT } from "../../utils/waits"
|
||||
import { fixture as stress } from "./session-timeline-stress.fixture"
|
||||
import { SERVER } from "../../utils/app"
|
||||
import { fixture as stress } from "../../utils/session-fixture"
|
||||
import { createHomeIndexFixture, type HomeIndexFixture } from "./home-session-index.fixture"
|
||||
|
||||
// Home fetches the root-session index on mount. These cases hold the visible
|
||||
@@ -28,7 +29,7 @@ type Probe = {
|
||||
|
||||
type ProbeWindow = Window & {
|
||||
__homeIndexProbe?: Probe
|
||||
__mockServerStream?: { push: (payloads: unknown[]) => void }
|
||||
__mockServerStreams?: Record<string, { push: (payloads: unknown[]) => void }>
|
||||
}
|
||||
|
||||
// Interaction-scoped tracing keeps the page-lifetime Chrome trace off unless a
|
||||
@@ -137,11 +138,12 @@ benchmark.describe("performance: home session index", () => {
|
||||
target.time.updated += 1000
|
||||
target.time.idle = target.time.updated
|
||||
const pushed = await page.evaluate(
|
||||
({ id, title, event }) => {
|
||||
({ id, title, event, server }) => {
|
||||
const host = window as ProbeWindow
|
||||
if (!host.__homeIndexProbe || !host.__mockServerStream) throw new Error("Missing Home index probe")
|
||||
const stream = host.__mockServerStreams?.[server]
|
||||
if (!host.__homeIndexProbe || !stream) throw new Error("Missing Home index probe")
|
||||
host.__homeIndexProbe.pending[id] = title
|
||||
host.__mockServerStream.push([event])
|
||||
stream.push([event])
|
||||
return performance.now()
|
||||
},
|
||||
{
|
||||
@@ -153,6 +155,7 @@ benchmark.describe("performance: home session index", () => {
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: target.id },
|
||||
},
|
||||
server: SERVER,
|
||||
},
|
||||
)
|
||||
await expect(titleLocator).toHaveText(title)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
fixture,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
} from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const homeRow = '[data-component="home-session-row"]'
|
||||
@@ -19,7 +19,7 @@ benchmark.describe("performance: home and tab navigation", () => {
|
||||
await page.goto("/")
|
||||
const row = page.locator(homeRow).filter({ hasText: fixture.expected.targetTitle }).first()
|
||||
await expect(row).toBeVisible()
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const href = sessionHref(fixture.targetID)
|
||||
const result = await measureNavigationMilestones(page, {
|
||||
triggerSelector: homeRow,
|
||||
milestones: {
|
||||
@@ -80,7 +80,7 @@ benchmark.describe("performance: home and tab navigation", () => {
|
||||
|
||||
benchmark("closes the only session tab and paints home", async ({ page, report }) => {
|
||||
await setup(page, [fixture.sourceID])
|
||||
const href = stressSessionHref(fixture.sourceID)
|
||||
const href = sessionHref(fixture.sourceID)
|
||||
await page.goto(href)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { messages } from "./session-tab-switch.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { fixture, installStressSessionTabs } from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { messages } from "../../utils/markdown-sessions"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const sessions = Array.from({ length: 8 }, (_, index) => ({
|
||||
@@ -87,7 +87,7 @@ for (const close of [false, true]) {
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
await page.goto(stressSessionHref(sessions[0].id))
|
||||
await page.goto(sessionHref(sessions[0].id))
|
||||
await expectSessionTitle(page, sessions[0].title)
|
||||
await waitForStableTimeline(page, pages[sessions[0].id].at(-2)!.id)
|
||||
// Every inactive tab's scheduled attention request must finish. This gates on the
|
||||
@@ -102,7 +102,7 @@ for (const close of [false, true]) {
|
||||
if (close) {
|
||||
const tab = page
|
||||
.locator("[data-titlebar-tab-slot]")
|
||||
.filter({ has: page.locator(`a[href="${stressSessionHref(closed.id)}"]`) })
|
||||
.filter({ has: page.locator(`a[href="${sessionHref(closed.id)}"]`) })
|
||||
await tab.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(tab).toHaveCount(0)
|
||||
}
|
||||
@@ -126,9 +126,9 @@ for (const close of [false, true]) {
|
||||
sourceIDs: pages[sessions[0].id].map((message) => message.id),
|
||||
lastID: pages[target.id].at(-2)!.id,
|
||||
requiredPartID: `${pages[target.id].at(-1)!.id}:text:0`,
|
||||
href: stressSessionHref(target.id),
|
||||
href: sessionHref(target.id),
|
||||
switch: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(target.id)}"]`).click()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(target.id)}"]`).click()
|
||||
await expectSessionTitle(page, target.title)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { stressSessionHref } from "./timeline-test-helpers"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { startChromeTrace } from "../chrome-trace"
|
||||
|
||||
const file = process.env.LAGGY_SESSION_FILE
|
||||
@@ -130,7 +130,7 @@ for (const mode of ["compact", "ungrouped"] as const) {
|
||||
dirBase64: base64Encode(directory),
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(sourceID))
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
@@ -152,9 +152,9 @@ for (const mode of ["compact", "ungrouped"] as const) {
|
||||
lastID,
|
||||
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
|
||||
requireBottomAnchor: true,
|
||||
href: stressSessionHref(session.info.id),
|
||||
href: sessionHref(session.info.id),
|
||||
switch: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(session.info.id)}"]`).click()
|
||||
},
|
||||
})
|
||||
await stopTrace?.()
|
||||
@@ -191,7 +191,7 @@ for (const mode of ["compact", "ungrouped"] as const) {
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
|
||||
break
|
||||
}
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(sourceID)}"]`).click()
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { fixture, installStressSessionTabs, installTimelineSettings, pageMessages } from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import type { ModelUpdated } from "@opencode/client/promise"
|
||||
|
||||
@@ -12,7 +12,7 @@ benchmark("measures retained renderer memory with a large model catalog", async
|
||||
const switches = Number(process.env.PROVIDER_MEMORY_SWITCHES ?? 10)
|
||||
const provider = fixture.provider.all[0]
|
||||
const selected = { ...provider.models["claude-opus-4-6"] }
|
||||
await mockOpenCodeServer(page, {
|
||||
const mock = await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
@@ -46,7 +46,7 @@ benchmark("measures retained renderer memory with a large model catalog", async
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
@@ -56,7 +56,7 @@ benchmark("measures retained renderer memory with a large model catalog", async
|
||||
if (index > 0) {
|
||||
const target = index % 2 === 1
|
||||
const id = target ? fixture.targetID : fixture.sourceID
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`).click()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(id)}"]`).click()
|
||||
await expectSessionTitle(page, target ? fixture.expected.targetTitle : fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(
|
||||
page,
|
||||
@@ -75,12 +75,7 @@ benchmark("measures retained renderer memory with a large model catalog", async
|
||||
expect(samples).toHaveLength(switches + 1)
|
||||
expect(samples.every((sample) => sample.heap.usedSize > 0)).toBe(true)
|
||||
selected.name = "Updated catalog model"
|
||||
await page.evaluate(
|
||||
(event) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: ModelUpdated[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([event])
|
||||
},
|
||||
await mock.push([
|
||||
{
|
||||
id: "evt_model_refresh",
|
||||
created: Date.now(),
|
||||
@@ -88,7 +83,7 @@ benchmark("measures retained renderer memory with a large model catalog", async
|
||||
location: { directory: fixture.directory },
|
||||
data: {},
|
||||
} satisfies ModelUpdated,
|
||||
)
|
||||
])
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText(selected.name)
|
||||
report(
|
||||
{ samples },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
|
||||
import { benchmarkLocation, setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
|
||||
|
||||
const changedLinesPerFile = 100
|
||||
const linesPerSide = changedLinesPerFile / 2
|
||||
@@ -44,7 +44,10 @@ benchmark.describe("performance: review pane scaling", () => {
|
||||
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
|
||||
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
|
||||
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
|
||||
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
|
||||
const responseBody = JSON.stringify({
|
||||
location: benchmarkLocation,
|
||||
data: createScalingDiffs(fileCount, patchByteLimit),
|
||||
})
|
||||
await setupTimelineBenchmark(page, {
|
||||
historyTurns: 0,
|
||||
eventBatch: 1,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { measureNavigationMilestones } from "./navigation-milestones"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { fixture, installStressSessionTabs, mockStressTimeline } from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||
|
||||
benchmark.use({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
@@ -16,7 +16,7 @@ for (const entry of ["home", "session"] as const) {
|
||||
benchmark(`entry: new session from ${entry}`, async ({ page, report }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page, { sessionIDs: entry === "home" ? [] : [fixture.sourceID] })
|
||||
await page.goto(entry === "home" ? "/" : stressSessionHref(fixture.sourceID))
|
||||
await page.goto(entry === "home" ? "/" : sessionHref(fixture.sourceID))
|
||||
if (entry === "session") await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
const trigger = entry === "home" ? '[data-action="home-new-session"]' : 'button[aria-label="New session"]'
|
||||
await expect(page.locator(trigger)).toBeVisible()
|
||||
@@ -68,7 +68,7 @@ benchmark("entry: cold session from Home", async ({ page, report }) => {
|
||||
const selector = `[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"] [data-component="home-session-row"]`
|
||||
await expect(page.locator(selector)).toBeVisible()
|
||||
expect(requests).not.toContain(fixture.targetID)
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const href = sessionHref(fixture.targetID)
|
||||
await benchmarkDiagnostics(page).startTrace()
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { fixture, installStressSessionTabs } from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
@@ -121,11 +121,11 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const href = sessionHref(fixture.targetID)
|
||||
await page.evaluate(
|
||||
({ href, title }) => {
|
||||
const link = document.createElement("a")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import {
|
||||
collectCachedRepaintTrace,
|
||||
compressCachedRepaintTrace,
|
||||
@@ -9,29 +8,30 @@ import {
|
||||
} from "./session-tab-repaint-probe"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import {
|
||||
fixture,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
} from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
|
||||
benchmark("samples cached session repaint after the click", async ({ page, report }) => {
|
||||
benchmark.setTimeout(120_000)
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
await installTimelineSettings(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await page
|
||||
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`)
|
||||
.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(fixture.sourceID)}"]`)
|
||||
.first()
|
||||
.click()
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
await installCachedRepaintProbe(page, {
|
||||
targetHref: stressSessionHref(fixture.targetID),
|
||||
targetHref: sessionHref(fixture.targetID),
|
||||
destination: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
source: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||
last: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
@@ -39,7 +39,7 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
||||
})
|
||||
|
||||
await page
|
||||
.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`)
|
||||
.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(fixture.targetID)}"]`)
|
||||
.first()
|
||||
.click()
|
||||
await Promise.all([expectSessionTitle(page, fixture.expected.targetTitle), waitForCachedRepaintWindow(page, 1_000)])
|
||||
@@ -64,7 +64,7 @@ benchmark("loads only the selected restored tab's transcript", async ({ page, re
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/form`),
|
||||
),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
|
||||
await attention
|
||||
|
||||
@@ -2,14 +2,11 @@ import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { expected, messages, workload } from "./session-tab-switch.fixture"
|
||||
import {
|
||||
createReviewDiffs,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { fixture, installStressSessionTabs, installTimelineSettings } from "../../utils/session-fixture"
|
||||
import { sessionHref } from "../../utils/app"
|
||||
import { expected, messages } from "../../utils/markdown-sessions"
|
||||
import { workload } from "./session-tab-switch.fixture"
|
||||
import { createReviewDiffs } from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
const scenarios = [
|
||||
@@ -43,7 +40,7 @@ scenarios.forEach((scenario) => {
|
||||
sourceIDs: messages[fixture.sourceID].map((message) => message.id),
|
||||
lastID: expected[fixture.targetID].lastID,
|
||||
requiredPartID: expected[fixture.targetID].answerID,
|
||||
href: stressSessionHref(fixture.targetID),
|
||||
href: sessionHref(fixture.targetID),
|
||||
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
|
||||
})
|
||||
|
||||
@@ -97,7 +94,7 @@ async function prepareSessionTabs(page: Page) {
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await page.goto(sessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expectReadyTimeline(page, fixture.sourceID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${expected[fixture.targetID].answerID}"]`)).toHaveCount(0)
|
||||
@@ -120,7 +117,7 @@ async function expectReadyTimeline(page: Page, sessionID: string) {
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(sessionID)}"]`)
|
||||
await expect(tab).toHaveCount(1)
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
@@ -1,48 +1,4 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
|
||||
export const exchanges = 200
|
||||
|
||||
export const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((sessionID) => [
|
||||
sessionID,
|
||||
Array.from({ length: exchanges }, (_, index) => {
|
||||
const seed = fixture.messages[fixture.targetID]
|
||||
const user = seed[(index % (seed.length / 2)) * 2]!
|
||||
const assistant = seed[(index % (seed.length / 2)) * 2 + 1]!
|
||||
if (user.type !== "user" || assistant.type !== "assistant") throw new Error("Expected a user/assistant pair")
|
||||
const suffix = `${sessionID}_${String(index).padStart(4, "0")}`
|
||||
return [
|
||||
{
|
||||
...user,
|
||||
id: `msg_user_${suffix}`,
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
},
|
||||
{
|
||||
...assistant,
|
||||
id: `msg_assistant_${suffix}`,
|
||||
time: { created: 1700000001000 + index * 10_000, completed: 1700000008000 + index * 10_000 },
|
||||
content: [
|
||||
...assistant.content
|
||||
.filter((part) => part.type !== "text")
|
||||
.map((part) => (part.type === "tool" ? { ...part, id: `${part.id}_${suffix}` } : part)),
|
||||
{ type: "text", text: complexMarkdown(sessionID, index) },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
}).flat(),
|
||||
]),
|
||||
)
|
||||
|
||||
export const expected = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((sessionID) => [
|
||||
sessionID,
|
||||
{
|
||||
lastID: messages[sessionID].at(-2)!.id,
|
||||
answerID: `${messages[sessionID].at(-1)!.id}:text:0`,
|
||||
},
|
||||
]),
|
||||
)
|
||||
import { exchanges, messages } from "../../utils/markdown-sessions"
|
||||
|
||||
export const workload = {
|
||||
fixture: "long-complex-markdown-v1",
|
||||
@@ -69,75 +25,3 @@ export const workload = {
|
||||
]),
|
||||
),
|
||||
}
|
||||
|
||||
function complexMarkdown(sessionID: string, index: number) {
|
||||
return `## Renderer review ${sessionID} / ${index}
|
||||
|
||||
Preserve **semantic identity**, *measured geometry*, and ~~obsolete estimates~~ when switching sessions. The \`measureElement(node)\` result must agree with the [rendering contract](https://example.com/rendering/${sessionID}/${index}).
|
||||
|
||||
> A completed answer contains formatted prose, highlighted source, and structured results.
|
||||
> Keep the previous view until the destination is ready, rather than exposing partially formatted content.
|
||||
|
||||
### Readiness checklist
|
||||
|
||||
- [x] Resolve the destination session and its messages.
|
||||
- [x] Parse Markdown and highlight fenced code.
|
||||
- [ ] Verify a different panel width.
|
||||
- Preserve the bottom anchor.
|
||||
- Reuse the measured rows when their width matches.
|
||||
|
||||
| Stage | Input | Expected result | Verification |
|
||||
| :--- | ---: | :--- | :--- |
|
||||
${Array.from({ length: 8 }, (_, row) => `| stage-${index}-${row} | ${index * 8 + row} | **ready** with \`row[${row}]\` | stable geometry and visible content |`).join("\n")}
|
||||
|
||||
### Implementation
|
||||
|
||||
\`\`\`tsx
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
|
||||
type Row = { id: string; title: string; ready: boolean; height: number }
|
||||
|
||||
export function SessionRows${index}(props: { rows: Row[]; selected: string }) {
|
||||
const visible = createMemo(() => props.rows.filter((row) => row.ready))
|
||||
return (
|
||||
<section aria-label="${sessionID}-${index}">
|
||||
<For each={visible()}>{(row) => (
|
||||
<article data-selected={row.id === props.selected}>
|
||||
<h3>{row.title}</h3>
|
||||
<Show when={row.height > 0} fallback={<span>Measuring</span>}>
|
||||
<output>{row.height.toFixed(2)} pixels</output>
|
||||
</Show>
|
||||
</article>
|
||||
)}</For>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify({ session: sessionID, exchange: index, stages: ["hydrate", "parse", "highlight", "measure"], viewport: { width: 1440, height: 900 }, cache: { markdown: true, geometry: true } }, null, 2)}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`sql
|
||||
SELECT session_id, COUNT(*) AS messages, MAX(created_at) AS latest
|
||||
FROM session_message
|
||||
WHERE session_id = '${sessionID}' AND ordinal >= ${index}
|
||||
GROUP BY session_id
|
||||
ORDER BY latest DESC;
|
||||
\`\`\`
|
||||
|
||||
### Verification
|
||||
|
||||
1. Open the long source session and wait for its final answer.
|
||||
2. Select the destination tab, without changing the viewport.
|
||||
3. Confirm that **all Markdown is ready** and the bottom anchor is correct.
|
||||
|
||||
\`\`\`bash
|
||||
bun typecheck
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts
|
||||
git diff --check # ${sessionID}-${index}
|
||||
\`\`\`
|
||||
|
||||
**Review complete: ${sessionID} / ${index}.**
|
||||
`
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { createTwoFilesPatch } from "diff"
|
||||
|
||||
const directory = "C:/OpenCode/TimelineStateRegression"
|
||||
const projectID = "proj_timeline_state_regression"
|
||||
/** The workspace a benchmark response names, in the shape the server's location-scoped routes return. */
|
||||
export const benchmarkLocation = { directory, project: { id: projectID, directory, canonical: directory } }
|
||||
const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
|
||||
@@ -1,87 +1,3 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
|
||||
export async function installTimelineSettings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function mockStressTimeline(
|
||||
page: Page,
|
||||
input?: {
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
vcsDiff?: unknown[]
|
||||
},
|
||||
) {
|
||||
return mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
onMessages: input?.onMessages,
|
||||
vcsDiff: input?.vcsDiff,
|
||||
})
|
||||
}
|
||||
|
||||
export async function installStressSessionTabs(page: Page, input?: { draftID?: string; sessionIDs?: string[] }) {
|
||||
const server = stressServer()
|
||||
await page.addInitScript(
|
||||
({ directory, sessionIDs, dirBase64, server, draftID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
type: "session",
|
||||
server,
|
||||
dirBase64,
|
||||
sessionId,
|
||||
})),
|
||||
...(draftID ? [{ type: "draft", draftID, server, directory }] : []),
|
||||
]),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
sessionIDs: input?.sessionIDs ?? [fixture.sourceID, fixture.targetID],
|
||||
dirBase64: base64Encode(fixture.directory),
|
||||
server,
|
||||
draftID: input?.draftID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function stressSessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(stressServer())}/session/${sessionID}`
|
||||
}
|
||||
|
||||
export function stressDraftHref(draftID: string) {
|
||||
return `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||
}
|
||||
|
||||
function stressServer() {
|
||||
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
|
||||
export function createReviewDiffs() {
|
||||
return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
|
||||
const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { fixture } from "../timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../timeline/timeline-test-helpers"
|
||||
|
||||
test("builds stress session links for the benchmark server", () => {
|
||||
expect(stressSessionHref(fixture.sourceID)).toBe(
|
||||
`/server/${base64Encode("http://127.0.0.1:4096")}/session/${fixture.sourceID}`,
|
||||
)
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ArtifactReopen"
|
||||
const projectID = "proj_artifact_reopen"
|
||||
const sessionID = "ses_artifact_reopen"
|
||||
const title = "Artifact reopen"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = [
|
||||
{ id: "msg_prompt", type: "user", text: "Write the shopping list", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_reply",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
content: [{ type: "text", text: "Updated [shopping.txt](shopping.txt)." }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("rereads a file each time an artifact link opens it", async ({ page }) => {
|
||||
const file = { content: "first draft" }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
vcs: "git",
|
||||
name: "artifact-reopen",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", variants: {}, limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "artifact-reopen",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 3 },
|
||||
},
|
||||
],
|
||||
fileList: () => [],
|
||||
fileContent: (path) => (path === "shopping.txt" ? file : ""),
|
||||
pageMessages: () => ({ items: messages }),
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const link = page.getByRole("link", { name: "shopping.txt", exact: true })
|
||||
const panel = page.locator("#review-panel")
|
||||
const tab = panel.getByRole("tab", { name: "shopping.txt" })
|
||||
|
||||
await link.click()
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("first draft", { exact: true })).toBeVisible()
|
||||
|
||||
file.content = "second draft"
|
||||
await tab.locator("..").getByRole("button", { name: "Close tab" }).click()
|
||||
await expect(tab).toHaveCount(0)
|
||||
|
||||
await link.click()
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("second draft", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { openCommandPalette, paletteSession } from "../utils/command-palette"
|
||||
|
||||
@@ -10,19 +11,15 @@ test("failed event-driven reads report an error and recover without an unhandled
|
||||
const path = `**/api/session/${paletteSession.id}`
|
||||
await page.route(path, (route) => route.abort("failed"))
|
||||
const requested = page.waitForRequest(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_failed_refresh",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, idle: 2 },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await palette.push([
|
||||
{
|
||||
id: "evt_failed_refresh",
|
||||
created: 2,
|
||||
type: "session.viewed",
|
||||
durable: { aggregateID: paletteSession.id, seq: 1, version: 1 },
|
||||
data: { sessionID: paletteSession.id, idle: 2 },
|
||||
} as OpenCodeEvent,
|
||||
])
|
||||
await requested
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await palette.input.fill("copy session")
|
||||
@@ -32,19 +29,15 @@ test("failed event-driven reads report an error and recover without an unhandled
|
||||
)
|
||||
await palette.input.press("Escape")
|
||||
await page.unroute(path)
|
||||
await page.evaluate((sessionID) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: unknown[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([
|
||||
{
|
||||
id: "evt_recovered_refresh",
|
||||
created: 3,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: sessionID, seq: 2, version: 1 },
|
||||
data: { sessionID, title: "Recovered session" },
|
||||
},
|
||||
])
|
||||
}, paletteSession.id)
|
||||
await palette.push([
|
||||
{
|
||||
id: "evt_recovered_refresh",
|
||||
created: 3,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: paletteSession.id, seq: 2, version: 1 },
|
||||
data: { sessionID: paletteSession.id, title: "Recovered session" },
|
||||
} as OpenCodeEvent,
|
||||
])
|
||||
await expect(page.getByRole("heading", { name: "Recovered session", exact: true })).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
// Safari 16.6 has neither API. Remove them before the web entry runs.
|
||||
delete (Map as Partial<typeof Map>).groupBy
|
||||
delete (Promise as Partial<typeof Promise>).withResolvers
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Add project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(row).toBeVisible()
|
||||
})
|
||||
@@ -1,103 +1,32 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { openSession } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/BtwSidebar"
|
||||
const projectID = "proj_btw_sidebar"
|
||||
const sessionID = "ses_btw_sidebar"
|
||||
const otherSessionID = "ses_btw_sidebar_other"
|
||||
const title = "Side question session"
|
||||
const otherTitle = "Other side question session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
const prompts: unknown[] = []
|
||||
const generated = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "btw-sidebar",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: otherSessionID,
|
||||
slug: otherSessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title: otherTitle,
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsDiff: [],
|
||||
const main = { id: "ses_btw_sidebar", title: "Side question session" }
|
||||
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwSidebar",
|
||||
sessions: [main, other],
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: async (input) => {
|
||||
generations.push(input)
|
||||
if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." }
|
||||
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
|
||||
await generated.promise
|
||||
return {
|
||||
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, otherSessionID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "session", server, sessionId: otherSessionID },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, otherSessionID },
|
||||
)
|
||||
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await expectSessionTitle(page, title)
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
await editor.fill("/btw")
|
||||
const suggestion = page.locator('[data-suggestion-id="session.btw"]')
|
||||
const suggestion = page.locator('[data-suggestion-id="btw.ask"]')
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/btw ")
|
||||
@@ -111,35 +40,33 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
|
||||
await editor.fill("/btw how does the retry loop work?")
|
||||
await editor.press("Enter")
|
||||
|
||||
const tab = page.getByRole("tab", { name: "/btw" })
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(tab).toHaveAttribute("data-selected", "")
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "")
|
||||
generated.resolve()
|
||||
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
|
||||
expect(generations).toHaveLength(1)
|
||||
expect(generations[0]?.sessionID).toBe(sessionID)
|
||||
expect(generations[0]?.sessionID).toBe(main.id)
|
||||
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
|
||||
expect(prompts).toEqual([])
|
||||
await expect(editor).toHaveText("")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click()
|
||||
await expectSessionTitle(page, otherTitle)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(other.id)}"]`).click()
|
||||
await expectSessionTitle(page, other.title)
|
||||
await editor.fill("/btw what belongs here?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click()
|
||||
await expectSessionTitle(page, title)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, title)
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0)
|
||||
await expect(panel).toHaveCount(0)
|
||||
})
|
||||
@@ -76,6 +76,7 @@ test("appends search results without resetting the selected command", async ({ p
|
||||
})
|
||||
|
||||
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
@@ -97,6 +98,72 @@ test("keeps the automatically selected file when session results arrive later",
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("tab", { name: "README.md", exact: true })).toBeVisible()
|
||||
const tab = page.getByRole("tab", { name: "README.md", exact: true })
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
await page
|
||||
.getByRole("complementary", { name: "Review and files" })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
// The duplicate command ID warning is DEV-only, so only dev-server runs can catch duplicate tab commands here.
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page, true)
|
||||
await palette.input.press("Escape")
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("region", { name: "Recent sessions" })
|
||||
.getByRole("button", { name: /Palette fixture session/ })
|
||||
.click()
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?/)
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.blur()
|
||||
await page.keyboard.press("Control+l")
|
||||
await expect(editor).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
|
||||
await dialog.getByRole("textbox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
const tabs = page.locator("[data-titlebar-tab-link]")
|
||||
await tabs.filter({ hasText: paletteSession.title }).click()
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
for (const count of [3, 4]) {
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(tabs).toHaveCount(count)
|
||||
await expect(editor).toBeEditable()
|
||||
}
|
||||
await page.setViewportSize({ width: 600, height: 800 })
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
const active = new URL(page.url())
|
||||
const closing = page.locator(`[data-titlebar-tab-link][href="${active.pathname}${active.search}"]`)
|
||||
await expect(closing).toHaveCount(1)
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(tabs).toHaveCount(3)
|
||||
await expect(closing).toHaveCount(0)
|
||||
await expect(tabs.filter({ hasText: paletteSession.title })).toHaveCount(1)
|
||||
await tabs.filter({ hasText: paletteSession.title }).click()
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { captureConsoleWarnings, openCommandPalette } from "../utils/command-palette"
|
||||
|
||||
test.use({ serviceWorkers: "block", video: "off" })
|
||||
|
||||
test("opening and closing files does not duplicate tab commands", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page)
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
json: { data: [{ path: "fixture.txt", type: "file" }] },
|
||||
}),
|
||||
)
|
||||
await palette.input.fill("fixture.txt")
|
||||
await palette.dialog.getByRole("option", { name: /fixture\.txt/ }).click()
|
||||
const file = page.getByRole("tab", { name: /fixture\.txt/ })
|
||||
await expect(file).toBeVisible()
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("complementary", { name: "Review and files" })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(file).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
test("navigation replaces commands without retaining disposed owners", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const palette = await openCommandPalette(page, true)
|
||||
await palette.input.press("Escape")
|
||||
await expect(palette.dialog).toHaveCount(0)
|
||||
await page
|
||||
.getByRole("region", { name: "Recent sessions" })
|
||||
.getByRole("button", { name: /Palette fixture session/ })
|
||||
.click()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+t")
|
||||
await expect(page).toHaveURL(/\/new-session\?/)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await page.locator('[data-component="composer-editor"]').blur()
|
||||
await page.keyboard.press("Control+l")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await expect(dialog.getByRole("textbox")).toHaveAttribute("placeholder", "Search files, commands, and sessions")
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveCount(0)
|
||||
await dialog.getByRole("textbox").press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
for (const count of [3, 4]) {
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(count)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
}
|
||||
await page.setViewportSize({ width: 600, height: 800 })
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.setViewportSize({ width: 1280, height: 800 })
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-link]')).toHaveCount(4)
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(page.locator("[data-titlebar-tab-link]")).toHaveCount(3)
|
||||
await page.locator("[data-titlebar-tab-link]").filter({ hasText: "Palette fixture session" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Palette fixture session", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("textbox").fill("copy session")
|
||||
await expect(dialog.getByRole("option", { name: "Copy Session ID", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ComposerEditing"
|
||||
const projectID = "proj_composer_editing"
|
||||
const sessionID = "ses_composer_editing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "composer-editing",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "composer-editing",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Composer editing",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
await expect
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
await expectAppVisible(composer)
|
||||
await expect(page.locator('[data-slot="session-chat-panel"]')).toHaveCSS("min-width", "0px")
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
|
||||
await input.fill("keep me")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
await page.locator('[data-suggestion-id="model.choose"]').click()
|
||||
|
||||
await expect(input).toHaveText("keep me")
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_large_paste"
|
||||
const directory = "/repo/large-paste"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_large_paste",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "large-paste",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
const input = page.locator('[data-component="composer-editor"]')
|
||||
await expectAppVisible(input)
|
||||
await expect(input).toBeEditable()
|
||||
await expect
|
||||
.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content))
|
||||
.toBe(`"${String.fromCodePoint(0x200b)}"`)
|
||||
await input.click()
|
||||
})
|
||||
|
||||
for (const lines of [6000, 25000]) {
|
||||
test(`keeps a ${lines}-line crash report editable in a new session`, async ({ page }) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(lines) + "End of report"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
const events = await input.evaluateHandle((element) => {
|
||||
const events = { count: 0 }
|
||||
element.addEventListener("input", () => events.count++)
|
||||
return events
|
||||
})
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
await expect(input).toBeFocused()
|
||||
await expectCaretVisible(input)
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
|
||||
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
|
||||
await expectCaretVisible(input)
|
||||
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
|
||||
const bounds = await scroll.boundingBox()
|
||||
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Home")
|
||||
await page.keyboard.press("ControlOrMeta+End")
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCaretVisible(input: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
input.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
|
||||
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const suffix = "\nExisting trailing content".repeat(100)
|
||||
await input.fill("Before " + suffix)
|
||||
await input.press("ControlOrMeta+Home")
|
||||
await input.press("ArrowRight")
|
||||
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
"\n\n indented\ttext \n\nlast\n\n",
|
||||
'literal <b>bold</b> & & < > "quotes"\n<script>not code</script>\n<img src="example">',
|
||||
"first\r\nsecond\rthird",
|
||||
]) {
|
||||
test(`preserves text and native undo: ${JSON.stringify(text)}`, async ({ page }) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
const expected = text.replace(/\r\n?/g, "\n")
|
||||
await expect.poll(() => input.innerText()).toBe(expected)
|
||||
await expect(input.locator("b, script, img")).toHaveCount(0)
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toBeEmpty()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+Z")
|
||||
await expect.poll(() => input.innerText()).toBe(expected)
|
||||
})
|
||||
}
|
||||
|
||||
test("replaces only the selected text and leaves the caret after the paste", async ({ page }) => {
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await page.evaluate(() => navigator.clipboard.writeText("one\ntwo"))
|
||||
await page.keyboard.type("before replace after")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const word = await input.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(element.firstChild!, 7)
|
||||
range.setEnd(element.firstChild!, 14)
|
||||
const rect = range.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
|
||||
})
|
||||
await page.mouse.move(word.x, word.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("replace")
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.keyboard.press("ControlOrMeta+Shift+Z")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo! after")
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { NO_PROVIDER, T0, provider } from "../utils/app"
|
||||
import { openDraft, openSession } from "../utils/workspace"
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
async function draft(page: Page) {
|
||||
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
|
||||
await editor.click()
|
||||
return editor
|
||||
}
|
||||
|
||||
async function expectCaretVisible(input: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
input.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
|
||||
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test("keeps a 25000-line crash report editable in a new session", async ({ page }) => {
|
||||
const input = await draft(page)
|
||||
const text = "Thread 0 Crashed:\n" + "0 Example 0x0000000100000000 frame + 32\n".repeat(25000) + "End of report"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
const events = await input.evaluateHandle((element) => {
|
||||
const events = { count: 0 }
|
||||
element.addEventListener("input", () => events.count++)
|
||||
return events
|
||||
})
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
await expect(input).toBeFocused()
|
||||
await expectCaretVisible(input)
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
|
||||
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
|
||||
await expectCaretVisible(input)
|
||||
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
|
||||
const bounds = await scroll.boundingBox()
|
||||
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Home")
|
||||
await page.keyboard.press("ControlOrMeta+End")
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
|
||||
for (const [width, direction] of [
|
||||
[390, "rtl"],
|
||||
[1280, "ltr"],
|
||||
] as const) {
|
||||
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
|
||||
const input = await draft(page)
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
const suffix = "\nExisting trailing content".repeat(100)
|
||||
await input.fill("Before " + suffix)
|
||||
await input.press("ControlOrMeta+Home")
|
||||
await input.press("ArrowRight")
|
||||
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
|
||||
test("pastes plain text without markup and keeps native undo", async ({ page }) => {
|
||||
const input = await draft(page)
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
"\n\n indented\ttext \n\nlast\n\n",
|
||||
'literal <b>bold</b> & & < > "quotes"\n<script>not code</script>\n<img src="example">',
|
||||
"first\r\nsecond\rthird",
|
||||
]) {
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
const expected = text.replace(/\r\n?/g, "\n")
|
||||
await expect.poll(() => input.innerText()).toBe(expected)
|
||||
await expect(input.locator("b, script, img")).toHaveCount(0)
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toBeEmpty()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+Z")
|
||||
await expect.poll(() => input.innerText()).toBe(expected)
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toBeEmpty()
|
||||
}
|
||||
})
|
||||
|
||||
test("replaces only the selected text and leaves the caret after the paste", async ({ page }) => {
|
||||
const input = await draft(page)
|
||||
await page.evaluate(() => navigator.clipboard.writeText("one\ntwo"))
|
||||
await page.keyboard.type("before replace after")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const word = await input.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
range.setStart(element.firstChild!, 7)
|
||||
range.setEnd(element.firstChild!, 14)
|
||||
const rect = range.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y + rect.height / 2, width: rect.width }
|
||||
})
|
||||
await page.mouse.move(word.x, word.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(word.x + word.width, word.y, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("replace")
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
|
||||
await page.keyboard.press("ControlOrMeta+Z")
|
||||
await expect(input).toHaveText("before replace after")
|
||||
await page.keyboard.press("ControlOrMeta+Shift+Z")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo after")
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("before one\ntwo! after")
|
||||
})
|
||||
|
||||
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
|
||||
const writes: { path: string; directory: string; body: string }[] = []
|
||||
await openDraft(page, {
|
||||
name: "ComposerDraft",
|
||||
provider: NO_PROVIDER,
|
||||
onFileWrite: (write) => void writes.push(write),
|
||||
})
|
||||
const surface = page.locator('[data-component="new-session"]')
|
||||
const dropzone = page.locator('[data-component="session-dropzone"]')
|
||||
const transfer = await page.evaluateHandle(() => {
|
||||
const value = new DataTransfer()
|
||||
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
|
||||
return value
|
||||
})
|
||||
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
|
||||
await expect(dropzone).toHaveAttribute("data-visible", "true")
|
||||
await expect(dropzone).toContainText("Drop files to add")
|
||||
|
||||
await surface.dispatchEvent("drop", { dataTransfer: transfer })
|
||||
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
// The file is uploaded to its own directory in the server's temporary directory, then attached by path.
|
||||
await expect
|
||||
.poll(() => writes)
|
||||
.toEqual([
|
||||
{
|
||||
path: expect.stringMatching(/\/uploads\/[0-9a-f-]{36}\/dropzone\.txt$/),
|
||||
directory: "C:/OpenCode/ComposerDraft",
|
||||
body: "Dropzone fixture",
|
||||
},
|
||||
])
|
||||
await expect(page.locator('[data-component="upload-progress"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
|
||||
})
|
||||
|
||||
test("keeps a narrow session composer contained when invoking a built-in", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
const { editor } = await openSession(page, { name: "ComposerCommand", provider: NO_PROVIDER })
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
|
||||
await editor.fill("keep me")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
await page.locator('[data-suggestion-id="model.choose"]').click()
|
||||
|
||||
await expect(editor).toHaveText("keep me")
|
||||
})
|
||||
|
||||
const followUp = "Add follow-up, / for commands, @ for context…"
|
||||
for (const row of [
|
||||
{ state: "an idle", copy: "Ask anything, / for commands, @ for context…" },
|
||||
{ state: "a running", copy: followUp, sessionStatus: { ses_placeholder: { type: "running" } } },
|
||||
{
|
||||
state: "an idle queued",
|
||||
copy: followUp,
|
||||
inbox: [
|
||||
{
|
||||
id: "inb_placeholder",
|
||||
sessionID: "ses_placeholder",
|
||||
time: { created: T0 },
|
||||
type: "user",
|
||||
payload: { text: "Queued follow-up" },
|
||||
delivery: "queue",
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
test(`shows the placeholder for ${row.state} session and the shell example in shell mode`, async ({ page }) => {
|
||||
const { editor } = await openSession(page, {
|
||||
name: "ComposerPlaceholder",
|
||||
sessions: [{ id: "ses_placeholder", title: "Placeholder" }],
|
||||
sessionStatus: row.sessionStatus,
|
||||
inbox: row.inbox,
|
||||
})
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll).toHaveText(row.copy)
|
||||
await editor.pressSequentially("!")
|
||||
await expect(scroll).toHaveText("Enter shell command… git status")
|
||||
})
|
||||
}
|
||||
|
||||
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
|
||||
const { editor: input } = await openSession(page, {
|
||||
name: "ComposerThinking",
|
||||
provider: provider({ id: "thinking-model", name: "Thinking Model", variants: { high: {} } }),
|
||||
})
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
const option = (name: string) => page.getByRole("menuitemradio", { name, exact: true })
|
||||
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
await expect(control).toHaveCSS("pointer-events", "none")
|
||||
|
||||
await input.hover()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
|
||||
// The menu keeps the control visible after the pointer leaves.
|
||||
await control.click()
|
||||
await expect(option("high")).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await option("high").click()
|
||||
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("high")
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
|
||||
await control.click()
|
||||
await option("default").click()
|
||||
await input.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toHaveText("default")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
|
||||
// The single-agent fixture has only Add and Model before the thinking trigger.
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(composer.getByRole("button", { name: "Add images and files" })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(composer.getByRole("button", { name: "Thinking Model" })).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(option("default")).toBeFocused()
|
||||
await expect(control).toHaveCSS("opacity", "1")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(control).toBeFocused()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(control).toHaveCSS("opacity", "0")
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
|
||||
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await mockServers(page, requests)
|
||||
await page.addInitScript(
|
||||
({ serverA, serverB, sessionA, sessionB }) => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: serverA, sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||
})
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServers(page: Page, requests: string[]) {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
requests.push(url.toString())
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/info")
|
||||
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: current.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, [project])
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
directory: current.directory,
|
||||
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
|
||||
})
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
data: { branch: "main", defaultBranch: "main" },
|
||||
})
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/FileBrowserSidebar"
|
||||
const projectID = "proj_file_browser_sidebar"
|
||||
const sessionID = "ses_file_browser_sidebar"
|
||||
const title = "File browser sidebar"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
||||
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
||||
// switches. Remounting resets scroll and filter state.
|
||||
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
|
||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||
await expect(sidebar).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
||||
|
||||
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(0)
|
||||
await writeProbe(page)
|
||||
|
||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
|
||||
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page
|
||||
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
||||
.evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "file-browser-sidebar",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [],
|
||||
fileList: (path) => {
|
||||
if (path) return []
|
||||
return files.map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${directory}/${name}`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
}))
|
||||
},
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route("**/api/session/*", async (route) => {
|
||||
if (route.request().method() !== "PATCH") return route.fallback()
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/").at(-1)
|
||||
const session = sessions.find((item) => item.id === sessionID)
|
||||
const payload: unknown = route.request().postDataJSON()
|
||||
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
|
||||
throw new Error("Invalid rename payload")
|
||||
if (session) session.title = payload.title
|
||||
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
|
||||
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
|
||||
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
|
||||
|
||||
await row.focus()
|
||||
await row.press("Shift+F10")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
|
||||
await expect(row).toBeFocused()
|
||||
|
||||
const rowBox = await row.boundingBox()
|
||||
await row.click({ button: "right", position: { x: 48, y: 12 } })
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Export…" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Delete…" })).toBeVisible()
|
||||
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
|
||||
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
|
||||
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click()
|
||||
const title = page.locator('[data-component="home-session-rename"]')
|
||||
await expect(title).toBeFocused()
|
||||
await expect(title).toHaveValue(fixture.expected.targetTitle)
|
||||
const editorBox = await title.boundingBox()
|
||||
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
expect(editorBox?.x).toBe(titleBox?.x)
|
||||
expect(editingAvatarBox).toEqual(avatarBox)
|
||||
expect(
|
||||
await title.evaluate((element) => ({
|
||||
outline: getComputedStyle(element).outlineStyle,
|
||||
shadow: getComputedStyle(element).boxShadow,
|
||||
})),
|
||||
).toEqual({ outline: "none", shadow: "none" })
|
||||
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
|
||||
await title.fill("Renamed from Home")
|
||||
const renamed = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "PATCH" && new URL(request.url()).pathname.endsWith(`/session/${fixture.targetID}`),
|
||||
)
|
||||
await title.press("Enter")
|
||||
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
|
||||
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
|
||||
await expect(renamedRow).toBeVisible()
|
||||
|
||||
await renamedRow.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home" }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
|
||||
await expect(renamedRow).toBeVisible()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
const download = page.waitForEvent("download")
|
||||
const exportItem = page.getByRole("menuitem", { name: "Export…" })
|
||||
await exportItem.click()
|
||||
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
|
||||
await expect(exportItem).toBeHidden()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Delete…" }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Delete session" }).click()
|
||||
await removed
|
||||
await expect(renamedRow).toBeHidden()
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { holdRoute, seed, sessionHref } from "../utils/app"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
const row = (page: Page, title: string) =>
|
||||
page.locator('[data-component="home-session-row"]').filter({ hasText: title })
|
||||
|
||||
async function openHome(page: Page, input: Parameters<typeof mockStressTimeline>[1] = {}) {
|
||||
await mockStressTimeline(page, input)
|
||||
await seed(page, {
|
||||
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
|
||||
lastProject: { local: fixture.directory },
|
||||
})
|
||||
await page.goto("/")
|
||||
}
|
||||
|
||||
test("the session context menu renames, exports, and deletes a Home session", async ({ page }) => {
|
||||
await openHome(page)
|
||||
const target = row(page, fixture.expected.targetTitle)
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
await target.focus()
|
||||
await target.press("Shift+F10")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
|
||||
await expect(target).toBeFocused()
|
||||
|
||||
const box = await target.boundingBox()
|
||||
await target.click({ button: "right", position: { x: 48, y: 12 } })
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.getByRole("menuitem")).toHaveText(["Rename", "Export…", "Delete…"])
|
||||
const menu = await page.locator('[data-component="menu-v2-content"]').boundingBox()
|
||||
expect(Math.abs((menu?.x ?? 0) - (box?.x ?? 0) - 48)).toBeLessThan(4)
|
||||
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click()
|
||||
const title = page.locator('[data-component="home-session-rename"]')
|
||||
await expect(title).toBeFocused()
|
||||
await expect(title).toHaveValue(fixture.expected.targetTitle)
|
||||
await title.fill("Renamed from Home")
|
||||
const renamed = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "PATCH" && new URL(request.url()).pathname.endsWith(`/session/${fixture.targetID}`),
|
||||
)
|
||||
await title.press("Enter")
|
||||
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
|
||||
const renamedRow = row(page, "Renamed from Home")
|
||||
await renamedRow.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home" }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
const download = page.waitForEvent("download")
|
||||
await page.getByRole("menuitem", { name: "Export…" }).click()
|
||||
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Delete…" }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Delete session" }).click()
|
||||
await removed
|
||||
await expect(renamedRow).toBeHidden()
|
||||
})
|
||||
|
||||
test("Home shows loaded sessions before the location request resolves", async ({ page }) => {
|
||||
const location = await holdRoute(page, (url) => url.pathname === "/api/location")
|
||||
await openHome(page)
|
||||
await expectAppVisible(row(page, fixture.expected.sourceTitle))
|
||||
location.release()
|
||||
})
|
||||
|
||||
test("Home and the directory picker load without newer browser APIs", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
// Safari 16.6 has none of these APIs. Remove them before the web entry runs.
|
||||
delete (Map as Partial<typeof Map>).groupBy
|
||||
delete (Promise as Partial<typeof Promise>).withResolvers
|
||||
delete (Promise as Partial<typeof Promise>).try
|
||||
})
|
||||
await openHome(page, { fileList: () => [] })
|
||||
const target = row(page, fixture.expected.targetTitle)
|
||||
await expect(target).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Add project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(target).toBeVisible()
|
||||
})
|
||||
|
||||
const recovery = "C:/OpenCode/Worktrees/project-menu-recovery"
|
||||
const worktree =
|
||||
"C:/OpenCode/Worktrees/project-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name"
|
||||
|
||||
for (const state of [
|
||||
{ name: "local", directory: fixture.directory, icon: "monitor" },
|
||||
{ name: "worktree", directory: worktree, icon: "outline-worktree" },
|
||||
{ name: "closed worktree", directory: recovery, icon: "outline-worktree", recovery: "closed" },
|
||||
{ name: "unopened worktree", directory: recovery, icon: "outline-worktree", recovery: "unopened" },
|
||||
]) {
|
||||
test(`the session project menu opens settings and Home for a ${state.name} project`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const workspace = state.directory !== fixture.directory
|
||||
const messages = Promise.withResolvers<void>()
|
||||
await mockStressTimeline(page, {
|
||||
directory: state.directory,
|
||||
// An image icon keeps the avatar initial out of the menu item text.
|
||||
project: {
|
||||
...fixture.project,
|
||||
sandboxes: workspace ? [state.directory] : [],
|
||||
icon: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>')}`,
|
||||
},
|
||||
},
|
||||
sessions: fixture.sessions.map((item) => ({ ...item, directory: state.directory })),
|
||||
beforeMessagesResponse: (input) =>
|
||||
state.recovery && input.sessionID === fixture.targetID ? messages.promise : Promise.resolve(),
|
||||
})
|
||||
// An unopened project has no stored project list or tabs.
|
||||
if (state.recovery !== "unopened")
|
||||
await seed(page, {
|
||||
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
|
||||
lastProject: { local: fixture.directory },
|
||||
tabs: [fixture.sourceID, fixture.targetID],
|
||||
})
|
||||
const name = fixture.project.name
|
||||
if (state.recovery === "closed") {
|
||||
await page.goto("/")
|
||||
const project = page.locator('[data-component="home-project-row"]').filter({ hasText: name })
|
||||
await project.locator("..").getByRole("button", { name: "More options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click()
|
||||
await expect(project).toHaveCount(0)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(fixture.targetID)}"]`).click()
|
||||
}
|
||||
if (state.recovery !== "closed") await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name, exact: true })
|
||||
const menu = page.getByRole("menu", { name, exact: true })
|
||||
const projectItem = menu.getByRole("menuitem", { name, exact: true })
|
||||
const pathItem = menu.getByRole("menuitem", { name: state.directory, exact: true })
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
for (const loaded of state.recovery ? [false, true] : [true]) {
|
||||
if (state.recovery && loaded) {
|
||||
messages.resolve()
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(trigger.locator("use")).toHaveAttribute("href", `#opencode-v2-icon-${state.icon}`)
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([name, state.directory, "Edit project"])
|
||||
await expect(pathItem).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.press("ArrowDown")
|
||||
await expect(projectItem).toBeFocused()
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(pathItem).toBeFocused()
|
||||
for (const key of ["Enter", "Space"]) {
|
||||
await page.keyboard.press(key)
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(pathItem).toBeFocused()
|
||||
}
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue(name)
|
||||
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
}
|
||||
|
||||
await trigger.click()
|
||||
await projectItem.click()
|
||||
await expect(page).toHaveURL(new URL("/", page.url()).href)
|
||||
const project = page.locator('[data-component="home-project-row"]').filter({ hasText: name })
|
||||
await expect(project).toHaveAttribute("data-selected", "")
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("the project menu path arrow has a glyph when the page has an older icon sprite", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await seed(page, { projects: { local: [{ worktree: fixture.directory, expanded: true }] }, tabs: [fixture.targetID] })
|
||||
// A page cached before the arrow icon shipped carries a sprite without it.
|
||||
await page.route(
|
||||
(url) => url.pathname === sessionHref(fixture.targetID),
|
||||
async (route) => {
|
||||
const response = await route.fetch()
|
||||
await route.fulfill({
|
||||
response,
|
||||
body: (await response.text()).replace(
|
||||
'<div id="root"',
|
||||
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
|
||||
),
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
|
||||
const arrow = page
|
||||
.getByRole("menu")
|
||||
.getByRole("menuitem", { name: fixture.directory, exact: true })
|
||||
.locator('[data-slot="session-project-open-icon"]')
|
||||
await expect(arrow).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
|
||||
.toBeGreaterThan(0)
|
||||
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("inactive tabs load attention, but read transcript and inbox only on selection", async ({ page }) => {
|
||||
const reads: string[] = []
|
||||
const mutations: string[] = []
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
page.on("response", (response) => {
|
||||
if (new URL(response.url()).pathname.startsWith("/api/") && !response.ok())
|
||||
errors.push(`HTTP ${response.status()}: ${response.url()}`)
|
||||
})
|
||||
const state = { text: "Original fixture answer" }
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (!path.startsWith("/api/")) return
|
||||
if (request.method() === "GET") reads.push(path)
|
||||
if (request.method() === "DELETE" || /\/(interrupt|prompt)$/.test(path)) mutations.push(path)
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_${id}_user`, type: "user", text: "Review the renderer change", time: { created: 1 } },
|
||||
{
|
||||
id: `msg_${id}_assistant`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
content: [{ type: "text", text: state.text }],
|
||||
time: { created: 2, completed: 3 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID, fixture.targetID, fixture.childID] })
|
||||
const attention = Promise.all(
|
||||
[fixture.targetID, fixture.childID].flatMap((id) =>
|
||||
["permission", "form"].map((kind) =>
|
||||
page.waitForResponse((response) => new URL(response.url()).pathname === `/api/session/${id}/${kind}`),
|
||||
),
|
||||
),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.sourceID}_assistant:text:0"]`)).toContainText(
|
||||
state.text,
|
||||
)
|
||||
await attention
|
||||
const child = page
|
||||
.locator("[data-titlebar-tab-slot]")
|
||||
.filter({ has: page.locator(`a[href="${stressSessionHref(fixture.childID)}"]`) })
|
||||
await child.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(child).toHaveCount(0)
|
||||
state.text = "Latest fixture answer after tab restoration"
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_${fixture.targetID}_assistant:text:0"]`)).toContainText(
|
||||
state.text,
|
||||
)
|
||||
for (const id of [fixture.sourceID, fixture.targetID]) {
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/message`)).toHaveLength(1)
|
||||
expect(reads.filter((path) => path === `/api/session/${id}/inbox`)).toHaveLength(1)
|
||||
}
|
||||
expect(
|
||||
reads.filter(
|
||||
(path) => path === `/api/session/${fixture.childID}/message` || path === `/api/session/${fixture.childID}/inbox`,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(mutations).toEqual([])
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -1,199 +1,141 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { NO_PROVIDER, sessionHref } from "../utils/app"
|
||||
import type { MockServerConfig } from "../utils/mock-server"
|
||||
import { mockWorkspace } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:\\OpenCode\\main"
|
||||
const workspace = "C:\\OpenCode\\worktree"
|
||||
const projectID = "proj_mcp_workspace"
|
||||
const sessionID = "ses_mcp_workspace"
|
||||
const title = "Workspace MCP routing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
type Surface = "popover" | "dialog"
|
||||
|
||||
// A session in a worktree of the project. The harness answers the MCP list for each location directory and stores
|
||||
// connect/disconnect results per directory; `requests` records every MCP request and the directory it targeted.
|
||||
// The popover belongs to the summary extension; the dialog (Ctrl+;) is the app's own MCP toggle.
|
||||
async function open(page: Page, input: { surface: Surface } & Pick<MockServerConfig, "mcp" | "onMcpAction">) {
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (request.method() === "OPTIONS" || !/^\/api\/(?:experimental\/)?mcp(?:\/|$)/.test(url.pathname)) return
|
||||
requests.push({ path: url.pathname, directory: url.searchParams.get("location[directory]") || directory })
|
||||
})
|
||||
await mockWorkspace(page, {
|
||||
name: "mcp-workspace",
|
||||
directory,
|
||||
project: { sandboxes: [workspace] },
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [{ id: sessionID, title, directory: workspace }],
|
||||
seed: { settings: { keybinds: { "mcp.toggle": "ctrl+;" } } },
|
||||
mcp: input.mcp,
|
||||
onMcpAction: input.onMcpAction,
|
||||
})
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
const panel = page.getByRole("dialog", { name: input.surface === "popover" ? "MCP" : "MCPs", exact: true })
|
||||
const show = async () => {
|
||||
if (input.surface === "dialog") return page.keyboard.press("Control+;")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
await show()
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
return { requests, panel, show, toggle: panel.getByRole("switch") }
|
||||
}
|
||||
|
||||
for (const shared of [true, false]) {
|
||||
test(`toggles the workspace MCP when the default location ${shared ? "has" : "does not have"} the server`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const connected = new Set<string>()
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "mcp-workspace",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [workspace],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}) => {
|
||||
const actions: Parameters<NonNullable<MockServerConfig["onMcpAction"]>>[0][] = []
|
||||
const view = await open(page, {
|
||||
surface: "popover",
|
||||
mcp: (target) =>
|
||||
!shared && target !== workspace ? [] : [{ name: "figma-desktop", status: { status: "disabled" } }],
|
||||
onMcpAction: (action) => void actions.push(action),
|
||||
})
|
||||
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/experimental/mcp/figma-desktop/connect") {
|
||||
connected.add(target)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/mcp/figma-desktop/disconnect") {
|
||||
connected.delete(target)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: !shared && target !== workspace
|
||||
? []
|
||||
: [{ name: "figma-desktop", status: { status: connected.has(target) ? "connected" : "disabled" } }],
|
||||
},
|
||||
})
|
||||
await expect(view.toggle).not.toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
view.requests.length = 0
|
||||
|
||||
await view.panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(view.toggle).toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
expect(actions).toEqual([{ server: "figma-desktop", action: "connect", directory: workspace }])
|
||||
expect(view.requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace })
|
||||
expect(view.requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
|
||||
expect(view.requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
|
||||
view.requests.length = 0
|
||||
await view.panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(view.toggle).not.toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
expect(actions).toEqual([
|
||||
{ server: "figma-desktop", action: "connect", directory: workspace },
|
||||
{ server: "figma-desktop", action: "disconnect", directory: workspace },
|
||||
])
|
||||
expect(view.requests).toContainEqual({
|
||||
path: "/api/experimental/mcp/figma-desktop/disconnect",
|
||||
directory: workspace,
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
const toggle = dialog.getByRole("switch")
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
|
||||
await dialog.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(connected).toEqual(new Set([workspace]))
|
||||
expect(requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace })
|
||||
expect(requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
|
||||
expect(requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
await testInfo.attach("workspace-connected", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
requests.length = 0
|
||||
await dialog.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(connected.size).toBe(0)
|
||||
expect(requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/disconnect", directory: workspace })
|
||||
expect(requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
expect(view.requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
})
|
||||
}
|
||||
|
||||
for (const surface of ["popover", "dialog"] as const) {
|
||||
test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }, testInfo) => {
|
||||
test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }) => {
|
||||
const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found"
|
||||
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "mcp-workspace",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [workspace],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/experimental/mcp/figma-desktop/disconnect") {
|
||||
state.status = "disabled"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/mcp/figma-desktop/connect") {
|
||||
state.status = state.fail ? "failed" : "connected"
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "figma-desktop",
|
||||
status: { status: target === workspace ? state.status : "connected", error },
|
||||
},
|
||||
],
|
||||
const state = { fail: true }
|
||||
const view = await open(page, {
|
||||
surface,
|
||||
mcp: (target) => [
|
||||
{
|
||||
name: "figma-desktop",
|
||||
status: { status: target !== workspace ? "connected" : surface === "popover" ? "failed" : "disabled", error },
|
||||
},
|
||||
})
|
||||
],
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
onMcpAction: (input) => (input.action === "connect" && state.fail ? { status: "failed", error } : undefined),
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
if (surface === "popover") {
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
// A failed server shows as enabled in the popover; switch it off first.
|
||||
const reset = async () => {
|
||||
if (surface !== "popover") return
|
||||
await expect(view.toggle).toBeChecked()
|
||||
await view.panel.getByText("figma-desktop", { exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
|
||||
const toggle = panel.getByRole("switch")
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await expect(toggle).toBeChecked()
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
}
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
await reset()
|
||||
await expect(view.toggle).not.toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
view.requests.length = 0
|
||||
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await view.panel.locator('[data-slot="switch-control"]').click()
|
||||
const toast = page
|
||||
.getByRole("listitem", { includeHidden: true })
|
||||
.filter({ has: page.getByText("Request failed", { exact: true }) })
|
||||
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeChecked({ checked: surface === "popover" })
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
await expect(view.toggle).toBeChecked({ checked: surface === "popover" })
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
expect(view.requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
{ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace },
|
||||
])
|
||||
expect(requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
await expect(toast).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("mcp-connection-error", { body: await page.screenshot(), contentType: "image/png" })
|
||||
expect(view.requests.every((request) => request.directory === workspace)).toBe(true)
|
||||
|
||||
if (surface === "popover") await page.keyboard.press("Escape")
|
||||
if (surface === "dialog") await panel.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(panel).toBeHidden()
|
||||
if (surface === "dialog") await view.panel.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(view.panel).toBeHidden()
|
||||
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
|
||||
await expect(toast).toBeHidden()
|
||||
state.fail = false
|
||||
if (surface === "popover") {
|
||||
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
}
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
|
||||
await view.show()
|
||||
await reset()
|
||||
await expect(view.toggle).not.toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
await view.panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(view.toggle).toBeChecked()
|
||||
await expect(view.toggle).toBeEnabled()
|
||||
await expect(toast).toBeHidden()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { expect, test, type Request } from "@playwright/test"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { fileNode } from "../utils/workspace"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
test("mobile files browse, comment, search, and close tabs", async ({ page }) => {
|
||||
await mockStressTimeline(page, {
|
||||
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
|
||||
fileContent: (path) => `contents:${path}`,
|
||||
findFiles: ({ query }) =>
|
||||
["first.ts", "second.ts"].filter((path) => path.includes(query)).map((path) => fileNode(fixture.directory, path)),
|
||||
})
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await files.getByRole("button", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files.locator('[data-column-number="1"]').click()
|
||||
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
|
||||
await expect(editor.getByRole("textbox")).toBeVisible()
|
||||
const fullWidth = async (card: typeof editor) => {
|
||||
const panel = (await files.boundingBox())!
|
||||
const box = await card.boundingBox()
|
||||
return !!box && Math.abs(box.x - panel.x - 12) < 2 && Math.abs(box.width - panel.width + 24) < 2
|
||||
}
|
||||
for (const width of [390, 700]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await expect.poll(() => fullWidth(editor)).toBe(true)
|
||||
}
|
||||
await editor.getByRole("textbox").fill("Full-width file comment")
|
||||
await editor.getByRole("button", { name: "Comment", exact: true }).click()
|
||||
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
|
||||
await expect(comment).toContainText("Full-width file comment")
|
||||
await expect.poll(() => fullWidth(comment)).toBe(true)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const filter = files.getByRole("combobox", { name: "Filter files", exact: true })
|
||||
await expect(filter).toBeHidden()
|
||||
await files.getByRole("button", { name: "All files", exact: true }).click()
|
||||
await filter.fill("second")
|
||||
await files.getByRole("option", { name: "second.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
|
||||
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files
|
||||
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
|
||||
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
await files.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(filter).toBeVisible()
|
||||
await expect(openTabs.getByRole("tab")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("mobile changes summarize expanded diffs, stage comments, wrap by setting, and open files", async ({ page }) => {
|
||||
const held = Promise.withResolvers<void>()
|
||||
await mockStressTimeline(page, {
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "added.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch:
|
||||
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
|
||||
},
|
||||
{
|
||||
file: "removed.ts",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch:
|
||||
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
|
||||
},
|
||||
{
|
||||
file: "modified.ts",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
|
||||
},
|
||||
{
|
||||
file: "src/review.ts",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch:
|
||||
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
|
||||
},
|
||||
],
|
||||
fileContent: async (path) => {
|
||||
if (path === "modified.ts") await held.promise
|
||||
return `contents:${path}`
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
|
||||
await page.evaluate(() => (document.documentElement.dir = "rtl"))
|
||||
|
||||
for (const change of [
|
||||
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
|
||||
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
|
||||
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
|
||||
]) {
|
||||
const item = review.locator(`[data-file="${change.file}"]`)
|
||||
const trigger = item.getByRole("button", { name: change.file, exact: true })
|
||||
const summary = item.locator('[data-slot="session-review-change-summary"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
|
||||
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
|
||||
await expect(summary).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
|
||||
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
|
||||
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
|
||||
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCount(0)
|
||||
}
|
||||
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
|
||||
const modified = review.locator('[data-file="modified.ts"]')
|
||||
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
|
||||
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
|
||||
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
|
||||
|
||||
// A line comment is staged into the composer context without writing anything to the server.
|
||||
const writes: string[] = []
|
||||
const recordWrite = (request: Request) => {
|
||||
if (request.method() !== "GET") writes.push(`${request.method()} ${new URL(request.url()).pathname}`)
|
||||
}
|
||||
page.on("request", recordWrite)
|
||||
const note = "Use the existing value instead"
|
||||
const commented = review.locator('[data-file="src/review.ts"]')
|
||||
await commented.getByRole("button", { expanded: false }).click()
|
||||
await commented.getByText("export const value = 'after'", { exact: true }).click()
|
||||
await expect(commented.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||
await commented.getByRole("textbox").fill(note)
|
||||
await commented.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
|
||||
await expect(commented.getByText(note, { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
const attachments = page.locator('[data-component="composer-attachments"]')
|
||||
await expect(attachments.getByText(note, { exact: true })).toBeVisible()
|
||||
await expect(attachments).toContainText("review.ts:2")
|
||||
page.off("request", recordWrite)
|
||||
expect(writes).toEqual([])
|
||||
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
|
||||
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
|
||||
const storedWrap = () =>
|
||||
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap)
|
||||
await expect(wrap).toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await expect.poll(storedWrap).toBe(false)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL(sessionHref(fixture.targetID))
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true)
|
||||
await modified.locator("[data-code]").evaluate((element) => {
|
||||
element.scrollLeft = 100
|
||||
})
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
|
||||
.toBeGreaterThan(0)
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).toBeChecked()
|
||||
await expect.poll(storedWrap).toBe(true)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL(sessionHref(fixture.targetID))
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
|
||||
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = (await openFile.boundingBox())!
|
||||
const summary = (await modified.locator('[data-slot="session-review-change-summary"]').boundingBox())!
|
||||
return button.x + button.width < summary.x + summary.width / 2
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
// Opening a changed file selects its tab at once, before a slower file finishes loading; reopening switches back.
|
||||
await review.locator('[data-file="added.ts"]').getByRole("button", { name: "added.ts", exact: true }).click()
|
||||
const selected = files.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { selected: true })
|
||||
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(selected).toHaveText([file])
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
if (file === "modified.ts") held.resolve()
|
||||
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const bounds = (await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox())!
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 1)
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: (path) =>
|
||||
path
|
||||
? []
|
||||
: ["first.ts", "second.ts"].map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${fixture.directory}/${name}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})),
|
||||
fileContent: (path) => `contents:${path}`,
|
||||
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
const separator = getComputedStyle(element, "::before")
|
||||
return {
|
||||
height: separator.height,
|
||||
fullWidth: parseFloat(separator.width) === header.clientWidth,
|
||||
start: separator.insetInlineStart,
|
||||
bottom: separator.bottom,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
|
||||
await files.getByRole("button", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files.locator('[data-column-number="1"]').click()
|
||||
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
|
||||
await expect(editor.getByRole("textbox")).toBeVisible()
|
||||
for (const width of [390, 700]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const comment = await editor.boundingBox()
|
||||
if (!panel || !comment) return false
|
||||
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await editor.getByRole("textbox").fill("Full-width file comment")
|
||||
await editor.getByRole("button", { name: "Comment", exact: true }).click()
|
||||
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
|
||||
await expect(comment).toContainText("Full-width file comment")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const card = await comment.boundingBox()
|
||||
if (!panel || !card) return false
|
||||
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
|
||||
await files.getByRole("button", { name: "All files", exact: true }).click()
|
||||
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
|
||||
await files.getByRole("option", { name: "second.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
|
||||
await expect
|
||||
.poll(() =>
|
||||
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
|
||||
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files
|
||||
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
|
||||
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
await files.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(openTabs.getByRole("tab")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const loading = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
fileContent: async (path) => {
|
||||
if (path === "second.ts") await loading.promise
|
||||
return `contents:${path}`
|
||||
},
|
||||
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
|
||||
})),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
|
||||
const header = diff.getByRole("button", { name: file, exact: true })
|
||||
await expect(header).toBeEnabled()
|
||||
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
|
||||
await diff.getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
if (file === "second.ts") loading.resolve()
|
||||
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page, {
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "added.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch:
|
||||
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
|
||||
},
|
||||
{
|
||||
file: "removed.ts",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch:
|
||||
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
|
||||
},
|
||||
{
|
||||
file: "modified.ts",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
|
||||
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
)
|
||||
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"0px",
|
||||
)
|
||||
|
||||
for (const change of [
|
||||
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
|
||||
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
|
||||
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
|
||||
]) {
|
||||
const item = review.locator(`[data-file="${change.file}"]`)
|
||||
const trigger = item.getByRole("button", { name: change.file, exact: true })
|
||||
const summary = item.locator('[data-slot="session-review-change-summary"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
|
||||
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
|
||||
await expect(summary).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
if (change.file === "modified.ts") {
|
||||
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
|
||||
}
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
|
||||
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
|
||||
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
|
||||
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCount(0)
|
||||
}
|
||||
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
|
||||
const modified = review.locator('[data-file="modified.ts"]')
|
||||
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
|
||||
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
|
||||
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
|
||||
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
|
||||
await expect(wrap).toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(false)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true)
|
||||
await modified.locator("[data-code]").evaluate((element) => {
|
||||
element.scrollLeft = 100
|
||||
})
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
|
||||
.toBeGreaterThan(0)
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(true)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
|
||||
await expect(openFile).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await openFile.boundingBox()
|
||||
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
|
||||
if (!button || !summary) return false
|
||||
return direction === "ltr"
|
||||
? button.x > summary.x + summary.width / 2
|
||||
: button.x + button.width < summary.x + summary.width / 2
|
||||
})
|
||||
.toBe(true)
|
||||
await openFile.click()
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true")
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
})
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const position of ["top", "bottom"] as const) {
|
||||
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(position) =>
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
|
||||
position,
|
||||
)
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: {
|
||||
id: "pty_mobile_views",
|
||||
title: "Terminal 1",
|
||||
command: "sh",
|
||||
args: [],
|
||||
cwd: fixture.directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
|
||||
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator("[data-session-title]")).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
|
||||
await expect(drawer).toHaveAttribute("data-open", "")
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
const usage = page.locator('[data-slot="session-usage-content"]')
|
||||
await expect(usage).toHaveCSS("padding-top", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-start", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-end", "16px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("data-opened", "true")
|
||||
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
|
||||
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(panel.locator("textarea")).toBeEditable()
|
||||
await expect(panel).toHaveCount(1)
|
||||
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await expect(message).toBeVisible()
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(panel).toHaveAttribute("inert", "")
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(page.locator("[data-session-title]")).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { seed, sessionHref, type SeedInput } from "../utils/app"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { fileNode } from "../utils/workspace"
|
||||
|
||||
// The source and target sessions; the child session is not a home row.
|
||||
const rows = 2
|
||||
|
||||
async function openStress(page: Page, extra: SeedInput = {}) {
|
||||
await seed(page, {
|
||||
projects: { local: [{ worktree: fixture.directory, expanded: true }] },
|
||||
tabs: [fixture.sourceID, fixture.targetID],
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
for (const position of ["top", "bottom"] as const) {
|
||||
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page, { fileList: () => [], pty: {} })
|
||||
await openStress(page, {
|
||||
lastProject: { local: fixture.directory },
|
||||
settings: { general: { mobileTitlebarPosition: position } },
|
||||
})
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator("[data-session-title]")).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
|
||||
await expect(drawer).toHaveAttribute("data-open", "")
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
const usage = page.locator('[data-slot="session-usage-content"]')
|
||||
await expect(usage).toHaveCSS("padding-top", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-start", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-end", "16px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
|
||||
await expect(composer).toBeHidden()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("data-opened", "true")
|
||||
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
|
||||
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(panel.locator("textarea")).toBeEditable()
|
||||
await expect(panel).toHaveCount(1)
|
||||
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await expect(message).toBeVisible()
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(panel).toHaveAttribute("inert", "")
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
// The view resets to Session whenever the routed session changes, including through Home.
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
const openTabs = async () => {
|
||||
await trigger.click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
}
|
||||
const openTab = async (title: string) => {
|
||||
await openTabs()
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: title }).click()
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(title)
|
||||
}
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
await openTab(fixture.expected.sourceTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await openTab(fixture.expected.targetTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
await openTab(fixture.expected.sourceTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await openTabs()
|
||||
await drawer.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(drawer).toBeHidden()
|
||||
await openTab(fixture.expected.sourceTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(page.locator("[data-session-title]")).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("the summary's changes row switches the view and keeps the side tabs", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await mockStressTimeline(page, {
|
||||
fileList: (path) => (path ? [] : [fileNode(fixture.directory, "README.md")]),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
})
|
||||
await openStress(page, { lastProject: { local: fixture.directory } })
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
// One click previews the file in place of the Open file tab.
|
||||
const panel = page.locator("#review-panel")
|
||||
const readme = panel.getByRole("tab", { name: "README.md", exact: true })
|
||||
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
await panel.getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await panel.getByRole("button", { name: "README.md", exact: true }).click()
|
||||
await expect(readme).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByRole("tab", { name: "Open file", exact: true })).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Changes")
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { name: "README.md", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(readme).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test.describe("touch", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await openStress(page)
|
||||
await page.goto("/")
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(rows)
|
||||
})
|
||||
|
||||
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
|
||||
const projects = page.getByRole("button", { name: "Projects", exact: true })
|
||||
await projects.click()
|
||||
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
|
||||
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
|
||||
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(projects).toContainText(fixture.project.name)
|
||||
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveCount(0)
|
||||
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
|
||||
const help = drawer.getByRole("button", { name: "Help", exact: true })
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(help).toBeVisible()
|
||||
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
|
||||
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(fixture.expected.sourceTitle)
|
||||
await trigger.click()
|
||||
await drawer.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(rows)
|
||||
})
|
||||
|
||||
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
.getByRole("button", { name: "Settings", exact: true })
|
||||
.click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
|
||||
await expect(settings).toBeVisible()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
|
||||
const content = await panel.boundingBox()
|
||||
return !!navigation && !!content && navigation.y + navigation.height <= content.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(rows)
|
||||
})
|
||||
|
||||
test.describe("tab drawer", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
|
||||
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
})
|
||||
|
||||
test("reorders session tabs with touch", async ({ page }) => {
|
||||
const tabs = page.locator('[data-slot="vertical-tabs"] a')
|
||||
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
|
||||
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
|
||||
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
|
||||
const targetBox = await target.boundingBox()
|
||||
const sourceBox = await source.boundingBox()
|
||||
expect(targetBox).not.toBeNull()
|
||||
expect(sourceBox).not.toBeNull()
|
||||
|
||||
await touchDrag(page, source, {
|
||||
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
|
||||
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
|
||||
})
|
||||
|
||||
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
|
||||
})
|
||||
|
||||
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
|
||||
const homeBox = await drawer.getByRole("button", { name: "Home", exact: true }).boundingBox()
|
||||
const box = await tab.boundingBox()
|
||||
expect(homeBox).not.toBeNull()
|
||||
expect(box).not.toBeNull()
|
||||
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
const touch = { pointerType: "touch", pointerId: 1, isPrimary: true, clientX: point.x, clientY: point.y }
|
||||
|
||||
await tab.dispatchEvent("pointerdown", touch)
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
|
||||
await expect(rename).toBeVisible()
|
||||
await expect(drawer).toBeVisible()
|
||||
await tab.filter({ visible: true }).dispatchEvent("pointerup", touch)
|
||||
|
||||
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
|
||||
await expect(rename).toBeHidden()
|
||||
await expect(drawer).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
async function touchDrag(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
input: { from: { x: number; y: number }; to: { x: number; y: number } },
|
||||
) {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ ...input.from, id: 1 }] })
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
|
||||
})
|
||||
await expect(source).toHaveCount(2)
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ ...input.to, id: 1 }] })
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
test("mobile project selection and drawer navigation preserve session identity", async ({ page }) => {
|
||||
const projects = page.getByRole("button", { name: "Projects", exact: true })
|
||||
await projects.click()
|
||||
const picker = page.getByRole("dialog", { name: "Projects", exact: true })
|
||||
await expect(picker.getByRole("button", { name: "All projects", exact: true })).toBeVisible()
|
||||
await picker.getByRole("button", { name: new RegExp(` ${fixture.project.name}$`) }).click()
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(projects).toContainText(fixture.project.name)
|
||||
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(drawer.locator('[data-slot="tab-project"]')).toHaveCount(0)
|
||||
const settings = drawer.getByRole("button", { name: "Settings", exact: true })
|
||||
const help = drawer.getByRole("button", { name: "Help", exact: true })
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(help).toBeVisible()
|
||||
expect((await settings.boundingBox())?.width).toBeGreaterThan((await help.boundingBox())?.width ?? 0)
|
||||
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(fixture.expected.sourceTitle)
|
||||
await trigger.click()
|
||||
await drawer.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
|
||||
test("mobile settings section menu stays above a full-width panel", async ({ page }) => {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await page.locator('[data-slot="mobile-tabs-drawer"]').getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeHidden()
|
||||
await expect(settings).toBeVisible()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitemradio", { name: "Models", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("button", { name: "Models", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("switch", { name: "Claude Opus 4.6", exact: true })).toBeAttached()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
|
||||
const content = await panel.boundingBox()
|
||||
return !!navigation && !!content && navigation.y + navigation.height <= content.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((session) => ({ type: "session", server, sessionId: session.id }))),
|
||||
)
|
||||
},
|
||||
{ directory: fixture.directory, server: fixture.serverKey, sessions: fixture.sessions },
|
||||
)
|
||||
await page.goto("/")
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"]')).toBeVisible()
|
||||
const drawer = page.locator('[data-slot="mobile-drawer-content"]')
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
})
|
||||
|
||||
test("reorders session tabs with touch", async ({ page }) => {
|
||||
const tabs = page.locator('[data-slot="vertical-tabs"] a')
|
||||
await expect(tabs).toContainText([fixture.expected.sourceTitle, fixture.expected.targetTitle])
|
||||
|
||||
const target = tabs.filter({ hasText: fixture.expected.targetTitle })
|
||||
const source = tabs.filter({ hasText: fixture.expected.sourceTitle })
|
||||
const targetBox = await target.boundingBox()
|
||||
const sourceBox = await source.boundingBox()
|
||||
expect(targetBox).not.toBeNull()
|
||||
expect(sourceBox).not.toBeNull()
|
||||
|
||||
await touchDrag(page, source, {
|
||||
from: { x: sourceBox!.x + sourceBox!.width / 2, y: sourceBox!.y + sourceBox!.height / 2 },
|
||||
to: { x: targetBox!.x + targetBox!.width / 2, y: targetBox!.y + targetBox!.height / 2 },
|
||||
})
|
||||
|
||||
await expect(tabs).toContainText([fixture.expected.targetTitle, fixture.expected.sourceTitle])
|
||||
})
|
||||
|
||||
test("dismisses a touch context menu by tapping outside", async ({ page }) => {
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator('[data-slot="tab-link"]').filter({ hasText: fixture.expected.sourceTitle })
|
||||
const home = drawer.getByRole("button", { name: "Home", exact: true })
|
||||
const homeBox = await home.boundingBox()
|
||||
const box = await tab.boundingBox()
|
||||
expect(homeBox).not.toBeNull()
|
||||
expect(box).not.toBeNull()
|
||||
const point = { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 }
|
||||
|
||||
await tab.dispatchEvent("pointerdown", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
const rename = page.getByRole("menuitem", { name: "Rename", exact: true })
|
||||
await expect(rename).toBeVisible()
|
||||
await expect(drawer).toBeVisible()
|
||||
await tab.filter({ visible: true }).dispatchEvent("pointerup", {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
isPrimary: true,
|
||||
clientX: point.x,
|
||||
clientY: point.y,
|
||||
})
|
||||
|
||||
await page.touchscreen.tap(homeBox!.x + homeBox!.width / 2, homeBox!.y + homeBox!.height / 2)
|
||||
await expect(rename).toBeHidden()
|
||||
await expect(drawer).toBeVisible()
|
||||
})
|
||||
|
||||
async function touchDrag(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
input: { from: { x: number; y: number }; to: { x: number; y: number } },
|
||||
) {
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ ...input.from, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: (input.from.x + input.to.x) / 2, y: (input.from.y + input.to.y) / 2, id: 1 }],
|
||||
})
|
||||
await expect(source).toHaveCount(2)
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ ...input.to, id: 1 }],
|
||||
})
|
||||
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
} from "../utils/timeline"
|
||||
import { capturePromptMotion, promptMotionIssues, readPromptPositions } from "../utils/prompt-motion"
|
||||
|
||||
// Compositor prediction can add 20–25px to discrete CDP moves even in a plain
|
||||
// scrollport. Disable it so the visual assertion measures the supplied gesture.
|
||||
@@ -68,79 +69,71 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
expect((await anchor.boundingBox())?.y).toBeCloseTo(before.y, 0)
|
||||
})
|
||||
|
||||
for (const imageCount of [1, 2]) {
|
||||
test(`keeps the reading anchor when ${imageCount} earlier images load during a drag`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const image = Promise.withResolvers<void>()
|
||||
const url = new URL("/mobile-scroll-images.svg", testInfo.project.use.baseURL).href
|
||||
await page.route(url, async (route) => {
|
||||
await image.promise
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="340" height="600"><rect width="340" height="600" fill="steelblue"/></svg>',
|
||||
})
|
||||
test("keeps the reading anchor when 2 earlier images load during a drag", async ({ page }, testInfo) => {
|
||||
const image = Promise.withResolvers<void>()
|
||||
const url = new URL("/mobile-scroll-images.svg", testInfo.project.use.baseURL).href
|
||||
await page.route(url, async (route) => {
|
||||
await image.promise
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="340" height="600"><rect width="340" height="600" fill="steelblue"/></svg>',
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
Array.from({ length: 6 }, (_, index) =>
|
||||
textPart(
|
||||
`prt_images_${index}`,
|
||||
index < imageCount
|
||||
? `Image ${index}.\n\n`
|
||||
: index < 2
|
||||
? "Earlier context."
|
||||
: Array.from({ length: 12 }, (_, line) => `Part ${index} line ${line}.`).join("\n\n"),
|
||||
),
|
||||
),
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
const reading = await readFrom(page, "Part 2 line 0.")
|
||||
const images = Array.from({ length: imageCount }, (_, index) =>
|
||||
page.getByAltText(`Image ${index}`, { exact: true }),
|
||||
)
|
||||
const rows = images.map((image) => reading.scroller.locator("[data-timeline-key]", { has: image }))
|
||||
const heights = await Promise.all(
|
||||
rows.map((row) => row.evaluate((element) => element.getBoundingClientRect().height)),
|
||||
)
|
||||
const before = await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)
|
||||
const bounds = (await reading.scroller.boundingBox())!
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 }],
|
||||
})
|
||||
image.resolve()
|
||||
for (const [index, row] of rows.entries()) {
|
||||
await expect(images[index]).toHaveJSProperty("naturalHeight", 600)
|
||||
await expect
|
||||
.poll(() => row.evaluate((element) => element.getBoundingClientRect().height))
|
||||
.toBe(heights[index] + 600)
|
||||
}
|
||||
await testInfo.attach("images-held.png", { body: await page.screenshot(), contentType: "image/png" })
|
||||
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
|
||||
for (let step = 1; step <= 21; step++) {
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 + step * 30 }],
|
||||
})
|
||||
await expect
|
||||
.poll(() => reading.anchor.evaluate((element) => element.getBoundingClientRect().top))
|
||||
.toBeCloseTo(before + step * 30 - 15, 0)
|
||||
}
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(
|
||||
before + 615,
|
||||
0,
|
||||
)
|
||||
})
|
||||
}
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
Array.from({ length: 6 }, (_, index) =>
|
||||
textPart(
|
||||
`prt_images_${index}`,
|
||||
index < 2
|
||||
? `Image ${index}.\n\n`
|
||||
: Array.from({ length: 12 }, (_, line) => `Part ${index} line ${line}.`).join("\n\n"),
|
||||
),
|
||||
),
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
const reading = await readFrom(page, "Part 2 line 0.")
|
||||
const images = [0, 1].map((index) => page.getByAltText(`Image ${index}`, { exact: true }))
|
||||
const rows = images.map((image) => reading.scroller.locator("[data-timeline-key]", { has: image }))
|
||||
const heights = await Promise.all(
|
||||
rows.map((row) => row.evaluate((element) => element.getBoundingClientRect().height)),
|
||||
)
|
||||
const before = await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)
|
||||
const bounds = (await reading.scroller.boundingBox())!
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 }],
|
||||
})
|
||||
image.resolve()
|
||||
for (const [index, row] of rows.entries()) {
|
||||
await expect(images[index]).toHaveJSProperty("naturalHeight", 600)
|
||||
await expect
|
||||
.poll(() => row.evaluate((element) => element.getBoundingClientRect().height))
|
||||
.toBe(heights[index] + 600)
|
||||
}
|
||||
await testInfo.attach("images-held.png", { body: await page.screenshot(), contentType: "image/png" })
|
||||
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
|
||||
for (let step = 1; step <= 21; step++) {
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: bounds.x + 120, y: bounds.y + 5 + step * 30 }],
|
||||
})
|
||||
await expect
|
||||
.poll(() => reading.anchor.evaluate((element) => element.getBoundingClientRect().top))
|
||||
.toBeCloseTo(before + step * 30 - 15, 0)
|
||||
}
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
await expect(reading.timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
expect(await reading.anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(
|
||||
before + 615,
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
test("reaches the start without a gap or release jump after earlier output shrinks", async ({ page }, testInfo) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
@@ -308,105 +301,86 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const width of [390, 1000]) {
|
||||
for (const release of ["touchEnd", "touchCancel"] as const) {
|
||||
test(`detached session gestures do not unpin the selected session (${width}px, ${release})`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const server = testInfo.project.use.baseURL!
|
||||
const second = "ses_gesture_destination"
|
||||
await page.addInitScript(
|
||||
({ server, first, second }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", sessionId: first, server },
|
||||
{ type: "session", sessionId: second, server },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, first: sessionID, second },
|
||||
)
|
||||
const content = Array.from({ length: 60 }, (_, index) => `Read output ${index}.`).join("\n\n")
|
||||
const fixture = await setupTimeline(page, {
|
||||
sessions: [session(), session({ id: second, title: "Second gesture session" })],
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_session_gesture", content)], { completed: false }),
|
||||
],
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
const tail = page.getByText("Read output 59.", { exact: true })
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(tail).toBeInViewport()
|
||||
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
const before = await tail.evaluate((element) => element.getBoundingClientRect().top)
|
||||
const bounds = (await scroller.boundingBox())!
|
||||
const point = { x: bounds.x + 150, y: bounds.y + 200 }
|
||||
const oldTarget = await page.evaluateHandle((point) => document.elementFromPoint(point.x, point.y), point)
|
||||
const oldRoot = (await scroller.elementHandle())!
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] })
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 40 }],
|
||||
})
|
||||
await expect
|
||||
.poll(() => tail.evaluate((element) => element.getBoundingClientRect().top))
|
||||
.toBeGreaterThan(before)
|
||||
if (width < 768) {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
.locator(`[data-titlebar-tab-link][href$="/session/${second}"]`)
|
||||
.click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText("Second gesture session")
|
||||
}
|
||||
if (width >= 768) {
|
||||
await page.locator(`[data-titlebar-tab-link][href$="/session/${second}"]`).click()
|
||||
await expect(page.getByRole("heading", { name: "Second gesture session", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${second}$`))
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(tail).toBeInViewport()
|
||||
await expect.poll(() => oldTarget.evaluate((element) => element?.isConnected)).toBe(false)
|
||||
expect(await oldRoot.evaluate((element) => element.isConnected)).toBe(false)
|
||||
const selected = await tail.evaluate((element) => element.getBoundingClientRect().top)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 70 }],
|
||||
})
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
|
||||
expect(await tail.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(selected, 0)
|
||||
await oldTarget.dispose()
|
||||
await oldRoot.dispose()
|
||||
const delta = partDelta(
|
||||
"prt_session_gesture",
|
||||
`\n\n${Array.from({ length: 30 }, (_, index) => `Newly streamed ${index}.`).join("\n\n")}`,
|
||||
)
|
||||
if (delta.type !== "session.text.delta") throw new Error("Expected a text delta")
|
||||
await fixture.send({ ...delta, data: { ...delta.data, sessionID: second } })
|
||||
await expect(page.getByText("Newly streamed 29.", { exact: true })).toBeInViewport()
|
||||
await testInfo.attach("selected-session-follows.png", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const [width, release] of [
|
||||
[390, "touchEnd"],
|
||||
[1000, "touchCancel"],
|
||||
] as const) {
|
||||
test(`detached session gestures do not unpin the selected session (${width}px, ${release})`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const second = "ses_gesture_destination"
|
||||
const content = Array.from({ length: 60 }, (_, index) => `Read output ${index}.`).join("\n\n")
|
||||
const fixture = await setupTimeline(page, {
|
||||
tabs: [sessionID, second],
|
||||
sessions: [session(), session({ id: second, title: "Second gesture session" })],
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_session_gesture", content)], { completed: false })],
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
}
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
const tail = page.getByText("Read output 59.", { exact: true })
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(tail).toBeInViewport()
|
||||
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
const before = await tail.evaluate((element) => element.getBoundingClientRect().top)
|
||||
const bounds = (await scroller.boundingBox())!
|
||||
const point = { x: bounds.x + 150, y: bounds.y + 200 }
|
||||
const oldTarget = await page.evaluateHandle((point) => document.elementFromPoint(point.x, point.y), point)
|
||||
const oldRoot = (await scroller.elementHandle())!
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] })
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 40 }],
|
||||
})
|
||||
await expect.poll(() => tail.evaluate((element) => element.getBoundingClientRect().top)).toBeGreaterThan(before)
|
||||
if (width < 768) {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
await page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
.locator(`[data-titlebar-tab-link][href$="/session/${second}"]`)
|
||||
.click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText("Second gesture session")
|
||||
}
|
||||
if (width >= 768) {
|
||||
await page.locator(`[data-titlebar-tab-link][href$="/session/${second}"]`).click()
|
||||
await expect(page.getByRole("heading", { name: "Second gesture session", exact: true })).toBeVisible()
|
||||
}
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${second}$`))
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(tail).toBeInViewport()
|
||||
await expect.poll(() => oldTarget.evaluate((element) => element?.isConnected)).toBe(false)
|
||||
expect(await oldRoot.evaluate((element) => element.isConnected)).toBe(false)
|
||||
const selected = await tail.evaluate((element) => element.getBoundingClientRect().top)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 70 }],
|
||||
})
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
|
||||
expect(await tail.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(selected, 0)
|
||||
await oldTarget.dispose()
|
||||
await oldRoot.dispose()
|
||||
const delta = partDelta(
|
||||
"prt_session_gesture",
|
||||
`\n\n${Array.from({ length: 30 }, (_, index) => `Newly streamed ${index}.`).join("\n\n")}`,
|
||||
)
|
||||
if (delta.type !== "session.text.delta") throw new Error("Expected a text delta")
|
||||
await fixture.send({ ...delta, data: { ...delta.data, sessionID: second } })
|
||||
await expect(page.getByText("Newly streamed 29.", { exact: true })).toBeInViewport()
|
||||
await testInfo.attach("selected-session-follows.png", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Released Home, Control+Home, Control+End, and scrollbar take the same prepareNavigation path as these rows.
|
||||
for (const handoff of [
|
||||
{ key: "Home", held: false },
|
||||
{ key: "Home", held: true },
|
||||
{ key: "End", held: false },
|
||||
{ key: "Control+Home", held: false },
|
||||
{ key: "Control+Home", held: true },
|
||||
{ key: "Control+End", held: false },
|
||||
{ key: "latest", held: false },
|
||||
{ key: "scrollbar", held: false },
|
||||
{ key: "scrollbar", held: true },
|
||||
{ key: "scrollbar-large", held: true },
|
||||
] as const) {
|
||||
@@ -587,180 +561,134 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const release of ["touchEnd", "touchCancel"] as const) {
|
||||
test(`returns Home after scrolling the touch target out of view (${release})`, async ({ page }, testInfo) => {
|
||||
const image = Promise.withResolvers<void>()
|
||||
const url = new URL("/lifecycle-image.svg", testInfo.project.use.baseURL).href
|
||||
await page.route(url, async (route) => {
|
||||
await image.promise
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><rect width="300" height="300" fill="steelblue"/></svg>',
|
||||
for (const detach of ["scroll", "stream"] as const) {
|
||||
for (const release of ["touchEnd", "touchCancel"] as const) {
|
||||
const cause =
|
||||
detach === "scroll" ? "scrolling the touch target out of view" : "streaming replaces the touch target"
|
||||
test(`returns Home after ${cause} (${release})`, async ({ page }, testInfo) => {
|
||||
const image = Promise.withResolvers<void>()
|
||||
const url = new URL(`/${detach}-target-image.svg`, testInfo.project.use.baseURL).href
|
||||
await page.route(url, async (route) => {
|
||||
await image.promise
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><rect width="300" height="300" fill="steelblue"/></svg>',
|
||||
})
|
||||
})
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
Array.from({ length: 80 }, (_, index) =>
|
||||
textPart(
|
||||
`prt_lifecycle_${index}`,
|
||||
`Part ${index}.${index === 46 ? `\n\n` : ""}`,
|
||||
),
|
||||
const fixture = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
detach === "scroll"
|
||||
? Array.from({ length: 80 }, (_, index) =>
|
||||
textPart(
|
||||
`prt_lifecycle_${index}`,
|
||||
`Part ${index}.${index === 46 ? `\n\n` : ""}`,
|
||||
),
|
||||
)
|
||||
: [
|
||||
textPart(
|
||||
"prt_target_prefix",
|
||||
Array.from({ length: 60 }, (_, index) => `Prefix ${index}.`).join("\n\n"),
|
||||
),
|
||||
textPart("prt_target_image", `Earlier image.\n\n`),
|
||||
textPart(
|
||||
"prt_target_reading",
|
||||
Array.from({ length: 25 }, (_, index) => `Reading ${index}.`).join("\n\n"),
|
||||
),
|
||||
textPart("prt_target_heading", "Heading text"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(page.getByText("Part 79.", { exact: true })).toBeInViewport()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1 }))
|
||||
element.scrollTop = 0
|
||||
})
|
||||
const first = scroller.locator('[data-timeline-row="UserMessage"]')
|
||||
await expect(first).toBeInViewport()
|
||||
const start = await first.evaluate((element) => element.getBoundingClientRect().top)
|
||||
// Measure the history before exercising a gesture across the virtual window.
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await scroller.evaluate((element) => (element.scrollTop += 300))
|
||||
],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
const hidden = timeline.locator('[data-orientation="vertical"][data-visible="false"]')
|
||||
const heading = page.locator(`[data-timeline-part-id="${renderedPartID("prt_target_heading")}"]`)
|
||||
const last = detach === "scroll" ? page.getByText("Part 79.", { exact: true }) : heading
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(last).toBeInViewport()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1 }))
|
||||
element.scrollTop = 0
|
||||
})
|
||||
const first = scroller.locator('[data-timeline-row="UserMessage"]')
|
||||
await expect(first).toBeInViewport()
|
||||
const start = await first.evaluate((element) => element.getBoundingClientRect().top)
|
||||
// Measure the history before exercising a gesture across the virtual window.
|
||||
if (detach === "scroll")
|
||||
for (let index = 0; index < 10; index++) {
|
||||
await scroller.evaluate((element) => (element.scrollTop += 300))
|
||||
await page.screenshot()
|
||||
await expect(hidden).toHaveCount(1)
|
||||
}
|
||||
if (detach === "stream") await expect(hidden).toHaveCount(1)
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(last).toBeInViewport()
|
||||
await expect(hidden).toHaveCount(1)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
if (detach === "scroll") {
|
||||
const bounds = (await scroller.boundingBox())!
|
||||
const touched = page.getByText("Part 66.", { exact: true })
|
||||
await expect(touched).toBeInViewport()
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: bounds.x + 100, y: bounds.y + 70 }],
|
||||
})
|
||||
for (let step = 1; step <= 23; step++)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: bounds.x + 100, y: bounds.y + 70 + step * 30 }],
|
||||
})
|
||||
await expect(touched).not.toBeInViewport()
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
|
||||
await expect(hidden).toHaveCount(1)
|
||||
await expect(touched).toHaveCount(0)
|
||||
}
|
||||
if (detach === "stream") {
|
||||
const bounds = (await heading.getByText("Heading text", { exact: true }).boundingBox())!
|
||||
const point = { x: bounds.x + 25, y: bounds.y + bounds.height / 2 }
|
||||
const target = await page.evaluateHandle((point) => document.elementFromPoint(point.x, point.y), point)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] })
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 60 }],
|
||||
})
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="true"]')).toHaveCount(1)
|
||||
await fixture.send(partDelta("prt_target_heading", "\n\nMore content."))
|
||||
await expect(heading).toContainText("More content.")
|
||||
await expect(heading.locator("p")).toHaveCount(2)
|
||||
await expect.poll(() => target.evaluate((element) => element?.isConnected)).toBe(false)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
|
||||
await target.dispose()
|
||||
await expect(hidden).toHaveCount(1)
|
||||
}
|
||||
const anchor = page.getByText(detach === "scroll" ? "Part 47." : "Reading 20.", { exact: true })
|
||||
await expect(anchor).toBeInViewport()
|
||||
const before = await anchor.evaluate((element) => element.getBoundingClientRect().top)
|
||||
image.resolve()
|
||||
const loaded = page.getByAltText("Delayed image", { exact: true })
|
||||
await expect(loaded).toHaveJSProperty("naturalHeight", 300)
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller
|
||||
.locator("[data-timeline-key]", { has: loaded })
|
||||
.evaluate((element) => element.getBoundingClientRect().height),
|
||||
)
|
||||
.toBeGreaterThan(300)
|
||||
await page.screenshot()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
}
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.getByText("Part 79.", { exact: true })).toBeInViewport()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
const bounds = (await scroller.boundingBox())!
|
||||
const touched = page.getByText("Part 66.", { exact: true })
|
||||
await expect(touched).toBeInViewport()
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [{ x: bounds.x + 100, y: bounds.y + 70 }],
|
||||
expect(await anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
|
||||
await scroller.press("Home")
|
||||
await expect(first).toBeInViewport()
|
||||
await expect(hidden).toHaveCount(1)
|
||||
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(start, 0)
|
||||
await testInfo.attach("home-after-touch.png", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
for (let step = 1; step <= 23; step++)
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: bounds.x + 100, y: bounds.y + 70 + step * 30 }],
|
||||
})
|
||||
await expect(touched).not.toBeInViewport()
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await expect(touched).toHaveCount(0)
|
||||
const anchor = page.getByText("Part 47.", { exact: true })
|
||||
await expect(anchor).toBeInViewport()
|
||||
const before = await anchor.evaluate((element) => element.getBoundingClientRect().top)
|
||||
image.resolve()
|
||||
const loaded = page.getByAltText("Delayed image", { exact: true })
|
||||
await expect(loaded).toHaveJSProperty("naturalHeight", 300)
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller
|
||||
.locator("[data-timeline-key]", { has: loaded })
|
||||
.evaluate((element) => element.getBoundingClientRect().height),
|
||||
)
|
||||
.toBeGreaterThan(300)
|
||||
await page.screenshot()
|
||||
expect(await anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
|
||||
await scroller.press("Home")
|
||||
await expect(first).toBeInViewport()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(start, 0)
|
||||
await testInfo.attach("home-after-touch.png", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
|
||||
test(`returns Home after streaming replaces the touch target (${release})`, async ({ page }, testInfo) => {
|
||||
const image = Promise.withResolvers<void>()
|
||||
const url = new URL("/stream-target-image.svg", testInfo.project.use.baseURL).href
|
||||
await page.route(url, async (route) => {
|
||||
await image.promise
|
||||
await route.fulfill({
|
||||
contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><rect width="300" height="300" fill="steelblue"/></svg>',
|
||||
})
|
||||
})
|
||||
const fixture = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(
|
||||
"prt_target_prefix",
|
||||
Array.from({ length: 60 }, (_, index) => `Prefix ${index}.`).join("\n\n"),
|
||||
),
|
||||
textPart("prt_target_image", `Earlier image.\n\n`),
|
||||
textPart(
|
||||
"prt_target_reading",
|
||||
Array.from({ length: 25 }, (_, index) => `Reading ${index}.`).join("\n\n"),
|
||||
),
|
||||
textPart("prt_target_heading", "Heading text"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
const heading = page.locator(`[data-timeline-part-id="${renderedPartID("prt_target_heading")}"]`)
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await expect(heading).toBeInViewport()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await expect(timeline.locator('[data-component="markdown"]:not([data-markdown-ready])')).toHaveCount(0)
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1 }))
|
||||
element.scrollTop = 0
|
||||
})
|
||||
const first = scroller.locator('[data-timeline-row="UserMessage"]')
|
||||
await expect(first).toBeInViewport()
|
||||
const start = await first.evaluate((element) => element.getBoundingClientRect().top)
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(heading).toBeInViewport()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
const bounds = (await heading.getByText("Heading text", { exact: true }).boundingBox())!
|
||||
const point = { x: bounds.x + 25, y: bounds.y + bounds.height / 2 }
|
||||
const target = await page.evaluateHandle((point) => document.elementFromPoint(point.x, point.y), point)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [point] })
|
||||
await devtools.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: point.x, y: point.y + 60 }],
|
||||
})
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="true"]')).toHaveCount(1)
|
||||
await fixture.send(partDelta("prt_target_heading", "\n\nMore content."))
|
||||
await expect(heading).toContainText("More content.")
|
||||
await expect(heading.locator("p")).toHaveCount(2)
|
||||
await expect.poll(() => target.evaluate((element) => element?.isConnected)).toBe(false)
|
||||
await devtools.send("Input.dispatchTouchEvent", { type: release, touchPoints: [] })
|
||||
await target.dispose()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
const anchor = page.getByText("Reading 20.", { exact: true })
|
||||
await expect(anchor).toBeInViewport()
|
||||
const before = await anchor.evaluate((element) => element.getBoundingClientRect().top)
|
||||
image.resolve()
|
||||
const loaded = page.getByAltText("Delayed image", { exact: true })
|
||||
await expect(loaded).toHaveJSProperty("naturalHeight", 300)
|
||||
await expect
|
||||
.poll(() =>
|
||||
scroller
|
||||
.locator("[data-timeline-key]", { has: loaded })
|
||||
.evaluate((element) => element.getBoundingClientRect().height),
|
||||
)
|
||||
.toBeGreaterThan(300)
|
||||
await page.screenshot()
|
||||
expect(await anchor.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(before, 0)
|
||||
await scroller.press("Home")
|
||||
await expect(first).toBeInViewport()
|
||||
await expect(timeline.locator('[data-orientation="vertical"][data-visible="false"]')).toHaveCount(1)
|
||||
expect(await first.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(start, 0)
|
||||
await testInfo.attach("home-after-stream.png", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("keeps the visible row anchored through reflow and touch cancellation", async ({ page }, testInfo) => {
|
||||
@@ -824,30 +752,27 @@ for (const device of ["Pixel 7", "iPhone 13"]) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const hidden of ["translateY(-500px)", "scale(0)"]) {
|
||||
test(`painted motion rejects a disappearing prompt (${hidden})`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_pixel_control", "Content.")])],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const prompt = timeline.locator('[data-timeline-row="UserMessage"]')
|
||||
await expect(prompt).toBeInViewport()
|
||||
const view = (await timeline.getByRole("region", { name: "scrollable content", exact: true }).boundingBox())!
|
||||
const frames = [(await page.screenshot({ type: "jpeg" })).toString("base64")]
|
||||
// Negative control: a captured disappearance must fail even if the layout recovers.
|
||||
await prompt.evaluate((element, transform) => (element.style.transform = transform), hidden)
|
||||
frames.push((await page.screenshot({ type: "jpeg" })).toString("base64"))
|
||||
await prompt.evaluate((element) => element.style.removeProperty("transform"))
|
||||
frames.push((await page.screenshot({ type: "jpeg" })).toString("base64"))
|
||||
const positions = await readPromptPositions(page, frames, view)
|
||||
expect(positions.map((position) => position.top !== null)).toEqual([true, false, true])
|
||||
expect(promptMotionIssues(positions)).toEqual([{ frame: 1, reason: "missing" }])
|
||||
test("painted motion rejects a prompt whose pixels disappear", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_pixel_control", "Content.")])],
|
||||
viewport: { width: 390, height: 844 },
|
||||
})
|
||||
}
|
||||
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
await expect(timeline.locator("[data-timeline-virtual-content]")).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
const prompt = timeline.locator('[data-timeline-row="UserMessage"]')
|
||||
await expect(prompt).toBeInViewport()
|
||||
const view = (await timeline.getByRole("region", { name: "scrollable content", exact: true }).boundingBox())!
|
||||
const frames = [(await page.screenshot({ type: "jpeg" })).toString("base64")]
|
||||
// Negative control: a captured disappearance must fail even if the layout recovers.
|
||||
await prompt.evaluate((element) => (element.style.transform = "translateY(-500px)"))
|
||||
frames.push((await page.screenshot({ type: "jpeg" })).toString("base64"))
|
||||
await prompt.evaluate((element) => element.style.removeProperty("transform"))
|
||||
frames.push((await page.screenshot({ type: "jpeg" })).toString("base64"))
|
||||
const positions = await readPromptPositions(page, frames, view)
|
||||
expect(positions.map((position) => position.top !== null)).toEqual([true, false, true])
|
||||
expect(promptMotionIssues(positions)).toEqual([{ frame: 1, reason: "missing" }])
|
||||
})
|
||||
async function readFrom(page: Page, text: string) {
|
||||
const timeline = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const scroller = timeline.getByRole("region", { name: "scrollable content", exact: true })
|
||||
@@ -870,75 +795,3 @@ async function readFrom(page: Page, text: string) {
|
||||
await expect(anchor).toBeInViewport()
|
||||
return { timeline, scroller, anchor, start }
|
||||
}
|
||||
|
||||
async function capturePromptMotion(page: Page, view: { x: number; y: number; width: number }) {
|
||||
const session = await page.context().newCDPSession(page)
|
||||
const frames: string[] = []
|
||||
const acknowledgements: Promise<unknown>[] = []
|
||||
const onFrame = (frame: { data: string; sessionId: number }) => {
|
||||
frames.push(frame.data)
|
||||
acknowledgements.push(session.send("Page.screencastFrameAck", { sessionId: frame.sessionId }))
|
||||
}
|
||||
session.on("Page.screencastFrame", onFrame)
|
||||
const viewport = page.viewportSize()!
|
||||
await session.send("Page.startScreencast", {
|
||||
format: "jpeg",
|
||||
quality: 90,
|
||||
maxWidth: viewport.width,
|
||||
maxHeight: viewport.height,
|
||||
everyNthFrame: 1,
|
||||
})
|
||||
return async () => {
|
||||
session.off("Page.screencastFrame", onFrame)
|
||||
await session.send("Page.stopScreencast")
|
||||
await Promise.all(acknowledgements)
|
||||
await session.detach()
|
||||
return { positions: await readPromptPositions(page, frames, view), frames }
|
||||
}
|
||||
}
|
||||
|
||||
async function readPromptPositions(page: Page, frames: string[], view: { x: number; y: number; width: number }) {
|
||||
return page.evaluate(
|
||||
async ({ frames, view, viewport }) => {
|
||||
const positions: { frame: number; top: number | null }[] = []
|
||||
for (const [frame, encoded] of frames.entries()) {
|
||||
const position = { frame, top: null as number | null }
|
||||
const image = await createImageBitmap(
|
||||
new Blob([Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0))], { type: "image/jpeg" }),
|
||||
)
|
||||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
const context = canvas.getContext("2d")!
|
||||
context.drawImage(image, 0, 0)
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data
|
||||
const scale = image.height / viewport.height
|
||||
// This fixture's only blue content in the top 200px is its user prompt.
|
||||
// Track painted ink, including compositor-only scroll frames.
|
||||
for (let y = Math.ceil(view.y * scale); y < Math.min(image.height, (view.y + 200) * scale); y++) {
|
||||
let ink = 0
|
||||
for (let x = Math.ceil((view.x + 16) * scale); x < (view.x + view.width - 16) * scale; x++) {
|
||||
const offset = (y * image.width + x) * 4
|
||||
if (pixels[offset + 2] > pixels[offset] + 50 && pixels[offset + 2] > pixels[offset + 1] + 30) ink++
|
||||
}
|
||||
if (ink < 3) continue
|
||||
position.top = y / scale
|
||||
break
|
||||
}
|
||||
positions.push(position)
|
||||
image.close()
|
||||
}
|
||||
return positions
|
||||
},
|
||||
{ frames, view, viewport: page.viewportSize()! },
|
||||
)
|
||||
}
|
||||
|
||||
function promptMotionIssues(positions: { frame: number; top: number | null }[]) {
|
||||
const first = positions.findIndex((position) => position.top !== null)
|
||||
if (first < 0) return [{ frame: 0, reason: "never-painted" }]
|
||||
return positions.slice(first).flatMap((position, index, all) => {
|
||||
if (position.top === null) return [{ frame: position.frame, reason: "missing" }]
|
||||
const previous = all[index - 1]?.top
|
||||
if (previous === undefined || previous === null || position.top >= previous - 2) return []
|
||||
return [{ frame: position.frame, reason: "reversed" }]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { REMOTE_SERVER, SERVER, expectPath, project, seed, session, sessionHref, type TabSeed } from "../utils/app"
|
||||
import { mockServers, type MockServerConfig } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
const sessionA = session({ id: "ses_server_a", directory: directoryA, title: "Server A session" })
|
||||
const childA = session({
|
||||
id: "ses_server_a_child",
|
||||
directory: directoryA,
|
||||
title: "Server A child",
|
||||
parentID: sessionA.id,
|
||||
})
|
||||
const sessionB = session({ id: "ses_server_b", directory: directoryB, title: "Server B session" })
|
||||
|
||||
type Reply = { origin: string; sessionID: string; permissionID: string; body: unknown }
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
function pending(id: string, sessionID: string) {
|
||||
return { id, sessionID, action: "shell", resources: ["git status"], metadata: {}, save: [] }
|
||||
}
|
||||
|
||||
async function setup(page: Page, input: { tabs: TabSeed[]; a?: Partial<MockServerConfig> }) {
|
||||
const replies: Reply[] = []
|
||||
const lists: URL[] = []
|
||||
const sessionGets: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname === "/api/permission/request") lists.push(url)
|
||||
})
|
||||
const config = (origin: string, name: string, directory: string, sessions: ReturnType<typeof session>[]) => {
|
||||
const id = name.toLowerCase().replace(" ", "-")
|
||||
return {
|
||||
directory,
|
||||
project: project({ id: `proj_${id}`, directory }),
|
||||
provider: {
|
||||
all: [{ id, name: `${name} Provider`, models: { [id]: { id, name: `${name} Model` } } }],
|
||||
connected: [id],
|
||||
default: { providerID: id, modelID: id },
|
||||
},
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: Object.fromEntries(sessions.map((item) => [item.id, { type: "running" }])),
|
||||
strictDirectory: true,
|
||||
onPermissionReply: (reply: Omit<Reply, "origin">) => replies.push({ origin, ...reply }),
|
||||
}
|
||||
}
|
||||
const servers = await mockServers(page, {
|
||||
[SERVER]: {
|
||||
...config(SERVER, "Server A", directoryA, [sessionA, childA]),
|
||||
eventRetry: 20,
|
||||
onSession: (id) => sessionGets.push(id),
|
||||
...input.a,
|
||||
},
|
||||
[REMOTE_SERVER]: config(REMOTE_SERVER, "Server B", directoryB, [sessionB]),
|
||||
})
|
||||
await seed(page, { servers: [REMOTE_SERVER], tabs: input.tabs })
|
||||
const listed = (origin: string, directory: string) =>
|
||||
expect
|
||||
.poll(() =>
|
||||
lists.some((url) => url.origin === origin && url.searchParams.get("location[directory]") === directory),
|
||||
)
|
||||
.toBe(true)
|
||||
const enableAutoAccept = async () => {
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
}
|
||||
return { replies, sessionGets, transport: servers[SERVER]!.transport, listed, enableAutoAccept }
|
||||
}
|
||||
|
||||
const reply = (sessionID: string, permissionID: string) => ({
|
||||
origin: SERVER,
|
||||
sessionID,
|
||||
permissionID,
|
||||
body: { decision: "once" },
|
||||
})
|
||||
|
||||
test("settings opened from a remote session sweep every server and keep the remote scope", async ({ page }) => {
|
||||
// Server A has no tab and is never visited: its pending request proves one toggle sweeps every connected server.
|
||||
const view = await setup(page, { tabs: [], a: { permissions: [pending("permission-pending-a", sessionA.id)] } })
|
||||
await page.goto(sessionHref(sessionB.id, REMOTE_SERVER))
|
||||
await expectSessionTitle(page, sessionB.title)
|
||||
|
||||
await view.enableAutoAccept()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await view.listed(REMOTE_SERVER, directoryB)
|
||||
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-pending-a")])
|
||||
|
||||
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
|
||||
await expect(settings.getByRole("tab")).toHaveText([
|
||||
"127.0.0.1:4097",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
])
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Back to settings" }).click()
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER))
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await page.keyboard.press("Escape")
|
||||
await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER))
|
||||
await expectSessionTitle(page, sessionB.title)
|
||||
})
|
||||
|
||||
test("auto-accept responds for an unfocused server session and its child", async ({ page }) => {
|
||||
const view = await setup(page, { tabs: [sessionA.id, { session: sessionB.id, server: REMOTE_SERVER }] })
|
||||
await page.goto(sessionHref(sessionA.id))
|
||||
await expectSessionTitle(page, sessionA.title)
|
||||
await view.enableAutoAccept()
|
||||
await view.listed(SERVER, directoryA)
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(sessionB.id, REMOTE_SERVER)}"])`).click()
|
||||
await expectSessionTitle(page, sessionB.title)
|
||||
await view.transport.waitForConnection()
|
||||
for (const [index, item] of [sessionA, childA].entries()) {
|
||||
await view.transport.send({
|
||||
id: `evt_permission_background_${index}`,
|
||||
created: 1700000001000 + index,
|
||||
type: "permission.asked",
|
||||
location: { directory: directoryA },
|
||||
data: { ...pending(`permission-background-${index}`, item.id) },
|
||||
})
|
||||
}
|
||||
await expect
|
||||
.poll(() => view.replies)
|
||||
.toEqual([reply(sessionA.id, "permission-background-0"), reply(childA.id, "permission-background-1")])
|
||||
})
|
||||
|
||||
test("auto-accept sweeps again after a reconnect and resyncs active sessions", async ({ page }) => {
|
||||
const queued: ReturnType<typeof pending>[] = []
|
||||
const failures = { next: 0 }
|
||||
const view = await setup(page, {
|
||||
tabs: [sessionA.id],
|
||||
a: {
|
||||
permissions: () => queued,
|
||||
permissionListFailures: () => failures.next-- > 0,
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(sessionA.id))
|
||||
await expectSessionTitle(page, sessionA.title)
|
||||
const first = await view.transport.waitForConnection()
|
||||
await view.enableAutoAccept()
|
||||
await view.listed(SERVER, directoryA)
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
// Asked while the client is disconnected, so only a reconnect sweep finds it. The first listing after the
|
||||
// reconnect fails, so only the bounded sweep retry can deliver the reply.
|
||||
queued.push(pending("permission-offline-a", sessionA.id))
|
||||
failures.next = 1
|
||||
const before = view.sessionGets.length
|
||||
await view.transport.disconnect()
|
||||
await view.transport.waitForConnection({ after: first.id })
|
||||
|
||||
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-offline-a")])
|
||||
expect(view.sessionGets.slice(before)).toContain(sessionA.id)
|
||||
})
|
||||
|
||||
test("auto-accept approves a request discovered by opening a session", async ({ page }) => {
|
||||
// Served only by the per-session permission list, never by a location sweep or an event.
|
||||
const view = await setup(page, {
|
||||
tabs: [sessionA.id],
|
||||
a: { sessionPermissions: { [sessionA.id]: [pending("permission-synced-a", sessionA.id)] } },
|
||||
})
|
||||
await page.goto(sessionHref(sessionA.id))
|
||||
await expectSessionTitle(page, sessionA.title)
|
||||
await view.enableAutoAccept()
|
||||
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-synced-a")])
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_dropzone"
|
||||
const directory = "C:/OpenCode/NewSessionDropzone"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows the dropzone and attaches a dropped file", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_dropzone",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "new-session-dropzone",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
|
||||
const surface = page.locator('[data-component="new-session"]')
|
||||
const dropzone = page.locator('[data-component="session-dropzone"]')
|
||||
const transfer = await page.evaluateHandle(() => {
|
||||
const value = new DataTransfer()
|
||||
value.items.add(new File(["Dropzone fixture"], "dropzone.txt", { type: "text/plain" }))
|
||||
return value
|
||||
})
|
||||
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
await surface.dispatchEvent("dragover", { dataTransfer: transfer })
|
||||
await expect(dropzone).toHaveAttribute("data-visible", "true")
|
||||
await expect(dropzone).toContainText("Drop files to add")
|
||||
|
||||
await surface.dispatchEvent("drop", { dataTransfer: transfer })
|
||||
await expect(page.locator('[data-component="composer-attachments"]')).toContainText("dropzone.txt")
|
||||
await expect(dropzone).toHaveCount(0)
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_panel_corner"
|
||||
const directory = "C:/OpenCode/NewSessionPanelCorner"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({
|
||||
viewport: { width: 935, height: 522 },
|
||||
deviceScaleFactor: 1,
|
||||
})
|
||||
|
||||
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_panel_corner",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "new-session-panel-corner",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
|
||||
await expect(panel).toHaveCount(1)
|
||||
const box = await panel.boundingBox()
|
||||
if (!box) throw new Error("New-session panel bounds are unavailable")
|
||||
|
||||
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
|
||||
const corners = await page.evaluate(
|
||||
async ({ source, points }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
points: [
|
||||
{ x: Math.floor(box.x), y: Math.floor(box.y) },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
|
||||
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_summary"
|
||||
const directory = "/workspace/summary-project"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("does not show session details on the new session screen", async ({ page }, testInfo) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "summary-project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "summary-model" },
|
||||
},
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0)
|
||||
await testInfo.attach("new-session-without-summary", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
@@ -1,621 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/WorkspacePending"
|
||||
const workspace = "C:/OpenCode/pending-workspace"
|
||||
const projectID = "proj_workspace_pending"
|
||||
const draftID = "draft_workspace_pending"
|
||||
const otherID = "ses_workspace_pending_other"
|
||||
const text = "Create the workspace, then explain the pending session."
|
||||
const followUp = "Then explain the setup scripts.\nInclude the install command."
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sessionPath = `/server/${base64Encode(server)}/session/`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
|
||||
test.use({
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1280, height: 900 },
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
})
|
||||
|
||||
for (const viewport of [
|
||||
{ name: "desktop", width: 1280, height: 900 },
|
||||
{ name: "mobile", width: 390, height: 844 },
|
||||
]) {
|
||||
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page, { untitled: true })
|
||||
const label = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tab-title"]'
|
||||
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
|
||||
)
|
||||
await expect(label).toHaveText("Session")
|
||||
const pending = await submitPending(page, mock)
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("pending-tab-title", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await label.click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
}
|
||||
|
||||
mock.events.push({
|
||||
id: "evt_generated_title",
|
||||
type: "session.renamed",
|
||||
created: Date.now(),
|
||||
location: { directory: workspace },
|
||||
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID: pending.sessionID, title: "Generated session title" },
|
||||
})
|
||||
await expect(label).toHaveText("Generated session title")
|
||||
})
|
||||
|
||||
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await draftFollowUp(page)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await editor.press("Enter")
|
||||
await editor.press("ControlOrMeta+Enter")
|
||||
await page.locator('[data-component="composer"]').dispatchEvent("submit")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(editor).toBeInViewport()
|
||||
|
||||
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(pending.title).toBeInViewport()
|
||||
const spinner = page.locator(
|
||||
viewport.name === "mobile"
|
||||
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
|
||||
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
|
||||
)
|
||||
await expect(spinner).toBeVisible()
|
||||
await testInfo.attach("creating-worktree", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
|
||||
if (viewport.name === "mobile") {
|
||||
await page.locator('[data-slot="mobile-tabs-trigger"]').click()
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
await expect(tab.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
await tab.click()
|
||||
await expect(drawer).toBeHidden()
|
||||
await page.locator("html").evaluate((element) => {
|
||||
element.setAttribute("dir", "rtl")
|
||||
})
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
|
||||
await expect(pending.message).toBeInViewport()
|
||||
await expect(pending.shimmer).toBeInViewport()
|
||||
await expect(pending.title).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
|
||||
expect(
|
||||
await page
|
||||
.locator('[data-component="session-preparing"]')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth),
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
if (viewport.name === "desktop") {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(pending.shimmer).toBeHidden()
|
||||
await expect(spinner).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(pending.title).toHaveText("Session")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep focus in this other session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
}
|
||||
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) }])
|
||||
expect(mock.creates).toEqual([
|
||||
expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } }),
|
||||
])
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
|
||||
if (viewport.name === "desktop") {
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toContainText(
|
||||
"Created workspace session",
|
||||
)
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep focus in this other session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(spinner).toHaveCount(1)
|
||||
await expect(pending.message).toHaveCount(1)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.prompts).toHaveLength(1)
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`keeps the title and message stable through worktree creation in ${direction}`, async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await page.locator('[data-component="composer-editor"]').press("ControlOrMeta+Home")
|
||||
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
|
||||
const before = await title.boundingBox()
|
||||
const messageBefore = await pending.message.boundingBox()
|
||||
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
|
||||
const message = document.querySelector<HTMLElement>('[data-component="user-message"]')
|
||||
const spinner = document.querySelector(
|
||||
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
|
||||
)
|
||||
const editor = document.querySelector('[data-component="composer-editor"]')
|
||||
frames.push({
|
||||
title: title?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? title.textContent : null,
|
||||
message: !!message?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
|
||||
spinner: !!spinner?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
|
||||
draft: editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? editor.textContent : null,
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
sample()
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(title).toHaveText("Created workspace session")
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
|
||||
const frames = await observation.evaluate((observation) => observation.stop())
|
||||
await observation.dispose()
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
frames.filter(
|
||||
(frame) =>
|
||||
!frame.message ||
|
||||
!frame.spinner ||
|
||||
frame.draft !== followUp ||
|
||||
!["Session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
),
|
||||
).toEqual([])
|
||||
const after = await title.boundingBox()
|
||||
const messageAfter = await pending.message.boundingBox()
|
||||
expect(after?.y).toBe(before?.y)
|
||||
expect(after?.height).toBe(before?.height)
|
||||
expect(messageAfter).toEqual(messageBefore)
|
||||
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
}
|
||||
|
||||
for (const failure of ["worktree", "session"]) {
|
||||
test(`preserves both inputs when ${failure} creation fails`, async ({ page }) => {
|
||||
const mock = await openDraft(page, { failSessionCreate: failure === "session" })
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve(
|
||||
failure === "worktree"
|
||||
? { status: 500, json: { message: "Worktree creation failed" } }
|
||||
: { status: 200, json: { directory: workspace } },
|
||||
)
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("preserves both inputs when the initial prompt cannot be sent", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { message: "Prompt admission failed" },
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt", "prompt"])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("restores the original draft when worktree creation fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed in the fixture" } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("retains the draft and reuses the created workspace after session creation fails", async ({ page }) => {
|
||||
const mock = await openDraft(page, { failSessionCreate: true })
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.calls).toEqual(["worktree", "session"])
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.creates).toHaveLength(2)
|
||||
expect(mock.creates[1]).toMatchObject({ location: { directory: workspace } })
|
||||
expect(mock.prompts[0]).toMatchObject({ sessionID: mock.creates[1].id, body: { text } })
|
||||
expect(mock.calls).toEqual(["worktree", "session", "session", "prompt"])
|
||||
await expect(page).toHaveURL(`${sessionPath}${mock.creates[1].id}`)
|
||||
await expect(page.locator('[data-component="user-message"] [data-slot="user-message-text"]')).toHaveText(text)
|
||||
})
|
||||
|
||||
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(tab).toHaveCount(1)
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(pending.message).toHaveCount(1)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toBeVisible()
|
||||
await expect(pending.shimmer).toContainText("Creating worktree")
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
|
||||
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("executes a selected slash command after creating its worktree", async ({ page }, testInfo) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const mock = await openDraft(page, { command: true, events: () => events.splice(0) })
|
||||
const commands: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const expanded =
|
||||
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
|
||||
await page.route("**/api/session/*/command", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/")[3]
|
||||
commands.push({ sessionID, body: route.request().postDataJSON() })
|
||||
// The server owns command expansion; the client receives the expanded inbox item.
|
||||
events.push({
|
||||
id: "evt_workspace_review",
|
||||
type: "session.inbox.enqueued",
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "msg_workspace_review",
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await route.fulfill({ status: 204, headers })
|
||||
})
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.fill("/review")
|
||||
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
|
||||
await expect(suggestion).toBeVisible()
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/review")
|
||||
const pending = await submitPending(page, mock, "/review latest commit")
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect
|
||||
.poll(() => commands)
|
||||
.toEqual([
|
||||
{
|
||||
sessionID: pending.sessionID,
|
||||
body: { name: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
|
||||
},
|
||||
])
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
|
||||
await expect(editor).toHaveText(followUp)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.prompts).toEqual([])
|
||||
await testInfo.attach("expanded-worktree-command", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("expanded-worktree-command.png") }),
|
||||
contentType: "image/png",
|
||||
})
|
||||
})
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
await editor.pressSequentially("!")
|
||||
await expect(editor).toHaveText("!")
|
||||
await expect(editor).toHaveAttribute("dir", "auto")
|
||||
await editor.fill("")
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
|
||||
await editor.press("ControlOrMeta+V")
|
||||
await expect(editor).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function openDraft(
|
||||
page: Page,
|
||||
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
|
||||
) {
|
||||
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
|
||||
const calls: string[] = []
|
||||
const worktreeRequests: Record<string, unknown>[] = []
|
||||
const creates: Record<string, unknown>[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
const project = {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "workspace-pending",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [] as string[],
|
||||
}
|
||||
const sessions = [currentSession({ id: otherID, projectID, title: "Other session" }, directory)]
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "pending-model": { id: "pending-model", name: "Pending Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "pending-model" },
|
||||
},
|
||||
sessions,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
events: options?.events ?? (() => events.splice(0)),
|
||||
})
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === "/api/worktree") {
|
||||
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
|
||||
expect(request.postDataJSON()).toMatchObject({ projectID })
|
||||
calls.push("worktree")
|
||||
worktreeRequests.push(request.postDataJSON())
|
||||
}
|
||||
if (path === "/api/session") calls.push("session")
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
// Keep the real HTTP response pending until the test has checked the preview.
|
||||
const response = await worktree.promise
|
||||
if (response.status === 200) project.sandboxes.push(workspace)
|
||||
await route.fulfill({ ...response, headers })
|
||||
},
|
||||
)
|
||||
await page.route("**/api/session", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: Record<string, unknown> = route.request().postDataJSON()
|
||||
creates.push(body)
|
||||
if (options?.failSessionCreate && creates.length === 1) {
|
||||
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
|
||||
}
|
||||
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
|
||||
const session = currentSession(
|
||||
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
|
||||
workspace,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session }, headers })
|
||||
})
|
||||
await page.route("**/api/location?**", (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: projectID, directory, canonical: directory },
|
||||
},
|
||||
headers,
|
||||
})
|
||||
})
|
||||
await page.route("**/api/agent?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
if (options?.command) {
|
||||
await page.route("**/api/command?**", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers,
|
||||
}),
|
||||
)
|
||||
}
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, otherID, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory },
|
||||
{ type: "session", sessionId: otherID, server },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, otherID, server },
|
||||
)
|
||||
await page.goto(draftPath)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
return { worktree, worktreeRequests, calls, creates, prompts, events }
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {
|
||||
await page.locator('[data-component="composer-editor"]').fill(prompt)
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
|
||||
const url = page.url()
|
||||
const sessionID = new URL(url).pathname.slice(sessionPath.length)
|
||||
const preparing = page.locator('[data-component="session-preparing"]')
|
||||
const message = page.locator('[data-component="user-message"]')
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
const title = preparing.getByRole("heading", { level: 1 })
|
||||
await expect(preparing).toBeVisible()
|
||||
await expect(title).toHaveText("Session")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
|
||||
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
|
||||
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
|
||||
await expect(shimmer).toBeVisible()
|
||||
await expect(shimmer).toContainText("Creating worktree")
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
return { url, sessionID, messageID, message, shimmer, title }
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
|
||||
import { draftHref, expectPath, provider, sessionHref } from "../utils/app"
|
||||
import type { MockAnswer } from "../utils/mock-server"
|
||||
import { openDraft, openSession, openWorktreeDraft, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/OpenCode/WorkspacePending"
|
||||
const workspace = "C:/OpenCode/pending-workspace"
|
||||
const projectID = "proj_workspace_pending"
|
||||
const draftID = "draft_workspace_pending"
|
||||
const otherID = "ses_workspace_pending_other"
|
||||
const text = "Create the workspace, then explain the pending session."
|
||||
const followUp = "Then explain the setup scripts.\nInclude the install command."
|
||||
const headers = { "access-control-allow-origin": "*" }
|
||||
const editor = (page: Page) => page.locator('[data-component="composer-editor"]')
|
||||
const submit = (page: Page) => page.locator('[data-action="composer-submit"]')
|
||||
const tabLink = (page: Page, sessionID: string) =>
|
||||
page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`)
|
||||
// A draft on the local project (C:/OpenCode/WorkspacePending) beside one other session tab.
|
||||
const pendingDraft = {
|
||||
name: "WorkspacePending",
|
||||
draftID,
|
||||
project: { id: projectID, name: "workspace-pending" },
|
||||
provider: provider({ id: "pending-model", name: "Pending Model" }),
|
||||
sessions: [{ id: otherID, title: "Other session" }],
|
||||
createdSessionTitle: "Created workspace session",
|
||||
} satisfies WorkspaceInput & { draftID: string }
|
||||
const failFirstCreate = (_: unknown, attempt: number): MockAnswer | undefined =>
|
||||
attempt === 1 ? { status: 500, body: { message: "Session creation failed in the fixture" } } : undefined
|
||||
|
||||
test.use({
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1280, height: 900 },
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
})
|
||||
|
||||
test("the new session screen does not show session details", async ({ page }) => {
|
||||
await openDraft(page, { name: "Summary" })
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("the dark new session panel exposes no lighter background at its rounded corners", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 935, height: 522 })
|
||||
await openDraft(page, { name: "PanelCorner", seed: { theme: { id: "oc-2", scheme: "dark" } } })
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
const box = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
if (!box) throw new Error("New-session panel bounds are unavailable")
|
||||
const screenshot = await page.screenshot()
|
||||
// Sample the four corner pixels, which lie outside the rounded panel.
|
||||
const corners = await page.evaluate(
|
||||
async ({ source, points }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")!
|
||||
context.drawImage(image, 0, 0)
|
||||
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
points: [
|
||||
{ x: Math.floor(box.x), y: Math.floor(box.y) },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
|
||||
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
|
||||
],
|
||||
},
|
||||
)
|
||||
expect(corners.filter(([red, green, blue, alpha]) => red > 8 || green > 8 || blue > 8 || alpha !== 255)).toEqual([])
|
||||
})
|
||||
|
||||
test("a pending worktree session shows immediately, keeps its draft, and hands off to the created session", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mock = await openWorktreeDraft(page, { ...pendingDraft, createdSessionTitle: "" })
|
||||
const label = page.locator('[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]')
|
||||
await expect(label).toHaveText("Session")
|
||||
const pending = await submitPending(page, mock)
|
||||
const spinner = tabLink(page, pending.sessionID).locator('[data-component="session-progress-indicator-v2"]')
|
||||
await expect(spinner).toBeVisible()
|
||||
await expect(label).toHaveText("Session")
|
||||
|
||||
await draftFollowUp(page)
|
||||
await expect(submit(page)).toBeDisabled()
|
||||
await editor(page).press("Enter")
|
||||
await editor(page).press("ControlOrMeta+Enter")
|
||||
await page.locator('[data-component="composer"]').dispatchEvent("submit")
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
expect(mock.worktreeRequests.map((request) => request.body)).toEqual([expect.objectContaining({ from: directory })])
|
||||
|
||||
await tabLink(page, otherID).click()
|
||||
await expectPath(page, sessionHref(otherID))
|
||||
await expect(pending.shimmer).toBeHidden()
|
||||
await expect(spinner).toBeVisible()
|
||||
await tabLink(page, pending.sessionID).click()
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
|
||||
await tabLink(page, otherID).click()
|
||||
await expectPath(page, sessionHref(otherID))
|
||||
await editor(page).fill("Keep focus in this other session")
|
||||
await expect(editor(page)).toBeFocused()
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) }])
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
await expectPath(page, sessionHref(otherID))
|
||||
await expect(editor(page)).toHaveText("Keep focus in this other session")
|
||||
await expect(editor(page)).toBeFocused()
|
||||
|
||||
await tabLink(page, pending.sessionID).click()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
await expect(label).toHaveText("Session")
|
||||
await submit(page).click()
|
||||
await expect
|
||||
.poll(() => mock.prompts)
|
||||
.toEqual([
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
|
||||
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
|
||||
])
|
||||
|
||||
await mock.push([renamed(pending.sessionID)])
|
||||
await expect(label).toHaveText("Generated session title")
|
||||
})
|
||||
|
||||
test("a pending worktree session keeps Session as its mobile title until the generated title arrives", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const mock = await openWorktreeDraft(page, { ...pendingDraft, createdSessionTitle: "" })
|
||||
const label = page.locator('[data-slot="mobile-tab-title"]')
|
||||
await expect(label).toHaveText("Session")
|
||||
const pending = await submitPending(page, mock)
|
||||
await expect(
|
||||
page.locator('[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'),
|
||||
).toBeVisible()
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await expect(label).toHaveText("Session")
|
||||
await mock.push([renamed(pending.sessionID)])
|
||||
await expect(label).toHaveText("Generated session title")
|
||||
})
|
||||
|
||||
test("the title and message stay stable through worktree creation", async ({ page }) => {
|
||||
const mock = await openWorktreeDraft(page, pendingDraft)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
await editor(page).press("ControlOrMeta+Home")
|
||||
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
|
||||
const before = await title.boundingBox()
|
||||
const messageBefore = await pending.message.boundingBox()
|
||||
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
|
||||
let frame = 0
|
||||
const visible = (element: Element | null) =>
|
||||
!!element?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true })
|
||||
const sample = () => {
|
||||
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
|
||||
const editor = document.querySelector('[data-component="composer-editor"]')
|
||||
frames.push({
|
||||
title: visible(title) ? title!.textContent : null,
|
||||
message: visible(document.querySelector('[data-component="user-message"]')),
|
||||
spinner: visible(
|
||||
document.querySelector(
|
||||
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
|
||||
),
|
||||
),
|
||||
draft: visible(editor) ? editor!.textContent : null,
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
sample()
|
||||
return {
|
||||
// A frame painted after the synchronous first sample that already shows the created session's title.
|
||||
handedOff: () => frames.slice(1).some((frame) => frame.title === "Created workspace session"),
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect(title).toHaveText("Created workspace session")
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
await expect(editor(page)).toBeFocused()
|
||||
// The DOM can reach the final state before the sampler's next frame; wait until a sample has observed it.
|
||||
await expect.poll(() => observation.evaluate((observation) => observation.handedOff())).toBe(true)
|
||||
const frames = await observation.evaluate((observation) => observation.stop())
|
||||
await observation.dispose()
|
||||
// At least one painted frame after the handoff, beyond the synchronous first sample.
|
||||
expect(frames.slice(1).some((frame) => frame.title === "Created workspace session")).toBe(true)
|
||||
expect(
|
||||
frames.filter(
|
||||
(frame) =>
|
||||
!frame.message ||
|
||||
!frame.spinner ||
|
||||
frame.draft !== followUp ||
|
||||
!["Session", "Created workspace session"].includes(frame.title ?? ""),
|
||||
),
|
||||
).toEqual([])
|
||||
const after = await title.boundingBox()
|
||||
expect(after?.y).toBe(before?.y)
|
||||
expect(after?.height).toBe(before?.height)
|
||||
expect(await pending.message.boundingBox()).toEqual(messageBefore)
|
||||
await editor(page).pressSequentially("Also: ")
|
||||
await expect(editor(page)).toHaveText(`Also: ${followUp}`)
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
|
||||
for (const row of [
|
||||
{ failure: "worktree", followUp: true, calls: ["worktree"] },
|
||||
{ failure: "session", followUp: true, calls: ["worktree", "session"] },
|
||||
{ failure: "prompt", followUp: true, calls: ["worktree", "session", "prompt", "prompt"] },
|
||||
{ failure: "worktree", followUp: false, calls: ["worktree"] },
|
||||
]) {
|
||||
test(`a ${row.failure} failure restores the draft (follow-up: ${row.followUp})`, async ({ page }) => {
|
||||
const mock = await openWorktreeDraft(page, {
|
||||
...pendingDraft,
|
||||
onSessionCreate: row.failure === "session" ? failFirstCreate : undefined,
|
||||
})
|
||||
const pending = await submitPending(page, mock)
|
||||
if (row.followUp) await draftFollowUp(page)
|
||||
if (row.failure === "prompt")
|
||||
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
|
||||
route.fulfill({ status: 500, json: { message: "Prompt admission failed" }, headers }),
|
||||
)
|
||||
|
||||
mock.worktree.resolve(
|
||||
row.failure === "worktree"
|
||||
? { status: 500, json: { message: "Worktree creation failed" } }
|
||||
: { status: 200, json: { directory: workspace } },
|
||||
)
|
||||
|
||||
if (row.failure !== "prompt") await expectPath(page, draftHref(draftID))
|
||||
await expect(editor(page)).toHaveText(row.followUp ? `${text}\n\n${followUp}` : text)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect.poll(() => mock.calls).toEqual(row.calls)
|
||||
expect(mock.prompts).toEqual([])
|
||||
if (row.followUp) return
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
await expect(tabLink(page, pending.sessionID)).toHaveCount(0)
|
||||
expect(mock.creates).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("retains the draft and reuses the created workspace after session creation fails", async ({ page }) => {
|
||||
const mock = await openWorktreeDraft(page, { ...pendingDraft, onSessionCreate: failFirstCreate })
|
||||
const pending = await submitPending(page, mock)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expectPath(page, draftHref(draftID))
|
||||
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
|
||||
await expect(editor(page)).toHaveText(text)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.calls).toEqual(["worktree", "session"])
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
await submit(page).click()
|
||||
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.creates).toHaveLength(2)
|
||||
expect(mock.creates[1]).toMatchObject({ location: { directory: workspace } })
|
||||
expect(mock.prompts[0]).toMatchObject({ sessionID: mock.creates[1]!.id, body: { text } })
|
||||
expect(mock.calls).toEqual(["worktree", "session", "session", "prompt"])
|
||||
await expectPath(page, sessionHref(String(mock.creates[1]!.id)))
|
||||
await expect(page.locator('[data-component="user-message"] [data-slot="user-message-text"]')).toHaveText(text)
|
||||
})
|
||||
|
||||
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
|
||||
const mock = await openWorktreeDraft(page, pendingDraft)
|
||||
const pending = await submitPending(page, mock)
|
||||
await draftFollowUp(page)
|
||||
const tab = tabLink(page, pending.sessionID)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
|
||||
|
||||
await expectPath(page, sessionHref(otherID))
|
||||
await expect(editor(page)).toBeEditable()
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(pending.url)
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
|
||||
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
|
||||
await expect(pending.shimmer).toContainText("Creating worktree")
|
||||
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
|
||||
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
|
||||
|
||||
await expectPath(page, draftHref(draftID))
|
||||
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
|
||||
await expect(editor(page)).toHaveText(`${text}\n\n${followUp}`)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-titlebar-tab-link][href="${draftHref(draftID)}"]`)).toHaveCount(1)
|
||||
await expect(tab).toHaveCount(0)
|
||||
await expect(pending.message).toHaveCount(0)
|
||||
expect(mock.calls).toEqual(["worktree"])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("executes a selected slash command after creating its worktree", async ({ page }) => {
|
||||
const commands: { sessionID: string; body: unknown }[] = []
|
||||
const mock = await openWorktreeDraft(page, {
|
||||
...pendingDraft,
|
||||
commands: [{ name: "review", description: "Review changes" }],
|
||||
onCommand: (input) => commands.push(input),
|
||||
})
|
||||
const expanded =
|
||||
"Review the latest commit for correctness and regressions. Check the relevant tests and report actionable findings."
|
||||
await editor(page).fill("/review")
|
||||
const suggestion = page.getByRole("button", { name: "/review Review changes", exact: true })
|
||||
await suggestion.click()
|
||||
await expect(editor(page)).toHaveText("/review")
|
||||
const pending = await submitPending(page, mock, "/review latest commit")
|
||||
await draftFollowUp(page)
|
||||
|
||||
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
|
||||
|
||||
await expect
|
||||
.poll(() => commands)
|
||||
.toEqual([
|
||||
{
|
||||
sessionID: pending.sessionID,
|
||||
body: { name: "review", text: "latest commit", files: [], agents: [], skills: [], delivery: "steer" },
|
||||
},
|
||||
])
|
||||
// The server owns command expansion; the client receives the expanded inbox item.
|
||||
await mock.push([
|
||||
{
|
||||
id: "evt_workspace_review",
|
||||
type: "session.inbox.enqueued",
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: pending.sessionID,
|
||||
inboxID: "msg_workspace_review",
|
||||
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
|
||||
},
|
||||
} as OpenCodeEvent,
|
||||
])
|
||||
await expect(pending.shimmer).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText(expanded)
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
const accentRoot = "C:/OpenCode/WorkspaceAccent"
|
||||
const accentWorkspace = `${accentRoot}/.worktrees/feature`
|
||||
|
||||
for (const row of [
|
||||
{ name: "a managed worktree", directory: accentWorkspace, accent: true },
|
||||
{
|
||||
name: "the main root written with Windows case and separators",
|
||||
directory: "c:\\OPENCODE\\workspaceaccent\\",
|
||||
accent: false,
|
||||
},
|
||||
]) {
|
||||
test(`workspace accent colors the user message only for ${row.name}`, async ({ page }) => {
|
||||
const inventory: WorktreeDirectory[] = [{ directory: accentRoot }, { directory: accentWorkspace, strategy: "git" }]
|
||||
const listed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("projectID") === "proj_workspaceaccent" &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
const view = await openSession(page, {
|
||||
name: "WorkspaceAccent",
|
||||
// The mock answers every location with the session directory while the project stays rooted at main.
|
||||
directory: row.directory,
|
||||
project: { worktree: accentRoot, canonical: accentRoot },
|
||||
provider: provider({ id: "accent-model", name: "Accent Model" }),
|
||||
sessions: [
|
||||
{
|
||||
id: "ses_workspace_accent",
|
||||
title: "Workspace accent",
|
||||
model: { id: "accent-model", providerID: "opencode" },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{ id: "msg_workspace_accent", type: "user", text: "Check this fixture workspace.", time: { created: 1 } },
|
||||
],
|
||||
}),
|
||||
worktrees: () => inventory,
|
||||
seed: { theme: { id: "oc-2", scheme: "light" } },
|
||||
})
|
||||
expect((await listed).ok()).toBe(true)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const send = composer.getByRole("button", { name: "Send", exact: true })
|
||||
await expect(composer.locator('[data-action="composer-model"]')).toHaveText("Accent Model")
|
||||
await view.editor.fill("Inspect this fixture workspace.")
|
||||
await expect(send).toBeEnabled()
|
||||
await expectBackground(send, "icon-button-contrast")
|
||||
await send.hover()
|
||||
await expectBackground(send, "icon-button-contrast")
|
||||
await composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(send).toBeFocused()
|
||||
await expectBackground(send, "icon-button-contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectToken(message, "background-color", row.accent ? "--v2-background-bg-accent" : "--v2-blue-100")
|
||||
await expectToken(message, "color", row.accent ? "--v2-text-text-contrast" : "--v2-blue-700")
|
||||
if (!row.accent) return
|
||||
|
||||
const url = page.url()
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === "/api/worktree" && response.request().method() === "GET",
|
||||
)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID: "proj_workspaceaccent" },
|
||||
},
|
||||
] as OpenCodeEvent[])
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.editor).toHaveText("Inspect this fixture workspace.")
|
||||
await expectBackground(send, "icon-button-contrast")
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: "ses_workspace_accent", seq: 1, version: 1 },
|
||||
data: { sessionID: "ses_workspace_accent" },
|
||||
},
|
||||
] as OpenCodeEvent[])
|
||||
await view.editor.fill("")
|
||||
const stop = composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "icon-button-contrast")
|
||||
})
|
||||
}
|
||||
|
||||
function renamed(sessionID: string) {
|
||||
return {
|
||||
id: "evt_generated_title",
|
||||
type: "session.renamed",
|
||||
created: Date.now(),
|
||||
location: { directory: workspace },
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, title: "Generated session title" },
|
||||
} as OpenCodeEvent
|
||||
}
|
||||
|
||||
async function draftFollowUp(page: Page) {
|
||||
await editor(page).pressSequentially("!")
|
||||
await expect(editor(page)).toHaveText("!")
|
||||
await expect(editor(page)).toHaveAttribute("dir", "auto")
|
||||
await editor(page).fill("")
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
|
||||
await editor(page).press("ControlOrMeta+V")
|
||||
await expect(editor(page)).toHaveText(followUp)
|
||||
}
|
||||
|
||||
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openWorktreeDraft>>, prompt = text) {
|
||||
await editor(page).fill(prompt)
|
||||
await expect(submit(page)).toBeEnabled()
|
||||
await submit(page).click()
|
||||
const sessionPath = sessionHref("")
|
||||
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
|
||||
const url = page.url()
|
||||
const sessionID = new URL(url).pathname.slice(sessionPath.length)
|
||||
const preparing = page.locator('[data-component="session-preparing"]')
|
||||
const message = page.locator('[data-component="user-message"]')
|
||||
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
|
||||
await expect(preparing.getByRole("heading", { level: 1 })).toHaveText("Session")
|
||||
await expect(editor(page)).toBeEditable()
|
||||
await expect(submit(page)).toBeDisabled()
|
||||
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
|
||||
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(prompt)
|
||||
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
|
||||
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
|
||||
await expect(shimmer).toContainText("Creating worktree")
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree"])
|
||||
// Worktrees are created by project, without a location.
|
||||
expect(mock.worktreeRequests.map((request) => request.url.searchParams.has("location[directory]"))).toEqual([false])
|
||||
expect(mock.worktreeRequests.map((request) => request.body)).toEqual([expect.objectContaining({ projectID })])
|
||||
expect(mock.creates).toEqual([])
|
||||
expect(mock.prompts).toEqual([])
|
||||
return { url, sessionID, messageID, message, shimmer }
|
||||
}
|
||||
|
||||
// Resolves semantic colors through the browser, without reproducing the button's gradient.
|
||||
async function expectBackground(element: Locator, token: string) {
|
||||
const color = await element.evaluate((element, token) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
probe.style.backgroundColor = `var(--v2-background-bg-${token})`
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).backgroundColor
|
||||
probe.remove()
|
||||
return color
|
||||
}, token)
|
||||
await expect(element).toHaveCSS("background-image", new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
||||
}
|
||||
|
||||
async function expectToken(element: Locator, property: string, token: string) {
|
||||
const color = await element.evaluate((element, token) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.hidden = true
|
||||
probe.style.color = `var(${token})`
|
||||
element.append(probe)
|
||||
const color = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return color
|
||||
}, token)
|
||||
await expect(element).toHaveCSS(property, color)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/OpenFileExpand"
|
||||
const projectID = "proj_open_file_expand"
|
||||
const sessionID = "ses_open_file_expand"
|
||||
const title = "Open file expand"
|
||||
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
|
||||
const longPath = `frontend/${longFilename}`
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "open-file-expand",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: longPath,
|
||||
before: "",
|
||||
after: "export const added = true\n",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "added",
|
||||
patch: "@@ -0,0 +1 @@\n+export const added = true\n",
|
||||
},
|
||||
],
|
||||
fileList: (path) => {
|
||||
if (path === "frontend\\" || path === "frontend") {
|
||||
return [
|
||||
{
|
||||
name: "app.ts",
|
||||
path: "frontend\\app.ts",
|
||||
absolute: `${directory}/frontend/app.ts`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
name: longFilename,
|
||||
path: `frontend\\${longFilename}`,
|
||||
absolute: `${directory}/${longPath}`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (path) return []
|
||||
return [
|
||||
{
|
||||
name: "",
|
||||
path: "frontend\\",
|
||||
absolute: `${directory}/frontend`,
|
||||
type: "directory" as const,
|
||||
ignored: false,
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
path: "README.md",
|
||||
absolute: `${directory}/README.md`,
|
||||
type: "file" as const,
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionID, tabKey }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:review-panel-v2",
|
||||
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
|
||||
)
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
|
||||
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||
await expect(sidebar).toBeVisible()
|
||||
|
||||
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||
await expect(frontendRow).toBeVisible()
|
||||
await expect(frontendRow.getByText("frontend", { exact: true })).toBeVisible()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||
await frontendRow.click()
|
||||
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
const longRow = panel.getByRole("button", { name: longFilename })
|
||||
await expect(longRow).toBeVisible()
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
|
||||
expect(
|
||||
await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow),
|
||||
).toBe("clip")
|
||||
expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual(
|
||||
await viewport.evaluate((element) => element.clientWidth),
|
||||
)
|
||||
await expect
|
||||
.poll(() =>
|
||||
panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => {
|
||||
const widths = rows.map((row) => row.getBoundingClientRect().width)
|
||||
return Math.max(...widths) - Math.min(...widths)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px")
|
||||
const status = longRow.locator('[data-slot="file-tree-v2-change"]')
|
||||
await expect(status).toHaveText("A")
|
||||
const statusBox = await status.boundingBox()
|
||||
if (!statusBox) throw new Error("File status has no bounding box")
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
if (!viewportBox) throw new Error("File tree viewport has no bounding box")
|
||||
expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24)
|
||||
|
||||
await viewport.hover()
|
||||
const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]')
|
||||
await expect(horizontalThumb).toHaveCSS("opacity", "1")
|
||||
await page.mouse.wheel(1_000, 0)
|
||||
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0)
|
||||
await expect(horizontalThumb).toHaveAttribute("data-visible", "true")
|
||||
await expect
|
||||
.poll(() =>
|
||||
status.evaluate((element) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
return viewport.right - element.getBoundingClientRect().right
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(24)
|
||||
|
||||
const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft))
|
||||
const thumbBox = await horizontalThumb.boundingBox()
|
||||
if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box")
|
||||
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag)
|
||||
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await filter.fill(longFilename)
|
||||
const filteredRow = panel.getByRole("option", { name: longFilename })
|
||||
await expect(filteredRow).toBeVisible()
|
||||
const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]')
|
||||
await expect(filteredStatus).toHaveText("A")
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
|
||||
|
||||
await viewport.evaluate((element) => {
|
||||
element.setAttribute("dir", "rtl")
|
||||
element.scrollLeft = 0
|
||||
element.dispatchEvent(new Event("scroll"))
|
||||
})
|
||||
await expect
|
||||
.poll(() =>
|
||||
filteredStatus.evaluate((element) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
return element.getBoundingClientRect().left - viewport.left
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(24)
|
||||
const rtlThumbBox = await horizontalThumb.boundingBox()
|
||||
if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box")
|
||||
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0)
|
||||
await viewport.evaluate((element) => {
|
||||
element.removeAttribute("dir")
|
||||
element.scrollLeft = 0
|
||||
element.dispatchEvent(new Event("scroll"))
|
||||
})
|
||||
|
||||
await filter.fill("")
|
||||
|
||||
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||
await expect(appRow).toBeVisible()
|
||||
await appRow.click()
|
||||
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../utils/timeline"
|
||||
|
||||
test.use({
|
||||
deviceScaleFactor: 2,
|
||||
@@ -8,61 +8,59 @@ test.use({
|
||||
launchOptions: { args: ["--force-device-scale-factor=2"] },
|
||||
})
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`patch file borders retain a full CSS pixel at ${width}px on high-density displays`, async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
test("patch file borders retain a full CSS pixel at 390px on high-density displays", async ({ page }) => {
|
||||
const file = `src/${"long-directory/".repeat(12)}patch-border.ts`
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
"prt_border_patch",
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update file" },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(file, file, "const value = 1\n", "const value = 2\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
reducedMotion: true,
|
||||
viewport: { width: 390, height: 900 },
|
||||
})
|
||||
}
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger).toHaveCSS("height", "32px")
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
await expect(trigger).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
|
||||
const box = await trigger.boundingBox()
|
||||
await trigger.hover()
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
await page.mouse.move(0, 0)
|
||||
expect(await trigger.boundingBox()).toEqual(box)
|
||||
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
const content = patch.locator('[data-slot="accordion-content"]')
|
||||
await expect(content).toBeVisible()
|
||||
for (const side of ["left", "right", "bottom"]) {
|
||||
await expect(content).toHaveCSS(`border-${side}-width`, "1px")
|
||||
}
|
||||
await expect(content).toHaveCSS("border-top-width", "0px")
|
||||
await trigger.press("Enter")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
Loaded 100 of 1640 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user