@firstpick/pi-package-webui 0.3.6 → 0.3.8
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 +2 -1
- package/WEBUI_TUI_NATIVE_PARITY.json +22 -22
- package/bin/pi-webui.mjs +452 -110
- package/images/WebUI_v0.3.7.png +0 -0
- package/index.ts +15 -4
- package/lib/auth-actions.mjs +81 -0
- package/lib/native-command-adapter.mjs +220 -0
- package/lib/session-actions.mjs +134 -0
- package/lib/temp-artifacts.mjs +34 -0
- package/lib/trust-boundaries.mjs +141 -0
- package/package.json +8 -4
- package/public/app.js +1278 -99
- package/public/index.html +22 -4
- package/public/service-worker.js +23 -9
- package/public/styles.css +454 -0
- package/start-webui.sh +6 -5
- package/tests/fixtures/fake-pi.mjs +73 -0
- package/tests/http-endpoints-harness.test.mjs +146 -0
- package/tests/mobile-static.test.mjs +66 -21
- package/tests/native-parity-harness.test.mjs +147 -0
- package/tests/native-parity.test.mjs +25 -6
- package/tests/run-all.mjs +19 -0
- package/tests/session-auth-harness.test.mjs +140 -0
- package/tests/temp-artifacts-harness.test.mjs +38 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Minimal JSONL RPC stub standing in for the pi coding agent so HTTP endpoint
|
|
3
|
+
// tests can boot the real pi-webui server without a model provider.
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
|
|
6
|
+
const sessionIndex = process.argv.indexOf("--session");
|
|
7
|
+
const sessionFile = sessionIndex !== -1 ? process.argv[sessionIndex + 1] : undefined;
|
|
8
|
+
|
|
9
|
+
let activeBash = 0;
|
|
10
|
+
let peakBash = 0;
|
|
11
|
+
|
|
12
|
+
function respond(payload) {
|
|
13
|
+
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rl = createInterface({ input: process.stdin });
|
|
17
|
+
rl.on("line", (line) => {
|
|
18
|
+
if (!line.trim()) return;
|
|
19
|
+
let command;
|
|
20
|
+
try {
|
|
21
|
+
command = JSON.parse(line);
|
|
22
|
+
} catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const { id, type } = command || {};
|
|
26
|
+
if (!id || !type) return;
|
|
27
|
+
const base = { type: "response", id, command: type, success: true };
|
|
28
|
+
|
|
29
|
+
switch (type) {
|
|
30
|
+
case "get_state":
|
|
31
|
+
respond({
|
|
32
|
+
...base,
|
|
33
|
+
data: {
|
|
34
|
+
model: { provider: "fake", id: "fake-model" },
|
|
35
|
+
thinkingLevel: "off",
|
|
36
|
+
isStreaming: false,
|
|
37
|
+
isCompacting: false,
|
|
38
|
+
steeringMode: "one-at-a-time",
|
|
39
|
+
followUpMode: "one-at-a-time",
|
|
40
|
+
sessionFile,
|
|
41
|
+
sessionId: "fake-session",
|
|
42
|
+
sessionName: "fake",
|
|
43
|
+
autoCompactionEnabled: false,
|
|
44
|
+
messageCount: 0,
|
|
45
|
+
pendingMessageCount: 0,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
case "get_messages":
|
|
50
|
+
respond({ ...base, data: { messages: [] } });
|
|
51
|
+
return;
|
|
52
|
+
case "get_available_models":
|
|
53
|
+
respond({ ...base, data: { models: [{ provider: "fake", id: "fake-model", name: "Fake Model" }] } });
|
|
54
|
+
return;
|
|
55
|
+
case "get_session_stats":
|
|
56
|
+
respond({ ...base, data: { tokens: 0 } });
|
|
57
|
+
return;
|
|
58
|
+
case "get_last_assistant_text":
|
|
59
|
+
respond({ ...base, data: { text: "fake last text" } });
|
|
60
|
+
return;
|
|
61
|
+
case "bash": {
|
|
62
|
+
activeBash += 1;
|
|
63
|
+
peakBash = Math.max(peakBash, activeBash);
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
activeBash -= 1;
|
|
66
|
+
respond({ ...base, data: { output: `peak:${peakBash}`, exitCode: 0, cancelled: false } });
|
|
67
|
+
}, 150);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
respond({ ...base, data: {} });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const serverScript = join(root, "bin", "pi-webui.mjs");
|
|
12
|
+
const fakePi = join(root, "tests", "fixtures", "fake-pi.mjs");
|
|
13
|
+
const port = 30000 + Math.floor(Math.random() * 20000);
|
|
14
|
+
|
|
15
|
+
function lanAddress() {
|
|
16
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
17
|
+
for (const entry of entries || []) {
|
|
18
|
+
if (entry.family === "IPv4" && !entry.internal) return entry.address;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function request(host, pathname, { method = "GET", body, timeoutMs = 5_000 } = {}) {
|
|
25
|
+
const response = await fetch(`http://${host}:${port}${pathname}`, {
|
|
26
|
+
method,
|
|
27
|
+
headers: body === undefined ? undefined : { "Content-Type": "application/json" },
|
|
28
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
30
|
+
});
|
|
31
|
+
let payload;
|
|
32
|
+
try {
|
|
33
|
+
payload = await response.json();
|
|
34
|
+
} catch {
|
|
35
|
+
payload = undefined;
|
|
36
|
+
}
|
|
37
|
+
return { status: response.status, body: payload };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cwd = await mkdtemp(path.join(tmpdir(), "pi-webui-http-harness-"));
|
|
41
|
+
await chmod(fakePi, 0o755);
|
|
42
|
+
|
|
43
|
+
const child = spawn(process.execPath, [serverScript, "--cwd", cwd, "--host", "0.0.0.0", "--port", String(port), "--pi", fakePi], {
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
let serverOutput = "";
|
|
47
|
+
child.stdout.on("data", (chunk) => {
|
|
48
|
+
serverOutput += String(chunk);
|
|
49
|
+
});
|
|
50
|
+
child.stderr.on("data", (chunk) => {
|
|
51
|
+
serverOutput += String(chunk);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Wait for the HTTP server to accept requests.
|
|
56
|
+
let health;
|
|
57
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
58
|
+
if (child.exitCode !== null) break;
|
|
59
|
+
try {
|
|
60
|
+
health = await request("127.0.0.1", "/api/health", { timeoutMs: 1_000 });
|
|
61
|
+
if (health.status === 200) break;
|
|
62
|
+
} catch {
|
|
63
|
+
// Server not listening yet.
|
|
64
|
+
}
|
|
65
|
+
await delay(200);
|
|
66
|
+
}
|
|
67
|
+
assert.equal(health?.status, 200, `server should become healthy, output:\n${serverOutput}`);
|
|
68
|
+
assert.equal(health.body.ok, true);
|
|
69
|
+
assert.equal(health.body.piRunning, true, "fake pi RPC process should be attached and running");
|
|
70
|
+
|
|
71
|
+
const tabsResponse = await request("127.0.0.1", "/api/tabs");
|
|
72
|
+
assert.equal(tabsResponse.status, 200);
|
|
73
|
+
const tabList = tabsResponse.body?.data?.tabs || tabsResponse.body?.tabs || [];
|
|
74
|
+
assert.equal(tabList.length, 1, "startup should create one tab for --cwd");
|
|
75
|
+
const tabId = tabList[0].id;
|
|
76
|
+
assert.ok(tabId, "tab should have an id");
|
|
77
|
+
|
|
78
|
+
const state = await request("127.0.0.1", `/api/state?tab=${encodeURIComponent(tabId)}`);
|
|
79
|
+
assert.equal(state.status, 200);
|
|
80
|
+
assert.equal(state.body?.data?.model?.provider, "fake", "state should come from the fake pi RPC");
|
|
81
|
+
|
|
82
|
+
// Native slash command routed through the adapter (/copy → get_last_assistant_text).
|
|
83
|
+
const copy = await request("127.0.0.1", "/api/prompt", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
body: { message: "/copy", tab: tabId },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(copy.status, 200);
|
|
88
|
+
assert.equal(copy.body?.data?.status, "succeeded", "native /copy should succeed through the adapter");
|
|
89
|
+
assert.equal(copy.body?.data?.copyText, "fake last text");
|
|
90
|
+
|
|
91
|
+
// Bash FIFO queue: concurrent requests must execute serially on the RPC.
|
|
92
|
+
const [bashA, bashB] = await Promise.all([
|
|
93
|
+
request("127.0.0.1", "/api/bash", { method: "POST", body: { command: "echo a", tab: tabId }, timeoutMs: 10_000 }),
|
|
94
|
+
request("127.0.0.1", "/api/bash", { method: "POST", body: { command: "echo b", tab: tabId }, timeoutMs: 10_000 }),
|
|
95
|
+
]);
|
|
96
|
+
assert.equal(bashA.status, 200);
|
|
97
|
+
assert.equal(bashB.status, 200);
|
|
98
|
+
for (const result of [bashA, bashB]) {
|
|
99
|
+
assert.equal(result.body?.data?.output, "peak:1", "bash queue must never run two commands concurrently");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Session-dir confinement: traversal targets are rejected even from localhost.
|
|
103
|
+
const traversalDelete = await request("127.0.0.1", "/api/session-delete", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
|
|
106
|
+
});
|
|
107
|
+
assert.equal(traversalDelete.status, 403, "session delete outside the session dir must return 403");
|
|
108
|
+
assert.match(String(traversalDelete.body?.error || ""), /session directory/i);
|
|
109
|
+
|
|
110
|
+
const lan = lanAddress();
|
|
111
|
+
if (lan) {
|
|
112
|
+
const remoteDelete = await request(lan, "/api/session-delete", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
body: { sessionPath: path.join(cwd, "outside.jsonl"), confirmed: true, tab: tabId },
|
|
115
|
+
});
|
|
116
|
+
assert.equal(remoteDelete.status, 403, "session delete must be localhost-only");
|
|
117
|
+
|
|
118
|
+
const remoteExport = await request(lan, "/api/prompt", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: { message: "/export", tab: tabId },
|
|
121
|
+
});
|
|
122
|
+
assert.equal(remoteExport.status, 200, "guarded slash commands return blocked adapter cards, not raw HTTP errors");
|
|
123
|
+
assert.equal(remoteExport.body?.data?.status, "blocked", "guards-driven dispatch must block /export for LAN clients");
|
|
124
|
+
|
|
125
|
+
const remoteClose = await request(lan, "/api/network/close", { method: "POST" });
|
|
126
|
+
assert.equal(remoteClose.status, 403, "network close must be localhost-only");
|
|
127
|
+
} else {
|
|
128
|
+
console.log("http-endpoints-harness: no LAN address detected; skipping remote-client checks");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const localClose = await request("127.0.0.1", "/api/network/close", { method: "POST" });
|
|
132
|
+
assert.equal(localClose.status, 202, "network close from localhost should be accepted");
|
|
133
|
+
|
|
134
|
+
const shutdownResponse = await request("127.0.0.1", "/api/shutdown", { method: "POST" });
|
|
135
|
+
assert.equal(shutdownResponse.status, 200);
|
|
136
|
+
|
|
137
|
+
for (let attempt = 0; attempt < 50 && child.exitCode === null; attempt++) {
|
|
138
|
+
await delay(100);
|
|
139
|
+
}
|
|
140
|
+
assert.notEqual(child.exitCode, null, "server should exit after /api/shutdown");
|
|
141
|
+
} finally {
|
|
142
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
143
|
+
await rm(cwd, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log("http-endpoints-harness.test.mjs passed");
|
|
@@ -27,6 +27,7 @@ const companionDependencies = {
|
|
|
27
27
|
"@firstpick/pi-extension-git-footer-status": "^0.2.1",
|
|
28
28
|
"@firstpick/pi-extension-release-aur": "^0.1.3",
|
|
29
29
|
"@firstpick/pi-extension-release-npm": "^0.3.3",
|
|
30
|
+
"@firstpick/pi-extension-safety-guard": "^0.2.3",
|
|
30
31
|
"@firstpick/pi-extension-setup-skills": "^0.1.5",
|
|
31
32
|
"@firstpick/pi-extension-stats": "^0.2.0",
|
|
32
33
|
"@firstpick/pi-extension-todo-progress": "^0.1.7",
|
|
@@ -147,6 +148,7 @@ assert.doesNotMatch(html, /<code>\/release-(?:npm|aur)<\/code>/, "Publish menu s
|
|
|
147
148
|
assert.doesNotMatch(html, /data-tooltip="[^"]*\/release-(?:npm|aur)/, "Publish tooltip should not show slash command names");
|
|
148
149
|
assert.match(html, /id="steerButton"[\s\S]*?data-tooltip="Steer usage:/, "Steer should explain type-first usage in a tooltip");
|
|
149
150
|
assert.match(html, /id="followUpButton"[\s\S]*?data-tooltip="Follow-up usage:/, "Follow-up should explain type-first usage in a tooltip");
|
|
151
|
+
assert.match(html, /id="gitWorkflowButton"[\s\S]*?data-tooltip="Guided Git workflow:[\s\S]*Optional: create or type a PR branch[\s\S]*Push normally, or push the PR branch, generate\/review \/pr, and create the PR/, "Git workflow tooltip should describe the current commit-or-PR flow");
|
|
150
152
|
assert.ok(
|
|
151
153
|
html.indexOf('<main class="layout">') < html.indexOf('id="sidePanelBackdrop"') &&
|
|
152
154
|
html.indexOf('id="sidePanelBackdrop"') < html.indexOf('id="sidePanel"'),
|
|
@@ -309,9 +311,25 @@ assert.match(css, /\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(-
|
|
|
309
311
|
assert.match(css, /\.footer-thinking \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "git-footer effort chip should have its own styling");
|
|
310
312
|
assert.match(css, /\.footer-changes \{[\s\S]*?border-color:\s*rgba\(249, 226, 175, 0\.36\)/, "git-footer changes chip should use a higher-contrast warning tint");
|
|
311
313
|
assert.match(css, /\.footer-changes \.footer-meta-value \{[\s\S]*?color:\s*var\(--ctp-yellow\)[\s\S]*?font-weight:\s*950/, "git-footer changes value should be bright and bold");
|
|
314
|
+
assert.match(css, /\.footer-changed-files-popover \{[\s\S]*?position:\s*absolute;[\s\S]*?bottom:\s*calc\(100% \+ 0\.48rem\)[\s\S]*?max-height:/, "git-footer changes chip should expose a hover popover for changed files");
|
|
315
|
+
assert.match(css, /\.footer-changes-with-files:hover \.footer-changed-files-popover,[\s\S]*?\.footer-changes-with-files:focus \.footer-changed-files-popover,[\s\S]*?\.footer-changes-with-files:focus-within \.footer-changed-files-popover \{[\s\S]*?display:\s*grid/, "git-footer changed-files popover should open on hover or keyboard focus");
|
|
316
|
+
assert.match(css, /\.footer-changed-file\.modified \.footer-changed-file-status \{ color:\s*var\(--ctp-yellow\); \}/, "modified changed-file rows should keep the changes warning color");
|
|
312
317
|
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");
|
|
313
|
-
assert.match(css, /\.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");
|
|
314
|
-
assert.doesNotMatch(css, /\.footer-meta-action::after/, "clickable footer boxes should not show a corner indicator dot");
|
|
318
|
+
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");
|
|
319
|
+
assert.doesNotMatch(css, /\.footer-(?:metric|meta)-action::after/, "clickable footer boxes should not show a corner indicator dot");
|
|
320
|
+
assert.match(css, /\.extension-dialog\.git-changes-dialog \{[\s\S]*?--git-changes-dialog-size:[\s\S]*?width:\s*var\(--git-changes-dialog-size\)[\s\S]*?height:\s*var\(--git-changes-dialog-size\)[\s\S]*?aspect-ratio:\s*1 \/ 1/, "git changes modal should override the base dialog with a square wide diff layout");
|
|
321
|
+
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");
|
|
322
|
+
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");
|
|
323
|
+
assert.match(html, /id="gitChangesDialog"[\s\S]*id="gitChangesRefreshButton"[\s\S]*id="gitChangesBody"/, "git changes modal should expose refresh controls and a diff body");
|
|
324
|
+
assert.match(app, /chip\.key === "changes"[\s\S]*?options\.onClick = openGitChangesDialog/, "footer CHANGES chip should open the git changes modal");
|
|
325
|
+
assert.match(app, /async function loadGitChangesDialog[\s\S]*api\("\/api\/git-changes"/, "git changes modal should load diff data from the server endpoint");
|
|
326
|
+
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");
|
|
327
|
+
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");
|
|
328
|
+
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");
|
|
329
|
+
assert.match(server, /async function readGitUntrackedEntry\(root, file\)[\s\S]*?content: binary \? "" : buffer\.toString\("utf8"\)/, "server should include complete text contents for untracked files");
|
|
330
|
+
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");
|
|
331
|
+
assert.match(server, /async function readGitChanges\(cwd\)[\s\S]*?const diffArgs = \["diff", "--no-ext-diff"[\s\S]*?\["diff", "--cached"/, "server should collect both staged and unstaged git diffs for the changes modal");
|
|
332
|
+
assert.match(server, /url\.pathname === "\/api\/git-changes" && req\.method === "GET"/, "server should expose GET /api/git-changes for the changes modal");
|
|
315
333
|
assert.match(css, /@media \(max-width: 1050px\)[\s\S]*?\.footer-line-meta \{[\s\S]*?display:\s*flex;[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?\.footer-line-meta \.footer-meta \{[\s\S]*?flex:\s*1 1 var\(--footer-chip-min-width\);[\s\S]*?width:\s*auto;[\s\S]*?\.footer-workspace,\n\s+\.footer-model,\n\s+\.footer-thinking \{ grid-column:\s*auto; \}/, "narrow git-footer metadata should wrap like the top metric row instead of forcing a two-column grid");
|
|
316
334
|
assert.match(css, /\.footer-line-tui \{[\s\S]*?white-space:\s*nowrap/, "default Web UI footer should use a minimal TUI-like line");
|
|
317
335
|
assert.match(css, /\.footer-tui-cwd[\s\S]*?max-width:\s*38%/, "TUI-like footer should keep cwd compact on desktop");
|
|
@@ -477,12 +495,15 @@ assert.match(app, /restoreSidePanelSectionState\(\);\nbindSidePanelSectionToggle
|
|
|
477
495
|
assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable toggles should persist in browser storage");
|
|
478
496
|
assert.match(app, /GIT_FOOTER_WEBUI_STATUS_KEY = "git-footer-webui"/, "git footer Web UI data should be received as an extension-owned status payload");
|
|
479
497
|
assert.match(app, /function parseGitFooterWebuiPayloadRaw\(raw\)[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_TYPE[\s\S]*GIT_FOOTER_WEBUI_PAYLOAD_VERSION/, "Web UI footer should parse the structured payload emitted by git-footer-status");
|
|
498
|
+
assert.match(app, /function normalizeFooterPayloadChangedFile\(value\)[\s\S]*FOOTER_CHANGED_FILE_KINDS\.has\(value\.kind\)[\s\S]*oldPath/, "git footer payload parsing should preserve changed-file details for changes popovers");
|
|
499
|
+
assert.match(app, /const files = value\.files\.map\(normalizeFooterPayloadChangedFile\)\.filter\(Boolean\)\.slice\(0, 80\);[\s\S]*chip\.files = files;/, "git footer payload chips should retain bounded changed-file lists");
|
|
480
500
|
assert.match(app, /title: cleanFooterPayloadText\(value\.title, "", 4000\)/, "git footer tooltip titles should preserve long cwd paths instead of truncating at chip display length");
|
|
481
501
|
assert.match(app, /const sourceTitle = cleanFooterPayloadText\(chip\?\.title, "", 4000\)/, "git footer tooltip rendering should keep full source titles for long cwd paths");
|
|
482
502
|
assert.match(app, /function renderFooter\(\)[\s\S]*parseGitFooterWebuiPayload\(\)[\s\S]*renderGitFooterPayload\(footerPayloadWithLiveModel\(gitFooterPayload\)\)/, "detailed footer rendering should prefer the git-footer-status extension payload");
|
|
483
503
|
assert.match(app, /function footerPayloadWithLiveModel\(payload\)[\s\S]*?shortModelLabel\(currentState\.model\)[\s\S]*?footerThinkingDisplay\(\)[\s\S]*?key: "thinking", label: "effort"/, "git footer payload rendering should split model and effort chips from live Web UI state");
|
|
484
|
-
assert.match(app, /function footerContextDisplayWithAuto\(value, state = currentState\)[\s\S]*
|
|
504
|
+
assert.match(app, /function footerContextDisplayWithAuto\(value, state = currentState\)[\s\S]*footerAutoCompactionEnabled\(state\)[\s\S]*`\$\{withoutAuto\} \(auto\)`/, "context displays should append the auto-compaction indicator when enabled");
|
|
485
505
|
assert.match(app, /function footerPayloadWithLiveModel\(payload\)[\s\S]*const contextChip = \(chip\)[\s\S]*footerContextDisplayWithAuto\(chip\?\.value\)[\s\S]*if \(chip\?\.key === "context"\) return \[contextChip\(chip\)\]/, "git footer context chips should use live Web UI auto-compaction state");
|
|
506
|
+
assert.match(app, /async function toggleFooterAutoCompaction\(tabContext = activeTabContext\(\)\)[\s\S]*currentState = \{ \.\.\.currentState, autoCompactionEnabled: enabled \}[\s\S]*api\("\/api\/auto-compaction", \{ method: "POST", body: \{ enabled \}, tabId: tabContext\.tabId \}\)/, "git footer context box should optimistically toggle auto-compaction through the Web UI API");
|
|
486
507
|
assert.match(app, /function renderGitFooterPayload\(payload\)[\s\S]*classList\.remove\("statusbar-tui-footer"\)[\s\S]*classList\.add\("statusbar-git-footer"\)[\s\S]*payload\.main\.map\(renderGitFooterPayloadMetric\)[\s\S]*payload\.meta\.map/, "enabled git footer payload should use the styled extension chip renderer, not the default TUI line");
|
|
487
508
|
assert.match(app, /function ensureFooterTooltipNode\(\)[\s\S]*footer-floating-tooltip[\s\S]*document\.body\.append\(footerTooltipNode\)/, "git footer tooltips should render into a single floating viewport-level node");
|
|
488
509
|
const footerTooltipSource = app.match(/function applyFooterTooltip\(node, tooltip, options = \{\}\)[\s\S]*?\n}\n\nfunction footerMetric/)?.[0] || "";
|
|
@@ -492,8 +513,19 @@ assert.match(app, /const GIT_FOOTER_TOOLTIP_COPY = \{[\s\S]*tokens:[\s\S]*cache:
|
|
|
492
513
|
assert.match(app, /function gitFooterPayloadTooltip\(chip, options = \{\}\)[\s\S]*GIT_FOOTER_TOOLTIP_COPY\[key\][\s\S]*`Current: \$\{value\}`/, "git footer tooltips should combine explanations with the current chip value");
|
|
493
514
|
assert.match(app, /function isRedundantFooterTooltipTitle\(sourceTitle, chip, value\)[\s\S]*labels\.map\(\(label\) => `\$\{label\}: \$\{value\}`\)/, "git footer tooltips should suppress duplicate label/current title lines");
|
|
494
515
|
assert.match(app, /function gitFooterTooltipAlign\(chip\)[\s\S]*\["tokens", "cwd"\][\s\S]*return "start";[\s\S]*\["model", "thinking"\][\s\S]*return "end";/, "git footer tooltip alignment should keep edge boxes readable");
|
|
495
|
-
assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*
|
|
496
|
-
assert.match(app, /function
|
|
516
|
+
assert.match(app, /function renderGitFooterPayloadMetric\(chip\)[\s\S]*applyGitFooterContextToggleOptions\(chip, options\)[\s\S]*gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMetric\(chip\.icon/, "git footer main payload chips should render as styled metrics with explanatory tooltips and context action support");
|
|
517
|
+
assert.match(app, /function applyFooterChangedFilesDropdown\(node, chip\)[\s\S]*chip\?\.key !== "changes"[\s\S]*footer-changes-with-files[\s\S]*footer-changed-files-popover/, "git footer changes chip should render a changed-files hover popover when files are present");
|
|
518
|
+
assert.match(app, /function insertChangedFilePathReference\(path\)[\s\S]*formatPathReference\(path\)[\s\S]*input\.focus\(\)/, "clicking changed files should insert an @path reference and focus the composer");
|
|
519
|
+
assert.match(app, /function renderGitFooterPayloadMeta\(chip, tab\)[\s\S]*options\.title = gitFooterPayloadTooltip\(chip, \{ action \}\)[\s\S]*footerMeta\(chip\.label, chip\.value, footerMetaClassForPayload\(chip\), options\)[\s\S]*applyFooterChangedFilesDropdown\(node, chip\)/, "git footer meta payload chips should render as styled metadata with explanatory tooltips and changes popovers");
|
|
520
|
+
assert.match(app, /chip\.key === "git"[\s\S]*setFooterBranchPickerOpen\(!footerBranchPickerOpen\)[\s\S]*Click to switch to another local branch/, "git branch footer chip should open the branch picker");
|
|
521
|
+
assert.match(app, /function renderFooterBranchPicker\(\)[\s\S]*Git branches[\s\S]*applyFooterGitBranch\(branch\.name\)/, "git branch picker should render available branches and switch on selection");
|
|
522
|
+
assert.match(app, /Create new branch[\s\S]*createFooterGitBranch\(\)/, "git branch picker should offer branch creation when no other branches are available");
|
|
523
|
+
assert.match(app, /function promptFooterGitBranchName\(\)[\s\S]*window\.prompt\("New git branch name:"[\s\S]*function createFooterGitBranch\(\)[\s\S]*confirmFooterGitBranchAction\(branchName, \{ create: true, requireConfirm: true/, "new branch creation should prompt for a branch name and require confirmation before creating it");
|
|
524
|
+
assert.match(app, /function footerBranchAgentWarningLines[\s\S]*WARNING:[\s\S]*still running or waiting for input in this Git working tree/, "branch create/switch confirmation should warn when an agent is active in the current git working tree");
|
|
525
|
+
assert.match(app, /if \(footerBranchPickerOpen\) elements\.statusBar\.append\(renderFooterBranchPicker\(\)\)/, "footer should append the branch picker above the status bar when open");
|
|
526
|
+
assert.match(server, /url\.pathname === "\/api\/git-branches"[\s\S]*readGitBranches\(tab\.cwd\)/, "server should expose local git branch listing for the footer picker");
|
|
527
|
+
assert.match(server, /url\.pathname === "\/api\/git-branch"[\s\S]*switchGitBranch\(tab\.cwd, body\.branch, \{ create: body\.create === true \}\)/, "server should expose git branch switching and creation for the footer picker");
|
|
528
|
+
assert.match(server, /async function switchGitBranch[\s\S]*const args = create \? \["switch", "-c", targetBranch\] : \["switch", targetBranch\]/, "server branch creation should run git switch -c for new local branches");
|
|
497
529
|
assert.match(app, /let latestStats = null/, "default footer should retain session stats for token and context display");
|
|
498
530
|
assert.match(app, /async function refreshStats\(tabContext = activeTabContext\(\)\)[\s\S]*api\("\/api\/stats"/, "default footer should fetch session stats");
|
|
499
531
|
assert.match(app, /function renderMinimalFooter\(\)[\s\S]*stats: fallbackFooterStats\(\)/, "minimal default footer should include token, cost, and context stats");
|
|
@@ -502,7 +534,7 @@ assert.match(app, /function footerStatsCostDisplay\(stats = latestStats\)[\s\S]*
|
|
|
502
534
|
assert.doesNotMatch(app, /Git footer status disabled/, "disabled git footer should show only the minimal footer metadata");
|
|
503
535
|
assert.doesNotMatch(app, /footerMeta\("runtime"/, "minimal Web UI footer should not render runtime metadata");
|
|
504
536
|
assert.match(app, /statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "optional feature detection should recognize the git-footer-status Web UI payload");
|
|
505
|
-
assert.match(app,
|
|
537
|
+
assert.match(app, /message: `\/\$\{refreshCommand\} --webui-silent`/, "Web UI should quietly request the extension-owned footer payload when idle and missing, using the loaded RPC command name");
|
|
506
538
|
assert.match(app, /function requestGitFooterWebuiPayload\(tabContext = activeTabContext\(\), \{ force = false \} = \{\}\)[\s\S]*?!force && statusEntries\.has\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "git footer payload refresh should support forced refresh even when a live payload already exists");
|
|
507
539
|
assert.doesNotMatch(app, /function requestGitFooterWebuiPayload\([\s\S]*?statusEntries\.delete\(GIT_FOOTER_WEBUI_STATUS_KEY\)/, "forced git footer refreshes should keep the existing payload visible while the refresh runs");
|
|
508
540
|
assert.match(app, /function applyOptimisticModelSelection\(model, tabContext = activeTabContext\(\)\)[\s\S]*?currentState = \{ \.\.\.currentState, model: nextModel \}[\s\S]*?renderStatus\(\)[\s\S]*?requestGitFooterWebuiPayload\(tabContext, \{ force: true \}\)/, "model changes should update current state and footer immediately before async refreshes complete");
|
|
@@ -526,13 +558,16 @@ assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiS
|
|
|
526
558
|
assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
|
|
527
559
|
assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
|
|
528
560
|
assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
|
|
561
|
+
assert.match(app, /id: "safetyGuard"[\s\S]*?@firstpick\/pi-extension-safety-guard/, "optional features should include the safety guard companion");
|
|
529
562
|
assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
|
|
530
563
|
assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
|
|
531
|
-
assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
564
|
+
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"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
565
|
+
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");
|
|
566
|
+
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");
|
|
532
567
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
|
|
533
568
|
assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled\(detectedReleasePrompt\.featureId\) \? detectedReleasePrompt : null/, "release confirmation dialogs should use specialized rendering only when their release optional feature is enabled");
|
|
534
569
|
assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
|
|
535
|
-
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*
|
|
570
|
+
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*resolveAvailableCommandName\(commandName, \{ rpcOnly: true \}\)/, "publish workflow launch should guard on loaded slash commands, including duplicate-suffixed RPC command names");
|
|
536
571
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("gitWorkflow"\)\)/, "guided git workflow should guard on enabled /git-staged-msg feature");
|
|
537
572
|
assert.doesNotMatch(html, /gitWorkflowProcessSelect/, "guided git workflow should not expose process selection as a dropdown");
|
|
538
573
|
assert.match(app, /const GIT_WORKFLOW_PROCESSES = \[[\s\S]*value: "stage", label: "Stage"[\s\S]*value: "message", label: "Message"[\s\S]*value: "commit", label: "Commit"[\s\S]*value: "push", label: "Push"/, "guided git workflow should define Stage/Message/Commit/Push process buttons");
|
|
@@ -550,8 +585,8 @@ assert.match(app, /function bindGitWorkflowToActiveTab\(\) \{\n\s+gitWorkflow =
|
|
|
550
585
|
assert.match(app, /function setGitWorkflow\(patch, \{ tabId = activeTabId \} = \{\}\)[\s\S]*if \(tabId === activeTabId\) \{[\s\S]*renderGitWorkflow\(\);/, "guided git workflow should not render inactive terminal workflows globally");
|
|
551
586
|
assert.match(html, /id="gitPrDialog"[\s\S]*id="gitPrTitleInput"[\s\S]*id="gitPrBodyEditor"[\s\S]*id="gitPrCreateButton"/, "guided git workflow should expose a PR review dialog with title and body editing");
|
|
552
587
|
assert.match(app, /addGitWorkflowAction\("Create PR", \(\) => createGitPrBranch\(\), "primary", false, GIT_WORKFLOW_CREATE_PR_TOOLTIP\)/, "guided git workflow should offer a Create PR branch action after message generation");
|
|
553
|
-
assert.match(app, /const GIT_WORKFLOW_CREATE_PR_TOOLTIP = \[[\s\S]*"1\. Ask Pi to generate a type\/feature-name branch from staged changes\."[\s\S]*"
|
|
554
|
-
assert.match(app, /const GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP = \[[\s\S]*"1\. Skip agent branch-name generation\."[\s\S]*"
|
|
588
|
+
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");
|
|
589
|
+
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");
|
|
555
590
|
assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
|
|
556
591
|
assert.match(css, /\.git-workflow-actions button\[data-tooltip\]::after \{[\s\S]*content:\s*attr\(data-tooltip\)[\s\S]*white-space:\s*pre-line/, "guided git workflow action tooltips should render multiline step lists");
|
|
557
592
|
assert.match(app, /function gitBranchNamePromptMessage\(\)[\s\S]*hasAvailableCommand\("git-branch-name"\)[\s\S]*return "\/git-branch-name"/, "guided git workflow should ask the agent to generate PR branch names when the prompt is available");
|
|
@@ -726,7 +761,7 @@ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*cho
|
|
|
726
761
|
assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
|
|
727
762
|
assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
|
|
728
763
|
assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
|
|
729
|
-
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt",
|
|
764
|
+
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", `\/\$\{resolvedCommandName\}\$\{commandRest\}`\)/, "Publish workflows should send resolved slash commands directly without replacing the draft");
|
|
730
765
|
assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
|
|
731
766
|
assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "generic native command menu should fall back to slash-command prompt execution");
|
|
732
767
|
assert.match(app, /function setOptionsMenuOpen\(open\)/, "Options menu should have explicit open state");
|
|
@@ -759,8 +794,11 @@ assert.match(app, /function openNativeSettingsDialog\(\)[\s\S]*?\/api\/steering-
|
|
|
759
794
|
assert.match(app, /function openNativeNameDialog\(\)[\s\S]*?sendPrompt\("prompt", `\/name \$\{name\}`\)/, "native /name selector should prompt before running the slash command");
|
|
760
795
|
assert.match(app, /function openNativeForkSelector\(\)[\s\S]*?\/api\/fork-messages[\s\S]*?\/api\/fork/, "native /fork selector should pair fork-point loading with the fork action");
|
|
761
796
|
assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
|
|
797
|
+
assert.match(app, /\/api\/session-rename/, "native /resume selector should rename session metadata");
|
|
798
|
+
assert.match(app, /\/api\/session-delete/, "native /resume selector should delete sessions with confirmation");
|
|
762
799
|
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");
|
|
763
|
-
assert.match(app, /
|
|
800
|
+
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");
|
|
801
|
+
assert.match(app, /\/api\/auth-logout[\s\S]*?confirmed: true/, "native /logout should remove stored credentials through a confirmed localhost-only endpoint");
|
|
764
802
|
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");
|
|
765
803
|
assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
|
|
766
804
|
assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
|
|
@@ -874,8 +912,8 @@ assert.match(app, /bindMobileViewChanges\(/, "side panel state should react to m
|
|
|
874
912
|
assert.match(app, /function restoreSidePanelState\(\) \{\n\s+if \(isSidePanelOverlayView\(\)\)/, "mobile and narrow overlay layouts should start with side panel collapsed even if desktop state was expanded");
|
|
875
913
|
assert.match(app, /case "webui_tab_reloaded":/, "frontend should handle native /reload tab restart events");
|
|
876
914
|
assert.match(app, /addTransientMessage\(\{ role: "native", title: "\/reload"/, "native /reload should produce visible transcript output");
|
|
877
|
-
assert.match(app, /
|
|
878
|
-
assert.match(app, /Clipboard access failed:[\s\S]*?
|
|
915
|
+
assert.match(app, /copyText\(data\.copyText\)\.catch/, "native /copy should use the shared browser clipboard helper when available");
|
|
916
|
+
assert.match(app, /Clipboard access failed:[\s\S]*?data\.copyText/, "native /copy should show text in transcript when clipboard access fails");
|
|
879
917
|
assert.match(app, /setTimeout\(\(\) => \{[\s\S]*?refreshAll\(tabContext\)\.catch/, "frontend should refresh state after native /reload restarts the RPC process");
|
|
880
918
|
assert.match(app, /api\("\/api\/path-fast-picks"/, "frontend should load/save fast picks through the server API");
|
|
881
919
|
assert.match(app, /loadLegacyFastPicks\(/, "frontend should migrate existing browser-local fast picks");
|
|
@@ -885,7 +923,9 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
|
|
|
885
923
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
|
|
886
924
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
|
|
887
925
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
|
|
888
|
-
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-
|
|
926
|
+
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v\d+"/, "PWA service worker should define a versioned app-shell cache");
|
|
927
|
+
assert.match(serviceWorker, /fetchThenCache\(request\)\.catch\(/, "PWA service worker should serve the app shell network-first with offline cache fallback");
|
|
928
|
+
assert.match(serviceWorker, /ignoreSearch: true/, "PWA service worker offline fallback should ignore ?v= cache busters");
|
|
889
929
|
assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
|
|
890
930
|
assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
|
|
891
931
|
assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
|
|
@@ -902,7 +942,7 @@ assert.match(server, /AuthStorage, SessionManager/, "server should import AuthSt
|
|
|
902
942
|
assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
|
|
903
943
|
assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
|
|
904
944
|
assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
|
|
905
|
-
assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
|
|
945
|
+
assert.match(server, /const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries\(nativeParityMatrix\)/, "server should define Pi native slash commands for autocomplete from the parity matrix");
|
|
906
946
|
assert.match(server, /WEBUI_TUI_NATIVE_PARITY\.json/, "native command descriptions should come from the parity matrix source of truth");
|
|
907
947
|
assert.match(server, /function parseSlashCommand\(message\)/, "server should parse native slash commands before prompt forwarding");
|
|
908
948
|
assert.match(server, /function generatedTabTitleFromPrompt\(message\)/, "server should derive concise automatic tab titles from first prompts");
|
|
@@ -970,6 +1010,9 @@ assert.match(server, /case "copy": \{[\s\S]*?get_last_assistant_text[\s\S]*?copy
|
|
|
970
1010
|
assert.match(server, /case "export": \{[\s\S]*?handleNativeExportCommand\(tab, parsed\.args, req\)/, "native /export should run through the Web UI export helper");
|
|
971
1011
|
assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "native /export should expose short-lived opaque download URLs");
|
|
972
1012
|
assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should auto-start native command downloads");
|
|
1013
|
+
assert.match(app, /function safeHttpUrl\(value/, "frontend should validate server-provided URLs through a shared helper");
|
|
1014
|
+
assert.match(app, /const url = safeHttpUrl\(download\?\.url\)/, "native downloads must reject non-http(s) URL schemes");
|
|
1015
|
+
assert.match(app, /const href = safeHttpUrl\(url\);/, "network status links must reject non-http(s) URL schemes");
|
|
973
1016
|
assert.match(server, /case "\/api\/bash": \{[\s\S]*?type: "bash", command, excludeFromContext: body\.excludeFromContext === true/, "server should expose user bash execution with exclude-from-context support");
|
|
974
1017
|
assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
|
|
975
1018
|
assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
|
|
@@ -1008,6 +1051,7 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
|
|
|
1008
1051
|
assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
|
|
1009
1052
|
assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
|
|
1010
1053
|
assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
|
|
1054
|
+
assert.match(server, /\["safetyGuard", "@firstpick\/pi-extension-safety-guard"\]/, "server should allow installing the safety guard optional feature");
|
|
1011
1055
|
assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
|
|
1012
1056
|
assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
|
|
1013
1057
|
assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
|
|
@@ -1017,9 +1061,9 @@ assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webu
|
|
|
1017
1061
|
assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
|
|
1018
1062
|
assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
|
|
1019
1063
|
assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
|
|
1020
|
-
assert.match(server, /
|
|
1064
|
+
assert.match(server, /requireLocalhostRoute\(req, url\.pathname\)/, "optional feature install endpoint should use shared localhost trust policy");
|
|
1021
1065
|
assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "GET"[\s\S]*?getSkillFileData/, "server should expose GET /api/skill-file for editable skill content");
|
|
1022
|
-
assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?
|
|
1066
|
+
assert.match(server, /url\.pathname === "\/api\/skill-file" && req\.method === "POST"[\s\S]*?requireLocalhostRoute\(req, url\.pathname\)[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
|
|
1023
1067
|
assert.match(server, /function resolveEditableSkillFile\(tab, request = \{\}\)[\s\S]*?path\.basename\(skill\.filePath\) !== "SKILL\.md"/, "skill file API should validate that edits target resolved SKILL.md resources");
|
|
1024
1068
|
assert.match(server, /function resolveExplicitSkillFilePath\(tab, filePath, requestedName = ""\)[\s\S]*?Skill path must point to \/skills\/<name>\/SKILL\.md[\s\S]*?allowedRoots/, "skill file API should allow exact read SKILL.md paths from trusted Pi skill roots");
|
|
1025
1069
|
assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
|
|
@@ -1056,8 +1100,7 @@ assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui
|
|
|
1056
1100
|
assert.match(startScript, /export PI_WEBUI_DEV=1/, "start-webui.sh --dev should mark the Web UI server as dev mode");
|
|
1057
1101
|
assert.match(startScript, /"\$\{webui_cmd\[@\]\}" --cwd "\$cwd" --host "\$host" --port "\$port" "\$\{pass_args\[@\]\}"/, "start-webui.sh should launch through the selected server command without forwarding --dev");
|
|
1058
1102
|
|
|
1059
|
-
assert.match(pkg.scripts?.test || "", /node tests\/
|
|
1060
|
-
assert.match(pkg.scripts?.test || "", /node tests\/native-parity\.test\.mjs/, "package test script should run the native parity harness");
|
|
1103
|
+
assert.match(pkg.scripts?.test || "", /node tests\/run-all\.mjs/, "package test script should run every tests/*.test.mjs through the shared runner");
|
|
1061
1104
|
assert.ok(pkg.files?.includes("start-webui.sh"), "npm package should include the Bash helper launcher");
|
|
1062
1105
|
assert.ok(pkg.files?.includes("start-webui.ps1"), "npm package should include the PowerShell helper launcher");
|
|
1063
1106
|
for (const [name, range] of Object.entries(companionDependencies)) {
|
|
@@ -1069,6 +1112,7 @@ for (const extensionPath of [
|
|
|
1069
1112
|
"../pi-extension-git-footer-status/index.ts",
|
|
1070
1113
|
"../pi-extension-release-aur/index.ts",
|
|
1071
1114
|
"../pi-extension-release-npm/index.ts",
|
|
1115
|
+
"../pi-extension-safety-guard/index.ts",
|
|
1072
1116
|
"../pi-extension-setup-skills/index.ts",
|
|
1073
1117
|
"../pi-extension-stats/index.ts",
|
|
1074
1118
|
"../pi-extension-todo-progress/index.ts",
|
|
@@ -1076,6 +1120,7 @@ for (const extensionPath of [
|
|
|
1076
1120
|
"node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
|
|
1077
1121
|
"node_modules/@firstpick/pi-extension-release-aur/index.ts",
|
|
1078
1122
|
"node_modules/@firstpick/pi-extension-release-npm/index.ts",
|
|
1123
|
+
"node_modules/@firstpick/pi-extension-safety-guard/index.ts",
|
|
1079
1124
|
"node_modules/@firstpick/pi-extension-setup-skills/index.ts",
|
|
1080
1125
|
"node_modules/@firstpick/pi-extension-stats/index.ts",
|
|
1081
1126
|
"node_modules/@firstpick/pi-extension-todo-progress/index.ts",
|
|
@@ -1090,6 +1135,6 @@ assert.ok(pkg.pi?.prompts?.includes("node_modules/@firstpick/pi-prompts-git-pr/p
|
|
|
1090
1135
|
assert.ok(pkg.pi?.themes?.includes("../pi-package-themes-bundle/themes"), "webui Pi manifest should load sibling bundled themes when present");
|
|
1091
1136
|
assert.ok(pkg.pi?.themes?.includes("node_modules/@firstpick/pi-themes-bundle/themes"), "webui Pi manifest should load nested bundled themes when present");
|
|
1092
1137
|
assert.ok(pkg.scripts?.check?.includes("node --check public/app.js"), "check script should syntax-check app.js");
|
|
1093
|
-
assert.ok(pkg.scripts?.check?.includes("node tests/
|
|
1138
|
+
assert.ok(pkg.scripts?.check?.includes("node tests/run-all.mjs"), "check script should run the shared test runner");
|
|
1094
1139
|
|
|
1095
1140
|
console.log("mobile static checks passed");
|