mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge master into fix/terminal-history-scroll
This commit is contained in:
@@ -204,7 +204,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting; the dip is also clamped at 104px rather than 44, since a skill worker lands at the END of the strip where the old cap flattened the arc into a straight thread. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||
|
||||
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -84,7 +84,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Resolved, not trusted** (`resolveParentSessionId()`, route-helpers.ts): exact id first, then a UNIQUE prefix of ≥8 chars (ids reach agents truncated — mux names and a Docker export's `$CODEMAN_SESSION_ID` both carry 8), and an ambiguous prefix resolves to NOTHING rather than to a guess. The parent must be a live session the caller can already see (`canAccessOwned`) AND carry the same owner as the session being created, so a multi-user caller cannot staple their session under someone else's tab. ⚠️ **Everything unresolvable is DROPPED, never a 400**: a stale id from a cached skill preamble must cost a decorative line, not a worker. ⚠️ It is decoration at every layer — never an ownership, permission or lifecycle signal; a child outlives its parent, and the Session ctor refuses a self-parent (reachable only via recovery, where both values come off disk). It rides `toState()` into `session_created` / `session_updated`, so there is **no new SSE event**, and `server.ts`'s recovery path restores it so lineage survives a restart.
|
||||
|
||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and same-row pairs get a shallow U-bridge HANGING BELOW the strip (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting), while a wrapped strip (`tabs-two-rows`/`tabs-auto-wrap`) falls back to the vertical bezier.
|
||||
**Rendering is an additional LAYER, not a second pass** (`session-lineage.js`, loadorder 15.6): `_updateConnectionLinesImmediate()` (subagent-windows.js) calls `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode's two layers, so all of them share ONE batched read→write reflow and the same `tab:<id>` rect cache. Geometry is pure and unit-tested in `computeLineagePath()` (constants.js): both endpoints live in one horizontal strip, so the subagent shape (tab-bottom → window-top) has nothing to aim at, and every pair gets a U-bridge HANGING BELOW the strip, anchored on both tabs' BOTTOM edges (dip scales with distance, plus a per-sibling step so several children of one parent nest instead of overprinting, plus the row offset when the strip has wrapped). ⚠️ **A wrapped strip used to get its own shape, and that shape was the bug** (fixed 2026-08-14): `tabs-two-rows`/`tabs-auto-wrap` put a parent on row 1 ~14px above its child on row 2, so the old parent-bottom → child-TOP bezier had 14px to bend in and drew a flat line inside the row gap, siblings overprinting. Hanging the control points below the LOWER row gives the wrapped case the same bracket as the flat one and deletes the branch. The same pass raised the dip clamp (44 → 104, 0.06 → 0.085/px) because a skill worker is appended to the END of the strip, where the old cap flattened an 800-1500px span into a straight thread across the terminal, and traded weight for a second, wider glow (2 → 2.5px, `4 4` → `5 5` dashes at `-20`, opacity .55 → .72 / .95 working) because the original styling vanished into terminal text at 1:1.
|
||||
|
||||
⚠️ **Desktop only, for a z-index reason**: the overlay is `z-index: 999` and the desktop header is 100, so arcs paint OVER it — which is exactly what lets them touch tab bottoms. Under 1024px mobile.css makes the header `position: fixed; z-index: 1200` and would bury them, and the phone strip is a scroller where both endpoints are rarely on screen at once. Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is the phase-2 option, and needs a real check against the mobile overview and the drawer.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,17 +93,37 @@ The path math itself lives in `constants.js` as a pure
|
||||
### 4.2 Geometry
|
||||
|
||||
Both endpoints are tabs in one horizontal strip, so the subagent shape (tab-bottom →
|
||||
window-top) does not apply. Two cases:
|
||||
window-top) does not apply. **One case**, a **U-bridge hanging below the strip** that
|
||||
touches both tabs on their bottom edge:
|
||||
|
||||
- **Same row** (the normal case): a shallow **U-bridge hanging below the strip**.
|
||||
`y0 = max(parent.bottom, child.bottom)`, dip
|
||||
`d = clamp(14 + |x2 - x1| * 0.06, 16, 44) + depth * 6`, path
|
||||
`M x1 y0 C x1 y0+d, x2 y0+d, x2 y0`. `depth` is the child's index among its
|
||||
siblings, so several children of one parent **nest** instead of overprinting.
|
||||
- **Different rows** (`tabs-two-rows` / `tabs-auto-wrap` on desktop): the existing
|
||||
vertical bezier from parent-bottom-center to child-top-center.
|
||||
```
|
||||
y0 = max(parent.bottom, child.bottom)
|
||||
d = clamp(14 + |x2 - x1| * 0.085, 22, 104) + depth * 8 + |child.bottom - parent.bottom|
|
||||
path: M x1 parent.bottom C x1 y0+d, x2 y0+d, x2 child.bottom
|
||||
```
|
||||
|
||||
A small `<circle r="3">` at the child end marks direction (an SVG `marker` would need a
|
||||
`depth` is the child's index among its siblings, so several children of one parent
|
||||
**nest** instead of overprinting.
|
||||
|
||||
> **Superseded (2026-08-14): the two shapes this section used to specify.** The dip was
|
||||
> `clamp(14 + span * 0.06, 16, 44) + depth * 6`, and a wrapped strip
|
||||
> (`tabs-two-rows` / `tabs-auto-wrap`) got its own parent-bottom → child-**top** bezier.
|
||||
> Both were tuned against two tabs side by side and failed at the distances the feature
|
||||
> is used at:
|
||||
>
|
||||
> - a skill worker is appended to the **end** of the strip, so the real span is
|
||||
> 800-1500px, where a 44px cap is a 33px sag, i.e. a line that reads as straight and
|
||||
> crosses the terminal instead of bracketing under the strip;
|
||||
> - and when the strip wraps, parent-bottom (34) to child-top (48) leaves **14px** to
|
||||
> bend in, so the arc was a flat line hidden in the row gap, with siblings drawn on
|
||||
> top of each other. Reported as *"they connect already, but the lines are straight
|
||||
> and not easy visible"*.
|
||||
>
|
||||
> Anchoring both ends at the tab bottoms and hanging the control points below the
|
||||
> **lower** row gives the wrapped case the same bracket as the flat one, and removes the
|
||||
> branch. Pinned by `test/session-lineage-lines.test.ts`.
|
||||
|
||||
A small `<circle r="3.5">` at the child end marks direction (it breathes to 4.5 while that worker is busy) (an SVG `marker` would need a
|
||||
`<defs>` block and fights `stroke-dasharray`).
|
||||
|
||||
Each path gets `class="connection-line lineage-line"`, `data-parent-tab`,
|
||||
@@ -139,8 +159,12 @@ callers are cheap. Needed:
|
||||
### 4.5 Styling
|
||||
|
||||
`.connection-line.lineage-line`: violet stroke from a `--lineage-line` token,
|
||||
`stroke-width: 2`, `dasharray 4 4`, `opacity: .55`, softer glow than the subagent lines
|
||||
so the two layers read as different things. Trap to respect: the skin block nests under
|
||||
`stroke-width: 2.5`, `dasharray 5 5`, `opacity: .72` (`.95` while the child works),
|
||||
softer than the subagent lines so the two layers read as different things, but the
|
||||
contrast comes from a **second, wider glow** rather than more weight, because the first
|
||||
cut (2px / `4 4` / `.55` / one 5px glow) disappeared into terminal text on a real 1080p
|
||||
desktop. `lineage-flow` marches by two dash cycles, so it moves with the dash array
|
||||
(`5 5` → `-20`). Trap to respect: the skin block nests under
|
||||
`html:not([data-skin="og"])`, so a bare `.lineage-line` rule inside it would outrank the
|
||||
base rule at higher specificity. **Define the color as a token per skin, keep exactly
|
||||
one `.lineage-line` rule.** Light skins get a darker stroke.
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
+45
-35
@@ -201,24 +201,43 @@ function computeTabScrollLeft(input) {
|
||||
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||||
//
|
||||
// Two shapes, because both endpoints live in ONE horizontal strip and the subagent
|
||||
// shape (tab-bottom → window-top) has nothing to aim at:
|
||||
// - same row: a shallow U-bridge HANGING BELOW the strip, so it reads as a
|
||||
// bracket joining two tabs rather than as a line crossing them. The dip grows
|
||||
// with horizontal distance and with `depth` (the child's index among its
|
||||
// siblings), so several children of one parent nest instead of overprinting.
|
||||
// - different rows (desktop `tabs-two-rows` / `tabs-auto-wrap`): the vertical
|
||||
// bezier the subagent lines already use, parent edge → child edge.
|
||||
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
|
||||
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
||||
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
||||
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
||||
// horizontal distance and with `depth` (the child's index among its siblings), so
|
||||
// several children of one parent nest instead of overprinting.
|
||||
//
|
||||
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
||||
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
||||
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
||||
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
||||
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
||||
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
||||
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
||||
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
||||
// row once, and comes back up under the child. Same formula, no branch.
|
||||
//
|
||||
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
|
||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and the first shipped numbers were tuned
|
||||
// against two tabs sitting side by side. A worker the agent skill starts is appended
|
||||
// to the END of the strip, so the real span between a lead and its worker is 800-1500px,
|
||||
// not 200, and a 44px cap over 1300px of span is a 33px sag, i.e. a line that reads as
|
||||
// STRAIGHT and crosses the terminal instead of bracketing under the strip. The dip now
|
||||
// keeps growing with the span (0.085/px, ~3x steeper against the old cap) so the bracket
|
||||
// survives the distance the feature is actually used at. The ceiling is what keeps a
|
||||
// full-width pair out of the terminal's fourth line: 104 + the sibling step lands the
|
||||
// deepest sag around y=140 on a 1080 screen, the same proportion two adjacent tabs get.
|
||||
const LINEAGE_DIP_BASE_PX = 14;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
const LINEAGE_DIP_MIN_PX = 16;
|
||||
const LINEAGE_DIP_MAX_PX = 44;
|
||||
const LINEAGE_SIBLING_STEP_PX = 6;
|
||||
const LINEAGE_DIP_PER_PX = 0.085;
|
||||
const LINEAGE_DIP_MIN_PX = 22;
|
||||
const LINEAGE_DIP_MAX_PX = 104;
|
||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
|
||||
function computeLineagePath(input) {
|
||||
@@ -250,29 +269,20 @@ function computeLineagePath(input) {
|
||||
const cBottom = cTop + ch;
|
||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||
|
||||
let d;
|
||||
let endX;
|
||||
let endY;
|
||||
if (sameRow) {
|
||||
const y0 = Math.max(pBottom, cBottom);
|
||||
const span = Math.abs(cx - px);
|
||||
const dip =
|
||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||
depth * LINEAGE_SIBLING_STEP_PX;
|
||||
const yc = y0 + dip;
|
||||
d = `M ${r1(px)} ${r1(y0)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(y0)}`;
|
||||
endX = cx;
|
||||
endY = y0;
|
||||
} else {
|
||||
const childBelow = cTop + ch / 2 > pTop + ph / 2;
|
||||
const y1 = childBelow ? pBottom : pTop;
|
||||
const y2 = childBelow ? cTop : cBottom;
|
||||
const mid = (y1 + y2) / 2;
|
||||
d = `M ${r1(px)} ${r1(y1)} C ${r1(px)} ${r1(mid)}, ${r1(cx)} ${r1(mid)}, ${r1(cx)} ${r1(y2)}`;
|
||||
endX = cx;
|
||||
endY = y2;
|
||||
}
|
||||
return { d, endX, endY, sameRow };
|
||||
// Both ends anchor on the tab BOTTOM, and the control points hang below whichever
|
||||
// row is lower, so one formula covers a flat strip and a wrapped one.
|
||||
const span = Math.abs(cx - px);
|
||||
const rowDrop = Math.abs(cBottom - pBottom);
|
||||
// ⚠ A wrapped pair needs the dip measured from the LOWER row, or the bracket would
|
||||
// only reach the row gap again. Adding the row offset also keeps the curve clear of
|
||||
// the row it crosses instead of grazing its bottom edge.
|
||||
const dip =
|
||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||
depth * LINEAGE_SIBLING_STEP_PX +
|
||||
rowDrop;
|
||||
const yc = Math.max(pBottom, cBottom) + dip;
|
||||
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||
return { d, endX: cx, endY: cBottom, sameRow };
|
||||
}
|
||||
|
||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||
|
||||
@@ -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)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -148,7 +148,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(geom.endX));
|
||||
dot.setAttribute('cy', String(geom.endY));
|
||||
dot.setAttribute('r', '3');
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working);
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
svg.appendChild(dot);
|
||||
|
||||
+32
-13
@@ -9334,31 +9334,46 @@ kbd {
|
||||
light skins included). Do not add a per-skin `.lineage-line` override inside the
|
||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to (0,2,1)
|
||||
and would outrank this one from a surprising place. */
|
||||
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
|
||||
with a single 5px glow, which reads on a design mock and disappears on a real
|
||||
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
|
||||
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
|
||||
separate, and the second, wider glow is what buys the contrast instead: it lifts
|
||||
the line off the terminal without thickening it. Dashes scale with the stroke
|
||||
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||
exactly two dash cycles, so it has to move with them. */
|
||||
.connection-line.lineage-line {
|
||||
stroke: var(--session-purple, #a98fe0);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 4 4;
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.55;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 5px var(--session-purple, #a98fe0));
|
||||
opacity: 0.72;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--session-purple, #a98fe0))
|
||||
drop-shadow(0 0 11px var(--session-purple, #a98fe0));
|
||||
}
|
||||
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
the line exists to signal, and pairing the brightness with the marching dashes
|
||||
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
|
||||
.connection-line.lineage-line--working {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line:hover {
|
||||
opacity: 0.9;
|
||||
stroke-width: 2.5;
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.lineage-line-dot {
|
||||
fill: var(--session-purple, #a98fe0);
|
||||
opacity: 0.7;
|
||||
filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0));
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 4px var(--session-purple, #a98fe0)) drop-shadow(0 0 9px var(--session-purple, #a98fe0));
|
||||
}
|
||||
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.connection-line.lineage-line--working {
|
||||
opacity: 0.85;
|
||||
animation: lineage-flow 1.1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -9368,20 +9383,24 @@ kbd {
|
||||
}
|
||||
}
|
||||
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
|
||||
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
|
||||
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
|
||||
iteration. */
|
||||
@keyframes lineage-flow {
|
||||
to {
|
||||
stroke-dashoffset: -16;
|
||||
stroke-dashoffset: -20;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lineage-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.6;
|
||||
r: 3;
|
||||
opacity: 0.75;
|
||||
r: 3.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: 4;
|
||||
r: 4.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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