pi-ui-extend 1.0.19 → 1.0.21

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.
@@ -251,8 +251,11 @@ The built-in `browser-qa` role runs on `antigravity/gemini-3-flash-preview`, wit
251
251
  `openai-codex/gpt-5.4-mini` as its fallback. Its browser workflow is an explicit
252
252
  private skill under `src/async-subagents/private-skills/`, outside normal Pi skill
253
253
  discovery. The role's first-class `isolatedSkills` setting launches the child with
254
- `--no-skills` plus only that skill, so parent and ordinary sub-agent sessions do
255
- not discover it.
254
+ `--no-skills` plus one self-contained private workflow. It bundles the relevant
255
+ scenario-design, locator, waiting, assertion, evidence, and cleanup guidance next
256
+ to its trusted runner, so browser QA does not depend on a separately installed
257
+ skill or CLI. The private workflow remains mandatory when configuration appends
258
+ other isolated skills; parent and ordinary sub-agent sessions do not discover it.
256
259
 
257
260
  Keep named dev/staging auth profiles in project `.pi/qa_auth.jsonc` (there is no
258
261
  `/qa-auth` command). The private runner supports `form`, `cookie`, `localStorage`,
@@ -287,13 +290,18 @@ blocked. Example:
287
290
 
288
291
  Do not place credential values in prompts, QA flows, shell arguments, reports,
289
292
  or evidence. The helper reads JSONC internally, emits only redacted statuses, and
