pi-ui-extend 1.0.36 → 1.0.38
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 +1 -0
- package/dist/app/rendering/conversation-entry-renderer.js +25 -3
- package/dist/markdown-format.d.ts +8 -0
- package/dist/markdown-format.js +262 -6
- package/dist/theme.d.ts +2 -0
- package/dist/theme.js +4 -0
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +65 -22
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +23 -8
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +180 -41
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -520,6 +520,7 @@ skills/ packaged agent skills
|
|
|
520
520
|
docs/ release documentation
|
|
521
521
|
tests/ unit, integration, and PTY tests
|
|
522
522
|
scripts/ build, release, sync, and capture tooling
|
|
523
|
+
acp/ pix-acp: ACP adapter embedding pi into editors like Zed (own install; `npm run check:acp`)
|
|
523
524
|
```
|
|
524
525
|
|
|
525
526
|
---
|
|
@@ -28,10 +28,15 @@ export function renderConversationEntry(entry, width, options) {
|
|
|
28
28
|
const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
|
|
29
29
|
...userLine(line.text, userEntry.id, line.syntaxHighlight, [
|
|
30
30
|
...(line.segments ?? []),
|
|
31
|
+
...diagramStyledSegments(line.diagramSegments, options.colors),
|
|
31
32
|
...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
|
|
32
33
|
], line.links),
|
|
33
34
|
...(line.copyText === undefined ? {} : { copyText: line.copyText }),
|
|
34
35
|
...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
|
|
36
|
+
...(line.codeBlock ? {
|
|
37
|
+
colorOverride: options.colors.codeBlockForeground,
|
|
38
|
+
backgroundOverride: options.colors.codeBlockBackground,
|
|
39
|
+
} : {}),
|
|
35
40
|
}));
|
|
36
41
|
return attachImageClickTargets(lines, userEntry.id, userEntry.images, { foreground: options.colors.info, underline: true });
|
|
37
42
|
};
|
|
@@ -94,13 +99,16 @@ function renderAssistantLines(text, width, options) {
|
|
|
94
99
|
const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
|
|
95
100
|
const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
|
|
96
101
|
const linkSegments = links.map((link) => ({ start: link.start, end: link.end, foreground: options.colors.info, underline: true }));
|
|
97
|
-
const
|
|
102
|
+
const diagramSegments = diagramStyledSegments(line.diagramSegments, options.colors, contentLeft);
|
|
103
|
+
const allSegments = headingSegment
|
|
104
|
+
? [headingSegment, ...existingSegments, ...diagramSegments, ...linkSegments]
|
|
105
|
+
: [...existingSegments, ...diagramSegments, ...linkSegments];
|
|
98
106
|
lines.push({
|
|
99
107
|
text: padHorizontalText(line.text, width),
|
|
100
108
|
...(line.copyText === undefined ? {} : { copyText: line.copyText }),
|
|
101
109
|
...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
|
|
102
|
-
colorOverride: options.colors.assistantForeground,
|
|
103
|
-
backgroundOverride: options.colors.assistantMessageBackground,
|
|
110
|
+
colorOverride: line.codeBlock ? options.colors.codeBlockForeground : options.colors.assistantForeground,
|
|
111
|
+
backgroundOverride: line.codeBlock ? options.colors.codeBlockBackground : options.colors.assistantMessageBackground,
|
|
104
112
|
...(allSegments.length > 0 ? { segments: allSegments } : {}),
|
|
105
113
|
...(links.length > 0 ? { links } : {}),
|
|
106
114
|
...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
|
|
@@ -108,3 +116,17 @@ function renderAssistantLines(text, width, options) {
|
|
|
108
116
|
}
|
|
109
117
|
return lines;
|
|
110
118
|
}
|
|
119
|
+
function diagramStyledSegments(segments, colors, offset = 0) {
|
|
120
|
+
return (segments ?? []).map((segment) => {
|
|
121
|
+
const range = { start: segment.start + offset, end: segment.end + offset };
|
|
122
|
+
switch (segment.class) {
|
|
123
|
+
case "border":
|
|
124
|
+
case "edgeLabel":
|
|
125
|
+
return { ...range, foreground: colors.muted };
|
|
126
|
+
case "edge":
|
|
127
|
+
return { ...range, foreground: colors.accent };
|
|
128
|
+
case "title":
|
|
129
|
+
return { ...range, foreground: colors.accent, bold: true };
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Cls as MermaidSpanClass } from "grok-mermaid";
|
|
1
2
|
import { type SyntaxLineHighlight, type ToolBodySyntaxHighlights } from "./syntax-highlight.js";
|
|
2
3
|
export type RenderedMarkdownLine = {
|
|
3
4
|
text: string;
|
|
@@ -22,6 +23,7 @@ export type RenderedMarkdownTextLine = {
|
|
|
22
23
|
text: string;
|
|
23
24
|
copyText?: string;
|
|
24
25
|
continuesOnNextLine?: boolean;
|
|
26
|
+
codeBlock?: true;
|
|
25
27
|
segments?: readonly {
|
|
26
28
|
start: number;
|
|
27
29
|
end: number;
|
|
@@ -29,8 +31,14 @@ export type RenderedMarkdownTextLine = {
|
|
|
29
31
|
}[] | undefined;
|
|
30
32
|
links?: readonly RenderedMarkdownLink[] | undefined;
|
|
31
33
|
syntaxHighlight?: SyntaxLineHighlight | undefined;
|
|
34
|
+
diagramSegments?: readonly RenderedMarkdownDiagramSegment[] | undefined;
|
|
32
35
|
heading?: boolean;
|
|
33
36
|
};
|
|
37
|
+
export type RenderedMarkdownDiagramSegment = {
|
|
38
|
+
start: number;
|
|
39
|
+
end: number;
|
|
40
|
+
class: Exclude<MermaidSpanClass, "none" | "text">;
|
|
41
|
+
};
|
|
34
42
|
export type RenderMarkdownTextLinesOptions = {
|
|
35
43
|
preserveWrappedWordSeparator?: boolean;
|
|
36
44
|
};
|
package/dist/markdown-format.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { render as renderMermaid } from "grok-mermaid";
|
|
1
2
|
import { displayGraphemes, expandTabs, stringDisplayWidth } from "./terminal-width.js";
|
|
2
3
|
import { syntaxHighlightLanguageForMarkdownFence, } from "./syntax-highlight.js";
|
|
3
4
|
const MIN_TRAILING_WORD_WIDTH_TO_REBALANCE = 5;
|
|
5
|
+
const MERMAID_CACHE_LIMIT = 100;
|
|
6
|
+
const MERMAID_MAX_PANELS = 4;
|
|
7
|
+
const MERMAID_MIN_PANEL_WIDTH = 40;
|
|
8
|
+
const MERMAID_SEQUENCE_MESSAGE_OPERATORS = ["-->>", "->>", "--x", "-x", "--)", "-)", "-->", "->"];
|
|
9
|
+
const mermaidRenderCache = new Map();
|
|
4
10
|
export function formatMarkdownTables(text, maxWidth) {
|
|
5
11
|
const lines = text.split("\n");
|
|
6
12
|
const formatted = [];
|
|
@@ -85,14 +91,40 @@ export function renderMarkdownLine(text, start = 0) {
|
|
|
85
91
|
export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
|
|
86
92
|
const lines = [];
|
|
87
93
|
let fence;
|
|
94
|
+
let fenceHasBodyLine = false;
|
|
88
95
|
const formattedText = formatMarkdownTables(sanitizeMarkdownText(text), width);
|
|
89
96
|
if (formattedText.length === 0)
|
|
90
97
|
return [];
|
|
91
|
-
|
|
98
|
+
const rawLines = formattedText.split("\n");
|
|
99
|
+
for (let lineIndex = 0; lineIndex < rawLines.length;) {
|
|
100
|
+
const rawLine = rawLines[lineIndex] ?? "";
|
|
92
101
|
const nextFence = markdownFence(rawLine);
|
|
102
|
+
if (!fence && nextFence && isMermaidFence(nextFence)) {
|
|
103
|
+
const diagram = renderMermaidBlock(rawLines, lineIndex, nextFence, width);
|
|
104
|
+
if (diagram) {
|
|
105
|
+
lines.push(...diagram.lines);
|
|
106
|
+
lineIndex = diagram.nextLineIndex;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
93
110
|
const closesFence = Boolean(fence && nextFence && fence.marker === nextFence.marker && nextFence.length >= fence.length);
|
|
94
111
|
const opensFence = !fence && nextFence !== undefined;
|
|
95
|
-
|
|
112
|
+
if (opensFence && nextFence) {
|
|
113
|
+
fence = { ...nextFence, language: syntaxHighlightLanguageForMarkdownFence(nextFence.info) };
|
|
114
|
+
fenceHasBodyLine = false;
|
|
115
|
+
lineIndex += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (closesFence) {
|
|
119
|
+
if (!fenceHasBodyLine)
|
|
120
|
+
lines.push({ text: "", codeBlock: true });
|
|
121
|
+
fence = undefined;
|
|
122
|
+
fenceHasBodyLine = false;
|
|
123
|
+
lineIndex += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const syntaxHighlight = markdownLineSyntaxHighlight(fence, false, start);
|
|
127
|
+
const codeBlock = fence !== undefined;
|
|
96
128
|
const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
|
|
97
129
|
const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
|
|
98
130
|
const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
|
|
@@ -113,21 +145,245 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
|
|
|
113
145
|
text: wrapped.text,
|
|
114
146
|
...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
|
|
115
147
|
...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
|
|
148
|
+
...(codeBlock ? { codeBlock: true } : {}),
|
|
116
149
|
...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
|
|
117
150
|
...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
|
|
118
151
|
...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
|
|
119
152
|
...(isHeadingLine ? { heading: true } : {}),
|
|
120
153
|
});
|
|
121
154
|
}
|
|
122
|
-
if (
|
|
123
|
-
|
|
155
|
+
if (codeBlock)
|
|
156
|
+
fenceHasBodyLine = true;
|
|
157
|
+
lineIndex += 1;
|
|
158
|
+
}
|
|
159
|
+
if (fence && !fenceHasBodyLine)
|
|
160
|
+
lines.push({ text: "", codeBlock: true });
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
function renderMermaidBlock(rawLines, openingLineIndex, openingFence, width) {
|
|
164
|
+
let closingLineIndex;
|
|
165
|
+
for (let index = openingLineIndex + 1; index < rawLines.length; index += 1) {
|
|
166
|
+
const candidate = markdownFence(rawLines[index] ?? "");
|
|
167
|
+
if (candidate && candidate.marker === openingFence.marker && candidate.length >= openingFence.length) {
|
|
168
|
+
closingLineIndex = index;
|
|
169
|
+
break;
|
|
124
170
|
}
|
|
125
|
-
|
|
126
|
-
|
|
171
|
+
}
|
|
172
|
+
const sourceEnd = closingLineIndex ?? rawLines.length;
|
|
173
|
+
const source = rawLines.slice(openingLineIndex + 1, sourceEnd).join("\n");
|
|
174
|
+
let art = cachedMermaidRender(source);
|
|
175
|
+
if (!art)
|
|
176
|
+
return undefined;
|
|
177
|
+
const availableWidth = Math.max(1, width);
|
|
178
|
+
let participantLegend = [];
|
|
179
|
+
let messageLegend = [];
|
|
180
|
+
if (art.width > availableWidth) {
|
|
181
|
+
const compactSequence = compactMermaidSequence(source);
|
|
182
|
+
if (compactSequence) {
|
|
183
|
+
const compactArt = cachedMermaidRender(compactSequence.source);
|
|
184
|
+
if (compactArt && compactArt.width < art.width) {
|
|
185
|
+
art = compactArt;
|
|
186
|
+
participantLegend = compactSequence.participantLegend;
|
|
187
|
+
messageLegend = compactSequence.messageLegend;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const panelStarts = mermaidPanelStarts(art.width, availableWidth);
|
|
192
|
+
if (panelStarts.length > 1 && (availableWidth < MERMAID_MIN_PANEL_WIDTH || panelStarts.length > MERMAID_MAX_PANELS)) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
lines: [
|
|
197
|
+
...mermaidArtLines(art, availableWidth, panelStarts),
|
|
198
|
+
...mermaidLegendLines("Participants", participantLegend, availableWidth),
|
|
199
|
+
...mermaidLegendLines("Messages", messageLegend, availableWidth),
|
|
200
|
+
],
|
|
201
|
+
nextLineIndex: closingLineIndex === undefined ? rawLines.length : closingLineIndex + 1,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function compactMermaidSequence(source) {
|
|
205
|
+
const lines = source.split("\n");
|
|
206
|
+
const header = lines.find((line) => line.trim().length > 0 && !line.trimStart().startsWith("%%"));
|
|
207
|
+
if (!header || !/^sequenceDiagram\s*$/iu.test(header.trim()))
|
|
208
|
+
return undefined;
|
|
209
|
+
const participantLegend = [];
|
|
210
|
+
const messageLegend = [];
|
|
211
|
+
const autonumber = lines.some((line) => /^\s*autonumber(?:\s|$)/iu.test(line));
|
|
212
|
+
let participantNumber = 0;
|
|
213
|
+
let messageNumber = 0;
|
|
214
|
+
const compactLines = lines.map((line) => {
|
|
215
|
+
const match = /^(\s*)(participant|actor)\s+(\S+?)(?:\s+as\s+(.+?))?\s*$/iu.exec(line);
|
|
216
|
+
if (match) {
|
|
217
|
+
const [, indent = "", declaration = "participant", id = "", rawLabel] = match;
|
|
218
|
+
participantNumber += 1;
|
|
219
|
+
const marker = `P${participantNumber}`;
|
|
220
|
+
const label = rawLabel ? normalizeMermaidLegendLabel(rawLabel) : id;
|
|
221
|
+
participantLegend.push(`${marker} (${id}) — ${label}`);
|
|
222
|
+
return `${indent}${declaration} ${id} as ${marker}`;
|
|
223
|
+
}
|
|
224
|
+
const message = mermaidSequenceMessage(line);
|
|
225
|
+
if (!message)
|
|
226
|
+
return line;
|
|
227
|
+
messageNumber += 1;
|
|
228
|
+
if (message.label.length === 0)
|
|
229
|
+
return line;
|
|
230
|
+
messageLegend.push(`${messageNumber}. ${normalizeMermaidLegendLabel(message.label)}`);
|
|
231
|
+
return autonumber ? message.prefix : `${message.prefix}: [${messageNumber}]`;
|
|
232
|
+
});
|
|
233
|
+
return participantLegend.length > 0 || messageLegend.length > 0
|
|
234
|
+
? { source: compactLines.join("\n"), participantLegend, messageLegend }
|
|
235
|
+
: undefined;
|
|
236
|
+
}
|
|
237
|
+
function mermaidSequenceMessage(line) {
|
|
238
|
+
for (const operator of MERMAID_SEQUENCE_MESSAGE_OPERATORS) {
|
|
239
|
+
const operatorIndex = line.indexOf(operator);
|
|
240
|
+
if (operatorIndex < 0)
|
|
241
|
+
continue;
|
|
242
|
+
const colonIndex = line.indexOf(":", operatorIndex + operator.length);
|
|
243
|
+
const targetEnd = colonIndex < 0 ? line.length : colonIndex;
|
|
244
|
+
const from = line.slice(0, operatorIndex).trim();
|
|
245
|
+
const to = line.slice(operatorIndex + operator.length, targetEnd).trim();
|
|
246
|
+
if (!from || !to || /\s/u.test(from) || /\s/u.test(to))
|
|
247
|
+
return undefined;
|
|
248
|
+
return {
|
|
249
|
+
prefix: line.slice(0, targetEnd).trimEnd(),
|
|
250
|
+
label: colonIndex < 0 ? "" : line.slice(colonIndex + 1).trim(),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
function normalizeMermaidLegendLabel(label) {
|
|
256
|
+
const normalized = label
|
|
257
|
+
.replace(/<br\s*\/?\s*>/giu, " ")
|
|
258
|
+
.replace(/<\/?[A-Za-z][^>]*>/gu, "")
|
|
259
|
+
.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, "$1$2")
|
|
260
|
+
.trim();
|
|
261
|
+
return normalized || label.trim();
|
|
262
|
+
}
|
|
263
|
+
function mermaidPanelStarts(artWidth, panelWidth) {
|
|
264
|
+
if (artWidth <= panelWidth)
|
|
265
|
+
return [0];
|
|
266
|
+
const overlap = Math.min(24, Math.max(8, Math.floor(panelWidth / 5)));
|
|
267
|
+
const stride = Math.max(1, panelWidth - overlap);
|
|
268
|
+
const panelCount = 1 + Math.ceil((artWidth - panelWidth) / stride);
|
|
269
|
+
return Array.from({ length: panelCount }, (_, index) => Math.min(index * stride, artWidth - panelWidth));
|
|
270
|
+
}
|
|
271
|
+
function mermaidArtLines(art, width, panelStarts) {
|
|
272
|
+
if (panelStarts.length === 1) {
|
|
273
|
+
return art.plain.map((text, rowIndex) => {
|
|
274
|
+
const diagramSegments = mermaidDiagramSegments(text, art.styled[rowIndex] ?? []);
|
|
275
|
+
return { text, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const lines = [];
|
|
279
|
+
for (const [panelIndex, startColumn] of panelStarts.entries()) {
|
|
280
|
+
const continuesLeft = startColumn > 0;
|
|
281
|
+
const continuesRight = startColumn + width < art.width;
|
|
282
|
+
const title = `Mermaid ${panelIndex + 1}/${panelStarts.length}${continuesLeft ? " ←" : ""}${continuesRight ? " →" : ""}`;
|
|
283
|
+
lines.push({
|
|
284
|
+
text: title,
|
|
285
|
+
diagramSegments: [{ start: 0, end: title.length, class: "title" }],
|
|
286
|
+
});
|
|
287
|
+
for (const [rowIndex, text] of art.plain.entries()) {
|
|
288
|
+
lines.push(sliceMermaidDiagramRow(text, art.styled[rowIndex] ?? [], startColumn, width));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return lines;
|
|
292
|
+
}
|
|
293
|
+
function mermaidLegendLines(title, entries, width) {
|
|
294
|
+
if (entries.length === 0)
|
|
295
|
+
return [];
|
|
296
|
+
const lines = [{
|
|
297
|
+
text: title,
|
|
298
|
+
diagramSegments: [{ start: 0, end: title.length, class: "title" }],
|
|
299
|
+
}];
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
for (const wrapped of wrapRenderedMarkdownLine({ text: entry, segments: [], links: [] }, width, {})) {
|
|
302
|
+
lines.push({
|
|
303
|
+
text: wrapped.text,
|
|
304
|
+
...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
|
|
305
|
+
});
|
|
127
306
|
}
|
|
128
307
|
}
|
|
129
308
|
return lines;
|
|
130
309
|
}
|
|
310
|
+
function sliceMermaidDiagramRow(text, spans, startColumn, width) {
|
|
311
|
+
const endColumn = startColumn + width;
|
|
312
|
+
const diagramSegments = [];
|
|
313
|
+
let result = "";
|
|
314
|
+
let column = 0;
|
|
315
|
+
let spanIndex = 0;
|
|
316
|
+
let spanEnd = spans[0]?.text.length ?? 0;
|
|
317
|
+
for (const grapheme of displayGraphemes(text)) {
|
|
318
|
+
const nextColumn = column + grapheme.width;
|
|
319
|
+
if (nextColumn <= startColumn) {
|
|
320
|
+
column = nextColumn;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (column >= endColumn)
|
|
324
|
+
break;
|
|
325
|
+
while (spanIndex < spans.length - 1 && grapheme.start >= spanEnd) {
|
|
326
|
+
spanIndex += 1;
|
|
327
|
+
spanEnd += spans[spanIndex]?.text.length ?? 0;
|
|
328
|
+
}
|
|
329
|
+
const fullyVisible = column >= startColumn && nextColumn <= endColumn;
|
|
330
|
+
const chunk = fullyVisible
|
|
331
|
+
? grapheme.text
|
|
332
|
+
: " ".repeat(Math.max(0, Math.min(nextColumn, endColumn) - Math.max(column, startColumn)));
|
|
333
|
+
const chunkStart = result.length;
|
|
334
|
+
result += chunk;
|
|
335
|
+
const spanClass = spans[spanIndex]?.cls;
|
|
336
|
+
if (fullyVisible && spanClass && spanClass !== "none" && spanClass !== "text") {
|
|
337
|
+
const previous = diagramSegments.at(-1);
|
|
338
|
+
if (previous && previous.class === spanClass && previous.end === chunkStart) {
|
|
339
|
+
previous.end = result.length;
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
diagramSegments.push({ start: chunkStart, end: result.length, class: spanClass });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
column = nextColumn;
|
|
346
|
+
}
|
|
347
|
+
return { text: result, ...(diagramSegments.length > 0 ? { diagramSegments } : {}) };
|
|
348
|
+
}
|
|
349
|
+
function cachedMermaidRender(source) {
|
|
350
|
+
if (mermaidRenderCache.has(source)) {
|
|
351
|
+
const cached = mermaidRenderCache.get(source) ?? null;
|
|
352
|
+
mermaidRenderCache.delete(source);
|
|
353
|
+
mermaidRenderCache.set(source, cached);
|
|
354
|
+
return cached;
|
|
355
|
+
}
|
|
356
|
+
let art;
|
|
357
|
+
try {
|
|
358
|
+
art = renderMermaid(source.replace(/<br\s*\/?\s*>/giu, " "));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
art = null;
|
|
362
|
+
}
|
|
363
|
+
mermaidRenderCache.set(source, art);
|
|
364
|
+
if (mermaidRenderCache.size > MERMAID_CACHE_LIMIT) {
|
|
365
|
+
const oldestKey = mermaidRenderCache.keys().next().value;
|
|
366
|
+
if (oldestKey !== undefined)
|
|
367
|
+
mermaidRenderCache.delete(oldestKey);
|
|
368
|
+
}
|
|
369
|
+
return art;
|
|
370
|
+
}
|
|
371
|
+
function mermaidDiagramSegments(text, spans) {
|
|
372
|
+
const segments = [];
|
|
373
|
+
let start = 0;
|
|
374
|
+
for (const span of spans) {
|
|
375
|
+
const end = start + span.text.length;
|
|
376
|
+
if (start < text.length && span.cls !== "none" && span.cls !== "text") {
|
|
377
|
+
segments.push({ start, end: Math.min(end, text.length), class: span.cls });
|
|
378
|
+
}
|
|
379
|
+
start = end;
|
|
380
|
+
}
|
|
381
|
+
return segments;
|
|
382
|
+
}
|
|
383
|
+
function isMermaidFence(fence) {
|
|
384
|
+
const token = fence.info.trim().split(/\s+/, 1)[0]?.toLowerCase().replace(/^[{.]+|[}.]+$/gu, "") ?? "";
|
|
385
|
+
return token === "mermaid";
|
|
386
|
+
}
|
|
131
387
|
export function markdownSyntaxHighlightsForText(text, startColumn = 0) {
|
|
132
388
|
const highlights = [];
|
|
133
389
|
let fence;
|
package/dist/theme.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type Theme = {
|
|
|
18
18
|
assistantMessageBackground: string;
|
|
19
19
|
userMessageBackground: string;
|
|
20
20
|
thinkingMessageBackground: string;
|
|
21
|
+
codeBlockForeground: string;
|
|
22
|
+
codeBlockBackground: string;
|
|
21
23
|
inputCursorBackground: string;
|
|
22
24
|
popupForeground: string;
|
|
23
25
|
popupBackground: string;
|
package/dist/theme.js
CHANGED
|
@@ -19,6 +19,8 @@ export const THEMES = {
|
|
|
19
19
|
assistantMessageBackground: "",
|
|
20
20
|
userMessageBackground: "",
|
|
21
21
|
thinkingMessageBackground: "",
|
|
22
|
+
codeBlockForeground: "#e6edf3",
|
|
23
|
+
codeBlockBackground: "#2a2f36",
|
|
22
24
|
inputCursorBackground: "#7fb3c8",
|
|
23
25
|
popupForeground: "#e6edf3",
|
|
24
26
|
popupBackground: "#1e1e1e",
|
|
@@ -75,6 +77,8 @@ export const THEMES = {
|
|
|
75
77
|
assistantMessageBackground: "",
|
|
76
78
|
userMessageBackground: "",
|
|
77
79
|
thinkingMessageBackground: "",
|
|
80
|
+
codeBlockForeground: "#0f172a",
|
|
81
|
+
codeBlockBackground: "#f1f5f9",
|
|
78
82
|
inputCursorBackground: "#0284c7",
|
|
79
83
|
popupForeground: "#0f172a",
|
|
80
84
|
popupBackground: "#ffffff",
|
|
@@ -218,7 +218,7 @@ const BUILTIN_CONFIG: SubagentConfig = {
|
|
|
218
218
|
model: "zai/glm-5.3-flash",
|
|
219
219
|
fallbackModels: ["openai-codex/gpt-5.6-luna"],
|
|
220
220
|
thinking: "low",
|
|
221
|
-
timeoutMs:
|
|
221
|
+
timeoutMs: 300_000,
|
|
222
222
|
tools: ["read", "grep", "bash"],
|
|
223
223
|
isolatedSkills: [getBrowserQaSkillPath()],
|
|
224
224
|
},
|
|
@@ -24,12 +24,18 @@ the requested target in the browser.
|
|
|
24
24
|
|
|
25
25
|
## Workflow
|
|
26
26
|
|
|
27
|
-
Treat target discovery as a 30-second preflight and
|
|
28
|
-
seconds of starting.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
Treat target discovery as a 30-second preflight and begin browser execution within
|
|
28
|
+
45 seconds of starting. For a verification task, use at most one actual `run`
|
|
29
|
+
browser execution per profile. Metadata/preparation commands such as `profiles`
|
|
30
|
+
and form-auth `scaffold` do not count as browser verification runs. For an
|
|
31
|
+
explicit exploratory/manual-QA task, use at most three `run` rounds, give each a
|
|
32
|
+
unique `--run-id`, bound each with `--runner-timeout-ms 60000`, and let each round
|
|
33
|
+
test one concrete hypothesis learned from the prior evidence. Stop earlier once
|
|
34
|
+
the requested behavior is explained or no new supported hypothesis remains. If
|
|
35
|
+
you cannot identify a reachable target and a supported deterministic assertion
|
|
36
|
+
inside the preflight, return a structured `BLOCKED` result immediately. Do not
|
|
37
|
+
consume the launcher budget on open-ended source reading, server polling,
|
|
38
|
+
capability probing, or retries.
|
|
33
39
|
|
|
34
40
|
1. Resolve `scripts/browser-qa-runner.mjs` relative to this skill.
|
|
35
41
|
2. Use the launcher-provided `$PI_SUBAGENT_AGENT_DIR/browser-qa/` workspace.
|
|
@@ -56,15 +62,27 @@ source reading, server polling, capability probing, or retries.
|
|
|
56
62
|
operations documented below; it does not accept expressions or scripts.
|
|
57
63
|
6. Run public QA with
|
|
58
64
|
`node <runner> run --base-url <url> --flow <flow.jsonc>`. The URL's exact
|
|
59
|
-
origin becomes the fail-closed allowlist.
|
|
60
|
-
|
|
65
|
+
origin becomes the fail-closed allowlist. When the real app requires known
|
|
66
|
+
API/CDN origins, add repeatable `--allow-origin <exact-origin>` flags. Each
|
|
67
|
+
value must be an exact `http(s)` origin with no path, credentials, wildcard,
|
|
68
|
+
or inferred sibling domain; undeclared origins remain blocked. Only for
|
|
69
|
+
authenticated QA, add `--profile <id>`; the selected profile then owns the
|
|
70
|
+
URL and allowlist.
|
|
61
71
|
Profile id, URL, and flow path are non-secret; never pass credentials as
|
|
62
72
|
arguments or environment variables.
|
|
63
73
|
7. Report deterministic assertions and every artifact returned by the runner.
|
|
64
|
-
For each screenshot, video, trace, or retained download, emit a separate
|
|
65
|
-
link using its `uri` and also show its absolute `path`.
|
|
66
|
-
runs too whenever `artifacts` is present; never report only
|
|
67
|
-
|
|
74
|
+
For each screenshot, video, trace, or retained download, emit a separate
|
|
75
|
+
clickable Markdown link using its `uri` and also show its absolute `path`.
|
|
76
|
+
Do this for failed runs too whenever `artifacts` is present; never report only
|
|
77
|
+
`evidenceDir`.
|
|
78
|
+
When screenshots are present, inspect at least one representative meaningful
|
|
79
|
+
PNG directly with the `read` tool on its absolute path before claiming visual
|
|
80
|
+
QA. Inspect additional screenshots when they represent materially different
|
|
81
|
+
states or popups. Record `visualInspection: inspected` plus the inspected
|
|
82
|
+
paths and concrete findings. If image reading is unavailable in the active
|
|
83
|
+
model, report `visualInspection: unavailable` and do not claim a visual pass;
|
|
84
|
+
deterministic assertions may still be reported separately. Visual inspection
|
|
85
|
+
supplements assertions; it does not replace them.
|
|
68
86
|
|
|
69
87
|
## Scenario design
|
|
70
88
|
|
|
@@ -84,11 +102,14 @@ source reading, server polling, capability probing, or retries.
|
|
|
84
102
|
animation/debounce. Set flow `timeoutMs` only as high as the target
|
|
85
103
|
legitimately needs.
|
|
86
104
|
- The runner waits after every visible interaction until the document is ready,
|
|
87
|
-
requests started by the interaction
|
|
105
|
+
observes requests started by the interaction for a bounded readiness window,
|
|
106
|
+
and waits for common visible busy
|
|
88
107
|
markers (including `aria-busy`, progress bars, loading/spinner/skeleton test
|
|
89
|
-
ids and classes) disappear.
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
ids and classes) to disappear. EventSource/WebSocket traffic is excluded and
|
|
109
|
+
a long-poll/background request cannot pin readiness for the full flow timeout;
|
|
110
|
+
visible busy UI can. It then keeps the stable state on video for 500 ms. A
|
|
111
|
+
busy page that does not settle within `timeoutMs` fails instead of continuing
|
|
112
|
+
against a skeleton. For an app-specific loader not covered by
|
|
92
113
|
those conventions, add an explicit `waitFor`/`assertHidden` for that loader
|
|
93
114
|
and assert the loaded content before interacting with it.
|
|
94
115
|
- Recorded pointer actions are annotated automatically: clicks and double-clicks
|
|
@@ -101,7 +122,8 @@ source reading, server polling, capability probing, or retries.
|
|
|
101
122
|
pointer input, and clears before the runner's post-action stable interval
|
|
102
123
|
completes.
|
|
103
124
|
- Place `authRejectedIf` immediately after navigation or any transition that
|
|
104
|
-
may reveal expired authentication.
|
|
125
|
+
may reveal expired authentication. It must declare `urlIncludes` or a locator;
|
|
126
|
+
an empty rejection check is invalid.
|
|
105
127
|
- Never weaken an assertion merely to make a failing run pass. If the observed
|
|
106
128
|
product behavior differs from the expectation, preserve the failure evidence
|
|
107
129
|
and report the mismatch.
|
|
@@ -120,7 +142,7 @@ Supported actions:
|
|
|
120
142
|
`uncheck`, `selectOption`, `wheel`, `evaluate`, `dragTo`, `uploadFiles`,
|
|
121
143
|
`openPopup`, `download`
|
|
122
144
|
- assertions: `assertVisible`, `assertHidden`, `assertEnabled`,
|
|
123
|
-
`assertDisabled`, `assertChecked`, `assertUnchecked`, `assertText`,
|
|
145
|
+
`assertDisabled`, `assertChecked`, `assertUnchecked`, `assertText`, `assertTextContent`,
|
|
124
146
|
`assertValue`, `assertAttribute`, `assertCount`, `assertURL`,
|
|
125
147
|
`assertDOMMetric`
|
|
126
148
|
- evidence/auth: `screenshot`, `authRejectedIf`
|
|
@@ -128,6 +150,9 @@ Supported actions:
|
|
|
128
150
|
Locators accept one of `testId`, `role` (plus optional `name`), `label`,
|
|
129
151
|
`placeholder`, `text`, or `css`; add `exact: true` where useful. String
|
|
130
152
|
assertions require exactly one of `equals` or `includes`.
|
|
153
|
+
`assertText` matches visible, user-facing `innerText` and therefore fails for a
|
|
154
|
+
hidden locator. Use `assertTextContent` only when raw DOM text, including hidden
|
|
155
|
+
content, is intentionally the oracle.
|
|
131
156
|
`assertAttribute` additionally requires a bounded `attribute` name and is
|
|
132
157
|
useful for `aria-*`, `data-*`, `href`, and similar observable state. All
|
|
133
158
|
assertions retry until `timeoutMs` and report generic failures without exposing
|
|
@@ -208,7 +233,11 @@ oracle. Raw JavaScript remains intentionally unsupported.
|
|
|
208
233
|
```jsonc
|
|
209
234
|
{
|
|
210
235
|
"viewport": { "width": 844, "height": 847 },
|
|
211
|
-
"environment": {
|
|
236
|
+
"environment": {
|
|
237
|
+
"locale": "en-GB",
|
|
238
|
+
"timezoneId": "Europe/London",
|
|
239
|
+
"colorScheme": "dark"
|
|
240
|
+
},
|
|
212
241
|
"steps": [
|
|
213
242
|
{ "action": "goto", "path": "/settings" },
|
|
214
243
|
{ "action": "authRejectedIf", "urlIncludes": "/login" },
|
|
@@ -216,7 +245,11 @@ oracle. Raw JavaScript remains intentionally unsupported.
|
|
|
216
245
|
"action": "assertVisible",
|
|
217
246
|
"locator": { "role": "heading", "name": "Settings", "exact": true }
|
|
218
247
|
},
|
|
219
|
-
{
|
|
248
|
+
{
|
|
249
|
+
"action": "wheel",
|
|
250
|
+
"locator": { "css": ".settings-panel" },
|
|
251
|
+
"deltaY": 500
|
|
252
|
+
},
|
|
220
253
|
{
|
|
221
254
|
"action": "assertDOMMetric",
|
|
222
255
|
"locator": { "css": ".settings-panel" },
|
|
@@ -226,9 +259,17 @@ oracle. Raw JavaScript remains intentionally unsupported.
|
|
|
226
259
|
{
|
|
227
260
|
"action": "click",
|
|
228
261
|
"locator": { "testId": "save-settings" },
|
|
229
|
-
"expectResponse": {
|
|
262
|
+
"expectResponse": {
|
|
263
|
+
"path": "/api/settings",
|
|
264
|
+
"method": "PUT",
|
|
265
|
+
"status": 200
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"action": "assertText",
|
|
270
|
+
"locator": { "testId": "toast" },
|
|
271
|
+
"includes": "Saved"
|
|
230
272
|
},
|
|
231
|
-
{ "action": "assertText", "locator": { "testId": "toast" }, "includes": "Saved" },
|
|
232
273
|
{ "action": "screenshot", "name": "settings-saved" }
|
|
233
274
|
]
|
|
234
275
|
}
|
|
@@ -301,6 +342,8 @@ After any runner invocation that actually performed browser testing, include
|
|
|
301
342
|
all non-empty `artifacts.screenshots`, `artifacts.videos`, and
|
|
302
343
|
`artifacts.traces`, and `artifacts.downloads` groups in the final response.
|
|
303
344
|
These links are mandatory so the user can open the evidence directly.
|
|
345
|
+
Also include `visualInspection` with `inspected` or `unavailable`; never infer a
|
|
346
|
+
visual pass from a successful runner status alone.
|
|
304
347
|
|
|
305
348
|
See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`,
|
|
306
349
|
`references/qa-design.md`, and `references/auth-scaffold-spec.md`.
|
|
@@ -19,6 +19,12 @@ proof.
|
|
|
19
19
|
When verifying a fix, prefer a focused regression flow over a broad tour of the
|
|
20
20
|
application. If multiple independent states matter, assert each one explicitly.
|
|
21
21
|
|
|
22
|
+
For explicit exploratory/manual QA, keep exploration bounded rather than turning
|
|
23
|
+
it into an open-ended crawl. Run at most three minimal rounds. Each round should
|
|
24
|
+
start from one concrete hypothesis, produce a deterministic observation plus a
|
|
25
|
+
meaningful screenshot, and use that evidence to decide whether another round is
|
|
26
|
+
justified. Verification tasks remain one browser run per profile.
|
|
27
|
+
|
|
22
28
|
## Choose resilient locators
|
|
23
29
|
|
|
24
30
|
Prefer locators that match how users and accessibility APIs identify controls:
|
|
@@ -42,13 +48,14 @@ by an assertion is enough. Use `waitFor` only when the next operation depends on
|
|
|
42
48
|
a distinct attached/detached/visible/hidden transition.
|
|
43
49
|
|
|
44
50
|
After navigation and visible interactions, the runner also waits for DOM
|
|
45
|
-
readiness,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
readiness, tracks requests causally started by that action through a bounded
|
|
52
|
+
readiness window, waits for common visible
|
|
53
|
+
`aria-busy`/progress/loading/spinner/skeleton markers, and keeps a 500 ms stable
|
|
54
|
+
interval. EventSource/WebSocket traffic is ignored, and a long poll is not
|
|
55
|
+
allowed to pin the entire flow timeout. A visible busy indicator may still hold
|
|
56
|
+
readiness until `timeoutMs`. This is a safe baseline, not an application-specific
|
|
57
|
+
oracle: explicitly wait for a custom loader to become hidden and assert the
|
|
58
|
+
loaded content when the application uses different readiness semantics.
|
|
52
59
|
|
|
53
60
|
`waitForTimeout` is bounded to five seconds and should be exceptional—for a
|
|
54
61
|
known animation, debounce, or externally scheduled transition with no
|
|
@@ -58,7 +65,9 @@ behavior. If a normal operation legitimately needs more time, adjust the flow's
|
|
|
58
65
|
|
|
59
66
|
All assertion actions retry until that timeout. This makes an action followed
|
|
60
67
|
directly by `assertText`, `assertVisible`, `assertURL`, or another assertion
|
|
61
|
-
safe for asynchronously rendered outcomes.
|
|
68
|
+
safe for asynchronously rendered outcomes. `assertText` requires the locator to
|
|
69
|
+
be visible and matches rendered `innerText`; use `assertTextContent` only when
|
|
70
|
+
hidden/raw DOM text is deliberately part of the oracle. Use `assertAttribute` for
|
|
62
71
|
observable state such as `aria-expanded`, `aria-invalid`, or `data-state`
|
|
63
72
|
instead of reading DOM state through executable JavaScript.
|
|
64
73
|
|
|
@@ -177,6 +186,12 @@ they explain the chronology without becoming screenshot or assertion oracles.
|
|
|
177
186
|
Assertions determine pass/fail; evidence explains it. Preserve and link every
|
|
178
187
|
artifact group returned on both passed and failed runs.
|
|
179
188
|
|
|
189
|
+
For visual QA, do not stop at artifact generation. Open at least one meaningful
|
|
190
|
+
PNG with the model's image-capable `read` path and inspect layout, clipping,
|
|
191
|
+
overlap, state styling, and other visual defects relevant to the scenario. If
|
|
192
|
+
the active model cannot read images, explicitly report visual inspection as
|
|
193
|
+
unavailable rather than treating deterministic assertions as a visual pass.
|
|
194
|
+
|
|
180
195
|
## Diagnose failures without weakening the test
|
|
181
196
|
|
|
182
197
|
Classify the first failing step:
|
|
@@ -17,6 +17,7 @@ const QA_WORKSPACE_RELATIVE = "browser-qa";
|
|
|
17
17
|
const EVIDENCE_RELATIVE = "evidence";
|
|
18
18
|
const UI_READY_SETTLE_MS = 500;
|
|
19
19
|
const UI_READY_POLL_MS = 100;
|
|
20
|
+
const ACTION_REQUEST_WAIT_MAX_MS = 2_000;
|
|
20
21
|
const FORM_VIDEO_STEP_DELAY_MS = UI_READY_SETTLE_MS;
|
|
21
22
|
const POPUP_VIDEO_SETTLE_MS = 250;
|
|
22
23
|
const CLICK_VIDEO_DELAY_MS = 120;
|
|
@@ -160,7 +161,7 @@ async function main() {
|
|
|
160
161
|
const args = parseArgs(rawArgs);
|
|
161
162
|
const selected = args.profile
|
|
162
163
|
? selectProfile(readAuthConfig(cwd, true, true).profiles, args.profile)
|
|
163
|
-
: createPublicProfile(args.baseUrl);
|
|
164
|
+
: createPublicProfile(args.baseUrl, args.allowOrigin);
|
|
164
165
|
const profileId = selected.id;
|
|
165
166
|
const profile = selected.profile;
|
|
166
167
|
const secrets = collectSecrets(profile.auth);
|
|
@@ -249,6 +250,9 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
|
|
|
249
250
|
runtimeSecrets.push(...collectStorageStateSecrets(initialStorageState));
|
|
250
251
|
await runStage(progress, "trace_start", deadline, () => context.tracing.start({ screenshots: true, snapshots: true, sources: false }), profileId);
|
|
251
252
|
await runStage(progress, "flow_execute", deadline, () => executeFlow({ context, page, baseURL, evidenceDir, flow, allowedOrigins, profileId, secrets: runtimeSecrets, observations, videos }), profileId);
|
|
253
|
+
await runStage(progress, "storage_state_refresh", deadline, async () => {
|
|
254
|
+
if (await refreshRuntimeSecrets(context, runtimeSecrets)) discardRecordedVideos(videos);
|
|
255
|
+
}, profileId);
|
|
252
256
|
await runStage(progress, "secret_exposure_check", deadline, () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId), profileId);
|
|
253
257
|
await runStage(progress, "final_screenshot", deadline, () => page.screenshot({ path: path.join(evidenceDir, "final.png"), fullPage: true }), profileId);
|
|
254
258
|
} catch (error) {
|
|
@@ -256,15 +260,25 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
|
|
|
256
260
|
failure = error;
|
|
257
261
|
if (page && !page.isClosed() && !error.suppressEvidence) {
|
|
258
262
|
try {
|
|
263
|
+
await runCleanupStage(progress, "failure_storage_state_refresh", async () => {
|
|
264
|
+
if (await refreshRuntimeSecrets(context, runtimeSecrets)) discardRecordedVideos(videos);
|
|
265
|
+
});
|
|
266
|
+
await runCleanupStage(progress, "failure_secret_exposure_check", () => assertBrowserDoesNotExposeSecrets(context, page, runtimeSecrets, profileId));
|
|
259
267
|
await runCleanupStage(progress, "failure_screenshot", () => page.screenshot({ path: path.join(evidenceDir, "failure.png"), fullPage: true }));
|
|
260
|
-
} catch {
|
|
268
|
+
} catch (evidenceError) {
|
|
269
|
+
if (evidenceError instanceof QaStatusError && evidenceError.suppressEvidence) {
|
|
270
|
+
failure = evidenceError;
|
|
271
|
+
outcome = evidenceError.status;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
261
274
|
}
|
|
262
275
|
} finally {
|
|
263
276
|
const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
|
|
277
|
+
let discoveredLateSecrets = false;
|
|
264
278
|
if (context) {
|
|
265
279
|
try {
|
|
266
280
|
const finalStorageState = await runCleanupStage(progress, "storage_state_finalize", () => context.storageState(), cleanupDeadline);
|
|
267
|
-
runtimeSecrets
|
|
281
|
+
discoveredLateSecrets = mergeRuntimeSecrets(runtimeSecrets, collectStorageStateSecrets(finalStorageState));
|
|
268
282
|
} catch { /* best effort */ }
|
|
269
283
|
try {
|
|
270
284
|
await runCleanupStage(progress, "trace_stop", () => context.tracing.stop({ path: tracePath }), cleanupDeadline);
|
|
@@ -293,6 +307,14 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
|
|
|
293
307
|
} catch { /* best effort */ }
|
|
294
308
|
}
|
|
295
309
|
await runCleanupStage(progress, "browser_close", () => browser.close(), cleanupDeadline).catch(() => {});
|
|
310
|
+
if (discoveredLateSecrets) {
|
|
311
|
+
removeVisualEvidence(evidenceDir);
|
|
312
|
+
if (!failure) {
|
|
313
|
+
outcome = "failed";
|
|
314
|
+
failure = new QaStatusError("QA_RUN_FAILED", "authentication changed after the final evidence safety check; visual evidence was discarded", 1, profileId);
|
|
315
|
+
failure.suppressEvidence = true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
296
318
|
}
|
|
297
319
|
try {
|
|
298
320
|
for (const name of fs.readdirSync(evidenceDir).filter((name) => /^download-[A-Za-z0-9._-]+\.bin$/.test(name))) {
|
|
@@ -886,23 +908,29 @@ function authPlaceholderCount(profile) {
|
|
|
886
908
|
)).length;
|
|
887
909
|
}
|
|
888
910
|
|
|
889
|
-
function createPublicProfile(rawBaseUrl) {
|
|
911
|
+
function createPublicProfile(rawBaseUrl, extraAllowedOrigins = []) {
|
|
890
912
|
if (typeof rawBaseUrl !== "string") {
|
|
891
913
|
throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
|
|
892
914
|
}
|
|
893
915
|
try {
|
|
894
916
|
const url = new URL(rawBaseUrl);
|
|
895
917
|
if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
|
|
918
|
+
const allowedOrigins = [url.origin];
|
|
919
|
+
for (const raw of Array.isArray(extraAllowedOrigins) ? extraAllowedOrigins : []) {
|
|
920
|
+
const extra = new URL(raw);
|
|
921
|
+
if (!/^https?:$/.test(extra.protocol) || extra.username || extra.password || extra.href !== `${extra.origin}/`) throw new Error();
|
|
922
|
+
allowedOrigins.push(extra.origin);
|
|
923
|
+
}
|
|
896
924
|
return {
|
|
897
925
|
id: "public",
|
|
898
926
|
profile: {
|
|
899
927
|
baseUrl: url.href,
|
|
900
|
-
allowedOrigins: [
|
|
928
|
+
allowedOrigins: [...new Set(allowedOrigins)],
|
|
901
929
|
auth: { type: "none" },
|
|
902
930
|
},
|
|
903
931
|
};
|
|
904
932
|
} catch {
|
|
905
|
-
throw new QaStatusError("QA_RUN_FAILED", "public --base-url must be
|
|
933
|
+
throw new QaStatusError("QA_RUN_FAILED", "public --base-url and --allow-origin values must be http(s) URLs without credentials; --allow-origin must be an exact origin", 1, "public");
|
|
906
934
|
}
|
|
907
935
|
}
|
|
908
936
|
|
|
@@ -1050,6 +1078,15 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1050
1078
|
let capturedPopup;
|
|
1051
1079
|
let unexpectedPopupError;
|
|
1052
1080
|
const readinessTrackers = new Map();
|
|
1081
|
+
let discardFutureVideos = false;
|
|
1082
|
+
const refreshSecrets = async () => {
|
|
1083
|
+
const changed = await refreshRuntimeSecrets(context, secrets);
|
|
1084
|
+
if (changed) {
|
|
1085
|
+
discardFutureVideos = true;
|
|
1086
|
+
discardRecordedVideos(videos);
|
|
1087
|
+
}
|
|
1088
|
+
return changed;
|
|
1089
|
+
};
|
|
1053
1090
|
const readinessFor = (candidate) => {
|
|
1054
1091
|
if (!readinessTrackers.has(candidate)) readinessTrackers.set(candidate, createPageReadinessTracker(candidate));
|
|
1055
1092
|
return readinessTrackers.get(candidate);
|
|
@@ -1078,6 +1115,9 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1078
1115
|
}
|
|
1079
1116
|
const { scope, ownerPage } = await resolveStepTarget({ page, popups, step, allowedOrigins, profileId, index });
|
|
1080
1117
|
const locator = step.locator === undefined ? undefined : resolveLocator(scope, step.locator, profileId, index);
|
|
1118
|
+
const readinessTracker = readinessFor(ownerPage);
|
|
1119
|
+
const requestScope = UI_READY_ACTIONS.has(step.action) ? readinessTracker.beginActionScope() : undefined;
|
|
1120
|
+
try {
|
|
1081
1121
|
switch (step.action) {
|
|
1082
1122
|
case "goto": {
|
|
1083
1123
|
const target = typeof step.url === "string" ? step.url : step.path;
|
|
@@ -1149,7 +1189,7 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1149
1189
|
[, popup] = await Promise.all([requireLocator(locator, profileId, index).click({ delay: CLICK_VIDEO_DELAY_MS }), popupPromise]);
|
|
1150
1190
|
popup.setDefaultTimeout(timeout);
|
|
1151
1191
|
popup.setDefaultNavigationTimeout(timeout);
|
|
1152
|
-
popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard:
|
|
1192
|
+
popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: discardFutureVideos };
|
|
1153
1193
|
videos.push(popupVideo);
|
|
1154
1194
|
await popup.waitForLoadState("domcontentloaded", { timeout });
|
|
1155
1195
|
await waitForUiReady({
|
|
@@ -1234,6 +1274,15 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1234
1274
|
const target = requireLocator(locator, profileId, index);
|
|
1235
1275
|
await retryAssertion({
|
|
1236
1276
|
page: ownerPage, timeout, profileId, index, reason: "text assertion failed",
|
|
1277
|
+
check: async () => (await target.isVisible()) && matches(await target.innerText()),
|
|
1278
|
+
});
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
case "assertTextContent": {
|
|
1282
|
+
const matches = expectedStringMatcher(step, profileId, index, "text content");
|
|
1283
|
+
const target = requireLocator(locator, profileId, index);
|
|
1284
|
+
await retryAssertion({
|
|
1285
|
+
page: ownerPage, timeout, profileId, index, reason: "text content assertion failed",
|
|
1237
1286
|
check: async () => matches((await target.textContent()) ?? ""),
|
|
1238
1287
|
});
|
|
1239
1288
|
break;
|
|
@@ -1290,6 +1339,7 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1290
1339
|
break;
|
|
1291
1340
|
}
|
|
1292
1341
|
case "authRejectedIf": {
|
|
1342
|
+
if (typeof step.urlIncludes !== "string" && !locator) throw flowError(profileId, index, "authRejectedIf requires urlIncludes or locator");
|
|
1293
1343
|
const urlRejected = typeof step.urlIncludes === "string" && scope.url().includes(step.urlIncludes);
|
|
1294
1344
|
const locatorRejected = locator ? await locator.isVisible().catch(() => false) : false;
|
|
1295
1345
|
if (!urlRejected && !locatorRejected) break;
|
|
@@ -1298,21 +1348,34 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1298
1348
|
case "screenshot": {
|
|
1299
1349
|
const name = typeof step.name === "string" ? step.name : `step-${index + 1}`;
|
|
1300
1350
|
if (!isSafeName(name)) throw flowError(profileId, index, "screenshot name is invalid");
|
|
1351
|
+
await refreshSecrets();
|
|
1301
1352
|
await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
|
|
1302
1353
|
await ownerPage.screenshot({ path: path.join(evidenceDir, `${name}.png`), fullPage: step.fullPage !== false });
|
|
1303
1354
|
break;
|
|
1304
1355
|
}
|
|
1305
1356
|
default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
|
|
1306
1357
|
}
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
requestScope?.stopCapture();
|
|
1360
|
+
requestScope?.dispose();
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1307
1363
|
if (UI_READY_ACTIONS.has(step.action)) {
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1364
|
+
try {
|
|
1365
|
+
await waitForUiReady({
|
|
1366
|
+
scope,
|
|
1367
|
+
tracker: readinessTracker,
|
|
1368
|
+
requestScope,
|
|
1369
|
+
timeout,
|
|
1370
|
+
failure: () => flowError(profileId, index, "page did not finish loading"),
|
|
1371
|
+
});
|
|
1372
|
+
} finally {
|
|
1373
|
+
requestScope?.stopCapture();
|
|
1374
|
+
requestScope?.dispose();
|
|
1375
|
+
}
|
|
1314
1376
|
}
|
|
1315
1377
|
if (unexpectedPopupError) throw unexpectedPopupError;
|
|
1378
|
+
await refreshSecrets();
|
|
1316
1379
|
await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
|
|
1317
1380
|
}
|
|
1318
1381
|
} finally {
|
|
@@ -1323,15 +1386,36 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
|
|
|
1323
1386
|
|
|
1324
1387
|
function createPageReadinessTracker(page) {
|
|
1325
1388
|
const pendingRequests = new Set();
|
|
1326
|
-
const
|
|
1327
|
-
const
|
|
1389
|
+
const actionScopes = new Set();
|
|
1390
|
+
const onRequest = (request) => {
|
|
1391
|
+
if (isLongLivedRequest(request)) return;
|
|
1392
|
+
pendingRequests.add(request);
|
|
1393
|
+
for (const scope of actionScopes) {
|
|
1394
|
+
if (scope.capturing) scope.pendingRequests.add(request);
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
const onRequestDone = (request) => {
|
|
1398
|
+
pendingRequests.delete(request);
|
|
1399
|
+
for (const scope of actionScopes) scope.pendingRequests.delete(request);
|
|
1400
|
+
};
|
|
1328
1401
|
page.on("request", onRequest);
|
|
1329
1402
|
page.on("requestfinished", onRequestDone);
|
|
1330
1403
|
page.on("requestfailed", onRequestDone);
|
|
1331
1404
|
return {
|
|
1332
1405
|
page,
|
|
1333
1406
|
pendingRequests,
|
|
1407
|
+
beginActionScope() {
|
|
1408
|
+
const scope = {
|
|
1409
|
+
pendingRequests: new Set(),
|
|
1410
|
+
capturing: true,
|
|
1411
|
+
stopCapture() { scope.capturing = false; },
|
|
1412
|
+
dispose() { actionScopes.delete(scope); },
|
|
1413
|
+
};
|
|
1414
|
+
actionScopes.add(scope);
|
|
1415
|
+
return scope;
|
|
1416
|
+
},
|
|
1334
1417
|
dispose() {
|
|
1418
|
+
actionScopes.clear();
|
|
1335
1419
|
page.off("request", onRequest);
|
|
1336
1420
|
page.off("requestfinished", onRequestDone);
|
|
1337
1421
|
page.off("requestfailed", onRequestDone);
|
|
@@ -1339,8 +1423,18 @@ function createPageReadinessTracker(page) {
|
|
|
1339
1423
|
};
|
|
1340
1424
|
}
|
|
1341
1425
|
|
|
1342
|
-
|
|
1426
|
+
function isLongLivedRequest(request) {
|
|
1427
|
+
try {
|
|
1428
|
+
return ["eventsource", "websocket"].includes(request.resourceType?.());
|
|
1429
|
+
} catch {
|
|
1430
|
+
return false;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
async function waitForUiReady({ scope, tracker, requestScope = undefined, timeout, failure }) {
|
|
1343
1435
|
const deadline = Date.now() + timeout;
|
|
1436
|
+
const pendingRequests = requestScope?.pendingRequests ?? tracker.pendingRequests;
|
|
1437
|
+
const requestDeadline = Date.now() + Math.min(ACTION_REQUEST_WAIT_MAX_MS, Math.max(0, timeout - UI_READY_SETTLE_MS));
|
|
1344
1438
|
try {
|
|
1345
1439
|
await scope.waitForLoadState("domcontentloaded", { timeout });
|
|
1346
1440
|
} catch {
|
|
@@ -1350,10 +1444,12 @@ async function waitForUiReady({ scope, tracker, timeout, failure }) {
|
|
|
1350
1444
|
const remaining = deadline - Date.now();
|
|
1351
1445
|
if (remaining <= 0) throw failure();
|
|
1352
1446
|
const hasBusyIndicator = await hasVisibleBusyIndicator(scope);
|
|
1353
|
-
|
|
1447
|
+
const requestsStillBlock = pendingRequests.size > 0 && Date.now() < requestDeadline;
|
|
1448
|
+
if (!requestsStillBlock && !hasBusyIndicator) {
|
|
1354
1449
|
await tracker.page.waitForTimeout(Math.min(UI_READY_SETTLE_MS, remaining));
|
|
1355
1450
|
const stillBusy = await hasVisibleBusyIndicator(scope);
|
|
1356
|
-
|
|
1451
|
+
const requestsStillBlockAfterSettle = pendingRequests.size > 0 && Date.now() < requestDeadline;
|
|
1452
|
+
if (!requestsStillBlockAfterSettle && !stillBusy) return;
|
|
1357
1453
|
continue;
|
|
1358
1454
|
}
|
|
1359
1455
|
await tracker.page.waitForTimeout(Math.min(UI_READY_POLL_MS, remaining));
|
|
@@ -1813,13 +1909,20 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
|
|
|
1813
1909
|
const timeout = Math.min(finitePositive(auth.timeoutMs) ?? 15_000, 60_000);
|
|
1814
1910
|
page.setDefaultTimeout(timeout);
|
|
1815
1911
|
page.setDefaultNavigationTimeout(timeout);
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1912
|
+
const loginRequestScope = readinessTracker.beginActionScope();
|
|
1913
|
+
try {
|
|
1914
|
+
await page.goto(auth.loginUrl, { timeout });
|
|
1915
|
+
await waitForUiReady({
|
|
1916
|
+
scope: page,
|
|
1917
|
+
tracker: readinessTracker,
|
|
1918
|
+
requestScope: loginRequestScope,
|
|
1919
|
+
timeout,
|
|
1920
|
+
failure: () => authError(profileId, "form page did not finish loading"),
|
|
1921
|
+
});
|
|
1922
|
+
} finally {
|
|
1923
|
+
loginRequestScope.stopCapture();
|
|
1924
|
+
loginRequestScope.dispose();
|
|
1925
|
+
}
|
|
1823
1926
|
for (const field of auth.fields) {
|
|
1824
1927
|
if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
|
|
1825
1928
|
throw authError(profileId, "form fields must contain selector/value strings");
|
|
@@ -1827,20 +1930,27 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
|
|
|
1827
1930
|
await page.locator(field.selector).fill(field.value);
|
|
1828
1931
|
await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
|
|
1829
1932
|
}
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1933
|
+
const submitRequestScope = readinessTracker.beginActionScope();
|
|
1934
|
+
try {
|
|
1935
|
+
await page.locator(auth.submitSelector).click({ delay: CLICK_VIDEO_DELAY_MS });
|
|
1936
|
+
if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
|
|
1937
|
+
if (isObject(auth.success) && typeof auth.success.selector === "string") {
|
|
1938
|
+
await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
|
|
1939
|
+
}
|
|
1940
|
+
if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
|
|
1941
|
+
throw authError(profileId, "form success.url or success.selector is required");
|
|
1942
|
+
}
|
|
1943
|
+
await waitForUiReady({
|
|
1944
|
+
scope: page,
|
|
1945
|
+
tracker: readinessTracker,
|
|
1946
|
+
requestScope: submitRequestScope,
|
|
1947
|
+
timeout,
|
|
1948
|
+
failure: () => authError(profileId, "authenticated page did not finish loading"),
|
|
1949
|
+
});
|
|
1950
|
+
} finally {
|
|
1951
|
+
submitRequestScope.stopCapture();
|
|
1952
|
+
submitRequestScope.dispose();
|
|
1837
1953
|
}
|
|
1838
|
-
await waitForUiReady({
|
|
1839
|
-
scope: page,
|
|
1840
|
-
tracker: readinessTracker,
|
|
1841
|
-
timeout,
|
|
1842
|
-
failure: () => authError(profileId, "authenticated page did not finish loading"),
|
|
1843
|
-
});
|
|
1844
1954
|
} catch (error) {
|
|
1845
1955
|
if (error instanceof QaStatusError) throw error;
|
|
1846
1956
|
throw authError(profileId, "form login was rejected or did not reach the configured success condition");
|
|
@@ -1931,7 +2041,10 @@ function cookieAllowed(cookie, allowedOrigins) {
|
|
|
1931
2041
|
if (typeof cookie.url === "string") return isAllowedUrl(cookie.url, allowedOrigins);
|
|
1932
2042
|
if (typeof cookie.domain !== "string") return false;
|
|
1933
2043
|
const domain = cookie.domain.replace(/^\./, "").toLowerCase();
|
|
1934
|
-
return allowedOrigins.some((origin) =>
|
|
2044
|
+
return allowedOrigins.some((origin) => {
|
|
2045
|
+
const host = new URL(origin).hostname.toLowerCase();
|
|
2046
|
+
return host === domain || (domain.includes(".") && host.endsWith(`.${domain}`));
|
|
2047
|
+
});
|
|
1935
2048
|
}
|
|
1936
2049
|
|
|
1937
2050
|
function loadPlaywright(cwd) {
|
|
@@ -1990,13 +2103,18 @@ function findPackageJson(startFile) {
|
|
|
1990
2103
|
}
|
|
1991
2104
|
|
|
1992
2105
|
function parseArgs(values) {
|
|
1993
|
-
const result = {};
|
|
2106
|
+
const result = { allowOrigin: [] };
|
|
1994
2107
|
for (let index = 0; index < values.length; index += 2) {
|
|
1995
2108
|
const flag = values[index];
|
|
1996
2109
|
const value = values[index + 1];
|
|
1997
2110
|
if (!flag?.startsWith("--") || value === undefined) throw new QaStatusError("QA_RUN_FAILED", `invalid argument: ${flag ?? "(missing)"}`, 1);
|
|
1998
2111
|
const key = flag.slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
1999
|
-
if (!["profile", "flow", "baseUrl", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
|
|
2112
|
+
if (!["profile", "flow", "baseUrl", "allowOrigin", "runId", "runnerTimeoutMs"].includes(key)) throw new QaStatusError("QA_RUN_FAILED", `unknown option: ${flag}`, 1);
|
|
2113
|
+
if (key === "allowOrigin") {
|
|
2114
|
+
result.allowOrigin.push(value);
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
if (result[key] !== undefined) throw new QaStatusError("QA_RUN_FAILED", `duplicate option: ${flag}`, 1);
|
|
2000
2118
|
result[key] = value;
|
|
2001
2119
|
}
|
|
2002
2120
|
return result;
|
|
@@ -2251,6 +2369,23 @@ function collectStorageStateSecrets(state) {
|
|
|
2251
2369
|
return expandSecretForms(output);
|
|
2252
2370
|
}
|
|
2253
2371
|
|
|
2372
|
+
async function refreshRuntimeSecrets(context, runtimeSecrets) {
|
|
2373
|
+
if (!context || typeof context.storageState !== "function") return false;
|
|
2374
|
+
return mergeRuntimeSecrets(runtimeSecrets, collectStorageStateSecrets(await context.storageState()));
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function mergeRuntimeSecrets(runtimeSecrets, candidates) {
|
|
2378
|
+
const known = new Set(runtimeSecrets);
|
|
2379
|
+
let added = false;
|
|
2380
|
+
for (const candidate of candidates) {
|
|
2381
|
+
if (!candidate || known.has(candidate)) continue;
|
|
2382
|
+
known.add(candidate);
|
|
2383
|
+
runtimeSecrets.push(candidate);
|
|
2384
|
+
added = true;
|
|
2385
|
+
}
|
|
2386
|
+
return added;
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2254
2389
|
function expandSecretForms(values) {
|
|
2255
2390
|
const forms = new Set();
|
|
2256
2391
|
for (const value of values) {
|
|
@@ -2570,6 +2705,10 @@ function removeVisualEvidence(evidenceDir) {
|
|
|
2570
2705
|
}
|
|
2571
2706
|
}
|
|
2572
2707
|
|
|
2708
|
+
function discardRecordedVideos(videos) {
|
|
2709
|
+
for (const entry of videos) entry.discard = true;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2573
2712
|
function writeJsonPrivate(file, value) {
|
|
2574
2713
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
2575
2714
|
fs.chmodSync(file, 0o600);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ui-extend",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.38",
|
|
4
4
|
"description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -60,6 +60,8 @@
|
|
|
60
60
|
"test": "mise exec node@24.16.0 -- npm run test:inner",
|
|
61
61
|
"test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
|
|
62
62
|
"test:tools-suite": "npm --prefix external/pi-tools-suite test",
|
|
63
|
+
"build:acp": "npm --prefix acp run build",
|
|
64
|
+
"check:acp": "npm --prefix acp run check",
|
|
63
65
|
"test:browser-qa-e2e": "npm --prefix external/pi-tools-suite run test:browser-qa-e2e",
|
|
64
66
|
"sync:pi-tools-suite": "node scripts/sync-pi-tools-suite.mjs",
|
|
65
67
|
"sync:pi-tools-suite:check": "node scripts/sync-pi-tools-suite.mjs --check",
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
83
85
|
"@earendil-works/pi-tui": "0.84.4",
|
|
84
86
|
"@mariozechner/clipboard": "^0.3.9",
|
|
87
|
+
"grok-mermaid": "0.2.2",
|
|
85
88
|
"jsonc-parser": "3.3.1",
|
|
86
89
|
"typebox": "1.1.38",
|
|
87
90
|
"vscode-jsonrpc": "^8.2.1",
|