Fix mobile web gestures, compact chat layout and notification diagnostics

This commit is contained in:
2026-11-02 18:34:44 -08:00
parent d4f8710ee8
commit 332d10b401
17 changed files with 683 additions and 66 deletions
+68 -1
View File
@@ -6,6 +6,7 @@ import {
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
} from "../src/lib/agent-push.ts";
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
import { AgentPushError, browserPushOperation } from "../src/lib/agent-push-errors.ts";
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
@@ -120,7 +121,72 @@ test("a stuck cross-tab lock cannot prevent server retirement or local logout cl
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
await f.controller.refresh(); await f.controller.enable();
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /browser.*system settings/);
});
test("browser subscription failures retain safe diagnoses across background refreshes", async () => {
for (const [name, message] of [["AbortError", /push service \(AbortError\)/], ["NotAllowedError", /site settings.*system settings/],
["NotSupportedError", /cannot create a push subscription/], ["InvalidStateError", /incompatible notification subscription/], ["InvalidAccessError", /notification key/]]) {
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => Promise.reject(new DOMException("private-endpoint-and-key", name)), "subscription") } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, message);
assert.equal(f.controller.state().enabled, false);
assert.ok(!f.calls.some(([name]) => name === "register"));
await f.controller.refresh();
assert.match(f.controller.state().error, message, "heartbeat cannot erase a failed opt-in");
assert.doesNotMatch(JSON.stringify(f.states), /private-endpoint-and-key/);
await f.controller.refresh(true);
assert.equal(f.controller.state().error, null, "explicit refresh clears the previous attempt");
}
});
test("worker startup and subscription timeouts have distinct actionable errors", async () => {
for (const [stage, message] of [["worker", /worker could not start/], ["subscription", /push service \(TimeoutError\)/]]) {
const f = fixture({ platform: { subscribe: () => browserPushOperation(() => new Promise(() => {}), stage, 5) } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().busy, false);
assert.equal(f.controller.state().enabled, false);
}
});
test("dismissed prompts and insecure contexts do not attempt a subscription", async () => {
const f = fixture({ platform: { requestPermission: async () => "default" } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, /dismissed/); assert.equal(f.calls.length, 0);
f.setSupport("insecure");
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /HTTPS/);
});
test("notification API errors retain status-specific guidance through rollback without echoing bodies", async () => {
for (const [status, message] of [[401, /sign-in.*authorized/], [403, /sign-in.*authorized/], [400, /rejected.*subscription/],
[409, /expired/], [429, /busy/], [503, /unavailable on the Sybil server/], [500, /server.*save notification settings/]]) {
const api = new AgentPushClient({ baseUrl: "https://sybil.example", token: () => "private-auth-token",
fetch: async () => new Response(JSON.stringify({ error: "private-endpoint-and-key" }), { status }) });
const f = fixture({ api: { register: (id, subscription) => api.register(id, subscription) } });
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, message); assert.equal(f.controller.state().enabled, false);
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
assert.doesNotMatch(JSON.stringify(f.states), /private-auth-token|private-endpoint-and-key/);
}
});
test("failed storage after subscribing still unsubscribes and retains cleanup intent", async () => {
const f = fixture(); let writes = 0;
const write = f.options.write;
f.options.write = value => { if (++writes > 1) throw new DOMException("private-storage-detail", "QuotaExceededError"); write(value); };
await f.controller.refresh(); await f.controller.enable();
assert.match(f.controller.state().error, /storage.*free some space/);
assert.equal(f.controller.state().enabled, false);
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
assert.ok(f.calls.some(([name]) => name === "revoke"));
assert.ok(!f.calls.some(([name]) => name === "register"));
assert.doesNotMatch(JSON.stringify(f.states), /private-storage-detail/);
});
test("server errors during refresh are distinct from unavailable browser push", async () => {
const f = fixture({ api: { config: async () => { throw new AgentPushError("Web Push is unavailable on the Sybil server."); } } });
await f.controller.refresh();
assert.match(f.controller.state().error, /Sybil server/); assert.equal(f.controller.state().canEnable, false);
});
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
@@ -418,6 +484,7 @@ test("read frames validate source/cursor and preserve existing read semantics",
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
assert.throws(() => parseAgentPushConfig({ ...configuration(), vapidPublicKey: undefined }), /invalid notification configuration/);
});
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {