mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): navigate bounded native console log windows
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# QingLong 3.0 Architecture RFC
|
||||
|
||||
- D-430/ADR-0532(原生 Console 源码候选):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。客户端行为测试和真实 Chromium 预览覆盖分页、重复点击、过期响应与字节边界;远端 CI 和 exact 双架构 Console 实物尚待验证。
|
||||
|
||||
- D-429/ADR-0531(exact 双架构 Console 阶段实物已交付):现有面板的 Cron “日志”入口不恢复 2.x `/api/crons/:id/log` 或文件系统日志权威,而是复用规范 v3 Run/Attempt/Artifact 链。`BoundedRunListItem` 仅增加经 128 字节无控制字符验证的可选 `triggerId`;页面按 `triggerId + taskId` 在 Edge 64/Standalone 256 条 Run 总预算中定位最新 Run,再读取 detail 的 latest Attempt 与 16/32 KiB 首个日志片段,明确呈现 pending/retired/truncated。所有请求继续走 Bearer、Project Policy、durable audit、credential reconfirm 与 Artifact retention;不暴露路径、Artifact id、Secret 或其他 Run 私有字段。QL3 Action 列只开放日志,列表关闭旧 10 秒轮询,日志关闭旧 2 秒轮询,仅由初次进入、点击与手动刷新驱动;2.x 行为保持不变。没有新增 package、dependency、migration、schema、listener、连接或后台进程,headless 与 Cluster authority 不变。本地 runtime-core 594/594、Local API 93/93、全包/后端/架构审计、Node 20 production build、240 files / 11,965,017 bytes 闭包审计和真实 Chromium journey 均通过;浏览器稳定 15 秒只有一次 Cron list,日志稳定 5 秒只有 Run list/detail/log 各一次。运行时代码提交 `09ef1745226c05521a9a44accb9d9ef95dd46c85` 的主 CI run `33787481413` 为 42 success / 3 expected skip / 0 fail,Console artifact run `33789576578` 为 43 success / 2 expected skip / 0 fail;amd64/arm64/milestone artifact `9907367021`/`9907250404`/`9907412968` 保留至 2026-10-03,下载后的 `SHA256SUMS` 与 v7 离线 auditor 均返回 `compatible=true`。
|
||||
|
||||
- D-428/ADR-0530(exact 双架构阶段实物已交付):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health`、`GET /api/system`、`GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user`、`GET /api/system/config` 仍经过 Bearer、`task.read` Policy、durable audit 与 credential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timer、watcher 或后台进程;默认 headless、Cluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。提交 `fa730da0912d0e3c503697e7816aa0b14fa02165` 的普通主 CI run `33597913690` 为 42 success / 3 expected condition skip / 0 fail,Console artifact run `33597938992` 为 43 success / 2 expected skip / 0 fail;amd64/arm64/milestone artifact `9834695619`/`9834657575`/`9834716653` 保留至 2026-10-02。下载后的 v7 milestone `SHA256SUMS` 与离线 auditor 均返回 `compatible=true`,D-428 因此已经是可直接试运行的阶段页面产物。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-0532:原生 Console 的日志窗口导航
|
||||
|
||||
- 状态:Accepted(D-430 源码候选;远端 CI 与 exact Console 实物待验证)
|
||||
- 日期:2026-09-04
|
||||
- 关联:QL-RFC-0001 D-430、ADR-0515、ADR-0531
|
||||
|
||||
## 问题
|
||||
|
||||
原生 3.0 Console 已能创建任务、配置定时与加密凭据,但日志超过首个 32 KiB 后仍要求部署者离开页面自行调用 API。旧面板兼容不是长期产品目标,本切片在原生页面补齐日志观察的用户操作闭环。
|
||||
|
||||
## 决策
|
||||
|
||||
- 在既有 Attempt log API 上增加“下一片段”“回到开头”“刷新当前片段”。每次用户动作只读取一个 32 KiB 窗口并替换 `<pre>`,不累计历史窗口、不自动翻页、不新增轮询或全文下载。
|
||||
- 翻页固定选择时的 Project、Run 与 Attempt;刷新当前片段不会悄然切换到另一个 Attempt。重新选择 Run 才重新读取 latest Attempt、Run/Event/Step 事实。
|
||||
- 验证响应 schema、Project/Run/Attempt 身份、字节范围、解码字节数与前进 cursor。异常或停滞 cursor 不生成下一页操作;pending、retired、masked absence 和 unavailable 保持显式状态。
|
||||
- 选择请求使用不含凭据的内存代次标记;旧请求在切换 Run、离开运行页、断开连接或同一 Run 重选后不能覆盖当前详情。翻页按钮请求中禁用且合并重复点击,未连接到当前文档的旧节点不能启动新请求。
|
||||
- 字节分片仍遵循既有 API,UTF-8 跨片字符可能显示替换符,页面明确说明;不伪造完整行、不保存跨窗口解码缓冲、不修改日志字节偏移。
|
||||
|
||||
## 部署边界与验证
|
||||
|
||||
不新增 package、dependency、schema、migration、后端 route、数据库连接、listener 或后台任务。默认 headless 与独立 Cluster Console 不变,Local 原生资产仍受单文件 96 KiB / 总计 192 KiB 限制。
|
||||
|
||||
客户端行为测试直接执行发布的 `console.js`,覆盖分页替换、范围/身份漂移、连续点击、过期响应、错误状态、空窗口和 UTF-8 字节边界。真实 Chromium 预览已验证首片段 → 下一片段 → 回到开头,DOM 始终只有一个日志窗口,中文与 `<script>` 字面文本安全展示,三个动作只产生三个日志 GET。
|
||||
|
||||
本地验证:客户端行为测试 6/6、完整 Local API 99/99、后端 1685 项(1683 pass / 2 环境条件 skip / 0 fail),Local image 与 package boundary audit 均 compatible。原生 JS 为 74,695 bytes,三项资产合计 106,216 bytes,均在既有硬上限内。
|
||||
|
||||
源码与本地门通过不等同阶段镜像交付。远端完整 CI、exact Console 双架构构建与离线实物校验仍需闭合;此前 ADR-0531 的 `09ef1745` artifact 不包含本切片。
|
||||
@@ -535,6 +535,7 @@
|
||||
| [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Accepted(D-427 双架构阶段实物已交付) |
|
||||
| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Accepted(D-428 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) |
|
||||
| [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | Accepted(D-430 源码候选;实物待验证) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -1527,6 +1527,14 @@
|
||||
|
||||
async function selectRun(runId) {
|
||||
state.selectedId = runId;
|
||||
const selection = { project: state.project };
|
||||
state.runSelection = selection;
|
||||
const isCurrent = () =>
|
||||
state.runSelection === selection &&
|
||||
state.selectedId === runId &&
|
||||
state.view === 'runs' &&
|
||||
state.project === selection.project &&
|
||||
Boolean(state.token);
|
||||
const selected = nodes.ledger.querySelectorAll('.record');
|
||||
for (const row of selected) {
|
||||
if (row.dataset.identity === runId) {
|
||||
@@ -1544,24 +1552,44 @@
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
|
||||
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
|
||||
]);
|
||||
const logView = await readRunLog(runValue.run);
|
||||
renderRunDetail(runValue.run, eventValue, stepValue, logView);
|
||||
if (!isCurrent()) return;
|
||||
if (runValue.run?.id !== runId) throw new Error('Run identity mismatch');
|
||||
const loadPage = (offset) =>
|
||||
readRunLog(runValue.run, offset, selection.project);
|
||||
const logView = await loadPage(0);
|
||||
if (!isCurrent()) return;
|
||||
renderRunDetail(runValue.run, eventValue, stepValue, {
|
||||
...logView,
|
||||
loadPage,
|
||||
isCurrent,
|
||||
});
|
||||
} catch (error) {
|
||||
detailEmpty(describeError(error));
|
||||
if (isCurrent()) detailEmpty(describeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function readRunLog(run) {
|
||||
async function readRunLog(run, offset = 0, project = state.project) {
|
||||
const attempt = run?.latestAttempt;
|
||||
if (!attempt || typeof attempt.id !== 'string') {
|
||||
return Object.freeze({ status: 'not_started' });
|
||||
}
|
||||
const context = { attempt, offset };
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) {
|
||||
return Object.freeze({ status: 'unavailable', ...context });
|
||||
}
|
||||
try {
|
||||
const value = await api(
|
||||
`/api/v3/projects/${state.project}/runs/${run.id}/attempts/${attempt.id}/log?offset=0&length=${LOG_READ_BYTES}`,
|
||||
`/api/v3/projects/${project}/runs/${run.id}/attempts/${attempt.id}/log?offset=${offset}&length=${LOG_READ_BYTES}`,
|
||||
);
|
||||
if (
|
||||
value.schema !== 'qinglong/run-attempt-log-read-result@v1' ||
|
||||
value.projectId !== project ||
|
||||
value.runId !== run.id ||
|
||||
value.attemptId !== attempt.id
|
||||
)
|
||||
return Object.freeze({ status: 'unavailable', ...context });
|
||||
if (value.status === 'pending') {
|
||||
return Object.freeze({ status: 'pending', attempt });
|
||||
return Object.freeze({ status: 'pending', ...context });
|
||||
}
|
||||
const content =
|
||||
value.status === 'available' && value.encoding === 'base64'
|
||||
@@ -1572,25 +1600,36 @@
|
||||
!value.range ||
|
||||
!Number.isSafeInteger(value.range.start) ||
|
||||
!Number.isSafeInteger(value.range.endExclusive) ||
|
||||
!Number.isSafeInteger(value.range.totalBytes)
|
||||
!Number.isSafeInteger(value.range.totalBytes) ||
|
||||
value.range.totalBytes < 0 ||
|
||||
value.range.start !== Math.min(offset, value.range.totalBytes) ||
|
||||
value.range.endExclusive < value.range.start ||
|
||||
value.range.endExclusive > value.range.totalBytes ||
|
||||
value.range.endExclusive - value.range.start > LOG_READ_BYTES ||
|
||||
window.atob(value.content).length !==
|
||||
value.range.endExclusive - value.range.start ||
|
||||
(value.range.endExclusive < value.range.totalBytes
|
||||
? value.range.nextOffset !== value.range.endExclusive ||
|
||||
value.range.nextOffset <= offset
|
||||
: value.range.nextOffset !== undefined)
|
||||
) {
|
||||
return Object.freeze({ status: 'unavailable', attempt });
|
||||
return Object.freeze({ status: 'unavailable', ...context });
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'available',
|
||||
attempt,
|
||||
...context,
|
||||
content,
|
||||
range: value.range,
|
||||
truncation: value.truncation,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConsoleRequestError && error.status === 410) {
|
||||
return Object.freeze({ status: 'retired', attempt });
|
||||
return Object.freeze({ status: 'retired', ...context });
|
||||
}
|
||||
if (error instanceof ConsoleRequestError && error.status === 404) {
|
||||
return Object.freeze({ status: 'not_found', attempt });
|
||||
return Object.freeze({ status: 'not_found', ...context });
|
||||
}
|
||||
return Object.freeze({ status: 'unavailable', attempt });
|
||||
return Object.freeze({ status: 'unavailable', ...context });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1609,6 +1648,45 @@
|
||||
);
|
||||
}
|
||||
section.append(header);
|
||||
if (logView.attempt && logView.loadPage && logView.isCurrent) {
|
||||
const actions = element('div', 'detail-actions');
|
||||
let busy = false;
|
||||
const load = async (offset) => {
|
||||
if (busy || !section.isConnected || !logView.isCurrent()) return;
|
||||
busy = true;
|
||||
section.setAttribute('aria-busy', 'true');
|
||||
for (const button of actions.querySelectorAll('button'))
|
||||
button.disabled = true;
|
||||
try {
|
||||
const page = await logView.loadPage(offset);
|
||||
if (!section.isConnected || !logView.isCurrent()) return;
|
||||
section.replaceWith(
|
||||
renderRunLog({
|
||||
...page,
|
||||
loadPage: logView.loadPage,
|
||||
isCurrent: logView.isCurrent,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
busy = false;
|
||||
section.setAttribute('aria-busy', 'false');
|
||||
for (const button of actions.querySelectorAll('button'))
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
actions.append(actionButton('刷新当前片段', () => load(logView.offset)));
|
||||
if (logView.offset > 0)
|
||||
actions.append(actionButton('回到开头', () => load(0)));
|
||||
if (
|
||||
logView.status === 'available' &&
|
||||
logView.range.nextOffset !== undefined
|
||||
) {
|
||||
actions.append(
|
||||
actionButton('下一片段', () => load(logView.range.nextOffset)),
|
||||
);
|
||||
}
|
||||
section.append(actions);
|
||||
}
|
||||
if (logView.status === 'available') {
|
||||
const metadata = [
|
||||
`${logView.range.start}–${logView.range.endExclusive} / ${logView.range.totalBytes} bytes`,
|
||||
@@ -1617,7 +1695,8 @@
|
||||
if (logView.truncation?.truncated === 'unknown')
|
||||
metadata.push('截断状态未知');
|
||||
if (logView.range.nextOffset !== undefined)
|
||||
metadata.push('后续内容可经 API 分页读取');
|
||||
metadata.push('有后续片段;翻页替换当前窗口,不累计全文');
|
||||
metadata.push('按字节分片,跨片段的 UTF-8 字符可能显示替换符');
|
||||
section.append(element('p', 'run-log-meta', metadata.join(' · ')));
|
||||
section.append(
|
||||
element('pre', 'run-log-content', logView.content || '(空日志)'),
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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');
|
||||
|
||||
// Execute the shipped client, with only a DOM/transport fixture and test exports.
|
||||
class Node {
|
||||
constructor(tag = 'div') {
|
||||
this.tag = tag;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.listeners = {};
|
||||
this.isConnected = true;
|
||||
}
|
||||
set textContent(value) {
|
||||
this.text = value;
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return (this.text || '') + this.children.map((x) => x.textContent).join('');
|
||||
}
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = this;
|
||||
this.children.push(child);
|
||||
}
|
||||
}
|
||||
detach() {
|
||||
this.isConnected = false;
|
||||
for (const child of this.children) child.detach();
|
||||
}
|
||||
replaceChildren(...children) {
|
||||
for (const child of this.children) child.detach();
|
||||
this.children = [];
|
||||
this.append(...children);
|
||||
}
|
||||
replaceWith(node) {
|
||||
const index = this.parent.children.indexOf(this);
|
||||
this.parent.children[index] = node;
|
||||
node.parent = this.parent;
|
||||
this.detach();
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
removeAttribute(name) {
|
||||
delete this[name];
|
||||
}
|
||||
addEventListener(name, handler) {
|
||||
this.listeners[name] = handler;
|
||||
}
|
||||
querySelector() {
|
||||
return new Node();
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
return this.children.flatMap((child) => [
|
||||
...(selector === child.tag || selector === `.${child.className}`
|
||||
? [child]
|
||||
: []),
|
||||
...child.querySelectorAll(selector),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const run = {
|
||||
id: 'run-a',
|
||||
taskId: 'task-a',
|
||||
status: 'succeeded',
|
||||
latestAttempt: { id: 'attempt-a', attempt: 1, status: 'succeeded' },
|
||||
};
|
||||
function page(offset = 0, content = 'first', totalBytes = 10) {
|
||||
const endExclusive = offset + Buffer.byteLength(content);
|
||||
return {
|
||||
schema: 'qinglong/run-attempt-log-read-result@v1',
|
||||
status: 'available',
|
||||
projectId: 'default',
|
||||
runId: run.id,
|
||||
attemptId: run.latestAttempt.id,
|
||||
encoding: 'base64',
|
||||
content: Buffer.from(content).toString('base64'),
|
||||
range: {
|
||||
start: offset,
|
||||
endExclusive,
|
||||
totalBytes,
|
||||
...(endExclusive < totalBytes ? { nextOffset: endExclusive } : {}),
|
||||
},
|
||||
truncation: { truncated: false },
|
||||
};
|
||||
}
|
||||
function fixture(logResponse = () => page()) {
|
||||
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 Node());
|
||||
return nodes.get(id);
|
||||
},
|
||||
querySelector() {
|
||||
return new Node();
|
||||
},
|
||||
createElement: (tag) => new Node(tag),
|
||||
createDocumentFragment: () => new Node('fragment'),
|
||||
addEventListener() {},
|
||||
},
|
||||
fetch: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
const result = url.includes('/log?')
|
||||
? await logResponse(url)
|
||||
: url.includes('/events?')
|
||||
? { events: [] }
|
||||
: url.includes('/steps?')
|
||||
? { steps: [] }
|
||||
: { run };
|
||||
const status = result.httpStatus || 200;
|
||||
return {
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: async () => result,
|
||||
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, readRunLog, selectRun };})();',
|
||||
context,
|
||||
);
|
||||
Object.assign(context.client.state, {
|
||||
token: 'test-memory-token',
|
||||
project: 'default',
|
||||
view: 'runs',
|
||||
});
|
||||
return { ...context.client, calls };
|
||||
}
|
||||
const button = (client, label) =>
|
||||
client.nodes.detail
|
||||
.querySelectorAll('button')
|
||||
.find((node) => node.textContent === label);
|
||||
const content = (client) =>
|
||||
client.nodes.detail.querySelectorAll('pre')[0]?.textContent;
|
||||
|
||||
test('native log pages replace one bounded window and preserve the Attempt binding', async () => {
|
||||
const client = fixture((url) =>
|
||||
url.includes('offset=5&') ? page(5, 'other') : page(),
|
||||
);
|
||||
await client.selectRun(run.id);
|
||||
assert.equal(content(client), 'first');
|
||||
await button(client, '下一片段').listeners.click();
|
||||
assert.equal(content(client), 'other');
|
||||
assert.equal(client.nodes.detail.querySelectorAll('pre').length, 1);
|
||||
assert.equal(button(client, '下一片段'), undefined);
|
||||
await button(client, '回到开头').listeners.click();
|
||||
assert.equal(content(client), 'first');
|
||||
assert.equal(client.calls.length, 6); // detail/events/steps plus three explicit reads
|
||||
assert.ok(
|
||||
client.calls
|
||||
.filter((x) => x.url.includes('/log?'))
|
||||
.every(
|
||||
(x) =>
|
||||
x.url.includes('/runs/run-a/attempts/attempt-a/log?') &&
|
||||
x.url.endsWith('length=32768'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed identity, byte ranges, oversized content and non-progressing cursors', async () => {
|
||||
const invalid = [
|
||||
{ ...page(), runId: 'foreign' },
|
||||
{ ...page(), projectId: 'foreign' },
|
||||
{ ...page(), attemptId: 'foreign' },
|
||||
{ ...page(), schema: 'unknown' },
|
||||
{ ...page(), content: '***' },
|
||||
{ ...page(), content: Buffer.alloc(32769).toString('base64') },
|
||||
...[
|
||||
{ start: 1 },
|
||||
{ endExclusive: 4 },
|
||||
{ totalBytes: -1 },
|
||||
{ nextOffset: 0 },
|
||||
{ nextOffset: 6 },
|
||||
{ nextOffset: undefined },
|
||||
{ totalBytes: 5 },
|
||||
{ totalBytes: Number.MAX_SAFE_INTEGER + 1 },
|
||||
].map((range) => ({ ...page(), range: { ...page().range, ...range } })),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
const client = fixture(() => value);
|
||||
assert.equal((await client.readRunLog(run)).status, 'unavailable');
|
||||
}
|
||||
const client = fixture();
|
||||
assert.equal((await client.readRunLog(run, -1)).status, 'unavailable');
|
||||
assert.equal(client.calls.length, 0);
|
||||
});
|
||||
|
||||
test('coalesces repeated clicks and discards a page after switching away', async () => {
|
||||
let finish;
|
||||
const client = fixture((url) =>
|
||||
url.includes('offset=5&')
|
||||
? new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
})
|
||||
: page(),
|
||||
);
|
||||
await client.selectRun(run.id);
|
||||
const next = button(client, '下一片段');
|
||||
const pending = next.listeners.click();
|
||||
await next.listeners.click();
|
||||
assert.equal(client.calls.length, 5);
|
||||
assert.equal(next.disabled, true);
|
||||
client.state.view = 'tasks';
|
||||
finish(page(5, 'other'));
|
||||
await pending;
|
||||
assert.equal(content(client), 'first');
|
||||
});
|
||||
|
||||
test('discards stale initial reads on re-selection and disconnect', async () => {
|
||||
const finishes = [];
|
||||
const client = fixture(
|
||||
() => new Promise((resolve) => finishes.push(resolve)),
|
||||
);
|
||||
const old = client.selectRun(run.id);
|
||||
while (finishes.length < 1)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const newer = client.selectRun(run.id);
|
||||
while (finishes.length < 2)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
finishes[1](page(0, 'newer'));
|
||||
await newer;
|
||||
finishes[0](page(0, 'older'));
|
||||
await old;
|
||||
assert.equal(content(client), 'newer');
|
||||
const pending = button(client, '下一片段').listeners.click();
|
||||
client.state.token = null;
|
||||
finishes[2](page(5, 'other'));
|
||||
await pending;
|
||||
assert.equal(content(client), 'newer');
|
||||
});
|
||||
|
||||
test('renders pending, retention, denial and unavailable states without a continuation', async () => {
|
||||
for (const [result, expected] of [
|
||||
[{ ...page(), status: 'pending', httpStatus: 202 }, 'pending'],
|
||||
[{ httpStatus: 410 }, 'retired'],
|
||||
[{ httpStatus: 404 }, 'not_found'],
|
||||
[{ httpStatus: 401 }, 'unavailable'],
|
||||
[{ httpStatus: 503 }, 'unavailable'],
|
||||
]) {
|
||||
const client = fixture(() => result);
|
||||
await client.selectRun(run.id);
|
||||
assert.equal(
|
||||
client.nodes.detail.querySelectorAll('.run-log')[0].dataset.state,
|
||||
expected,
|
||||
);
|
||||
assert.equal(button(client, '下一片段'), undefined);
|
||||
assert.ok(button(client, '刷新当前片段'));
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts empty EOF, a full window and bounded UTF-8 replacement at a byte boundary', async () => {
|
||||
for (const [value, offset] of [
|
||||
[page(0, '', 0), 0],
|
||||
[page(10, '', 10), 20],
|
||||
[page(0, 'a'.repeat(32768), 32768), 0],
|
||||
]) {
|
||||
const client = fixture(() => value);
|
||||
assert.equal((await client.readRunLog(run, offset)).status, 'available');
|
||||
}
|
||||
const value = page(0, 'a', 1);
|
||||
value.content = Buffer.from([0xe4]).toString('base64');
|
||||
const client = fixture(() => value);
|
||||
const result = await client.readRunLog(run);
|
||||
assert.equal(result.status, 'available');
|
||||
assert.equal(result.content, '\uFFFD');
|
||||
});
|
||||
Reference in New Issue
Block a user