Skip to content

Commit abfcf70

Browse files
authored
Merge pull request #75 from lossless-group/hygene/test-coverage
test(test-coverage): the corpora-builder harmony suite — 43 tests across ten groups, and the bugs they caught
2 parents 755e6d5 + 13d77df commit abfcf70

47 files changed

Lines changed: 4322 additions & 137 deletions

Some content is hidden

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

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,35 @@ pnpm preview # Preview the production build
151151

152152
The `scripts/dev.sh` script prints the full URL list on start.
153153

154+
## Testing
155+
156+
Run the whole suite — every package plus the id-didi-sh identity contract — with one command:
157+
158+
```bash
159+
pnpm test:all
160+
```
161+
162+
That runs `scripts/test-all.sh`: each vitest package in turn, the end-to-end backend-chain integration, and (if `mix` is present) id-didi-sh's ExUnit suite in the sibling repo — ending with a grand-total tally (e.g. `✅ ALL SUITES PASSED — 67 tests across 7 suites`).
163+
164+
Of that total, **43 tests across ten groups** are the corpora-builder coverage added in this effort — tracked in human language in [`context-v/specs/Corpora-Builder-Harmony-Test-Registry.md`](context-v/specs/Corpora-Builder-Harmony-Test-Registry.md), where each test's name is the ✓-phrase you see go green. The remainder is id-didi-sh's pre-existing suite, which `mix test` runs alongside this effort's Group A.
165+
166+
Run a single group directly with `pnpm test` in its package:
167+
168+
| Group | Command (`cd` then `pnpm test`) | What it covers |
169+
|---|---|---|
170+
| C | `packages/workspace` | client transport resilience (the property test that caught the reconnect bug) |
171+
| B, D, H | `services/workspace` | session tenancy, workspace registry, chat corpora slab |
172+
| E, J | `services/record-surrealdb-resolver` | canonical CRUD + the alignment-audit diff |
173+
| F | `services/content-ingest` | corpus markdown files |
174+
| G | `apps/strategy-curator` | curator surface state (Svelte 5 runes) |
175+
| I | `e2e` | full backend chain over a real WebSocket |
176+
177+
Notes:
178+
179+
- **Nothing touches the shared cloud.** Canonical-layer tests spin a throwaway in-memory SurrealDB; temp dirs stand in for the clients volume. The only thing that reads the real cloud is the read-only **corpora-alignment audit** (`node scripts/audit-corpora-alignment.mjs`) — it flags DB↔disk drift and changes nothing; it's not part of `test:all`'s pass/fail.
180+
- **The E2E group (I)** stands up a disposable backend (throwaway Docker NATS + in-memory SurrealDB + the resolver, workspace-service, and content-ingest) and tears it down after — so it needs **Docker running** and the `surreal` CLI. It uses ports `3199`/`4223`; if an interrupted run leaves anything behind, clear it with `docker rm -f augment-e2e-nats` and `pkill -f "tsx src/server.ts"`.
181+
- **No `ws` package** — the transport tests run against a hand-rolled RFC-6455 server and every client uses the platform-native `WebSocket`.
182+
154183
## Deployment
155184

156185
The humain-vc single-tenant instance runs live on **Railway** at

