pi-ui-extend 1.0.41 → 1.0.45
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 +50 -13
- package/dist/app/app.d.ts +13 -0
- package/dist/app/app.js +158 -8
- package/dist/app/cli/install.js +2 -2
- package/dist/app/commands/command-controller.d.ts +4 -0
- package/dist/app/commands/command-controller.js +11 -0
- package/dist/app/commands/command-git-actions.d.ts +25 -0
- package/dist/app/commands/command-git-actions.js +381 -0
- package/dist/app/commands/command-host.d.ts +4 -0
- package/dist/app/commands/command-host.js +5 -2
- package/dist/app/commands/command-model-actions.d.ts +1 -0
- package/dist/app/commands/command-model-actions.js +57 -35
- package/dist/app/commands/command-navigation-actions.d.ts +1 -0
- package/dist/app/commands/command-navigation-actions.js +16 -3
- package/dist/app/commands/command-registry.d.ts +2 -0
- package/dist/app/commands/command-registry.js +15 -1
- package/dist/app/commands/command-session-actions.js +9 -3
- package/dist/app/commands/reload-context-inventory.d.ts +11 -0
- package/dist/app/commands/reload-context-inventory.js +70 -0
- package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
- package/dist/app/extensions/extension-actions-controller.js +6 -0
- package/dist/app/extensions/subagent-catalog-state.d.ts +9 -0
- package/dist/app/extensions/subagent-catalog-state.js +23 -0
- package/dist/app/input/autocomplete-controller.js +61 -34
- package/dist/app/input/input-action-controller.d.ts +2 -0
- package/dist/app/input/input-action-controller.js +8 -1
- package/dist/app/input/input-controller.d.ts +2 -1
- package/dist/app/input/input-controller.js +7 -2
- package/dist/app/input/prompt-enhancer-controller.js +53 -35
- package/dist/app/input/voice-controller.d.ts +41 -45
- package/dist/app/input/voice-controller.js +351 -419
- package/dist/app/model/model-usage-controller.js +17 -7
- package/dist/app/model/model-usage-status.d.ts +4 -1
- package/dist/app/model/model-usage-status.js +270 -46
- package/dist/app/popup/menu-items-controller.d.ts +13 -3
- package/dist/app/popup/menu-items-controller.js +37 -21
- package/dist/app/popup/popup-action-controller.d.ts +12 -2
- package/dist/app/popup/popup-action-controller.js +77 -24
- package/dist/app/popup/popup-menu-controller.d.ts +36 -14
- package/dist/app/popup/popup-menu-controller.js +239 -69
- package/dist/app/rendering/dcp-stats.d.ts +9 -4
- package/dist/app/rendering/dcp-stats.js +40 -425
- package/dist/app/rendering/editor-panels.js +10 -4
- package/dist/app/rendering/popup-menu-renderer.d.ts +3 -5
- package/dist/app/rendering/popup-menu-renderer.js +42 -37
- package/dist/app/rendering/render-controller.js +23 -2
- package/dist/app/rendering/status-line-renderer.d.ts +5 -0
- package/dist/app/rendering/status-line-renderer.js +41 -5
- package/dist/app/rendering/tab-line-renderer.js +26 -20
- package/dist/app/runtime.d.ts +12 -1
- package/dist/app/runtime.js +120 -12
- package/dist/app/screen/mouse-controller.d.ts +2 -0
- package/dist/app/screen/mouse-controller.js +17 -7
- package/dist/app/screen/status-controller.d.ts +4 -0
- package/dist/app/screen/status-controller.js +5 -0
- package/dist/app/session/lazy-session-manager.js +34 -0
- package/dist/app/session/session-event-controller.d.ts +1 -0
- package/dist/app/session/session-event-controller.js +10 -1
- package/dist/app/session/session-history.d.ts +1 -0
- package/dist/app/session/session-history.js +12 -1
- package/dist/app/session/session-lifecycle-controller.d.ts +7 -1
- package/dist/app/session/session-lifecycle-controller.js +16 -1
- package/dist/app/session/tabs-controller.d.ts +26 -1
- package/dist/app/session/tabs-controller.js +454 -147
- package/dist/app/subagents/subagents-files.js +60 -1
- package/dist/app/subagents/subagents-model.d.ts +1 -0
- package/dist/app/subagents/subagents-model.js +18 -2
- package/dist/app/subagents/subagents-widget-controller.d.ts +1 -0
- package/dist/app/subagents/subagents-widget-controller.js +6 -0
- package/dist/app/types.d.ts +16 -1
- package/dist/app/workspace/workspace-actions-controller.js +10 -2
- package/dist/app/workspace/workspace-undo.d.ts +1 -0
- package/dist/app/workspace/workspace-undo.js +1 -0
- package/dist/bundled-extensions/question/index.js +9 -1
- package/dist/bundled-extensions/question/remote.d.ts +4 -0
- package/dist/bundled-extensions/question/remote.js +33 -0
- package/dist/bundled-extensions/telegram-connector/bot.d.ts +43 -0
- package/dist/bundled-extensions/telegram-connector/bot.js +166 -0
- package/dist/bundled-extensions/telegram-connector/config.d.ts +8 -0
- package/dist/bundled-extensions/telegram-connector/config.js +87 -0
- package/dist/bundled-extensions/telegram-connector/coordinator.d.ts +66 -0
- package/dist/bundled-extensions/telegram-connector/coordinator.js +413 -0
- package/dist/bundled-extensions/telegram-connector/index.d.ts +3 -0
- package/dist/bundled-extensions/telegram-connector/index.js +195 -0
- package/dist/bundled-extensions/terminal-bell/index.d.ts +0 -8
- package/dist/bundled-extensions/terminal-bell/index.js +0 -76
- package/dist/bundled-extensions/workspace-undo/index.d.ts +26 -0
- package/dist/bundled-extensions/workspace-undo/index.js +191 -0
- package/dist/config.d.ts +21 -2
- package/dist/config.js +159 -32
- package/dist/default-pix-config.js +25 -5
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/pi-tools-suite-schema.d.ts +88 -62
- package/dist/schemas/pi-tools-suite-schema.js +54 -83
- package/dist/schemas/pix-schema.d.ts +19 -2
- package/dist/schemas/pix-schema.js +47 -5
- package/dist/schemas/tasks-schema.d.ts +18 -0
- package/dist/schemas/tasks-schema.js +41 -0
- package/docs/concurrency.md +9 -1
- package/docs/desktop-mvp.md +22 -6
- package/docs/desktop-task-manager.md +148 -84
- package/docs/release.md +30 -6
- package/external/pi-tools-suite/README.md +197 -84
- package/external/pi-tools-suite/docs/evals.md +1 -1
- package/external/pi-tools-suite/docs/session-recovery.md +47 -14
- package/external/pi-tools-suite/docs/subagent-model-pools.md +48 -30
- package/external/pi-tools-suite/docs/ui-qa-subagent.md +440 -0
- package/external/pi-tools-suite/package.json +6 -1
- package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +2 -1
- package/external/pi-tools-suite/src/antigravity-auth/constants.ts +9 -3
- package/external/pi-tools-suite/src/antigravity-auth/headers.ts +41 -3
- package/external/pi-tools-suite/src/antigravity-auth/models.ts +94 -21
- package/external/pi-tools-suite/src/antigravity-auth/oauth.ts +5 -17
- package/external/pi-tools-suite/src/antigravity-auth/payload.ts +72 -7
- package/external/pi-tools-suite/src/antigravity-auth/stream.ts +13 -1
- package/external/pi-tools-suite/src/async-subagents/agents/frontier-review.md +23 -0
- package/external/pi-tools-suite/src/async-subagents/agents/implement.md +1 -1
- package/external/pi-tools-suite/src/async-subagents/agents/presets.jsonc +16 -0
- package/external/pi-tools-suite/src/async-subagents/agents/research.md +5 -3
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/browser.mjs +346 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/desktop.mjs +1038 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/backends/tui.mjs +759 -0
- package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/scripts/browser-qa-runner.mjs +32 -23
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/chrome-devtools/chrome-devtools-provider.mjs +895 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/linux/linux-atspi.py +494 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/macos/macos-accessibility.swift +1085 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/bridge-client.mjs +50 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/native-terminal/native-terminal-host.mjs +501 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/drivers/windows/windows-uia.ps1 +454 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-auth.md +114 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-chrome-devtools.md +91 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser-playwright.md +145 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/browser.md +82 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-linux.md +26 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-macos.md +29 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop-windows.md +23 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/desktop.md +54 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-native-terminal.md +59 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui-pty.md +37 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/guides/tui.md +60 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa/scripts/ui-qa-runner.mjs +535 -0
- package/external/pi-tools-suite/src/async-subagents/agents/ui-qa.md +75 -0
- package/external/pi-tools-suite/src/async-subagents/commands.ts +15 -71
- package/external/pi-tools-suite/src/async-subagents/core/activity.ts +33 -0
- package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +5 -4
- package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +8 -5
- package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +16 -2
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +160 -327
- package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +2 -1
- package/external/pi-tools-suite/src/async-subagents/core/registry.ts +158 -33
- package/external/pi-tools-suite/src/async-subagents/core/routing.ts +21 -5
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +40 -26
- package/external/pi-tools-suite/src/async-subagents/core/state.ts +40 -0
- package/external/pi-tools-suite/src/async-subagents/core/types.ts +7 -0
- package/external/pi-tools-suite/src/async-subagents/core/ultrawork-auto.ts +51 -45
- package/external/pi-tools-suite/src/async-subagents/index.ts +69 -5
- package/external/pi-tools-suite/src/async-subagents/lib.ts +16 -9
- package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +8 -5
- package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +5 -4
- package/external/pi-tools-suite/src/async-subagents/types.ts +1 -0
- package/external/pi-tools-suite/src/coding-discipline/index.ts +90 -59
- package/external/pi-tools-suite/src/config.ts +55 -1
- package/external/pi-tools-suite/src/context-gateway/accounting-log.ts +282 -0
- package/external/pi-tools-suite/src/context-gateway/config.ts +77 -3
- package/external/pi-tools-suite/src/context-gateway/efficiency.ts +414 -0
- package/external/pi-tools-suite/src/context-gateway/enforcement.ts +222 -0
- package/external/pi-tools-suite/src/context-gateway/index.ts +206 -19
- package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +7 -7
- package/external/pi-tools-suite/src/context-gateway/telemetry.ts +101 -28
- package/external/pi-tools-suite/src/context-gateway/types.ts +16 -3
- package/external/pi-tools-suite/src/context-inventory.ts +99 -0
- package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +39 -4
- package/external/pi-tools-suite/src/dcp/auto-compress.ts +128 -49
- package/external/pi-tools-suite/src/dcp/commands.ts +32 -156
- package/external/pi-tools-suite/src/dcp/compress-tool.ts +177 -46
- package/external/pi-tools-suite/src/dcp/compression-blocks.ts +6 -58
- package/external/pi-tools-suite/src/dcp/compression-preview.ts +9 -0
- package/external/pi-tools-suite/src/dcp/compression-progress.ts +22 -0
- package/external/pi-tools-suite/src/dcp/config.ts +114 -17
- package/external/pi-tools-suite/src/dcp/conversation-index.ts +36 -9
- package/external/pi-tools-suite/src/dcp/diagnostics.ts +41 -0
- package/external/pi-tools-suite/src/dcp/fresh-tool-results.ts +38 -0
- package/external/pi-tools-suite/src/dcp/index.ts +338 -66
- package/external/pi-tools-suite/src/dcp/journal.ts +126 -4
- package/external/pi-tools-suite/src/dcp/progress-controller.ts +2 -1
- package/external/pi-tools-suite/src/dcp/prompts.ts +83 -192
- package/external/pi-tools-suite/src/dcp/protected-continuity.ts +175 -0
- package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +93 -3
- package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +6 -10
- package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +36 -33
- package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -3
- package/external/pi-tools-suite/src/dcp/pruner.ts +1 -0
- package/external/pi-tools-suite/src/dcp/routine-pressure.ts +86 -0
- package/external/pi-tools-suite/src/dcp/state.ts +9 -0
- package/external/pi-tools-suite/src/dcp/statistics.d.ts +8 -0
- package/external/pi-tools-suite/src/dcp/statistics.js +156 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +78 -22
- package/external/pi-tools-suite/src/index.ts +7 -1
- package/external/pi-tools-suite/src/lib/project.ts +36 -1
- package/external/pi-tools-suite/src/model-tools/index.ts +10 -7
- package/external/pi-tools-suite/src/repo-discovery/index.ts +304 -4
- package/external/pi-tools-suite/src/resource-registry/index.ts +2551 -0
- package/external/pi-tools-suite/src/session-recovery/index.ts +17 -0
- package/external/pi-tools-suite/src/shell-command-policy.ts +219 -0
- package/external/pi-tools-suite/src/todo/index.ts +21 -0
- package/external/pi-tools-suite/src/todo/todo.ts +19 -1
- package/external/pi-tools-suite/src/tool-descriptions.ts +47 -19
- package/package.json +10 -9
- package/schemas/pi-tools-suite.json +466 -287
- package/schemas/pix.json +129 -11
- package/schemas/tasks.json +131 -0
- package/skills/simplify/SKILL.md +33 -5
- package/docs/desktop-markdown-media.md +0 -77
- package/external/pi-tools-suite/docs/browser-qa-subagent.md +0 -177
- package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +0 -102
- package/external/pi-tools-suite/src/async-subagents/agents/browser-qa.md +0 -598
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +0 -54
- package/external/pi-tools-suite/src/skill-installer/index.ts +0 -333
- package/skills/playwright-cli/SKILL.md +0 -420
- package/skills/playwright-cli/references/element-attributes.md +0 -23
- package/skills/playwright-cli/references/playwright-tests.md +0 -50
- package/skills/playwright-cli/references/request-mocking.md +0 -87
- package/skills/playwright-cli/references/running-code.md +0 -241
- package/skills/playwright-cli/references/session-management.md +0 -273
- package/skills/playwright-cli/references/spec-driven-testing.md +0 -311
- package/skills/playwright-cli/references/storage-state.md +0 -290
- package/skills/playwright-cli/references/test-generation.md +0 -142
- package/skills/playwright-cli/references/tracing.md +0 -154
- package/skills/playwright-cli/references/video-recording.md +0 -147
- package/skills/spec-lite/SKILL.md +0 -140
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-auth.example.jsonc +0 -0
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/examples/qa-flow.example.jsonc +0 -0
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.LICENSE +0 -0
- /package/external/pi-tools-suite/src/async-subagents/agents/{browser-qa → ui-qa/browser}/vendor/fflate.mjs +0 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import pty from "@lydell/node-pty";
|
|
4
|
+
import xterm from "@xterm/headless";
|
|
5
|
+
import { NATIVE_TERMINAL_PRESENTATION, probeNativeTerminalBackend, startNativeTerminalDisplay } from "../drivers/native-terminal/native-terminal-host.mjs";
|
|
6
|
+
|
|
7
|
+
const { Terminal } = xterm;
|
|
8
|
+
const MAX_TRANSCRIPT_BYTES = 1024 * 1024;
|
|
9
|
+
const RECORDING_FILE = "terminal-recording.cast";
|
|
10
|
+
const MAX_ARG_LENGTH = 4096;
|
|
11
|
+
const CLEANUP_GRACE_MS = 1_000;
|
|
12
|
+
const PTY_PRESENTATION = "pty";
|
|
13
|
+
const SAFE_ENV = new Set(["CI", "NO_COLOR", "FORCE_COLOR", "LANG", "LC_ALL", "TERM", "TZ"]);
|
|
14
|
+
const BLOCKED_EXECUTABLES = new Set([
|
|
15
|
+
"sh", "bash", "zsh", "fish", "dash", "ksh", "cmd", "cmd.exe", "powershell", "powershell.exe",
|
|
16
|
+
"pwsh", "pwsh.exe", "osascript", "wscript", "wscript.exe", "cscript", "cscript.exe",
|
|
17
|
+
]);
|
|
18
|
+
const INLINE_CODE_FLAGS = new Set(["-c", "-e", "--eval", "-p", "--print", "-command", "--command", "/c"]);
|
|
19
|
+
const STEP_FIELDS = {
|
|
20
|
+
waitForStable: new Set(["action", "settleMs", "timeoutMs"]),
|
|
21
|
+
waitForText: new Set(["action", "text", "settleMs", "timeoutMs"]),
|
|
22
|
+
sendText: new Set(["action", "text", "timeoutMs"]),
|
|
23
|
+
sendKeys: new Set(["action", "keys", "timeoutMs"]),
|
|
24
|
+
resize: new Set(["action", "cols", "rows", "settleMs", "timeoutMs"]),
|
|
25
|
+
assertText: new Set(["action", "text", "timeoutMs"]),
|
|
26
|
+
assertNotText: new Set(["action", "text", "timeoutMs"]),
|
|
27
|
+
assertCursor: new Set(["action", "row", "column", "timeoutMs"]),
|
|
28
|
+
assertProcessRunning: new Set(["action", "timeoutMs"]),
|
|
29
|
+
assertProcessExited: new Set(["action", "exitCode", "timeoutMs"]),
|
|
30
|
+
capture: new Set(["action", "name", "timeoutMs"]),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const KEY_SEQUENCES = {
|
|
34
|
+
enter: "\r",
|
|
35
|
+
tab: "\t",
|
|
36
|
+
escape: "\u001b",
|
|
37
|
+
backspace: "\u007f",
|
|
38
|
+
delete: "\u001b[3~",
|
|
39
|
+
space: " ",
|
|
40
|
+
up: "\u001b[A",
|
|
41
|
+
down: "\u001b[B",
|
|
42
|
+
right: "\u001b[C",
|
|
43
|
+
left: "\u001b[D",
|
|
44
|
+
home: "\u001b[H",
|
|
45
|
+
end: "\u001b[F",
|
|
46
|
+
pageup: "\u001b[5~",
|
|
47
|
+
pagedown: "\u001b[6~",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const NATIVE_TERMINAL_ACTIONS = new Set([
|
|
51
|
+
"waitForStable", "waitForText", "sendText", "sendKeys", "assertText", "assertNotText", "assertCursor",
|
|
52
|
+
"assertProcessRunning", "assertProcessExited", "capture",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
export async function probeTuiBackend(context = {}) {
|
|
56
|
+
const presentation = resolveTuiPresentation(context.flow);
|
|
57
|
+
if (presentation === NATIVE_TERMINAL_PRESENTATION) {
|
|
58
|
+
return { ...(await probeNativeTerminalBackend(context)), guideTopic: "native-terminal" };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
available: true,
|
|
62
|
+
guideTopic: "pty",
|
|
63
|
+
platformDriver: process.platform === "win32" ? "conpty+xterm" : "forkpty+xterm",
|
|
64
|
+
supportedCapabilities: [
|
|
65
|
+
"launch", "ptyInput", "terminalResize", "screenState", "ansiVt", "alternateScreen",
|
|
66
|
+
"stableFrame", "visibleTextAssertions", "cursorAssertions", "terminalCapture", "terminalRecording", "ownedCleanup",
|
|
67
|
+
],
|
|
68
|
+
missingCapabilities: ["terminalScreenshot"],
|
|
69
|
+
reason: "the bundled PTY and headless terminal emulator are available",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runTuiBackend(context) {
|
|
74
|
+
const config = validateFlow(context.flow, context.projectRoot);
|
|
75
|
+
const nativePresentation = config.presentation === NATIVE_TERMINAL_PRESENTATION;
|
|
76
|
+
const assertions = [];
|
|
77
|
+
const observations = [];
|
|
78
|
+
const artifacts = emptyArtifacts();
|
|
79
|
+
if (nativePresentation) {
|
|
80
|
+
const probe = await probeNativeTerminalBackend(context);
|
|
81
|
+
if (!probe.available) {
|
|
82
|
+
return { status: "BLOCKED", reason: probe.reason, remediation: probe.remediation, assertions, observations, artifacts };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let nativeDisplay;
|
|
86
|
+
let recording;
|
|
87
|
+
let terminal;
|
|
88
|
+
let processHandle;
|
|
89
|
+
let failure;
|
|
90
|
+
let nativeDisplayFailure;
|
|
91
|
+
let transcript = Buffer.alloc(0);
|
|
92
|
+
let transcriptTruncated = false;
|
|
93
|
+
let writeChain = Promise.resolve();
|
|
94
|
+
let exited = false;
|
|
95
|
+
let exitInfo;
|
|
96
|
+
let lastDataAt = Date.now();
|
|
97
|
+
let lastFrameChangeAt = Date.now();
|
|
98
|
+
let lastFingerprint = "";
|
|
99
|
+
let resolveExit;
|
|
100
|
+
const exitPromise = new Promise((resolve) => { resolveExit = resolve; });
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (nativePresentation) nativeDisplay = await startNativeTerminalDisplay(context, observations, artifacts);
|
|
104
|
+
const viewport = nativeDisplay?.viewport ?? config.viewport;
|
|
105
|
+
// The asciicast always mirrors the PTY stream for diagnosis. In native-
|
|
106
|
+
// terminal mode it remains secondary semantic evidence; real-window
|
|
107
|
+
// screenshots/video are the pixel source of truth.
|
|
108
|
+
recording = createTerminalRecording(context.evidenceDir, viewport);
|
|
109
|
+
terminal = new Terminal({
|
|
110
|
+
cols: viewport.cols,
|
|
111
|
+
rows: viewport.rows,
|
|
112
|
+
allowProposedApi: true,
|
|
113
|
+
convertEol: false,
|
|
114
|
+
scrollback: 1_000,
|
|
115
|
+
termName: "xterm-256color",
|
|
116
|
+
});
|
|
117
|
+
lastFingerprint = screenFingerprint(snapshotScreen(terminal));
|
|
118
|
+
processHandle = pty.spawn(config.command.argv[0], config.command.argv.slice(1), {
|
|
119
|
+
name: "xterm-256color",
|
|
120
|
+
cols: viewport.cols,
|
|
121
|
+
rows: viewport.rows,
|
|
122
|
+
cwd: config.command.cwd,
|
|
123
|
+
env: launchEnvironment(config.command.env),
|
|
124
|
+
});
|
|
125
|
+
if (!Number.isInteger(processHandle.pid) || processHandle.pid <= 0) throw new Error("PTY launch did not return an owned process id");
|
|
126
|
+
observations.push({ action: "launch", pid: processHandle.pid, argv0: path.basename(config.command.argv[0]), presentation: config.presentation });
|
|
127
|
+
context.progress("tui_launched", { pid: processHandle.pid, cols: viewport.cols, rows: viewport.rows, presentation: config.presentation });
|
|
128
|
+
|
|
129
|
+
if (nativeDisplay) {
|
|
130
|
+
nativeDisplay.setInputHandler((data) => {
|
|
131
|
+
if (!exited) processHandle.write(Buffer.from(data).toString("utf8"));
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
terminal.onData((data) => {
|
|
135
|
+
if (!exited) processHandle.write(data);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
processHandle.onData((data) => {
|
|
139
|
+
lastDataAt = Date.now();
|
|
140
|
+
const bytes = Buffer.from(data, "utf8");
|
|
141
|
+
recording.recordOutput(data);
|
|
142
|
+
if (nativeDisplay && !nativeDisplayFailure) {
|
|
143
|
+
try { nativeDisplay.write(data); } catch (error) { nativeDisplayFailure = error; }
|
|
144
|
+
}
|
|
145
|
+
if (transcript.length < MAX_TRANSCRIPT_BYTES) {
|
|
146
|
+
const kept = bytes.subarray(0, MAX_TRANSCRIPT_BYTES - transcript.length);
|
|
147
|
+
transcript = Buffer.concat([transcript, kept]);
|
|
148
|
+
if (kept.length < bytes.length) transcriptTruncated = true;
|
|
149
|
+
} else transcriptTruncated = true;
|
|
150
|
+
writeChain = writeChain.then(() => terminalWrite(terminal, data)).then(() => {
|
|
151
|
+
const current = screenFingerprint(snapshotScreen(terminal));
|
|
152
|
+
if (current !== lastFingerprint) {
|
|
153
|
+
lastFingerprint = current;
|
|
154
|
+
lastFrameChangeAt = Date.now();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
processHandle.onExit((info) => {
|
|
159
|
+
exited = true;
|
|
160
|
+
exitInfo = info;
|
|
161
|
+
resolveExit(info);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
for (let index = 0; index < config.steps.length; index += 1) {
|
|
165
|
+
if (nativeDisplayFailure) throw new Error(`native terminal mirror failed: ${safeReason(nativeDisplayFailure)}`);
|
|
166
|
+
if (nativeDisplay && !nativeDisplay.connected()) throw new Error("native terminal mirror disconnected before the flow completed");
|
|
167
|
+
const step = config.steps[index];
|
|
168
|
+
context.progress("tui_action_started", { index, action: step.action });
|
|
169
|
+
try {
|
|
170
|
+
await executeStep({
|
|
171
|
+
context,
|
|
172
|
+
terminal,
|
|
173
|
+
processHandle,
|
|
174
|
+
step,
|
|
175
|
+
index,
|
|
176
|
+
assertions,
|
|
177
|
+
observations,
|
|
178
|
+
artifacts,
|
|
179
|
+
recording,
|
|
180
|
+
nativeDisplay,
|
|
181
|
+
state: () => ({ exited, exitInfo, lastDataAt, lastFrameChangeAt, writeChain }),
|
|
182
|
+
exitPromise,
|
|
183
|
+
});
|
|
184
|
+
context.progress("tui_action_finished", { index, action: step.action });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
failure = new Error(`step ${index + 1} (${step.action}) failed: ${safeReason(error)}`);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
await writeChain;
|
|
191
|
+
if (nativeDisplayFailure) throw new Error(`native terminal mirror failed: ${safeReason(nativeDisplayFailure)}`);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
failure = error;
|
|
194
|
+
} finally {
|
|
195
|
+
try {
|
|
196
|
+
await writeChain;
|
|
197
|
+
const name = failure ? "failure" : "final";
|
|
198
|
+
if (terminal) captureScreen({ context, terminal, name, artifacts, metadata: { exit: exitInfo ?? null } });
|
|
199
|
+
if (nativeDisplay) await nativeDisplay.capture(name).catch((error) => {
|
|
200
|
+
if (!failure) failure = new Error(`native terminal evidence capture failed: ${safeReason(error)}`);
|
|
201
|
+
});
|
|
202
|
+
const rawName = "raw-transcript.ansi";
|
|
203
|
+
fs.writeFileSync(path.join(context.evidenceDir, rawName), transcript, { flag: "wx", mode: 0o600 });
|
|
204
|
+
artifacts.terminalCaptures.push({ ...context.artifact(rawName, "raw terminal transcript"), format: "ansi", diagnosticOnly: true, truncated: transcriptTruncated });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!failure) failure = error;
|
|
207
|
+
}
|
|
208
|
+
if (processHandle) await cleanupOwnedPty(processHandle, () => exited, exitPromise);
|
|
209
|
+
// Finalize after PTY cleanup so trailing output during the cleanup grace is
|
|
210
|
+
// captured; finalize() awaits the async flush chain, is best-effort, and
|
|
211
|
+
// never throws.
|
|
212
|
+
if (recording) {
|
|
213
|
+
observations.push({ action: "terminalRecording", ...(await recording.finalize(artifacts, context)), presentation: config.presentation });
|
|
214
|
+
if (nativePresentation) {
|
|
215
|
+
for (const artifact of artifacts.videos) if (artifact.format === "asciicast-v2") artifact.diagnosticOnly = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (nativeDisplay) await nativeDisplay.finish();
|
|
219
|
+
terminal?.dispose();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
status: failure ? "FAILED" : "PASSED",
|
|
224
|
+
...(failure ? { reason: safeReason(failure) } : {}),
|
|
225
|
+
assertions,
|
|
226
|
+
observations: [...observations, { transcriptTruncated, exit: exitInfo ?? null }],
|
|
227
|
+
artifacts,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function resolveTuiPresentation(flow) {
|
|
232
|
+
const value = flow?.target?.command?.presentation ?? PTY_PRESENTATION;
|
|
233
|
+
if (value !== PTY_PRESENTATION && value !== NATIVE_TERMINAL_PRESENTATION) {
|
|
234
|
+
throw new Error(`target.command.presentation must be ${JSON.stringify(PTY_PRESENTATION)} or ${JSON.stringify(NATIVE_TERMINAL_PRESENTATION)}`);
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function executeStep(options) {
|
|
240
|
+
const { context, terminal, processHandle, step, index, assertions, observations, artifacts, recording, nativeDisplay, state, exitPromise } = options;
|
|
241
|
+
const timeoutMs = stepTimeout(step, context);
|
|
242
|
+
switch (step.action) {
|
|
243
|
+
case "waitForStable":
|
|
244
|
+
await waitForStable(terminal, state, settleMs(step.settleMs), timeoutMs);
|
|
245
|
+
observations.push({ action: step.action, stable: true });
|
|
246
|
+
return;
|
|
247
|
+
case "waitForText": {
|
|
248
|
+
const text = boundedText(step.text, "waitForText.text");
|
|
249
|
+
await waitFor(async () => {
|
|
250
|
+
await state().writeChain;
|
|
251
|
+
return visibleText(terminal).includes(text);
|
|
252
|
+
}, timeoutMs, `visible screen did not contain ${JSON.stringify(text)}`);
|
|
253
|
+
if (step.settleMs !== undefined) await waitForStable(terminal, state, settleMs(step.settleMs), timeoutMs);
|
|
254
|
+
observations.push({ action: step.action, text });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
case "sendText": {
|
|
258
|
+
const text = boundedText(step.text, "sendText.text");
|
|
259
|
+
if (/[-]/u.test(text)) throw new Error("sendText cannot contain control characters; use sendKeys");
|
|
260
|
+
if (state().exited) throw new Error("cannot send text after the PTY process exited");
|
|
261
|
+
processHandle.write(text);
|
|
262
|
+
observations.push({ action: step.action, characters: [...text].length });
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
case "sendKeys": {
|
|
266
|
+
const sequences = keySequences(step.keys);
|
|
267
|
+
if (state().exited) throw new Error("cannot send keys after the PTY process exited");
|
|
268
|
+
for (const sequence of sequences) processHandle.write(sequence);
|
|
269
|
+
observations.push({ action: step.action, keys: step.keys });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case "resize": {
|
|
273
|
+
const cols = boundedInt(step.cols, 20, 300, "resize.cols");
|
|
274
|
+
const rows = boundedInt(step.rows, 5, 120, "resize.rows");
|
|
275
|
+
terminal.resize(cols, rows);
|
|
276
|
+
if (!state().exited) processHandle.resize(cols, rows);
|
|
277
|
+
recording.recordResize(cols, rows);
|
|
278
|
+
await waitForStable(terminal, state, settleMs(step.settleMs), timeoutMs);
|
|
279
|
+
observations.push({ action: step.action, cols, rows });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
case "assertText": {
|
|
283
|
+
const text = boundedText(step.text, "assertText.text");
|
|
284
|
+
await state().writeChain;
|
|
285
|
+
const passed = visibleText(terminal).includes(text);
|
|
286
|
+
assertions.push({ action: step.action, expected: { visibleText: text }, passed });
|
|
287
|
+
if (!passed) throw new Error(`visible screen does not contain ${JSON.stringify(text)}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
case "assertNotText": {
|
|
291
|
+
const text = boundedText(step.text, "assertNotText.text");
|
|
292
|
+
await state().writeChain;
|
|
293
|
+
const passed = !visibleText(terminal).includes(text);
|
|
294
|
+
assertions.push({ action: step.action, expected: { absentVisibleText: text }, passed });
|
|
295
|
+
if (!passed) throw new Error(`visible screen still contains ${JSON.stringify(text)}`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
case "assertCursor": {
|
|
299
|
+
const row = boundedInt(step.row, 1, terminal.rows, "assertCursor.row");
|
|
300
|
+
const column = boundedInt(step.column, 1, terminal.cols, "assertCursor.column");
|
|
301
|
+
await state().writeChain;
|
|
302
|
+
const screen = snapshotScreen(terminal);
|
|
303
|
+
const passed = screen.cursor.row === row && screen.cursor.column === column;
|
|
304
|
+
assertions.push({ action: step.action, expected: { row, column }, actual: screen.cursor, passed });
|
|
305
|
+
if (!passed) throw new Error(`expected cursor ${row}:${column}, observed ${screen.cursor.row}:${screen.cursor.column}`);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case "assertProcessRunning": {
|
|
309
|
+
const passed = !state().exited;
|
|
310
|
+
assertions.push({ action: step.action, expected: "running", actual: passed ? "running" : "exited", passed });
|
|
311
|
+
if (!passed) throw new Error(`PTY process exited early with code ${state().exitInfo?.exitCode ?? "unknown"}`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
case "assertProcessExited": {
|
|
315
|
+
if (!state().exited) await waitForPromise(exitPromise, timeoutMs, "PTY process did not exit");
|
|
316
|
+
const actual = state().exitInfo?.exitCode;
|
|
317
|
+
const passed = step.exitCode === undefined || actual === step.exitCode;
|
|
318
|
+
assertions.push({ action: step.action, expected: { exited: true, ...(step.exitCode === undefined ? {} : { exitCode: step.exitCode }) }, actual: state().exitInfo, passed });
|
|
319
|
+
if (!passed) throw new Error(`expected exit code ${step.exitCode}, observed ${actual}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case "capture":
|
|
323
|
+
await state().writeChain;
|
|
324
|
+
{
|
|
325
|
+
const name = `${index + 1}-${captureName(step.name)}`;
|
|
326
|
+
captureScreen({ context, terminal, name, artifacts, metadata: { exit: state().exitInfo ?? null } });
|
|
327
|
+
if (nativeDisplay) await nativeDisplay.capture(name);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
default:
|
|
331
|
+
throw new Error(`unsupported TUI action: ${step.action}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function validateFlow(flow, projectRoot) {
|
|
336
|
+
if (!isObject(flow.target?.command)) throw new Error("TUI target requires target.command");
|
|
337
|
+
assertKnownFields(flow.target.command, new Set(["argv", "cwd", "env", "presentation"]), "target.command");
|
|
338
|
+
const presentation = resolveTuiPresentation(flow);
|
|
339
|
+
const rawArgv = flow.target.command.argv;
|
|
340
|
+
if (!Array.isArray(rawArgv) || rawArgv.length < 1 || rawArgv.length > 64) throw new Error("target.command.argv must contain 1..64 strings");
|
|
341
|
+
const argv = rawArgv.map((value, index) => requireString(value, `target.command.argv[${index}]`, MAX_ARG_LENGTH));
|
|
342
|
+
const executable = path.basename(argv[0]).toLowerCase();
|
|
343
|
+
if (BLOCKED_EXECUTABLES.has(executable)) throw new Error("TUI launch cannot invoke a shell or scripting host");
|
|
344
|
+
if (["node", "node.exe", "bun", "bun.exe", "python", "python3", "python.exe", "ruby", "perl"].includes(executable) && INLINE_CODE_FLAGS.has((argv[1] ?? "").toLowerCase())) {
|
|
345
|
+
throw new Error("TUI launch cannot contain inline executable code");
|
|
346
|
+
}
|
|
347
|
+
const command = {
|
|
348
|
+
argv,
|
|
349
|
+
cwd: resolveProjectDirectory(projectRoot, flow.target.command.cwd ?? "."),
|
|
350
|
+
env: validateEnv(flow.target.command.env),
|
|
351
|
+
};
|
|
352
|
+
validateLaunchContract(command.argv, command.cwd, projectRoot);
|
|
353
|
+
const viewportValue = flow.viewport ?? {};
|
|
354
|
+
if (!isObject(viewportValue)) throw new Error("viewport must be an object");
|
|
355
|
+
assertKnownFields(viewportValue, new Set(["cols", "rows"]), "viewport");
|
|
356
|
+
if (presentation === NATIVE_TERMINAL_PRESENTATION && flow.viewport !== undefined) {
|
|
357
|
+
throw new Error("viewport is PTY-only; native-terminal presentation uses the real terminal window geometry");
|
|
358
|
+
}
|
|
359
|
+
const viewport = {
|
|
360
|
+
cols: viewportValue.cols === undefined ? 80 : boundedInt(viewportValue.cols, 20, 300, "viewport.cols"),
|
|
361
|
+
rows: viewportValue.rows === undefined ? 24 : boundedInt(viewportValue.rows, 5, 120, "viewport.rows"),
|
|
362
|
+
};
|
|
363
|
+
if (!Array.isArray(flow.steps) || flow.steps.length < 1 || flow.steps.length > 100) throw new Error("TUI flow requires 1..100 steps");
|
|
364
|
+
const steps = flow.steps.map((step, index) => validateStep(step, index));
|
|
365
|
+
if (presentation === NATIVE_TERMINAL_PRESENTATION) validateNativeTerminalSteps(steps);
|
|
366
|
+
return { command, viewport, steps, presentation };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function validateNativeTerminalSteps(steps) {
|
|
370
|
+
for (const step of steps) {
|
|
371
|
+
if (!NATIVE_TERMINAL_ACTIONS.has(step.action)) {
|
|
372
|
+
throw new Error(`TUI action ${step.action} is PTY-only and cannot be used with native-terminal presentation`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return steps;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function validateLaunchContract(argv, cwd, projectRoot) {
|
|
379
|
+
const executable = argv[0];
|
|
380
|
+
const base = path.basename(executable).toLowerCase();
|
|
381
|
+
if (path.isAbsolute(executable) || executable.includes("/") || executable.includes("\\")) {
|
|
382
|
+
const resolved = path.resolve(cwd, executable);
|
|
383
|
+
if (fs.existsSync(resolved) && fs.realpathSync(resolved) === fs.realpathSync(process.execPath)) {
|
|
384
|
+
validateRuntimeScript(base, argv, cwd, projectRoot);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (!isProjectFile(projectRoot, resolved, true)) throw new Error("TUI executable paths must resolve to a non-symlink executable inside the project");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (["node", "node.exe", "python", "python3", "python.exe", "ruby", "perl", "deno", "deno.exe"].includes(base)) {
|
|
391
|
+
validateRuntimeScript(base, argv, cwd, projectRoot);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (base === "bun" || base === "bun.exe") {
|
|
395
|
+
if (argv[1] === "run") {
|
|
396
|
+
if (!/^[A-Za-z0-9:_-]+$/.test(argv[2] ?? "")) throw new Error("bun run requires a bounded package script name");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!isProjectFile(projectRoot, path.resolve(cwd, argv[1] ?? ""), false)) throw new Error("bun launch requires a project-local script or `bun run <script>`");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (["npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"].includes(base)) {
|
|
403
|
+
const offset = argv[1] === "run" ? 2 : 1;
|
|
404
|
+
if (!/^[A-Za-z0-9:_-]+$/.test(argv[offset] ?? "")) throw new Error(`${base} launch requires a bounded package script name`);
|
|
405
|
+
if (argv[1] !== "run" && !["start", "test"].includes(argv[1])) throw new Error(`${base} launch must use run, start, or test`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (["cargo", "go", "dotnet"].includes(base) && argv[1] === "run") return;
|
|
409
|
+
if (base === "java" && argv[1] === "-jar" && isProjectFile(projectRoot, path.resolve(cwd, argv[2] ?? ""), false)) return;
|
|
410
|
+
throw new Error("TUI launch executable must be project-local or a bounded package/runtime launch contract");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function validateRuntimeScript(base, argv, cwd, projectRoot) {
|
|
414
|
+
const scriptIndex = base.startsWith("deno") ? (argv[1] === "run" ? 2 : -1) : 1;
|
|
415
|
+
if (scriptIndex < 1 || !argv[scriptIndex] || argv[scriptIndex].startsWith("-")) throw new Error(`${base} launch requires an explicit project-local script file`);
|
|
416
|
+
if (!isProjectFile(projectRoot, path.resolve(cwd, argv[scriptIndex]), false)) throw new Error(`${base} script must be a non-symlink file inside the project`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isProjectFile(projectRoot, value, requireExecutable) {
|
|
420
|
+
if (!isInside(projectRoot, value) || !fs.existsSync(value)) return false;
|
|
421
|
+
let current = projectRoot;
|
|
422
|
+
for (const part of path.relative(projectRoot, value).split(path.sep).filter(Boolean)) {
|
|
423
|
+
current = path.join(current, part);
|
|
424
|
+
if (fs.lstatSync(current).isSymbolicLink()) return false;
|
|
425
|
+
}
|
|
426
|
+
const stat = fs.statSync(value);
|
|
427
|
+
return stat.isFile() && (!requireExecutable || process.platform === "win32" || (stat.mode & 0o111) !== 0);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function validateStep(step, index) {
|
|
431
|
+
if (!isObject(step) || typeof step.action !== "string" || !STEP_FIELDS[step.action]) throw new Error(`TUI step ${index + 1} has an unsupported action`);
|
|
432
|
+
assertKnownFields(step, STEP_FIELDS[step.action], `TUI step ${index + 1}`);
|
|
433
|
+
if (step.timeoutMs !== undefined) validateTimeout(step.timeoutMs);
|
|
434
|
+
if (step.settleMs !== undefined) settleMs(step.settleMs);
|
|
435
|
+
if (step.action === "assertProcessExited" && step.exitCode !== undefined && (!Number.isInteger(step.exitCode) || step.exitCode < 0 || step.exitCode > 255)) throw new Error("assertProcessExited.exitCode must be 0..255");
|
|
436
|
+
return step;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function validateEnv(value) {
|
|
440
|
+
if (value === undefined) return {};
|
|
441
|
+
if (!isObject(value)) throw new Error("target.command.env must be an object");
|
|
442
|
+
const result = {};
|
|
443
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
444
|
+
if (!SAFE_ENV.has(key) && !key.startsWith("PI_UI_QA_")) throw new Error(`target.command.env key is not allowed: ${key}`);
|
|
445
|
+
result[key] = requireString(raw, `target.command.env.${key}`, MAX_ARG_LENGTH);
|
|
446
|
+
}
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function launchEnvironment(extra) {
|
|
451
|
+
const result = {};
|
|
452
|
+
for (const key of ["PATH", "HOME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "WINDIR", "USERPROFILE", "ComSpec"]) {
|
|
453
|
+
if (process.env[key] !== undefined) result[key] = process.env[key];
|
|
454
|
+
}
|
|
455
|
+
return { ...result, TERM: "xterm-256color", PI_UI_QA: "1", ...extra };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function snapshotScreen(terminal) {
|
|
459
|
+
const buffer = terminal.buffer.active;
|
|
460
|
+
const rows = [];
|
|
461
|
+
for (let row = 0; row < terminal.rows; row += 1) {
|
|
462
|
+
rows.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
|
|
463
|
+
}
|
|
464
|
+
while (rows.length > 0 && rows.at(-1) === "") rows.pop();
|
|
465
|
+
return {
|
|
466
|
+
cols: terminal.cols,
|
|
467
|
+
rows: terminal.rows,
|
|
468
|
+
bufferType: buffer.type,
|
|
469
|
+
cursor: { row: buffer.cursorY + 1, column: buffer.cursorX + 1 },
|
|
470
|
+
lines: rows,
|
|
471
|
+
text: rows.join("\n"),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function visibleText(terminal) {
|
|
476
|
+
return snapshotScreen(terminal).text;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function screenFingerprint(screen) {
|
|
480
|
+
return JSON.stringify([screen.cols, screen.rows, screen.bufferType, screen.cursor.row, screen.cursor.column, screen.lines]);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function waitForStable(terminal, state, settle, timeoutMs) {
|
|
484
|
+
const deadline = Date.now() + timeoutMs;
|
|
485
|
+
let previous = "";
|
|
486
|
+
let sameSince = Date.now();
|
|
487
|
+
while (Date.now() < deadline) {
|
|
488
|
+
await state().writeChain;
|
|
489
|
+
const fingerprint = screenFingerprint(snapshotScreen(terminal));
|
|
490
|
+
if (fingerprint !== previous) {
|
|
491
|
+
previous = fingerprint;
|
|
492
|
+
sameSince = Date.now();
|
|
493
|
+
}
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
if (now - sameSince >= settle && now - state().lastDataAt >= settle && now - state().lastFrameChangeAt >= settle) return;
|
|
496
|
+
await sleep(Math.min(25, Math.max(1, deadline - now)));
|
|
497
|
+
}
|
|
498
|
+
throw new Error(`terminal screen did not settle for ${settle} ms`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function captureScreen({ context, terminal, name, artifacts, metadata }) {
|
|
502
|
+
const screen = snapshotScreen(terminal);
|
|
503
|
+
const textName = `${name}.screen.txt`;
|
|
504
|
+
const jsonName = `${name}.screen.json`;
|
|
505
|
+
const header = `# terminal ${screen.cols}x${screen.rows} buffer=${screen.bufferType} cursor=${screen.cursor.row}:${screen.cursor.column}\n`;
|
|
506
|
+
writePrivate(path.join(context.evidenceDir, textName), `${header}${screen.text}\n`);
|
|
507
|
+
writePrivate(path.join(context.evidenceDir, jsonName), `${JSON.stringify({ ...screen, ...metadata }, null, 2)}\n`);
|
|
508
|
+
artifacts.terminalCaptures.push({ ...context.artifact(textName, "terminal screen"), format: "text", bufferType: screen.bufferType });
|
|
509
|
+
artifacts.terminalCaptures.push({ ...context.artifact(jsonName, "terminal screen state"), format: "json", bufferType: screen.bufferType });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Builds a standards-compatible asciicast v2 recording (header plus time-coded
|
|
513
|
+
// output/resize events) from the PTY output stream. Events are timestamped on
|
|
514
|
+
// arrival, queued in order, and flushed by the async filesystem so a burst of
|
|
515
|
+
// PTY output never blocks the terminal write chain; the total file size stays
|
|
516
|
+
// bounded by the same limit as the raw transcript, and every failure is
|
|
517
|
+
// best-effort so a recording problem can never fail an otherwise valid QA run
|
|
518
|
+
// or alter screen-state assertions.
|
|
519
|
+
function createTerminalRecording(evidenceDir, viewport) {
|
|
520
|
+
const file = path.join(evidenceDir, RECORDING_FILE);
|
|
521
|
+
const startedAt = Date.now();
|
|
522
|
+
const state = { bytes: 0, events: 0, truncated: false, headerOnly: true, reason: null };
|
|
523
|
+
let active = true;
|
|
524
|
+
let queue = Promise.resolve();
|
|
525
|
+
const enqueue = (kind, payload) => {
|
|
526
|
+
if (!active) return;
|
|
527
|
+
const seconds = Number(Math.max(0, (Date.now() - startedAt) / 1000).toFixed(6));
|
|
528
|
+
const line = `${JSON.stringify([seconds, kind, payload])}\n`;
|
|
529
|
+
const length = Buffer.byteLength(line);
|
|
530
|
+
if (state.bytes + length > MAX_TRANSCRIPT_BYTES) {
|
|
531
|
+
state.truncated = true;
|
|
532
|
+
active = false;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
state.bytes += length;
|
|
536
|
+
const buffer = Buffer.from(line, "utf8");
|
|
537
|
+
queue = queue
|
|
538
|
+
.then(() => fs.promises.appendFile(file, buffer))
|
|
539
|
+
.then(
|
|
540
|
+
() => {
|
|
541
|
+
state.events += 1;
|
|
542
|
+
state.headerOnly = false;
|
|
543
|
+
},
|
|
544
|
+
(error) => {
|
|
545
|
+
state.reason = state.reason ?? safeReason(error);
|
|
546
|
+
active = false;
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
};
|
|
550
|
+
try {
|
|
551
|
+
const header = `${JSON.stringify({
|
|
552
|
+
version: 2,
|
|
553
|
+
width: viewport.cols,
|
|
554
|
+
height: viewport.rows,
|
|
555
|
+
timestamp: Math.floor(startedAt / 1000),
|
|
556
|
+
env: { TERM: "xterm-256color" },
|
|
557
|
+
title: "ui-qa terminal recording",
|
|
558
|
+
})}\n`;
|
|
559
|
+
fs.writeFileSync(file, header, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
560
|
+
state.bytes = Buffer.byteLength(header);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
state.reason = safeReason(error);
|
|
563
|
+
active = false;
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
recordOutput(data) {
|
|
567
|
+
enqueue("o", typeof data === "string" ? data : Buffer.from(data).toString("utf8"));
|
|
568
|
+
},
|
|
569
|
+
recordResize(cols, rows) {
|
|
570
|
+
enqueue("r", `${cols}x${rows}`);
|
|
571
|
+
},
|
|
572
|
+
// Awaits the async flush chain so the artifact always reflects every
|
|
573
|
+
// enqueued event; best-effort and never throws.
|
|
574
|
+
async finalize(artifacts, context) {
|
|
575
|
+
await queue.catch(() => {});
|
|
576
|
+
if (state.reason) {
|
|
577
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
578
|
+
return { status: "unavailable", reason: state.reason, ...(state.headerOnly ? {} : { truncated: state.truncated }) };
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
const stat = fs.statSync(file);
|
|
582
|
+
if (!stat.isFile()) throw new Error("terminal recording was not created");
|
|
583
|
+
if (state.headerOnly) throw new Error("terminal recording contains no terminal output");
|
|
584
|
+
if (process.platform !== "win32") fs.chmodSync(file, 0o600);
|
|
585
|
+
artifacts.videos.push({ ...context.artifact(RECORDING_FILE, "terminal recording"), format: "asciicast-v2", events: state.events, truncated: state.truncated });
|
|
586
|
+
return { status: "recorded", events: state.events, truncated: state.truncated };
|
|
587
|
+
} catch (error) {
|
|
588
|
+
return { status: "unavailable", reason: safeReason(error) };
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function cleanupOwnedPty(processHandle, exited, exitPromise) {
|
|
595
|
+
try {
|
|
596
|
+
if (exited()) return;
|
|
597
|
+
terminateOwnedPty(processHandle, "SIGTERM");
|
|
598
|
+
await Promise.race([exitPromise, sleep(CLEANUP_GRACE_MS)]);
|
|
599
|
+
if (exited()) return;
|
|
600
|
+
// Windows node-pty 1.1.0 has no signal-aware escalation: kill(signal)
|
|
601
|
+
// throws before starting its owned ConPTY process-tree cleanup. One
|
|
602
|
+
// signal-less kill above is therefore the Windows termination request;
|
|
603
|
+
// repeating it would start a second async console-process-list helper.
|
|
604
|
+
if (process.platform !== "win32") terminateOwnedPty(processHandle, "SIGKILL");
|
|
605
|
+
} finally {
|
|
606
|
+
await releaseWindowsPtyResources(processHandle);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function terminateOwnedPty(processHandle, signal, platform = process.platform) {
|
|
611
|
+
try {
|
|
612
|
+
if (platform === "win32") processHandle.kill();
|
|
613
|
+
else processHandle.kill(signal);
|
|
614
|
+
} catch { /* already exited or unsupported by the PTY implementation */ }
|
|
615
|
+
if (platform !== "win32") {
|
|
616
|
+
try { process.kill(-processHandle.pid, signal); } catch { /* node-pty may already have reaped the group */ }
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* @lydell/node-pty 1.1.0 leaves Windows ConPTY host handles alive after a
|
|
622
|
+
* natural child exit. The dedicated UI QA runner has no more PTY work after
|
|
623
|
+
* backend cleanup, so release those handles explicitly instead of keeping the
|
|
624
|
+
* runner process alive indefinitely.
|
|
625
|
+
*
|
|
626
|
+
* This is best-effort and Windows-only because these members are node-pty
|
|
627
|
+
* implementation details and must never affect an already-settled QA result.
|
|
628
|
+
*/
|
|
629
|
+
export async function releaseWindowsPtyResources(processHandle, platform = process.platform) {
|
|
630
|
+
if (platform !== "win32") return;
|
|
631
|
+
const agent = processHandle?._agent;
|
|
632
|
+
for (const socket of [agent?.inSocket, agent?.outSocket]) {
|
|
633
|
+
try { socket?.destroy?.(); } catch { /* best effort */ }
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
if (agent?._closeTimeout) clearTimeout(agent._closeTimeout);
|
|
637
|
+
} catch { /* best effort */ }
|
|
638
|
+
const conout = agent?._conoutSocketWorker;
|
|
639
|
+
try { conout?.dispose?.(); } catch { /* best effort */ }
|
|
640
|
+
try {
|
|
641
|
+
if (conout?._drainTimeout) clearTimeout(conout._drainTimeout);
|
|
642
|
+
} catch { /* best effort */ }
|
|
643
|
+
try { await conout?._worker?.terminate?.(); } catch { /* best effort */ }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function keySequences(value) {
|
|
647
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 100) throw new Error("sendKeys.keys must contain 1..100 named keys");
|
|
648
|
+
return value.map((raw) => {
|
|
649
|
+
const key = requireString(raw, "sendKeys key", 32).toLowerCase().replace(/[ _-]/g, "");
|
|
650
|
+
if (KEY_SEQUENCES[key]) return KEY_SEQUENCES[key];
|
|
651
|
+
const control = /^ctrl\+([a-z])$/.exec(raw.toLowerCase());
|
|
652
|
+
if (control) return String.fromCharCode(control[1].charCodeAt(0) - 96);
|
|
653
|
+
throw new Error(`unsupported named key: ${raw}`);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function resolveProjectDirectory(projectRoot, value) {
|
|
658
|
+
const requested = path.resolve(projectRoot, requireString(value, "target.command.cwd", MAX_ARG_LENGTH));
|
|
659
|
+
if (!isInside(projectRoot, requested) || !fs.existsSync(requested)) throw new Error("target.command.cwd must be an existing project-local directory");
|
|
660
|
+
let current = projectRoot;
|
|
661
|
+
for (const part of path.relative(projectRoot, requested).split(path.sep).filter(Boolean)) {
|
|
662
|
+
current = path.join(current, part);
|
|
663
|
+
if (fs.lstatSync(current).isSymbolicLink()) throw new Error("target.command.cwd must not contain symlinks");
|
|
664
|
+
}
|
|
665
|
+
const real = fs.realpathSync(requested);
|
|
666
|
+
if (!isInside(projectRoot, real) || !fs.statSync(real).isDirectory()) throw new Error("target.command.cwd must be a project-local directory");
|
|
667
|
+
return real;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function stepTimeout(step, context) {
|
|
671
|
+
const value = step.timeoutMs ?? Math.min(15_000, context.stageTimeoutMs);
|
|
672
|
+
validateTimeout(value);
|
|
673
|
+
const remaining = context.deadline - Date.now();
|
|
674
|
+
if (remaining <= 0) throw new Error("UI QA runner deadline expired");
|
|
675
|
+
return Math.max(1, Math.min(Math.round(value), remaining));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function validateTimeout(value) {
|
|
679
|
+
if (!Number.isFinite(value) || value < 50 || value > 30_000) throw new Error("step timeoutMs must be between 50 and 30000");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function settleMs(value) {
|
|
683
|
+
const result = value ?? 150;
|
|
684
|
+
if (!Number.isFinite(result) || result < 50 || result > 2_000) throw new Error("settleMs must be between 50 and 2000");
|
|
685
|
+
return Math.round(result);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function waitFor(check, timeoutMs, reason) {
|
|
689
|
+
const deadline = Date.now() + timeoutMs;
|
|
690
|
+
while (Date.now() < deadline) {
|
|
691
|
+
if (await check()) return;
|
|
692
|
+
await sleep(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
693
|
+
}
|
|
694
|
+
throw new Error(reason);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function terminalWrite(terminal, data) {
|
|
698
|
+
return new Promise((resolve) => terminal.write(data, resolve));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function waitForPromise(promise, timeoutMs, reason) {
|
|
702
|
+
return new Promise((resolve, reject) => {
|
|
703
|
+
const timer = setTimeout(() => reject(new Error(reason)), timeoutMs);
|
|
704
|
+
promise.then(
|
|
705
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
706
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
707
|
+
);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function boundedText(value, label) {
|
|
712
|
+
return requireString(value, label, 4096);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function boundedInt(value, min, max, label) {
|
|
716
|
+
if (!Number.isInteger(value) || value < min || value > max) throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
717
|
+
return value;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function captureName(value) {
|
|
721
|
+
const result = value === undefined ? "capture" : requireString(value, "capture.name", 80);
|
|
722
|
+
if (!/^[A-Za-z0-9_-]+$/.test(result)) throw new Error("capture.name must contain only letters, digits, underscore, or dash");
|
|
723
|
+
return result;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function assertKnownFields(value, allowed, label) {
|
|
727
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${label} contains unsupported field: ${key}`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function requireString(value, label, maxLength = 4096) {
|
|
731
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maxLength || value.includes("\0")) throw new Error(`${label} must be a non-empty string no longer than ${maxLength}`);
|
|
732
|
+
return value;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function writePrivate(file, content) {
|
|
736
|
+
fs.writeFileSync(file, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
737
|
+
if (process.platform !== "win32") fs.chmodSync(file, 0o600);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function emptyArtifacts() {
|
|
741
|
+
return { screenshots: [], videos: [], traces: [], terminalCaptures: [], accessibilitySnapshots: [], observations: [], downloads: [] };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function isInside(root, target) {
|
|
745
|
+
const relative = path.relative(root, target);
|
|
746
|
+
return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isObject(value) {
|
|
750
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function sleep(ms) {
|
|
754
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function safeReason(error) {
|
|
758
|
+
return error instanceof Error ? error.message : String(error);
|
|
759
|
+
}
|