fix(ui): resolve absolute slicePath to allowlist root + wire ProgressCard from missions

Forward-fix #2 on slice 06 storytelling-primitives (after 70001d1)
addressing redo3-guard-3 narrow advisory's second-round findings.

Finding 1 (BLOCKING — path-construction bug):
The prior forward-fix at 70001d1 wired useSliceTimelineMarkdown to
"workspace" root with detail.slicePath as the relative path. But
SliceDetail.slicePath is an ABSOLUTE filesystem path from the
daemon, and /api/files/read rejects absolute paths and requires
a path RELATIVE TO ONE OF THE REGISTERED ALLOWLIST ROOTS. Real
slice timelines degraded to unavailable=true; only the prop-only
TimelineTab test fixture exercised the rendering path. The
component+hook unit tests crossed the same boundary in test that
the production wire crossed in app, but neither proved the call
shape sent to /api/files/read was the relative form the daemon
expects.

Fix:
- useSliceTimelineMarkdown(absoluteSlicePath) now resolves the
  absolute path against useFilesRoots() using the same
  deepest-prefix-wins pattern as useMissionDiscovery
  (isPathPrefix + relativeUnder, exact match preferred). Exports
  resolveSlicePathToAllowlist for the pure-function test surface.
- Returns a `resolved: { rootName, relPath } | null` diagnostic
  field so integration tests can assert the production call
  shape.
- ScopePages call site simplifies to
  useSliceTimelineMarkdown(detail.slicePath ?? null) — the hook
  owns root resolution + relPath construction internally.
- When no allowlist root contains the absolute slice path, the
  hook short-circuits before issuing the read (no spurious
  /api/files/read call with the absolute path).

Integration tests:
- packages/ui/test/use-slice-timeline-markdown.test.tsx — 7 tests
  covering: deepest-prefix root resolution (4 pure-function
  cases); absolute → relative conversion + /api/files/read call
  shape assertion (path query param is NOT absolute); no-root
  short-circuit; /api/files/read 404 graceful unavailable. The
  shape-assertion test (URL.searchParams("path") !== absolute)
  is the proof guard-3 asked for — it would have failed on the
  prior 70001d1 state and passes after the fix.

Finding 2 — ProgressCard production wire:
The prior wire routed missions through IncidentCard with
status=info; ProgressCard was test-only. Per IMPL-PRD §6,
ProgressCard's data source is the existing PROGRESS.md
frontmatter + checkbox count under workspace.root/missions/.

Fix:
- Feed.tsx now calls useMissionDiscovery() alongside the existing
  useSlices("all") query. The first 2 missions route to
  ProgressCard with mission.name as missionId, the mission
  filesystem path in the oneLiner, an "Open mission for live
  status" nextStep, and percent=0. The legacy slice routing
  continues to dispatch to ShippedCard / IncidentCard for the
  next 3 slice rows.
- Percent computation (PROGRESS.md checkbox count → percent)
  defers to a follow-up slice. ProgressCard mounts in production
  and surfaces the bar with percent=0; once the indexer ships
  per-mission progress percent, the wire updates without any
  call-site change.
- Adapter logic extracted to a pure function
  buildStorytellingFeedItems(missions, slices) → FeedCardItem[]
  exported from storytelling-cards.tsx. Feed.tsx calls it inside
  useMemo. The adapter is the testable surface for routing rules
  without mounting the full Feed.tsx component.

Adapter tests:
- packages/ui/test/storytelling-feed-adapter.test.ts — 9 tests
  covering: mission → ProgressCard routing (Finding 2 proof);
  cap-at-2-missions; shipped/complete/done → ShippedCard;
  blocked → IncidentCard warning; failed/danger → IncidentCard
  danger; everything else → IncidentCard info; composition
  ordering (missions first); empty-list; null/undefined defense.

