mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
179 lines
6.0 KiB
TypeScript
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);
|
|
});
|
|
});
|