Skip to content

Commit 0e80422

Browse files
committed
Fix TikTok DOM chat capture and parser races
1 parent 06693cf commit 0e80422

2 files changed

Lines changed: 157 additions & 32 deletions

File tree

scripts/playwright-reactions-overlay-e2e.cjs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,109 @@ async function runTikTokSourceLikeCaptureCheck(context, captureLikeEvent, expect
356356
await page.close();
357357
}
358358

359+
async function runTikTokIncrementalChatCaptureCheck(context) {
360+
const page = await context.newPage();
361+
362+
await addTikTokSourceInitScript(page, true);
363+
await page.route('https://www.tiktok.com/@playwright-chat/live', async (route) => {
364+
await route.fulfill({
365+
status: 200,
366+
contentType: 'text/html; charset=utf-8',
367+
body: '<!DOCTYPE html><html><head><title>TikTok chat fixture</title></head><body><div data-e2e="chat-room"><div data-e2e="chat-message"><span data-e2e="message-owner-name">History</span><div class="Fixture-DivComment">Existing history</div></div></div></body></html>'
368+
});
369+
});
370+
371+
await page.goto('https://www.tiktok.com/@playwright-chat/live', { waitUntil: 'domcontentloaded' });
372+
await page.addScriptTag({ path: path.join(ROOT, 'sources', 'tiktok.js') });
373+
await page.waitForTimeout(4500);
374+
await page.evaluate(async () => {
375+
const chatRoom = document.querySelector('[data-e2e="chat-room"]');
376+
const row = document.createElement('div');
377+
const owner = document.createElement('span');
378+
const comment = document.createElement('div');
379+
380+
row.dataset.e2e = 'chat-message';
381+
owner.dataset.e2e = 'message-owner-name';
382+
owner.textContent = 'Alex';
383+
comment.className = 'Fixture-DivComment';
384+
comment.textContent = 'Hello from TikTok';
385+
386+
chatRoom.appendChild(row);
387+
await new Promise((resolve) => setTimeout(resolve, 50));
388+
row.appendChild(owner);
389+
row.appendChild(comment);
390+
});
391+
await page.waitForTimeout(500);
392+
393+
const chatMessages = await page.evaluate(() => {
394+
return window.__chromeMessages
395+
.filter((entry) => entry.message && entry.message.type === 'tiktok' && entry.message.chatmessage)
396+
.map((entry) => entry.message);
397+
});
398+
399+
assert(chatMessages.length === 1, `TikTok incremental chat emitted ${chatMessages.length} rows instead of one.`);
400+
assert(chatMessages[0].chatname === 'Alex', 'TikTok incremental chat lost the author name.');
401+
assert(chatMessages[0].chatmessage === 'Hello from TikTok', 'TikTok incremental chat changed the message text.');
402+
assert(chatMessages[0].event === false, 'TikTok normal chat was incorrectly emitted as an event.');
403+
404+
await page.evaluate(() => {
405+
const oldRoom = document.querySelector('[data-e2e="chat-room"]');
406+
const replacementRoom = document.createElement('div');
407+
replacementRoom.dataset.e2e = 'chat-room';
408+
oldRoom.replaceWith(replacementRoom);
409+
});
410+
await page.waitForTimeout(4500);
411+
await page.evaluate(async () => {
412+
const chatRoom = document.querySelector('[data-e2e="chat-room"]');
413+
for (let index = 0; index < 75; index += 1) {
414+
const row = document.createElement('div');
415+
const owner = document.createElement('span');
416+
const comment = document.createElement('div');
417+
const nestedMutation = document.createElement('span');
418+
419+
row.dataset.e2e = 'chat-message';
420+
owner.dataset.e2e = 'message-owner-name';
421+
owner.textContent = `Viewer ${index}`;
422+
comment.className = 'Fixture-DivComment';
423+
comment.textContent = `Soak message ${index}`;
424+
425+
chatRoom.appendChild(row);
426+
await new Promise((resolve) => setTimeout(resolve, 15));
427+
row.appendChild(owner);
428+
row.appendChild(comment);
429+
comment.appendChild(nestedMutation);
430+
await new Promise((resolve) => setTimeout(resolve, 10));
431+
432+
if (chatRoom.children.length > 30) {
433+
chatRoom.children[0].remove();
434+
}
435+
}
436+
});
437+
await page.waitForFunction(() => {
438+
return window.__chromeMessages
439+
.filter((entry) => entry.message && entry.message.type === 'tiktok' && entry.message.chatmessage)
440+
.length >= 76;
441+
}, null, { timeout: 10000 }).catch(() => {});
442+
443+
const soakedChatMessages = await page.evaluate(() => {
444+
return window.__chromeMessages
445+
.filter((entry) => entry.message && entry.message.type === 'tiktok' && entry.message.chatmessage)
446+
.map((entry) => entry.message);
447+
});
448+
const soakedKeys = new Set(soakedChatMessages.map((message) => `${message.chatname}\n${message.chatmessage}`));
449+
const expectedSoakKeys = new Set(['Alex\nHello from TikTok']);
450+
for (let index = 0; index < 75; index += 1) {
451+
expectedSoakKeys.add(`Viewer ${index}\nSoak message ${index}`);
452+
}
453+
const missingSoakKeys = Array.from(expectedSoakKeys).filter((key) => !soakedKeys.has(key));
454+
455+
assert(soakedChatMessages.length === 76, `TikTok soak emitted ${soakedChatMessages.length} rows instead of 76; missing: ${missingSoakKeys.join(', ') || 'none'}.`);
456+
assert(soakedKeys.size === 76, 'TikTok soak emitted duplicate chat rows.');
457+
assert(soakedChatMessages.every((message) => message.event === false), 'TikTok soak misclassified normal chat as events.');
458+
459+
await page.close();
460+
}
461+
359462
(async () => {
360463
const server = await startStaticServer({ root: ROOT, host: HOST, port: PORT });
361464
const blockedExternalRequests = [];
@@ -387,6 +490,15 @@ async function runTikTokSourceLikeCaptureCheck(context, captureLikeEvent, expect
387490
await route.continue();
388491
});
389492

493+
if (process.argv.includes('--tiktok-only')) {
494+
await runTikTokSourceLikeCaptureCheck(context, false, 'reactions');
495+
await runTikTokSourceLikeCaptureCheck(context, true, '');
496+
await runTikTokIncrementalChatCaptureCheck(context);
497+
await browser.close();
498+
console.log('TikTok DOM soak passed.');
499+
return;
500+
}
501+
390502
const popupPage = await context.newPage();
391503
await addPopupInitScript(popupPage, `http://${HOST}:${PORT}`);
392504
await popupPage.goto(`http://${HOST}:${PORT}/popup.html`, { waitUntil: 'domcontentloaded' });
@@ -686,6 +798,7 @@ async function runTikTokSourceLikeCaptureCheck(context, captureLikeEvent, expect
686798

687799
await runTikTokSourceLikeCaptureCheck(context, false, 'reactions');
688800
await runTikTokSourceLikeCaptureCheck(context, true, '');
801+
await runTikTokIncrementalChatCaptureCheck(context);
689802

690803
await browser.close();
691804

sources/tiktok.js

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,11 +1074,10 @@
10741074
if (ele.querySelector("[class*='DivTopGiverContainer']")) {
10751075
return;
10761076
}
1077-
if (checkNextSiblingsForAttribute(ele, "data-skip")) {
1077+
if (checkNextSiblingsForAttribute(ele, "data-tiktok-initial")) {
10781078
ele.dataset.skip = ++msgCount;
10791079
return;
10801080
}
1081-
ele.dataset.skip = ++msgCount;
10821081
const eventHints = deriveEventHints(ele);
10831082
var ital = false;
10841083
if (ele.dataset.e2e && (ele.dataset.e2e == "social-message")) {
@@ -1343,6 +1342,7 @@
13431342
} else if (chatmessage) {
13441343
chatmessage = chatmessage.trim();
13451344
}
1345+
ele.dataset.skip = ++msgCount;
13461346
let normalizedMessage = chatmessage ? chatmessage.toLowerCase() : "";
13471347
if (chatmessage == "Moderator") {
13481348
return;
@@ -1487,7 +1487,7 @@
14871487
if (ele.dataset.skip) {
14881488
return;
14891489
}
1490-
if (checkNextSiblingsForAttribute(ele, "data-skip")) {
1490+
if (checkNextSiblingsForAttribute(ele, "data-tiktok-initial")) {
14911491
ele.dataset.skip = ++msgCount;
14921492
return;
14931493
}
@@ -2137,6 +2137,20 @@
21372137
}
21382138
return target2 || null;
21392139
}
2140+
2141+
function findTikTokChatMessageForMutationNode(node) {
2142+
if (!node || node.nodeType !== 1) {
2143+
return null;
2144+
}
2145+
if (node.dataset && node.dataset.e2e === "chat-message") {
2146+
return node;
2147+
}
2148+
var nestedMessage = node.querySelector && node.querySelector('[data-e2e="chat-message"]');
2149+
if (nestedMessage) {
2150+
return nestedMessage;
2151+
}
2152+
return node.closest ? node.closest('[data-e2e="chat-message"]') : null;
2153+
}
21402154

21412155
function start() {
21422156
if (!isExtensionOn) {
@@ -2284,7 +2298,7 @@
22842298
}
22852299
}
22862300
} else {
2287-
let msg = (node.dataset && node.dataset.e2e === "chat-message") ? node : (node.querySelector && node.querySelector('[data-e2e="chat-message"]'));
2301+
let msg = findTikTokChatMessageForMutationNode(node);
22882302
if (msg) {
22892303
setTimeout(processMessage, 10, msg);
22902304
} else {
@@ -2305,36 +2319,34 @@
23052319
}
23062320
}
23072321
});
2308-
const currentTargetForTimeout = target;
2309-
setTimeout(function() {
2310-
if (observer && observer instanceof MutationObserver && currentTargetForTimeout && currentTargetForTimeout.isConnected && isExtensionOn) {
2311-
if (currentTargetForTimeout.children) {
2312-
Array.from(currentTargetForTimeout.children).forEach(ele => {
2313-
if (ele && ele.dataset && ele.isConnected) {
2314-
ele.dataset.skip = ++msgCount;
2315-
}
2316-
});
2317-
}
2318-
document.querySelectorAll('[data-e2e="chat-message"]').forEach(ele => {
2319-
ele.dataset.skip = ++msgCount;
2320-
});
2321-
observer.observe(currentTargetForTimeout, {
2322-
childList: true,
2323-
subtree: subtree
2322+
if (observer && observer instanceof MutationObserver && target && target.isConnected && isExtensionOn) {
2323+
if (target.children) {
2324+
Array.from(target.children).forEach(ele => {
2325+
if (ele && ele.dataset && ele.isConnected) {
2326+
ele.dataset.skip = ++msgCount;
2327+
ele.dataset.tiktokInitial = "true";
2328+
}
23242329
});
2325-
observedDomElementForObserver1 = currentTargetForTimeout;
2326-
markTikTokStandardConnected();
2327-
////console.log("Main observer is now observing.", currentTargetForTimeout);
2328-
} else {
2329-
if (observer instanceof MutationObserver) {
2330-
observer.disconnect();
2331-
}
2332-
observer = false;
2333-
observedDomElementForObserver1 = null;
2334-
////console.log("Main observer NOT started or target/state became invalid before observe.", currentTargetForTimeout);
23352330
}
2336-
2337-
}, 2000);
2331+
document.querySelectorAll('[data-e2e="chat-message"]').forEach(ele => {
2332+
ele.dataset.skip = ++msgCount;
2333+
ele.dataset.tiktokInitial = "true";
2334+
});
2335+
observer.observe(target, {
2336+
childList: true,
2337+
subtree: subtree
2338+
});
2339+
observedDomElementForObserver1 = target;
2340+
markTikTokStandardConnected();
2341+
////console.log("Main observer is now observing.", target);
2342+
} else {
2343+
if (observer instanceof MutationObserver) {
2344+
observer.disconnect();
2345+
}
2346+
observer = false;
2347+
observedDomElementForObserver1 = null;
2348+
////console.log("Main observer NOT started or target/state became invalid before observe.", target);
2349+
}
23382350
}
23392351

23402352
function start2(other = false) {

0 commit comments

Comments
 (0)