Compare commits
385
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfc72838d7 | ||
|
|
4246368a88 | ||
|
|
548d9ac726 | ||
|
|
a60fd89d1e | ||
|
|
d25a7fbb2c | ||
|
|
da0f81d36f | ||
|
|
627159acac | ||
|
|
f44aa02e26 | ||
|
|
1ca9804604 | ||
|
|
ddad871b46 | ||
|
|
d215188e4c | ||
|
|
f73ff781e7 | ||
|
|
68a9a47976 | ||
|
|
fb92bd470c | ||
|
|
02f8a24e23 | ||
|
|
467e5689ec | ||
|
|
fba752a501 | ||
|
|
87b2a9d749 | ||
|
|
8df7ccc304 | ||
|
|
2c36bf9490 | ||
|
|
bddf830083 | ||
|
|
50c1d0a43b | ||
|
|
60b8041ebb | ||
|
|
3b2a2c461d | ||
|
|
6706358a6e | ||
|
|
f6409759e5 | ||
|
|
f9d99f044d | ||
|
|
bbd5faf5cd | ||
|
|
aeb7d99d20 | ||
|
|
3695057bee | ||
|
|
4ed3afea84 | ||
|
|
3cf7c7536b | ||
|
|
85674f4bfd | ||
|
|
f2525a63c9 | ||
|
|
8c42d391f5 | ||
|
|
7f9bf91073 | ||
|
|
6ce5c01b1a | ||
|
|
a53fae1511 | ||
|
|
4626458175 | ||
|
|
9a5178e4ac | ||
|
|
68384613be | ||
|
|
4f967d5bc0 | ||
|
|
ff60859e36 | ||
|
|
020c47a055 | ||
|
|
64171db173 | ||
|
|
ad265797ab | ||
|
|
b1312a3181 | ||
|
|
a8f9f6b705 | ||
|
|
d312c677c5 | ||
|
|
5b60e51c9f | ||
|
|
7cbe1627ec | ||
|
|
d6840868d4 | ||
|
|
9b2648dd57 | ||
|
|
f954854232 | ||
|
|
6a99079012 | ||
|
|
0a8b6298cd | ||
|
|
f40209bdfb | ||
|
|
a2cb4909da | ||
|
|
7a05ba47d1 | ||
|
|
36745caa2a | ||
|
|
c2403d0f15 | ||
|
|
34e2429c49 | ||
|
|
10ba68c772 | ||
|
|
e8471256f2 | ||
|
|
43b37346b6 | ||
|
|
d199648aeb | ||
|
|
a06f40297b | ||
|
|
59c0fc28ee | ||
|
|
b22add292c | ||
|
|
67aaecacac | ||
|
|
29c202e6ab | ||
|
|
dcbf11f41a | ||
|
|
14ccff4037 | ||
|
|
5b8b874732 | ||
|
|
1d81c0266c | ||
|
|
913120759a | ||
|
|
7a6ce05d09 | ||
|
|
1dc69359d5 | ||
|
|
329fcb040b | ||
|
|
bf50d1c028 | ||
|
|
b8801dbd22 | ||
|
|
f7c6943817 | ||
|
|
91fe4db27c | ||
|
|
21d7a85e76 | ||
|
|
663e798e76 | ||
|
|
5bc2d2498d | ||
|
|
c22e34853d | ||
|
|
6825b0bbc7 | ||
|
|
3644581b55 | ||
|
|
79cc15335e | ||
|
|
ca6200121b | ||
|
|
7239b38b7f | ||
|
|
9ae8dc2d01 | ||
|
|
7164662be2 | ||
|
|
94f71f59a3 | ||
|
|
3eb6508a64 | ||
|
|
6fdb8ab90d | ||
|
|
321bf1f8e1 | ||
|
|
62bd023086 | ||
|
|
cb1a50055c | ||
|
|
65e3348232 | ||
|
|
a6b9f0dac1 | ||
|
|
34f5bdbc99 | ||
|
|
0b4fe14b0a | ||
|
|
7230cd2683 | ||
|
|
a915fe74be | ||
|
|
26d35583c5 | ||
|
|
ae17b416b8 | ||
|
|
8ffadde85c | ||
|
|
3c0ad70653 | ||
|
|
264418c0cd | ||
|
|
fa2c69f09c | ||
|
|
113304a058 | ||
|
|
8c4d49c2bc | ||
|
|
2aa6110c6e | ||
|
|
8b9b9ad31e | ||
|
|
3729fd5706 | ||
|
|
74b14a2d4e | ||
|
|
cdb951ec2f | ||
|
|
fc01cad2b8 | ||
|
|
c1ddc0ea2d | ||
|
|
319b7655b7 | ||
|
|
824c12c01a | ||
|
|
17b2900884 | ||
|
|
003010bdb6 | ||
|
|
82a4292934 | ||
|
|
eea4253d67 | ||
|
|
1eacc3c339 | ||
|
|
1a509d62a0 | ||
|
|
4c4eef46f1 | ||
|
|
d62ec7776e | ||
|
|
cb1e5d9e41 | ||
|
|
ca5f086759 | ||
|
|
57c40eb7c2 | ||
|
|
63035f977f | ||
|
|
514d2a36bc | ||
|
|
0b6fd5f612 | ||
|
|
029e7135b7 | ||
|
|
c43591f8a2 | ||
|
|
a2c22714cb | ||
|
|
312f10f797 | ||
|
|
d1f05b0f3a | ||
|
|
ccb0b320e1 | ||
|
|
5ee7edaf9e | ||
|
|
27190635ea | ||
|
|
2e340d976f | ||
|
|
fe4dfb9f6f | ||
|
|
5e3dc80999 | ||
|
|
d84cc33742 | ||
|
|
c92c462148 | ||
|
|
9ca06e0336 | ||
|
|
3b523b32f5 | ||
|
|
ba3600a515 | ||
|
|
03ce2e5288 | ||
|
|
87e23abb10 | ||
|
|
2868000c20 | ||
|
|
f38f415bf0 | ||
|
|
4341ab838e | ||
|
|
cd004cf0b2 | ||
|
|
19ae8c88b0 | ||
|
|
3dd09147c2 | ||
|
|
9581bf0670 | ||
|
|
af8aff3788 | ||
|
|
2a8a59ded9 | ||
|
|
5917ac2162 | ||
|
|
b6af4d0dc6 | ||
|
|
577139c626 | ||
|
|
c5fb6281f0 | ||
|
|
f99812443c | ||
|
|
b898c6d0ea | ||
|
|
9e7045eaec | ||
|
|
a17ac02061 | ||
|
|
57f9397677 | ||
|
|
a4c686025c | ||
|
|
face879100 | ||
|
|
605559b165 | ||
|
|
5cd4c6eb22 | ||
|
|
40358d60a0 | ||
|
|
96c1c0363d | ||
|
|
33819932ec | ||
|
|
5d6fe01465 | ||
|
|
cf27a73397 | ||
|
|
f2c492a8e6 | ||
|
|
0556774097 | ||
|
|
d9d5a0615e | ||
|
|
d72ddd71fa | ||
|
|
fb26308bc7 | ||
|
|
b41fa8e318 | ||
|
|
57b2e64345 | ||
|
|
346b3e1b8d | ||
|
|
b139bc2ef3 | ||
|
|
378b8ca241 | ||
|
|
f63bdc8e08 | ||
|
|
ce26120205 | ||
|
|
d2d5d84d1e | ||
|
|
847f1d99c9 | ||
|
|
59d08683ea | ||
|
|
f7514d9eca | ||
|
|
180ded6a27 | ||
|
|
bf601628db | ||
|
|
00e39d2114 | ||
|
|
46b74e0873 | ||
|
|
aedc4e964f | ||
|
|
e83404367c | ||
|
|
42206da1f8 | ||
|
|
44f38193c0 | ||
|
|
9a6b455bfe | ||
|
|
8063e0b5c6 | ||
|
|
157c5d77f8 | ||
|
|
ce19c051be | ||
|
|
91786d2fc1 | ||
|
|
eca11ca71a | ||
|
|
17bd16667c | ||
|
|
16c60c9ee7 | ||
|
|
0970b102e1 | ||
|
|
04074d3f4a | ||
|
|
b16ee08fd5 | ||
|
|
98874a09f7 | ||
|
|
877be7e8e0 | ||
|
|
eac50f9151 | ||
|
|
1a902b291c | ||
|
|
bbe4a04f9f | ||
|
|
b2f621b897 | ||
|
|
7202b3a325 | ||
|
|
35b44df94a | ||
|
|
10441efad1 | ||
|
|
3199383eef | ||
|
|
9f54115c5d | ||
|
|
2ecc6ae65f | ||
|
|
02b32e1ba7 | ||
|
|
34b9792654 | ||
|
|
537160dbc0 | ||
|
|
b0600664ab | ||
|
|
581a7692ff | ||
|
|
f73e4d5d31 | ||
|
|
a7743e6467 | ||
|
|
5d3dba666c | ||
|
|
bd53b651a3 | ||
|
|
46da801f30 | ||
|
|
58a99916bb | ||
|
|
c29392d085 | ||
|
|
46f243fea7 | ||
|
|
847fc9d268 | ||
|
|
489f57974d | ||
|
|
3fc3974cbc | ||
|
|
ca57248246 | ||
|
|
ee23043d64 | ||
|
|
9c1c061b84 | ||
|
|
d82b163e56 | ||
|
|
cd8e8a9928 | ||
|
|
8bdcc22541 | ||
|
|
2bdd279467 | ||
|
|
51535d8ef3 | ||
|
|
38f8714c09 | ||
|
|
4961d72c0f | ||
|
|
00cb8839ae | ||
|
|
689b1a4b3a | ||
|
|
d98be39344 | ||
|
|
039c60170d | ||
|
|
cd87d4f9d3 | ||
|
|
988c9894f2 | ||
|
|
ae614d919f | ||
|
|
65cde7f494 | ||
|
|
98325dcdc6 | ||
|
|
0788a535e2 | ||
|
|
b7fab49b64 | ||
|
|
463318486f | ||
|
|
7afb517a1a | ||
|
|
c589724729 | ||
|
|
9385714373 | ||
|
|
c90fc6a486 | ||
|
|
bc1840b196 | ||
|
|
095aeba0a7 | ||
|
|
e945436b6f | ||
|
|
6bfa82de65 | ||
|
|
d83fe4b540 | ||
|
|
81bdffc81c | ||
|
|
2549a38a71 | ||
|
|
5d48e7bd44 | ||
|
|
ec8b9810b4 | ||
|
|
65318a80f7 | ||
|
|
6a5aae9a84 | ||
|
|
1f94c48bdd | ||
|
|
01c5eb679c | ||
|
|
41612b3dbe | ||
|
|
c2d2ca3522 | ||
|
|
3a1ec27feb | ||
|
|
3c96bf8468 | ||
|
|
3ea6413407 | ||
|
|
885df8eb54 | ||
|
|
f4975ef32a | ||
|
|
37883a9f3a | ||
|
|
3c31d04666 | ||
|
|
e64548fb4d | ||
|
|
31f6f43cfc | ||
|
|
090ad8290e | ||
|
|
d1258ac19c | ||
|
|
48c1b6b338 | ||
|
|
40e4cd27a1 | ||
|
|
5a6d10cd53 | ||
|
|
527b51477d | ||
|
|
535343bf56 | ||
|
|
4394e42615 | ||
|
|
2e4c43c1cf | ||
|
|
965c751522 | ||
|
|
24bdd3c9fb | ||
|
|
01f0319192 | ||
|
|
517e6c9aa4 | ||
|
|
a4a9ea4ab0 | ||
|
|
eaa272ef7f | ||
|
|
70b636a360 | ||
|
|
a8fd0159be | ||
|
|
342436dfc4 | ||
|
|
77a462c930 | ||
|
|
9965d385de | ||
|
|
f0f1e51c5c | ||
|
|
4712c18a58 | ||
|
|
9e156ea168 | ||
|
|
68f4aa220e | ||
|
|
3a0e00dd7f | ||
|
|
66b4e5e020 | ||
|
|
8b8d4fa066 | ||
|
|
6253ef0c27 | ||
|
|
c6ebc7ff7c | ||
|
|
985663620f | ||
|
|
c796b9a19e | ||
|
|
6ea108a03b | ||
|
|
280eb16e77 | ||
|
|
930e94a3ea | ||
|
|
629e866ff0 | ||
|
|
c08fa5675f | ||
|
|
cc50b778eb | ||
|
|
00fa68b3a7 | ||
|
|
288eb044cb | ||
|
|
59ca4543d8 | ||
|
|
650d0dbe54 | ||
|
|
a5ec741cff | ||
|
|
fff98636f7 | ||
|
|
c72642dd35 | ||
|
|
f2d4ced8ea | ||
|
|
ae7e2eb3fb | ||
|
|
a32ffaba35 | ||
|
|
a4e75a0794 | ||
|
|
35350b1d25 | ||
|
|
263dcf75b5 | ||
|
|
7994dce0f2 | ||
|
|
fbfa148e4e | ||
|
|
9d57f21f9f | ||
|
|
3deee3a02b | ||
|
|
2002f08f2e | ||
|
|
c307505f8b | ||
|
|
6359d00fb4 | ||
|
|
b969066a20 | ||
|
|
500dcfc586 | ||
|
|
7b8dc8065e | ||
|
|
e89527c9f0 | ||
|
|
aa2239d5de | ||
|
|
8daeacc989 | ||
|
|
81d3ac3bf0 | ||
|
|
eb6f1dada8 | ||
|
|
8e9e79d276 | ||
|
|
38014fe448 | ||
|
|
8942fc21aa | ||
|
|
7f45943a9e | ||
|
|
6e1400fc45 | ||
|
|
bf26c08d51 | ||
|
|
29f7dc073b | ||
|
|
5e1b513527 | ||
|
|
f549fde874 | ||
|
|
6dfb30448c | ||
|
|
b5b5f7e019 | ||
|
|
ae7b49b034 | ||
|
|
f151c660b1 | ||
|
|
c3ef69c866 | ||
|
|
363891126c | ||
|
|
1989704abe | ||
|
|
f0a9ebfed4 | ||
|
|
7e32f80d82 | ||
|
|
966d9cfa41 | ||
|
|
92e820fdc8 | ||
|
|
c4b3971548 | ||
|
|
3faabdadb7 | ||
|
|
93a139315c | ||
|
|
10ca1ace6b | ||
|
|
c3dfd08ba8 |
@@ -25,7 +25,9 @@ kommander
|
|||||||
-opencodeengineer bot that spams issues
|
-opencodeengineer bot that spams issues
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
-ricardo-m-l
|
||||||
-robinmordasiewicz
|
-robinmordasiewicz
|
||||||
|
simonklee
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-toastythebot
|
-toastythebot
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -421,7 +422,6 @@ jobs:
|
|||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
platform_flag: --linux
|
platform_flag: --linux
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
# if: github.ref_name == 'beta'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
@@ -547,6 +547,7 @@ jobs:
|
|||||||
- sign-cli-windows
|
- sign-cli-windows
|
||||||
- build-tauri
|
- build-tauri
|
||||||
- build-electron
|
- build-electron
|
||||||
|
if: always() && !failure() && !cancelled()
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
@@ -37,6 +41,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
@@ -45,8 +54,39 @@ jobs:
|
|||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
|
- name: Cache Turbo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||||
|
turbo-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test
|
run: bun turbo test:ci
|
||||||
|
env:
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Publish unit reports
|
||||||
|
if: always()
|
||||||
|
uses: mikepenz/action-junit-report@v6
|
||||||
|
with:
|
||||||
|
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||||
|
check_name: "unit results (${{ matrix.settings.name }})"
|
||||||
|
detailed_summary: true
|
||||||
|
include_time_in_summary: true
|
||||||
|
fail_on_failure: false
|
||||||
|
|
||||||
|
- name: Upload unit artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
path: packages/*/.artifacts/unit/junit.xml
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
@@ -70,13 +110,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Read Playwright version
|
- name: Read Playwright version
|
||||||
id: playwright-version
|
id: playwright-version
|
||||||
run: |
|
run: |
|
||||||
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
@@ -100,15 +145,17 @@ jobs:
|
|||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
if: failure()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
path: |
|
path: |
|
||||||
|
packages/app/e2e/junit-*.xml
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ plans
|
|||||||
package.json
|
package.json
|
||||||
bun.lock
|
bun.lock
|
||||||
.gitignore
|
.gitignore
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
references/
|
||||||
|
|||||||
@@ -653,23 +653,30 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
|||||||
const skin = look(ctx.theme.current)
|
const skin = look(ctx.theme.current)
|
||||||
type Prompt = (props: {
|
type Prompt = (props: {
|
||||||
workspaceID?: string
|
workspaceID?: string
|
||||||
|
visible?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onSubmit?: () => void
|
||||||
hint?: JSX.Element
|
hint?: JSX.Element
|
||||||
|
right?: JSX.Element
|
||||||
|
showPlaceholder?: boolean
|
||||||
placeholders?: {
|
placeholders?: {
|
||||||
normal?: string[]
|
normal?: string[]
|
||||||
shell?: string[]
|
shell?: string[]
|
||||||
}
|
}
|
||||||
}) => JSX.Element
|
}) => JSX.Element
|
||||||
if (!("Prompt" in api.ui)) return null
|
type Slot = (
|
||||||
const view = api.ui.Prompt
|
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
|
||||||
if (typeof view !== "function") return null
|
) => JSX.Element | null
|
||||||
const Prompt = view as Prompt
|
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
|
||||||
|
const Prompt = ui.Prompt
|
||||||
|
const Slot = ui.Slot
|
||||||
const normal = [
|
const normal = [
|
||||||
`[SMOKE] route check for ${input.label}`,
|
`[SMOKE] route check for ${input.label}`,
|
||||||
"[SMOKE] confirm home_prompt slot override",
|
"[SMOKE] confirm home_prompt slot override",
|
||||||
"[SMOKE] verify api.ui.Prompt rendering",
|
"[SMOKE] verify prompt-right slot passthrough",
|
||||||
]
|
]
|
||||||
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
||||||
const Hint = (
|
const hint = (
|
||||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||||
<text fg={skin.muted}>
|
<text fg={skin.muted}>
|
||||||
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
||||||
@@ -677,7 +684,46 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
|
return (
|
||||||
|
<Prompt
|
||||||
|
workspaceID={value.workspace_id}
|
||||||
|
hint={hint}
|
||||||
|
right={
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
|
||||||
|
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
placeholders={{ normal, shell }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
home_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const id = value.workspace_id?.slice(0, 8) ?? "none"
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
session_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
smoke_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
|
||||||
|
const label = typeof value.label === "string" ? value.label : input.label
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
home_bottom(ctx) {
|
home_bottom(ctx) {
|
||||||
const skin = look(ctx.theme.current)
|
const skin = look(ctx.theme.current)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: effect
|
||||||
|
description: Answer questions about the Effect framework
|
||||||
|
---
|
||||||
|
|
||||||
|
# Effect
|
||||||
|
|
||||||
|
This codebase uses Effect, a framework for writing typescript.
|
||||||
|
|
||||||
|
## How to Answer Effect Questions
|
||||||
|
|
||||||
|
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
|
||||||
|
`.opencode/references/effect-smol` in this project NOT the skill folder.
|
||||||
|
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
|
||||||
|
3. Provide responses based on the actual Effect source code and documentation
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Always use the explore agent with the cloned repository when answering Effect-related questions
|
||||||
|
- Reference specific files and patterns found in the Effect codebase
|
||||||
|
- Do not answer from memory - always verify against the source
|
||||||
@@ -116,8 +116,8 @@
|
|||||||
"light": "nord5"
|
"light": "nord5"
|
||||||
},
|
},
|
||||||
"diffLineNumber": {
|
"diffLineNumber": {
|
||||||
"dark": "nord2",
|
"dark": "#abafb7",
|
||||||
"light": "nord4"
|
"light": "textMuted"
|
||||||
},
|
},
|
||||||
"diffAddedLineNumberBg": {
|
"diffAddedLineNumberBg": {
|
||||||
"dark": "#3B4252",
|
"dark": "#3B4252",
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
|
|||||||
appliesToProducts: [zenLiteProduct.id],
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
duration: "once",
|
duration: "once",
|
||||||
})
|
})
|
||||||
|
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
|
||||||
|
name: "First month 100% off",
|
||||||
|
percentOff: 100,
|
||||||
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
|
duration: "once",
|
||||||
|
})
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
@@ -124,6 +130,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
priceInr: 92900,
|
priceInr: 92900,
|
||||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
|
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -229,6 +236,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SALESFORCE_INSTANCE_URL,
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
|
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
|
"x86_64-linux": "sha256-3VYF84QGROFpwBCYDEWHDpRFkSHwmiVWQJR81/bqjYo=",
|
||||||
"aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
|
"aarch64-linux": "sha256-k12n4GqrjBqKqBvLjzXQhDxbc8ZMZ/1TenDp2pKh888=",
|
||||||
"aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
|
"aarch64-darwin": "sha256-OCRX1VC5SJmrXk7whl6bsdmlRwjARGw+4RSk8c59N10=",
|
||||||
"x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
|
"x86_64-darwin": "sha256-l+g/cMREarOVIK3a01+csC3Mk3ZfMVWAiAosSA5/U6Y="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-18
@@ -7,7 +7,6 @@
|
|||||||
sysctl,
|
sysctl,
|
||||||
makeBinaryWrapper,
|
makeBinaryWrapper,
|
||||||
models-dev,
|
models-dev,
|
||||||
ripgrep,
|
|
||||||
installShellFiles,
|
installShellFiles,
|
||||||
versionCheckHook,
|
versionCheckHook,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
@@ -52,25 +51,25 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase = ''
|
installPhase =
|
||||||
runHook preInstall
|
''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode
|
||||||
install -Dm644 schema.json $out/share/opencode/schema.json
|
install -Dm644 schema.json $out/share/opencode/schema.json
|
||||||
|
''
|
||||||
wrapProgram $out/bin/opencode \
|
# bun runs sysctl to detect if dunning on rosetta2
|
||||||
--prefix PATH : ${
|
+ lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
|
||||||
lib.makeBinPath (
|
wrapProgram $out/bin/opencode \
|
||||||
[
|
--prefix PATH : ${
|
||||||
ripgrep
|
lib.makeBinPath [
|
||||||
|
sysctl
|
||||||
]
|
]
|
||||||
# bun runs sysctl to detect if dunning on rosetta2
|
}
|
||||||
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
''
|
||||||
)
|
+ ''
|
||||||
}
|
runHook postInstall
|
||||||
|
'';
|
||||||
runHook postInstall
|
|
||||||
'';
|
|
||||||
|
|
||||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||||
# trick yargs into also generating zsh completions
|
# trick yargs into also generating zsh completions
|
||||||
|
|||||||
+13
-8
@@ -12,6 +12,7 @@
|
|||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
@@ -25,8 +26,10 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/platform-node": "4.0.0-beta.43",
|
"@effect/opentelemetry": "4.0.0-beta.48",
|
||||||
|
"@effect/platform-node": "4.0.0-beta.48",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -45,8 +48,9 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.43",
|
"effect": "4.0.0-beta.48",
|
||||||
"ai": "6.0.138",
|
"ai": "6.0.158",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
@@ -54,7 +58,7 @@
|
|||||||
"marked": "17.0.1",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.51.0",
|
"@playwright/test": "1.59.1",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
@@ -68,7 +72,8 @@
|
|||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"vite-plugin-solid": "2.11.10"
|
"vite-plugin-solid": "2.11.10",
|
||||||
|
"@lydell/node-pty": "1.2.0-beta.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -88,6 +93,7 @@
|
|||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"heap-snapshot-toolkit": "1.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -101,6 +107,7 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
@@ -114,8 +121,6 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
|
|
||||||
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,10 @@ Your app is ready to be deployed!
|
|||||||
|
|
||||||
## E2E Testing
|
## E2E Testing
|
||||||
|
|
||||||
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
|
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
|
||||||
Use the local runner to create a temp sandbox, seed data, and run the tests.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx playwright install
|
bunx playwright install chromium
|
||||||
bun run test:e2e:local
|
bun run test:e2e:local
|
||||||
bun run test:e2e:local -- --grep "settings"
|
bun run test:e2e:local -- --grep "settings"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
# E2E Testing Guide
|
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all e2e tests
|
|
||||||
bun test:e2e
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
bun test:e2e -- app/home.spec.ts
|
|
||||||
|
|
||||||
# Run single test by title
|
|
||||||
bun test:e2e -- -g "home renders and shows core entrypoints"
|
|
||||||
|
|
||||||
# Run tests with UI mode (for debugging)
|
|
||||||
bun test:e2e:ui
|
|
||||||
|
|
||||||
# Run tests locally with full server setup
|
|
||||||
bun test:e2e:local
|
|
||||||
|
|
||||||
# View test report
|
|
||||||
bun test:e2e:report
|
|
||||||
|
|
||||||
# Typecheck
|
|
||||||
bun typecheck
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Structure
|
|
||||||
|
|
||||||
All tests live in `packages/app/e2e/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
e2e/
|
|
||||||
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
|
|
||||||
├── actions.ts # Reusable action helpers
|
|
||||||
├── selectors.ts # DOM selectors
|
|
||||||
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
|
|
||||||
└── [feature]/
|
|
||||||
└── *.spec.ts # Test files
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Patterns
|
|
||||||
|
|
||||||
### Basic Test Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { withSession } from "../actions"
|
|
||||||
|
|
||||||
test("test description", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await gotoSession() // or gotoSession(sessionID)
|
|
||||||
|
|
||||||
// Your test code
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using Fixtures
|
|
||||||
|
|
||||||
- `page` - Playwright page
|
|
||||||
- `sdk` - OpenCode SDK client for API calls
|
|
||||||
- `gotoSession(sessionID?)` - Navigate to session
|
|
||||||
|
|
||||||
### Helper Functions
|
|
||||||
|
|
||||||
**Actions** (`actions.ts`):
|
|
||||||
|
|
||||||
- `openPalette(page)` - Open command palette
|
|
||||||
- `openSettings(page)` - Open settings dialog
|
|
||||||
- `closeDialog(page, dialog)` - Close any dialog
|
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
|
||||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
|
||||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
|
||||||
- `withProject(...)` - Create temp project/workspace
|
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
|
||||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
|
||||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
|
||||||
|
|
||||||
- `promptSelector` - Prompt input
|
|
||||||
- `terminalSelector` - Terminal panel
|
|
||||||
- `sessionItemSelector(id)` - Session in sidebar
|
|
||||||
- `listItemSelector` - Generic list items
|
|
||||||
|
|
||||||
**Utils** (`utils.ts`):
|
|
||||||
|
|
||||||
- `modKey` - Meta (Mac) or Control (Linux/Win)
|
|
||||||
- `serverUrl` - Backend server URL
|
|
||||||
- `sessionPath(dir, id?)` - Build session URL
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
|
||||||
|
|
||||||
### Imports
|
|
||||||
|
|
||||||
Always import from `../fixtures`, not `@playwright/test`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Good
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
|
|
||||||
// ❌ Bad
|
|
||||||
import { test, expect } from "@playwright/test"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- Test files: `feature-name.spec.ts`
|
|
||||||
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
|
|
||||||
- Variables: camelCase
|
|
||||||
- Constants: SCREAMING_SNAKE_CASE
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withSession(sdk, "test session", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
// Test code...
|
|
||||||
}) // Auto-deletes session
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
- Prefer `withSession(...)` for temp sessions
|
|
||||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
|
||||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
|
||||||
- Avoid calling `sdk.session.delete(...)` directly
|
|
||||||
|
|
||||||
### Timeouts
|
|
||||||
|
|
||||||
Default: 60s per test, 10s per assertion. Override when needed:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
test.setTimeout(120_000) // For long LLM operations
|
|
||||||
test("slow test", async () => {
|
|
||||||
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Selectors
|
|
||||||
|
|
||||||
Use `data-component`, `data-action`, or semantic roles:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Good
|
|
||||||
await page.locator('[data-component="prompt-input"]').click()
|
|
||||||
await page.getByRole("button", { name: "Open settings" }).click()
|
|
||||||
|
|
||||||
// ❌ Bad
|
|
||||||
await page.locator(".css-class-name").click()
|
|
||||||
await page.locator("#id-name").click()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
|
||||||
|
|
||||||
Use `modKey` for cross-platform compatibility:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
|
||||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|
||||||
```
|
|
||||||
|
|
||||||
### Terminal Tests
|
|
||||||
|
|
||||||
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
|
||||||
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
|
||||||
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
|
||||||
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
|
||||||
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
|
||||||
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
|
||||||
|
|
||||||
### Wait on state
|
|
||||||
|
|
||||||
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
|
|
||||||
- Avoid race-prone flows that assume work is finished after an action
|
|
||||||
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
|
||||||
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
|
||||||
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
|
||||||
- Do not treat a visible element as proof that the app will route the next action to it
|
|
||||||
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
|
||||||
|
|
||||||
### Add hooks
|
|
||||||
|
|
||||||
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
|
|
||||||
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
|
||||||
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
|
||||||
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
|
||||||
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
|
||||||
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
|
||||||
|
|
||||||
### Prefer helpers
|
|
||||||
|
|
||||||
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
|
||||||
- Use direct locators when the interaction is simple and a helper would not add clarity
|
|
||||||
- Prefer helpers that both perform an action and verify the app consumed it
|
|
||||||
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
|
||||||
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
|
||||||
|
|
||||||
## Writing New Tests
|
|
||||||
|
|
||||||
1. Choose appropriate folder or create new one
|
|
||||||
2. Import from `../fixtures`
|
|
||||||
3. Use helper functions from `../actions` and `../selectors`
|
|
||||||
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
|
||||||
5. Clean up any created resources
|
|
||||||
6. Use specific selectors (avoid CSS classes)
|
|
||||||
7. Test one feature per test file
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
For UI debugging, use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun test:e2e:ui
|
|
||||||
```
|
|
||||||
|
|
||||||
This opens Playwright's interactive UI for step-through debugging.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { serverNamePattern } from "../utils"
|
|
||||||
|
|
||||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
|
||||||
await page.goto("/")
|
|
||||||
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
|
||||||
await expect(nav.getByText("No projects open")).toBeVisible()
|
|
||||||
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
|
||||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("server picker dialog opens from home", async ({ page }) => {
|
|
||||||
await page.goto("/")
|
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: serverNamePattern })
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click()
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { dirPath } from "../utils"
|
|
||||||
|
|
||||||
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
|
||||||
await page.goto(dirPath(directory))
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { closeDialog, openPalette } from "../actions"
|
|
||||||
|
|
||||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openPalette(page)
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openPalette(page, "P")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { serverNamePattern, serverUrls } from "../utils"
|
|
||||||
import { closeDialog, clickMenuItem } from "../actions"
|
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
|
||||||
|
|
||||||
test("can set a default server on web", async ({ page, gotoSession }) => {
|
|
||||||
await page.addInitScript((key: string) => {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, DEFAULT_SERVER_URL_KEY)
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const status = page.getByRole("button", { name: "Status" })
|
|
||||||
await expect(status).toBeVisible()
|
|
||||||
const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" })
|
|
||||||
|
|
||||||
const ensurePopoverOpen = async () => {
|
|
||||||
if (await popover.isVisible()) return
|
|
||||||
await status.click()
|
|
||||||
await expect(popover).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
|
||||||
await popover.getByRole("button", { name: "Manage servers" }).click()
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
|
||||||
|
|
||||||
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
|
||||||
await expect(menuTrigger).toBeVisible()
|
|
||||||
await menuTrigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
await clickMenuItem(menu, /set as default/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () =>
|
|
||||||
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
|
||||||
|
|
||||||
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
|
||||||
await expect(serverRow).toBeVisible()
|
|
||||||
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { withSession } from "../actions"
|
|
||||||
|
|
||||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
|
||||||
const title = `e2e smoke ${Date.now()}`
|
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("hello from e2e")
|
|
||||||
await expect(prompt).toContainText("hello from e2e")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { defocus, openSidebar, withSession } from "../actions"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const stamp = Date.now()
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
|
||||||
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
|
||||||
await gotoSession(one.id)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
|
||||||
await expect(link).toBeVisible()
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
const back = page.getByRole("button", { name: "Back" })
|
|
||||||
const forward = page.getByRole("button", { name: "Forward" })
|
|
||||||
|
|
||||||
await expect(back).toBeVisible()
|
|
||||||
await expect(back).toBeEnabled()
|
|
||||||
await back.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await expect(forward).toBeVisible()
|
|
||||||
await expect(forward).toBeEnabled()
|
|
||||||
await forward.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const stamp = Date.now()
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
|
|
||||||
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
|
|
||||||
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
|
|
||||||
await gotoSession(a.id)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
|
||||||
await expect(second).toBeVisible()
|
|
||||||
await second.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
const back = page.getByRole("button", { name: "Back" })
|
|
||||||
const forward = page.getByRole("button", { name: "Forward" })
|
|
||||||
|
|
||||||
await expect(back).toBeVisible()
|
|
||||||
await expect(back).toBeEnabled()
|
|
||||||
await back.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
|
||||||
await expect(third).toBeVisible()
|
|
||||||
await third.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await expect(forward).toBeVisible()
|
|
||||||
await expect(forward).toBeDisabled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const stamp = Date.now()
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
|
|
||||||
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
|
|
||||||
await gotoSession(one.id)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
|
||||||
await expect(link).toBeVisible()
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await defocus(page)
|
|
||||||
await page.keyboard.press(`${modKey}+[`)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await defocus(page)
|
|
||||||
await page.keyboard.press(`${modKey}+]`)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { spawn } from "node:child_process"
|
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import net from "node:net"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
|
|
||||||
type Handle = {
|
|
||||||
url: string
|
|
||||||
stop: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function freePort() {
|
|
||||||
return new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer()
|
|
||||||
server.once("error", reject)
|
|
||||||
server.listen(0, () => {
|
|
||||||
const address = server.address()
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
server.close((err) => {
|
|
||||||
if (err) reject(err)
|
|
||||||
else resolve(address.port)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHealth(url: string, probe = "/global/health") {
|
|
||||||
const end = Date.now() + 120_000
|
|
||||||
let last = ""
|
|
||||||
while (Date.now() < end) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${url}${probe}`)
|
|
||||||
if (res.ok) return
|
|
||||||
last = `status ${res.status}`
|
|
||||||
} catch (err) {
|
|
||||||
last = err instanceof Error ? err.message : String(err)
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
||||||
}
|
|
||||||
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
|
||||||
if (proc.exitCode !== null) return
|
|
||||||
await Promise.race([
|
|
||||||
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
|
||||||
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_CAP = 100
|
|
||||||
|
|
||||||
function cap(input: string[]) {
|
|
||||||
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
|
||||||
}
|
|
||||||
|
|
||||||
function tail(input: string[]) {
|
|
||||||
return input.slice(-40).join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startBackend(label: string): Promise<Handle> {
|
|
||||||
const port = await freePort()
|
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
|
||||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
||||||
const repoDir = path.resolve(appDir, "../..")
|
|
||||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
|
||||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
|
||||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
|
||||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
|
||||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
|
||||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
|
||||||
OPENCODE_CLIENT: "app",
|
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
|
||||||
} satisfies Record<string, string | undefined>
|
|
||||||
const out: string[] = []
|
|
||||||
const err: string[] = []
|
|
||||||
const proc = spawn(
|
|
||||||
"bun",
|
|
||||||
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
|
||||||
{
|
|
||||||
cwd: opencodeDir,
|
|
||||||
env,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
proc.stdout?.on("data", (chunk) => {
|
|
||||||
out.push(String(chunk))
|
|
||||||
cap(out)
|
|
||||||
})
|
|
||||||
proc.stderr?.on("data", (chunk) => {
|
|
||||||
err.push(String(chunk))
|
|
||||||
cap(err)
|
|
||||||
})
|
|
||||||
|
|
||||||
const url = `http://127.0.0.1:${port}`
|
|
||||||
try {
|
|
||||||
await waitForHealth(url)
|
|
||||||
} catch (error) {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`Failed to start isolated e2e backend for ${label}`,
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
tail(out),
|
|
||||||
tail(err),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
async stop() {
|
|
||||||
if (proc.exitCode === null) {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await waitExit(proc)
|
|
||||||
}
|
|
||||||
if (proc.exitCode === null) {
|
|
||||||
proc.kill("SIGKILL")
|
|
||||||
await waitExit(proc)
|
|
||||||
}
|
|
||||||
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
await expect(prompt).not.toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Control+L")
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
})
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
|
|
||||||
const value = await el.getAttribute("aria-expanded")
|
|
||||||
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
|
|
||||||
return value === "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const reviewPanel = page.locator("#review-panel")
|
|
||||||
|
|
||||||
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
|
||||||
await expect(treeToggle).toBeVisible()
|
|
||||||
if (await expanded(treeToggle)) await treeToggle.click()
|
|
||||||
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
|
|
||||||
await expect(reviewToggle).toBeVisible()
|
|
||||||
if (await expanded(reviewToggle)) await reviewToggle.click()
|
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
|
||||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
|
||||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
|
||||||
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/open")
|
|
||||||
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page
|
|
||||||
.getByRole("dialog")
|
|
||||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
.first()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
await dialog.getByRole("textbox").first().fill("package.json")
|
|
||||||
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
|
||||||
await expect(item).toBeVisible({ timeout: 30_000 })
|
|
||||||
await item.click()
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: "package.json" }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+W`)
|
|
||||||
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/open")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page
|
|
||||||
.getByRole("dialog")
|
|
||||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
.first()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
|
||||||
await input.fill("package.json")
|
|
||||||
|
|
||||||
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
|
||||||
await expect(item).toBeVisible({ timeout: 30_000 })
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
|
||||||
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
|
|
||||||
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
|
||||||
const panel = page.locator("#file-tree-panel")
|
|
||||||
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
|
||||||
await expect(panel).toBeVisible()
|
|
||||||
await expect(treeTabs).toBeVisible()
|
|
||||||
|
|
||||||
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
|
|
||||||
await expect(allTab).toBeVisible()
|
|
||||||
await allTab.click()
|
|
||||||
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
|
|
||||||
await expect(tree).toBeVisible()
|
|
||||||
|
|
||||||
const expand = async (name: string) => {
|
|
||||||
const folder = tree.getByRole("button", { name, exact: true }).first()
|
|
||||||
await expect(folder).toBeVisible()
|
|
||||||
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
|
|
||||||
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
|
|
||||||
await expect(folder).toHaveAttribute("aria-expanded", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
await expand("packages")
|
|
||||||
await expand("app")
|
|
||||||
await expand("src")
|
|
||||||
await expand("components")
|
|
||||||
|
|
||||||
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
|
|
||||||
await expect(file).toBeVisible()
|
|
||||||
await file.click()
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
await toggle.click()
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
await toggle.click()
|
|
||||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
|
||||||
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
await expect(viewer).toContainText("export default function FileTree")
|
|
||||||
})
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/open")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page
|
|
||||||
.getByRole("dialog")
|
|
||||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
.first()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
|
||||||
await input.fill("package.json")
|
|
||||||
|
|
||||||
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
|
||||||
let index = -1
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
|
||||||
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
|
||||||
return index >= 0
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const item = items.nth(index)
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: "package.json" })
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/open")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page
|
|
||||||
.getByRole("dialog")
|
|
||||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
.first()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
|
||||||
await input.fill("package.json")
|
|
||||||
|
|
||||||
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
|
||||||
let index = -1
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
|
||||||
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
|
||||||
return index >= 0
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const item = items.nth(index)
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: "package.json" })
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.press(`${modKey}+f`)
|
|
||||||
|
|
||||||
const findInput = page.getByPlaceholder("Find")
|
|
||||||
await expect(findInput).toBeVisible()
|
|
||||||
await expect(findInput).toBeFocused()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/open")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="file.open"]').first()
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page
|
|
||||||
.getByRole("dialog")
|
|
||||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
.first()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
|
||||||
await input.fill("package.json")
|
|
||||||
|
|
||||||
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
|
||||||
let index = -1
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
|
||||||
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
|
||||||
return index >= 0
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const item = items.nth(index)
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.click()
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: "package.json" })
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
|
|
||||||
await viewer.click()
|
|
||||||
await page.keyboard.press(`${modKey}+f`)
|
|
||||||
|
|
||||||
const findInput = page.getByPlaceholder("Find")
|
|
||||||
await expect(findInput).toBeVisible()
|
|
||||||
await expect(findInput).toBeFocused()
|
|
||||||
})
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
|
||||||
import { ManagedRuntime } from "effect"
|
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
|
||||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
|
||||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
|
||||||
import { startBackend } from "./backend"
|
|
||||||
import {
|
|
||||||
healthPhase,
|
|
||||||
cleanupSession,
|
|
||||||
cleanupTestProject,
|
|
||||||
createTestProject,
|
|
||||||
setHealthPhase,
|
|
||||||
seedProjects,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
waitSlug,
|
|
||||||
waitSession,
|
|
||||||
} from "./actions"
|
|
||||||
import { openaiModel, withMockOpenAI } from "./prompt/mock"
|
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
|
||||||
|
|
||||||
type LLMFixture = {
|
|
||||||
url: string
|
|
||||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
|
||||||
pushMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
...input: (Item | Reply)[]
|
|
||||||
) => Promise<void>
|
|
||||||
textMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
value: string,
|
|
||||||
opts?: { usage?: Usage },
|
|
||||||
) => Promise<void>
|
|
||||||
toolMatch: (
|
|
||||||
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
|
||||||
name: string,
|
|
||||||
input: unknown,
|
|
||||||
) => Promise<void>
|
|
||||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
|
||||||
tool: (name: string, input: unknown) => Promise<void>
|
|
||||||
toolHang: (name: string, input: unknown) => Promise<void>
|
|
||||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
|
||||||
fail: (message?: unknown) => Promise<void>
|
|
||||||
error: (status: number, body: unknown) => Promise<void>
|
|
||||||
hang: () => Promise<void>
|
|
||||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
|
||||||
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
|
||||||
calls: () => Promise<number>
|
|
||||||
wait: (count: number) => Promise<void>
|
|
||||||
inputs: () => Promise<Record<string, unknown>[]>
|
|
||||||
pending: () => Promise<number>
|
|
||||||
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
|
||||||
|
|
||||||
const seedModel = (() => {
|
|
||||||
const [providerID = "opencode", modelID = "big-pickle"] = (
|
|
||||||
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
|
||||||
).split("/")
|
|
||||||
return {
|
|
||||||
providerID: providerID || "opencode",
|
|
||||||
modelID: modelID || "big-pickle",
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
type ProjectHandle = {
|
|
||||||
directory: string
|
|
||||||
slug: string
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
trackSession: (sessionID: string, directory?: string) => void
|
|
||||||
trackDirectory: (directory: string) => void
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProjectOptions = {
|
|
||||||
extra?: string[]
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
setup?: (directory: string) => Promise<void>
|
|
||||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestFixtures = {
|
|
||||||
llm: LLMFixture
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkerFixtures = {
|
|
||||||
backend: {
|
|
||||||
url: string
|
|
||||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
}
|
|
||||||
directory: string
|
|
||||||
slug: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|
||||||
backend: [
|
|
||||||
async ({}, use, workerInfo) => {
|
|
||||||
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
|
||||||
try {
|
|
||||||
await use({
|
|
||||||
url: handle.url,
|
|
||||||
sdk: (directory?: string) => createSdk(directory, handle.url),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await handle.stop()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
llm: async ({}, use) => {
|
|
||||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
|
||||||
try {
|
|
||||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
|
||||||
await use({
|
|
||||||
url: svc.url,
|
|
||||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
|
||||||
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
|
||||||
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
|
||||||
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
|
||||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
|
||||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
|
||||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
|
||||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
|
||||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
|
||||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
|
||||||
hang: () => rt.runPromise(svc.hang),
|
|
||||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
|
||||||
hits: () => rt.runPromise(svc.hits),
|
|
||||||
calls: () => rt.runPromise(svc.calls),
|
|
||||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
|
||||||
inputs: () => rt.runPromise(svc.inputs),
|
|
||||||
pending: () => rt.runPromise(svc.pending),
|
|
||||||
misses: () => rt.runPromise(svc.misses),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await rt.dispose()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
page: async ({ page }, use) => {
|
|
||||||
let boundary: string | undefined
|
|
||||||
setHealthPhase(page, "test")
|
|
||||||
const consoleHandler = (msg: { text(): string }) => {
|
|
||||||
const text = msg.text()
|
|
||||||
if (!text.includes("[e2e:error-boundary]")) return
|
|
||||||
if (healthPhase(page) === "cleanup") {
|
|
||||||
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
boundary ||= text
|
|
||||||
console.log(text)
|
|
||||||
}
|
|
||||||
const pageErrorHandler = (err: Error) => {
|
|
||||||
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
|
||||||
}
|
|
||||||
page.on("console", consoleHandler)
|
|
||||||
page.on("pageerror", pageErrorHandler)
|
|
||||||
await use(page)
|
|
||||||
page.off("console", consoleHandler)
|
|
||||||
page.off("pageerror", pageErrorHandler)
|
|
||||||
if (boundary) throw new Error(boundary)
|
|
||||||
},
|
|
||||||
directory: [
|
|
||||||
async ({}, use) => {
|
|
||||||
const directory = await getWorktree()
|
|
||||||
await use(directory)
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
slug: [
|
|
||||||
async ({ directory }, use) => {
|
|
||||||
await use(dirSlug(directory))
|
|
||||||
},
|
|
||||||
{ scope: "worker" },
|
|
||||||
],
|
|
||||||
sdk: async ({ directory }, use) => {
|
|
||||||
await use(createSdk(directory))
|
|
||||||
},
|
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
|
||||||
await seedStorage(page, { directory })
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
|
||||||
await waitSession(page, { directory, sessionID })
|
|
||||||
}
|
|
||||||
await use(gotoSession)
|
|
||||||
},
|
|
||||||
withProject: async ({ page }, use) => {
|
|
||||||
await use((callback, options) => runProject(page, callback, options))
|
|
||||||
},
|
|
||||||
withBackendProject: async ({ page, backend }, use) => {
|
|
||||||
await use((callback, options) =>
|
|
||||||
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
withMockProject: async ({ page, llm, backend }, use) => {
|
|
||||||
await use((callback, options) =>
|
|
||||||
withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: () =>
|
|
||||||
runProject(page, callback, {
|
|
||||||
...options,
|
|
||||||
model: options?.model ?? openaiModel,
|
|
||||||
serverUrl: backend.url,
|
|
||||||
sdk: backend.sdk,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function runProject<T>(
|
|
||||||
page: Page,
|
|
||||||
callback: (project: ProjectHandle) => Promise<T>,
|
|
||||||
options?: ProjectOptions & {
|
|
||||||
serverUrl?: string
|
|
||||||
sdk?: (directory?: string) => ReturnType<typeof createSdk>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const url = options?.serverUrl
|
|
||||||
const root = await createTestProject(url ? { serverUrl: url } : undefined)
|
|
||||||
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
|
|
||||||
const sessions = new Map<string, string>()
|
|
||||||
const dirs = new Set<string>()
|
|
||||||
await options?.setup?.(root)
|
|
||||||
await seedStorage(page, {
|
|
||||||
directory: root,
|
|
||||||
extra: options?.extra,
|
|
||||||
model: options?.model,
|
|
||||||
serverUrl: url,
|
|
||||||
})
|
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
|
||||||
await page.goto(sessionPath(root, sessionID))
|
|
||||||
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
|
||||||
const current = sessionIDFromUrl(page.url())
|
|
||||||
if (current) trackSession(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
|
||||||
sessions.set(sessionID, directory ?? root)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDirectory = (directory: string) => {
|
|
||||||
if (directory !== root) dirs.add(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await options?.beforeGoto?.({ directory: root, sdk })
|
|
||||||
await gotoSession()
|
|
||||||
const slug = await waitSlug(page)
|
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
|
||||||
} finally {
|
|
||||||
setHealthPhase(page, "cleanup")
|
|
||||||
await Promise.allSettled(
|
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
|
|
||||||
)
|
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
|
||||||
await cleanupTestProject(root)
|
|
||||||
setHealthPhase(page, "test")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedStorage(
|
|
||||||
page: Page,
|
|
||||||
input: {
|
|
||||||
directory: string
|
|
||||||
extra?: string[]
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
serverUrl?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
await seedProjects(page, input)
|
|
||||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
|
||||||
const win = window as E2EWindow
|
|
||||||
win.__opencode_e2e = {
|
|
||||||
...win.__opencode_e2e,
|
|
||||||
model: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
prompt: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
terminal: {
|
|
||||||
enabled: true,
|
|
||||||
terminals: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.global.dat:model",
|
|
||||||
JSON.stringify({
|
|
||||||
recent: [model],
|
|
||||||
user: [],
|
|
||||||
variant: {},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}, input.model ?? seedModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { expect }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { clickListItem } from "../actions"
|
|
||||||
|
|
||||||
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="model.choose"]')
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
const input = dialog.getByRole("textbox").first()
|
|
||||||
|
|
||||||
const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first()
|
|
||||||
await expect(selected).toBeVisible()
|
|
||||||
|
|
||||||
const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first()
|
|
||||||
const target = (await other.count()) > 0 ? other : selected
|
|
||||||
|
|
||||||
const key = await target.getAttribute("data-key")
|
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
|
||||||
|
|
||||||
const model = key.split(":").slice(1).join(":")
|
|
||||||
|
|
||||||
await input.fill(model)
|
|
||||||
|
|
||||||
await clickListItem(dialog, { key })
|
|
||||||
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialogAgain = page.getByRole("dialog")
|
|
||||||
await expect(dialogAgain).toBeVisible()
|
|
||||||
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { closeDialog, openSettings, clickListItem } from "../actions"
|
|
||||||
|
|
||||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="model.choose"]')
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const picker = page.getByRole("dialog")
|
|
||||||
await expect(picker).toBeVisible()
|
|
||||||
|
|
||||||
const target = picker.locator('[data-slot="list-item"]').first()
|
|
||||||
await expect(target).toBeVisible()
|
|
||||||
|
|
||||||
const key = await target.getAttribute("data-key")
|
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
|
||||||
|
|
||||||
const name = (await target.locator("span").first().innerText()).trim()
|
|
||||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(picker).toHaveCount(0)
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
|
|
||||||
await settings.getByRole("tab", { name: "Models" }).click()
|
|
||||||
const search = settings.getByPlaceholder("Search models")
|
|
||||||
await expect(search).toBeVisible()
|
|
||||||
await search.fill(name)
|
|
||||||
|
|
||||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
|
||||||
const input = toggle.locator('[data-slot="switch-input"]')
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
|
||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const pickerAgain = page.getByRole("dialog")
|
|
||||||
await expect(pickerAgain).toBeVisible()
|
|
||||||
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
|
|
||||||
|
|
||||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(pickerAgain).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const open = async () => {
|
|
||||||
const menu = await openProjectMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
|
||||||
return dialog
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = `e2e project ${Date.now()}`
|
|
||||||
const startup = `echo e2e_${Date.now()}`
|
|
||||||
|
|
||||||
const dialog = await open()
|
|
||||||
|
|
||||||
const nameInput = dialog.getByLabel("Name")
|
|
||||||
await nameInput.fill(name)
|
|
||||||
|
|
||||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
|
||||||
await startupInput.fill(startup)
|
|
||||||
|
|
||||||
await dialog.getByRole("button", { name: "Save" }).click()
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
|
|
||||||
const header = page.locator(".group\\/project").first()
|
|
||||||
await expect(header).toContainText(name)
|
|
||||||
|
|
||||||
const reopened = await open()
|
|
||||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
|
||||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
|
||||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
|
||||||
await expect(reopened).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
|
||||||
import { projectSwitchSelector } from "../selectors"
|
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const otherSlug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const pathname = new URL(page.url()).pathname
|
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
|
||||||
if (pathname === "/") return "home"
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toMatch(/^(project|home)$/)
|
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import {
|
|
||||||
defocus,
|
|
||||||
createTestProject,
|
|
||||||
cleanupTestProject,
|
|
||||||
openSidebar,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitSession,
|
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const otherSlug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async ({ directory }) => {
|
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const currentSlug = dirSlug(directory)
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
|
||||||
|
|
||||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
|
||||||
await expect(currentButton).toBeVisible()
|
|
||||||
await currentButton.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const otherSlug = dirSlug(other)
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
|
||||||
await defocus(page)
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
|
|
||||||
const raw = await waitSlug(page, [slug])
|
|
||||||
const dir = base64Decode(raw)
|
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
|
||||||
const space = await resolveDirectory(dir)
|
|
||||||
const next = dirSlug(space)
|
|
||||||
trackDirectory(space)
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
|
||||||
await expect(btn).toBeVisible()
|
|
||||||
await btn.click({ force: true })
|
|
||||||
|
|
||||||
await waitSession(page, { directory: space })
|
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.fill("test")
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for the URL to update with the new session ID
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
|
||||||
trackSession(created, space)
|
|
||||||
await waitSessionSaved(space, created)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.click({ force: true })
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
await expect(rootButton).toBeVisible()
|
|
||||||
await rootButton.click({ force: true })
|
|
||||||
|
|
||||||
await waitSession(page, { directory: space, sessionID: created })
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import {
|
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitDir,
|
|
||||||
waitSession,
|
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
function item(space: { slug: string; raw: string }) {
|
|
||||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function button(space: { slug: string; raw: string }) {
|
|
||||||
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|
||||||
await openSidebar(page)
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
|
||||||
await waitDir(page, next.directory)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
|
||||||
await waitWorkspaceReady(page, space)
|
|
||||||
|
|
||||||
const row = page.locator(item(space)).first()
|
|
||||||
await row.hover()
|
|
||||||
|
|
||||||
const next = page.locator(button(space)).first()
|
|
||||||
await expect(next).toBeVisible()
|
|
||||||
await next.click({ force: true })
|
|
||||||
|
|
||||||
await waitSession(page, { directory: space.directory })
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
|
||||||
page: Page,
|
|
||||||
space: { slug: string; raw: string; directory: string },
|
|
||||||
text: string,
|
|
||||||
) {
|
|
||||||
await openWorkspaceNewSession(page, space)
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.fill(text)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
|
|
||||||
await waitSessionSaved(space.directory, sessionID)
|
|
||||||
await createSdk(space.directory)
|
|
||||||
.session.abort({ sessionID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sessionID
|
|
||||||
}
|
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
|
||||||
await openSidebar(page)
|
|
||||||
await setWorkspacesEnabled(page, root, true)
|
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
|
||||||
trackDirectory(first.directory)
|
|
||||||
await waitWorkspaceReady(page, first)
|
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
|
||||||
trackDirectory(second.directory)
|
|
||||||
await waitWorkspaceReady(page, second)
|
|
||||||
|
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
|
||||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
import fs from "node:fs/promises"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
|
|
||||||
test.describe.configure({ mode: "serial" })
|
|
||||||
import {
|
|
||||||
cleanupTestProject,
|
|
||||||
clickMenuItem,
|
|
||||||
confirmDialog,
|
|
||||||
openSidebar,
|
|
||||||
openWorkspaceMenu,
|
|
||||||
resolveSlug,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
slugFromUrl,
|
|
||||||
waitDir,
|
|
||||||
waitSlug,
|
|
||||||
} from "../actions"
|
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
|
||||||
import { createSdk, dirSlug } from "../utils"
|
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|
||||||
const rootSlug = project.slug
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
|
||||||
await waitDir(page, next.directory)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
|
||||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, false)
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can create a workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
|
||||||
await openSidebar(page)
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
|
||||||
await waitDir(page, next.directory)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
|
||||||
|
|
||||||
await cleanupTestProject(next.directory)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
|
||||||
const nonGitSlug = dirSlug(nonGit)
|
|
||||||
|
|
||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(async () => {
|
|
||||||
await page.goto(`/${nonGitSlug}/session`)
|
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
|
|
||||||
const trigger = page.locator('[data-action="project-menu"]').first()
|
|
||||||
const hasMenu = await trigger
|
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!hasMenu) return
|
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(toggle).toBeDisabled()
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(nonGit)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const { slug } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
const rename = `e2e workspace ${Date.now()}`
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
|
||||||
|
|
||||||
await expect(menu).toHaveCount(0)
|
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
await expect(item).toBeVisible()
|
|
||||||
const input = item.locator(inlineInputSelector).first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await input.fill(rename)
|
|
||||||
await input.press("Enter")
|
|
||||||
await expect(item).toContainText(rename)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
const readme = path.join(createdDir, "README.md")
|
|
||||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
|
||||||
const original = await fs.readFile(readme, "utf8")
|
|
||||||
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
|
||||||
await fs.writeFile(readme, dirty, "utf8")
|
|
||||||
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Reset workspace$/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
|
|
||||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can delete a workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const sdk = createSdk(project.directory)
|
|
||||||
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
|
||||||
|
|
||||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(false)
|
|
||||||
|
|
||||||
await project.gotoSession()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
await withProject(async ({ slug: rootSlug }) => {
|
|
||||||
const workspaces = [] as { directory: string; slug: string }[]
|
|
||||||
|
|
||||||
const listSlugs = async () => {
|
|
||||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
|
||||||
const slugs = await nodes.evaluateAll((els) => {
|
|
||||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
|
||||||
})
|
|
||||||
return slugs
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitReady = async (slug: string) => {
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = async (from: string, to: string) => {
|
|
||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
|
||||||
const b = await dst.boundingBox()
|
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
|
||||||
|
|
||||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
|
||||||
await page.mouse.down()
|
|
||||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
|
||||||
await page.mouse.up()
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
|
||||||
|
|
||||||
for (const _ of [0, 1]) {
|
|
||||||
const prev = slugFromUrl(page.url())
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
|
||||||
await waitDir(page, next.directory)
|
|
||||||
workspaces.push(next)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
|
||||||
|
|
||||||
const a = workspaces[0].slug
|
|
||||||
const b = workspaces[1].slug
|
|
||||||
|
|
||||||
await waitReady(a)
|
|
||||||
await waitReady(b)
|
|
||||||
|
|
||||||
const list = async () => {
|
|
||||||
const slugs = await listSlugs()
|
|
||||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const slugs = await list()
|
|
||||||
return slugs.length === 2
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const before = await list()
|
|
||||||
const from = before[1]
|
|
||||||
const to = before[0]
|
|
||||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
|
||||||
|
|
||||||
await drag(from, to)
|
|
||||||
|
|
||||||
await expect.poll(async () => await list()).toEqual([from, to])
|
|
||||||
} finally {
|
|
||||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { withSession } from "../actions"
|
|
||||||
|
|
||||||
function contextButton(page: Page) {
|
|
||||||
return page
|
|
||||||
.locator('[data-component="button"]')
|
|
||||||
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
|
||||||
.first()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
|
||||||
await input.sdk.session.promptAsync({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
noReply: true,
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "seed context",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const messages = await input.sdk.session
|
|
||||||
.messages({ sessionID: input.sessionID, limit: 1 })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
|
||||||
const title = `e2e smoke context ${Date.now()}`
|
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
|
||||||
await seedContextSession({ sessionID: session.id, sdk })
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const trigger = contextButton(page)
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click()
|
|
||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
|
||||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
|
||||||
await seedContextSession({ sessionID: session.id, sdk })
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
|
|
||||||
const trigger = contextButton(page)
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click()
|
|
||||||
|
|
||||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
|
||||||
const context = tabs.getByRole("tab", { name: "Context" })
|
|
||||||
await expect(context).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Close tab" }).first().click()
|
|
||||||
await expect(context).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
|
||||||
await seedContextSession({ sessionID: session.id, sdk })
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
|
|
||||||
const trigger = contextButton(page)
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click()
|
|
||||||
|
|
||||||
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
|
||||||
await page.getByRole("button", { name: "Open file" }).first().click()
|
|
||||||
|
|
||||||
const dialog = page
|
|
||||||
.getByRole("dialog")
|
|
||||||
.filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
.first()
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
|
|
||||||
|
|
||||||
type Hit = { body: Record<string, unknown> }
|
|
||||||
|
|
||||||
export function bodyText(hit: Hit) {
|
|
||||||
return JSON.stringify(hit.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function titleMatch(hit: Hit) {
|
|
||||||
return bodyText(hit).includes("Generate a title for this conversation")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function promptMatch(token: string) {
|
|
||||||
return (hit: Hit) => bodyText(hit).includes(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Match requests whose body contains the exact serialized tool input.
|
|
||||||
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
|
||||||
* gets escaped again inside the JSON body — so we double-escape to match.
|
|
||||||
*/
|
|
||||||
export function inputMatch(input: unknown) {
|
|
||||||
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
|
||||||
return (hit: Hit) => bodyText(hit).includes(escaped)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
|
|
||||||
const sdk = createSdk(undefined, input.serverUrl)
|
|
||||||
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sdk.global.config.update({
|
|
||||||
config: {
|
|
||||||
...prev,
|
|
||||||
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
|
|
||||||
enabled_providers: ["openai"],
|
|
||||||
provider: {
|
|
||||||
...prev.provider,
|
|
||||||
openai: {
|
|
||||||
...prev.provider?.openai,
|
|
||||||
options: {
|
|
||||||
...prev.provider?.openai?.options,
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: input.llmUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return await input.fn()
|
|
||||||
} finally {
|
|
||||||
await sdk.global.config.update({ config: prev })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
|
||||||
|
|
||||||
await withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
|
||||||
await llm.textMatch(promptMatch(token), token)
|
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
const value = `restore ${Date.now()}`
|
|
||||||
|
|
||||||
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
|
||||||
route.fulfill({
|
|
||||||
status: 500,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ message: "e2e prompt failure" }),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(value)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
|
|
||||||
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
|
|
||||||
const dt = await page.evaluateHandle((text) => {
|
|
||||||
const dt = new DataTransfer()
|
|
||||||
dt.setData("text/plain", text)
|
|
||||||
return dt
|
|
||||||
}, `file:${path}`)
|
|
||||||
|
|
||||||
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
|
||||||
|
|
||||||
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
|
||||||
await expect(pill).toBeVisible()
|
|
||||||
await expect(pill).toHaveAttribute("data-path", path)
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
|
|
||||||
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
|
|
||||||
const dt = await page.evaluateHandle((b64) => {
|
|
||||||
const dt = new DataTransfer()
|
|
||||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
|
||||||
const file = new File([bytes], "drop.png", { type: "image/png" })
|
|
||||||
dt.items.add(file)
|
|
||||||
return dt
|
|
||||||
}, png)
|
|
||||||
|
|
||||||
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
|
||||||
|
|
||||||
const img = page.locator('img[alt="drop.png"]').first()
|
|
||||||
await expect(img).toBeVisible()
|
|
||||||
|
|
||||||
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
|
|
||||||
await expect(remove).toBeVisible()
|
|
||||||
|
|
||||||
await img.hover()
|
|
||||||
await remove.click()
|
|
||||||
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { createSdk } from "../utils"
|
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
|
||||||
type Sdk = ReturnType<typeof createSdk>
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
|
||||||
if (!part || typeof part !== "object") return false
|
|
||||||
if (!("type" in part) || part.type !== "tool") return false
|
|
||||||
if (!("tool" in part) || part.tool !== "bash") return false
|
|
||||||
return "state" in part
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wait(page: Page, value: string) {
|
|
||||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
|
||||||
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
const part = messages
|
|
||||||
.filter((item) => item.info.role === "assistant")
|
|
||||||
.flatMap((item) => item.parts)
|
|
||||||
.filter(isBash)
|
|
||||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
|
||||||
|
|
||||||
if (!part || part.state.status !== "completed") return
|
|
||||||
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
backend,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
await withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
|
||||||
const draft = `draft ${Date.now()}`
|
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
|
||||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
|
||||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(first)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
await reply(project.sdk, sessionID, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(second)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await reply(project.sdk, sessionID, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(draft)
|
|
||||||
await wait(page, draft)
|
|
||||||
|
|
||||||
await prompt.fill("")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, first)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, "")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
|
||||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
|
||||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
|
||||||
const first = `echo ${firstToken}`
|
|
||||||
const second = `echo ${secondToken}`
|
|
||||||
const normal = `Reply with exactly: ${normalToken}`
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("!")
|
|
||||||
await page.keyboard.type(first)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
|
||||||
await shell(sdk, sessionID, first, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("!")
|
|
||||||
await page.keyboard.type(second)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await shell(sdk, sessionID, second, secondToken)
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("!")
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, first)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(normal)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await reply(sdk, sessionID, normalToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.press("ArrowUp")
|
|
||||||
await wait(page, normal)
|
|
||||||
})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
const sep = process.platform === "win32" ? "\\" : "/"
|
|
||||||
const file = ["packages", "app", "package.json"].join(sep)
|
|
||||||
const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/
|
|
||||||
|
|
||||||
await page.keyboard.type(`@${file}`)
|
|
||||||
|
|
||||||
const suggestion = page.getByRole("button", { name: filePattern }).first()
|
|
||||||
await expect(suggestion).toBeVisible()
|
|
||||||
await suggestion.hover()
|
|
||||||
|
|
||||||
await page.keyboard.press("Tab")
|
|
||||||
|
|
||||||
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
|
||||||
await expect(pill).toBeVisible()
|
|
||||||
await expect(pill).toHaveAttribute("data-path", filePattern)
|
|
||||||
|
|
||||||
await page.keyboard.type(" ok")
|
|
||||||
await expect(page.locator(promptSelector)).toContainText("ok")
|
|
||||||
})
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.focus()
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
|
|
||||||
await prompt.pressSequentially("line one")
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
|
|
||||||
await prompt.press("Shift+Enter")
|
|
||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
|
|
||||||
await prompt.pressSequentially("line two")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
|
||||||
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { sessionIDFromUrl } from "../actions"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
|
||||||
if (!part || typeof part !== "object") return false
|
|
||||||
if (!("type" in part) || part.type !== "tool") return false
|
|
||||||
if (!("tool" in part) || part.tool !== "bash") return false
|
|
||||||
return "state" in part
|
|
||||||
}
|
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("!")
|
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
|
||||||
|
|
||||||
await page.keyboard.type(cmd)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
trackSession(id, directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
|
||||||
const msg = list.findLast(
|
|
||||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
|
||||||
)
|
|
||||||
if (!msg) return
|
|
||||||
|
|
||||||
const part = msg.parts
|
|
||||||
.filter(isBash)
|
|
||||||
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
|
||||||
|
|
||||||
if (!part || part.state.status !== "completed") return
|
|
||||||
const output =
|
|
||||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
|
||||||
if (!output.includes("README.md")) return
|
|
||||||
|
|
||||||
return { cwd: directory, output }
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
|
||||||
|
|
||||||
await expect(prompt).toHaveText("")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/open")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="file.open"]')
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
|
||||||
await expect(dialog).toBeVisible()
|
|
||||||
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(dialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { withSession } from "../actions"
|
|
||||||
|
|
||||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
|
||||||
|
|
||||||
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
|
||||||
await sdk.session.promptAsync({
|
|
||||||
sessionID,
|
|
||||||
noReply: true,
|
|
||||||
parts: [{ type: "text", text: "e2e share seed" }],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
|
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
|
|
||||||
await seed(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("/share")
|
|
||||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
|
||||||
return data?.share?.url || undefined
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.not.toBeUndefined()
|
|
||||||
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type("/unshare")
|
|
||||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
|
||||||
return data?.share?.url || undefined
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
|
|
||||||
await expect(terminal).not.toBeVisible()
|
|
||||||
|
|
||||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
|
||||||
await waitTerminalFocusIdle(page, { term: terminal })
|
|
||||||
|
|
||||||
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
|
||||||
await expect(terminal).not.toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
|
||||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
|
||||||
const onPageError = (err: Error) => {
|
|
||||||
pageErrors.push(err.message)
|
|
||||||
}
|
|
||||||
page.on("pageerror", onPageError)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withMockOpenAI({
|
|
||||||
serverUrl: backend.url,
|
|
||||||
llmUrl: llm.url,
|
|
||||||
fn: async () => {
|
|
||||||
const token = `E2E_OK_${Date.now()}`
|
|
||||||
|
|
||||||
await llm.textMatch(titleMatch, "E2E Title")
|
|
||||||
await llm.textMatch(promptMatch(token), token)
|
|
||||||
|
|
||||||
await withBackendProject(
|
|
||||||
async (project) => {
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const sessionID = (() => {
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
return id
|
|
||||||
})()
|
|
||||||
project.trackSession(sessionID)
|
|
||||||
|
|
||||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
|
||||||
|
|
||||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: openaiModel,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
page.off("pageerror", onPageError)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
|
||||||
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
|
||||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
|
||||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
|
||||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
|
||||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
|
||||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
|
||||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
|
||||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
|
||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
|
||||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
|
||||||
export const promptModelSelector = '[data-component="prompt-model-control"]'
|
|
||||||
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
|
||||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
|
||||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
|
||||||
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
|
|
||||||
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
|
|
||||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
|
||||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
|
||||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
|
||||||
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
|
|
||||||
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
|
|
||||||
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
|
|
||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
|
||||||
|
|
||||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
|
||||||
|
|
||||||
export const projectSwitchSelector = (slug: string) =>
|
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectMenuTriggerSelector = (slug: string) =>
|
|
||||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectClearNotificationsSelector = (slug: string) =>
|
|
||||||
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const titlebarRightSelector = "#opencode-titlebar-right"
|
|
||||||
|
|
||||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
|
||||||
|
|
||||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
|
||||||
|
|
||||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
|
||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
|
||||||
|
|
||||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
|
||||||
|
|
||||||
export const workspaceItemSelector = (slug: string) =>
|
|
||||||
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
|
|
||||||
|
|
||||||
export const workspaceMenuTriggerSelector = (slug: string) =>
|
|
||||||
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
|
|
||||||
|
|
||||||
export const workspaceNewSessionSelector = (slug: string) =>
|
|
||||||
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`
|
|
||||||
|
|
||||||
export const listItemSelector = '[data-slot="list-item"]'
|
|
||||||
|
|
||||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
|
||||||
|
|
||||||
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
|
|
||||||
|
|
||||||
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { seedSessionTask, withSession } from "../actions"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { inputMatch } from "../prompt/mock"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
const errs: string[] = []
|
|
||||||
const onError = (err: Error) => {
|
|
||||||
errs.push(err.message)
|
|
||||||
}
|
|
||||||
page.on("pageerror", onError)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
|
|
||||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
|
||||||
const taskInput = {
|
|
||||||
description: "Open child session",
|
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
|
||||||
subagent_type: "general",
|
|
||||||
}
|
|
||||||
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
|
||||||
const child = await seedSessionTask(sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
description: taskInput.description,
|
|
||||||
prompt: taskInput.prompt,
|
|
||||||
})
|
|
||||||
trackSession(child.sessionID)
|
|
||||||
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const link = page
|
|
||||||
.locator("a.subagent-link")
|
|
||||||
.filter({ hasText: /open child session/i })
|
|
||||||
.first()
|
|
||||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
|
||||||
await link.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
page.off("pageerror", onError)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,678 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import {
|
|
||||||
composerEvent,
|
|
||||||
type ComposerDriverState,
|
|
||||||
type ComposerProbeState,
|
|
||||||
type ComposerWindow,
|
|
||||||
} from "../../src/testing/session-composer"
|
|
||||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
|
|
||||||
import {
|
|
||||||
permissionDockSelector,
|
|
||||||
promptSelector,
|
|
||||||
questionDockSelector,
|
|
||||||
sessionComposerDockSelector,
|
|
||||||
sessionTodoToggleButtonSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
import { inputMatch } from "../prompt/mock"
|
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
|
||||||
|
|
||||||
async function withDockSession<T>(
|
|
||||||
sdk: Sdk,
|
|
||||||
title: string,
|
|
||||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
|
||||||
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
|
|
||||||
) {
|
|
||||||
const session = await sdk.session
|
|
||||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!session?.id) throw new Error("Session create did not return an id")
|
|
||||||
opts?.trackSession?.(session.id)
|
|
||||||
try {
|
|
||||||
return await fn(session)
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk, sessionID: session.id })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultQuestions = [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
|
||||||
try {
|
|
||||||
return await fn()
|
|
||||||
} finally {
|
|
||||||
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearPermissionDock(page: any, label: RegExp) {
|
|
||||||
const dock = page.locator(permissionDockSelector)
|
|
||||||
await expect(dock).toBeVisible()
|
|
||||||
await dock.getByRole("button", { name: label }).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setAutoAccept(page: any, enabled: boolean) {
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
|
||||||
if (pressed === enabled) return
|
|
||||||
await button.click()
|
|
||||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectQuestionBlocked(page: any) {
|
|
||||||
await expect(page.locator(questionDockSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectQuestionOpen(page: any) {
|
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectPermissionBlocked(page: any) {
|
|
||||||
await expect(page.locator(permissionDockSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectPermissionOpen(page: any) {
|
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function todoDock(page: any, sessionID: string) {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
const win = window as ComposerWindow
|
|
||||||
win.__opencode_e2e = {
|
|
||||||
...win.__opencode_e2e,
|
|
||||||
composer: {
|
|
||||||
enabled: true,
|
|
||||||
sessions: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const write = async (driver: ComposerDriverState | undefined) => {
|
|
||||||
await page.evaluate(
|
|
||||||
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
|
|
||||||
const win = window as ComposerWindow
|
|
||||||
const composer = win.__opencode_e2e?.composer
|
|
||||||
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
|
||||||
composer.sessions ??= {}
|
|
||||||
const prev = composer.sessions[input.sessionID] ?? {}
|
|
||||||
if (!input.driver) {
|
|
||||||
if (!prev.probe) {
|
|
||||||
delete composer.sessions[input.sessionID]
|
|
||||||
} else {
|
|
||||||
composer.sessions[input.sessionID] = { probe: prev.probe }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
composer.sessions[input.sessionID] = {
|
|
||||||
...prev,
|
|
||||||
driver: input.driver,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
|
|
||||||
},
|
|
||||||
{ event: composerEvent, sessionID, driver },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const read = () =>
|
|
||||||
page.evaluate((sessionID: string) => {
|
|
||||||
const win = window as ComposerWindow
|
|
||||||
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
|
||||||
}, sessionID) as Promise<ComposerProbeState | null>
|
|
||||||
|
|
||||||
const api = {
|
|
||||||
async clear() {
|
|
||||||
await write(undefined)
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
|
|
||||||
await write({ live: true, todos })
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
|
|
||||||
await write({ live: false, todos })
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async expectOpen(states: ComposerProbeState["states"]) {
|
|
||||||
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
|
||||||
mounted: true,
|
|
||||||
collapsed: false,
|
|
||||||
hidden: false,
|
|
||||||
count: states.length,
|
|
||||||
states,
|
|
||||||
})
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async expectCollapsed(states: ComposerProbeState["states"]) {
|
|
||||||
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
|
||||||
mounted: true,
|
|
||||||
collapsed: true,
|
|
||||||
hidden: true,
|
|
||||||
count: states.length,
|
|
||||||
states,
|
|
||||||
})
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async expectClosed() {
|
|
||||||
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async collapse() {
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
async expand() {
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
|
||||||
return api
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return api
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withMockPermission<T>(
|
|
||||||
page: any,
|
|
||||||
request: {
|
|
||||||
id: string
|
|
||||||
sessionID: string
|
|
||||||
permission: string
|
|
||||||
patterns: string[]
|
|
||||||
metadata?: Record<string, unknown>
|
|
||||||
always?: string[]
|
|
||||||
},
|
|
||||||
opts: { child?: any } | undefined,
|
|
||||||
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
|
||||||
) {
|
|
||||||
const listUrl = /\/permission(?:\?.*)?$/
|
|
||||||
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
|
|
||||||
let pending = [
|
|
||||||
{
|
|
||||||
...request,
|
|
||||||
always: request.always ?? ["*"],
|
|
||||||
metadata: request.metadata ?? {},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const list = async (route: any) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(pending),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reply = async (route: any) => {
|
|
||||||
const url = new URL(route.request().url())
|
|
||||||
const parts = url.pathname.split("/").filter(Boolean)
|
|
||||||
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
|
|
||||||
pending = pending.filter((item) => item.id !== id)
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(true),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.route(listUrl, list)
|
|
||||||
for (const item of replyUrls) {
|
|
||||||
await page.route(item, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionList = opts?.child
|
|
||||||
? async (route: any) => {
|
|
||||||
const res = await route.fetch()
|
|
||||||
const json = await res.json()
|
|
||||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
|
||||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
|
||||||
await route.fulfill({
|
|
||||||
status: res.status(),
|
|
||||||
headers: res.headers(),
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(json),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (sessionList) await page.route("**/session?*", sessionList)
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
async resolved() {
|
|
||||||
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await fn(state)
|
|
||||||
} finally {
|
|
||||||
await page.unroute(listUrl, list)
|
|
||||||
for (const item of replyUrls) {
|
|
||||||
await page.unroute(item, reply)
|
|
||||||
}
|
|
||||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock default",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async ({ gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
|
||||||
|
|
||||||
await setAutoAccept(page, true)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock question",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: defaultQuestions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock question keyboard",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: defaultQuestions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await expect(second).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Space")
|
|
||||||
await page.keyboard.press(`${modKey}+Enter`)
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock question escape",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions: defaultQuestions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
const first = dock.locator('[data-slot="question-option"]').first()
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
await expect(first).toBeFocused()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock permission once",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_once",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock permission reject",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_reject",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock permission always",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_always",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
|
||||||
page,
|
|
||||||
llm,
|
|
||||||
withMockProject,
|
|
||||||
}) => {
|
|
||||||
const questions = [
|
|
||||||
{
|
|
||||||
header: "Child input",
|
|
||||||
question: "Pick one child option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue child" },
|
|
||||||
{ label: "Stop", description: "Stop child" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock child question parent",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const child = await project.sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child question",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
project.trackSession(child.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withDockSeed(project.sdk, child.id, async () => {
|
|
||||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: child.id,
|
|
||||||
questions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
|
||||||
page,
|
|
||||||
withBackendProject,
|
|
||||||
}) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock child permission parent",
|
|
||||||
async (session) => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
|
|
||||||
const child = await project.sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child permission",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
project.trackSession(child.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_child",
|
|
||||||
sessionID: child.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
|
||||||
metadata: { description: "Need child permission" },
|
|
||||||
},
|
|
||||||
{ child },
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock todo",
|
|
||||||
async (session) => {
|
|
||||||
const dock = await todoDock(page, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await dock.open([
|
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
|
||||||
])
|
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.collapse()
|
|
||||||
await dock.expectCollapsed(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.expand()
|
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.finish([
|
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
|
||||||
])
|
|
||||||
await dock.expectClosed()
|
|
||||||
} finally {
|
|
||||||
await dock.clear()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
|
|
||||||
const questions = [
|
|
||||||
{
|
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withDockSession(
|
|
||||||
project.sdk,
|
|
||||||
"e2e composer dock keyboard",
|
|
||||||
async (session) => {
|
|
||||||
await withDockSeed(project.sdk, session.id, async () => {
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
|
||||||
await seedSessionQuestion(project.sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
questions,
|
|
||||||
})
|
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
await page.keyboard.type("abc")
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ trackSession: project.trackSession },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import {
|
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitSession,
|
|
||||||
waitSessionIdle,
|
|
||||||
waitSlug,
|
|
||||||
withNoReplyPrompt,
|
|
||||||
} from "../actions"
|
|
||||||
import {
|
|
||||||
promptAgentSelector,
|
|
||||||
promptModelSelector,
|
|
||||||
promptVariantSelector,
|
|
||||||
workspaceItemSelector,
|
|
||||||
workspaceNewSessionSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
import { createSdk, sessionPath } from "../utils"
|
|
||||||
|
|
||||||
type Footer = {
|
|
||||||
agent: string
|
|
||||||
model: string
|
|
||||||
variant: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Probe = {
|
|
||||||
dir?: string
|
|
||||||
sessionID?: string
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string; name?: string }
|
|
||||||
variant?: string | null
|
|
||||||
pick?: {
|
|
||||||
agent?: string
|
|
||||||
model?: { providerID: string; modelID: string }
|
|
||||||
variant?: string | null
|
|
||||||
}
|
|
||||||
variants?: string[]
|
|
||||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
|
||||||
agents?: Array<{ name: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
||||||
|
|
||||||
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
|
|
||||||
|
|
||||||
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
|
||||||
|
|
||||||
async function probe(page: Page): Promise<Probe | null> {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
current?: Probe
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return win.__opencode_e2e?.model?.current ?? null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function currentModel(page: Page) {
|
|
||||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
|
|
||||||
const value = await probe(page).then(modelKey)
|
|
||||||
if (!value) throw new Error("Failed to resolve current model key")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() =>
|
|
||||||
page.evaluate((key) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !!win.__opencode_e2e?.model?.controls?.[key]
|
|
||||||
}, key),
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickAgent(page: Page, value: string) {
|
|
||||||
await waitControl(page, "setAgent")
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: {
|
|
||||||
setAgent?: (value: string | undefined) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn = win.__opencode_e2e?.model?.controls?.setAgent
|
|
||||||
if (!fn) throw new Error("Model e2e agent control is not enabled")
|
|
||||||
fn(value)
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
|
|
||||||
await waitControl(page, "setModel")
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: {
|
|
||||||
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn = win.__opencode_e2e?.model?.controls?.setModel
|
|
||||||
if (!fn) throw new Error("Model e2e model control is not enabled")
|
|
||||||
fn(value)
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickVariant(page: Page, value: string) {
|
|
||||||
await waitControl(page, "setVariant")
|
|
||||||
await page.evaluate((value) => {
|
|
||||||
const win = window as Window & {
|
|
||||||
__opencode_e2e?: {
|
|
||||||
model?: {
|
|
||||||
controls?: {
|
|
||||||
setVariant?: (value: string | undefined) => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fn = win.__opencode_e2e?.model?.controls?.setVariant
|
|
||||||
if (!fn) throw new Error("Model e2e variant control is not enabled")
|
|
||||||
fn(value)
|
|
||||||
}, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function read(page: Page): Promise<Footer> {
|
|
||||||
return {
|
|
||||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
|
||||||
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
|
|
||||||
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitFooter(page: Page, expected: Partial<Footer>) {
|
|
||||||
let hit: Footer | null = null
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await read(page)
|
|
||||||
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
|
|
||||||
if (ok) hit = state
|
|
||||||
return ok
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
if (!hit) throw new Error("Failed to resolve prompt footer state")
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitModel(page: Page, value: string) {
|
|
||||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function choose(page: Page, root: string, value: string) {
|
|
||||||
const select = page.locator(root)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
await pickAgent(page, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function variantCount(page: Page) {
|
|
||||||
return (await probe(page))?.variants?.length ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
async function agents(page: Page) {
|
|
||||||
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
|
||||||
const current = await read(page)
|
|
||||||
if ((await variantCount(page)) >= 2) return current
|
|
||||||
|
|
||||||
const cfg = await createSdk(directory)
|
|
||||||
.config.get()
|
|
||||||
.then((x) => x.data)
|
|
||||||
const visible = new Set(await agents(page))
|
|
||||||
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
|
|
||||||
const value = item[1]
|
|
||||||
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
|
|
||||||
})
|
|
||||||
const name = entry?.[0]
|
|
||||||
test.skip(!name, "no agent with alternate variants available")
|
|
||||||
if (!name) return current
|
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, name)
|
|
||||||
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
|
|
||||||
return waitFooter(page, { agent: name })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
|
||||||
const current = await read(page)
|
|
||||||
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
|
|
||||||
if (!next) throw new Error("Current model has no alternate variant to select")
|
|
||||||
|
|
||||||
await pickVariant(page, next)
|
|
||||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
|
|
||||||
const current = await currentModel(page)
|
|
||||||
const next = (await probe(page))?.models?.find((item) => {
|
|
||||||
const key = `${item.providerID}:${item.modelID}`
|
|
||||||
return key !== current && !skip.includes(key)
|
|
||||||
})
|
|
||||||
if (!next) throw new Error("Failed to choose a different model")
|
|
||||||
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
|
|
||||||
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
|
|
||||||
return read(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
|
||||||
await waitSession(page, { directory, sessionID })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(page: Page, value: string) {
|
|
||||||
const prompt = page.locator('[data-component="prompt-input"]')
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
|
|
||||||
await withNoReplyPrompt(page, async () => {
|
|
||||||
await prompt.click()
|
|
||||||
await prompt.fill(value)
|
|
||||||
await prompt.press("Enter")
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUser(directory: string, sessionID: string) {
|
|
||||||
const sdk = createSdk(directory)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
|
||||||
return items.some((item) => item.info.role === "user")
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
|
||||||
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
|
||||||
await openSidebar(page)
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
|
||||||
await waitSession(page, { directory: next.directory })
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitWorkspace(page: Page, slug: string) {
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newWorkspaceSession(page: Page, slug: string) {
|
|
||||||
await waitWorkspace(page, slug)
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click({ force: true })
|
|
||||||
|
|
||||||
const next = await resolveSlug(await waitSlug(page))
|
|
||||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
|
||||||
const firstKey = await currentModel(page)
|
|
||||||
const first = await submit(page, `session variant ${Date.now()}`)
|
|
||||||
trackSession(first)
|
|
||||||
await waitUser(directory, first)
|
|
||||||
|
|
||||||
await page.reload()
|
|
||||||
await waitSession(page, { directory, sessionID: first })
|
|
||||||
await waitFooter(page, firstState)
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
const fresh = await read(page)
|
|
||||||
expect(fresh.model).not.toBe(firstState.model)
|
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
|
||||||
const second = await submit(page, `session model ${Date.now()}`)
|
|
||||||
trackSession(second)
|
|
||||||
await waitUser(directory, second)
|
|
||||||
|
|
||||||
await goto(page, directory, first)
|
|
||||||
await waitFooter(page, firstState)
|
|
||||||
|
|
||||||
await goto(page, directory, second)
|
|
||||||
await waitFooter(page, secondState)
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await waitFooter(page, fresh)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
|
||||||
|
|
||||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
|
||||||
const firstKey = await currentModel(page)
|
|
||||||
const first = await submit(page, `root session ${Date.now()}`)
|
|
||||||
trackSession(first, root)
|
|
||||||
await waitUser(root, first)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
|
|
||||||
const one = await createWorkspace(page, slug, [])
|
|
||||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
|
||||||
trackDirectory(oneDir)
|
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
|
||||||
const secondKey = await currentModel(page)
|
|
||||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
|
||||||
trackSession(second, oneDir)
|
|
||||||
await waitUser(oneDir, second)
|
|
||||||
|
|
||||||
const two = await createWorkspace(page, slug, [one.slug])
|
|
||||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
|
||||||
trackDirectory(twoDir)
|
|
||||||
|
|
||||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
|
||||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
|
||||||
trackSession(third, twoDir)
|
|
||||||
await waitUser(twoDir, third)
|
|
||||||
|
|
||||||
await goto(page, root, first)
|
|
||||||
await waitFooter(page, firstState)
|
|
||||||
|
|
||||||
await goto(page, oneDir, second)
|
|
||||||
await waitFooter(page, secondState)
|
|
||||||
|
|
||||||
await goto(page, twoDir, third)
|
|
||||||
await waitFooter(page, thirdState)
|
|
||||||
|
|
||||||
await goto(page, root, first)
|
|
||||||
await waitFooter(page, firstState)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await ensureVariant(page, directory)
|
|
||||||
const updated = await chooseDifferentVariant(page)
|
|
||||||
|
|
||||||
const available = await agents(page)
|
|
||||||
const other = available.find((name) => name !== updated.agent)
|
|
||||||
test.skip(!other, "only one agent available")
|
|
||||||
if (!other) return
|
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, other)
|
|
||||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, updated.agent)
|
|
||||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import { waitSessionIdle, withSession } from "../actions"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { inputMatch } from "../prompt/mock"
|
|
||||||
|
|
||||||
const count = 14
|
|
||||||
|
|
||||||
function body(mark: string) {
|
|
||||||
return [
|
|
||||||
`title ${mark}`,
|
|
||||||
`mark ${mark}`,
|
|
||||||
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function files(tag: string) {
|
|
||||||
return Array.from({ length: count }, (_, i) => {
|
|
||||||
const id = String(i).padStart(2, "0")
|
|
||||||
return {
|
|
||||||
file: `review-scroll-${id}.txt`,
|
|
||||||
mark: `${tag}-${id}`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function seed(list: ReturnType<typeof files>) {
|
|
||||||
const out = ["*** Begin Patch"]
|
|
||||||
|
|
||||||
for (const item of list) {
|
|
||||||
out.push(`*** Add File: ${item.file}`)
|
|
||||||
for (const line of body(item.mark)) out.push(`+${line}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push("*** End Patch")
|
|
||||||
return out.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit(file: string, prev: string, next: string) {
|
|
||||||
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
|
||||||
"\n",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patchWithMock(
|
|
||||||
llm: Parameters<typeof test>[0]["llm"],
|
|
||||||
sdk: Parameters<typeof withSession>[0],
|
|
||||||
sessionID: string,
|
|
||||||
patchText: string,
|
|
||||||
) {
|
|
||||||
const callsBefore = await llm.calls()
|
|
||||||
await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
|
|
||||||
await sdk.session.promptAsync({
|
|
||||||
sessionID,
|
|
||||||
agent: "build",
|
|
||||||
system: [
|
|
||||||
"You are seeding deterministic e2e UI state.",
|
|
||||||
"Your only valid response is one apply_patch tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
|
||||||
"Do not call any other tools.",
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n"),
|
|
||||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for the agent loop to actually start before checking idle.
|
|
||||||
// promptAsync is fire-and-forget — without this, waitSessionIdle can
|
|
||||||
// return immediately because the session status is still undefined.
|
|
||||||
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
|
||||||
|
|
||||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
|
||||||
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
|
||||||
await expect(btn).toBeVisible()
|
|
||||||
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
|
||||||
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
|
||||||
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
|
||||||
const open = await close
|
|
||||||
.isVisible()
|
|
||||||
.then((value) => value)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
|
||||||
if (open) {
|
|
||||||
await close.click()
|
|
||||||
await expect(btn).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(btn).toBeVisible()
|
|
||||||
await btn.click()
|
|
||||||
await expect(close).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
|
||||||
await page.waitForFunction(
|
|
||||||
({ file, mark }) => {
|
|
||||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
|
||||||
if (!(view instanceof HTMLElement)) return false
|
|
||||||
|
|
||||||
const head = Array.from(view.querySelectorAll("h3")).find(
|
|
||||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
|
||||||
)
|
|
||||||
if (!(head instanceof HTMLElement)) return false
|
|
||||||
|
|
||||||
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
|
||||||
if (!(host instanceof HTMLElement)) return false
|
|
||||||
const root = host.shadowRoot
|
|
||||||
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
{ file, mark },
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
|
||||||
return page.evaluate((file) => {
|
|
||||||
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
|
||||||
if (!(view instanceof HTMLElement)) return null
|
|
||||||
|
|
||||||
const row = Array.from(view.querySelectorAll("h3")).find(
|
|
||||||
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
|
||||||
)
|
|
||||||
if (!(row instanceof HTMLElement)) return null
|
|
||||||
|
|
||||||
const a = row.getBoundingClientRect()
|
|
||||||
const b = view.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
top: a.top - b.top,
|
|
||||||
y: view.scrollTop,
|
|
||||||
}
|
|
||||||
}, file)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
|
|
||||||
const row = page.locator(`[data-file="${file}"]`).first()
|
|
||||||
await expect(row).toBeVisible()
|
|
||||||
|
|
||||||
const line = row.locator('diffs-container [data-line="2"]').first()
|
|
||||||
await expect(line).toBeVisible()
|
|
||||||
await line.hover()
|
|
||||||
|
|
||||||
const add = row.getByRole("button", { name: /^Comment$/ }).first()
|
|
||||||
await expect(add).toBeVisible()
|
|
||||||
await add.click()
|
|
||||||
|
|
||||||
const area = row.locator('[data-slot="line-comment-textarea"]').first()
|
|
||||||
await expect(area).toBeVisible()
|
|
||||||
await area.fill(note)
|
|
||||||
|
|
||||||
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
|
||||||
await expect(submit).toBeEnabled()
|
|
||||||
await submit.click()
|
|
||||||
|
|
||||||
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
|
||||||
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
|
|
||||||
const row = page.locator(`[data-file="${file}"]`).first()
|
|
||||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
|
||||||
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
|
||||||
const tools = row.locator('[data-slot="line-comment-tools"]').first()
|
|
||||||
|
|
||||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
|
||||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
|
||||||
view.boundingBox(),
|
|
||||||
pop.boundingBox(),
|
|
||||||
tools.boundingBox(),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!viewBox || !popBox || !toolsBox) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
|
||||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
|
||||||
const row = page.locator(`[data-file="${file}"]`).first()
|
|
||||||
await expect(row).toBeVisible()
|
|
||||||
await row.hover()
|
|
||||||
|
|
||||||
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
|
||||||
await expect(open).toBeVisible()
|
|
||||||
await open.click()
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: file }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
return viewer
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
await expect(viewer).toBeVisible()
|
|
||||||
|
|
||||||
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
|
||||||
await expect(line).toBeVisible()
|
|
||||||
await line.hover()
|
|
||||||
|
|
||||||
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
|
||||||
await expect(add).toBeVisible()
|
|
||||||
await add.click()
|
|
||||||
|
|
||||||
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
|
||||||
await expect(area).toBeVisible()
|
|
||||||
await area.fill(note)
|
|
||||||
|
|
||||||
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
|
||||||
await expect(submit).toBeEnabled()
|
|
||||||
await submit.click()
|
|
||||||
|
|
||||||
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
|
||||||
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|
||||||
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
|
||||||
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
|
||||||
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
|
||||||
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
|
||||||
|
|
||||||
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
|
||||||
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
|
||||||
view.boundingBox(),
|
|
||||||
pop.boundingBox(),
|
|
||||||
tools.boundingBox(),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!viewBox || !popBox || !toolsBox) return null
|
|
||||||
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
|
||||||
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
|
|
||||||
test.setTimeout(180_000)
|
|
||||||
|
|
||||||
const tag = `review-comment-${Date.now()}`
|
|
||||||
const file = `review-comment-${tag}.txt`
|
|
||||||
const note = `comment ${tag}`
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(1)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await show(page)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
await expand(page)
|
|
||||||
await waitMark(page, file, tag)
|
|
||||||
await comment(page, file, note)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
|
|
||||||
test.setTimeout(180_000)
|
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
|
||||||
const file = `review-file-comment-${tag}.txt`
|
|
||||||
const note = `comment ${tag}`
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(1)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await show(page)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
await expand(page)
|
|
||||||
await waitMark(page, file, tag)
|
|
||||||
await openReviewFile(page, file)
|
|
||||||
await fileComment(page, note)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
|
|
||||||
test.setTimeout(180_000)
|
|
||||||
|
|
||||||
const tag = `review-${Date.now()}`
|
|
||||||
const list = files(tag)
|
|
||||||
const hit = list[list.length - 4]!
|
|
||||||
const next = `${tag}-live`
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
|
||||||
|
|
||||||
await withMockProject(async (project) => {
|
|
||||||
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
|
||||||
return info?.summary?.files ?? 0
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(list.length)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(list.length)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await show(page)
|
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
|
|
||||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
|
||||||
await expect(view).toBeVisible()
|
|
||||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
|
||||||
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
|
||||||
|
|
||||||
await expand(page)
|
|
||||||
await waitMark(page, hit.file, hit.mark)
|
|
||||||
|
|
||||||
const row = page
|
|
||||||
.getByRole("heading", {
|
|
||||||
level: 3,
|
|
||||||
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
|
||||||
})
|
|
||||||
.first()
|
|
||||||
await expect(row).toBeVisible()
|
|
||||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
|
||||||
|
|
||||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
|
||||||
const prev = await spot(page, hit.file)
|
|
||||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
|
||||||
|
|
||||||
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
const item = diff.find((item) => item.file === hit.file)
|
|
||||||
return typeof item?.after === "string" ? item.after : ""
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toContain(`mark ${next}`)
|
|
||||||
|
|
||||||
await waitMark(page, hit.file, next)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const next = await spot(page, hit.file)
|
|
||||||
if (!next) return Number.POSITIVE_INFINITY
|
|
||||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBeLessThanOrEqual(32)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { withSession } from "../actions"
|
|
||||||
import { createSdk, modKey } from "../utils"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
async function seedConversation(input: {
|
|
||||||
page: Page
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
sessionID: string
|
|
||||||
token: string
|
|
||||||
}) {
|
|
||||||
const messages = async () =>
|
|
||||||
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
|
|
||||||
const seeded = await messages()
|
|
||||||
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
|
|
||||||
|
|
||||||
const prompt = input.page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await input.sdk.session.promptAsync({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
noReply: true,
|
|
||||||
parts: [{ type: "text", text: input.token }],
|
|
||||||
})
|
|
||||||
|
|
||||||
let userMessageID: string | undefined
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const users = (await messages()).filter(
|
|
||||||
(m) =>
|
|
||||||
!userIDs.has(m.info.id) &&
|
|
||||||
m.info.role === "user" &&
|
|
||||||
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
|
|
||||||
)
|
|
||||||
if (users.length === 0) return false
|
|
||||||
|
|
||||||
const user = users[users.length - 1]
|
|
||||||
if (!user) return false
|
|
||||||
userMessageID = user.info.id
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
{ timeout: 90_000, intervals: [250, 500, 1_000] },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
|
||||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
|
|
||||||
return { prompt, userMessageID }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
const sdk = project.sdk
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
|
||||||
|
|
||||||
await seeded.prompt.click()
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(seeded.userMessageID)
|
|
||||||
|
|
||||||
await expect(seeded.prompt).toContainText(token)
|
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
const sdk = project.sdk
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
|
||||||
|
|
||||||
await seeded.prompt.click()
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(seeded.userMessageID)
|
|
||||||
|
|
||||||
await seeded.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
const sdk = project.sdk
|
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const first = await seedConversation({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
sessionID: session.id,
|
|
||||||
token: firstToken,
|
|
||||||
})
|
|
||||||
const second = await seedConversation({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
sessionID: session.id,
|
|
||||||
token: secondToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(first.userMessageID).not.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(first.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(0)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import {
|
|
||||||
openSidebar,
|
|
||||||
openSessionMoreMenu,
|
|
||||||
clickMenuItem,
|
|
||||||
confirmDialog,
|
|
||||||
openSharePopover,
|
|
||||||
withSession,
|
|
||||||
} from "../actions"
|
|
||||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
|
||||||
|
|
||||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
|
||||||
|
|
||||||
type Sdk = Parameters<typeof withSession>[0]
|
|
||||||
|
|
||||||
async function seedMessage(sdk: Sdk, sessionID: string) {
|
|
||||||
await sdk.session.promptAsync({
|
|
||||||
sessionID,
|
|
||||||
noReply: true,
|
|
||||||
parts: [{ type: "text", text: "e2e seed" }],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
|
||||||
return messages.length
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
|
|
||||||
const stamp = Date.now()
|
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withSession(project.sdk, originalTitle, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
|
||||||
await clickMenuItem(menu, /rename/i)
|
|
||||||
|
|
||||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await expect(input).toBeFocused()
|
|
||||||
await input.fill(renamedTitle)
|
|
||||||
await expect(input).toHaveValue(renamedTitle)
|
|
||||||
await input.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
|
||||||
return data?.title
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(renamedTitle)
|
|
||||||
|
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
|
|
||||||
const stamp = Date.now()
|
|
||||||
const title = `e2e archive test ${stamp}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withSession(project.sdk, title, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
|
||||||
await clickMenuItem(menu, /archive/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
|
||||||
return data?.time?.archived
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.not.toBeUndefined()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
|
|
||||||
const stamp = Date.now()
|
|
||||||
const title = `e2e delete test ${stamp}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withSession(project.sdk, title, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
|
||||||
await clickMenuItem(menu, /delete/i)
|
|
||||||
await confirmDialog(page, /delete/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session
|
|
||||||
.get({ sessionID: session.id })
|
|
||||||
.then((r) => r.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
return data?.id
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
|
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
|
||||||
|
|
||||||
const stamp = Date.now()
|
|
||||||
const title = `e2e share test ${stamp}`
|
|
||||||
|
|
||||||
await withBackendProject(async (project) => {
|
|
||||||
await withSession(project.sdk, title, async (session) => {
|
|
||||||
project.trackSession(session.id)
|
|
||||||
await seedMessage(project.sdk, session.id)
|
|
||||||
await project.gotoSession(session.id)
|
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
|
||||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
|
||||||
await publish.click()
|
|
||||||
|
|
||||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
|
||||||
return data?.share?.url || undefined
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.not.toBeUndefined()
|
|
||||||
|
|
||||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
|
||||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
|
||||||
await unpublish.click()
|
|
||||||
|
|
||||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
|
||||||
return data?.share?.url || undefined
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
const unshared = await openSharePopover(page)
|
|
||||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
|
||||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
|
||||||
import { modKey } from "../utils"
|
|
||||||
|
|
||||||
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
|
||||||
expect(initialKeybind).toContain("B")
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+KeyH`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newKeybind = await keybindButton.textContent()
|
|
||||||
expect(newKeybind).toContain("H")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
||||||
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
|
||||||
|
|
||||||
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
|
||||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
|
||||||
|
|
||||||
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
|
||||||
expect(finalClosed).toBe(initiallyClosed)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
|
||||||
expect(initialKeybind).toContain("B")
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+KeyP`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const toast = page.locator('[data-component="toast"]').last()
|
|
||||||
await expect(toast).toBeVisible()
|
|
||||||
await expect(toast).toContainText(/already/i)
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toContainText("B")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const customKeybind = await keybindButton.textContent()
|
|
||||||
expect(customKeybind).toContain("X")
|
|
||||||
|
|
||||||
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
|
|
||||||
await expect(resetButton).toBeVisible()
|
|
||||||
await expect(resetButton).toBeEnabled()
|
|
||||||
await resetButton.click()
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const restoredKeybind = await keybindButton.textContent()
|
|
||||||
expect(restoredKeybind).toContain("B")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clearing a keybind works", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
|
||||||
expect(initialKeybind).toContain("B")
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press("Delete")
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const clearedKeybind = await keybindButton.textContent()
|
|
||||||
expect(clearedKeybind).toMatch(/unassigned|press/i)
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const stillOnSession = page.url().includes("/session")
|
|
||||||
expect(stillOnSession).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing settings open keybind works", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
|
||||||
expect(initialKeybind).toContain(",")
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Slash`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newKeybind = await keybindButton.textContent()
|
|
||||||
expect(newKeybind).toContain("/")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
const settingsDialog = page.getByRole("dialog")
|
|
||||||
await expect(settingsDialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Slash`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
await expect(settingsDialog).toBeVisible()
|
|
||||||
|
|
||||||
await closeDialog(page, settingsDialog)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withSession(sdk, "test session for keybind", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const initialUrl = page.url()
|
|
||||||
expect(initialUrl).toContain(`/session/${session.id}`)
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+KeyN`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newKeybind = await keybindButton.textContent()
|
|
||||||
expect(newKeybind).toContain("N")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+N`)
|
|
||||||
await page.waitForTimeout(200)
|
|
||||||
|
|
||||||
const newUrl = page.url()
|
|
||||||
expect(newUrl).toMatch(/\/session\/?$/)
|
|
||||||
expect(newUrl).not.toContain(session.id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing file open keybind works", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
|
||||||
expect(initialKeybind).toContain("K")
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+KeyF`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newKeybind = await keybindButton.textContent()
|
|
||||||
expect(newKeybind).toContain("F")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
|
|
||||||
await expect(filePickerDialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+F`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
await expect(filePickerDialog).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(filePickerDialog).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+KeyY`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newKeybind = await keybindButton.textContent()
|
|
||||||
expect(newKeybind).toContain("Y")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
await expect(terminal).not.toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
|
||||||
await waitTerminalFocusIdle(page, { term: terminal })
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
|
||||||
await expect(terminal).not.toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+KeyY`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
await expect(keybindButton).toContainText("Y")
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
await page.reload()
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
if (!raw) return
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
return parsed?.keybinds?.["terminal.toggle"]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBe("mod+shift+y")
|
|
||||||
|
|
||||||
const reloaded = await openSettings(page)
|
|
||||||
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
|
|
||||||
await expect(reloadedKeybind).toContainText("Y")
|
|
||||||
await closeDialog(page, reloaded)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing command palette keybind works", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
|
|
||||||
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
|
|
||||||
await expect(keybindButton).toBeVisible()
|
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
|
||||||
expect(initialKeybind).toContain("P")
|
|
||||||
|
|
||||||
await keybindButton.click()
|
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+KeyK`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newKeybind = await keybindButton.textContent()
|
|
||||||
expect(newKeybind).toContain("K")
|
|
||||||
|
|
||||||
const stored = await page.evaluate(() => {
|
|
||||||
const raw = localStorage.getItem("settings.v3")
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
})
|
|
||||||
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
|
|
||||||
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
|
|
||||||
await expect(palette).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+K`)
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
await expect(palette).toBeVisible()
|
|
||||||
await expect(palette.getByRole("textbox").first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(palette).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
import { closeDialog, openSettings } from "../actions"
|
|
||||||
|
|
||||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="model.choose"]')
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const picker = page.getByRole("dialog")
|
|
||||||
await expect(picker).toBeVisible()
|
|
||||||
|
|
||||||
const target = picker.locator('[data-slot="list-item"]').first()
|
|
||||||
await expect(target).toBeVisible()
|
|
||||||
|
|
||||||
const key = await target.getAttribute("data-key")
|
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
|
||||||
|
|
||||||
const name = (await target.locator("span").first().innerText()).trim()
|
|
||||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(picker).toHaveCount(0)
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
|
|
||||||
await settings.getByRole("tab", { name: "Models" }).click()
|
|
||||||
const search = settings.getByPlaceholder("Search models")
|
|
||||||
await expect(search).toBeVisible()
|
|
||||||
await search.fill(name)
|
|
||||||
|
|
||||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
|
||||||
const input = toggle.locator('[data-slot="switch-input"]')
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
|
||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const pickerAgain = page.getByRole("dialog")
|
|
||||||
await expect(pickerAgain).toBeVisible()
|
|
||||||
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
|
|
||||||
|
|
||||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(pickerAgain).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
|
|
||||||
const command = page.locator('[data-slash-id="model.choose"]')
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const picker = page.getByRole("dialog")
|
|
||||||
await expect(picker).toBeVisible()
|
|
||||||
|
|
||||||
const target = picker.locator('[data-slot="list-item"]').first()
|
|
||||||
await expect(target).toBeVisible()
|
|
||||||
|
|
||||||
const key = await target.getAttribute("data-key")
|
|
||||||
if (!key) throw new Error("Failed to resolve model key from list item")
|
|
||||||
|
|
||||||
const name = (await target.locator("span").first().innerText()).trim()
|
|
||||||
if (!name) throw new Error("Failed to resolve model name from list item")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(picker).toHaveCount(0)
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
|
|
||||||
await settings.getByRole("tab", { name: "Models" }).click()
|
|
||||||
const search = settings.getByPlaceholder("Search models")
|
|
||||||
await expect(search).toBeVisible()
|
|
||||||
await search.fill(name)
|
|
||||||
|
|
||||||
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
|
||||||
const input = toggle.locator('[data-slot="switch-input"]')
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
|
||||||
|
|
||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "false")
|
|
||||||
|
|
||||||
await toggle.locator('[data-slot="switch-control"]').click()
|
|
||||||
await expect(input).toHaveAttribute("aria-checked", "true")
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.type("/model")
|
|
||||||
await expect(command).toBeVisible()
|
|
||||||
await command.hover()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
const pickerAgain = page.getByRole("dialog")
|
|
||||||
await expect(pickerAgain).toBeVisible()
|
|
||||||
|
|
||||||
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(pickerAgain).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { closeDialog, openSettings } from "../actions"
|
|
||||||
|
|
||||||
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
|
||||||
|
|
||||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
|
||||||
await expect(customProviderSection).toBeVisible()
|
|
||||||
|
|
||||||
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
|
|
||||||
await connectButton.click()
|
|
||||||
|
|
||||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
|
||||||
await expect(providerDialog).toBeVisible()
|
|
||||||
|
|
||||||
await providerDialog.getByLabel("Provider ID").fill("test-provider")
|
|
||||||
await providerDialog.getByLabel("Display name").fill("Test Provider")
|
|
||||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
|
|
||||||
await providerDialog.getByLabel("API key").fill("fake-key")
|
|
||||||
|
|
||||||
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
|
|
||||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
|
|
||||||
|
|
||||||
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
|
|
||||||
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
|
|
||||||
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
|
|
||||||
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
|
|
||||||
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
|
|
||||||
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(providerDialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
|
||||||
|
|
||||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
|
||||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
|
||||||
|
|
||||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
|
||||||
await expect(providerDialog).toBeVisible()
|
|
||||||
|
|
||||||
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
|
|
||||||
await providerDialog.getByLabel("Base URL").fill("not-a-url")
|
|
||||||
|
|
||||||
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
|
|
||||||
|
|
||||||
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
|
|
||||||
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(providerDialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
|
||||||
|
|
||||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
|
||||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
|
||||||
|
|
||||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
|
||||||
await expect(providerDialog).toBeVisible()
|
|
||||||
|
|
||||||
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
|
|
||||||
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
|
|
||||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
|
|
||||||
|
|
||||||
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
|
|
||||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
|
|
||||||
|
|
||||||
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
|
|
||||||
await providerDialog.getByRole("button", { name: "Add model" }).click()
|
|
||||||
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
|
|
||||||
expect(idInputsAfter).toBe(idInputsBefore + 1)
|
|
||||||
|
|
||||||
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
|
|
||||||
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
|
|
||||||
|
|
||||||
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
|
|
||||||
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(providerDialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const settings = await openSettings(page)
|
|
||||||
await settings.getByRole("tab", { name: "Providers" }).click()
|
|
||||||
|
|
||||||
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
|
|
||||||
await customProviderSection.getByRole("button", { name: "Connect" }).click()
|
|
||||||
|
|
||||||
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
|
|
||||||
await expect(providerDialog).toBeVisible()
|
|
||||||
|
|
||||||
await providerDialog.getByLabel("Provider ID").fill("header-test")
|
|
||||||
await providerDialog.getByLabel("Display name").fill("Header Test")
|
|
||||||
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
|
|
||||||
|
|
||||||
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
|
|
||||||
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
|
|
||||||
|
|
||||||
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
|
|
||||||
await providerDialog.getByRole("button", { name: "Add header" }).click()
|
|
||||||
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
|
|
||||||
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
|
|
||||||
|
|
||||||
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
|
|
||||||
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
|
|
||||||
|
|
||||||
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
|
|
||||||
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(providerDialog).toHaveCount(0)
|
|
||||||
|
|
||||||
await closeDialog(page, settings)
|
|
||||||
})
|
|
||||||
@@ -1,708 +0,0 @@
|
|||||||
import { test, expect, settingsKey } from "../fixtures"
|
|
||||||
import { closeDialog, openSettings } from "../actions"
|
|
||||||
import {
|
|
||||||
settingsColorSchemeSelector,
|
|
||||||
settingsCodeFontSelector,
|
|
||||||
settingsLanguageSelectSelector,
|
|
||||||
settingsNotificationsAgentSelector,
|
|
||||||
settingsNotificationsErrorsSelector,
|
|
||||||
settingsNotificationsPermissionsSelector,
|
|
||||||
settingsReleaseNotesSelector,
|
|
||||||
settingsSoundsAgentSelector,
|
|
||||||
settingsSoundsErrorsSelector,
|
|
||||||
settingsSoundsPermissionsSelector,
|
|
||||||
settingsThemeSelector,
|
|
||||||
settingsUIFontSelector,
|
|
||||||
settingsUpdatesStartupSelector,
|
|
||||||
} from "../selectors"
|
|
||||||
|
|
||||||
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
|
|
||||||
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
|
|
||||||
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
|
|
||||||
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing language updates settings labels", async ({ page, gotoSession }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
|
|
||||||
const heading = dialog.getByRole("heading", { level: 2 })
|
|
||||||
await expect(heading).toHaveText("General")
|
|
||||||
|
|
||||||
const select = dialog.locator(settingsLanguageSelectSelector)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
|
|
||||||
|
|
||||||
await expect(heading).toHaveText("Allgemein")
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
|
|
||||||
await expect(heading).toHaveText("General")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const select = dialog.locator(settingsColorSchemeSelector)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
|
||||||
|
|
||||||
const colorScheme = await page.evaluate(() => {
|
|
||||||
return document.documentElement.getAttribute("data-color-scheme")
|
|
||||||
})
|
|
||||||
expect(colorScheme).toBe("dark")
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
|
|
||||||
|
|
||||||
const lightColorScheme = await page.evaluate(() => {
|
|
||||||
return document.documentElement.getAttribute("data-color-scheme")
|
|
||||||
})
|
|
||||||
expect(lightColorScheme).toBe("light")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const select = dialog.locator(settingsThemeSelector)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
|
|
||||||
const currentThemeId = await page.evaluate(() => {
|
|
||||||
return document.documentElement.getAttribute("data-theme")
|
|
||||||
})
|
|
||||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
|
||||||
const count = await items.count()
|
|
||||||
expect(count).toBeGreaterThan(1)
|
|
||||||
|
|
||||||
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
|
||||||
.map((x) => x.trim())
|
|
||||||
.find((x) => x && x !== currentTheme)
|
|
||||||
expect(nextTheme).toBeTruthy()
|
|
||||||
|
|
||||||
await items.filter({ hasText: nextTheme! }).first().click()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
|
|
||||||
const storedThemeId = await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-id")
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(storedThemeId).not.toBeNull()
|
|
||||||
expect(storedThemeId).not.toBe(currentThemeId)
|
|
||||||
|
|
||||||
const dataTheme = await page.evaluate(() => {
|
|
||||||
return document.documentElement.getAttribute("data-theme")
|
|
||||||
})
|
|
||||||
expect(dataTheme).toBe(storedThemeId)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("opencode-theme-id", "oc-1")
|
|
||||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
|
||||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
|
||||||
})
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-id")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBe("oc-2")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-css-light")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBeNull()
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
return localStorage.getItem("opencode-theme-css-dark")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsCodeFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
const initialUIFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(initialFontFamily).toContain("ui-monospace")
|
|
||||||
|
|
||||||
const next = "Test Mono"
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially(next)
|
|
||||||
await expect(input).toHaveValue(next)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono: next,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const newFontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
const newUIFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(newFontFamily).toContain(next)
|
|
||||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
|
||||||
expect(newUIFamily).toBe(initialUIFamily)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsUIFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
const initialCodeFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
expect(initialFontFamily).toContain("ui-sans-serif")
|
|
||||||
|
|
||||||
const next = "Test Sans"
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially(next)
|
|
||||||
await expect(input).toHaveValue(next)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
sans: next,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const newFontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
const newCodeFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
expect(newFontFamily).toContain(next)
|
|
||||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
|
||||||
expect(newCodeFamily).toBe(initialCodeFamily)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsCodeFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially("Reset Mono")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono: "Reset Mono",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await input.clear()
|
|
||||||
await input.press("Space")
|
|
||||||
await expect(input).toHaveValue("")
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
expect(fontFamily).toContain("ui-monospace")
|
|
||||||
expect(fontFamily).not.toContain("Reset Mono")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const input = dialog.locator(settingsUIFontSelector)
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
|
|
||||||
await input.click()
|
|
||||||
await input.clear()
|
|
||||||
await input.pressSequentially("Reset Sans")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
sans: "Reset Sans",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await input.clear()
|
|
||||||
await input.press("Space")
|
|
||||||
await expect(input).toHaveValue("")
|
|
||||||
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
sans: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const fontFamily = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(fontFamily).toContain("ui-sans-serif")
|
|
||||||
expect(fontFamily).not.toContain("Reset Sans")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
|
|
||||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
|
||||||
await expect(colorSchemeSelect).toBeVisible()
|
|
||||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
|
||||||
|
|
||||||
const code = dialog.locator(settingsCodeFontSelector)
|
|
||||||
const ui = dialog.locator(settingsUIFontSelector)
|
|
||||||
await expect(code).toBeVisible()
|
|
||||||
await expect(ui).toBeVisible()
|
|
||||||
|
|
||||||
const initialMono = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
const initialSans = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const initialSettings = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
|
||||||
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
|
||||||
|
|
||||||
await code.click()
|
|
||||||
await code.clear()
|
|
||||||
await code.pressSequentially(mono)
|
|
||||||
await expect(code).toHaveValue(mono)
|
|
||||||
|
|
||||||
await ui.click()
|
|
||||||
await ui.clear()
|
|
||||||
await ui.pressSequentially(sans)
|
|
||||||
await expect(ui).toHaveValue(sans)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono,
|
|
||||||
sans,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const updatedSettings = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
const updatedMono = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
const updatedSans = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(updatedMono).toContain(mono)
|
|
||||||
expect(updatedMono).not.toBe(initialMono)
|
|
||||||
expect(updatedSans).toContain(sans)
|
|
||||||
expect(updatedSans).not.toBe(initialSans)
|
|
||||||
expect(updatedSettings?.appearance?.mono).toBe(mono)
|
|
||||||
expect(updatedSettings?.appearance?.sans).toBe(sans)
|
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
|
||||||
await page.reload()
|
|
||||||
|
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
appearance: {
|
|
||||||
mono,
|
|
||||||
sans,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const rehydratedSettings = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.toContain(mono)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.toContain(sans)
|
|
||||||
|
|
||||||
const rehydratedMono = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
|
||||||
)
|
|
||||||
const rehydratedSans = await page.evaluate(() =>
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
|
||||||
)
|
|
||||||
expect(rehydratedMono).toContain(mono)
|
|
||||||
expect(rehydratedMono).not.toBe(initialMono)
|
|
||||||
expect(rehydratedSans).toContain(sans)
|
|
||||||
expect(rehydratedSans).not.toBe(initialSans)
|
|
||||||
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
|
|
||||||
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
|
|
||||||
await expect(switchContainer).toBeVisible()
|
|
||||||
|
|
||||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
|
||||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(initialState).toBe(true)
|
|
||||||
|
|
||||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(newState).toBe(false)
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.notifications?.agent).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
|
|
||||||
await expect(switchContainer).toBeVisible()
|
|
||||||
|
|
||||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
|
||||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(initialState).toBe(true)
|
|
||||||
|
|
||||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(newState).toBe(false)
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.notifications?.permissions).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
|
|
||||||
await expect(switchContainer).toBeVisible()
|
|
||||||
|
|
||||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
|
||||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(initialState).toBe(false)
|
|
||||||
|
|
||||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(newState).toBe(true)
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.notifications?.errors).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const select = dialog.locator(settingsSoundsAgentSelector)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
|
||||||
await items.nth(2).click()
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("selecting none disables agent sound", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const select = dialog.locator(settingsSoundsAgentSelector)
|
|
||||||
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
await expect(trigger).toBeEnabled()
|
|
||||||
|
|
||||||
await trigger.click()
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
|
||||||
await expect(items.first()).toBeVisible()
|
|
||||||
await items.first().click()
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.sounds?.agentEnabled).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
|
|
||||||
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
|
|
||||||
await expect(permissionsSelect).toBeVisible()
|
|
||||||
await expect(errorsSelect).toBeVisible()
|
|
||||||
|
|
||||||
const initial = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
const permissionsCurrent =
|
|
||||||
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
|
||||||
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
const permissionItems = page.locator('[data-slot="select-select-item"]')
|
|
||||||
expect(await permissionItems.count()).toBeGreaterThan(1)
|
|
||||||
if (permissionsCurrent) {
|
|
||||||
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
|
|
||||||
}
|
|
||||||
if (!permissionsCurrent) {
|
|
||||||
await permissionItems.nth(1).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorsCurrent =
|
|
||||||
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
|
||||||
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
const errorItems = page.locator('[data-slot="select-select-item"]')
|
|
||||||
expect(await errorItems.count()).toBeGreaterThan(1)
|
|
||||||
if (errorsCurrent) {
|
|
||||||
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
|
|
||||||
}
|
|
||||||
if (!errorsCurrent) {
|
|
||||||
await errorItems.nth(1).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
})
|
|
||||||
.toMatchObject({
|
|
||||||
sounds: {
|
|
||||||
permissions: expect.any(String),
|
|
||||||
errors: expect.any(String),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
|
|
||||||
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
|
|
||||||
await expect(switchContainer).toBeVisible()
|
|
||||||
|
|
||||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
|
||||||
|
|
||||||
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
|
|
||||||
if (isDisabled) {
|
|
||||||
test.skip()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(initialState).toBe(true)
|
|
||||||
|
|
||||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(newState).toBe(false)
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.updates?.startup).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
|
||||||
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
|
|
||||||
await expect(switchContainer).toBeVisible()
|
|
||||||
|
|
||||||
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
|
|
||||||
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(initialState).toBe(true)
|
|
||||||
|
|
||||||
await switchContainer.locator('[data-slot="switch-control"]').click()
|
|
||||||
await page.waitForTimeout(100)
|
|
||||||
|
|
||||||
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
|
|
||||||
expect(newState).toBe(false)
|
|
||||||
|
|
||||||
const stored = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.general?.releaseNotes).toBe(false)
|
|
||||||
})
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import {
|
|
||||||
defocus,
|
|
||||||
cleanupSession,
|
|
||||||
cleanupTestProject,
|
|
||||||
closeSidebar,
|
|
||||||
createTestProject,
|
|
||||||
hoverSessionItem,
|
|
||||||
openSidebar,
|
|
||||||
waitSession,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector } from "../selectors"
|
|
||||||
import { dirSlug } from "../utils"
|
|
||||||
|
|
||||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
|
||||||
const stamp = Date.now()
|
|
||||||
|
|
||||||
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
|
|
||||||
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!one?.id) throw new Error("Session create did not return an id")
|
|
||||||
if (!two?.id) throw new Error("Session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession(one.id)
|
|
||||||
await closeSidebar(page)
|
|
||||||
|
|
||||||
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
|
||||||
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
await expect(project).toBeVisible()
|
|
||||||
await project.hover()
|
|
||||||
|
|
||||||
await expect(oneItem).toBeVisible()
|
|
||||||
await expect(twoItem).toBeVisible()
|
|
||||||
|
|
||||||
const item = await hoverSessionItem(page, one.id)
|
|
||||||
await item
|
|
||||||
.getByRole("button", { name: /archive/i })
|
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
await expect(twoItem).toBeVisible()
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk, sessionID: one.id })
|
|
||||||
await cleanupSession({ sdk, sessionID: two.id })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const slug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
const card = page.locator('[data-component="hover-card-content"]')
|
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
|
||||||
await project.hover()
|
|
||||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
|
||||||
|
|
||||||
await page.mouse.down()
|
|
||||||
await expect(card).toHaveCount(0)
|
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
await expect(card).toHaveCount(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const slug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
|
||||||
|
|
||||||
let hit = false
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
hit = await project.evaluate((el) => {
|
|
||||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
|
||||||
})
|
|
||||||
if (hit) break
|
|
||||||
await page.keyboard.press("Tab")
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(hit).toBe(true)
|
|
||||||
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { cleanupSession, openSidebar, withSession } from "../actions"
|
|
||||||
import { promptSelector } from "../selectors"
|
|
||||||
|
|
||||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
|
||||||
const stamp = Date.now()
|
|
||||||
|
|
||||||
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
|
|
||||||
const two = await sdk.session.create({ title: `e2e sidebar nav 2 ${stamp}` }).then((r) => r.data)
|
|
||||||
|
|
||||||
if (!one?.id) throw new Error("Session create did not return an id")
|
|
||||||
if (!two?.id) throw new Error("Session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession(one.id)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
|
||||||
await expect(target).toBeVisible()
|
|
||||||
await target.click()
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk, sessionID: one.id })
|
|
||||||
await cleanupSession({ sdk, sessionID: two.id })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { openSidebar, toggleSidebar, withSession } from "../actions"
|
|
||||||
|
|
||||||
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
|
||||||
|
|
||||||
await toggleSidebar(page)
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
await toggleSidebar(page)
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "true")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withSession(sdk, "sidebar persist session 1", async (session1) => {
|
|
||||||
await withSession(sdk, "sidebar persist session 2", async (session2) => {
|
|
||||||
await gotoSession(session1.id)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
||||||
await toggleSidebar(page)
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
await gotoSession(session2.id)
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
await page.reload()
|
|
||||||
await expect(button).toHaveAttribute("aria-expanded", "false")
|
|
||||||
|
|
||||||
const opened = await page.evaluate(
|
|
||||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
|
||||||
)
|
|
||||||
await expect(opened).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { openStatusPopover } from "../actions"
|
|
||||||
|
|
||||||
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
|
|
||||||
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
|
|
||||||
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
|
|
||||||
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
|
|
||||||
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(popoverBody).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
|
|
||||||
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
|
|
||||||
await expect(serversTab).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
const serverList = popoverBody.locator('[role="tabpanel"]').first()
|
|
||||||
await expect(serverList.locator("button").first()).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
|
|
||||||
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
|
|
||||||
await mcpTab.click()
|
|
||||||
|
|
||||||
const ariaSelected = await mcpTab.getAttribute("aria-selected")
|
|
||||||
expect(ariaSelected).toBe("true")
|
|
||||||
|
|
||||||
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
|
||||||
await expect(mcpContent).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
|
|
||||||
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
|
|
||||||
await lspTab.click()
|
|
||||||
|
|
||||||
const ariaSelected = await lspTab.getAttribute("aria-selected")
|
|
||||||
expect(ariaSelected).toBe("true")
|
|
||||||
|
|
||||||
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
|
||||||
await expect(lspContent).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
|
|
||||||
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
|
|
||||||
await pluginsTab.click()
|
|
||||||
|
|
||||||
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
|
|
||||||
expect(ariaSelected).toBe("true")
|
|
||||||
|
|
||||||
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
|
|
||||||
await expect(pluginsContent).toBeVisible()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status popover closes on escape", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
await expect(popoverBody).toBeVisible()
|
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
|
||||||
await expect(popoverBody).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const { popoverBody } = await openStatusPopover(page)
|
|
||||||
await expect(popoverBody).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
|
|
||||||
await expect(popoverBody).toHaveCount(0)
|
|
||||||
})
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
|
||||||
import { terminalToggleKey } from "../utils"
|
|
||||||
|
|
||||||
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const terminals = page.locator(terminalSelector)
|
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
|
||||||
const opened = await terminals.first().isVisible()
|
|
||||||
|
|
||||||
if (!opened) {
|
|
||||||
await page.keyboard.press(terminalToggleKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
|
||||||
await expect(terminals).toHaveCount(1)
|
|
||||||
|
|
||||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
|
||||||
// to the app shell before triggering `terminal.new`.
|
|
||||||
await page.locator(promptSelector).click()
|
|
||||||
await page.keyboard.press("Control+Alt+T")
|
|
||||||
|
|
||||||
await expect(tabs).toHaveCount(2)
|
|
||||||
await expect(terminals).toHaveCount(1)
|
|
||||||
await waitTerminalReady(page, { term: terminals.first() })
|
|
||||||
})
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { terminalSelector } from "../selectors"
|
|
||||||
import { terminalToggleKey } from "../utils"
|
|
||||||
|
|
||||||
async function open(page: Page) {
|
|
||||||
const term = page.locator(terminalSelector).first()
|
|
||||||
const visible = await term.isVisible().catch(() => false)
|
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
|
||||||
await waitTerminalReady(page, { term })
|
|
||||||
return term
|
|
||||||
}
|
|
||||||
|
|
||||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ gotoSession }) => {
|
|
||||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
|
||||||
const token = `E2E_RECONNECT_${Date.now()}`
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const term = await open(page)
|
|
||||||
const id = await term.getAttribute("data-pty-id")
|
|
||||||
if (!id) throw new Error("Active terminal missing data-pty-id")
|
|
||||||
|
|
||||||
const prev = await terminalConnects(page, { term })
|
|
||||||
|
|
||||||
await runTerminal(page, {
|
|
||||||
term,
|
|
||||||
cmd: `export ${name}=${token}; echo ${token}`,
|
|
||||||
token,
|
|
||||||
})
|
|
||||||
|
|
||||||
await disconnectTerminal(page, { term })
|
|
||||||
|
|
||||||
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
|
||||||
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
|
||||||
|
|
||||||
await runTerminal(page, {
|
|
||||||
term,
|
|
||||||
cmd: `echo $${name}`,
|
|
||||||
token,
|
|
||||||
timeout: 15_000,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { runTerminal, waitTerminalReady } from "../actions"
|
|
||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
active?: string
|
|
||||||
all: Array<{
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
titleNumber: number
|
|
||||||
buffer?: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function open(page: Page) {
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
const visible = await terminal.isVisible().catch(() => false)
|
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
|
||||||
await waitTerminalReady(page, { term: terminal })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function store(page: Page, key: string) {
|
|
||||||
return page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
if (raw) return JSON.parse(raw) as State
|
|
||||||
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const next = localStorage.key(i)
|
|
||||||
if (!next?.endsWith(":workspace:terminal")) continue
|
|
||||||
const value = localStorage.getItem(next)
|
|
||||||
if (!value) continue
|
|
||||||
return JSON.parse(value) as State
|
|
||||||
}
|
|
||||||
}, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await open(page)
|
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
|
||||||
await expect(tabs).toHaveCount(2)
|
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
|
||||||
|
|
||||||
await first.click()
|
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
|
||||||
return {
|
|
||||||
first: first.includes(one),
|
|
||||||
second: second.includes(two),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ first: false, second: true })
|
|
||||||
|
|
||||||
await second.click()
|
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
|
||||||
return {
|
|
||||||
first: first.includes(one),
|
|
||||||
second: second.includes(two),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ first: true, second: false })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await open(page)
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
|
||||||
await expect(tabs).toHaveCount(2)
|
|
||||||
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
|
||||||
await second.click()
|
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
|
||||||
|
|
||||||
await second.hover()
|
|
||||||
await page
|
|
||||||
.getByRole("button", { name: /close terminal/i })
|
|
||||||
.nth(1)
|
|
||||||
.click({ force: true })
|
|
||||||
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
|
||||||
await expect(tabs).toHaveCount(1)
|
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
return {
|
|
||||||
count: state?.all.length ?? 0,
|
|
||||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toEqual({ count: 1, first: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
|
||||||
const rename = `E2E term ${Date.now()}`
|
|
||||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
await open(page)
|
|
||||||
|
|
||||||
await expect(tab).toContainText(/Terminal 1/)
|
|
||||||
await tab.click({ button: "right" })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
|
||||||
await expect(menu).toHaveCount(0)
|
|
||||||
|
|
||||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await input.fill(rename)
|
|
||||||
await input.press("Enter")
|
|
||||||
|
|
||||||
await expect(input).toHaveCount(0)
|
|
||||||
await expect(tab).toContainText(rename)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const state = await store(page, key)
|
|
||||||
return state?.all[0]?.title
|
|
||||||
},
|
|
||||||
{ timeout: 5_000 },
|
|
||||||
)
|
|
||||||
.toBe(rename)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { test, expect } from "../fixtures"
|
|
||||||
import { waitTerminalReady } from "../actions"
|
|
||||||
import { terminalSelector } from "../selectors"
|
|
||||||
import { terminalToggleKey } from "../utils"
|
|
||||||
|
|
||||||
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
const initiallyOpen = await terminal.isVisible()
|
|
||||||
if (initiallyOpen) {
|
|
||||||
await page.keyboard.press(terminalToggleKey)
|
|
||||||
await expect(terminal).toHaveCount(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.keyboard.press(terminalToggleKey)
|
|
||||||
await waitTerminalReady(page, { term: terminal })
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { test, expect } from "./fixtures"
|
|
||||||
import { modelVariantCycleSelector } from "./selectors"
|
|
||||||
|
|
||||||
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
await page.addStyleTag({
|
|
||||||
content: `${modelVariantCycleSelector} { display: inline-block !important; }`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const button = page.locator(modelVariantCycleSelector)
|
|
||||||
const exists = (await button.count()) > 0
|
|
||||||
test.skip(!exists, "current model has no variants")
|
|
||||||
if (!exists) return
|
|
||||||
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
|
|
||||||
const before = (await button.innerText()).trim()
|
|
||||||
await button.click()
|
|
||||||
await expect(button).not.toHaveText(before)
|
|
||||||
|
|
||||||
const after = (await button.innerText()).trim()
|
|
||||||
await button.click()
|
|
||||||
await expect(button).not.toHaveText(after)
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { test } from "@playwright/test"
|
||||||
|
|
||||||
|
test(
|
||||||
|
"test something cool",
|
||||||
|
{
|
||||||
|
annotation: { type: "todo" },
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
test.fixme()
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
|
"include": ["./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
|
||||||
|
|
||||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
|
||||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|
||||||
|
|
||||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
|
||||||
export const serverName = `${serverHost}:${serverPort}`
|
|
||||||
|
|
||||||
const localHosts = ["127.0.0.1", "localhost"]
|
|
||||||
|
|
||||||
const serverLabels = (() => {
|
|
||||||
const url = new URL(serverUrl)
|
|
||||||
if (!localHosts.includes(url.hostname)) return [serverName]
|
|
||||||
return localHosts.map((host) => `${host}:${url.port}`)
|
|
||||||
})()
|
|
||||||
|
|
||||||
export const serverNames = [...new Set(serverLabels)]
|
|
||||||
|
|
||||||
export const serverUrls = serverNames.map((name) => `http://${name}`)
|
|
||||||
|
|
||||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
||||||
|
|
||||||
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
|
|
||||||
|
|
||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
|
||||||
|
|
||||||
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
|
||||||
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
|
||||||
return createSdk(directory, baseUrl)
|
|
||||||
.path.get()
|
|
||||||
.then((x) => x.data?.directory ?? directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWorktree(baseUrl = serverUrl) {
|
|
||||||
const sdk = createSdk(undefined, baseUrl)
|
|
||||||
const result = await sdk.path.get()
|
|
||||||
const data = result.data
|
|
||||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
|
||||||
return data.worktree
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dirSlug(directory: string) {
|
|
||||||
return base64Encode(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dirPath(directory: string) {
|
|
||||||
return `/${dirSlug(directory)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionPath(directory: string, sessionID?: string) {
|
|
||||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function workspacePersistKey(directory: string, key: string) {
|
|
||||||
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
|
||||||
const sum = checksum(directory) ?? "0"
|
|
||||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.13",
|
"version": "1.4.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -15,17 +15,18 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "bun run test:unit",
|
||||||
|
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "bun script/e2e-local.ts",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@happy-dom/global-registrator": "20.0.11",
|
"@happy-dom/global-registrator": "20.0.11",
|
||||||
"@playwright/test": "1.57.0",
|
"@playwright/test": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
|
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
||||||
|
|
||||||
|
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
||||||
|
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
@@ -19,7 +24,7 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers,
|
workers,
|
||||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
reporter,
|
||||||
webServer: {
|
webServer: {
|
||||||
command,
|
command,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import fs from "node:fs/promises"
|
|
||||||
import net from "node:net"
|
|
||||||
import os from "node:os"
|
|
||||||
import path from "node:path"
|
|
||||||
|
|
||||||
async function freePort() {
|
|
||||||
return await new Promise<number>((resolve, reject) => {
|
|
||||||
const server = net.createServer()
|
|
||||||
server.once("error", reject)
|
|
||||||
server.listen(0, () => {
|
|
||||||
const address = server.address()
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
server.close(() => reject(new Error("Failed to acquire a free port")))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
server.close((err) => {
|
|
||||||
if (err) {
|
|
||||||
reject(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve(address.port)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForHealth(url: string) {
|
|
||||||
const timeout = Date.now() + 120_000
|
|
||||||
const errors: string[] = []
|
|
||||||
while (Date.now() < timeout) {
|
|
||||||
const result = await fetch(url)
|
|
||||||
.then((r) => ({ ok: r.ok, error: undefined }))
|
|
||||||
.catch((error) => ({
|
|
||||||
ok: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
}))
|
|
||||||
if (result.ok) return
|
|
||||||
if (result.error) errors.push(result.error)
|
|
||||||
await new Promise((r) => setTimeout(r, 250))
|
|
||||||
}
|
|
||||||
const last = errors.length ? ` (last error: ${errors[errors.length - 1]})` : ""
|
|
||||||
throw new Error(`Timed out waiting for server health: ${url}${last}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const appDir = process.cwd()
|
|
||||||
const repoDir = path.resolve(appDir, "../..")
|
|
||||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
|
||||||
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
if (args[0] === "--") return args.slice(1)
|
|
||||||
return args
|
|
||||||
})()
|
|
||||||
|
|
||||||
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
|
||||||
|
|
||||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
|
||||||
const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1"
|
|
||||||
|
|
||||||
const serverEnv = {
|
|
||||||
...process.env,
|
|
||||||
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
|
||||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
|
||||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
|
||||||
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
|
||||||
XDG_DATA_HOME: path.join(sandbox, "share"),
|
|
||||||
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
|
||||||
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
|
||||||
XDG_STATE_HOME: path.join(sandbox, "state"),
|
|
||||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
|
||||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
|
||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
|
||||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
|
||||||
OPENCODE_CLIENT: "app",
|
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
|
||||||
} satisfies Record<string, string>
|
|
||||||
|
|
||||||
const runnerEnv = {
|
|
||||||
...serverEnv,
|
|
||||||
PLAYWRIGHT_SERVER_HOST: "127.0.0.1",
|
|
||||||
PLAYWRIGHT_SERVER_PORT: String(serverPort),
|
|
||||||
VITE_OPENCODE_SERVER_HOST: "127.0.0.1",
|
|
||||||
VITE_OPENCODE_SERVER_PORT: String(serverPort),
|
|
||||||
PLAYWRIGHT_PORT: String(webPort),
|
|
||||||
} satisfies Record<string, string>
|
|
||||||
|
|
||||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
|
||||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
|
||||||
let server: { stop: () => Promise<void> | void } | undefined
|
|
||||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
|
||||||
let cleaned = false
|
|
||||||
|
|
||||||
const cleanup = async () => {
|
|
||||||
if (cleaned) return
|
|
||||||
cleaned = true
|
|
||||||
|
|
||||||
if (seed && seed.exitCode === null) seed.kill("SIGTERM")
|
|
||||||
if (runner && runner.exitCode === null) runner.kill("SIGTERM")
|
|
||||||
|
|
||||||
const jobs = [
|
|
||||||
inst?.Instance.disposeAll(),
|
|
||||||
server?.stop(),
|
|
||||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
|
||||||
].filter(Boolean)
|
|
||||||
await Promise.allSettled(jobs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const shutdown = (code: number, reason: string) => {
|
|
||||||
process.exitCode = code
|
|
||||||
void cleanup().finally(() => {
|
|
||||||
console.error(`e2e-local shutdown: ${reason}`)
|
|
||||||
process.exit(code)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reportInternalError = (reason: string, error: unknown) => {
|
|
||||||
console.warn(`e2e-local ignored server error: ${reason}`)
|
|
||||||
console.warn(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
process.once("SIGINT", () => shutdown(130, "SIGINT"))
|
|
||||||
process.once("SIGTERM", () => shutdown(143, "SIGTERM"))
|
|
||||||
process.once("SIGHUP", () => shutdown(129, "SIGHUP"))
|
|
||||||
process.once("uncaughtException", (error) => {
|
|
||||||
reportInternalError("uncaughtException", error)
|
|
||||||
})
|
|
||||||
process.once("unhandledRejection", (error) => {
|
|
||||||
reportInternalError("unhandledRejection", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
let code = 1
|
|
||||||
|
|
||||||
try {
|
|
||||||
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
|
||||||
cwd: opencodeDir,
|
|
||||||
env: serverEnv,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
})
|
|
||||||
|
|
||||||
const seedExit = await seed.exited
|
|
||||||
if (seedExit !== 0) {
|
|
||||||
code = seedExit
|
|
||||||
} else {
|
|
||||||
Object.assign(process.env, serverEnv)
|
|
||||||
process.env.AGENT = "1"
|
|
||||||
process.env.OPENCODE = "1"
|
|
||||||
process.env.OPENCODE_PID = String(process.pid)
|
|
||||||
|
|
||||||
const log = await import("../../opencode/src/util/log")
|
|
||||||
const install = await import("../../opencode/src/installation")
|
|
||||||
await log.Log.init({
|
|
||||||
print: true,
|
|
||||||
dev: install.Installation.isLocal(),
|
|
||||||
level: "WARN",
|
|
||||||
})
|
|
||||||
|
|
||||||
const servermod = await import("../../opencode/src/server/server")
|
|
||||||
inst = await import("../../opencode/src/project/instance")
|
|
||||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
|
||||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
|
||||||
|
|
||||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
|
||||||
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
|
||||||
cwd: appDir,
|
|
||||||
env: runnerEnv,
|
|
||||||
stdout: "inherit",
|
|
||||||
stderr: "inherit",
|
|
||||||
})
|
|
||||||
code = await runner.exited
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
code = 1
|
|
||||||
} finally {
|
|
||||||
await cleanup()
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(code)
|
|
||||||
@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
if (checkMode() === "background" || type === "http") return false
|
if (checkMode() === "background" || type === "http") return false
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
|
||||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -93,25 +94,31 @@ export function ModelSelectorPopover(props: {
|
|||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: ValidComponent
|
triggerAs?: ValidComponent
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: Dismiss | null
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const handleManage = () => {
|
const close = (dismiss: Dismiss) => {
|
||||||
|
setStore("dismiss", dismiss)
|
||||||
setStore("open", false)
|
setStore("open", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManage = () => {
|
||||||
|
close("manage")
|
||||||
void import("./dialog-manage-models").then((x) => {
|
void import("./dialog-manage-models").then((x) => {
|
||||||
dialog.show(() => <x.DialogManageModels />)
|
dialog.show(() => <x.DialogManageModels />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
setStore("open", false)
|
close("provider")
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
})
|
})
|
||||||
@@ -136,21 +143,19 @@ export function ModelSelectorPopover(props: {
|
|||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
close("escape")
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={() => {
|
onPointerDownOutside={() => close("outside")}
|
||||||
setStore("dismiss", "outside")
|
onFocusOutside={() => close("outside")}
|
||||||
setStore("open", false)
|
|
||||||
}}
|
|
||||||
onFocusOutside={() => {
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
if (store.dismiss === "outside") event.preventDefault()
|
const dismiss = store.dismiss
|
||||||
|
if (dismiss === "outside") event.preventDefault()
|
||||||
|
if (dismiss === "escape" || dismiss === "select") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onClose?.(dismiss)
|
||||||
|
}
|
||||||
setStore("dismiss", null)
|
setStore("dismiss", null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -158,7 +163,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
<ModelList
|
<ModelList
|
||||||
provider={props.provider}
|
provider={props.provider}
|
||||||
model={props.model}
|
model={props.model}
|
||||||
onSelect={() => setStore("open", false)}
|
onSelect={() => close("select")}
|
||||||
class="p-1"
|
class="p-1"
|
||||||
action={
|
action={
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||||
@@ -243,23 +242,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
const tip = () => {
|
|
||||||
if (working()) {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -297,6 +279,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
})
|
})
|
||||||
|
const blank = createMemo(() => {
|
||||||
|
const text = prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
||||||
|
})
|
||||||
|
const stopping = createMemo(() => working() && blank())
|
||||||
|
const tip = () => {
|
||||||
|
if (stopping()) {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const contextItems = createMemo(() => {
|
const contextItems = createMemo(() => {
|
||||||
const items = prompt.context.items()
|
const items = prompt.context.items()
|
||||||
@@ -502,6 +509,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return getCursorPosition(editorRef)
|
return getCursorPosition(editorRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoreFocus = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||||
|
editorRef.focus()
|
||||||
|
setCursorPosition(editorRef, cursor)
|
||||||
|
queueScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const renderEditorWithCursor = (parts: Prompt) => {
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
const cursor = currentCursor()
|
const cursor = currentCursor()
|
||||||
renderEditor(parts)
|
renderEditor(parts)
|
||||||
@@ -622,7 +638,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
promptProbe.select(cmd.id)
|
|
||||||
closePopover()
|
closePopover()
|
||||||
const images = imageAttachments()
|
const images = imageAttachments()
|
||||||
|
|
||||||
@@ -711,21 +726,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (promptEnabled()) {
|
|
||||||
createEffect(() => {
|
|
||||||
promptProbe.set({
|
|
||||||
popover: store.popover,
|
|
||||||
slash: {
|
|
||||||
active: slashActive() ?? null,
|
|
||||||
ids: slashFlat().map((cmd) => cmd.id),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => promptProbe.clear())
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectPopoverActive = () => {
|
const selectPopoverActive = () => {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
const items = atFlat()
|
const items = atFlat()
|
||||||
@@ -1062,17 +1062,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
const acceptLabel = createMemo(() =>
|
|
||||||
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
|
|
||||||
)
|
|
||||||
const toggleAccept = () => {
|
|
||||||
if (!params.id) {
|
|
||||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
@@ -1316,11 +1305,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
if (
|
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||||
target.closest(
|
|
||||||
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editorRef?.focus()
|
editorRef?.focus()
|
||||||
@@ -1398,17 +1383,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 pointer-events-auto">
|
<div class="flex items-center gap-1 pointer-events-auto">
|
||||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
disabled={store.mode !== "normal" || (!working() && blank())}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={stopping() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
style={buttons()}
|
style={buttons()}
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1471,7 +1456,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
size="normal"
|
size="normal"
|
||||||
options={agentNames()}
|
options={agentNames()}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={local.agent.current()?.name ?? ""}
|
||||||
onSelect={local.agent.set}
|
onSelect={(value) => {
|
||||||
|
local.agent.set(value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
triggerStyle={control()}
|
triggerStyle={control()}
|
||||||
@@ -1480,28 +1468,62 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
<div data-component="prompt-model-control">
|
<Show when={store.mode !== "shell"}>
|
||||||
<Show
|
<div data-component="prompt-model-control">
|
||||||
when={providers.paid().length > 0}
|
<Show
|
||||||
fallback={
|
when={providers.paid().length > 0}
|
||||||
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-model"
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
|
style={control()}
|
||||||
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.choose")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<Button
|
<ModelSelectorPopover
|
||||||
data-action="prompt-model"
|
model={local.model}
|
||||||
as="div"
|
triggerAs={Button}
|
||||||
variant="ghost"
|
triggerProps={{
|
||||||
size="normal"
|
variant: "ghost",
|
||||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
size: "normal",
|
||||||
style={control()}
|
style: control(),
|
||||||
onClick={() => {
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
"data-action": "prompt-model",
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
|
onClose={restoreFocus}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1514,85 +1536,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
|
<div data-component="prompt-variant-control">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.variant.cycle")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<Select
|
||||||
model={local.model}
|
size="normal"
|
||||||
triggerAs={Button}
|
options={variants()}
|
||||||
triggerProps={{
|
current={local.model.variant.current() ?? "default"}
|
||||||
variant: "ghost",
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
size: "normal",
|
onSelect={(value) => {
|
||||||
style: control(),
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
restoreFocus()
|
||||||
"data-action": "prompt-model",
|
|
||||||
}}
|
}}
|
||||||
>
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
<ProviderIcon
|
triggerStyle={control()}
|
||||||
id={local.model.current()?.provider?.id ?? ""}
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
variant="ghost"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
/>
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
<div data-component="prompt-variant-control">
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="normal"
|
|
||||||
options={variants()}
|
|
||||||
current={local.model.variant.current() ?? "default"}
|
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
|
||||||
triggerStyle={control()}
|
|
||||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={acceptLabel()}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="prompt-permissions"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={toggleAccept}
|
|
||||||
classList={{
|
|
||||||
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
|
|
||||||
"text-text-base": !accepting(),
|
|
||||||
"hover:bg-surface-success-base": accepting(),
|
|
||||||
}}
|
|
||||||
style={control()}
|
|
||||||
aria-label={acceptLabel()}
|
|
||||||
aria-pressed={accepting()}
|
|
||||||
>
|
|
||||||
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
||||||
type PromptImageAttachmentsProps = {
|
type PromptImageAttachmentsProps = {
|
||||||
@@ -22,34 +23,36 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||||
<For each={props.attachments}>
|
<For each={props.attachments}>
|
||||||
{(attachment) => (
|
{(attachment) => (
|
||||||
<div class="relative group">
|
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||||
<Show
|
<div class="relative group">
|
||||||
when={attachment.mime.startsWith("image/")}
|
<Show
|
||||||
fallback={
|
when={attachment.mime.startsWith("image/")}
|
||||||
<div class={fallbackClass}>
|
fallback={
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
<div class={fallbackClass}>
|
||||||
</div>
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
<img
|
>
|
||||||
src={attachment.dataUrl}
|
<img
|
||||||
alt={attachment.filename}
|
src={attachment.dataUrl}
|
||||||
class={imageClass}
|
alt={attachment.filename}
|
||||||
onClick={() => props.onOpen(attachment)}
|
class={imageClass}
|
||||||
/>
|
onClick={() => props.onOpen(attachment)}
|
||||||
</Show>
|
/>
|
||||||
<button
|
</Show>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
type="button"
|
||||||
class={removeClass}
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
aria-label={props.removeLabel}
|
class={removeClass}
|
||||||
>
|
aria-label={props.removeLabel}
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
>
|
||||||
</button>
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
<div class={nameClass}>
|
</button>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<div class={nameClass}>
|
||||||
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ beforeAll(async () => {
|
|||||||
add: (value: {
|
add: (value: {
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
}) => {
|
}) => {
|
||||||
optimistic.push(value)
|
optimistic.push(value)
|
||||||
optimisticSeeded.push(
|
optimisticSeeded.push(
|
||||||
@@ -310,8 +310,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
message: {
|
message: {
|
||||||
agent: "agent",
|
agent: "agent",
|
||||||
model: { providerID: "provider", modelID: "model" },
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
variant: "high",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -120,8 +120,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: { ...input.draft.model, variant: input.draft.variant },
|
||||||
variant: input.draft.variant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = () =>
|
const add = () =>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
sansInput,
|
sansInput,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
@@ -64,7 +67,9 @@ const playDemoSound = (id: string | undefined) => {
|
|||||||
export const SettingsGeneral: Component = () => {
|
export const SettingsGeneral: Component = () => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -76,6 +81,31 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value) return false
|
||||||
|
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||||
|
return permission.isAutoAccepting(params.id, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAccept = (checked: boolean) => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
if (!params.id) {
|
||||||
|
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||||
|
permission.toggleAutoAcceptDirectory(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
permission.enableAutoAccept(params.id, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.disableAutoAccept(params.id, value)
|
||||||
|
}
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
@@ -139,11 +169,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||||
])
|
])
|
||||||
|
|
||||||
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
|
||||||
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
|
||||||
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
const languageOptions = createMemo(() =>
|
||||||
language.locales.map((locale) => ({
|
language.locales.map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
@@ -206,6 +231,15 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-auto-accept-permissions">
|
||||||
|
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||||
@@ -241,24 +275,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.followup.title")}
|
|
||||||
description={language.t("settings.general.row.followup.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-followup"
|
|
||||||
options={followupOptions()}
|
|
||||||
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
|
|
||||||
@@ -174,10 +173,10 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const auth = server.current?.http
|
const auth = server.current?.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
const password = auth?.password ?? ""
|
const password = auth?.password ?? ""
|
||||||
|
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
const probe = terminalProbe(id)
|
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
restore &&
|
restore &&
|
||||||
@@ -348,9 +347,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
probe.init()
|
|
||||||
cleanups.push(() => probe.drop())
|
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const loaded = await loadGhostty()
|
const loaded = await loadGhostty()
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
@@ -380,8 +376,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
term = t
|
term = t
|
||||||
output = terminalWriter((data, done) =>
|
output = terminalWriter((data, done) =>
|
||||||
t.write(data, () => {
|
t.write(data, () => {
|
||||||
probe.render(data)
|
|
||||||
probe.settle()
|
|
||||||
done?.()
|
done?.()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -519,8 +513,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
next.searchParams.set("directory", directory)
|
next.searchParams.set("directory", directory)
|
||||||
next.searchParams.set("cursor", String(seek))
|
next.searchParams.set("cursor", String(seek))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
next.username = username
|
if (!sameOrigin && password) {
|
||||||
next.password = password
|
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||||
|
// For same-origin requests, let the browser reuse the page's existing auth.
|
||||||
|
next.username = username
|
||||||
|
next.password = password
|
||||||
|
}
|
||||||
|
|
||||||
const socket = new WebSocket(next)
|
const socket = new WebSocket(next)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
@@ -529,7 +527,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
tries = 0
|
tries = 0
|
||||||
probe.connect()
|
|
||||||
local.onConnect?.()
|
local.onConnect?.()
|
||||||
scheduleSize(t.cols, t.rows)
|
scheduleSize(t.cols, t.rows)
|
||||||
}
|
}
|
||||||
@@ -594,13 +591,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
socket.addEventListener("close", handleClose)
|
socket.addEventListener("close", handleClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
probe.control({
|
|
||||||
disconnect: () => {
|
|
||||||
if (!ws) return
|
|
||||||
ws.close(4_000, "e2e")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
open()
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +630,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
<div
|
<div
|
||||||
ref={container}
|
ref={container}
|
||||||
data-component="terminal"
|
data-component="terminal"
|
||||||
{...{ [terminalAttr]: id }}
|
|
||||||
data-prevent-autofocus
|
data-prevent-autofocus
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ "background-color": terminalColors().background }}
|
style={{ "background-color": terminalColors().background }}
|
||||||
|
|||||||
@@ -155,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
const directory = event.directory ?? "global"
|
||||||
const payload = event.payload
|
if (event.payload.type === "sync") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = event.payload as Event
|
||||||
|
|
||||||
const k = key(directory, payload)
|
const k = key(directory, payload)
|
||||||
if (k) {
|
if (k) {
|
||||||
const i = coalesced.get(k)
|
const i = coalesced.get(k)
|
||||||
|
|||||||
@@ -122,20 +122,21 @@ export async function bootstrapGlobal(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
await runAll(fast)
|
||||||
showErrors({
|
// showErrors({
|
||||||
errors: errors(await runAll(fast)),
|
// errors: errors(await runAll(fast)),
|
||||||
title: input.requestFailedTitle,
|
// title: input.requestFailedTitle,
|
||||||
translate: input.translate,
|
// translate: input.translate,
|
||||||
formatMoreCount: input.formatMoreCount,
|
// formatMoreCount: input.formatMoreCount,
|
||||||
})
|
// })
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
showErrors({
|
await runAll(slow)
|
||||||
errors: errors(await runAll(slow)),
|
// showErrors({
|
||||||
title: input.requestFailedTitle,
|
// errors: errors(),
|
||||||
translate: input.translate,
|
// title: input.requestFailedTitle,
|
||||||
formatMoreCount: input.formatMoreCount,
|
// translate: input.translate,
|
||||||
})
|
// formatMoreCount: input.formatMoreCount,
|
||||||
|
// })
|
||||||
input.setGlobalStore("ready", true)
|
input.setGlobalStore("ready", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +249,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.sdk.vcs.get().then((x) => {
|
input.sdk.vcs.get().then((x) => {
|
||||||
const next = x.data ?? input.store.vcs
|
const next = x.data ?? input.store.vcs
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
|
|||||||
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
const [store, setStore] = createStore(baseState())
|
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
const [cacheStore, setCacheStore] = createStore({
|
||||||
|
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||||
|
})
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type {
|
import type {
|
||||||
FileDiff,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
@@ -9,11 +8,13 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -161,8 +162,8 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: FileDiff[] }
|
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
@@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = (event.properties as { info: Message }).info
|
const info = clean((event.properties as { info: Message }).info)
|
||||||
const messages = input.store.message[info.sessionID]
|
const messages = input.store.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
input.setStore("message", info.sessionID, [info])
|
input.setStore("message", info.sessionID, [info])
|
||||||
@@ -271,9 +272,9 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vcs.branch.updated": {
|
case "vcs.branch.updated": {
|
||||||
const props = event.properties as { branch: string }
|
const props = event.properties as { branch?: string }
|
||||||
if (input.store.vcs?.branch === props.branch) break
|
if (input.store.vcs?.branch === props.branch) break
|
||||||
const next = { branch: props.branch }
|
const next = { ...input.store.vcs, branch: props.branch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type {
|
||||||
FileDiff,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
@@ -33,7 +33,7 @@ describe("app session cache", () => {
|
|||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
@@ -64,7 +64,7 @@ describe("app session cache", () => {
|
|||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
FileDiff,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
|
|||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
Command,
|
Command,
|
||||||
Config,
|
Config,
|
||||||
FileDiff,
|
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
Message,
|
Message,
|
||||||
@@ -14,6 +13,7 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -48,7 +48,7 @@ export type State = {
|
|||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
}
|
}
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: FileDiff[]
|
[sessionID: string]: SnapshotFileDiff[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
|
import { batch, createEffect, createMemo } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { modelEnabled, modelProbe } from "@/testing/model-selection"
|
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
|
|
||||||
export type ModelKey = { providerID: string; modelID: string }
|
export type ModelKey = { providerID: string; modelID: string; variant?: string }
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -373,7 +372,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
handoff.set(handoffKey(dir, session), next)
|
handoff.set(handoffKey(dir, session), next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
},
|
},
|
||||||
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
|
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
@@ -383,58 +382,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setSaved("session", session, {
|
setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
model: msg.model,
|
model: msg.model,
|
||||||
variant: msg.variant ?? null,
|
variant: msg.model.variant ?? null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelEnabled()) {
|
|
||||||
const probe = Symbol("model-probe")
|
|
||||||
|
|
||||||
modelProbe.bind(probe, {
|
|
||||||
setAgent: agent.set,
|
|
||||||
setModel: model.set,
|
|
||||||
setVariant: model.variant.set,
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const agent = result.agent.current()
|
|
||||||
const model = result.model.current()
|
|
||||||
modelProbe.set(probe, {
|
|
||||||
dir: sdk.directory,
|
|
||||||
sessionID: id(),
|
|
||||||
last: store.last,
|
|
||||||
agent: agent?.name,
|
|
||||||
model: model
|
|
||||||
? {
|
|
||||||
providerID: model.provider.id,
|
|
||||||
modelID: model.id,
|
|
||||||
name: model.name,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
variant: result.model.variant.current() ?? null,
|
|
||||||
selected: result.model.variant.selected(),
|
|
||||||
configured: result.model.variant.configured(),
|
|
||||||
pick: scope(),
|
|
||||||
base: undefined,
|
|
||||||
current: store.current,
|
|
||||||
variants: result.model.variant.list(),
|
|
||||||
models: result.model
|
|
||||||
.list()
|
|
||||||
.filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
|
|
||||||
.map((item) => ({
|
|
||||||
providerID: item.provider.id,
|
|
||||||
modelID: item.id,
|
|
||||||
name: item.name,
|
|
||||||
})),
|
|
||||||
agents: result.agent.list().map((item) => ({ name: item.name })),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => modelProbe.clear(probe))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (store.general?.followup !== "queue") return
|
||||||
|
setStore("general", "followup", "steer")
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
get current() {
|
get current() {
|
||||||
@@ -150,9 +155,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setReleaseNotes(value: boolean) {
|
setReleaseNotes(value: boolean) {
|
||||||
setStore("general", "releaseNotes", value)
|
setStore("general", "releaseNotes", value)
|
||||||
},
|
},
|
||||||
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
|
followup: withFallback(
|
||||||
|
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
||||||
|
defaultSettings.general.followup,
|
||||||
|
),
|
||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value)
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||||
},
|
},
|
||||||
showReasoningSummaries: withFallback(
|
showReasoningSummaries: withFallback(
|
||||||
() => store.general?.showReasoningSummaries,
|
() => store.general?.showReasoningSummaries,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
|
|||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||||
return {
|
return {
|
||||||
@@ -416,8 +417,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: { ...input.model, variant: input.variant },
|
||||||
variant: input.variant,
|
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
||||||
@@ -510,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ export const dict = {
|
|||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
"prompt.mode.normal": "Prompt",
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc to exit",
|
"prompt.mode.shell.exit": "esc to exit",
|
||||||
|
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
|
||||||
|
"session.child.backToParent": "Back to main session.",
|
||||||
|
|
||||||
"prompt.example.1": "Fix a TODO in the codebase",
|
"prompt.example.1": "Fix a TODO in the codebase",
|
||||||
"prompt.example.2": "What is the tech stack of this project?",
|
"prompt.example.2": "What is the tech stack of this project?",
|
||||||
@@ -535,6 +537,8 @@ export const dict = {
|
|||||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
|
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||||
|
"session.review.noBranchChanges": "No branch changes yet",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
@keyframes session-progress-whip {
|
||||||
|
0% {
|
||||||
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
|
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
48% {
|
||||||
|
clip-path: inset(0 0 0 0 round 999px);
|
||||||
|
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
clip-path: inset(0 0 0 100% round 999px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress"][data-state="hiding"] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress-bar"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--session-progress-color);
|
||||||
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
|
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
|
||||||
|
will-change: clip-path;
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="getting-started"] {
|
[data-component="getting-started"] {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
container-name: getting-started;
|
container-name: getting-started;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
import { Logo } from "@opencode-ai/ui/logo"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Component, Show, onMount } from "solid-js"
|
import { Component, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import type { E2EWindow } from "@/testing/terminal"
|
|
||||||
|
|
||||||
export type InitError = {
|
export type InitError = {
|
||||||
name: string
|
name: string
|
||||||
@@ -227,13 +226,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
|||||||
actionError: undefined as string | undefined,
|
actionError: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const win = window as E2EWindow
|
|
||||||
if (!win.__opencode_e2e) return
|
|
||||||
const detail = formatError(props.error, language.t)
|
|
||||||
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
setStore("checking", true)
|
setStore("checking", true)
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory,
|
autoselect: !initialDirectory,
|
||||||
busyWorkspaces: {} as Record<string, boolean>,
|
busyWorkspaces: {} as Record<string, boolean>,
|
||||||
hoverSession: undefined as string | undefined,
|
|
||||||
hoverProject: undefined as string | undefined,
|
hoverProject: undefined as string | undefined,
|
||||||
scrollSessionKey: undefined as string | undefined,
|
scrollSessionKey: undefined as string | undefined,
|
||||||
nav: undefined as HTMLElement | undefined,
|
nav: undefined as HTMLElement | undefined,
|
||||||
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
onActivate: (directory) => {
|
onActivate: (directory) => {
|
||||||
globalSync.child(directory)
|
globalSync.child(directory)
|
||||||
setState("hoverProject", directory)
|
setState("hoverProject", directory)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
aim.reset()
|
aim.reset()
|
||||||
}
|
}
|
||||||
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
||||||
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
|
||||||
|
|
||||||
const disarm = () => {
|
const disarm = () => {
|
||||||
if (navLeave.current === undefined) return
|
if (navLeave.current === undefined) return
|
||||||
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
disarm()
|
disarm()
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navLeave.current = window.setTimeout(() => {
|
navLeave.current = window.setTimeout(() => {
|
||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navList: currentSessions,
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
nav: () => state.nav,
|
|
||||||
hoverSession: () => state.hoverSession,
|
|
||||||
setHoverSession,
|
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
sidebarOpened: () => layout.sidebar.opened(),
|
sidebarOpened: () => layout.sidebar.opened(),
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
hoverProject: () => state.hoverProject,
|
hoverProject: () => state.hoverProject,
|
||||||
nav: () => state.nav,
|
|
||||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||||
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
sessionProps: {
|
sessionProps: {
|
||||||
navList: currentSessions,
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
|
||||||
nav: () => state.nav,
|
|
||||||
hoverSession: () => state.hoverSession,
|
|
||||||
setHoverSession,
|
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
},
|
},
|
||||||
setHoverSession,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarPanel = (panelProps: {
|
const SidebarPanel = (panelProps: {
|
||||||
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||||
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
|
||||||
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const item = project()
|
const item = project()
|
||||||
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={project()!}
|
project={project()!}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
popover={popover()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={project()!}
|
project={project()!}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
popover={popover()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
|
childSessionOnPath,
|
||||||
displayName,
|
displayName,
|
||||||
effectiveWorkspaceOrder,
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
|
|||||||
expect(result?.id).toBe("root")
|
expect(result?.id).toBe("root")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("finds the direct child on the active session path", () => {
|
||||||
|
const list = [
|
||||||
|
session({ id: "root", directory: "/workspace" }),
|
||||||
|
session({ id: "child", directory: "/workspace", parentID: "root" }),
|
||||||
|
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
|
||||||
|
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
|
||||||
|
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
|
||||||
|
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("formats fallback project display name", () => {
|
test("formats fallback project display name", () => {
|
||||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||||
|
|||||||
@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
|
|||||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[] | undefined) => {
|
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
|
||||||
const map = new Map<string, string[]>()
|
if (!activeID || activeID === rootID) return
|
||||||
for (const session of sessions ?? []) {
|
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
||||||
if (!session.parentID) continue
|
let id = activeID
|
||||||
const existing = map.get(session.parentID)
|
|
||||||
if (existing) {
|
while (id) {
|
||||||
existing.push(session.id)
|
const session = map.get(id)
|
||||||
continue
|
if (!session?.parentID) return
|
||||||
}
|
if (session.parentID === rootID) return session
|
||||||
map.set(session.parentID, [session.id])
|
id = session.parentID
|
||||||
}
|
}
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||||
import { hasProjectPermissions } from "./helpers"
|
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
||||||
|
|
||||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||||
|
|
||||||
@@ -38,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
|||||||
)
|
)
|
||||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||||
<div class="size-full rounded overflow-clip">
|
<div class="size-full rounded overflow-clip">
|
||||||
@@ -72,13 +71,10 @@ export type SessionItemProps = {
|
|||||||
slug: string
|
slug: string
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
dense?: boolean
|
dense?: boolean
|
||||||
popover?: boolean
|
showTooltip?: boolean
|
||||||
children: Map<string, string[]>
|
showChild?: boolean
|
||||||
|
level?: number
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
@@ -94,112 +90,52 @@ const SessionRow = (props: {
|
|||||||
hasPermissions: Accessor<boolean>
|
hasPermissions: Accessor<boolean>
|
||||||
hasError: Accessor<boolean>
|
hasError: Accessor<boolean>
|
||||||
unseenCount: Accessor<number>
|
unseenCount: Accessor<number>
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
warmHover: () => void
|
|
||||||
warmPress: () => void
|
warmPress: () => void
|
||||||
warmFocus: () => void
|
warmFocus: () => void
|
||||||
cancelHoverPrefetch: () => void
|
|
||||||
}): JSX.Element => (
|
|
||||||
<A
|
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
|
||||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
|
||||||
onPointerDown={props.warmPress}
|
|
||||||
onPointerEnter={props.warmHover}
|
|
||||||
onPointerLeave={props.cancelHoverPrefetch}
|
|
||||||
onFocus={props.warmFocus}
|
|
||||||
onClick={() => {
|
|
||||||
props.setHoverSession(undefined)
|
|
||||||
if (props.sidebarOpened()) return
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="shrink-0 size-6 flex items-center justify-center"
|
|
||||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
|
||||||
>
|
|
||||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
|
||||||
<Match when={props.isWorking()}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.hasPermissions()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.hasError()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.unseenCount() > 0}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
|
||||||
</A>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionHoverPreview = (props: {
|
|
||||||
mobile?: boolean
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
session: Session
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
hoverReady: Accessor<boolean>
|
|
||||||
hoverMessages: Accessor<UserMessage[] | undefined>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
isActive: Accessor<boolean>
|
|
||||||
slug: string
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
messageLabel: (message: Message) => string | undefined
|
|
||||||
onMessageSelect: (message: Message) => void
|
|
||||||
trigger: JSX.Element
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
let ref: HTMLDivElement | undefined
|
const title = () => sessionTitle(props.session.title)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard
|
<A
|
||||||
openDelay={1000}
|
href={`/${props.slug}/session/${props.session.id}`}
|
||||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
placement="right-start"
|
onPointerDown={props.warmPress}
|
||||||
gutter={16}
|
onFocus={props.warmFocus}
|
||||||
shift={-2}
|
onClick={() => {
|
||||||
trigger={
|
if (props.sidebarOpened()) return
|
||||||
<div ref={ref} class="min-w-0 w-full">
|
props.clearHoverProjectSoon()
|
||||||
{props.trigger}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={props.hoverSession() === props.session.id}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
props.setHoverSession(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!ref?.matches(":hover")) return
|
|
||||||
props.setHoverSession(props.session.id)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show
|
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
||||||
when={props.hoverReady()}
|
<div
|
||||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
class="shrink-0 size-6 flex items-center justify-center"
|
||||||
>
|
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
>
|
||||||
<MessageNav
|
<Switch>
|
||||||
messages={props.hoverMessages() ?? []}
|
<Match when={props.isWorking()}>
|
||||||
current={undefined}
|
<Spinner class="size-[15px]" />
|
||||||
getLabel={props.messageLabel}
|
</Match>
|
||||||
onMessageSelect={props.onMessageSelect}
|
<Match when={props.hasPermissions()}>
|
||||||
size="normal"
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
class="w-60"
|
</Match>
|
||||||
/>
|
<Match when={props.hasError()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.unseenCount() > 0}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</HoverCard>
|
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
||||||
|
</A>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
@@ -229,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const tint = createMemo(() => {
|
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
|
||||||
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
|
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
||||||
|
const currentChild = createMemo(() => {
|
||||||
|
if (!props.showChild) return
|
||||||
|
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hoverMessages = createMemo(() =>
|
|
||||||
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
|
||||||
)
|
|
||||||
const hoverReady = createMemo(() => hoverMessages() !== undefined)
|
|
||||||
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
|
||||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
|
||||||
const isActive = createMemo(() => props.session.id === params.id)
|
|
||||||
|
|
||||||
const warm = (span: number, priority: "high" | "low") => {
|
const warm = (span: number, priority: "high" | "low") => {
|
||||||
const nav = props.navList?.()
|
const nav = props.navList?.()
|
||||||
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||||
@@ -261,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoverPrefetch = {
|
|
||||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
||||||
}
|
|
||||||
const cancelHoverPrefetch = () => {
|
|
||||||
if (hoverPrefetch.current === undefined) return
|
|
||||||
clearTimeout(hoverPrefetch.current)
|
|
||||||
hoverPrefetch.current = undefined
|
|
||||||
}
|
|
||||||
const scheduleHoverPrefetch = () => {
|
|
||||||
warm(1, "high")
|
|
||||||
if (hoverPrefetch.current !== undefined) return
|
|
||||||
hoverPrefetch.current = setTimeout(() => {
|
|
||||||
hoverPrefetch.current = undefined
|
|
||||||
warm(2, "low")
|
|
||||||
}, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(cancelHoverPrefetch)
|
|
||||||
|
|
||||||
const messageLabel = (message: Message) => {
|
|
||||||
const parts = sessionStore.part[message.id] ?? []
|
|
||||||
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
|
|
||||||
return text?.text
|
|
||||||
}
|
|
||||||
const item = (
|
const item = (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
session={props.session}
|
session={props.session}
|
||||||
@@ -296,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
hasPermissions={hasPermissions}
|
hasPermissions={hasPermissions}
|
||||||
hasError={hasError}
|
hasError={hasError}
|
||||||
unseenCount={unseenCount}
|
unseenCount={unseenCount}
|
||||||
setHoverSession={props.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||||
sidebarOpened={layout.sidebar.opened}
|
sidebarOpened={layout.sidebar.opened}
|
||||||
warmHover={scheduleHoverPrefetch}
|
|
||||||
warmPress={() => warm(2, "high")}
|
warmPress={() => warm(2, "high")}
|
||||||
warmFocus={() => warm(2, "high")}
|
warmFocus={() => warm(2, "high")}
|
||||||
cancelHoverPrefetch={cancelHoverPrefetch}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
data-session-id={props.session.id}
|
<div
|
||||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
data-session-id={props.session.id}
|
||||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||||
>
|
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
||||||
<div class="flex min-w-0 items-center gap-1">
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
<Show
|
<div class="min-w-0 flex-1">
|
||||||
when={hoverEnabled()}
|
<Show
|
||||||
fallback={
|
when={!tooltip()}
|
||||||
<Tooltip
|
fallback={
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
<Tooltip
|
||||||
value={props.session.title}
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
gutter={10}
|
value={sessionTitle(props.session.title)}
|
||||||
class="min-w-0 w-full"
|
gutter={10}
|
||||||
>
|
class="min-w-0 w-full"
|
||||||
{item}
|
>
|
||||||
</Tooltip>
|
{item}
|
||||||
}
|
</Tooltip>
|
||||||
>
|
}
|
||||||
<SessionHoverPreview
|
>
|
||||||
mobile={props.mobile}
|
{item}
|
||||||
nav={props.nav}
|
</Show>
|
||||||
hoverSession={props.hoverSession}
|
</div>
|
||||||
session={props.session}
|
|
||||||
sidebarHovering={props.sidebarHovering}
|
|
||||||
hoverReady={hoverReady}
|
|
||||||
hoverMessages={hoverMessages}
|
|
||||||
language={language}
|
|
||||||
isActive={isActive}
|
|
||||||
slug={props.slug}
|
|
||||||
setHoverSession={props.setHoverSession}
|
|
||||||
messageLabel={messageLabel}
|
|
||||||
onMessageSelect={(message) => {
|
|
||||||
if (!isActive())
|
|
||||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
|
||||||
|
|
||||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
<Show when={!props.level}>
|
||||||
|
<div
|
||||||
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
|
classList={{
|
||||||
|
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||||
|
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||||
|
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||||
|
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||||
}}
|
}}
|
||||||
trigger={item}
|
>
|
||||||
/>
|
<Tooltip value={language.t("common.archive")} placement="top">
|
||||||
|
<IconButton
|
||||||
|
icon="archive"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
aria-label={language.t("common.archive")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void props.archiveSession(props.session)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
|
||||||
classList={{
|
|
||||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
|
||||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
|
||||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
|
||||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip value={language.t("common.archive")} placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon="archive"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
aria-label={language.t("common.archive")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
void props.archiveSession(props.session)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Show when={currentChild()}>
|
||||||
|
{(child) => (
|
||||||
|
<div class="w-full">
|
||||||
|
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +280,6 @@ export const NewSessionItem = (props: {
|
|||||||
dense?: boolean
|
dense?: boolean
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -395,9 +289,8 @@ export const NewSessionItem = (props: {
|
|||||||
<A
|
<A
|
||||||
href={`/${props.slug}/session`}
|
href={`/${props.slug}/session`}
|
||||||
end
|
end
|
||||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.setHoverSession(undefined)
|
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
props.clearHoverProjectSoon()
|
props.clearHoverProjectSoon()
|
||||||
}}
|
}}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user