test: verify real browser Body recovery and authenticated conversation continuity

This commit is contained in:
2026-10-31 16:22:20 +09:00
parent af074254ec
commit e8477882b5
4 changed files with 173 additions and 31 deletions
@@ -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
@@ -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(),
@@ -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<string, unknown>[] = [];
const bodyPosts: { path: string; input: Record<string, unknown> }[] = [];
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<void>((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",
@@ -189,7 +189,7 @@
<section class="subject-conversation" aria-label="Conversation with {subjectName}">
<header>
<h2>Conversation</h2>
{#if !inaccessible}<button type="button" onclick={() => refresh()} disabled={reading}>{reading ? 'Loading history…' : 'Refresh history'}</button>{/if}
{#if !inaccessible}<button class="workspace-secondary-button" type="button" onclick={() => refresh()} disabled={reading}>{reading ? 'Loading history…' : 'Refresh history'}</button>{/if}
</header>
{#if historyError}<p class="error" role="alert">{historyError}</p>{/if}
{#if !conversation && !historyError && !inaccessible}<p role="status">Loading conversation…</p>{/if}
@@ -202,7 +202,7 @@
{/each}
</ol>
{#if conversation && messages.length === 0}<p>No public messages yet.</p>{/if}
{#if hasMore && !inaccessible}<button type="button" onclick={() => refresh()} disabled={reading}>Load newer messages</button>{/if}
{#if hasMore && !inaccessible}<button class="workspace-secondary-button" type="button" onclick={() => refresh()} disabled={reading}>Load newer messages</button>{/if}
<div class="send-status" aria-live="polite">
{#if sending}<p>Sending saved input…</p>
@@ -210,11 +210,11 @@
{:else if attempt}<p>Previous send outcome is unknown. Its original input is retained.</p>{/if}
</div>
{#if sendError}<p class="error" role="alert">{sendError}</p>{/if}
{#if storageBlocked}<button type="button" onclick={recheckStorage} disabled={sending}>Recheck retry storage</button>{/if}
{#if storageBlocked}<button class="workspace-secondary-button" type="button" onclick={recheckStorage} disabled={sending}>Recheck retry storage</button>{/if}
{#if attempt}
<details><summary>Retained send input</summary><p class="message-text">{attempt.text}</p></details>
<p>Retry uses the same ID and full input. Runtime duplicate suppression has bounded receipt retention; it is not an indefinite delivery guarantee.</p>
{#if !inaccessible && !storageBlocked}<button type="button" onclick={() => send(true)} disabled={sending || !conversation}>Retry same input</button>{/if}
{#if !inaccessible && !storageBlocked}<button class="workspace-secondary-button" type="button" onclick={() => send(true)} disabled={sending || !conversation}>Retry same input</button>{/if}
{/if}
{#if !inaccessible}
<form onsubmit={(event) => { event.preventDefault(); void send(); }}>
@@ -223,7 +223,7 @@
<p id="conversation-constraint">Text only, up to {CHAT_LIMITS.maxTextBytes} UTF-8 bytes. Attachments are not supported.</p>
{#if draftBytes > CHAT_LIMITS.maxTextBytes}<p class="error">The message exceeds the text limit.</p>{/if}
{#if attempt}<p>Resolve the retained send before sending another message. Your next draft remains editable.</p>{/if}
<button type="submit" disabled={sending || !!attempt || storageBlocked || !conversation || !draft.trim() || draftBytes > CHAT_LIMITS.maxTextBytes}>Send message</button>
<button class="workspace-secondary-button" type="submit" disabled={sending || !!attempt || storageBlocked || !conversation || !draft.trim() || draftBytes > CHAT_LIMITS.maxTextBytes}>Send message</button>
</form>
{/if}
</section>
@@ -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); }
</style>