Skip to content

Commit 8a6f467

Browse files
committed
🤖 ci: add browser UI tests organized by product section
_Generated with `mux`_
1 parent 51d398f commit 8a6f467

File tree

11 files changed

+807
-407
lines changed

11 files changed

+807
-407
lines changed
Lines changed: 2 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,15 @@
77

88
import userEvent from "@testing-library/user-event";
99
import "@testing-library/jest-dom";
10-
import { shouldRunIntegrationTests } from "../testUtils";
10+
import { shouldRunIntegrationTests } from "../../testUtils";
1111
import {
1212
renderWithBackend,
1313
createTempGitRepo,
1414
cleanupTempGitRepo,
1515
waitForAppLoad,
1616
addProjectViaUI,
17-
expandProject,
1817
getProjectName,
19-
} from "./harness";
18+
} from "../harness";
2019

2120
const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip;
2221

@@ -76,39 +75,6 @@ describeIntegration("AppLoader React Integration", () => {
7675
}
7776
});
7877

79-
test("workspace appears under project when expanded", async () => {
80-
const user = userEvent.setup();
81-
const gitRepo = await createTempGitRepo();
82-
const { cleanup, env, ...queries } = await renderWithBackend();
83-
try {
84-
await waitForAppLoad(queries);
85-
86-
// Add project via UI
87-
await addProjectViaUI(user, queries, gitRepo);
88-
const projectName = getProjectName(gitRepo);
89-
90-
// Create workspace via oRPC (UI flow requires sending a chat message)
91-
const workspaceResult = await env.orpc.workspace.create({
92-
projectPath: gitRepo,
93-
branchName: "test-branch",
94-
trunkBranch: "main",
95-
});
96-
expect(workspaceResult.success).toBe(true);
97-
if (!workspaceResult.success) throw new Error("Workspace creation failed");
98-
99-
// Expand project via UI
100-
await expandProject(user, queries, projectName);
101-
102-
// Workspace should be visible
103-
const workspaceElement = await queries.findByRole("button", {
104-
name: `Select workspace ${workspaceResult.metadata.name}`,
105-
});
106-
expect(workspaceElement).toBeInTheDocument();
107-
} finally {
108-
await cleanupTempGitRepo(gitRepo);
109-
await cleanup();
110-
}
111-
});
11278
});
11379

