Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/backend.ai-ui/setupTests.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
11 changes: 2 additions & 9 deletions packages/backend.ai-ui/src/components/BAIButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<BAIButton action={action}>Complete Action</BAIButton>);
Expand Down Expand Up @@ -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(<BAIButton action={action}>Async Success</BAIButton>);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<BAIUnmountAfterClose>
<Modal open={true} title="Test Modal">
Expand Down Expand Up @@ -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');
Expand Down
59 changes: 29 additions & 30 deletions scripts/i18n-merge-driver.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
});
});

Expand Down
11 changes: 11 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
],
},
},
Expand Down
Loading