Both failures showed up as "Test timed out in 30000ms" followed by "Hook timed out in 30000ms" on loaded PR runners, in files nobody had touched. relay host-client: the scripted client dialed its WebSocket before awaiting its WebCrypto setup and only then attached the open/message listeners. On a loaded runner the loopback socket opened while key generation was still queued on the threadpool, the open event found no listener, no hello was sent, and the client waited forever. The socket is now dialed after the key material is ready, in the same tick as the listeners. The fake relay also terminates leftover sockets on stop so a failure is reported once, not twice. walkthrough routes: each test slept 20 ms and assumed the request had reached the route by then. The tests now wait until the service has been asked to generate one more time than before the request, and afterEach closes idle keep-alive connections so server.close() cannot hang on a response a failed test never received. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
166 lines
5.6 KiB
JavaScript
166 lines
5.6 KiB
JavaScript
import express from 'express';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
import { registerWalkthroughRoutes } from './routes.js';
|
|
|
|
// These run over real HTTP on purpose. The bug this file exists for was
|
|
// invisible to unit tests: the service and the store were both correct, and the
|
|
// response was dropped by a disconnect check that misread a healthy request.
|
|
|
|
const SOURCE = { kind: 'working-tree', scope: 'all' };
|
|
|
|
describe('walkthrough routes', () => {
|
|
let server;
|
|
let base;
|
|
let releaseJob;
|
|
let job;
|
|
|
|
let lastArgs;
|
|
let generateCalls = 0;
|
|
|
|
const service = {
|
|
async getWalkthrough(args) {
|
|
lastArgs = args;
|
|
return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) };
|
|
},
|
|
async generateWalkthrough(args) {
|
|
lastArgs = args;
|
|
generateCalls += 1;
|
|
if (job) return job;
|
|
job = new Promise((resolve) => {
|
|
releaseJob = () => resolve({ walkthrough: { title: 'DONE' }, hunks: [], hunkCount: 1 });
|
|
}).finally(() => { job = null; });
|
|
return job;
|
|
},
|
|
async cancelWalkthroughGeneration() {
|
|
return { cancelled: Boolean(job) };
|
|
},
|
|
};
|
|
|
|
const generate = (signal) => fetch(`${base}/api/walkthrough/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ directory: '/repo', source: SOURCE }),
|
|
signal,
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
job = null;
|
|
releaseJob = undefined;
|
|
lastArgs = undefined;
|
|
const app = express();
|
|
app.use(express.json());
|
|
registerWalkthroughRoutes(app, { getWalkthroughService: async () => service });
|
|
server = app.listen(0);
|
|
await new Promise((resolve) => server.once('listening', resolve));
|
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// A response a failed test never received keeps its keep-alive socket
|
|
// open, and server.close() would wait on it until the hook timeout.
|
|
server.closeAllConnections();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
});
|
|
|
|
// Resolves once the route has asked the service to generate one more time
|
|
// than `seen`. A fixed sleep assumed the request had arrived by then; on a
|
|
// loaded runner it had not, and the step that followed acted on a request
|
|
// the server had not seen yet.
|
|
const untilGenerateCalled = async (seen) => {
|
|
for (let attempt = 0; attempt < 300 && generateCalls <= seen; attempt += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
if (generateCalls <= seen) throw new Error('the route never asked the service to generate');
|
|
};
|
|
|
|
it('answers a generation request that nobody interrupted', async () => {
|
|
const seen = generateCalls;
|
|
const pending = generate();
|
|
await untilGenerateCalled(seen);
|
|
releaseJob();
|
|
|
|
const body = await (await pending).json();
|
|
|
|
expect(body.walkthrough).toEqual({ title: 'DONE' });
|
|
});
|
|
|
|
it('delivers the result to a client that reconnected after a refresh', async () => {
|
|
const controller = new AbortController();
|
|
const seen = generateCalls;
|
|
generate(controller.signal).catch(() => {});
|
|
await untilGenerateCalled(seen);
|
|
controller.abort();
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
// The reloaded page sees work in progress and re-attaches to it.
|
|
const read = await (await fetch(
|
|
`${base}/api/walkthrough?directory=/repo&source=${encodeURIComponent(JSON.stringify(SOURCE))}`,
|
|
)).json();
|
|
expect(read.generating).toBe(true);
|
|
|
|
const seenBeforeReattach = generateCalls;
|
|
const reattached = generate();
|
|
await untilGenerateCalled(seenBeforeReattach);
|
|
releaseJob();
|
|
|
|
const body = await (await reattached).json();
|
|
expect(body.walkthrough).toEqual({ title: 'DONE' });
|
|
});
|
|
|
|
it('rejects a request without a directory before touching the service', async () => {
|
|
const response = await fetch(`${base}/api/walkthrough/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ source: SOURCE }),
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(job).toBeNull();
|
|
});
|
|
|
|
// The language belongs to the request, not to a setting, so both the read
|
|
// and the generation have to carry it: readiness is computed from a prompt
|
|
// that contains the language instruction.
|
|
it('carries the requested language into the service', async () => {
|
|
await fetch(
|
|
`${base}/api/walkthrough?directory=/repo&language=uk&source=${encodeURIComponent(JSON.stringify(SOURCE))}`,
|
|
);
|
|
expect(lastArgs.language).toBe('uk');
|
|
|
|
const seen = generateCalls;
|
|
const pending = fetch(`${base}/api/walkthrough/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ directory: '/repo', source: SOURCE, language: 'ja' }),
|
|
});
|
|
await untilGenerateCalled(seen);
|
|
releaseJob();
|
|
await pending;
|
|
|
|
expect(lastArgs.language).toBe('ja');
|
|
});
|
|
|
|
it('ignores a language that is not a string', async () => {
|
|
await fetch(
|
|
`${base}/api/walkthrough?directory=/repo&language[]=uk&source=${encodeURIComponent(JSON.stringify(SOURCE))}`,
|
|
);
|
|
|
|
expect(lastArgs.language).toBeUndefined();
|
|
});
|
|
|
|
it('cancels through its own endpoint rather than a dropped connection', async () => {
|
|
const seen = generateCalls;
|
|
generate().catch(() => {});
|
|
await untilGenerateCalled(seen);
|
|
|
|
const response = await fetch(`${base}/api/walkthrough/cancel`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ directory: '/repo', source: SOURCE }),
|
|
});
|
|
|
|
expect(await response.json()).toEqual({ cancelled: true });
|
|
releaseJob();
|
|
});
|
|
});
|