290
- caches generated storage state under `.pi/qa-auth-state`. Evidence is isolated by
291
- run/profile under `.pi/qa-runs`; trace archives have network records and non-image
292
- resources removed, then known configured/runtime credential values are redacted
293
- and verified before the trace is retained. Missing, ambiguous, rejected, or
294
- expired auth returns `QA_PROFILE_REQUIRED` or `QA_AUTH_UPDATE_REQUIRED`, naming
295
- only the profile/file/reason needed for the parent to ask the user for an update
296
- and rerun. See
293
+ caches generated storage state under `.pi/qa-auth-state`. The launcher provides
294
+ each browser QA process with its own
295
+ `.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Declarative flows,
296
+ screenshots, video, sanitized traces, and result manifests stay there, so normal
297
+ session shutdown or `subagents cleanup` removes them with the run directory.
298
+ The runner validates the owning agent metadata and refuses flows outside that
299
+ workspace; reusing an agent id clears stale browser QA files first. Trace archives
300
+ have network records and non-image resources removed, then known
301
+ configured/runtime credential values are redacted and verified before retention.
302
+ Missing, ambiguous, rejected, or expired auth returns `QA_PROFILE_REQUIRED` or
303
+ `QA_AUTH_UPDATE_REQUIRED`, naming only the profile/file/reason needed for the
304
+ parent to ask the user for an update and rerun. See
297
305
  `src/async-subagents/private-skills/browser-qa/references/qa-auth.example.jsonc`
298
306
  for complete profile shapes and `references/qa-flow.example.jsonc` beside it for
299
307
  the declarative, non-executable QA action/assertion format.
@@ -17,6 +17,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
17
17
  Antigravity-backed role unavailable.
18
18
  - A type profile may declare `isolatedSkills`. Spawning that profile adds
19
19
  `--no-skills` followed by one explicit `--skill` per configured path.
20
+ - The `browser-qa` profile always loads one self-contained private workflow.
21
+ Relevant browser-test design guidance is bundled beside its trusted runner;
22
+ no separately discovered skill or browser CLI is required. Configuration may
23
+ append isolated skills but cannot remove the mandatory private workflow.
20
24
  - Other sub-agent profiles and the parent session must not discover the private
21
25
  skill automatically.
22
26
 
@@ -37,9 +41,11 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
37
41
  - The bundled runner reads secrets internally. Credentials must never be copied
38
42
  into prompts, generated QA flows, shell arguments, transcripts, reports,
39
43
  or QA evidence.
40
- - Generated browser state is private cache under `.pi/qa-auth-state`; evidence
41
- is written under `.pi/qa-runs`. Multiple profiles always use separate browser
42
- contexts and evidence directories.
44
+ - Generated browser state is private cache under `.pi/qa-auth-state`. Ephemeral
45
+ flows, evidence, and result manifests are written under the owning agent's
46
+ `.pi/subagents/<run>/<agent-id>/browser-qa/` workspace. Multiple profiles use
47
+ separate browser contexts/evidence directories, and normal sub-agent shutdown
48
+ or cleanup removes the whole workspace with its run.
43
49
  - Missing, ambiguous, rejected, or expired auth returns a machine-readable
44
50
  update-required/profile-required status naming only the profile id, config
45
51
  file, and redacted reason. The parent asks the user to update the file and
@@ -51,6 +57,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
51
57
  trusted runner implements a bounded set of navigation, interaction,
52
58
  assertion, screenshot, and auth-rejection actions and never gives the flow a
53
59
  Playwright context or credential values.
60
+ - The launcher injects `PI_SUBAGENT_AGENT_DIR`, pre-creates a private
61
+ `browser-qa/flows/` workspace, and clears stale browser QA files when an agent
62
+ id is reused. The runner validates the directory's project/type metadata and
63
+ refuses flows outside it; the model cannot select a shared evidence root.
54
64
  - The runner owns browser lifecycle, origin checks, auth application, tracing,
55
65
  screenshots, video finalization, and redacted result output. Before retaining
56
66
  a trace it removes network/non-image resource entries, redacts configured and
@@ -66,9 +76,10 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
66
76
 
67
77
  ## Acceptance criteria
68
78
 
69
- 1. `browser-qa` resolves to the intended model/fallback and private skill, and
70
- its isolated child process can register the configured Antigravity model.
71
- 2. Spawn args contain `--no-skills` and only the explicit private skill for this
79
+ 1. `browser-qa` resolves to the intended model/fallback and its self-contained
80
+ private workflow, and its isolated child process can register the configured
81
+ Antigravity model.
82
+ 2. Spawn args contain `--no-skills` and the mandatory private skill for this
72
83
  profile; ordinary profiles retain existing skill discovery behavior.
73
84
  3. Auth profile listing and all error output are redacted; model-authored input
74
85
  cannot execute code in the credential-bearing process.
@@ -76,6 +87,26 @@ evidence. The role uses `antigravity/gemini-3-flash-preview`, falling back to
76
87
  origins, path/mode hardening, private empty-template creation, explicit
77
88
  credential requests, non-executable flows, and successful redacted evidence
78
89
  creation.
79
- 5. Completed test runs report clickable screenshot, video, and trace links
90
+ 5. Browser QA flows/evidence live only inside the owning sub-agent directory;
91
+ deleting the run removes them while persistent auth config/state remains.
92
+ 6. Completed test runs report clickable screenshot, video, and trace links
80
93
  whenever those artifacts exist.
81
- 6. Suite tests/typecheck, host checks, and suite sync pass.
94
+ 7. Suite tests/typecheck, host checks, and suite sync pass.
95
+
96
+ ## Real-browser regression test
97
+
98
+ The repository includes a local mock-page E2E that launches real Chromium and
99
+ asserts PNG screenshots, WebM video, sanitized trace output, and absolute
100
+ path/`file:` URI metadata:
101
+
102
+ ```bash
103
+ npx playwright install chromium
104
+ npm run test:browser-qa-e2e
105
+ ```
106
+
107
+ Normal suite tests keep this case skipped; the Publish workflow runs it on
108
+ Linux after installing Chromium. The runner writes into a temporary simulated
109
+ sub-agent directory. For manual inspection only, explicit E2E runs copy the
110
+ latest artifacts to `.pi/qa-runs/browser-qa-e2e/latest/` and print clickable
111
+ links; this test-only published copy is not the runtime storage contract. Set
112
+ `BROWSER_QA_KEEP_EVIDENCE=0` to skip that copy.
@@ -22,6 +22,7 @@
22
22
  "smoke:tools": "PI_OFFLINE=1 pi --no-session -p \"ping\"",
23
23
  "smoke": "npm run smoke:explicit && npm run smoke:auto && npm run smoke:tools",
24
24
  "test": "bun test test",
25
+ "test:browser-qa-e2e": "BROWSER_QA_RUNNER_E2E=1 bun test test/async-subagents/browser-qa-runner.e2e.test.ts",
25
26
  "test:async-subagents-e2e": "ASYNC_SUBAGENTS_E2E=1 ASYNC_SUBAGENTS_DEBUG_LOGS=1 ASYNC_SUBAGENTS_MODEL=zai/glm-5-turbo bun test --concurrent --max-concurrency=30 test/async-subagents",
26
27
  "test:async-subagents-selection-e2e": "ASYNC_SUBAGENTS_SELECTION_E2E=1 ASYNC_SUBAGENTS_MODEL=zai/glm-5-turbo bun test --concurrent --max-concurrency=30 test/async-subagents/selection-e2e.test.ts",
27
28
  "test:prompt-evals:tool-selection": "PROMPT_EVAL_E2E=1 bun test --concurrent --max-concurrency=10 test/tool-selection-e2e.test.ts",
@@ -381,7 +381,7 @@ export function resolveAgentTaskConfig(
381
381
  return {
382
382
  profile,
383
383
  extraArgs,
384
- isolatedSkills: arrayOfStrings(profile?.isolatedSkills) ?? [],
384
+ isolatedSkills: resolveIsolatedSkills(selectedType, profile),
385
385
  fallbackModels,
386
386
  retry: resolveRetryConfig(config.retry, profile?.retry),
387
387
  maxResultBytes: profile?.maxResultBytes ?? config.maxResultBytes,
@@ -399,6 +399,12 @@ export function resolveAgentTaskConfig(
399
399
  };
400
400
  }
401
401
 
402
+ function resolveIsolatedSkills(selectedType: string | undefined, profile: SubagentTypeConfig | undefined): string[] {
403
+ const configured = arrayOfStrings(profile?.isolatedSkills) ?? [];
404
+ if (selectedType !== "browser-qa") return configured;
405
+ return [...new Set([getBrowserQaSkillPath(), ...configured])];
406
+ }
407
+
402
408
  export function resolveSubagentRoutingConfig(config: SubagentConfig): ResolvedSubagentRoutingConfig {
403
409
  return { ...DEFAULT_ROUTING_CONFIG, ...(config.routing ?? {}) };
404
410
  }
@@ -24,6 +24,8 @@ export interface SpawnAgentOptions {
24
24
  }
25
25
 
26
26
  export const DEFAULT_AGENT_TIMEOUT_MS = 30 * 60 * 1000;
27
+ const BROWSER_QA_WORKSPACE_DIR = "browser-qa";
28
+ const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
27
29
  const AGENT_TIMEOUT_EXIT_CODE = 124;
28
30
  const AGENT_TIMEOUT_KILL_GRACE_MS = 5_000;
29
31
  const AGENT_SETTLED_TERMINATE_GRACE_MS = 50;
@@ -46,6 +48,7 @@ export function spawnAgent(
46
48
  validateBasename(task.id, "task.id");
47
49
  const agentDir = path.join(runDir, task.id);
48
50
  fs.mkdirSync(agentDir, { recursive: true });
51
+ prepareBrowserQaWorkspace(agentDir, task.subagentType);
49
52
 
50
53
  // Clean previous state when reusing a run directory/agent id.
51
54
  for (const f of [
@@ -136,7 +139,7 @@ export function spawnAgent(
136
139
 
137
140
  const proc = spawn(invocation.command, invocation.args, {
138
141
  cwd,
139
- env: subagentEnvironment(process.env),
142
+ env: subagentEnvironment(process.env, task.subagentType === "browser-qa" ? agentDir : undefined),
140
143
  stdio: ["pipe", "pipe", "pipe"],
141
144
  });
142
145
  proc.stdin.on("error", (error: NodeJS.ErrnoException) => {
@@ -698,8 +701,8 @@ function getEnvModel(): string | undefined {
698
701
  return trimmed ? trimmed : undefined;
699
702
  }
700
703
 
701
- function subagentEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
702
- return {
704
+ function subagentEnvironment(env: NodeJS.ProcessEnv, agentDir?: string): NodeJS.ProcessEnv {
705
+ const result: NodeJS.ProcessEnv = {
703
706
  ...env,
704
707
  PI_MODEL_SUITABLE_TOOLS_PRESERVE_SELECTION: "1",
705
708
  PI_TERMINAL_BELL_DISABLED: "1",
@@ -709,6 +712,21 @@ function subagentEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
709
712
  "question",
710
713
  ]),
711
714
  };
715
+ delete result[SUBAGENT_AGENT_DIR_ENV];
716
+ if (agentDir) result[SUBAGENT_AGENT_DIR_ENV] = fs.realpathSync(agentDir);
717
+ return result;
718
+ }
719
+
720
+ function prepareBrowserQaWorkspace(agentDir: string, subagentType: string | undefined): void {
721
+ const workspace = path.join(agentDir, BROWSER_QA_WORKSPACE_DIR);
722
+ fs.rmSync(workspace, { recursive: true, force: true });
723
+ if (subagentType !== "browser-qa") return;
724
+ const flows = path.join(workspace, "flows");
725
+ fs.mkdirSync(flows, { recursive: true, mode: 0o700 });
726
+ if (process.platform !== "win32") {
727
+ fs.chmodSync(workspace, 0o700);
728
+ fs.chmodSync(flows, 0o700);
729
+ }
712
730
  }
713
731
 
714
732
  function appendEnvList(value: string | undefined, items: readonly string[]): string {
@@ -1,31 +1,71 @@
1
1
  ---
2
2
  name: browser-qa-private
3
- description: Private workflow for deterministic browser bug reproduction and fix verification with redacted project auth and Playwright evidence.
3
+ description: Private self-contained workflow for deterministic browser bug reproduction and fix verification with redacted project auth and Playwright evidence.
4
4
  ---
5
5
 
6
6
  # Browser QA
7
7
 
8
- Use the bundled runner; do not read, print, grep, copy, or edit credential values
9
- from `.pi/qa_auth.jsonc` yourself.
8
+ Use this skill's bundled runner as the only browser interface. It already owns
9
+ Playwright, browser/context lifecycle, tracing, video, screenshots, origin
10
+ isolation, authentication, redaction, and cleanup. Do not invoke another browser
11
+ CLI, create shared/default browser sessions, or generate executable browser code.
12
+
13
+ Never read, print, grep, copy, or edit credential values from
14
+ `.pi/qa_auth.jsonc` yourself.
10
15
 
11
16
  ## Workflow
12
17
 
13
18
  1. Resolve `scripts/browser-qa-runner.mjs` relative to this skill.
14
- 2. Run `node <runner> profiles`. Choose a profile only when the task names its
19
+ 2. Use the launcher-provided `$PI_SUBAGENT_AGENT_DIR/browser-qa/` workspace.
20
+ The launcher creates its private `flows/` directory and the runner rejects
21
+ flows or evidence destinations outside this owning sub-agent directory. Do
22
+ not override `PI_SUBAGENT_AGENT_DIR` or copy evidence to shared project paths.
23
+ 3. Discover the requested target, expected behavior, and the smallest scenario
24
+ that can prove it. If the target cannot be reached or started, report the
25
+ concrete blocker instead of substituting static checks for browser QA.
26
+ 4. Run `node <runner> profiles`. Choose a profile only when the task names its
15
27
  id or safe profile traits make the choice unambiguous. Otherwise stop with
16
28
  `QA_PROFILE_REQUIRED` and list only ids, descriptions, and traits.
17
- 3. Inspect the target code and write a declarative JSONC flow under
18
- `.pi/qa-flows/`. Never put credentials or executable JavaScript in it.
19
- 4. Run:
29
+ 5. Inspect the target code and write a declarative JSONC flow under
30
+ `$PI_SUBAGENT_AGENT_DIR/browser-qa/flows/`. Never put credentials or
31
+ executable JavaScript in it.
32
+ 6. Run:
20
33
  `node <runner> run --profile <id> --base-url <url> --flow <flow.jsonc>`.
21
34
  Profile id, URL, and flow path are non-secret; never pass credentials as
22
35
  arguments or environment variables.
23
- 5. Report deterministic assertions and every artifact returned by the runner.
36
+ 7. Report deterministic assertions and every artifact returned by the runner.
24
37
  For each screenshot, video, or trace, emit a separate clickable Markdown
25
38
  link using its `uri` and also show its absolute `path`. Do this for failed
26
39
  runs too whenever `artifacts` is present; never report only `evidenceDir`.
27
40
  Visual inspection supplements assertions; it does not replace them.
28
41
 
42
+ ## Scenario design
43
+
44
+ - Define the expected postcondition before writing interactions. A successful
45
+ click or navigation is not proof; assert the resulting URL, text, value,
46
+ count, visibility, enabled state, or checked state.
47
+ - Keep the flow minimal and reproducible. Capture setup, the action under test,
48
+ and at least one observable outcome; add a screenshot at the state that best
49
+ explains the result.
50
+ - Prefer stable user-facing locators in this order: `testId`; semantic `role`
51
+ plus accessible `name`; `label`; `placeholder`; visible `text`; CSS only as a
52
+ last resort. Use `exact: true` when similar elements could make a match
53
+ ambiguous.
54
+ - Let locator actions auto-wait. Use `waitFor` for an explicit UI state and use
55
+ `waitForTimeout` only for a short, unavoidable animation/debounce—not as a
56
+ substitute for an assertion. Set flow `timeoutMs` only as high as the target
57
+ legitimately needs.
58
+ - Place `authRejectedIf` immediately after navigation or any transition that
59
+ may reveal expired authentication.
60
+ - Never weaken an assertion merely to make a failing run pass. If the observed
61
+ product behavior differs from the expectation, preserve the failure evidence
62
+ and report the mismatch.
63
+
64
+ Read `references/qa-design.md` when designing a non-trivial flow, diagnosing an
65
+ ambiguous failure, or deciding what evidence proves the result.
66
+
67
+ ## Flow contract
68
+
29
69
  The flow is `{ "steps": [...] }` with at most 100 steps. Supported actions:
30
70
 
31
71
  - navigation: `goto`, `reload`, `waitFor`, `waitForTimeout`
@@ -50,16 +90,21 @@ assertions require exactly one of `equals` or `includes`.
50
90
  "locator": { "role": "heading", "name": "Settings", "exact": true }
51
91
  },
52
92
  { "action": "click", "locator": { "testId": "save-settings" } },
53
- { "action": "assertText", "locator": { "testId": "toast" }, "includes": "Saved" }
93
+ { "action": "assertText", "locator": { "testId": "toast" }, "includes": "Saved" },
94
+ { "action": "screenshot", "name": "settings-saved" }
54
95
  ]
55
96
  }
56
97
  ```
