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
12 changes: 7 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -359,18 +359,20 @@ 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
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
shard: [1, 2, 3, 4]
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down
23 changes: 23 additions & 0 deletions apps/hq/test/harness/testPostgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
3 changes: 3 additions & 0 deletions apps/server/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/ui/tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Tooltip>
<TooltipTrigger
delay={50}
render={link ? <a href="/workspace/src/summary.ts" /> : <button type="button" />}
>
summary.ts
</TooltipTrigger>
<TooltipPopup>/workspace/src/summary.ts</TooltipPopup>
</Tooltip>
);
const render = (link: boolean) =>
root.render(
area === "inside the conversation" ? (
<TooltipScrollDismissArea>{tooltip(link)}</TooltipScrollDismissArea>
) : (
tooltip(link)
),
);
try {
await act(async () => render(false));
await act(async () => render(true));
const trigger = container.querySelector<HTMLAnchorElement>("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();
}
},
);
});
19 changes: 17 additions & 2 deletions apps/web/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -71,11 +79,18 @@ function Tooltip<Payload>(props: TooltipPrimitive.Root.Props<Payload>) {
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 <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
return <TooltipPrimitive.Trigger key={key} data-slot="tooltip-trigger" {...props} />;
}
return (
<TooltipPrimitive.Trigger
key={key}
data-slot="tooltip-trigger"
{...props}
onMouseEnter={(event) => {
Expand Down
17 changes: 12 additions & 5 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 26 additions & 1 deletion apps/web/test/scenarios/areas/g-outage/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
8 changes: 5 additions & 3 deletions apps/web/test/scenarios/areas/g-outage/outages.scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
startHq,
declareNewerProtocol,
endHqSession,
resubscribesOnResume,
refuseHq,
resumeTab,
hqRefusalShown,
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/test/scenarios/harness/clientClock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 16 additions & 4 deletions apps/web/test/scenarios/harness/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down
29 changes: 29 additions & 0 deletions scripts/lib/spread-shard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions scripts/lib/spread-shard.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
index: number,
count: number,
): Array<string> {
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<TestSpecification>): Promise<Array<TestSpecification>> {
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)!);
}
}
Loading
Loading