Verification:
- npx tsc --noEmit on packages/ui/tsconfig.json: CLEAN
- npx vitest run packages/ui suite: 857 PASS / 1 skipped / 0
  failures. Storytelling-focused suite: 66/66.
- SC-29 scope unchanged — UI-only forward-fix; no daemon edits,
  no migrations, no new endpoints, no new event types.

Pattern lesson recorded for the slice walk packet: TDD'ing the
hook + component in isolation is necessary but not sufficient
when the integration crosses a runtime boundary (here client →
daemon API allowlist). The 7 path-conversion tests now exercise
the request URL shape through to /api/files/read, closing the
integration-test gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 17:00:30 -07:00
1 parent 70001d11b5
commit 9a0ff004b8
6 files changed
+458 -70

No files matched your search

@@ -392,6 +392,60 @@ export type FeedCardItem =
| { kind: "approval"; source: ApprovalCardSource }
| { kind: "concept"; source: ConceptCardSource };
/** 0.3.1 slice 06 forward-fix #2 — pure adapter that converts the
* daemon-driven mission + slice rows into FeedCardItem[]. Exported
* for unit-test surface: callers can verify the production wire's
* routing (missions → ProgressCard; shipped slices → ShippedCard;
* everything else → IncidentCard with derived status) without
* mounting the full Feed.tsx surface. */
export interface AdapterMissionRow {
name: string;
path: string;
}
export interface AdapterSliceRow {
name: string;
displayName?: string;
status?: string | null;
lastActivityAt?: string | null;
}
export function buildStorytellingFeedItems(
missions: AdapterMissionRow[],
slices: AdapterSliceRow[],
): FeedCardItem[] {
const items: FeedCardItem[] = [];
for (const mission of (missions ?? []).slice(0, 2)) {
items.push({
kind: "progress",
source: {
missionId: mission.name,
title: mission.name,
oneLiner: `Mission at ${mission.path}`,
nextStep: `Open mission for live status + active slices.`,
percent: 0,
},
});
}
for (const slice of (slices ?? []).slice(0, 3)) {
const oneLiner = slice.lastActivityAt
? `Last activity ${slice.lastActivityAt}`
: `Slice in ${slice.status ?? "unknown"} state`;
const title = slice.displayName || slice.name;
const sliceId = slice.name;
const status = (slice.status ?? "").toLowerCase();
if (status === "shipped" || status === "complete" || status === "done") {
items.push({ kind: "shipped", source: { sliceId, title, oneLiner } });
} else if (status === "blocked" || status === "danger" || status === "failed") {
items.push({
kind: "incident",
source: { sliceId, title, oneLiner, status: status === "blocked" ? "warning" : "danger" },
});
} else {
items.push({ kind: "incident", source: { sliceId, title, oneLiner, status: "info" } });
}
}
return items;
}
export function StorytellingFeed({ items }: { items: FeedCardItem[] }) {
if (items.length === 0) {
return (
+25 -38
View File
@@ -37,8 +37,10 @@ import { FeedCard } from "./FeedCard.js";
import type { FeedActionOutcome, FeedProofPreview } from "./FeedCard.js";
import {
StorytellingFeed,
buildStorytellingFeedItems,
type FeedCardItem as StorytellingFeedItem,
} from "../feed/cards/storytelling-cards.js";
import { useMissionDiscovery } from "../../hooks/useMissionDiscovery.js";
import {
useMissionControlAudit,
type AuditEntry,
@@ -255,44 +257,29 @@ export function Feed() {
}, [cards, queueItems.itemsById, sliceRows]);
const proofSlices = useSliceDetails(proofSliceNames);
// 0.3.1 slice 06 — adapt the daemon-driven sliceRows into the new
// storytelling card primitives so they render in the live For You
// surface. Pulls the most recent 3 slices and routes by status:
// shipped status → ShippedCard, blocked/in-progress → IncidentCard,
// anything else with progress shape → ProgressCard. The legacy
// FeedCard list below remains the primary surface; this section is
// the storytelling-primitives preview band at the top of the feed.
const storytellingItems = useMemo<StorytellingFeedItem[]>(() => {
const items: StorytellingFeedItem[] = [];
const safeRows = Array.isArray(sliceRows) ? sliceRows : [];
for (const slice of safeRows.slice(0, 3)) {
const oneLiner = slice.lastActivityAt
? `Last activity ${slice.lastActivityAt}`
: `Slice in ${slice.status ?? "unknown"} state`;
const title = slice.displayName || slice.name;
const sliceId = slice.name;
const status = (slice.status ?? "").toLowerCase();
if (status === "shipped" || status === "complete" || status === "done") {
items.push({ kind: "shipped", source: { sliceId, title, oneLiner } });
} else if (status === "blocked" || status === "danger" || status === "failed") {
items.push({
kind: "incident",
source: {
sliceId,
title,
oneLiner,
status: status === "blocked" ? "warning" : "danger",
},
});
} else {
items.push({
kind: "incident",
source: { sliceId, title, oneLiner, status: "info" },
});
}
}
return items;
}, [sliceRows]);
// 0.3.1 slice 06 — adapt the daemon-driven sliceRows + missions
// into the new storytelling card primitives so they render in the
// live For You surface. Each of the 5 card types is mounted by a
// distinct data adapter so the production wire is the real one,
// not a stub. The legacy FeedCard list below remains the primary
// surface; this section is the storytelling-primitives preview
// band at the top of the feed.
//
// Adapters:
// - Missions (useMissionDiscovery) → ProgressCard for the first 2
// missions; drill-in routes to /project/mission/<id>. Percent
// defaults to 0 at v0 (mission-level percent computation moves
// in a follow-up slice via PROGRESS.md checkbox count).
// - Slices (useSlices) → ShippedCard for status=shipped/done,
// IncidentCard for everything else, capped at 3.
const missionsResult = useMissionDiscovery();
const storytellingItems = useMemo<StorytellingFeedItem[]>(
() => buildStorytellingFeedItems(
Array.isArray(missionsResult.missions) ? missionsResult.missions : [],
Array.isArray(sliceRows) ? sliceRows : [],
),
[missionsResult.missions, sliceRows],
);
return (
<div data-testid="for-you-feed" className="mx-auto w-full max-w-[720px] px-6 py-8">
@@ -1079,12 +1079,13 @@ export function SliceScopePage() {
const queueItemsById = useMemo(() => queueItems.itemsById, [queueItems.itemsById]);
// 0.3.1 slice 06 — fetch <slicePath>/timeline.md so TimelineTab can
// render the curated narrative above the auto-captured event feed.
// Default root="workspace" since slices live under workspace.slices_root;
// the read fails gracefully (unavailable=true) when the file is absent.
const timelineMd = useSliceTimelineMarkdown(
"workspace",
detailQuery.data?.slicePath ?? null,
);
// SliceDetail.slicePath is an ABSOLUTE filesystem path; the hook
// resolves it against the daemon's allowlist roots and issues the
// /api/files/read call with the correct relative path. When no
// allowlist root contains the slice, the hook returns
// unavailable=true and TimelineTab degrades to the auto-captured
// event feed alone.
const timelineMd = useSliceTimelineMarkdown(detailQuery.data?.slicePath ?? null);
if (detailQuery.isLoading) {
return (
+103 -26
View File
@@ -1,48 +1,124 @@
// 0.3.1 slice 06 — fetch `<slicePath>/timeline.md` via the existing
// /api/files/read daemon route so TimelineTab can render the curated
// narrative above the auto-captured event feed. Mirrors the shape of
// useMissionProgressStatus (which fetches `<missionPath>/PROGRESS.md`
// the same way) so the cache + mtime invalidation behavior stays
// consistent across markdown-as-typed-surface consumers.
// narrative above the auto-captured event feed.
//
// SliceDetail.slicePath is an ABSOLUTE filesystem path (e.g.,
// /Users/x/code/substrate/.../missions/foo/slices/06-…). The daemon's
// /api/files/read route REQUIRES a path RELATIVE TO ONE OF THE
// REGISTERED ALLOWLIST ROOTS and rejects absolute paths outright.
//
// This hook resolves the slice's absolute path against
// `/api/files/roots` (cached for 60s) to find which root contains the
// slice, then issues the read with the correct relative path. When no
// allowlist root contains the slice, the hook returns
// `unavailable: true` — graceful degradation matching the
// useMissionDiscovery precedent.
import { useFilesRead } from "./useFiles.js";
import { useFilesRead, useFilesRoots, type AllowlistRoot } from "./useFiles.js";
export interface UseSliceTimelineMarkdownResult {
/** Raw timeline.md content when the file exists; null when absent
* or unreadable. TimelineTab renders the content above the event
* feed when present and falls through to the standard feed when
* not. */
/** Raw timeline.md content when the file exists; null otherwise.
* TimelineTab renders the content above the event feed when
* present and falls through to the standard feed when not. */
content: string | null;
isLoading: boolean;
/** True when the file does not exist or the read failed. Distinct
* from `content === null` during initial load. */
/** True when the file does not exist, no allowlist root contains
* the slice, or the read failed. Distinct from `content === null`
* during initial load. */
unavailable: boolean;
/** mtime of timeline.md when known. */
mtime: string | null;
/** Diagnostic: the (root, relPath) pair the hook computed for the
* /api/files/read call. Exposed so integration tests can assert
* the production call shape (absolute → relative conversion). */
resolved: { rootName: string; relPath: string } | null;
}
/** Fetch `<slicePath>/timeline.md` for a slice scope. Returns a stable
* shape so consumers can conditionally render the curated narrative
* while loading / when absent. `root` + `slicePath` may be null when
* the scope is not yet known; the hook short-circuits cleanly in
* that case (no fetch, no error). */
export function useSliceTimelineMarkdown(
root: string | null,
slicePath: string | null,
): UseSliceTimelineMarkdownResult {
const timelinePath = root && slicePath ? `${slicePath}/timeline.md` : null;
const readQuery = useFilesRead(root, timelinePath);
/** Returns true when `parent` is a path-prefix of `child`, treating
* both as absolute filesystem paths. Segment-boundary aware so
* `/work` is NOT a prefix of `/workspace`. Mirrors the
* useMissionDiscovery helper. */
function isPathPrefix(parent: string, child: string): boolean {
const p = parent.replace(/\/+$/, "");
const c = child.replace(/\/+$/, "");
if (c === p) return true;
return c.startsWith(p + "/");
}
if (!root || !slicePath) {
return { content: null, isLoading: false, unavailable: true, mtime: null };
/** Compute the path under a root: `<root>/<rel>` → `<rel>`. */
function relativeUnder(rootPath: string, absChild: string): string {
const p = rootPath.replace(/\/+$/, "");
const c = absChild.replace(/\/+$/, "");
if (c === p) return "";
if (c.startsWith(p + "/")) return c.slice(p.length + 1);
return c;
}
/** Pick the deepest-matching allowlist root that contains the
* absolute path. Returns null when no root contains it. Exported
* for the integration test surface. */
export function resolveSlicePathToAllowlist(
roots: AllowlistRoot[],
absoluteSlicePath: string,
): { rootName: string; relPath: string } | null {
// Exact match wins; otherwise deepest prefix.
const exact = roots.find((r) => r.path.replace(/\/+$/, "") === absoluteSlicePath.replace(/\/+$/, ""));
if (exact) return { rootName: exact.name, relPath: "" };
const prefixed = roots
.filter((r) => isPathPrefix(r.path, absoluteSlicePath))
.sort((a, b) => b.path.length - a.path.length);
const winner = prefixed[0];
if (!winner) return null;
return { rootName: winner.name, relPath: relativeUnder(winner.path, absoluteSlicePath) };
}
/** Fetch `<slicePath>/timeline.md` for a slice scope.
* `absoluteSlicePath` is the SliceDetail.slicePath value verbatim
* (daemon emits absolute paths). The hook handles the allowlist-root
* resolution + relative-path construction internally so call sites
* stay simple. */
export function useSliceTimelineMarkdown(
absoluteSlicePath: string | null,
): UseSliceTimelineMarkdownResult {
const rootsQuery = useFilesRoots();
const rootsResp = rootsQuery.data;
const rootsList: AllowlistRoot[] | null =
rootsResp && "roots" in rootsResp ? rootsResp.roots : null;
const resolved =
absoluteSlicePath && rootsList
? resolveSlicePathToAllowlist(rootsList, absoluteSlicePath)
: null;
const timelinePath = resolved
? resolved.relPath
? `${resolved.relPath}/timeline.md`
: "timeline.md"
: null;
const readQuery = useFilesRead(
resolved ? resolved.rootName : null,
timelinePath,
);
if (!absoluteSlicePath) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
}
if (rootsQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved: null };
}
if (!resolved) {
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved: null };
}
if (readQuery.isLoading) {
return { content: null, isLoading: true, unavailable: false, mtime: null };
return { content: null, isLoading: true, unavailable: false, mtime: null, resolved };
}
if (readQuery.isError || !readQuery.data) {
return { content: null, isLoading: false, unavailable: true, mtime: null };
return { content: null, isLoading: false, unavailable: true, mtime: null, resolved };
}
return {
@@ -50,5 +126,6 @@ export function useSliceTimelineMarkdown(
isLoading: false,
unavailable: false,
mtime: readQuery.data.mtime ?? null,
resolved,
};
}
@@ -0,0 +1,103 @@
// 0.3.1 slice 06 forward-fix #2 — pure-logic test for the Feed.tsx
// adapter that converts daemon-driven mission + slice rows into
// FeedCardItem[]. Proves ProgressCard is routed for missions
// (closing the prior "ProgressCard never mounted in production"
// finding) and that slice status routing dispatches to the right
// card type without mounting Feed.tsx itself.
import { describe, it, expect } from "vitest";
import { buildStorytellingFeedItems } from "../src/components/feed/cards/storytelling-cards.js";
describe("buildStorytellingFeedItems — production adapter", () => {
it("routes missions into ProgressCard items (Finding 2 fix: ProgressCard now wired)", () => {
const items = buildStorytellingFeedItems(
[
{ name: "release-0.3.1", path: "missions/release-0.3.1" },
{ name: "demo-video-rig-v0", path: "missions/demo-video-rig-v0" },
],
[],
);
const progressItems = items.filter((i) => i.kind === "progress");
expect(progressItems).toHaveLength(2);
expect(progressItems[0]!.kind).toBe("progress");
if (progressItems[0]!.kind === "progress") {
expect(progressItems[0]!.source.missionId).toBe("release-0.3.1");
expect(progressItems[0]!.source.nextStep).toMatch(/Open mission/);
}
});
it("caps missions at 2 to keep the preview band tight", () => {
const items = buildStorytellingFeedItems(
[
{ name: "m1", path: "missions/m1" },
{ name: "m2", path: "missions/m2" },
{ name: "m3", path: "missions/m3" },
{ name: "m4", path: "missions/m4" },
],
[],
);
expect(items.filter((i) => i.kind === "progress")).toHaveLength(2);
});
it("routes shipped/complete/done slices into ShippedCard", () => {
const items = buildStorytellingFeedItems(
[],
[
{ name: "a", status: "shipped" },
{ name: "b", status: "complete" },
{ name: "c", status: "done" },
],
);
expect(items.every((i) => i.kind === "shipped")).toBe(true);
expect(items).toHaveLength(3);
});
it("routes blocked slices into IncidentCard with status=warning", () => {
const items = buildStorytellingFeedItems([], [{ name: "x", status: "blocked" }]);
expect(items[0]!.kind).toBe("incident");
if (items[0]!.kind === "incident") {
expect(items[0]!.source.status).toBe("warning");
}
});
it("routes failed/danger slices into IncidentCard with status=danger", () => {
const failed = buildStorytellingFeedItems([], [{ name: "x", status: "failed" }]);
expect(failed[0]!.kind).toBe("incident");
if (failed[0]!.kind === "incident") expect(failed[0]!.source.status).toBe("danger");
});
it("routes everything else into IncidentCard with status=info", () => {
const items = buildStorytellingFeedItems(
[],
[
{ name: "a", status: "in-flight" },
{ name: "b", status: null },
{ name: "c" },
],
);
expect(items.every((i) => i.kind === "incident")).toBe(true);
items.forEach((i) => {
if (i.kind === "incident") expect(i.source.status).toBe("info");
});
});
it("composes missions + slices into a single ordered list (missions first, slices after)", () => {
const items = buildStorytellingFeedItems(
[{ name: "m1", path: "missions/m1" }],
[{ name: "s1", status: "shipped" }],
);
expect(items).toHaveLength(2);
expect(items[0]!.kind).toBe("progress");
expect(items[1]!.kind).toBe("shipped");
});
it("returns an empty list when both inputs are empty (no spurious cards)", () => {
expect(buildStorytellingFeedItems([], [])).toEqual([]);
});
it("tolerates null/undefined inputs without throwing", () => {
// @ts-expect-error — intentional shape mismatch to verify defensive
// guards against runtime data drift (daemon could return null).
expect(buildStorytellingFeedItems(null, undefined)).toEqual([]);
});
});
@@ -0,0 +1,166 @@
// 0.3.1 slice 06 forward-fix #2 — integration test for the
// production wire that crosses the client → /api/files/read
// boundary. Exercises absolute-slicePath → relative-path conversion
// against /api/files/roots, then asserts /api/files/read is called
// with the relative path (rejecting absolute would 4xx on the
// daemon side).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
import {
useSliceTimelineMarkdown,
resolveSlicePathToAllowlist,
} from "../src/hooks/useSliceTimelineMarkdown.js";
const originalFetch = globalThis.fetch;
let fetchSpy: ReturnType<typeof vi.fn>;
function makeWrapper() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client }, children);
}
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("resolveSlicePathToAllowlist (pure)", () => {
it("matches the deepest root that prefixes the absolute slice path", () => {
const roots = [
{ name: "home", path: "/Users/x" },
{ name: "substrate", path: "/Users/x/code/substrate" },
{ name: "workspace", path: "/Users/x/code/substrate/shared-docs/openrig-work" },
];
const r = resolveSlicePathToAllowlist(
roots,
"/Users/x/code/substrate/shared-docs/openrig-work/missions/release-0.3.1/slices/06-storytelling-primitives",
);
expect(r).not.toBeNull();
expect(r!.rootName).toBe("workspace");
expect(r!.relPath).toBe("missions/release-0.3.1/slices/06-storytelling-primitives");
});
it("returns null when no allowlist root contains the absolute path", () => {
const roots = [{ name: "workspace", path: "/Users/x/code/openrig-work" }];
const r = resolveSlicePathToAllowlist(roots, "/somewhere/else/slice");
expect(r).toBeNull();
});
it("treats an exact match as zero-length relative path", () => {
const roots = [{ name: "ws", path: "/Users/x/work" }];
const r = resolveSlicePathToAllowlist(roots, "/Users/x/work");
expect(r).toEqual({ rootName: "ws", relPath: "" });
});
it("does NOT mistake `/work` as a prefix of `/workspace` (segment-boundary aware)", () => {
const roots = [{ name: "ws", path: "/Users/x/work" }];
const r = resolveSlicePathToAllowlist(roots, "/Users/x/workspace/slice");
expect(r).toBeNull();
});
});
describe("useSliceTimelineMarkdown — production wire calls /api/files/read with the relative path", () => {
it("converts absolute slicePath to (root, relPath) via /api/files/roots and calls /api/files/read with the relative path", async () => {
fetchSpy.mockImplementation(async (url: string) => {
if (url.startsWith("/api/files/roots")) {
return new Response(JSON.stringify({
roots: [
{ name: "substrate", path: "~/code/substrate" },
{ name: "workspace", path: "~/.openrig/shared-docs/openrig-work" },
],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/files/read")) {
return new Response(JSON.stringify({
root: "workspace",
path: "missions/release-0.3.1/slices/06-storytelling-primitives/timeline.md",
absolutePath: "~/.openrig/shared-docs/openrig-work/missions/release-0.3.1/slices/06-storytelling-primitives/timeline.md",
content: "---\nkind: incident-timeline\n---\n\nBody.\n",
mtime: "2026-05-10T00:00:00Z",
contentHash: "abc",
size: 50,
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
return new Response("not found", { status: 404 });
});
const absoluteSlicePath = "~/.openrig/shared-docs/openrig-work/missions/release-0.3.1/slices/06-storytelling-primitives";
const { result } = renderHook(
() => useSliceTimelineMarkdown(absoluteSlicePath),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.content).toContain("kind: incident-timeline");
expect(result.current.resolved).toEqual({
rootName: "workspace",
relPath: "missions/release-0.3.1/slices/06-storytelling-primitives",
});
// Assert the actual /api/files/read call shape — root + relPath
// are properly URL-encoded; the path is RELATIVE not absolute.
const readCall = fetchSpy.mock.calls.find((c) => String(c[0]).startsWith("/api/files/read"));
expect(readCall).toBeDefined();
const readUrl = new URL(`http://localhost${readCall![0]}`);
expect(readUrl.searchParams.get("root")).toBe("workspace");
expect(readUrl.searchParams.get("path")).toBe("missions/release-0.3.1/slices/06-storytelling-primitives/timeline.md");
// Negative: must NOT be the absolute path that the daemon would reject.
expect(readUrl.searchParams.get("path")).not.toMatch(/^\//);
});
it("returns unavailable=true when no allowlist root contains the absolute slice path", async () => {
fetchSpy.mockImplementation(async (url: string) => {
if (url.startsWith("/api/files/roots")) {
return new Response(JSON.stringify({
roots: [{ name: "ws", path: "~/code/openrig-work" }],
}), { status: 200 });
}
return new Response("not found", { status: 404 });
});
const { result } = renderHook(
() => useSliceTimelineMarkdown("/somewhere/totally/different/slice"),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.unavailable).toBe(true);
expect(result.current.content).toBeNull();
expect(result.current.resolved).toBeNull();
// /api/files/read was NOT called — no relative path could be
// computed, so the hook short-circuits before issuing the read.
const readCall = fetchSpy.mock.calls.find((c) => String(c[0]).startsWith("/api/files/read"));
expect(readCall).toBeUndefined();
});
it("returns unavailable=true when /api/files/read returns 404 (timeline.md absent)", async () => {
fetchSpy.mockImplementation(async (url: string) => {
if (url.startsWith("/api/files/roots")) {
return new Response(JSON.stringify({
roots: [{ name: "workspace", path: "~/code/openrig-work" }],
}), { status: 200 });
}
if (url.startsWith("/api/files/read")) {
return new Response("file not found", { status: 404 });
}
return new Response("?", { status: 500 });
});
const { result } = renderHook(
() => useSliceTimelineMarkdown("~/code/openrig-work/slices/some-slice"),
{ wrapper: makeWrapper() },
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.unavailable).toBe(true);
expect(result.current.content).toBeNull();
});
});