mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #284 from Ark0N/fix/file-viewer-video
fix(file-viewer): make previewed video seekable and stop it on close
This commit is contained in:
@@ -234,6 +234,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md`
|
||||
|
||||
**Raw file bodies are streamed and range-aware**: `file-raw` and the attachments `/raw` route always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416). ⚠️ A 200-only response is what made the File Viewer's `<video>` unseekable: Chrome then reports `video.seekable` as `[0, 0]`, the scrub bar is inert and `currentTime = x` silently reverts (measured on an 18MB mp4), and Safari refuses to start the media at all. ⚠️ These bodies go out through `reply.hijack()`, which bypasses Fastify's status handling — `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships labelled `200` and the browser treats a slice as the whole file. ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause.
|
||||
|
||||
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
|
||||
|
||||
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones a public repo into the caller's case space synchronously (request held open, bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` reports whether the URL can be cloned anonymously plus its real branches/tags. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: `ext::sh -c <cmd>` (and ANY `<name>::<payload>` helper) makes git run a command, so every `::` form is refused, a leading `-` is refused, and every spawn is an argv array with `--` before the operands. ⚠️ **Non-interactive or the open request hangs** — `gitNonInteractiveEnv()` closes the terminal/askpass/ssh/GCM prompt paths; `HOME`/`PATH` stay inherited, so a user's OWN credential helper may authenticate (Codeman still never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ Timeout kills the process GROUP (clone fans out into child processes), the destination is removed only if this attempt created it, and repository contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo-shipped `.claude/settings*` reported as a warning since its hooks run locally). The **Brain** picker sets the toolbar run mode on success. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
|
||||
|
||||
@@ -312,7 +312,7 @@ TOCTOU window.
|
||||
| Route | Cap | Notes |
|
||||
|-------|-----|-------|
|
||||
| `file-content` | 10 MB | text preview |
|
||||
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses** |
|
||||
| `file-raw` | 50 MB | inline MIME map; **`X-Content-Type-Options: nosniff` on all responses**; streamed, `Range`-aware (206 slices come from the same validated path, and the cap is checked before the range) |
|
||||
| `POST /api/download` | 50 MB | forced `attachment`; sensitive‑path blocklist |
|
||||
|
||||
### SVG / content‑type XSS
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @fileoverview Pure HTTP byte-range parsing for the raw file-serving routes.
|
||||
*
|
||||
* Why this exists: a `<video>`/`<audio>` element is only seekable when the
|
||||
* server advertises `Accept-Ranges: bytes` and answers `Range` requests with
|
||||
* `206 Partial Content`. Serving the whole file with `200 OK` (what file-raw
|
||||
* did) makes Chrome report `video.seekable === [0, 0]`, so the scrub bar is
|
||||
* inert and `currentTime = x` is silently ignored; Safari refuses to start the
|
||||
* media at all. Parsing lives here, away from the IO, so the edge cases
|
||||
* (suffix ranges, open-ended ranges, oversized specs, empty files) are unit
|
||||
* testable without touching the filesystem.
|
||||
*
|
||||
* Deliberately single-range only: multi-range responses require a
|
||||
* `multipart/byteranges` body that no media element asks for, and RFC 9110
|
||||
* §14.2 lets a server ignore a Range it does not want to honor and answer with
|
||||
* the full representation. Same for syntactically invalid specs — those are
|
||||
* ignored (200), while a syntactically valid but out-of-bounds spec is the one
|
||||
* case that earns a 416.
|
||||
*/
|
||||
|
||||
/** Result of parsing a `Range` header against a known representation size. */
|
||||
export type ByteRangeRequest =
|
||||
/** No range, an unsupported unit, or a malformed spec — serve the whole file with 200. */
|
||||
| { kind: 'full' }
|
||||
/** A satisfiable single range, inclusive on both ends — serve 206. */
|
||||
| { kind: 'partial'; start: number; end: number }
|
||||
/** Syntactically valid but outside the representation — serve 416. */
|
||||
| { kind: 'unsatisfiable' };
|
||||
|
||||
const BYTES_RANGE_SPEC = /^(\d*)-(\d*)$/;
|
||||
|
||||
/**
|
||||
* Digits → number, bounded. A range spec is arbitrary client input, so a
|
||||
* 100-digit first-byte-pos must not become `Infinity` (which would then flow
|
||||
* into a `createReadStream` offset). Anything longer than a safe integer is
|
||||
* clamped, which the callers then treat as "past the end of the file".
|
||||
*/
|
||||
function parseBoundedInt(digits: string): number {
|
||||
return digits.length > 15 ? Number.MAX_SAFE_INTEGER : Number(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `Range` request header against a file of `size` bytes.
|
||||
*
|
||||
* @param header - Raw header value (`req.headers.range`). Arrays (a duplicated
|
||||
* header) are ignored rather than guessed at.
|
||||
* @param size - Size of the full representation in bytes.
|
||||
*/
|
||||
export function parseByteRange(header: string | string[] | undefined, size: number): ByteRangeRequest {
|
||||
if (typeof header !== 'string') return { kind: 'full' };
|
||||
|
||||
const trimmed = header.trim();
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0 || trimmed.slice(0, eq).trim().toLowerCase() !== 'bytes') return { kind: 'full' };
|
||||
|
||||
const spec = trimmed.slice(eq + 1).trim();
|
||||
// Multi-range requests would need a multipart/byteranges body; ignoring the
|
||||
// header and serving the full representation is a valid answer.
|
||||
if (!spec || spec.includes(',')) return { kind: 'full' };
|
||||
|
||||
const match = BYTES_RANGE_SPEC.exec(spec);
|
||||
if (!match) return { kind: 'full' };
|
||||
const [, rawStart, rawEnd] = match;
|
||||
if (!rawStart && !rawEnd) return { kind: 'full' };
|
||||
|
||||
// Suffix range: `bytes=-N` means the LAST N bytes, not "from N to the end".
|
||||
if (!rawStart) {
|
||||
const suffix = parseBoundedInt(rawEnd);
|
||||
if (suffix === 0 || size === 0) return { kind: 'unsatisfiable' };
|
||||
return { kind: 'partial', start: Math.max(0, size - suffix), end: size - 1 };
|
||||
}
|
||||
|
||||
const start = parseBoundedInt(rawStart);
|
||||
if (size === 0 || start >= size) return { kind: 'unsatisfiable' };
|
||||
|
||||
// `bytes=N-` — from N to the end of the file. This is the form Chrome opens
|
||||
// a media element with (`bytes=0-`), so it must answer 206, not 200.
|
||||
if (!rawEnd) return { kind: 'partial', start, end: size - 1 };
|
||||
|
||||
const requestedEnd = parseBoundedInt(rawEnd);
|
||||
// last-byte-pos < first-byte-pos is an invalid spec, not an unsatisfiable
|
||||
// one: RFC 9110 §14.1.1 says the whole header field is then ignored.
|
||||
if (requestedEnd < start) return { kind: 'full' };
|
||||
|
||||
return { kind: 'partial', start, end: Math.min(requestedEnd, size - 1) };
|
||||
}
|
||||
@@ -3246,6 +3246,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Edit mode: reset any prior editor state whenever a preview (re)loads.
|
||||
this._resetFilePreviewEdit();
|
||||
// Stop whatever the previous preview was playing. Overwriting innerHTML
|
||||
// only DETACHES a <video>/<audio>; a detached media element keeps playing.
|
||||
this._stopFilePreviewMedia();
|
||||
|
||||
// Show overlay with loading state
|
||||
overlay.classList.add('visible');
|
||||
@@ -3330,10 +3333,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'video') {
|
||||
bodyEl.innerHTML = `<video src="${data.url}" controls autoplay></video>`;
|
||||
// playsinline: iOS otherwise hijacks playback into its fullscreen
|
||||
// player, which leaves the overlay behind it and its own close button
|
||||
// as the only way back.
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${data.url}" controls autoplay></audio>`;
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||
@@ -3366,9 +3372,36 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
// The overlay is hidden with display:none, which stops it being PAINTED and
|
||||
// nothing else: a <video>/<audio> inside it keeps playing, keeps its audio
|
||||
// audible and keeps streaming from the server. Closing has to stop it.
|
||||
this._stopFilePreviewMedia();
|
||||
this.filePreviewContent = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* Pause and unload every media element in the preview body, then empty it.
|
||||
*
|
||||
* Removing the element from the DOM is NOT enough — a detached HTMLMediaElement
|
||||
* plays on until it is garbage collected, which is why the X button used to
|
||||
* leave a video audible. pause() stops playback, dropping src + load() aborts
|
||||
* the in-flight network fetch and puts the element back in NETWORK_EMPTY.
|
||||
*/
|
||||
_stopFilePreviewMedia() {
|
||||
const bodyEl = this.$('filePreviewBody');
|
||||
if (!bodyEl) return;
|
||||
for (const media of bodyEl.querySelectorAll('video, audio')) {
|
||||
try {
|
||||
media.pause();
|
||||
media.removeAttribute('src');
|
||||
media.load();
|
||||
} catch (err) {
|
||||
console.warn('Failed to stop preview media:', err);
|
||||
}
|
||||
}
|
||||
bodyEl.innerHTML = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { parseByteRange } from '../http-range.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
@@ -86,7 +87,13 @@ function buildContentDisposition(disposition: 'inline' | 'attachment', fileName:
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
const headers = reply.getHeaders();
|
||||
// hijack() answers on reply.raw, which keeps Fastify's own status handling out
|
||||
// of the picture — so a 206 set with reply.code() has to be carried across by
|
||||
// hand or a partial body would go out labelled 200 and the browser would treat
|
||||
// it as the whole file.
|
||||
const statusCode = reply.statusCode;
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = statusCode;
|
||||
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value !== undefined) {
|
||||
@@ -106,12 +113,54 @@ function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
content.pipe(reply.raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file body, honoring a `Range` request header.
|
||||
*
|
||||
* Callers set Content-Type/Content-Disposition first; this adds the
|
||||
* range-related headers and the body. Range support is what makes the file
|
||||
* viewer's `<video>`/`<audio>` seekable: with a plain 200 and no
|
||||
* `Accept-Ranges`, Chrome reports `video.seekable` as `[0, 0]`, the scrub bar
|
||||
* does nothing and `currentTime = x` is silently reverted (measured against an
|
||||
* 18MB mp4 before this existed). It also stops each seek from re-reading the
|
||||
* whole file into memory.
|
||||
*/
|
||||
function sendFileBody(
|
||||
reply: FastifyReply,
|
||||
resolvedPath: string,
|
||||
size: number,
|
||||
rangeHeader: string | string[] | undefined
|
||||
): void {
|
||||
reply.header('Accept-Ranges', 'bytes');
|
||||
const range = parseByteRange(rangeHeader, size);
|
||||
|
||||
if (range.kind === 'unsatisfiable') {
|
||||
reply
|
||||
.code(416)
|
||||
.header('Content-Range', `bytes */${size}`)
|
||||
.type('application/json; charset=utf-8')
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Requested range not satisfiable'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (range.kind === 'partial') {
|
||||
reply.code(206);
|
||||
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`);
|
||||
reply.header('Content-Length', range.end - range.start + 1);
|
||||
sendRawStream(reply, createReadStream(resolvedPath, { start: range.start, end: range.end }));
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Length', size);
|
||||
sendRawStream(reply, createReadStream(resolvedPath));
|
||||
}
|
||||
|
||||
async function serveRawFile(
|
||||
reply: FastifyReply,
|
||||
resolvedPath: string,
|
||||
fileName: string,
|
||||
extension: string,
|
||||
download?: boolean
|
||||
download?: boolean,
|
||||
rangeHeader?: string | string[]
|
||||
): Promise<void> {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download
|
||||
@@ -126,24 +175,21 @@ async function serveRawFile(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const content = createReadStream(resolvedPath);
|
||||
if (download || extension === 'svg') {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
sendFileBody(reply, resolvedPath, stat.size, rangeHeader);
|
||||
}
|
||||
|
||||
function getAttachmentOr404(
|
||||
@@ -849,7 +895,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, resolvedPath, fileName, extension);
|
||||
await serveRawFile(reply, resolvedPath, fileName, extension, false, req.headers.range);
|
||||
});
|
||||
|
||||
// File tree listing
|
||||
@@ -1369,24 +1415,24 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
json: 'application/json',
|
||||
};
|
||||
|
||||
const content = await fs.readFile(resolvedPath);
|
||||
const rawBasename = filePath!.split('/').pop() || 'download';
|
||||
// Sanitize filename for Content-Disposition header (prevent header injection)
|
||||
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
|
||||
if (download === 'true' || ext === 'svg') {
|
||||
reply.raw.writeHead(200, {
|
||||
...inheritedHeaders(reply),
|
||||
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${basename}"`,
|
||||
'Content-Length': content.length,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
reply.raw.end(content);
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${basename}"`);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
return;
|
||||
}
|
||||
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(content);
|
||||
// Streamed, range-aware: this is the <video>/<audio> source the file
|
||||
// viewer points at, and a 200-only response makes the media unseekable.
|
||||
sendFileBody(reply, resolvedPath, stat.size, req.headers.range);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
@@ -1503,7 +1549,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
if (!servePath) return;
|
||||
|
||||
try {
|
||||
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true');
|
||||
await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true', req.headers.range);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user