Split browser workflows from presentation
This commit is contained in:
parent
c9642f4067
commit
0c2d992224
5 changed files with 1683 additions and 854 deletions
37
web/test/browser-boundaries.test.mjs
Normal file
37
web/test/browser-boundaries.test.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('Svelte components own neither HTTP nor browser persistence policy', async () => {
|
||||
const sources = await sourceFiles('src');
|
||||
const svelte = sources.filter((file) => file.endsWith('.svelte'));
|
||||
for (const file of svelte) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
assert.doesNotMatch(source, /\bfetch\s*\(/, file);
|
||||
assert.doesNotMatch(source, /\blocalStorage\b/, file);
|
||||
assert.doesNotMatch(source, /\bon:/, file);
|
||||
assert.doesNotMatch(source, /\$:/, file);
|
||||
}
|
||||
|
||||
const storageOwners = [];
|
||||
for (const file of sources) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
if (source.includes('nuthatch-recursive-delete')) storageOwners.push(file);
|
||||
}
|
||||
assert.deepEqual(storageOwners, [
|
||||
path.resolve('src/workflows/recursive-delete-persistence.ts')
|
||||
]);
|
||||
});
|
||||
|
||||
async function sourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(target) : [target];
|
||||
})
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
90
web/test/components.test.mjs
Normal file
90
web/test/components.test.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import test from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
test('components render root, browse, upload, delete, resume, notice, and error states', async () => {
|
||||
const server = await createServer({
|
||||
appType: 'custom',
|
||||
configFile: false,
|
||||
plugins: [svelte()],
|
||||
server: { hmr: false, middlewareMode: true }
|
||||
});
|
||||
try {
|
||||
const { render } = await server.ssrLoadModule('svelte/server');
|
||||
const WorkspaceHeader = await component(server, 'WorkspaceHeader');
|
||||
const workspace = render(WorkspaceHeader, {
|
||||
props: { root: 'shared', busy: false, onselect: async () => {} }
|
||||
}).body;
|
||||
assert.match(workspace, /Shared files/);
|
||||
assert.match(workspace, /Shared root/);
|
||||
assert.match(workspace, /Personal/);
|
||||
|
||||
const UploadPanel = await component(server, 'UploadPanel');
|
||||
assert.match(
|
||||
render(UploadPanel, {
|
||||
props: { busy: false, onfile: async () => {} }
|
||||
}).body,
|
||||
/Drop a file here/
|
||||
);
|
||||
|
||||
const ObjectBrowser = await component(server, 'ObjectBrowser');
|
||||
const browser = render(ObjectBrowser, {
|
||||
props: {
|
||||
root: 'shared',
|
||||
entries: [
|
||||
{ name: 'folder', is_prefix: true, size: null, path: 'folder' }
|
||||
],
|
||||
continuation: 'next',
|
||||
partial: true,
|
||||
busy: false,
|
||||
query: 'fold',
|
||||
onquery: () => {},
|
||||
onbrowse: async () => {},
|
||||
onopen: async () => {},
|
||||
ondelete: async () => {}
|
||||
}
|
||||
}).body;
|
||||
assert.match(browser, /Search this root/);
|
||||
assert.match(browser, /Partial search results/);
|
||||
assert.match(browser, /folder\//);
|
||||
assert.match(browser, /Delete/);
|
||||
assert.match(browser, /Clear/);
|
||||
|
||||
const ResumeDelete = await component(server, 'ResumeDelete');
|
||||
assert.match(
|
||||
render(ResumeDelete, {
|
||||
props: {
|
||||
pending: { root: 'personal', prefix: 'tree', items: [] },
|
||||
busy: false,
|
||||
onresume: async () => {}
|
||||
}
|
||||
}).body,
|
||||
/Resume deletion/
|
||||
);
|
||||
|
||||
const Feedback = await component(server, 'Feedback');
|
||||
const feedback = render(Feedback, {
|
||||
props: {
|
||||
notice: 'Uploaded file.txt',
|
||||
error: 'Delete is pending',
|
||||
uploadPending: true,
|
||||
uploadFollowup: 'replay',
|
||||
deletePending: true,
|
||||
deleteFollowup: 'restart',
|
||||
onuploadretry: async () => {},
|
||||
ondeleteretry: async () => {}
|
||||
}
|
||||
}).body;
|
||||
assert.match(feedback, /Uploaded file\.txt/);
|
||||
assert.match(feedback, /Retry same upload/);
|
||||
assert.match(feedback, /Start new delete operation/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function component(server, name) {
|
||||
const loaded = await server.ssrLoadModule(`/src/components/${name}.svelte`);
|
||||
return loaded.default;
|
||||
}
|
||||
60
web/test/dev-boundary.test.mjs
Normal file
60
web/test/dev-boundary.test.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createServer as createHttpServer } from 'node:http';
|
||||
import test from 'node:test';
|
||||
import { createServer as createViteServer } from 'vite';
|
||||
|
||||
test('development server proxies login, API, and logout to one backend', async () => {
|
||||
const seen = [];
|
||||
const backend = createHttpServer((request, response) => {
|
||||
seen.push(`${request.method} ${request.url}`);
|
||||
response.writeHead(request.url === '/login' ? 303 : 200, {
|
||||
'Content-Type': 'application/json',
|
||||
Location: '/oidc'
|
||||
});
|
||||
response.end('{}');
|
||||
});
|
||||
await listen(backend);
|
||||
const address = backend.address();
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('backend did not bind a TCP port');
|
||||
}
|
||||
const previous = process.env.NUTHATCH_DEV_BACKEND;
|
||||
process.env.NUTHATCH_DEV_BACKEND = `http://127.0.0.1:${address.port}`;
|
||||
const vite = await createViteServer({
|
||||
server: { host: '127.0.0.1', port: 0 }
|
||||
});
|
||||
try {
|
||||
await vite.listen();
|
||||
const origin = vite.resolvedUrls?.local[0];
|
||||
if (origin === undefined) throw new Error('Vite did not publish a local URL');
|
||||
await fetch(new URL('/api/session', origin));
|
||||
await fetch(new URL('/login', origin), { redirect: 'manual' });
|
||||
await fetch(new URL('/logout', origin), { method: 'POST' });
|
||||
assert.deepEqual(seen, [
|
||||
'GET /api/session',
|
||||
'GET /login',
|
||||
'POST /logout'
|
||||
]);
|
||||
} finally {
|
||||
await vite.close();
|
||||
await close(backend);
|
||||
if (previous === undefined) {
|
||||
delete process.env.NUTHATCH_DEV_BACKEND;
|
||||
} else {
|
||||
process.env.NUTHATCH_DEV_BACKEND = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error === undefined ? resolve() : reject(error)));
|
||||
});
|
||||
}
|
||||
190
web/test/recursive-delete.test.mjs
Normal file
190
web/test/recursive-delete.test.mjs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
RecursiveDeletePageLimitError,
|
||||
runRecursiveDelete
|
||||
} from '../src/workflows/recursive-delete.ts';
|
||||
import {
|
||||
browserRecursiveDeletePersistence
|
||||
} from '../src/workflows/recursive-delete-persistence.ts';
|
||||
|
||||
const session = {
|
||||
user_id: 'user-1',
|
||||
display: null,
|
||||
csrf: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
};
|
||||
|
||||
test('recursive delete continues from the first page through concurrent additions', async () => {
|
||||
const persistence = memoryPersistence();
|
||||
const plans = [
|
||||
{ paths: ['tree/a'], continuation: 'page-2' },
|
||||
{ paths: ['tree/b'], continuation: null },
|
||||
{ paths: ['tree/new'], continuation: null },
|
||||
{ paths: [], continuation: null }
|
||||
];
|
||||
const continuations = [];
|
||||
const batches = [];
|
||||
const client = recursiveClient({
|
||||
plan(request) {
|
||||
continuations.push(request.continuation);
|
||||
return plans.shift();
|
||||
},
|
||||
batch(items) {
|
||||
batches.push(items.map((item) => item.path));
|
||||
return succeeded(items);
|
||||
}
|
||||
});
|
||||
let operation = 0;
|
||||
const result = await runRecursiveDelete(
|
||||
client,
|
||||
persistence,
|
||||
session,
|
||||
'personal',
|
||||
'tree',
|
||||
() => `operation-${++operation}`
|
||||
);
|
||||
assert.deepEqual(result, { deleted: 3, pages: 3 });
|
||||
assert.deepEqual(continuations, [undefined, 'page-2', undefined, undefined]);
|
||||
assert.deepEqual(batches, [['tree/a'], ['tree/b'], ['tree/new']]);
|
||||
assert.equal(persistence.read(), null);
|
||||
});
|
||||
|
||||
test('recursive delete reload preserves pending IDs and restarts terminal IDs', async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const firstAdapter = browserRecursiveDeletePersistence(storage);
|
||||
firstAdapter.save('shared', 'tree', [
|
||||
{ path: 'tree/pending', operation_id: 'pending-id' },
|
||||
{ path: 'tree/failed', operation_id: 'failed-id' }
|
||||
]);
|
||||
const reloaded = browserRecursiveDeletePersistence(storage);
|
||||
const client = recursiveClient({
|
||||
plan() {
|
||||
throw new Error('saved batch should replay before planning');
|
||||
},
|
||||
batch(items) {
|
||||
return [
|
||||
{
|
||||
path: items[0].path,
|
||||
operation_id: items[0].operation_id,
|
||||
result: 'pending',
|
||||
error: null
|
||||
},
|
||||
{
|
||||
path: items[1].path,
|
||||
operation_id: items[1].operation_id,
|
||||
result: 'failed',
|
||||
error: null
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
||||
await assert.rejects(
|
||||
runRecursiveDelete(
|
||||
client,
|
||||
reloaded,
|
||||
session,
|
||||
'shared',
|
||||
'tree',
|
||||
() => 'replacement-id'
|
||||
),
|
||||
/2 objects remain/
|
||||
);
|
||||
assert.deepEqual(reloaded.read()?.items, [
|
||||
{ path: 'tree/pending', operation_id: 'pending-id' },
|
||||
{ path: 'tree/failed', operation_id: 'replacement-id' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('recursive delete enforces the page bound and retains resumable work', async () => {
|
||||
const persistence = memoryPersistence();
|
||||
const client = recursiveClient({
|
||||
plan() {
|
||||
return { paths: ['tree/item'], continuation: null };
|
||||
},
|
||||
batch(items) {
|
||||
return succeeded(items);
|
||||
}
|
||||
});
|
||||
let operation = 0;
|
||||
await assert.rejects(
|
||||
runRecursiveDelete(
|
||||
client,
|
||||
persistence,
|
||||
session,
|
||||
'personal',
|
||||
'tree',
|
||||
() => `operation-${++operation}`,
|
||||
100
|
||||
),
|
||||
(error) =>
|
||||
error instanceof RecursiveDeletePageLimitError && error.pages === 100
|
||||
);
|
||||
assert.equal(operation, 100);
|
||||
});
|
||||
|
||||
test('invalid persisted state is removed on reload', () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem('nuthatch-recursive-delete', '{"items":[null]}');
|
||||
const persistence = browserRecursiveDeletePersistence(storage);
|
||||
assert.equal(persistence.read(), null);
|
||||
assert.equal(storage.length, 0);
|
||||
});
|
||||
|
||||
function recursiveClient(behavior) {
|
||||
return {
|
||||
async deletePlan(request) {
|
||||
const plan = behavior.plan(request);
|
||||
return {
|
||||
ok: true,
|
||||
value: plan
|
||||
};
|
||||
},
|
||||
async deleteBatch({ body }) {
|
||||
return {
|
||||
ok: true,
|
||||
value: { results: behavior.batch(body.items) }
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function succeeded(items) {
|
||||
return items.map((item) => ({
|
||||
path: item.path,
|
||||
operation_id: item.operation_id,
|
||||
result: 'succeeded',
|
||||
error: null
|
||||
}));
|
||||
}
|
||||
|
||||
function memoryPersistence() {
|
||||
return browserRecursiveDeletePersistence(new MemoryStorage());
|
||||
}
|
||||
|
||||
class MemoryStorage {
|
||||
values = new Map();
|
||||
|
||||
get length() {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index) {
|
||||
return [...this.values.keys()][index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -171,6 +171,20 @@ test('declared, undeclared, and network failures remain distinct without retries
|
|||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test('logout keeps session mutation policy in the transport', async () => {
|
||||
const calls = [];
|
||||
const client = new BrowserProductClient(async (input, init) => {
|
||||
calls.push({ input, init });
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
await client.logout('csrf-token');
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, '/logout');
|
||||
assert.equal(calls[0].init.method, 'POST');
|
||||
assert.equal(calls[0].init.credentials, 'same-origin');
|
||||
assert.equal(calls[0].init.headers['X-CSRF-Token'], 'csrf-token');
|
||||
});
|
||||
|
||||
test('checked generated artifact contains no transport policy', async () => {
|
||||
const generated = await readFile(
|
||||
new URL('../src/generated/contract.ts', import.meta.url),
|
||||
|
|
|
|||
Loading…
Reference in a new issue