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", ], }, },