57
98
 
58
- The runner owns Playwright/browser lifecycle, strict network-origin boundaries,
59
- auth application, assertions, screenshots, video, trace sanitization, and
60
- cleanup. Do not start an additional shared/default browser session. For multiple
61
- profiles, invoke the runner separately; each invocation gets an isolated context
62
- and exclusive evidence directory.
99
+ For multiple profiles, invoke the runner separately. Every invocation gets an
100
+ isolated browser context and exclusive evidence directory; the runner closes
101
+ all owned browser resources on success and failure. Flows, screenshots, video,
102
+ sanitized traces, and runner result manifests remain under
103
+ `$PI_SUBAGENT_AGENT_DIR/browser-qa/` so normal sub-agent shutdown or cleanup
104
+ deletes them with the run directory. `.pi/qa_auth.jsonc` and reusable
105
+ `.pi/qa-auth-state/` remain project-local and persistent.
106
+
107
+ ## Credentials and blocked runs
63
108
 
64
109
  If the runner returns `QA_AUTH_UPDATE_REQUIRED`, stop and explicitly report that
65
110
  browser QA requires credentials or an auth-config update. Ask the user to fill
@@ -68,9 +113,13 @@ empty template was created at that path. Relay only the runner's profile, file,
68
113
  reason, action, and template-created state; never read the generated file or
