From b928cb43bdfa94b15339823905ad99af8c698e1a Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:50:37 +0800 Subject: [PATCH 1/4] test: select source Python for Node subprocesses Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .github/workflows/python-tests.yml | 3 + ...goal-acceptance-contract-browser-smoke.mjs | 3 +- ...hboard-attention-details-browser-smoke.mjs | 3 +- ...ashboard-goal-acceptance-browser-smoke.mjs | 3 +- .../dashboard-reward-append-browser-smoke.mjs | 3 +- examples/dashboard-usage-browser-smoke.mjs | 3 +- examples/export-frontstage-share-bundle.mjs | 3 +- .../personal-workspace-browser/fixture.mjs | 3 +- .../status-source-switch-browser-smoke.mjs | 3 +- ...pace-progressive-loading-browser-smoke.mjs | 3 +- scripts/test-python.d.mts | 7 ++ scripts/test-python.mjs | 77 +++++++++++++++ .../coordination_state_contract.test.ts | 7 +- .../local_authority_shadow_outbox.test.ts | 6 +- .../nokv_jsonl_transport.test.ts | 4 +- ...nokv_stage2a_qualification_harness.test.ts | 3 +- .../succession_read_conformance.ts | 3 +- .../test_python_runtime.test.ts | 99 +++++++++++++++++++ .../todo_consumer_scope_conformance.ts | 3 +- .../todo_continuation.test.ts | 9 +- tsconfig.control-plane.json | 3 +- 21 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 scripts/test-python.d.mts create mode 100644 scripts/test-python.mjs create mode 100644 tests/control_plane_ts/test_python_runtime.test.ts diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 91d4230e74..7d424dbd2a 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -555,6 +555,9 @@ jobs: - name: Install test dependencies run: python -m pip install --disable-pip-version-check -e ".[test]" + - name: Verify native Windows test Python discovery + run: node --no-warnings --experimental-strip-types --test tests/control_plane_ts/test_python_runtime.test.ts + - name: Run native Windows lifecycle tests run: >- python -m pytest -q diff --git a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs index 31a21ce911..c259c7f011 100644 --- a/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-acceptance-contract-browser-smoke.mjs @@ -7,10 +7,11 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { cleanupBrowserSmoke, launchBrowser, startViteDashboardServer, waitForHttp } from "../../../../examples/dashboard-browser-smoke-support.mjs"; import { acceptance, contract, snapshot, verifiedContract } from "./goal-acceptance-contract-fixture.mjs"; +import { resolveTestPython } from "../../../../scripts/test-python.mjs"; const dashboardDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const root = resolve(dashboardDir, "../../.."); -const python = process.env.LOOPX_PYTHON ?? "python3"; +const python = resolveTestPython(); const port = Number(process.env.LOOPX_ACCEPTANCE_CONTRACT_PORT ?? 5297); const packaged = process.env.LOOPX_ACCEPTANCE_CONTRACT_PACKAGED === "1"; const payload = JSON.parse(execFileSync(python, ["-c", ` diff --git a/examples/dashboard-attention-details-browser-smoke.mjs b/examples/dashboard-attention-details-browser-smoke.mjs index c78ee061db..896dba52ea 100644 --- a/examples/dashboard-attention-details-browser-smoke.mjs +++ b/examples/dashboard-attention-details-browser-smoke.mjs @@ -6,6 +6,7 @@ import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { launchBrowser, loadPlaywright, startViteDashboardServer, waitForHttp } from "./dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const require = createRequire(import.meta.url); const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -14,7 +15,7 @@ const port = Number(process.env.LOOPX_ATTENTION_DETAILS_PORT ?? 5293); const packaged = process.env.LOOPX_ATTENTION_DETAILS_PACKAGED === "1"; const output = resolve(root, "output/playwright/attention-details"); const server = packaged - ? spawn(process.env.LOOPX_PYTHON_BIN ?? "python3", ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(root, "loopx/web")], { stdio: "ignore" }) + ? spawn(resolveTestPython(), ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(root, "loopx/web")], { stdio: "ignore" }) : startViteDashboardServer({ dashboardDir, port }); const url = `http://127.0.0.1:${port}/${packaged ? "chat/" : ""}?statusUrl=/status.json`; let browser; diff --git a/examples/dashboard-goal-acceptance-browser-smoke.mjs b/examples/dashboard-goal-acceptance-browser-smoke.mjs index b09eaa9df9..d1ac19f39d 100644 --- a/examples/dashboard-goal-acceptance-browser-smoke.mjs +++ b/examples/dashboard-goal-acceptance-browser-smoke.mjs @@ -6,11 +6,12 @@ import { mkdir } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { cleanupBrowserSmoke, launchBrowser, loadPlaywright, startViteDashboardServer, waitForHttp } from "./dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const port = Number(process.env.LOOPX_GOAL_ACCEPTANCE_PORT ?? 5291); const packaged = process.env.LOOPX_GOAL_ACCEPTANCE_PACKAGED === "1"; -const python = process.env.LOOPX_PYTHON ?? "python3"; +const python = resolveTestPython(); const payload = JSON.parse(execFileSync(python, ["-c", "import runpy,tempfile,json; from pathlib import Path; m=runpy.run_path('tests/control_plane/test_goal_acceptance_observation.py'); t=tempfile.TemporaryDirectory(); print(json.dumps(m['collect_fixture'](Path(t.name), missing_claim=True)))"], { cwd: root, encoding: "utf8" })); const dashboardDir = resolve(root, "apps/presentation/dashboard"); const server = packaged diff --git a/examples/dashboard-reward-append-browser-smoke.mjs b/examples/dashboard-reward-append-browser-smoke.mjs index 4c39cc30c0..6b7513266b 100644 --- a/examples/dashboard-reward-append-browser-smoke.mjs +++ b/examples/dashboard-reward-append-browser-smoke.mjs @@ -5,6 +5,7 @@ import { existsSync, statSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { resolve } from "node:path"; import net from "node:net"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const repoRoot = resolve(new URL("..", import.meta.url).pathname); const pwcli = process.env.PWCLI ?? resolve(homedir(), ".codex/skills/playwright/scripts/playwright_cli.sh"); @@ -280,7 +281,7 @@ async function main() { const statusBase = `http://127.0.0.1:${statusPort}`; const dashboardBase = `http://127.0.0.1:${dashboardPort}`; - statusServer = startProcess("python3", [ + statusServer = startProcess(resolveTestPython(), [ "-m", "loopx.cli", "--registry", diff --git a/examples/dashboard-usage-browser-smoke.mjs b/examples/dashboard-usage-browser-smoke.mjs index 452f9d3f0c..f73923fd76 100644 --- a/examples/dashboard-usage-browser-smoke.mjs +++ b/examples/dashboard-usage-browser-smoke.mjs @@ -12,6 +12,7 @@ import { startViteDashboardServer, waitForHttp, } from "./dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const require = createRequire(import.meta.url); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -70,7 +71,7 @@ async function main() { await mkdir(outputDir, { recursive: true }); const { chromium } = loadPlaywright(); const server = packaged - ? spawn(process.env.LOOPX_PYTHON_BIN || "python3", ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(repoRoot, "loopx/web")], { stdio: "ignore" }) + ? spawn(resolveTestPython(), ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(repoRoot, "loopx/web")], { stdio: "ignore" }) : startViteDashboardServer({ dashboardDir, port }); let browser; try { diff --git a/examples/export-frontstage-share-bundle.mjs b/examples/export-frontstage-share-bundle.mjs index 020db0dfae..9ad1afa251 100644 --- a/examples/export-frontstage-share-bundle.mjs +++ b/examples/export-frontstage-share-bundle.mjs @@ -6,6 +6,7 @@ import { copyFile, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "nod import { existsSync } from "node:fs"; import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const dashboardDir = resolve(repoRoot, "apps/presentation/dashboard"); @@ -463,7 +464,7 @@ async function main() { await copyPublicSiteRoutes(siteDir); const interactivePages = await copyInteractiveCasePages(siteDir); - const projectionOutput = run("python3", [resolve(repoRoot, "examples/goal-channel-frontstage-fixture.py"), "--format", "json"], { + const projectionOutput = run(resolveTestPython(), [resolve(repoRoot, "examples/goal-channel-frontstage-fixture.py"), "--format", "json"], { capture: true, cwd: repoRoot, }); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index bef9529362..3097c68190 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -3,6 +3,7 @@ import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { startViteDashboardServer } from "../dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; const require = createRequire(import.meta.url); export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -273,7 +274,7 @@ export function startServer() { if (packaged) { // An explicit installed interpreter must resolve its own package, not the checkout. const isolation = process.env.LOOPX_PYTHON_BIN ? ["-I"] : []; - return spawn(process.env.LOOPX_PYTHON_BIN || "python3", [...isolation, "-c", ` + return spawn(resolveTestPython(), [...isolation, "-c", ` from loopx.chat_server import ChatHTTPServer, ChatRequestHandler, default_chat_assets_dir from loopx.presentation.chat_bundle import validate_bundle assets = default_chat_assets_dir() diff --git a/examples/status-source-switch-browser-smoke.mjs b/examples/status-source-switch-browser-smoke.mjs index 75f818b066..c6472797b4 100644 --- a/examples/status-source-switch-browser-smoke.mjs +++ b/examples/status-source-switch-browser-smoke.mjs @@ -13,6 +13,7 @@ import { startViteDashboardServer, waitForHttp, } from "./dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const require = createRequire(import.meta.url); const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -23,7 +24,7 @@ const packaged = process.env.LOOPX_STATUS_SOURCE_SWITCH_PACKAGED === "1"; function startServer() { if (packaged) { - return spawn(process.env.LOOPX_PYTHON_BIN || "python3", [ + return spawn(resolveTestPython(), [ "-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(repoRoot, "loopx/web"), ], { cwd: repoRoot, diff --git a/examples/workspace-progressive-loading-browser-smoke.mjs b/examples/workspace-progressive-loading-browser-smoke.mjs index 25ba55bd16..64c435b928 100644 --- a/examples/workspace-progressive-loading-browser-smoke.mjs +++ b/examples/workspace-progressive-loading-browser-smoke.mjs @@ -7,6 +7,7 @@ import { mkdir } from "node:fs/promises"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { cleanupBrowserSmoke, launchBrowser, loadPlaywright, waitForHttp } from "./dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const root = fileURLToPath(new URL("../", import.meta.url)); process.env.LOOPX_PLAYWRIGHT_PACKAGE ??= resolve(root, "apps/presentation/dashboard/node_modules/playwright"); const require = createRequire(import.meta.url); @@ -21,7 +22,7 @@ function snapshot(id) { payload.workspace_registry_revision = directory.registry_revision; return payload; } -const server = spawn(process.env.LOOPX_PYTHON_BIN ?? "python3", ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(root, "loopx/web")], { stdio: "ignore" }); +const server = spawn(resolveTestPython(), ["-m", "http.server", String(port), "--bind", "127.0.0.1", "--directory", resolve(root, "loopx/web")], { stdio: "ignore" }); let browser; let releaseSlow; const slowGate = new Promise((done) => { releaseSlow = done; }); diff --git a/scripts/test-python.d.mts b/scripts/test-python.d.mts new file mode 100644 index 0000000000..c6b5c6c4de --- /dev/null +++ b/scripts/test-python.d.mts @@ -0,0 +1,7 @@ +export interface TestPythonOptions { + env?: NodeJS.ProcessEnv; + repoRoot?: string; + platform?: NodeJS.Platform; +} + +export function resolveTestPython(options?: TestPythonOptions): string; diff --git a/scripts/test-python.mjs b/scripts/test-python.mjs new file mode 100644 index 0000000000..b14da2d65c --- /dev/null +++ b/scripts/test-python.mjs @@ -0,0 +1,77 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { delimiter, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); +const versionProbe = "import json,sys; print(json.dumps({'executable':sys.executable,'version':list(sys.version_info[:2])}))"; + +function probe(candidate, root, env, prefix = []) { + const command = candidate.includes("/") || candidate.includes("\\") + ? resolve(root, candidate) : candidate; + const result = spawnSync(command, [...prefix, "-c", versionProbe], { + cwd: root, env, encoding: "utf8", timeout: 5_000, + }); + if (result.status !== 0) return null; + try { + const { executable, version } = JSON.parse(result.stdout.trim()); + if (isAbsolute(executable) && Array.isArray(version) + && (version[0] > 3 || (version[0] === 3 && version[1] >= 11))) { + return executable; + } + } catch { /* A candidate that cannot report its runtime is not usable. */ } + return null; +} + +function windowsCandidates(root, env) { + const candidates = []; + const recorded = join(root, ".loopx-python"); + if (existsSync(recorded)) candidates.push([readFileSync(recorded, "utf8").split(/\r?\n/, 1)[0].trim()]); + const versioned = []; + for (const directory of (env.PATH ?? "").split(delimiter)) { + try { + for (const name of readdirSync(directory || ".")) { + const match = /^python3\.(\d+)(?:\.exe)?$/i.exec(name); + if (match) versioned.push({ minor: Number(match[1]), path: join(directory, name) }); + } + } catch { /* Missing PATH entries are ordinary discovery misses. */ } + } + versioned.sort((a, b) => b.minor - a.minor); + candidates.push(...versioned.map(({ path }) => [path])); + candidates.push(["python3"], ["python"], ["py", "-3"]); + return candidates; +} + +/** Select the interpreter used by source-checkout tests; never install or mutate one. */ +export function resolveTestPython({ env = process.env, repoRoot = repositoryRoot, platform = process.platform } = {}) { + const root = resolve(repoRoot); + for (const key of ["LOOPX_TEST_PYTHON", "LOOPX_PYTHON_BIN", "LOOPX_PYTHON"]) { + if (!env[key]) continue; + const selected = probe(env[key], root, env); + if (selected) return selected; + throw new Error(`${key} does not resolve to Python 3.11+; choose the test environment's Python executable.`); + } + const environmentPython = platform === "win32" ? join("Scripts", "python.exe") : join("bin", "python"); + for (const directory of [join(root, ".venv"), env.VIRTUAL_ENV]) { + if (!directory) continue; + const selected = probe(join(directory, environmentPython), root, env); + if (selected) return selected; + } + if (platform !== "win32") { + // Reuse the source launcher's .loopx-python/.venv/PATH precedence on POSIX. + const selected = spawnSync("bash", [join(root, "scripts", "loopx-python.sh")], { + cwd: root, env, encoding: "utf8", timeout: 10_000, + }); + if (selected.status === 0) { + const python = probe(selected.stdout.trim(), root, env); + if (python) return python; + } + } else { + for (const [candidate, ...prefix] of windowsCandidates(root, env)) { + if (!candidate) continue; + const selected = probe(candidate, root, env, prefix); + if (selected) return selected; + } + } + throw new Error("No Python 3.11+ interpreter found for LoopX tests. Run `uv sync --extra test` from this worktree or set LOOPX_TEST_PYTHON to a compatible executable."); +} diff --git a/tests/control_plane_ts/coordination_state_contract.test.ts b/tests/control_plane_ts/coordination_state_contract.test.ts index 713e81d2fc..774aa61001 100644 --- a/tests/control_plane_ts/coordination_state_contract.test.ts +++ b/tests/control_plane_ts/coordination_state_contract.test.ts @@ -2,6 +2,9 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import test from "node:test"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; + +const PYTHON = resolveTestPython(); import { canonicalCoordinationRecord, @@ -69,7 +72,7 @@ const TODO = { function pythonContract(): Promise> { return new Promise((resolve, reject) => { - const child = spawn("python3", ["-c", [ + const child = spawn(PYTHON, ["-c", [ "import json", "from loopx.control_plane.coordination.coordination_state_contract import COORDINATION_STATE_CONTRACT", "print(json.dumps(COORDINATION_STATE_CONTRACT, default=dict, sort_keys=True, separators=(',', ':')))", @@ -100,7 +103,7 @@ test("coordination state contract generates identical cross-language bindings", test("generated coordination bindings are current", async () => { await new Promise((resolve, reject) => { const child = spawn( - "python3", + PYTHON, ["scripts/generate_coordination_state_contract.py", "--check"], { cwd: process.cwd() }, ); diff --git a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts index 0ed1ff74bc..cf83620ff7 100644 --- a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts +++ b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts @@ -14,8 +14,10 @@ import { outboxEntryIdentity, beginLeaseOutboxEntry } from "../../loopx/control_ import { requireShadowCaptureBinding } from "../../loopx/control_plane/coordination/shadow_management.ts"; import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; import { fixture, pendingEntry, settleFiles, todo, sha } from "./shadow_file_fixture.ts"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; const execFileAsync = promisify(execFile); +const PYTHON = resolveTestPython(); test("one primary entry commits exactly once after a complete baseline", async (t) => { const f = await fixture(t); @@ -266,7 +268,7 @@ test("prose bytes may change only while the canonical previous partition remains test("Python and TypeScript entry identity include the same root and lineage", async () => { const source = sha("source"); const root = sha("root"); const script = "from loopx.control_plane.coordination.local_authority_shadow_outbox import entry_identity\nprint(entry_identity(goal_id='goal-a',partition='leases',seq=7,source_ref='" + source + "',capture_lineage_id='lineage-a',source_root_digest='" + root + "'))"; - const result = await execFileAsync(process.env.LOOPX_TEST_PYTHON ?? "python3", ["-c", script], + const result = await execFileAsync(PYTHON, ["-c", script], { cwd: join(import.meta.dirname, "..", "..") }); assert.equal(result.stdout.trim(), outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-a", root)); assert.notEqual(outboxEntryIdentity("goal-a", "leases", 7, source, "lineage-a", root), @@ -291,7 +293,7 @@ test("Python and TypeScript share the stable Todo partition digest", async () => "print(partition_digest(json.loads(sys.argv[1])))", ].join("\n"); const result = await execFileAsync( - process.env.LOOPX_TEST_PYTHON ?? "python3", + PYTHON, ["-c", script, JSON.stringify(projection)], { cwd: join(import.meta.dirname, "..", "..") }, ); diff --git a/tests/control_plane_ts/nokv_jsonl_transport.test.ts b/tests/control_plane_ts/nokv_jsonl_transport.test.ts index 34c2552df1..137214fe16 100644 --- a/tests/control_plane_ts/nokv_jsonl_transport.test.ts +++ b/tests/control_plane_ts/nokv_jsonl_transport.test.ts @@ -9,8 +9,9 @@ import { } from "../../loopx/control_plane/coordination/nokv_authority_store.ts"; import { NoKVJsonLinesTransport } from "../../loopx/control_plane/coordination/nokv_jsonl_transport.ts"; import { registerAuthorityStoreConformance } from "./authority_store_conformance.ts"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; -const PYTHON = process.env.LOOPX_TEST_PYTHON ?? "python3"; +const PYTHON = resolveTestPython(); const FAULT_HELPER = fileURLToPath( new URL("../fixtures/nokv_jsonl_fake_helper.py", import.meta.url), ); @@ -296,4 +297,3 @@ test("JSON-lines transport surfaces an unknown routing kind as a typed protocol error instanceof NoKVTransportProtocolError && /routing kind/.test(error.message), ); }); - diff --git a/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts b/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts index 1ceef39e8b..15ea6f309a 100644 --- a/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts +++ b/tests/control_plane_ts/nokv_stage2a_qualification_harness.test.ts @@ -27,11 +27,12 @@ import { import { NoKVJsonLinesTransport, } from "../../loopx/control_plane/coordination/nokv_jsonl_transport.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; const REPOSITORY_HELPER = fileURLToPath( new URL("../../loopx/control_plane/coordination/nokv_jsonl_helper.py", import.meta.url), ); -const PYTHON = process.env.LOOPX_TEST_PYTHON ?? "python3"; +const PYTHON = resolveTestPython(); /** Minimal module that satisfies helper admission and records that it was imported. */ const STAND_IN_SDK_SOURCE = `import os diff --git a/tests/control_plane_ts/succession_read_conformance.ts b/tests/control_plane_ts/succession_read_conformance.ts index ae0dcda9ec..0a3ba8c09d 100644 --- a/tests/control_plane_ts/succession_read_conformance.ts +++ b/tests/control_plane_ts/succession_read_conformance.ts @@ -4,8 +4,9 @@ import test from "node:test"; import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; import {productionScaleSuccessionFixture} from "./production_scale_coordination_fixture.ts"; import {validateCoordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; -const PYTHON = process.env.LOOPX_TEST_PYTHON ?? "python3"; +const PYTHON = resolveTestPython(); // Exercise the shipped Python consumer → typed policy, not a second test reducer. const CONSUMER = ` diff --git a/tests/control_plane_ts/test_python_runtime.test.ts b/tests/control_plane_ts/test_python_runtime.test.ts new file mode 100644 index 0000000000..ea7fc1298f --- /dev/null +++ b/tests/control_plane_ts/test_python_runtime.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { resolveTestPython } from "../../scripts/test-python.mjs"; + +const root = fileURLToPath(new URL("../../", import.meta.url)); + +test("test subprocess discovery selects a compatible checkout Python", () => { + const python = resolveTestPython(); + const probe = spawnSync(python, ["-c", "import json,sys,loopx; print(json.dumps({'version':list(sys.version_info[:2]),'source':loopx.__file__}))"], { + cwd: root, encoding: "utf8", + }); + assert.equal(probe.status, 0, probe.stderr); + const result = JSON.parse(probe.stdout); + assert.ok(result.version[0] > 3 || result.version[0] === 3 && result.version[1] >= 11); + assert.ok(resolve(result.source).startsWith(root), result.source); +}); + +test("an invalid explicit test Python never falls back silently", t => { + const directory = mkdtempSync(join(tmpdir(), "loopx-test-python-invalid-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const missing = join(directory, "missing-python"); + assert.throws(() => resolveTestPython({ env: { ...process.env, LOOPX_TEST_PYTHON: missing } }), + /LOOPX_TEST_PYTHON does not resolve to Python 3\.11\+/); + const current = resolveTestPython(); + assert.equal(resolveTestPython({ env: { + ...process.env, LOOPX_TEST_PYTHON: current, LOOPX_PYTHON_BIN: missing, + } }), current, "the test override takes precedence over legacy browser overrides"); +}); + +test("a reported Python 3.9 is rejected even when the executable starts", t => { + if (process.platform === "win32") { + t.skip("the POSIX fake executable is covered on the Unix test lane"); + return; + } + const directory = mkdtempSync(join(tmpdir(), "loopx-test-python-old-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const oldPython = join(directory, "python3"); + writeFileSync(oldPython, "#!/bin/sh\nprintf '%s\\n' '{\"executable\":\"/usr/bin/python3\",\"version\":[3,9]}'\n", { mode: 0o755 }); + assert.throws(() => resolveTestPython({ env: { ...process.env, LOOPX_TEST_PYTHON: oldPython } }), + /LOOPX_TEST_PYTHON does not resolve to Python 3\.11\+/); +}); + +test("a worktree venv wins over an unusable system python3", t => { + if (process.platform === "win32") { + t.skip("POSIX launcher precedence; Windows discovery runs in the native CI lane"); + return; + } + const directory = mkdtempSync(join(tmpdir(), "loopx-test-python-venv-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + mkdirSync(join(directory, "scripts")); + mkdirSync(join(directory, ".venv", "bin"), { recursive: true }); + const fakeBin = join(directory, "bin"); + mkdirSync(fakeBin); + copyFileSync(join(root, "scripts", "loopx-python.sh"), join(directory, "scripts", "loopx-python.sh")); + symlinkSync(resolveTestPython(), join(directory, ".venv", "bin", "python")); + const systemMarker = join(directory, "system-python-was-used"); + const fakeSystem = join(fakeBin, "python3"); + writeFileSync(fakeSystem, `#!/bin/sh\ntouch '${systemMarker}'\nexit 1\n`, { mode: 0o755 }); + writeFileSync(join(directory, ".loopx-python"), `${fakeSystem}\n`); + const env: NodeJS.ProcessEnv = { ...process.env, PATH: `${fakeBin}:/usr/bin:/bin` }; + delete env.LOOPX_TEST_PYTHON; + delete env.LOOPX_PYTHON_BIN; + delete env.LOOPX_PYTHON; + delete env.VIRTUAL_ENV; + const selected = resolveTestPython({ env, repoRoot: directory }); + assert.equal(selected, join(directory, ".venv", "bin", "python")); + assert.equal(existsSync(systemMarker), false); +}); + +test("test and browser smokes may not introduce bare python3 subprocess fallbacks", () => { + const directories = ["tests/control_plane_ts", "examples", "apps/presentation/dashboard/smoke"]; + const offenders: string[] = []; + const direct = /\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*["']python3["']/; + const fallback = /\b(?:LOOPX_TEST_PYTHON|LOOPX_PYTHON_BIN|LOOPX_PYTHON)\s*(?:\?\?|\|\|)\s*["']python3["']/; + const assigned = /\b(?:const|let)\s+(?:PYTHON|python)\s*=\s*["']python3["']/; + const bare = JSON.stringify("python3"); + assert.ok(direct.test(`spawn(${bare}, ["-m", "loopx.cli"])`)); + assert.ok(fallback.test(`process.env.LOOPX_TEST_PYTHON ?? ${bare}`)); + assert.ok(assigned.test(`const PYTHON = ${bare}`)); + assert.equal(direct.test(`validation_command_argv: [${bare}, "-m", "pytest"]`), false); + function inspect(directory: string) { + for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) inspect(path); + else if (/\.(?:cjs|js|mjs|mts|ts)$/.test(entry.name)) { + const source = readFileSync(join(root, path), "utf8"); + if (direct.test(source) || fallback.test(source) || assigned.test(source)) offenders.push(path); + } + } + } + for (const directory of directories) inspect(directory); + assert.deepEqual(offenders, [], `Use scripts/test-python.mjs for Python subprocesses: ${offenders.join(", ")}`); +}); diff --git a/tests/control_plane_ts/todo_consumer_scope_conformance.ts b/tests/control_plane_ts/todo_consumer_scope_conformance.ts index c44c08f2d3..58f4f6b7e6 100644 --- a/tests/control_plane_ts/todo_consumer_scope_conformance.ts +++ b/tests/control_plane_ts/todo_consumer_scope_conformance.ts @@ -4,8 +4,9 @@ import test from "node:test"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; import type {AuthorityStoreConformanceFactory} from "./authority_store_conformance.ts"; import {productionScaleConsumerScopeFixture} from "./production_scale_coordination_fixture.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; -const PYTHON = process.env.LOOPX_TEST_PYTHON ?? "python3"; +const PYTHON = resolveTestPython(); // The actual Python read consumer still hosts rendering; policy runs in TS. const CONSUMER = ` diff --git a/tests/control_plane_ts/todo_continuation.test.ts b/tests/control_plane_ts/todo_continuation.test.ts index 2314b82ac8..af02ebcbe5 100644 --- a/tests/control_plane_ts/todo_continuation.test.ts +++ b/tests/control_plane_ts/todo_continuation.test.ts @@ -15,6 +15,9 @@ import {computeContinuationTodoFacts} from "../../loopx/control_plane/coordinati import {prepareCoordinationProjectionCommit, indexCoordinationProjection} from "../../loopx/control_plane/coordination/coordination_projection.ts"; import {engageLegacyCoordinationWriterFence, LEGACY_COORDINATION_WRITER_FENCE_ENGAGE_REQUEST_SCHEMA, LEGACY_COORDINATION_WRITER_FENCE_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; import {selectLocalSqliteAuthority, openLocalAuthorityStore} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; + +const PYTHON = resolveTestPython(); async function fixture(t: test.TestContext) { const root = await mkdtemp(join(tmpdir(), "loopx-continuation-")); @@ -110,7 +113,7 @@ test("real Python CLI source/target processes use the file backend and do not au await writeFile(registry, JSON.stringify({common_runtime_root: f.root, goals: [{id: "goal-a", repo: f.root, state_file: "ACTIVE_GOAL_STATE.md", coordination: {agent_model: "peer_v1", registered_agents: ["agent-a", "agent-b"]}}]})); const cli = (action: string, session: string, args: string[] = []) => { - const run = spawnSync("python3", ["-m", "loopx.cli", "--registry", registry, "--runtime-root", f.root, "--format", "json", + const run = spawnSync(PYTHON, ["-m", "loopx.cli", "--registry", registry, "--runtime-root", f.root, "--format", "json", "handoff", action, "--goal-id", "goal-a", "--todo-id", "todo_a", "--agent-id", "agent-a", "--session-id", session, "--workspace", f.root, ...args], {encoding: "utf8", env: {...process.env, PYTHONPATH: process.cwd()}, timeout: 30000}); assert.equal(run.error, undefined); @@ -139,7 +142,7 @@ test("real Python CLI source/target processes use the file backend and do not au }); function runCli(cliRoot: string, registry: string, action: string, session: string, args: string[] = []) { - const run = spawnSync("python3", ["-m", "loopx.cli", "--registry", registry, "--runtime-root", cliRoot, "--format", "json", + const run = spawnSync(PYTHON, ["-m", "loopx.cli", "--registry", registry, "--runtime-root", cliRoot, "--format", "json", "handoff", action, "--goal-id", "goal-a", "--todo-id", "todo_a", "--agent-id", "agent-a", "--session-id", session, "--workspace", cliRoot, ...args], {encoding: "utf8", env: {...process.env, PYTHONPATH: process.cwd()}, timeout: 30000}); assert.equal(run.error, undefined, run.stderr); @@ -288,7 +291,7 @@ test("CLI --format digest renders readable handoff summary", async t => { const cli = (action: string, session: string, args: string[] = [], format = "json", agentId = "agent-a") => { // The 'digest' format is a subcommand-specific flag; pass it after the action. const formatArgs = format === "digest" ? ["--format", "digest"] : []; - const run = spawnSync("python3", ["-m", "loopx.cli", "--registry", registry, "--runtime-root", f.root, + const run = spawnSync(PYTHON, ["-m", "loopx.cli", "--registry", registry, "--runtime-root", f.root, "--format", "json", "handoff", action, "--goal-id", "goal-a", "--todo-id", "todo_a", "--agent-id", agentId, "--session-id", session, "--workspace", f.root, ...formatArgs, ...args], {encoding: "utf8", env: {...process.env, PYTHONPATH: process.cwd()}, timeout: 30000}); diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index fef1bb0868..8794127f2c 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -153,6 +153,7 @@ "tests/control_plane_ts/shared_goal_alignment.test.ts", "tests/control_plane_ts/replan_history.test.ts", "tests/control_plane_ts/goal_amendment_proposal.test.ts", - "tests/control_plane_ts/goal_instance_identity.test.ts" + "tests/control_plane_ts/goal_instance_identity.test.ts", + "tests/control_plane_ts/test_python_runtime.test.ts" ] } From 8e21a9071e9c914384c5d83a8608717e92d10c8e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:51:21 +0800 Subject: [PATCH 2/4] docs: record Node test Python discovery contract Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/development/testing-and-quality.md | 17 +++++++++++++++++ .../references/repair-patterns.md | 1 + 2 files changed, 18 insertions(+) diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index ebdb54a9c1..013361a7f9 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -371,6 +371,23 @@ Confirm the interpreter and imported checkout when diagnosing a mismatch: uv run python -c "import sys, loopx; print(sys.executable); print(loopx.__file__)" ``` +Node-based TypeScript tests and browser smokes that launch Python use +`scripts/test-python.mjs`. It honors an explicit `LOOPX_TEST_PYTHON` (then the +existing `LOOPX_PYTHON_BIN`/`LOOPX_PYTHON` overrides), otherwise reuses the +source launcher's selection on POSIX or discovers a compatible interpreter on +Windows. It prefers the worktree environment, checks Python `>=3.11`, and +fails with a setup hint instead of falling back to an incompatible system +`python3`. A source-level regression test rejects new bare-`python3` +subprocess/fallback patterns in test and browser-smoke entry points. After +`uv sync --extra test`, `npm run test:control-plane` needs no manual Python +environment variable; `LOOPX_TEST_PYTHON=/path/to/python` is an explicit +override when a separate compatible environment is intentional. + +会启动 Python 的 Node/TypeScript 测试和浏览器 smoke 统一使用 +`scripts/test-python.mjs`:显式覆盖优先,否则优先当前 worktree 环境,校验 +Python `>=3.11`;不会静默退回不兼容的系统 `python3`。回归测试会拦截测试入口 +重新引入裸 `python3` 子进程或默认值。 + Canary executes Python checks with the interpreter that launched LoopX (`sys.executable`). Its displayed `python3` command is not a second interpreter selection. Keep subprocesses on `sys.executable`; use `uv run` at the developer diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index e80306ed43..fed3e1476d 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -145,6 +145,7 @@ teaches a reusable control-plane lesson. | `stale_local_service_reused_after_upgrade` | The default command and desktop assets contain a fix, but dashboard mutations still return a pre-fix error after an upgrade or reinstall; a UI-only development preview may instead expose an empty `502` response as a raw JSON parser failure. | `readlink ~/.local/bin/loopx`, release manifests for the selected command and listeners, listener process start times, `LOOPX_RELEASE_ROOT` for the status/Chat services, Vite proxy target reachability, and loopback capability payloads. | A long-lived local status or Chat process survived the upgrade, the UI was started without its matching Chat service, or the launcher accepted a schema-only service fingerprint and silently reused code from another release. | Expose a public-safe runtime identity (`package_version`, immutable `release_id`, and source revision) on local service probes. Start development UI and Chat as one version-matched surface; parse API failures at the shared client boundary so empty or non-JSON responses become actionable service errors. Reuse only an exact installed-release match; classify a valid LoopX service from another release as stale and require its owning dashboard/app to stop before the current release starts. Cover browser launcher, UI-only proxy failure, and desktop supervisor probes. | | `release_evidence_goal_binding_gap` | A release/promotion canary passes every check, then its evidence writeback fails because a fixed or caller-selected project Goal is absent from the registry. | canary command result, evidence write command, registry goals, runtime root, promotion gate and doctor readback. | Installation-scoped release evidence reused project `refresh-state`, so an unrelated Goal became a hidden prerequisite for validating the shared local release. | Store new evidence in an append-only runtime release ledger through an explicit CLI write contract; read it for every Goal and retain old Goal run-history events only as a compatibility fallback. Cover an empty-goal registry plus installer, gate, doctor, and goal-filtered status readback. | | `local_release_python_runtime_drift` | A locally promoted release imports under CI's declared Python but the default macOS `python3` exits during CLI startup on a newly added standard-library symbol. | `python3 --version`, default wrapper target, failing import traceback, `pyproject.toml` requirement, changed module imports, candidate doctor under the actual wrapper interpreter. | A new module used a newer convenience alias even though the source-based local wrapper still runs on an older system interpreter, and release validation did not exercise that real default path before promotion. | Prefer an equivalent broadly available standard-library API at the narrow import seam, add a focused timestamp/import regression, run candidate doctor plus the affected product smoke with the actual default interpreter, and only then promote the repaired release. Do not claim broad interpreter support beyond the verified CLI path. | +| `node_test_python_selector_bypass` | Python-backed Node tests or browser smokes fail under a system Python 3.9 although `uv` prepared a compatible worktree interpreter. | `requires-python`, source checkout/active venv, selected child argv and `sys.executable`, bare `python3` fallbacks, and the test runner's environment. | JS/TS subprocess callers independently selected `python3` from PATH instead of the source checkout's Python discovery contract. | Resolve one checked Python 3.11+ interpreter for test subprocesses, honoring explicit overrides and the active/worktree environment before system discovery; fail with an actionable setup hint when none exists. Cover 3.9 rejection, worktree precedence, native Windows discovery, source import provenance, and a static guard against new bare-`python3` test subprocesses. Do not rewrite historical validation receipts or change product runtime interpreter authority. | | `heartbeat_prompt_error_projection_gap` | `loopx heartbeat-prompt --help` succeeds, but an invalid generation command exits 1 while markdown shows a copyable empty task body and `Generator Inputs` fields such as `active_state`, `thin`, `agent_id`, or `agent_scopes` as `None`. | failing markdown output, `--format json` payload, CLI handler exception branch, active-state lookup inputs, agent identity arguments. | The command caught generation errors into a minimal `{ok:false,error}` payload and then reused the success markdown renderer, which made an error look like a generated blank automation. | Build a heartbeat-prompt error payload that preserves requested inputs, render a distinct error page with no copyable task-body fence, and cover markdown plus JSON failure output with a focused smoke. | | `shell_pr_comment_command_substitution` | A PR comment, issue body, or review body is posted with shell command substitution artifacts because Markdown backticks were passed through a double-quoted `gh ... --body "..."` argument. | shell command used for the PR/comment operation, rendered public comment body, recent agent transcript, and PR review/comment hygiene instructions. | Agent treated Markdown as inert text inside a shell string; the shell evaluated backticks before `gh` received the body. | Do not pass Markdown-rich bodies through double-quoted shell arguments. Use a single-quoted body when safe, stdin or `--body-file` for multiline text, or a GitHub connector/API call that bypasses shell interpolation; add a smoke or checklist item when the workflow recurs. | | `external_delivery_clock_override` | A caller can move an external send into or out of a configured delivery window by supplying an artifact timestamp such as `--generated-at`. | execute-path CLI arguments, sink window decision input, current invocation time, focused zero-provider-call smoke. | One caller-controlled timestamp was reused for both reproducible artifact metadata and runtime delivery authorization. | Keep artifact time and execution-observation time separate. Bind live CLI delivery decisions to a trusted invocation clock, expose any injected clock only as an internal test seam, and prove that an in-window artifact timestamp cannot override an out-of-window execution observation. | From f1ef1e5e0bf3e5497111e2b41cf5086c8d2bca11 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:56:08 +0800 Subject: [PATCH 3/4] test: tighten cross-platform Python fallback guard Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- tests/control_plane_ts/test_python_runtime.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/control_plane_ts/test_python_runtime.test.ts b/tests/control_plane_ts/test_python_runtime.test.ts index ea7fc1298f..44fd85b8b1 100644 --- a/tests/control_plane_ts/test_python_runtime.test.ts +++ b/tests/control_plane_ts/test_python_runtime.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { isAbsolute, join, relative, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; @@ -18,7 +18,8 @@ test("test subprocess discovery selects a compatible checkout Python", () => { assert.equal(probe.status, 0, probe.stderr); const result = JSON.parse(probe.stdout); assert.ok(result.version[0] > 3 || result.version[0] === 3 && result.version[1] >= 11); - assert.ok(resolve(result.source).startsWith(root), result.source); + const sourceInCheckout = relative(root, resolve(result.source)); + assert.ok(sourceInCheckout && !sourceInCheckout.startsWith("..") && !isAbsolute(sourceInCheckout), result.source); }); test("an invalid explicit test Python never falls back silently", t => { @@ -77,12 +78,14 @@ test("test and browser smokes may not introduce bare python3 subprocess fallback const directories = ["tests/control_plane_ts", "examples", "apps/presentation/dashboard/smoke"]; const offenders: string[] = []; const direct = /\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*["']python3["']/; - const fallback = /\b(?:LOOPX_TEST_PYTHON|LOOPX_PYTHON_BIN|LOOPX_PYTHON)\s*(?:\?\?|\|\|)\s*["']python3["']/; - const assigned = /\b(?:const|let)\s+(?:PYTHON|python)\s*=\s*["']python3["']/; + const fallback = /(?:\?\?|\|\|)\s*["']python3["']/; + const assigned = /\b(?:const|let)\s+\w+\s*=\s*["']python3["']/; const bare = JSON.stringify("python3"); assert.ok(direct.test(`spawn(${bare}, ["-m", "loopx.cli"])`)); assert.ok(fallback.test(`process.env.LOOPX_TEST_PYTHON ?? ${bare}`)); + assert.ok(fallback.test(`process.env.NEW_TEST_PYTHON || ${bare}`)); assert.ok(assigned.test(`const PYTHON = ${bare}`)); + assert.ok(assigned.test(`const testInterpreter = ${bare}`)); assert.equal(direct.test(`validation_command_argv: [${bare}, "-m", "pytest"]`), false); function inspect(directory: string) { for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) { From 0b1de3a909571521306a1485ea4e70bb4ab51fa1 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:34:01 +0800 Subject: [PATCH 4/4] test: route every bare-python test caller through the source selector The previous head still left active callers on a bare interpreter, so the original failure mode stayed reachable even with a compatible worktree environment present, and the static guard only recognized literal `python3`. Route the remaining callers through resolveTestPython(): the repository identity oracle in monitor_successor, the legacy fingerprint oracle in quota_monitor_poll_commit, and the tab-upgrade chat server smoke that arrived on main while this branch was open. Widen the guard to reject bare `python` and `python3` launches, fallbacks and assigned defaults, and add negative assertions so resolved paths such as `/usr/bin/python3` and validation argv data stay allowed. Validated with the full control-plane suite (3009 passed, 0 failed, 28 skipped), the guard and typecheck, and a restricted-PATH rerun where a bare `python` mutation fails monitor_successor while the selector passes both affected files. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../chat-bundle-upgrade-browser-smoke.mjs | 3 ++- .../monitor_successor.test.ts | 7 ++++--- .../quota_monitor_poll_commit.test.ts | 3 ++- .../test_python_runtime.test.ts | 21 +++++++++++++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/examples/chat-bundle-upgrade-browser-smoke.mjs b/examples/chat-bundle-upgrade-browser-smoke.mjs index a84184573f..e21e994da3 100644 --- a/examples/chat-bundle-upgrade-browser-smoke.mjs +++ b/examples/chat-bundle-upgrade-browser-smoke.mjs @@ -5,6 +5,7 @@ import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; import { resolve } from "node:path"; import { launchBrowser, loadPlaywright } from "./dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../scripts/test-python.mjs"; const python = String.raw` import importlib.util, json, sys, tempfile, threading @@ -42,7 +43,7 @@ with tempfile.TemporaryDirectory(prefix="loopx-tab-upgrade-") as temporary: server.server_close() thread.join() `; -const child = spawn(process.env.LOOPX_PYTHON_BIN || "python3", ["-u", "-c", python], { +const child = spawn(resolveTestPython(), ["-u", "-c", python], { cwd: resolve(import.meta.dirname, ".."), stdio: ["pipe", "pipe", "inherit"], }); const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator](); diff --git a/tests/control_plane_ts/monitor_successor.test.ts b/tests/control_plane_ts/monitor_successor.test.ts index 17212558eb..77c4658471 100644 --- a/tests/control_plane_ts/monitor_successor.test.ts +++ b/tests/control_plane_ts/monitor_successor.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { spawnSync } from "node:child_process"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; import { productionScaleCoordinationFixture } from "./production_scale_coordination_fixture.ts"; import { monitorSuccessorIntent, planMonitorSuccessor, MONITOR_SUCCESSOR_REQUEST_SCHEMA } from "../../loopx/control_plane/scheduler/monitor_successor.ts"; @@ -57,10 +58,10 @@ test("repository transport codecs agree with explicit scheme-specific port expec ["https://GITHUB.com/example/repo.git", "git:github.com/example/repo"], ]; const inputs = cases.map(([input]) => input); - const python = spawnSync("python", ["-c", "import json,sys; from loopx.repository_identity import normalize_repository_identity; print(json.dumps([normalize_repository_identity(x) for x in json.load(sys.stdin)]))"], + const oracle = spawnSync(resolveTestPython(), ["-c", "import json,sys; from loopx.repository_identity import normalize_repository_identity; print(json.dumps([normalize_repository_identity(x) for x in json.load(sys.stdin)]))"], {input: JSON.stringify(inputs), encoding: "utf8"}); - assert.equal(python.status, 0, python.stderr); - assert.deepEqual(JSON.parse(python.stdout), cases.map(([, expected]) => expected)); + assert.equal(oracle.status, 0, oracle.stderr); + assert.deepEqual(JSON.parse(oracle.stdout), cases.map(([, expected]) => expected)); for (const [input, expected] of cases) { const actual = monitorSuccessorIntent({...intent, next_task_repository: input}).next_task_repository; assert.equal(actual, expected, input); diff --git a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts index ea681c8f1e..3851680566 100644 --- a/tests/control_plane_ts/quota_monitor_poll_commit.test.ts +++ b/tests/control_plane_ts/quota_monitor_poll_commit.test.ts @@ -12,6 +12,7 @@ import { QUOTA_LEASED_MONITOR_POLL_COMMIT_REQUEST_SCHEMA, } from "../../loopx/control_plane/quota/monitor_poll_commit.ts"; import { EffectRuntimeRequestError } from "../../loopx/control_plane/effect_runtime_errors.ts"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; const goalId = "monitor-native-goal"; @@ -621,7 +622,7 @@ test("successor normalization preserves the legacy pending observation fingerpri // The shipped v0 identity recipe hashes wire observation, not its normalized route. const legacyEnvelope = Object.fromEntries(["schema_version", "effect_id", "runtime_root", "goal_id", "source", "turn_instance_id", "observation"].map(key => [key, params[key]])); - const oracle = spawnSync("python", ["-c", "import hashlib,json,sys; print('sha256:'+hashlib.sha256(json.dumps(json.load(sys.stdin),ensure_ascii=False,sort_keys=True).encode()).hexdigest())"], + const oracle = spawnSync(resolveTestPython(), ["-c", "import hashlib,json,sys; print('sha256:'+hashlib.sha256(json.dumps(json.load(sys.stdin),ensure_ascii=False,sort_keys=True).encode()).hexdigest())"], {input: JSON.stringify(legacyEnvelope), encoding: "utf8"}); assert.equal(oracle.status, 0, oracle.stderr); const first = await evaluateQuotaMonitorPollCommit(params); diff --git a/tests/control_plane_ts/test_python_runtime.test.ts b/tests/control_plane_ts/test_python_runtime.test.ts index 44fd85b8b1..86897ff9c1 100644 --- a/tests/control_plane_ts/test_python_runtime.test.ts +++ b/tests/control_plane_ts/test_python_runtime.test.ts @@ -74,19 +74,32 @@ test("a worktree venv wins over an unusable system python3", t => { assert.equal(existsSync(systemMarker), false); }); -test("test and browser smokes may not introduce bare python3 subprocess fallbacks", () => { +test("test and browser smokes may not introduce bare python or python3 subprocess fallbacks", () => { const directories = ["tests/control_plane_ts", "examples", "apps/presentation/dashboard/smoke"]; const offenders: string[] = []; - const direct = /\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*["']python3["']/; - const fallback = /(?:\?\?|\|\|)\s*["']python3["']/; - const assigned = /\b(?:const|let)\s+\w+\s*=\s*["']python3["']/; + // A bare `python` alias is not guaranteed to exist (and may point at an + // incompatible interpreter), so direct launches, fallbacks and assigned + // defaults must both route through resolveTestPython(). + const direct = /\b(?:spawn|spawnSync|execFile|execFileSync)\s*\(\s*["']python3?["']/; + const fallback = /(?:\?\?|\|\|)\s*["']python3?["']/; + const assigned = /\b(?:const|let)\s+\w+\s*=\s*["']python3?["']/; const bare = JSON.stringify("python3"); + const barePython = JSON.stringify("python"); assert.ok(direct.test(`spawn(${bare}, ["-m", "loopx.cli"])`)); + assert.ok(direct.test(`spawn(${barePython}, ["-m", "loopx.cli"])`)); + assert.ok(direct.test(`spawnSync(${barePython}, ["-c", "raise SystemExit(0)"])`)); assert.ok(fallback.test(`process.env.LOOPX_TEST_PYTHON ?? ${bare}`)); + assert.ok(fallback.test(`process.env.LOOPX_TEST_PYTHON ?? ${barePython}`)); assert.ok(fallback.test(`process.env.NEW_TEST_PYTHON || ${bare}`)); + assert.ok(fallback.test(`process.env.NEW_TEST_PYTHON || ${barePython}`)); assert.ok(assigned.test(`const PYTHON = ${bare}`)); + assert.ok(assigned.test(`const PYTHON = ${barePython}`)); assert.ok(assigned.test(`const testInterpreter = ${bare}`)); assert.equal(direct.test(`validation_command_argv: [${bare}, "-m", "pytest"]`), false); + assert.equal(direct.test(`validation_command_argv: [${barePython}, "-m", "pytest"]`), false); + // An absolute path or a versioned executable is a resolved interpreter, not a bare alias. + assert.equal(direct.test(`spawnSync(${JSON.stringify("/usr/bin/python3")}, [])`), false); + assert.equal(assigned.test(`const executable = ${JSON.stringify("/opt/loopx-qualification/bin/python")}`), false); function inspect(directory: string) { for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) { const path = join(directory, entry.name);