@unbrained/pm-cli 2026.3.12 → 2026.5.1
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/.agents/pm/extensions/.managed-extensions.json +42 -0
- package/.agents/pm/extensions/beads/index.js +109 -0
- package/.agents/pm/extensions/beads/manifest.json +7 -0
- package/{dist/cli/commands/beads.js → .agents/pm/extensions/beads/runtime.js} +31 -21
- package/.agents/pm/extensions/beads/runtime.ts +702 -0
- package/.agents/pm/extensions/todos/index.js +126 -0
- package/.agents/pm/extensions/todos/manifest.json +7 -0
- package/{dist/extensions/builtins/todos/import-export.js → .agents/pm/extensions/todos/runtime.js} +39 -29
- package/.agents/pm/extensions/todos/runtime.ts +568 -0
- package/AGENTS.md +196 -92
- package/CHANGELOG.md +399 -0
- package/CODE_OF_CONDUCT.md +42 -0
- package/CONTRIBUTING.md +144 -0
- package/PRD.md +512 -164
- package/README.md +1053 -2
- package/SECURITY.md +51 -0
- package/dist/cli/commands/activity.d.ts +5 -0
- package/dist/cli/commands/activity.js +66 -3
- package/dist/cli/commands/activity.js.map +1 -1
- package/dist/cli/commands/aggregate.d.ts +54 -0
- package/dist/cli/commands/aggregate.js +181 -0
- package/dist/cli/commands/aggregate.js.map +1 -0
- package/dist/cli/commands/append.js +4 -1
- package/dist/cli/commands/append.js.map +1 -1
- package/dist/cli/commands/calendar.d.ts +109 -0
- package/dist/cli/commands/calendar.js +797 -0
- package/dist/cli/commands/calendar.js.map +1 -0
- package/dist/cli/commands/claim.d.ts +5 -1
- package/dist/cli/commands/claim.js +42 -21
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/close.d.ts +1 -0
- package/dist/cli/commands/close.js +54 -5
- package/dist/cli/commands/close.js.map +1 -1
- package/dist/cli/commands/comments-audit.d.ts +91 -0
- package/dist/cli/commands/comments-audit.js +195 -0
- package/dist/cli/commands/comments-audit.js.map +1 -0
- package/dist/cli/commands/comments.d.ts +1 -0
- package/dist/cli/commands/comments.js +70 -21
- package/dist/cli/commands/comments.js.map +1 -1
- package/dist/cli/commands/completion.d.ts +10 -4
- package/dist/cli/commands/completion.js +1184 -137
- package/dist/cli/commands/completion.js.map +1 -1
- package/dist/cli/commands/config.d.ts +35 -3
- package/dist/cli/commands/config.js +968 -13
- package/dist/cli/commands/config.js.map +1 -1
- package/dist/cli/commands/context.d.ts +86 -0
- package/dist/cli/commands/context.js +299 -0
- package/dist/cli/commands/context.js.map +1 -0
- package/dist/cli/commands/contracts.d.ts +78 -0
- package/dist/cli/commands/contracts.js +920 -0
- package/dist/cli/commands/contracts.js.map +1 -0
- package/dist/cli/commands/create.d.ts +48 -14
- package/dist/cli/commands/create.js +1331 -160
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/commands/dedupe-audit.d.ts +81 -0
- package/dist/cli/commands/dedupe-audit.js +330 -0
- package/dist/cli/commands/dedupe-audit.js.map +1 -0
- package/dist/cli/commands/deps.d.ts +52 -0
- package/dist/cli/commands/deps.js +204 -0
- package/dist/cli/commands/deps.js.map +1 -0
- package/dist/cli/commands/docs.d.ts +19 -0
- package/dist/cli/commands/docs.js +212 -13
- package/dist/cli/commands/docs.js.map +1 -1
- package/dist/cli/commands/extension.d.ts +122 -0
- package/dist/cli/commands/extension.js +1850 -0
- package/dist/cli/commands/extension.js.map +1 -0
- package/dist/cli/commands/files.d.ts +52 -1
- package/dist/cli/commands/files.js +443 -13
- package/dist/cli/commands/files.js.map +1 -1
- package/dist/cli/commands/gc.d.ts +11 -1
- package/dist/cli/commands/gc.js +89 -11
- package/dist/cli/commands/gc.js.map +1 -1
- package/dist/cli/commands/get.d.ts +13 -0
- package/dist/cli/commands/get.js +35 -3
- package/dist/cli/commands/get.js.map +1 -1
- package/dist/cli/commands/health.d.ts +10 -2
- package/dist/cli/commands/health.js +774 -23
- package/dist/cli/commands/health.js.map +1 -1
- package/dist/cli/commands/history.d.ts +20 -0
- package/dist/cli/commands/history.js +152 -6
- package/dist/cli/commands/history.js.map +1 -1
- package/dist/cli/commands/index.d.ts +16 -3
- package/dist/cli/commands/index.js +16 -3
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.d.ts +7 -2
- package/dist/cli/commands/init.js +137 -5
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/learnings.d.ts +17 -0
- package/dist/cli/commands/learnings.js +129 -0
- package/dist/cli/commands/learnings.js.map +1 -0
- package/dist/cli/commands/list.d.ts +29 -1
- package/dist/cli/commands/list.js +289 -53
- package/dist/cli/commands/list.js.map +1 -1
- package/dist/cli/commands/normalize.d.ts +51 -0
- package/dist/cli/commands/normalize.js +298 -0
- package/dist/cli/commands/normalize.js.map +1 -0
- package/dist/cli/commands/notes.d.ts +17 -0
- package/dist/cli/commands/notes.js +129 -0
- package/dist/cli/commands/notes.js.map +1 -0
- package/dist/cli/commands/reindex.d.ts +1 -0
- package/dist/cli/commands/reindex.js +208 -32
- package/dist/cli/commands/reindex.js.map +1 -1
- package/dist/cli/commands/restore.js +164 -30
- package/dist/cli/commands/restore.js.map +1 -1
- package/dist/cli/commands/search.d.ts +14 -1
- package/dist/cli/commands/search.js +475 -81
- package/dist/cli/commands/search.js.map +1 -1
- package/dist/cli/commands/stats.js +26 -10
- package/dist/cli/commands/stats.js.map +1 -1
- package/dist/cli/commands/templates.d.ts +26 -0
- package/dist/cli/commands/templates.js +179 -0
- package/dist/cli/commands/templates.js.map +1 -0
- package/dist/cli/commands/test-all.d.ts +19 -1
- package/dist/cli/commands/test-all.js +161 -13
- package/dist/cli/commands/test-all.js.map +1 -1
- package/dist/cli/commands/test-runs.d.ts +63 -0
- package/dist/cli/commands/test-runs.js +179 -0
- package/dist/cli/commands/test-runs.js.map +1 -0
- package/dist/cli/commands/test.d.ts +75 -1
- package/dist/cli/commands/test.js +1360 -41
- package/dist/cli/commands/test.js.map +1 -1
- package/dist/cli/commands/update-many.d.ts +57 -0
- package/dist/cli/commands/update-many.js +631 -0
- package/dist/cli/commands/update-many.js.map +1 -0
- package/dist/cli/commands/update.d.ts +30 -0
- package/dist/cli/commands/update.js +1393 -84
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/commands/validate.d.ts +30 -0
- package/dist/cli/commands/validate.js +1140 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/error-guidance.d.ts +33 -0
- package/dist/cli/error-guidance.js +337 -0
- package/dist/cli/error-guidance.js.map +1 -0
- package/dist/cli/extension-command-options.d.ts +1 -0
- package/dist/cli/extension-command-options.js +92 -0
- package/dist/cli/extension-command-options.js.map +1 -1
- package/dist/cli/help-content.d.ts +20 -0
- package/dist/cli/help-content.js +543 -0
- package/dist/cli/help-content.js.map +1 -0
- package/dist/cli/main.js +3625 -445
- package/dist/cli/main.js.map +1 -1
- package/dist/core/extensions/index.d.ts +13 -1
- package/dist/core/extensions/index.js +108 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/item-fields.d.ts +2 -0
- package/dist/core/extensions/item-fields.js +79 -0
- package/dist/core/extensions/item-fields.js.map +1 -0
- package/dist/core/extensions/loader.d.ts +322 -9
- package/dist/core/extensions/loader.js +911 -20
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/runtime-registrations.d.ts +5 -0
- package/dist/core/extensions/runtime-registrations.js +51 -0
- package/dist/core/extensions/runtime-registrations.js.map +1 -0
- package/dist/core/history/history-stream-policy.d.ts +20 -0
- package/dist/core/history/history-stream-policy.js +53 -0
- package/dist/core/history/history-stream-policy.js.map +1 -0
- package/dist/core/history/history.js +90 -1
- package/dist/core/history/history.js.map +1 -1
- package/dist/core/item/id.js +4 -1
- package/dist/core/item/id.js.map +1 -1
- package/dist/core/item/index.d.ts +1 -0
- package/dist/core/item/index.js +1 -0
- package/dist/core/item/index.js.map +1 -1
- package/dist/core/item/item-format.d.ts +11 -5
- package/dist/core/item/item-format.js +507 -24
- package/dist/core/item/item-format.js.map +1 -1
- package/dist/core/item/parent-reference-policy.d.ts +6 -0
- package/dist/core/item/parent-reference-policy.js +32 -0
- package/dist/core/item/parent-reference-policy.js.map +1 -0
- package/dist/core/item/parse.d.ts +5 -0
- package/dist/core/item/parse.js +216 -19
- package/dist/core/item/parse.js.map +1 -1
- package/dist/core/item/sprint-release-format.d.ts +6 -0
- package/dist/core/item/sprint-release-format.js +33 -0
- package/dist/core/item/sprint-release-format.js.map +1 -0
- package/dist/core/item/status.d.ts +3 -0
- package/dist/core/item/status.js +24 -0
- package/dist/core/item/status.js.map +1 -0
- package/dist/core/item/type-registry.d.ts +37 -0
- package/dist/core/item/type-registry.js +706 -0
- package/dist/core/item/type-registry.js.map +1 -0
- package/dist/core/lock/lock.d.ts +1 -1
- package/dist/core/lock/lock.js +101 -12
- package/dist/core/lock/lock.js.map +1 -1
- package/dist/core/output/command-aware.d.ts +1 -0
- package/dist/core/output/command-aware.js +394 -0
- package/dist/core/output/command-aware.js.map +1 -0
- package/dist/core/output/output.d.ts +3 -0
- package/dist/core/output/output.js +124 -6
- package/dist/core/output/output.js.map +1 -1
- package/dist/core/schema/runtime-field-filters.d.ts +3 -0
- package/dist/core/schema/runtime-field-filters.js +39 -0
- package/dist/core/schema/runtime-field-filters.js.map +1 -0
- package/dist/core/schema/runtime-field-values.d.ts +8 -0
- package/dist/core/schema/runtime-field-values.js +154 -0
- package/dist/core/schema/runtime-field-values.js.map +1 -0
- package/dist/core/schema/runtime-schema.d.ts +68 -0
- package/dist/core/schema/runtime-schema.js +554 -0
- package/dist/core/schema/runtime-schema.js.map +1 -0
- package/dist/core/search/cache.d.ts +13 -1
- package/dist/core/search/cache.js +123 -14
- package/dist/core/search/cache.js.map +1 -1
- package/dist/core/search/semantic-defaults.d.ts +6 -0
- package/dist/core/search/semantic-defaults.js +120 -0
- package/dist/core/search/semantic-defaults.js.map +1 -0
- package/dist/core/search/vector-stores.js +3 -1
- package/dist/core/search/vector-stores.js.map +1 -1
- package/dist/core/shared/command-types.d.ts +2 -0
- package/dist/core/shared/conflict-markers.d.ts +7 -0
- package/dist/core/shared/conflict-markers.js +27 -0
- package/dist/core/shared/conflict-markers.js.map +1 -0
- package/dist/core/shared/constants.d.ts +15 -4
- package/dist/core/shared/constants.js +141 -1
- package/dist/core/shared/constants.js.map +1 -1
- package/dist/core/shared/errors.d.ts +10 -1
- package/dist/core/shared/errors.js +3 -1
- package/dist/core/shared/errors.js.map +1 -1
- package/dist/core/shared/text-normalization.d.ts +4 -0
- package/dist/core/shared/text-normalization.js +33 -0
- package/dist/core/shared/text-normalization.js.map +1 -0
- package/dist/core/shared/time.d.ts +1 -2
- package/dist/core/shared/time.js +98 -11
- package/dist/core/shared/time.js.map +1 -1
- package/dist/core/store/index.d.ts +1 -0
- package/dist/core/store/index.js +1 -0
- package/dist/core/store/index.js.map +1 -1
- package/dist/core/store/item-format-migration.d.ts +9 -0
- package/dist/core/store/item-format-migration.js +87 -0
- package/dist/core/store/item-format-migration.js.map +1 -0
- package/dist/core/store/item-store.d.ts +13 -4
- package/dist/core/store/item-store.js +238 -51
- package/dist/core/store/item-store.js.map +1 -1
- package/dist/core/store/paths.d.ts +21 -3
- package/dist/core/store/paths.js +59 -4
- package/dist/core/store/paths.js.map +1 -1
- package/dist/core/store/settings.d.ts +14 -1
- package/dist/core/store/settings.js +463 -7
- package/dist/core/store/settings.js.map +1 -1
- package/dist/core/telemetry/consent.d.ts +2 -0
- package/dist/core/telemetry/consent.js +79 -0
- package/dist/core/telemetry/consent.js.map +1 -0
- package/dist/core/telemetry/runtime.d.ts +38 -0
- package/dist/core/telemetry/runtime.js +733 -0
- package/dist/core/telemetry/runtime.js.map +1 -0
- package/dist/core/test/background-runs.d.ts +117 -0
- package/dist/core/test/background-runs.js +760 -0
- package/dist/core/test/background-runs.js.map +1 -0
- package/dist/core/test/item-test-run-tracking.d.ts +9 -0
- package/dist/core/test/item-test-run-tracking.js +50 -0
- package/dist/core/test/item-test-run-tracking.js.map +1 -0
- package/dist/sdk/cli-contracts.d.ts +92 -0
- package/dist/sdk/cli-contracts.js +2357 -0
- package/dist/sdk/cli-contracts.js.map +1 -0
- package/dist/sdk/index.d.ts +34 -0
- package/dist/sdk/index.js +23 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/types.d.ts +197 -3
- package/dist/types.js +48 -1
- package/dist/types.js.map +1 -1
- package/docs/ARCHITECTURE.md +368 -39
- package/docs/EXTENSIONS.md +454 -49
- package/docs/RELEASING.md +68 -19
- package/docs/SDK.md +123 -0
- package/docs/examples/starter-extension/README.md +48 -0
- package/docs/examples/starter-extension/index.js +191 -0
- package/docs/examples/starter-extension/manifest.json +17 -0
- package/docs/examples/starter-extension/package.json +10 -0
- package/package.json +33 -6
- package/.pi/extensions/pm-cli/index.ts +0 -778
- package/dist/cli/commands/beads.d.ts +0 -16
- package/dist/cli/commands/beads.js.map +0 -1
- package/dist/cli/commands/install.d.ts +0 -18
- package/dist/cli/commands/install.js +0 -87
- package/dist/cli/commands/install.js.map +0 -1
- package/dist/core/extensions/builtins.d.ts +0 -3
- package/dist/core/extensions/builtins.js +0 -47
- package/dist/core/extensions/builtins.js.map +0 -1
- package/dist/extensions/builtins/beads/index.d.ts +0 -8
- package/dist/extensions/builtins/beads/index.js +0 -33
- package/dist/extensions/builtins/beads/index.js.map +0 -1
- package/dist/extensions/builtins/todos/import-export.d.ts +0 -26
- package/dist/extensions/builtins/todos/import-export.js.map +0 -1
- package/dist/extensions/builtins/todos/index.d.ts +0 -8
- package/dist/extensions/builtins/todos/index.js +0 -38
- package/dist/extensions/builtins/todos/index.js.map +0 -1
|
@@ -1,24 +1,150 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { cp, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { getActiveExtensionRegistrations } from "../../core/extensions/index.js";
|
|
6
6
|
import { pathExists } from "../../core/fs/fs-utils.js";
|
|
7
|
-
import {
|
|
7
|
+
import { resolveItemTypeRegistry } from "../../core/item/type-registry.js";
|
|
8
|
+
import { createStdinTokenResolver, parseCsvKv, parseOptionalNumber } from "../../core/item/parse.js";
|
|
8
9
|
import { EXIT_CODE } from "../../core/shared/constants.js";
|
|
9
10
|
import { PmCliError } from "../../core/shared/errors.js";
|
|
11
|
+
import { nowIso } from "../../core/shared/time.js";
|
|
10
12
|
import { locateItem, mutateItem, readLocatedItem } from "../../core/store/item-store.js";
|
|
11
|
-
import { getSettingsPath, resolvePmRoot } from "../../core/store/paths.js";
|
|
13
|
+
import { getSettingsPath, ITEM_FILE_EXTENSIONS, resolveGlobalPmRoot, resolvePmRoot } from "../../core/store/paths.js";
|
|
12
14
|
import { readSettings } from "../../core/store/settings.js";
|
|
15
|
+
import { appendTrackedTestRunSummary } from "../../core/test/item-test-run-tracking.js";
|
|
13
16
|
import { runInit } from "./init.js";
|
|
14
17
|
import { SCOPE_VALUES } from "../../types/index.js";
|
|
15
|
-
const exec = promisify(execCb);
|
|
16
18
|
const TEST_OUTPUT_MAX_BUFFER_BYTES = 20 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_LINKED_TEST_TIMEOUT_FORCE_KILL_DELAY_MS = 3000;
|
|
20
|
+
const DEFAULT_LINKED_TEST_HEARTBEAT_INTERVAL_MS = 10000;
|
|
21
|
+
const MAX_LINKED_TEST_COMMAND_LABEL_LENGTH = 120;
|
|
22
|
+
const LINKED_TEST_PROTECTED_ENV_KEYS = new Set(["PM_PATH", "PM_GLOBAL_PATH", "FORCE_COLOR"]);
|
|
23
|
+
const LINKED_TEST_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
24
|
+
const PM_CONTEXT_MODE_VALUES = ["schema", "tracker", "auto"];
|
|
25
|
+
const LINKED_TEST_TRACKER_DIRS_TO_SKIP = new Set(["locks", "extensions"]);
|
|
26
|
+
const LINKED_TEST_ITEM_COUNT_DIRS_TO_SKIP = new Set(["history", "index", "search", "extensions", "locks"]);
|
|
27
|
+
const LINKED_TEST_INFRA_COLLISION_PATTERNS = [
|
|
28
|
+
/eaddrinuse/i,
|
|
29
|
+
/address already in use/i,
|
|
30
|
+
/port\s+\d+\s+is already in use/i,
|
|
31
|
+
/web server[^.\n]*already running/i,
|
|
32
|
+
/failed to listen on/i,
|
|
33
|
+
];
|
|
34
|
+
const PM_SUBCOMMANDS_WITH_ITEM_REFERENCE = new Set([
|
|
35
|
+
"get",
|
|
36
|
+
"history",
|
|
37
|
+
"restore",
|
|
38
|
+
"update",
|
|
39
|
+
"close",
|
|
40
|
+
"delete",
|
|
41
|
+
"append",
|
|
42
|
+
"claim",
|
|
43
|
+
"release",
|
|
44
|
+
"comments",
|
|
45
|
+
"notes",
|
|
46
|
+
"learnings",
|
|
47
|
+
"files",
|
|
48
|
+
"docs",
|
|
49
|
+
"deps",
|
|
50
|
+
"test",
|
|
51
|
+
]);
|
|
52
|
+
const PM_TRACKER_READ_SUBCOMMANDS = new Set([
|
|
53
|
+
"activity",
|
|
54
|
+
"calendar",
|
|
55
|
+
"context",
|
|
56
|
+
"ctx",
|
|
57
|
+
"deps",
|
|
58
|
+
"get",
|
|
59
|
+
"health",
|
|
60
|
+
"history",
|
|
61
|
+
"list",
|
|
62
|
+
"list-all",
|
|
63
|
+
"list-blocked",
|
|
64
|
+
"list-canceled",
|
|
65
|
+
"list-closed",
|
|
66
|
+
"list-draft",
|
|
67
|
+
"list-in-progress",
|
|
68
|
+
"list-open",
|
|
69
|
+
"search",
|
|
70
|
+
"stats",
|
|
71
|
+
"test-all",
|
|
72
|
+
"validate",
|
|
73
|
+
]);
|
|
74
|
+
function readPositiveIntegerEnv(name, fallback) {
|
|
75
|
+
const raw = process.env[name];
|
|
76
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
77
|
+
return fallback;
|
|
78
|
+
}
|
|
79
|
+
const parsed = Number.parseInt(raw, 10);
|
|
80
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
function linkedTestTimeoutForceKillDelayMs() {
|
|
86
|
+
return readPositiveIntegerEnv("PM_LINKED_TEST_TIMEOUT_FORCE_KILL_DELAY_MS", DEFAULT_LINKED_TEST_TIMEOUT_FORCE_KILL_DELAY_MS);
|
|
87
|
+
}
|
|
88
|
+
function linkedTestHeartbeatIntervalMs() {
|
|
89
|
+
return readPositiveIntegerEnv("PM_LINKED_TEST_HEARTBEAT_INTERVAL_MS", DEFAULT_LINKED_TEST_HEARTBEAT_INTERVAL_MS);
|
|
90
|
+
}
|
|
17
91
|
function resolveAuthor(candidate, fallback) {
|
|
18
92
|
const resolved = candidate ?? process.env.PM_AUTHOR ?? fallback;
|
|
19
93
|
const trimmed = resolved.trim();
|
|
20
94
|
return trimmed || "unknown";
|
|
21
95
|
}
|
|
96
|
+
function resolveTrackedRunId(kind) {
|
|
97
|
+
const fromEnv = process.env.PM_BACKGROUND_TEST_RUN_ID?.trim();
|
|
98
|
+
if (fromEnv && fromEnv.length > 0) {
|
|
99
|
+
return fromEnv;
|
|
100
|
+
}
|
|
101
|
+
return `${kind}-local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
102
|
+
}
|
|
103
|
+
function summarizeRunResultStatuses(results) {
|
|
104
|
+
let passed = 0;
|
|
105
|
+
let failed = 0;
|
|
106
|
+
let skipped = 0;
|
|
107
|
+
for (const entry of results) {
|
|
108
|
+
if (entry.status === "passed") {
|
|
109
|
+
passed += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (entry.status === "failed") {
|
|
113
|
+
failed += 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
skipped += 1;
|
|
117
|
+
}
|
|
118
|
+
return { passed, failed, skipped };
|
|
119
|
+
}
|
|
120
|
+
export function summarizeContextPreflight(runResults) {
|
|
121
|
+
let checkedPmCommands = 0;
|
|
122
|
+
let trackerReadCommands = 0;
|
|
123
|
+
let mismatches = 0;
|
|
124
|
+
let autoRemediated = 0;
|
|
125
|
+
for (const result of runResults) {
|
|
126
|
+
const context = result.execution_context;
|
|
127
|
+
if (!context || context.is_pm_command !== true) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
checkedPmCommands += 1;
|
|
131
|
+
if (context.is_pm_tracker_read_command === true) {
|
|
132
|
+
trackerReadCommands += 1;
|
|
133
|
+
}
|
|
134
|
+
if (context.mismatch_detected === true) {
|
|
135
|
+
mismatches += 1;
|
|
136
|
+
}
|
|
137
|
+
if (context.auto_pm_context_applied === true) {
|
|
138
|
+
autoRemediated += 1;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
checked_pm_commands: checkedPmCommands,
|
|
143
|
+
tracker_read_commands: trackerReadCommands,
|
|
144
|
+
mismatches,
|
|
145
|
+
auto_remediated: autoRemediated,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
22
148
|
function ensureScope(raw) {
|
|
23
149
|
const value = (raw ?? "project");
|
|
24
150
|
if (!SCOPE_VALUES.includes(value)) {
|
|
@@ -26,6 +152,242 @@ function ensureScope(raw) {
|
|
|
26
152
|
}
|
|
27
153
|
return value;
|
|
28
154
|
}
|
|
155
|
+
function parseLinkedTestBooleanValue(raw, optionName, fieldLabel) {
|
|
156
|
+
if (!raw || raw.trim().length === 0) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const normalized = raw.trim().toLowerCase();
|
|
160
|
+
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
throw new PmCliError(`${optionName} ${fieldLabel} must be one of true|false|1|0|yes|no`, EXIT_CODE.USAGE);
|
|
167
|
+
}
|
|
168
|
+
function parseLinkedTestEnvSetValue(raw, optionName) {
|
|
169
|
+
if (!raw || raw.trim().length === 0) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
const assignments = raw
|
|
173
|
+
.split(/[;\n]/)
|
|
174
|
+
.map((entry) => entry.trim())
|
|
175
|
+
.filter((entry) => entry.length > 0);
|
|
176
|
+
if (assignments.length === 0) {
|
|
177
|
+
throw new PmCliError(`${optionName} env_set must include at least one KEY=VALUE assignment`, EXIT_CODE.USAGE);
|
|
178
|
+
}
|
|
179
|
+
const envSet = {};
|
|
180
|
+
for (const assignment of assignments) {
|
|
181
|
+
const separatorIndex = assignment.indexOf("=");
|
|
182
|
+
if (separatorIndex <= 0) {
|
|
183
|
+
throw new PmCliError(`${optionName} env_set entries must use KEY=VALUE and be separated by semicolons. Example: env_set=PORT=0;PLAYWRIGHT_BASE_URL=http://127.0.0.1:4173`, EXIT_CODE.USAGE);
|
|
184
|
+
}
|
|
185
|
+
const key = assignment.slice(0, separatorIndex).trim();
|
|
186
|
+
const value = assignment.slice(separatorIndex + 1);
|
|
187
|
+
if (!LINKED_TEST_ENV_NAME_PATTERN.test(key)) {
|
|
188
|
+
throw new PmCliError(`${optionName} env_set key "${key}" is invalid`, EXIT_CODE.USAGE);
|
|
189
|
+
}
|
|
190
|
+
if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
|
|
191
|
+
throw new PmCliError(`${optionName} env_set key "${key}" is reserved for sandbox safety`, EXIT_CODE.USAGE);
|
|
192
|
+
}
|
|
193
|
+
envSet[key] = value;
|
|
194
|
+
}
|
|
195
|
+
return Object.keys(envSet).length > 0 ? envSet : undefined;
|
|
196
|
+
}
|
|
197
|
+
function parseLinkedTestEnvClearValue(raw, optionName) {
|
|
198
|
+
if (!raw || raw.trim().length === 0) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const values = [...new Set(raw.split(/[;,\n]/).map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
|
|
202
|
+
if (values.length === 0) {
|
|
203
|
+
throw new PmCliError(`${optionName} env_clear must include at least one environment variable name`, EXIT_CODE.USAGE);
|
|
204
|
+
}
|
|
205
|
+
for (const key of values) {
|
|
206
|
+
if (!LINKED_TEST_ENV_NAME_PATTERN.test(key)) {
|
|
207
|
+
throw new PmCliError(`${optionName} env_clear key "${key}" is invalid`, EXIT_CODE.USAGE);
|
|
208
|
+
}
|
|
209
|
+
if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
|
|
210
|
+
throw new PmCliError(`${optionName} env_clear key "${key}" is reserved for sandbox safety`, EXIT_CODE.USAGE);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return values;
|
|
214
|
+
}
|
|
215
|
+
function parseLinkedTestStringList(raw) {
|
|
216
|
+
if (!raw || raw.trim().length === 0) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const values = [...new Set(raw.split(/[;\n]/).map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
|
|
220
|
+
return values.length > 0 ? values : undefined;
|
|
221
|
+
}
|
|
222
|
+
function parseLinkedTestRegexList(raw, optionName, fieldLabel) {
|
|
223
|
+
const values = parseLinkedTestStringList(raw);
|
|
224
|
+
if (!values || values.length === 0) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
for (const pattern of values) {
|
|
228
|
+
try {
|
|
229
|
+
// Validate syntax when linked-test metadata is added.
|
|
230
|
+
new RegExp(pattern, "m");
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
throw new PmCliError(`${optionName} ${fieldLabel} includes invalid regex "${pattern}": ${error instanceof Error ? error.message : String(error)}`, EXIT_CODE.USAGE);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return values;
|
|
237
|
+
}
|
|
238
|
+
function parseLinkedTestMinLines(raw, optionName) {
|
|
239
|
+
if (!raw || raw.trim().length === 0) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
const parsed = parseOptionalNumber(raw, "assert_stdout_min_lines");
|
|
243
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
244
|
+
throw new PmCliError(`${optionName} assert_stdout_min_lines must be an integer >= 0`, EXIT_CODE.USAGE);
|
|
245
|
+
}
|
|
246
|
+
return parsed;
|
|
247
|
+
}
|
|
248
|
+
function parseLinkedTestAssertionEqualsMap(raw, optionName) {
|
|
249
|
+
if (!raw || raw.trim().length === 0) {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const assignments = raw
|
|
253
|
+
.split(/[;\n]/)
|
|
254
|
+
.map((entry) => entry.trim())
|
|
255
|
+
.filter((entry) => entry.length > 0);
|
|
256
|
+
if (assignments.length === 0) {
|
|
257
|
+
throw new PmCliError(`${optionName} assert_json_field_equals must include at least one path=value assignment`, EXIT_CODE.USAGE);
|
|
258
|
+
}
|
|
259
|
+
const values = {};
|
|
260
|
+
for (const assignment of assignments) {
|
|
261
|
+
const separatorIndex = assignment.indexOf("=");
|
|
262
|
+
if (separatorIndex <= 0) {
|
|
263
|
+
throw new PmCliError(`${optionName} assert_json_field_equals entries must use path=value and be separated by semicolons`, EXIT_CODE.USAGE);
|
|
264
|
+
}
|
|
265
|
+
const key = assignment.slice(0, separatorIndex).trim();
|
|
266
|
+
const value = assignment.slice(separatorIndex + 1).trim();
|
|
267
|
+
if (key.length === 0 || value.length === 0) {
|
|
268
|
+
throw new PmCliError(`${optionName} assert_json_field_equals entries must include non-empty path and value`, EXIT_CODE.USAGE);
|
|
269
|
+
}
|
|
270
|
+
values[key] = value;
|
|
271
|
+
}
|
|
272
|
+
return Object.keys(values).length > 0 ? values : undefined;
|
|
273
|
+
}
|
|
274
|
+
function parseLinkedTestAssertionGteMap(raw, optionName) {
|
|
275
|
+
if (!raw || raw.trim().length === 0) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
const assignments = raw
|
|
279
|
+
.split(/[;\n]/)
|
|
280
|
+
.map((entry) => entry.trim())
|
|
281
|
+
.filter((entry) => entry.length > 0);
|
|
282
|
+
if (assignments.length === 0) {
|
|
283
|
+
throw new PmCliError(`${optionName} assert_json_field_gte must include at least one path=value assignment`, EXIT_CODE.USAGE);
|
|
284
|
+
}
|
|
285
|
+
const values = {};
|
|
286
|
+
for (const assignment of assignments) {
|
|
287
|
+
const separatorIndex = assignment.indexOf("=");
|
|
288
|
+
if (separatorIndex <= 0) {
|
|
289
|
+
throw new PmCliError(`${optionName} assert_json_field_gte entries must use path=value and be separated by semicolons`, EXIT_CODE.USAGE);
|
|
290
|
+
}
|
|
291
|
+
const key = assignment.slice(0, separatorIndex).trim();
|
|
292
|
+
const valueRaw = assignment.slice(separatorIndex + 1).trim();
|
|
293
|
+
if (key.length === 0 || valueRaw.length === 0) {
|
|
294
|
+
throw new PmCliError(`${optionName} assert_json_field_gte entries must include non-empty path and value`, EXIT_CODE.USAGE);
|
|
295
|
+
}
|
|
296
|
+
const value = Number.parseFloat(valueRaw);
|
|
297
|
+
if (!Number.isFinite(value)) {
|
|
298
|
+
throw new PmCliError(`${optionName} assert_json_field_gte value for "${key}" must be numeric`, EXIT_CODE.USAGE);
|
|
299
|
+
}
|
|
300
|
+
values[key] = value;
|
|
301
|
+
}
|
|
302
|
+
return Object.keys(values).length > 0 ? values : undefined;
|
|
303
|
+
}
|
|
304
|
+
function parseLinkedTestContextModeValue(raw, optionName) {
|
|
305
|
+
if (!raw || raw.trim().length === 0) {
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
const normalized = raw.trim().toLowerCase();
|
|
309
|
+
if (PM_CONTEXT_MODE_VALUES.includes(normalized)) {
|
|
310
|
+
return normalized;
|
|
311
|
+
}
|
|
312
|
+
throw new PmCliError(`${optionName} pm_context_mode must be one of: ${PM_CONTEXT_MODE_VALUES.join(", ")}`, EXIT_CODE.USAGE);
|
|
313
|
+
}
|
|
314
|
+
function parsePmContextMode(raw) {
|
|
315
|
+
if (!raw) {
|
|
316
|
+
return "schema";
|
|
317
|
+
}
|
|
318
|
+
const normalized = raw.trim().toLowerCase();
|
|
319
|
+
if (PM_CONTEXT_MODE_VALUES.includes(normalized)) {
|
|
320
|
+
return normalized;
|
|
321
|
+
}
|
|
322
|
+
throw new PmCliError(`Invalid --pm-context value "${raw}". Expected one of: ${PM_CONTEXT_MODE_VALUES.join(", ")}`, EXIT_CODE.USAGE);
|
|
323
|
+
}
|
|
324
|
+
function resolveLinkedTestRequestedContextMode(linkedTest, runLevelMode, overrideLinkedPmContext) {
|
|
325
|
+
if (overrideLinkedPmContext) {
|
|
326
|
+
return runLevelMode;
|
|
327
|
+
}
|
|
328
|
+
if (typeof linkedTest.pm_context_mode !== "string" || linkedTest.pm_context_mode.trim().length === 0) {
|
|
329
|
+
return runLevelMode;
|
|
330
|
+
}
|
|
331
|
+
return parsePmContextMode(linkedTest.pm_context_mode);
|
|
332
|
+
}
|
|
333
|
+
function resolveLinkedTestEffectiveContextMode(requestedMode, isPmTrackerReadCommand) {
|
|
334
|
+
if (requestedMode === "auto") {
|
|
335
|
+
return isPmTrackerReadCommand ? "tracker" : "schema";
|
|
336
|
+
}
|
|
337
|
+
return requestedMode;
|
|
338
|
+
}
|
|
339
|
+
function hasLinkedTestAssertions(linkedTest) {
|
|
340
|
+
return ((linkedTest.assert_stdout_contains?.length ?? 0) > 0 ||
|
|
341
|
+
(linkedTest.assert_stdout_regex?.length ?? 0) > 0 ||
|
|
342
|
+
(linkedTest.assert_stderr_contains?.length ?? 0) > 0 ||
|
|
343
|
+
(linkedTest.assert_stderr_regex?.length ?? 0) > 0 ||
|
|
344
|
+
typeof linkedTest.assert_stdout_min_lines === "number" ||
|
|
345
|
+
Object.keys(linkedTest.assert_json_field_equals ?? {}).length > 0 ||
|
|
346
|
+
Object.keys(linkedTest.assert_json_field_gte ?? {}).length > 0);
|
|
347
|
+
}
|
|
348
|
+
function buildPmContextMismatchHint(params) {
|
|
349
|
+
const { executionContext, runLevelPmContextMode, linkedOverridePmContextMode } = params;
|
|
350
|
+
if (!executionContext.is_pm_tracker_read_command || !executionContext.mismatch_detected) {
|
|
351
|
+
return "";
|
|
352
|
+
}
|
|
353
|
+
if (runLevelPmContextMode === "tracker" && linkedOverridePmContextMode === "schema") {
|
|
354
|
+
return (" Linked test metadata pm_context_mode=schema overrides run-level --pm-context tracker." +
|
|
355
|
+
" Set pm_context_mode=tracker (or auto) on the linked test, or remove the override, to run against seeded tracker data.");
|
|
356
|
+
}
|
|
357
|
+
if (executionContext.pm_context_mode === "schema") {
|
|
358
|
+
return " Use --pm-context tracker to run PM tracker-read commands against seeded tracker data.";
|
|
359
|
+
}
|
|
360
|
+
return "";
|
|
361
|
+
}
|
|
362
|
+
function mergeEnvSetDirectives(entries, optionName) {
|
|
363
|
+
const merged = {};
|
|
364
|
+
if (!entries) {
|
|
365
|
+
return merged;
|
|
366
|
+
}
|
|
367
|
+
for (const entry of entries) {
|
|
368
|
+
const parsed = parseLinkedTestEnvSetValue(entry, optionName);
|
|
369
|
+
if (!parsed) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
373
|
+
merged[key] = value;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return merged;
|
|
377
|
+
}
|
|
378
|
+
function mergeEnvClearDirectives(entries, optionName) {
|
|
379
|
+
if (!entries) {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
const values = [];
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
const parsed = parseLinkedTestEnvClearValue(entry, optionName);
|
|
385
|
+
if (parsed) {
|
|
386
|
+
values.push(...parsed);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return [...new Set(values)];
|
|
390
|
+
}
|
|
29
391
|
const PM_GLOBAL_FLAGS_WITH_VALUE = new Set(["--path"]);
|
|
30
392
|
const NPX_FLAGS_WITH_VALUE = new Set(["-p", "--package", "-c", "--call"]);
|
|
31
393
|
const PNPM_GLOBAL_FLAGS_WITH_VALUE = new Set([
|
|
@@ -180,6 +542,124 @@ function parseNpmExecCommand(tokens) {
|
|
|
180
542
|
}
|
|
181
543
|
return parseNpxCommand(parsed.args);
|
|
182
544
|
}
|
|
545
|
+
function resolvePmSubcommandContext(args) {
|
|
546
|
+
let index = 0;
|
|
547
|
+
while (index < args.length) {
|
|
548
|
+
const token = args[index];
|
|
549
|
+
if (token === "--") {
|
|
550
|
+
index += 1;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (token.startsWith("-")) {
|
|
554
|
+
if (PM_GLOBAL_FLAGS_WITH_VALUE.has(token)) {
|
|
555
|
+
index += 2;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
index += 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
subcommand: token,
|
|
563
|
+
remaining: args.slice(index + 1),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
function firstPositionalToken(tokens) {
|
|
569
|
+
for (const token of tokens) {
|
|
570
|
+
if (!token.startsWith("-")) {
|
|
571
|
+
return token;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
function looksLikePrefixedItemId(token, idPrefix) {
|
|
577
|
+
const normalizedPrefix = idPrefix.trim().toLowerCase().replace(/-+$/, "");
|
|
578
|
+
if (normalizedPrefix.length === 0) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
const normalized = token.trim().toLowerCase();
|
|
582
|
+
if (!normalized.startsWith(`${normalizedPrefix}-`)) {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
return normalized.length > normalizedPrefix.length + 1;
|
|
586
|
+
}
|
|
587
|
+
function extractPmInvocationArgsFromSegment(segment) {
|
|
588
|
+
const rawTokens = segment.split(" ").filter((token) => token.length > 0);
|
|
589
|
+
const tokens = stripLeadingEnvAssignments(rawTokens);
|
|
590
|
+
if (tokens.length === 0) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const [executable, ...args] = tokens;
|
|
594
|
+
if (isPmExecutableToken(executable) || isPmCliScriptToken(executable)) {
|
|
595
|
+
return args;
|
|
596
|
+
}
|
|
597
|
+
if (executable === "node" && args.length > 0 && isPmCliScriptToken(args[0])) {
|
|
598
|
+
return args.slice(1);
|
|
599
|
+
}
|
|
600
|
+
if (executable === "npx") {
|
|
601
|
+
const parsed = parseNpxCommand(args);
|
|
602
|
+
if (parsed && (isPmExecutableToken(parsed.command) || isPmCliPackageToken(parsed.command))) {
|
|
603
|
+
return parsed.args;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (executable === "pnpm") {
|
|
607
|
+
const parsed = parsePnpmDlxCommand(args);
|
|
608
|
+
if (parsed && (isPmExecutableToken(parsed.command) || isPmCliPackageToken(parsed.command))) {
|
|
609
|
+
return parsed.args;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (executable === "npm") {
|
|
613
|
+
const parsed = parseNpmExecCommand(args);
|
|
614
|
+
if (parsed && (isPmExecutableToken(parsed.command) || isPmCliPackageToken(parsed.command))) {
|
|
615
|
+
return parsed.args;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
function commandInvokesPmCli(command) {
|
|
621
|
+
const normalizedCommand = normalizeCommandForValidation(command);
|
|
622
|
+
return splitNormalizedCommandSegments(normalizedCommand).some((segment) => extractPmInvocationArgsFromSegment(segment) !== null);
|
|
623
|
+
}
|
|
624
|
+
function commandInvokesPmTrackerReadCommand(command) {
|
|
625
|
+
const normalizedCommand = normalizeCommandForValidation(command);
|
|
626
|
+
return splitNormalizedCommandSegments(normalizedCommand).some((segment) => {
|
|
627
|
+
const invocationArgs = extractPmInvocationArgsFromSegment(segment);
|
|
628
|
+
if (!invocationArgs) {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
const context = resolvePmSubcommandContext(invocationArgs);
|
|
632
|
+
if (!context) {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
return PM_TRACKER_READ_SUBCOMMANDS.has(context.subcommand);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
export function extractReferencedPmItemIdsFromCommand(command, idPrefix = "pm") {
|
|
639
|
+
const normalizedCommand = normalizeCommandForValidation(command);
|
|
640
|
+
const ids = new Set();
|
|
641
|
+
for (const segment of splitNormalizedCommandSegments(normalizedCommand)) {
|
|
642
|
+
const invocationArgs = extractPmInvocationArgsFromSegment(segment);
|
|
643
|
+
if (!invocationArgs) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const context = resolvePmSubcommandContext(invocationArgs);
|
|
647
|
+
if (!context) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (!PM_SUBCOMMANDS_WITH_ITEM_REFERENCE.has(context.subcommand)) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const candidate = firstPositionalToken(context.remaining);
|
|
654
|
+
if (!candidate) {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (looksLikePrefixedItemId(candidate, idPrefix)) {
|
|
658
|
+
ids.add(candidate);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return [...ids].sort((left, right) => left.localeCompare(right));
|
|
662
|
+
}
|
|
183
663
|
function resolveDirectRunnerSubcommand(parsed) {
|
|
184
664
|
if (!parsed) {
|
|
185
665
|
return undefined;
|
|
@@ -314,8 +794,8 @@ function parseAddEntries(raw) {
|
|
|
314
794
|
const kv = parseCsvKv(entry, "--add");
|
|
315
795
|
const command = kv.command?.trim() || undefined;
|
|
316
796
|
const filePath = kv.path?.trim() || undefined;
|
|
317
|
-
if (!command
|
|
318
|
-
throw new PmCliError("--add requires command=<value>
|
|
797
|
+
if (!command) {
|
|
798
|
+
throw new PmCliError("--add requires command=<value> (path=<value> is optional metadata)", EXIT_CODE.USAGE);
|
|
319
799
|
}
|
|
320
800
|
if (command) {
|
|
321
801
|
assertNoRecursiveTestAllCommand(command);
|
|
@@ -328,11 +808,26 @@ function parseAddEntries(raw) {
|
|
|
328
808
|
}
|
|
329
809
|
const timeoutRaw = timeoutSecondsRaw ?? timeoutAliasRaw;
|
|
330
810
|
const timeoutSeconds = timeoutRaw === undefined ? undefined : Math.floor(parseOptionalNumber(timeoutRaw, "timeout_seconds"));
|
|
811
|
+
const envSet = parseLinkedTestEnvSetValue(kv.env_set?.trim(), "--add");
|
|
812
|
+
const envClear = parseLinkedTestEnvClearValue(kv.env_clear?.trim(), "--add");
|
|
813
|
+
const pmContextMode = parseLinkedTestContextModeValue(kv.pm_context_mode?.trim(), "--add");
|
|
814
|
+
const sharedHostSafe = parseLinkedTestBooleanValue(kv.shared_host_safe?.trim(), "--add", "shared_host_safe");
|
|
331
815
|
return {
|
|
332
816
|
command,
|
|
333
817
|
path: filePath,
|
|
334
818
|
scope: ensureScope(kv.scope),
|
|
335
819
|
timeout_seconds: timeoutSeconds,
|
|
820
|
+
pm_context_mode: pmContextMode,
|
|
821
|
+
env_set: envSet,
|
|
822
|
+
env_clear: envClear,
|
|
823
|
+
shared_host_safe: sharedHostSafe,
|
|
824
|
+
assert_stdout_contains: parseLinkedTestStringList(kv.assert_stdout_contains?.trim()),
|
|
825
|
+
assert_stdout_regex: parseLinkedTestRegexList(kv.assert_stdout_regex?.trim(), "--add", "assert_stdout_regex"),
|
|
826
|
+
assert_stderr_contains: parseLinkedTestStringList(kv.assert_stderr_contains?.trim()),
|
|
827
|
+
assert_stderr_regex: parseLinkedTestRegexList(kv.assert_stderr_regex?.trim(), "--add", "assert_stderr_regex"),
|
|
828
|
+
assert_stdout_min_lines: parseLinkedTestMinLines(kv.assert_stdout_min_lines?.trim(), "--add"),
|
|
829
|
+
assert_json_field_equals: parseLinkedTestAssertionEqualsMap(kv.assert_json_field_equals?.trim(), "--add"),
|
|
830
|
+
assert_json_field_gte: parseLinkedTestAssertionGteMap(kv.assert_json_field_gte?.trim(), "--add"),
|
|
336
831
|
note: kv.note?.trim() || undefined,
|
|
337
832
|
};
|
|
338
833
|
});
|
|
@@ -345,7 +840,9 @@ function parseRemoveEntries(raw) {
|
|
|
345
840
|
if (!trimmed) {
|
|
346
841
|
throw new PmCliError("--remove requires command or path value", EXIT_CODE.USAGE);
|
|
347
842
|
}
|
|
348
|
-
if (trimmed.includes("=")
|
|
843
|
+
if (trimmed.includes("=") ||
|
|
844
|
+
/^(?:[-*+]\s+)?(?:path|command)\s*[:=]/i.test(trimmed) ||
|
|
845
|
+
trimmed.startsWith("```")) {
|
|
349
846
|
const kv = parseCsvKv(trimmed, "--remove");
|
|
350
847
|
const value = kv.path ?? kv.command;
|
|
351
848
|
if (!value?.trim()) {
|
|
@@ -356,19 +853,673 @@ function parseRemoveEntries(raw) {
|
|
|
356
853
|
return trimmed;
|
|
357
854
|
});
|
|
358
855
|
}
|
|
359
|
-
|
|
856
|
+
function closeLinkedTestStdin(child) {
|
|
857
|
+
// Force EOF on child stdin so non-interactive runs do not wait on input.
|
|
858
|
+
try {
|
|
859
|
+
child.stdin?.end();
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
// Child stdin can already be closed depending on command startup timing.
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function summarizeLinkedTestCommand(command) {
|
|
866
|
+
const normalized = command.trim().replaceAll(/\s+/g, " ");
|
|
867
|
+
if (normalized.length <= MAX_LINKED_TEST_COMMAND_LABEL_LENGTH) {
|
|
868
|
+
return normalized;
|
|
869
|
+
}
|
|
870
|
+
return `${normalized.slice(0, MAX_LINKED_TEST_COMMAND_LABEL_LENGTH - 3)}...`;
|
|
871
|
+
}
|
|
872
|
+
function shouldEmitLinkedTestProgress(mode) {
|
|
873
|
+
/* c8 ignore start -- reserved for future explicit "off" mode wiring. */
|
|
874
|
+
if (mode === "off") {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
/* c8 ignore stop */
|
|
878
|
+
if (mode === "always") {
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
return process.stderr.isTTY === true;
|
|
882
|
+
}
|
|
883
|
+
function emitLinkedTestProgress(message) {
|
|
884
|
+
try {
|
|
885
|
+
process.stderr.write(`${message}\n`);
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
// Ignore transient stderr write failures.
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function beginLinkedTestProgress(context, mode) {
|
|
892
|
+
if (!shouldEmitLinkedTestProgress(mode)) {
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
const commandLabel = summarizeLinkedTestCommand(context.command);
|
|
896
|
+
const startAt = Date.now();
|
|
897
|
+
emitLinkedTestProgress(`[pm test] linked-test ${context.index}/${context.total} start timeout_ms=${context.timeoutMs} command="${commandLabel}"`);
|
|
898
|
+
const heartbeat = setInterval(() => {
|
|
899
|
+
const elapsedMs = Date.now() - startAt;
|
|
900
|
+
emitLinkedTestProgress(`[pm test] linked-test ${context.index}/${context.total} running elapsed_ms=${elapsedMs} command="${commandLabel}"`);
|
|
901
|
+
}, linkedTestHeartbeatIntervalMs());
|
|
902
|
+
heartbeat.unref?.();
|
|
903
|
+
return heartbeat;
|
|
904
|
+
}
|
|
905
|
+
function endLinkedTestProgress(context, executionResult, startedAt, mode) {
|
|
906
|
+
if (!shouldEmitLinkedTestProgress(mode)) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const commandLabel = summarizeLinkedTestCommand(context.command);
|
|
910
|
+
const elapsedMs = Date.now() - startedAt;
|
|
911
|
+
const failed = executionResult.timedOut || executionResult.maxBufferExceeded || executionResult.exitCode !== 0;
|
|
912
|
+
const statusLabel = failed ? "failed" : "passed";
|
|
913
|
+
const reasonTokens = [];
|
|
914
|
+
if (executionResult.timedOut) {
|
|
915
|
+
reasonTokens.push("reason=timeout");
|
|
916
|
+
}
|
|
917
|
+
if (executionResult.maxBufferExceeded) {
|
|
918
|
+
reasonTokens.push("reason=max_buffer");
|
|
919
|
+
}
|
|
920
|
+
if (executionResult.signal) {
|
|
921
|
+
reasonTokens.push(`signal=${executionResult.signal}`);
|
|
922
|
+
}
|
|
923
|
+
const exitLabel = executionResult.exitCode === null ? "null" : String(executionResult.exitCode);
|
|
924
|
+
const reasonSuffix = reasonTokens.length > 0 ? ` ${reasonTokens.join(" ")}` : "";
|
|
925
|
+
emitLinkedTestProgress(`[pm test] linked-test ${context.index}/${context.total} end status=${statusLabel} exit_code=${exitLabel} elapsed_ms=${elapsedMs}${reasonSuffix} command="${commandLabel}"`);
|
|
926
|
+
}
|
|
927
|
+
/* c8 ignore start -- process-tree teardown paths are highly platform-dependent. */
|
|
928
|
+
async function killProcessTree(pid) {
|
|
929
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (process.platform === "win32") {
|
|
933
|
+
await new Promise((resolve) => {
|
|
934
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
935
|
+
stdio: "ignore",
|
|
936
|
+
windowsHide: true,
|
|
937
|
+
});
|
|
938
|
+
killer.on("error", () => resolve());
|
|
939
|
+
killer.on("close", () => resolve());
|
|
940
|
+
});
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
try {
|
|
944
|
+
process.kill(-pid, "SIGKILL");
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
// Fall back to direct child kill when no process group is available.
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
process.kill(pid, "SIGKILL");
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// The process can already be gone.
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/* c8 ignore stop */
|
|
958
|
+
async function runLinkedTestCommand(command, timeoutMs, env, progressContext, progressMode) {
|
|
959
|
+
const startedAt = Date.now();
|
|
960
|
+
const heartbeat = beginLinkedTestProgress(progressContext, progressMode);
|
|
961
|
+
const child = spawn(command, {
|
|
962
|
+
cwd: process.cwd(),
|
|
963
|
+
env,
|
|
964
|
+
shell: true,
|
|
965
|
+
windowsHide: true,
|
|
966
|
+
detached: process.platform !== "win32",
|
|
967
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
968
|
+
});
|
|
969
|
+
closeLinkedTestStdin(child);
|
|
970
|
+
let stdout = "";
|
|
971
|
+
let stderr = "";
|
|
972
|
+
let stdoutBytes = 0;
|
|
973
|
+
let stderrBytes = 0;
|
|
974
|
+
let timedOut = false;
|
|
975
|
+
let maxBufferExceeded = false;
|
|
976
|
+
let spawnError;
|
|
977
|
+
let forceKillTimer = null;
|
|
978
|
+
let timedOutTimer = null;
|
|
979
|
+
let terminationRequested = false;
|
|
980
|
+
const clearTimers = () => {
|
|
981
|
+
if (heartbeat) {
|
|
982
|
+
clearInterval(heartbeat);
|
|
983
|
+
}
|
|
984
|
+
if (timedOutTimer) {
|
|
985
|
+
clearTimeout(timedOutTimer);
|
|
986
|
+
timedOutTimer = null;
|
|
987
|
+
}
|
|
988
|
+
if (forceKillTimer) {
|
|
989
|
+
clearTimeout(forceKillTimer);
|
|
990
|
+
forceKillTimer = null;
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
/* c8 ignore start -- timeout termination branches depend on scheduler/process-group timing. */
|
|
994
|
+
const requestTermination = async () => {
|
|
995
|
+
if (terminationRequested) {
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
terminationRequested = true;
|
|
999
|
+
const pid = child.pid;
|
|
1000
|
+
if (!pid || pid <= 0) {
|
|
1001
|
+
try {
|
|
1002
|
+
child.kill("SIGTERM");
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
// Child can already be closed.
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (process.platform === "win32") {
|
|
1010
|
+
await killProcessTree(pid);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
try {
|
|
1014
|
+
process.kill(-pid, "SIGTERM");
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
/* c8 ignore next 4 -- platform-specific process-group fallback path. */
|
|
1018
|
+
try {
|
|
1019
|
+
child.kill("SIGTERM");
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
// Child can already be closed.
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/* c8 ignore next 3 -- exercised only when timeout escalation triggers force-kill fallback. */
|
|
1026
|
+
forceKillTimer = setTimeout(() => {
|
|
1027
|
+
void killProcessTree(pid);
|
|
1028
|
+
}, linkedTestTimeoutForceKillDelayMs());
|
|
1029
|
+
forceKillTimer.unref?.();
|
|
1030
|
+
};
|
|
1031
|
+
/* c8 ignore stop */
|
|
1032
|
+
const appendChunk = (chunk, target) => {
|
|
1033
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
1034
|
+
const bytes = Buffer.byteLength(text);
|
|
1035
|
+
if (target === "stdout") {
|
|
1036
|
+
stdoutBytes += bytes;
|
|
1037
|
+
if (stdoutBytes <= TEST_OUTPUT_MAX_BUFFER_BYTES) {
|
|
1038
|
+
stdout += text;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
stderrBytes += bytes;
|
|
1043
|
+
if (stderrBytes <= TEST_OUTPUT_MAX_BUFFER_BYTES) {
|
|
1044
|
+
stderr += text;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (!maxBufferExceeded && (stdoutBytes > TEST_OUTPUT_MAX_BUFFER_BYTES || stderrBytes > TEST_OUTPUT_MAX_BUFFER_BYTES)) {
|
|
1048
|
+
maxBufferExceeded = true;
|
|
1049
|
+
void requestTermination();
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
child.stdout?.on("data", (chunk) => appendChunk(chunk, "stdout"));
|
|
1053
|
+
child.stderr?.on("data", (chunk) => appendChunk(chunk, "stderr"));
|
|
1054
|
+
/* c8 ignore next 5 -- shell spawn error callbacks are non-deterministic across platforms. */
|
|
1055
|
+
child.on("error", (error) => {
|
|
1056
|
+
spawnError = error.message;
|
|
1057
|
+
});
|
|
1058
|
+
/* c8 ignore next 4 -- callback scheduling timing is non-deterministic under coverage instrumentation. */
|
|
1059
|
+
timedOutTimer = setTimeout(() => {
|
|
1060
|
+
timedOut = true;
|
|
1061
|
+
void requestTermination();
|
|
1062
|
+
}, timeoutMs);
|
|
1063
|
+
timedOutTimer.unref?.();
|
|
1064
|
+
const { code, signal } = await new Promise((resolve) => {
|
|
1065
|
+
child.on("close", (closeCode, closeSignal) => {
|
|
1066
|
+
resolve({
|
|
1067
|
+
code: closeCode,
|
|
1068
|
+
signal: closeSignal,
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
});
|
|
1072
|
+
clearTimers();
|
|
1073
|
+
const executionResult = {
|
|
1074
|
+
stdout,
|
|
1075
|
+
stderr,
|
|
1076
|
+
exitCode: code,
|
|
1077
|
+
signal,
|
|
1078
|
+
timedOut,
|
|
1079
|
+
maxBufferExceeded,
|
|
1080
|
+
spawnError,
|
|
1081
|
+
};
|
|
1082
|
+
endLinkedTestProgress(progressContext, executionResult, startedAt, progressMode);
|
|
1083
|
+
return executionResult;
|
|
1084
|
+
}
|
|
1085
|
+
function formatLinkedTestExecutionError(result, timeoutMs) {
|
|
1086
|
+
const details = [];
|
|
1087
|
+
if (result.maxBufferExceeded) {
|
|
1088
|
+
details.push(`Linked test output exceeded maxBuffer=${TEST_OUTPUT_MAX_BUFFER_BYTES} bytes. Reduce output volume or split the command.`);
|
|
1089
|
+
}
|
|
1090
|
+
if (result.timedOut && timeoutMs > 0) {
|
|
1091
|
+
details.push(`Linked test timed out after ${timeoutMs}ms.`);
|
|
1092
|
+
}
|
|
1093
|
+
const signalMessage = result.signal ? `Linked test command terminated by signal ${result.signal}.` : undefined;
|
|
1094
|
+
const baseMessage = result.spawnError?.trim() || signalMessage || "Linked test command failed.";
|
|
1095
|
+
if (details.length === 0) {
|
|
1096
|
+
return baseMessage;
|
|
1097
|
+
}
|
|
1098
|
+
return `${baseMessage} ${details.join(" ")}`;
|
|
1099
|
+
}
|
|
1100
|
+
function hasInfraCollisionSignal(result) {
|
|
1101
|
+
const combined = [result.spawnError ?? "", result.stderr, result.stdout].join("\n");
|
|
1102
|
+
return LINKED_TEST_INFRA_COLLISION_PATTERNS.some((pattern) => pattern.test(combined));
|
|
1103
|
+
}
|
|
1104
|
+
export function classifyLinkedTestFailure(result) {
|
|
1105
|
+
if (hasInfraCollisionSignal(result)) {
|
|
1106
|
+
return "infra_collision";
|
|
1107
|
+
}
|
|
1108
|
+
if (result.timedOut) {
|
|
1109
|
+
return "timeout";
|
|
1110
|
+
}
|
|
1111
|
+
if (result.maxBufferExceeded) {
|
|
1112
|
+
return "max_buffer";
|
|
1113
|
+
}
|
|
1114
|
+
if (result.spawnError) {
|
|
1115
|
+
return "spawn_error";
|
|
1116
|
+
}
|
|
1117
|
+
if (result.signal) {
|
|
1118
|
+
return "signal";
|
|
1119
|
+
}
|
|
1120
|
+
return "assertion_failure";
|
|
1121
|
+
}
|
|
1122
|
+
function createEmptyFailureCategoryCounts() {
|
|
1123
|
+
return {
|
|
1124
|
+
infra_collision: 0,
|
|
1125
|
+
assertion_failure: 0,
|
|
1126
|
+
empty_run: 0,
|
|
1127
|
+
timeout: 0,
|
|
1128
|
+
max_buffer: 0,
|
|
1129
|
+
spawn_error: 0,
|
|
1130
|
+
signal: 0,
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
export function countFailureCategories(runResults) {
|
|
1134
|
+
const counts = createEmptyFailureCategoryCounts();
|
|
1135
|
+
for (const result of runResults) {
|
|
1136
|
+
if (result.status !== "failed" || !result.failure_category) {
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
counts[result.failure_category] += 1;
|
|
1140
|
+
}
|
|
1141
|
+
return counts;
|
|
1142
|
+
}
|
|
1143
|
+
function applyEnvDirectiveStage(env, directives) {
|
|
1144
|
+
for (const [key, value] of Object.entries(directives.env_set)) {
|
|
1145
|
+
if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
env[key] = value;
|
|
1149
|
+
}
|
|
1150
|
+
for (const key of directives.env_clear) {
|
|
1151
|
+
if (LINKED_TEST_PROTECTED_ENV_KEYS.has(key.toUpperCase())) {
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
delete env[key];
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
function applySharedHostSafeDefaults(env) {
|
|
1158
|
+
if (env.PORT === undefined) {
|
|
1159
|
+
env.PORT = "0";
|
|
1160
|
+
}
|
|
1161
|
+
if (env.HOST === undefined) {
|
|
1162
|
+
env.HOST = "127.0.0.1";
|
|
1163
|
+
}
|
|
1164
|
+
if (env.PM_SHARED_HOST_SAFE === undefined) {
|
|
1165
|
+
env.PM_SHARED_HOST_SAFE = "1";
|
|
1166
|
+
}
|
|
1167
|
+
if (env.PLAYWRIGHT_HTML_OPEN === undefined) {
|
|
1168
|
+
env.PLAYWRIGHT_HTML_OPEN = "never";
|
|
1169
|
+
}
|
|
1170
|
+
if (env.PW_TEST_HTML_REPORT_OPEN === undefined) {
|
|
1171
|
+
env.PW_TEST_HTML_REPORT_OPEN = "never";
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function resolveEffectiveLinkedTestDirectives(runtimeDirectives, linkedTest) {
|
|
1175
|
+
const envSet = { ...runtimeDirectives.env_set, ...(linkedTest.env_set ?? {}) };
|
|
1176
|
+
const envClear = [...new Set([...runtimeDirectives.env_clear, ...(linkedTest.env_clear ?? [])])];
|
|
1177
|
+
const sharedHostSafe = linkedTest.shared_host_safe ?? runtimeDirectives.shared_host_safe;
|
|
1178
|
+
return {
|
|
1179
|
+
env_set: envSet,
|
|
1180
|
+
env_clear: envClear,
|
|
1181
|
+
shared_host_safe: sharedHostSafe,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
function resolveRuntimeDirectives(envSetEntries, envClearEntries, sharedHostSafe) {
|
|
1185
|
+
return {
|
|
1186
|
+
env_set: mergeEnvSetDirectives(envSetEntries, "--env-set"),
|
|
1187
|
+
env_clear: mergeEnvClearDirectives(envClearEntries, "--env-clear"),
|
|
1188
|
+
shared_host_safe: sharedHostSafe === true,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
async function copyIntoSandboxIfPresent(sourcePath, targetPath, recursive = false) {
|
|
1192
|
+
if (!(await pathExists(sourcePath))) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
1196
|
+
if (recursive) {
|
|
1197
|
+
await cp(sourcePath, targetPath, { recursive: true, force: true });
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
await cp(sourcePath, targetPath, { force: true });
|
|
1201
|
+
}
|
|
1202
|
+
async function seedLinkedTestSandbox(sandboxPmPath, sandboxGlobalPath, sourceRoots) {
|
|
1203
|
+
await copyIntoSandboxIfPresent(getSettingsPath(sourceRoots.projectPmRoot), getSettingsPath(sandboxPmPath));
|
|
1204
|
+
await copyIntoSandboxIfPresent(path.join(sourceRoots.projectPmRoot, "extensions"), path.join(sandboxPmPath, "extensions"), true);
|
|
1205
|
+
await copyIntoSandboxIfPresent(getSettingsPath(sourceRoots.globalPmRoot), getSettingsPath(sandboxGlobalPath));
|
|
1206
|
+
await copyIntoSandboxIfPresent(path.join(sourceRoots.globalPmRoot, "extensions"), path.join(sandboxGlobalPath, "extensions"), true);
|
|
1207
|
+
}
|
|
1208
|
+
async function seedLinkedTestTrackerData(sourceRoot, sandboxRoot) {
|
|
1209
|
+
if (!(await pathExists(sourceRoot))) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const entries = await readdir(sourceRoot, { withFileTypes: true });
|
|
1213
|
+
for (const entry of entries) {
|
|
1214
|
+
if (LINKED_TEST_TRACKER_DIRS_TO_SKIP.has(entry.name)) {
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const sourcePath = path.join(sourceRoot, entry.name);
|
|
1218
|
+
const targetPath = path.join(sandboxRoot, entry.name);
|
|
1219
|
+
if (entry.isDirectory()) {
|
|
1220
|
+
await copyIntoSandboxIfPresent(sourcePath, targetPath, true);
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
if (entry.isFile()) {
|
|
1224
|
+
await copyIntoSandboxIfPresent(sourcePath, targetPath);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
async function countLinkedTestItemFiles(pmRoot) {
|
|
1229
|
+
if (!(await pathExists(pmRoot))) {
|
|
1230
|
+
return 0;
|
|
1231
|
+
}
|
|
1232
|
+
let total = 0;
|
|
1233
|
+
const entries = await readdir(pmRoot, { withFileTypes: true });
|
|
1234
|
+
for (const entry of entries) {
|
|
1235
|
+
if (!entry.isDirectory() || LINKED_TEST_ITEM_COUNT_DIRS_TO_SKIP.has(entry.name)) {
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
const folderPath = path.join(pmRoot, entry.name);
|
|
1239
|
+
let files;
|
|
1240
|
+
try {
|
|
1241
|
+
files = await readdir(folderPath, { withFileTypes: true });
|
|
1242
|
+
}
|
|
1243
|
+
catch {
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
for (const file of files) {
|
|
1247
|
+
if (!file.isFile()) {
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (ITEM_FILE_EXTENSIONS.some((extension) => file.name.toLowerCase().endsWith(extension))) {
|
|
1251
|
+
total += 1;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return total;
|
|
1256
|
+
}
|
|
1257
|
+
export function resolveLinkedTestFailureExitCode(execution) {
|
|
1258
|
+
const rawExitCode = typeof execution.exitCode === "number" ? execution.exitCode : 1;
|
|
1259
|
+
if ((execution.timedOut || execution.maxBufferExceeded) && rawExitCode === 0) {
|
|
1260
|
+
return 1;
|
|
1261
|
+
}
|
|
1262
|
+
return rawExitCode;
|
|
1263
|
+
}
|
|
1264
|
+
function splitJsonPathSegments(fieldPath) {
|
|
1265
|
+
const segments = [];
|
|
1266
|
+
const tokens = fieldPath.match(/[^.[\]]+|\[\d+\]/g) ?? [];
|
|
1267
|
+
for (const token of tokens) {
|
|
1268
|
+
if (token.startsWith("[") && token.endsWith("]")) {
|
|
1269
|
+
const parsedIndex = Number.parseInt(token.slice(1, -1), 10);
|
|
1270
|
+
if (!Number.isInteger(parsedIndex) || parsedIndex < 0) {
|
|
1271
|
+
return [];
|
|
1272
|
+
}
|
|
1273
|
+
segments.push(parsedIndex);
|
|
1274
|
+
continue;
|
|
1275
|
+
}
|
|
1276
|
+
segments.push(token);
|
|
1277
|
+
}
|
|
1278
|
+
return segments;
|
|
1279
|
+
}
|
|
1280
|
+
function readJsonPathValue(root, fieldPath) {
|
|
1281
|
+
const normalizedPath = fieldPath.trim();
|
|
1282
|
+
if (normalizedPath.length === 0) {
|
|
1283
|
+
return { found: false, value: undefined };
|
|
1284
|
+
}
|
|
1285
|
+
const segments = splitJsonPathSegments(normalizedPath);
|
|
1286
|
+
if (segments.length === 0) {
|
|
1287
|
+
return { found: false, value: undefined };
|
|
1288
|
+
}
|
|
1289
|
+
let current = root;
|
|
1290
|
+
for (const segment of segments) {
|
|
1291
|
+
if (typeof segment === "number") {
|
|
1292
|
+
if (!Array.isArray(current) || segment >= current.length) {
|
|
1293
|
+
return { found: false, value: undefined };
|
|
1294
|
+
}
|
|
1295
|
+
current = current[segment];
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
if (typeof current !== "object" || current === null || !(segment in current)) {
|
|
1299
|
+
return { found: false, value: undefined };
|
|
1300
|
+
}
|
|
1301
|
+
current = current[segment];
|
|
1302
|
+
}
|
|
1303
|
+
return { found: true, value: current };
|
|
1304
|
+
}
|
|
1305
|
+
function parseAssertionLiteral(raw) {
|
|
1306
|
+
const trimmed = raw.trim();
|
|
1307
|
+
if (trimmed === "true") {
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
if (trimmed === "false") {
|
|
1311
|
+
return false;
|
|
1312
|
+
}
|
|
1313
|
+
if (trimmed === "null") {
|
|
1314
|
+
return null;
|
|
1315
|
+
}
|
|
1316
|
+
const numeric = Number(trimmed);
|
|
1317
|
+
if (trimmed.length > 0 && Number.isFinite(numeric)) {
|
|
1318
|
+
return numeric;
|
|
1319
|
+
}
|
|
1320
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
1321
|
+
try {
|
|
1322
|
+
return JSON.parse(trimmed);
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
// Fall back to string comparison for malformed JSON literals.
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return trimmed;
|
|
1329
|
+
}
|
|
1330
|
+
function compareAssertionValues(actual, expected) {
|
|
1331
|
+
if (typeof actual === "object" &&
|
|
1332
|
+
actual !== null &&
|
|
1333
|
+
typeof expected === "object" &&
|
|
1334
|
+
expected !== null) {
|
|
1335
|
+
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
1336
|
+
}
|
|
1337
|
+
return Object.is(actual, expected);
|
|
1338
|
+
}
|
|
1339
|
+
function evaluateLinkedTestAssertions(linkedTest, stdout, stderr) {
|
|
1340
|
+
const failures = [];
|
|
1341
|
+
for (const expected of linkedTest.assert_stdout_contains ?? []) {
|
|
1342
|
+
if (!stdout.includes(expected)) {
|
|
1343
|
+
failures.push(`stdout missing required text: "${expected}"`);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
for (const pattern of linkedTest.assert_stdout_regex ?? []) {
|
|
1347
|
+
try {
|
|
1348
|
+
const regex = new RegExp(pattern, "m");
|
|
1349
|
+
if (!regex.test(stdout)) {
|
|
1350
|
+
failures.push(`stdout failed regex assertion: /${pattern}/m`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
failures.push(`stdout regex assertion is invalid: /${pattern}/ (${error instanceof Error ? error.message : String(error)})`);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
for (const expected of linkedTest.assert_stderr_contains ?? []) {
|
|
1358
|
+
if (!stderr.includes(expected)) {
|
|
1359
|
+
failures.push(`stderr missing required text: "${expected}"`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
for (const pattern of linkedTest.assert_stderr_regex ?? []) {
|
|
1363
|
+
try {
|
|
1364
|
+
const regex = new RegExp(pattern, "m");
|
|
1365
|
+
if (!regex.test(stderr)) {
|
|
1366
|
+
failures.push(`stderr failed regex assertion: /${pattern}/m`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
catch (error) {
|
|
1370
|
+
failures.push(`stderr regex assertion is invalid: /${pattern}/ (${error instanceof Error ? error.message : String(error)})`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
if (typeof linkedTest.assert_stdout_min_lines === "number") {
|
|
1374
|
+
const lineCount = stdout
|
|
1375
|
+
.split(/\r?\n/)
|
|
1376
|
+
.map((line) => line.trim())
|
|
1377
|
+
.filter((line) => line.length > 0).length;
|
|
1378
|
+
if (lineCount < linkedTest.assert_stdout_min_lines) {
|
|
1379
|
+
failures.push(`stdout line count ${lineCount} is below required minimum ${linkedTest.assert_stdout_min_lines}`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
const jsonEqualsAssertions = linkedTest.assert_json_field_equals ?? {};
|
|
1383
|
+
const jsonGteAssertions = linkedTest.assert_json_field_gte ?? {};
|
|
1384
|
+
const needsJsonAssertions = Object.keys(jsonEqualsAssertions).length > 0 || Object.keys(jsonGteAssertions).length > 0;
|
|
1385
|
+
if (!needsJsonAssertions) {
|
|
1386
|
+
return failures;
|
|
1387
|
+
}
|
|
1388
|
+
let parsedJson;
|
|
1389
|
+
try {
|
|
1390
|
+
parsedJson = JSON.parse(stdout);
|
|
1391
|
+
}
|
|
1392
|
+
catch (error) {
|
|
1393
|
+
failures.push(`stdout is not valid JSON for assert_json_field_* checks: ${error instanceof Error ? error.message : String(error)}`);
|
|
1394
|
+
return failures;
|
|
1395
|
+
}
|
|
1396
|
+
for (const [fieldPath, expectedRaw] of Object.entries(jsonEqualsAssertions)) {
|
|
1397
|
+
const resolved = readJsonPathValue(parsedJson, fieldPath);
|
|
1398
|
+
if (!resolved.found) {
|
|
1399
|
+
failures.push(`assert_json_field_equals missing path "${fieldPath}"`);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const expected = parseAssertionLiteral(expectedRaw);
|
|
1403
|
+
if (!compareAssertionValues(resolved.value, expected)) {
|
|
1404
|
+
failures.push(`assert_json_field_equals mismatch at "${fieldPath}" (expected=${JSON.stringify(expected)} actual=${JSON.stringify(resolved.value)})`);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
for (const [fieldPath, expectedMinimum] of Object.entries(jsonGteAssertions)) {
|
|
1408
|
+
const resolved = readJsonPathValue(parsedJson, fieldPath);
|
|
1409
|
+
if (!resolved.found) {
|
|
1410
|
+
failures.push(`assert_json_field_gte missing path "${fieldPath}"`);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
if (typeof resolved.value !== "number" || !Number.isFinite(resolved.value)) {
|
|
1414
|
+
failures.push(`assert_json_field_gte path "${fieldPath}" resolved to non-numeric value`);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
if (resolved.value < expectedMinimum) {
|
|
1418
|
+
failures.push(`assert_json_field_gte failed at "${fieldPath}" (expected >= ${expectedMinimum}, actual ${resolved.value})`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return failures;
|
|
1422
|
+
}
|
|
1423
|
+
const EMPTY_LINKED_TEST_RUN_PATTERNS = [
|
|
1424
|
+
{ code: "no_projects_matched_filters", regex: /\bNo projects matched the filters\b/i },
|
|
1425
|
+
{ code: "no_test_files_found", regex: /\bNo test files found\b/i },
|
|
1426
|
+
{ code: "no_tests_found", regex: /\bNo tests found\b/i },
|
|
1427
|
+
{ code: "no_matching_tests", regex: /\bNo matching tests?\b/i },
|
|
1428
|
+
{ code: "collected_zero_items", regex: /\bcollected 0 items?\b/i },
|
|
1429
|
+
];
|
|
1430
|
+
function detectEmptyLinkedTestRun(stdout, stderr) {
|
|
1431
|
+
const combined = `${stdout}\n${stderr}`;
|
|
1432
|
+
for (const pattern of EMPTY_LINKED_TEST_RUN_PATTERNS) {
|
|
1433
|
+
if (pattern.regex.test(combined)) {
|
|
1434
|
+
return pattern.code;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
export async function runLinkedTests(tests, defaultTimeoutSeconds, options) {
|
|
360
1440
|
const results = [];
|
|
361
1441
|
const sandboxRoot = await mkdtemp(path.join(tmpdir(), "pm-linked-test-"));
|
|
362
|
-
const
|
|
363
|
-
const
|
|
1442
|
+
const schemaSandboxPmPath = path.join(sandboxRoot, "schema", "project", ".agents", "pm");
|
|
1443
|
+
const schemaSandboxGlobalPath = path.join(sandboxRoot, "schema", "global");
|
|
1444
|
+
const trackerSandboxPmPath = path.join(sandboxRoot, "tracker", "project", ".agents", "pm");
|
|
1445
|
+
const trackerSandboxGlobalPath = path.join(sandboxRoot, "tracker", "global");
|
|
1446
|
+
const runLevelPmContextMode = parsePmContextMode(options?.pmContext);
|
|
1447
|
+
const progressMode = options?.progress === true ? "always" : "auto";
|
|
1448
|
+
const runtimeDirectives = resolveRuntimeDirectives(options?.envSet, options?.envClear, options?.sharedHostSafe);
|
|
1449
|
+
let sourceProjectItemCount = 0;
|
|
1450
|
+
let sourceGlobalItemCount = 0;
|
|
1451
|
+
let schemaSandboxProjectItemCount = 0;
|
|
1452
|
+
let schemaSandboxGlobalItemCount = 0;
|
|
1453
|
+
let trackerSandboxProjectItemCount = 0;
|
|
1454
|
+
let trackerSandboxGlobalItemCount = 0;
|
|
1455
|
+
const sourceRoots = options?.sourceRoots;
|
|
1456
|
+
const projectExtensionsSeeded = Boolean(sourceRoots);
|
|
1457
|
+
const globalExtensionsSeeded = Boolean(sourceRoots);
|
|
364
1458
|
try {
|
|
365
|
-
await runInit(undefined, { path:
|
|
366
|
-
|
|
1459
|
+
await runInit(undefined, { path: schemaSandboxPmPath });
|
|
1460
|
+
await runInit(undefined, { path: schemaSandboxGlobalPath });
|
|
1461
|
+
await runInit(undefined, { path: trackerSandboxPmPath });
|
|
1462
|
+
await runInit(undefined, { path: trackerSandboxGlobalPath });
|
|
1463
|
+
if (sourceRoots) {
|
|
1464
|
+
await seedLinkedTestSandbox(schemaSandboxPmPath, schemaSandboxGlobalPath, sourceRoots);
|
|
1465
|
+
await seedLinkedTestSandbox(trackerSandboxPmPath, trackerSandboxGlobalPath, sourceRoots);
|
|
1466
|
+
await seedLinkedTestTrackerData(sourceRoots.projectPmRoot, trackerSandboxPmPath);
|
|
1467
|
+
await seedLinkedTestTrackerData(sourceRoots.globalPmRoot, trackerSandboxGlobalPath);
|
|
1468
|
+
sourceProjectItemCount = await countLinkedTestItemFiles(sourceRoots.projectPmRoot);
|
|
1469
|
+
sourceGlobalItemCount = await countLinkedTestItemFiles(sourceRoots.globalPmRoot);
|
|
1470
|
+
}
|
|
1471
|
+
schemaSandboxProjectItemCount = await countLinkedTestItemFiles(schemaSandboxPmPath);
|
|
1472
|
+
schemaSandboxGlobalItemCount = await countLinkedTestItemFiles(schemaSandboxGlobalPath);
|
|
1473
|
+
trackerSandboxProjectItemCount = await countLinkedTestItemFiles(trackerSandboxPmPath);
|
|
1474
|
+
trackerSandboxGlobalItemCount = await countLinkedTestItemFiles(trackerSandboxGlobalPath);
|
|
1475
|
+
const buildExecutionContext = (isPmCommand, isPmTrackerReadCommand, requestedPmContextMode, effectivePmContextMode, autoPmContextApplied) => {
|
|
1476
|
+
const selectedSandboxProjectPmPath = effectivePmContextMode === "tracker" ? trackerSandboxPmPath : schemaSandboxPmPath;
|
|
1477
|
+
const selectedSandboxGlobalPmPath = effectivePmContextMode === "tracker" ? trackerSandboxGlobalPath : schemaSandboxGlobalPath;
|
|
1478
|
+
const selectedSandboxProjectItemCount = effectivePmContextMode === "tracker" ? trackerSandboxProjectItemCount : schemaSandboxProjectItemCount;
|
|
1479
|
+
const selectedSandboxGlobalItemCount = effectivePmContextMode === "tracker" ? trackerSandboxGlobalItemCount : schemaSandboxGlobalItemCount;
|
|
1480
|
+
const mismatchDetected = isPmCommand && sourceProjectItemCount !== selectedSandboxProjectItemCount;
|
|
1481
|
+
return {
|
|
1482
|
+
requested_pm_context_mode: requestedPmContextMode,
|
|
1483
|
+
pm_context_mode: effectivePmContextMode,
|
|
1484
|
+
auto_pm_context_applied: autoPmContextApplied,
|
|
1485
|
+
is_pm_command: isPmCommand,
|
|
1486
|
+
is_pm_tracker_read_command: isPmTrackerReadCommand,
|
|
1487
|
+
source_project_pm_path: sourceRoots?.projectPmRoot ?? "",
|
|
1488
|
+
sandbox_project_pm_path: selectedSandboxProjectPmPath,
|
|
1489
|
+
source_global_pm_path: sourceRoots?.globalPmRoot ?? "",
|
|
1490
|
+
sandbox_global_pm_path: selectedSandboxGlobalPmPath,
|
|
1491
|
+
source_project_item_count: sourceProjectItemCount,
|
|
1492
|
+
sandbox_project_item_count: selectedSandboxProjectItemCount,
|
|
1493
|
+
source_global_item_count: sourceGlobalItemCount,
|
|
1494
|
+
sandbox_global_item_count: selectedSandboxGlobalItemCount,
|
|
1495
|
+
mismatch_detected: mismatchDetected,
|
|
1496
|
+
project_extensions_seeded: projectExtensionsSeeded,
|
|
1497
|
+
global_extensions_seeded: globalExtensionsSeeded,
|
|
1498
|
+
};
|
|
1499
|
+
};
|
|
1500
|
+
for (let index = 0; index < tests.length; index += 1) {
|
|
1501
|
+
const linkedTest = tests[index];
|
|
1502
|
+
const linkedOverridePmContextMode = typeof linkedTest.pm_context_mode === "string" && linkedTest.pm_context_mode.trim().length > 0
|
|
1503
|
+
? parsePmContextMode(linkedTest.pm_context_mode)
|
|
1504
|
+
: undefined;
|
|
1505
|
+
const isPmCommand = typeof linkedTest.command === "string" && linkedTest.command.length > 0
|
|
1506
|
+
? commandInvokesPmCli(linkedTest.command)
|
|
1507
|
+
: false;
|
|
1508
|
+
const isPmTrackerReadCommand = isPmCommand && typeof linkedTest.command === "string" && linkedTest.command.length > 0
|
|
1509
|
+
? commandInvokesPmTrackerReadCommand(linkedTest.command)
|
|
1510
|
+
: false;
|
|
1511
|
+
const autoPmContextApplied = options?.autoPmContext === true && isPmTrackerReadCommand;
|
|
1512
|
+
const requestedPmContextMode = autoPmContextApplied
|
|
1513
|
+
? "auto"
|
|
1514
|
+
: resolveLinkedTestRequestedContextMode(linkedTest, runLevelPmContextMode, options?.overrideLinkedPmContext === true);
|
|
1515
|
+
const effectivePmContextMode = resolveLinkedTestEffectiveContextMode(requestedPmContextMode, isPmTrackerReadCommand);
|
|
1516
|
+
const executionContext = buildExecutionContext(isPmCommand, isPmTrackerReadCommand, requestedPmContextMode, effectivePmContextMode, autoPmContextApplied);
|
|
367
1517
|
if (!linkedTest.command) {
|
|
368
1518
|
results.push({
|
|
369
1519
|
command: linkedTest.command,
|
|
370
1520
|
path: linkedTest.path,
|
|
371
1521
|
status: "skipped",
|
|
1522
|
+
execution_context: executionContext,
|
|
372
1523
|
error: "No command configured for this linked test.",
|
|
373
1524
|
});
|
|
374
1525
|
continue;
|
|
@@ -379,43 +1530,124 @@ export async function runLinkedTests(tests, defaultTimeoutSeconds) {
|
|
|
379
1530
|
command: linkedTest.command,
|
|
380
1531
|
path: linkedTest.path,
|
|
381
1532
|
status: "skipped",
|
|
1533
|
+
execution_context: executionContext,
|
|
382
1534
|
error: runtimeSafetySkipReason,
|
|
383
1535
|
});
|
|
384
1536
|
continue;
|
|
385
1537
|
}
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
PM_GLOBAL_PATH: sandboxGlobalPath,
|
|
396
|
-
},
|
|
1538
|
+
const failOnMismatchByDefault = executionContext.pm_context_mode === "schema" &&
|
|
1539
|
+
executionContext.is_pm_tracker_read_command &&
|
|
1540
|
+
executionContext.mismatch_detected;
|
|
1541
|
+
const failOnMismatchByFlag = options?.failOnContextMismatch === true && executionContext.is_pm_command && executionContext.mismatch_detected;
|
|
1542
|
+
if (failOnMismatchByDefault || failOnMismatchByFlag) {
|
|
1543
|
+
const mismatchHint = buildPmContextMismatchHint({
|
|
1544
|
+
executionContext,
|
|
1545
|
+
runLevelPmContextMode,
|
|
1546
|
+
linkedOverridePmContextMode,
|
|
397
1547
|
});
|
|
1548
|
+
const mismatchPrefix = options?.checkContext === true ? "Linked test preflight PM context mismatch detected" : "Linked test PM context mismatch detected";
|
|
398
1549
|
results.push({
|
|
399
1550
|
command: linkedTest.command,
|
|
400
1551
|
path: linkedTest.path,
|
|
401
|
-
status: "
|
|
402
|
-
exit_code:
|
|
403
|
-
|
|
404
|
-
|
|
1552
|
+
status: "failed",
|
|
1553
|
+
exit_code: 1,
|
|
1554
|
+
failure_category: "assertion_failure",
|
|
1555
|
+
execution_context: executionContext,
|
|
1556
|
+
error: `${mismatchPrefix} (source_project_items=${executionContext.source_project_item_count}, ` +
|
|
1557
|
+
`sandbox_project_items=${executionContext.sandbox_project_item_count}).${mismatchHint}`,
|
|
405
1558
|
});
|
|
1559
|
+
continue;
|
|
406
1560
|
}
|
|
407
|
-
|
|
408
|
-
const err = error;
|
|
1561
|
+
if (options?.requireAssertionsForPm === true && executionContext.is_pm_command && !hasLinkedTestAssertions(linkedTest)) {
|
|
409
1562
|
results.push({
|
|
410
1563
|
command: linkedTest.command,
|
|
411
1564
|
path: linkedTest.path,
|
|
412
1565
|
status: "failed",
|
|
413
|
-
exit_code:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
error:
|
|
1566
|
+
exit_code: 1,
|
|
1567
|
+
failure_category: "assertion_failure",
|
|
1568
|
+
execution_context: executionContext,
|
|
1569
|
+
error: "Linked PM command requires assertions when --require-assertions-for-pm is enabled.",
|
|
1570
|
+
});
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
const timeoutMs = ((linkedTest.timeout_seconds ?? defaultTimeoutSeconds ?? 120) * 1000);
|
|
1574
|
+
const effectiveDirectives = resolveEffectiveLinkedTestDirectives(runtimeDirectives, linkedTest);
|
|
1575
|
+
const executionEnv = { ...process.env };
|
|
1576
|
+
applyEnvDirectiveStage(executionEnv, runtimeDirectives);
|
|
1577
|
+
applyEnvDirectiveStage(executionEnv, {
|
|
1578
|
+
env_set: linkedTest.env_set ?? {},
|
|
1579
|
+
env_clear: linkedTest.env_clear ?? [],
|
|
1580
|
+
});
|
|
1581
|
+
if (effectiveDirectives.shared_host_safe) {
|
|
1582
|
+
applySharedHostSafeDefaults(executionEnv);
|
|
1583
|
+
}
|
|
1584
|
+
executionEnv.FORCE_COLOR = "0";
|
|
1585
|
+
executionEnv.PM_PATH = executionContext.sandbox_project_pm_path;
|
|
1586
|
+
executionEnv.PM_GLOBAL_PATH = executionContext.sandbox_global_pm_path;
|
|
1587
|
+
const execution = await runLinkedTestCommand(linkedTest.command, timeoutMs, executionEnv, {
|
|
1588
|
+
index: index + 1,
|
|
1589
|
+
total: tests.length,
|
|
1590
|
+
timeoutMs,
|
|
1591
|
+
command: linkedTest.command,
|
|
1592
|
+
}, progressMode);
|
|
1593
|
+
const passed = execution.exitCode === 0 && !execution.timedOut && !execution.maxBufferExceeded;
|
|
1594
|
+
if (passed) {
|
|
1595
|
+
if (options?.failOnEmptyTestRun === true) {
|
|
1596
|
+
const emptyRunSignal = detectEmptyLinkedTestRun(execution.stdout, execution.stderr);
|
|
1597
|
+
if (emptyRunSignal) {
|
|
1598
|
+
results.push({
|
|
1599
|
+
command: linkedTest.command,
|
|
1600
|
+
path: linkedTest.path,
|
|
1601
|
+
status: "failed",
|
|
1602
|
+
exit_code: 1,
|
|
1603
|
+
failure_category: "empty_run",
|
|
1604
|
+
execution_context: executionContext,
|
|
1605
|
+
stdout: execution.stdout,
|
|
1606
|
+
stderr: execution.stderr,
|
|
1607
|
+
error: `Linked test reported an empty test run (${emptyRunSignal}) while --fail-on-empty-test-run is enabled. ` +
|
|
1608
|
+
"Update test selection or disable --fail-on-empty-test-run for this run.",
|
|
1609
|
+
});
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
const assertionFailures = evaluateLinkedTestAssertions(linkedTest, execution.stdout, execution.stderr);
|
|
1614
|
+
if (assertionFailures.length > 0) {
|
|
1615
|
+
results.push({
|
|
1616
|
+
command: linkedTest.command,
|
|
1617
|
+
path: linkedTest.path,
|
|
1618
|
+
status: "failed",
|
|
1619
|
+
exit_code: 1,
|
|
1620
|
+
failure_category: "assertion_failure",
|
|
1621
|
+
execution_context: executionContext,
|
|
1622
|
+
stdout: execution.stdout,
|
|
1623
|
+
stderr: execution.stderr,
|
|
1624
|
+
error: `Linked test assertion(s) failed: ${assertionFailures.join("; ")}`,
|
|
1625
|
+
});
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
results.push({
|
|
1629
|
+
command: linkedTest.command,
|
|
1630
|
+
path: linkedTest.path,
|
|
1631
|
+
status: "passed",
|
|
1632
|
+
exit_code: 0,
|
|
1633
|
+
execution_context: executionContext,
|
|
1634
|
+
stdout: execution.stdout,
|
|
1635
|
+
stderr: execution.stderr,
|
|
417
1636
|
});
|
|
1637
|
+
continue;
|
|
418
1638
|
}
|
|
1639
|
+
const failureCategory = classifyLinkedTestFailure(execution);
|
|
1640
|
+
results.push({
|
|
1641
|
+
command: linkedTest.command,
|
|
1642
|
+
path: linkedTest.path,
|
|
1643
|
+
status: "failed",
|
|
1644
|
+
exit_code: resolveLinkedTestFailureExitCode(execution),
|
|
1645
|
+
failure_category: failureCategory,
|
|
1646
|
+
execution_context: executionContext,
|
|
1647
|
+
stdout: execution.stdout,
|
|
1648
|
+
stderr: execution.stderr,
|
|
1649
|
+
error: formatLinkedTestExecutionError(execution, timeoutMs),
|
|
1650
|
+
});
|
|
419
1651
|
}
|
|
420
1652
|
}
|
|
421
1653
|
finally {
|
|
@@ -424,13 +1656,17 @@ export async function runLinkedTests(tests, defaultTimeoutSeconds) {
|
|
|
424
1656
|
return results;
|
|
425
1657
|
}
|
|
426
1658
|
export async function runTest(id, options, global) {
|
|
1659
|
+
const stdinResolver = createStdinTokenResolver();
|
|
427
1660
|
const pmRoot = resolvePmRoot(process.cwd(), global.path);
|
|
428
1661
|
if (!(await pathExists(getSettingsPath(pmRoot)))) {
|
|
429
1662
|
throw new PmCliError(`Tracker is not initialized at ${pmRoot}. Run pm init first.`, EXIT_CODE.NOT_FOUND);
|
|
430
1663
|
}
|
|
431
1664
|
const settings = await readSettings(pmRoot);
|
|
432
|
-
const
|
|
433
|
-
const
|
|
1665
|
+
const typeRegistry = resolveItemTypeRegistry(settings, getActiveExtensionRegistrations());
|
|
1666
|
+
const resolvedAdds = await stdinResolver.resolveList(options.add, "--add");
|
|
1667
|
+
const resolvedRemoves = await stdinResolver.resolveList(options.remove, "--remove");
|
|
1668
|
+
const adds = parseAddEntries(resolvedAdds);
|
|
1669
|
+
const removes = parseRemoveEntries(resolvedRemoves);
|
|
434
1670
|
const shouldMutate = adds.length > 0 || removes.length > 0;
|
|
435
1671
|
let tests = [];
|
|
436
1672
|
let itemId;
|
|
@@ -447,7 +1683,10 @@ export async function runTest(id, options, global) {
|
|
|
447
1683
|
mutate(document) {
|
|
448
1684
|
const next = [...(document.front_matter.tests ?? [])];
|
|
449
1685
|
for (const add of adds) {
|
|
450
|
-
const exists = next.some((entry) => entry.command === add.command &&
|
|
1686
|
+
const exists = next.some((entry) => entry.command === add.command &&
|
|
1687
|
+
entry.path === add.path &&
|
|
1688
|
+
entry.scope === add.scope &&
|
|
1689
|
+
entry.pm_context_mode === add.pm_context_mode);
|
|
451
1690
|
if (!exists) {
|
|
452
1691
|
next.push(add);
|
|
453
1692
|
}
|
|
@@ -468,23 +1707,103 @@ export async function runTest(id, options, global) {
|
|
|
468
1707
|
itemId = result.item.id;
|
|
469
1708
|
}
|
|
470
1709
|
else {
|
|
471
|
-
const located = await locateItem(pmRoot, id, settings.id_prefix);
|
|
1710
|
+
const located = await locateItem(pmRoot, id, settings.id_prefix, settings.item_format, typeRegistry.type_to_folder);
|
|
472
1711
|
if (!located) {
|
|
473
1712
|
throw new PmCliError(`Item ${id} not found`, EXIT_CODE.NOT_FOUND);
|
|
474
1713
|
}
|
|
475
1714
|
itemId = located.id;
|
|
476
|
-
const loaded = await readLocatedItem(located);
|
|
1715
|
+
const loaded = await readLocatedItem(located, { schema: settings.schema });
|
|
477
1716
|
tests = loaded.document.front_matter.tests ?? [];
|
|
478
1717
|
}
|
|
479
1718
|
let defaultTimeoutSeconds;
|
|
480
1719
|
if (options.timeout !== undefined) {
|
|
481
1720
|
defaultTimeoutSeconds = parseOptionalNumber(options.timeout, "timeout");
|
|
482
1721
|
}
|
|
483
|
-
const
|
|
1722
|
+
const pmContextMode = parsePmContextMode(options.pmContext);
|
|
1723
|
+
const hasRuntimeDirectiveFlags = (options.envSet?.length ?? 0) > 0 ||
|
|
1724
|
+
(options.envClear?.length ?? 0) > 0 ||
|
|
1725
|
+
options.sharedHostSafe === true ||
|
|
1726
|
+
options.pmContext !== undefined ||
|
|
1727
|
+
options.overrideLinkedPmContext === true ||
|
|
1728
|
+
options.failOnContextMismatch === true ||
|
|
1729
|
+
options.failOnSkipped === true ||
|
|
1730
|
+
options.failOnEmptyTestRun === true ||
|
|
1731
|
+
options.requireAssertionsForPm === true ||
|
|
1732
|
+
options.checkContext === true ||
|
|
1733
|
+
options.autoPmContext === true;
|
|
1734
|
+
if (hasRuntimeDirectiveFlags && options.run !== true) {
|
|
1735
|
+
throw new PmCliError("--env-set, --env-clear, --shared-host-safe, --pm-context, --override-linked-pm-context, --fail-on-context-mismatch, --fail-on-skipped, --fail-on-empty-test-run, --require-assertions-for-pm, --check-context, and --auto-pm-context require --run", EXIT_CODE.USAGE);
|
|
1736
|
+
}
|
|
1737
|
+
const runStartedAt = options.run === true ? nowIso() : undefined;
|
|
1738
|
+
const runResults = options.run === true
|
|
1739
|
+
? await runLinkedTests(tests, defaultTimeoutSeconds, {
|
|
1740
|
+
progress: options.progress,
|
|
1741
|
+
envSet: options.envSet,
|
|
1742
|
+
envClear: options.envClear,
|
|
1743
|
+
sharedHostSafe: options.sharedHostSafe,
|
|
1744
|
+
pmContext: pmContextMode,
|
|
1745
|
+
overrideLinkedPmContext: options.overrideLinkedPmContext,
|
|
1746
|
+
failOnContextMismatch: options.failOnContextMismatch,
|
|
1747
|
+
failOnEmptyTestRun: options.failOnEmptyTestRun,
|
|
1748
|
+
requireAssertionsForPm: options.requireAssertionsForPm,
|
|
1749
|
+
checkContext: options.checkContext,
|
|
1750
|
+
autoPmContext: options.autoPmContext,
|
|
1751
|
+
sourceRoots: {
|
|
1752
|
+
projectPmRoot: pmRoot,
|
|
1753
|
+
globalPmRoot: resolveGlobalPmRoot(process.cwd()),
|
|
1754
|
+
},
|
|
1755
|
+
})
|
|
1756
|
+
: [];
|
|
1757
|
+
const failureCategories = countFailureCategories(runResults);
|
|
1758
|
+
const failOnSkippedTriggered = options.run === true && options.failOnSkipped === true && runResults.some((entry) => entry.status === "skipped");
|
|
1759
|
+
const warnings = [];
|
|
1760
|
+
if (options.run === true && options.checkContext === true) {
|
|
1761
|
+
const preflight = summarizeContextPreflight(runResults);
|
|
1762
|
+
warnings.push(`context_preflight:checked_pm_commands=${preflight.checked_pm_commands};` +
|
|
1763
|
+
`tracker_read_commands=${preflight.tracker_read_commands};` +
|
|
1764
|
+
`mismatches=${preflight.mismatches};` +
|
|
1765
|
+
`auto_remediated=${preflight.auto_remediated}`);
|
|
1766
|
+
}
|
|
1767
|
+
if (options.run === true && runStartedAt && settings.testing.record_results_to_items === true) {
|
|
1768
|
+
const summary = summarizeRunResultStatuses(runResults);
|
|
1769
|
+
const trackedRunId = resolveTrackedRunId("test");
|
|
1770
|
+
const attemptRaw = process.env.PM_BACKGROUND_TEST_RUN_ATTEMPT?.trim();
|
|
1771
|
+
const parsedAttempt = attemptRaw ? Number.parseInt(attemptRaw, 10) : Number.NaN;
|
|
1772
|
+
const resumedFrom = process.env.PM_BACKGROUND_TEST_RUN_RESUMED_FROM?.trim();
|
|
1773
|
+
try {
|
|
1774
|
+
await appendTrackedTestRunSummary({
|
|
1775
|
+
pmRoot,
|
|
1776
|
+
settings,
|
|
1777
|
+
itemId,
|
|
1778
|
+
author: resolveAuthor(options.author, settings.author_default),
|
|
1779
|
+
message: `Track test run summary (${trackedRunId})`,
|
|
1780
|
+
entry: {
|
|
1781
|
+
run_id: trackedRunId,
|
|
1782
|
+
kind: "test",
|
|
1783
|
+
status: summary.failed > 0 || failOnSkippedTriggered === true ? "failed" : "passed",
|
|
1784
|
+
started_at: runStartedAt,
|
|
1785
|
+
finished_at: nowIso(),
|
|
1786
|
+
recorded_at: nowIso(),
|
|
1787
|
+
attempt: Number.isFinite(parsedAttempt) && parsedAttempt >= 1 ? parsedAttempt : undefined,
|
|
1788
|
+
resumed_from: resumedFrom && resumedFrom.length > 0 ? resumedFrom : undefined,
|
|
1789
|
+
passed: summary.passed,
|
|
1790
|
+
failed: summary.failed,
|
|
1791
|
+
skipped: summary.skipped,
|
|
1792
|
+
fail_on_skipped_triggered: failOnSkippedTriggered ? true : undefined,
|
|
1793
|
+
},
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
catch (error) {
|
|
1797
|
+
warnings.push(`test_result_tracking_failed:${itemId}:${error instanceof Error ? error.message : String(error)}`);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
484
1800
|
return {
|
|
485
1801
|
id: itemId,
|
|
486
1802
|
tests,
|
|
487
1803
|
run_results: runResults,
|
|
1804
|
+
failure_categories: failureCategories,
|
|
1805
|
+
fail_on_skipped_triggered: failOnSkippedTriggered ? true : undefined,
|
|
1806
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
488
1807
|
changed: shouldMutate,
|
|
489
1808
|
count: tests.length,
|
|
490
1809
|
};
|