11480
describe("User Interactions", () => {

tests/browser/harness/env.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ function createMockBrowserWindow(): BrowserWindow {
6464
*/
6565
export async function createBrowserTestEnv(): Promise<BrowserTestEnv> {
6666
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-browser-test-"));
67+
// Prevent browser UI tests from making real network calls for AI.
68+
// This keeps tests hermetic even if the environment has provider credentials.
69+
const previousMockAI = process.env.MUX_MOCK_AI;
70+
process.env.MUX_MOCK_AI = "1";
6771

6872
const config = new Config(tempDir);
6973
const mockWindow = createMockBrowserWindow();
@@ -105,6 +109,13 @@ export async function createBrowserTestEnv(): Promise<BrowserTestEnv> {
105109
}
106110

107111
const maxRetries = 3;
112+
113+
// Restore process env to avoid leaking mock mode across unrelated tests.
114+
if (previousMockAI === undefined) {
115+
delete process.env.MUX_MOCK_AI;
116+
} else {
117+
process.env.MUX_MOCK_AI = previousMockAI;
118+
}
108119
for (let i = 0; i < maxRetries; i++) {
109120
try {
110121
await fs.rm(tempDir, { recursive: true, force: true });

tests/browser/harness/global-setup.js

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,51 +14,109 @@ if (typeof globalThis.ResizeObserver === "undefined") {
1414
disconnect() {}
1515
};
1616
}
17+
// Default to a desktop viewport so mobile-only behavior (like auto-collapsing the sidebar)
18+
// doesn't interfere with UI integration tests.
19+
try {
20+
Object.defineProperty(globalThis, "innerWidth", { value: 1024, writable: true });
21+
Object.defineProperty(globalThis, "innerHeight", { value: 768, writable: true });
22+
} catch {
23+
// ignore
24+
}
25+
1726
globalThis.import_meta_env = {
1827
VITE_BACKEND_URL: undefined,
1928
MODE: "test",
2029
DEV: false,
2130
PROD: false,
2231
};
2332

24-
// Patch setTimeout to add unref method (required by undici timers)
25-
// jsdom's setTimeout doesn't have unref, but node's does
26-
const originalSetTimeout = globalThis.setTimeout;
27-
globalThis.setTimeout = function patchedSetTimeout(...args) {
28-
const timer = originalSetTimeout.apply(this, args);
29-
if (timer && typeof timer === "object" && !timer.unref) {
30-
timer.unref = () => timer;
31-
timer.ref = () => timer;
32-
}
33-
return timer;
34-
};
33+
// Use Node's timers implementation so the returned handles support unref/ref
34+
// requestIdleCallback is used by the renderer for stream batching.
35+
// jsdom doesn't provide it.
36+
globalThis.requestIdleCallback =
37+
globalThis.requestIdleCallback ??
38+
((cb) =>
39+
globalThis.setTimeout(() =>
40+
cb({ didTimeout: false, timeRemaining: () => 50 })
41+
));
42+
globalThis.cancelIdleCallback =
43+
globalThis.cancelIdleCallback ?? ((id) => globalThis.clearTimeout(id));
3544

36-
const originalSetInterval = globalThis.setInterval;
37-
globalThis.setInterval = function patchedSetInterval(...args) {
38-
const timer = originalSetInterval.apply(this, args);
39-
if (timer && typeof timer === "object" && !timer.unref) {
40-
timer.unref = () => timer;
41-
timer.ref = () => timer;
42-
}
43-
return timer;
44-
};
45+
// (required by undici timers). This also provides setImmediate/clearImmediate.
46+
const nodeTimers = require("node:timers");
47+
globalThis.setTimeout = nodeTimers.setTimeout;
48+
globalThis.clearTimeout = nodeTimers.clearTimeout;
49+
globalThis.setInterval = nodeTimers.setInterval;
50+
globalThis.clearInterval = nodeTimers.clearInterval;
51+
globalThis.setImmediate = nodeTimers.setImmediate;
52+
globalThis.clearImmediate = nodeTimers.clearImmediate;
4553

4654
// Polyfill TextEncoder/TextDecoder - required by undici
4755
const { TextEncoder, TextDecoder } = require("util");
4856
globalThis.TextEncoder = globalThis.TextEncoder ?? TextEncoder;
4957
globalThis.TextDecoder = globalThis.TextDecoder ?? TextDecoder;
5058

5159
// Polyfill streams - required by AI SDK
52-
const { TransformStream, ReadableStream, WritableStream } = require("node:stream/web");
60+
const {
61+
TransformStream,
62+
ReadableStream,
63+
WritableStream,
64+
TextDecoderStream,
65+
} = require("node:stream/web");
5366
globalThis.TransformStream = globalThis.TransformStream ?? TransformStream;
5467
globalThis.ReadableStream = globalThis.ReadableStream ?? ReadableStream;
5568
globalThis.WritableStream = globalThis.WritableStream ?? WritableStream;
69+
globalThis.TextDecoderStream = globalThis.TextDecoderStream ?? TextDecoderStream;
5670

5771
// Polyfill MessageChannel/MessagePort - required by undici
5872
const { MessageChannel, MessagePort } = require("node:worker_threads");
5973
globalThis.MessageChannel = globalThis.MessageChannel ?? MessageChannel;
74+
75+
// Radix UI (Select, etc.) relies on Pointer Events + pointer capture.
76+
// jsdom doesn't implement these, so provide minimal no-op shims.
77+
if (globalThis.Element && !globalThis.Element.prototype.hasPointerCapture) {
78+
globalThis.Element.prototype.hasPointerCapture = () => false;
79+
}
80+
if (globalThis.Element && !globalThis.Element.prototype.setPointerCapture) {
81+
globalThis.Element.prototype.setPointerCapture = () => {};
82+
}
83+
if (globalThis.Element && !globalThis.Element.prototype.scrollIntoView) {
84+
globalThis.Element.prototype.scrollIntoView = () => {};
85+
}
86+
if (globalThis.Element && !globalThis.Element.prototype.releasePointerCapture) {
87+
globalThis.Element.prototype.releasePointerCapture = () => {};
88+
}
6089
globalThis.MessagePort = globalThis.MessagePort ?? MessagePort;
6190

91+
// undici reads `performance.markResourceTiming` at import time. In jsdom,
92+
// Some renderer code uses `performance.mark()` for lightweight timing.
93+
if (globalThis.performance && typeof globalThis.performance.mark !== "function") {
94+
globalThis.performance.mark = () => {};
95+
}
96+
if (globalThis.performance && typeof globalThis.performance.measure !== "function") {
97+
globalThis.performance.measure = () => {};
98+
}
99+
if (
100+
globalThis.performance &&
101+
typeof globalThis.performance.clearMarks !== "function"
102+
) {
103+
globalThis.performance.clearMarks = () => {};
104+
}
105+
if (
106+
globalThis.performance &&
107+
typeof globalThis.performance.clearMeasures !== "function"
108+
) {
109+
globalThis.performance.clearMeasures = () => {};
110+
}
111+
112+
// `performance` exists but doesn't implement the Resource Timing API.
113+
if (
114+
globalThis.performance &&
115+
typeof globalThis.performance.markResourceTiming !== "function"
116+
) {
117+
globalThis.performance.markResourceTiming = () => {};
118+
}
119+
62120
// Now undici can be safely imported
63121
const { fetch, Request, Response, Headers, FormData, Blob } = require("undici");
64122
globalThis.fetch = globalThis.fetch ?? fetch;

tests/browser/harness/jestSetup.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ const originalConsoleError = console.error.bind(console);
1313
const originalDefaultMaxListeners = EventEmitter.defaultMaxListeners;
1414
const originalConsoleLog = console.log.bind(console);
1515

16-
const shouldSuppressActWarning = (args: unknown[]) => {
17-
return args.some(
18-
(arg) => typeof arg === "string" && arg.toLowerCase().includes("not wrapped in act")
16+
const shouldSuppressConsoleError = (args: unknown[]) => {
17+
const text = args.map(String).join(" ").toLowerCase();
18+
return (
19+
text.includes("not wrapped in act") ||
20+
text.includes("mock scenario turn mismatch") ||
21+
(text.includes("failed to save metadata") && text.includes("extensionmetadata.json")) ||
22+
(text.includes("failed to remove project") &&
23+
text.includes("cannot remove project with active workspaces"))
1924
);
2025
};
2126

@@ -25,7 +30,7 @@ beforeAll(() => {
2530
// once the default (10) listener limit is exceeded.
2631
EventEmitter.defaultMaxListeners = 50;
2732
jest.spyOn(console, "error").mockImplementation((...args) => {
28-
if (shouldSuppressActWarning(args)) {
33+
if (shouldSuppressConsoleError(args)) {
2934
return;
3035
}
3136
originalConsoleError(...args);
@@ -35,13 +40,15 @@ beforeAll(() => {
3540
// they need to assert on logs.
3641
jest.spyOn(console, "log").mockImplementation(() => {});
3742
jest.spyOn(console, "warn").mockImplementation(() => {});
43+
jest.spyOn(console, "debug").mockImplementation(() => {});
3844
});
3945

4046
afterAll(() => {
4147
EventEmitter.defaultMaxListeners = originalDefaultMaxListeners;
4248
(console.error as jest.Mock).mockRestore();
4349
(console.log as jest.Mock).mockRestore();
4450
(console.warn as jest.Mock).mockRestore();
51+
(console.debug as jest.Mock).mockRestore();
4552

4653
// Ensure captured originals don't get tree-shaken / flagged as unused in some tooling.
4754
void originalConsoleLog;

tests/browser/harness/render.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ export interface RenderWithBackendOptions extends Omit<RenderOptions, "wrapper">
3838
* With pre-existing env (for setup before render):
3939
* ```tsx
4040
* const env = await createBrowserTestEnv();
41-
* await env.orpc.projects.create({ projectPath: '/some/path' });
4241
* const { cleanup, getByText } = await renderWithBackend({ env });
4342
* ```
43+
*
44+
* Note: tests should prefer UI-driven setup. `env.orpc` exists for rare cases where
45+
* the UI cannot reasonably reach a state.
4446
*/
4547
export async function renderWithBackend(
4648
options?: RenderWithBackendOptions

0 commit comments

Comments
 (0)