69
114
  attempt to recover by exposing or replaying credentials.
70
115
 
116
+ For any other blocked run, report the runner status and redacted reason. Do not
117
+ claim that browser QA passed based on source inspection, unit tests, or a build.
118
+
71
119
  After any runner invocation that actually performed browser testing, include
72
120
  all non-empty `artifacts.screenshots`, `artifacts.videos`, and
73
121
  `artifacts.traces` groups in the final response. These links are mandatory so
74
122
  the user can open the evidence directly.
75
123
 
76
- See `references/qa-auth.example.jsonc` and `references/qa-flow.example.jsonc`.
124
+ See `references/qa-auth.example.jsonc`, `references/qa-flow.example.jsonc`, and
125
+ `references/qa-design.md`.
@@ -0,0 +1,106 @@
1
+ # Designing deterministic browser QA flows
2
+
3
+ Use this reference with the bundled declarative runner. It intentionally does
4
+ not describe a separate browser CLI or executable Playwright scripts.
5
+
6
+ ## Build the proof before the steps
7
+
8
+ Write down three things first:
9
+
10
+ 1. **Setup:** the page and state needed to expose the behavior.
11
+ 2. **Action:** the smallest user interaction that exercises it.
12
+ 3. **Oracle:** the observable state that proves success or reproduces failure.
13
+
14
+ Good oracles are product-visible and specific: an exact URL, a stable status
15
+ message, a field value, item count, enabled/disabled state, or checked state.
16
+ Avoid treating “the click did not throw” or “the screenshot looks plausible” as
17
+ proof.
18
+
19
+ When verifying a fix, prefer a focused regression flow over a broad tour of the
20
+ application. If multiple independent states matter, assert each one explicitly.
21
+
22
+ ## Choose resilient locators
23
+
24
+ Prefer locators that match how users and accessibility APIs identify controls:
25
+
26
+ 1. `testId` when the product exposes a stable test contract.
27
+ 2. `role` with accessible `name` for buttons, links, headings, dialogs, and
28
+ similar semantic elements.
29
+ 3. `label` for form controls.
30
+ 4. `placeholder` or visible `text` when they are stable product copy.
31
+ 5. `css` only when no semantic contract exists.
32
+
33
+ Use `exact: true` when duplicate or substring matches are possible. Avoid CSS
34
+ that encodes DOM depth, generated classes, styling details, or element order.
35
+ If a locator is ambiguous, inspect nearby source or rendered copy and choose a
36
+ more specific product contract rather than adding arbitrary delays.
37
+
38
+ ## Wait for state, not time
39
+
40
+ Runner interactions inherit Playwright auto-waiting. Usually an action followed
41
+ by an assertion is enough. Use `waitFor` only when the next operation depends on
42
+ a distinct attached/detached/visible/hidden transition.
43
+
44
+ `waitForTimeout` is bounded to five seconds and should be exceptional—for a
45
+ known animation, debounce, or externally scheduled transition with no
46
+ observable intermediate state. Sleeping longer hides races instead of proving
47
+ behavior. If a normal operation legitimately needs more time, adjust the flow's
48
+ `timeoutMs` rather than inserting repeated sleeps.
49
+
50
+ ## Authentication transitions
51
+
52
+ Add `authRejectedIf` directly after initial navigation and after transitions
53
+ that can redirect to login or display an expired-session marker. This converts
54
+ stale credentials into an explicit update request instead of misreporting a
55
+ product regression.
56
+
57
+ Do not encode credentials, tokens, storage values, or login form secrets in the
58
+ flow. The trusted runner applies the selected profile internally and removes
59
+ secret-bearing evidence if it detects disclosure.
60
+
61
+ ## Evidence strategy
62
+
63
+ The runner always attempts a final or failure screenshot, records video, and
64
+ creates a sanitized trace once the browser launches. Add named `screenshot`
65
+ steps only at states that materially help explain the result—for example before
66
+ and after a destructive interaction, or when a transient success message is
67
+ the oracle.
68
+
69
+ Use evidence by purpose:
70
+
71
+ - **Screenshot:** quick review of one meaningful visual state.
72
+ - **Video:** chronological confirmation of the complete user flow.
73
+ - **Trace:** action/DOM timing diagnosis for a failed or flaky interaction.
74
+
75
+ Assertions determine pass/fail; evidence explains it. Preserve and link every
76
+ artifact group returned on both passed and failed runs.
77
+
78
+ ## Diagnose failures without weakening the test
79
+
80
+ Classify the first failing step:
81
+
82
+ - wrong target/setup or service unavailable;
83
+ - authentication rejected or expired;
84
+ - locator no longer matches the product contract;
85
+ - expected state never appeared;
86
+ - actual product behavior contradicts the expectation.
87
+
88
+ Fix the flow only when its setup or locator is wrong. Do not replace a precise
89
+ assertion with a vague one, increase timeouts reflexively, or remove the failing
90
+ step to manufacture a pass. Keep the failure artifacts and state the expected
91
+ versus observed behavior.
92
+
93
+ ## Cleanup and isolation
94
+
95
+ Each runner invocation owns one isolated context and evidence directory and
96
+ closes its browser resources in a `finally` path. Do not create parallel shared
97
+ or default sessions outside the runner. Test multiple auth profiles with
98
+ separate invocations so cookies, storage, traces, and evidence cannot mix.
99
+
100
+ Keep the declarative flow and every generated screenshot, video, trace, and
101
+ result manifest inside `$PI_SUBAGENT_AGENT_DIR/browser-qa/`. The launcher owns
102
+ that path and the runner validates it before opening a browser. Do not override
103
+ the environment path or copy evidence into shared `.pi/qa-runs`/`.pi/qa-flows`
104
+ directories: the agent-local workspace is intentionally removed by the normal
105
+ sub-agent shutdown and cleanup lifecycle. Authentication config and reusable
106
+ auth state are separate persistent inputs and stay under project `.pi/`.
@@ -10,7 +10,9 @@ import { strFromU8, strToU8, unzipSync, zipSync } from "../vendor/fflate.mjs";
10
10
 
