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
8 changes: 4 additions & 4 deletions examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +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";
import { testPythonCommand } from "../../scripts/test-python.mjs";

const require = createRequire(import.meta.url);
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
Expand Down Expand Up @@ -276,8 +276,7 @@ function teamPlanApplyReceipt(proposal) {
export async function startServer() {
if (packaged) {
// An explicit installed interpreter must resolve its own package, not the checkout.
const isolation = process.env.LOOPX_PYTHON_BIN ? ["-I"] : [];
const server = spawn(resolveTestPython(), [...isolation, "-c", `
const backendCommand = testPythonCommand(`
from pathlib import Path
from loopx.chat_server import ChatHTTPServer, ChatRequestHandler, default_chat_assets_dir
from loopx.presentation.chat_bundle import validate_bundle
Expand All @@ -288,7 +287,8 @@ server.assets_dir = assets
server.verbose = False
print("loopx-packaged-smoke-ready", flush=True)
server.serve_forever()
`], {
`);
const server = spawn(backendCommand.command, backendCommand.args, {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ import {mkdtemp, readFile, rename, rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {resolve} from "node:path";
import {createInterface} from "node:readline";
import {resolveTestPython} from "../../scripts/test-python.mjs";
import {testPythonCommand} from "../../scripts/test-python.mjs";
import {outputDir, repoRoot} from "./fixture.mjs";
import {openWorkspacePage} from "./scenario-context.mjs";

export const goalWorkspaceSelectionScenario = {
id: "goal-workspace-selection",
async run({browser, collectCoverage, url}) {
const root = await mkdtemp(resolve(tmpdir(), "loopx-goal-workspace-"));
const child = spawn(resolveTestPython(), ["-u", resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-backend.py"), root],
// runpy keeps the fixture's __file__ while the shared launcher owns imports.
const backendCommand = testPythonCommand(`
import runpy
sys.argv = [${JSON.stringify(resolve(repoRoot, "examples/personal-workspace-browser/goal-workspace-backend.py"))}, ${JSON.stringify(root)}]
runpy.run_path(sys.argv[0], run_name="__main__")
`);
const child = spawn(backendCommand.command, backendCommand.args,
{cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"]});
const lines = createInterface({input: child.stdout});
let diagnostics = "", context;
Expand Down
5 changes: 5 additions & 0 deletions scripts/test-python.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,8 @@ export interface TestPythonOptions {
}

export function resolveTestPython(options?: TestPythonOptions): string;

export function testPythonCommand(code: string, options?: TestPythonOptions): {
command: string;
args: string[];
};
22 changes: 22 additions & 0 deletions scripts/test-python.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,25 @@ export function resolveTestPython({ env = process.env, repoRoot = repositoryRoot
}
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.");
}

/** Keep browser backend imports on the selected source or installed package. */
export function testPythonCommand(code, options = {}) {
const env = options.env ?? process.env;
const root = resolve(options.repoRoot ?? repositoryRoot);
const installed = Boolean(env.LOOPX_PYTHON_BIN);
const sourcePackage = JSON.stringify(join(root, "loopx"));
const bootstrap = `
import sys
from pathlib import Path
${installed ? "" : `sys.path.insert(0, ${JSON.stringify(root)})`}
import loopx
package = Path(loopx.__file__).resolve().parent
source_package = Path(${sourcePackage}).resolve()
if ${installed ? "package == source_package" : "package != source_package"}:
raise RuntimeError("Browser backend resolved the wrong LoopX package source")
`;
return {
command: resolveTestPython(options),
args: ["-I", "-u", "-c", bootstrap + code],
};
}
155 changes: 155 additions & 0 deletions tests/test_browser_workspace_backend_provenance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Browser launchers select a package source without installing or borrowing one."""

import json
import os
from pathlib import Path
import shutil
import subprocess
import venv

import pytest


ROOT = Path(__file__).resolve().parents[1]
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="Node is required by browser smokes")
LAUNCH = """
import {spawnSync} from 'node:child_process';
import {testPythonCommand} from './scripts/test-python.mjs';
const plan = testPythonCommand(process.argv[1]);
const result = spawnSync(plan.command, plan.args, {encoding:'utf8', timeout:30000});
process.stdout.write(result.stdout ?? '');
process.stderr.write(result.stderr ?? '');
process.exit(result.status ?? 1);
"""


@pytest.fixture
def bare_python(tmp_path: Path) -> Path:
target = tmp_path / "python environment"
venv.EnvBuilder(with_pip=False).create(target)
python = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
probe = subprocess.run(
[str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('loopx') is None"],
check=True, capture_output=True, text=True,
)
assert probe.stdout == ""
return python


def environment(python: Path, tmp_path: Path, *, installed: bool = False) -> dict[str, str]:
env = dict(os.environ)
for key in ("PYTHONPATH", "PYTHONHOME", "LOOPX_TEST_PYTHON", "LOOPX_PYTHON_BIN", "LOOPX_PYTHON", "VIRTUAL_ENV"):
env.pop(key, None)
env["LOOPX_PYTHON_BIN" if installed else "LOOPX_TEST_PYTHON"] = str(python)
env.update(TMPDIR=str(tmp_path), TEMP=str(tmp_path), TMP=str(tmp_path))
return env


def launch(code: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]:
assert NODE is not None
return subprocess.run(
[NODE, "--input-type=module", "-e", LAUNCH, code],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=40,
)


def test_source_backend_uses_selected_bare_interpreter(bare_python: Path, tmp_path: Path) -> None:
result = launch(
"import json; from loopx.chat_server import ChatHTTPServer; "
"print(json.dumps({'python':sys.executable,'package':loopx.__file__}))",
environment(bare_python, tmp_path),
)
assert result.returncode == 0, result.stderr
observed = json.loads(result.stdout)
assert Path(observed["python"]) == bare_python
assert Path(observed["package"]).resolve() == ROOT / "loopx/__init__.py"


def test_installed_profile_missing_package_cannot_borrow_checkout(bare_python: Path, tmp_path: Path) -> None:
env = environment(bare_python, tmp_path, installed=True)
env["PYTHONPATH"] = str(ROOT)
result = launch("print('unexpected backend startup')", env)
assert result.returncode != 0
assert "ModuleNotFoundError: No module named 'loopx'" in result.stderr
assert "unexpected backend startup" not in result.stdout


@pytest.mark.parametrize("editable", [False, True])
def test_installed_profile_package_origin(bare_python: Path, tmp_path: Path, editable: bool) -> None:
# Minimal import fixture tests the loader, not installed product acceptance.
probe = subprocess.run(
[str(bare_python), "-I", "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"],
check=True, capture_output=True, text=True,
)
site = Path(probe.stdout.strip())
if editable:
(site / "checkout.pth").write_text(str(ROOT) + "\n", encoding="utf-8")
else:
package = site / "loopx"
package.mkdir()
(package / "__init__.py").write_text("marker = 'installed-only'\n", encoding="utf-8")
result = launch("print(loopx.marker)", environment(bare_python, tmp_path, installed=True))
if editable:
assert result.returncode != 0
assert "wrong LoopX package source" in result.stderr
else:
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "installed-only"


def test_source_profile_ignores_foreign_pythonpath(bare_python: Path, tmp_path: Path) -> None:
foreign = tmp_path / "foreign"
(foreign / "loopx").mkdir(parents=True)
(foreign / "loopx/__init__.py").write_text("raise RuntimeError('foreign package imported')\n", encoding="utf-8")
env = environment(bare_python, tmp_path)
env["PYTHONPATH"] = str(foreign)
result = launch("print(loopx.__file__)", env)
assert result.returncode == 0, result.stderr
assert Path(result.stdout.strip()).resolve() == ROOT / "loopx/__init__.py"


def test_real_workspace_backend_http_from_bare_python(bare_python: Path, tmp_path: Path) -> None:
assert NODE is not None
code = f"""
import runpy
sys.argv = [{str(ROOT / 'examples/personal-workspace-browser/goal-workspace-backend.py')!r}, {str(tmp_path)!r}]
runpy.run_path(sys.argv[0], run_name='__main__')
"""
runner = """
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {createInterface} from 'node:readline';
import {testPythonCommand} from './scripts/test-python.mjs';
const plan = testPythonCommand(process.argv[1]);
const child = spawn(plan.command, plan.args, {stdio:['ignore','pipe','pipe']});
const exited = once(child, 'exit');
const lines = createInterface({input:child.stdout});
let diagnostic = '';
child.stderr.on('data', chunk => {diagnostic += chunk;});
let timer;
try {
const ready = await Promise.race([
once(lines, 'line').then(([line]) => JSON.parse(line)),
exited.then(() => {throw new Error(diagnostic || 'Backend exited before readiness');}),
new Promise((_, reject) => {timer = setTimeout(() => reject(new Error('Backend readiness timeout')), 30000);}),
]);
const response = await fetch(`http://127.0.0.1:${ready.port}/api/actions`);
console.log(JSON.stringify({status:response.status, body:await response.json(), pid:child.pid}));
} finally {
clearTimeout(timer);
lines.close();
child.kill('SIGTERM');
const stopTimer = setTimeout(() => child.kill('SIGKILL'), 5000);
try {await exited;} finally {clearTimeout(stopTimer);}
}
"""
result = subprocess.run(
[NODE, "--input-type=module", "-e", runner, code], cwd=ROOT,
env=environment(bare_python, tmp_path), capture_output=True, text=True, timeout=40,
)
assert result.returncode == 0, result.stderr
observed = json.loads(result.stdout)
assert observed["status"] == 200
assert observed["body"]["ok"] is True
assert observed["pid"] != os.getpid()
Loading