@ai-agent-forge/plugin-test-runner 0.85.0
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 +44 -0
- package/agent-forge.json +12 -0
- package/dist/capability.d.ts +5 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +138 -0
- package/dist/capability.js.map +1 -0
- package/dist/entry.d.ts +13 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +14 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/node-adapter.d.ts +24 -0
- package/dist/node-adapter.d.ts.map +1 -0
- package/dist/node-adapter.js +448 -0
- package/dist/node-adapter.js.map +1 -0
- package/dist/node-jest-json-report.d.ts +12 -0
- package/dist/node-jest-json-report.d.ts.map +1 -0
- package/dist/node-jest-json-report.js +52 -0
- package/dist/node-jest-json-report.js.map +1 -0
- package/dist/node-jest-runner.d.ts +26 -0
- package/dist/node-jest-runner.d.ts.map +1 -0
- package/dist/node-jest-runner.js +51 -0
- package/dist/node-jest-runner.js.map +1 -0
- package/dist/node-junit-xml-report.d.ts +11 -0
- package/dist/node-junit-xml-report.d.ts.map +1 -0
- package/dist/node-junit-xml-report.js +76 -0
- package/dist/node-junit-xml-report.js.map +1 -0
- package/dist/node-pytest-runner.d.ts +34 -0
- package/dist/node-pytest-runner.d.ts.map +1 -0
- package/dist/node-pytest-runner.js +111 -0
- package/dist/node-pytest-runner.js.map +1 -0
- package/dist/node-test-process.d.ts +24 -0
- package/dist/node-test-process.d.ts.map +1 -0
- package/dist/node-test-process.js +89 -0
- package/dist/node-test-process.js.map +1 -0
- package/dist/node-vitest-runner.d.ts +24 -0
- package/dist/node-vitest-runner.d.ts.map +1 -0
- package/dist/node-vitest-runner.js +49 -0
- package/dist/node-vitest-runner.js.map +1 -0
- package/dist/shell-utils.d.ts +15 -0
- package/dist/shell-utils.d.ts.map +1 -0
- package/dist/shell-utils.js +43 -0
- package/dist/shell-utils.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
- package/plugin.json +9 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-jest-runner.d.ts","sourceRoot":"","sources":["../src/node-jest-runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,2BAA2B,EAAE,yBAAyB,EAAoB,MAAM,YAAY,CAAC;AAK3G,MAAM,WAAW,uBAAuB;IACvC,sFAAsF;IACtF,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,CAAC,EAAE,yBAAyB,CAAC;IAC5C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED,+FAA+F;AAC/F,wBAAgB,kBAAkB,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKjF;AAED;;;;;GAKG;AACH,wBAAgB,qCAAqC,CAAC,OAAO,EAAE;IAC9D,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACpC,GAAG,2BAA2B,CAe9B;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAAC,OAAO,EAAE,uBAAuB,4CAQjF","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { isAbsolute, join } from \"node:path\";\nimport { createNodeTestRunnerAdapterV1 } from \"./node-adapter.ts\";\nimport { createJsonReportTestRunnerCommandRunnerV1 } from \"./node-test-process.ts\";\nimport type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1, TestRunRequestV1 } from \"./types.ts\";\n\nconst DEFAULT_JEST_TIMEOUT_MS_V1 = 120_000;\nconst TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;\n\nexport interface JestTestRunnerOptionsV1 {\n\t/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */\n\treadonly workspaceDirectory: string;\n\t/** Injectable runner override for deterministic tests; the real spawn is used when absent. */\n\treadonly runner?: TestRunnerCommandRunnerV1;\n\treadonly defaultTimeoutMs?: number;\n}\n\n/** Resolves the workspace-local Jest entry; undefined keeps the default wiring fail-closed. */\nexport function resolveJestEntryV1(workspaceDirectory: string): string | undefined {\n\tif (typeof workspaceDirectory !== \"string\" || workspaceDirectory.trim() === \"\" || !isAbsolute(workspaceDirectory))\n\t\treturn undefined;\n\tconst entry = join(workspaceDirectory, \"node_modules\", \"jest\", \"bin\", \"jest.js\");\n\treturn existsSync(entry) ? entry : undefined;\n}\n\n/**\n * Host-owned resolver: only the jest framework is supported in this slice;\n * other frameworks resolve to undefined so the adapter reports unsupported.\n * The target becomes a Jest test-path pattern argument after a bounded\n * identifier check.\n */\nexport function createJestTestRunnerCommandResolverV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandResolverV1 {\n\treturn {\n\t\tresolve: async (request: TestRunRequestV1) => {\n\t\t\tif (request.framework !== \"jest\") return undefined;\n\t\t\tconst entry = resolveJestEntryV1(options.workspaceDirectory);\n\t\t\tif (entry === undefined) return undefined;\n\t\t\tconst target =\n\t\t\t\ttypeof request.target === \"string\" && request.target.trim() !== \"\" ? request.target.trim() : undefined;\n\t\t\tif (target !== undefined && !TARGET_IDENTIFIER_V1.test(target)) return undefined;\n\t\t\treturn {\n\t\t\t\tcommandId: \"node-jest\",\n\t\t\t\targs: [entry, \"--json\", ...(target === undefined ? [] : [target])],\n\t\t\t};\n\t\t},\n\t};\n}\n\n/**\n * Real Jest-backed test-runner adapter over one workspace directory. Jest\n * availability is probed per resolution; a workspace without a local Jest\n * install resolves to `unsupported` instead of a synthetic result.\n */\nexport function createJestRealTestRunnerAdapterV1(options: JestTestRunnerOptionsV1) {\n\treturn createNodeTestRunnerAdapterV1({\n\t\tcommandResolver: createJestTestRunnerCommandResolverV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\trunner:\n\t\t\toptions.runner ??\n\t\t\tcreateJsonReportTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\tdefaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_JEST_TIMEOUT_MS_V1,\n\t});\n}\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { createNodeTestRunnerAdapterV1 } from "./node-adapter.js";
|
|
4
|
+
import { createJsonReportTestRunnerCommandRunnerV1 } from "./node-test-process.js";
|
|
5
|
+
const DEFAULT_JEST_TIMEOUT_MS_V1 = 120_000;
|
|
6
|
+
const TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;
|
|
7
|
+
/** Resolves the workspace-local Jest entry; undefined keeps the default wiring fail-closed. */
|
|
8
|
+
export function resolveJestEntryV1(workspaceDirectory) {
|
|
9
|
+
if (typeof workspaceDirectory !== "string" || workspaceDirectory.trim() === "" || !isAbsolute(workspaceDirectory))
|
|
10
|
+
return undefined;
|
|
11
|
+
const entry = join(workspaceDirectory, "node_modules", "jest", "bin", "jest.js");
|
|
12
|
+
return existsSync(entry) ? entry : undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Host-owned resolver: only the jest framework is supported in this slice;
|
|
16
|
+
* other frameworks resolve to undefined so the adapter reports unsupported.
|
|
17
|
+
* The target becomes a Jest test-path pattern argument after a bounded
|
|
18
|
+
* identifier check.
|
|
19
|
+
*/
|
|
20
|
+
export function createJestTestRunnerCommandResolverV1(options) {
|
|
21
|
+
return {
|
|
22
|
+
resolve: async (request) => {
|
|
23
|
+
if (request.framework !== "jest")
|
|
24
|
+
return undefined;
|
|
25
|
+
const entry = resolveJestEntryV1(options.workspaceDirectory);
|
|
26
|
+
if (entry === undefined)
|
|
27
|
+
return undefined;
|
|
28
|
+
const target = typeof request.target === "string" && request.target.trim() !== "" ? request.target.trim() : undefined;
|
|
29
|
+
if (target !== undefined && !TARGET_IDENTIFIER_V1.test(target))
|
|
30
|
+
return undefined;
|
|
31
|
+
return {
|
|
32
|
+
commandId: "node-jest",
|
|
33
|
+
args: [entry, "--json", ...(target === undefined ? [] : [target])],
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Real Jest-backed test-runner adapter over one workspace directory. Jest
|
|
40
|
+
* availability is probed per resolution; a workspace without a local Jest
|
|
41
|
+
* install resolves to `unsupported` instead of a synthetic result.
|
|
42
|
+
*/
|
|
43
|
+
export function createJestRealTestRunnerAdapterV1(options) {
|
|
44
|
+
return createNodeTestRunnerAdapterV1({
|
|
45
|
+
commandResolver: createJestTestRunnerCommandResolverV1({ workspaceDirectory: options.workspaceDirectory }),
|
|
46
|
+
runner: options.runner ??
|
|
47
|
+
createJsonReportTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory }),
|
|
48
|
+
defaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_JEST_TIMEOUT_MS_V1,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=node-jest-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-jest-runner.js","sourceRoot":"","sources":["../src/node-jest-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,6BAA6B,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,yCAAyC,EAAE,MAAM,wBAAwB,CAAC;AAGnF,MAAM,0BAA0B,GAAG,OAAO,CAAC;AAC3C,MAAM,oBAAoB,GAAG,kCAAkC,CAAC;AAUhE,+FAA+F;AAC/F,MAAM,UAAU,kBAAkB,CAAC,kBAA0B,EAAsB;IAClF,IAAI,OAAO,kBAAkB,KAAK,QAAQ,IAAI,kBAAkB,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,UAAU,CAAC,kBAAkB,CAAC;QAChH,OAAO,SAAS,CAAC;IAClB,MAAM,KAAK,GAAG,IAAI,CAAC,kBAAkB,EAAE,cAAc,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC;IACjF,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAAA,CAC7C;AAED;;;;;GAKG;AACH,MAAM,UAAU,qCAAqC,CAAC,OAErD,EAA+B;IAC/B,OAAO;QACN,OAAO,EAAE,KAAK,EAAE,OAAyB,EAAE,EAAE,CAAC;YAC7C,IAAI,OAAO,CAAC,SAAS,KAAK,MAAM;gBAAE,OAAO,SAAS,CAAC;YACnD,MAAM,KAAK,GAAG,kBAAkB,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;YAC7D,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,SAAS,CAAC;YAC1C,MAAM,MAAM,GACX,OAAO,OAAO,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;YACxG,IAAI,MAAM,KAAK,SAAS,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,MAAM,CAAC;gBAAE,OAAO,SAAS,CAAC;YACjF,OAAO;gBACN,SAAS,EAAE,WAAW;gBACtB,IAAI,EAAE,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;aAClE,CAAC;QAAA,CACF;KACD,CAAC;AAAA,CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,iCAAiC,CAAC,OAAgC,EAAE;IACnF,OAAO,6BAA6B,CAAC;QACpC,eAAe,EAAE,qCAAqC,CAAC,EAAE,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,EAAE,CAAC;QAC1G,MAAM,EACL,OAAO,CAAC,MAAM;YACd,yCAAyC,CAAC,EAAE,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,EAAE,CAAC;QAC9F,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,IAAI,0BAA0B;KACxE,CAAC,CAAC;AAAA,CACH","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { isAbsolute, join } from \"node:path\";\nimport { createNodeTestRunnerAdapterV1 } from \"./node-adapter.ts\";\nimport { createJsonReportTestRunnerCommandRunnerV1 } from \"./node-test-process.ts\";\nimport type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1, TestRunRequestV1 } from \"./types.ts\";\n\nconst DEFAULT_JEST_TIMEOUT_MS_V1 = 120_000;\nconst TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;\n\nexport interface JestTestRunnerOptionsV1 {\n\t/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */\n\treadonly workspaceDirectory: string;\n\t/** Injectable runner override for deterministic tests; the real spawn is used when absent. */\n\treadonly runner?: TestRunnerCommandRunnerV1;\n\treadonly defaultTimeoutMs?: number;\n}\n\n/** Resolves the workspace-local Jest entry; undefined keeps the default wiring fail-closed. */\nexport function resolveJestEntryV1(workspaceDirectory: string): string | undefined {\n\tif (typeof workspaceDirectory !== \"string\" || workspaceDirectory.trim() === \"\" || !isAbsolute(workspaceDirectory))\n\t\treturn undefined;\n\tconst entry = join(workspaceDirectory, \"node_modules\", \"jest\", \"bin\", \"jest.js\");\n\treturn existsSync(entry) ? entry : undefined;\n}\n\n/**\n * Host-owned resolver: only the jest framework is supported in this slice;\n * other frameworks resolve to undefined so the adapter reports unsupported.\n * The target becomes a Jest test-path pattern argument after a bounded\n * identifier check.\n */\nexport function createJestTestRunnerCommandResolverV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandResolverV1 {\n\treturn {\n\t\tresolve: async (request: TestRunRequestV1) => {\n\t\t\tif (request.framework !== \"jest\") return undefined;\n\t\t\tconst entry = resolveJestEntryV1(options.workspaceDirectory);\n\t\t\tif (entry === undefined) return undefined;\n\t\t\tconst target =\n\t\t\t\ttypeof request.target === \"string\" && request.target.trim() !== \"\" ? request.target.trim() : undefined;\n\t\t\tif (target !== undefined && !TARGET_IDENTIFIER_V1.test(target)) return undefined;\n\t\t\treturn {\n\t\t\t\tcommandId: \"node-jest\",\n\t\t\t\targs: [entry, \"--json\", ...(target === undefined ? [] : [target])],\n\t\t\t};\n\t\t},\n\t};\n}\n\n/**\n * Real Jest-backed test-runner adapter over one workspace directory. Jest\n * availability is probed per resolution; a workspace without a local Jest\n * install resolves to `unsupported` instead of a synthetic result.\n */\nexport function createJestRealTestRunnerAdapterV1(options: JestTestRunnerOptionsV1) {\n\treturn createNodeTestRunnerAdapterV1({\n\t\tcommandResolver: createJestTestRunnerCommandResolverV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\trunner:\n\t\t\toptions.runner ??\n\t\t\tcreateJsonReportTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\tdefaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_JEST_TIMEOUT_MS_V1,\n\t});\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TestRunFailureV1, TestRunSummaryV1 } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Parses a pytest JUnit XML report (`--junitxml`) into a bounded projection.
|
|
4
|
+
* It accepts only bounded input, never evaluates anything, and converts host
|
|
5
|
+
* failure paths/messages through the shared sanitizer before they cross.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseJunitXmlReportV1(raw: string): {
|
|
8
|
+
readonly summary: TestRunSummaryV1;
|
|
9
|
+
readonly failures: readonly TestRunFailureV1[];
|
|
10
|
+
} | undefined;
|
|
11
|
+
//# sourceMappingURL=node-junit-xml-report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-junit-xml-report.d.ts","sourceRoot":"","sources":["../src/node-junit-xml-report.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAoCrE;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAC9C;IACA,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,QAAQ,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAC;CAC9C,GACD,SAAS,CAoCX","sourcesContent":["import { sanitizeTestRunnerTextV1 } from \"./node-adapter.ts\";\nimport type { TestRunFailureV1, TestRunSummaryV1 } from \"./types.ts\";\n\nconst MAX_JUNIT_XML_BYTES_V1 = 16 * 1024 * 1024;\nconst MAX_JUNIT_FAILURES_V1 = 256;\nconst MAX_JUNIT_MESSAGE_CHARS_V1 = 2048;\n\nfunction boundedCount(value: string | undefined): number {\n\tconst parsed = value === undefined ? Number.NaN : Number(value);\n\treturn Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;\n}\n\nfunction decodeXmlEntitiesV1(value: string): string {\n\treturn value\n\t\t.replaceAll(/&#x([0-9a-fA-F]+);/g, (raw, code: string) => {\n\t\t\tconst point = Number.parseInt(code, 16);\n\t\t\treturn point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : raw;\n\t\t})\n\t\t.replaceAll(/&#(\\d+);/g, (raw, code: string) => {\n\t\t\tconst point = Number.parseInt(code, 10);\n\t\t\treturn point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : raw;\n\t\t})\n\t\t.replaceAll(\"<\", \"<\")\n\t\t.replaceAll(\">\", \">\")\n\t\t.replaceAll(\""\", '\"')\n\t\t.replaceAll(\"'\", \"'\")\n\t\t.replaceAll(\"&\", \"&\");\n}\n\nfunction attributesV1(tag: string): Record<string, string> {\n\tconst attributes: Record<string, string> = {};\n\tfor (const match of tag.matchAll(/([A-Za-z_][\\w.:-]*)\\s*=\\s*\"([^\"]*)\"/g)) {\n\t\tattributes[match[1]!] = decodeXmlEntitiesV1(match[2]!);\n\t}\n\treturn attributes;\n}\n\n/**\n * Parses a pytest JUnit XML report (`--junitxml`) into a bounded projection.\n * It accepts only bounded input, never evaluates anything, and converts host\n * failure paths/messages through the shared sanitizer before they cross.\n */\nexport function parseJunitXmlReportV1(raw: string):\n\t| {\n\t\t\treadonly summary: TestRunSummaryV1;\n\t\t\treadonly failures: readonly TestRunFailureV1[];\n\t }\n\t| undefined {\n\tif (typeof raw !== \"string\" || new TextEncoder().encode(raw).byteLength > MAX_JUNIT_XML_BYTES_V1) return undefined;\n\tconst testsuites = [...raw.matchAll(/<testsuite\\b([^>]*)>/g)];\n\tif (testsuites.length === 0) return undefined;\n\tlet total = 0;\n\tlet failed = 0;\n\tlet skipped = 0;\n\tfor (const suite of testsuites) {\n\t\tconst attributes = attributesV1(suite[1]!);\n\t\ttotal += boundedCount(attributes.tests);\n\t\tfailed += boundedCount(attributes.failures) + boundedCount(attributes.errors);\n\t\tskipped += boundedCount(attributes.skipped);\n\t}\n\tconst passed = Math.max(0, total - failed - skipped);\n\tconst summary: TestRunSummaryV1 = { total, passed, failed, skipped, flaky: 0 };\n\tconst failures: TestRunFailureV1[] = [];\n\tfor (const match of raw.matchAll(/<testcase\\b([^>]*?)(?:\\/>|>([\\s\\S]*?)<\\/testcase>)/g)) {\n\t\tif (failures.length >= MAX_JUNIT_FAILURES_V1) break;\n\t\tconst attributes = attributesV1(match[1] ?? \"\");\n\t\tconst body = match[2] ?? \"\";\n\t\tconst outcome = body.match(/<(failure|error)\\b([^>]*)>([\\s\\S]*?)<\\/\\1>/);\n\t\tif (outcome === undefined || outcome === null) continue;\n\t\tconst message = attributesV1(outcome[2] ?? \"\").message;\n\t\tconst text = outcome[3]?.trim() ?? \"\";\n\t\tconst file = attributes.file ?? attributes.classname ?? \"unknown-test-file\";\n\t\tconst line = boundedCount(attributes.line);\n\t\tfailures.push({\n\t\t\tfile,\n\t\t\t...(line > 0 ? { line } : {}),\n\t\t\tmessage: sanitizeTestRunnerTextV1(\n\t\t\t\tdecodeXmlEntitiesV1(message ?? (text === \"\" ? \"Test failed\" : text)).slice(0, MAX_JUNIT_MESSAGE_CHARS_V1) ||\n\t\t\t\t\t\"Test failed\",\n\t\t\t),\n\t\t});\n\t}\n\treturn { summary, failures };\n}\n"]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { sanitizeTestRunnerTextV1 } from "./node-adapter.js";
|
|
2
|
+
const MAX_JUNIT_XML_BYTES_V1 = 16 * 1024 * 1024;
|
|
3
|
+
const MAX_JUNIT_FAILURES_V1 = 256;
|
|
4
|
+
const MAX_JUNIT_MESSAGE_CHARS_V1 = 2048;
|
|
5
|
+
function boundedCount(value) {
|
|
6
|
+
const parsed = value === undefined ? Number.NaN : Number(value);
|
|
7
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
8
|
+
}
|
|
9
|
+
function decodeXmlEntitiesV1(value) {
|
|
10
|
+
return value
|
|
11
|
+
.replaceAll(/&#x([0-9a-fA-F]+);/g, (raw, code) => {
|
|
12
|
+
const point = Number.parseInt(code, 16);
|
|
13
|
+
return point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : raw;
|
|
14
|
+
})
|
|
15
|
+
.replaceAll(/&#(\d+);/g, (raw, code) => {
|
|
16
|
+
const point = Number.parseInt(code, 10);
|
|
17
|
+
return point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : raw;
|
|
18
|
+
})
|
|
19
|
+
.replaceAll("<", "<")
|
|
20
|
+
.replaceAll(">", ">")
|
|
21
|
+
.replaceAll(""", '"')
|
|
22
|
+
.replaceAll("'", "'")
|
|
23
|
+
.replaceAll("&", "&");
|
|
24
|
+
}
|
|
25
|
+
function attributesV1(tag) {
|
|
26
|
+
const attributes = {};
|
|
27
|
+
for (const match of tag.matchAll(/([A-Za-z_][\w.:-]*)\s*=\s*"([^"]*)"/g)) {
|
|
28
|
+
attributes[match[1]] = decodeXmlEntitiesV1(match[2]);
|
|
29
|
+
}
|
|
30
|
+
return attributes;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Parses a pytest JUnit XML report (`--junitxml`) into a bounded projection.
|
|
34
|
+
* It accepts only bounded input, never evaluates anything, and converts host
|
|
35
|
+
* failure paths/messages through the shared sanitizer before they cross.
|
|
36
|
+
*/
|
|
37
|
+
export function parseJunitXmlReportV1(raw) {
|
|
38
|
+
if (typeof raw !== "string" || new TextEncoder().encode(raw).byteLength > MAX_JUNIT_XML_BYTES_V1)
|
|
39
|
+
return undefined;
|
|
40
|
+
const testsuites = [...raw.matchAll(/<testsuite\b([^>]*)>/g)];
|
|
41
|
+
if (testsuites.length === 0)
|
|
42
|
+
return undefined;
|
|
43
|
+
let total = 0;
|
|
44
|
+
let failed = 0;
|
|
45
|
+
let skipped = 0;
|
|
46
|
+
for (const suite of testsuites) {
|
|
47
|
+
const attributes = attributesV1(suite[1]);
|
|
48
|
+
total += boundedCount(attributes.tests);
|
|
49
|
+
failed += boundedCount(attributes.failures) + boundedCount(attributes.errors);
|
|
50
|
+
skipped += boundedCount(attributes.skipped);
|
|
51
|
+
}
|
|
52
|
+
const passed = Math.max(0, total - failed - skipped);
|
|
53
|
+
const summary = { total, passed, failed, skipped, flaky: 0 };
|
|
54
|
+
const failures = [];
|
|
55
|
+
for (const match of raw.matchAll(/<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g)) {
|
|
56
|
+
if (failures.length >= MAX_JUNIT_FAILURES_V1)
|
|
57
|
+
break;
|
|
58
|
+
const attributes = attributesV1(match[1] ?? "");
|
|
59
|
+
const body = match[2] ?? "";
|
|
60
|
+
const outcome = body.match(/<(failure|error)\b([^>]*)>([\s\S]*?)<\/\1>/);
|
|
61
|
+
if (outcome === undefined || outcome === null)
|
|
62
|
+
continue;
|
|
63
|
+
const message = attributesV1(outcome[2] ?? "").message;
|
|
64
|
+
const text = outcome[3]?.trim() ?? "";
|
|
65
|
+
const file = attributes.file ?? attributes.classname ?? "unknown-test-file";
|
|
66
|
+
const line = boundedCount(attributes.line);
|
|
67
|
+
failures.push({
|
|
68
|
+
file,
|
|
69
|
+
...(line > 0 ? { line } : {}),
|
|
70
|
+
message: sanitizeTestRunnerTextV1(decodeXmlEntitiesV1(message ?? (text === "" ? "Test failed" : text)).slice(0, MAX_JUNIT_MESSAGE_CHARS_V1) ||
|
|
71
|
+
"Test failed"),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return { summary, failures };
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=node-junit-xml-report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-junit-xml-report.js","sourceRoot":"","sources":["../src/node-junit-xml-report.ts"],"names":[],"mappings":"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","sourcesContent":["import { sanitizeTestRunnerTextV1 } from \"./node-adapter.ts\";\nimport type { TestRunFailureV1, TestRunSummaryV1 } from \"./types.ts\";\n\nconst MAX_JUNIT_XML_BYTES_V1 = 16 * 1024 * 1024;\nconst MAX_JUNIT_FAILURES_V1 = 256;\nconst MAX_JUNIT_MESSAGE_CHARS_V1 = 2048;\n\nfunction boundedCount(value: string | undefined): number {\n\tconst parsed = value === undefined ? Number.NaN : Number(value);\n\treturn Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;\n}\n\nfunction decodeXmlEntitiesV1(value: string): string {\n\treturn value\n\t\t.replaceAll(/&#x([0-9a-fA-F]+);/g, (raw, code: string) => {\n\t\t\tconst point = Number.parseInt(code, 16);\n\t\t\treturn point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : raw;\n\t\t})\n\t\t.replaceAll(/&#(\\d+);/g, (raw, code: string) => {\n\t\t\tconst point = Number.parseInt(code, 10);\n\t\t\treturn point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : raw;\n\t\t})\n\t\t.replaceAll(\"<\", \"<\")\n\t\t.replaceAll(\">\", \">\")\n\t\t.replaceAll(\""\", '\"')\n\t\t.replaceAll(\"'\", \"'\")\n\t\t.replaceAll(\"&\", \"&\");\n}\n\nfunction attributesV1(tag: string): Record<string, string> {\n\tconst attributes: Record<string, string> = {};\n\tfor (const match of tag.matchAll(/([A-Za-z_][\\w.:-]*)\\s*=\\s*\"([^\"]*)\"/g)) {\n\t\tattributes[match[1]!] = decodeXmlEntitiesV1(match[2]!);\n\t}\n\treturn attributes;\n}\n\n/**\n * Parses a pytest JUnit XML report (`--junitxml`) into a bounded projection.\n * It accepts only bounded input, never evaluates anything, and converts host\n * failure paths/messages through the shared sanitizer before they cross.\n */\nexport function parseJunitXmlReportV1(raw: string):\n\t| {\n\t\t\treadonly summary: TestRunSummaryV1;\n\t\t\treadonly failures: readonly TestRunFailureV1[];\n\t }\n\t| undefined {\n\tif (typeof raw !== \"string\" || new TextEncoder().encode(raw).byteLength > MAX_JUNIT_XML_BYTES_V1) return undefined;\n\tconst testsuites = [...raw.matchAll(/<testsuite\\b([^>]*)>/g)];\n\tif (testsuites.length === 0) return undefined;\n\tlet total = 0;\n\tlet failed = 0;\n\tlet skipped = 0;\n\tfor (const suite of testsuites) {\n\t\tconst attributes = attributesV1(suite[1]!);\n\t\ttotal += boundedCount(attributes.tests);\n\t\tfailed += boundedCount(attributes.failures) + boundedCount(attributes.errors);\n\t\tskipped += boundedCount(attributes.skipped);\n\t}\n\tconst passed = Math.max(0, total - failed - skipped);\n\tconst summary: TestRunSummaryV1 = { total, passed, failed, skipped, flaky: 0 };\n\tconst failures: TestRunFailureV1[] = [];\n\tfor (const match of raw.matchAll(/<testcase\\b([^>]*?)(?:\\/>|>([\\s\\S]*?)<\\/testcase>)/g)) {\n\t\tif (failures.length >= MAX_JUNIT_FAILURES_V1) break;\n\t\tconst attributes = attributesV1(match[1] ?? \"\");\n\t\tconst body = match[2] ?? \"\";\n\t\tconst outcome = body.match(/<(failure|error)\\b([^>]*)>([\\s\\S]*?)<\\/\\1>/);\n\t\tif (outcome === undefined || outcome === null) continue;\n\t\tconst message = attributesV1(outcome[2] ?? \"\").message;\n\t\tconst text = outcome[3]?.trim() ?? \"\";\n\t\tconst file = attributes.file ?? attributes.classname ?? \"unknown-test-file\";\n\t\tconst line = boundedCount(attributes.line);\n\t\tfailures.push({\n\t\t\tfile,\n\t\t\t...(line > 0 ? { line } : {}),\n\t\t\tmessage: sanitizeTestRunnerTextV1(\n\t\t\t\tdecodeXmlEntitiesV1(message ?? (text === \"\" ? \"Test failed\" : text)).slice(0, MAX_JUNIT_MESSAGE_CHARS_V1) ||\n\t\t\t\t\t\"Test failed\",\n\t\t\t),\n\t\t});\n\t}\n\treturn { summary, failures };\n}\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1 } from "./types.ts";
|
|
2
|
+
export interface PytestTestRunnerOptionsV1 {
|
|
3
|
+
/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */
|
|
4
|
+
readonly workspaceDirectory: string;
|
|
5
|
+
/** Host-owned Python launcher; resolved from PATH when absent. */
|
|
6
|
+
readonly launcher?: string;
|
|
7
|
+
/** Injectable runner override for deterministic tests; the real spawn is used when absent. */
|
|
8
|
+
readonly runner?: TestRunnerCommandRunnerV1;
|
|
9
|
+
readonly defaultTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolves a Python launcher from the PATH environment (injectable for tests);
|
|
13
|
+
* undefined keeps the default wiring fail-closed.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolvePytestLauncherV1(pathEnv?: string): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Host-owned resolver: only the pytest framework is supported in this slice;
|
|
18
|
+
* other frameworks resolve to undefined so the adapter reports unsupported.
|
|
19
|
+
* The target becomes a pytest node-id argument after a bounded identifier
|
|
20
|
+
* check.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createPytestTestRunnerCommandResolverV1(options: {
|
|
23
|
+
readonly launcher: string;
|
|
24
|
+
}): TestRunnerCommandResolverV1;
|
|
25
|
+
export declare function createPytestTestRunnerCommandRunnerV1(options: {
|
|
26
|
+
readonly workspaceDirectory: string;
|
|
27
|
+
}): TestRunnerCommandRunnerV1;
|
|
28
|
+
/**
|
|
29
|
+
* Real Pytest-backed test-runner adapter over one workspace directory. The
|
|
30
|
+
* Python launcher is probed from PATH per wiring; an unavailable launcher
|
|
31
|
+
* resolves to `unsupported` instead of a synthetic result.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createPytestRealTestRunnerAdapterV1(options: PytestTestRunnerOptionsV1): import("./types.ts").TestRunnerAdapterV1;
|
|
34
|
+
//# sourceMappingURL=node-pytest-runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-pytest-runner.d.ts","sourceRoot":"","sources":["../src/node-pytest-runner.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,2BAA2B,EAAE,yBAAyB,EAAoB,MAAM,YAAY,CAAC;AAK3G,MAAM,WAAW,yBAAyB;IACzC,sFAAsF;IACtF,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,CAAC,EAAE,yBAAyB,CAAC;IAC5C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAY5E;AAED;;;;;GAKG;AACH,wBAAgB,uCAAuC,CAAC,OAAO,EAAE;IAChE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC1B,GAAG,2BAA2B,CAa9B;AAED,wBAAgB,qCAAqC,CAAC,OAAO,EAAE;IAC9D,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACpC,GAAG,yBAAyB,CAqC5B;AAED;;;;GAIG;AACH,wBAAgB,mCAAmC,CAAC,OAAO,EAAE,yBAAyB,4CAWrF","sourcesContent":["import { spawn } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { mkdtemp, readFile, rm } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { isAbsolute, join } from \"node:path\";\nimport { createNodeTestRunnerAdapterV1 } from \"./node-adapter.ts\";\nimport { parseJunitXmlReportV1 } from \"./node-junit-xml-report.ts\";\nimport type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1, TestRunRequestV1 } from \"./types.ts\";\n\nconst DEFAULT_PYTEST_TIMEOUT_MS_V1 = 120_000;\nconst TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;\n\nexport interface PytestTestRunnerOptionsV1 {\n\t/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */\n\treadonly workspaceDirectory: string;\n\t/** Host-owned Python launcher; resolved from PATH when absent. */\n\treadonly launcher?: string;\n\t/** Injectable runner override for deterministic tests; the real spawn is used when absent. */\n\treadonly runner?: TestRunnerCommandRunnerV1;\n\treadonly defaultTimeoutMs?: number;\n}\n\n/**\n * Resolves a Python launcher from the PATH environment (injectable for tests);\n * undefined keeps the default wiring fail-closed.\n */\nexport function resolvePytestLauncherV1(pathEnv?: string): string | undefined {\n\tconst path = pathEnv ?? process.env.PATH;\n\tif (typeof path !== \"string\" || path.trim() === \"\") return undefined;\n\tconst names = process.platform === \"win32\" ? [\"python.exe\", \"python3.exe\"] : [\"python3\", \"python\"];\n\tfor (const directory of path.split(process.platform === \"win32\" ? \";\" : \":\")) {\n\t\tif (directory.trim() === \"\") continue;\n\t\tfor (const name of names) {\n\t\t\tconst candidate = join(directory, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/**\n * Host-owned resolver: only the pytest framework is supported in this slice;\n * other frameworks resolve to undefined so the adapter reports unsupported.\n * The target becomes a pytest node-id argument after a bounded identifier\n * check.\n */\nexport function createPytestTestRunnerCommandResolverV1(options: {\n\treadonly launcher: string;\n}): TestRunnerCommandResolverV1 {\n\treturn {\n\t\tresolve: async (request: TestRunRequestV1) => {\n\t\t\tif (request.framework !== \"pytest\") return undefined;\n\t\t\tconst target =\n\t\t\t\ttypeof request.target === \"string\" && request.target.trim() !== \"\" ? request.target.trim() : undefined;\n\t\t\tif (target !== undefined && !TARGET_IDENTIFIER_V1.test(target)) return undefined;\n\t\t\treturn {\n\t\t\t\tcommandId: \"node-pytest\",\n\t\t\t\targs: [options.launcher, \"-m\", \"pytest\", ...(target === undefined ? [] : [target])],\n\t\t\t};\n\t\t},\n\t};\n}\n\nexport function createPytestTestRunnerCommandRunnerV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandRunnerV1 {\n\tconst workspaceDirectory = options.workspaceDirectory;\n\treturn {\n\t\trun: async ({ command, signal }) => {\n\t\t\tif (typeof command.args[0] !== \"string\" || command.args[0].trim() === \"\") return { status: \"unsupported\" };\n\t\t\tif (signal.aborted) return { status: \"unsupported\" };\n\t\t\tconst reportDirectory = await mkdtemp(join(tmpdir(), \"agent-forge-pytest-\"));\n\t\t\ttry {\n\t\t\t\tconst reportPath = join(reportDirectory, \"report.xml\");\n\t\t\t\tconst args = [...command.args, `--junitxml=${reportPath}`];\n\t\t\t\tconst exitCode = await new Promise<number | null>((resolve) => {\n\t\t\t\t\tconst child = spawn(command.args[0]!, args.slice(1), {\n\t\t\t\t\t\tshell: false,\n\t\t\t\t\t\twindowsHide: true,\n\t\t\t\t\t\tcwd: isAbsolute(workspaceDirectory) ? workspaceDirectory : process.cwd(),\n\t\t\t\t\t\tstdio: [\"ignore\", \"ignore\", \"ignore\"],\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t});\n\t\t\t\t\tchild.once(\"error\", () => resolve(null));\n\t\t\t\t\tchild.once(\"exit\", (code) => resolve(code));\n\t\t\t\t});\n\t\t\t\tif (exitCode === null) return { status: \"unsupported\" };\n\t\t\t\tconst report = await readFile(reportPath, \"utf8\").catch(() => undefined);\n\t\t\t\tif (report === undefined) return { status: \"unsupported\" };\n\t\t\t\tconst parsed = parseJunitXmlReportV1(report);\n\t\t\t\tif (parsed === undefined) return { status: \"unsupported\" };\n\t\t\t\treturn {\n\t\t\t\t\tstatus: \"completed\" as const,\n\t\t\t\t\texitCode: exitCode >= 0 && exitCode <= 255 ? exitCode : 1,\n\t\t\t\t\tsummary: parsed.summary,\n\t\t\t\t\tfailures: parsed.failures,\n\t\t\t\t};\n\t\t\t} finally {\n\t\t\t\tawait rm(reportDirectory, { recursive: true, force: true });\n\t\t\t}\n\t\t},\n\t};\n}\n\n/**\n * Real Pytest-backed test-runner adapter over one workspace directory. The\n * Python launcher is probed from PATH per wiring; an unavailable launcher\n * resolves to `unsupported` instead of a synthetic result.\n */\nexport function createPytestRealTestRunnerAdapterV1(options: PytestTestRunnerOptionsV1) {\n\tconst launcher = options.launcher ?? resolvePytestLauncherV1() ?? \"\";\n\treturn createNodeTestRunnerAdapterV1({\n\t\tcommandResolver: createPytestTestRunnerCommandResolverV1({ launcher }),\n\t\trunner:\n\t\t\toptions.runner ??\n\t\t\t(launcher === \"\"\n\t\t\t\t? undefined\n\t\t\t\t: createPytestTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory })),\n\t\tdefaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_PYTEST_TIMEOUT_MS_V1,\n\t});\n}\n"]}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { createNodeTestRunnerAdapterV1 } from "./node-adapter.js";
|
|
7
|
+
import { parseJunitXmlReportV1 } from "./node-junit-xml-report.js";
|
|
8
|
+
const DEFAULT_PYTEST_TIMEOUT_MS_V1 = 120_000;
|
|
9
|
+
const TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;
|
|
10
|
+
/**
|
|
11
|
+
* Resolves a Python launcher from the PATH environment (injectable for tests);
|
|
12
|
+
* undefined keeps the default wiring fail-closed.
|
|
13
|
+
*/
|
|
14
|
+
export function resolvePytestLauncherV1(pathEnv) {
|
|
15
|
+
const path = pathEnv ?? process.env.PATH;
|
|
16
|
+
if (typeof path !== "string" || path.trim() === "")
|
|
17
|
+
return undefined;
|
|
18
|
+
const names = process.platform === "win32" ? ["python.exe", "python3.exe"] : ["python3", "python"];
|
|
19
|
+
for (const directory of path.split(process.platform === "win32" ? ";" : ":")) {
|
|
20
|
+
if (directory.trim() === "")
|
|
21
|
+
continue;
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
const candidate = join(directory, name);
|
|
24
|
+
if (existsSync(candidate))
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Host-owned resolver: only the pytest framework is supported in this slice;
|
|
32
|
+
* other frameworks resolve to undefined so the adapter reports unsupported.
|
|
33
|
+
* The target becomes a pytest node-id argument after a bounded identifier
|
|
34
|
+
* check.
|
|
35
|
+
*/
|
|
36
|
+
export function createPytestTestRunnerCommandResolverV1(options) {
|
|
37
|
+
return {
|
|
38
|
+
resolve: async (request) => {
|
|
39
|
+
if (request.framework !== "pytest")
|
|
40
|
+
return undefined;
|
|
41
|
+
const target = typeof request.target === "string" && request.target.trim() !== "" ? request.target.trim() : undefined;
|
|
42
|
+
if (target !== undefined && !TARGET_IDENTIFIER_V1.test(target))
|
|
43
|
+
return undefined;
|
|
44
|
+
return {
|
|
45
|
+
commandId: "node-pytest",
|
|
46
|
+
args: [options.launcher, "-m", "pytest", ...(target === undefined ? [] : [target])],
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function createPytestTestRunnerCommandRunnerV1(options) {
|
|
52
|
+
const workspaceDirectory = options.workspaceDirectory;
|
|
53
|
+
return {
|
|
54
|
+
run: async ({ command, signal }) => {
|
|
55
|
+
if (typeof command.args[0] !== "string" || command.args[0].trim() === "")
|
|
56
|
+
return { status: "unsupported" };
|
|
57
|
+
if (signal.aborted)
|
|
58
|
+
return { status: "unsupported" };
|
|
59
|
+
const reportDirectory = await mkdtemp(join(tmpdir(), "agent-forge-pytest-"));
|
|
60
|
+
try {
|
|
61
|
+
const reportPath = join(reportDirectory, "report.xml");
|
|
62
|
+
const args = [...command.args, `--junitxml=${reportPath}`];
|
|
63
|
+
const exitCode = await new Promise((resolve) => {
|
|
64
|
+
const child = spawn(command.args[0], args.slice(1), {
|
|
65
|
+
shell: false,
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
cwd: isAbsolute(workspaceDirectory) ? workspaceDirectory : process.cwd(),
|
|
68
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
69
|
+
signal,
|
|
70
|
+
});
|
|
71
|
+
child.once("error", () => resolve(null));
|
|
72
|
+
child.once("exit", (code) => resolve(code));
|
|
73
|
+
});
|
|
74
|
+
if (exitCode === null)
|
|
75
|
+
return { status: "unsupported" };
|
|
76
|
+
const report = await readFile(reportPath, "utf8").catch(() => undefined);
|
|
77
|
+
if (report === undefined)
|
|
78
|
+
return { status: "unsupported" };
|
|
79
|
+
const parsed = parseJunitXmlReportV1(report);
|
|
80
|
+
if (parsed === undefined)
|
|
81
|
+
return { status: "unsupported" };
|
|
82
|
+
return {
|
|
83
|
+
status: "completed",
|
|
84
|
+
exitCode: exitCode >= 0 && exitCode <= 255 ? exitCode : 1,
|
|
85
|
+
summary: parsed.summary,
|
|
86
|
+
failures: parsed.failures,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(reportDirectory, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Real Pytest-backed test-runner adapter over one workspace directory. The
|
|
97
|
+
* Python launcher is probed from PATH per wiring; an unavailable launcher
|
|
98
|
+
* resolves to `unsupported` instead of a synthetic result.
|
|
99
|
+
*/
|
|
100
|
+
export function createPytestRealTestRunnerAdapterV1(options) {
|
|
101
|
+
const launcher = options.launcher ?? resolvePytestLauncherV1() ?? "";
|
|
102
|
+
return createNodeTestRunnerAdapterV1({
|
|
103
|
+
commandResolver: createPytestTestRunnerCommandResolverV1({ launcher }),
|
|
104
|
+
runner: options.runner ??
|
|
105
|
+
(launcher === ""
|
|
106
|
+
? undefined
|
|
107
|
+
: createPytestTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory })),
|
|
108
|
+
defaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_PYTEST_TIMEOUT_MS_V1,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=node-pytest-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-pytest-runner.js","sourceRoot":"","sources":["../src/node-pytest-runner.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { mkdtemp, readFile, rm } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { isAbsolute, join } from \"node:path\";\nimport { createNodeTestRunnerAdapterV1 } from \"./node-adapter.ts\";\nimport { parseJunitXmlReportV1 } from \"./node-junit-xml-report.ts\";\nimport type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1, TestRunRequestV1 } from \"./types.ts\";\n\nconst DEFAULT_PYTEST_TIMEOUT_MS_V1 = 120_000;\nconst TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;\n\nexport interface PytestTestRunnerOptionsV1 {\n\t/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */\n\treadonly workspaceDirectory: string;\n\t/** Host-owned Python launcher; resolved from PATH when absent. */\n\treadonly launcher?: string;\n\t/** Injectable runner override for deterministic tests; the real spawn is used when absent. */\n\treadonly runner?: TestRunnerCommandRunnerV1;\n\treadonly defaultTimeoutMs?: number;\n}\n\n/**\n * Resolves a Python launcher from the PATH environment (injectable for tests);\n * undefined keeps the default wiring fail-closed.\n */\nexport function resolvePytestLauncherV1(pathEnv?: string): string | undefined {\n\tconst path = pathEnv ?? process.env.PATH;\n\tif (typeof path !== \"string\" || path.trim() === \"\") return undefined;\n\tconst names = process.platform === \"win32\" ? [\"python.exe\", \"python3.exe\"] : [\"python3\", \"python\"];\n\tfor (const directory of path.split(process.platform === \"win32\" ? \";\" : \":\")) {\n\t\tif (directory.trim() === \"\") continue;\n\t\tfor (const name of names) {\n\t\t\tconst candidate = join(directory, name);\n\t\t\tif (existsSync(candidate)) return candidate;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/**\n * Host-owned resolver: only the pytest framework is supported in this slice;\n * other frameworks resolve to undefined so the adapter reports unsupported.\n * The target becomes a pytest node-id argument after a bounded identifier\n * check.\n */\nexport function createPytestTestRunnerCommandResolverV1(options: {\n\treadonly launcher: string;\n}): TestRunnerCommandResolverV1 {\n\treturn {\n\t\tresolve: async (request: TestRunRequestV1) => {\n\t\t\tif (request.framework !== \"pytest\") return undefined;\n\t\t\tconst target =\n\t\t\t\ttypeof request.target === \"string\" && request.target.trim() !== \"\" ? request.target.trim() : undefined;\n\t\t\tif (target !== undefined && !TARGET_IDENTIFIER_V1.test(target)) return undefined;\n\t\t\treturn {\n\t\t\t\tcommandId: \"node-pytest\",\n\t\t\t\targs: [options.launcher, \"-m\", \"pytest\", ...(target === undefined ? [] : [target])],\n\t\t\t};\n\t\t},\n\t};\n}\n\nexport function createPytestTestRunnerCommandRunnerV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandRunnerV1 {\n\tconst workspaceDirectory = options.workspaceDirectory;\n\treturn {\n\t\trun: async ({ command, signal }) => {\n\t\t\tif (typeof command.args[0] !== \"string\" || command.args[0].trim() === \"\") return { status: \"unsupported\" };\n\t\t\tif (signal.aborted) return { status: \"unsupported\" };\n\t\t\tconst reportDirectory = await mkdtemp(join(tmpdir(), \"agent-forge-pytest-\"));\n\t\t\ttry {\n\t\t\t\tconst reportPath = join(reportDirectory, \"report.xml\");\n\t\t\t\tconst args = [...command.args, `--junitxml=${reportPath}`];\n\t\t\t\tconst exitCode = await new Promise<number | null>((resolve) => {\n\t\t\t\t\tconst child = spawn(command.args[0]!, args.slice(1), {\n\t\t\t\t\t\tshell: false,\n\t\t\t\t\t\twindowsHide: true,\n\t\t\t\t\t\tcwd: isAbsolute(workspaceDirectory) ? workspaceDirectory : process.cwd(),\n\t\t\t\t\t\tstdio: [\"ignore\", \"ignore\", \"ignore\"],\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t});\n\t\t\t\t\tchild.once(\"error\", () => resolve(null));\n\t\t\t\t\tchild.once(\"exit\", (code) => resolve(code));\n\t\t\t\t});\n\t\t\t\tif (exitCode === null) return { status: \"unsupported\" };\n\t\t\t\tconst report = await readFile(reportPath, \"utf8\").catch(() => undefined);\n\t\t\t\tif (report === undefined) return { status: \"unsupported\" };\n\t\t\t\tconst parsed = parseJunitXmlReportV1(report);\n\t\t\t\tif (parsed === undefined) return { status: \"unsupported\" };\n\t\t\t\treturn {\n\t\t\t\t\tstatus: \"completed\" as const,\n\t\t\t\t\texitCode: exitCode >= 0 && exitCode <= 255 ? exitCode : 1,\n\t\t\t\t\tsummary: parsed.summary,\n\t\t\t\t\tfailures: parsed.failures,\n\t\t\t\t};\n\t\t\t} finally {\n\t\t\t\tawait rm(reportDirectory, { recursive: true, force: true });\n\t\t\t}\n\t\t},\n\t};\n}\n\n/**\n * Real Pytest-backed test-runner adapter over one workspace directory. The\n * Python launcher is probed from PATH per wiring; an unavailable launcher\n * resolves to `unsupported` instead of a synthetic result.\n */\nexport function createPytestRealTestRunnerAdapterV1(options: PytestTestRunnerOptionsV1) {\n\tconst launcher = options.launcher ?? resolvePytestLauncherV1() ?? \"\";\n\treturn createNodeTestRunnerAdapterV1({\n\t\tcommandResolver: createPytestTestRunnerCommandResolverV1({ launcher }),\n\t\trunner:\n\t\t\toptions.runner ??\n\t\t\t(launcher === \"\"\n\t\t\t\t? undefined\n\t\t\t\t: createPytestTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory })),\n\t\tdefaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_PYTEST_TIMEOUT_MS_V1,\n\t});\n}\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TestRunnerCommandRunnerV1 } from "./types.ts";
|
|
2
|
+
export declare const MAX_NODE_TEST_REPORT_BYTES_V1: number;
|
|
3
|
+
/** Minimal `CI=1` plus a platform environment allowlist for spawned runners. */
|
|
4
|
+
export declare function boundedNodeTestEnvV1(): NodeJS.ProcessEnv;
|
|
5
|
+
export interface NodeTestSpawnResultV1 {
|
|
6
|
+
readonly error?: Error;
|
|
7
|
+
readonly status: number | null;
|
|
8
|
+
readonly stdout: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function spawnNodeTestProcessV1(command: string, args: readonly string[], options: {
|
|
11
|
+
readonly cwd: string;
|
|
12
|
+
readonly env: NodeJS.ProcessEnv;
|
|
13
|
+
readonly signal: AbortSignal;
|
|
14
|
+
}): Promise<NodeTestSpawnResultV1>;
|
|
15
|
+
/**
|
|
16
|
+
* Shared JSON-report runner: spawns the workspace-local test runner entry with
|
|
17
|
+
* a bounded environment, parses the Jest JSON report into a bounded
|
|
18
|
+
* projection, and never returns raw stdout/stderr or absolute paths. Parse
|
|
19
|
+
* failures degrade to `unsupported` instead of a synthetic passing run.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createJsonReportTestRunnerCommandRunnerV1(options: {
|
|
22
|
+
readonly workspaceDirectory: string;
|
|
23
|
+
}): TestRunnerCommandRunnerV1;
|
|
24
|
+
//# sourceMappingURL=node-test-process.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-test-process.d.ts","sourceRoot":"","sources":["../src/node-test-process.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA6B,yBAAyB,EAAE,MAAM,YAAY,CAAC;AAEvF,eAAO,MAAM,6BAA6B,QAAmB,CAAC;AAgB9D,gFAAgF;AAChF,wBAAgB,oBAAoB,IAAI,MAAM,CAAC,UAAU,CAOxD;AAED,MAAM,WAAW,qBAAqB;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,sBAAsB,CACrC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,EAAE;IACR,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC7B,GACC,OAAO,CAAC,qBAAqB,CAAC,CAuBhC;AAED;;;;;GAKG;AACH,wBAAgB,yCAAyC,CAAC,OAAO,EAAE;IAClE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACpC,GAAG,yBAAyB,CA0B5B","sourcesContent":["import { spawn } from \"node:child_process\";\nimport { parseJestJsonReportV1 } from \"./node-jest-json-report.ts\";\nimport type { TestRunnerCommandResultV1, TestRunnerCommandRunnerV1 } from \"./types.ts\";\n\nexport const MAX_NODE_TEST_REPORT_BYTES_V1 = 16 * 1024 * 1024;\n\nconst BOUNDED_ENV_KEYS_V1 = [\n\t\"COMSPEC\",\n\t\"NUMBER_OF_PROCESSORS\",\n\t\"OS\",\n\t\"PATH\",\n\t\"PATHEXT\",\n\t\"PROGRAMDATA\",\n\t\"SYSTEMDRIVE\",\n\t\"SYSTEMROOT\",\n\t\"TEMP\",\n\t\"TMP\",\n\t\"WINDIR\",\n] as const;\n\n/** Minimal `CI=1` plus a platform environment allowlist for spawned runners. */\nexport function boundedNodeTestEnvV1(): NodeJS.ProcessEnv {\n\tconst environment: NodeJS.ProcessEnv = { CI: \"1\" };\n\tfor (const key of BOUNDED_ENV_KEYS_V1) {\n\t\tconst value = process.env[key];\n\t\tif (value !== undefined) environment[key] = value;\n\t}\n\treturn environment;\n}\n\nexport interface NodeTestSpawnResultV1 {\n\treadonly error?: Error;\n\treadonly status: number | null;\n\treadonly stdout: string;\n}\n\nexport function spawnNodeTestProcessV1(\n\tcommand: string,\n\targs: readonly string[],\n\toptions: {\n\t\treadonly cwd: string;\n\t\treadonly env: NodeJS.ProcessEnv;\n\t\treadonly signal: AbortSignal;\n\t},\n): Promise<NodeTestSpawnResultV1> {\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(command, args, {\n\t\t\tshell: false,\n\t\t\twindowsHide: true,\n\t\t\tcwd: options.cwd,\n\t\t\tenv: options.env,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t\tsignal: options.signal,\n\t\t});\n\t\tlet stdout = \"\";\n\t\tlet settled = false;\n\t\tconst finish = (result: NodeTestSpawnResultV1): void => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tresolve(result);\n\t\t};\n\t\tchild.stdout?.on(\"data\", (chunk: Buffer) => {\n\t\t\tif (stdout.length < MAX_NODE_TEST_REPORT_BYTES_V1) stdout += chunk.toString(\"utf8\");\n\t\t});\n\t\tchild.once(\"error\", (error: Error) => finish({ error, status: null, stdout }));\n\t\tchild.once(\"exit\", (code) => finish({ status: code ?? 1, stdout }));\n\t});\n}\n\n/**\n * Shared JSON-report runner: spawns the workspace-local test runner entry with\n * a bounded environment, parses the Jest JSON report into a bounded\n * projection, and never returns raw stdout/stderr or absolute paths. Parse\n * failures degrade to `unsupported` instead of a synthetic passing run.\n */\nexport function createJsonReportTestRunnerCommandRunnerV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandRunnerV1 {\n\tconst workspaceDirectory = options.workspaceDirectory;\n\treturn {\n\t\trun: async ({ command, signal }) => {\n\t\t\tif (typeof command.args[0] !== \"string\" || command.args[0].trim() === \"\") return { status: \"unsupported\" };\n\t\t\tif (signal.aborted) return { status: \"unsupported\" };\n\t\t\tconst result = await spawnNodeTestProcessV1(process.execPath, command.args, {\n\t\t\t\tcwd: workspaceDirectory,\n\t\t\t\tenv: boundedNodeTestEnvV1(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (result.error !== undefined) return { status: \"unsupported\" };\n\t\t\tif (new TextEncoder().encode(result.stdout).byteLength > MAX_NODE_TEST_REPORT_BYTES_V1)\n\t\t\t\treturn { status: \"unsupported\" };\n\t\t\tconst parsed = parseJestJsonReportV1(result.stdout);\n\t\t\tif (parsed === undefined) return { status: \"unsupported\" };\n\t\t\tconst completed: Extract<TestRunnerCommandResultV1, { status: \"completed\" }> = {\n\t\t\t\tstatus: \"completed\",\n\t\t\t\texitCode:\n\t\t\t\t\ttypeof result.status === \"number\" && result.status >= 0 && result.status <= 255 ? result.status : 1,\n\t\t\t\tsummary: parsed.summary,\n\t\t\t\tfailures: parsed.failures,\n\t\t\t};\n\t\t\treturn completed;\n\t\t},\n\t};\n}\n"]}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { parseJestJsonReportV1 } from "./node-jest-json-report.js";
|
|
3
|
+
export const MAX_NODE_TEST_REPORT_BYTES_V1 = 16 * 1024 * 1024;
|
|
4
|
+
const BOUNDED_ENV_KEYS_V1 = [
|
|
5
|
+
"COMSPEC",
|
|
6
|
+
"NUMBER_OF_PROCESSORS",
|
|
7
|
+
"OS",
|
|
8
|
+
"PATH",
|
|
9
|
+
"PATHEXT",
|
|
10
|
+
"PROGRAMDATA",
|
|
11
|
+
"SYSTEMDRIVE",
|
|
12
|
+
"SYSTEMROOT",
|
|
13
|
+
"TEMP",
|
|
14
|
+
"TMP",
|
|
15
|
+
"WINDIR",
|
|
16
|
+
];
|
|
17
|
+
/** Minimal `CI=1` plus a platform environment allowlist for spawned runners. */
|
|
18
|
+
export function boundedNodeTestEnvV1() {
|
|
19
|
+
const environment = { CI: "1" };
|
|
20
|
+
for (const key of BOUNDED_ENV_KEYS_V1) {
|
|
21
|
+
const value = process.env[key];
|
|
22
|
+
if (value !== undefined)
|
|
23
|
+
environment[key] = value;
|
|
24
|
+
}
|
|
25
|
+
return environment;
|
|
26
|
+
}
|
|
27
|
+
export function spawnNodeTestProcessV1(command, args, options) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const child = spawn(command, args, {
|
|
30
|
+
shell: false,
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
cwd: options.cwd,
|
|
33
|
+
env: options.env,
|
|
34
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
35
|
+
signal: options.signal,
|
|
36
|
+
});
|
|
37
|
+
let stdout = "";
|
|
38
|
+
let settled = false;
|
|
39
|
+
const finish = (result) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
resolve(result);
|
|
44
|
+
};
|
|
45
|
+
child.stdout?.on("data", (chunk) => {
|
|
46
|
+
if (stdout.length < MAX_NODE_TEST_REPORT_BYTES_V1)
|
|
47
|
+
stdout += chunk.toString("utf8");
|
|
48
|
+
});
|
|
49
|
+
child.once("error", (error) => finish({ error, status: null, stdout }));
|
|
50
|
+
child.once("exit", (code) => finish({ status: code ?? 1, stdout }));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Shared JSON-report runner: spawns the workspace-local test runner entry with
|
|
55
|
+
* a bounded environment, parses the Jest JSON report into a bounded
|
|
56
|
+
* projection, and never returns raw stdout/stderr or absolute paths. Parse
|
|
57
|
+
* failures degrade to `unsupported` instead of a synthetic passing run.
|
|
58
|
+
*/
|
|
59
|
+
export function createJsonReportTestRunnerCommandRunnerV1(options) {
|
|
60
|
+
const workspaceDirectory = options.workspaceDirectory;
|
|
61
|
+
return {
|
|
62
|
+
run: async ({ command, signal }) => {
|
|
63
|
+
if (typeof command.args[0] !== "string" || command.args[0].trim() === "")
|
|
64
|
+
return { status: "unsupported" };
|
|
65
|
+
if (signal.aborted)
|
|
66
|
+
return { status: "unsupported" };
|
|
67
|
+
const result = await spawnNodeTestProcessV1(process.execPath, command.args, {
|
|
68
|
+
cwd: workspaceDirectory,
|
|
69
|
+
env: boundedNodeTestEnvV1(),
|
|
70
|
+
signal,
|
|
71
|
+
});
|
|
72
|
+
if (result.error !== undefined)
|
|
73
|
+
return { status: "unsupported" };
|
|
74
|
+
if (new TextEncoder().encode(result.stdout).byteLength > MAX_NODE_TEST_REPORT_BYTES_V1)
|
|
75
|
+
return { status: "unsupported" };
|
|
76
|
+
const parsed = parseJestJsonReportV1(result.stdout);
|
|
77
|
+
if (parsed === undefined)
|
|
78
|
+
return { status: "unsupported" };
|
|
79
|
+
const completed = {
|
|
80
|
+
status: "completed",
|
|
81
|
+
exitCode: typeof result.status === "number" && result.status >= 0 && result.status <= 255 ? result.status : 1,
|
|
82
|
+
summary: parsed.summary,
|
|
83
|
+
failures: parsed.failures,
|
|
84
|
+
};
|
|
85
|
+
return completed;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=node-test-process.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-test-process.js","sourceRoot":"","sources":["../src/node-test-process.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from \"node:child_process\";\nimport { parseJestJsonReportV1 } from \"./node-jest-json-report.ts\";\nimport type { TestRunnerCommandResultV1, TestRunnerCommandRunnerV1 } from \"./types.ts\";\n\nexport const MAX_NODE_TEST_REPORT_BYTES_V1 = 16 * 1024 * 1024;\n\nconst BOUNDED_ENV_KEYS_V1 = [\n\t\"COMSPEC\",\n\t\"NUMBER_OF_PROCESSORS\",\n\t\"OS\",\n\t\"PATH\",\n\t\"PATHEXT\",\n\t\"PROGRAMDATA\",\n\t\"SYSTEMDRIVE\",\n\t\"SYSTEMROOT\",\n\t\"TEMP\",\n\t\"TMP\",\n\t\"WINDIR\",\n] as const;\n\n/** Minimal `CI=1` plus a platform environment allowlist for spawned runners. */\nexport function boundedNodeTestEnvV1(): NodeJS.ProcessEnv {\n\tconst environment: NodeJS.ProcessEnv = { CI: \"1\" };\n\tfor (const key of BOUNDED_ENV_KEYS_V1) {\n\t\tconst value = process.env[key];\n\t\tif (value !== undefined) environment[key] = value;\n\t}\n\treturn environment;\n}\n\nexport interface NodeTestSpawnResultV1 {\n\treadonly error?: Error;\n\treadonly status: number | null;\n\treadonly stdout: string;\n}\n\nexport function spawnNodeTestProcessV1(\n\tcommand: string,\n\targs: readonly string[],\n\toptions: {\n\t\treadonly cwd: string;\n\t\treadonly env: NodeJS.ProcessEnv;\n\t\treadonly signal: AbortSignal;\n\t},\n): Promise<NodeTestSpawnResultV1> {\n\treturn new Promise((resolve) => {\n\t\tconst child = spawn(command, args, {\n\t\t\tshell: false,\n\t\t\twindowsHide: true,\n\t\t\tcwd: options.cwd,\n\t\t\tenv: options.env,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"ignore\"],\n\t\t\tsignal: options.signal,\n\t\t});\n\t\tlet stdout = \"\";\n\t\tlet settled = false;\n\t\tconst finish = (result: NodeTestSpawnResultV1): void => {\n\t\t\tif (settled) return;\n\t\t\tsettled = true;\n\t\t\tresolve(result);\n\t\t};\n\t\tchild.stdout?.on(\"data\", (chunk: Buffer) => {\n\t\t\tif (stdout.length < MAX_NODE_TEST_REPORT_BYTES_V1) stdout += chunk.toString(\"utf8\");\n\t\t});\n\t\tchild.once(\"error\", (error: Error) => finish({ error, status: null, stdout }));\n\t\tchild.once(\"exit\", (code) => finish({ status: code ?? 1, stdout }));\n\t});\n}\n\n/**\n * Shared JSON-report runner: spawns the workspace-local test runner entry with\n * a bounded environment, parses the Jest JSON report into a bounded\n * projection, and never returns raw stdout/stderr or absolute paths. Parse\n * failures degrade to `unsupported` instead of a synthetic passing run.\n */\nexport function createJsonReportTestRunnerCommandRunnerV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandRunnerV1 {\n\tconst workspaceDirectory = options.workspaceDirectory;\n\treturn {\n\t\trun: async ({ command, signal }) => {\n\t\t\tif (typeof command.args[0] !== \"string\" || command.args[0].trim() === \"\") return { status: \"unsupported\" };\n\t\t\tif (signal.aborted) return { status: \"unsupported\" };\n\t\t\tconst result = await spawnNodeTestProcessV1(process.execPath, command.args, {\n\t\t\t\tcwd: workspaceDirectory,\n\t\t\t\tenv: boundedNodeTestEnvV1(),\n\t\t\t\tsignal,\n\t\t\t});\n\t\t\tif (result.error !== undefined) return { status: \"unsupported\" };\n\t\t\tif (new TextEncoder().encode(result.stdout).byteLength > MAX_NODE_TEST_REPORT_BYTES_V1)\n\t\t\t\treturn { status: \"unsupported\" };\n\t\t\tconst parsed = parseJestJsonReportV1(result.stdout);\n\t\t\tif (parsed === undefined) return { status: \"unsupported\" };\n\t\t\tconst completed: Extract<TestRunnerCommandResultV1, { status: \"completed\" }> = {\n\t\t\t\tstatus: \"completed\",\n\t\t\t\texitCode:\n\t\t\t\t\ttypeof result.status === \"number\" && result.status >= 0 && result.status <= 255 ? result.status : 1,\n\t\t\t\tsummary: parsed.summary,\n\t\t\t\tfailures: parsed.failures,\n\t\t\t};\n\t\t\treturn completed;\n\t\t},\n\t};\n}\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1 } from "./types.ts";
|
|
2
|
+
export interface VitestTestRunnerOptionsV1 {
|
|
3
|
+
/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */
|
|
4
|
+
readonly workspaceDirectory: string;
|
|
5
|
+
/** Injectable runner override for deterministic tests; the real spawn is used when absent. */
|
|
6
|
+
readonly runner?: TestRunnerCommandRunnerV1;
|
|
7
|
+
readonly defaultTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Resolves the workspace-local Vitest entry; undefined keeps the default wiring fail-closed. */
|
|
10
|
+
export declare function resolveVitestEntryV1(workspaceDirectory: string): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Host-owned resolver: only the vitest framework is supported in this slice;
|
|
13
|
+
* other frameworks resolve to undefined so the adapter reports unsupported.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createVitestTestRunnerCommandResolverV1(options: {
|
|
16
|
+
readonly workspaceDirectory: string;
|
|
17
|
+
}): TestRunnerCommandResolverV1;
|
|
18
|
+
/**
|
|
19
|
+
* Real Vitest-backed test-runner adapter over one workspace directory. Vitest
|
|
20
|
+
* availability is probed per resolution; a workspace without a local Vitest
|
|
21
|
+
* install resolves to `unsupported` instead of a synthetic result.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createVitestRealTestRunnerAdapterV1(options: VitestTestRunnerOptionsV1): import("./types.ts").TestRunnerAdapterV1;
|
|
24
|
+
//# sourceMappingURL=node-vitest-runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-vitest-runner.d.ts","sourceRoot":"","sources":["../src/node-vitest-runner.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,2BAA2B,EAAE,yBAAyB,EAAoB,MAAM,YAAY,CAAC;AAK3G,MAAM,WAAW,yBAAyB;IACzC,sFAAsF;IACtF,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,CAAC,EAAE,yBAAyB,CAAC;IAC5C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED,iGAAiG;AACjG,wBAAgB,oBAAoB,CAAC,kBAAkB,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKnF;AAED;;;GAGG;AACH,wBAAgB,uCAAuC,CAAC,OAAO,EAAE;IAChE,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;CACpC,GAAG,2BAA2B,CAe9B;AAED;;;;GAIG;AACH,wBAAgB,mCAAmC,CAAC,OAAO,EAAE,yBAAyB,4CAQrF","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { isAbsolute, join } from \"node:path\";\nimport { createNodeTestRunnerAdapterV1 } from \"./node-adapter.ts\";\nimport { createJsonReportTestRunnerCommandRunnerV1 } from \"./node-test-process.ts\";\nimport type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1, TestRunRequestV1 } from \"./types.ts\";\n\nconst DEFAULT_VITEST_TIMEOUT_MS_V1 = 120_000;\nconst TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;\n\nexport interface VitestTestRunnerOptionsV1 {\n\t/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */\n\treadonly workspaceDirectory: string;\n\t/** Injectable runner override for deterministic tests; the real spawn is used when absent. */\n\treadonly runner?: TestRunnerCommandRunnerV1;\n\treadonly defaultTimeoutMs?: number;\n}\n\n/** Resolves the workspace-local Vitest entry; undefined keeps the default wiring fail-closed. */\nexport function resolveVitestEntryV1(workspaceDirectory: string): string | undefined {\n\tif (typeof workspaceDirectory !== \"string\" || workspaceDirectory.trim() === \"\" || !isAbsolute(workspaceDirectory))\n\t\treturn undefined;\n\tconst entry = join(workspaceDirectory, \"node_modules\", \"vitest\", \"vitest.mjs\");\n\treturn existsSync(entry) ? entry : undefined;\n}\n\n/**\n * Host-owned resolver: only the vitest framework is supported in this slice;\n * other frameworks resolve to undefined so the adapter reports unsupported.\n */\nexport function createVitestTestRunnerCommandResolverV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandResolverV1 {\n\treturn {\n\t\tresolve: async (request: TestRunRequestV1) => {\n\t\t\tif (request.framework !== \"vitest\") return undefined;\n\t\t\tconst entry = resolveVitestEntryV1(options.workspaceDirectory);\n\t\t\tif (entry === undefined) return undefined;\n\t\t\tconst target =\n\t\t\t\ttypeof request.target === \"string\" && request.target.trim() !== \"\" ? request.target.trim() : undefined;\n\t\t\tif (target !== undefined && !TARGET_IDENTIFIER_V1.test(target)) return undefined;\n\t\t\treturn {\n\t\t\t\tcommandId: \"node-vitest\",\n\t\t\t\targs: [entry, \"run\", \"--reporter=json\", ...(target === undefined ? [] : [target])],\n\t\t\t};\n\t\t},\n\t};\n}\n\n/**\n * Real Vitest-backed test-runner adapter over one workspace directory. Vitest\n * availability is probed per resolution; a workspace without a local Vitest\n * install resolves to `unsupported` instead of a synthetic result.\n */\nexport function createVitestRealTestRunnerAdapterV1(options: VitestTestRunnerOptionsV1) {\n\treturn createNodeTestRunnerAdapterV1({\n\t\tcommandResolver: createVitestTestRunnerCommandResolverV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\trunner:\n\t\t\toptions.runner ??\n\t\t\tcreateJsonReportTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\tdefaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_VITEST_TIMEOUT_MS_V1,\n\t});\n}\n"]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { createNodeTestRunnerAdapterV1 } from "./node-adapter.js";
|
|
4
|
+
import { createJsonReportTestRunnerCommandRunnerV1 } from "./node-test-process.js";
|
|
5
|
+
const DEFAULT_VITEST_TIMEOUT_MS_V1 = 120_000;
|
|
6
|
+
const TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;
|
|
7
|
+
/** Resolves the workspace-local Vitest entry; undefined keeps the default wiring fail-closed. */
|
|
8
|
+
export function resolveVitestEntryV1(workspaceDirectory) {
|
|
9
|
+
if (typeof workspaceDirectory !== "string" || workspaceDirectory.trim() === "" || !isAbsolute(workspaceDirectory))
|
|
10
|
+
return undefined;
|
|
11
|
+
const entry = join(workspaceDirectory, "node_modules", "vitest", "vitest.mjs");
|
|
12
|
+
return existsSync(entry) ? entry : undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Host-owned resolver: only the vitest framework is supported in this slice;
|
|
16
|
+
* other frameworks resolve to undefined so the adapter reports unsupported.
|
|
17
|
+
*/
|
|
18
|
+
export function createVitestTestRunnerCommandResolverV1(options) {
|
|
19
|
+
return {
|
|
20
|
+
resolve: async (request) => {
|
|
21
|
+
if (request.framework !== "vitest")
|
|
22
|
+
return undefined;
|
|
23
|
+
const entry = resolveVitestEntryV1(options.workspaceDirectory);
|
|
24
|
+
if (entry === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
const target = typeof request.target === "string" && request.target.trim() !== "" ? request.target.trim() : undefined;
|
|
27
|
+
if (target !== undefined && !TARGET_IDENTIFIER_V1.test(target))
|
|
28
|
+
return undefined;
|
|
29
|
+
return {
|
|
30
|
+
commandId: "node-vitest",
|
|
31
|
+
args: [entry, "run", "--reporter=json", ...(target === undefined ? [] : [target])],
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Real Vitest-backed test-runner adapter over one workspace directory. Vitest
|
|
38
|
+
* availability is probed per resolution; a workspace without a local Vitest
|
|
39
|
+
* install resolves to `unsupported` instead of a synthetic result.
|
|
40
|
+
*/
|
|
41
|
+
export function createVitestRealTestRunnerAdapterV1(options) {
|
|
42
|
+
return createNodeTestRunnerAdapterV1({
|
|
43
|
+
commandResolver: createVitestTestRunnerCommandResolverV1({ workspaceDirectory: options.workspaceDirectory }),
|
|
44
|
+
runner: options.runner ??
|
|
45
|
+
createJsonReportTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory }),
|
|
46
|
+
defaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_VITEST_TIMEOUT_MS_V1,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=node-vitest-runner.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node-vitest-runner.js","sourceRoot":"","sources":["../src/node-vitest-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,6BAA6B,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,EAAE,yCAAyC,EAAE,MAAM,wBAAwB,CAAC;AAGnF,MAAM,4BAA4B,GAAG,OAAO,CAAC;AAC7C,MAAM,oBAAoB,GAAG,kCAAkC,CAAC;AAUhE,iGAAiG;AACjG,MAAM,UAAU,oBAAoB,CAAC,kBAA0B,EAAsB;IACpF,IAAI,OAAO,kBAAkB,KAAK,QAAQ,IAAI,kBAAkB,CAAC,IAAI,EAAE,KAAK,EAAE,IAAI,CAAC,UAAU,CAAC,kBAAkB,CAAC;QAChH,OAAO,SAAS,CAAC;IAClB,MAAM,KAAK,GAAG,IAAI,CAAC,kBAAkB,EAAE,cAAc,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;IAC/E,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAAA,CAC7C;AAED;;;GAGG;AACH,MAAM,UAAU,uCAAuC,CAAC,OAEvD,EAA+B;IAC/B,OAAO;QACN,OAAO,EAAE,KAAK,EAAE,OAAyB,EAAE,EAAE,CAAC;YAC7C,IAAI,OAAO,CAAC,SAAS,KAAK,QAAQ;gBAAE,OAAO,SAAS,CAAC;YACrD,MAAM,KAAK,GAAG,oBAAoB,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC;YAC/D,IAAI,KAAK,KAAK,SAAS;gBAAE,OAAO,SAAS,CAAC;YAC1C,MAAM,MAAM,GACX,OAAO,OAAO,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;YACxG,IAAI,MAAM,KAAK,SAAS,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,MAAM,CAAC;gBAAE,OAAO,SAAS,CAAC;YACjF,OAAO;gBACN,SAAS,EAAE,aAAa;gBACxB,IAAI,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;aAClF,CAAC;QAAA,CACF;KACD,CAAC;AAAA,CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,mCAAmC,CAAC,OAAkC,EAAE;IACvF,OAAO,6BAA6B,CAAC;QACpC,eAAe,EAAE,uCAAuC,CAAC,EAAE,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,EAAE,CAAC;QAC5G,MAAM,EACL,OAAO,CAAC,MAAM;YACd,yCAAyC,CAAC,EAAE,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,EAAE,CAAC;QAC9F,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,IAAI,4BAA4B;KAC1E,CAAC,CAAC;AAAA,CACH","sourcesContent":["import { existsSync } from \"node:fs\";\nimport { isAbsolute, join } from \"node:path\";\nimport { createNodeTestRunnerAdapterV1 } from \"./node-adapter.ts\";\nimport { createJsonReportTestRunnerCommandRunnerV1 } from \"./node-test-process.ts\";\nimport type { TestRunnerCommandResolverV1, TestRunnerCommandRunnerV1, TestRunRequestV1 } from \"./types.ts\";\n\nconst DEFAULT_VITEST_TIMEOUT_MS_V1 = 120_000;\nconst TARGET_IDENTIFIER_V1 = /^[A-Za-z0-9][A-Za-z0-9._/:@-]*$/u;\n\nexport interface VitestTestRunnerOptionsV1 {\n\t/** Host-owned workspace directory the runner executes in. Never returned in a DTO. */\n\treadonly workspaceDirectory: string;\n\t/** Injectable runner override for deterministic tests; the real spawn is used when absent. */\n\treadonly runner?: TestRunnerCommandRunnerV1;\n\treadonly defaultTimeoutMs?: number;\n}\n\n/** Resolves the workspace-local Vitest entry; undefined keeps the default wiring fail-closed. */\nexport function resolveVitestEntryV1(workspaceDirectory: string): string | undefined {\n\tif (typeof workspaceDirectory !== \"string\" || workspaceDirectory.trim() === \"\" || !isAbsolute(workspaceDirectory))\n\t\treturn undefined;\n\tconst entry = join(workspaceDirectory, \"node_modules\", \"vitest\", \"vitest.mjs\");\n\treturn existsSync(entry) ? entry : undefined;\n}\n\n/**\n * Host-owned resolver: only the vitest framework is supported in this slice;\n * other frameworks resolve to undefined so the adapter reports unsupported.\n */\nexport function createVitestTestRunnerCommandResolverV1(options: {\n\treadonly workspaceDirectory: string;\n}): TestRunnerCommandResolverV1 {\n\treturn {\n\t\tresolve: async (request: TestRunRequestV1) => {\n\t\t\tif (request.framework !== \"vitest\") return undefined;\n\t\t\tconst entry = resolveVitestEntryV1(options.workspaceDirectory);\n\t\t\tif (entry === undefined) return undefined;\n\t\t\tconst target =\n\t\t\t\ttypeof request.target === \"string\" && request.target.trim() !== \"\" ? request.target.trim() : undefined;\n\t\t\tif (target !== undefined && !TARGET_IDENTIFIER_V1.test(target)) return undefined;\n\t\t\treturn {\n\t\t\t\tcommandId: \"node-vitest\",\n\t\t\t\targs: [entry, \"run\", \"--reporter=json\", ...(target === undefined ? [] : [target])],\n\t\t\t};\n\t\t},\n\t};\n}\n\n/**\n * Real Vitest-backed test-runner adapter over one workspace directory. Vitest\n * availability is probed per resolution; a workspace without a local Vitest\n * install resolves to `unsupported` instead of a synthetic result.\n */\nexport function createVitestRealTestRunnerAdapterV1(options: VitestTestRunnerOptionsV1) {\n\treturn createNodeTestRunnerAdapterV1({\n\t\tcommandResolver: createVitestTestRunnerCommandResolverV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\trunner:\n\t\t\toptions.runner ??\n\t\t\tcreateJsonReportTestRunnerCommandRunnerV1({ workspaceDirectory: options.workspaceDirectory }),\n\t\tdefaultTimeoutMs: options.defaultTimeoutMs ?? DEFAULT_VITEST_TIMEOUT_MS_V1,\n\t});\n}\n"]}
|