From 96b5ca9de0be3dc1dfb3247d1d89a23945a47369 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Mon, 30 Jun 2025 15:08:01 +0100 Subject: [PATCH] feat(ui): use fake timer in tests and real timer in browser for storybook interaction tests I believe the time-based tests are falsely failing when the CI machine is under high load. This also speeds up the tests in CI. I'm not 100% happy with the approach, but this should resolve CI issues in the short term until I can improve things. --- pkgs/clan-app/ui/package-lock.json | 39 +++++++++++++ pkgs/clan-app/ui/package.json | 2 + .../components/v2/Button/Button.stories.tsx | 21 ++++++- pkgs/clan-app/ui/tests/clock.ts | 56 +++++++++++++++++++ 4 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 pkgs/clan-app/ui/tests/clock.ts diff --git a/pkgs/clan-app/ui/package-lock.json b/pkgs/clan-app/ui/package-lock.json index 58cba6dba..9835495b8 100644 --- a/pkgs/clan-app/ui/package-lock.json +++ b/pkgs/clan-app/ui/package-lock.json @@ -25,12 +25,14 @@ "@babel/plugin-syntax-import-attributes": "^7.27.1", "@eslint/js": "^9.3.0", "@kachurun/storybook-solid-vite": "^9.0.11", + "@sinonjs/fake-timers": "^14.0.0", "@storybook/addon-a11y": "^9.0.8", "@storybook/addon-docs": "^9.0.8", "@storybook/addon-links": "^9.0.8", "@storybook/addon-viewport": "^9.0.8", "@storybook/addon-vitest": "^9.0.8", "@types/node": "^22.15.19", + "@types/sinonjs__fake-timers": "^8.1.5", "@types/three": "^0.176.0", "@typescript-eslint/parser": "^8.32.1", "@vitest/browser": "^3.2.3", @@ -1776,6 +1778,26 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-14.0.0.tgz", + "integrity": "sha512-QfoXRaUTjMVVn/ZbnD4LS3TPtqOkOdKIYCKldIVPnuClcwRKat6LI2mRZ2s5qiBfO6Fy03An35dSls/2/FEc0Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, "node_modules/@solid-devtools/debugger": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@solid-devtools/debugger/-/debugger-0.28.0.tgz", @@ -2503,6 +2525,13 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stats.js": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", @@ -7699,6 +7728,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", diff --git a/pkgs/clan-app/ui/package.json b/pkgs/clan-app/ui/package.json index 649a7e54d..65c77e3c0 100644 --- a/pkgs/clan-app/ui/package.json +++ b/pkgs/clan-app/ui/package.json @@ -25,12 +25,14 @@ "@babel/plugin-syntax-import-attributes": "^7.27.1", "@eslint/js": "^9.3.0", "@kachurun/storybook-solid-vite": "^9.0.11", + "@sinonjs/fake-timers": "^14.0.0", "@storybook/addon-a11y": "^9.0.8", "@storybook/addon-docs": "^9.0.8", "@storybook/addon-links": "^9.0.8", "@storybook/addon-viewport": "^9.0.8", "@storybook/addon-vitest": "^9.0.8", "@types/node": "^22.15.19", + "@types/sinonjs__fake-timers": "^8.1.5", "@types/three": "^0.176.0", "@typescript-eslint/parser": "^8.32.1", "@vitest/browser": "^3.2.3", diff --git a/pkgs/clan-app/ui/src/components/v2/Button/Button.stories.tsx b/pkgs/clan-app/ui/src/components/v2/Button/Button.stories.tsx index 43513727e..af458c67e 100644 --- a/pkgs/clan-app/ui/src/components/v2/Button/Button.stories.tsx +++ b/pkgs/clan-app/ui/src/components/v2/Button/Button.stories.tsx @@ -3,6 +3,9 @@ import { Button, ButtonProps } from "./Button"; import { Component } from "solid-js"; import { expect, fn, waitFor } from "storybook/test"; import { StoryContext } from "@kachurun/storybook-solid-vite"; +import { StorybookClock } from "@/tests/clock"; + +const clock = StorybookClock(); const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor; @@ -149,7 +152,7 @@ export const Primary: Story = { hierarchy: "primary", onAction: fn(async () => { // wait 500 ms to simulate an action - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => clock.setTimeout(resolve, 2000)); // randomly fail to check that the loading state still returns to normal if (Math.random() > 0.5) { throw new Error("Action failure"); @@ -163,7 +166,13 @@ export const Primary: Story = { }, }, - play: async ({ canvas, step, userEvent, args }: StoryContext) => { + play: async ({ + canvas, + canvasElement, + step, + userEvent, + args, + }: StoryContext) => { const buttons = await canvas.findAllByRole("button"); for (const button of buttons) { @@ -192,6 +201,9 @@ export const Primary: Story = { // click the button await userEvent.click(button); + // advance the clock + clock.tick(1); + // check the button has changed await waitFor(async () => { // the action handler should have been called @@ -204,6 +216,9 @@ export const Primary: Story = { await expect(getCursorStyle(button)).toEqual("wait"); }); + // advance the clock + clock.tick(2000); + // wait for the action handler to finish await waitFor( async () => { @@ -214,7 +229,7 @@ export const Primary: Story = { // the pointer should be normal await expect(getCursorStyle(button)).toEqual("pointer"); }, - { timeout: 1500 }, + { timeout: 2500 }, ); }); } diff --git a/pkgs/clan-app/ui/tests/clock.ts b/pkgs/clan-app/ui/tests/clock.ts new file mode 100644 index 000000000..931d1fb79 --- /dev/null +++ b/pkgs/clan-app/ui/tests/clock.ts @@ -0,0 +1,56 @@ +import { query } from "@solidjs/router"; +import set = query.set; +import FakeTimers, { Clock } from "@sinonjs/fake-timers"; + +export interface StorybookClock { + tick: (ms: number) => void; + setTimeout: ( + callback: (...args: any[]) => void, + delay: number, + ...args: any[] + ) => void; +} + +class BrowserClock implements StorybookClock { + setTimeout( + callback: (...args: any[]) => void, + delay: number, + args: any, + ): void { + // set a normal timeout + setTimeout(callback, delay, args); + } + + tick(_: number): void { + // do nothing + } +} + +class FakeClock implements StorybookClock { + private clock: Clock; + + constructor() { + this.clock = FakeTimers.createClock(); + } + + setTimeout( + callback: (...args: any[]) => void, + delay: number, + args: any, + ): void { + this.clock.setTimeout(callback, delay, args); + } + + tick(ms: number): void { + this.clock.tick(ms); + } +} + +export function StorybookClock(): StorybookClock { + // Check if we're in a browser environment + const isBrowser = process.env.NODE_ENV !== "test"; + + console.log("is browser", isBrowser); + + return isBrowser ? new BrowserClock() : new FakeClock(); +}