fix(workspaces): restore clean runtime test paths

This commit is contained in:
Mohamed Boudra
2026-08-14 14:11:12 +02:00
parent accd2d7587
commit ff83ca4bdc
13 changed files with 171 additions and 25 deletions
+1
View File
@@ -23,6 +23,7 @@
"dev": "./scripts/dev.sh",
"predev:win": "npm run dev:prerequisites",
"dev:win": "powershell ./scripts/dev.ps1",
"pretest:e2e:renderer": "npm --prefix ../.. run build:workspace-runtime-fixture",
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
@@ -19,6 +19,7 @@ import { describe, expect, test } from "vitest";
const desktopDir = path.resolve(import.meta.dirname, "..");
const repoRoot = path.resolve(desktopDir, "../..");
const execFileAsync = promisify(execFile);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const prerequisite = "npm run dev:prerequisites";
describe("desktop dev build contract", () => {
@@ -38,6 +39,14 @@ describe("desktop dev build contract", () => {
);
});
test("builds the private runtime fixture before renderer acceptance", async () => {
const packageJson = JSON.parse(await readFile(path.join(desktopDir, "package.json"), "utf8"));
expect(packageJson.scripts["pretest:e2e:renderer"]).toBe(
"npm --prefix ../.. run build:workspace-runtime-fixture",
);
});
test.each(["missing", "stale"])(
"the public prerequisite repairs %s server artifacts in an isolated checkout",
async (initialState) => {
@@ -118,7 +127,7 @@ async function createIsolatedBuildCheckout() {
async function runPrerequisite(isolatedRoot) {
try {
await execFileAsync("npm", ["run", "dev:prerequisites", "--workspace=@getpaseo/desktop"], {
await execFileAsync(npmCommand, ["run", "dev:prerequisites", "--workspace=@getpaseo/desktop"], {
cwd: isolatedRoot,
timeout: 60_000,
});
+1
View File
@@ -53,6 +53,7 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"pretest:unit": "npm --prefix ../.. run build:workspace-runtime-fixture",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
@@ -1891,6 +1891,62 @@ test("selected workspace availability fails closed without probing or launching
}
});
test("host-placed workspaces require host provider availability before create and resume", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-host-workspace-provider-"));
const availabilityScopes: Array<FetchCatalogOptions | undefined> = [];
let createCalls = 0;
let resumeCalls = 0;
class UnavailableHostClient extends TestAgentClient {
override async isAvailable(options?: FetchCatalogOptions): Promise<boolean> {
availabilityScopes.push(options);
return false;
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
createCalls += 1;
return new TestAgentSession(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
resumeCalls += 1;
return await super.resumeSession(handle, overrides);
}
}
const manager = new AgentManager({
clients: { codex: new UnavailableHostClient() },
logger,
resolveProviderWorkspace: async () => null,
});
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "host-workspace-session",
metadata: { cwd: workdir },
};
try {
await expect(
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "local-workspace",
}),
).rejects.toThrow("Provider 'codex' is not available");
await expect(
manager.resumeAgentFromPersistence(handle, undefined, undefined, {
workspaceId: "local-workspace",
}),
).rejects.toThrow("Provider 'codex' is not available");
expect(availabilityScopes.length).toBeGreaterThanOrEqual(2);
expect(availabilityScopes.every((scope) => scope === undefined)).toBe(true);
expect(createCalls).toBe(0);
expect(resumeCalls).toBe(0);
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("selected workspace without a bound capability never checks host availability", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-missing-workspace-capability-"));
let hostAvailabilityCalls = 0;
@@ -1141,9 +1141,7 @@ export class AgentManager {
options.workspaceId,
);
this.requireEnabledProvider(storedConfig.provider);
const client = options.workspaceId
? this.requireClient(storedConfig.provider)
: await this.requireAvailableClient({ provider: storedConfig.provider });
const client = this.requireClient(storedConfig.provider);
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
@@ -1151,7 +1149,7 @@ export class AgentManager {
options?.env,
options.workspaceId,
);
await this.requireWorkspaceProviderAvailable(
await this.requireProviderAvailableForLaunch(
client,
storedConfig.provider,
storedConfig.cwd,
@@ -1232,11 +1230,6 @@ export class AgentManager {
);
const client = this.requireClient(handle.provider);
if (!options?.workspaceId && !(await client.isAvailable())) {
throw new Error(
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
@@ -1244,7 +1237,7 @@ export class AgentManager {
undefined,
options?.workspaceId,
);
await this.requireWorkspaceProviderAvailable(
await this.requireProviderAvailableForLaunch(
client,
storedConfig.provider,
storedConfig.cwd,
@@ -1307,7 +1300,7 @@ export class AgentManager {
undefined,
input.workspaceId,
);
await this.requireWorkspaceProviderAvailable(
await this.requireProviderAvailableForLaunch(
client,
input.provider,
storedConfig.cwd,
@@ -4687,15 +4680,20 @@ export class AgentManager {
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
}
private async requireWorkspaceProviderAvailable(
private async requireProviderAvailableForLaunch(
client: AgentClient,
provider: AgentProvider,
cwd: string,
workspaceId: string | undefined,
launchContext: AgentLaunchContext,
): Promise<void> {
if (!workspaceId) return;
if (!launchContext.workspace) return;
if (!launchContext.workspace) {
await this.requireAvailableClient({ provider });
return;
}
if (!workspaceId) {
throw new Error(`Workspace-scoped provider '${provider}' is missing a workspace ID.`);
}
const available = await client.isAvailable({
scope: "workspace",
cwd,
+10 -3
View File
@@ -51,6 +51,8 @@ import {
import type { CheckDetails, ForgeService } from "../services/forge-service.js";
import type { GitHubPullRequestStatusFacts } from "../services/github-facts.js";
import { bindWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import { createStub } from "./test-utils/class-mocks.js";
import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js";
interface SessionHandlerInternals {
interruptAgentIfRunning(agentId: string): Promise<void>;
@@ -304,6 +306,7 @@ interface SessionForTestOptions {
getProjectSlug?: ReturnType<typeof vi.fn>;
};
workspaceRegistry?: { get: ReturnType<typeof vi.fn> };
workspaceRuntime?: WorkspaceRuntimeService;
projectRegistry?: Partial<SessionOptions["projectRegistry"]>;
terminalManager?: SessionOptions["terminalManager"];
serviceProxy?: SessionOptions["serviceProxy"];
@@ -477,6 +480,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
workspaceRuntime: options.workspaceRuntime,
scheduleService: asScheduleService(),
checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager),
github: asGitHubService(github),
@@ -1832,7 +1836,7 @@ describe("session provider refresh cwd routing", () => {
test("get_providers_snapshot_request forwards cwd to the provider authority", async () => {
const messages: unknown[] = [];
const workspaceCwd = resolvePath("/tmp/session-provider-snapshot");
const { manager: providerSnapshotManager, getSnapshot } = createProviderSnapshotManagerStub();
const { manager: providerSnapshotManager, readSnapshot } = createProviderSnapshotManagerStub();
const session = createSessionForTest({ messages, providerSnapshotManager });
await session.handleMessage({
@@ -1841,7 +1845,7 @@ describe("session provider refresh cwd routing", () => {
requestId: "snapshot-workspace",
});
expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd);
expect(readSnapshot).toHaveBeenCalledWith({ cwd: workspaceCwd });
});
test("preserves legacy model and mode list requests without cwd as global", async () => {
@@ -4328,7 +4332,10 @@ describe("session stash mutation handling", () => {
describe("session paseo worktree creation handling", () => {
test("forces workspace git refreshes for the source repo and created worktree", async () => {
const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) };
const session = createSessionForTest({ workspaceGitService });
const session = createSessionForTest({
workspaceGitService,
workspaceRuntime: createStub<WorkspaceRuntimeService>({}),
});
paseoWorktreeServiceMocks.createPaseoWorktree.mockResolvedValue({
repoRoot: "/tmp/repo",
worktree: {
@@ -148,7 +148,10 @@ export function findByType<T extends SessionOutboundMessage["type"]>(
// ---------------------------------------------------------------------------
export interface ProviderSnapshotManagerSpies {
getSnapshot: ReturnType<typeof vi.fn<[cwd?: string], ProviderSnapshotEntry[]>>;
getSnapshot: ReturnType<
typeof vi.fn<[cwd?: string, workspaceId?: string], ProviderSnapshotEntry[]>
>;
readSnapshot: ReturnType<typeof vi.fn<[unknown], Promise<ProviderSnapshotEntry[]>>>;
refreshSnapshotForCwd: ReturnType<typeof vi.fn<[unknown], Promise<void>>>;
refreshSettingsSnapshot: ReturnType<typeof vi.fn<[unknown], Promise<void>>>;
warmUpSnapshotForCwd: ReturnType<typeof vi.fn<[unknown], Promise<void>>>;
@@ -175,7 +178,13 @@ export interface ProviderSnapshotManagerSpies {
export function createProviderSnapshotManagerStub(): {
manager: ProviderSnapshotManager;
} & ProviderSnapshotManagerSpies {
const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []);
const getSnapshot = vi.fn<[cwd?: string, workspaceId?: string], ProviderSnapshotEntry[]>(
() => [],
);
const readSnapshot = vi.fn<[unknown], Promise<ProviderSnapshotEntry[]>>(async (options) => {
const { cwd, workspaceId } = options as { cwd?: string | null; workspaceId?: string };
return getSnapshot(cwd ?? undefined, workspaceId);
});
const refreshSnapshotForCwd = vi.fn<[unknown], Promise<void>>(async () => {});
const refreshSettingsSnapshot = vi.fn<[unknown], Promise<void>>(async () => {});
const warmUpSnapshotForCwd = vi.fn<[unknown], Promise<void>>(async () => {});
@@ -218,6 +227,7 @@ export function createProviderSnapshotManagerStub(): {
const destroy = vi.fn<[], void>();
const stub = {
getSnapshot,
readSnapshot,
refreshSnapshotForCwd,
refreshSettingsSnapshot,
warmUpSnapshotForCwd,
@@ -244,6 +254,7 @@ export function createProviderSnapshotManagerStub(): {
return {
manager,
getSnapshot,
readSnapshot,
refreshSnapshotForCwd,
refreshSettingsSnapshot,
warmUpSnapshotForCwd,
@@ -17,6 +17,7 @@ import {
TerminalStreamOpcode,
} from "@getpaseo/protocol/terminal-stream-protocol";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js";
type SocketListener = (...args: unknown[]) => void;
@@ -293,6 +294,12 @@ function createServer(options?: {
undefined,
undefined,
createProviderSnapshotManagerStub().manager,
undefined,
undefined,
undefined,
undefined,
undefined,
createStub<WorkspaceRuntimeService>({}),
);
}
+1
View File
@@ -23,6 +23,7 @@
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false && node -e \"const fs=require('node:fs');fs.copyFileSync('src/executable.mjs','dist/executable.mjs');fs.chmodSync('dist/executable.mjs',0o755)\"",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"test": "npm run build && vitest run test/workspace-helper.posix.test.ts --bail=1",
"test:standalone": "node --test test/standalone.test.mjs",
"typecheck": "tsgo -p tsconfig.json --noEmit"
@@ -7,6 +7,7 @@ import assert from "node:assert/strict";
import { promisify } from "node:util";
const run = promisify(execFile);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const packageRoot = path.resolve(import.meta.dirname, "..");
test("packed helper works from a fresh project without monorepo source fallback", async (t) => {
@@ -15,10 +16,10 @@ test("packed helper works from a fresh project without monorepo source fallback"
const packDirectory = path.join(root, "packs");
const project = path.join(root, "project");
await Promise.all([mkdir(packDirectory), mkdir(project)]);
await run("npm", ["run", "build"], { cwd: packageRoot });
await run(npmCommand, ["run", "build"], { cwd: packageRoot });
const packed = JSON.parse(
(
await run("npm", ["pack", "--json", "--pack-destination", packDirectory], {
await run(npmCommand, ["pack", "--json", "--pack-destination", packDirectory], {
cwd: packageRoot,
})
).stdout,
@@ -30,7 +31,7 @@ test("packed helper works from a fresh project without monorepo source fallback"
dependencies: { "@getpaseo/workspace-helper": `file:${path.join(packDirectory, packed)}` },
}),
);
await run("npm", ["install", "--ignore-scripts"], { cwd: project });
await run(npmCommand, ["install", "--ignore-scripts"], { cwd: project });
await writeFile(path.join(project, "notes.txt"), "before\n");
const imported = JSON.parse(
@@ -21,6 +21,7 @@
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"test": "npm run build && tsc -p tsconfig.test.json && node --test test/*.test.mjs",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
@@ -33,6 +33,7 @@ import {
} from "../dist/index.js";
const run = promisify(execFile);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const examples = JSON.parse(await readFile(path.join(packageRoot, "examples/v1.json"), "utf8"));
@@ -521,7 +522,7 @@ test("every export condition resolves from a packed standalone install", async (
t.after(() => rm(root, { recursive: true, force: true }));
const pack = JSON.parse(
(
await run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", root], {
await run(npmCommand, ["pack", "--json", "--ignore-scripts", "--pack-destination", root], {
cwd: packageRoot,
})
).stdout,
@@ -529,7 +530,7 @@ test("every export condition resolves from a packed standalone install", async (
const archive = path.join(root, pack[0].filename);
const installRoot = path.join(root, "standalone");
await mkdir(installRoot);
await run("npm", ["install", "--ignore-scripts", "--no-package-lock", archive], {
await run(npmCommand, ["install", "--ignore-scripts", "--no-package-lock", archive], {
cwd: installRoot,
});
const installed = path.join(installRoot, "node_modules/@getpaseo/workspace-runtime-contract");
+52
View File
@@ -56,6 +56,58 @@ test("build:server:clean constructs public prerequisites without runtime impleme
await assert.rejects(access(path.join(isolatedRoot, "runtimes")));
});
test("public runtime packages construct complete tarballs from source", async (t) => {
const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-runtime-packages-"));
t.after(() => rm(isolatedRoot, { recursive: true, force: true }));
await copyBuildCheckout(isolatedRoot);
await createNodeModulesOverlay(isolatedRoot);
await createPackageNodeModulesOverlays(isolatedRoot);
for (const relativePath of [
"packages/workspace-runtime-contract/dist",
"packages/workspace-helper/dist",
]) {
await assert.rejects(access(path.join(isolatedRoot, relativePath)), relativePath);
}
const packDirectory = path.join(isolatedRoot, "packs");
await mkdir(packDirectory);
const packages = [
{
workspace: "@getpaseo/workspace-runtime-contract",
files: ["dist/index.js", "dist/index.d.ts"],
},
{
workspace: "@getpaseo/workspace-helper",
files: ["dist/index.js", "dist/index.d.ts", "dist/executable.mjs"],
executable: "dist/executable.mjs",
},
];
for (const expected of packages) {
const packed = JSON.parse(
(
await run(
"npm",
[
"pack",
"--json",
`--workspace=${expected.workspace}`,
"--pack-destination",
packDirectory,
],
{ cwd: isolatedRoot, timeout: 120_000 },
)
).stdout,
)[0];
const files = new Map(packed.files.map((file) => [file.path, file]));
for (const expectedFile of expected.files) assert.ok(files.has(expectedFile), expectedFile);
if (expected.executable) assert.equal(files.get(expected.executable)?.mode, 0o755);
await access(path.join(packDirectory, packed.filename));
}
});
async function copyBuildCheckout(isolatedRoot) {
await Promise.all([
...["package.json", "package-lock.json", "tsconfig.base.json", "tsconfig.json"].map((file) =>