Track client operations and show inline progress
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 16s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-07-11 20:55:20 +03:00
parent 9da4fef1f0
commit 74660d915f
6 changed files with 299 additions and 66 deletions

View File

@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import test from 'node:test';
import {
createLatestRequest,
createOperationRegistry,
OPERATION_CONFLICTS,
operationBlocked,
} from '../../src/web/state/operations.js';
const deferred = () => {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
};
test('operation conflicts block domain controls but leave copy and navigation alone', () => {
for (const [key, conflicts] of Object.entries(OPERATION_CONFLICTS)) {
for (const conflict of conflicts) {
assert.ok(OPERATION_CONFLICTS[conflict].includes(key), `${key} -> ${conflict} is not symmetric`);
}
}
const refreshing = { subscriptionRefresh: { status: 'running' } };
assert.equal(operationBlocked(refreshing, 'connection'), true);
assert.equal(operationBlocked(refreshing, 'serverApply'), true);
assert.equal(operationBlocked(refreshing, 'subscriptionDelete'), true);
assert.equal(operationBlocked(refreshing, 'copy'), false);
assert.equal(operationBlocked(refreshing, 'navigation'), false);
const applying = { serverApply: { status: 'running' } };
assert.equal(operationBlocked(applying, 'connection'), true);
assert.equal(operationBlocked(applying, 'subscriptionRefresh'), true);
assert.equal(operationBlocked(applying, 'copy'), false);
});
test('double click shares one in-flight request end to end', async (t) => {
let requests = 0;
const server = http.createServer((request, response) => {
requests += 1;
setTimeout(() => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"success":true}');
}, 20);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const registry = createOperationRegistry();
const action = () => fetch(`http://127.0.0.1:${server.address().port}/apply`).then((response) => response.json());
const first = registry.run('serverApply', action);
const second = registry.run('serverApply', action);
assert.equal(second, first);
assert.equal(registry.getSnapshot().serverApply.status, 'running');
assert.deepEqual(await first, { success: true });
assert.equal(requests, 1);
assert.deepEqual(registry.getSnapshot(), {});
});
test('a conflicting operation is rejected before its action starts', async () => {
const connection = deferred();
const registry = createOperationRegistry();
const running = registry.run('connection', () => connection.promise);
let applyCalls = 0;
const result = registry.run('serverApply', () => { applyCalls += 1; });
assert.equal(await result, false);
assert.equal(applyCalls, 0);
connection.resolve(true);
await running;
});
test('latest request cancels and ignores a stale validation result', async () => {
const latest = createLatestRequest();
const oldResult = deferred();
const newResult = deferred();
let oldSignal;
const oldRequest = latest.run((signal) => {
oldSignal = signal;
return oldResult.promise;
});
const newRequest = latest.run(() => newResult.promise);
oldResult.resolve('old');
newResult.resolve('new');
assert.equal(await oldRequest, undefined);
assert.equal(await newRequest, 'new');
assert.equal(oldSignal.aborted, true);
});