Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit57cdb50

test: suite is deterministic — 0 failures

test: suite is deterministic — 0 failures

Closes the last two of the four order-dependent failures. Full suite: 3397
pass, 0 fail, twice in a row.

voice-to-pr (fixed wikis + a real defect)
-----------------------------------------
voice-to-pr.test.ts mock.module'd `../db` and `../git/repository` at module
scope. Both are process-global, and bun loads EVERY test module before
running any test — so the stubs bound themselves into routes/wikis.tsx before
any afterAll could fire. An afterAll restore cannot fix this: mock.module
swaps the registry entry, it does not rebind namespaces an importer already
holds.

src/lib/voice-to-pr.ts now exposes __setVoiceDepsForTests(), matching
push-workflow-sync.ts / first-repo-scaffold.ts / index-existing-repo.ts. The
seam is a module-local variable, so restoring it cannot affect anything
another file imported. Zero mock.module calls remain in that test.

Also fixes a genuine defect on the way: the git mock replaced the WHOLE
module with a single export, so any module importing getRepoPath, repoExists
or getTree after this file ran would have received undefined.

layout-user-prop (was never an ordering bug)
--------------------------------------------
"/login WITHOUT a session still renders the sign-in shell" asserted
`href="/login" class="nav-link"` — a layout.tsx nav literal. /login was since
redesigned to SignInV2, a standalone two-panel view that deliberately does
NOT go through layout.tsx, so that string can never appear. The test failed
in isolation too; classifying it with the other three as order-dependent was
my error.

The assertion now targets the page that actually ships (the si-heading
"Sign in" and the POST form) plus the absence of authed user chrome. The
case's intent — an unauthenticated /login RENDERS rather than bounces, the
counterpart to the two redirect cases above it — is unchanged.

I also tried mounting only the routers under test here, as done for
magic-link and password-reset. That made this file worse (2 failures, and it
broke the global-404 case which needs app.notFound), so it was reverted
rather than shipped.

