feat(web): add Subject creation to Memory index
This commit is contained in:
@@ -23,8 +23,13 @@ const ungeneratedSubjectId = "ungenerated-subject";
|
||||
const errorSubjectId = "error-subject";
|
||||
const pagedSubjectId = "paged-subject";
|
||||
const subjectPageCursor = "fixture-subject-page-2";
|
||||
const emptySubjectPageCursor = "fixture-empty-subject-page";
|
||||
const errorSubjectPageCursor = "fixture-error-subject-page";
|
||||
const memoryPageCursor = "fixture-memory-page-2";
|
||||
const revisionPageCursor = "fixture-revision-page-2";
|
||||
const createdSubjectIds = new Set<string>();
|
||||
const subjectCreateRequests: unknown[] = [];
|
||||
let createdSubjectCount = 0;
|
||||
|
||||
const paragraphs = Array.from(
|
||||
{ length: sectionCount },
|
||||
@@ -82,6 +87,7 @@ function subject(id: string, role: string, state: "active" | "retired", storeRev
|
||||
}
|
||||
|
||||
function memoriesFor(subjectId: string) {
|
||||
if (createdSubjectIds.has(subjectId)) return [];
|
||||
if (subjectId === emptySubjectId || subjectId === ungeneratedSubjectId) return [];
|
||||
if (subjectId === staleSubjectId) {
|
||||
return [
|
||||
@@ -314,6 +320,9 @@ async function staticResponse(pathname: string): Promise<Response> {
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
if (url.pathname === "/fixture/subject-create-requests") {
|
||||
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
|
||||
}
|
||||
if (url.pathname === "/api/workspaces") {
|
||||
return json([{
|
||||
workspace_id: workspaceId,
|
||||
@@ -361,7 +370,31 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
|
||||
const subjectsPath = `/api/w/${workspaceId}/subjektiv/subjects`;
|
||||
if (url.pathname === subjectsPath) {
|
||||
const continued = url.searchParams.get("cursor") === subjectPageCursor;
|
||||
if (request.method === "POST") {
|
||||
const payload = await request.json().catch(() => null) as { role?: unknown } | null;
|
||||
subjectCreateRequests.push(payload);
|
||||
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
||||
}
|
||||
if (payload.role === "Permission denied") {
|
||||
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
||||
}
|
||||
createdSubjectCount += 1;
|
||||
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
||||
const created = subject(id, payload.role, "active", 0);
|
||||
createdSubjectIds.add(id);
|
||||
subjects.unshift(created);
|
||||
return json(created, 201);
|
||||
}
|
||||
if (request.method !== "GET") return new Response("method not allowed", { status: 405 });
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
if (cursor === errorSubjectPageCursor) {
|
||||
return json({ error: "Service Unavailable", message: "subject list unavailable" }, 503);
|
||||
}
|
||||
if (cursor === emptySubjectPageCursor) {
|
||||
return json({ limit: 100, items: [], has_more: false });
|
||||
}
|
||||
const continued = cursor === subjectPageCursor;
|
||||
return json({
|
||||
limit: 100,
|
||||
items: continued ? [pagedSubject] : subjects,
|
||||
|
||||
@@ -13,6 +13,11 @@ const subjectId =
|
||||
"release-coordination-subject-with-a-deliberately-long-stable-identity-for-responsive-review";
|
||||
const memoryId =
|
||||
"memory-decision-with-a-deliberately-long-stable-identity-for-overflow-review-0000000001";
|
||||
const subjectPageCursor = "fixture-subject-page-2";
|
||||
const createInteractionArtifacts = join(
|
||||
repositoryRoot,
|
||||
"target/web-ux/t-689-browser-interactions",
|
||||
);
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
@@ -158,6 +163,7 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown,
|
||||
`Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`,
|
||||
);
|
||||
}
|
||||
await Deno.mkdir(createInteractionArtifacts, { recursive: true });
|
||||
|
||||
const port = await freePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
@@ -374,6 +380,93 @@ Deno.test("production subject Memory shell renders exact states, safe Markdown,
|
||||
await assertNoPageWideOverflow(paginationPage);
|
||||
await paginationContext.close();
|
||||
|
||||
const emptyContext = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
colorScheme: "dark",
|
||||
});
|
||||
const emptyPage = await emptyContext.newPage();
|
||||
await emptyPage.route(`**/api/w/${workspaceId}/subjektiv/subjects?*`, async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ limit: 100, items: [], has_more: false }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
|
||||
await emptyPage.getByText("No Memory subjects.").waitFor();
|
||||
await emptyPage.getByRole("button", { name: "New Subject" }).click();
|
||||
const emptyRole = emptyPage.getByRole("textbox", { name: "Role" });
|
||||
await emptyRole.waitFor();
|
||||
assertEquals(
|
||||
await emptyRole.evaluate((input) => input === (globalThis as any).document.activeElement),
|
||||
true,
|
||||
);
|
||||
await assertNoPageWideOverflow(emptyPage);
|
||||
await emptyPage.screenshot({
|
||||
path: join(createInteractionArtifacts, "empty-create-form-mobile-dark.png"),
|
||||
});
|
||||
await emptyPage.getByRole("button", { name: "Cancel" }).click();
|
||||
assertEquals(
|
||||
await emptyPage.getByRole("button", { name: "New Subject" }).evaluate((button) =>
|
||||
button === (globalThis as any).document.activeElement
|
||||
),
|
||||
true,
|
||||
);
|
||||
await emptyContext.close();
|
||||
|
||||
const createContext = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
colorScheme: "light",
|
||||
});
|
||||
const createPage = await createContext.newPage();
|
||||
const mutationPaths: string[] = [];
|
||||
createPage.on("request", (request) => {
|
||||
if (request.method() === "POST") mutationPaths.push(new URL(request.url()).pathname);
|
||||
});
|
||||
await createPage.goto(`${baseUrl}/w/${workspaceId}/memory?cursor=${subjectPageCursor}`);
|
||||
await createPage.getByText("Subject on the next page").waitFor();
|
||||
await createPage.getByRole("button", { name: "New Subject" }).click();
|
||||
const createRole = createPage.getByRole("textbox", { name: "Role" });
|
||||
await createRole.fill("Permission denied");
|
||||
await createPage.getByRole("button", { name: "Create Subject" }).click();
|
||||
const permissionAlert = createPage.getByRole("alert");
|
||||
await permissionAlert.waitFor();
|
||||
assert((await permissionAlert.textContent())?.includes("do not have permission"));
|
||||
assertEquals(await createRole.inputValue(), "Permission denied");
|
||||
await createPage.screenshot({
|
||||
path: join(createInteractionArtifacts, "permission-error-desktop-light.png"),
|
||||
});
|
||||
|
||||
await createPage.getByRole("button", { name: "Cancel" }).click();
|
||||
await createPage.getByRole("button", { name: "New Subject" }).click();
|
||||
await createPage.getByRole("textbox", { name: "Role" }).fill(" Release steward ");
|
||||
await createPage.getByRole("button", { name: "Create Subject" }).click();
|
||||
await createPage.waitForURL("**/memory/created-subject-0001");
|
||||
await createPage.getByRole("heading", { name: "Release steward", level: 1 }).waitFor();
|
||||
assertEquals(
|
||||
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
|
||||
1,
|
||||
);
|
||||
const currentWorker = createPage.locator(".subject-facts div").filter({
|
||||
hasText: "Current worker",
|
||||
});
|
||||
assert((await currentWorker.textContent())?.includes("None"));
|
||||
assertEquals(mutationPaths, [
|
||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||
`/api/w/${workspaceId}/subjektiv/subjects`,
|
||||
]);
|
||||
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`)).json();
|
||||
assertEquals(createEvidence, {
|
||||
count: 2,
|
||||
requests: [{ role: "Permission denied" }, { role: " Release steward " }],
|
||||
});
|
||||
await assertNoPageWideOverflow(createPage);
|
||||
await createContext.close();
|
||||
|
||||
const stateContext = await browser.newContext({ viewport: { width: 390, height: 844 } });
|
||||
const stateCases = [
|
||||
{
|
||||
|
||||
@@ -21,10 +21,98 @@
|
||||
"id": "subjects",
|
||||
"label": "Subject index",
|
||||
"path": "/w/memory-review/memory",
|
||||
"goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.",
|
||||
"dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" },
|
||||
"capturePoints": [{ "id": "initial", "label": "Subjects" }]
|
||||
"goal": "Choose an explicit Memory subject or create one with a Backend-validated role.",
|
||||
"dataState": "Active and retired subjects across a paginated list, plus create-form and permission-rejection states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subjects'] .subject-list"
|
||||
},
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Subjects" },
|
||||
{
|
||||
"id": "create-form",
|
||||
"label": "Create Subject form",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "button.subject-create-trigger" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-panel input"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "permission-error",
|
||||
"label": "Create permission error",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#subject-create-panel input",
|
||||
"value": "Permission denied"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "#subject-create-panel button[type='submit']"
|
||||
},
|
||||
{
|
||||
"action": "wait",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "subjects-empty",
|
||||
"label": "Empty Subject index",
|
||||
"path": "/w/memory-review/memory?cursor=fixture-empty-subject-page",
|
||||
"goal": "Create the first visible Subject when the current bounded page is empty.",
|
||||
"dataState": "Successful empty Subject page with a return-to-first-page recovery link.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-state='empty']"
|
||||
},
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Empty Subjects" },
|
||||
{
|
||||
"id": "create-form",
|
||||
"label": "Create from empty Subjects",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "button.subject-create-trigger" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-panel input"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "subjects-error",
|
||||
"label": "Subject list error",
|
||||
"path": "/w/memory-review/memory?cursor=fixture-error-subject-page",
|
||||
"goal": "Keep explicit Subject creation available while the existing list cannot load.",
|
||||
"dataState": "Intentional Subject-list HTTP 503 with the independent create disclosure still available.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-state='error']"
|
||||
},
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Subjects unavailable" },
|
||||
{
|
||||
"id": "create-form",
|
||||
"label": "Create while list unavailable",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "button.subject-create-trigger" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-panel input"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "representative",
|
||||
@@ -44,7 +132,10 @@
|
||||
"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",
|
||||
"goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.",
|
||||
"dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" },
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='detail'] .revision-list"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Memory detail" }]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,10 +21,98 @@
|
||||
"id": "subjects",
|
||||
"label": "Subject index",
|
||||
"path": "/w/memory-review/memory",
|
||||
"goal": "Choose an explicit Memory subject and inspect state, role, store revision, worker availability, and long identities.",
|
||||
"dataState": "Active and retired subjects spanning representative, empty, stale, failed, ungenerated, and unavailable states.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='subjects'] .subject-list" },
|
||||
"capturePoints": [{ "id": "initial", "label": "Subjects" }]
|
||||
"goal": "Choose an explicit Memory subject or create one with a Backend-validated role.",
|
||||
"dataState": "Active and retired subjects across a paginated list, plus create-form and permission-rejection states.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='subjects'] .subject-list"
|
||||
},
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Subjects" },
|
||||
{
|
||||
"id": "create-form",
|
||||
"label": "Create Subject form",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "button.subject-create-trigger" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-panel input"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "permission-error",
|
||||
"label": "Create permission error",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#subject-create-panel input",
|
||||
"value": "Permission denied"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "#subject-create-panel button[type='submit']"
|
||||
},
|
||||
{
|
||||
"action": "wait",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "subjects-empty",
|
||||
"label": "Empty Subject index",
|
||||
"path": "/w/memory-review/memory?cursor=fixture-empty-subject-page",
|
||||
"goal": "Create the first visible Subject when the current bounded page is empty.",
|
||||
"dataState": "Successful empty Subject page with a return-to-first-page recovery link.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-state='empty']"
|
||||
},
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Empty Subjects" },
|
||||
{
|
||||
"id": "create-form",
|
||||
"label": "Create from empty Subjects",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "button.subject-create-trigger" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-panel input"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "subjects-error",
|
||||
"label": "Subject list error",
|
||||
"path": "/w/memory-review/memory?cursor=fixture-error-subject-page",
|
||||
"goal": "Keep explicit Subject creation available while the existing list cannot load.",
|
||||
"dataState": "Intentional Subject-list HTTP 503 with the independent create disclosure still available.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-state='error']"
|
||||
},
|
||||
"capturePoints": [
|
||||
{ "id": "initial", "label": "Subjects unavailable" },
|
||||
{
|
||||
"id": "create-form",
|
||||
"label": "Create while list unavailable",
|
||||
"interaction": [
|
||||
{ "action": "click", "selector": "button.subject-create-trigger" }
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#subject-create-panel input"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "representative",
|
||||
@@ -44,7 +132,10 @@
|
||||
"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",
|
||||
"goal": "Audit immutable Memory body, candidate evidence, source refs, derivations, and revision history.",
|
||||
"dataState": "Current active revision with bounded typed provenance and active/resolved/retracted immutable history.",
|
||||
"ready": { "kind": "selector", "selector": "[data-memory-view='detail'] .revision-list" },
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[data-memory-view='detail'] .revision-list"
|
||||
},
|
||||
"capturePoints": [{ "id": "initial", "label": "Memory detail" }]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user