mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
fix(plugins): isolate search stop failures and float operation feedback
A searchCancel deadline previously killed the shared plugin process and prevented subsequent acquisition. Release only the stop request and allow late cleanup to finish. Keep ordinary hard cancellation unchanged. Cover process and HTTP acquisition regressions and compact extension feedback without layout shifts.
This commit is contained in:
1 parent
1fda200d69
commit
0782202dae
10 files changed
+163
-20
No files matched your search
@@ -10,6 +10,8 @@ GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourc
|
||||
|
||||
返回 Page DTO:`title/description/notice/forms/sections/tabs/activeTab`。每个 Tab 包含 `id/title/description/forms/sections/outputs`,页面公共内容和当前 Tab 同时渲染。Form 包含 `id/title/submit/fields/values`,可选 `layout: "inline"` 和 `confirm` 声明紧凑布局及行内二次确认,fields 支持 text、textarea、number、boolean、select;values 携带不透明行 ID。Field 可选 `placeholder/min/max`,Page 可选 `noticeKind: "info" | "error"`;所有字段由宿主验证。section 支持 `emptyText`,item 支持 `collapsible`。`outputs` 用于调试日志、JSON 或普通文本,格式为 `{title,text,format:"text"|"log"|"json"}`,宿主以纯文本块渲染并限制数量与大小,不执行内容。一次动作返回新页面;可选 activeTab 请求切换到指定 Tab,否则保留当前选择。纯 Tab 切换保留输入草稿。提交后保留其它声明未变化的表单草稿;服务端改变表单声明或主动刷新时以新页面为准。
|
||||
|
||||
操作中的状态、`notice` 和请求错误由宿主统一显示为紧凑浮动提示,不占用页面内容高度。完成提示可关闭并自动消失;错误保留 `alert` 语义,操作完成后的焦点回到内容面板,不滚动到提示。需要持续查看的日志和结果仍放在 `outputs`。
|
||||
|
||||
宿主校验声明、页面结构、选项和输入配额;未声明页面拒绝访问。渲染器只呈现文本,不接受 HTML、JS、iframe 或任意前端代码。授权在 HTTP 层执行,隐藏入口不代替鉴权。
|
||||
|
||||
启用 storage 权限后,RPC 获得 `host.dataDir = DATA_DIR/plugin-data/<SHA256(pluginId)>`;带实例上下文时还获得 `host.instanceDataDir = host.dataDir/sources/<SHA256(sourceId)>`。插件负责创建目录和原子写入;数据与 npm 包目录分离,停用、升级、卸载均保留。这是受信任插件的约定,不是操作系统沙箱。
|
||||
|
||||
@@ -245,7 +245,9 @@ response = { jsonrpc: "2.0", id, result }
|
||||
error = { jsonrpc: "2.0", id, error: { code, message, data? } }
|
||||
```
|
||||
|
||||
宿主每次来源调用预算为 60 秒,同一来源实例最多并发 2 次,宿主共 8 次,超限返回 `429 RATE_LIMITED`。插件单次 RPC 预算为 30 秒,每条 JSON 消息上限为 2 MiB(包括 base64 和协议字段)。请求取消、超时、协议错误或进程异常退出会终止 worker,全部在途调用失败;取消是硬停止,不是合作式取消。插件进入 `failed` 后可通过管理员重新启用来加载新进程。重试由调用方显式决定,不自动重复 `acquire` 等可能有副作用的调用。后续再加入进度事件、背压流和宿主能力的反向调用。
|
||||
宿主每次来源调用预算为 60 秒,同一来源实例最多并发 2 次,宿主共 8 次,超限返回 `429 RATE_LIMITED`。插件单次 RPC 预算为 30 秒,每条 JSON 消息上限为 2 MiB(包括 base64 和协议字段)。声明 `search.cancel` 的搜索请求支持合作式取消:宿主发送 `$/cancelRequest`,清理该请求的计时器并忽略迟到响应,其它调用继续运行。声明 `search.session` 的 `searchCancel` 控制请求不占用普通调用槽位,宿主等待确认最多 5 秒;取消或超时只结束等待,不终止插件,让迟到的停止操作仍有机会清理搜索会话。插件应优先处理停止请求,并为断开的搜索设置空闲回收机制。
|
||||
|
||||
其它不支持合作式取消的请求被取消、普通 RPC 超时、协议错误或进程异常退出时,宿主会终止 worker,全部在途调用失败。插件进入 `failed` 后可通过管理员重新启用来加载新进程。重试由调用方显式决定,不自动重复 `acquire` 等可能有副作用的调用。
|
||||
|
||||
## 6. 内置来源
|
||||
|
||||
@@ -455,7 +457,7 @@ HTTP 返回宿主 DTO,凭据不回显。插件和来源配置管理需管理
|
||||
| SourceHost、来源实例及凭据 | 已实现 | 实例由管理员配置共享,密码按来源/用户加密,调用预算 60 秒;并发限额每实例 2、全宿主 8。 |
|
||||
| 来源管理与配置 UI | 已实现并测试 | 创建、编辑、启停、删除空来源、浏览/搜索、获取及个人凭据。 |
|
||||
| 内置 local 与本地内容 seam | 已实现并测试 | 保留现有 book ID、书架及阅读接口,集中本地与下载文件解析。 |
|
||||
| 受信任 Node ProcessRunner | 已实现并测试 | JSON-RPC、30 秒调用预算、硬取消、异常退出、2 MiB 消息上限。 |
|
||||
| 受信任 Node ProcessRunner | 已实现并测试 | JSON-RPC、30 秒调用预算、搜索合作式取消、停止请求隔离、其它请求硬取消、异常退出、2 MiB 消息上限。 |
|
||||
| 插件安装、启停、卸载与启动恢复 | 已实现并测试 | Web 安装 npm 包或上传 .tgz;失败清理本次目录,卸载保留数据和包文件。 |
|
||||
| 最小外部来源示例 | 已实现并测试 | `examples/plugins/demo-chapters`,提供静态示例目录和正文,不联网。 |
|
||||
| 插件安装 UI | 已实现 | 输入 npm 包名或上传 .tgz 后自动启用、启停、重启失败进程、卸载;覆盖升级和版本回滚仍后续。 |
|
||||
|
||||
@@ -204,17 +204,24 @@ export class ProcessPlugin {
|
||||
return Promise.reject(new PluginError('PLUGIN_MESSAGE_TOO_LARGE', 'plugin request exceeds the message limit'));
|
||||
}
|
||||
return new Promise((resolveRequest, reject) => {
|
||||
const type = this.manifest.sourceTypes.find(type => type.id === params.sourceType);
|
||||
const cancellationRequest = method === 'searchCancel' && type?.capabilities.includes('search.session');
|
||||
const cancelRequest = (error: PluginError) => {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(id);
|
||||
pending.cleanup();
|
||||
// A stop command may still finish late: keep it running so it can release
|
||||
// the search session, but never let its deadline kill unrelated work.
|
||||
if (!cancellationRequest) child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } }) + '\n');
|
||||
pending.reject(error);
|
||||
};
|
||||
const cancel = () => {
|
||||
if (!this.pending.has(id)) return;
|
||||
const type = this.manifest.sourceTypes.find(type => type.id === params.sourceType);
|
||||
if (method === 'search' && type?.capabilities.includes('search.cancel')) {
|
||||
if (cancellationRequest || (method === 'search' && type?.capabilities.includes('search.cancel'))) {
|
||||
// A cancelled request must not later time out unrelated calls.
|
||||
// onMessage ignores any late acknowledgement from the plugin.
|
||||
const pending = this.pending.get(id);
|
||||
this.pending.delete(id);
|
||||
pending?.cleanup();
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id } }) + '\n');
|
||||
reject(new PluginError('PLUGIN_CANCELLED', 'search cancelled'));
|
||||
cancelRequest(new PluginError('PLUGIN_CANCELLED', cancellationRequest ? 'search cancellation was not acknowledged' : 'search cancelled'));
|
||||
return;
|
||||
}
|
||||
// Killing the worker is the only hard cancellation available for trusted
|
||||
@@ -222,7 +229,9 @@ export class ProcessPlugin {
|
||||
this.fail(new PluginError('PLUGIN_CANCELLED', 'plugin process stopped because a request was cancelled'));
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
this.fail(new PluginError('PLUGIN_TIMEOUT', 'plugin request exceeded its execution deadline'));
|
||||
const error = new PluginError('PLUGIN_TIMEOUT', 'plugin request exceeded its execution deadline');
|
||||
if (cancellationRequest) cancelRequest(error);
|
||||
else this.fail(error);
|
||||
}, this.timeoutMs);
|
||||
timer.unref();
|
||||
const cleanup = () => {
|
||||
|
||||
@@ -175,6 +175,44 @@ test('cooperative cancellation clears the deadline even without an acknowledgeme
|
||||
}
|
||||
});
|
||||
|
||||
for (const end of ['abort', 'deadline'] as const) test(`search cancellation ${end} does not disable acquisition`, async () => {
|
||||
const source = await fixture(`
|
||||
import { createInterface } from 'node:readline';
|
||||
createInterface({ input: process.stdin }).on('line', line => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.method === 'searchCancel' || request.method === '$/cancelRequest') return;
|
||||
const result = request.method === 'acquire'
|
||||
? { kind: 'chapters', publicationRef: 'book', title: 'Book' }
|
||||
: { ref: 'book', title: 'Book' };
|
||||
setTimeout(() => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n'), 50);
|
||||
});
|
||||
`);
|
||||
await writeFile(join(source.directory, 'plugin.json'), JSON.stringify({ ...manifest,
|
||||
sourceTypes: [{ id: 'test', label: 'Test', capabilities: ['detail', 'search', 'search.cancel', 'search.session'] }],
|
||||
}));
|
||||
const plugin = await ProcessPlugin.load(source.directory, { timeoutMs: 2000 });
|
||||
try {
|
||||
const provider = plugin.providers()[0]!;
|
||||
await provider.detail(context(), 'book');
|
||||
const controller = new AbortController();
|
||||
const cancelled = assert.rejects(provider.cancelSearch!(context(controller.signal), 'session'), {
|
||||
code: end === 'abort' ? 'PLUGIN_CANCELLED' : 'PLUGIN_TIMEOUT',
|
||||
});
|
||||
if (end === 'deadline') await cancelled;
|
||||
const acquiring = provider.acquire(context(), { entryRef: 'book' });
|
||||
const outcome = Promise.allSettled([acquiring]);
|
||||
if (end === 'abort') controller.abort();
|
||||
await cancelled;
|
||||
assert.deepEqual(await outcome, [{ status: 'fulfilled', value: { kind: 'chapters', publicationRef: 'book', title: 'Book' } }]);
|
||||
assert.equal(plugin.status().state, 'running');
|
||||
assert.equal(plugin.status().pendingRequests, 0);
|
||||
assert.equal((await provider.acquire(context(), { entryRef: 'book' })).kind, 'chapters');
|
||||
} finally {
|
||||
await plugin.close();
|
||||
await source.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('wire validation prevents plugins claiming host books or corrupting chapter identities', () => {
|
||||
assert.throws(() => decodeChapterAcquisition({ kind: 'ready', publicationId: 'other-users-book' }), /must acquire chapter/);
|
||||
assert.throws(() => decodeChapterAcquisition({ kind: 'action-required', action: { type: 'external', label: 'Open', url: 'javascript:alert(1)' } }), /HTTP or HTTPS/);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
@@ -451,6 +451,31 @@ test('trusted example plugins can be installed, queried, disabled, reloaded afte
|
||||
assert.ok(instances.json().sources.some((source: { id: string }) => source.id === 'demo-instance'), 'uninstall keeps instance configuration');
|
||||
});
|
||||
|
||||
test('an unacknowledged search stop does not prevent adding a book to the shelf', { timeout: 15000 }, async t => {
|
||||
const h = await harness(); t.after(() => h.close());
|
||||
const directory = join(h.dataDir, 'plugins', 'node_modules', 'reader-source-example');
|
||||
await cp(resolve(import.meta.dirname, '../../examples/plugins/demo-chapters'), directory, { recursive: true });
|
||||
const manifest = JSON.parse(await readFile(join(directory, 'plugin.json'), 'utf8'));
|
||||
manifest.sourceTypes[0].capabilities.push('search.cancel', 'search.session');
|
||||
await writeFile(join(directory, 'plugin.json'), JSON.stringify(manifest));
|
||||
const entry = await readFile(join(directory, 'main.mjs'), 'utf8');
|
||||
await writeFile(join(directory, 'main.mjs'), entry.replace("request.method === '$/cancelRequest'", "['$/cancelRequest', 'searchCancel'].includes(request.method)"));
|
||||
const installed = await h.app.inject({ method: 'POST', url: '/api/v1/plugins', headers: auth(h.admin), payload: { folder: 'npm:reader-source-example', trusted: true } });
|
||||
assert.equal(installed.statusCode, 201, installed.body);
|
||||
const created = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: {
|
||||
id: 'cancel-test', pluginId: manifest.id, sourceType: 'demo-chapters', name: 'Cancel test', config: {},
|
||||
} });
|
||||
assert.equal(created.statusCode, 201, created.body);
|
||||
const stopped = await h.app.inject({ method: 'POST', url: '/api/v1/sources/cancel-test/search/cancel', headers: auth(h.admin), payload: { sessionId: 'test-search-session' } });
|
||||
assert.equal(stopped.json().error.code, 'PLUGIN_CANCELLED', stopped.body);
|
||||
const acquired = await h.app.inject({ method: 'POST', url: '/api/v1/sources/cancel-test/acquire', headers: auth(h.admin), payload: { entryRef: 'demo-book' } });
|
||||
assert.equal(acquired.statusCode, 200, acquired.body);
|
||||
const bookId = acquired.json().publicationId;
|
||||
assert.ok(h.ctx.db.get('SELECT book_id FROM user_books WHERE user_id = ? AND book_id = ?', h.admin.id, bookId));
|
||||
const book = await h.app.inject({ method: 'GET', url: `/api/v1/books/${bookId}`, headers: auth(h.admin) });
|
||||
assert.equal(book.json().book.format, 'chapters');
|
||||
});
|
||||
|
||||
test('search sessions pass generic options and cancellation remains available with saturated search slots', { timeout: 10000 }, async t => {
|
||||
const h = await harness(); t.after(() => h.close());
|
||||
const member = await h.member(), stopped: Array<{ user: string; source: string; session: string }> = [];
|
||||
|
||||
@@ -2869,6 +2869,8 @@ book-content {
|
||||
}
|
||||
.floating-notice > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.floating-notice .icon-button { flex: none; width: 32px; height: 32px; color: var(--reader-muted); }
|
||||
.floating-notice[data-error='true'] { color: #a32c22; }
|
||||
[data-theme='dark'] .floating-notice[data-error='true'] { color: #ffaaa0; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-toast {
|
||||
@@ -3846,7 +3848,6 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin
|
||||
.extension-toolbar { flex: none; width: 100%; max-width: 64rem; margin-inline: auto; padding: 0 1rem; box-sizing: border-box; }
|
||||
.extension-toolbar .extension-tabs { margin-bottom: .75rem; border-color: var(--reader-line); }
|
||||
.extension-toolbar .notice { margin: 0 0 .75rem; overflow-wrap: anywhere; }
|
||||
.extension-toolbar .extension-error { color: #a32c22; border: 1px solid currentColor; background: var(--reader-surface); }
|
||||
.extension-description, .extension-empty { color: var(--reader-muted); }
|
||||
.extension-screen .sources-header h1 { min-width: 0; overflow-wrap: anywhere; }
|
||||
.extension-form-inline { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; }
|
||||
@@ -3867,7 +3868,6 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin
|
||||
.extension-screen .extension-form-inline label[data-field-type=number] { flex: 1 1 9rem; }
|
||||
.extension-item-heading { gap: .75rem; }
|
||||
}
|
||||
[data-theme='dark'] .extension-toolbar .extension-error { color: #ffaaa0; }
|
||||
.extension-tabs button:disabled { opacity: .55; cursor: default; }
|
||||
.extension-output { min-width: 0; }
|
||||
.extension-output summary { font-weight: 650; cursor: pointer; padding-block: .35rem; }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useLayoutEffect, useState } from './vendor/preact.ts';
|
||||
import { IconButton } from './toolkit.tsx';
|
||||
|
||||
/** Transient feedback stays out of document flow; ongoing operations remain visible. */
|
||||
export function FloatingNotice({ message, busy = false }: { message: string; busy?: boolean }) {
|
||||
export function FloatingNotice({ message, busy = false, error = false }: { message: string; busy?: boolean; error?: boolean }) {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
setDismissed(false);
|
||||
@@ -11,8 +11,8 @@ export function FloatingNotice({ message, busy = false }: { message: string; bus
|
||||
return () => clearTimeout(timer);
|
||||
}, [message, busy]);
|
||||
if (!message || dismissed) return null;
|
||||
return <div className="app-toast floating-notice" data-shown="true">
|
||||
<span role="status" aria-live="polite" aria-atomic="true">{message}</span>
|
||||
return <div className="app-toast floating-notice" data-shown="true" data-error={error && !busy ? 'true' : undefined}>
|
||||
<span role={error && !busy ? 'alert' : 'status'} aria-live={error && !busy ? 'assertive' : 'polite'} aria-atomic="true">{message}</span>
|
||||
{!busy && <IconButton label="关闭提示" icon="xmark" onClick={() => setDismissed(true)} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ApiError, type ReaderApi } from '../api/client.ts';
|
||||
import type { ExtensionContent, ExtensionForm, ExtensionPage } from '../api/sources.ts';
|
||||
import { mountUI } from './mount.ts';
|
||||
import { Button, IconButton } from './toolkit.tsx';
|
||||
import { FloatingNotice } from './floating-notice.tsx';
|
||||
|
||||
/** Safe declarative UI: plugins supply data and actions, never app-origin HTML. */
|
||||
export class PluginPageScreen {
|
||||
@@ -47,7 +48,7 @@ export class PluginPageScreen {
|
||||
} finally {
|
||||
this.busy = false; this.draw();
|
||||
if (previousTab !== this.activeTab) { const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0; }
|
||||
if (action) (this.element.querySelector<HTMLElement>('[data-extension-feedback]') ?? this.element.querySelector<HTMLElement>('[role=tabpanel]'))?.focus({ preventScroll: true });
|
||||
if (action) (this.element.querySelector<HTMLElement>('[role=tabpanel]') ?? this.element.querySelector<HTMLElement>('.sources-body'))?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
private form(form: ExtensionForm) {
|
||||
@@ -119,9 +120,9 @@ export class PluginPageScreen {
|
||||
event.preventDefault(); this.selectTab(tabs[next]!.id);
|
||||
document.getElementById('extension-tab-' + this.activeTab)?.focus();
|
||||
}}>{tab.title}</button>)}</div>}
|
||||
{status && <div data-extension-feedback tabIndex={-1} role={isError ? 'alert' : 'status'} className={isError ? 'notice extension-error' : 'notice'}>{status}</div>}
|
||||
</div>
|
||||
<main className="sources-body" aria-busy={this.busy}>
|
||||
{status && <FloatingNotice message={status} busy={this.busy} error={isError} />}
|
||||
<main className="sources-body" aria-busy={this.busy} tabIndex={-1}>
|
||||
{this.page?.description && <p className="extension-description">{this.page.description}</p>}
|
||||
{this.page && this.content(this.page)}
|
||||
{selected && <div key={selected.id} tabIndex={-1} role="tabpanel" id={'extension-panel-' + selected.id} aria-labelledby={'extension-tab-' + selected.id}>
|
||||
|
||||
@@ -12,6 +12,27 @@ function fixture() {
|
||||
const api = new ReaderApi(makePlatform(transport), { async load() { return null; }, async save() {}, async clear() {} });
|
||||
return { api, transport };
|
||||
}
|
||||
it('shows action errors in a dismissible floating alert without replacing the tab or its drafts', async () => {
|
||||
const { api, transport } = fixture();
|
||||
transport.respondWith(request => request.method === 'POST'
|
||||
? { status: 502, headers: {}, json: { error: { code: 'PLUGIN_UNAVAILABLE', message: '插件暂时不可用' } } }
|
||||
: { status: 200, headers: {}, json: { title: '配置', forms: [], tabs: [{ id: 'settings', title: '设置', forms: [
|
||||
{ id: 'save', title: '保存配置', submit: '保存', fields: [{ key: 'value', label: '内容', type: 'text' }] },
|
||||
] }] } });
|
||||
const screen = new PluginPageScreen({ api, sourceId: 'one', pageId: 'settings', onBack() {}, onSignedOut() {} });
|
||||
screens.push(screen); document.body.append(screen.element); await screen.show();
|
||||
const panel = screen.element.querySelector('[role=tabpanel]');
|
||||
const input = screen.element.querySelector('input')!;
|
||||
input.value = '保留草稿'; input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
screen.element.querySelector<HTMLButtonElement>('button[type=submit]')!.click();
|
||||
await vi.waitFor(() => expect(screen.element.querySelector('.floating-notice [role=alert]')?.textContent).toBe('插件暂时不可用'));
|
||||
expect(screen.element.querySelector('[role=tabpanel]')).toBe(panel);
|
||||
expect(input.value).toBe('保留草稿');
|
||||
expect(document.activeElement).toBe(panel);
|
||||
screen.element.querySelector<HTMLButtonElement>('[aria-label="关闭提示"]')!.click();
|
||||
await vi.waitFor(() => expect(screen.element.querySelector('.floating-notice')).toBeNull());
|
||||
expect(screen.element.querySelector('[role=tabpanel]')).toBe(panel);
|
||||
});
|
||||
it('renders generic log and JSON outputs as bounded text blocks in the selected tab', async () => {
|
||||
const { api, transport } = fixture();
|
||||
transport.respondWith(() => ({ status: 200, headers: {}, json: { title: '诊断', forms: [], tabs: [
|
||||
@@ -125,6 +146,8 @@ it('preserves unrelated tab drafts after actions and requires inline confirmatio
|
||||
button('取消').click(); expect(screen.element.querySelector('[role=group]')).toBeNull();
|
||||
button('删除').click(); button('确认删除').click();
|
||||
await vi.waitFor(() => expect(screen.element.querySelector('[role=status]')?.textContent).toBe('删除成功'));
|
||||
expect(document.activeElement?.getAttribute('role')).toBe('status');
|
||||
expect(document.activeElement?.getAttribute('role')).toBe('tabpanel');
|
||||
expect(screen.element.querySelector('.floating-notice [role=status]')?.textContent).toBe('删除成功');
|
||||
expect(screen.element.querySelector('.extension-toolbar [role=status]')).toBeNull();
|
||||
tab('草稿').click(); expect(screen.element.querySelector('textarea')?.value).toBe('unsaved');
|
||||
});
|
||||
@@ -125,8 +125,51 @@ try {
|
||||
await button('加入书架').click(); await button('阅读《插件示例书》').click();
|
||||
await page.locator('book-content').getByText('这是通过独立 Node 进程提供的示例章节。',{exact:false}).waitFor();
|
||||
await page.setViewportSize({width:390,height:844}); await shot('reader-mobile');
|
||||
|
||||
// Deterministic extension responses exercise the shared page renderer's
|
||||
// busy/error/success feedback without depending on a third-party service.
|
||||
let releaseAction, failAction = true;
|
||||
const extensionPage = { title: '扩展配置', forms: [], tabs: [{ id: 'settings', title: '设置', forms: [
|
||||
{ id: 'save', title: '来源设置', submit: '保存配置', fields: [{ key: 'name', label: '配置名称', type: 'text', value: '示例' }] },
|
||||
], sections: [{ title: '配置项目', items: Array.from({ length: 12 }, (_, index) => ({ title: '项目 ' + index, description: '用于验证操作反馈不会移动内容。' })) }] }] };
|
||||
await page.route('**/api/v1/sources/feedback-fixture/pages/settings', async route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
await new Promise(resolve => { releaseAction = resolve; });
|
||||
if (failAction) return route.fulfill({ status: 502, json: { error: { code: 'PLUGIN_UNAVAILABLE', message: '插件暂时不可用,请重启后重试。' } } });
|
||||
return route.fulfill({ json: { ...extensionPage, notice: '配置已保存' } });
|
||||
}
|
||||
return route.fulfill({ json: extensionPage });
|
||||
});
|
||||
for (const width of [390, 1920]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto(base + '/#/sources/feedback-fixture/settings');
|
||||
await button('保存配置').waitFor(); await idle();
|
||||
const bodyBounds = await page.locator('.sources-body').boundingBox();
|
||||
const toolbarBounds = await page.locator('.extension-toolbar').boundingBox();
|
||||
for (const failed of [true, false]) {
|
||||
failAction = failed;
|
||||
await button('保存配置').click();
|
||||
await page.locator('.floating-notice').getByText('正在处理…', { exact: true }).waitFor();
|
||||
assert.deepEqual(await page.locator('.sources-body').boundingBox(), bodyBounds);
|
||||
assert.deepEqual(await page.locator('.extension-toolbar').boundingBox(), toolbarBounds);
|
||||
await page.locator('.sources-body').evaluate(node => { node.scrollTop = 180; });
|
||||
const scrollTop = await page.locator('.sources-body').evaluate(node => node.scrollTop);
|
||||
releaseAction();
|
||||
await page.locator(failed ? '.floating-notice [role=alert]' : '.floating-notice [role=status]').getByText(failed ? '插件暂时不可用,请重启后重试。' : '配置已保存', { exact: true }).waitFor();
|
||||
assert.equal(await page.locator('.sources-body').evaluate(node => node.scrollTop), scrollTop);
|
||||
assert.deepEqual(await page.locator('.sources-body').boundingBox(), bodyBounds);
|
||||
assert.deepEqual(await page.locator('.extension-toolbar').boundingBox(), toolbarBounds);
|
||||
const notice = await page.locator('.floating-notice').evaluate(node => ({ width: node.getBoundingClientRect().width, position: getComputedStyle(node).position }));
|
||||
assert.equal(notice.position, 'fixed'); assert.ok(notice.width <= Math.min(448, width - 32));
|
||||
await shot('extension-feedback-' + (failed ? 'error-' : 'success-') + width);
|
||||
await button('关闭提示').click(); await page.locator('.floating-notice').waitFor({ state: 'hidden' });
|
||||
assert.deepEqual(await page.locator('.sources-body').boundingBox(), bodyBounds);
|
||||
assert.equal(await page.locator('.sources-body').evaluate(node => node.scrollTop), scrollTop);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS real HTTP + SQLite + stdio plugin + production Web: registration, tgz upload, npm registry install, auto-enable, independent instances, tabs, layout, streamed search, acquisition and chapter reading');
|
||||
console.log('PASS extension feedback fixtures: mobile/desktop floating busy/error/success, bounded width and stable content position');
|
||||
} catch (error) {
|
||||
if (page) { await page.screenshot({ path: join(shots, 'sources-e2e-failure.png') }); console.error((await page.locator('body').innerText()).slice(-4500)); }
|
||||
throw error;
|
||||
|
||||
Reference in new issue
Block a user