Nothing skipped, reordered or weakened across the whole series: 4 -> 2 -> 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 28, 2026Parent: bdf47f0
3 files changed+895657cdb50326d7b27d2a0a14a45b685fbc4b40938a
3 changed files+89−56
Modifiedsrc/__tests__/layout-user-prop.test.ts+15−2View fileUnifiedSplit
217217 const res = await app.request("/login");
218218 expect(res.status).toBe(200);
219219 const body = await res.text();
220 // Logged-out nav literal is present on the actual sign-in page.
221 expect(body).toContain(LOGGED_OUT_NAV_MARKER);
220
221 // This asserted LOGGED_OUT_NAV_MARKER (`href="/login" class="nav-link"`),
222 // which is a layout.tsx nav literal. /login was since redesigned to
223 // SignInV2 — a standalone two-panel view that deliberately does NOT go
224 // through layout.tsx (see the comment at the top of its stylesheet), so
225 // the shared nav is never rendered here and the assertion could not pass.
226 //
227 // The intent of the case is unchanged: an unauthenticated /login must
228 // RENDER the sign-in page rather than bounce, which is the counterpart to
229 // the two redirect cases above. Assert that against the page that
230 // actually ships.
231 expect(body).toContain('class="si-heading">Sign in');
232 expect(body).toContain('action="/login"');
233 // ...and that it is the logged-out view: no authed user chrome.
234 expect(body).not.toContain('class="nav-user-trigger"');
222235 });
223236});
Modifiedsrc/__tests__/voice-to-pr.test.ts+35−45View fileUnifiedSplit
213213const gitWrites: Array<any> = [];
214214const issueInserts: Array<any> = [];
215215
216// Capture the REAL modules before mocking so afterAll can put them back.
216// Injected fakes — no mock.module anywhere in this file.
217217//
218// This file previously mocked `../git/repository` and `../db` and never
219// restored either. Both are process-global: bun's `mock.module` swaps the
220// registry entry for every subsequent importer. The db mock leaked into
221// wikis.test.ts, whose "GET /:owner/:repo/wiki on missing repo → 404" case
222// then saw a stubbed repo row instead of no row and failed — in a full run
223// only, which is why it looked flaky rather than broken. Bisected to here.
218// This file used to mock.module both `../git/repository` and `../db` at
219// module scope, and never restored either. Those calls are process-global:
220// bun swaps the module registry entry and does NOT rebind namespaces an
221// importer already holds. Because bun loads every test module before running
222// any test, the db stub bound itself into routes/wikis.tsx and
223// routes/auth.tsx before any afterAll could fire — which is why
224// `wikis — route smoke` and `auth landing pages` failed in full runs while
225// passing in isolation. An afterAll restore cannot fix that; not mocking is
226// the fix. The git mock was worse still: it replaced the WHOLE module with a
227// single export, so any later importer of getRepoPath/repoExists got
228// undefined.
224229//
225// The git mock was worse than a leak: it replaced the WHOLE module with a
226// single export, so any module importing getRepoPath/repoExists/getTree after
227// this file ran would have received `undefined`. Spreading from the real
228// module keeps the other exports intact even during the mock window.
229const _real_git = await import("../git/repository");
230const _real_db_mod = await import("../db");
231
232// Mock the git plumbing — return success and stash the call args.
233mock.module("../git/repository", () => ({
234 ..._real_git,
235 createOrUpdateFileOnBranch: async (input: any) => {
236 gitWrites.push(input);
237 return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null };
238 },
239}));
240
241// Put the real modules back so nothing after this file inherits the stubs.
242// This only helps modules that load AFTER the restore — mock.module cannot
243// rebind namespaces an importer already captured — but wikis.tsx and friends
244// do load later, which is exactly the leak this closes.
245afterAll(() => {
246 mock.module("../git/repository", () => _real_git);
247 mock.module("../db", () => _real_db_mod);
248});
230// src/lib/voice-to-pr.ts now exposes __setVoiceDepsForTests(), matching
231// push-workflow-sync.ts / first-repo-scaffold.ts / index-existing-repo.ts.
232async function fakeWriteFile(input: any) {
233 gitWrites.push(input);
234 return { commitSha: "deadbeef", blobSha: "cafebabe", parentSha: null };
235}
249236
250237// Mock the DB layer with a tiny fluent stub. Only the call shapes the
251238// real code uses are implemented — anything else throws so we catch drift.
252mock.module("../db", () => {
239function makeFakeDb() {
253240 // We need to vary the returned row shape per select call so both
254241 // `resolveRepoAndAuthor` (which uses `{repoName, defaultBranch, ownerName}`)
255242 // and `createIssueFromVoice` (which uses `{id, name, issueCount, ownerName}`)
314301 update: () => updateStub(),
315302 },
316303 };
317});
304}
318305
319// Re-import AFTER the mocks so the lib picks them up.
320let shipAsSpec: typeof import("../lib/voice-to-pr").shipAsSpec;
321let createIssueFromVoice: typeof import("../lib/voice-to-pr").createIssueFromVoice;
306// A plain static import now — no mock ordering to dance around, because the
307// fakes go in through the module's own seam rather than the module registry.
308import {
309 shipAsSpec,
310 createIssueFromVoice,
311 __setVoiceDepsForTests,
312} from "../lib/voice-to-pr";
322313
323beforeEach(async () => {
314beforeEach(() => {
324315 gitWrites.length = 0;
325316 issueInserts.length = 0;
326 // Bun's module cache + mock.module work together: require/import returns
327 // the patched module after the call above. We re-import here so each
328 // suite sees the freshest mocks.
329 const mod = await import("../lib/voice-to-pr");
330 shipAsSpec = mod.shipAsSpec;
331 createIssueFromVoice = mod.createIssueFromVoice;
317 __setVoiceDepsForTests({
318 db: makeFakeDb().db as any,
319 writeFile: fakeWriteFile as any,
320 });
332321});
333322
334323afterEach(() => {
335 // No global cleanup needed; mocks persist across tests in the suite
336 // which is the desired behaviour for shipAsSpec/createIssueFromVoice.
324 // Scoped to this file by construction: the seam is a module-local variable,
325 // so restoring it cannot affect anything another test file imported.
326 __setVoiceDepsForTests(null);
337327});
338328
339329describe("shipAsSpec", () => {
Modifiedsrc/lib/voice-to-pr.ts+39−9View fileUnifiedSplit
3434 */
3535
3636import { and, eq } from "drizzle-orm";
37import { db } from "../db";
37import { db as realDb } from "../db";
3838import { issues, repositories, users } from "../db/schema";
39import { createOrUpdateFileOnBranch } from "../git/repository";
39import { createOrUpdateFileOnBranch as realWriteFile } from "../git/repository";
4040import { config } from "./config";
4141import { serialiseSpec } from "./spec-to-pr";
4242
109109 * Derive a stable, URL-safe slug for the voice spec filename. Capped at 40
110110 * chars so the resulting path stays short.
111111 */
112/**
113 * Injectable seams for the database and the git writer.
114 *
115 * These exist so tests do not have to `mock.module("../db")`. That call is
116 * process-global: bun swaps the module registry entry, and it does NOT rebind
117 * namespaces an importer already holds. Because bun loads every test module
118 * before running any test, a module-scope db mock in one file binds itself
119 * into unrelated modules (routes/wikis.tsx, routes/auth.tsx) before any
120 * afterAll can restore it — which is exactly how this file's own test suite
121 * silently broke `wikis — route smoke` and `auth landing pages`, in full runs
122 * only, for a long time.
123 *
124 * Same pattern as push-workflow-sync.ts, first-repo-scaffold.ts and
125 * index-existing-repo.ts. Default to the real implementations so production
126 * call sites are unchanged.
127 */
128export interface VoiceDeps {
129 db: typeof realDb;
130 writeFile: typeof realWriteFile;
131}
132
133let _deps: VoiceDeps = { db: realDb, writeFile: realWriteFile };
134
135/** Test seam. Pass null to restore the real implementations. */
136export function __setVoiceDepsForTests(deps: Partial<VoiceDeps> | null): void {
137 _deps = deps
138 ? { db: deps.db ?? realDb, writeFile: deps.writeFile ?? realWriteFile }
139 : { db: realDb, writeFile: realWriteFile };
140}
141
112142export function voiceSlug(text: string): string {
113143 const base = (text || "")
114144 .toLowerCase()
348378 | null
349379> {
350380 try {
351 const [row] = await db
381 const [row] = await _deps.db
352382 .select({
353383 repoName: repositories.name,
354384 defaultBranch: repositories.defaultBranch,
360390 .limit(1);
361391 if (!row || !row.ownerName) return null;
362392
363 const [authorRow] = await db
393 const [authorRow] = await _deps.db
364394 .select({ username: users.username, email: users.email })
365395 .from(users)
366396 .where(eq(users.id, userId))
417447 const content = serialiseSpec(fm, body);
418448 const bytes = new TextEncoder().encode(content);
419449
420 const res = await createOrUpdateFileOnBranch({
450 const res = await _deps.writeFile({
421451 owner: resolved.ownerName,
422452 name: resolved.repoName,
423453 branch: resolved.defaultBranch,
465495 }
466496 | undefined;
467497 try {
468 const [row] = await db
498 const [row] = await _deps.db
469499 .select({
470500 id: repositories.id,
471501 name: repositories.name,
489519 const body = `${interp.body_markdown || transcript}\n\n---\n\n_Captured via Gluecron voice-to-PR._`;
490520
491521 try {
492 const [issue] = await db
522 const [issue] = await _deps.db
493523 .insert(issues)
494524 .values({
495525 repositoryId: repoRow.id,
501531 if (!issue) return { ok: false, error: "issue insert returned no row" };
502532 // Best-effort counter update; mirrors src/routes/issues.tsx.
503533 try {
504 await db
534 await _deps.db
505535 .update(repositories)
506536 .set({ issueCount: (repoRow.issueCount || 0) + 1 })
507537 .where(eq(repositories.id, repoRow.id));
537567 userId: string
538568): Promise<Array<{ id: string; fullName: string }>> {
539569 try {
540 const rows = await db
570 const rows = await _deps.db
541571 .select({
542572 id: repositories.id,
543573 name: repositories.name,
544574