pi-ui-extend 1.0.22 → 1.0.23
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/dist/app/rendering/conversation-entry-renderer.js +12 -3
- package/dist/app/rendering/render-controller.js +7 -0
- package/dist/app/screen/mouse-controller.d.ts +1 -0
- package/dist/app/screen/mouse-controller.js +2 -1
- package/dist/markdown-format.d.ts +7 -0
- package/dist/markdown-format.js +59 -2
- package/external/pi-tools-suite/README.md +14 -7
- package/external/pi-tools-suite/docs/browser-qa-subagent.md +15 -10
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +23 -11
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +40 -17
- package/package.json +1 -1
|
@@ -7,11 +7,14 @@ import { renderConversationShellEntry } from "./conversation-shell-renderer.js";
|
|
|
7
7
|
import { renderConversationToolEntry, renderThinkingEntry } from "./conversation-tool-renderer.js";
|
|
8
8
|
export function renderConversationEntry(entry, width, options) {
|
|
9
9
|
const { left: userContentLeft, contentWidth: userContentWidth } = horizontalPaddingLayout(width);
|
|
10
|
-
const userLine = (text, entryId, syntaxHighlight, segments) => ({
|
|
10
|
+
const userLine = (text, entryId, syntaxHighlight, segments, links) => ({
|
|
11
11
|
text: padHorizontalText(text, width),
|
|
12
12
|
colorOverride: options.colors.userForeground,
|
|
13
13
|
backgroundOverride: options.colors.userMessageBackground,
|
|
14
14
|
...(segments && segments.length > 0 ? { segments: segments.map((segment) => ({ ...segment, start: segment.start + userContentLeft, end: segment.end + userContentLeft })) } : {}),
|
|
15
|
+
...(links && links.length > 0 ? {
|
|
16
|
+
links: links.map((link) => ({ ...link, start: link.start + userContentLeft, end: link.end + userContentLeft })),
|
|
17
|
+
} : {}),
|
|
15
18
|
...(syntaxHighlight === undefined ? {} : { syntaxHighlight }),
|
|
16
19
|
...(entryId === undefined ? {} : { target: { kind: "user-message", id: entryId } }),
|
|
17
20
|
});
|
|
@@ -23,7 +26,10 @@ export function renderConversationEntry(entry, width, options) {
|
|
|
23
26
|
});
|
|
24
27
|
const userMessageLines = (userEntry) => {
|
|
25
28
|
const lines = renderMarkdownTextLines(userEntry.text, userContentWidth, userContentLeft).map((line) => ({
|
|
26
|
-
...userLine(line.text, userEntry.id, line.syntaxHighlight,
|
|
29
|
+
...userLine(line.text, userEntry.id, line.syntaxHighlight, [
|
|
30
|
+
...(line.segments ?? []),
|
|
31
|
+
...(line.links?.map((link) => ({ ...link, foreground: options.colors.info, underline: true })) ?? []),
|
|
32
|
+
], line.links),
|
|
27
33
|
...(line.copyText === undefined ? {} : { copyText: line.copyText }),
|
|
28
34
|
...(line.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
|
|
29
35
|
}));
|
|
@@ -86,7 +92,9 @@ function renderAssistantLines(text, width, options) {
|
|
|
86
92
|
? { start: contentLeft, end: contentLeft + line.text.length, foreground: options.colors.assistantForeground, bold: true }
|
|
87
93
|
: undefined;
|
|
88
94
|
const existingSegments = line.segments?.map((segment) => ({ ...segment, start: segment.start + contentLeft, end: segment.end + contentLeft })) ?? [];
|
|
89
|
-
const
|
|
95
|
+
const links = line.links?.map((link) => ({ ...link, start: link.start + contentLeft, end: link.end + contentLeft })) ?? [];
|
|
96
|
+
const linkSegments = links.map((link) => ({ start: link.start, end: link.end, foreground: options.colors.info, underline: true }));
|
|
97
|
+
const allSegments = headingSegment ? [headingSegment, ...existingSegments, ...linkSegments] : [...existingSegments, ...linkSegments];
|
|
90
98
|
lines.push({
|
|
91
99
|
text: padHorizontalText(line.text, width),
|
|
92
100
|
...(line.copyText === undefined ? {} : { copyText: line.copyText }),
|
|
@@ -94,6 +102,7 @@ function renderAssistantLines(text, width, options) {
|
|
|
94
102
|
colorOverride: options.colors.assistantForeground,
|
|
95
103
|
backgroundOverride: options.colors.assistantMessageBackground,
|
|
96
104
|
...(allSegments.length > 0 ? { segments: allSegments } : {}),
|
|
105
|
+
...(links.length > 0 ? { links } : {}),
|
|
97
106
|
...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
|
|
98
107
|
});
|
|
99
108
|
}
|
|
@@ -57,6 +57,7 @@ export class AppRenderController {
|
|
|
57
57
|
this.deps.mouseController.syncConversationSelectionForRender(scrollMetrics.start, bodyHeight, topReservedRows, conversationColumns);
|
|
58
58
|
this.deps.mouseController.renderedTargets.clear();
|
|
59
59
|
this.deps.mouseController.renderedRowTexts.clear();
|
|
60
|
+
this.deps.mouseController.renderedLinks.clear();
|
|
60
61
|
this.deps.mouseController.renderedRowBackgrounds.clear();
|
|
61
62
|
this.deps.mouseController.renderedImageTargets.clear();
|
|
62
63
|
this.deps.mouseController.statusModelTarget = undefined;
|
|
@@ -109,6 +110,8 @@ export class AppRenderController {
|
|
|
109
110
|
this.deps.mouseController.renderedTargets.set(row, rendered.target);
|
|
110
111
|
if (rendered?.imageTargets?.length)
|
|
111
112
|
this.deps.mouseController.renderedImageTargets.set(row, rendered.imageTargets);
|
|
113
|
+
if (rendered?.links?.length)
|
|
114
|
+
this.deps.mouseController.renderedLinks.set(row, rendered.links);
|
|
112
115
|
this.deps.mouseController.renderedRowTexts.set(row, rendered?.text ?? "");
|
|
113
116
|
setRenderedBackground(row, rendered?.backgroundOverride);
|
|
114
117
|
appendFrameOutput(row, this.renderFrameRow(row, this.deps.screenStyler.styleBaseLine(row, rendered, conversationColumns)));
|
|
@@ -128,6 +131,8 @@ export class AppRenderController {
|
|
|
128
131
|
this.deps.mouseController.renderedTargets.set(row, rendered.line.target);
|
|
129
132
|
if (rendered.line?.imageTargets?.length)
|
|
130
133
|
this.deps.mouseController.renderedImageTargets.set(row, rendered.line.imageTargets);
|
|
134
|
+
if (rendered.line?.links?.length)
|
|
135
|
+
this.deps.mouseController.renderedLinks.set(row, rendered.line.links);
|
|
131
136
|
this.deps.mouseController.renderedRowTexts.set(row, rendered.text);
|
|
132
137
|
setRenderedBackground(row, rendered.line?.backgroundOverride);
|
|
133
138
|
appendFrameOutput(row, this.renderFrameRow(row, rendered.output(row)));
|
|
@@ -182,6 +187,8 @@ export class AppRenderController {
|
|
|
182
187
|
this.deps.mouseController.renderedTargets.set(row, rendered.line.target);
|
|
183
188
|
if (rendered.line?.imageTargets?.length)
|
|
184
189
|
this.deps.mouseController.renderedImageTargets.set(row, rendered.line.imageTargets);
|
|
190
|
+
if (rendered.line?.links?.length)
|
|
191
|
+
this.deps.mouseController.renderedLinks.set(row, rendered.line.links);
|
|
185
192
|
this.deps.mouseController.renderedRowTexts.set(row, rendered.text);
|
|
186
193
|
setRenderedBackground(row, rendered.line?.backgroundOverride);
|
|
187
194
|
appendFrameOutput(row, this.renderFrameRow(row, rendered.output(row)));
|
|
@@ -97,6 +97,7 @@ export declare class AppMouseController {
|
|
|
97
97
|
id: string;
|
|
98
98
|
} | import("../types.js").ToastLineTarget | undefined>;
|
|
99
99
|
readonly renderedRowTexts: Map<number, string>;
|
|
100
|
+
readonly renderedLinks: Map<number, readonly RenderedLink[]>;
|
|
100
101
|
readonly renderedRowBackgrounds: Map<number, string>;
|
|
101
102
|
readonly renderedImageTargets: Map<number, readonly ImageClickTarget[]>;
|
|
102
103
|
statusModelTarget: StatusModelTarget | undefined;
|
|
@@ -18,6 +18,7 @@ export class AppMouseController {
|
|
|
18
18
|
commandController;
|
|
19
19
|
renderedTargets = new Map();
|
|
20
20
|
renderedRowTexts = new Map();
|
|
21
|
+
renderedLinks = new Map();
|
|
21
22
|
renderedRowBackgrounds = new Map();
|
|
22
23
|
renderedImageTargets = new Map();
|
|
23
24
|
statusModelTarget;
|
|
@@ -308,7 +309,7 @@ export class AppMouseController {
|
|
|
308
309
|
const text = this.renderedRowTexts.get(event.y);
|
|
309
310
|
if (!text)
|
|
310
311
|
return undefined;
|
|
311
|
-
for (const link of detectFileLinks(text, this.host.cwd())) {
|
|
312
|
+
for (const link of [...(this.renderedLinks.get(event.y) ?? []), ...detectFileLinks(text, this.host.cwd())]) {
|
|
312
313
|
const startColumn = stringDisplayWidth(text.slice(0, link.start)) + 1;
|
|
313
314
|
const endColumn = startColumn + stringDisplayWidth(text.slice(link.start, link.end));
|
|
314
315
|
if (event.x >= startColumn && event.x < endColumn)
|
|
@@ -8,10 +8,16 @@ export type RenderedMarkdownLine = {
|
|
|
8
8
|
end: number;
|
|
9
9
|
bold: true;
|
|
10
10
|
}[];
|
|
11
|
+
links?: readonly RenderedMarkdownLink[];
|
|
11
12
|
heading?: boolean;
|
|
12
13
|
sourceStart?: number;
|
|
13
14
|
sourceEnd?: number;
|
|
14
15
|
};
|
|
16
|
+
export type RenderedMarkdownLink = {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
url: string;
|
|
20
|
+
};
|
|
15
21
|
export type RenderedMarkdownTextLine = {
|
|
16
22
|
text: string;
|
|
17
23
|
copyText?: string;
|
|
@@ -21,6 +27,7 @@ export type RenderedMarkdownTextLine = {
|
|
|
21
27
|
end: number;
|
|
22
28
|
bold: true;
|
|
23
29
|
}[] | undefined;
|
|
30
|
+
links?: readonly RenderedMarkdownLink[] | undefined;
|
|
24
31
|
syntaxHighlight?: SyntaxLineHighlight | undefined;
|
|
25
32
|
heading?: boolean;
|
|
26
33
|
};
|
package/dist/markdown-format.js
CHANGED
|
@@ -44,6 +44,7 @@ export function formatMarkdownTables(text, maxWidth) {
|
|
|
44
44
|
export function renderMarkdownLine(text, start = 0) {
|
|
45
45
|
const safeStart = Math.max(0, Math.min(text.length, start));
|
|
46
46
|
const segments = [];
|
|
47
|
+
const links = [];
|
|
47
48
|
let rendered = text.slice(0, safeStart);
|
|
48
49
|
let index = safeStart;
|
|
49
50
|
let inCode = false;
|
|
@@ -66,10 +67,20 @@ export function renderMarkdownLine(text, start = 0) {
|
|
|
66
67
|
continue;
|
|
67
68
|
}
|
|
68
69
|
}
|
|
70
|
+
if (!inCode && char === "[" && text[index - 1] !== "!" && !isEscaped(text, index)) {
|
|
71
|
+
const link = markdownLinkAt(text, index);
|
|
72
|
+
if (link) {
|
|
73
|
+
const linkStart = rendered.length;
|
|
74
|
+
rendered += link.label;
|
|
75
|
+
links.push({ start: linkStart, end: rendered.length, url: link.url });
|
|
76
|
+
index = link.end;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
69
80
|
rendered += char;
|
|
70
81
|
index += 1;
|
|
71
82
|
}
|
|
72
|
-
return { text: rendered, segments, ...(isHeading ? { heading: true } : {}) };
|
|
83
|
+
return { text: rendered, segments, ...(links.length > 0 ? { links } : {}), ...(isHeading ? { heading: true } : {}) };
|
|
73
84
|
}
|
|
74
85
|
export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
|
|
75
86
|
const lines = [];
|
|
@@ -84,7 +95,7 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
|
|
|
84
95
|
const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
|
|
85
96
|
const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
|
|
86
97
|
const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
|
|
87
|
-
const logicalLine = markdownLine ?? { text: rawLine, segments: [] };
|
|
98
|
+
const logicalLine = markdownLine ?? { text: rawLine, segments: [], links: [] };
|
|
88
99
|
for (const wrapped of wrapRenderedMarkdownLine(logicalLine, width, options)) {
|
|
89
100
|
const wrappedSyntaxHighlight = syntaxHighlight && wrapped.sourceStart !== undefined && wrapped.sourceEnd !== undefined
|
|
90
101
|
? {
|
|
@@ -103,6 +114,7 @@ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
|
|
|
103
114
|
...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
|
|
104
115
|
...(wrapped.continuesOnNextLine ? { continuesOnNextLine: true } : {}),
|
|
105
116
|
...(wrapped.segments.length > 0 ? { segments: wrapped.segments } : {}),
|
|
117
|
+
...(wrapped.links && wrapped.links.length > 0 ? { links: wrapped.links } : {}),
|
|
106
118
|
...(wrappedSyntaxHighlight ? { syntaxHighlight: wrappedSyntaxHighlight } : {}),
|
|
107
119
|
...(isHeadingLine ? { heading: true } : {}),
|
|
108
120
|
});
|
|
@@ -141,11 +153,15 @@ function wrapRenderedMarkdownLine(line, width, options) {
|
|
|
141
153
|
if (stringDisplayWidth(line.text) <= safeWidth)
|
|
142
154
|
return [line];
|
|
143
155
|
const ranges = wrapDisplayLineByWordsWithRanges(line.text, safeWidth, options);
|
|
156
|
+
const links = line.links ?? [];
|
|
144
157
|
return ranges.map((range, index) => ({
|
|
145
158
|
text: range.text,
|
|
146
159
|
copyText: line.text.slice(range.start, ranges[index + 1]?.start ?? range.end),
|
|
147
160
|
...(index < ranges.length - 1 ? { continuesOnNextLine: true } : {}),
|
|
148
161
|
segments: line.segments.flatMap((segment) => shiftSegmentToRange(segment, range.start, range.end)),
|
|
162
|
+
...(links.length > 0
|
|
163
|
+
? { links: links.flatMap((link) => shiftLinkToRange(link, range.start, range.end)) }
|
|
164
|
+
: {}),
|
|
149
165
|
sourceStart: range.start,
|
|
150
166
|
sourceEnd: range.end,
|
|
151
167
|
}));
|
|
@@ -320,6 +336,47 @@ function shiftSegmentToRange(segment, rangeStart, rangeEnd) {
|
|
|
320
336
|
return [];
|
|
321
337
|
return [{ ...segment, start: start - rangeStart, end: end - rangeStart }];
|
|
322
338
|
}
|
|
339
|
+
function shiftLinkToRange(link, rangeStart, rangeEnd) {
|
|
340
|
+
const start = Math.max(link.start, rangeStart);
|
|
341
|
+
const end = Math.min(link.end, rangeEnd);
|
|
342
|
+
if (end <= start)
|
|
343
|
+
return [];
|
|
344
|
+
return [{ ...link, start: start - rangeStart, end: end - rangeStart }];
|
|
345
|
+
}
|
|
346
|
+
function markdownLinkAt(text, start) {
|
|
347
|
+
const labelEnd = findUnescapedCharacter(text, "]", start + 1);
|
|
348
|
+
if (labelEnd <= start + 1 || text[labelEnd + 1] !== "(")
|
|
349
|
+
return undefined;
|
|
350
|
+
const destinationStart = labelEnd + 2;
|
|
351
|
+
let nestedParentheses = 0;
|
|
352
|
+
for (let index = destinationStart; index < text.length; index += 1) {
|
|
353
|
+
if (isEscaped(text, index))
|
|
354
|
+
continue;
|
|
355
|
+
const char = text[index] ?? "";
|
|
356
|
+
if (char === "(") {
|
|
357
|
+
nestedParentheses += 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (char !== ")")
|
|
361
|
+
continue;
|
|
362
|
+
if (nestedParentheses > 0) {
|
|
363
|
+
nestedParentheses -= 1;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const url = text.slice(destinationStart, index);
|
|
367
|
+
if (!/^(?:file|https?):\/\/\S+$/u.test(url))
|
|
368
|
+
return undefined;
|
|
369
|
+
return { label: text.slice(start + 1, labelEnd), url, end: index + 1 };
|
|
370
|
+
}
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
function findUnescapedCharacter(text, character, start) {
|
|
374
|
+
for (let index = start; index < text.length; index += 1) {
|
|
375
|
+
if (text[index] === character && !isEscaped(text, index))
|
|
376
|
+
return index;
|
|
377
|
+
}
|
|
378
|
+
return -1;
|
|
379
|
+
}
|
|
323
380
|
function parseMarkdownTableBlock(lines, start) {
|
|
324
381
|
const header = parseMarkdownTableRow(lines[start] ?? "");
|
|
325
382
|
if (!header)
|
|
@@ -257,13 +257,18 @@ to its trusted runner, so browser QA does not depend on a separately installed
|
|
|
257
257
|
skill or CLI. The private workflow remains mandatory when configuration appends
|
|
258
258
|
other isolated skills; parent and ordinary sub-agent sessions do not discover it.
|
|
259
259
|
|
|
260
|
-
|
|
261
|
-
|
|
260
|
+
Public browser QA does not require an auth profile or `.pi/qa_auth.jsonc`: run it
|
|
261
|
+
with an explicit base URL, whose exact origin becomes the fail-closed allowlist.
|
|
262
|
+
The runner neither creates nor requests a credential file for that path.
|
|
263
|
+
|
|
264
|
+
For targets that actually require login, keep named dev/staging auth profiles in
|
|
265
|
+
project `.pi/qa_auth.jsonc` (there is no `/qa-auth` command). The private runner
|
|
266
|
+
supports `form`, `cookie`, `localStorage`,
|
|
262
267
|
`sessionStorage`, `bearer`, and existing Playwright `storageState` auth. Every
|
|
263
|
-
profile must declare exact `allowedOrigins`; select the profile id explicitly
|
|
264
|
-
|
|
265
|
-
also form the fail-closed HTTP(S)/WebSocket allowlist and service
|
|
266
|
-
blocked. Example:
|
|
268
|
+
profile must declare exact `allowedOrigins`; select the profile id explicitly only
|
|
269
|
+
for authenticated QA. On POSIX, keep the config at mode `0600`. During a run
|
|
270
|
+
those origins also form the fail-closed HTTP(S)/WebSocket allowlist and service
|
|
271
|
+
workers are blocked. Example:
|
|
267
272
|
|
|
268
273
|
```jsonc
|
|
269
274
|
{
|
|
@@ -302,7 +307,9 @@ The runner validates the owning agent metadata and refuses flows outside that
|
|
|
302
307
|
workspace; reusing an agent id clears stale browser QA files first. Trace archives
|
|
303
308
|
have network records and non-image resources removed, then known
|
|
304
309
|
configured/runtime credential values are redacted and verified before retention.
|
|
305
|
-
|
|
310
|
+
Listing profiles when the auth file is absent returns an empty list without
|
|
311
|
+
creating a template. Only an explicit authenticated request may create the
|
|
312
|
+
private template. Missing, rejected, or expired selected auth returns
|
|
306
313
|
`QA_AUTH_UPDATE_REQUIRED`, naming only the profile/file/reason needed for the
|
|
307
314
|
parent to ask the user for an update and rerun. See
|
|
308
315
|
`src/async-subagents/private-skills/browser-qa/references/qa-auth.example.jsonc`
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Provide a cheap, fast `browser-qa` async-subagent that reproduces browser bugs
|
|
6
6
|
and proves fixes with deterministic assertions plus screenshot, video, and trace
|
|
7
|
-
evidence. The role uses `
|
|
8
|
-
`
|
|
7
|
+
evidence. The role uses `openai-codex/gpt-5.4-mini`, falling back to
|
|
8
|
+
`antigravity/gemini-3-flash-preview` and then `zai/glm-5.3`.
|
|
9
9
|
|
|
10
10
|
## Private skill isolation
|
|
11
11
|
|
|
@@ -26,11 +26,16 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
|
|
|
26
26
|
|
|
27
27
|
## Authentication contract
|
|
28
28
|
|
|
29
|
+
- Public browser QA requires no auth profile and does not create or require
|
|
30
|
+
`.pi/qa_auth.jsonc`. Its explicit base URL supplies the one exact allowed
|
|
31
|
+
origin, and the runner still blocks every other HTTP(S)/WebSocket origin.
|
|
29
32
|
- Auth profiles live in project-local `.pi/qa_auth.jsonc` and are selected by
|
|
30
33
|
explicit id. The file must be a real project-local file with mode `0600` on
|
|
31
34
|
POSIX. Profile listings expose only `id`, description, and traits.
|
|
32
|
-
-
|
|
33
|
-
|
|
35
|
+
- Listing profiles when the file is absent returns an empty list without side
|
|
36
|
+
effects. When authenticated QA explicitly requests credentials and that file
|
|
37
|
+
is absent, the runner creates a private empty template and returns
|
|
38
|
+
`provide_credentials`.
|
|
34
39
|
The sub-agent must explicitly ask the user to fill the reported file and
|
|
35
40
|
rerun QA; it must not read or edit the credential values itself.
|
|
36
41
|
- Every profile requires one or more exact `allowedOrigins`. Secret-bearing auth
|
|
@@ -46,8 +51,8 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
|
|
|
46
51
|
`.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Multiple profiles use
|
|
47
52
|
separate browser contexts/evidence directories, and normal sub-agent shutdown
|
|
48
53
|
or cleanup removes the whole workspace with its run.
|
|
49
|
-
- Missing,
|
|
50
|
-
update-required
|
|
54
|
+
- Missing, rejected, or expired explicitly selected auth returns a
|
|
55
|
+
machine-readable update-required status naming only the profile id, config
|
|
51
56
|
file, and redacted reason. The parent asks the user to update the file and
|
|
52
57
|
reruns; there is no `/qa-auth` command.
|
|
53
58
|
|
|
@@ -83,10 +88,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
|
|
|
83
88
|
profile; ordinary profiles retain existing skill discovery behavior.
|
|
84
89
|
3. Auth profile listing and all error output are redacted; model-authored input
|
|
85
90
|
cannot execute code in the credential-bearing process.
|
|
86
|
-
4. Runner tests cover
|
|
87
|
-
origins, path/mode hardening, private
|
|
88
|
-
|
|
89
|
-
creation.
|
|
91
|
+
4. Runner tests cover public execution without an auth file, explicit profile
|
|
92
|
+
selection, all auth modes, fail-closed origins, path/mode hardening, private
|
|
93
|
+
empty-template creation only on an explicit auth request, non-executable
|
|
94
|
+
flows, and successful redacted evidence creation.
|
|
90
95
|
5. Browser QA flows/evidence live only inside the owning sub-agent directory;
|
|
91
96
|
deleting the run removes them while persistent auth config/state remains.
|
|
92
97
|
6. Completed test runs report clickable screenshot, video, and trace links
|
|
@@ -23,14 +23,19 @@ Never read, print, grep, copy, or edit credential values from
|
|
|
23
23
|
3. Discover the requested target, expected behavior, and the smallest scenario
|
|
24
24
|
that can prove it. If the target cannot be reached or started, report the
|
|
25
25
|
concrete blocker instead of substituting static checks for browser QA.
|
|
26
|
-
4.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
4. If the requested behavior requires authentication, run
|
|
27
|
+
`node <runner> profiles`. A missing auth config is valid and returns an empty
|
|
28
|
+
list without creating `.pi/qa_auth.jsonc`. Otherwise skip profile discovery
|
|
29
|
+
and use public mode. Choose an auth profile only when the task names its id,
|
|
30
|
+
safe profile traits make the choice unambiguous, or a public run proves that
|
|
31
|
+
the requested page requires login.
|
|
29
32
|
5. Inspect the target code and write a declarative JSONC flow under
|
|
30
33
|
`$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or
|
|
31
34
|
executable JavaScript in it.
|
|
32
|
-
6. Run
|
|
33
|
-
`node <runner> run --
|
|
35
|
+
6. Run public QA with
|
|
36
|
+
`node <runner> run --base-url <url> --flow <flow.jsonc>`. The URL's exact
|
|
37
|
+
origin becomes the fail-closed allowlist. Only for authenticated QA, add
|
|
38
|
+
`--profile <id>`; the selected profile then owns the URL and allowlist.
|
|
34
39
|
Profile id, URL, and flow path are non-secret; never pass credentials as
|
|
35
40
|
arguments or environment variables.
|
|
36
41
|
7. Report deterministic assertions and every artifact returned by the runner.
|
|
@@ -108,12 +113,19 @@ after login succeeds so credentials are not captured in the trace.
|
|
|
108
113
|
|
|
109
114
|
## Credentials and blocked runs
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
Do not request credentials merely because `.pi/qa_auth.jsonc` is absent. If the
|
|
117
|
+
task explicitly requires authenticated behavior, or a public run reaches the
|
|
118
|
+
flow's `authRejectedIf` check, and `profiles` returned no usable profile, run
|
|
119
|
+
`node <runner> profiles --require-auth`. Only this explicit authenticated path
|
|
120
|
+
may create the private empty template.
|
|
121
|
+
|
|
122
|
+
If that command or an authenticated run returns `QA_AUTH_UPDATE_REQUIRED`, stop
|
|
123
|
+
and explicitly report that authenticated browser QA requires credentials or an
|
|
124
|
+
auth-config update. Ask the user to fill the reported file and rerun QA. If
|
|
125
|
+
`templateCreated` is true, say that a private empty template was created at that
|
|
126
|
+
path. Relay only the runner's profile, file, reason, action, and template-created
|
|
127
|
+
state; never read the generated file or attempt to recover by exposing or
|
|
128
|
+
replaying credentials.
|
|
117
129
|
|
|
118
130
|
For any other blocked run, report the runner status and redacted reason. Do not
|
|
119
131
|
claim that browser QA passed based on source inspection, unit tests, or a build.
|
|
@@ -13,7 +13,6 @@ const QA_WORKSPACE_RELATIVE = "browser-qa";
|
|
|
13
13
|
const EVIDENCE_RELATIVE = "evidence";
|
|
14
14
|
const FORM_VIDEO_STEP_DELAY_MS = 250;
|
|
15
15
|
const EXIT_AUTH_UPDATE_REQUIRED = 42;
|
|
16
|
-
const EXIT_PROFILE_REQUIRED = 43;
|
|
17
16
|
const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
|
|
18
17
|
const AUTH_TEMPLATE = `{
|
|
19
18
|
// Authenticated browser QA requires at least one project-local profile.
|
|
@@ -58,7 +57,7 @@ main().catch((error) => {
|
|
|
58
57
|
writeStatus({
|
|
59
58
|
status: statusError.status,
|
|
60
59
|
profile: statusError.profileId,
|
|
61
|
-
file: CONFIG_RELATIVE,
|
|
60
|
+
...(statusError.status === "QA_AUTH_UPDATE_REQUIRED" ? { file: CONFIG_RELATIVE } : {}),
|
|
62
61
|
reason: statusError.reason,
|
|
63
62
|
...statusError.details,
|
|
64
63
|
});
|
|
@@ -68,15 +67,21 @@ main().catch((error) => {
|
|
|
68
67
|
async function main() {
|
|
69
68
|
const [command = "profiles", ...rawArgs] = process.argv.slice(2);
|
|
70
69
|
const cwd = fs.realpathSync(process.cwd());
|
|
71
|
-
const config = readAuthConfig(cwd);
|
|
72
70
|
if (command === "profiles") {
|
|
73
|
-
|
|
71
|
+
const requireAuth = rawArgs.length === 1 && rawArgs[0] === "--require-auth";
|
|
72
|
+
if (rawArgs.length > 0 && !requireAuth) {
|
|
73
|
+
throw new QaStatusError("QA_RUN_FAILED", `invalid profiles argument: ${rawArgs[0]}`, 1);
|
|
74
|
+
}
|
|
75
|
+
const config = readAuthConfig(cwd, requireAuth);
|
|
76
|
+
writeStatus({ status: "QA_PROFILES", authConfigPresent: config.present, profiles: safeProfiles(config.profiles) });
|
|
74
77
|
return;
|
|
75
78
|
}
|
|
76
79
|
if (command !== "run") throw new QaStatusError("QA_RUN_FAILED", `unknown command: ${command}`, 1);
|
|
77
80
|
|
|
78
81
|
const args = parseArgs(rawArgs);
|
|
79
|
-
const selected =
|
|
82
|
+
const selected = args.profile
|
|
83
|
+
? selectProfile(readAuthConfig(cwd, true).profiles, args.profile)
|
|
84
|
+
: createPublicProfile(args.baseUrl);
|
|
80
85
|
const profileId = selected.id;
|
|
81
86
|
const profile = selected.profile;
|
|
82
87
|
const secrets = collectSecrets(profile.auth);
|
|
@@ -192,9 +197,10 @@ async function runQa({ cwd, agentDir, args, profileId, profile }) {
|
|
|
192
197
|
writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
function readAuthConfig(cwd) {
|
|
200
|
+
function readAuthConfig(cwd, required = false) {
|
|
196
201
|
const candidate = path.join(cwd, CONFIG_RELATIVE);
|
|
197
202
|
if (!fs.existsSync(candidate)) {
|
|
203
|
+
if (!required) return { present: false, profiles: {} };
|
|
198
204
|
let templateCreated;
|
|
199
205
|
try {
|
|
200
206
|
templateCreated = createAuthTemplate(cwd);
|
|
@@ -222,7 +228,10 @@ function readAuthConfig(cwd) {
|
|
|
222
228
|
if (errors.length > 0) {
|
|
223
229
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", `auth config is invalid JSONC (${printParseErrorCode(errors[0].error)})`, EXIT_AUTH_UPDATE_REQUIRED);
|
|
224
230
|
}
|
|
225
|
-
if (!isObject(value) || !isObject(value.profiles)
|
|
231
|
+
if (!isObject(value) || !isObject(value.profiles)) {
|
|
232
|
+
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth config must define a profiles object", EXIT_AUTH_UPDATE_REQUIRED);
|
|
233
|
+
}
|
|
234
|
+
if (required && Object.keys(value.profiles).length === 0) {
|
|
226
235
|
throw new QaStatusError(
|
|
227
236
|
"QA_AUTH_UPDATE_REQUIRED",
|
|
228
237
|
"credentials are required; auth config must define a non-empty profiles object",
|
|
@@ -234,7 +243,7 @@ function readAuthConfig(cwd) {
|
|
|
234
243
|
if (Object.keys(value.profiles).some((id) => !isSafeName(id))) {
|
|
235
244
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "auth profile ids may contain only letters, digits, dot, underscore, or dash", EXIT_AUTH_UPDATE_REQUIRED);
|
|
236
245
|
}
|
|
237
|
-
return value;
|
|
246
|
+
return { ...value, present: true };
|
|
238
247
|
}
|
|
239
248
|
|
|
240
249
|
function safeProfiles(profiles) {
|
|
@@ -248,15 +257,6 @@ function safeProfiles(profiles) {
|
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
function selectProfile(profiles, requestedId) {
|
|
251
|
-
if (!requestedId) {
|
|
252
|
-
throw new QaStatusError(
|
|
253
|
-
"QA_PROFILE_REQUIRED",
|
|
254
|
-
"select one auth profile by id",
|
|
255
|
-
EXIT_PROFILE_REQUIRED,
|
|
256
|
-
undefined,
|
|
257
|
-
{ profiles: safeProfiles(profiles) },
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
260
|
if (!isSafeName(requestedId) || !isObject(profiles[requestedId])) {
|
|
261
261
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "requested auth profile is missing or invalid", EXIT_AUTH_UPDATE_REQUIRED, requestedId);
|
|
262
262
|
}
|
|
@@ -267,6 +267,26 @@ function selectProfile(profiles, requestedId) {
|
|
|
267
267
|
return { id: requestedId, profile };
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
function createPublicProfile(rawBaseUrl) {
|
|
271
|
+
if (typeof rawBaseUrl !== "string") {
|
|
272
|
+
throw new QaStatusError("QA_RUN_FAILED", "--base-url is required when running without --profile", 1, "public");
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
const url = new URL(rawBaseUrl);
|
|
276
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password) throw new Error();
|
|
277
|
+
return {
|
|
278
|
+
id: "public",
|
|
279
|
+
profile: {
|
|
280
|
+
baseUrl: url.href,
|
|
281
|
+
allowedOrigins: [url.origin],
|
|
282
|
+
auth: { type: "none" },
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
} catch {
|
|
286
|
+
throw new QaStatusError("QA_RUN_FAILED", "public --base-url must be an http(s) URL without embedded credentials", 1, "public");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
270
290
|
function normalizeAllowedOrigins(value, profileId) {
|
|
271
291
|
if (!Array.isArray(value) || value.length === 0) {
|
|
272
292
|
throw new QaStatusError("QA_AUTH_UPDATE_REQUIRED", "profile must define allowedOrigins", EXIT_AUTH_UPDATE_REQUIRED, profileId);
|
|
@@ -296,6 +316,8 @@ function normalizeBaseUrl(raw, allowedOrigins, profileId) {
|
|
|
296
316
|
|
|
297
317
|
function validateAuthConfiguration(cwd, auth, allowedOrigins, profileId) {
|
|
298
318
|
switch (auth.type) {
|
|
319
|
+
case "none":
|
|
320
|
+
break;
|
|
299
321
|
case "cookie":
|
|
300
322
|
if (!Array.isArray(auth.cookies) || auth.cookies.length === 0) throw authError(profileId, "cookie auth requires cookies");
|
|
301
323
|
for (const cookie of auth.cookies) {
|
|
@@ -467,6 +489,7 @@ function isStringArray(value) {
|
|
|
467
489
|
|
|
468
490
|
async function contextOptionsForAuth({ cwd, profileId, profile, allowedOrigins }) {
|
|
469
491
|
const auth = profile.auth;
|
|
492
|
+
if (auth.type === "none") return {};
|
|
470
493
|
if (auth.type === "storageState") {
|
|
471
494
|
if (typeof auth.path !== "string") throw authError(profileId, "storageState path is missing");
|
|
472
495
|
const statePath = resolveExistingPrivateFile(cwd, auth.path, "storageState");
|
package/package.json
CHANGED