Skip to content

Commit d41c317

Browse files
committed
🤖 ci: stabilize integration suite (DockerRuntime contract tests)
- Add DockerRuntime to runtime contract integration matrix (reusing ssh fixture container)\n- Make runtime contract tests sequential for remote fixtures to avoid timeouts\n- Avoid global bun module mocks leaking across tests\n- Make lockfile failure test deterministic in CI\n- Reduce sendMessage heavy test flake and ensure CI uses enough Jest workers\n\n_Generated with mux_
1 parent e9efc53 commit d41c317

File tree

8 files changed

+273
-197
lines changed

8 files changed

+273
-197
lines changed

jest.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ module.exports = {
2121
// Transform ESM modules to CommonJS for Jest
2222
transformIgnorePatterns: ["node_modules/(?!(@orpc|shiki|json-schema-typed|rou3)/)"],
2323
// Run tests in parallel (use 50% of available cores, or 4 minimum)
24-
maxWorkers: "50%",
24+
// CI runners often have a low core count; "50%" can result in a single Jest worker,
25+
// which can push the integration job over its 10-minute timeout.
26+
maxWorkers: process.env.CI ? 4 : "50%",
2527
// Force exit after tests complete to avoid hanging on lingering handles
2628
forceExit: true,
2729
// 10 minute timeout for integration tests, 10s for unit tests

src/browser/hooks/useDraftWorkspaceSettings.test.ts

Lines changed: 0 additions & 116 deletions
This file was deleted.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
3+
import { GlobalWindow } from "happy-dom";
4+
import React from "react";
5+
import { APIProvider, type APIClient } from "@/browser/contexts/API";
6+
import { ModeProvider } from "@/browser/contexts/ModeContext";
7+
import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
8+
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
9+
import { getLastDockerImageKey, getLastSshHostKey } from "@/common/constants/storage";
10+
import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings";
11+
12+
function createStubApiClient(): APIClient {
13+
// useModelLRU() only needs providers.getConfig + providers.onConfigChanged.
14+
// Provide a minimal stub so tests can run without spinning up a real oRPC client.
15+
async function* empty() {
16+
// no-op
17+
}
18+
19+
return {
20+
providers: {
21+
getConfig: () => Promise.resolve({}),
22+
onConfigChanged: () => Promise.resolve(empty()),
23+
},
24+
} as unknown as APIClient;
25+
}
26+
27+
describe("useDraftWorkspaceSettings", () => {
28+
beforeEach(() => {
29+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
30+
globalThis.document = globalThis.window.document;
31+
globalThis.localStorage = globalThis.window.localStorage;
32+
globalThis.localStorage.clear();
33+
});
34+
35+
afterEach(() => {
36+
cleanup();
37+
globalThis.window = undefined as unknown as Window & typeof globalThis;
38+
globalThis.document = undefined as unknown as Document;
39+
});
40+
41+
test("does not reset selected runtime to the default while editing SSH host", async () => {
42+
const projectPath = "/tmp/project";
43+
44+
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
45+
<APIProvider client={createStubApiClient()}>
46+
<ModeProvider projectPath={projectPath}>
47+
<ThinkingProvider projectPath={projectPath}>{props.children}</ThinkingProvider>
48+
</ModeProvider>
49+
</APIProvider>
50+
);
51+
52+
const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), {
53+
wrapper,
54+
});
55+
56+
act(() => {
57+
result.current.setSelectedRuntime({ mode: "ssh", host: "dev@host" });
58+
});
59+
60+
await waitFor(() => {
61+
expect(result.current.settings.selectedRuntime).toEqual({ mode: "ssh", host: "dev@host" });
62+
});
63+
});
64+
65+
test("seeds SSH host from the remembered value when switching modes", async () => {
66+
const projectPath = "/tmp/project";
67+
68+
updatePersistedState(getLastSshHostKey(projectPath), "remembered@host");
69+
70+
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
71+
<APIProvider client={createStubApiClient()}>
72+
<ModeProvider projectPath={projectPath}>
73+
<ThinkingProvider projectPath={projectPath}>{props.children}</ThinkingProvider>
74+
</ModeProvider>
75+
</APIProvider>
76+
);
77+
78+
const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), {
79+
wrapper,
80+
});
81+
82+
act(() => {
83+
// Simulate UI switching into ssh mode with an empty field.
84+
result.current.setSelectedRuntime({ mode: "ssh", host: "" });
85+
});
86+
87+
await waitFor(() => {
88+
expect(result.current.settings.selectedRuntime).toEqual({
89+
mode: "ssh",
90+
host: "remembered@host",
91+
});
92+
});
93+
});
94+
95+
test("seeds Docker image from the remembered value when switching modes", async () => {
96+
const projectPath = "/tmp/project";
97+
98+
updatePersistedState(getLastDockerImageKey(projectPath), "ubuntu:22.04");
99+
100+
const wrapper: React.FC<{ children: React.ReactNode }> = (props) => (
101+
<APIProvider client={createStubApiClient()}>
102+
<ModeProvider projectPath={projectPath}>
103+
<ThinkingProvider projectPath={projectPath}>{props.children}</ThinkingProvider>
104+
</ModeProvider>
105+
</APIProvider>
106+
);
107+
108+
const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"), {
109+
wrapper,
110+
});
111+
112+
act(() => {
113+
// Simulate UI switching into docker mode with an empty field.
114+
result.current.setSelectedRuntime({ mode: "docker", image: "" });
115+
});
116+
117+
await waitFor(() => {
118+
expect(result.current.settings.selectedRuntime).toEqual({
119+
mode: "docker",
120+
image: "ubuntu:22.04",
121+
});
122+
});
123+
});
124+
});

