@firstpick/pi-package-webui 0.4.7 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/bin/pi-webui.mjs +148 -52
- package/package.json +2 -2
- package/public/app.js +998 -152
- package/public/index.html +11 -5
- package/public/styles.css +458 -46
- package/tests/http-endpoints-harness.test.mjs +112 -0
- package/tests/mobile-static.test.mjs +40 -18
- package/tests/native-parity.test.mjs +5 -1
|
@@ -37,6 +37,22 @@ async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000
|
|
|
37
37
|
return { status: response.status, body: payload };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function runGitFixture(args, cwd, message) {
|
|
41
|
+
const result = spawnSync("git", args, {
|
|
42
|
+
cwd,
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
env: {
|
|
45
|
+
...process.env,
|
|
46
|
+
GIT_AUTHOR_NAME: "Pi WebUI Test",
|
|
47
|
+
GIT_AUTHOR_EMAIL: "pi-webui-test@example.invalid",
|
|
48
|
+
GIT_COMMITTER_NAME: "Pi WebUI Test",
|
|
49
|
+
GIT_COMMITTER_EMAIL: "pi-webui-test@example.invalid",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
assert.equal(result.status, 0, `${message}\n$ git ${args.join(" ")}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
|
53
|
+
return result.stdout.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
|
|
41
57
|
const settingsFile = path.join(cwd, "webui-settings.json");
|
|
42
58
|
await chmod(fakePi, 0o755);
|
|
@@ -152,6 +168,47 @@ try {
|
|
|
152
168
|
assert.equal(gitMain.status, 200);
|
|
153
169
|
assert.equal(gitMain.body?.ok, true, "main branch endpoint should rename the branch");
|
|
154
170
|
|
|
171
|
+
const remoteFixtureRoot = await mkdtemp(path.join(tmpdir(), "pi-webui-git-remote-"));
|
|
172
|
+
const remoteBare = path.join(remoteFixtureRoot, "origin.git");
|
|
173
|
+
const localRepo = path.join(remoteFixtureRoot, "local");
|
|
174
|
+
const remoteWork = path.join(remoteFixtureRoot, "remote-work");
|
|
175
|
+
runGitFixture(["init", "--bare", remoteBare], remoteFixtureRoot, "remote fixture should initialize a bare origin");
|
|
176
|
+
runGitFixture(["init", localRepo], remoteFixtureRoot, "remote fixture should initialize a local repo");
|
|
177
|
+
runGitFixture(["config", "user.name", "Pi WebUI Test"], localRepo, "local repo should set a user name");
|
|
178
|
+
runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], localRepo, "local repo should set a user email");
|
|
179
|
+
await writeFile(path.join(localRepo, "incoming.txt"), "base\n");
|
|
180
|
+
runGitFixture(["add", "incoming.txt"], localRepo, "local repo should stage base content");
|
|
181
|
+
runGitFixture(["commit", "-m", "base"], localRepo, "local repo should commit base content");
|
|
182
|
+
runGitFixture(["branch", "-M", "main"], localRepo, "local repo should rename main branch");
|
|
183
|
+
runGitFixture(["remote", "add", "origin", remoteBare], localRepo, "local repo should add bare origin");
|
|
184
|
+
runGitFixture(["push", "-u", "origin", "main"], localRepo, "local repo should push main to bare origin");
|
|
185
|
+
runGitFixture(["symbolic-ref", "HEAD", "refs/heads/main"], remoteBare, "bare origin should advertise main as HEAD");
|
|
186
|
+
runGitFixture(["clone", remoteBare, remoteWork], remoteFixtureRoot, "remote worktree should clone bare origin");
|
|
187
|
+
runGitFixture(["config", "user.name", "Pi WebUI Test"], remoteWork, "remote worktree should set a user name");
|
|
188
|
+
runGitFixture(["config", "user.email", "pi-webui-test@example.invalid"], remoteWork, "remote worktree should set a user email");
|
|
189
|
+
await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\n");
|
|
190
|
+
runGitFixture(["commit", "-am", "remote one"], remoteWork, "remote worktree should commit first incoming change");
|
|
191
|
+
await writeFile(path.join(remoteWork, "incoming.txt"), "base\nremote one\nremote two\n");
|
|
192
|
+
runGitFixture(["commit", "-am", "remote two"], remoteWork, "remote worktree should commit second incoming change");
|
|
193
|
+
runGitFixture(["push", "origin", "main"], remoteWork, "remote worktree should push incoming commits");
|
|
194
|
+
runGitFixture(["fetch", "origin"], localRepo, "local repo should fetch incoming commits");
|
|
195
|
+
|
|
196
|
+
const remoteTab = await request("127.0.0.1", "/api/tabs", { method: "POST", body: { cwd: localRepo, title: "remote-behind-fixture" } });
|
|
197
|
+
assert.equal(remoteTab.status, 201);
|
|
198
|
+
const remoteTabId = remoteTab.body?.data?.tab?.id;
|
|
199
|
+
assert.ok(remoteTabId, "remote fixture tab should have an id");
|
|
200
|
+
const incomingChanges = await request("127.0.0.1", `/api/git-changes?tab=${encodeURIComponent(remoteTabId)}`);
|
|
201
|
+
assert.equal(incomingChanges.status, 200);
|
|
202
|
+
assert.equal(incomingChanges.body?.ok, true, "git changes endpoint should load a fetched-behind repo");
|
|
203
|
+
assert.equal(incomingChanges.body?.data?.summary?.behind, 2, "git changes endpoint should report two fetched commits behind");
|
|
204
|
+
assert.equal(incomingChanges.body?.data?.remote?.canPull, true, "git changes endpoint should mark fetched commits as pullable");
|
|
205
|
+
assert.ok(incomingChanges.body?.data?.sections?.some((section) => section.key === "incoming"), "git changes endpoint should include an incoming diff section");
|
|
206
|
+
|
|
207
|
+
const pullIncoming = await request("127.0.0.1", "/api/git-changes/pull", { method: "POST", body: { tab: remoteTabId }, timeoutMs: 20_000 });
|
|
208
|
+
assert.equal(pullIncoming.status, 200);
|
|
209
|
+
assert.equal(pullIncoming.body?.ok, true, "pull endpoint should fast-forward fetched incoming commits");
|
|
210
|
+
assert.equal(pullIncoming.body?.data?.changes?.summary?.behind, 0, "pull endpoint should refresh changes with no remote commits left behind");
|
|
211
|
+
|
|
155
212
|
const gitRemote = await request("127.0.0.1", "/api/git-workflow/remote", { method: "POST", body: { username: "Firstp1ck", repoName: "pi-webui-http-harness", tab: tabId } });
|
|
156
213
|
assert.equal(gitRemote.status, 200);
|
|
157
214
|
assert.equal(gitRemote.body?.ok, true, "remote endpoint should add origin without pushing");
|
|
@@ -256,6 +313,61 @@ try {
|
|
|
256
313
|
assert.match((customRunState.body?.data?.activeRun?.lines || []).join("\n"), /custom runner ok/, "custom runner output should be captured");
|
|
257
314
|
await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
|
|
258
315
|
|
|
316
|
+
await writeFile(path.join(cwd, "interactive-runner.mjs"), [
|
|
317
|
+
"import readline from 'node:readline';",
|
|
318
|
+
"const rl = readline.createInterface({ input: process.stdin, output: process.stdout });",
|
|
319
|
+
"console.log('interactive ready');",
|
|
320
|
+
"rl.question('name? ', (answer) => {",
|
|
321
|
+
" console.log(`hello ${answer}`);",
|
|
322
|
+
" rl.close();",
|
|
323
|
+
"});",
|
|
324
|
+
"",
|
|
325
|
+
].join("\n"));
|
|
326
|
+
const savedInteractiveRunner = await request("127.0.0.1", "/api/app-runner-config", {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: { tab: tabId, runner: { label: "Interactive node", command: process.execPath, path: "interactive-runner.mjs" } },
|
|
329
|
+
timeoutMs: 10_000,
|
|
330
|
+
});
|
|
331
|
+
assert.equal(savedInteractiveRunner.status, 200, `saving an interactive custom runner should succeed: ${savedInteractiveRunner.body?.error || ""}`);
|
|
332
|
+
const interactiveRunner = savedInteractiveRunner.body?.data?.runners?.find((runner) => runner.custom === true && runner.label === "Interactive node");
|
|
333
|
+
assert.ok(interactiveRunner?.id, "interactive custom runner should appear in detected app runners");
|
|
334
|
+
const interactiveRunStart = await request("127.0.0.1", "/api/app-runner", {
|
|
335
|
+
method: "POST",
|
|
336
|
+
body: { tab: tabId, runnerId: interactiveRunner.id },
|
|
337
|
+
timeoutMs: 10_000,
|
|
338
|
+
});
|
|
339
|
+
assert.equal(interactiveRunStart.status, 200, `interactive runner start should return ok: ${interactiveRunStart.body?.error || ""}`);
|
|
340
|
+
let interactiveRunState = interactiveRunStart;
|
|
341
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
342
|
+
interactiveRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
|
|
343
|
+
const output = [
|
|
344
|
+
...(interactiveRunState.body?.data?.activeRun?.lines || []),
|
|
345
|
+
interactiveRunState.body?.data?.activeRun?.pendingLine || "",
|
|
346
|
+
].join("\n");
|
|
347
|
+
if (/name\?/.test(output)) break;
|
|
348
|
+
await delay(100);
|
|
349
|
+
}
|
|
350
|
+
assert.match([
|
|
351
|
+
...(interactiveRunState.body?.data?.activeRun?.lines || []),
|
|
352
|
+
interactiveRunState.body?.data?.activeRun?.pendingLine || "",
|
|
353
|
+
].join("\n"), /name\?/, "interactive app runner should expose a prompt without waiting for a newline");
|
|
354
|
+
const interactiveInput = await request("127.0.0.1", "/api/app-runner/input", {
|
|
355
|
+
method: "POST",
|
|
356
|
+
body: { tab: tabId, text: "webui", closeStdin: true },
|
|
357
|
+
timeoutMs: 10_000,
|
|
358
|
+
});
|
|
359
|
+
assert.equal(interactiveInput.status, 200, `interactive app runner input should be accepted: ${interactiveInput.body?.error || ""}`);
|
|
360
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
361
|
+
if (interactiveRunState.body?.data?.activeRun?.status && interactiveRunState.body.data.activeRun.status !== "running") break;
|
|
362
|
+
await delay(100);
|
|
363
|
+
interactiveRunState = await request("127.0.0.1", `/api/app-runners?tab=${encodeURIComponent(tabId)}`, { timeoutMs: 5_000 });
|
|
364
|
+
}
|
|
365
|
+
assert.equal(interactiveRunState.body?.data?.activeRun?.status, "done", "interactive custom runner should finish after stdin");
|
|
366
|
+
const interactiveOutput = (interactiveRunState.body?.data?.activeRun?.lines || []).join("\n");
|
|
367
|
+
assert.match(interactiveOutput, /hello webui/, "interactive custom runner should receive stdin from the app-runner input endpoint");
|
|
368
|
+
assert.match(interactiveOutput, /# stdin sent \(5 chars\) and closed/, "app runner output should show that stdin was sent without echoing the input text itself");
|
|
369
|
+
await request("127.0.0.1", "/api/app-runner/clear", { method: "POST", body: { tab: tabId } });
|
|
370
|
+
|
|
259
371
|
await writeFile(path.join(cwd, ".pi-webui-runners.json"), `${JSON.stringify({
|
|
260
372
|
version: 1,
|
|
261
373
|
runners: [{ id: "broken-custom", label: "Broken custom", command: "definitely-missing-pi-webui-runner", path: "custom-runner.mjs" }],
|
|
@@ -53,7 +53,8 @@ assert.match(html, /id="sidePanelBackdrop"/, "mobile side panel needs an overlay
|
|
|
53
53
|
assert.match(html, /<strong class="side-panel-title">[\s\S]*Control Deck[\s\S]*id="webuiVersionBadge"[\s\S]*id="webuiDevBadge"/, "Control Deck title should expose Web UI version and dev badges");
|
|
54
54
|
assert.doesNotMatch(html, /id="sessionLine"/, "Control Deck title should not show verbose session status metadata");
|
|
55
55
|
assert.match(html, /id="themeSelect"/, "side panel should expose a theme selector");
|
|
56
|
-
assert.match(html, /<label for="themeSelect"
|
|
56
|
+
assert.match(html, /<label for="themeSelect"[^>]*id="themeControlLabel"[^>]*>Theme<\/label>/, "theme selector should be labeled in side-panel controls");
|
|
57
|
+
assert.match(html, /id="themeSearchInput"[\s\S]*id="themeSelect"[\s\S]*id="themeSearchResults"/, "side-panel theme selector should expose searchable theme results");
|
|
57
58
|
assert.match(html, /id="backgroundInput"[^>]*type="file"[^>]*accept="image\/png,image\/jpeg,image\/webp,image\/gif"/, "side panel should expose an image picker for custom backgrounds");
|
|
58
59
|
assert.match(html, /id="backgroundClearButton"[\s\S]*?>×<\/button>/, "side-panel background control should expose an X remove button");
|
|
59
60
|
assert.match(html, /id="serverActionSelect"[\s\S]*<option value="restart">Restart Server<\/option>[\s\S]*<option value="stop">Stop Server<\/option>/, "side panel should expose restart and stop server actions in a dropdown");
|
|
@@ -328,7 +329,7 @@ assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body
|
|
|
328
329
|
assert.match(css, /body\.mobile-keyboard-open \.composer-actions-button,[\s\S]*?body\.mobile-keyboard-open \.composer-actions-panel/, "mobile keyboard mode should hide the secondary actions sheet while keeping active-run controls available");
|
|
329
330
|
assert.match(css, /\.server-offline-panel/, "PWA/offline shell should style a backend-offline recovery panel");
|
|
330
331
|
assert.match(css, /body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{ grid-column: span 2; \}[\s\S]*?body:not\(\.pi-run-active\):not\(\.mobile-keyboard-open\) \.composer-btw-button\[hidden\] \+ button\.primary \{ grid-column: span 4; \}/, "idle mobile composer should keep Actions, /btw, and Send on one compact row with a hidden-button fallback");
|
|
331
|
-
assert.match(css,
|
|
332
|
+
assert.match(css, /\[hidden\] \{ display: none !important; \}/, "hidden controls should not occupy layout space or be overridden by component display styles");
|
|
332
333
|
assert.match(css, /\.statusbar-tui-footer \{[\s\S]*?gap:\s*0/, "default TUI-like footer should reduce statusbar chrome around the compact line");
|
|
333
334
|
assert.match(css, /\.statusbar-git-footer \{[\s\S]*?--footer-chip-min-width:\s*7\.6rem;[\s\S]*?gap:\s*0\.58rem/, "enabled git-footer extension should keep styled spacing and one shared minimal chip width token");
|
|
334
335
|
assert.match(css, /\.footer-line-main \.footer-metric \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?min-width:\s*0/, "git-footer metrics should use a shared preferred minimum and distribute spare row space equally");
|
|
@@ -343,8 +344,10 @@ assert.match(css, /\.footer-changed-file\.modified \.footer-changed-file-status
|
|
|
343
344
|
assert.match(css, /\.footer-git-extra \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-sky\)[\s\S]*?font-weight:\s*900/, "git-footer extras value should be bright enough to read at footer size");
|
|
344
345
|
assert.match(css, /\.footer-metric-action,\n\.footer-meta-action \{[\s\S]*?position:\s*relative;[\s\S]*?border-color:\s*rgba\(148, 226, 213, 0\.26\)/, "clickable footer boxes should have a subtle always-visible highlight");
|
|
345
346
|
assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable footer boxes should not show a corner indicator dot");
|
|
346
|
-
assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-
|
|
347
|
+
assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-width:[\s\S]*?--git-changes-dialog-height:[\s\S]*?width:\s*var\(--git-changes-dialog-width\)[\s\S]*?height:\s*var\(--git-changes-dialog-height\)/, "git changes modal should override the base dialog with a wide bounded diff layout");
|
|
348
|
+
assert.match(css, /\.git-changes-body \{[\s\S]*?align-content:\s*start/, "git changes modal should keep summary and file content packed at the top of the scroller");
|
|
347
349
|
assert.match(css, /\.git-current-file-header \{[\s\S]*?position:\s*sticky[\s\S]*?top:\s*-0\.72rem/, "git changes modal should keep a sticky current-file header inside the diff scroller");
|
|
350
|
+
assert.match(css, /\.git-changes-file-list \{[\s\S]*?grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/, "git changes modal should show changed-file jump buttons in two columns without horizontal scrolling");
|
|
348
351
|
assert.match(css, /\.git-diff-grid \{[\s\S]*?grid-template-columns:\s*3\.8rem minmax\(22rem, 1fr\) 3\.8rem minmax\(22rem, 1fr\)/, "git changes modal should render a side-by-side diff grid");
|
|
349
352
|
assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesPullButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh, pull controls, and a diff body");
|
|
350
353
|
assert.match(app, /chip\.key === "changes"[\s\S]*?options\.onClick = openGitChangesDialog/, "footer CHANGES chip should open the git changes modal");
|
|
@@ -354,9 +357,12 @@ assert.match(app, /function gitDiffDisplayLine\(row, side\)[\s\S]*`-\$\{text\}`[
|
|
|
354
357
|
assert.match(app, /function gitUntrackedEntryToDiffFile\(entry\)[\s\S]*?renderRowLimit:\s*Number\.POSITIVE_INFINITY[\s\S]*?type: "added"/, "untracked files should render as complete added-file diffs without the row preview cap");
|
|
355
358
|
assert.match(app, /async function loadMissingGitUntrackedContent\(entry[\s\S]*?\/api\/git-changes\/untracked-file\?path=/, "untracked path-only payloads should fetch complete file contents instead of rendering as empty files");
|
|
356
359
|
assert.match(app, /function updateGitChangesCurrentFileHeader\(\)[\s\S]*?querySelectorAll\("\.git-diff-file\[data-git-diff-file\]"\)/, "git changes modal should derive the sticky current-file header from visible file cards");
|
|
360
|
+
assert.match(app, /function renderGitChangesFileList\(parsedSections, untracked\)[\s\S]*dataset\.gitChangesJumpFile = item\.path[\s\S]*git-changes-file-jump-meta/, "git changes modal should render jump buttons for each changed file");
|
|
361
|
+
assert.match(app, /gitChangesBody\?\.addEventListener\("click"[\s\S]*data-git-changes-jump-file[\s\S]*scrollIntoView\(\{ block: "start", behavior: "smooth" \}\)/, "git changes file jump buttons should scroll to their diff cards");
|
|
357
362
|
assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
|
|
358
363
|
assert.match(server, /url\.pathname === "\/api\/git-changes\/untracked-file" && req\.method === "GET"/, "server should expose a focused untracked-file content endpoint for stale path-only payload fallbacks");
|
|
359
364
|
assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?"--unified=0"[\s\S]*?\["diff", "--cached"/, "server should collect compact staged and unstaged git diffs for the changes modal");
|
|
365
|
+
assert.match(server, /\["status", "--porcelain=2", "--branch", "--untracked-files=all"\][\s\S]*?summarizeGitPorcelainStatus\(porcelainStatusText\)/, "server should derive behind/ahead from locale-independent porcelain status so the Pull button activates after fetch");
|
|
360
366
|
assert.match(server, /async function readGitIncomingChanges\(root, summary\)[\s\S]*?"HEAD\.\.@\{upstream\}"/, "server should collect incoming upstream diffs when remote commits are behind");
|
|
361
367
|
assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
|
|
362
368
|
assert.match(server, /url\.pathname === "\/api\/git-changes\/pull" && req\.method === "POST"[\s\S]*?pullGitChanges\(tab\.cwd\)/, "server should expose POST /api/git-changes/pull for the changes modal Pull button");
|
|
@@ -384,7 +390,8 @@ assert.match(css, /\.extension-dialog[\s\S]*?max-height:\s*calc\(var\(--visual-v
|
|
|
384
390
|
assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialogs should behave like bottom sheets");
|
|
385
391
|
assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
|
|
386
392
|
assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
|
|
387
|
-
assert.
|
|
393
|
+
assert.doesNotMatch(css, /--tree-depth/, "native slash selector choices should not indent tree entries by depth");
|
|
394
|
+
assert.match(css, /\.native-selector-index \{[\s\S]*?font-variant-numeric:\s*tabular-nums/, "native tree selector choices should use numeric prefixes");
|
|
388
395
|
assert.match(css, /\.native-selector-badge\.native-selector-badge-pi-native[\s\S]*?color:\s*var\(--ctp-blue\)/, "Tools Setup should distinguish Pi native tools with a Pi Native tag");
|
|
389
396
|
assert.match(css, /\.native-selector-badge\.native-selector-badge-external[\s\S]*?color:\s*var\(--ctp-mauve\)/, "Tools Setup should distinguish external tools with an External tag");
|
|
390
397
|
assert.match(css, /\.native-settings-grid,[\s\S]*?\.native-tree-options \{[\s\S]*?grid-template-columns:/, "native settings and tree selector options should use responsive grids");
|
|
@@ -432,8 +439,9 @@ assert.match(app, /initializeCustomBackground\(\)\.catch/, "startup should resto
|
|
|
432
439
|
assert.match(app, /Restart Web UI to load themes/, "frontend should explain when a stale server cannot serve the themes endpoint");
|
|
433
440
|
assert.match(app, /themeSelect\.addEventListener\("change"/, "side-panel theme selector should switch themes immediately");
|
|
434
441
|
assert.match(app, /open \? "Close for network" : "Open to network"/, "network button should toggle from open to close action");
|
|
435
|
-
assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "
|
|
436
|
-
assert.match(
|
|
442
|
+
assert.match(app, /remoteAuthToggle: \$\("#remoteAuthToggle"\)/, "Remote WebUI controls should bind the remote PIN auth toggle");
|
|
443
|
+
assert.match(html, /id="networkControlField"[^>]*hidden/, "Remote WebUI browser controls should be hidden until the optional package is loaded and enabled");
|
|
444
|
+
assert.match(app, /remoteWebuiCommand\(enable \? "authOn" : "authOff"/, "remote PIN auth toggle should dispatch through the Remote WebUI package command");
|
|
437
445
|
assert.match(server, /function webuiSettingsFile\(\)[\s\S]*pi-webui[\s\S]*settings\.json/, "server should persist Web UI settings under a pi-webui settings file");
|
|
438
446
|
assert.match(server, /let persistedRemoteAuthEnabled = await readPersistedRemoteAuthEnabled\(\)/, "server should load the saved Remote PIN auth preference before startup");
|
|
439
447
|
assert.match(server, /if \(remoteAuthStartupEnabled\(\)\) enableRemoteAuth\(remoteAuthStartupReason\(\)\)/, "saved Remote PIN auth preference should enable auth on startup");
|
|
@@ -441,7 +449,7 @@ assert.match(server, /await saveRemoteAuthPreference\(true\)/, "enabling Remote
|
|
|
441
449
|
assert.match(server, /await saveRemoteAuthPreference\(false\)/, "disabling Remote PIN auth should persist the off preference");
|
|
442
450
|
assert.match(server, /function pinFromHash\(\)[\s\S]*new URLSearchParams\(String\(window\.location\.hash \|\| ""\)\.replace\(\/\^#\/, ""\)\)/, "remote auth page should read QR-provided PINs from the URL fragment");
|
|
443
451
|
assert.match(server, /window\.history\.replaceState\(null, "", window\.location\.pathname \+ \(window\.location\.search \|\| ""\)\)/, "remote auth page should scrub fragment PINs from the address bar before authenticating");
|
|
444
|
-
assert.match(app, /
|
|
452
|
+
assert.match(app, /remoteWebuiCommand\(open \? "close" : "open"/, "network open\/close button should dispatch through the Remote WebUI package command");
|
|
445
453
|
assert.match(app, /webuiVersionBadge: \$\("#webuiVersionBadge"\)/, "frontend should bind the Control Deck version badge");
|
|
446
454
|
assert.match(app, /webuiDevBadge: \$\("#webuiDevBadge"\)/, "frontend should bind the Control Deck dev badge");
|
|
447
455
|
assert.match(app, /function refreshWebuiVersion\(\)[\s\S]*api\("\/api\/health", \{ scoped: false \}\)[\s\S]*setWebuiVersion\(health\.webuiVersion\)[\s\S]*setWebuiDevServer\(isWebuiDevMetadata\(health\)\)/, "frontend should load Web UI version and dev mode from health metadata");
|
|
@@ -456,7 +464,7 @@ assert.match(server, /cwdExplicit: false/, "server should track whether startup
|
|
|
456
464
|
assert.match(server, /return options\.cwdExplicit \? \[await createTab\(\)\] : \[\]/, "server should wait for UI cwd selection when no --cwd is supplied");
|
|
457
465
|
assert.match(server, /async function resolvedPiCliScript\(\)[\s\S]*require\.resolve\.paths\(PI_CODING_AGENT_PACKAGE\)[\s\S]*nodeModulesRoot[\s\S]*dist[\s\S]*cli\.js/, "server should resolve the bundled Pi CLI through Node resolution roots so hoisted global installs can spawn RPC tabs");
|
|
458
466
|
assert.match(server, /const bundledCli = await resolvedPiCliScript\(\)/, "standalone server should prefer the resolved Pi CLI script before falling back to PATH pi");
|
|
459
|
-
assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(\
|
|
467
|
+
assert.match(server, /if \(options\.piBinExplicit\) \{\n\s+const command = await resolvePiCommand\(updateArgs\)/, "explicit --pi JavaScript launchers should also work for update commands");
|
|
460
468
|
assert.match(app, /serverActionSelect\.addEventListener\("change", updateServerActionButton\)/, "Server action dropdown should control the guarded run button");
|
|
461
469
|
assert.match(app, /runServerActionButton\.addEventListener\("click"[\s\S]*runSelectedServerAction/, "Server action run button should execute the selected action");
|
|
462
470
|
assert.match(app, /api\("\/api\/restart", \{ method: "POST", scoped: false \}\)/, "Restart Server action should call the unscoped restart endpoint");
|
|
@@ -466,10 +474,12 @@ assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)
|
|
|
466
474
|
assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
|
|
467
475
|
assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
|
|
468
476
|
assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
|
|
469
|
-
assert.match(server, /
|
|
470
|
-
assert.match(server, /function
|
|
471
|
-
assert.match(app, /
|
|
472
|
-
assert.match(
|
|
477
|
+
assert.match(server, /const updateArgs = all \? \["update", "--all"\] : \["update"\]/, "server update should use pi update by default and pi update --all for package-inclusive updates");
|
|
478
|
+
assert.match(server, /async function resolveUpdateTasks\(\{ all = false \} = \{\}\)[\s\S]*await resolvePiUpdateCommand\(\{ all \}\)/, "server update should resolve a single Pi update command with the selected all mode");
|
|
479
|
+
assert.match(app, /const command = all \? "pi update --all" : "pi update"/, "frontend update confirmation should describe self-only and all update commands");
|
|
480
|
+
assert.match(app, /api\(all \? "\/api\/update\?all=1" : "\/api\/update"/, "frontend all update should call the explicit all-mode endpoint");
|
|
481
|
+
assert.match(html, /<option value="update-all">Update Pi \+ Packages & Restart<\/option>/, "side panel should expose pi update --all as a separate server action");
|
|
482
|
+
assert.match(readme, /`pi update` for Pi-only updates[\s\S]*`pi update --all` for Pi plus configured packages/, "README should document self-only and all update modes");
|
|
473
483
|
assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
|
|
474
484
|
assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
|
|
475
485
|
assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
|
|
@@ -626,8 +636,12 @@ assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-
|
|
|
626
636
|
assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
|
|
627
637
|
assert.match(app, /id: "remoteWebui"[\s\S]*?@firstpick\/pi-package-remote-webui/, "optional features should include the Remote WebUI companion");
|
|
628
638
|
assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasAvailableCommand\("safety-guard"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)[\s\S]*hasAvailableCommand\("remote"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
629
|
-
assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand/, "Options menu should
|
|
639
|
+
assert.match(app, /hasRemoteWebuiCommand = isOptionalFeatureEnabled\("remoteWebui"\) && hasAvailableCommand\("remote"\)[\s\S]*optionsRemoteButton\.hidden = !hasRemoteWebuiCommand[\s\S]*syncRemoteWebuiControlVisibility\(hasRemoteWebuiCommand\)/, "Options menu should track /remote availability and delegate network card visibility");
|
|
640
|
+
assert.match(app, /function syncRemoteWebuiControlVisibility[\s\S]*networkControlField\.hidden = !hasRemoteWebuiCommand/, "Remote WebUI network card should render whenever the optional feature and /remote command are enabled");
|
|
641
|
+
assert.match(app, /if \(featureId === "remoteWebui"\) syncRemoteWebuiControlVisibility\(false\)/, "Disabling Remote WebUI should immediately hide browser network controls before broader rerendering");
|
|
642
|
+
assert.match(app, /window\.addEventListener\("storage"[\s\S]*OPTIONAL_FEATURES_STORAGE_KEY[\s\S]*reconcileDisabledOptionalFeaturesFromStorage/, "Optional feature disables should live-sync across open Web UI pages");
|
|
630
643
|
assert.match(app, /if \(key === "pi-remote-webui"\) return "remoteWebui"/, "optional feature handling should recognize Remote WebUI widget events without rendering them as overlays");
|
|
644
|
+
assert.match(app, /REMOTE_WEBUI_CONTROLS_PAYLOAD_TYPE = "firstpick\.pi-package-remote-webui\.controls"/, "Remote WebUI package should announce browser controls through a package-owned status payload");
|
|
631
645
|
assert.match(app, /function combineIdenticalDuplicateCommands\(commands\)[\s\S]*duplicateGroups[\s\S]*duplicateCount: group\.length/, "identical duplicate RPC commands should be combined into one visible command entry");
|
|
632
646
|
assert.match(app, /if \(kind === "prompt" && attachments\.length === 0\) message = resolveRpcSlashCommandMessage\(message\)/, "manual slash prompts should resolve combined duplicate command aliases before reaching Pi RPC");
|
|
633
647
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
|
|
@@ -654,9 +668,9 @@ assert.match(app, /addGitWorkflowAction\("Create PR", \(\) => createGitPrBranch\
|
|
|
654
668
|
assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"Create PR branch:"[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Create PR should have an up-to-date step-by-step tooltip");
|
|
655
669
|
assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"Manual PR branch:"[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"6\. Push and Create PR will push upstream, run \/pr, let you review, then run gh pr create\."/, "Manual branch should have an up-to-date step-by-step tooltip");
|
|
656
670
|
assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
|
|
657
|
-
assert.match(app, /function renderGitWorkflowManualCommitInput\(\)[\s\S]*git-workflow-message-input[\s\S]*Commit input[\s\S]*commitGitWorkflow\("input", tabId\)/, "Message stage should render a manual commit message input with a Commit input action");
|
|
658
|
-
assert.match(app, /gitWorkflow\.step === "generate"\) \{\n\s+renderGitWorkflowManualCommitInput\(\)
|
|
659
|
-
assert.match(app, /renderGitWorkflowManualCommitInput\(\);[\s\S]*addGitWorkflowAction\("
|
|
671
|
+
assert.match(app, /function renderGitWorkflowManualCommitInput\(\{ appendCommitButton = true \} = \{\}\)[\s\S]*git-workflow-message-input[\s\S]*Commit input[\s\S]*commitGitWorkflow\("input", tabId\)/, "Message stage should render a manual commit message input with a Commit input action");
|
|
672
|
+
assert.match(app, /gitWorkflow\.step === "generate"\) \{\n\s+const commitInputButton = renderGitWorkflowManualCommitInput\(\{ appendCommitButton: false \}\);[\s\S]*addGitWorkflowAction\("Preview current message files"[\s\S]*gitWorkflowActions\.append\(commitInputButton\)/, "Message process stage should place Commit input immediately after Preview current message files");
|
|
673
|
+
assert.match(app, /gitWorkflow\.step === "message"\) \{[\s\S]*const commitInputButton = renderGitWorkflowManualCommitInput\(\{ appendCommitButton: false \}\);[\s\S]*addGitWorkflowAction\("Regenerate"[\s\S]*gitWorkflowActions\.append\(commitInputButton\)/, "Commit choice stage should place Commit input immediately after Regenerate");
|
|
660
674
|
assert.match(app, /async function commitGitWorkflow\(variant[\s\S]*variant === "input"[\s\S]*message: inputMessage/, "Commit input should send the typed message to the git workflow commit API");
|
|
661
675
|
assert.match(app, /const donePatch = variant === "input"[\s\S]*message: true, commit: true/, "Commit input should mark both message and commit workflow processes done");
|
|
662
676
|
assert.match(server, /\["short", "long", "input"\][\s\S]*cleanGitCommitMessageInput\(body\.message\)[\s\S]*git commit -m <input message>/, "server should accept typed git workflow commit messages");
|
|
@@ -674,11 +688,13 @@ assert.doesNotMatch(app, /gitWorkflowVisibleTabId|Workflow belongs to/, "guided
|
|
|
674
688
|
assert.match(app, /function renderReleaseNpmOutputWidget\(\)/, "release-npm live output should use a specialized Web UI renderer");
|
|
675
689
|
assert.match(app, /async function refreshAppRunners\(tabContext = activeTabContext\(\)\)/, "frontend should load detected app runners for the active tab cwd");
|
|
676
690
|
assert.match(app, /function renderAppRunnerWidget\(\)/, "frontend should render app runner output in the shared top widget area");
|
|
691
|
+
assert.match(app, /function renderAppRunnerInputForm\(run\)[\s\S]*app-runner-stdin-input[\s\S]*Send stdin/, "frontend should let running app runners receive line-oriented stdin");
|
|
677
692
|
assert.match(app, /function appRunnerFailureState\(runnerId, error[\s\S]*failed to start app runner/, "frontend should render visible app-runner start failures instead of only logging them");
|
|
678
693
|
assert.match(app, /appRunnerCustomFeedback[\s\S]*Custom app runner was not saved/, "custom app-runner save failures should be shown inline in the dialog");
|
|
679
694
|
assert.match(server, /function customAppRunnerUnavailableReason\(projectRoot, runner\)[\s\S]*Command is not available/, "server should explain why saved custom app runners are unavailable");
|
|
680
695
|
assert.match(server, /url\.pathname === "\/api\/app-runners" && req\.method === "GET"/, "server should expose detected app runners for the active tab cwd");
|
|
681
696
|
assert.match(server, /url\.pathname === "\/api\/app-runner" && req\.method === "POST"/, "server should start selected app runners directly");
|
|
697
|
+
assert.match(server, /url\.pathname === "\/api\/app-runner\/input" && req\.method === "POST"[\s\S]*sendAppRunnerInput/, "server should accept stdin for running app runners");
|
|
682
698
|
assert.match(server, /function addGoRunner\(runners, cwd\)[\s\S]*Go\/Golang app entry/, "server should detect Go\/Golang app runners");
|
|
683
699
|
assert.match(server, /function addZigRunner\(runners, cwd\)[\s\S]*zig build run[\s\S]*zig run/, "server should detect Zig build and entry-file runners");
|
|
684
700
|
assert.match(server, /function addCppRunners\(runners, cwd\)[\s\S]*C\/C\+\+ CMake executable target[\s\S]*language: "C\+\+"/, "server should detect C\/C++ CMake and entry-file runners");
|
|
@@ -774,6 +790,8 @@ assert.match(app, /Abort requested/, "abort feedback should clarify that Web UI
|
|
|
774
790
|
assert.match(app, /const ABORT_LONG_PRESS_MS = 3000/, "Abort long-press timing should be explicit");
|
|
775
791
|
assert.match(app, /const ABORT_LONG_PRESS_TICK_MS = 100/, "Abort hold countdown should update visibly while held");
|
|
776
792
|
assert.match(app, /const ABORT_LONG_PRESS_RELEASE_GRACE_MS = 350/, "Escape release cancellation should be debounced to ignore spurious keyup during key repeat");
|
|
793
|
+
assert.match(app, /let escapeAbortHoldSuppressesDoubleEscape = false/, "Escape abort hold should track suppression separately from abort button UI state");
|
|
794
|
+
assert.match(app, /function shouldSuppressEmptyPromptEscapeAction\(\)[\s\S]*escapeAbortHoldSuppressesDoubleEscape[\s\S]*suppressEmptyPromptEscapeUntil/, "Escape abort hold should suppress the empty-prompt double-Escape action until keyup or grace expiry");
|
|
777
795
|
assert.match(app, /function isAbortLongPressActive\(\) \{\n\s+return abortLongPressStartedAt > 0;\n\}/, "Abort hold state should stay active from its monotonic start time, not timer id truthiness");
|
|
778
796
|
assert.match(app, /async function abortActiveRun\(\{ source = "button" \} = \{\}\)/, "Abort should be centralized for button, Esc, and long-press triggers");
|
|
779
797
|
assert.match(app, /elements\.abortButton\.addEventListener\("pointerdown", startAbortLongPress\)/, "Abort should support pointer long-press");
|
|
@@ -781,8 +799,9 @@ assert.match(app, /else if \(!event\.repeat\) startAbortLongPress\(event, \{ sou
|
|
|
781
799
|
assert.match(app, /if \(isAbortLongPressActive\(\)\) \{\n\s+resumeAbortLongPressAffordance\(\);\n\s+return true;\n\s+\}\n\s+resetAbortLongPressAffordance\(\);/, "repeat or duplicate start events should resume instead of restart an in-progress abort countdown");
|
|
782
800
|
assert.match(app, /abortLongPressDeadlineAt = abortLongPressStartedAt \+ ABORT_LONG_PRESS_MS/, "Abort hold countdown should use an immutable deadline for display and completion");
|
|
783
801
|
assert.match(app, /function completeAbortLongPress\(\)[\s\S]*?if \(abortLongPressReleasePending\) return;[\s\S]*?if \(isAbortAvailable\(\)\) abortActiveRun\(\{ source \}\);[\s\S]*?else \{\n\s+resetAbortLongPressAffordance\(\);\n\s+updateComposerModeButtons\(\);\n\s+\}/, "completed abort holds should abort only when no release is pending and reset cleanly if the run already stopped");
|
|
802
|
+
assert.match(app, /if \(shouldSuppressEmptyPromptEscapeAction\(\)\) \{\n\s+event\.preventDefault\(\);\n\s+return;\n\s+\}\n\s+if \(event\.repeat\)/, "completed Escape abort holds should suppress trailing Escape events before double-Escape handling");
|
|
784
803
|
assert.match(app, /if \(event\.repeat\) \{\n\s+event\.preventDefault\(\);\n\s+return;\n\s+\}\n\s+if \(document\.activeElement === elements\.promptInput[\s\S]*doubleEscapeAction/, "held Escape key-repeat should not trigger the double-Escape action");
|
|
785
|
-
assert.match(app, /window\.addEventListener\("keyup"[\s\S]*abortLongPressSource === "escape"[\s\S]*scheduleAbortLongPressReleaseReset/, "releasing Escape should debounce-cancel a pending guarded abort hold");
|
|
804
|
+
assert.match(app, /window\.addEventListener\("keyup"[\s\S]*abortLongPressSource === "escape"[\s\S]*scheduleAbortLongPressReleaseReset[\s\S]*finishEscapeAbortHoldSuppression\(\)/, "releasing Escape should debounce-cancel a pending guarded abort hold and re-enable double-Escape after a grace window");
|
|
786
805
|
assert.match(app, /function resumeAbortLongPressAffordance\(\)[\s\S]*clearAbortLongPressResetTimer\(\);\n\s+abortLongPressReleasePending = false;\n\s+tickAbortLongPressAffordance\(\);/, "new Escape keydown events should cancel pending release resets without restarting countdown");
|
|
787
806
|
assert.match(app, /function addAbortTranscriptNotice\(/, "abort button should render a transcript-visible aborted notice");
|
|
788
807
|
assert.match(app, /this transcript marks the run as aborted/, "abort notice should clearly mark the agent output as aborted");
|
|
@@ -890,6 +909,7 @@ assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*
|
|
|
890
909
|
assert.match(app, /\/api\/session-rename/, "native /resume selector should rename session metadata");
|
|
891
910
|
assert.match(app, /\/api\/session-delete/, "native /resume selector should delete sessions with confirmation");
|
|
892
911
|
assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tree[\s\S]*?\/api\/tree-navigate/, "native /tree selector should list tree entries and navigate through the backend helper");
|
|
912
|
+
assert.match(app, /renderNativeSelectorItems\(toItems\(\), \{ emptyText: "No session tree entries match this filter\.", onSelect: navigate, numbered: true \}\)/, "native /tree selector should number entries instead of indenting by depth");
|
|
893
913
|
assert.match(app, /async function openNativeAuthSelector\(mode\)[\s\S]*?\/api\/auth-providers[\s\S]*?Browser login is not implemented yet/, "native /login should list provider status without browser credential entry");
|
|
894
914
|
assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
|
|
895
915
|
assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "internal Web UI helper commands should stay out of command pickers");
|
|
@@ -1114,7 +1134,9 @@ assert.match(server, /case "session": \{[\s\S]*?formatSessionOutput\(tab, state\
|
|
|
1114
1134
|
assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copyText: text/, "native /copy should return text for browser clipboard handling");
|
|
1115
1135
|
assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
|
|
1116
1136
|
assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
|
|
1117
|
-
assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should
|
|
1137
|
+
assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should be able to start native command downloads");
|
|
1138
|
+
assert.match(app, /function openNativeExportDownloadPrompt\(download\)/, "frontend should prompt before opening /export HTML in the browser");
|
|
1139
|
+
assert.match(app, /function alternateLoopbackBrowserUrl\(value\)/, "frontend should avoid reopening exports inside the installed PWA when possible");
|
|
1118
1140
|
assert.match(app, /function safeHttpUrl\(value/, "frontend should validate server-provided URLs through a shared helper");
|
|
1119
1141
|
assert.match(app, /const url = safeHttpUrl\(download\?\.url\)/, "native downloads must reject non-http(s) URL schemes");
|
|
1120
1142
|
assert.match(app, /const href = safeHttpUrl\(url\);/, "network status links must reject non-http(s) URL schemes");
|
|
@@ -148,10 +148,14 @@ assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) &&
|
|
|
148
148
|
assert.match(server, /case "export": \{\n\s+return handleNativeExportCommand\(tab, parsed\.args, req\);\n\s+\}/, "native /export should route through the native command adapter");
|
|
149
149
|
assert.match(server, /tab\.rpc\.send\(\{ type: "export_html", outputPath \}\)/, "no-path /export should use RPC export_html into a controlled temp path");
|
|
150
150
|
assert.match(server, /registerNativeDownload\(exportedPath/, "no-path /export should return a short-lived browser download token");
|
|
151
|
+
assert.match(server, /openUrl: record\.contentType === MIME_TYPES\.get\("\.html"\)/, "HTML native downloads should expose a browser-open URL");
|
|
152
|
+
assert.match(server, /url\.searchParams\.get\("disposition"\) === "inline"/, "native download endpoint should support inline HTML rendering");
|
|
151
153
|
assert.match(server, /copyFile\(sessionFile, targetPath\)/, "explicit .jsonl /export should copy the active session file");
|
|
152
154
|
assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should know how to trigger native command downloads");
|
|
153
155
|
assert.match(app, /function applyNativeSlashCommandEffects\(response, message, tabContext/, "frontend should apply centralized native slash-command adapter effects");
|
|
154
|
-
assert.match(app, /data\.download &&
|
|
156
|
+
assert.match(app, /data\.download && handleNativeDownloadResponse\(data\.download, data\.command\)/, "frontend should route native downloads through command-specific handling");
|
|
157
|
+
assert.match(app, /function openNativeExportDownloadPrompt\(download\)[\s\S]*Open in browser/, "frontend should ask Web UI users to open /export results in the browser");
|
|
158
|
+
assert.match(app, /function alternateLoopbackBrowserUrl\(value\)[\s\S]*hostname === "localhost"[\s\S]*127\.0\.0\.1/, "PWA export opens should escape same-origin app scope via alternate loopback host");
|
|
155
159
|
assert.match(app, /for \(const warning of response\.warnings/, "frontend should surface remote bash trust warnings");
|
|
156
160
|
assert.match(server, /case "\/api\/bash": \{[\s\S]*?return \{ type: "bash", command, excludeFromContext: body\.excludeFromContext === true \}/, "server should expose RPC bash with include/exclude context semantics");
|
|
157
161
|
assert.match(server, /case "\/api\/abort-bash":[\s\S]*?return \{ type: "abort_bash" \}/, "server should expose abort_bash for user bash cancellation");
|