@firstpick/pi-package-webui 0.3.9 → 0.4.1
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 +16 -11
- package/bin/pi-webui.mjs +841 -39
- package/package.json +11 -22
- package/public/app.js +2629 -193
- package/public/index.html +78 -4
- package/public/service-worker.js +1 -1
- package/public/styles.css +931 -4
- package/tests/fixtures/fake-pi.mjs +10 -1
- package/tests/http-endpoints-harness.test.mjs +140 -2
- package/tests/mobile-static.test.mjs +96 -31
|
@@ -47,7 +47,16 @@ rl.on("line", (line) => {
|
|
|
47
47
|
});
|
|
48
48
|
return;
|
|
49
49
|
case "get_messages":
|
|
50
|
-
respond({
|
|
50
|
+
respond({
|
|
51
|
+
...base,
|
|
52
|
+
data: {
|
|
53
|
+
messages: [
|
|
54
|
+
{ role: "user", content: "fake prompt", timestamp: 1000 },
|
|
55
|
+
{ role: "assistant", content: [{ type: "text", text: "fake answer" }], timestamp: 2000 },
|
|
56
|
+
{ role: "user", content: "fake follow-up", timestamp: 3000 },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
51
60
|
return;
|
|
52
61
|
case "get_available_models":
|
|
53
62
|
respond({ ...base, data: { models: [{ provider: "fake", id: "fake-model", name: "Fake Model" }] } });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -42,6 +42,13 @@ await chmod(fakePi, 0o755);
|
|
|
42
42
|
|
|
43
43
|
const child = spawn(process.execPath, [serverScript, "--cwd", cwd, "--host", "0.0.0.0", "--port", String(port), "--pi", fakePi], {
|
|
44
44
|
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
env: {
|
|
46
|
+
...process.env,
|
|
47
|
+
GIT_AUTHOR_NAME: "Pi WebUI Test",
|
|
48
|
+
GIT_AUTHOR_EMAIL: "pi-webui-test@example.invalid",
|
|
49
|
+
GIT_COMMITTER_NAME: "Pi WebUI Test",
|
|
50
|
+
GIT_COMMITTER_EMAIL: "pi-webui-test@example.invalid",
|
|
51
|
+
},
|
|
45
52
|
});
|
|
46
53
|
let serverOutput = "";
|
|
47
54
|
child.stdout.on("data", (chunk) => {
|
|
@@ -68,6 +75,38 @@ try {
|
|
|
68
75
|
assert.equal(health.body.ok, true);
|
|
69
76
|
assert.equal(health.body.piRunning, true, "fake pi RPC process should be attached and running");
|
|
70
77
|
|
|
78
|
+
// Static assets: brotli/gzip compression plus ETag revalidation (P0-2).
|
|
79
|
+
const brotliResponse = await fetch(`http://127.0.0.1:${port}/app.js`, {
|
|
80
|
+
headers: { "accept-encoding": "br, gzip" },
|
|
81
|
+
signal: AbortSignal.timeout(5_000),
|
|
82
|
+
});
|
|
83
|
+
assert.equal(brotliResponse.status, 200);
|
|
84
|
+
assert.equal(brotliResponse.headers.get("content-encoding"), "br", "app.js should be served brotli-compressed");
|
|
85
|
+
assert.equal(brotliResponse.headers.get("cache-control"), "no-cache", "static assets should allow ETag revalidation");
|
|
86
|
+
assert.equal(brotliResponse.headers.get("vary"), "Accept-Encoding");
|
|
87
|
+
const appEtag = brotliResponse.headers.get("etag");
|
|
88
|
+
assert.ok(appEtag, "app.js response should carry an ETag");
|
|
89
|
+
// Node fetch transparently decompresses; equal size proves the brotli
|
|
90
|
+
// round-trip reproduced the exact raw asset.
|
|
91
|
+
const appBody = await brotliResponse.arrayBuffer();
|
|
92
|
+
const rawAppSize = (await stat(join(root, "public", "app.js"))).size;
|
|
93
|
+
assert.equal(appBody.byteLength, rawAppSize, "decompressed app.js should match the raw file byte-for-byte in size");
|
|
94
|
+
|
|
95
|
+
const conditionalResponse = await fetch(`http://127.0.0.1:${port}/app.js`, {
|
|
96
|
+
headers: { "if-none-match": appEtag },
|
|
97
|
+
signal: AbortSignal.timeout(5_000),
|
|
98
|
+
});
|
|
99
|
+
assert.equal(conditionalResponse.status, 304, "matching If-None-Match should return 304");
|
|
100
|
+
await conditionalResponse.arrayBuffer();
|
|
101
|
+
|
|
102
|
+
const gzipResponse = await fetch(`http://127.0.0.1:${port}/styles.css`, {
|
|
103
|
+
headers: { "accept-encoding": "gzip" },
|
|
104
|
+
signal: AbortSignal.timeout(5_000),
|
|
105
|
+
});
|
|
106
|
+
assert.equal(gzipResponse.status, 200);
|
|
107
|
+
assert.equal(gzipResponse.headers.get("content-encoding"), "gzip", "styles.css should fall back to gzip");
|
|
108
|
+
await gzipResponse.arrayBuffer();
|
|
109
|
+
|
|
71
110
|
const tabsResponse = await request("127.0.0.1", "/api/tabs");
|
|
72
111
|
assert.equal(tabsResponse.status, 200);
|
|
73
112
|
const tabList = tabsResponse.body?.data?.tabs || tabsResponse.body?.tabs || [];
|
|
@@ -79,6 +118,105 @@ try {
|
|
|
79
118
|
assert.equal(state.status, 200);
|
|
80
119
|
assert.equal(state.body?.data?.model?.provider, "fake", "state should come from the fake pi RPC");
|
|
81
120
|
|
|
121
|
+
const gitAvailable = spawnSync("git", ["--version"], { encoding: "utf8" }).status === 0;
|
|
122
|
+
if (gitAvailable) {
|
|
123
|
+
const gitInit = await request("127.0.0.1", "/api/git-workflow/init", { method: "POST", body: { tab: tabId } });
|
|
124
|
+
assert.equal(gitInit.status, 200);
|
|
125
|
+
assert.equal(gitInit.body?.ok, true, "git init endpoint should initialize a temp repository");
|
|
126
|
+
|
|
127
|
+
const initFileStatus = await request("127.0.0.1", `/api/git-workflow/init-files-status?tab=${encodeURIComponent(tabId)}`);
|
|
128
|
+
assert.equal(initFileStatus.status, 200);
|
|
129
|
+
assert.equal(initFileStatus.body?.ok, true, "init files status endpoint should check README.md and .gitignore");
|
|
130
|
+
assert.equal(initFileStatus.body?.data?.readmeExists, false);
|
|
131
|
+
assert.equal(initFileStatus.body?.data?.gitignoreExists, false);
|
|
132
|
+
|
|
133
|
+
const gitReadme = await request("127.0.0.1", "/api/git-workflow/readme", { method: "POST", body: { repoName: "pi-webui-http-harness", stack: "Node.js / TypeScript", tab: tabId } });
|
|
134
|
+
assert.equal(gitReadme.status, 200);
|
|
135
|
+
assert.equal(gitReadme.body?.ok, true, "README endpoint should create/stage README.md and .gitignore");
|
|
136
|
+
assert.equal(gitReadme.body?.data?.readme?.created, true);
|
|
137
|
+
assert.equal(gitReadme.body?.data?.gitignore?.created, true);
|
|
138
|
+
|
|
139
|
+
const gitReadmeAgain = await request("127.0.0.1", "/api/git-workflow/readme", { method: "POST", body: { repoName: "pi-webui-http-harness", stack: "Node.js / TypeScript", tab: tabId } });
|
|
140
|
+
assert.equal(gitReadmeAgain.status, 200);
|
|
141
|
+
assert.equal(gitReadmeAgain.body?.ok, true, "README endpoint should re-check existing files without overwriting");
|
|
142
|
+
assert.equal(gitReadmeAgain.body?.data?.readme?.created, false);
|
|
143
|
+
assert.equal(gitReadmeAgain.body?.data?.gitignore?.created, false);
|
|
144
|
+
|
|
145
|
+
const gitCommit = await request("127.0.0.1", "/api/git-workflow/initial-commit", { method: "POST", body: { tab: tabId } });
|
|
146
|
+
assert.equal(gitCommit.status, 200);
|
|
147
|
+
assert.equal(gitCommit.body?.ok, true, "initial commit endpoint should commit the staged README.md");
|
|
148
|
+
|
|
149
|
+
const gitMain = await request("127.0.0.1", "/api/git-workflow/main-branch", { method: "POST", body: { tab: tabId } });
|
|
150
|
+
assert.equal(gitMain.status, 200);
|
|
151
|
+
assert.equal(gitMain.body?.ok, true, "main branch endpoint should rename the branch");
|
|
152
|
+
|
|
153
|
+
const gitRemote = await request("127.0.0.1", "/api/git-workflow/remote", { method: "POST", body: { username: "Firstp1ck", repoName: "pi-webui-http-harness", tab: tabId } });
|
|
154
|
+
assert.equal(gitRemote.status, 200);
|
|
155
|
+
assert.equal(gitRemote.body?.ok, true, "remote endpoint should add origin without pushing");
|
|
156
|
+
assert.equal(gitRemote.body?.data?.remoteUrl, "https://github.com/Firstp1ck/pi-webui-http-harness.git");
|
|
157
|
+
|
|
158
|
+
await writeFile(path.join(cwd, "single.txt"), "created\n");
|
|
159
|
+
const gitAddCreated = await request("127.0.0.1", "/api/git-workflow/add", { method: "POST", body: { tab: tabId } });
|
|
160
|
+
assert.equal(gitAddCreated.status, 200);
|
|
161
|
+
assert.equal(gitAddCreated.body?.ok, true, "git add endpoint should stage a new single file");
|
|
162
|
+
const createdDefault = await request("127.0.0.1", `/api/git-workflow/default-commit-message?tab=${encodeURIComponent(tabId)}`);
|
|
163
|
+
assert.equal(createdDefault.status, 200);
|
|
164
|
+
assert.equal(createdDefault.body?.ok, true, "default commit message endpoint should return ok for a staged single file");
|
|
165
|
+
assert.equal(createdDefault.body?.data?.message, "created single.txt");
|
|
166
|
+
const createdCommit = await request("127.0.0.1", "/api/git-workflow/commit", { method: "POST", body: { variant: "input", message: createdDefault.body?.data?.message, tab: tabId } });
|
|
167
|
+
assert.equal(createdCommit.status, 200);
|
|
168
|
+
assert.equal(createdCommit.body?.ok, true, "input commit endpoint should accept the generated single-file default");
|
|
169
|
+
|
|
170
|
+
await writeFile(path.join(cwd, "single.txt"), "updated\n");
|
|
171
|
+
const gitAddUpdated = await request("127.0.0.1", "/api/git-workflow/add", { method: "POST", body: { tab: tabId } });
|
|
172
|
+
assert.equal(gitAddUpdated.status, 200);
|
|
173
|
+
assert.equal(gitAddUpdated.body?.ok, true, "git add endpoint should stage a single-file update");
|
|
174
|
+
const updatedDefault = await request("127.0.0.1", `/api/git-workflow/default-commit-message?tab=${encodeURIComponent(tabId)}`);
|
|
175
|
+
assert.equal(updatedDefault.status, 200);
|
|
176
|
+
assert.equal(updatedDefault.body?.data?.message, "updated single.txt");
|
|
177
|
+
const updatedCommit = await request("127.0.0.1", "/api/git-workflow/commit", { method: "POST", body: { variant: "input", message: updatedDefault.body?.data?.message, tab: tabId } });
|
|
178
|
+
assert.equal(updatedCommit.status, 200);
|
|
179
|
+
assert.equal(updatedCommit.body?.ok, true, "input commit endpoint should accept the update default");
|
|
180
|
+
|
|
181
|
+
await rm(path.join(cwd, "single.txt"));
|
|
182
|
+
const gitAddDeleted = await request("127.0.0.1", "/api/git-workflow/add", { method: "POST", body: { tab: tabId } });
|
|
183
|
+
assert.equal(gitAddDeleted.status, 200);
|
|
184
|
+
assert.equal(gitAddDeleted.body?.ok, true, "git add endpoint should stage a single-file deletion");
|
|
185
|
+
const deletedDefault = await request("127.0.0.1", `/api/git-workflow/default-commit-message?tab=${encodeURIComponent(tabId)}`);
|
|
186
|
+
assert.equal(deletedDefault.status, 200);
|
|
187
|
+
assert.equal(deletedDefault.body?.data?.message, "deleted single.txt");
|
|
188
|
+
|
|
189
|
+
await writeFile(path.join(cwd, "multi-a.txt"), "a\n");
|
|
190
|
+
await writeFile(path.join(cwd, "multi-b.txt"), "b\n");
|
|
191
|
+
const gitAddMultiple = await request("127.0.0.1", "/api/git-workflow/add", { method: "POST", body: { tab: tabId } });
|
|
192
|
+
assert.equal(gitAddMultiple.status, 200);
|
|
193
|
+
assert.equal(gitAddMultiple.body?.ok, true, "git add endpoint should stage multiple files");
|
|
194
|
+
const multipleDefault = await request("127.0.0.1", `/api/git-workflow/default-commit-message?tab=${encodeURIComponent(tabId)}`);
|
|
195
|
+
assert.equal(multipleDefault.status, 200);
|
|
196
|
+
assert.equal(multipleDefault.body?.ok, true, "default commit message endpoint should still return ok when no default is available");
|
|
197
|
+
assert.equal(multipleDefault.body?.data?.message, "", "multiple staged files should not get a default commit message");
|
|
198
|
+
} else {
|
|
199
|
+
console.log("http-endpoints-harness: git not available; skipping git init workflow endpoint checks");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Delta transcript endpoint (P1-1): ?since= returns only the tail plus merge metadata.
|
|
203
|
+
const fullMessages = await request("127.0.0.1", `/api/messages?tab=${encodeURIComponent(tabId)}`);
|
|
204
|
+
assert.equal(fullMessages.status, 200);
|
|
205
|
+
assert.equal((fullMessages.body?.data?.messages || []).length, 3, "fake pi should provide a 3-message transcript");
|
|
206
|
+
assert.equal(fullMessages.body?.data?.totalCount, undefined, "full fetches should keep the legacy payload shape");
|
|
207
|
+
|
|
208
|
+
const deltaMessages = await request("127.0.0.1", `/api/messages?since=2&tab=${encodeURIComponent(tabId)}`);
|
|
209
|
+
assert.equal(deltaMessages.status, 200);
|
|
210
|
+
assert.equal(deltaMessages.body?.data?.since, 2);
|
|
211
|
+
assert.equal(deltaMessages.body?.data?.totalCount, 3);
|
|
212
|
+
assert.equal((deltaMessages.body?.data?.messages || []).length, 1, "since=2 should return only the tail message");
|
|
213
|
+
assert.equal(deltaMessages.body?.data?.messages?.[0]?.content, "fake follow-up");
|
|
214
|
+
|
|
215
|
+
const clampedMessages = await request("127.0.0.1", `/api/messages?since=99&tab=${encodeURIComponent(tabId)}`);
|
|
216
|
+
assert.equal(clampedMessages.status, 200);
|
|
217
|
+
assert.equal(clampedMessages.body?.data?.since, 3, "since beyond the transcript should clamp to the total count");
|
|
218
|
+
assert.equal((clampedMessages.body?.data?.messages || []).length, 0);
|
|
219
|
+
|
|
82
220
|
// Native slash command routed through the adapter (/copy → get_last_assistant_text).
|
|
83
221
|
const copy = await request("127.0.0.1", "/api/prompt", {
|
|
84
222
|
method: "POST",
|
|
@@ -24,16 +24,16 @@ const [pkgRaw, html, css, app, server, extension, readme, startScript, manifestR
|
|
|
24
24
|
const pkg = JSON.parse(pkgRaw);
|
|
25
25
|
const manifest = JSON.parse(manifestRaw);
|
|
26
26
|
const companionDependencies = {
|
|
27
|
-
"@firstpick/pi-extension-git-footer-status": "^0.
|
|
28
|
-
"@firstpick/pi-extension-release-aur": "^0.1.
|
|
29
|
-
"@firstpick/pi-extension-release-npm": "^0.
|
|
27
|
+
"@firstpick/pi-extension-git-footer-status": "^0.3.3",
|
|
28
|
+
"@firstpick/pi-extension-release-aur": "^0.1.6",
|
|
29
|
+
"@firstpick/pi-extension-release-npm": "^0.4.0",
|
|
30
30
|
"@firstpick/pi-extension-safety-guard": "^0.2.3",
|
|
31
|
-
"@firstpick/pi-extension-setup-skills": "^0.1.
|
|
32
|
-
"@firstpick/pi-extension-stats": "^0.2.
|
|
33
|
-
"@firstpick/pi-extension-todo-progress": "^0.
|
|
34
|
-
"@firstpick/pi-extension-tools": "^0.1.
|
|
35
|
-
"@firstpick/pi-prompts-git-pr": "^0.1.
|
|
36
|
-
"@firstpick/pi-themes-bundle": "^0.1.
|
|
31
|
+
"@firstpick/pi-extension-setup-skills": "^0.1.8",
|
|
32
|
+
"@firstpick/pi-extension-stats": "^0.2.6",
|
|
33
|
+
"@firstpick/pi-extension-todo-progress": "^0.2.4",
|
|
34
|
+
"@firstpick/pi-extension-tools": "^0.1.6",
|
|
35
|
+
"@firstpick/pi-prompts-git-pr": "^0.1.2",
|
|
36
|
+
"@firstpick/pi-themes-bundle": "^0.1.4",
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
assert.match(html, /viewport-fit=cover/, "viewport should opt into safe-area-aware full-screen layout");
|
|
@@ -267,7 +267,7 @@ assert.match(css, /\.composer-publish-menu:hover > \.composer-publish-button\[da
|
|
|
267
267
|
assert.match(css, /\.composer-publish-menu-panel \{[\s\S]*?display:\s*none;[\s\S]*?flex-direction:\s*column/, "Publish workflow menu should hide when closed and expand like grouped tabs");
|
|
268
268
|
assert.match(css, /\.composer-publish-menu:hover \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu:focus-within \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu\.open \.composer-publish-menu-panel \{\n\s+display:\s*flex;/, "Publish workflow menu should open on hover, focus, or explicit open state");
|
|
269
269
|
assert.match(css, /\.composer-native-command-button \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu should have a distinct slash-command button style");
|
|
270
|
-
assert.match(css, /\.composer-options-menu-panel \{[\s\S]*?max-height:\s*min\(
|
|
270
|
+
assert.match(css, /\.composer-options-menu-panel \{[\s\S]*?max-height:\s*min\(calc\(var\(--visual-viewport-height, 100dvh\) - 2rem\), 44rem\)/, "Options menu should be tall enough for common commands without scrolling on normal viewports");
|
|
271
271
|
assert.match(css, /\.composer-native-command-menu-item \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu items should be styled separately from publish actions");
|
|
272
272
|
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish and command menu buttons should fit beside Git workflow in mobile actions");
|
|
273
273
|
assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
|
|
@@ -421,6 +421,10 @@ assert.match(app, /api\("\/api\/shutdown", \{ method: "POST", scoped: false \}\)
|
|
|
421
421
|
assert.match(server, /url\.pathname === "\/api\/restart" && req\.method === "POST"/, "server should expose restart endpoint");
|
|
422
422
|
assert.match(server, /PI_WEBUI_RESTORE_TABS: JSON\.stringify\(restorableTabs \|\| \[\]\)/, "server restart should preserve restorable tab metadata");
|
|
423
423
|
assert.match(server, /if \(webuiDevServer\) env\.PI_WEBUI_DEV = "1";/, "server restart should explicitly preserve dev mode");
|
|
424
|
+
assert.match(server, /async function resolveUpdateTasks\(\)[\s\S]*currentWebuiPackageUpdateTask\(\)[\s\S]*agentPackageRootUpdateTask\(\)[\s\S]*npmGlobalPackageRootUpdateTask\(\)[\s\S]*bunGlobalPackageRootUpdateTask\(\)/, "server update should include current, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots");
|
|
425
|
+
assert.match(server, /function packageInstallSpecs\(packageNames\)[\s\S]*`\$\{packageName\}@latest`/, "server package update tasks should force latest Web UI/Pi package specs instead of staying inside stale semver ranges");
|
|
426
|
+
assert.match(app, /Run Pi\/Web UI package updates now\?/, "frontend update confirmation should describe the broader package update set");
|
|
427
|
+
assert.match(readme, /detected local\/global Web UI and Pi package-manager updates/, "README should document that update refreshes local and global Web UI\/Pi package roots");
|
|
424
428
|
assert.match(server, /async function closeNetworkAccess\(\)/, "server should expose a local-only rebind helper for closing network access");
|
|
425
429
|
assert.match(server, /url\.pathname === "\/api\/network\/close" && req\.method === "POST"/, "server should route network close requests");
|
|
426
430
|
assert.match(server, /server\.closeAllConnections\?\.\(\)/, "network rebind should force-close long-lived clients so close-to-localhost can complete");
|
|
@@ -548,7 +552,7 @@ assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s
|
|
|
548
552
|
const workspaceInfoSource = server.match(/async function getWorkspaceInfo[\s\S]*?\n}\n\nlet activeGitWorkflowProcess/)?.[0] || "";
|
|
549
553
|
assert.ok(workspaceInfoSource, "server workspace info source should be inspectable");
|
|
550
554
|
assert.doesNotMatch(workspaceInfoSource, /runCommand\("git"|branchStatus|isRepo/, "Web UI workspace endpoint should not duplicate git footer status collection");
|
|
551
|
-
assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
|
|
555
|
+
assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true, forceRebuild: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
|
|
552
556
|
assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*renderOptionalFeatureDependentDisplays\(\);[\s\S]*const tabContext = activeTabContext\(\);[\s\S]*refreshCommands\(tabContext\)/, "optional feature enable/disable should re-render the GUI and then refresh command capabilities");
|
|
553
557
|
assert.match(app, /function setOptionalControlState\(button, available, unavailableTitle\)[\s\S]*setAttribute\("aria-label", nextAriaLabel\)[\s\S]*setAttribute\("data-tooltip", nextTooltip\)/, "optional feature button disabled state should update accessible labels and visible tooltips");
|
|
554
558
|
assert.match(app, /const hasGitWorkflow = isOptionalFeatureEnabled\("gitWorkflow"\);\n\s+elements\.gitWorkflowButton\.hidden = !hasGitWorkflow/, "guided git workflow composer button should be hidden when unavailable or disabled");
|
|
@@ -557,7 +561,10 @@ assert.match(app, /\["skills", "tuiSkillsCommand"\][\s\S]*\["tools", "tuiToolsCo
|
|
|
557
561
|
assert.match(app, /function setNativeCommandMenuOpen\(open\)/, "frontend should track the skills/tools command menu open state separately from Publish");
|
|
558
562
|
assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiSkillsCommand"\)[\s\S]*nativeToolsButton\.hidden = !isOptionalFeatureEnabled\("tuiToolsCommand"\)/, "skills/tools menu items should be hidden by their optional feature toggles");
|
|
559
563
|
assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
|
|
560
|
-
assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose
|
|
564
|
+
assert.match(app, /function installOptionalFeature\(featureId, \{ update = false \} = \{\}\)/, "optional features should expose install and update actions");
|
|
565
|
+
assert.match(app, /api\("\/api\/optional-features"/, "optional feature panel should fetch package install/update status from the backend");
|
|
566
|
+
assert.match(app, /packageStatus\?\.updateAvailable[\s\S]*action\.textContent = "Update…"/, "optional feature package drift should turn the install action into an update action");
|
|
567
|
+
assert.match(app, /optionalFeatureInstallMessages\.set\(featureId[\s\S]*waiting for package-manager output/, "optional feature installs should show running feedback while npm is active");
|
|
561
568
|
assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
|
|
562
569
|
assert.match(app, /id: "safetyGuard"[\s\S]*?@firstpick\/pi-extension-safety-guard/, "optional features should include the safety guard companion");
|
|
563
570
|
assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
|
|
@@ -589,6 +596,13 @@ assert.match(app, /addGitWorkflowAction\("Create PR", \(\) => createGitPrBranch\
|
|
|
589
596
|
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");
|
|
590
597
|
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");
|
|
591
598
|
assert.match(app, /addGitWorkflowAction\("Manual branch", \(\) => createGitPrBranchManually\(\), "", false, GIT_WORKFLOW_MANUAL_BRANCH_TOOLTIP\)/, "Manual branch should render with its tooltip");
|
|
599
|
+
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");
|
|
600
|
+
assert.match(app, /gitWorkflow\.step === "generate"\) \{\n\s+renderGitWorkflowManualCommitInput\(\);\n\s+addGitWorkflowAction\("Run \/git-staged-msg"/, "Message process stage should show manual input before generated-message actions");
|
|
601
|
+
assert.match(app, /renderGitWorkflowManualCommitInput\(\);[\s\S]*addGitWorkflowAction\("Commit short"/, "Commit choice stage should keep manual commit input before generated commit choices");
|
|
602
|
+
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");
|
|
603
|
+
assert.match(app, /const donePatch = variant === "input"[\s\S]*message: true, commit: true/, "Commit input should mark both message and commit workflow processes done");
|
|
604
|
+
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");
|
|
605
|
+
assert.match(css, /\.git-workflow-message-input-row \{[\s\S]*flex:\s*1 1 100%/, "manual git workflow commit input should span the Message stage actions row");
|
|
592
606
|
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");
|
|
593
607
|
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");
|
|
594
608
|
assert.match(app, /async function loadGitWorkflowBranchName\([\s\S]*gitWorkflowRequest\("\/api\/git-workflow\/branch-name"/, "guided git workflow should load generated agent branch names before branch creation");
|
|
@@ -637,7 +651,7 @@ assert.match(app, /function scheduleLiveToolRunRender\(run[\s\S]*?liveToolRender
|
|
|
637
651
|
assert.match(app, /function handleToolExecutionUpdate\(event\)[\s\S]*?event\.partialResult[\s\S]*?scheduleLiveToolRunRender\(run, \{ scroll: false \}\)/, "live tool_execution_update events should update transcript-visible tool cards without replacing them per event");
|
|
638
652
|
assert.match(app, /function captureReusableToolCards\(\)[\s\S]*?\.message\.toolExecution\[data-tool-call-id\]/, "full transcript re-renders should capture existing tool cards before clearing the chat");
|
|
639
653
|
assert.match(app, /function appendMessage\(message,[\s\S]*?reusableToolCards = null[\s\S]*?reuseToolExecutionBubble\(reusableToolCards, message/, "message rendering should reuse matching tool cards instead of replacing them during refreshes");
|
|
640
|
-
assert.match(app, /function renderAllMessages\(\{ preserveScroll = false \} = \{\}\)[\s\S]*?const reusableToolCards = captureReusableToolCards\(\);[\s\S]*?appendTranscriptMessage\(item\.message,[\s\S]*?reusableToolCards,/, "transcript refreshes should pass reusable tool cards through to item rendering");
|
|
654
|
+
assert.match(app, /function renderAllMessages\(\{ preserveScroll = false, forceRebuild = false \} = \{\}\)[\s\S]*?const reusableToolCards = captureReusableToolCards\(\);[\s\S]*?appendTranscriptMessage\(entry\.item\.message,[\s\S]*?reusableToolCards,/, "transcript refreshes should pass reusable tool cards through to item rendering");
|
|
641
655
|
assert.match(app, /const keyedToolExecution = message\.role === "toolExecution" && message\.toolCallId[\s\S]*?keyedToolExecution \? "toolExecution"[\s\S]*?keyedToolExecution \? "" : message\.title[\s\S]*?keyedToolExecution \? "" : message\.timestamp/, "tool action entry identity should stay stable when live transient cards become persisted transcript cards");
|
|
642
656
|
assert.match(app, /appendText\(preview, toolResultPreviewText\(message, 10\), "code-block tool-result-preview-text"\)/, "collapsed tool results should render the first ten preview lines by default");
|
|
643
657
|
assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
|
|
@@ -670,7 +684,7 @@ assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assist
|
|
|
670
684
|
assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
|
|
671
685
|
assert.match(app, /const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220/, "live assistant text should be briefly guarded so pre-tool-call text can be suppressed");
|
|
672
686
|
assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
|
|
673
|
-
assert.match(app, /if \(assistantText\) \{[\s\S]*?
|
|
687
|
+
assert.match(app, /if \(assistantText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, assistantText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
|
|
674
688
|
assert.match(app, /if \(streamToolCallSeen \|\| streamBubble\) renderStreamingAssistantText\(\);\n\s+else scheduleStreamingAssistantTextRender\(\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
|
|
675
689
|
assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
|
|
676
690
|
assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "final output"/, "live Assistant cards should be created only for final output text without a noisy Assistant label");
|
|
@@ -847,7 +861,7 @@ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu:hover::aft
|
|
|
847
861
|
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?position:\s*absolute;[\s\S]*?inset:\s*auto auto calc\(100% \+ 0\.38rem\) 0;[\s\S]*?max-height:\s*min\(34dvh, 18rem\);[\s\S]*?overflow:\s*auto;/, "opened mobile Actions dropdown panels should float upward over the Actions controls with their own scrollbar");
|
|
848
862
|
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-panel \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?max-width:\s*100%;/, "mobile Actions dropdown panels should align to the width of their trigger buttons");
|
|
849
863
|
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu \.composer-publish-menu-item \{[\s\S]*?width:\s*100%;[\s\S]*?min-width:\s*0;[\s\S]*?white-space:\s*normal;/, "mobile Actions dropdown option buttons should not keep desktop min-widths that misalign with triggers");
|
|
850
|
-
assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel \{[\s\S]*?inset-inline:\s*auto 0;[\s\S]*?max-height:\s*min\(
|
|
864
|
+
assert.match(css, /\.composer-actions-panel > \.composer-options-menu \.composer-publish-menu-panel \{[\s\S]*?inset-inline:\s*auto 0;[\s\S]*?max-height:\s*min\(calc\(var\(--visual-viewport-height, 100dvh\) - 2rem\), 44rem\);/, "mobile Options dropdown should be tall enough to avoid scrolling for the standard option list");
|
|
851
865
|
assert.match(app, /function setMobileTabsExpanded\(/, "mobile tab strip should be JS-toggleable");
|
|
852
866
|
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group \{\n\s+display:\s*grid;\n\s+grid-template-columns:\s*minmax\(0, 1fr\) auto;/, "mobile terminal tab groups should use a stable grid row for the tab and close button when expanded");
|
|
853
867
|
assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.terminal-tab-group-menu \{[\s\S]*?grid-column:\s*1 \/ -1;[\s\S]*?margin:\s*0\.34rem 0 0;/, "mobile terminal tab group menus should not add horizontal margins that overflow and distort the tab card");
|
|
@@ -940,6 +954,12 @@ assert.ok(matrixBackground.length > 100000, "Matrix background image should be p
|
|
|
940
954
|
assert.ok(mochaBackground.length > 8000, "Catppuccin Mocha background image should be present as a compact PNG asset");
|
|
941
955
|
|
|
942
956
|
assert.match(server, /AuthStorage, SessionManager/, "server should import AuthStorage for safe OAuth token refresh");
|
|
957
|
+
assert.match(server, /DefaultPackageManager/, "server should use Pi's package resolver when controlling Web UI tab extension loading");
|
|
958
|
+
assert.match(server, /WEBUI_CONTROLLED_PACKAGES = new Set\(\[WEBUI_PACKAGE, \.\.\.OPTIONAL_FEATURE_PACKAGES\.values\(\)\]\)/, "server should identify Web UI-controlled packages for de-duplicated feature loading");
|
|
959
|
+
assert.match(server, /const args = \["--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes"\]/, "Web UI tabs should disable implicit resource loading before adding curated resource paths");
|
|
960
|
+
assert.match(server, /normalPiResourcePathsForTab[\s\S]*WEBUI_CONTROLLED_PACKAGES\.has\(packageName\)[\s\S]*continue/, "Web UI tab resource resolution should exclude separately installed Web UI feature packages");
|
|
961
|
+
assert.match(server, /startedWebuiResourcePaths\(resourceType\)/, "Web UI tabs should load feature resources from the started Web UI package");
|
|
962
|
+
assert.match(server, /resolveInstalledPackageSubpath\(nodeModulesRef\.packageName, nodeModulesRef\.subpath\)/, "Web UI should prefer workspace/global/package-root installed packages for node_modules manifest entries");
|
|
943
963
|
assert.match(server, /const CODEX_TOKEN_REFRESH_SKEW_MS = 5 \* 60 \* 1000/, "server should refresh Codex OAuth tokens before they expire");
|
|
944
964
|
assert.match(server, /url\.pathname === "\/api\/codex-usage" && req\.method === "GET"/, "server should expose a sanitized Codex usage endpoint");
|
|
945
965
|
assert.match(server, /OPENAI_CODEX_USAGE_ENDPOINT/, "server should query Codex usage from the backend, not the browser");
|
|
@@ -1055,12 +1075,16 @@ assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should
|
|
|
1055
1075
|
assert.match(server, /\["safetyGuard", "@firstpick\/pi-extension-safety-guard"\]/, "server should allow installing the safety guard optional feature");
|
|
1056
1076
|
assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
|
|
1057
1077
|
assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
|
|
1078
|
+
assert.match(server, /function optionalFeaturePackageStatus\(featureId\)/, "server should report optional feature package install/update status");
|
|
1058
1079
|
assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
|
|
1059
1080
|
assert.match(server, /PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT/, "optional feature installs should support an explicit package-manager root override");
|
|
1060
|
-
assert.match(server, /function configuredAgentNpmRoot\(\)/, "global Web UI launches should
|
|
1061
|
-
assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webui/s, "optional feature installs should
|
|
1081
|
+
assert.match(server, /function configuredAgentNpmRoot\(\)/, "global Web UI launches should consider Pi's agent npm root for optional packages");
|
|
1082
|
+
assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webui/s, "optional feature installs should reuse a node_modules parent that declares the Web UI package dependency");
|
|
1083
|
+
assert.match(server, /installRootContainsPackage\(.*?@firstpick\/pi-package-webui/s, "global npm Web UI launches should also accept the prefix containing the Web UI package folder");
|
|
1084
|
+
assert.match(server, /resolveInstalledPackageSubpath\(nodeModulesRef\.packageName, nodeModulesRef\.subpath\)/, "started Web UI resource resolution should fall back to globally installed sibling optional packages");
|
|
1062
1085
|
assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
|
|
1063
|
-
assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no
|
|
1086
|
+
assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no safe package root can be found");
|
|
1087
|
+
assert.match(server, /url\.pathname === "\/api\/optional-features" && req\.method === "GET"/, "server should expose optional feature package status endpoint");
|
|
1064
1088
|
assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
|
|
1065
1089
|
assert.match(server, /requireLocalhostRoute\(req, url\.pathname\)/, "optional feature install endpoint should use shared localhost trust policy");
|
|
1066
1090
|
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");
|
|
@@ -1085,16 +1109,21 @@ assert.match(readme, /Feedback reactions \(`👍`, `👎`, `\?`\) on final assis
|
|
|
1085
1109
|
assert.match(readme, /POST \/api\/action-feedback\?tab=<tabId>/, "README should document the action-feedback endpoint");
|
|
1086
1110
|
assert.match(readme, /`@` file\/path references with live suggestions/, "README should describe @ file/path reference autocomplete");
|
|
1087
1111
|
assert.match(readme, /GET \/api\/path-suggestions\?tab=<tabId>&query=<path>/, "README should document the path-suggestions endpoint");
|
|
1112
|
+
assert.match(readme, /GET \/api\/optional-features/, "README should document optional feature status endpoint");
|
|
1088
1113
|
assert.match(readme, /POST \/api\/optional-feature-install/, "README should document optional feature install endpoint");
|
|
1089
1114
|
assert.match(readme, /server-persisted fast picks/, "README should describe server-persisted fast picks");
|
|
1090
1115
|
assert.match(readme, /browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications/, "README should describe blocked-tab and agent-done notifications");
|
|
1091
1116
|
assert.match(readme, /blocked-tab browser notifications, and optional agent-done notifications require browser service-worker\/notification support/, "README should document notification requirements");
|
|
1092
1117
|
assert.match(readme, /Side-panel theme picker backed by optional `@firstpick\/pi-themes-bundle` themes when loaded/, "README should describe optional theme selection");
|
|
1093
1118
|
assert.match(readme, /## Optional companion packages/, "README should document optional Web UI companion packages");
|
|
1119
|
+
assert.match(readme, /curates Pi resources from the Web UI package that started the server/, "README should document started-package-based Web UI feature loading");
|
|
1120
|
+
assert.match(readme, /Companion packages installed as global\/npm-prefix siblings/, "README should document global sibling companion discovery");
|
|
1121
|
+
assert.match(readme, /avoiding duplicate loads while keeping global `pi-webui` launches working/, "README should document duplicate companion suppression");
|
|
1094
1122
|
assert.match(readme, /checks loaded Pi capabilities directly through RPC-visible commands and live widget events/, "README should document capability-based startup checks");
|
|
1095
|
-
assert.match(readme, /side panel shows each optional feature as enabled, disabled, or install-needed/, "README should document optional feature side-panel controls");
|
|
1096
|
-
assert.match(readme, /Installing a
|
|
1123
|
+
assert.match(readme, /side panel shows each optional feature as enabled, disabled, installed-but-not-loaded, update-available, or install-needed/, "README should document optional feature side-panel controls");
|
|
1124
|
+
assert.match(readme, /Installing or updating a feature is an explicit, warned action with running\/failure feedback/, "README should document optional feature install and update warning behavior");
|
|
1097
1125
|
assert.match(readme, /\.\/start-webui\.sh --dev --cwd \/path\/to\/project/, "README should document the dev helper launcher");
|
|
1126
|
+
assert.match(readme, /sync-pi-package-symlinks\.sh[\s\S]*only one copy is loaded/, "README should document dev companion symlink setup");
|
|
1098
1127
|
assert.match(startScript, /--dev\)/, "start-webui.sh should accept a --dev flag");
|
|
1099
1128
|
assert.match(startScript, /local_pi_webui_bin\(\)/, "start-webui.sh should resolve this checkout's local server entrypoint");
|
|
1100
1129
|
assert.match(startScript, /webui_cmd=\(node "\$local_webui_bin"\)/, "start-webui.sh --dev should run the local bin with node");
|
|
@@ -1109,15 +1138,8 @@ for (const [name, range] of Object.entries(companionDependencies)) {
|
|
|
1109
1138
|
assert.equal(pkg.dependencies?.[name], undefined, `webui package should not require optional companion ${name}`);
|
|
1110
1139
|
}
|
|
1111
1140
|
assert.equal(pkg.bundledDependencies, undefined, "webui optional companion packages should not be bundled into the tarball");
|
|
1141
|
+
assert.ok(pkg.pi?.extensions?.includes("./index.ts"), "webui Pi manifest should load its own extension");
|
|
1112
1142
|
for (const extensionPath of [
|
|
1113
|
-
"../pi-extension-git-footer-status/index.ts",
|
|
1114
|
-
"../pi-extension-release-aur/index.ts",
|
|
1115
|
-
"../pi-extension-release-npm/index.ts",
|
|
1116
|
-
"../pi-extension-safety-guard/index.ts",
|
|
1117
|
-
"../pi-extension-setup-skills/index.ts",
|
|
1118
|
-
"../pi-extension-stats/index.ts",
|
|
1119
|
-
"../pi-extension-todo-progress/index.ts",
|
|
1120
|
-
"../pi-extension-tools/index.ts",
|
|
1121
1143
|
"node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
|
|
1122
1144
|
"node_modules/@firstpick/pi-extension-release-aur/index.ts",
|
|
1123
1145
|
"node_modules/@firstpick/pi-extension-release-npm/index.ts",
|
|
@@ -1129,13 +1151,56 @@ for (const extensionPath of [
|
|
|
1129
1151
|
]) {
|
|
1130
1152
|
assert.ok(pkg.pi?.extensions?.includes(extensionPath), `webui Pi manifest should load ${extensionPath} when present`);
|
|
1131
1153
|
}
|
|
1132
|
-
|
|
1154
|
+
for (const siblingExtensionPath of [
|
|
1155
|
+
"../pi-extension-git-footer-status/index.ts",
|
|
1156
|
+
"../pi-extension-release-aur/index.ts",
|
|
1157
|
+
"../pi-extension-release-npm/index.ts",
|
|
1158
|
+
"../pi-extension-safety-guard/index.ts",
|
|
1159
|
+
"../pi-extension-setup-skills/index.ts",
|
|
1160
|
+
"../pi-extension-stats/index.ts",
|
|
1161
|
+
"../pi-extension-todo-progress/index.ts",
|
|
1162
|
+
"../pi-extension-tools/index.ts",
|
|
1163
|
+
]) {
|
|
1164
|
+
assert.ok(!pkg.pi?.extensions?.includes(siblingExtensionPath), `webui Pi manifest should avoid duplicate sibling load path ${siblingExtensionPath}`);
|
|
1165
|
+
}
|
|
1133
1166
|
assert.ok(pkg.pi?.skills?.includes("node_modules/@firstpick/pi-extension-release-aur/skills"), "webui Pi manifest should load release-aur nested skills when present");
|
|
1134
|
-
assert.ok(pkg.pi?.
|
|
1167
|
+
assert.ok(!pkg.pi?.skills?.includes("../pi-extension-release-aur/skills"), "webui Pi manifest should avoid duplicate release-aur sibling skills");
|
|
1135
1168
|
assert.ok(pkg.pi?.prompts?.includes("node_modules/@firstpick/pi-prompts-git-pr/prompts"), "webui Pi manifest should load guided-git nested prompts when present");
|
|
1136
|
-
assert.ok(pkg.pi?.
|
|
1169
|
+
assert.ok(!pkg.pi?.prompts?.includes("../pi-package-prompts-git-pr/prompts"), "webui Pi manifest should avoid duplicate guided-git sibling prompts");
|
|
1137
1170
|
assert.ok(pkg.pi?.themes?.includes("node_modules/@firstpick/pi-themes-bundle/themes"), "webui Pi manifest should load nested bundled themes when present");
|
|
1171
|
+
assert.ok(!pkg.pi?.themes?.includes("../pi-package-themes-bundle/themes"), "webui Pi manifest should avoid duplicate sibling bundled themes");
|
|
1138
1172
|
assert.ok(pkg.scripts?.check?.includes("node --check public/app.js"), "check script should syntax-check app.js");
|
|
1139
1173
|
assert.ok(pkg.scripts?.check?.includes("node tests/run-all.mjs"), "check script should run the shared test runner");
|
|
1140
1174
|
|
|
1175
|
+
// --- Performance: keyed transcript reconciliation (P0-1) ---
|
|
1176
|
+
assert.match(app, /let renderedTranscriptState = \{ epoch: "", entries: \[\] \};/, "transcript reconciliation should track rendered entries");
|
|
1177
|
+
assert.match(app, /function renderAllMessages\(\{ preserveScroll = false, forceRebuild = false \} = \{\}\)[\s\S]*?if \(prefixLength === 0\) resetChatOutput\(\);[\s\S]*?removeChatBubblesAfterPrefix\(/, "renderAllMessages should reuse the unchanged transcript prefix instead of always rebuilding");
|
|
1178
|
+
assert.match(app, /function removeChatBubblesAfterPrefix\(keptKeys\)[\s\S]*?child === elements\.stickyUserPromptButton \|\| child === runIndicatorBubble/, "prefix removal must preserve the sticky prompt button and run indicator");
|
|
1179
|
+
assert.match(app, /function resetChatOutput\(\) \{\n liveToolCards\.clear\(\);\n renderedTranscriptState = \{ epoch: "", entries: \[\] \};/, "full chat resets must clear reconciliation state");
|
|
1180
|
+
assert.match(app, /function transcriptRenderEpoch\(\)[\s\S]*?thinkingOutputVisible/, "reconciliation epoch must include thinking visibility so toggles rebuild the transcript");
|
|
1181
|
+
assert.match(app, /pruneDisconnectedLiveToolCards\(\);/, "reconciliation must prune live tool card references to removed DOM nodes");
|
|
1182
|
+
|
|
1183
|
+
// --- Performance: incremental streaming markdown (P0-3) ---
|
|
1184
|
+
assert.match(app, /function streamingMarkdownStableBoundary\(text\)[\s\S]*?for \(let index = 0; index < lines\.length - 1; index \+= 1\)/, "streaming markdown boundary must never treat the final partial line as stable");
|
|
1185
|
+
assert.match(app, /function renderStreamingMarkdown\(block, text\)[\s\S]*?if \(!text\.startsWith\(state\.stableText\)\)/, "streaming markdown must fall back to a full re-render when earlier content changes");
|
|
1186
|
+
assert.match(app, /streamRawText = "";\n streamMarkdownState = null;/, "resetting the stream bubble must clear incremental markdown state");
|
|
1187
|
+
|
|
1188
|
+
// --- Performance: delta transcript fetch (P1-1) ---
|
|
1189
|
+
assert.match(app, /function mergeMessagesDelta\(previous, data\)[\s\S]*?messagesLookEqual\(previous\[since\], data\.messages\[0\]\)/, "delta merges must verify the one-message overlap before applying");
|
|
1190
|
+
assert.match(app, /async function refreshMessages\(tabContext = activeTabContext\(\)\)[\s\S]*?\/api\/messages\?since=/, "message refreshes should request transcript deltas");
|
|
1191
|
+
assert.match(app, /if \(!nextMessages\) \{[\s\S]*?api\("\/api\/messages", \{ tabId: tabContext\.tabId \}\)/, "delta failures must fall back to a full transcript fetch");
|
|
1192
|
+
assert.match(server, /function applyMessagesSinceParam\(response, url\)/, "server should slice get_messages results for \\?since= requests");
|
|
1193
|
+
assert.match(app, /const messageStaticSignatureCache = new WeakMap\(\);/, "static message signatures should be cached by object identity");
|
|
1194
|
+
assert.match(app, /case "tool_execution_end":(?:(?!scheduleRefreshMessages)[\s\S])*?break;/, "tool completions must not trigger full transcript refreshes");
|
|
1195
|
+
assert.match(app, /case "message_end":[\s\S]*?scheduleRefreshMessages\(\);/, "assistant message completion must still reconcile the transcript");
|
|
1196
|
+
|
|
1197
|
+
// --- UX: transcript search (P2-1) ---
|
|
1198
|
+
assert.match(html, /id="chatSearchBar"[\s\S]*?id="chatSearchInput"[\s\S]*?id="chatSearchPrevButton"[\s\S]*?id="chatSearchNextButton"[\s\S]*?id="chatSearchCloseButton"/, "transcript search bar markup should exist with navigation controls");
|
|
1199
|
+
assert.match(app, /function openChatSearch\(\)[\s\S]*?elements\.chatSearchInput\?\.focus\(\)/, "opening transcript search should focus the input");
|
|
1200
|
+
assert.match(app, /\(event\.ctrlKey \|\| event\.metaKey\) && !event\.altKey && !event\.shiftKey && event\.key\.toLowerCase\(\) === "f"/, "Ctrl\/Cmd+F should open the transcript search");
|
|
1201
|
+
assert.match(app, /function focusChatSearchMatch\(\)[\s\S]*?details\.open = true;[\s\S]*?scrollIntoView/, "navigating to a search match should expand collapsed tool output and scroll to the bubble");
|
|
1202
|
+
assert.match(app, /autoFollowChat = false;\n lastChatProgrammaticScrollAt = performance\.now\(\);/, "search navigation must not fight chat auto-follow");
|
|
1203
|
+
assert.match(css, /\.message\.search-current \{/, "current search match should have a highlight style");
|
|
1204
|
+
assert.match(css, /\.chat-search-bar \{/, "transcript search bar should be styled");
|
|
1205
|
+
|
|
1141
1206
|
console.log("mobile static checks passed");
|