mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 17:32:41 -10:00
fix(ui): resolve absolute slicePath to allowlist root + wire ProgressCard from missions
Forward-fix #2 on slice 06 storytelling-primitives (after70001d1) addressing redo3-guard-3 narrow advisory's second-round findings. Finding 1 (BLOCKING — path-construction bug): The prior forward-fix at70001d1wired 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 prior70001d1state 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:
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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user