mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
feat(plugins): add bounded diagnostic output
This commit is contained in:
1 parent
5f8554eadd
commit
a131a8cc45
7 files changed
+47
-3
No files matched your search
@@ -16,7 +16,7 @@
|
||||
|
||||
GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourceType,context:{instance,userId},pageId,action,values}`。宿主根据 sourceId 从数据库解析插件与实例,客户端不能伪造上下文;暂停的来源仍可管理,停用插件则不可访问页面。普通读者无页面读取及写入权限。
|
||||
|
||||
返回 Page DTO:`title/description/notice/forms/sections/tabs/activeTab`。每个 Tab 包含 `id/title/description/forms/sections`,页面公共内容和当前 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`。一次动作返回新页面;可选 activeTab 请求切换到指定 Tab,否则保留当前选择。纯 Tab 切换保留输入草稿。提交后保留其它声明未变化的表单草稿;服务端改变表单声明或主动刷新时以新页面为准。
|
||||
返回 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 切换保留输入草稿。提交后保留其它声明未变化的表单草稿;服务端改变表单声明或主动刷新时以新页面为准。
|
||||
|
||||
宿主校验声明、页面结构、选项和输入配额;未声明页面拒绝访问。渲染器只呈现文本,不接受 HTML、JS、iframe 或任意前端代码。授权在 HTTP 层执行,隐藏入口不代替鉴权。
|
||||
|
||||
@@ -42,7 +42,7 @@ GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourc
|
||||
|
||||
## 验证方式
|
||||
|
||||
生产 bundle 的 Chrome 验证脚本覆盖 390px 手机和 1280px 桌面布局、从自定义来源入口进入、三个 Tab、规则展开与编辑、订阅启停、搜索筛选和目录换源。运行 `node web/tools/ui-review/plugin-extensions.mjs`(仓库根目录,预先构建 Web;CHROME_PATH 可指定浏览器),截图输出到 docs/ui-review/plugin-*.png。
|
||||
生产 bundle 的 Chrome 验证脚本覆盖 390px 手机和 1280px 桌面布局、从自定义来源入口进入、四个 Tab、规则展开与编辑、订阅启停、书源调试、搜索筛选和目录换源。运行 `node web/tools/ui-review/plugin-extensions.mjs`(仓库根目录,预先构建 Web;CHROME_PATH 可指定浏览器),截图输出到 docs/ui-review/plugin-*.png。
|
||||
|
||||
## 本次验证(2026-09-21)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ExtensionForm {
|
||||
}
|
||||
export interface ExtensionContent {
|
||||
forms: ExtensionForm[];
|
||||
outputs?: Array<{ title: string; text: string; format: 'text' | 'log' | 'json' }>;
|
||||
sections?: Array<{ title: string; emptyText?: string; items: Array<{ title: string; description?: string; collapsible?: boolean; forms?: ExtensionForm[] }> }>;
|
||||
}
|
||||
export interface ExtensionPage extends ExtensionContent {
|
||||
@@ -85,6 +86,14 @@ export function extensionPage(input: unknown): ExtensionPage {
|
||||
};
|
||||
const content = (input: Record<string, unknown>) => {
|
||||
forms(input.forms);
|
||||
if (input.outputs !== undefined) {
|
||||
check(Array.isArray(input.outputs) && input.outputs.length <= 8);
|
||||
for (const output of input.outputs) {
|
||||
record(output); label(output.title);
|
||||
check(typeof output.text === 'string' && output.text.length <= 65536);
|
||||
check(['text', 'log', 'json'].includes(String(output.format)));
|
||||
}
|
||||
}
|
||||
if (input.sections !== undefined) {
|
||||
check(Array.isArray(input.sections) && input.sections.length <= 16);
|
||||
for (const section of input.sections) {
|
||||
|
||||
@@ -31,3 +31,11 @@ test('extension UX hints are validated without allowing executable or unbounded
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [], noticeKind: 'html' }));
|
||||
assert.throws(() => extensionPage({ title: 'x', forms: [{ id: 'save', title: '', submit: 'Save', fields: [], confirm: {} }] }));
|
||||
});
|
||||
|
||||
test('read-only extension outputs are text-only and bounded', () => {
|
||||
const output = { title: 'Log', text: '<script>plain text</script>\n200 OK', format: 'log' };
|
||||
assert.equal(extensionPage({ title: 'Debug', forms: [], outputs: [output] }).outputs?.[0]?.text, output.text);
|
||||
for (const outputs of [[{ ...output, format: 'html' }], [{ ...output, text: {} }], [{ ...output, text: 'x'.repeat(65537) }], Array(9).fill(output)]) {
|
||||
assert.throws(() => extensionPage({ title: 'Debug', forms: [], outputs }));
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export interface ExtensionForm {
|
||||
layout?: 'inline'; confirm?: string; id: string; title: string; submit: string; fields: ExtensionField[]; values?: Record<string, string | number | boolean> }
|
||||
export interface ExtensionContent {
|
||||
forms: ExtensionForm[];
|
||||
outputs?: Array<{ title: string; text: string; format: 'text' | 'log' | 'json' }>;
|
||||
sections?: Array<{ title: string; emptyText?: string; items: Array<{ title: string; description?: string; collapsible?: boolean; forms?: ExtensionForm[] }> }>;
|
||||
}
|
||||
export interface ExtensionPage extends ExtensionContent {
|
||||
|
||||
@@ -3849,6 +3849,10 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin
|
||||
}
|
||||
[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; }
|
||||
.extension-output pre { max-height: 26rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; margin: .75rem 0 0; padding: 1rem; background: var(--reader-surface); border: 1px solid var(--reader-line); border-radius: .5rem; font: .8125rem/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.extension-output pre:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
|
||||
/* Source hub: navigation tabs, one primary action per row, secondary settings on demand. */
|
||||
.sources-hub .sources-tabs { flex: none; flex-wrap: nowrap; gap: 0; padding: 0 1rem; width: 100%; max-width: 66rem; margin: 0 auto 1rem; }
|
||||
|
||||
@@ -61,7 +61,7 @@ export class PluginPageScreen {
|
||||
{form.fields.map(field => <label key={field.key} data-field-type={field.type}>{field.label}
|
||||
{field.type === 'textarea' ? <textarea aria-label={field.label} placeholder={field.placeholder} required={field.required} disabled={this.busy} value={String(data[field.key] ?? '')} onInput={event => { data[field.key] = event.currentTarget.value; }} />
|
||||
: field.type === 'boolean' ? <input aria-label={field.label} type="checkbox" disabled={this.busy} checked={data[field.key] === true} onChange={event => { data[field.key] = event.currentTarget.checked; }} />
|
||||
: field.type === 'select' ? <select aria-label={field.label} disabled={this.busy} value={String(data[field.key] ?? '')} onChange={event => { data[field.key] = event.currentTarget.value; }}>
|
||||
: field.type === 'select' ? <select aria-label={field.label} required={field.required} disabled={this.busy} value={String(data[field.key] ?? '')} onChange={event => { data[field.key] = event.currentTarget.value; }}>
|
||||
{field.options?.map(option => <option value={option.value}>{option.label}</option>)}
|
||||
</select> : <input aria-label={field.label} placeholder={field.placeholder} min={field.min} max={field.max} required={field.required} disabled={this.busy} type={field.type === 'number' ? 'number' : 'text'} value={String(data[field.key] ?? '')}
|
||||
onInput={event => { data[field.key] = field.type === 'number' ? Number(event.currentTarget.value) : event.currentTarget.value; }} />}
|
||||
@@ -89,6 +89,10 @@ export class PluginPageScreen {
|
||||
</article>;
|
||||
})}
|
||||
</section>)}
|
||||
{content.outputs?.map(output => <details className="sources-card extension-output" open key={output.title}>
|
||||
<summary>{output.title}</summary>
|
||||
<pre tabIndex={0} aria-label={output.title} data-format={output.format}>{output.text}</pre>
|
||||
</details>)}
|
||||
</>;
|
||||
}
|
||||
private selectTab(id: string) {
|
||||
|
||||
@@ -12,6 +12,24 @@ function fixture() {
|
||||
const api = new ReaderApi(makePlatform(transport), { async load() { return null; }, async save() {}, async clear() {} });
|
||||
return { api, transport };
|
||||
}
|
||||
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: [
|
||||
{ id: 'config', title: '配置', forms: [] },
|
||||
{ id: 'diagnostic', title: '诊断', forms: [], outputs: [
|
||||
{ title: '运行日志', format: 'log', text: '<img src=x onerror=alert(1)>\n200 OK' },
|
||||
{ title: '结果', format: 'json', text: '{"count":1}' },
|
||||
] },
|
||||
] } }));
|
||||
const screen = new PluginPageScreen({ api, sourceId: 'third-party', pageId: 'diagnostics', onBack() {}, onSignedOut() {} });
|
||||
screens.push(screen); document.body.append(screen.element); await screen.show();
|
||||
expect(screen.element.querySelector('pre')).toBeNull();
|
||||
screen.element.querySelectorAll<HTMLButtonElement>('[role=tab]')[1]!.click();
|
||||
expect(screen.element.querySelectorAll('.extension-output')).toHaveLength(2);
|
||||
expect(screen.element.querySelector('pre')?.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(screen.element.querySelector('img')).toBeNull();
|
||||
expect(screen.element.querySelector('pre')?.tabIndex).toBe(0);
|
||||
});
|
||||
it('renders generic plugin forms as text and submits typed values on an independent route', async () => {
|
||||
const { api, transport } = fixture();
|
||||
const route = { name: 'plugin-page' as const, pluginId: 'third.party', pageId: 'settings' };
|
||||
|
||||
Reference in new issue
Block a user