11
11
  const CONFIG_RELATIVE = ".pi/qa_auth.jsonc";
12
12
  const STATE_RELATIVE = path.join(".pi", "qa-auth-state");
13
- const RUNS_RELATIVE = path.join(".pi", "qa-runs");
13
+ const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
14
+ const QA_WORKSPACE_RELATIVE = "browser-qa";
15
+ const EVIDENCE_RELATIVE = "evidence";
14
16
  const EXIT_AUTH_UPDATE_REQUIRED = 42;
15
17
  const EXIT_PROFILE_REQUIRED = 43;
16
18
  const PROFILE_ID = /^[A-Za-z0-9._-]+$/;
@@ -80,23 +82,25 @@ async function main() {
80
82
  const profile = selected.profile;
81
83
  const secrets = collectSecrets(profile.auth);
82
84
  try {
83
- await runQa({ cwd, args, profileId, profile });
85
+ const agentDir = resolveBrowserQaAgentDirectory(cwd, process.env[SUBAGENT_AGENT_DIR_ENV]);
86
+ await runQa({ cwd, agentDir, args, profileId, profile });
84
87
  } catch (error) {
85
88
  if (error instanceof QaStatusError) throw error;
86
89
  throw new QaStatusError("QA_RUN_FAILED", redact(safeReason(error), secrets), 1, profileId);
87
90
  }
88
91
  }
