Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -1,46 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createProvisioningRefresh, type ProvisioningRefreshState } from '../../src/lib/ui/provisioning-refresh.js';
|
||||
import {
|
||||
createProvisioningRefresh,
|
||||
type ProvisioningRefreshState
|
||||
} from '../../src/lib/ui/provisioning-refresh.js';
|
||||
|
||||
type Detail = { id: string; revision: number };
|
||||
const detail = (id: string, revision = 1): Detail => ({ id, revision });
|
||||
const response = (id: string, revision = 1) => Response.json(detail(id, revision));
|
||||
const controllers: { dispose: () => void }[] = [];
|
||||
afterEach(() => { controllers.splice(0).forEach(controller => controller.dispose()); vi.useRealTimers(); });
|
||||
afterEach(() => {
|
||||
controllers.splice(0).forEach((controller) => controller.dispose());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
function setup(fetcher: typeof fetch) {
|
||||
let state!: ProvisioningRefreshState<Detail>;
|
||||
const onChange = vi.fn(value => { state = value; });
|
||||
const controller = createProvisioningRefresh<Detail>({ fetcher, onChange, timeoutMs: 100,
|
||||
validate: (value, id): value is Detail => !!value && typeof value === 'object' && (value as Detail).id === id,
|
||||
now: () => '2026-09-28T12:00:00Z' });
|
||||
const onChange = vi.fn((value) => {
|
||||
state = value;
|
||||
});
|
||||
const controller = createProvisioningRefresh<Detail>({
|
||||
fetcher,
|
||||
onChange,
|
||||
timeoutMs: 100,
|
||||
validate: (value, id): value is Detail =>
|
||||
!!value && typeof value === 'object' && (value as Detail).id === id,
|
||||
now: () => '2026-09-28T12:00:00Z'
|
||||
});
|
||||
controllers.push(controller);
|
||||
return { ...controller, onChange, state: () => state };
|
||||
}
|
||||
function deferred() {
|
||||
let resolve!: (value: Response) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<Response>((done, fail) => { resolve = done; reject = fail; });
|
||||
const promise = new Promise<Response>((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('provisioning detail refresh ownership', () => {
|
||||
it('requests a rebuild retry and refreshes the original failed operation', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true, deploymentId: 'recovery-a' })).mockResolvedValueOnce(response('a', 2));
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Response.json({ ok: true, deploymentId: 'recovery-a' }))
|
||||
.mockResolvedValueOnce(response('a', 2));
|
||||
const page = setup(fetcher);
|
||||
page.setOperation('a', detail('a'));
|
||||
await page.act('retry-rebuild', 'Follow the linked recovery operation.');
|
||||
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/provisioning/a/retry-rebuild', expect.objectContaining({ method: 'POST' }));
|
||||
expect(fetcher).toHaveBeenNthCalledWith(2, '/api/provisioning/a?events=recent', expect.anything());
|
||||
expect(page.state()).toMatchObject({ id: 'a', detail: detail('a', 2), notice: 'Follow the linked recovery operation.', busy: null });
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/provisioning/a/retry-rebuild',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/provisioning/a?events=recent',
|
||||
expect.anything()
|
||||
);
|
||||
expect(page.state()).toMatchObject({
|
||||
id: 'a',
|
||||
detail: detail('a', 2),
|
||||
notice: 'Follow the linked recovery operation.',
|
||||
busy: null
|
||||
});
|
||||
});
|
||||
it('sends explicit override confirmation and refreshes the operation after acceptance', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true })).mockResolvedValueOnce(response('a', 2));
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Response.json({ ok: true }))
|
||||
.mockResolvedValueOnce(response('a', 2));
|
||||
const page = setup(fetcher);
|
||||
page.setOperation('a', detail('a'));
|
||||
await page.act('override', 'Override enabled.', { confirmed: true });
|
||||
expect(fetcher).toHaveBeenNthCalledWith(1, '/api/provisioning/a/override', expect.objectContaining({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"confirmed":true}'
|
||||
}));
|
||||
expect(page.state()).toMatchObject({ detail: detail('a', 2), notice: 'Override enabled.', busy: null });
|
||||
expect(fetcher).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/provisioning/a/override',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"confirmed":true}'
|
||||
})
|
||||
);
|
||||
expect(page.state()).toMatchObject({
|
||||
detail: detail('a', 2),
|
||||
notice: 'Override enabled.',
|
||||
busy: null
|
||||
});
|
||||
});
|
||||
it('uses the server snapshot without another initial request and coalesces background reads', async () => {
|
||||
const pending = deferred();
|
||||
@@ -53,12 +98,22 @@ describe('provisioning detail refresh ownership', () => {
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
pending.resolve(response('a', 2));
|
||||
await Promise.all([first, second]);
|
||||
expect(page.state()).toMatchObject({ detail: detail('a', 2), refreshing: false, refreshedAt: '2026-09-28T12:00:00Z' });
|
||||
expect(page.state()).toMatchObject({
|
||||
detail: detail('a', 2),
|
||||
refreshing: false,
|
||||
refreshedAt: '2026-09-28T12:00:00Z'
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores obsolete success and failure across A → B → A, even when transport ignores abort', async () => {
|
||||
const oldA = deferred(), oldB = deferred(), newA = deferred();
|
||||
const fetcher = vi.fn().mockReturnValueOnce(oldA.promise).mockReturnValueOnce(oldB.promise).mockReturnValueOnce(newA.promise);
|
||||
const oldA = deferred(),
|
||||
oldB = deferred(),
|
||||
newA = deferred();
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(oldA.promise)
|
||||
.mockReturnValueOnce(oldB.promise)
|
||||
.mockReturnValueOnce(newA.promise);
|
||||
const page = setup(fetcher);
|
||||
page.setOperation('a', detail('a'));
|
||||
const first = page.refresh();
|
||||
@@ -71,12 +126,20 @@ describe('provisioning detail refresh ownership', () => {
|
||||
oldA.resolve(response('a', 0));
|
||||
oldB.reject(new Error('Obsolete failure'));
|
||||
await Promise.all([first, second]);
|
||||
expect(page.state()).toMatchObject({ id: 'a', detail: detail('a', 4), loadError: '', refreshing: false });
|
||||
expect(page.state()).toMatchObject({
|
||||
id: 'a',
|
||||
detail: detail('a', 4),
|
||||
loadError: '',
|
||||
refreshing: false
|
||||
});
|
||||
});
|
||||
|
||||
it('invalidates a pending poll and reconciles an accepted mutation before old data can return', async () => {
|
||||
const old = deferred();
|
||||
const fetcher = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(Response.json({ ok: true }))
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(old.promise)
|
||||
.mockResolvedValueOnce(Response.json({ ok: true }))
|
||||
.mockResolvedValueOnce(response('a', 3));
|
||||
const page = setup(fetcher);
|
||||
page.setOperation('a', detail('a'));
|
||||
@@ -85,21 +148,42 @@ describe('provisioning detail refresh ownership', () => {
|
||||
old.resolve(response('a', 2));
|
||||
await polling;
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
expect(page.state()).toMatchObject({ detail: detail('a', 3), notice: 'Verification resumed.', busy: null, loadError: '', actionError: '' });
|
||||
expect(page.state()).toMatchObject({
|
||||
detail: detail('a', 3),
|
||||
notice: 'Verification resumed.',
|
||||
busy: null,
|
||||
loadError: '',
|
||||
actionError: ''
|
||||
});
|
||||
});
|
||||
|
||||
it('retains accepted-action feedback if reconciliation fails, and action errors survive successful polls', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true }))
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(Response.json({ ok: true }))
|
||||
.mockRejectedValueOnce(new Error('Offline'))
|
||||
.mockResolvedValueOnce(Response.json({ error: 'Removal already began.' }, { status: 409 }))
|
||||
.mockResolvedValueOnce(response('a', 2)).mockResolvedValueOnce(response('a', 3));
|
||||
.mockResolvedValueOnce(
|
||||
Response.json({ error: 'Removal already began.' }, { status: 409 })
|
||||
)
|
||||
.mockResolvedValueOnce(response('a', 2))
|
||||
.mockResolvedValueOnce(response('a', 3));
|
||||
const page = setup(fetcher);
|
||||
page.setOperation('a', detail('a'));
|
||||
await page.act('resume', 'Resume accepted.');
|
||||
expect(page.state()).toMatchObject({ notice: 'Resume accepted.', loadError: 'Offline', actionError: '', detail: detail('a') });
|
||||
expect(page.state()).toMatchObject({
|
||||
notice: 'Resume accepted.',
|
||||
loadError: 'Offline',
|
||||
actionError: '',
|
||||
detail: detail('a')
|
||||
});
|
||||
await page.act('cancel', 'Cancelled.');
|
||||
await page.refresh();
|
||||
expect(page.state()).toMatchObject({ actionError: 'Removal already began.', notice: '', loadError: '', detail: detail('a', 3) });
|
||||
expect(page.state()).toMatchObject({
|
||||
actionError: 'Removal already began.',
|
||||
notice: '',
|
||||
loadError: '',
|
||||
detail: detail('a', 3)
|
||||
});
|
||||
});
|
||||
|
||||
it('does not duplicate mutations or poll during them and ignores completion after navigation', async () => {
|
||||
@@ -120,15 +204,22 @@ describe('provisioning detail refresh ownership', () => {
|
||||
|
||||
it('times out reads without discarding the last snapshot and suppresses updates after unmount', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetcher = vi.fn((_url, init) => new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
})) as typeof fetch;
|
||||
const fetcher = vi.fn(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
})
|
||||
) as typeof fetch;
|
||||
const page = setup(fetcher);
|
||||
page.setOperation('a', detail('a'));
|
||||
const pending = page.refresh();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await pending;
|
||||
expect(page.state()).toMatchObject({ detail: detail('a'), loadError: 'Operation refresh timed out. Try again.', refreshing: false });
|
||||
expect(page.state()).toMatchObject({
|
||||
detail: detail('a'),
|
||||
loadError: 'Operation refresh timed out. Try again.',
|
||||
refreshing: false
|
||||
});
|
||||
const next = page.refresh();
|
||||
page.dispose();
|
||||
const calls = page.onChange.mock.calls.length;
|
||||
|
||||
Reference in New Issue
Block a user