- Support both HTML table and div-based grid layouts on creator/market - Harden DOM insertion by using actual parent nodes (prevents NotFoundError when page nesting differs from test fixtures) - Skip malformed/empty table rows instead of throwing on missing action cell - Add rowKey to BatchLoaderRow to align LoadRowsOptions typing - Add tests for div-grid sync and controller lifecycle at document_start
498 lines
14 KiB
TypeScript
498 lines
14 KiB
TypeScript
import { JSDOM } from "jsdom";
|
|
import { describe, expect, test, vi } from "vitest";
|
|
|
|
import { createMarketBatchLoader } from "../src/content/market/batch-loader";
|
|
import { createMarketContentController } from "../src/content/market/index";
|
|
|
|
describe("market controller", () => {
|
|
test("auto-loads the current market rows on startup", async () => {
|
|
const dom = createMarketDom();
|
|
const controller = createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient: {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
},
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: createMutationObserverFactory(),
|
|
window: dom.window
|
|
});
|
|
|
|
await tick();
|
|
|
|
const firstRow = dom.window.document.querySelector("tbody tr")!;
|
|
expect(cellTexts(firstRow)).toEqual([
|
|
"达人 A",
|
|
"111-single",
|
|
"111-personal",
|
|
"查看"
|
|
]);
|
|
|
|
controller.dispose();
|
|
dom.window.close();
|
|
});
|
|
|
|
test("auto-loads the current rows on the div-based market grid", async () => {
|
|
const dom = createDivMarketDom();
|
|
const controller = createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient: {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
},
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: createMutationObserverFactory(),
|
|
window: dom.window
|
|
});
|
|
|
|
await tick();
|
|
|
|
expect(divHeaderTexts(dom.window.document)).toEqual([
|
|
"21-60s报价",
|
|
"单视频看后搜率",
|
|
"个人视频看后搜率",
|
|
"操作"
|
|
]);
|
|
expect(divRightRowTexts(dom.window.document, 0)).toEqual([
|
|
"¥70,000",
|
|
"111-single",
|
|
"111-personal",
|
|
"下单"
|
|
]);
|
|
|
|
controller.dispose();
|
|
dom.window.close();
|
|
});
|
|
|
|
test("triggers a fresh sync when the visible list changes", async () => {
|
|
const dom = createMarketDom();
|
|
const apiClient = {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
};
|
|
const observer = createMutationObserverFactory();
|
|
const controller = createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient,
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: observer,
|
|
window: dom.window
|
|
});
|
|
|
|
await tick();
|
|
replaceRows(
|
|
dom.window.document,
|
|
`
|
|
<tr>
|
|
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/333">达人 C</a></td>
|
|
<td>查看</td>
|
|
</tr>
|
|
`
|
|
);
|
|
observer.trigger();
|
|
await flushSync();
|
|
|
|
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333");
|
|
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
|
|
"达人 C",
|
|
"333-single",
|
|
"333-personal",
|
|
"查看"
|
|
]);
|
|
|
|
controller.dispose();
|
|
dom.window.close();
|
|
});
|
|
|
|
test("drops stale async results after a newer list replaces the old one", async () => {
|
|
const dom = createMarketDom();
|
|
const firstDeferred = createDeferred<ReturnType<typeof successFor>>();
|
|
const apiClient = {
|
|
loadAuthorAseInfo: vi
|
|
.fn()
|
|
.mockImplementationOnce(() => firstDeferred.promise)
|
|
.mockImplementationOnce(async () => successFor("222"))
|
|
};
|
|
const observer = createMutationObserverFactory();
|
|
const controller = createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient,
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: observer,
|
|
window: dom.window
|
|
});
|
|
|
|
await tick();
|
|
replaceRows(
|
|
dom.window.document,
|
|
`
|
|
<tr>
|
|
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a></td>
|
|
<td>查看</td>
|
|
</tr>
|
|
`
|
|
);
|
|
observer.trigger();
|
|
await flushSync();
|
|
|
|
firstDeferred.resolve(successFor("111"));
|
|
await flushSync();
|
|
|
|
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
|
|
"达人 B",
|
|
"222-single",
|
|
"222-personal",
|
|
"查看"
|
|
]);
|
|
|
|
controller.dispose();
|
|
dom.window.close();
|
|
});
|
|
|
|
test("rehydrates cached rows immediately when they reappear", async () => {
|
|
const dom = createMarketDom();
|
|
const apiClient = {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
};
|
|
const observer = createMutationObserverFactory();
|
|
const controller = createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient,
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: observer,
|
|
window: dom.window
|
|
});
|
|
|
|
await tick();
|
|
replaceRows(
|
|
dom.window.document,
|
|
`
|
|
<tr>
|
|
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a></td>
|
|
<td>查看</td>
|
|
</tr>
|
|
`
|
|
);
|
|
observer.trigger();
|
|
await flushSync();
|
|
|
|
replaceRows(
|
|
dom.window.document,
|
|
`
|
|
<tr>
|
|
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a></td>
|
|
<td>查看</td>
|
|
</tr>
|
|
`
|
|
);
|
|
observer.trigger();
|
|
await flushSync();
|
|
|
|
const row = dom.window.document.querySelector("tbody tr")!;
|
|
expect(cellTexts(row)).toEqual([
|
|
"达人 A",
|
|
"111-single",
|
|
"111-personal",
|
|
"查看"
|
|
]);
|
|
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2);
|
|
|
|
controller.dispose();
|
|
dom.window.close();
|
|
});
|
|
|
|
test("boots safely at document_start when body is not ready yet", async () => {
|
|
const dom = createMarketDom();
|
|
Object.defineProperty(dom.window.document, "body", {
|
|
configurable: true,
|
|
value: null
|
|
});
|
|
Object.defineProperty(dom.window.document, "documentElement", {
|
|
configurable: true,
|
|
value: null
|
|
});
|
|
const strictObserverFactory = (callback: MutationCallback) => {
|
|
void callback;
|
|
return {
|
|
disconnect() {},
|
|
observe(target: Node | null) {
|
|
if (!target) {
|
|
throw new TypeError("observer target must be a Node");
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
expect(() =>
|
|
createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient: {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
},
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: strictObserverFactory,
|
|
window: dom.window
|
|
})
|
|
).not.toThrow();
|
|
|
|
dom.window.close();
|
|
});
|
|
|
|
test("waits until the document is ready before observing the market page", async () => {
|
|
const dom = createMarketDom();
|
|
let readyState = "loading";
|
|
Object.defineProperty(dom.window.document, "readyState", {
|
|
configurable: true,
|
|
get() {
|
|
return readyState;
|
|
}
|
|
});
|
|
|
|
const apiClient = {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
};
|
|
const observer = createMutationObserverFactory();
|
|
|
|
createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient,
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: observer,
|
|
window: dom.window
|
|
});
|
|
|
|
await tick();
|
|
|
|
expect(observer.observe).toHaveBeenCalledTimes(0);
|
|
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(0);
|
|
|
|
readyState = "interactive";
|
|
dom.window.dispatchEvent(new dom.window.Event("DOMContentLoaded"));
|
|
await flushSync();
|
|
|
|
expect(observer.observe).toHaveBeenCalledTimes(1);
|
|
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1);
|
|
|
|
dom.window.close();
|
|
});
|
|
|
|
test("does not override history methods on the market page", () => {
|
|
const dom = createMarketDom();
|
|
const originalPushState = dom.window.history.pushState;
|
|
const originalReplaceState = dom.window.history.replaceState;
|
|
|
|
const controller = createMarketContentController({
|
|
batchLoader: createMarketBatchLoader({
|
|
apiClient: {
|
|
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
|
|
},
|
|
concurrency: 4
|
|
}),
|
|
document: dom.window.document,
|
|
logger: createLogger(),
|
|
mutationObserverFactory: createMutationObserverFactory(),
|
|
window: dom.window
|
|
});
|
|
|
|
expect(dom.window.history.pushState).toBe(originalPushState);
|
|
expect(dom.window.history.replaceState).toBe(originalReplaceState);
|
|
|
|
controller.dispose();
|
|
dom.window.close();
|
|
});
|
|
});
|
|
|
|
function cellTexts(row: Element) {
|
|
return Array.from(row.querySelectorAll("td"), (cell) => cell.textContent?.trim() ?? "");
|
|
}
|
|
|
|
function divCellTexts(row: Element) {
|
|
return Array.from(row.children, (cell) => cell.textContent?.trim() ?? "");
|
|
}
|
|
|
|
function divHeaderTexts(document: Document) {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid="right-header"] > *'),
|
|
(cell) => cell.textContent?.trim() ?? ""
|
|
);
|
|
}
|
|
|
|
function divRightRowTexts(document: Document, rowIndex: number) {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
|
|
(column) =>
|
|
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
|
);
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((nextResolve) => {
|
|
resolve = nextResolve;
|
|
});
|
|
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function createLogger() {
|
|
return {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn()
|
|
};
|
|
}
|
|
|
|
function createMarketDom() {
|
|
const dom = new JSDOM(
|
|
`
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>达人信息</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a></td>
|
|
<td>查看</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
`,
|
|
{
|
|
url: "https://xingtu.cn/ad/creator/market"
|
|
}
|
|
);
|
|
|
|
let readyState = "complete";
|
|
Object.defineProperty(dom.window.document, "readyState", {
|
|
configurable: true,
|
|
get() {
|
|
return readyState;
|
|
}
|
|
});
|
|
|
|
return dom;
|
|
}
|
|
|
|
function createDivMarketDom() {
|
|
const dom = new JSDOM(
|
|
`
|
|
<div class="base-author-list">
|
|
<div class="section-wrapper sticky-header hide-scrollbar">
|
|
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
|
<div class="header-cell" style="min-width: 310px;">达人信息</div>
|
|
</div>
|
|
<div class="middle-columns" style="width: 190px; display: flex;">
|
|
<div class="header-cell" style="min-width: 190px;">代表视频</div>
|
|
</div>
|
|
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
|
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
|
|
<div class="header-cell" style="min-width: 200px;">操作</div>
|
|
</div>
|
|
</div>
|
|
<div class="section-wrapper hide-scrollbar">
|
|
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
|
<div class="content-column" style="min-width: 310px;">
|
|
<div class="content-cell" data-testid="author-row-a" style="height: 120px;">
|
|
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="middle-columns" style="width: 190px; display: flex;">
|
|
<div class="content-column" style="min-width: 190px;">
|
|
<div class="content-cell" style="height: 120px;">代表视频A</div>
|
|
</div>
|
|
</div>
|
|
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
|
<div class="content-column" style="min-width: 150px;">
|
|
<div class="content-cell" style="height: 120px;">¥70,000</div>
|
|
</div>
|
|
<div class="content-column" style="min-width: 200px;">
|
|
<div class="content-cell" style="height: 120px;">下单</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
{
|
|
url: "https://xingtu.cn/ad/creator/market"
|
|
}
|
|
);
|
|
|
|
let readyState = "complete";
|
|
Object.defineProperty(dom.window.document, "readyState", {
|
|
configurable: true,
|
|
get() {
|
|
return readyState;
|
|
}
|
|
});
|
|
|
|
return dom;
|
|
}
|
|
|
|
function createMutationObserverFactory() {
|
|
let callback: MutationCallback = () => undefined;
|
|
const observe = vi.fn();
|
|
|
|
return Object.assign(
|
|
(nextCallback: MutationCallback) => {
|
|
callback = nextCallback;
|
|
return {
|
|
disconnect() {},
|
|
observe
|
|
};
|
|
},
|
|
{
|
|
observe,
|
|
trigger() {
|
|
callback([], {} as MutationObserver);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function replaceRows(document: Document, rowsHtml: string) {
|
|
document.querySelector("tbody")!.innerHTML = rowsHtml;
|
|
}
|
|
|
|
function successFor(authorId: string) {
|
|
return {
|
|
rates: {
|
|
personalVideoAfterSearchRate: `${authorId}-personal`,
|
|
singleVideoAfterSearchRate: `${authorId}-single`
|
|
},
|
|
success: true as const
|
|
};
|
|
}
|
|
|
|
function tick() {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
|
|
async function flushSync() {
|
|
await tick();
|
|
await tick();
|
|
}
|