Split browser workflows from presentation

This commit is contained in:
ms2k 2026-07-20 03:37:35 +09:00
commit 0c2d992224
5 changed files with 1683 additions and 854 deletions

View 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();
}

View 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;
}

View 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)));
});
}

View 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);
}
}

View file

@ -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),