feat: browse Subject Journals and persistent Memory links

This commit is contained in:
2026-10-18 02:03:51 +09:00
parent 5ee42b8db8
commit 75b1557d63
30 changed files with 4594 additions and 120 deletions
@@ -0,0 +1,291 @@
/** UI向けのHTTP代替データ。Journalの正本・認可・永続化の実装ではない。 */
export const journalFixture = {
workspaceId: "memory-review",
subjectId:
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review",
memoryId:
"memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001",
candidateId: "candidate-release-decision-0001",
literalQuery: "release [a+b] %_",
};
function journal(id: string, subjectId: string) {
return {
id,
subject_id: subjectId,
source_session_id: `session-${id}`,
title: id === "journal-release"
? "Release coordination Session"
: id === "journal-review"
? "Independent review Session"
: "Archived planning Session",
current_position_bytes: 120,
process_order: 24,
process_byte: 120,
state_token: `state-${id}`,
created_at: "2026-01-02T03:04:05Z",
updated_at: "2026-01-04T03:04:05Z",
};
}
function source(journalId: string) {
return journalId === "journal-release"
? { state: "missing", history_href: null }
: journalId === "journal-review"
? { state: "unavailable", history_href: null }
: {
state: "unavailable",
history_href: "/w/memory-review/workers/W-fixture/console",
};
}
function entry(index: number) {
const journalId = index === 4
? "journal-planning"
: index % 2
? "journal-review"
: "journal-release";
return {
id: `entry-${String(index).padStart(2, "0")}`,
journal_id: journalId,
position: index + 1,
state: index === 0 ? "open" : "fixed",
title: index === 2
? "Corrected release evidence"
: `Release milestone ${
String(index).padStart(2, "0")
} · ${journalFixture.literalQuery}`,
correction_refs: index === 2 ? ["entry-00"] : [],
content_token: `content-entry-${index}`,
created_at: "2026-01-02T03:04:05Z",
updated_at: "2026-01-03T03:04:05Z",
fixed_at: index === 0 ? null : "2026-01-03T03:04:05Z",
};
}
const entries = Array.from({ length: 28 }, (_, index) => entry(index));
const related = Array.from({ length: 8 }, (_, index) => ({
related: {
selector: {
memory_id: index === 0
? journalFixture.memoryId
: `memory-related-${index}`,
change_id: "change-current",
candidate_id: journalFixture.candidateId,
},
source_index: index,
reconsideration: index === 0,
},
claim: index === 0
? "Keep subject Memory provenance typed and visible"
: `Related release lesson ${index}`,
}));
const body =
`# Preserved Journal narrative\n\nThis saved Journal remains readable without the original Worker or Session. It is not the original transcript or proof of each Memory claim.\n\n${
Array.from({ length: 64 }, (_, i) =>
`## Release event ${
i + 1
}\n\nThe team compared the available evidence and recorded the release decision. Literal search token: ${journalFixture.literalQuery}.`)
.join("\n\n")
}\n\n## Journal narrative complete`;
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
const failure = (status: number, message: string) =>
json(
{ error: status === 403 ? "Forbidden" : "Unavailable", message },
status,
);
export async function journalBrowserFixture(
request: Request,
subjectId: string,
): Promise<Response> {
if (request.method !== "POST") {
return failure(405, "read-only Journal queries require POST");
}
const r = await request.json().catch(() => null) as
| Record<string, any>
| null;
if (!r || typeof r.action !== "string") {
return failure(400, "missing query action");
}
const reply = (value: unknown) => json({ kind: r.action, value });
if (subjectId === "failed-subject") {
return failure(503, "Journal read temporarily unavailable");
}
if (r.journal_id === "journal-denied") {
return failure(403, "Journal read permission denied");
}
const empty = subjectId === "empty-subject" ||
subjectId === "ungenerated-subject";
switch (r.action) {
case "sessions": {
if (empty) return reply({ items: [], next: null });
const journals = ["journal-release", "journal-review", "journal-archive"];
const offset = r.after === "fixture-sessions-2" ? 2 : 0;
return reply({
items: journals.slice(offset, offset + 2).map((id) => ({
journal: journal(id, subjectId),
original_source: source(id),
unreflected_sources: id === "journal-review",
})),
next: offset === 0 ? "fixture-sessions-2" : null,
});
}
case "entries": {
if (r.query === "fixture-denied") {
return failure(403, "Journal read permission denied");
}
if (r.query === "fixture-failure") {
return failure(503, "Journal list unavailable");
}
const filtered = empty
? []
: entries.filter((e) =>
(!r.journal_id || r.journal_id === e.journal_id) &&
(!r.query ||
`${e.title} ${body}`.toLowerCase().includes(
String(r.query).toLowerCase(),
))
);
const offset = r.after === "fixture-entries-2" ? 16 : 0;
const limit = Math.min(Number(r.limit) || 20, 16);
return reply({
items: filtered.slice(offset, offset + limit),
next: offset + limit < filtered.length ? "fixture-entries-2" : null,
});
}
case "entry": {
const e = entries.find((e) =>
e.id === r.entry_id && e.journal_id === r.journal_id
);
return e
? reply({
journal: journal(e.journal_id, subjectId),
entry: e,
correction_targets: entries.filter((target) =>
e.correction_refs.includes(target.id)
),
original_source: source(e.journal_id),
})
: failure(404, "Journal entry not found");
}
case "text": {
const e = r.entry_id
? entries.find((e) =>
e.id === r.entry_id && e.journal_id === r.journal_id
)
: null;
const token = r.field === "current_position"
? `state-${r.journal_id}`
: e?.content_token;
if (!token) return failure(404, "Journal text not found");
if (r.expected_token && r.expected_token !== token) {
return failure(409, "Journal text changed; reread from the beginning");
}
const text = r.field === "body"
? body
: r.field === "ongoing"
? "## Ongoing release context\n\nThe next review is still open."
: "## Current Session position\n\nRelease review is awaiting the next evidence check. This current position does not replace the event history.";
const bytes = new TextEncoder().encode(text);
const start = Number(r.byte_offset) || 0;
let end = Math.min(
bytes.length,
start + Math.min(Number(r.max_bytes) || 16384, 16384),
);
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1;
return reply({
text: new TextDecoder().decode(bytes.slice(start, end)),
next_byte: end < bytes.length ? end : null,
content_token: token,
});
}
case "corrections":
return reply({
items: r.entry_id === "entry-00" ? [entry(2)] : [],
next: null,
});
case "related_memory": {
if (r.entry_id === "entry-03") return reply({ items: [], next: null });
const offset = r.after ? Number(r.after.source_index) + 1 : 0;
const limit = Math.min(Number(r.limit) || 3, 20);
const items = related.slice(offset, offset + limit);
const last = items.at(-1)?.related;
return reply({
items,
next: offset + limit < related.length && last
? { ...last.selector, source_index: last.source_index }
: null,
});
}
case "memory_sources": {
const s = r.selector;
// 不変selectorは本番Memory loaderから受け取り、routeだけから推測しない。
if (
!s || s.candidate_id !== journalFixture.candidateId ||
![
"change-current",
"change-earlier",
"change-original",
"change-parent",
].includes(
s.change_id,
)
) return failure(404, "Immutable candidate not found");
if (s.memory_id === "memory-sources-failure") {
return failure(503, "Related Journal unavailable");
}
if (s.memory_id === "memory-resolved-0002") {
return reply({
selector: s,
sources: [{
link: {
source_index: 0,
original_source: "unknown",
connection: {
state: "unconnected",
reason: "insufficient_source",
},
},
entry: null,
original_source: { state: "unavailable", history_href: null },
}],
total_sources: 1,
next_source_index: null,
});
}
const offset = Number(r.source_offset) || 0;
const limit = Math.min(Number(r.limit) || 3, 20);
const sources = entries.slice(offset, Math.min(8, offset + limit)).map((
e,
index,
) => ({
link: {
source_index: offset + index,
original_source: e.journal_id === "journal-release"
? "missing"
: "unknown",
connection: {
state: "linked",
journal_id: e.journal_id,
entry_id: e.id,
target: "available",
reconsideration: e.id === "entry-00",
},
},
entry: e,
original_source: source(e.journal_id),
}));
return reply({
selector: s,
sources,
total_sources: 8,
next_source_index: offset + limit < 8 ? offset + limit : null,
});
}
default:
return failure(400, "unsupported read-only Journal query");
}
}
@@ -0,0 +1,649 @@
/** 本番static web buildとloopback HTTP fixtureでUI境界だけを検証する。
* 最新のweb/workspace/buildが前提で、このsuiteはbuildしない。
* 製品・Worker・modelは起動せず、Journal保存・認可の保証はBackend試験が担う。
* tools/web-uxのtest:browserまたはこのfileを明示選択して実行する。
*/
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Locator, type Page } from "playwright";
import { journalFixture as fixture } from "./journal_browser_fixture.ts";
import {
assertMainOwnsVerticalScroll,
assertNoPageWideOverflow,
freePort,
mainScrollState,
waitForServer,
} from "./memory_document_harness.ts";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const artifacts = join(root, "target/web-ux/t-737-browser-interactions");
const subjectPath = `/w/${fixture.workspaceId}/memory/${fixture.subjectId}`;
const journalPath = `${subjectPath}/journal`;
const memoryPath =
`${subjectPath}/${fixture.memoryId}?change_id=change-current`;
const claim = "Keep subject Memory provenance typed and visible";
const entryTitle = "Release milestone 00 · release [a+b] %_";
const detailPath = `${journalPath}?journal=journal-release&entry=entry-00`;
async function ordinaryAnchor(link: Locator) {
assertEquals(await link.evaluate((el) => el.tagName), "A");
assertEquals(await link.getAttribute("target"), null);
assert(!(await link.textContent())?.includes("↗"));
assert((await link.getAttribute("href"))?.startsWith("/w/"));
}
async function restoreTop(page: Page, expected: number) {
await page.waitForFunction(
(top) => {
const main = (globalThis as any).document.querySelector("main");
return main && Math.abs(main.scrollTop - top) <= 3;
},
expected,
{ timeout: 10_000 },
);
assert(Math.abs((await mainScrollState(page)).scrollTop - expected) <= 3);
}
async function capture(page: Page, name: string) {
await assertNoPageWideOverflow(page);
await assertMainOwnsVerticalScroll(page);
await page.screenshot({ path: join(artifacts, `${name}.png`) });
}
Deno.test("Journal fixture UI keeps scoped navigation, bounded links and distinct read states", async (t) => {
await Deno.stat(join(root, "web/workspace/build/index.html"));
await Deno.mkdir(artifacts, { recursive: true });
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
join(
root,
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
),
String(port),
join(root, "web/workspace/build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
await waitForServer(`${base}/health`);
const browser = await chromium.launch({ headless: true });
const requests: {
path: string;
method: string;
payload: Record<string, any> | null;
}[] = [];
const errors: string[] = [];
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
});
context.on("request", (request) => {
const path = new URL(request.url()).pathname;
if (path.startsWith("/api/")) {
requests.push({
path,
method: request.method(),
payload: request.method() === "POST"
? request.postDataJSON()
: null,
});
}
});
context.on(
"page",
(page) => page.on("pageerror", (e) => errors.push(String(e))),
);
const page = await context.newPage();
await t.step(
"Subject entry and multiple Sessions retain pending/source state and continuation",
async () => {
await page.goto(`${base}${subjectPath}`);
const entry = page.getByRole("link", {
name: "Journal · 最近の出来事とSessionを探す →",
exact: true,
});
await entry.waitFor();
await ordinaryAnchor(entry);
await entry.click();
await page.locator("[data-journal-view='list']").waitFor();
await page.getByRole("link", {
name: "SessionごとのJournal",
exact: true,
}).click();
await page.getByRole("link", {
name: "Release coordination Session →",
exact: true,
})
.waitFor();
assertEquals(await page.locator(".session-list > li").count(), 2);
await page.getByText("記録待ち・未反映の原資料があります。", {
exact: true,
}).waitFor();
await capture(page, "sessions-desktop-light");
const next = page.getByRole("link", {
name: "次のページ →",
exact: true,
});
await next.click();
await page.getByRole("link", {
name: "Archived planning Session →",
exact: true,
})
.waitFor();
assertEquals(
new URL(page.url()).searchParams.get("view"),
"sessions",
);
assertEquals(
new URL(page.url()).searchParams.get("after"),
"fixture-sessions-2",
);
await page.getByRole("link", { name: "一覧の先頭へ", exact: true })
.click();
await page.getByRole("link", {
name: "Release coordination Session →",
exact: true,
})
.click();
await page.getByRole("heading", { name: "Current Session position" })
.waitFor();
await page.getByRole("heading", { name: "出来事の経緯", exact: true })
.waitFor();
assert((await page.locator(".entry-list > li").count()) > 1);
assertEquals(
new URL(page.url()).searchParams.get("journal"),
"journal-release",
);
},
);
await t.step(
"literal search and bounded continuation preserve the actual URL query",
async () => {
await page.goto(`${base}${journalPath}`);
await page.getByRole("searchbox", { name: "出来事を検索" }).fill(
fixture.literalQuery,
);
await page.getByRole("button", { name: "検索", exact: true }).click();
await page.waitForURL((url) =>
url.searchParams.get("q") === fixture.literalQuery
);
await page.getByRole("link", {
name: new RegExp(entryTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
}).waitFor();
assertEquals(await page.locator(".entry-list > li").count(), 16);
await page.getByRole("link", { name: "次のページ →", exact: true })
.click();
await page.waitForURL((url) =>
url.searchParams.get("after") === "fixture-entries-2"
);
assertEquals(
new URL(page.url()).searchParams.get("q"),
fixture.literalQuery,
);
assertEquals(
await page.getByRole("searchbox").inputValue(),
fixture.literalQuery,
);
await page.getByText("Release milestone 16 · release [a+b] %_", {
exact: true,
})
.waitFor();
assertEquals(await page.locator(".entry-list > li").count(), 12);
const searchRequests = requests.filter((r) =>
r.payload?.action === "entries" &&
r.payload.query === fixture.literalQuery
);
assert(
searchRequests.some((r) =>
r.payload?.after === "fixture-entries-2"
),
);
},
);
for (
const viewport of [
{ width: 1440, height: 900, theme: "light" as const },
{ width: 768, height: 900, theme: "dark" as const },
{ width: 390, height: 844, theme: "light" as const },
]
) {
await t.step(
`list → entry → Memory → Back → Back restores main scroll at ${viewport.width}`,
async () => {
await page.setViewportSize(viewport);
await page.emulateMedia({ colorScheme: viewport.theme });
await page.goto(
`${base}${journalPath}?q=${
encodeURIComponent(fixture.literalQuery)
}`,
);
const target = page.locator(".entry-list a").filter({
hasText: "Release milestone 12",
});
await target.waitFor();
await target.scrollIntoViewIfNeeded();
const listTop = (await mainScrollState(page)).scrollTop;
assert(
listTop > 100,
"list must actually be scrolled before testing restoration",
);
const listUrl = page.url();
await ordinaryAnchor(target);
await target.click();
await page.getByRole("heading", {
name: "Release milestone 12 · release [a+b] %_",
exact: true,
}).waitFor();
const memoryLink = page.locator(".related-list").getByRole("link", {
name: `${claim} →`,
exact: true,
});
await memoryLink.scrollIntoViewIfNeeded();
const detailTop = (await mainScrollState(page)).scrollTop;
assert(
detailTop > 100,
"detail must actually be scrolled before testing restoration",
);
const detailUrl = page.url();
await capture(
page,
`detail-before-memory-${viewport.width}-${viewport.theme}`,
);
await ordinaryAnchor(memoryLink);
assertEquals(await memoryLink.getAttribute("href"), memoryPath);
await memoryLink.click();
await page.getByRole("heading", { name: claim, exact: true })
.waitFor();
assertEquals(
new URL(page.url()).pathname,
`${subjectPath}/${fixture.memoryId}`,
);
assertEquals(
new URL(page.url()).searchParams.get("change_id"),
"change-current",
);
const journalLinks = page.locator(
"#related-journal .journal-link-list a",
);
assertEquals(await journalLinks.count(), 3);
const journalLink = journalLinks.filter({ hasText: entryTitle });
await ordinaryAnchor(journalLink);
assertEquals(await journalLink.getAttribute("href"), detailPath);
assert(
(await journalLink.textContent())!.length > entryTitle.length + 4,
"Journal link includes date as well as title",
);
await journalLink.scrollIntoViewIfNeeded();
await capture(
page,
`memory-related-${viewport.width}-${viewport.theme}`,
);
await page.goBack();
await page.locator("[data-journal-view='detail']").waitFor();
assertEquals(page.url(), detailUrl);
await restoreTop(page, detailTop);
await page.goBack();
await page.getByRole("searchbox").waitFor();
assertEquals(page.url(), listUrl);
assertEquals(
await page.getByRole("searchbox").inputValue(),
fixture.literalQuery,
);
await restoreTop(page, listTop);
await capture(
page,
`list-back-${viewport.width}-${viewport.theme}`,
);
},
);
}
await t.step(
"related previews, immutable candidates, normal same-tab and new-tab links",
async () => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(`${base}${detailPath}`);
await page.getByRole("heading", { name: entryTitle, exact: true })
.waitFor();
assertEquals(await page.locator(".related-list a").count(), 3);
await page.getByRole("link", {
name: "すべて見る(関連Memoryの続き) →",
exact: true,
})
.click();
await page.getByText("Related release lesson 3 →", { exact: true })
.waitFor();
assertEquals(await page.locator(".related-list a").count(), 5);
assertEquals(new URL(page.url()).searchParams.get("related"), "all");
assert(new URL(page.url()).searchParams.has("related_after"));
await page.locator(".related-list").scrollIntoViewIfNeeded();
await capture(page, "journal-all-related-1440-light");
await page.getByRole("link", {
name: "関連Memoryの先頭へ",
exact: true,
}).click();
const memoryLink = page.locator(".related-list a").filter({
hasText: claim,
});
const popupReady = context.waitForEvent("page");
await memoryLink.click({ button: "middle" });
const popup = await popupReady;
await popup.getByRole("heading", { name: claim, exact: true })
.waitFor();
assertEquals(
new URL(popup.url()).searchParams.get("change_id"),
"change-current",
);
const backLink = popup.locator("#related-journal a").filter({
hasText: entryTitle,
});
const journalPopupReady = context.waitForEvent("page");
await backLink.click({ button: "middle" });
const journalPopup = await journalPopupReady;
await journalPopup.getByRole("heading", {
name: entryTitle,
exact: true,
}).waitFor();
await journalPopup.getByRole("heading", {
name: "Preserved Journal narrative",
exact: true,
}).waitFor();
await journalPopup.close();
await popup.getByRole("link", {
name: "すべて見る(関連Journalの続き) →",
exact: true,
})
.click();
await popup.locator("#related-journal a").filter({
hasText: "Release milestone 03",
})
.waitFor();
assertEquals(
await popup.locator("#related-journal .journal-link-list a")
.count(),
5,
);
assertEquals(
new URL(popup.url()).searchParams.get("journal_source_offset"),
"3",
);
await popup.locator("#related-journal").scrollIntoViewIfNeeded();
await capture(popup, "memory-all-related-1440-light");
await popup.locator("#related-journal a").filter({
hasText: "Release milestone 03",
})
.click();
await popup.getByRole("heading", {
name: "Release milestone 03 · release [a+b] %_",
exact: true,
}).waitFor();
assertEquals(
new URL(popup.url()).searchParams.get("entry"),
"entry-03",
);
await popup.getByText("関連するMemoryは未接続です。", { exact: true })
.waitFor();
assertEquals(await popup.locator(".related-list a").count(), 0);
await popup.close();
const candidateRequests = requests.filter((r) =>
r.payload?.action === "memory_sources"
);
assert(candidateRequests.length > 0);
for (const r of candidateRequests) {
assertEquals(r.payload?.selector.candidate_id, fixture.candidateId);
assertEquals(r.payload?.selector.change_id, "change-current");
assertEquals(r.payload?.selector.memory_id, fixture.memoryId);
}
},
);
await t.step(
"bounded narrative continuation carries its content token and recovers from stale text",
async () => {
await page.goto(`${base}${detailPath}`);
const body = page.getByRole("region", {
name: "出来事の本文",
exact: true,
});
await body.getByRole("link", { name: "次の本文区間 →", exact: true })
.click();
await page.getByRole("heading", {
name: "Journal narrative complete",
exact: true,
})
.waitFor();
assertEquals(
new URL(page.url()).searchParams.get("body_token"),
"content-entry-0",
);
assert(Number(new URL(page.url()).searchParams.get("body_byte")) > 0);
await body.getByRole("link", { name: "本文の先頭へ", exact: true })
.click();
await page.getByRole("heading", {
name: "Preserved Journal narrative",
exact: true,
})
.waitFor();
await page.goto(
`${base}${detailPath}&body_byte=8192&body_token=stale-content-token`,
);
await body.getByRole("alert").waitFor();
await body.getByRole("link", {
name: "先頭から読み直す",
exact: true,
}).click();
await page.getByRole("heading", {
name: "Preserved Journal narrative",
exact: true,
})
.waitFor();
},
);
await t.step(
"correction targets and correcting-entry links resolve directly",
async () => {
await page.goto(`${base}${detailPath}`);
const correction = page.getByRole("link").filter({
hasText: "Corrected release evidence",
});
await correction.waitFor();
await ordinaryAnchor(correction);
await correction.click();
await page.getByRole("heading", {
name: "Corrected release evidence",
exact: true,
})
.waitFor();
const target = page.getByRole("link", {
name: `${entryTitle} →`,
exact: true,
});
await ordinaryAnchor(target);
assertEquals(await target.getAttribute("href"), detailPath);
await target.click();
await page.getByRole("heading", { name: entryTitle, exact: true })
.waitFor();
await page.getByRole("region", { name: "継続中の経緯", exact: true })
.waitFor();
},
);
await t.step(
"missing and unavailable original sources do not prevent saved Journal reading",
async () => {
for (
const state of [
{
journal: "journal-release",
entry: "entry-00",
text: "元の会話は削除済み",
},
{
journal: "journal-review",
entry: "entry-01",
text: "原資料は取得不能(存在・閲覧可否を確認できません)",
},
]
) {
await page.goto(
`${base}${journalPath}?journal=${state.journal}&entry=${state.entry}`,
);
await page.getByRole("heading", {
name: "Preserved Journal narrative",
exact: true,
})
.waitFor();
await page.getByRole("region", { name: "原資料", exact: true })
.getByText(state.text, {
exact: true,
}).waitFor();
assertEquals(
await page.getByRole("link", {
name: "元の会話を開く →",
exact: true,
}).count(),
0,
);
assertEquals(await page.locator(".related-list a").count(), 3);
}
await page.goto(
`${base}${journalPath}?journal=journal-planning&entry=entry-04`,
);
await page.getByText("原資料の本文は未確認です。", { exact: true })
.waitFor();
const originalLink = page.getByRole("link", {
name: "元の会話を確認する →",
exact: true,
});
await ordinaryAnchor(originalLink);
assertEquals(
await originalLink.getAttribute("href"),
"/w/memory-review/workers/W-fixture/console",
);
await originalLink.scrollIntoViewIfNeeded();
await capture(page, "retained-original-navigation-1440-light");
},
);
await t.step(
"empty, pending, unconnected, no search result and request failures remain distinct",
async () => {
for (
const state of [
{
path:
`/w/${fixture.workspaceId}/memory/empty-subject/journal?view=sessions`,
text: "Journalはまだありません。",
role: "status" as const,
},
{
path: `${journalPath}?q=definitely-no-such-literal`,
text: "検索結果は0件です。",
role: "status" as const,
},
{
path: `${journalPath}?q=fixture-failure`,
text: "出来事一覧を取得できません。",
role: "alert" as const,
},
{
path: `${journalPath}?journal=journal-denied&entry=entry-00`,
text: "Journalエントリを取得できません",
role: "alert" as const,
},
]
) {
await page.goto(`${base}${state.path}`);
await page.getByRole(state.role).filter({ hasText: state.text })
.waitFor();
assertEquals(
await page.locator(".entry-list a, .related-list a").count(),
0,
);
await assertNoPageWideOverflow(page);
}
await page.goto(
`${base}${subjectPath}/memory-resolved-0002?change_id=change-earlier`,
);
await page.locator("#related-journal").getByText(
"対応するJournalは未接続",
{
exact: true,
},
).waitFor();
assertEquals(await page.locator("#related-journal a").count(), 0);
assert(
!(await page.locator("#related-journal").textContent())?.includes(
"記録待ち",
),
);
assert(requests.some((r) =>
r.payload?.action === "memory_sources" &&
r.payload.selector.memory_id === "memory-resolved-0002" &&
r.payload.selector.change_id === "change-earlier" &&
r.payload.selector.candidate_id === fixture.candidateId
));
await page.goto(
`${base}${subjectPath}/memory-sources-failure?change_id=change-current`,
);
await page.getByRole("alert").filter({
hasText: "Journal参照を取得できません。",
})
.waitFor();
await page.getByRole("article", {
name: "Committed Memory body",
exact: true,
}).waitFor();
assertEquals(
await page.locator("#related-journal .journal-link-list a").count(),
0,
);
},
);
assertEquals(errors, []);
assertEquals(
requests.filter((r) =>
r.method !== "GET" &&
!(r.method === "POST" && r.path.endsWith("/journal-browser"))
),
[],
);
assertEquals(
requests.filter((r) => /history|restore|start/.test(r.path)),
[],
);
for (
const request of requests.filter((r) =>
r.path.endsWith("/journal-browser")
)
) {
assertEquals(request.method, "POST");
if (request.payload?.limit != null) {
assert(request.payload.limit > 0 && request.payload.limit <= 20);
}
if (request.payload?.action === "text") {
assert(
request.payload.max_bytes > 0 && request.payload.max_bytes <= 8192,
);
}
}
await Deno.writeTextFile(
join(artifacts, "requests.json"),
JSON.stringify(requests, null, 2),
);
await context.close();
} finally {
await browser.close();
}
} finally {
server.kill("SIGTERM");
await server.status;
}
});
@@ -1,4 +1,5 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
import { journalBrowserFixture } from "./journal_browser_fixture.ts";
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
@@ -491,6 +492,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const foundSubject = subjects.find((item) => item.id === subjectId);
if (!foundSubject) return json({}, 404);
if (parts.length === 2 && parts[1] === "journal-browser") {
return await journalBrowserFixture(request, subjectId);
}
if (parts.length === 2 && parts[1] === "behavior" && request.method === "PATCH") {
const payload = await request.json().catch(() => null) as {
expected_behavior_md?: unknown;
@@ -0,0 +1,55 @@
import { assertEquals } from "@std/assert";
import type { Page } from "playwright";
export async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
export async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
const response = await fetch(url);
await response.body?.cancel();
if (response.ok) return;
} catch {
// 所有するfixture serverの起動を有界に待つ。
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`fixture server did not become ready: ${url}`);
}
export async function mainScrollState(page: Page) {
return await page.locator("main").evaluate((main) => ({
scrollTop: main.scrollTop,
scrollHeight: main.scrollHeight,
clientHeight: main.clientHeight,
}));
}
export async function assertNoPageWideOverflow(page: Page): Promise<void> {
assertEquals(
await page.evaluate(() => {
const document = (globalThis as any).document;
return document.documentElement.scrollWidth ===
document.documentElement.clientWidth;
}),
true,
);
}
export async function assertMainOwnsVerticalScroll(page: Page): Promise<void> {
const nested = await page.locator("main").evaluate((main) =>
[...main.querySelectorAll("*")]
.filter((element) => {
const style = (globalThis as any).getComputedStyle(element);
return ["auto", "scroll"].includes(style.overflowY) &&
element.scrollHeight > element.clientHeight + 1;
})
.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
);
assertEquals(nested, []);
}
@@ -1,6 +1,13 @@
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
import {
assertMainOwnsVerticalScroll,
assertNoPageWideOverflow,
freePort,
mainScrollState,
waitForServer,
} from "./memory_document_harness.ts";
const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const workspaceRoot = join(repositoryRoot, "web/workspace");
@@ -19,33 +26,6 @@ const createInteractionArtifacts = join(
"target/web-ux/t-692-browser-interactions",
);
async function freePort(): Promise<number> {
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
return port;
}
async function waitForServer(url: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt += 1) {
try {
if ((await fetch(url)).ok) return;
} catch {
// Retry until the bounded deadline while the owned server starts.
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`fixture server did not become ready: ${url}`);
}
async function mainScrollState(page: Page) {
return await page.locator("main").evaluate((main) => ({
scrollTop: main.scrollTop,
scrollHeight: main.scrollHeight,
clientHeight: main.clientHeight,
}));
}
async function markerIsInsideMain(page: Page): Promise<boolean> {
return await page.evaluate(() => {
const document = (globalThis as any).document;
@@ -60,29 +40,6 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
});
}
async function assertNoPageWideOverflow(page: Page): Promise<void> {
assertEquals(
await page.evaluate(() => {
const document = (globalThis as any).document;
return document.documentElement.scrollWidth === document.documentElement.clientWidth;
}),
true,
);
}
async function assertMainOwnsVerticalScroll(page: Page): Promise<void> {
const nested = await page.locator("main").evaluate((main) =>
[...main.querySelectorAll("*")]
.filter((element) => {
const style = (globalThis as any).getComputedStyle(element);
return ["auto", "scroll"].includes(style.overflowY) &&
element.scrollHeight > element.clientHeight + 1;
})
.map((element) => `${element.tagName.toLowerCase()}.${element.className}`)
);
assertEquals(nested, []);
}
async function openSubjectPage(
page: Page,
baseUrl: string,
@@ -324,9 +281,18 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
assertEquals(await detailPage.getByText("Memory information design source").count(), 1);
assertEquals(await detailPage.getByText("memory-derived-source-0002").count(), 1);
assertEquals(await detailPage.getByRole("heading", { name: "Change history" }).count(), 1);
assertEquals(await detailPage.getByText("Memory change change-current", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Memory change change-earlier", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Memory change change-original", { exact: true }).count(), 0);
assertEquals(
await detailPage.getByText("Memory change change-current", { exact: true }).count(),
1,
);
assertEquals(
await detailPage.getByText("Memory change change-earlier", { exact: true }).count(),
1,
);
assertEquals(
await detailPage.getByText("Memory change change-original", { exact: true }).count(),
0,
);
await detailPage.getByRole("navigation", { name: "Change history pages" }).getByRole(
"link",
{ name: "Next page →" },
@@ -367,7 +333,10 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
).count(),
1,
);
assertEquals(await paginationPage.getByText("Memory fingerprint", { exact: true }).count(), 0);
assertEquals(
await paginationPage.getByText("Memory fingerprint", { exact: true }).count(),
0,
);
assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0);
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/deferred_navigation_test.ts browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/journal_browser_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/console_image_visibility_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
},
"fmt": {
"lineWidth": 100,
@@ -0,0 +1,280 @@
{
"schemaVersion": 1,
"id": "journal-browser-dark",
"title": "Journal browser UI fixture — dark",
"baseUrl": "http://127.0.0.1:15287",
"colorScheme": "dark",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "HTTP-only Journal UI fixture",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "sessions",
"label": "Session journals",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?view=sessions",
"goal": "Choose among saved Session journals and distinguish pending work from source presence.",
"dataState": "Two Sessions, one with unreflected sources; deleted versus unavailable originals; bounded next page.",
"ready": {
"kind": "selector",
"selector": ".session-list > li"
},
"capturePoints": [
{
"id": "initial",
"label": "Multiple Sessions"
},
{
"id": "source-state",
"label": "Missing and unavailable originals",
"interaction": [
{
"action": "click",
"selector": ".session-list li:first-child summary"
}
]
}
]
},
{
"id": "list",
"label": "Literal search results",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=release%20%5Ba%2Bb%5D%20%25_",
"goal": "Find literal text across Sessions and continue a bounded search.",
"dataState": "Sixteen matching events across two Sessions with fixed/open states and a continuation.",
"ready": {
"kind": "selector",
"selector": ".entry-list > li"
},
"capturePoints": [
{
"id": "initial",
"label": "Literal search results"
}
]
},
{
"id": "detail-missing",
"label": "Saved Journal with deleted original",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-release&entry=entry-00",
"goal": "Read narrative, correction and related Memory without restoring a Worker or original Session.",
"dataState": "Open event, three of eight related Memories, correcting entry; original conversation is deleted.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=detail] .related-list a"
},
"capturePoints": [
{
"id": "initial",
"label": "Narrative and open event"
},
{
"id": "related",
"label": "Correction and related Memory preview",
"interaction": [
{
"action": "click",
"selector": "#related-memory-title"
}
]
},
{
"id": "source",
"label": "Deleted original; saved narrative independent",
"interaction": [
{
"action": "click",
"selector": "section[aria-label=原資料] h2"
}
]
}
]
},
{
"id": "detail-unavailable",
"label": "Journal with unavailable original",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-review&entry=entry-01",
"goal": "Distinguish inability to check the original from confirmed deletion.",
"dataState": "Fixed event remains readable, but original presence/permission is unavailable.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=detail] .related-list a"
},
"capturePoints": [
{
"id": "source",
"label": "Unavailable original, not deleted",
"interaction": [
{
"action": "click",
"selector": "section[aria-label=原資料] h2"
}
]
}
]
},
{
"id": "memory",
"label": "Memory links back to exact Journal events",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001?change_id=change-current",
"goal": "Identify Journal targets by title and date, with bounded continuation and separate original-source status.",
"dataState": "Three of eight persisted candidate links, including missing/unavailable originals and reconsideration.",
"ready": {
"kind": "selector",
"selector": "#related-journal .journal-link-list a"
},
"capturePoints": [
{
"id": "related",
"label": "Journal title/date preview",
"interaction": [
{
"action": "click",
"selector": "#related-journal-heading"
}
]
},
{
"id": "source",
"label": "Originals separate from Journal links",
"interaction": [
{
"action": "click",
"selector": "section[aria-label=原資料] h2"
}
]
}
]
},
{
"id": "memory-unconnected",
"label": "Unconnected Memory",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-resolved-0002?change_id=change-earlier",
"goal": "Recognize a missing persisted connection without fabricating a target or pending reason.",
"dataState": "Immutable historical change with insufficient_source; no Journal anchors.",
"ready": {
"kind": "selector",
"selector": "#related-journal .empty-copy"
},
"capturePoints": [
{
"id": "unconnected",
"label": "Unconnected is not pending",
"interaction": [
{
"action": "click",
"selector": "#related-journal-heading"
}
]
}
]
},
{
"id": "no-journal",
"label": "No saved Journal",
"path": "/w/memory-review/memory/empty-subject/journal?view=sessions",
"goal": "Distinguish no saved Journal from pending, no search results and read failure.",
"dataState": "Successful empty Session-journal page.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=list] [role=status]"
},
"capturePoints": [
{
"id": "initial",
"label": "No saved Journal"
}
]
},
{
"id": "no-search-result",
"label": "No literal search matches",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=definitely-no-such-literal",
"goal": "Keep the query visible when search succeeds with zero results.",
"dataState": "Successful empty search, not an unavailable Journal.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=list] [role=status]"
},
"capturePoints": [
{
"id": "initial",
"label": "No literal search matches"
}
]
},
{
"id": "denied",
"label": "Journal read denied",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-denied&entry=entry-00",
"goal": "Show a read failure rather than inventing an empty or deleted Journal.",
"dataState": "Intentional fixture HTTP 403; verifies error presentation only, not backend authorization.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=detail] [role=alert]"
},
"capturePoints": [
{
"id": "initial",
"label": "Journal read denied"
}
]
},
{
"id": "request-failure",
"label": "Journal list unavailable",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=fixture-failure",
"goal": "Keep request failure distinct from zero matching entries.",
"dataState": "Intentional fixture HTTP 503 with the search query retained.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=list] [role=alert]"
},
"capturePoints": [
{
"id": "initial",
"label": "Journal list unavailable"
}
]
}
],
"processes": [
{
"id": "memory-fixture",
"command": "deno",
"cwd": "..",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/memory_document_fixture_server.ts",
"15287",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15287/health"
}
]
}
@@ -0,0 +1,280 @@
{
"schemaVersion": 1,
"id": "journal-browser-light",
"title": "Journal browser UI fixture — light",
"baseUrl": "http://127.0.0.1:15287",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "HTTP-only Journal UI fixture",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "sessions",
"label": "Session journals",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?view=sessions",
"goal": "Choose among saved Session journals and distinguish pending work from source presence.",
"dataState": "Two Sessions, one with unreflected sources; deleted versus unavailable originals; bounded next page.",
"ready": {
"kind": "selector",
"selector": ".session-list > li"
},
"capturePoints": [
{
"id": "initial",
"label": "Multiple Sessions"
},
{
"id": "source-state",
"label": "Missing and unavailable originals",
"interaction": [
{
"action": "click",
"selector": ".session-list li:first-child summary"
}
]
}
]
},
{
"id": "list",
"label": "Literal search results",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=release%20%5Ba%2Bb%5D%20%25_",
"goal": "Find literal text across Sessions and continue a bounded search.",
"dataState": "Sixteen matching events across two Sessions with fixed/open states and a continuation.",
"ready": {
"kind": "selector",
"selector": ".entry-list > li"
},
"capturePoints": [
{
"id": "initial",
"label": "Literal search results"
}
]
},
{
"id": "detail-missing",
"label": "Saved Journal with deleted original",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-release&entry=entry-00",
"goal": "Read narrative, correction and related Memory without restoring a Worker or original Session.",
"dataState": "Open event, three of eight related Memories, correcting entry; original conversation is deleted.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=detail] .related-list a"
},
"capturePoints": [
{
"id": "initial",
"label": "Narrative and open event"
},
{
"id": "related",
"label": "Correction and related Memory preview",
"interaction": [
{
"action": "click",
"selector": "#related-memory-title"
}
]
},
{
"id": "source",
"label": "Deleted original; saved narrative independent",
"interaction": [
{
"action": "click",
"selector": "section[aria-label=原資料] h2"
}
]
}
]
},
{
"id": "detail-unavailable",
"label": "Journal with unavailable original",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-review&entry=entry-01",
"goal": "Distinguish inability to check the original from confirmed deletion.",
"dataState": "Fixed event remains readable, but original presence/permission is unavailable.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=detail] .related-list a"
},
"capturePoints": [
{
"id": "source",
"label": "Unavailable original, not deleted",
"interaction": [
{
"action": "click",
"selector": "section[aria-label=原資料] h2"
}
]
}
]
},
{
"id": "memory",
"label": "Memory links back to exact Journal events",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001?change_id=change-current",
"goal": "Identify Journal targets by title and date, with bounded continuation and separate original-source status.",
"dataState": "Three of eight persisted candidate links, including missing/unavailable originals and reconsideration.",
"ready": {
"kind": "selector",
"selector": "#related-journal .journal-link-list a"
},
"capturePoints": [
{
"id": "related",
"label": "Journal title/date preview",
"interaction": [
{
"action": "click",
"selector": "#related-journal-heading"
}
]
},
{
"id": "source",
"label": "Originals separate from Journal links",
"interaction": [
{
"action": "click",
"selector": "section[aria-label=原資料] h2"
}
]
}
]
},
{
"id": "memory-unconnected",
"label": "Unconnected Memory",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/memory-resolved-0002?change_id=change-earlier",
"goal": "Recognize a missing persisted connection without fabricating a target or pending reason.",
"dataState": "Immutable historical change with insufficient_source; no Journal anchors.",
"ready": {
"kind": "selector",
"selector": "#related-journal .empty-copy"
},
"capturePoints": [
{
"id": "unconnected",
"label": "Unconnected is not pending",
"interaction": [
{
"action": "click",
"selector": "#related-journal-heading"
}
]
}
]
},
{
"id": "no-journal",
"label": "No saved Journal",
"path": "/w/memory-review/memory/empty-subject/journal?view=sessions",
"goal": "Distinguish no saved Journal from pending, no search results and read failure.",
"dataState": "Successful empty Session-journal page.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=list] [role=status]"
},
"capturePoints": [
{
"id": "initial",
"label": "No saved Journal"
}
]
},
{
"id": "no-search-result",
"label": "No literal search matches",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=definitely-no-such-literal",
"goal": "Keep the query visible when search succeeds with zero results.",
"dataState": "Successful empty search, not an unavailable Journal.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=list] [role=status]"
},
"capturePoints": [
{
"id": "initial",
"label": "No literal search matches"
}
]
},
{
"id": "denied",
"label": "Journal read denied",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?journal=journal-denied&entry=entry-00",
"goal": "Show a read failure rather than inventing an empty or deleted Journal.",
"dataState": "Intentional fixture HTTP 403; verifies error presentation only, not backend authorization.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=detail] [role=alert]"
},
"capturePoints": [
{
"id": "initial",
"label": "Journal read denied"
}
]
},
{
"id": "request-failure",
"label": "Journal list unavailable",
"path": "/w/memory-review/memory/release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review/journal?q=fixture-failure",
"goal": "Keep request failure distinct from zero matching entries.",
"dataState": "Intentional fixture HTTP 503 with the search query retained.",
"ready": {
"kind": "selector",
"selector": "[data-journal-view=list] [role=alert]"
},
"capturePoints": [
{
"id": "initial",
"label": "Journal list unavailable"
}
]
}
],
"processes": [
{
"id": "memory-fixture",
"command": "deno",
"cwd": "..",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/memory_document_fixture_server.ts",
"15287",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15287/health"
}
]
}