Files
Codeman/test/file-preview-media.test.ts
T
Codeman maintainer d68cba9432 fix(file-viewer): make previewed video seekable and stop it on close
Two bugs in the File Viewer's media player, both reproduced in a real
browser against an 18MB mp4 before and after the fix.

1. Closing the preview left the video playing. closeFilePreview() only
   dropped the overlay's `visible` class, which is display:none and
   nothing else, so the audio kept going with no visible player to pause.
   Detaching the element is not a fix either: a detached HTMLMediaElement
   plays on until it is garbage collected. _stopFilePreviewMedia() now
   pauses, drops src and load()s every media element (also on re-open,
   where overwriting innerHTML had the same effect), which additionally
   aborts the in-flight download.

2. The scrub bar was inert. file-raw read the whole file and answered
   200 with no Accept-Ranges, so Chrome reported video.seekable as
   [0, 0] and silently reverted `currentTime = x`; Safari refuses to
   start such media at all. Raw bodies are now streamed and range-aware:
   Accept-Ranges: bytes on every response, 206 + Content-Range for a
   Range request, 416 for one past EOF, and a malformed spec ignored
   (200) per RFC 9110. Parsing is pure in src/web/http-range.ts.

Measured on tmp/codeman-crt-v5-66s.mp4 (18MB, 66.6s):
  before  seekable [0, 0]     seek to 56.6s reverted to 3.9s   close: still playing
  after   seekable [0, 66.56] seek to 56.6s landed at 60.2s    close: paused, NETWORK_EMPTY

Range slices are byte-identical to `dd`, the full-file path is
byte-identical to the file, and the SVG octet-stream/attachment
hardening and the 50MB cap are unchanged (the cap is still checked
before the range).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 00:14:21 +02:00

179 lines
6.0 KiB
TypeScript

/**
* @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);
});
});