Skip to content

Commit f7811b0

Browse files
saphidclaude
andcommitted
chore: merge main into usage redesign
Main now lists a provider directory as refreshing instead of waiting for it. Keep that together with the per-directory provider instance this branch tracks for account attribution. Co-Authored-By: Claude Fable 5.2 <[email protected]>
2 parents 893e624 + 7a64505 commit f7811b0

182 files changed

Lines changed: 5824 additions & 739 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: 1 addition & 1 deletion
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

‎.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+
}

‎.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/src/app/DesktopCliShim.test.ts‎

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ const run = (shim: string, args: ReadonlyArray<string>, env: NodeJS.ProcessEnv =
4141
const REPORTER =
4242
'#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n';
4343

44-
const fakeAppImage = (canMount = true) => {
44+
const fakeAppImage = (canMount = true, app = REPORTER) => {
4545
const root = tempRoot();
4646
const image = NodePath.join(root, "image");
47-
writeExecutable(NodePath.join(image, "t3code"), REPORTER);
47+
writeExecutable(NodePath.join(image, "t3code"), app);
4848
const appImage = NodePath.join(root, "T3 Code.AppImage");
4949
writeExecutable(
5050
appImage,
@@ -53,8 +53,11 @@ const fakeAppImage = (canMount = true) => {
5353
`if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`,
5454
'[ "$1" = --appimage-mount ] || exit 9',
5555
'[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8',
56+
`echo $$ >'${root}/mounter.pid'`,
5657
canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1',
57-
`trap 'touch "${root}/unmounted"; exit 0' TERM`,
58+
// Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open.
59+
`trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`,
60+
"trap 'echo mounted >&3 2>/dev/null' WINCH",
5861
"while :; do sleep 0.05; done",
5962
"",
6063
].join("\n"),
@@ -63,7 +66,81 @@ const fakeAppImage = (canMount = true) => {
6366
return { root, image, appImage, shim };
6467
};
6568

69+
// Stays up after a signal until the test lets it finish, like a server shutting down.
70+
const SLOW_SERVER = [
71+
"#!/bin/sh",
72+
"trap 'echo stopping >&3; read go; echo stopped >&3; exit 0' TERM HUP",
73+
"echo ready",
74+
"while :; do sleep 0.05; done",
75+
"",
76+
].join("\n");
77+
78+
// The launcher mounts only under setsid, which stock macOS and Windows lack; there it extracts instead.
79+
const hasSetsid = NodeChildProcess.spawnSync("setsid", ["true"]).status === 0;
80+
81+
const lines = (stream: NodeJS.ReadableStream) => {
82+
const seen: Array<string> = [];
83+
const waiters: Array<() => void> = [];
84+
let buffer = "";
85+
stream.setEncoding("utf8");
86+
stream.on("data", (chunk: string) => {
87+
buffer += chunk;
88+
const parts = buffer.split("\n");
89+
buffer = parts.pop()!;
90+
seen.push(...parts);
91+
for (const wake of waiters.splice(0)) wake();
92+
});
93+
const until = async (match: (line: string) => boolean) => {
94+
for (;;) {
95+
const line = seen.find(match);
96+
if (line !== undefined) return line;
97+
await new Promise<void>((resolve) => waiters.push(resolve));
98+
}
99+
};
100+
return { seen, until };
101+
};
102+
66103
describe("renderCliShim", () => {
104+
it.skipIf(!hasSetsid).each(["SIGTERM", "SIGHUP"] as const)(
105+
"keeps the AppImage mounted until the server exits after %s to the process group",
106+
async (signal) => {
107+
const { root, shim } = fakeAppImage(true, SLOW_SERVER);
108+
const child = NodeChildProcess.spawn(shim, [], {
109+
detached: true,
110+
env: { PATH: process.env.PATH },
111+
stdio: ["pipe", "pipe", "ignore", "pipe"],
112+
});
113+
let mounter: number | undefined;
114+
try {
115+
const stdout = lines(child.stdout!);
116+
const events = lines(child.stdio[3] as NodeJS.ReadableStream);
117+
const exited = new Promise((resolve) => child.on("close", resolve));
118+
await stdout.until((line) => line === "ready");
119+
mounter = Number(NodeFS.readFileSync(NodePath.join(root, "mounter.pid"), "utf8"));
120+
121+
// Ctrl-C in a terminal, a closed SSH session, or a service manager signals the whole group.
122+
process.kill(-child.pid!, signal);
123+
// The shell runs pending traps in ascending signal number, and WINCH outnumbers TERM and
124+
// HUP, so a mounter that also got the group signal unmounts before it answers this probe.
125+
process.kill(mounter, "SIGWINCH");
126+
const mount = await events.until((line) => line === "mounted" || line === "unmounted");
127+
expect(mount).toBe("mounted");
128+
129+
await events.until((line) => line === "stopping");
130+
child.stdin!.end("go\n");
131+
await exited;
132+
expect(events.seen.slice(-2)).toEqual(["stopped", "unmounted"]);
133+
} finally {
134+
for (const pid of [-child.pid!, mounter]) {
135+
if (pid === undefined) continue;
136+
try {
137+
process.kill(pid, "SIGKILL");
138+
} catch {}
139+
}
140+
}
141+
},
142+
);
143+
67144
it("extracts the AppImage where it cannot be mounted", () => {
68145
const { shim } = fakeAppImage(false);
69146
const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" });

‎apps/desktop/src/app/DesktopCliShim.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,13 @@ export const renderCliShim = (input: {
9090
...header,
9191
// Extract-and-run would make the runtime start the app instead of mounting it.
9292
"unset APPIMAGE_EXTRACT_AND_RUN",
93-
// The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C.
93+
// Its own session keeps a group signal (Ctrl-C, SSH hangup) from unmounting it under the
94+
// running server; the traps unmount it after. Without setsid the extract fallback runs.
9495
"work=$(mktemp -d) || exit 1",
95-
'"$app" --appimage-mount >"$work/mount" 2>/dev/null &',
96+
'setsid "$app" --appimage-mount >"$work/mount" 2>/dev/null &',
9697
"mounter=$!",
9798
`trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`,
98-
"trap 'exit 130' INT TERM",
99+
"trap 'exit 130' INT TERM HUP",
99100
"mount=",
100101
'while kill -0 "$mounter" 2>/dev/null; do',
101102
' mount=$(head -n 1 "$work/mount")',

‎apps/marketing/src/layouts/Layout.astro‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const {
4646
const currentLocale = localeInfo(locale);
4747
const homeHref = localizedHref(locale, "/");
4848
const downloadHref = localizedHref(locale, "/download");
49-
// English-only pages still declare themselves, so every page carries hreflang and x-default.
49+
// English-only pages still declare themselves, so every indexable page carries hreflang and x-default.
5050
const alternates = localizedPath
5151
? LOCALES.map((entry) => ({
5252
hreflang: entry.code,
@@ -95,8 +95,12 @@ const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
9595
<link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16" />
9696
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
9797
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
98-
{alternates.map((entry) => <link rel="alternate" hreflang={entry.hreflang} href={entry.href} />)}
99-
<link rel="alternate" hreflang="x-default" href={defaultAlternate.href} />
98+
{!noindex && (
99+
<>
100+
{alternates.map((entry) => <link rel="alternate" hreflang={entry.hreflang} href={entry.href} />)}
101+
<link rel="alternate" hreflang="x-default" href={defaultAlternate.href} />
102+
</>
103+
)}
100104
<title>{title}</title>
101105
<script is:inline type="application/ld+json" set:html={structuredData} />
102106
</head>

‎apps/marketing/src/lib/seo.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ export const websiteJsonLd = (): JsonLd => ({
3838
publisher: { "@id": ORGANIZATION_ID },
3939
});
4040

41-
export const softwareJsonLd = (): JsonLd => ({
41+
export const softwareJsonLd = (description: string): JsonLd => ({
4242
"@type": "SoftwareApplication",
4343
"@id": SOFTWARE_ID,
4444
name: SITE_NAME,
45-
description: `${SITE_NAME}. ${SITE_TAGLINE}.`,
45+
description,
4646
url: SITE_ORIGIN,
4747
downloadUrl: absoluteUrl("/download"),
4848
applicationCategory: "DeveloperApplication",

0 commit comments

Comments
 (0)