2026-08-02 16:22:55 +03:00
|
|
|
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;
|
|
|
|
|
|
2026-08-03 01:27:27 +03:00
|
|
|
let lastArgs;
|
|
|
|
|
|
2026-08-02 16:22:55 +03:00
|
|
|
const service = {
|
2026-08-03 01:27:27 +03:00
|
|
|
async getWalkthrough(args) {
|
|
|
|
|
lastArgs = args;
|
2026-08-02 16:22:55 +03:00
|
|
|
return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) };
|
|
|
|
|
},
|
2026-08-03 01:27:27 +03:00
|
|
|
async generateWalkthrough(args) {
|
|
|
|
|
lastArgs = args;
|
2026-08-02 16:22:55 +03:00
|
|
|
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;
|
2026-08-03 01:27:27 +03:00
|
|
|
lastArgs = undefined;
|
2026-08-02 16:22:55 +03:00
|
|
|
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 () => {
|
|
|
|
|
await new Promise((resolve) => server.close(resolve));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('answers a generation request that nobody interrupted', async () => {
|
|
|
|
|
const pending = generate();
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
|
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();
|
|
|
|
|
generate(controller.signal).catch(() => {});
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
|
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 reattached = generate();
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-03 01:27:27 +03:00
|
|
|
// 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 pending = fetch(`${base}/api/walkthrough/generate`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ directory: '/repo', source: SOURCE, language: 'ja' }),
|
|
|
|
|
});
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-02 16:22:55 +03:00
|
|
|
it('cancels through its own endpoint rather than a dropped connection', async () => {
|
|
|
|
|
generate().catch(() => {});
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|