feat: browse Subject Journals and persistent Memory links
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user