src/node/runtime/DockerRuntime.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,27 @@ export class DockerRuntime extends RemoteRuntime {
190190
return { process };
191191
}
192192

193+
/**
194+
* Override buildWriteCommand to preserve symlinks and file permissions.
195+
*
196+
* This matches SSHRuntime behavior: write through the symlink to the final target,
197+
* while keeping the symlink itself intact.
198+
*/
199+
protected buildWriteCommand(quotedPath: string, quotedTempPath: string): string {
200+
return `RESOLVED=$(readlink -f ${quotedPath} 2>/dev/null || echo ${quotedPath}) && PERMS=$(stat -c '%a' "$RESOLVED" 2>/dev/null || echo 600) && mkdir -p $(dirname "$RESOLVED") && cat > ${quotedTempPath} && chmod "$PERMS" ${quotedTempPath} && mv ${quotedTempPath} "$RESOLVED"`;
201+
}
193202
// ===== Runtime interface implementations =====
194203

195204
resolvePath(filePath: string): Promise<string> {
196-
// Inside container, paths are already absolute
197-
// Just return as-is since we use fixed /src path
205+
// DockerRuntime uses a fixed workspace base (/src), but we still want reasonable shell-style
206+
// behavior for callers that pass "~" or "~/...".
207+
if (filePath === "~") {
208+
return Promise.resolve("/root");
209+
}
210+
if (filePath.startsWith("~/")) {
211+
return Promise.resolve(path.posix.join("/root", filePath.slice(2)));
212+
}
213+
198214
return Promise.resolve(
199215
filePath.startsWith("/") ? filePath : path.posix.join(CONTAINER_SRC_DIR, filePath)
200216
);

src/node/services/serverService.test.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,19 @@ describe("ServerService.startServer", () => {
7777
}
7878

7979
test("cleans up server when lockfile acquisition fails", async () => {
80-
// Skip on Windows where chmod doesn't work the same way
81-
if (process.platform === "win32") {
82-
return;
83-
}
84-
8580
const service = new ServerService();
8681

87-
// Make muxHome read-only so lockfile.acquire() will fail
88-
await fs.chmod(tempDir, 0o444);
82+
// Use a muxHome path that is a FILE (not a directory) so lockfile.acquire() fails
83+
// after the server has started.
84+
const muxHome = path.join(tempDir, "not-a-dir");
85+
await fs.writeFile(muxHome, "not a directory");
8986

9087
let thrownError: Error | null = null;
9188

9289
try {
9390
// Start server - this should fail when trying to write lockfile
9491
await service.startServer({
95-
muxHome: tempDir,
92+
muxHome,
9693
context: stubContext as ORPCContext,
9794
authToken: "test-token",
9895
port: 0, // random port
@@ -103,7 +100,7 @@ describe("ServerService.startServer", () => {
103100

104101
// Verify that an error was thrown
105102
expect(thrownError).not.toBeNull();
106-
expect(thrownError!.message).toMatch(/EACCES|permission denied/i);
103+
expect(thrownError!.message).toMatch(/ENOTDIR|not a directory|EACCES|permission denied/i);
107104

108105
// Verify the server is NOT left running
109106
expect(service.isServerRunning()).toBe(false);

tests/ipc/sendMessage.heavy.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ describeIntegration("sendMessage heavy/load tests", () => {
4141
await withSharedWorkspace(provider, async ({ env, workspaceId, collector }) => {
4242
// Build up large conversation history to exceed context limit
4343
// This approach is model-agnostic - it keeps sending until we've built up enough history
44-
const largeMessage = "x".repeat(50_000);
45-
for (let i = 0; i < 10; i++) {
44+
const largeMessage = "x".repeat(70_000);
45+
for (let i = 0; i < 8; i++) {
4646
await sendMessageWithModel(
4747
env,
4848
workspaceId,
@@ -101,7 +101,7 @@ describeIntegration("sendMessage heavy/load tests", () => {
101101
await collector.waitForEvent("stream-end", 30000);
102102
});
103103
},
104-
180000 // 3 minute timeout for building large history and API calls
104+
300000 // 5 minute timeout for building large history and API calls
105105
);
106106
});
107107

0 commit comments

Comments
 (0)