Files
edgeever/apps/extension/platform-capture.test.mjs
LuoQianhong 6c4e70262e fix(extension): 完成 #588 跨浏览器与登录态实站验收 (#611)
* fix(extension): correct cross-browser clipping and record live acceptance

* fix(extension): use string prefix check for Jike image URLs
2026-10-18 12:09:07 +08:00

213 lines
12 KiB
JavaScript

import { expect, test } from "bun:test";
import { markdownToDoc } from "../../packages/shared/src/content.ts";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { bundleTestScripts } from "./bundle-test-scripts.mjs";
import { createWindow } from "@mixmark-io/domino";
const [bundle] = bundleTestScripts(["capture-platform"]);
const xUrl = "https://www.xiaoyuzhoufm.com/episode/62ad4ae5f9b62781ea058a02";
const capture = async ({ fixture = "xiaoyuzhou", url = xUrl, expectedUrl = url, sourceUrl = expectedUrl, selectionFirst = false, prepare, detailLoad } = {}) => {
const window = createWindow(readFileSync(new URL(`./fixtures/platforms/${fixture}.html`, import.meta.url), "utf8"), url);
const doc = window.document;
const location = { href: url };
let notify = () => {};
const calls = { messages: [], clicks: 0, disconnected: false, details: [] };
prepare?.({ doc, window, location, changed: () => notify() });
const append=doc.body.appendChild.bind(doc.body);
Object.defineProperty(doc.body,'appendChild',{value:node=>{
const value=append(node);
if(node.tagName==='IFRAME') {
calls.details.push(node);
queueMicrotask(()=>{
const detail=detailLoad?.(node);
if(detail) {Object.defineProperty(node,'contentDocument',{value:detail});node.onload();}
});
}
return value;
}});
const expand = doc.querySelector(".expand");
expand?.addEventListener("click", () => { calls.clicks += 1; });
let done;
const result = new Promise((resolve) => { done = resolve; });
runInNewContext(bundle, {
window, document: doc, location, URL,
__edgeeverPlatformClipPayload: { requestId: "capture-1", url: expectedUrl, sourceUrl, selectionFirst },
setTimeout: (callback) => setTimeout(callback, 30), clearTimeout, setInterval, clearInterval,
MutationObserver: class {
constructor(callback) { notify = callback; }
observe() {}
disconnect() { calls.disconnected = true; }
},
chrome: {
i18n: { getMessage: (key) => key },
runtime: { sendMessage: (message) => { calls.messages.push(message); done(message.result); return Promise.resolve(); } },
},
AbortSignal,
fetch: () => { throw new Error("Unexpected page or media fetch"); },
});
return { result: await result, calls };
};
test("the real page's fully loaded Show Notes do not need an expansion click", async () => {
const { result, calls } = await capture();
expect(result.ok).toBe(true);
expect(result.clip.markdown).toContain("韦礼安");
expect(calls.clicks).toBe(0);
expect(calls.messages).toHaveLength(1);
expect(calls.messages[0].requestId).toBe("capture-1");
});
const publicDetail = (fixture, url) => createWindow(readFileSync(new URL(`./fixtures/platforms/${fixture}.html`,import.meta.url),'utf8'),url).document;
for(const [id,fixture,images] of [['6114376649/411862401','xueqiu-feed-detail',0],['2754211668/411841572','xueqiu-feed-detail-second',1]]) test(`Xueqiu feed reads only selected ${id} through its script-disabled same-origin detail`,async()=>{
const url='https://www.xueqiu.com/',expectedUrl='https://xueqiu.com/'+id;
const original=publicDetail(fixture,'https://www.xueqiu.com/'+id);
const finalParagraph=Array.from(original.querySelectorAll('.article__bd__detail p')).at(-1).textContent.trim().slice(-36);
const {result,calls}=await capture({fixture:'xueqiu-feed',url,sourceUrl:url,expectedUrl,detailLoad:frame=>{
expect(frame.src).toBe('https://www.xueqiu.com/'+id);expect(frame.getAttribute('sandbox')).toBe('allow-same-origin');
return original;
}});
expect(result.ok).toBe(true);expect(result.clip.url).toBe(expectedUrl);expect(result.clip.markdown).toContain(images ? '把自己的真金白银押在别人的嘴上,早晚是要付出代价的。' : finalParagraph);
expect(result.clip.markdown.match(/!\[/g) || []).toHaveLength(images);expect(calls.details).toHaveLength(1);
expect(calls.details[0].isConnected).toBe(false);expect(calls.messages).toHaveLength(1);
});
for(const failure of ['challenge','wrong-url','load-error','timeout','navigation','recycled-card','hidden-card','body-reference']) test(`Xueqiu feed ${failure} cannot archive a preview or another post`,async()=>{
const url='https://www.xueqiu.com/',id='6114376649/411862401',expectedUrl='https://xueqiu.com/'+id;
let prepared;
const {result,calls}=await capture({fixture:'xueqiu-feed',url,sourceUrl:url,expectedUrl,prepare:state=>{
prepared=state;
if(failure==='hidden-card')state.doc.querySelector('[class*="timeline__item__main_"]').style.display='none';
if(failure==='body-reference')state.doc.querySelectorAll('a[class*="date-and-source_"]').forEach(a=>a.remove());
},detailLoad:frame=>{
if(failure==='load-error'){frame.onerror();return;}
if(failure==='timeout')return;
if(failure==='navigation')prepared.location.href='https://www.xueqiu.com/S/SH600438';
if(failure==='recycled-card')prepared.doc.querySelectorAll('a[class*="date-and-source_"]').forEach(a=>a.setAttribute('href','/2754211668/411841572'));
if(failure==='challenge')return createWindow('<html><body>Site verification</body></html>','https://www.xueqiu.com/'+id).document;
return publicDetail('xueqiu-feed-detail',failure==='wrong-url'?'https://www.xueqiu.com/2754211668/411841572':'https://www.xueqiu.com/'+id);
}});
expect(result.ok).toBe(false);expect(result.clip).toBeUndefined();expect(calls.messages).toHaveLength(1);
expect(calls.details).toHaveLength(['hidden-card','body-reference'].includes(failure)?0:1);
for(const frame of calls.details)expect(frame.isConnected).toBe(false);
});
test('explicit Xueqiu feed selection precedes the public detail request',async()=>{
const url='https://www.xueqiu.com/';
const {result,calls}=await capture({fixture:'xueqiu-feed',url,sourceUrl:url,expectedUrl:'https://xueqiu.com/6114376649/411862401',selectionFirst:true,prepare:({doc,window})=>{
window.getSelection=()=>({rangeCount:1,toString:()=> 'Selected preview text',getRangeAt:()=>({cloneContents:()=>{const fragment=doc.createDocumentFragment();fragment.appendChild(doc.createTextNode('Selected preview text'));return fragment;}})});
}});
expect(result.selection.markdown).toBe('Selected preview text');expect(calls.details).toHaveLength(0);
});
test("an incomplete DOM expands only the Show Notes control once and waits for complete text", async () => {
const { result, calls } = await capture({ prepare: ({ doc, changed }) => {
const notes = doc.querySelector(".sn-content article"); const full = notes.innerHTML;
notes.innerHTML = "<p>preview</p>";
doc.querySelector(".expand").addEventListener("click", () => { notes.innerHTML = full; changed(); });
} });
expect(result.ok).toBe(true);
expect(result.clip.markdown).toContain("韦礼安");
expect(calls.clicks).toBe(1);
expect(calls.disconnected).toBe(true);
});
test("failed expansion is bounded and never returns the truncated text", async () => {
const { result, calls } = await capture({ prepare: ({ doc }) => {
doc.querySelector(".sn-content article").innerHTML = "<p>preview</p>";
} });
expect(result).toEqual({ ok: false, reason: "incomplete" });
expect(calls.clicks).toBe(1);
expect(calls.disconnected).toBe(true);
});
test("navigation during expansion rejects the old episode", async () => {
const { result, calls } = await capture({ prepare: ({ doc, location, changed }) => {
doc.querySelector(".sn-content article").innerHTML = "<p>preview</p>";
doc.querySelector(".expand").addEventListener("click", () => { location.href = xUrl.replace(/02$/, "03"); changed(); });
} });
expect(result).toEqual({ ok: false, reason: "changed" });
expect(calls.messages).toHaveLength(1);
});
test("toolbar selection wins over the platform article, while a page menu ignores the selection", async () => {
const prepare = ({ doc, window }) => {
window.getSelection = () => ({ rangeCount: 1, toString: () => "Selected passage", getRangeAt: () => ({ cloneContents: () => {
const fragment = doc.createDocumentFragment();
const p = doc.createElement("p"); p.textContent = "Selected passage"; fragment.appendChild(p); return fragment;
} }) });
};
const toolbar = await capture({ selectionFirst: true, prepare });
expect(toolbar.result.selection.markdown).toBe("Selected passage");
expect(toolbar.result.clip).toBeUndefined();
const menu = await capture({ selectionFirst: false, prepare });
expect(menu.result.clip.markdown).toContain("韦礼安");
expect(menu.result.selection).toBeUndefined();
});
test("an injection for another detail URL reports a page change without expansion", async () => {
const { result, calls } = await capture({ expectedUrl: xUrl.replace(/02$/, "03") });
expect(result).toEqual({ ok: false, reason: "changed" });
expect(calls.clicks).toBe(0);
});
test("a text selection removed by HTML cleanup stays a selection as escaped plain text", async () => {
const { result } = await capture({ selectionFirst: true, prepare: ({ doc, window }) => {
window.getSelection = () => ({ rangeCount: 1, toString: () => "Only *this* text", getRangeAt: () => ({ cloneContents: () => {
const fragment = doc.createDocumentFragment(); const button = doc.createElement("button");
button.textContent = "Only *this* text"; fragment.appendChild(button); return fragment;
} }) });
} });
const paragraph = markdownToDoc(result.selection.markdown).content[0];
expect(paragraph.type).toBe("paragraph");
expect(paragraph.content).toEqual([{ type: "text", text: "Only *this* text" }]);
expect(result.clip).toBeUndefined();
});
test("Substack Note injection filters hidden CSS before checking complete content", async () => {
const url="https://substack.com/@tedgioia/note/c-357811021";
const {result,calls}=await capture({fixture:"substack-note",url,prepare:({doc})=>{
doc.querySelector(".ProseMirror p").setAttribute("style","display:none");
}});
expect(result).toEqual({ok:false,reason:"incomplete"});
expect(calls.messages).toHaveLength(1);
});
test("Jike long text expands through the actual injected bundle and retains link identities",async()=>{
const {result,calls}=await capture({fixture:'jike-long',url:'https://m.okjike.com/originalPosts/64c9dd9a2edae6abdc610605',prepare:({doc,changed})=>{
const body=doc.querySelector('.text'),full=body.innerHTML;body.innerHTML='<span>我在即刻写东西</span><button class="expand">展开全文</button>';
body.querySelector('button').addEventListener('click',()=>{body.innerHTML=full;changed();});
}});
expect(result.ok).toBe(true);expect(result.clip.markdown).toContain('《世界尽头的咖啡馆》');expect(calls.clicks).toBe(1);expect(calls.disconnected).toBe(true);
});
for(const navigate of [false,true])test(`Jike expansion ${navigate?'navigation':'timeout'} refuses the partial post`,async()=>{
const {result,calls}=await capture({fixture:'jike-long',url:'https://m.okjike.com/originalPosts/64c9dd9a2edae6abdc610605',prepare:({doc,location,changed})=>{
const body=doc.querySelector('.text');body.innerHTML='<span>我在即刻写东西</span><button class="expand">展开全文</button>';
if(navigate)body.querySelector('button').addEventListener('click',()=>{location.href='https://m.okjike.com/originalPosts/6715e824d9dc81724c2ec74d';changed();});
}});
expect(result).toEqual({ok:false,reason:navigate?'changed':'incomplete'});expect(calls.clicks).toBe(1);expect(calls.disconnected).toBe(true);
});
for (const leaving of [false, true]) test(`Facebook expansion ${leaving ? "cancels before a cross-document URL commits" : "still captures without navigation"}`, async () => {
const url = "https://www.facebook.com/NASA/posts/pfbid0dPToN66CzcAHGMoaNe3jszH4FfBKvqkE5gogCNJkTxXDPsSGYrGvcVwsLgByyMQQl";
const { result, calls } = await capture({ fixture: "facebook", url, prepare: ({ doc, window, changed }) => {
const body = doc.querySelector('[data-ad-preview="message"]');
const full = body.innerHTML;
body.innerHTML = '<p>Preview <button role="button">See more</button></p>';
body.querySelector('button').addEventListener('click', () => {
// During a real navigation the old URL remains until the response commits.
if (leaving) window.dispatchEvent(new window.Event('beforeunload'));
body.innerHTML = full;
changed();
});
} });
if (leaving) expect(result).toEqual({ ok: false, reason: "changed" });
else expect(result.ok).toBe(true);
expect(calls.messages).toHaveLength(1);
expect(calls.disconnected).toBe(true);
});