From e8477882b58546e6e79e800a1269342347660327 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 31 Oct 2026 16:22:20 +0900 Subject: [PATCH] test: verify real browser Body recovery and authenticated conversation continuity --- .../src/server/tests/chat_tests.rs | 8 + .../src/server/tests/chat_web_connected.rs | 34 +++- .../subject_conversation_connected.ts | 147 +++++++++++++++--- .../memory/SubjectConversation.svelte | 15 +- 4 files changed, 173 insertions(+), 31 deletions(-) diff --git a/crates/workspace-server/src/server/tests/chat_tests.rs b/crates/workspace-server/src/server/tests/chat_tests.rs index 8c22a71aa..b8e6da761 100644 --- a/crates/workspace-server/src/server/tests/chat_tests.rs +++ b/crates/workspace-server/src/server/tests/chat_tests.rs @@ -968,6 +968,14 @@ impl ConnectedChat { let mut config = test_server_config(dir.path()); config.backend_base_url = Some(url.clone()); config.static_assets_dir = assets.clone(); + if assets.is_some() { + config.auth = AuthConfig::Passkey { + rp_id: "127.0.0.1".into(), + origin: url.clone(), + public_base_url: url.clone(), + cookie_name: "yoi_workspace_session".into(), + }; + } let control = Arc::new(test_control_store(&config)); seed_test_registered_workspace(control.as_ref(), &config) .await diff --git a/crates/workspace-server/src/server/tests/chat_web_connected.rs b/crates/workspace-server/src/server/tests/chat_web_connected.rs index 42d58f61c..44b974072 100644 --- a/crates/workspace-server/src/server/tests/chat_web_connected.rs +++ b/crates/workspace-server/src/server/tests/chat_web_connected.rs @@ -1,7 +1,7 @@ //! Explicit browser/component connection, not a separate product-process launcher. //! Reuses ConnectedChat's real server/auth/storage/Runtime/installed chat Feature. -//! The browser uses the actual static Web build and Account API-token auth; only -//! the external model and response-loss/Busy timing are controlled. Shared Body +//! The browser uses the actual static Web build and real persisted session-cookie auth; +//! only the external model and response-loss/Busy timing are controlled. Shared Body //! start/stop/restore and canonical/legacy Subject navigation stay real. Restore //! requires the runner-owned isolation checked before either server starts. //! Build `web/workspace` first, then explicitly select: @@ -133,6 +133,7 @@ async fn chat_web_connected_busy_unknown_replay_and_authorization_use_real_runti ) .await; let subject = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), json!({"operation_id":"browser-connected-subject","role":"Web connected companion","behavior_md":""})).await["id"].as_str().unwrap().to_owned(); + let other_subject = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"), json!({"operation_id":"browser-other-subject","role":"Other connected companion","behavior_md":""})).await["id"].as_str().unwrap().to_owned(); let conversation = f.post(&conversations_path(), json!({"operation_id":format!("web-subject-conversation/v1/{subject}"),"subject_id":subject})).await; let c = conversation["id"].as_str().unwrap(); *conversation_id.lock().unwrap() = c.into(); @@ -148,6 +149,31 @@ async fn chat_web_connected_busy_unknown_replay_and_authorization_use_real_runti f.lose_next_input_ack .store(true, std::sync::atomic::Ordering::SeqCst); let foreign = seed_test_api_token(f.api.store.as_ref(), "chat-connected-foreign"); + let mut sessions = Vec::new(); + for token in [&f.owner, &foreign] { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + let actor = resolve_actor(&ServerAuthApi::from(&f.api), &headers) + .await + .unwrap() + .unwrap(); + let session = uuid::Uuid::now_v7().to_string(); + f.api + .store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash(&session), + session_id: uuid::Uuid::now_v7().to_string(), + user_id: actor.user_id, + created_at: now_registry_timestamp(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + sessions.push(session); + } let mut child = tokio::process::Command::new("deno") .current_dir(&root) .args([ @@ -165,7 +191,7 @@ async fn chat_web_connected_busy_unknown_replay_and_authorization_use_real_runti .spawn() .expect("explicit browser test requires Deno"); let artifacts = root.join("target/t808-validation/body-connected"); - let wire = serde_json::to_vec(&json!({"origin":f.url,"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject,"subject_name":"Web connected companion","conversation_id":c,"owner_token":f.owner,"foreign_token":foreign,"artifact_dir":artifacts})).unwrap(); + let wire = serde_json::to_vec(&json!({"origin":f.url,"workspace_id":TEST_WORKSPACE_ID,"subject_id":subject,"other_subject_id":other_subject,"subject_name":"Web connected companion","conversation_id":c,"owner_token":f.owner,"foreign_token":foreign,"owner_session":sessions[0],"foreign_session":sessions[1],"artifact_dir":artifacts})).unwrap(); let mut stdin = child.stdin.take().unwrap(); stdin.write_all(&wire).await.unwrap(); drop(stdin); @@ -180,6 +206,8 @@ async fn chat_web_connected_busy_unknown_replay_and_authorization_use_real_runti String::from_utf8_lossy(bytes) .replace(&f.owner, "[redacted]") .replace(&foreign, "[redacted]") + .replace(&sessions[0], "[redacted]") + .replace(&sessions[1], "[redacted]") }; assert!( output.status.success(), diff --git a/tools/web-ux/browser-tests/subject_conversation_connected.ts b/tools/web-ux/browser-tests/subject_conversation_connected.ts index 8557c529e..ecc6db9b0 100644 --- a/tools/web-ux/browser-tests/subject_conversation_connected.ts +++ b/tools/web-ux/browser-tests/subject_conversation_connected.ts @@ -31,6 +31,7 @@ const git = async (args: string[]) => { }; const pageErrors: string[] = []; const captures: unknown[] = []; +const consoleErrors: { message: string; expected: boolean }[] = []; const manifest = { evidence: "actual-Web-Backend-SQLite-signed-Runtime; external model scripted", source: { @@ -42,6 +43,8 @@ const manifest = { visualVerdict: "UNREVIEWED: implementation author must view images", status: "running", pageErrors, + consoleErrors, + workspaceSubscriptions: 0, captures, }; const save = () => @@ -71,14 +74,50 @@ try { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, colorScheme: "light", - extraHTTPHeaders: { Authorization: `Bearer ${input.owner_token}` }, + extraHTTPHeaders: { Origin: origin.origin }, }); + const sessionCookie = (value: string) => ({ + name: "yoi_workspace_session", + value, + url: origin.origin, + httpOnly: true, + sameSite: "Lax" as const, + }); + await context.addCookies([sessionCookie(input.owner_session)]); const page = await context.newPage(); activePage = page; page.setDefaultTimeout(30000); const posts: Record[] = []; const bodyPosts: { path: string; input: Record }[] = []; page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() !== "error") return; + const text = message.text().replaceAll(input.owner_token, "[redacted]").replaceAll( + input.foreign_token, + "[redacted]", + ).replaceAll(input.owner_session, "[redacted]").replaceAll(input.foreign_session, "[redacted]"); + consoleErrors.push({ + message: text, + expected: /net::ERR_FAILED|status of (409|403)/.test(text), + }); + }); + const subscribed = new Promise((resolve, reject) => { + const deadline = setTimeout( + () => reject(new Error("real Workspace subscription deadline")), + 30000, + ); + page.on("websocket", (socket) => { + if (!new URL(socket.url()).pathname.endsWith("/protocol/ws")) return; + socket.on("framereceived", ({ payload }) => { + const frame = JSON.parse(typeof payload === "string" ? payload : payload.toString()); + if (frame.frame === "response" && frame.message?.result === "subscribed") { + manifest.workspaceSubscriptions++; + clearTimeout(deadline); + resolve(); + } + }); + }); + }); page.on("request", (request) => { const path = new URL(request.url()).pathname; if (request.method() !== "POST") return; @@ -88,7 +127,7 @@ try { if (path.startsWith(`${bodyPath}/`)) bodyPosts.push({ path, input: request.postDataJSON() }); }); // 有限の状態観測で同期する。refreshやACKからRuntimeのidleを推論しない。 - async function idle() { + async function idle(refreshControls = true) { await page.waitForFunction(async (path) => { const response = await fetch(path, { cache: "no-store" }); if (!response.ok) return false; @@ -96,13 +135,17 @@ try { return body.observation.kind === "available" && body.observation.worker.worker_state?.state.kind === "idle"; }, bodyPath); - await page.getByRole("button", { name: "Refresh body state", exact: true }).click(); - await page.getByText("idle", { exact: true }).first().waitFor(); + if (refreshControls) { + await page.getByRole("button", { name: "Refresh body state", exact: true }).click(); + await page.getByText("idle", { exact: true }).first().waitFor(); + } } const response = await page.goto(`${origin.origin}${legacy}${suffix}`); assertEquals(response?.status(), 200); await page.waitForURL(`${origin.origin}${detail}${suffix}`); await page.getByText("No current conversation body.", { exact: true }).waitFor(); + await subscribed; + assertEquals(await page.getByText("Worker updates unavailable", { exact: true }).count(), 0); await capture(page, "light-1440-absent-detail"); await page.getByRole("button", { name: "Start conversation body", exact: true }).click(); await idle(); @@ -115,6 +158,7 @@ try { await open.click(); await page.waitForURL(`${origin.origin}${chat}`); await page.getByText("No public messages yet.").waitFor(); + await page.getByRole("textbox", { name: `Message to ${input.subject_name}` }).focus(); await capture(page, "light-1440-empty-chat"); for (const alias of ["conversation", "chat"]) { const response = await page.goto(`${origin.origin}${legacy}/${alias}${suffix}`); @@ -243,7 +287,9 @@ try { const observed = await context.request.get(`${origin.origin}/__fixture/model-resumed`); assertEquals(observed.status(), 200); assertEquals(await observed.json(), { observed: true }); - await idle(); + // UIの停止tokenは再開後の新turnより前の実観測のまま残す。 + // Runtimeが新turnを終えても、その古い観測からの停止を認めない。 + await idle(false); await page.getByRole("button", { name: "Refresh history", exact: true }).click(); await history.getByText("WEB_CONNECTED_AFTER_RESTART", { exact: true }).waitFor(); assertEquals( @@ -258,30 +304,66 @@ try { await capture(page, `${theme}-${width}-restored-chat`, theme); } } + const staleResponse = page.waitForResponse((response) => + new URL(response.url()).pathname === `${bodyPath}/stop` && + response.request().method() === "POST" + ); + const independentObservation = page.waitForResponse((response) => + new URL(response.url()).pathname === bodyPath && response.request().method() === "GET" + ); await page.getByRole("button", { name: "Stop conversation body", exact: true }).click(); - const stopDisposition = await Promise.race([ - page.getByText("Body stopped.", { exact: true }).waitFor().then(() => "stopped"), - page.getByText("Request rejected.", { exact: true }).waitFor().then(() => "rejected"), - ]); - if (stopDisposition === "rejected") { - // 実Backendの既知の未実行拒否だけが新観測・新依頼へ進める。 - // unknownをここで清算したり、同IDの入力を差し替えたりしない。 - await page.getByRole("button", { name: "Refresh body state", exact: true }).click(); - await page.getByRole("button", { name: "Stop conversation body", exact: true }).click(); - await page.getByText("Body stopped.", { exact: true }).waitFor(); - const stops = bodyPosts.filter((post) => post.path === `${bodyPath}/stop`); - assert(stops.at(-1)!.input.request_id !== stops.at(-2)!.input.request_id); - } + const stale = await staleResponse; + assertEquals(stale.status(), 409); + const failed = await stale.json(); + assertEquals(Object.keys(failed).sort(), ["diagnostics", "error", "message", "request"]); + assertEquals(failed.request, { + workspace_id: input.workspace_id, + subject_id: input.subject_id, + request_id: bodyPosts.at(-1)!.input.request_id, + resolution: "not_accepted", + }); + await page.getByRole("alert").filter({ hasText: /Request not accepted\./ }).waitFor(); + assertEquals((await independentObservation).status(), 200); + await capture(page, "dark-390-stale-stop-not-accepted", "dark"); + // 既知terminal拒否と独立GETの後に限り、新たな利用者操作を行う。 + await page.getByRole("button", { name: "Stop conversation body", exact: true }).click(); + await page.getByText("Body stopped.", { exact: true }).waitFor(); + const stops = bodyPosts.filter((post) => post.path === `${bodyPath}/stop`); + assertEquals(stops.length, 3); + assert(stops.at(-1)!.input.request_id !== stops.at(-2)!.input.request_id); assertEquals(await history.getByRole("listitem").count(), 5); assertEquals(bodyPosts.filter((post) => post.path === `${bodyPath}/start`).length, 2); - assertEquals( - bodyPosts.filter((post) => post.path === `${bodyPath}/stop`).length, - stopDisposition === "rejected" ? 3 : 2, + // 通常のSPA導線で別Subjectへ移動し、元の履歴・Body・入力を持ち越さない。 + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.getByLabel("Workspace sections").getByRole("link", { name: "Subjects", exact: true }) + .click(); + await page.locator(`a[href="${subjectHref(input.workspace_id, input.other_subject_id)}"]`) + .click(); + await page.getByRole("link", { name: "Open conversation", exact: true }).click(); + await page.waitForURL( + `${origin.origin}${subjectChatHref(input.workspace_id, input.other_subject_id)}`, ); + await page.getByText("No public messages yet.").waitFor(); + await page.getByText("No current conversation body.", { exact: true }).waitFor(); + assertEquals(await history.getByRole("listitem").count(), 0); + assertEquals(await composer.count(), 0); + await capture(page, "dark-1440-other-subject", "dark"); + await page.getByLabel("Workspace sections").getByRole("link", { name: "Subjects", exact: true }) + .click(); + await page.locator(`a[href="${detail}"]`).click(); + await page.getByRole("link", { name: "Open conversation", exact: true }).click(); + await page.getByText("WEB_CONNECTED_AFTER_RESTART", { exact: true }).waitFor(); + assertEquals(await history.getByRole("listitem").count(), 5); + assertEquals(posts.length, 5, "navigation must not resubmit either conversation's input"); const deniedPath = `${origin.origin}${base}/${ encodeURIComponent(input.conversation_id) }/messages`; - await context.setExtraHTTPHeaders({ Authorization: `Bearer ${input.foreign_token}` }); + const ownerIdentity = await (await context.request.get(`${origin.origin}/api/auth/whoami`)) + .json(); + await context.addCookies([sessionCookie(input.foreign_session)]); + const foreignIdentity = await (await context.request.get(`${origin.origin}/api/auth/whoami`)) + .json(); + assert(ownerIdentity.actor.account_id !== foreignIdentity.actor.account_id); const denied = await context.request.post(deniedPath, { data: { operation_id: "foreign-cannot-post", text: "must not append" }, }); @@ -292,8 +374,29 @@ try { await page.getByRole("alert").filter({ hasText: /Body state could not be confirmed/ }).waitFor(); assertEquals(await page.getByRole("textbox").count(), 0); assertEquals(await history.getByRole("listitem").count(), 0); + await page.setViewportSize({ width: 390, height: 1000 }); await capture(page, "dark-390-permission-denied", "dark"); + const accountTab = await context.newPage(); + try { + await accountTab.goto(origin.origin); + await accountTab.evaluate( + 'localStorage.setItem("yoi.browser-auth-generation", JSON.stringify({id:crypto.randomUUID(),reason:"authentication"}))', + ); + await page.getByRole("alert").filter({ + hasText: /The conversation account could not be confirmed/, + }).waitFor(); + assertEquals( + await page.getByRole("heading", { name: "Conversation body", exact: true }).count(), + 0, + ); + assertEquals(await composer.count(), 0); + await capture(page, "dark-390-account-generation-fenced", "dark"); + } finally { + await accountTab.close(); + } assertEquals(pageErrors, []); + assertEquals(consoleErrors.filter((error) => !error.expected), []); + assert(manifest.workspaceSubscriptions > 0); manifest.status = "passed"; console.log( "real Web -> Backend -> Runtime -> explicit public replies passed; scripted model; shared Body start/stop/restore and canonical/legacy routes included", diff --git a/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte b/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte index 861760309..192aa598d 100644 --- a/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte +++ b/web/workspace/src/lib/workspace/memory/SubjectConversation.svelte @@ -189,7 +189,7 @@

Conversation

- {#if !inaccessible}{/if} + {#if !inaccessible}{/if}
{#if historyError}{/if} {#if !conversation && !historyError && !inaccessible}

Loading conversation…

{/if} @@ -202,7 +202,7 @@ {/each} {#if conversation && messages.length === 0}

No public messages yet.

{/if} - {#if hasMore && !inaccessible}{/if} + {#if hasMore && !inaccessible}{/if}
{#if sending}

Sending saved input…

@@ -210,11 +210,11 @@ {:else if attempt}

Previous send outcome is unknown. Its original input is retained.

{/if}
{#if sendError}{/if} - {#if storageBlocked}{/if} + {#if storageBlocked}{/if} {#if attempt}
Retained send input

{attempt.text}

Retry uses the same ID and full input. Runtime duplicate suppression has bounded receipt retention; it is not an indefinite delivery guarantee.

- {#if !inaccessible && !storageBlocked}{/if} + {#if !inaccessible && !storageBlocked}{/if} {/if} {#if !inaccessible}
{ event.preventDefault(); void send(); }}> @@ -223,7 +223,7 @@

Text only, up to {CHAT_LIMITS.maxTextBytes} UTF-8 bytes. Attachments are not supported.

{#if draftBytes > CHAT_LIMITS.maxTextBytes}

The message exceeds the text limit.

{/if} {#if attempt}

Resolve the retained send before sending another message. Your next draft remains editable.

{/if} - +
{/if}
@@ -238,6 +238,9 @@ .message-text { white-space: pre-wrap; overflow-wrap: anywhere; } form { display: grid; gap: var(--space-2); } textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line-strong); padding: var(--space-2); } - button { justify-self: start; } + button { justify-self: start; font-size: var(--font-size-body); line-height: var(--line-height-body); border-radius: var(--radius-soft); } + button:hover:not(:disabled) { background: var(--interactive-hover); } + button:active:not(:disabled) { background: var(--interactive-selected); } + button:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .error { color: var(--danger); }