mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
fix(web): toasts are announced, errors linger, hover holds them, and an unreachable server says so (TASK-2202) (#1914)
* fix(web): toasts are announced, errors linger and are assertive, hover and focus hold them, and an unreachable server says so (TASK-2202) * fix(web): the direct-fetch writes report an unreachable server too (TASK-2202 codex r1)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { browserLogin } from './lib/collab-helpers';
|
||||
import { createWorkspace, deleteWorkspace } from './lib/attachment-viewer';
|
||||
|
||||
/**
|
||||
* TASK-2202: when the server cannot be reached, the app says so, the error is
|
||||
* announced assertively, and it stays long enough to read.
|
||||
*
|
||||
* The page loads normally; then every API request is aborted the way an
|
||||
* offline network aborts it (`internetdisconnected`), and the command palette
|
||||
* fires a search. The search's own UI may handle its failure however it likes;
|
||||
* the "can't reach the server" toast comes from the API client's seam, so it
|
||||
* appears whatever the call site does.
|
||||
*/
|
||||
|
||||
test.describe('network unreachable toast (TASK-2202)', () => {
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'one browser is enough');
|
||||
});
|
||||
|
||||
test('an unreachable server raises an assertive error toast that outlasts the old 3s', async ({ page, fixture, request }) => {
|
||||
const { slug: ws } = await createWorkspace(fixture, request, 'Toast outage');
|
||||
try {
|
||||
await browserLogin(page);
|
||||
await page.goto(`/${fixture.adminUsername}/${ws}`);
|
||||
const announcer = page.locator('[data-toast-region="assertive"]');
|
||||
await expect(announcer, 'the assertive region exists before any toast').toHaveCount(1);
|
||||
await expect(announcer).toHaveAttribute('aria-live', 'assertive');
|
||||
await expect(announcer.locator('.toast')).toHaveCount(0);
|
||||
|
||||
await page.route('**/api/v1/**', (route) => route.abort('internetdisconnected'));
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
await page.keyboard.type('anything');
|
||||
|
||||
const toast = page.locator('.toast-error', { hasText: "Can't reach the server" });
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(announcer).toContainText("Can't reach the server");
|
||||
|
||||
// Errors linger: still up well past the old 3s default.
|
||||
await page.waitForTimeout(4000);
|
||||
await expect(toast).toBeVisible();
|
||||
} finally {
|
||||
await page.unroute('**/api/v1/**');
|
||||
await deleteWorkspace(fixture, request, ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
+87
-10
@@ -522,6 +522,70 @@ function notifyRateLimited(retryAfterMs?: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
type NetworkUnreachableHandler = () => void;
|
||||
|
||||
let networkUnreachableHandler: NetworkUnreachableHandler | null = null;
|
||||
|
||||
/**
|
||||
* Register a single callback fired when a request cannot reach the server at
|
||||
* all (TASK-2202): the browser's fetch rejected before any response, which is
|
||||
* what an offline laptop or a dead route looks like. +layout.svelte wires it to
|
||||
* a deduped "can't reach the server" toast, the same split as
|
||||
* setRateLimitHandler, so client.ts stays free of the toast store.
|
||||
*/
|
||||
export function setNetworkUnreachableHandler(handler: NetworkUnreachableHandler | null): void {
|
||||
networkUnreachableHandler = handler;
|
||||
}
|
||||
|
||||
function notifyNetworkUnreachable(): void {
|
||||
if (!networkUnreachableHandler) return;
|
||||
try {
|
||||
networkUnreachableHandler();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('network-unreachable handler threw', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The error a request throws when the server could not be reached (TASK-2202).
|
||||
* Its own code, so call sites and the toast can tell it from a refusal the
|
||||
* server made. A WRITE's message does not claim the change was lost: a fetch
|
||||
* can fail after the request left (a connection reset mid-response), so, as
|
||||
* with request_timeout, "not saved" is a claim nobody can make.
|
||||
*/
|
||||
export function networkUnreachableError(isIdempotent: boolean): PadApiError {
|
||||
return new PadApiError({
|
||||
code: 'network_unreachable',
|
||||
message: isIdempotent
|
||||
? "Can't reach the server. Check your connection and try again."
|
||||
: "Can't reach the server, so your change may not have been saved. Check your connection and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch` for a direct WRITE that bypasses request() (TASK-2202, codex r1):
|
||||
* attachment upload and transform, artifact import, checkout. Same rule as
|
||||
* requestAttempt: an abort keeps its own path (each site's deadline turns its
|
||||
* timeout into request_timeout), and any other rejection, which happened
|
||||
* before a response existed, reports the outage and becomes
|
||||
* network_unreachable instead of a raw TypeError.
|
||||
*/
|
||||
async function fetchWrite(input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetch(input, init);
|
||||
} catch (err) {
|
||||
if (init.signal?.aborted) throw err;
|
||||
notifyNetworkUnreachable();
|
||||
throw networkUnreachableError(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** True for the error a request throws when the server could not be reached. */
|
||||
export function isNetworkUnreachable(err: unknown): boolean {
|
||||
return err instanceof PadApiError && err.code === 'network_unreachable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a URL path and decide whether a 403 on it indicates that the
|
||||
* caller's READ access to the workspace's item set has been revoked.
|
||||
@@ -904,12 +968,25 @@ async function requestAttempt<T>(
|
||||
issuedAs: ReturnType<typeof currentIdentity>,
|
||||
deadline: ReturnType<typeof requestDeadline>,
|
||||
): Promise<T> {
|
||||
const resp = await fetch(BASE + path, {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
signal: deadline.signal,
|
||||
});
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(BASE + path, {
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
signal: deadline.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
// An abort, the caller's or our deadline's, keeps its own path:
|
||||
// request() turns a timeout into request_timeout, and a caller's abort
|
||||
// stays the AbortError it asked for. Anything else rejected before a
|
||||
// response existed, so the server was not reached (TASK-2202). Only
|
||||
// the fetch is wrapped: a TypeError from code below is a bug, not an
|
||||
// outage, and must not be reported as one.
|
||||
if (deadline.signal.aborted) throw err;
|
||||
notifyNetworkUnreachable();
|
||||
throw networkUnreachableError(isIdempotent);
|
||||
}
|
||||
// Every response, error statuses included, is a reading of the server's
|
||||
// clock; the sync cursor is stamped from these, never from Date.now()
|
||||
// (BUG-3207).
|
||||
@@ -2984,7 +3061,7 @@ export const api = {
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
|
||||
const qs = itemId ? `?item_id=${encodeURIComponent(itemId)}` : '';
|
||||
const resp = await fetch(`${BASE}/workspaces/${workspaceSlug}/attachments${qs}`, {
|
||||
const resp = await fetchWrite(`${BASE}/workspaces/${workspaceSlug}/attachments${qs}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
@@ -3052,7 +3129,7 @@ export const api = {
|
||||
// created.
|
||||
const deadline = requestDeadline(TRANSFORM_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
const resp = await fetchWrite(
|
||||
`${BASE}/workspaces/${workspaceSlug}/attachments/${attachmentId}/transform`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -3323,7 +3400,7 @@ export const api = {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/markdown' };
|
||||
const csrf = getCSRFToken();
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
const resp = await fetch(`${BASE}/workspaces/${ws}/import-artifact`, {
|
||||
const resp = await fetchWrite(`${BASE}/workspaces/${ws}/import-artifact`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
@@ -3427,7 +3504,7 @@ export const api = {
|
||||
// disabled on "Redirecting…" until it settles.
|
||||
const deadline = requestDeadline(requestTimeoutMs);
|
||||
try {
|
||||
const r = await fetch('/billing/checkout', {
|
||||
const r = await fetchWrite('/billing/checkout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// TASK-2202 — a request that cannot reach the server says so.
|
||||
//
|
||||
// A fetch that rejects before any response (offline, a dead route) used to
|
||||
// surface as a raw TypeError, so every call site showed its generic failure
|
||||
// text and nothing said the server was unreachable. It is now a PadApiError
|
||||
// with its own code, and the registered handler fires once per such request.
|
||||
// Aborts keep their own paths, and an error thrown AFTER a response exists is
|
||||
// not relabelled.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
api,
|
||||
PadApiError,
|
||||
isNetworkUnreachable,
|
||||
setNetworkUnreachableHandler,
|
||||
setRequestTimeoutForTests
|
||||
} from './client';
|
||||
|
||||
let notified = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
notified = 0;
|
||||
setNetworkUnreachableHandler(() => {
|
||||
notified++;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
setNetworkUnreachableHandler(null);
|
||||
setRequestTimeoutForTests();
|
||||
});
|
||||
|
||||
describe('network_unreachable (TASK-2202)', () => {
|
||||
it('a GET whose fetch rejects is network_unreachable, and the handler fires once', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch'))));
|
||||
const err = await api.workspaces.get('ws').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PadApiError);
|
||||
expect(err.code).toBe('network_unreachable');
|
||||
expect(isNetworkUnreachable(err)).toBe(true);
|
||||
expect(err.message).toBe("Can't reach the server. Check your connection and try again.");
|
||||
expect(notified).toBe(1);
|
||||
});
|
||||
|
||||
it("a WRITE's message does not claim the change was lost", async () => {
|
||||
const fetchMock = vi.fn(async () => Promise.reject(new TypeError('Failed to fetch')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const err = await api.workspaces.create({ name: 'x' } as never).catch((e) => e);
|
||||
expect(err.code).toBe('network_unreachable');
|
||||
expect(err.message).toContain('may not have been saved');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a caller's abort stays an AbortError and does not report an outage", async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
})
|
||||
)
|
||||
);
|
||||
const ctl = new AbortController();
|
||||
const p = api.items.copyPreflight('ws', 'item', {} as never, { signal: ctl.signal }).catch((e) => e);
|
||||
ctl.abort();
|
||||
const err = await p;
|
||||
expect(err).not.toBeInstanceOf(PadApiError);
|
||||
expect((err as DOMException).name).toBe('AbortError');
|
||||
expect(notified).toBe(0);
|
||||
});
|
||||
|
||||
it('a timeout stays request_timeout and does not report an outage', async () => {
|
||||
setRequestTimeoutForTests(30);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal as AbortSignal;
|
||||
signal.addEventListener('abort', () => reject(new TypeError('Failed to fetch')), { once: true });
|
||||
})
|
||||
)
|
||||
);
|
||||
const err = await api.workspaces.get('ws').catch((e) => e);
|
||||
expect(err.code).toBe('request_timeout');
|
||||
expect(notified).toBe(0);
|
||||
});
|
||||
|
||||
it('the direct-fetch WRITES (attachment upload, artifact import) report it too (codex r1)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => Promise.reject(new TypeError('Failed to fetch'))));
|
||||
const upload = await api.attachments.upload('ws', new Blob(['x'])).catch((e) => e);
|
||||
expect(upload.code).toBe('network_unreachable');
|
||||
expect(upload.message).toContain('may not have been saved');
|
||||
const imported = await api.importArtifact('ws', '# body').catch((e) => e);
|
||||
expect(imported.code).toBe('network_unreachable');
|
||||
expect(notified).toBe(2);
|
||||
});
|
||||
|
||||
it('a TypeError after a response arrived is not relabelled as an outage', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
json: async () => {
|
||||
throw new TypeError('body went wrong');
|
||||
}
|
||||
}) as unknown as Response
|
||||
)
|
||||
);
|
||||
const err = await api.workspaces.get('ws').catch((e) => e);
|
||||
expect(isNetworkUnreachable(err)).toBe(false);
|
||||
expect(notified).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the rune-based toast store so this stays a node test, like the busy
|
||||
// toast's test.
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({
|
||||
toastStore: { show: vi.fn() }
|
||||
}));
|
||||
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import {
|
||||
notifyNetworkUnreachable,
|
||||
NETWORK_UNREACHABLE_MESSAGE,
|
||||
__resetNetworkUnreachableToastForTest
|
||||
} from './networkUnreachableToast';
|
||||
|
||||
const showMock = toastStore.show as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('notifyNetworkUnreachable (TASK-2202)', () => {
|
||||
beforeEach(() => {
|
||||
showMock.mockClear();
|
||||
__resetNetworkUnreachableToastForTest();
|
||||
});
|
||||
|
||||
it('shows one ERROR toast with the unreachable message (an error, so it lingers and is assertive)', () => {
|
||||
expect(notifyNetworkUnreachable(0)).toBe(true);
|
||||
expect(showMock).toHaveBeenCalledTimes(1);
|
||||
expect(showMock).toHaveBeenCalledWith(NETWORK_UNREACHABLE_MESSAGE, 'error');
|
||||
});
|
||||
|
||||
it('an outage burst shows one toast, and a later outage shows another', () => {
|
||||
notifyNetworkUnreachable(0);
|
||||
notifyNetworkUnreachable(100);
|
||||
notifyNetworkUnreachable(14_999);
|
||||
expect(showMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyNetworkUnreachable(15_000)).toBe(true);
|
||||
expect(showMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
|
||||
/**
|
||||
* "Can't reach the server" toast for requests whose fetch rejected before any
|
||||
* response (TASK-2202). The API client fires `setNetworkUnreachableHandler`
|
||||
* once per such request; +layout.svelte wires that seam here, the same split
|
||||
* as serverBusyToast, so client.ts stays free of the toast store.
|
||||
*
|
||||
* It exists because most call sites show a fixed string ("Failed to save")
|
||||
* whatever went wrong, so an outage and a validation refusal looked the same.
|
||||
* This says WHY, once, beside whatever the call site shows.
|
||||
*
|
||||
* Deduped: an outage fails every in-flight request at once, and a stack of
|
||||
* identical toasts helps nobody. At most one inside DEDUPE_WINDOW_MS.
|
||||
*/
|
||||
|
||||
const DEDUPE_WINDOW_MS = 15_000;
|
||||
|
||||
export const NETWORK_UNREACHABLE_MESSAGE =
|
||||
"Can't reach the server. Check your connection; changes made now may not be saved.";
|
||||
|
||||
let lastShownAt = Number.NEGATIVE_INFINITY;
|
||||
|
||||
/**
|
||||
* Show the deduped error toast. An error, so it is announced assertively and
|
||||
* lingers (toast store defaults). Returns whether a toast was shown; `now` is
|
||||
* injectable for tests.
|
||||
*/
|
||||
export function notifyNetworkUnreachable(now: number = Date.now()): boolean {
|
||||
if (now - lastShownAt < DEDUPE_WINDOW_MS) return false;
|
||||
lastShownAt = now;
|
||||
toastStore.show(NETWORK_UNREACHABLE_MESSAGE, 'error');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test-only: reset the dedupe clock. */
|
||||
export function __resetNetworkUnreachableToastForTest(): void {
|
||||
lastShownAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
@@ -18,39 +18,84 @@
|
||||
goto(toast.link);
|
||||
}
|
||||
}
|
||||
|
||||
// ANNOUNCEMENT (TASK-2202). The stacks below ARE the live regions, and
|
||||
// both are mounted for the page's whole life, empty until a toast arrives:
|
||||
// screen readers announce a node added to a region that already exists,
|
||||
// and miss one that arrives together with its region. The single stack
|
||||
// used to be wrapped in {#if toasts.length}, so a toast after an idle
|
||||
// spell mounted with its region and was usually never read.
|
||||
//
|
||||
// Errors go in an assertive region (they interrupt), the rest in a polite
|
||||
// one. aria-atomic is left false, so only the added toast is read, not the
|
||||
// whole stack. Each toast is its own keyed node, so the same text twice is
|
||||
// still a new node and is read again. The text lives in the toast itself,
|
||||
// once: no hidden copy for a test or a screen reader to find twice.
|
||||
const errors = $derived(toastStore.toasts.filter((t) => t.type === 'error'));
|
||||
const others = $derived(toastStore.toasts.filter((t) => t.type !== 'error'));
|
||||
|
||||
// HOLD (TASK-2202): a toast stays while the pointer is over it or focus is
|
||||
// inside it. The two are separate holds, so leaving with the mouse while a
|
||||
// button inside still has focus keeps it up.
|
||||
function holdOnFocusOut(e: FocusEvent, id: string) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (next && (e.currentTarget as HTMLElement).contains(next)) return;
|
||||
toastStore.resume(id);
|
||||
}
|
||||
function holdOnFocusIn(e: FocusEvent, id: string) {
|
||||
const prev = e.relatedTarget as Node | null;
|
||||
if (prev && (e.currentTarget as HTMLElement).contains(prev)) return;
|
||||
toastStore.pause(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if toastStore.toasts.length > 0}
|
||||
<div class="toast-container" role="status" aria-live="polite">
|
||||
{#each toastStore.toasts as toast (toast.id)}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="toast toast-{toast.type}"
|
||||
class:clickable={!!toast.link}
|
||||
in:fly={{ x: 80, duration: 250 }}
|
||||
out:fade={{ duration: 150 }}
|
||||
onclick={() => handleClick(toast)}
|
||||
role={toast.link ? 'button' : undefined}
|
||||
tabindex={toast.link ? 0 : -1}
|
||||
onkeydown={(e) => { if (toast.link && e.key === 'Enter') handleClick(toast); }}
|
||||
>
|
||||
<span class="toast-icon">{iconForType(toast.type)}</span>
|
||||
<span class="toast-message">{toast.message}{#if toast.link}<span class="toast-link-hint"> →</span>{/if}</span>
|
||||
{#if toast.action}
|
||||
<button
|
||||
class="toast-action"
|
||||
onclick={(e) => { e.stopPropagation(); toast.action?.onAction(); toastStore.dismiss(toast.id); }}
|
||||
>{toast.action.label}</button>
|
||||
{/if}
|
||||
<button
|
||||
class="toast-dismiss"
|
||||
onclick={(e) => { e.stopPropagation(); toastStore.dismiss(toast.id); }}
|
||||
aria-label="Dismiss notification"
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#snippet toastItem(toast: Toast)}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="toast toast-{toast.type}"
|
||||
class:clickable={!!toast.link}
|
||||
data-toast-id={toast.id}
|
||||
in:fly={{ x: 80, duration: 250 }}
|
||||
out:fade={{ duration: 150 }}
|
||||
onclick={() => handleClick(toast)}
|
||||
onmouseenter={() => toastStore.pause(toast.id)}
|
||||
onmouseleave={() => toastStore.resume(toast.id)}
|
||||
onfocusin={(e) => holdOnFocusIn(e, toast.id)}
|
||||
onfocusout={(e) => holdOnFocusOut(e, toast.id)}
|
||||
role={toast.link ? 'button' : undefined}
|
||||
tabindex={toast.link ? 0 : -1}
|
||||
onkeydown={(e) => {
|
||||
if (!toast.link || e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClick(toast);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="toast-icon">{iconForType(toast.type)}</span>
|
||||
<span class="toast-message">{toast.message}{#if toast.link}<span class="toast-link-hint"> →</span>{/if}</span>
|
||||
{#if toast.action}
|
||||
<button
|
||||
class="toast-action"
|
||||
onclick={(e) => { e.stopPropagation(); toast.action?.onAction(); toastStore.dismiss(toast.id); }}
|
||||
>{toast.action.label}</button>
|
||||
{/if}
|
||||
<button
|
||||
class="toast-dismiss"
|
||||
onclick={(e) => { e.stopPropagation(); toastStore.dismiss(toast.id); }}
|
||||
aria-label="Dismiss notification"
|
||||
>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="toast-container">
|
||||
<div class="toast-stack" aria-live="assertive" data-toast-region="assertive">
|
||||
{#each errors as toast (toast.id)}{@render toastItem(toast)}{/each}
|
||||
</div>
|
||||
<div class="toast-stack" aria-live="polite" data-toast-region="polite">
|
||||
{#each others as toast (toast.id)}{@render toastItem(toast)}{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toast-container {
|
||||
@@ -65,6 +110,12 @@
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// TASK-2202 — the toast live regions exist before any toast does, errors go to
|
||||
// the assertive one, hover and focus hold a toast, and a link toast answers
|
||||
// Space as well as Enter (but not keys pressed on its own buttons).
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render } from '@testing-library/svelte';
|
||||
import { flushSync, tick } from 'svelte';
|
||||
|
||||
const nav = vi.hoisted(() => ({ goto: [] as string[] }));
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn(async (url: string) => {
|
||||
nav.goto.push(url);
|
||||
})
|
||||
}));
|
||||
|
||||
import ToastContainer from './ToastContainer.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
|
||||
const regions = (c: HTMLElement) => ({
|
||||
polite: c.querySelector('[aria-live="polite"]') as HTMLElement | null,
|
||||
assertive: c.querySelector('[aria-live="assertive"]') as HTMLElement | null
|
||||
});
|
||||
|
||||
// jsdom has no Web Animations API, which Svelte's fly/fade transitions use.
|
||||
// A stand-in that finishes at once: these tests are about announcements and
|
||||
// timers, not motion.
|
||||
if (!('animate' in Element.prototype)) {
|
||||
(Element.prototype as unknown as { animate: unknown }).animate = function () {
|
||||
const animation = {
|
||||
onfinish: null as null | (() => void),
|
||||
cancel() {},
|
||||
finish() {},
|
||||
currentTime: 0,
|
||||
addEventListener() {},
|
||||
finished: Promise.resolve()
|
||||
};
|
||||
queueMicrotask(() => animation.onfinish?.());
|
||||
return animation;
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nav.goto.length = 0;
|
||||
toastStore.clearAll();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
toastStore.clearAll();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('ToastContainer announcements (TASK-2202)', () => {
|
||||
it('both live regions are mounted and empty before any toast exists', () => {
|
||||
const { container } = render(ToastContainer);
|
||||
const r = regions(container);
|
||||
expect(r.polite, 'polite region exists with no toasts').not.toBeNull();
|
||||
expect(r.assertive, 'assertive region exists with no toasts').not.toBeNull();
|
||||
expect(r.polite!.textContent?.trim()).toBe('');
|
||||
expect(r.assertive!.textContent?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('an error is announced in the assertive region, a success in the polite one, each once', async () => {
|
||||
const { container } = render(ToastContainer);
|
||||
const r = regions(container);
|
||||
const politeBefore = r.polite;
|
||||
toastStore.show('Saved', 'success');
|
||||
await tick();
|
||||
toastStore.show('Could not save', 'error');
|
||||
await tick();
|
||||
expect(r.polite!.textContent).toContain('Saved');
|
||||
expect(r.polite!.textContent).not.toContain('Could not save');
|
||||
expect(r.assertive!.textContent).toContain('Could not save');
|
||||
// The SAME region element: content was added to a region that already
|
||||
// existed, which is what screen readers announce.
|
||||
expect(regions(container).polite).toBe(politeBefore);
|
||||
// The toast itself sits in its region, once: no hidden copy of the
|
||||
// text that a screen reader (or a test) would find twice.
|
||||
expect(r.assertive!.querySelector('.toast-error')).not.toBeNull();
|
||||
expect(r.polite!.querySelector('.toast-error')).toBeNull();
|
||||
expect(container.textContent!.split('Could not save').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('the same text twice in a row is a new node, so it is read again', async () => {
|
||||
const { container } = render(ToastContainer);
|
||||
toastStore.show('Saved', 'success');
|
||||
await tick();
|
||||
toastStore.show('Saved', 'success');
|
||||
await tick();
|
||||
expect(regions(container).polite!.querySelectorAll('.toast').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToastContainer hold and keys (TASK-2202)', () => {
|
||||
it('hovering a toast holds it past its duration; leaving lets it go', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(ToastContainer);
|
||||
const id = toastStore.show('Saved', 'success', 3000);
|
||||
flushSync();
|
||||
const el = container.querySelector(`[data-toast-id="${id}"]`) as HTMLElement;
|
||||
await fireEvent.mouseEnter(el);
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(toastStore.toasts.some((t) => t.id === id)).toBe(true);
|
||||
await fireEvent.mouseLeave(el);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(toastStore.toasts.some((t) => t.id === id)).toBe(false);
|
||||
});
|
||||
|
||||
it('focus inside a toast holds it, and moving focus between its own buttons keeps the hold', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(ToastContainer);
|
||||
const id = toastStore.show('Archived', 'success', 3000, undefined, { label: 'Undo', onAction: () => {} });
|
||||
flushSync();
|
||||
const el = container.querySelector(`[data-toast-id="${id}"]`) as HTMLElement;
|
||||
const undo = el.querySelector('.toast-action') as HTMLElement;
|
||||
const dismiss = el.querySelector('.toast-dismiss') as HTMLElement;
|
||||
await fireEvent.focusIn(undo, { relatedTarget: null });
|
||||
await fireEvent.focusOut(undo, { relatedTarget: dismiss });
|
||||
await fireEvent.focusIn(dismiss, { relatedTarget: undo });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(toastStore.toasts.some((t) => t.id === id), 'held while focus stays inside').toBe(true);
|
||||
await fireEvent.focusOut(dismiss, { relatedTarget: null });
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(toastStore.toasts.some((t) => t.id === id)).toBe(false);
|
||||
});
|
||||
|
||||
it('a link toast navigates on Space as well as Enter', async () => {
|
||||
const { container } = render(ToastContainer);
|
||||
const id = toastStore.show('Open it', 'info', 3000, '/somewhere');
|
||||
flushSync();
|
||||
const el = container.querySelector(`[data-toast-id="${id}"]`) as HTMLElement;
|
||||
await fireEvent.keyDown(el, { key: ' ' });
|
||||
expect(nav.goto).toEqual(['/somewhere']);
|
||||
});
|
||||
|
||||
it('Enter on the Dismiss button dismisses without navigating', async () => {
|
||||
const { container } = render(ToastContainer);
|
||||
const id = toastStore.show('Open it', 'info', 3000, '/somewhere');
|
||||
flushSync();
|
||||
const dismiss = container.querySelector(`[data-toast-id="${id}"] .toast-dismiss`) as HTMLElement;
|
||||
await fireEvent.keyDown(dismiss, { key: 'Enter' });
|
||||
expect(nav.goto).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,12 @@ export interface HistoryEntry {
|
||||
const MAX_TOASTS = 5;
|
||||
const MAX_HISTORY = 20;
|
||||
const DEFAULT_DURATION = 3000;
|
||||
/**
|
||||
* Errors linger (TASK-2202): an error is the toast a person most needs to read,
|
||||
* and at 3s it vanished before most could. Applied when the caller passes no
|
||||
* duration; an explicit duration still wins.
|
||||
*/
|
||||
export const ERROR_DURATION = 10000;
|
||||
|
||||
/**
|
||||
* Test-surface kill switch for CROSS-ACTOR notification toasts (BUG-2334).
|
||||
@@ -56,13 +62,30 @@ export function quietExternalToasts(): boolean {
|
||||
let toasts = $state<Toast[]>([]);
|
||||
let history = $state<HistoryEntry[]>([]);
|
||||
let unreadCount = $state(0);
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/**
|
||||
* Each toast's auto-dismiss clock. `holds` counts the reasons it is paused
|
||||
* (hover, keyboard focus: TASK-2202); the clock runs only at zero holds, and
|
||||
* `remaining` is what is left of the duration when it was last paused.
|
||||
*/
|
||||
interface ToastClock {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
startedAt: number;
|
||||
remaining: number;
|
||||
holds: number;
|
||||
}
|
||||
const timers = new Map<string, ToastClock>();
|
||||
|
||||
function startClock(id: string, clock: ToastClock): void {
|
||||
clock.startedAt = Date.now();
|
||||
clock.timer = setTimeout(() => dismiss(id), clock.remaining);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
}
|
||||
|
||||
function show(message: string, type: Toast['type'] = 'info', duration: number = DEFAULT_DURATION, link?: string, action?: ToastAction): string {
|
||||
function show(message: string, type: Toast['type'] = 'info', duration?: number, link?: string, action?: ToastAction): string {
|
||||
duration ??= type === 'error' ? ERROR_DURATION : DEFAULT_DURATION;
|
||||
const id = generateId();
|
||||
const toast: Toast = { id, message, type, duration, link, action };
|
||||
|
||||
@@ -84,14 +107,36 @@ function show(message: string, type: Toast['type'] = 'info', duration: number =
|
||||
}
|
||||
|
||||
// Auto-dismiss after duration
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(id);
|
||||
}, duration);
|
||||
timers.set(id, timer);
|
||||
const clock: ToastClock = { timer: null, startedAt: 0, remaining: duration, holds: 0 };
|
||||
timers.set(id, clock);
|
||||
startClock(id, clock);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a toast on screen while the pointer is over it or focus is inside it
|
||||
* (TASK-2202): a person reading or reaching for its button should not have it
|
||||
* vanish. Holds nest, so a hover that ends while focus stays keeps it held.
|
||||
*/
|
||||
function pause(id: string): void {
|
||||
const clock = timers.get(id);
|
||||
if (!clock) return;
|
||||
clock.holds++;
|
||||
if (clock.holds > 1 || clock.timer === null) return;
|
||||
clearTimeout(clock.timer);
|
||||
clock.timer = null;
|
||||
clock.remaining = Math.max(0, clock.remaining - (Date.now() - clock.startedAt));
|
||||
}
|
||||
|
||||
/** Release one hold; the clock resumes with what was left when the last one goes. */
|
||||
function resume(id: string): void {
|
||||
const clock = timers.get(id);
|
||||
if (!clock || clock.holds === 0) return;
|
||||
clock.holds--;
|
||||
if (clock.holds === 0) startClock(id, clock);
|
||||
}
|
||||
|
||||
function dismiss(id: string): void {
|
||||
clearTimerFor(id);
|
||||
const idx = toasts.findIndex((t) => t.id === id);
|
||||
@@ -101,9 +146,9 @@ function dismiss(id: string): void {
|
||||
}
|
||||
|
||||
function clearTimerFor(id: string): void {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
const clock = timers.get(id);
|
||||
if (clock) {
|
||||
if (clock.timer !== null) clearTimeout(clock.timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +187,8 @@ export const toastStore = {
|
||||
},
|
||||
show,
|
||||
dismiss,
|
||||
pause,
|
||||
resume,
|
||||
markAllRead,
|
||||
clearHistory,
|
||||
clearAll
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// TASK-2202 — errors linger, and a toast someone is reading or reaching for
|
||||
// does not vanish under them. Fake timers, so durations are exact.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { toastStore, ERROR_DURATION } from './toast.svelte';
|
||||
|
||||
const present = (id: string) => toastStore.toasts.some((t) => t.id === id);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
toastStore.clearAll();
|
||||
});
|
||||
afterEach(() => {
|
||||
toastStore.clearAll();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('toast durations (TASK-2202)', () => {
|
||||
it('an error with no duration stays for ERROR_DURATION, well past the 3s default', () => {
|
||||
expect(ERROR_DURATION).toBeGreaterThanOrEqual(8000);
|
||||
const id = toastStore.show('Could not save', 'error');
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(present(id), 'still up at the old 3s').toBe(true);
|
||||
vi.advanceTimersByTime(ERROR_DURATION - 3000 - 1);
|
||||
expect(present(id)).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(present(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('success keeps the 3s default, and an explicit duration still wins for errors', () => {
|
||||
const ok = toastStore.show('Saved', 'success');
|
||||
const brief = toastStore.show('Brief error', 'error', 2000);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(present(brief)).toBe(false);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(present(ok)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast hold (TASK-2202)', () => {
|
||||
it('a paused toast stays, and resumes with only what was left', () => {
|
||||
const id = toastStore.show('Saved', 'success', 3000);
|
||||
vi.advanceTimersByTime(2000);
|
||||
toastStore.pause(id);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(present(id), 'held while paused').toBe(true);
|
||||
toastStore.resume(id);
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(present(id)).toBe(true);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(present(id), 'gone after the remaining 1s').toBe(false);
|
||||
});
|
||||
|
||||
it('holds nest: hover ending while focus stays inside keeps it up', () => {
|
||||
const id = toastStore.show('Saved', 'success', 3000);
|
||||
toastStore.pause(id); // hover
|
||||
toastStore.pause(id); // focus
|
||||
toastStore.resume(id); // hover ends
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(present(id)).toBe(true);
|
||||
toastStore.resume(id); // focus leaves
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(present(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('an unmatched resume does not start a second clock', () => {
|
||||
const id = toastStore.show('Saved', 'success', 3000);
|
||||
toastStore.resume(id);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(present(id)).toBe(false);
|
||||
});
|
||||
|
||||
it('dismissing a paused toast leaves nothing behind', () => {
|
||||
const id = toastStore.show('Saved', 'success', 3000);
|
||||
toastStore.pause(id);
|
||||
toastStore.dismiss(id);
|
||||
toastStore.resume(id);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(present(id)).toBe(false);
|
||||
expect(toastStore.toasts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,9 @@
|
||||
} from '$lib/stores/identityReload.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
import { setAccessRevokedHandler, setRateLimitHandler, setIdentityProvider } from '$lib/api/client';
|
||||
import { setAccessRevokedHandler, setRateLimitHandler, setIdentityProvider, setNetworkUnreachableHandler } from '$lib/api/client';
|
||||
import { notifyServerBusy } from '$lib/api/serverBusyToast';
|
||||
import { notifyNetworkUnreachable } from '$lib/api/networkUnreachableToast';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import Sidebar from '$lib/components/layout/Sidebar.svelte';
|
||||
import TopBar from '$lib/components/layout/TopBar.svelte';
|
||||
@@ -125,6 +126,12 @@
|
||||
setRateLimitHandler((retryAfterMs) => {
|
||||
notifyServerBusy(retryAfterMs);
|
||||
});
|
||||
// A request whose fetch rejected before any response (TASK-2202): one
|
||||
// deduped "can't reach the server" error toast, beside whatever the call
|
||||
// site shows. Same split as the busy toast above.
|
||||
setNetworkUnreachableHandler(() => {
|
||||
notifyNetworkUnreachable();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Re-arm the one-shot workspace-load latch whenever the signed-in user
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock('$lib/api/client', () => {
|
||||
// without it throws before any assertion runs.
|
||||
setIdentityProvider: () => {},
|
||||
setRateLimitHandler: () => {},
|
||||
setNetworkUnreachableHandler: () => {},
|
||||
isPlanLimitError: () => false,
|
||||
planLimitMessage: () => '',
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock('$lib/api/client', () => ({
|
||||
// without it throws before any assertion runs.
|
||||
setIdentityProvider: () => {},
|
||||
setRateLimitHandler: () => {},
|
||||
setNetworkUnreachableHandler: () => {},
|
||||
isPlanLimitError: () => false,
|
||||
planLimitMessage: () => '',
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user