89
92
 
90
- async function runQa({ cwd, args, profileId, profile }) {
93
+ async function runQa({ cwd, agentDir, args, profileId, profile }) {
91
94
  if (!args.flow) throw new QaStatusError("QA_RUN_FAILED", "--flow is required", 1, profileId);
92
- const flowPath = resolveExistingPrivateFile(cwd, args.flow, "QA flow", false);
95
+ const workspaceDir = path.join(agentDir, QA_WORKSPACE_RELATIVE);
96
+ const flowPath = resolveExistingPrivateFile(workspaceDir, args.flow, "QA flow", false);
93
97
  const flow = readFlow(flowPath, profileId);
94
98
  const allowedOrigins = normalizeAllowedOrigins(profile.allowedOrigins, profileId);
95
99
  const baseURL = normalizeBaseUrl(args.baseUrl ?? profile.baseUrl ?? allowedOrigins[0], allowedOrigins, profileId);
96
100
  validateAuthConfiguration(cwd, profile.auth, allowedOrigins, profileId);
97
101
  const runId = safeRunId(args.runId ?? `${timestamp()}-${profileId}`);
98
- const evidenceDir = path.join(cwd, RUNS_RELATIVE, runId, profileId);
99
- createExclusivePrivateDirectory(cwd, evidenceDir);
102
+ const evidenceDir = path.join(workspaceDir, EVIDENCE_RELATIVE, runId, profileId);
103
+ createExclusivePrivateDirectory(agentDir, evidenceDir);
100
104
 
101
105
  const playwright = loadPlaywright(cwd);
102
106
  const browser = await playwright.chromium.launch({ headless: true });
@@ -779,6 +783,45 @@ function isAllowedUrl(raw, allowedOrigins) {
779
783
  }
780
784
  }
781
785
 
786
+ function resolveBrowserQaAgentDirectory(cwd, value) {
787
+ if (typeof value !== "string" || value.length === 0) {
788
+ throw new Error(`${SUBAGENT_AGENT_DIR_ENV} is required for browser QA runs`);
789
+ }
790
+ const projectRoot = fs.realpathSync(cwd);
791
+ const subagentRoot = path.join(projectRoot, ".pi", "subagents");
792
+ const resolved = path.resolve(value);
793
+ if (!fs.existsSync(resolved)) throw new Error("browser QA agent directory is missing");
794
+ const real = fs.realpathSync(resolved);
795
+ if (!isInside(subagentRoot, real)) throw new Error("browser QA agent directory must be inside .pi/subagents");
796
+ assertNoSymlinkComponents(projectRoot, real, "browser QA agent directory");
797
+ if (!fs.statSync(real).isDirectory()) throw new Error("browser QA agent directory must be a real directory");
798
+
799
+ const promptFile = path.join(real, "prompt.md");
800
+ const projectFile = path.join(real, "project_cwd");
801
+ const typeFile = path.join(real, "subagent_type");
802
+ for (const [file, label] of [[promptFile, "prompt"], [projectFile, "project metadata"], [typeFile, "type metadata"]]) {
803
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`browser QA ${label} is missing`);
804
+ assertNoSymlinkComponents(real, file, `browser QA ${label}`);
805
+ }
806
+ const recordedProject = fs.readFileSync(projectFile, "utf8").trim();
807
+ if (!recordedProject || fs.realpathSync(recordedProject) !== projectRoot) {
808
+ throw new Error("browser QA agent directory belongs to another project");
809
+ }
810
+ if (fs.readFileSync(typeFile, "utf8").trim() !== "browser-qa") {
811
+ throw new Error("browser QA runner requires a browser-qa sub-agent directory");
812
+ }
813
+
814
+ const workspace = path.join(real, QA_WORKSPACE_RELATIVE);
815
+ if (!fs.existsSync(workspace)) throw new Error("browser QA workspace is missing");
816
+ assertNoSymlinkComponents(real, workspace, "browser QA workspace");
817
+ const workspaceStat = fs.statSync(workspace);
818
+ if (!workspaceStat.isDirectory()) throw new Error("browser QA workspace must be a real directory");
819
+ if (process.platform !== "win32" && (workspaceStat.mode & 0o077) !== 0) {
820
+ throw new Error("browser QA workspace must use private directory permissions (0700)");
821
+ }
822
+ return real;
823
+ }
824
+
782
825
  function resolveExistingPrivateFile(cwd, value, label, requirePrivate = true) {
783
826
  if (typeof value !== "string" || value.length === 0) throw new Error(`${label} path is missing`);
784
827
  const root = fs.realpathSync(cwd);
@@ -794,9 +837,13 @@ function resolveExistingPrivateFile(cwd, value, label, requirePrivate = true) {
794
837
  return real;
795
838
  }
796
839
 
797
- function assertInside(root, target, label) {
840
+ function isInside(root, target) {
798
841
  const relative = path.relative(root, target);
799
- if (!relative || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
842
+ return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
843
+ }
844
+
845
+ function assertInside(root, target, label) {
846
+ if (isInside(root, target)) return;
800
847
  throw new Error(`${label} must be project-local`);
801
848
  }
802
849
 
@@ -1,6 +1,8 @@
1
1
  import type { TaskDetails } from "../tool/types.js";
2
2
  import { EMPTY_STATE, type TaskState } from "./state.js";
3
3
 
4
+ export const TODO_STATE_ENTRY_TYPE = "pi-tools-suite:todo-state";
5
+
4
6
  /**
5
7
  * Discriminator for `details` envelopes that match the persisted `TaskDetails`
6
8
  * shape. Defensive — branch entries from older or corrupt sessions are
@@ -12,10 +14,29 @@ export function isTaskDetails(value: unknown): value is TaskDetails {
12
14
  return Array.isArray(v.tasks) && typeof v.nextId === "number";
13
15
  }
14
16
 
17
+ function taskDetailsFromEntry(entry: unknown): TaskDetails | undefined {
18
+ const candidate = entry as {
19
+ type?: string;
20
+ customType?: string;
21
+ data?: unknown;
22
+ message?: { role?: string; toolName?: string; details?: unknown };
23
+ };
24
+
25
+ if (candidate.type === "custom" && candidate.customType === TODO_STATE_ENTRY_TYPE) {
26
+ return isTaskDetails(candidate.data) ? candidate.data : undefined;
27
+ }
28
+
29
+ const message = candidate.message;
30
+ if (candidate.type !== "message" || message?.role !== "toolResult" || message.toolName !== "todo") return undefined;
31
+ return isTaskDetails(message.details) ? message.details : undefined;
32
+ }
33
+
15
34
  /**
16
- * Walk the current branch in chronological order; the LAST `toolResult` whose
17
- * `toolName === "todo"` and whose `details` shape matches `TaskDetails` wins
18
- * (last-write-wins). When no matching entry exists, returns `EMPTY_STATE`.
35
+ * Walk the current branch in chronological order; the LAST valid snapshot
36
+ * from either a `todo` tool result or a todo custom state entry wins
37
+ * (last-write-wins). Custom entries persist slash-command mutations, which do
38
+ * not otherwise produce tool results. When no matching entry exists, returns
39
+ * `EMPTY_STATE`.
19
40
  *
20
41
  * Pure of module state — `index.ts` writes the returned snapshot into the
21
42
  * store after this returns. The function explicitly does NOT touch the store
@@ -24,14 +45,11 @@ export function isTaskDetails(value: unknown): value is TaskDetails {
24
45
  export function replayFromBranch(ctx: { sessionManager: { getBranch(): Iterable<unknown> } }): TaskState {
25
46
  let result: TaskState = { tasks: [...EMPTY_STATE.tasks], nextId: EMPTY_STATE.nextId };
26
47
  for (const entry of ctx.sessionManager.getBranch()) {
27
- const e = entry as { type?: string; message?: { role?: string; toolName?: string; details?: unknown } };
28
- if (e.type !== "message") continue;
29
- const msg = e.message;
30
- if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
31
- if (!isTaskDetails(msg.details)) continue;
48
+ const details = taskDetailsFromEntry(entry);
49
+ if (!details) continue;
32
50
  result = {
33
- tasks: msg.details.tasks.map((t) => ({ ...t })),
34
- nextId: msg.details.nextId,
51
+ tasks: details.tasks.map((t) => ({ ...t })),
52
+ nextId: details.nextId,
35
53
  };
36
54
  }
37
55
  return result;
@@ -23,7 +23,7 @@ import {
23
23
  syncPersistedPlan,
24
24
  } from "./state/persistence.js";
25
25
  import { AUTO_CLEAR_COMPLETED_MESSAGE, autoClearCompletedTodos } from "./state/auto-clear.js";
26
- import { replayFromBranch } from "./state/replay.js";
26
+ import { replayFromBranch, TODO_STATE_ENTRY_TYPE } from "./state/replay.js";
27
27
  import { isTaskBlocked, selectTasksByStatus, selectTodoCounts } from "./state/selectors.js";
28
28
  import { applyTaskMutation } from "./state/state-reducer.js";
29
29
  import { activateStateScope, commitState, getState, replaceState } from "./state/store.js";
@@ -98,6 +98,7 @@ interface TodoToolRegistrationOptions extends TodoToolHooks {
98
98
 
99
99
  type TodoStateEventContext = { sessionManager?: { getSessionFile?: () => unknown; getSessionId?: () => unknown } };
100
100
  type TodoStateEventEmitter = { events?: { emit?: (channel: string, data: unknown) => void } };
101
+ type TodoStateEntryWriter = { appendEntry<T = unknown>(customType: string, data?: T): void };
101
102
 
102
103
  interface TodosCommandOptions {
103
104
  status?: TaskStatus;
@@ -238,6 +239,20 @@ export function publishTodoState(
238
239
  });
239
240
  }
240
241
 
242
+ function appendTodoStateSnapshot(
243
+ pi: TodoStateEntryWriter,
244
+ action: TaskAction,
245
+ params: Record<string, unknown>,
246
+ ): void {
247
+ const state = getState();
248
+ pi.appendEntry(TODO_STATE_ENTRY_TYPE, {
249
+ action,
250
+ params,
251
+ tasks: state.tasks,
252
+ nextId: state.nextId,
253
+ });
254
+ }
255
+
241
256
  function handlePersistCommand(args: unknown, ctx: { cwd?: string; hasUI?: boolean; ui?: { notify?: (message: string, level?: NotifyLevel) => void } }): boolean {
242
257
  const [command, subcommand = "status"] = getCommandTokens(args);
243
258
  if (command !== "persist") return false;
@@ -310,12 +325,13 @@ function handleScopeCommand(
310
325
  }
311
326
 
312
327
  function clearTodos(
313
- pi: TodoStateEventEmitter,
328
+ pi: TodoStateEventEmitter & TodoStateEntryWriter,
314
329
  ctx: { cwd?: string; hasUI?: boolean; ui?: { notify?: (message: string, level?: NotifyLevel) => void } },
315
330
  ): void {
316
331
  const result = applyTaskMutation(getState(), "clear", { action: "clear" });
317
332
  if (result.op.kind !== "clear") return;
318
333
  commitState(result.state);
334
+ appendTodoStateSnapshot(pi, "clear", { action: "clear" });
319
335
  publishTodoState(pi, ctx, "clear", { action: "clear" });
320
336
  const sync = syncPersistedPlan(ctx.cwd, result.state);
321
337
  const persistedText = sync?.completed ? `\nProject todo plan removed: ${sync.path}` : "";
@@ -449,9 +465,12 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
449
465
  handler: async (args, ctx) => {
450
466
  activateTodoStateScope(ctx);
451
467
  if (handlePersistCommand(args, ctx)) return;
452
- if (handleScopeCommand(args, ctx, () => publishTodoState(pi as TodoStateEventEmitter, ctx))) return;
468
+ if (handleScopeCommand(args, ctx, () => {
469
+ appendTodoStateSnapshot(pi, "update", { command: "scope" });
470
+ publishTodoState(pi as TodoStateEventEmitter, ctx);
471
+ })) return;
453
472
  if (getCommandTokens(args)[0] === "clear") {
454
- clearTodos(pi as TodoStateEventEmitter, ctx);
473
+ clearTodos(pi, ctx);
455
474
  return;
456
475
  }
457
476
  if (!ctx.hasUI) {
@@ -529,7 +548,10 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
529
548
  description: "Select todo ids to continue from a persisted plan; pending/in_progress items outside the scope become deferred.",
530
549
  handler: async (args, ctx) => {
531
550
  activateTodoStateScope(ctx);
532
- handleScopeCommand(`scope ${getCommandText(args)}`, ctx, () => publishTodoState(pi as TodoStateEventEmitter, ctx));
551
+ handleScopeCommand(`scope ${getCommandText(args)}`, ctx, () => {
552
+ appendTodoStateSnapshot(pi, "update", { command: "scope" });
553
+ publishTodoState(pi as TodoStateEventEmitter, ctx);
554
+ });
533
555
  },
534
556
  });
535
557
 
@@ -537,7 +559,7 @@ export function registerTodosCommand(pi: ExtensionAPI): void {
537
559
  description: "Clear all todos on the current branch and remove the persisted project plan if enabled.",
538
560
  handler: async (_args, ctx) => {
539
561
  activateTodoStateScope(ctx);
540
- clearTodos(pi as TodoStateEventEmitter, ctx);
562
+ clearTodos(pi, ctx);
541
563
  },
542
564
  });
543
565
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
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": {
@@ -58,6 +58,7 @@
58
58
  "test": "mise exec node@24.16.0 -- npm run test:inner",
59
59
  "test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
60
60
  "test:tools-suite": "npm --prefix external/pi-tools-suite test",
61
+ "test:browser-qa-e2e": "npm --prefix external/pi-tools-suite run test:browser-qa-e2e",
61
62
  "sync:pi-tools-suite": "node scripts/sync-pi-tools-suite.mjs",
62
63
  "sync:pi-tools-suite:check": "node scripts/sync-pi-tools-suite.mjs --check",
63
64
  "sync:sdk-pin": "node scripts/sync-sdk-pin.mjs",
@@ -86,6 +87,7 @@
86
87
  },
87
88
  "devDependencies": {
88
89
  "@types/node": "24.12.4",
90
+ "playwright": "1.62.1",
89
91
  "tsx": "4.22.1",
90
92
  "typescript": "5.9.3"
91
93
  },