mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { test } = require('node:test');
|
|
|
|
// A minimal DOM; all navigation, requests and validation execute the shipped JS.
|
|
class Element {
|
|
constructor(tag = 'div') {
|
|
this.tag = tag;
|
|
this.children = [];
|
|
this.dataset = {};
|
|
this.listeners = {};
|
|
this.isConnected = true;
|
|
}
|
|
set textContent(value) {
|
|
this.text = value;
|
|
this.replaceChildren();
|
|
}
|
|
get textContent() {
|
|
return (this.text || '') + this.children.map((x) => x.textContent).join('');
|
|
}
|
|
append(...children) {
|
|
for (const child of children) {
|
|
if (child.tag === 'fragment') this.append(...child.children);
|
|
else {
|
|
child.parent = this;
|
|
this.children.push(child);
|
|
}
|
|
}
|
|
}
|
|
detach() {
|
|
this.isConnected = false;
|
|
this.children.forEach((child) => child.detach());
|
|
}
|
|
replaceChildren(...children) {
|
|
this.children.forEach((child) => child.detach());
|
|
this.children = [];
|
|
this.append(...children);
|
|
}
|
|
setAttribute(name, value) {
|
|
this[name] = value;
|
|
}
|
|
removeAttribute(name) {
|
|
delete this[name];
|
|
}
|
|
addEventListener(name, handler) {
|
|
this.listeners[name] = handler;
|
|
}
|
|
querySelector() {
|
|
return new Element();
|
|
}
|
|
querySelectorAll(selector) {
|
|
return this.children.flatMap((child) => [
|
|
...(selector === child.tag || selector === `.${child.className}`
|
|
? [child]
|
|
: []),
|
|
...child.querySelectorAll(selector),
|
|
]);
|
|
}
|
|
focus() {}
|
|
}
|
|
|
|
function fixture(view, response) {
|
|
const nodes = new Map();
|
|
const calls = [];
|
|
const context = vm.createContext({
|
|
TextDecoder,
|
|
Uint8Array,
|
|
URLSearchParams,
|
|
Intl,
|
|
console,
|
|
window: { atob },
|
|
document: {
|
|
getElementById(id) {
|
|
if (!nodes.has(id)) nodes.set(id, new Element());
|
|
return nodes.get(id);
|
|
},
|
|
querySelector: () => new Element(),
|
|
createElement: (tag) => new Element(tag),
|
|
createDocumentFragment: () => new Element('fragment'),
|
|
addEventListener() {},
|
|
},
|
|
fetch: async (url, options) => {
|
|
calls.push({ url, options });
|
|
const body = await response(url, calls.length);
|
|
return {
|
|
ok: !body.httpStatus,
|
|
status: body.httpStatus || 200,
|
|
json: async () => body,
|
|
headers: { get: () => null },
|
|
};
|
|
},
|
|
});
|
|
const source = fs.readFileSync(
|
|
path.join(__dirname, '../assets/console/console.js'),
|
|
'utf8',
|
|
);
|
|
assert.ok(source.endsWith('})();\n'));
|
|
vm.runInContext(
|
|
source.slice(0, -6) +
|
|
'globalThis.client = { state, nodes, refresh, selectTask, selectTrigger, disconnect };})();',
|
|
context,
|
|
);
|
|
Object.assign(context.client.state, {
|
|
token: 'memory-only-test-token',
|
|
project: 'default',
|
|
view,
|
|
});
|
|
return { ...context.client, calls };
|
|
}
|
|
|
|
const cursor = (view, row) =>
|
|
view === 'runs'
|
|
? { runId: row.id, createdAtMs: row.createdAtMs }
|
|
: view === 'tasks'
|
|
? { taskId: row.taskId }
|
|
: { triggerId: row.triggerId };
|
|
const rows = (view, count = 65) =>
|
|
Array.from({ length: count }, (_, index) => {
|
|
const id = `item:${String(
|
|
view === 'runs' ? count - index - 1 : index,
|
|
).padStart(3, '0')}`;
|
|
return {
|
|
id,
|
|
taskId: id,
|
|
triggerId: id,
|
|
name: id,
|
|
revision: 1,
|
|
taskRevision: 1,
|
|
kind: 'command',
|
|
specSchema: 'qinglong/command@v1',
|
|
enabled: true,
|
|
status: 'succeeded',
|
|
executionOrigin: 'manual',
|
|
executionOwner: 'runtime',
|
|
createdAtMs: 2000,
|
|
updatedAtMs: 2000,
|
|
};
|
|
});
|
|
const page = (view, entries, more = false) => ({
|
|
[view]: entries,
|
|
[view === 'triggers' ? 'truncated' : 'hasMore']: more,
|
|
...(more
|
|
? { next: cursor(view, entries.at(-1)) }
|
|
: view === 'triggers'
|
|
? { next: null }
|
|
: {}),
|
|
});
|
|
const button = (client, label) =>
|
|
client.nodes.ledger
|
|
.querySelectorAll('button')
|
|
.find((entry) => entry.textContent === label);
|
|
const records = (client) => client.nodes.ledger.querySelectorAll('.record');
|
|
const deferred = () => {
|
|
let resolve;
|
|
const promise = new Promise((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
};
|
|
|
|
for (const view of ['tasks', 'triggers', 'runs']) {
|
|
test(`${view}: reaches row 65, refreshes the current cursor and resets without retaining old rows`, async () => {
|
|
const entries = rows(view);
|
|
const client = fixture(view, (url) =>
|
|
url.includes('&after_')
|
|
? page(view, entries.slice(64))
|
|
: page(view, entries.slice(0, 64), true),
|
|
);
|
|
await client.refresh();
|
|
assert.equal(records(client).length, 64);
|
|
const next = button(client, '下一页');
|
|
await next.listeners.click();
|
|
await next.listeners.click(); // A detached footer cannot issue another request.
|
|
assert.equal(records(client).length, 1);
|
|
assert.equal(records(client)[0].dataset.identity, entries[64].id);
|
|
assert.equal(button(client, '下一页'), undefined);
|
|
assert.equal(client.calls.length, 2);
|
|
const expected =
|
|
view === 'runs'
|
|
? 'after_created_at_ms=2000&after_run_id=item:001'
|
|
: view === 'tasks'
|
|
? 'after_task_id=item:063'
|
|
: 'after_trigger_id=item:063';
|
|
assert.equal(
|
|
client.calls[1].url,
|
|
`/api/v3/projects/default/${view}?limit=64&${expected}`,
|
|
);
|
|
await button(client, '刷新当前页').listeners.click();
|
|
assert.equal(client.calls[2].url, client.calls[1].url);
|
|
await button(client, '回到首页').listeners.click();
|
|
assert.equal(client.calls[3].url, client.calls[0].url);
|
|
assert.equal(records(client).length, 64);
|
|
assert.equal(client.calls.length, 4);
|
|
assert.ok(client.calls.every(({ options }) => options.method === 'GET'));
|
|
assert.equal(client.state.selectedId, null);
|
|
});
|
|
|
|
test(`${view}: rejects oversized, unordered, empty-continuation and mismatched cursor responses`, async () => {
|
|
const entries = rows(view);
|
|
const invalid = [
|
|
page(view, entries),
|
|
page(view, [entries[1], entries[0]]),
|
|
page(view, [entries[0], entries[0]]),
|
|
{ [view]: [] },
|
|
{
|
|
...page(view, [], false),
|
|
[view === 'triggers' ? 'truncated' : 'hasMore']: true,
|
|
next: cursor(view, entries[0]),
|
|
},
|
|
{
|
|
...page(view, entries.slice(0, 2), true),
|
|
next: cursor(view, entries[0]),
|
|
},
|
|
{
|
|
...page(view, entries.slice(0, 2), true),
|
|
next: { ...cursor(view, entries[1]), extra: 'not-allowed' },
|
|
},
|
|
{ ...page(view, entries.slice(0, 2)), next: cursor(view, entries[1]) },
|
|
page(view, [
|
|
{
|
|
...entries[0],
|
|
id: 'bad&limit=100',
|
|
taskId: 'bad&limit=100',
|
|
triggerId: 'bad&limit=100',
|
|
},
|
|
]),
|
|
];
|
|
for (const value of invalid) {
|
|
const client = fixture(view, () => value);
|
|
await client.refresh();
|
|
assert.equal(records(client).length, 0);
|
|
assert.equal(button(client, '下一页'), undefined);
|
|
assert.equal(client.nodes.connection.dataset.state, 'error');
|
|
assert.equal(client.calls.length, 1);
|
|
}
|
|
});
|
|
|
|
test(`${view}: keeps an empty later page reachable and retries failures at the same boundary`, async () => {
|
|
const entries = rows(view);
|
|
const client = fixture(view, (_url, call) =>
|
|
call === 1
|
|
? page(view, entries.slice(0, 64), true)
|
|
: call === 2
|
|
? { httpStatus: 503, code: 'request_unavailable' }
|
|
: page(view, []),
|
|
);
|
|
await client.refresh();
|
|
await button(client, '下一页').listeners.click();
|
|
assert.equal(client.nodes.connection.dataset.state, 'error');
|
|
await button(client, '刷新当前页').listeners.click();
|
|
assert.equal(client.calls[2].url, client.calls[1].url);
|
|
assert.equal(records(client).length, 0);
|
|
assert.ok(button(client, '回到首页'));
|
|
assert.equal(button(client, '下一页'), undefined);
|
|
assert.equal(client.calls.length, 3);
|
|
});
|
|
}
|
|
|
|
test('coalesces footer clicks and prevents a stale page or error from replacing another view', async () => {
|
|
for (const result of [
|
|
page('tasks', rows('tasks', 1)),
|
|
{ httpStatus: 401, code: 'authentication_required' },
|
|
]) {
|
|
const pending = deferred();
|
|
const client = fixture('tasks', (url, call) =>
|
|
call === 1
|
|
? page('tasks', rows('tasks').slice(0, 64), true)
|
|
: url.includes('/tasks?')
|
|
? pending.promise
|
|
: page('runs', rows('runs', 1)),
|
|
);
|
|
await client.refresh();
|
|
const next = button(client, '下一页');
|
|
const reading = next.listeners.click();
|
|
await next.listeners.click();
|
|
assert.equal(client.calls.length, 2);
|
|
client.state.view = 'runs';
|
|
await client.refresh();
|
|
const displayed = client.nodes.ledger.textContent;
|
|
pending.resolve(result);
|
|
await reading;
|
|
assert.equal(client.nodes.ledger.textContent, displayed);
|
|
assert.equal(client.nodes.connection.dataset.state, 'connected');
|
|
assert.equal(client.nodes.refresh.disabled, false);
|
|
}
|
|
});
|
|
|
|
test('discarding a response after disconnect/reconnect does not change new session state', async () => {
|
|
const old = deferred();
|
|
const client = fixture('tasks', (_url, call) =>
|
|
call === 1 ? old.promise : page('tasks', rows('tasks', 1)),
|
|
);
|
|
const reading = client.refresh();
|
|
client.disconnect();
|
|
const disconnected = client.nodes.ledger.textContent;
|
|
old.resolve(page('tasks', rows('tasks')));
|
|
await reading;
|
|
assert.equal(client.nodes.ledger.textContent, disconnected);
|
|
assert.equal(client.state.token, null);
|
|
client.state.token = 'new-session-token';
|
|
await client.refresh();
|
|
assert.equal(records(client).length, 1);
|
|
assert.equal(
|
|
JSON.stringify(client.state.listRequest).includes('token'),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('same-view refresh rejects stale success and leaves current loading state intact', async () => {
|
|
const first = deferred();
|
|
const second = deferred();
|
|
const client = fixture('tasks', (_url, call) =>
|
|
call === 1 ? first.promise : second.promise,
|
|
);
|
|
const old = client.refresh();
|
|
const current = client.refresh();
|
|
first.resolve(page('tasks', rows('tasks', 1)));
|
|
await old;
|
|
assert.equal(records(client).length, 0);
|
|
assert.equal(client.nodes.refresh.disabled, true);
|
|
second.resolve(page('tasks', rows('tasks', 2)));
|
|
await current;
|
|
assert.equal(records(client).length, 2);
|
|
});
|
|
|
|
test('Task and Trigger details cannot overwrite a later list generation', async () => {
|
|
for (const view of ['tasks', 'triggers']) {
|
|
const pending = deferred();
|
|
const client = fixture(view, (url) =>
|
|
url.includes('?') ? page(view, rows(view, 1)) : pending.promise,
|
|
);
|
|
await client.refresh();
|
|
const select = view === 'tasks' ? client.selectTask : client.selectTrigger;
|
|
const reading = select('item:000');
|
|
await client.refresh();
|
|
client.nodes.detail.textContent = 'current detail';
|
|
pending.resolve(
|
|
view === 'tasks'
|
|
? { task: rows(view, 1)[0] }
|
|
: { trigger: rows(view, 1)[0] },
|
|
);
|
|
await reading;
|
|
assert.equal(client.nodes.detail.textContent, 'current detail');
|
|
}
|
|
});
|
|
|
|
test('stale Secret metadata cannot replace the new ledger or catalog', async () => {
|
|
const pending = deferred();
|
|
const client = fixture('secrets', (url) =>
|
|
url.includes('/secrets?') ? pending.promise : page('tasks', []),
|
|
);
|
|
const reading = client.refresh();
|
|
client.state.view = 'tasks';
|
|
await client.refresh();
|
|
client.state.secretCatalog = ['current-session-catalog'];
|
|
const displayed = client.nodes.ledger.textContent;
|
|
pending.resolve({ secrets: [], truncated: false });
|
|
await reading;
|
|
assert.equal(client.nodes.ledger.textContent, displayed);
|
|
assert.deepEqual(client.state.secretCatalog, ['current-session-catalog']);
|
|
});
|
|
|
|
test('generated continuation URLs pass the production HTTP parser for all three ledgers', async (t) => {
|
|
const net = require('node:net');
|
|
const {
|
|
startLocalApiHttpSurface,
|
|
} = require('../dist/transport/httpSurface.js');
|
|
const port = await new Promise((resolve, reject) => {
|
|
const listener = net.createServer();
|
|
listener.once('error', reject);
|
|
listener.listen(0, '127.0.0.1', () => {
|
|
const port = listener.address().port;
|
|
listener.close((error) => (error ? reject(error) : resolve(port)));
|
|
});
|
|
});
|
|
const observed = [];
|
|
const surface = await startLocalApiHttpSurface({
|
|
profile: 'edge',
|
|
host: '127.0.0.1',
|
|
port,
|
|
admission: {
|
|
async prepare({ operation }) {
|
|
observed.push(operation);
|
|
const view = {
|
|
'task.list': 'tasks',
|
|
'trigger.list': 'triggers',
|
|
'run.list': 'runs',
|
|
}[operation.operationId];
|
|
assert.ok(view);
|
|
const input = operation.input || operation;
|
|
assert.equal(input.limit, 64);
|
|
const entries = rows(view);
|
|
if (input.after)
|
|
assert.deepEqual(input.after, cursor(view, entries[63]));
|
|
return {
|
|
bodyMode: 'none',
|
|
maximumBodyBytes: 0,
|
|
async handle() {
|
|
return {
|
|
statusCode: 200,
|
|
body: input.after
|
|
? page(view, entries.slice(64))
|
|
: page(view, entries.slice(0, 64), true),
|
|
};
|
|
},
|
|
};
|
|
},
|
|
},
|
|
});
|
|
t.after(() => surface.stopAndDrain());
|
|
for (const view of ['tasks', 'triggers', 'runs']) {
|
|
const client = fixture(view, async (url) => {
|
|
const response = await fetch(`http://127.0.0.1:${port}${url}`, {
|
|
headers: { authorization: 'Bearer test' },
|
|
});
|
|
assert.equal(response.status, 200);
|
|
return response.json();
|
|
});
|
|
await client.refresh();
|
|
await button(client, '下一页').listeners.click();
|
|
assert.equal(records(client).length, 1);
|
|
}
|
|
assert.equal(observed.length, 6);
|
|
});
|