mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
Merge master into fix/terminal-history-scroll
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @fileoverview File viewer media teardown: closing the preview must stop the video.
|
||||
*
|
||||
* `closeFilePreview()` used to do nothing but drop the overlay's `visible`
|
||||
* class. That hides the overlay (`display: none`) and hides it ONLY: the
|
||||
* `<video>` inside carried on playing, so the audio kept going after the user
|
||||
* pressed X, with no visible player to pause. Detaching the element is not a fix
|
||||
* either — a detached HTMLMediaElement plays until it is garbage collected —
|
||||
* which is why the teardown has to pause() and unload the element explicitly.
|
||||
*
|
||||
* What is pinned here:
|
||||
* 1. close pauses AND unloads every media element (not just the first),
|
||||
* 2. close still works with no media in the body (the common text case),
|
||||
* 3. opening a NEW preview stops what the previous one was playing, since
|
||||
* overwriting innerHTML only detaches it,
|
||||
* 4. a dirty edit buffer still wins: cancelling the discard prompt must not
|
||||
* tear the buffer down.
|
||||
*
|
||||
* Loaded via `vm` against a stub app, same harness style as
|
||||
* file-browser-hidden.test.ts (no jsdom).
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
|
||||
interface FakeMedia {
|
||||
tag: 'video' | 'audio';
|
||||
paused: boolean;
|
||||
src: string | null;
|
||||
loadCalls: number;
|
||||
pause: () => void;
|
||||
removeAttribute: (name: string) => void;
|
||||
load: () => void;
|
||||
}
|
||||
|
||||
function fakeMedia(tag: 'video' | 'audio'): FakeMedia {
|
||||
const el: FakeMedia = {
|
||||
tag,
|
||||
paused: false,
|
||||
src: 'https://example.test/clip.mp4',
|
||||
loadCalls: 0,
|
||||
pause() {
|
||||
el.paused = true;
|
||||
},
|
||||
removeAttribute(name: string) {
|
||||
if (name === 'src') el.src = null;
|
||||
},
|
||||
load() {
|
||||
el.loadCalls += 1;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function loadApp(media: FakeMedia[]) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console: { ...console, warn: vi.fn() },
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
confirm: () => true,
|
||||
fetch: () => {
|
||||
throw new Error('fetch not stubbed');
|
||||
},
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const body = {
|
||||
innerHTML: '<video src="/api/sessions/s1/file-raw?path=clip.mp4" controls></video>',
|
||||
querySelectorAll: (sel: string) => {
|
||||
expect(sel).toBe('video, audio');
|
||||
return media;
|
||||
},
|
||||
};
|
||||
const overlay = {
|
||||
classes: new Set<string>(['visible']),
|
||||
classList: {
|
||||
add: (c: string) => overlay.classes.add(c),
|
||||
remove: (c: string) => overlay.classes.delete(c),
|
||||
contains: (c: string) => overlay.classes.has(c),
|
||||
},
|
||||
};
|
||||
const elements: Record<string, unknown> = { filePreviewBody: body, filePreviewOverlay: overlay };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const app = new CodemanApp() as Record<string, any>;
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.filePreviewContent = 'previous content';
|
||||
app.context = context;
|
||||
return { app, body, overlay, context };
|
||||
}
|
||||
|
||||
describe('file viewer media teardown', () => {
|
||||
let media: FakeMedia[];
|
||||
|
||||
beforeEach(() => {
|
||||
media = [fakeMedia('video')];
|
||||
});
|
||||
|
||||
it('pauses and unloads the video when the preview is closed', () => {
|
||||
const { app, overlay, body } = loadApp(media);
|
||||
|
||||
app.closeFilePreview();
|
||||
|
||||
expect(overlay.classList.contains('visible')).toBe(false);
|
||||
expect(media[0].paused).toBe(true);
|
||||
// src dropped + load() is what aborts the in-flight fetch; pause() alone
|
||||
// leaves the browser downloading the rest of the file.
|
||||
expect(media[0].src).toBeNull();
|
||||
expect(media[0].loadCalls).toBe(1);
|
||||
expect(body.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('stops every media element, not just the first', () => {
|
||||
media = [fakeMedia('video'), fakeMedia('audio')];
|
||||
const { app } = loadApp(media);
|
||||
|
||||
app.closeFilePreview();
|
||||
|
||||
expect(media.every((m) => m.paused && m.src === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('closes cleanly when the preview holds no media (the text case)', () => {
|
||||
const { app, overlay } = loadApp([]);
|
||||
|
||||
expect(() => app.closeFilePreview()).not.toThrow();
|
||||
expect(overlay.classList.contains('visible')).toBe(false);
|
||||
expect(app.filePreviewContent).toBe('');
|
||||
});
|
||||
|
||||
it('survives a media element that throws on teardown', () => {
|
||||
const hostile = fakeMedia('video');
|
||||
hostile.pause = () => {
|
||||
throw new Error('detached');
|
||||
};
|
||||
const { app, overlay } = loadApp([hostile]);
|
||||
|
||||
expect(() => app.closeFilePreview()).not.toThrow();
|
||||
expect(overlay.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops the previous video when another file is previewed', async () => {
|
||||
const { app, context } = loadApp(media);
|
||||
// openFilePreview bails right after the teardown: the fetch stub rejects and
|
||||
// the handler swallows it, which is enough to pin the teardown ordering.
|
||||
context.fetch = async () => ({ ok: false, json: async () => ({ success: false }) });
|
||||
app._resetFilePreviewEdit = () => {};
|
||||
app.$ = ((orig) => (id: string) => (id === 'filePreviewTitle' || id === 'filePreviewFooter' ? {} : orig(id)))(
|
||||
app.$
|
||||
);
|
||||
|
||||
await app.openFilePreview('other.txt', 's1');
|
||||
|
||||
expect(media[0].paused).toBe(true);
|
||||
expect(media[0].src).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the editor buffer when the discard prompt is declined', () => {
|
||||
const { app, overlay, context } = loadApp(media);
|
||||
context.confirm = () => false;
|
||||
app.filePreviewEdit = { dirty: true };
|
||||
|
||||
app.closeFilePreview();
|
||||
|
||||
expect(overlay.classList.contains('visible')).toBe(true);
|
||||
expect(media[0].paused).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @fileoverview Byte-range parsing for the raw file-serving routes.
|
||||
*
|
||||
* The file viewer's video player is only seekable when file-raw answers `Range`
|
||||
* requests with 206 (measured before the fix: `video.seekable` was `[0, 0]` and
|
||||
* `currentTime = x` silently reverted). What that correctness rests on is this
|
||||
* parser, so the cases pinned here are the ones a media element actually emits
|
||||
* plus the malformed input a browser never sends but a client can:
|
||||
*
|
||||
* - `bytes=0-` — how Chrome opens EVERY media element. Must be 206, not 200.
|
||||
* - `bytes=-N` — the SUFFIX form (last N bytes), not "from N onwards"; mp4
|
||||
* players use it to read a trailing moov atom.
|
||||
* - out of bounds -> 416, malformed -> ignored (200), which are different
|
||||
* answers for what looks like the same "bad range".
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseByteRange } from '../src/web/http-range.js';
|
||||
|
||||
describe('parseByteRange', () => {
|
||||
it('serves the full file when there is no Range header', () => {
|
||||
expect(parseByteRange(undefined, 1000)).toEqual({ kind: 'full' });
|
||||
expect(parseByteRange('', 1000)).toEqual({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('answers bytes=0- with a partial range (the form Chrome opens media with)', () => {
|
||||
expect(parseByteRange('bytes=0-', 1000)).toEqual({ kind: 'partial', start: 0, end: 999 });
|
||||
});
|
||||
|
||||
it('parses a closed range inclusive of both ends', () => {
|
||||
expect(parseByteRange('bytes=100-199', 1000)).toEqual({ kind: 'partial', start: 100, end: 199 });
|
||||
});
|
||||
|
||||
it('clamps an end past EOF instead of rejecting the range', () => {
|
||||
expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ kind: 'partial', start: 900, end: 999 });
|
||||
});
|
||||
|
||||
it('reads bytes=-N as the LAST N bytes, not as an offset', () => {
|
||||
expect(parseByteRange('bytes=-100', 1000)).toEqual({ kind: 'partial', start: 900, end: 999 });
|
||||
});
|
||||
|
||||
it('clamps a suffix longer than the file to the whole file', () => {
|
||||
expect(parseByteRange('bytes=-5000', 1000)).toEqual({ kind: 'partial', start: 0, end: 999 });
|
||||
});
|
||||
|
||||
it('accepts a single-byte range', () => {
|
||||
expect(parseByteRange('bytes=0-0', 1000)).toEqual({ kind: 'partial', start: 0, end: 0 });
|
||||
});
|
||||
|
||||
it('tolerates whitespace and a capitalised unit', () => {
|
||||
expect(parseByteRange(' BYTES = 10-20 ', 1000)).toEqual({ kind: 'partial', start: 10, end: 20 });
|
||||
});
|
||||
|
||||
it('reports a start at or past EOF as unsatisfiable (416)', () => {
|
||||
expect(parseByteRange('bytes=1000-', 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||
expect(parseByteRange('bytes=1500-1600', 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||
});
|
||||
|
||||
it('reports a zero-length suffix as unsatisfiable', () => {
|
||||
expect(parseByteRange('bytes=-0', 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||
});
|
||||
|
||||
it('reports any range against an empty file as unsatisfiable', () => {
|
||||
expect(parseByteRange('bytes=0-', 0)).toEqual({ kind: 'unsatisfiable' });
|
||||
expect(parseByteRange('bytes=-10', 0)).toEqual({ kind: 'unsatisfiable' });
|
||||
});
|
||||
|
||||
it('ignores an inverted range rather than 416-ing it (invalid spec, not unsatisfiable)', () => {
|
||||
expect(parseByteRange('bytes=500-100', 1000)).toEqual({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('ignores units it does not implement', () => {
|
||||
expect(parseByteRange('items=0-10', 1000)).toEqual({ kind: 'full' });
|
||||
expect(parseByteRange('bytes 0-10', 1000)).toEqual({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('ignores multi-range requests instead of answering only the first range', () => {
|
||||
// A multipart/byteranges body is the only correct answer to these, and no
|
||||
// media element asks for one — serving the whole file is spec-legal.
|
||||
expect(parseByteRange('bytes=0-99,200-299', 1000)).toEqual({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('ignores malformed specs', () => {
|
||||
expect(parseByteRange('bytes=', 1000)).toEqual({ kind: 'full' });
|
||||
expect(parseByteRange('bytes=-', 1000)).toEqual({ kind: 'full' });
|
||||
expect(parseByteRange('bytes=abc-def', 1000)).toEqual({ kind: 'full' });
|
||||
expect(parseByteRange('bytes=1.5-2', 1000)).toEqual({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('ignores a duplicated Range header rather than guessing which one won', () => {
|
||||
expect(parseByteRange(['bytes=0-10', 'bytes=20-30'], 1000)).toEqual({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('bounds an absurdly long offset instead of producing Infinity', () => {
|
||||
// A 100-digit first-byte-pos must not reach createReadStream as Infinity.
|
||||
const huge = '9'.repeat(100);
|
||||
expect(parseByteRange(`bytes=${huge}-`, 1000)).toEqual({ kind: 'unsatisfiable' });
|
||||
const range = parseByteRange(`bytes=0-${huge}`, 1000);
|
||||
expect(range).toEqual({ kind: 'partial', start: 0, end: 999 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @fileoverview Range-request coverage for the raw file-serving routes.
|
||||
*
|
||||
* The file viewer points a `<video>` at `GET /api/sessions/:id/file-raw`. That
|
||||
* route used to read the whole file and answer 200 with no `Accept-Ranges`,
|
||||
* which makes a browser treat the media as unseekable: measured against an 18MB
|
||||
* mp4, `video.seekable` was `[0, 0]` and assigning `currentTime` was reverted on
|
||||
* the next tick, so the scrub bar looked dead.
|
||||
*
|
||||
* These tests pin the wire contract that makes seeking work, since none of it is
|
||||
* visible from a plain 200-vs-404 assertion:
|
||||
* 1. `Accept-Ranges: bytes` on the un-ranged response (what tells the browser
|
||||
* it MAY seek at all),
|
||||
* 2. 206 + `Content-Range` + the sliced body for a range request,
|
||||
* 3. the slice actually coming from a bounded read, not a full-file read that
|
||||
* is then truncated,
|
||||
* 4. 416 (with `Content-Range: bytes */size`) for a range past EOF, rather
|
||||
* than a silent full-body 200 the media element cannot interpret.
|
||||
*
|
||||
* Uses app.inject() — no real ports.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
const FILE_BYTES = Buffer.from('0123456789ABCDEFGHIJ'); // 20 bytes, index == value position
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => Buffer.from('unused')),
|
||||
stat: vi.fn(async () => ({ size: 20, isFile: () => true, isDirectory: () => false, mtimeMs: 1 })),
|
||||
readdir: vi.fn(async () => []),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
// Honour start/end so a test can tell a real bounded read from a full read.
|
||||
createReadStream: vi.fn((_path: string, opts?: { start?: number; end?: number }) => {
|
||||
const start = opts?.start ?? 0;
|
||||
const end = opts?.end ?? FILE_BYTES.length - 1;
|
||||
return Readable.from([FILE_BYTES.subarray(start, end + 1)]);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/file-stream-manager.js', () => ({
|
||||
fileStreamManager: {
|
||||
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
|
||||
closeStream: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream, realpathSync } from 'node:fs';
|
||||
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||
const mockedCreateReadStream = vi.mocked(createReadStream);
|
||||
|
||||
describe('file-raw range requests', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let sid: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
vi.clearAllMocks();
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
mockedStat.mockResolvedValue({ size: FILE_BYTES.length, isFile: () => true } as never);
|
||||
mockedCreateReadStream.mockImplementation(
|
||||
(_path: unknown, opts?: unknown) =>
|
||||
Readable.from([
|
||||
FILE_BYTES.subarray(
|
||||
(opts as { start?: number })?.start ?? 0,
|
||||
((opts as { end?: number })?.end ?? FILE_BYTES.length - 1) + 1
|
||||
),
|
||||
]) as never
|
||||
);
|
||||
sid = harness.ctx._sessionId as string;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const rawUrl = (name = 'clip.mp4') => `/api/sessions/${sid}/file-raw?path=${name}`;
|
||||
|
||||
it('advertises Accept-Ranges on an un-ranged response, so the browser knows it may seek', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: rawUrl() });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['accept-ranges']).toBe('bytes');
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
expect(res.headers['content-length']).toBe(String(FILE_BYTES.length));
|
||||
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
|
||||
});
|
||||
|
||||
it('answers bytes=0- with 206 (Chrome opens every media element this way)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl(),
|
||||
headers: { range: 'bytes=0-' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe(`bytes 0-19/${FILE_BYTES.length}`);
|
||||
expect(res.headers['content-length']).toBe(String(FILE_BYTES.length));
|
||||
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves a mid-file slice from a bounded read', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl(),
|
||||
headers: { range: 'bytes=5-9' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 5-9/20');
|
||||
expect(res.headers['content-length']).toBe('5');
|
||||
expect(res.rawPayload.toString()).toBe('56789');
|
||||
// The read itself must be bounded: a full read that is sliced afterwards
|
||||
// would still pull an 18MB video into memory on every seek.
|
||||
expect(mockedCreateReadStream).toHaveBeenCalledWith(expect.any(String), { start: 5, end: 9 });
|
||||
});
|
||||
|
||||
it('serves a suffix range as the LAST N bytes', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl(),
|
||||
headers: { range: 'bytes=-4' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-range']).toBe('bytes 16-19/20');
|
||||
expect(res.rawPayload.toString()).toBe('GHIJ');
|
||||
});
|
||||
|
||||
it('answers a range past EOF with 416 instead of a full-body 200', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl(),
|
||||
headers: { range: 'bytes=100-200' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(416);
|
||||
expect(res.headers['content-range']).toBe('bytes */20');
|
||||
expect(JSON.parse(res.body).success).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a malformed range and serves the whole file', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl(),
|
||||
headers: { range: 'bytes=abc-def' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.rawPayload.equals(FILE_BYTES)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the security headers on a partial response', async () => {
|
||||
// 206 bodies go out through reply.hijack(), which bypasses Fastify's own
|
||||
// header write — the nosniff/type headers have to be carried across by hand.
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl(),
|
||||
headers: { range: 'bytes=0-3' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-type']).toBe('video/mp4');
|
||||
});
|
||||
|
||||
it('supports resuming a download (?download=true) as well as inline playback', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `${rawUrl('clip.mp4')}&download=true`,
|
||||
headers: { range: 'bytes=10-14' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(206);
|
||||
expect(res.headers['content-disposition']).toContain('attachment; filename="clip.mp4"');
|
||||
expect(res.rawPayload.toString()).toBe('ABCDE');
|
||||
});
|
||||
|
||||
it('still refuses files past the raw size cap before looking at Range', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: rawUrl('huge.mp4'),
|
||||
headers: { range: 'bytes=0-99' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { ApiErrorCode } from '../../src/types.js';
|
||||
@@ -19,12 +20,15 @@ vi.mock('node:fs/promises', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock realpathSync for symlink resolution
|
||||
// Mock realpathSync for symlink resolution, plus createReadStream: file-raw
|
||||
// STREAMS its body (range support), so an unmocked read would hit the real
|
||||
// filesystem and fail with ENOENT rather than serving the fixture bytes.
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
createReadStream: vi.fn(() => Readable.from([Buffer.from('fake file bytes')])),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,13 +41,14 @@ vi.mock('../../src/file-stream-manager.js', () => ({
|
||||
}));
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { createReadStream, realpathSync } from 'node:fs';
|
||||
import { fileStreamManager } from '../../src/file-stream-manager.js';
|
||||
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||
const mockedCreateReadStream = vi.mocked(createReadStream);
|
||||
const mockedFileStreamManager = vi.mocked(fileStreamManager);
|
||||
|
||||
describe('file-routes', () => {
|
||||
@@ -55,6 +60,7 @@ describe('file-routes', () => {
|
||||
|
||||
// Default: realpathSync returns the path unchanged
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
mockedCreateReadStream.mockImplementation(() => Readable.from([Buffer.from('fake file bytes')]) as never);
|
||||
// Default stat
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||
mockedReadFile.mockImplementation(async (path) =>
|
||||
@@ -739,7 +745,7 @@ describe('file-routes', () => {
|
||||
|
||||
it('serves raw file with correct content type', async () => {
|
||||
const content = Buffer.from('fake png data');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
@@ -752,7 +758,7 @@ describe('file-routes', () => {
|
||||
|
||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
|
||||
@@ -77,25 +77,47 @@ describe('lineage line geometry', () => {
|
||||
expect(first.d).not.toBe(second.d);
|
||||
});
|
||||
|
||||
it('switches to a vertical bezier when the strip has wrapped to two rows', () => {
|
||||
it('keeps bending at strip-wide spans instead of flattening into a straight line', () => {
|
||||
const helper = loadLineageHelper();
|
||||
// A worker the agent skill starts is appended to the END of the strip, so this
|
||||
// is the span the feature is actually used at. The first shipped clamp (44px)
|
||||
// turned it into a flat thread across the terminal.
|
||||
const wide = helper.computePath({ parent: tab(0), child: tab(1300), strip: { ...STRIP, width: 1500 } })!;
|
||||
const near = helper.computePath({ parent: tab(0), child: tab(140), strip: STRIP })!;
|
||||
|
||||
const wideDip = controlYs(wide.d)[0] - 34;
|
||||
const nearDip = controlYs(near.d)[0] - 34;
|
||||
expect(wideDip).toBeGreaterThan(nearDip * 2);
|
||||
expect(wideDip).toBeGreaterThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('brackets a wrapped pair BELOW the lower row rather than inside the row gap', () => {
|
||||
const helper = loadLineageHelper();
|
||||
// The reported bug: with the desktop strip wrapped, a parent on row 1 (bottom 34)
|
||||
// and its child on row 2 (top 48) are 14px apart, and a parent-bottom → child-TOP
|
||||
// bezier had 14px to bend in, so it drew a flat line hidden in the gap, three
|
||||
// siblings overprinting each other. Both ends now anchor on the tab BOTTOM and the
|
||||
// curve hangs below the LOWER row, the same bracket the flat strip gets.
|
||||
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
||||
const geom = helper.computePath({ parent: tab(0, 4), child: tab(200, 48), strip })!;
|
||||
|
||||
expect(geom.sameRow).toBe(false);
|
||||
// Parent bottom (34) → child top (48): the arc travels between rows.
|
||||
expect(geom.d.startsWith('M 60 34')).toBe(true);
|
||||
expect(geom.endY).toBe(48);
|
||||
expect(geom.d.startsWith('M 60 34')).toBe(true); // parent BOTTOM
|
||||
expect(geom.endY).toBe(78); // child BOTTOM, not its top
|
||||
// Every control point clears the lower row by at least the minimum dip.
|
||||
for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX);
|
||||
});
|
||||
|
||||
it('draws upward when the child sits on the row ABOVE its parent', () => {
|
||||
it('draws the same bracket when the child sits on the row ABOVE its parent', () => {
|
||||
const helper = loadLineageHelper();
|
||||
const strip: Rect = { left: 0, top: 0, width: 1200, height: 90 };
|
||||
const geom = helper.computePath({ parent: tab(0, 48), child: tab(200, 4), strip })!;
|
||||
|
||||
expect(geom.sameRow).toBe(false);
|
||||
expect(geom.d.startsWith('M 60 48')).toBe(true); // parent TOP edge
|
||||
expect(geom.endY).toBe(34); // child bottom edge
|
||||
expect(geom.d.startsWith('M 60 78')).toBe(true); // parent BOTTOM
|
||||
expect(geom.endY).toBe(34); // child BOTTOM
|
||||
// The parent's row is the lower one here, so that is what the curve clears.
|
||||
for (const y of controlYs(geom.d)) expect(y).toBeGreaterThanOrEqual(78 + helper.DIP_MIN_PX);
|
||||
});
|
||||
|
||||
it('skips an edge whose tab is scrolled out of the strip', () => {
|
||||
|
||||
Reference in New Issue
Block a user