diff --git a/docs/plugin-extensions.md b/docs/plugin-extensions.md index 08a19ae..c5f05d6 100644 --- a/docs/plugin-extensions.md +++ b/docs/plugin-extensions.md @@ -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/`;带实例上下文时还获得 `host.instanceDataDir = host.dataDir/sources/`。插件负责创建目录和原子写入;数据与 npm 包目录分离,停用、升级、卸载均保留。这是受信任插件的约定,不是操作系统沙箱。 diff --git a/docs/source-plugins.md b/docs/source-plugins.md index d079436..f9764e8 100644 --- a/docs/source-plugins.md +++ b/docs/source-plugins.md @@ -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 后自动启用、启停、重启失败进程、卸载;覆盖升级和版本回滚仍后续。 | diff --git a/server/src/sources/process-plugin.ts b/server/src/sources/process-plugin.ts index cd067b7..99a6dbc 100644 --- a/server/src/sources/process-plugin.ts +++ b/server/src/sources/process-plugin.ts @@ -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 = () => { diff --git a/server/test/source-plugins.test.ts b/server/test/source-plugins.test.ts index 2862393..9abaac7 100644 --- a/server/test/source-plugins.test.ts +++ b/server/test/source-plugins.test.ts @@ -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/); diff --git a/server/test/sources-api.test.ts b/server/test/sources-api.test.ts index 56463db..1d758f8 100644 --- a/server/test/sources-api.test.ts +++ b/server/test/sources-api.test.ts @@ -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 }> = []; diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 25463d4..d6ed945 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -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; } diff --git a/web/src/ui/floating-notice.tsx b/web/src/ui/floating-notice.tsx index a7bf5f6..e854ab4 100644 --- a/web/src/ui/floating-notice.tsx +++ b/web/src/ui/floating-notice.tsx @@ -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
- {message} + return
+ {message} {!busy && setDismissed(true)} />}
; } diff --git a/web/src/ui/plugin-page-screen.tsx b/web/src/ui/plugin-page-screen.tsx index 057c467..20dedff 100644 --- a/web/src/ui/plugin-page-screen.tsx +++ b/web/src/ui/plugin-page-screen.tsx @@ -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('[data-extension-feedback]') ?? this.element.querySelector('[role=tabpanel]'))?.focus({ preventScroll: true }); + if (action) (this.element.querySelector('[role=tabpanel]') ?? this.element.querySelector('.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})}
} - {status &&
{status}
} -
+ {status && } +
{this.page?.description &&

{this.page.description}

} {this.page && this.content(this.page)} {selected &&
diff --git a/web/test/plugin-page.test.ts b/web/test/plugin-page.test.ts index 1f1d5d3..6b79082 100644 --- a/web/test/plugin-page.test.ts +++ b/web/test/plugin-page.test.ts @@ -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('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('[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'); }); diff --git a/web/tools/ui-review/host-source-flow.mjs b/web/tools/ui-review/host-source-flow.mjs index 7b5a38e..01ca54c 100644 --- a/web/tools/ui-review/host-source-flow.mjs +++ b/web/tools/ui-review/host-source-flow.mjs @@ -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;