@firstpick/pi-package-webui 0.3.4 → 0.3.6

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.
@@ -106,6 +106,12 @@ assert.match(html, /id="stickyUserPromptButton"/, "chat should expose a fixed la
106
106
  assert.match(html, /id="feedbackTray"/, "chat should expose a queued action-feedback tray");
107
107
  assert.match(html, /id="sendFeedbackButton"/, "action feedback should be submittable after the agent finishes");
108
108
  assert.match(html, /<textarea id="promptInput"[^>]*rows="1"[^>]*enterkeyhint="enter"/, "prompt textarea should start at one row and hint that Return inserts a newline");
109
+ assert.ok(html.includes('id="commandSuggest"') && html.indexOf('id="commandSuggest"') < html.indexOf('id="promptInput"'), "slash-command and @ path suggestions should render above the prompt input");
110
+ assert.match(html, /id="busyPromptBehaviorTag"[\s\S]*class="composer-busy-mode-tag"[\s\S]*aria-controls="busyPromptBehaviorMenu"/, "composer should expose a clickable busy prompt behavior tag on the input frame");
111
+ assert.doesNotMatch(html, /Busy send:/i, "busy prompt behavior tag should show only the current mode label");
112
+ assert.match(html, /id="sessionSkillTags" class="composer-skill-tags"[\s\S]*hidden/, "composer should expose a hidden-until-used skill tag strip beside the busy mode tag");
113
+ assert.match(html, /id="skillEditorDialog"[\s\S]*id="skillEditorText"[\s\S]*id="skillEditorSaveButton"/, "skill tags should have an in-Web UI SKILL.md editing dialog");
114
+ assert.match(html, /id="busyPromptBehaviorMenu"[\s\S]*data-busy-prompt-behavior="followUp"[\s\S]*data-busy-prompt-behavior="steer"/, "busy prompt behavior dropdown should expose follow-up and steer choices");
109
115
  assert.match(app, /const LONG_INPUT_ATTACHMENT_LINE_THRESHOLD = 20/, "long composer text should use a 20-line threshold before becoming an attachment");
110
116
  assert.match(app, /function attachLongTextAsFile\(text, source = "input text"\)/, "long composer text should be attachable as a generated text file");
111
117
  assert.match(app, /function handleAttachmentPaste\(event\)[\s\S]*attachLongTextAsFile\(text, "clipboard text"\)/, "long pasted text should be attached instead of inserted into the prompt textarea");
@@ -170,9 +176,18 @@ assert.match(css, /button, select, input \{ min-height: 44px; \}/, "base control
170
176
  assert.match(css, /\.composer-row button[\s\S]*?min-height:\s*44px/, "mobile composer buttons should keep 44px touch targets");
171
177
  assert.match(css, /\.composer-abort-button,\n\.composer-row button\.primary \{[\s\S]*?min-width:/, "Abort and Send should share stable bottom-row sizing");
172
178
  assert.match(css, /\.composer-abort-button\.long-pressing::after[\s\S]*?animation:\s*abort-long-press-fill 700ms linear forwards/, "Abort should expose a visible long-press progress affordance");
173
- assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{ grid-column: span 2; \}/, "active mobile runs should keep Abort beside Send in the bottom controls");
179
+ assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-abort-button:not\(\[hidden\]\) \{\n\s+order:\s*1;\n\s+grid-column:\s*span 2;/, "active mobile runs should move Abort to the top row");
180
+ assert.match(css, /body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-actions-button \{ order:\s*4; \}[\s\S]*?body\.pi-run-active:not\(\.mobile-keyboard-open\) \.composer-row button\.primary \{\n\s+order:\s*5;\n\s+grid-column:\s*span 4;/, "active mobile runs should keep Actions beside Send on the bottom row");
174
181
  assert.match(css, /#promptInput \{[\s\S]*?min-height:\s*calc\(1\.5em \+ 1\.8rem\)/, "prompt input should default to a compact single-line height");
175
182
  assert.match(css, /#promptInput \{[\s\S]*?overflow-y:\s*hidden/, "prompt input should be JS-resized instead of showing a scrollbar by default");
183
+ assert.match(css, /\.composer-context-tags \{[\s\S]*?top:\s*-0\.48rem;[\s\S]*?left:\s*0\.75rem;/, "busy prompt behavior and skill tags should sit at the top-left of the input frame");
184
+ assert.match(css, /\.composer-busy-mode-tag \{[\s\S]*?var\(--ctp-crust\)/, "busy prompt behavior tag should use an opaque base background");
185
+ assert.match(css, /\.composer-skill-tag \{[\s\S]*?var\(--ctp-crust\)/, "skill tags should use an opaque base background");
186
+ assert.match(css, /button\.composer-skill-tag:hover,[\s\S]*?button\.composer-skill-tag:focus-visible/, "skill tags should be styled as clickable controls");
187
+ assert.match(css, /\.extension-dialog\.skill-editor-dialog \{[\s\S]*?--skill-editor-size:\s*min\(152rem[\s\S]*?width:\s*var\(--skill-editor-size\);[\s\S]*?height:\s*var\(--skill-editor-size\);[\s\S]*?aspect-ratio:\s*1 \/ 1/, "skill editor should use a square viewport-bounded modal layout");
188
+ assert.match(css, /\.skill-editor-dialog form \{[\s\S]*?height:\s*100%;[\s\S]*?min-height:\s*0/, "skill editor form should fill the square modal without forcing overflow");
189
+ assert.match(css, /\.skill-editor-text \{[\s\S]*?overflow-x:\s*hidden;[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?white-space:\s*pre-wrap/, "skill editor text should wrap long lines instead of horizontal scrolling");
190
+ assert.match(css, /\.composer-busy-mode-menu \{[\s\S]*?bottom:\s*calc\(100% \+ 0\.22rem\);[\s\S]*?background:\s*var\(--ctp-crust\)/, "busy prompt behavior dropdown should expand above the tag with an opaque background");
176
191
  assert.match(css, /\.sticky-user-prompt-button \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\) auto/, "last-user-prompt jump control should render as a fixed transcript header");
177
192
  assert.match(css, /\.message\.extension,[\s\S]*?\.message\.native/, "extension and native command output should have visible transcript styling");
178
193
  assert.match(css, /\.message\.run-indicator-message \{[\s\S]*?border-color/, "active agent runs should render a visible transcript indicator card");
@@ -230,6 +245,7 @@ assert.match(css, /\.command-item \{[\s\S]*?width:\s*100%/, "side-panel commands
230
245
  assert.match(css, /\.toggle-control \{[\s\S]*?grid-template-columns:\s*auto minmax\(0, 1fr\)/, "side-panel notification toggle should align checkbox and label text");
231
246
  assert.match(css, /\.toggle-control:has\(input:checked\)/, "side-panel notification toggle should style the enabled state");
232
247
  assert.match(css, /\.command-item:hover,[\s\S]*?\.command-item:focus-visible/, "side-panel commands should have hover and keyboard focus affordances");
248
+ assert.match(css, /\.command-suggest \{\n\s+margin:\s*0 0 0\.5rem;[\s\S]*?max-height:\s*15rem/, "slash-command and @ path suggestions should reserve spacing below themselves above the prompt input");
233
249
  assert.match(css, /\.command-suggest-item:hover \{\n\s+box-shadow: none;\n\s+transform: none;\n\}\n\.command-suggest-item\.active \{/, "autocomplete hover should not render as the selected suggestion unless JS marks it active");
234
250
  assert.doesNotMatch(css, /\.command-suggest-item:hover,\n\.command-suggest-item\.active/, "autocomplete hover and active selection styles should stay separate");
235
251
  assert.match(css, /\.feedback-tray\[hidden\] \{ display: none; \}/, "queued action-feedback tray should hide when empty");
@@ -273,9 +289,11 @@ assert.match(css, /\.terminal-tab-group\.active \{[\s\S]*?background:[\s\S]*?var
273
289
  assert.match(css, /\.terminal-tab-group\.stopped \{[\s\S]*?opacity:\s*1/, "stopped terminal tab groups should not become transparent");
274
290
  assert.match(css, /\.terminal-tabs:has\(\.terminal-tab-group\.menu-open\)/, "open terminal tab groups should keep the tab strip usable across rerenders");
275
291
  assert.match(css, /\.terminal-tab-group\.menu-open \.terminal-tab-group-menu \{[\s\S]*?display:\s*flex/, "open terminal tab group menus should remain visible without hover");
276
- assert.match(css, /\.terminal-tab\.activity-working[\s\S]*?terminal-tab-working-pulse/, "working tab indicators should be visibly animated");
292
+ assert.match(css, /\.terminal-tab\.activity-working > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?terminal-tab-working-pulse/, "working tab indicators should be visibly animated");
293
+ assert.match(css, /\.terminal-tab-group-item\.activity-working > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?terminal-tab-working-pulse/, "working indicators should still animate on grouped tab menu items themselves");
277
294
  assert.match(css, /\.terminal-tab\.activity-blocked[\s\S]*?rgba\(250, 179, 135/, "blocked tab indicators should use orange styling");
278
- assert.match(css, /\.terminal-tab\.activity-blocked \.terminal-tab-activity-indicator[\s\S]*?background:\s*var\(--ctp-peach\)/, "blocked tab indicator dots should be orange");
295
+ assert.match(css, /\.terminal-tab\.activity-blocked > \.terminal-tab-button \.terminal-tab-activity-indicator[\s\S]*?background:\s*var\(--ctp-peach\)/, "blocked tab indicator dots should be orange");
296
+ assert.doesNotMatch(css, /\.terminal-tab\.activity-(?:working|blocked|done)\s+\.terminal-tab-activity-indicator/, "group status styling should not cascade into child tabs in the group menu");
279
297
  assert.match(css, /\.terminal-tab\.activity-done/, "completed unseen work should have a distinct tab style");
280
298
  assert.match(css, /\.terminal-tabs[\s\S]*?position:\s*absolute/, "expanded mobile tabs should overlay instead of consuming transcript space");
281
299
  assert.match(css, /body\.mobile-keyboard-open \.terminal-tabs-shell,[\s\S]*?body\.mobile-keyboard-open \.widget-area,[\s\S]*?body\.mobile-keyboard-open \.statusbar/, "mobile keyboard mode should hide header, widgets, and footer");
@@ -396,7 +414,7 @@ assert.match(app, /function syncMobileChatToBottomForInput\(\)/, "mobile input f
396
414
  assert.match(app, /function focusPromptInput\(\{ defer = false \} = \{\}\)/, "frontend should focus the prompt composer programmatically after tab/app startup");
397
415
  assert.match(app, /async function switchTab\(tabId\)[\s\S]*?restoreActiveDraft\(\);\n\s+focusPromptInput\(\{ defer: true \}\);/, "switching to a newly opened tab should focus the prompt input immediately");
398
416
  assert.match(app, /async function initializeTabs\(\)[\s\S]*?restoreActiveDraft\(\);[\s\S]*if \(!loadedTabs\.length\)[\s\S]*focusPromptInput\(\{ defer: true \}\);/, "starting the Web UI should prompt for cwd when needed and focus active tabs");
399
- assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus before waiting for tab state refreshes");
417
+ assert.match(app, /resizePromptInput\(\);\nfocusPromptInput\(\{ defer: true \}\);\nrestoreStoredSkillUsage\(\);\nrestoreBusyPromptBehaviorSetting\(\);\nupdateComposerModeButtons\(\);/, "startup should request prompt focus and restore skill tags before waiting for tab state refreshes");
400
418
  assert.match(app, /elements\.promptInput\.addEventListener\("focus", \(\) => \{\n\s+syncMobileChatToBottomForInput\(\);/, "focusing mobile input should scroll output to bottom");
401
419
  assert.match(app, /navigator\.serviceWorker\.register\("\/service-worker\.js"\)/, "PWA service worker should be registered by the app");
402
420
  assert.match(app, /function serverStartCommandText\(\)[\s\S]*return `pi-webui\$\{currentPortArg\(\)\}`/, "PWA/offline shell should build a pathless pi-webui recovery command");
@@ -683,9 +701,29 @@ assert.match(app, /function updateComposerModeButtons\(\)/, "composer should rel
683
701
  assert.match(app, /const target = runActive \? elements\.composerRow : elements\.composerActionsPanel/, "Steer and Follow-up should move into the bottom row only while an agent run is active");
684
702
  assert.match(app, /const before = runActive \? elements\.abortButton : null/, "active Steer and Follow-up controls should sit before Abort and Send");
685
703
  assert.match(app, /button\.hidden = !runActive;\n\s+button\.disabled = !runActive;/, "Steer and Follow-up should be hidden and disabled when the agent is not running");
704
+ assert.match(app, /renderBusyPromptBehaviorTag\(\);\n\s+document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "composer mode refresh should keep the busy prompt behavior tag current");
686
705
  assert.match(app, /elements\.abortButton\.hidden = !abortAvailable;\n\s+elements\.abortButton\.disabled = !abortAvailable \|\| abortRequestInFlight;/, "Abort should only be exposed in the bottom bar while a run can be aborted");
687
706
  assert.match(app, /document\.body\.classList\.toggle\("pi-run-active", runActive \|\| abortAvailable\)/, "run-active or abort-available state should be reflected in CSS for mobile composer layout");
688
707
  assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-button taps should show the usage tooltip");
708
+ assert.match(app, /function renderBusyPromptBehaviorTag\(\)[\s\S]*?tag\.textContent = label/, "busy prompt behavior tag should render only the current follow-up\/steer setting");
709
+ assert.doesNotMatch(app, /Busy send: \$\{label\}/, "busy prompt behavior tag should not prefix the current mode label");
710
+ assert.match(app, /function renderSessionSkillTags\(tabId = activeTabId\)[\s\S]*?filter\(\(entry\) => entry\.kinds\.has\("read"\)\)[\s\S]*?make\("button", classes\.join\(" "\), entry\.name\)[\s\S]*?openSkillEditor\(entry\)/, "skill tags should render as clickable buttons only after the full skill context was read");
711
+ assert.ok(app.includes('normalized.match(/\\/skills\\/([^/]+)\\/SKILL\\.md$/i)'), "skill context tracking should require SKILL.md paths");
712
+ assert.match(app, /function trackSkillsFromToolInvocation\(tabId, toolName[\s\S]*?name\.toLowerCase\(\) !== "read"\) return;[\s\S]*?kind: "read"/, "skill context tracking should only follow read-tool invocations");
713
+ assert.match(app, /function trackSkillUsage\(tabId, skillName[\s\S]*?persistSkillUsage\(\);[\s\S]*?renderSessionSkillTags\(tabId\)/, "skill tags should persist and live-update when a read skill is tracked");
714
+ assert.match(app, /const SKILL_USAGE_STORAGE_KEY = "pi-webui-skill-usage-v1"/, "read skill tags should have browser storage for hard-refresh and restart restore");
715
+ assert.match(app, /function persistSkillUsage\(\)[\s\S]*?localStorage\.setItem\(SKILL_USAGE_STORAGE_KEY/, "read skill tags should be persisted to browser storage");
716
+ assert.match(app, /function restoreStoredSkillUsage\(\)[\s\S]*?localStorage\.getItem\(SKILL_USAGE_STORAGE_KEY/, "read skill tags should restore from browser storage");
717
+ assert.match(app, /restoreStoredSkillUsage\(\);[\s\S]*?initializeTabs\(\)/, "stored read skill tags should be restored before tabs initialize");
718
+ assert.match(app, /trackSkillsFromEvent\(event\);[\s\S]*?if \(!eventTargetsActiveTab\(event\)\)/, "skill usage should be tracked as soon as tab events arrive");
719
+ assert.doesNotMatch(app, /trackSkillsFromCommands\(rawAvailableCommands, tabContext\.tabId\)/, "loaded skill commands alone should not populate skill tags");
720
+ assert.match(app, /function openSkillEditor\(entry\)[\s\S]*?api\(skillEditorApiPath\(\{ name, path \}\), \{ tabId \}\)/, "clicking a skill tag should load the corresponding SKILL.md into the editor dialog");
721
+ assert.match(app, /function saveSkillEditor\(\)[\s\S]*?api\("\/api\/skill-file", \{[\s\S]*?method: "POST"[\s\S]*?content: elements\.skillEditorText\.value/, "skill editor should save changed SKILL.md contents through the API");
722
+ assert.match(app, /skillEditorDialog\?\.addEventListener\("keydown"[\s\S]*?saveSkillEditor\(\)/, "skill editor should support Ctrl\/Cmd+S saving");
723
+ assert.match(app, /function setBusyPromptBehaviorMenuOpen\(open,[\s\S]*aria-expanded[\s\S]*busyPromptBehaviorMenu\.hidden/, "busy prompt behavior tag should control a dropdown menu");
724
+ assert.match(app, /busyPromptBehaviorTag\?\.addEventListener\("click"[\s\S]*setBusyPromptBehaviorMenuOpen\(nextOpen\)/, "clicking the busy prompt behavior tag should toggle its dropdown");
725
+ assert.match(app, /busyPromptBehaviorMenu\?\.addEventListener\("click"[\s\S]*chooseBusyPromptBehaviorFromMenu/, "busy prompt behavior dropdown choices should update the setting");
726
+ assert.match(app, /setBusyPromptBehavior\(controls\.busyBehavior\.select\.value\)/, "native settings should update the busy prompt behavior tag immediately");
689
727
  assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
690
728
  assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
691
729
  assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
@@ -762,7 +800,18 @@ assert.match(app, /function syncLastUserPromptFromMessages\(messages = latestMes
762
800
  assert.match(app, /dataset\.compacted/, "sticky prompt should expose a compacted fallback state when its source message was summarized away");
763
801
  assert.match(app, /stickyUserPromptButton\?\.addEventListener\("click", jumpToStickyUserPrompt\)/, "last user prompt header should be clickable without breaking stale cached HTML");
764
802
  assert.match(app, /function setComposerActionsOpen\(/, "mobile composer actions panel should be JS-toggleable");
803
+ assert.match(css, /@media \(max-width: 720px\), \(max-device-width: 720px\), \(pointer: coarse\) and \(hover: none\) \{[\s\S]*?\.composer \{[\s\S]*?z-index:\s*50;/, "mobile composer should stack above transcript reaction controls while Actions are open");
804
+ assert.match(css, /\.composer-actions-panel \{[\s\S]*?z-index:\s*55;[\s\S]*?overflow:\s*visible;/, "mobile Actions panel should stay above message reactions and allow submenu overlays instead of clipping them into the panel layout");
805
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu\.open \{\n\s+z-index:\s*120;\n\s+\}/, "opened mobile Actions dropdowns should overlay neighboring controls without taking grid space");
806
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu::after \{[\s\S]*?bottom:\s*100%;[\s\S]*?height:\s*0\.8rem;[\s\S]*?pointer-events:\s*auto;/, "mobile Actions dropdowns should keep a hover bridge above the trigger and below the floating submenu");
807
+ assert.match(css, /\.composer-actions-panel > \.composer-publish-menu:hover::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu:focus-within::after,[\s\S]*?\.composer-actions-panel > \.composer-publish-menu\.open::after \{\n\s+display:\s*block;/, "mobile Actions dropdown hover bridge should activate while hovered, focused, or opened");
808
+ 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");
809
+ 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");
810
+ 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");
811
+ 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\(76dvh, 34rem\);/, "mobile Options dropdown should be tall enough to avoid scrolling for the standard option list");
765
812
  assert.match(app, /function setMobileTabsExpanded\(/, "mobile tab strip should be JS-toggleable");
813
+ 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");
814
+ 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");
766
815
  assert.match(app, /let openTerminalTabGroupKey = null/, "frontend should track the open terminal tab group across tab bar rerenders");
767
816
  assert.match(app, /function updateTerminalTabGroupOpenState\(\)/, "frontend should be able to reapply open terminal tab group state after rerenders");
768
817
  assert.match(app, /classList\.toggle\("terminal-tabs-dense", tabs\.length >= 10\)/, "frontend should enable dense tab layout before tab names become unreadable");
@@ -969,6 +1018,12 @@ assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkou
969
1018
  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");
970
1019
  assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
971
1020
  assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
1021
+ 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]*?Saving skill files is only allowed from localhost[\s\S]*?saveSkillFileData/, "server should expose localhost-only POST /api/skill-file for saving skill content");
1023
+ 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
+ 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
+ assert.match(server, /Skill path is outside allowed Pi skill locations/, "explicit skill path fallback should reject paths outside Pi skill roots");
1026
+ assert.match(server, /writeFile\(tmpFile, body\.content[\s\S]*?rename\(tmpFile, skill\.filePath\)/, "skill file saves should use an atomic temp-file rename");
972
1027
  assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
973
1028
  assert.match(server, /readBundledThemes\(\)/, "server should read bundled theme JSON files for the browser");
974
1029
  assert.match(server, /"apple-touch-icon\.png", "icon-192\.png"/, "server should serve the conventional apple touch icon path");