Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ jobs:
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: git diff --exit-code -- preview-gallery.html
- run: npm run test:embedded-data
- run: npm test
- run: npm run test:copy
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Node.js 22+。从仓库根目录执行:
npm ci
npm run build
npx playwright install chromium
npm run test:embedded-data
npm test
npm run test:copy
```
Expand All @@ -35,6 +36,8 @@ npm run test:copy

功能检查覆盖卡片、图片、筛选、选择、持久化和复制。测试截图写入被忽略的 `.artifacts/`。它不评估提示词生成页面与原网站的视觉或动画一致程度。

`npm run test:embedded-data` 用 Chromium 解析真实模板生成的临时页面,检查提示词中的 `</script>`、HTML 注释、替换符号和 Unicode 分隔符能完整恢复为数据,且附加脚本不会执行。临时页面在测试结束后删除。

## 维护与记录

- [维护工具](docs/maintenance.md):各脚本的输入、输出和运行范围。
Expand Down
2 changes: 2 additions & 0 deletions docs/maintenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

所有命令从仓库根目录运行。`npm run build` 是日常构建入口;不会重抓页面或更改条目。

画廊构建器在嵌入 JSON 时转义 `<` 和 Unicode 行/段分隔符,并用回调填入模板占位符。提示词中的脚本结束标签、HTML 注释与 `$&` 等替换符号因此保留为普通数据;浏览器读取后的内容与原 JSON 一致。维护模板或构建器后运行 `npm run test:embedded-data`:真实 Chromium 回归使用临时数据与页面,不访问条目网站,也不修改 `preview-data.json`。

| 工具 | 输入 | 输出/影响 |
| --- | --- | --- |
| `scripts/gen-gallery.js` | 模板、preview-data.json | preview-gallery.html |
Expand Down
2 changes: 2 additions & 0 deletions docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

`npm test` 检查本地画廊的卡片数量、图片加载、筛选、选择、localStorage、复制和控制台错误。`npm run test:copy` 检查单卡复制入口。这些检查不测量第三方网站或 AI 重建的动画质量。

`npm run test:embedded-data` 检查浏览器实际解析嵌入数据的行为:普通中文与转义字符、脚本结束标签(含大小写变化)、HTML 注释、`$&` 等替换符号和 Unicode 行/段分隔符。要求完整数据恢复、画廊初始化完成、只保留模板原有脚本且提示词内附加脚本没有执行。该回归只覆盖 JSON 嵌入边界,不代表所有卡片 HTML 渲染输入都完成了安全审查。

## 历史复现记录

旧 README 报告 204 项进入评估、194 项标为复现通过、其中 191 项标为动画还原。`preview-data.json` 保留原有状态、失败记录、备注和提示词;本次清理没有重新判定这些状态。194/204 约为 95.1%,旧文档括号中的 100% 是错误表述。
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"build": "node scripts/gen-gallery.js",
"test": "node tests/functional.js",
"test:copy": "node tests/smoke-copy.js"
"test:copy": "node tests/smoke-copy.js",
"test:embedded-data": "node --test tests/embedded-data.js"
}
}
2 changes: 1 addition & 1 deletion preview-gallery.html

Large diffs are not rendered by default.

30 changes: 20 additions & 10 deletions scripts/gen-gallery.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@
const fs = require('fs');
const path = require('path');

const TEMPLATE = path.join(__dirname, '..', 'gallery.template.html');
const DATA = path.join(__dirname, '..', 'preview-data.json');
const OUT = path.join(__dirname, '..', 'preview-gallery.html');
function buildGalleryHtml(tpl, data) {
// HTML parses script end tags and comments before JavaScript string literals.
const json = JSON.stringify(data).replace(/[<\u2028\u2029]/g,
character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'));
if (!tpl.includes('/*ITEMS*/')) throw new Error('模板缺少 /*ITEMS*/ 占位符');
// A callback keeps prompt text such as $& and $` literal during replacement.
return tpl.replace('/*ITEMS*/', () => json);
}

