From ed69ce67de9ca75925de7a52bc35a5a7198caeac Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:26:23 +0200 Subject: [PATCH 1/6] test(hq): freeze a template once its closed pool's backends have exited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pool's close returns before PostgreSQL's backend exits, so the supervisor counted a closed migration pool as open and refused the freeze: all 71 tests of deploys.test.ts failed in CI run 37934023035 with "Close database connections before freezing a template". The freeze now waits up to 2 s for the database's backends to leave (as PostgreSQL's own CREATE DATABASE … TEMPLATE waits for exiting ones) and answers each lease's commands in order. --- apps/hq/test/harness/testPostgres.test.ts | 23 +++++++++++++++ scripts/test-postgres-supervisor.ts | 36 +++++++++++++++++++---- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/hq/test/harness/testPostgres.test.ts b/apps/hq/test/harness/testPostgres.test.ts index 6a3200b32f..427d1e17be 100644 --- a/apps/hq/test/harness/testPostgres.test.ts +++ b/apps/hq/test/harness/testPostgres.test.ts @@ -94,6 +94,29 @@ it.effect( }).pipe(Effect.scoped), ); +// A pool's close returns before PostgreSQL's backend exits; the template must not race that exit. +it.live("freezes a database whose closed connection's backend is still exiting", () => + Effect.gen(function* () { + const owner = yield* lease; + const template = yield* Effect.promise(owner.createDatabase); + yield* Effect.scoped( + Effect.gen(function* () { + const source = yield* connection(template); + yield* source.query("CREATE TABLE baseline (value text)"); + // The backend notices its client left only once this statement ends. + yield* Effect.forkDetach(source.query("SELECT pg_sleep(0.5)")); + yield* Effect.sleep("50 millis"); + }), + ); + yield* Effect.promise(() => owner.freezeDatabase(template)); + const clone = yield* Effect.promise(() => owner.cloneDatabase(template)); + const copied = yield* connection(clone); + assert.deepEqual((yield* copied.query("SELECT to_regclass('baseline')::text AS t")).rows, [ + { t: "baseline" }, + ]); + }).pipe(Effect.scoped), +); + it.effect.each(["SIGKILL", "exit"] as const)( "reclaims a worker database after %s before the next owner's work", (mode) => diff --git a/scripts/test-postgres-supervisor.ts b/scripts/test-postgres-supervisor.ts index 209a3275c1..2493ee3c53 100644 --- a/scripts/test-postgres-supervisor.ts +++ b/scripts/test-postgres-supervisor.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off -- standalone host-only test PostgreSQL supervisor. +// @effect-diagnostics nodeBuiltinImport:off preferSchemaOverJson:off globalDate:off globalTimers:off -- standalone host-only test PostgreSQL supervisor. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; @@ -6,6 +6,7 @@ import * as NodeNet from "node:net"; import * as NodePath from "node:path"; import * as NodeReadline from "node:readline"; import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; import { lifecycleLock, postgresHome } from "./test-postgres.ts"; const pgBinDir = (() => { @@ -25,6 +26,7 @@ const pgBinDir = (() => { return "/opt/homebrew/bin"; })(); const data = NodePath.join(postgresHome, "data"); +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); const socketPath = NodePath.join(postgresHome, "supervisor.sock"); const run = (command: string, args: string[]) => { const result = NodeChildProcess.spawnSync(NodePath.join(pgBinDir, command), args, { @@ -145,6 +147,27 @@ async function supervise() { }; const sql = (query: string) => run("psql", [urlOf(port, "postgres"), "-X", "-v", "ON_ERROR_STOP=1", "-Atc", query]); + /** + * Whether a database's last backend left. A client's close returns before its backend exits + * (PostgreSQL's own CREATE DATABASE … TEMPLATE waits for such backends too), so a closed pool + * still shows for a moment; a connection held open is still there once the wait ends. + */ + const drained = async (name: string) => { + const deadline = Date.now() + 2_000; + for (;;) { + const { stdout } = await execFile(NodePath.join(pgBinDir, "psql"), [ + urlOf(port, "postgres"), + "-X", + "-v", + "ON_ERROR_STOP=1", + "-Atc", + `SELECT count(*) FROM pg_stat_activity WHERE datname = '${name}'`, + ]); + if (stdout.trim() === "0") return true; + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }; // A prior supervisor's leases cannot survive its loss. Reclaim its managed databases on start. for (const name of sql("SELECT datname FROM pg_database WHERE datname LIKE 'mate_test_%'") .trim() @@ -197,7 +220,12 @@ async function supervise() { return name; }; const lines = NodeReadline.createInterface({ input: socket }); + // One command at a time per lease: the client pairs replies with its requests in order. + let queue = Promise.resolve(); lines.on("line", (command) => { + queue = queue.then(() => answer(command)); + }); + const answer = async (command: string) => { try { if (released) return; if (command === "own") { @@ -216,9 +244,7 @@ async function supervise() { const name = ownedDatabase(command.slice("freeze ".length)); // Publication requires a closed migration pool; no writer survives the freeze. sql(`ALTER DATABASE ${name} ALLOW_CONNECTIONS false`); - if ( - sql(`SELECT count(*) FROM pg_stat_activity WHERE datname = '${name}'`).trim() !== "0" - ) { + if (!(await drained(name))) { sql(`ALTER DATABASE ${name} ALLOW_CONNECTIONS true`); throw new Error("Close database connections before freezing a template"); } @@ -241,7 +267,7 @@ async function supervise() { } catch (error) { socket.write(`${JSON.stringify({ error: String(error) })}\n`); } - }); + }; let released = false; const release = async (requested = false) => { if (released) return; From 7cc387b2ea5dfd615d788444b05d22bd4f23b9a3 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:26:24 +0200 Subject: [PATCH 2/6] ci: deal server tests over four shards by sorted path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vitest's contiguous hash slices put importV1Crew (129 s) and CrewEngine (105 s) in one shard: Test Server 1 ran 491 s on average and up to 590 s of its 600 s limit (once cancelled), against 438 s and 439 s for the others. Four shards that each take every 4th sorted path spread a directory's slow suites apart: about 310–350 s of tests each by the last run's timings. --- .github/workflows/ci.yml | 12 +++++++----- apps/server/vite.config.ts | 3 +++ scripts/lib/spread-shard.test.ts | 29 +++++++++++++++++++++++++++++ scripts/lib/spread-shard.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 scripts/lib/spread-shard.test.ts create mode 100644 scripts/lib/spread-shard.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 770ce97056..5fe27b90b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -359,10 +359,12 @@ jobs: - name: Test run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} - # apps/server sets `fileParallelism: false`, so its 239 files run strictly - # one at a time. Sharding spreads them over separate runners instead of - # separate workers, so no two server test files ever share a machine and the - # isolation that flag buys is preserved exactly. + # apps/server sets `fileParallelism: false`, so its files run strictly one at + # a time. Sharding spreads them over separate runners instead of separate + # workers, so no two server test files ever share a machine and the isolation + # that flag buys is preserved exactly. Each shard deals every 4th file of the + # sorted paths (scripts/lib/spread-shard.ts): about 340 s of tests each, where + # three hash slices ran 390–525 s and the heaviest neared the 10-minute limit. test_server: name: Test Server ${{ matrix.shard }} runs-on: ubuntu-latest @@ -370,7 +372,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3] + shard: [1, 2, 3, 4] steps: - name: Checkout uses: actions/checkout@v6 diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 755b917a2b..2548c3c79b 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -5,6 +5,7 @@ import { defineConfig, mergeConfig } from "vite-plus"; import baseConfig from "../../vite.config.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; import packageJson from "./package.json" with { type: "json" }; +import { SpreadShardSequencer } from "../../scripts/lib/spread-shard.ts"; // The bundle used to inline only workspace packages, leaving every third-party // runtime dep external. External deps must exist on the real filesystem (the WSL @@ -86,6 +87,8 @@ export default mergeConfig( // The server suite exercises sqlite, git, temp worktrees, and orchestration // runtimes heavily. Running files in parallel introduces load-sensitive flakes. fileParallelism: false, + // CI's shards each take every n-th file, so its slow crew and engine suites never share one. + sequence: { sequencer: SpreadShardSequencer }, // Appended to the root setup, which mergeConfig concatenates. setupFiles: ["./src/testUtils/gitConfig.setup.ts"], // Server integration tests exercise sqlite, git, and orchestration together. diff --git a/scripts/lib/spread-shard.test.ts b/scripts/lib/spread-shard.test.ts new file mode 100644 index 0000000000..b911963f47 --- /dev/null +++ b/scripts/lib/spread-shard.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; +import { spreadShard } from "./spread-shard.ts"; + +const files = [ + "src/zerops/crew/CrewEngine.test.ts", + "src/zerops/crew/CrewEngine.lead.test.ts", + "src/zerops/crew/CrewEngine.operations.test.ts", + "src/zerops/crew/CrewEngine.runs.test.ts", + "src/engine/engine.pump.test.ts", + "src/engine/engine.sim.test.ts", + "src/git/GitManager.test.ts", +]; + +describe("spreadShard", () => { + it("puts every file in exactly one shard, whatever the order it is given in", () => { + const shards = [1, 2, 3].map((index) => spreadShard(files, index, 3)); + const reversed = [1, 2, 3].map((index) => spreadShard(files.toReversed(), index, 3)); + expect(shards.flat().toSorted()).toEqual(files.toSorted()); + expect(reversed).toEqual(shards); + }); + + // Heavy suites cluster by directory; a contiguous slice gave one shard all of crew's engine files. + it("spreads a directory's neighbouring files over different shards", () => { + const crew = files.filter((file) => file.startsWith("src/zerops/crew/")); + const shardOf = (file: string) => + [1, 2, 3, 4].find((index) => spreadShard(files, index, 4).includes(file)); + expect(new Set(crew.map(shardOf)).size).toBe(crew.length); + }); +}); diff --git a/scripts/lib/spread-shard.ts b/scripts/lib/spread-shard.ts new file mode 100644 index 0000000000..0b8c1a044a --- /dev/null +++ b/scripts/lib/spread-shard.ts @@ -0,0 +1,26 @@ +import { BaseSequencer, type TestSpecification } from "vite-plus/test/node"; + +/** + * Shard `index` of `count` (1-based): every `count`-th file of the sorted paths. Slow suites + * cluster by directory (a module's engine, its runs, its lead…), so a contiguous slice of files — + * Vitest's default — can hand one shard several of them; dealing the sorted list round-robin + * spreads a directory's neighbours over every shard. + */ +export function spreadShard( + files: ReadonlyArray, + index: number, + count: number, +): Array { + return files.toSorted().filter((_, position) => position % count === index - 1); +} + +/** Vitest's sequencer with {@link spreadShard} in place of its contiguous hash slices. */ +export class SpreadShardSequencer extends BaseSequencer { + override async shard(files: Array): Promise> { + const { index, count } = this.ctx.config.shard!; + const root = this.ctx.config.root; + // Every shard sees the same list, so the path below the root orders it the same on each. + const byPath = new Map(files.map((spec) => [spec.moduleId.slice(root.length + 1), spec])); + return spreadShard([...byPath.keys()], index, count).map((path) => byPath.get(path)!); + } +} From e04a68074b1864b5b0acbb6f782b68075f7ffc14 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:25:03 +0200 Subject: [PATCH 3/6] test(scenarios): let HQ's reconnect grace run out on the scenario clock ab1ef8f00 (fix(data): hold what a scope read through a reconnect's grace) made a lost link read as current for STREAM_POLICY.reconnectGraceMs (5 s) and ends the hold on the store's own timer. The outage scenarios install the client clock, whose positive timers run only when advanced, so the grace never ran out: the menu never said HQ is unavailable and then.hq.isUnavailable timed out in 11 g-outage cases and foundation's "G: HQ outage preserves rows and catches up after return". CI's chat-gate runs c-mate only, so the areas rotted unseen. then.hq.isUnavailable now passes the grace on an installed clock, one grace at a time, until the tab says HQ is unavailable: the grace counts from when the tab noticed the drop, which the harness cannot observe, so a single advance raced the socket close. The clock says whether it is installed. No test title changes. --- .../web/test/scenarios/harness/clientClock.ts | 4 ++++ apps/web/test/scenarios/harness/scenario.ts | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/test/scenarios/harness/clientClock.ts b/apps/web/test/scenarios/harness/clientClock.ts index b36f1a53c6..22fd3bfd0e 100644 --- a/apps/web/test/scenarios/harness/clientClock.ts +++ b/apps/web/test/scenarios/harness/clientClock.ts @@ -172,6 +172,10 @@ export function clientClock(page: Page, wallClock?: ScenarioWallClock) { }; return { install, + /** Whether the page's timers run on this clock: its time moves only when advanced. */ + get installed() { + return installed; + }, advance, async advanceStepped(ms: number, options: SteppedAdvanceOptions = {}) { if (!installed) throw new Error("Install clientClock before sign-in/navigation"); diff --git a/apps/web/test/scenarios/harness/scenario.ts b/apps/web/test/scenarios/harness/scenario.ts index 9f7292a9fc..1a53df301c 100644 --- a/apps/web/test/scenarios/harness/scenario.ts +++ b/apps/web/test/scenarios/harness/scenario.ts @@ -5,6 +5,8 @@ import * as Schema from "effect/Schema"; import type { Page, BrowserContext } from "puppeteer-core"; import { inject } from "vite-plus/test"; import { expect } from "@effect/vitest"; +// The policy module alone: the data barrel would put the whole data layer in every scenario's graph. +import { STREAM_POLICY } from "../../../../../packages/client-runtime/src/data/streamMachine.ts"; import { HqAttentionScopeValue } from "@t3tools/shared/hqStream"; import { MateLinkUp, MateOverview } from "@t3tools/shared/mateLink"; import { @@ -352,10 +354,20 @@ export const createScenario = Effect.fn("scenarios.create")(function* ( const then = { hq: { isUnavailable: Effect.promise(async () => { - await page.waitForSelector('[data-zerops-surface="sidebar-hq-outage"]', { - visible: true, - timeout: 10_000, - }); + const outage = '[data-zerops-surface="sidebar-hq-outage"]'; + const clock = web.clock(page); + // A lost link reads as an outage only once its reconnect grace runs out, counted from + // when the tab noticed the loss. On a scenario clock that time passes only when + // advanced, so it passes a grace at a time until the tab says so (about 10 s at most). + for (let look = 0; clock.installed && look < 40; look++) { + const shown = await page + .waitForSelector(outage, { visible: true, timeout: 250 }) + .then(() => true) + .catch(() => false); + if (shown) return; + await clock.advance(STREAM_POLICY.reconnectGraceMs); + } + await page.waitForSelector(outage, { visible: true, timeout: 10_000 }); }), }, menu: { From 17469401ff14cb1c18539c14531144351e09305d Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:41:04 +0200 Subject: [PATCH 4/6] test(g-outage): prove a session ending renews by its new subscription Since ab1ef8f00 a link HQ ends for its session (4401) holds what it read through the reconnect grace, so a renewal within the grace shows no outage line: "successive HQ session endings renew and restore the menu" no longer can wait for then.hq.isUnavailable. With the previous commit letting the grace run out it passed only when the line happened to flash before the renewal. The case now proves the ending by what follows it: the tab, woken, opens a new HQ subscription, and the menu is caught up. The wake repeats until the tab has noticed the ending, since a wake that comes first finds no recovery to expedite. The title stays. --- apps/web/test/scenarios/areas/g-outage/dsl.ts | 27 ++++++++++++++++++- .../areas/g-outage/outages.scenario.ts | 8 +++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/test/scenarios/areas/g-outage/dsl.ts b/apps/web/test/scenarios/areas/g-outage/dsl.ts index 7c729b88be..62206dc4e5 100644 --- a/apps/web/test/scenarios/areas/g-outage/dsl.ts +++ b/apps/web/test/scenarios/areas/g-outage/dsl.ts @@ -29,7 +29,32 @@ export const givenOutage = Effect.fn("outage.given")(function* () { export const stopHq = (s: Scenario) => s.drivers.core.stop; export const declareNewerProtocol = (s: Scenario) => Effect.sync(() => newerHqProtocol(s.drivers)); -export const endHqSession = (s: Scenario) => Effect.sync(() => endHqStream(s.drivers, 4401)); +/** Ends the tab's HQ session; returns how many HQ sockets had opened before it ended. */ +export const endHqSession = (s: Scenario) => + Effect.sync(() => { + const opened = s.drivers.hq.counters.wsOpens; + endHqStream(s.drivers, 4401); + return opened; + }); +/** + * The tab, woken by `trigger`, opens a new HQ subscription after `opened` sockets. It is woken + * again until it does: a wake before the tab noticed the ending finds nothing to recover. + */ +export const resubscribesOnResume = ( + s: Scenario, + trigger: "online" | "focus" | "visibilitychange", + opened: number, +) => + Effect.promise(async () => { + for (let look = 0; look < 200; look++) { + await s.page.evaluate((trigger) => { + (trigger === "visibilitychange" ? document : window).dispatchEvent(new Event(trigger)); + }, trigger); + if (s.drivers.hq.counters.wsOpens > opened) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("The tab opened no new HQ subscription after its session ended"); + }); export const refuseHq = (s: Scenario) => Effect.sync(() => endHqStream(s.drivers, 4403)); export const resumeTab = (s: Scenario, trigger: "online" | "focus" | "visibilitychange") => Effect.promise(() => diff --git a/apps/web/test/scenarios/areas/g-outage/outages.scenario.ts b/apps/web/test/scenarios/areas/g-outage/outages.scenario.ts index 5d0b7fc66b..046daeeb45 100644 --- a/apps/web/test/scenarios/areas/g-outage/outages.scenario.ts +++ b/apps/web/test/scenarios/areas/g-outage/outages.scenario.ts @@ -25,6 +25,7 @@ import { startHq, declareNewerProtocol, endHqSession, + resubscribesOnResume, refuseHq, resumeTab, hqRefusalShown, @@ -93,9 +94,10 @@ describe("G: outages, sleep and several tabs", () => { yield* s.given.signedIn; yield* caughtUp(s, "Shop"); for (let end = 0; end < 3; end++) { - yield* endHqSession(s); - yield* s.then.hq.isUnavailable; - yield* resumeTab(s, "online"); + // A renewal within the reconnect grace is not an outage, so the menu says nothing of + // the ending; the renewal shows as a new subscription. + const opened = yield* endHqSession(s); + yield* resubscribesOnResume(s, "online", opened); yield* caughtUp(s, "Shop"); } yield* checkpoint(s); From 36834e32584e8bbb6201dd94b567ccf103421e9f Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:20:22 +0200 Subject: [PATCH 5/6] fix(web): paint the docked footer with a solid layer, not a gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the footer became a hard-stop gradient (#154), "history does not paint through the composer footer" failed about one run in four: the two screenshots differed by ±1 in one channel on every 8th pixel of every 8th row, which is Chrome's gradient dither, its phase set by the tile it lands in. Hiding the history re-rastered the footer and moved the dither. A solid ::before from the card's top edge down paints the same colour every time. The gap above the card stays clear and nothing paints below it. Before: 1 of 4 local runs failed. After: 8 of 8, plus 4 of 4 under eight CPU burners. --- apps/web/src/index.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9372691304..f78e531ebd 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -939,11 +939,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil the footer's own top padding (pt-1.5, sm:pt-2). */ .chat-composer-dock { --chat-composer-dock-gap: calc(var(--spacing) * 1.5); - background: linear-gradient( - to bottom, - transparent var(--chat-composer-dock-gap), - var(--background) var(--chat-composer-dock-gap) - ); + } + + /* A solid layer, not a gradient: Chrome dithers a gradient by its tile's + origin, so the footer's colour shifted whenever the history above it was + repainted. The dock's stacking context keeps it behind the composer. */ + .chat-composer-dock::before { + pointer-events: none; + position: absolute; + z-index: -1; + inset: var(--chat-composer-dock-gap) 0 0; + background: var(--background); + content: ""; } @media (min-width: 40rem) { From e4c2cfafac571ec10748f9288be6ebb16f4ac4ba Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 22:15:48 +0200 Subject: [PATCH 6/6] fix(web): keep a tooltip's hover when its trigger changes element Base UI binds a trigger's native mouseenter listener to the element it first renders and does not rebind when `render` swaps the element. A chat file chip renders a menu button until its open action is known, then an : hovering the link afterwards never cleared Base UI's mouse-move block, so its path tooltip never opened. This is the intermittent CI failure in Chat Contract (C shard 2), images "messages appear before image bytes, reserve their size, and reuse previews on reopen", timing out at waitForSelector('[data-slot="tooltip-popup"]'). A diagnostic under a 4x CPU throttle showed every failure had the BUTTON -> A swap, with pointerenter and mousemove delivered to the link and the pointer still on it; a second move did not open it either. TooltipTrigger now keys Base UI's trigger on the rendered host element's tag, so a change of element kind remounts it and its listeners. Under the 4x throttle the scenario failed 1 of 4 plain runs (and once in a full shard 2/2 run) before, and passes 6 of 6 after. --- apps/web/src/components/ui/tooltip.test.tsx | 50 +++++++++++++++++++++ apps/web/src/components/ui/tooltip.tsx | 19 +++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx index b9e1a92a8b..28ba5e39a2 100644 --- a/apps/web/src/components/ui/tooltip.test.tsx +++ b/apps/web/src/components/ui/tooltip.test.tsx @@ -137,3 +137,53 @@ describe("tooltip scroll dismissal", () => { } }); }); + +describe("tooltip trigger element", () => { + // A chat file link renders a menu button until its open action is known, then a link. + it.each([{ area: "inside the conversation" }, { area: "outside the conversation" }])( + "$area, a trigger that becomes a link still opens its tooltip on hover", + async ({ area }) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.useFakeTimers(); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const tooltip = (link: boolean) => ( + + :