diff --git a/packages/backend.ai-ui/setupTests.ts b/packages/backend.ai-ui/setupTests.ts
index 90f5210586..c484bbe089 100644
--- a/packages/backend.ai-ui/setupTests.ts
+++ b/packages/backend.ai-ui/setupTests.ts
@@ -1,3 +1,58 @@
+// Auto-complete `@rc-component/motion` (used by antd v6 Modal, Drawer,
+// Button-loading-icon) animations under jsdom. rc-motion attaches a
+// `transitionend` listener on each element that has a `-leave-active`,
+// `-enter-active`, or `-appear-active` class, and waits for the browser to
+// dispatch that event. jsdom does not run real CSS animations, so the
+// event never fires; antd components then stay stuck in the active phase
+// past `waitFor` timeouts.
+//
+// Jest's older jsdom didn't expose the vendor-prefixed style props that
+// rc-motion probes for support detection, so its `supportTransition` flag
+// resolved to false and the active-phase classes were applied
+// synchronously — masking this issue. jsdom 29 (used by Vitest) exposes
+// those props, so `supportTransition` is true and we have to manually fire
+// the end event.
+//
+// Approach: a MutationObserver watches the whole document for class
+// additions matching `*-(leave|enter|appear)-active`, and queues a
+// microtask to dispatch a synthetic `transitionend` on the element. This
+// is exactly what a real browser would do once the CSS transition
+// completes — rc-motion's listener fires its `onInternalMotionEnd`
+// callback, the active class gets removed, and the test sees the expected
+// post-transition DOM.
+if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
+ const ACTIVE_CLASS_RE = /(?:-(?:leave|enter|appear))-active(?:\s|$)/;
+ const fireTransitionEnd = (el: Element) => {
+ if (!(el instanceof HTMLElement)) return;
+ const evt = new Event('transitionend', { bubbles: true });
+ // rc-motion checks `event.target !== element` and `!event.deadline`
+ // so the dispatch must come FROM the element itself.
+ el.dispatchEvent(evt);
+ };
+ const checkAndFire = (el: Element) => {
+ if (ACTIVE_CLASS_RE.test(el.className ?? '')) {
+ queueMicrotask(() => fireTransitionEnd(el));
+ }
+ };
+ const observer = new MutationObserver((records) => {
+ for (const r of records) {
+ if (r.type === 'attributes' && r.attributeName === 'class') {
+ checkAndFire(r.target as Element);
+ } else if (r.type === 'childList') {
+ r.addedNodes.forEach((n) => {
+ if (n instanceof Element) checkAndFire(n);
+ });
+ }
+ }
+ });
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['class'],
+ subtree: true,
+ childList: true,
+ });
+}
+
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
diff --git a/packages/backend.ai-ui/src/components/BAIButton.test.tsx b/packages/backend.ai-ui/src/components/BAIButton.test.tsx
index 314b2f423b..4b1612e2bb 100644
--- a/packages/backend.ai-ui/src/components/BAIButton.test.tsx
+++ b/packages/backend.ai-ui/src/components/BAIButton.test.tsx
@@ -86,12 +86,7 @@ describe('BAIButton', () => {
});
});
- // TODO(FR-2609): re-enable when rc-motion works under Vitest's jsdom 29.
- // rc-motion's `supportTransition` probes vendor-prefixed style props on a
- // div; jsdom 29 exposes them, so it waits for a `transitionend` that never
- // fires, leaving `.ant-btn-loading-icon` in `-leave-active` class forever.
- // Jest's older jsdom did not expose them, so motion completed synchronously.
- it.skip('should clear loading state after action completes', async () => {
+ it('should clear loading state after action completes', async () => {
const action = jest.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(Complete Action);
@@ -132,9 +127,7 @@ describe('BAIButton', () => {
});
});
- // TODO(FR-2609): same rc-motion / jsdom 29 incompat as above. Re-enable
- // once rc-motion clears `-leave-active` class without a real `transitionend`.
- it.skip('should handle async action with successful resolution', async () => {
+ it('should handle async action with successful resolution', async () => {
const action = jest.fn().mockResolvedValue('success');
const user = userEvent.setup();
render(Async Success);
diff --git a/packages/backend.ai-ui/src/components/BAIUnmountAfterClose.test.tsx b/packages/backend.ai-ui/src/components/BAIUnmountAfterClose.test.tsx
index a99b719c00..ecf046cdbb 100644
--- a/packages/backend.ai-ui/src/components/BAIUnmountAfterClose.test.tsx
+++ b/packages/backend.ai-ui/src/components/BAIUnmountAfterClose.test.tsx
@@ -120,12 +120,7 @@ describe('BAIUnmountAfterClose', () => {
expect(originalAfterClose).not.toHaveBeenCalled();
});
- // TODO(FR-2609): re-enable once rc-motion + jsdom 29 play nicely.
- // jsdom 29 exposes vendor-prefixed transition props, so rc-motion waits
- // for a `transitionend` event that never fires, leaving the Modal
- // mounted past the waitFor timeout. Jest's older jsdom completed the
- // motion synchronously.
- it.skip('should keep modal mounted when initially open and then closed, and unmount after animation', async () => {
+ it('should keep modal mounted when initially open and then closed, and unmount after animation', async () => {
const { rerender: _rerender } = render(
@@ -354,10 +349,7 @@ describe('BAIUnmountAfterClose', () => {
);
});
- // TODO(FR-2609): same rc-motion / jsdom 29 incompat as the earlier
- // `should keep modal mounted…` test. Re-enable when the `transitionend`
- // fallback is reliable under vitest.
- it.skip('should maintain modal state during open->close transition', async () => {
+ it('should maintain modal state during open->close transition', async () => {
const TestComponent = () => {
const [isOpen, setIsOpen] = React.useState(true);
const [inputValue, setInputValue] = React.useState('test');
diff --git a/scripts/i18n-merge-driver.test.ts b/scripts/i18n-merge-driver.test.ts
index 2322d47027..f1f212eaa4 100644
--- a/scripts/i18n-merge-driver.test.ts
+++ b/scripts/i18n-merge-driver.test.ts
@@ -1,16 +1,15 @@
-import { readFileSync } from "fs";
+import { mkdtempSync, rmSync, writeFileSync } from "fs";
+import { tmpdir } from "os";
+import { join } from "path";
-// Mock fs functions. Must be `vi.mock` (not `jest.mock`) — only the literal
-// `vi.mock` identifier is hoisted by Vitest above the `import` statements.
-vi.mock("fs", () => ({
- readFileSync: vi.fn(),
-}));
-
-// Mock process.exit to avoid test termination
-const mockExit = jest.fn();
+// Mock process.exit to avoid test termination if the driver triggers it.
+const mockExit = vi.fn();
process.exit = mockExit as any;
-// Import functions from the actual implementation
+// Import functions from the actual CJS driver. Using `require()` (not
+// `vi.mock`) — the driver itself does `require("fs")` internally, and that
+// CJS require slips past Vitest's module mock transform. Tests below that
+// need to exercise file IO use real temp files instead of mocks.
const {
readJSON,
deepEqual,
@@ -19,36 +18,36 @@ const {
pathKey,
} = require("./i18n-merge-driver");
-const mockReadFileSync = readFileSync as jest.MockedFunction<
- typeof readFileSync
->;
-
describe("i18n-merge-driver utility functions", () => {
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
describe("readJSON", () => {
- // TODO(FR-2609): vitest's `vi.mock('fs', ...)` does not propagate into
- // the CJS `require("fs")` at the top of `i18n-merge-driver.js`. Jest's
- // `require` patching applied globally; vitest only intercepts imports on
- // its own transform pipeline, and the JS file's inline `require()` slips
- // through. Re-enable after migrating the driver to ESM or switching to
- // `vi.mock` on the driver module directly.
- it.skip("should parse JSON from file correctly", () => {
- const mockData = '{"test": "value"}';
- mockReadFileSync.mockReturnValue(mockData);
-
- const result = readJSON("test.json");
-
- expect(mockReadFileSync).toHaveBeenCalledWith("test.json", "utf8");
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "i18n-merge-driver-test-"));
+ });
+
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it("should parse JSON from file correctly", () => {
+ const file = join(tmpDir, "valid.json");
+ writeFileSync(file, '{"test": "value"}', "utf8");
+
+ const result = readJSON(file);
+
expect(result).toEqual({ test: "value" });
});
it("should throw error for invalid JSON", () => {
- mockReadFileSync.mockReturnValue("{invalid json}");
+ const file = join(tmpDir, "invalid.json");
+ writeFileSync(file, "{invalid json}", "utf8");
- expect(() => readJSON("invalid.json")).toThrow();
+ expect(() => readJSON(file)).toThrow();
});
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 25d0ac2a67..abd18343d2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -32,6 +32,13 @@ export default defineConfig({
"packages/**",
],
+ // V8 coverage instrumentation slows down tests significantly on CI's
+ // smaller boxes. `gen-theme-schema.test.ts > generates a valid JSON
+ // schema file` exercises the antd type tree several times per test
+ // and routinely takes 5–10s under coverage. Bump the per-test timeout
+ // so CI matches a normal local run without coverage.
+ testTimeout: 30000,
+
// Coverage settings — see comment in `react/vitest.config.ts`. Same
// reporters and provider so the `davelosert/vitest-coverage-report-action`
// PR comment shape is consistent across the three workspaces.
@@ -44,6 +51,10 @@ export default defineConfig({
"{src,scripts}/**/*.{test,spec}.ts",
"src/wsproxy/**",
"src/lib/backend.ai-client-node.*",
+ // Build tooling — instrumenting these slows tests without giving
+ // useful coverage signal. They are exercised end-to-end by the
+ // tests but the coverage % of build scripts is not actionable.
+ "scripts/**/*.cjs",
],
},
},