const tpl = fs.readFileSync(TEMPLATE, 'utf8');
if (require.main === module) {
const TEMPLATE = path.join(__dirname, '..', 'gallery.template.html');
const DATA = path.join(__dirname, '..', 'preview-data.json');
const OUT = path.join(__dirname, '..', 'preview-gallery.html');

const data = JSON.parse(fs.readFileSync(DATA, 'utf8'));
const json = JSON.stringify(data); // 紧凑,避免画廊体积过大
if (!tpl.includes('/*ITEMS*/')) { console.error('模板缺少 /*ITEMS*/ 占位符'); process.exit(1); }
const html = tpl.replace('/*ITEMS*/', json);
fs.writeFileSync(OUT, html);
console.log('gallery written:', OUT, '| items:', data.length, '| bytes:', html.length);
const tpl = fs.readFileSync(TEMPLATE, 'utf8');
const data = JSON.parse(fs.readFileSync(DATA, 'utf8'));
const html = buildGalleryHtml(tpl, data);
fs.writeFileSync(OUT, html);
console.log('gallery written:', OUT, '| items:', data.length, '| bytes:', html.length);
}

module.exports = { buildGalleryHtml };
73 changes: 73 additions & 0 deletions tests/embedded-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { before, after, test } = require('node:test');
const { chromium } = require('playwright');
const { buildGalleryHtml } = require('../scripts/gen-gallery');

const root = path.resolve(__dirname, '..');
const template = fs.readFileSync(path.join(root, 'gallery.template.html'), 'utf8');
const image = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>');
const item = {
id: 'embedded-data-fixture', name: '浏览器解析回归', kind: 'item',
fw: ['React'], theme: '测试', vendor: 'Fixture', chips: [],
link: 'https://example.invalid/', img: image,
};
const fixtures = [
['ordinary', '中文、"引号"、\\反斜线\n换行、& 符号与 emoji 🌸'],
['script-close', '</script><script>window.__embeddedDataExecuted = true</script><script>'],
['mixed-case-close', '</ScRiPt><script>window.__embeddedDataExecuted = true</script><script>'],
['comment-script', '<!--<script>'],
['comment-close', '<!--</script><script>window.__embeddedDataExecuted = true</script><!--'],
['replacement-tokens', "$& $` $' $$ /*ITEMS*/"],
['unicode-separators', 'line\u2028separator\u2029paragraph'],
];
let browser;
let directory;

before(async () => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gallery-embedded-data-'));
browser = await chromium.launch({
executablePath: process.env.CHROME_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu'],
});
});

after(async () => {
try {
if (browser) await browser.close();
} finally {
if (directory) fs.rmSync(directory, { recursive: true, force: true });
}
});

for (const [name, prompt] of fixtures) {
test(`embedded data survives Chromium parsing: ${name}`, async () => {
const data = [{ ...item, prompt, metadata: { source: prompt } }];
const filename = path.join(directory, `${name}.html`);
fs.writeFileSync(filename, buildGalleryHtml(template, data));
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
// Fixtures use an inline image; external links are never opened.
await page.route(/^https?:/, route => route.abort());
try {
await page.goto(pathToFileURL(filename).href, { waitUntil: 'domcontentloaded' });
const result = await page.evaluate(() => ({
items: typeof ITEMS === 'undefined' ? null : ITEMS,
injectedScriptExecuted: window.__embeddedDataExecuted === true,
scriptElements: document.scripts.length,
cards: document.querySelectorAll('.item').length,
}));
assert.equal(result.injectedScriptExecuted, false, 'prompt text must not execute an added script');
assert.deepEqual(result.items, data, 'the entire data payload must round-trip unchanged');
assert.equal(result.scriptElements, 1, 'prompt text must not create extra script elements');
assert.equal(result.cards, data.length, 'gallery initialization must finish');
assert.deepEqual(errors, [], 'gallery scripts must parse without errors');
} finally {
await page.close();
}
});
}
Loading