164 lines
5.9 KiB
JavaScript
164 lines
5.9 KiB
JavaScript
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { readFileSync } from 'node:fs';
|
||
|
|
import { createRequire } from 'node:module';
|
||
|
|
import { setImmediate } from 'node:timers/promises';
|
||
|
|
import test from 'node:test';
|
||
|
|
|
||
|
|
const requireWeb = createRequire(new URL('../../apps/web/package.json', import.meta.url));
|
||
|
|
const ts = requireWeb('typescript');
|
||
|
|
const source = readFileSync(new URL('../../apps/web/src/projects/store.ts', import.meta.url), 'utf8');
|
||
|
|
const compiled = ts.transpileModule(source, { compilerOptions: {
|
||
|
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||
|
|
} }).outputText;
|
||
|
|
|
||
|
|
function deferred() {
|
||
|
|
let resolve, reject;
|
||
|
|
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||
|
|
return { promise, resolve, reject };
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadStore(putSessionMessages, fetchSessionMessages = async () => ({ messages: [] })) {
|
||
|
|
const exported = {};
|
||
|
|
new Function('require', 'exports', compiled)((id) => {
|
||
|
|
if (id === '../api/client') return { putSessionMessages, fetchSessionMessages };
|
||
|
|
if (id === './sessionTitle') return {};
|
||
|
|
throw new Error(`Unexpected dependency: ${id}`);
|
||
|
|
}, exported);
|
||
|
|
return exported;
|
||
|
|
}
|
||
|
|
|
||
|
|
test('delayed old PUT completes before the confirmed snapshot can persist', async () => {
|
||
|
|
const oldResponse = deferred();
|
||
|
|
const requests = [];
|
||
|
|
let persisted;
|
||
|
|
const store = loadStore(async (_id, messages) => {
|
||
|
|
requests.push(messages);
|
||
|
|
if (requests.length === 1) await oldResponse.promise;
|
||
|
|
persisted = messages;
|
||
|
|
});
|
||
|
|
store.saveMessages('session', [{ confirmHandled: false }]);
|
||
|
|
await setImmediate();
|
||
|
|
const confirmed = store.persistMessages('session', [{ confirmHandled: true }]);
|
||
|
|
await setImmediate();
|
||
|
|
assert.equal(requests.length, 1);
|
||
|
|
oldResponse.resolve();
|
||
|
|
await confirmed;
|
||
|
|
assert.equal(requests.length, 2);
|
||
|
|
assert.deepEqual(persisted, [{ confirmHandled: true }]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('queued payload is a call-time snapshot including nested fields', async () => {
|
||
|
|
const gate = deferred();
|
||
|
|
const requests = [];
|
||
|
|
const store = loadStore(async (_id, messages) => {
|
||
|
|
requests.push(messages);
|
||
|
|
if (requests.length === 1) await gate.promise;
|
||
|
|
});
|
||
|
|
const first = store.persistMessages('session', [{ text: 'old' }]);
|
||
|
|
const messages = [{ block: { handled: true } }];
|
||
|
|
const second = store.persistMessages('session', messages);
|
||
|
|
messages[0].block.handled = false;
|
||
|
|
messages.push({ text: 'changed after enqueue' });
|
||
|
|
gate.resolve();
|
||
|
|
await Promise.all([first, second]);
|
||
|
|
assert.deepEqual(requests[1], [{ block: { handled: true } }]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a failed PUT rejects its caller but does not block the next write', async () => {
|
||
|
|
const gate = deferred();
|
||
|
|
const requests = [];
|
||
|
|
const store = loadStore(async (_id, messages) => {
|
||
|
|
requests.push(messages);
|
||
|
|
if (requests.length === 1) await gate.promise;
|
||
|
|
});
|
||
|
|
const first = store.persistMessages('session', [{ text: 'old' }]);
|
||
|
|
const failed = assert.rejects(first, /network/);
|
||
|
|
const second = store.persistMessages('session', [{ text: 'retry' }]);
|
||
|
|
await setImmediate();
|
||
|
|
gate.reject(new Error('network'));
|
||
|
|
await failed;
|
||
|
|
await second;
|
||
|
|
assert.deepEqual(requests[1], [{ text: 'retry' }]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('different sessions do not wait for each other', async () => {
|
||
|
|
const gate = deferred();
|
||
|
|
const written = [];
|
||
|
|
const store = loadStore(async (id) => {
|
||
|
|
if (id === 'slow') await gate.promise;
|
||
|
|
written.push(id);
|
||
|
|
});
|
||
|
|
const slow = store.persistMessages('slow', []);
|
||
|
|
await store.persistMessages('fast', []);
|
||
|
|
assert.deepEqual(written, ['fast']);
|
||
|
|
gate.resolve();
|
||
|
|
await slow;
|
||
|
|
assert.deepEqual(written, ['fast', 'slow']);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('clearMessages follows earlier saves through the same session queue', async () => {
|
||
|
|
const gate = deferred();
|
||
|
|
const cleared = deferred();
|
||
|
|
const requests = [];
|
||
|
|
let persisted;
|
||
|
|
const store = loadStore(async (_id, messages) => {
|
||
|
|
requests.push(messages);
|
||
|
|
if (requests.length === 1) await gate.promise;
|
||
|
|
persisted = messages;
|
||
|
|
if (requests.length === 2) cleared.resolve();
|
||
|
|
});
|
||
|
|
store.saveMessages('session', [{ text: 'old' }]);
|
||
|
|
store.clearMessages('session');
|
||
|
|
await setImmediate();
|
||
|
|
assert.equal(requests.length, 1);
|
||
|
|
gate.resolve();
|
||
|
|
await cleared.promise;
|
||
|
|
assert.deepEqual(persisted, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('history load waits for both a delayed old PUT and the queued confirmation', async () => {
|
||
|
|
const gate = deferred();
|
||
|
|
let written = [], writeCount = 0, readCount = 0;
|
||
|
|
const store = loadStore(async (_id, messages) => {
|
||
|
|
writeCount += 1;
|
||
|
|
if (writeCount === 1) await gate.promise;
|
||
|
|
written = messages;
|
||
|
|
}, async () => {
|
||
|
|
readCount += 1;
|
||
|
|
return { messages: written };
|
||
|
|
});
|
||
|
|
store.saveMessages('session', [{ confirmHandled: false }]);
|
||
|
|
await setImmediate();
|
||
|
|
const confirmed = store.persistMessages('session', [{ confirmHandled: true }]);
|
||
|
|
const loaded = store.loadMessagesPreferServer('session');
|
||
|
|
await setImmediate();
|
||
|
|
assert.equal(readCount, 0);
|
||
|
|
gate.resolve();
|
||
|
|
await confirmed;
|
||
|
|
assert.deepEqual(await loaded, [{ confirmHandled: true }]);
|
||
|
|
assert.equal(readCount, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('history GET overlapping a completed write is discarded and read again', async () => {
|
||
|
|
const oldGet = deferred();
|
||
|
|
let written = [{ confirmHandled: false }], readCount = 0;
|
||
|
|
const store = loadStore(async (_id, messages) => { written = messages; }, async () => {
|
||
|
|
readCount += 1;
|
||
|
|
const snapshot = structuredClone(written);
|
||
|
|
if (readCount === 1) await oldGet.promise;
|
||
|
|
return { messages: snapshot };
|
||
|
|
});
|
||
|
|
const loaded = store.loadMessagesPreferServer('session');
|
||
|
|
await setImmediate();
|
||
|
|
assert.equal(readCount, 1);
|
||
|
|
await store.persistMessages('session', [{ confirmHandled: true }]);
|
||
|
|
oldGet.resolve();
|
||
|
|
assert.deepEqual(await loaded, [{ confirmHandled: true }]);
|
||
|
|
assert.equal(readCount, 2);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('history read errors remain null instead of an empty conversation', async () => {
|
||
|
|
const store = loadStore(async () => {}, async () => { throw new Error('network'); });
|
||
|
|
assert.equal(await store.loadMessagesPreferServer('session'), null);
|
||
|
|
});
|