diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 770ce9705..5fe27b90b 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/hq/test/harness/testPostgres.test.ts b/apps/hq/test/harness/testPostgres.test.ts
index 6a3200b32..427d1e17b 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/apps/server/vite.config.ts b/apps/server/vite.config.ts
index 755b917a2..2548c3c79 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/apps/web/src/components/ui/tooltip.test.tsx b/apps/web/src/components/ui/tooltip.test.tsx
index b9e1a92a8..28ba5e39a 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) => (
+
+ : }
+ >
+ summary.ts
+
+ /workspace/src/summary.ts
+
+ );
+ const render = (link: boolean) =>
+ root.render(
+ area === "inside the conversation" ? (
+ {tooltip(link)}
+ ) : (
+ tooltip(link)
+ ),
+ );
+ try {
+ await act(async () => render(false));
+ await act(async () => render(true));
+ const trigger = container.querySelector("a")!;
+ await act(async () => {
+ trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
+ trigger.dispatchEvent(new MouseEvent("mouseenter"));
+ trigger.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
+ await vi.advanceTimersByTimeAsync(60);
+ });
+ expect(openPopupText()).toBe("/workspace/src/summary.ts");
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ }
+ },
+ );
+});
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 67476b2fb..416650714 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -1,5 +1,13 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
-import { createContext, use, useEffect, useRef, type ComponentProps, type RefObject } from "react";
+import {
+ createContext,
+ isValidElement,
+ use,
+ useEffect,
+ useRef,
+ type ComponentProps,
+ type RefObject,
+} from "react";
import { isFollowScroll } from "~/lib/followScroll";
import { cn } from "~/lib/utils";
@@ -71,11 +79,18 @@ function Tooltip(props: TooltipPrimitive.Root.Props) {
function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
const hovered = use(TooltipScrollContext);
const actionsRef = use(TooltipHoverContext);
+ // Base UI binds its hover listeners to the first element it renders; a trigger whose element
+ // changes kind (a file chip's menu button becoming its link) remounts so hover reaches it.
+ const key =
+ isValidElement(props.render) && typeof props.render.type === "string"
+ ? props.render.type
+ : undefined;
if (!hovered || !actionsRef) {
- return ;
+ return ;
}
return (
{
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 937269130..f78e531eb 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) {
diff --git a/apps/web/test/scenarios/areas/g-outage/dsl.ts b/apps/web/test/scenarios/areas/g-outage/dsl.ts
index 7c729b88b..62206dc4e 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 5d0b7fc66..046daeeb4 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);
diff --git a/apps/web/test/scenarios/harness/clientClock.ts b/apps/web/test/scenarios/harness/clientClock.ts
index b36f1a53c..22fd3bfd0 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 9f7292a9f..1a53df301 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: {
diff --git a/scripts/lib/spread-shard.test.ts b/scripts/lib/spread-shard.test.ts
new file mode 100644
index 000000000..b911963f4
--- /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 000000000..0b8c1a044
--- /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)!);
+ }
+}
diff --git a/scripts/test-postgres-supervisor.ts b/scripts/test-postgres-supervisor.ts
index 209a3275c..2493ee3c5 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;