Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", `
Expand Down
17 changes: 17 additions & 0 deletions docs/development/testing-and-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/chat-bundle-upgrade-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]();
Expand Down
3 changes: 2 additions & 1 deletion examples/dashboard-attention-details-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion examples/dashboard-goal-acceptance-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/dashboard-reward-append-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion examples/dashboard-usage-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion examples/export-frontstage-share-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
});
Expand Down
3 changes: 2 additions & 1 deletion examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "../..");
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion examples/status-source-switch-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)), "..");
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion examples/workspace-progressive-loading-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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; });
Expand Down
7 changes: 7 additions & 0 deletions scripts/test-python.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface TestPythonOptions {
env?: NodeJS.ProcessEnv;
repoRoot?: string;
platform?: NodeJS.Platform;
}

export function resolveTestPython(options?: TestPythonOptions): string;
77 changes: 77 additions & 0 deletions scripts/test-python.mjs
Original file line number Diff line number Diff line change
@@ -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.");
}
Loading
Loading