apps/strategy-curator/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"dev": "rsbuild dev",
88
"build": "rsbuild build",
99
"preview": "rsbuild preview",
10-
"check": "svelte-check --tsconfig ./tsconfig.json"
10+
"check": "svelte-check --tsconfig ./tsconfig.json",
11+
"test": "vitest run"
1112
},
1213
"dependencies": {
1314
"@augment-it/theme": "workspace:*",
@@ -19,7 +20,10 @@
1920
"@module-federation/rsbuild-plugin": "^2.6.0",
2021
"@rsbuild/core": "^2.1.2",
2122
"@rsbuild/plugin-svelte": "^2.0.0",
23+
"@sveltejs/vite-plugin-svelte": "^5.1.1",
24+
"jsdom": "^30.0.1",
2225
"svelte-check": "^4.7.1",
23-
"typescript": "^6.0.3"
26+
"typescript": "^6.0.3",
27+
"vitest": "^3.2.7"
2428
}
2529
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Group G — Curator surface state.
2+
// Registry: context-v/specs/Corpora-Builder-Harmony-Test-Registry.md
3+
//
4+
// Test names are the registry's ✓-phrases, verbatim. The curator is a
5+
// Svelte 5 runes singleton; tests run it under the svelte plugin + jsdom
6+
// with @augment-it/workspace mocked, and reset the singleton's state per
7+
// test. This is the client half of the humain-vc "wrong domain type" bug.
8+
9+
import { beforeEach, describe, expect, test, vi } from 'vitest';
10+
11+
// A mutable stand-in for the workspace singleton — tests reconfigure its
12+
// fields and its invoke spy per case.
13+
const ws = {
14+
workspaces: [] as Array<{ client_id: string; default_domain_type: string; display_name: string; has_env: boolean; org_id: string | null }>,
15+
active_client_id: null as string | null,
16+
connect: vi.fn(),
17+
loadWorkspaces: vi.fn(async () => {}),
18+
activateWorkspace: vi.fn(async (_id: string) => {}),
19+
invoke: vi.fn(async (_cap: string, _args: unknown) => ({}) as unknown),
20+
};
21+
22+
vi.mock('@augment-it/workspace', () => ({
23+
workspace: ws,
24+
WORKSPACE_CHANGED_EVENT: 'workspace-changed',
25+
}));
26+
27+
// Imported once; the constructor registers the cross-remote window listener.
28+
const { curation } = await import('../src/curation.svelte.ts');
29+
30+
const wsSummary = (client_id: string, default_domain_type: string) => ({
31+
client_id,
32+
default_domain_type,
33+
display_name: client_id,
34+
has_env: true,
35+
org_id: null,
36+
});
37+
38+
/** Route invoke by capability so select()'s domain.assemble + tag.suggest
39+
* don't blow up while a test focuses on domain.list. */
40+
const routedInvoke = (domains: Array<{ slug: string; title: string; tags: string[] }>) =>
41+
vi.fn(async (cap: string) => {
42+
if (cap === 'domain.list') return { domains };
43+
if (cap === 'domain.assemble') return { sources: [] };
44+
if (cap === 'tag.suggest') return { tags: [] };
45+
return {};
46+
});
47+
48+
const settle = () => new Promise((r) => setTimeout(r, 25));
49+
50+
beforeEach(() => {
51+
curation.clientSlug = null;
52+
curation.domainType = 'strategy';
53+
curation.strategies = [];
54+
curation.activeSlug = null;
55+
curation.sources = [];
56+
curation.lastError = null;
57+
ws.workspaces = [];
58+
ws.active_client_id = null;
59+
ws.invoke = vi.fn(async () => ({ domains: [] }));
60+
ws.activateWorkspace = vi.fn(async () => {});
61+
localStorage.clear();
62+
});
63+
64+
describe('Group G — curator surface state', () => {
65+
test('bootstrap resolves the active workspace’s default domain type before the first domain.list fires', async () => {
66+
ws.active_client_id = 'humain-vc';
67+
ws.workspaces = [wsSummary('humain-vc', 'thesis')];
68+
const invoke = vi.fn(async () => ({ domains: [] }));
69+
ws.invoke = invoke;
70+
71+
await curation.bootstrap();
72+
73+
// The humain-vc bug in one assertion: the FIRST query must ask for
74+
// thesis, not the flat 'strategy' fallback.
75+
expect(curation.domainType).toBe('thesis');
76+
expect(invoke).toHaveBeenCalledWith('domain.list', { type: 'thesis', client_slug: 'humain-vc' });
77+
});
78+
79+
test('switching workspaces resets the list, the active corpus, and the domain type to the new workspace’s default', async () => {
80+
// Start in humain-vc/thesis with loaded state.
81+
curation.clientSlug = 'humain-vc';
82+
curation.domainType = 'thesis';
83+
curation.strategies = [{ slug: 'x', title: 'X', tags: [] }] as never;
84+
curation.sources = [{}] as never;
85+
curation.activeSlug = 'x';
86+
ws.workspaces = [wsSummary('reach-edu', 'strategy')];
87+
ws.invoke = routedInvoke([]);
88+
// The real package dispatches WORKSPACE_CHANGED_EVENT on activate.
89+
ws.activateWorkspace = vi.fn(async (id: string) => {
90+
window.dispatchEvent(new CustomEvent('workspace-changed', { detail: { client_id: id } }));
91+
});
92+
93+
await curation.switchWorkspace('reach-edu');
94+
await settle();
95+
96+
expect(curation.clientSlug).toBe('reach-edu');
97+
expect(curation.domainType).toBe('strategy'); // reach-edu's default, not humain-vc's thesis
98+
expect(curation.activeSlug).toBeNull();
99+
expect(curation.sources).toEqual([]);
100+
});
101+
102+
test('a workspace change broadcast from another remote re-scopes this surface too', async () => {
103+
curation.clientSlug = 'humain-vc';
104+
curation.domainType = 'thesis';
105+
ws.workspaces = [wsSummary('reach-edu', 'strategy')];
106+
ws.invoke = routedInvoke([]);
107+
108+
// No switchWorkspace call here — a sibling remote flipped the workspace
109+
// and only the cross-remote event bridges to us.
110+
window.dispatchEvent(new CustomEvent('workspace-changed', { detail: { client_id: 'reach-edu' } }));
111+
await settle();
112+
113+
expect(curation.clientSlug).toBe('reach-edu');
114+
expect(curation.domainType).toBe('strategy');
115+
});
116+
117+
test('a handler error reply surfaces as a visible error, never as an empty rail', async () => {
118+
curation.clientSlug = 'humain-vc';
119+
curation.domainType = 'thesis';
120+
ws.invoke = vi.fn(async () => ({ ok: false, error: 'resolver down' }));
121+
122+
await curation.loadStrategies();
123+
124+
// "broken" is distinguishable from "empty": empty rail AND a set error.
125+
expect(curation.strategies).toEqual([]);
126+
expect(curation.lastError).toMatch(/domain\.list/);
127+
expect(curation.lastError).toMatch(/resolver down/);
128+
});
129+
130+
test('a saved corpus selection is restored only if it exists in the freshly loaded list', async () => {
131+
curation.clientSlug = 'humain-vc';
132+
curation.domainType = 'thesis';
133+
134+
// Saved slug IS in the loaded list → restored.
135+
localStorage.setItem('augment-it:active-strategy', 'consumer-immunology');
136+
ws.invoke = routedInvoke([{ slug: 'consumer-immunology', title: 'Consumer Immunology', tags: [] }]);
137+
await curation.loadStrategies();
138+
await settle();
139+
expect(curation.activeSlug).toBe('consumer-immunology');
140+
141+
// Saved slug is stale (not in the new list) → NOT restored.
142+
curation.activeSlug = null;
143+
localStorage.setItem('augment-it:active-strategy', 'ghost-from-another-workspace');
144+
ws.invoke = routedInvoke([{ slug: 'consumer-immunology', title: 'Consumer Immunology', tags: [] }]);
145+
await curation.loadStrategies();
146+
await settle();
147+
expect(curation.activeSlug).toBeNull();
148+
});
149+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vitest/config';
2+
import { svelte } from '@sveltejs/vite-plugin-svelte';
3+
4+
export default defineConfig({
5+
plugins: [svelte({ compilerOptions: { runes: true } })],
6+
test: { include: ['test/**/*.test.ts'], environment: 'jsdom', testTimeout: 10_000 },
7+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
---
2+
title: "Test coverage begins — and catches a real reconnect bug on day one"
3+
lede: "augment-it gets its first automated tests: Vitest wired, nine tests green across the transport, the workspace registry, and didi's corpora slab. The headline property test — 'no invoke silently vanishes' — failed first exactly as the registry predicted, and the failure was real: the client reconnect chain died on any refused connection, a live mechanism behind the zombie-session and lost-corpus symptoms. Found by the test, fixed in the transport, now guarded forever."
4+
date_created: 2026-07-30
5+
date_modified: 2026-07-30
6+
publish: true
7+
authors:
8+
- Michael Staton
9+
augmented_with:
10+
- Claude Code on Claude Fable 5
11+
- Claude Code on Claude Opus 4.8
12+
files_changed:
13+
- packages/workspace/src/transport.ts
14+
- packages/workspace/test/transport.test.ts
15+
- packages/workspace/test/workspace-socket-test-server.ts
16+
- packages/workspace/vitest.config.ts
17+
- services/workspace/test/workspaces.test.ts
18+
- services/workspace/test/chat-corpora-slab.test.ts
19+
- services/workspace/src/chat.ts
20+
- context-v/specs/Corpora-Builder-Harmony-Test-Registry.md
21+
---
22+
23+
# Test coverage begins — and catches a real reconnect bug on day one
24+
25+
## Why Care?
26+
27+
For the entire life of augment-it, agents have written and rewritten this
28+
codebase with **zero automated tests** guarding it — verification was
29+
typechecks, builds, hand-run prove-scripts, and the operator walking the
30+
surface. That debt came due this month: humain-vc's corpora failing to
31+
load, corpus creations that never landed, a rail that renders "empty"
32+
indistinguishably from "broken." We couldn't tell a bug from an empty
33+
workspace because nothing asserted the difference.
34+
35+
This is the first automated test coverage in the repo. And it earned its
36+
keep immediately: the very first property test we wrote failed on its
37+
first run — as the registry spec explicitly predicted it would — and the
38+
failure was not a test artifact. It was a **real reconnect bug in the
39+
client transport** that plausibly underlies the zombie-session and
40+
"invokes never reach the workspace" symptoms the operator has been
41+
hitting. The test found it; the fix closes it; the test now guards it.
42+
43+
## The bug the test caught
44+
45+
The client transport (`packages/workspace/src/transport.ts`) reconnects
46+
to the workspace-service after a dropped socket. But it only scheduled
47+
that reconnect from the WebSocket **`close`** handler. It turns out that
48+
when a connection is *refused* — the exact situation during a
49+
workspace-service restart or redeploy, when the browser's reconnect
50+
attempt hits a port with nothing listening — Node's native WebSocket
51+
(and, per spec, browsers too) fires **only `error`, never `close`**.
52+
53+
So the reconnect chain died on the first failed attempt. A surface that
54+
lost its socket during a backend restart would never reconnect on its
55+
own; it stayed wedged until a full page reload. Invokes fired into that
56+
window hung to their 120-second deadline and then blamed the server —
57+
precisely the "the workspace did not reply" mystery. This is a strong
58+
candidate for one of the mechanisms behind
59+
[[Search-And-Add-Invokes-Never-Reach-The-Workspace]] and the
60+
zombie-session class.
61+
62+
The fix: an `error` on a socket that never opened now runs the same
63+
reconnect tail the `close` handler does, guarded by a per-attempt flag so
64+
a browser that fires *both* `error` and `close` still schedules exactly
65+
one reconnect. Established-socket drops, auth-death (4401/4403), and
66+
deliberate `close()` all keep their existing behavior untouched.
67+
68+
Visible proof it works: the property test's whole suite dropped from
69+
2.1s to 0.9s after the fix, because the invoke now reconnects promptly
70+
instead of hanging to its deadline.
71+
72+
## What landed
73+
74+
### The harness
75+
76+
Vitest is wired as the repo's test runner (unit + service tiers;
77+
Playwright for the future E2E tier per the plan). `pnpm test` runs the
78+
repo's turbo `test` task; each package runs `vitest run`. No new runtime
79+
dependencies — the only additions are the test library itself. In
80+
particular, the transport tests needed a WebSocket server to connect to,
81+
and rather than pull the `ws` package back in (deliberately removed from
82+
this repo), the harness includes a small hand-rolled RFC-6455 endpoint
83+
over `node:http` — zero dependencies, mirroring the real
84+
workspace-service invoke/claim contract.
85+
86+
### Group C — transport resilience (4 tests, `packages/workspace`)
87+
88+
The registry's ✓-phrases, now green: no invoke silently vanishes across
89+
every socket-churn scenario (the one that caught the bug); a pre-open
90+
invoke is delivered exactly once after open; close 4401 fails all pending
91+
work immediately with "session expired"; and after auth-death the
92+
transport tries one silent refresh-reconnect, then waits — no storm. A
93+
timing seam was added to `transport.ts` so minutes-scale deadlines and
94+
backoff are assertable in milliseconds (production defaults unchanged).
95+
96+
### Group D — workspace registry (3 tests, `services/workspace`)
97+
98+
The `default_domain_type` resolution that suspect 1 hangs on —
99+
humain-vc's `thesis` vs the `strategy` fallback — plus `workspace.json`
100+
org_id winning over the `WORKSPACE_ORG_MAP` env fallback, and a
101+
.env-less workspace still listing with `has_env: false`. Each test builds
102+
a throwaway `clients/` root on disk and loads the module fresh, because
103+
the env map parses once at import.
104+
105+
### Group H — didi's corpora slab (2 tests, `services/workspace`)
106+
107+
The "Existing corpora" slab didi reads before resolving a corpus name:
108+
it lists every domain in the workspace regardless of type (the
109+
load-bearing difference from the curator rail's typed query), and it
110+
degrades to an empty slab — never a failed turn — when the resolver
111+
hiccups. Capability dispatch and NATS are mocked, so the contract is
112+
pinned without a live bus.
113+
114+
## Where this sits
115+
116+
Nine tests, three of the registry's ten MECE groups. Groups A (identity
117+
contract, ExUnit in id-didi-sh), B (session tenancy), E (canonical CRUD),
118+
F (corpus files), G (curator state), I (end-to-end), and J (the
119+
DB↔disk↔UI alignment audit) remain proposed in the registry, written as
120+
the ✓-phrases we want to see turn green next. The registry spec
121+
([[Corpora-Builder-Harmony-Test-Registry]]) tracks every one, Proposed or
122+
Implemented, with its purpose and location.
123+
124+
## See also
125+
126+
- [[Corpora-Builder-Harmony-Test-Registry]] — the living test registry
127+
- [[Test-Coverage-Harness-And-Regression-Floor]] — the harness plan
128+
- [[Troubleshooting-Workspace-DB-State-Alignment]] — the live bug hunt this serves
129+
- [[Search-And-Add-Invokes-Never-Reach-The-Workspace]] — the open issue this fix likely closes
130+
- [[Session-Expiry-Turns-The-App-Into-A-Zombie]] — the zombie class the reconnect bug feeds

0 commit comments

Comments
 (0)