Skip to content

Commit 4e03226

Browse files
Merge branch 'main' into factory/issue-34439
2 parents a0d2330 + c8ddb22 commit 4e03226

380 files changed

Lines changed: 17742 additions & 3224 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/test-delta.cjs‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Counts the test cases a pull request adds and removes, from a zero-context diff, so PR Size can
2+
// say when a change grows the suite. Counting declarations keeps the number deterministic, unlike
3+
// CI timings, which move with runner load.
4+
const MARKER = "<!-- test-delta -->";
5+
const NET_CASES_TO_REPORT = 20;
6+
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
7+
// `<` admits type arguments, as in `it.each<Row>(...)`, which may span lines.
8+
const TEST_CASE = /^\s*(?:it|test)(?:\.\w+)*\s*[(<]/;
9+
const isBrowserTest = (path) => path.includes(".browser.test.");
10+
11+
function kindOf(path) {
12+
if (isBrowserTest(path)) return "browser";
13+
if (path.startsWith("apps/server/") || path.includes(".integration.")) return "server";
14+
return "unit";
15+
}
16+
17+
/** @param {string} diff output of `git diff -U0 base...head` */
18+
function countTestDelta(diff) {
19+
const kinds = { browser: 0, server: 0, unit: 0 };
20+
const newBrowserFiles = [];
21+
let path = null;
22+
// A browser test file is new when it is added, or renamed from a file that was not one.
23+
let file = null;
24+
const flush = () => {
25+
if (file?.isNew && isBrowserTest(file.target)) newBrowserFiles.push(file.target);
26+
};
27+
for (const line of diff.split("\n")) {
28+
if (line.startsWith("diff --git ")) {
29+
flush();
30+
path = null;
31+
file = { target: line.replace(/^diff --git a\/\S+ b\//, ""), isNew: false };
32+
continue;
33+
}
34+
if (line.startsWith("new file mode") && file) file.isNew = true;
35+
if (line.startsWith("rename from ") && file) file.isNew = !isBrowserTest(line.slice(12));
36+
if (line.startsWith("--- ")) {
37+
const source = line.slice(4).replace(/^a\//, "");
38+
path = TEST_FILE.test(source) ? source : null;
39+
continue;
40+
}
41+
if (line.startsWith("+++ ")) {
42+
const target = line.slice(4).replace(/^b\//, "");
43+
// A deleted file's target is /dev/null; keep its old path to count the removed cases.
44+
if (target !== "/dev/null") path = TEST_FILE.test(target) ? target : null;
45+
continue;
46+
}
47+
if (line.startsWith("new file mode") || !path) continue;
48+
const sign = line[0] === "+" ? 1 : line[0] === "-" ? -1 : 0;
49+
if (sign !== 0 && TEST_CASE.test(line.slice(1))) kinds[kindOf(path)] += sign;
50+
}
51+
flush();
52+
return { kinds, net: kinds.browser + kinds.server + kinds.unit, newBrowserFiles };
53+
}
54+
55+
/** Returns the comment body, or null when the change does not grow the suite enough to mention. */
56+
function render(delta) {
57+
if (delta.net < NET_CASES_TO_REPORT && delta.newBrowserFiles.length === 0) return null;
58+
const signed = (n) => (n > 0 ? `+${n}` : String(n));
59+
const lines = [
60+
MARKER,
61+
`This PR changes the test suite by **${signed(delta.net)} test cases** ` +
62+
`(browser ${signed(delta.kinds.browser)}, unit ${signed(delta.kinds.unit)}, server ${signed(delta.kinds.server)}).`,
63+
];
64+
if (delta.newBrowserFiles.length > 0) {
65+
lines.push(
66+
"",
67+
`New browser test files: ${delta.newBrowserFiles.map((file) => `\`${file}\``).join(", ")}.`,
68+
"Each browser test file costs CI seconds of module loading before its first test runs; a unit test costs milliseconds.",
69+
);
70+
}
71+
lines.push(
72+
"",
73+
"Keep tests that would fail for a real reason, and test logic below the UI where it can be. See [Testing](https://github.com/pingdotgg/t3code/blob/main/docs/internals/testing.md).",
74+
);
75+
return lines.join("\n");
76+
}
77+
78+
module.exports = { MARKER, countTestDelta, render };
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const assert = require("node:assert/strict");
2+
const test = require("node:test");
3+
4+
const { countTestDelta, render } = require("./test-delta.cjs");
5+
6+
const diff = `diff --git a/apps/web/src/Panel.browser.test.tsx b/apps/web/src/Panel.browser.test.tsx
7+
new file mode 100644
8+
--- /dev/null
9+
+++ b/apps/web/src/Panel.browser.test.tsx
10+
@@ -0,0 +1,4 @@
11+
+it("opens", async () => {});
12+
+ it.each<{ n: number }>([{ n: 1 }])("renders $n", async () => {});
13+
+// it("is a comment, still counted only when it starts the line")
14+
diff --git a/apps/server/src/thing.test.ts b/apps/server/src/thing.test.ts
15+
--- a/apps/server/src/thing.test.ts
16+
+++ b/apps/server/src/thing.test.ts
17+
@@ -3,2 +3,1 @@
18+
-it.effect("old", () => {});
19+
-test("older", () => {});
20+
+ it.effect("new", () => {});
21+
diff --git a/packages/shared/src/gone.test.ts b/packages/shared/src/gone.test.ts
22+
deleted file mode 100644
23+
--- a/packages/shared/src/gone.test.ts
24+
+++ /dev/null
25+
@@ -1,1 +0,0 @@
26+
-it("goes", () => {});
27+
diff --git a/apps/web/src/Menu.test.tsx b/apps/web/src/Menu.browser.test.tsx
28+
similarity index 100%
29+
rename from apps/web/src/Menu.test.tsx
30+
rename to apps/web/src/Menu.browser.test.tsx
31+
diff --git a/apps/web/src/Old.browser.test.tsx b/apps/web/src/New.browser.test.tsx
32+
similarity index 100%
33+
rename from apps/web/src/Old.browser.test.tsx
34+
rename to apps/web/src/New.browser.test.tsx
35+
diff --git a/apps/web/src/Panel.tsx b/apps/web/src/Panel.tsx
36+
--- a/apps/web/src/Panel.tsx
37+
+++ b/apps/web/src/Panel.tsx
38+
@@ -1 +1 @@
39+
+it("is not a test file", () => {});
40+
`;
41+
42+
test("counts cases by kind, including deleted files, and finds new or renamed browser files", () => {
43+
assert.deepEqual(countTestDelta(diff), {
44+
kinds: { browser: 2, server: -1, unit: -1 },
45+
net: 0,
46+
newBrowserFiles: ["apps/web/src/Panel.browser.test.tsx", "apps/web/src/Menu.browser.test.tsx"],
47+
});
48+
});
49+
50+
test("reports only growth worth a reviewer's attention", () => {
51+
const quiet = { kinds: { browser: 0, server: 3, unit: 5 }, net: 8, newBrowserFiles: [] };
52+
assert.equal(render(quiet), null);
53+
const growth = { kinds: { browser: 4, server: 10, unit: 12 }, net: 26, newBrowserFiles: [] };
54+
assert.match(render(growth), /\*\*\+26 test cases\*\* \(browser \+4, unit \+12, server \+10\)/);
55+
assert.match(
56+
render(countTestDelta(diff)),
57+
/New browser test files: `apps\/web\/src\/Panel\.browser\.test\.tsx`/,
58+
);
59+
});

‎.github/workflows/ci.yml‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ jobs:
326326
run: python3 -B .github/scripts/stage-preview-bundle.test.py
327327

328328
- name: Test release checks
329-
run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/mobile-preview-comment.test.cjs
329+
run: node --test .github/scripts/check-nightly-release.test.cjs .github/scripts/vercel-deployment-output.test.cjs .github/scripts/mobile-preview-comment.test.cjs .github/scripts/test-delta.test.cjs
330330

331331
- name: Finish installing browser secret helper build libraries
332332
timeout-minutes: 5
@@ -355,16 +355,22 @@ jobs:
355355
356356
# The web suite is the largest outside the server. In the shared Test job it
357357
# started last and ran alone for about 40s after everything else finished.
358-
# release.yml mirrors this test, test_web, and test_server split.
358+
# Browser tests are bound by the Vite server transforming modules for each
359+
# file, not by Chromium, so a second runner (and Vite server) halves the job
360+
# that gated every run. release.yml mirrors this test, test_web, and test_server split.
359361
test_web:
360362
permissions:
361363
contents: read
362364
id-token: write
363-
name: Test Web
365+
name: Test Web ${{ matrix.shard }}
364366
needs: changes
365367
if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }}
366368
runs-on: blacksmith-8vcpu-ubuntu-2404
367369
timeout-minutes: 10
370+
strategy:
371+
fail-fast: false
372+
matrix:
373+
shard: [1, 2]
368374
steps:
369375
- name: Checkout
370376
uses: actions/checkout@v6
@@ -406,7 +412,7 @@ jobs:
406412
fi
407413
408414
- name: Test
409-
run: vp run --filter @t3tools/web test
415+
run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
410416

411417
# apps/server sets `fileParallelism: false`, so its test files run strictly
412418
# one at a time. Sharding spreads them over separate runners instead of
@@ -481,6 +487,17 @@ jobs:
481487
node apps/server/node_modules/playwright-core/cli.js install-deps chromium
482488
fi
483489
490+
# Each server test file runs in its own Vitest process, and a fresh install starts
491+
# Vitest's content-keyed module cache empty, so every file re-transformed its imports.
492+
# Only pushes to main save it, so pull requests never evict each other's entries.
493+
- name: Restore Vitest module cache
494+
if: steps.changes.outputs.test_server != 'false'
495+
uses: actions/cache/restore@v6
496+
with:
497+
path: node_modules/.vitest-cache
498+
key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
499+
restore-keys: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
500+
484501
- name: Test
485502
if: steps.changes.outputs.test_server != 'false'
486503
env:
@@ -489,15 +506,14 @@ jobs:
489506
run: >-
490507
vp run test:cached -- --shard ${{ matrix.shard }}/${{ strategy.job-total }}
491508
--uncached apps/server/integration/transferBudgetV2.integration.test.ts
492-
--verify-hits 1 --report "${{ runner.temp }}/test-cache-${{ matrix.shard }}.json" apps/server
509+
--verify-hits 1 apps/server
493510
494-
- name: Upload test cache measurements
495-
if: always() && steps.changes.outputs.test_server != 'false'
496-
uses: actions/upload-artifact@v7
511+
- name: Save Vitest module cache
512+
if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false'
513+
uses: actions/cache/save@v6
497514
with:
498-
name: test-cache-${{ matrix.shard }}
499-
path: ${{ runner.temp }}/test-cache-${{ matrix.shard }}.json
500-
if-no-files-found: error
515+
path: node_modules/.vitest-cache
516+
key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
501517

502518
# integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard
503519
# produces these files. Gating the upload on their presence keeps a

‎.github/workflows/pr-size.yml‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,55 @@ jobs:
293293
core.info(
294294
`PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`,
295295
);
296+
# Reads the script from the base checkout above; the PR's own commits are only diffed.
297+
- name: Report test suite growth
298+
uses: actions/github-script@v8
299+
with:
300+
script: |
301+
const { execFileSync } = require("node:child_process");
302+
const { MARKER, countTestDelta, render } = require(
303+
`${process.env.GITHUB_WORKSPACE}/.github/scripts/test-delta.cjs`,
304+
);
305+
306+
const issueNumber = context.payload.pull_request.number;
307+
const diff = execFileSync(
308+
"git",
309+
["diff", "-U0", `${context.payload.pull_request.base.sha}...refs/remotes/pr-size/${issueNumber}`],
310+
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
311+
);
312+
const delta = countTestDelta(diff);
313+
const body = render(delta);
314+
core.info(`PR #${issueNumber}: test cases ${JSON.stringify(delta.kinds)}, net ${delta.net}`);
315+
316+
const comments = await github.paginate(github.rest.issues.listComments, {
317+
owner: context.repo.owner,
318+
repo: context.repo.repo,
319+
issue_number: issueNumber,
320+
per_page: 100,
321+
});
322+
const existing = comments.find(
323+
(comment) => comment.user?.type === "Bot" && comment.body?.startsWith(MARKER),
324+
);
325+
if (body && existing) {
326+
if (existing.body !== body) {
327+
await github.rest.issues.updateComment({
328+
owner: context.repo.owner,
329+
repo: context.repo.repo,
330+
comment_id: existing.id,
331+
body,
332+
});
333+
}
334+
} else if (body) {
335+
await github.rest.issues.createComment({
336+
owner: context.repo.owner,
337+
repo: context.repo.repo,
338+
issue_number: issueNumber,
339+
body,
340+
});
341+
} else if (existing) {
342+
await github.rest.issues.deleteComment({
343+
owner: context.repo.owner,
344+
repo: context.repo.repo,
345+
comment_id: existing.id,
346+
});
347+
}

‎.github/workflows/release.yml‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,11 +300,15 @@ jobs:
300300
# Web browser tests time out when they share a runner with the other package
301301
# suites: Chromium gets too little CPU and diff workers miss their 10s start budget.
302302
test_web:
303-
name: Release web tests
303+
name: Release web tests ${{ matrix.shard }}
304304
needs: [preflight]
305305
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
306306
runs-on: blacksmith-8vcpu-ubuntu-2404
307307
timeout-minutes: 10
308+
strategy:
309+
fail-fast: false
310+
matrix:
311+
shard: [1, 2]
308312
steps:
309313
- name: Checkout
310314
uses: actions/checkout@v6
@@ -344,10 +348,11 @@ jobs:
344348
fi
345349
346350
- name: Test
347-
run: vp run --filter @t3tools/web test
351+
run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
348352

349353
# Browser tests run source modules; this boots the production chunk graph.
350354
- name: Boot the built web client
355+
if: matrix.shard == 1
351356
run: |
352357
vp run --filter @t3tools/web build
353358
node apps/web/scripts/verify-built-boot.ts

‎.macroscope/check-run-agents/ui-consistency.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ The reference for how this codebase wants UI built is the composer banner system
4444

4545
- `ComposerBanner.tsx` exports one object of small slot components (`Root`, `Row`, `Icon`, `Content`, `Actions`, `Dismiss`, `Children`, and so on). Each slot owns its own Tailwind classes, exposes a `data-slot` attribute, takes `className` and spreads the rest of its props, and uses `cn` so callers can adjust without overriding. Variants and density are props on `Root`, not ad hoc class strings at call sites. Where a slot needs a real control it renders `Button` from `ui/` rather than a styled `<button>`.
4646
- `ComposerBannerStack.tsx` composes those slots into the ordered stack and owns only stack behavior (priority, expand and collapse, dismiss transitions).
47-
- Consumers such as `ComposerPlanFollowUpBanner.tsx` are a few lines of `<ComposerBanner.Row><ComposerBanner.Icon/><ComposerBanner.Content>…` and style only their own content.
47+
- Consumers compose the slots and style only their own content.
4848

4949
Hold new and changed UI to that shape:
5050

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
105105
- Test behavior that can break, such as orchestration and event-sourcing, provider protocol translation, auth and permission guards, persistence and migrations, external-format parsing, race fixes, and the logic a bug fix changed. Leave out tests for what types already guarantee, trivial logic, copy text, smoke runs, mock-call mirrors, callback wiring, and props rendered to static markup.
106106
- Extend before you add. When an existing test or table covers the behavior, add a row or an assertion there. Write a new `it` only when no case fits.
107107
- Test at one layer: the lowest one that fails on `main`. Do not repeat the case in a wrapper test, at each call site, or in web and mobile copies of the same logic.
108+
- Write a browser test only for behavior that needs a real browser: layout and scrolling, focus and pointer input, real workers, or a component wired to its hooks. Logic a component calls gets a unit test beside it (`*.logic.test.ts`). A browser test file spends seconds in CI loading its module graph before its first test runs; a unit test takes milliseconds.
108109
- [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together.
109110
- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
110111
- Backend behavior changes ship with focused tests for that behavior.

‎apps/desktop/scripts/dev-electron.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const childTreeGracePeriodMs = 1_200;
5252
const remoteDebuggingPort = process.env.T3CODE_DESKTOP_REMOTE_DEBUGGING_PORT?.trim();
5353
// oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone dev script has no Effect runtime.
5454
const hostPlatform = NodeOS.platform();
55+
const parentPid = process.ppid;
56+
const parentWatchIntervalMs = 1_000;
5557

5658
NodeChildProcess.execFileSync(
5759
process.execPath,
@@ -271,3 +273,14 @@ process.once("SIGTERM", () => {
271273
process.once("SIGHUP", () => {
272274
void shutdown(129);
273275
});
276+
// A parent killed without forwarding a signal leaves us reparented (to a subreaper on Linux, not
277+
// necessarily PID 1), and nothing else would ever stop the restart loop. This only notices the
278+
// death of our direct parent, so vite.config.ts launches us without a shell in between. Windows
279+
// never reparents, and reading process.ppid there snapshots the process table.
280+
if (hostPlatform !== "win32") {
281+
setInterval(() => {
282+
if (process.ppid !== parentPid) {
283+
void shutdown(129);
284+
}
285+
}, parentWatchIntervalMs).unref();
286+
}

0 commit comments

Comments
 (0)