channel-worker 2.5.41 → 2.5.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api-client.js CHANGED
@@ -173,6 +173,26 @@ class ApiClient {
173
173
  async postExtensionLog({ level = 'info', message = '', data = null, profile_id = '', job_id = null } = {}) {
174
174
  return this.request('POST', '/extension/log', { level, message, data, profile_id, job_id });
175
175
  }
176
+
177
+ // Persist a browser screenshot captured by a Playwright publish script.
178
+ // The API stores the bytes on the media server; only the returned path/URL
179
+ // is written into extension_log, never the base64 payload itself.
180
+ async uploadDebugScreenshot({ filePath, tag = 'failure', command_id = '', job_id = '', profile_id = '', platform = '' } = {}) {
181
+ const fs = require('fs');
182
+ const path = require('path');
183
+ if (!filePath) throw new Error('debug screenshot filePath required');
184
+ const file = fs.readFileSync(filePath);
185
+ return this.request('POST', '/extension/debug-screenshot', {
186
+ image_base64: file.toString('base64'),
187
+ content_type: 'image/png',
188
+ file_name: path.basename(filePath),
189
+ tag,
190
+ command_id,
191
+ job_id,
192
+ profile_id,
193
+ platform,
194
+ });
195
+ }
176
196
  }
177
197
 
178
198
  module.exports = { ApiClient };
@@ -286,6 +286,17 @@ class CommandPoller {
286
286
  this.api.postExtensionLog?.({ level, message, data, profile_id: profileId, job_id: payload.job_id || null })
287
287
  .catch(() => {});
288
288
  };
289
+ // Publish scripts attach screenshots to warning logs through this helper.
290
+ // Keep the command/job identity here so the API and idea Publish History
291
+ // can associate an artifact with the exact platform attempt.
292
+ log.uploadDebugScreenshot = (filePath, meta = {}) => this.api.uploadDebugScreenshot({
293
+ filePath,
294
+ tag: meta.tag || 'failure',
295
+ command_id: String(command._id),
296
+ job_id: payload.job_id || '',
297
+ profile_id: profileId,
298
+ platform: scriptName.replace(/^upload_/, ''),
299
+ });
289
300
 
290
301
  try {
291
302
  const result = await runPlaywrightScript({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.41",
3
+ "version": "2.5.43",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -35,11 +35,11 @@ async function isActionable(el) {
35
35
  return true;
36
36
  }
37
37
 
38
- async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button' } = {}) {
38
+ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button', clickOpts = null } = {}) {
39
39
  const deadline = Date.now() + timeoutMs;
40
40
  while (Date.now() < deadline) {
41
41
  if (await isActionable(loc)) {
42
- await loc.click();
42
+ await loc.click(clickOpts || undefined);
43
43
  return;
44
44
  }
45
45
  await loc.page().waitForTimeout(800);
@@ -47,6 +47,106 @@ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button' } =
47
47
  throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
48
48
  }
49
49
 
50
+ // FB drops a "Thông báo mới" toast at the BOTTOM-LEFT of the wall (e.g. "… và
51
+ // 96 người khác thích thước phim của bạn") that lands ON TOP of the Reels
52
+ // composer's bottom CTA. Playwright's hit-target check then blocks every
53
+ // click — "<b>96 người khác</b> from <div data-visualcompletion='ignore'>
54
+ // subtree intercepts pointer events" — and the job dies at step 1 even though
55
+ // the button is visible + enabled. Same story for the feed hover-cards behind
56
+ // the modal. This mutes whatever sits over the button's centre point: from
57
+ // elementFromPoint, walk up to the outermost ancestor that still does NOT
58
+ // contain the target (= the toast/hover-card portal root) and kill its
59
+ // pointer-events. Muted nodes are tagged so we can restore them right after
60
+ // the click — leaving a portal permanently dead could break a later step that
61
+ // renders into the same container. Returns how many layers were muted.
62
+ async function muteClickInterceptors(page, selector) {
63
+ return page.evaluate((sel) => {
64
+ const el = document.querySelector(sel);
65
+ if (!el) return 0;
66
+ const r = el.getBoundingClientRect();
67
+ const cx = r.x + r.width / 2;
68
+ const cy = r.y + r.height / 2;
69
+ let muted = 0;
70
+ for (let pass = 0; pass < 4; pass++) {
71
+ const top = document.elementFromPoint(cx, cy);
72
+ if (!top || top === el || el.contains(top) || top.contains(el)) break;
73
+ let root = top;
74
+ while (root.parentElement && root.parentElement !== document.body && !root.parentElement.contains(el)) {
75
+ root = root.parentElement;
76
+ }
77
+ if (root === document.body || root.contains(el)) break;
78
+ // Already muted and STILL on top (a stylesheet !important beats our
79
+ // inline style) — muting again would overwrite the saved original with
80
+ // 'none' and leave the node permanently dead after the restore.
81
+ if (root.hasAttribute('__fbpw_muted__')) break;
82
+ root.setAttribute('__fbpw_muted__', root.style.pointerEvents || '_');
83
+ root.style.pointerEvents = 'none';
84
+ muted++;
85
+ }
86
+ return muted;
87
+ }, selector).catch(() => 0);
88
+ }
89
+
90
+ async function unmuteClickInterceptors(page) {
91
+ return page.evaluate(() => {
92
+ document.querySelectorAll('[__fbpw_muted__]').forEach((el) => {
93
+ const prev = el.getAttribute('__fbpw_muted__');
94
+ el.style.pointerEvents = prev === '_' ? '' : prev;
95
+ el.removeAttribute('__fbpw_muted__');
96
+ });
97
+ }).catch(() => {});
98
+ }
99
+
100
+ // Click that survives FB's overlay noise. Order of escalation:
101
+ // 1. park the cursor in a corner (a mouse resting over the feed behind the
102
+ // modal keeps a hover-card alive right on top of the CTA),
103
+ // 2. native click — highest fidelity,
104
+ // 3. on failure, mute whatever intercepts the click point and retry natively,
105
+ // 4. last resort, JS-dispatch el.click() (bypasses hit-testing entirely).
106
+ // `jsFallback:false` for actions that must never fire twice (publish) — those
107
+ // call sites run their own guarded fallback.
108
+ // `landedCheck` guards the escalation: Playwright can report a timeout for a
109
+ // click that DID register (the node detaches mid-action). Callers pass a probe
110
+ // for "the UI already moved on" so we don't fire a second click on top of it.
111
+ async function resilientClick(page, loc, selector, { timeout = 10_000, log, label = 'button', jsFallback = true, attempts = 3, landedCheck = null } = {}) {
112
+ try { await loc.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
113
+ await page.mouse.move(2, 2).catch(() => {});
114
+ let lastErr = null;
115
+ for (let attempt = 0; attempt < attempts; attempt++) {
116
+ try {
117
+ await loc.click({ timeout });
118
+ await unmuteClickInterceptors(page);
119
+ return 'native';
120
+ } catch (e) {
121
+ lastErr = e;
122
+ if (landedCheck && await landedCheck().catch(() => false)) {
123
+ if (log) log('info', `[fb-pw] ${label} click reported a timeout but the UI advanced → treating as clicked`);
124
+ await unmuteClickInterceptors(page);
125
+ return 'landed';
126
+ }
127
+ const muted = await muteClickInterceptors(page, selector);
128
+ if (log) log('warn', `[fb-pw] ${label} click attempt ${attempt + 1}/${attempts} failed (${e.message.split('\n')[0].slice(0, 70)}) — muted ${muted} overlay layer(s)`);
129
+ await page.waitForTimeout(500);
130
+ }
131
+ }
132
+ if (jsFallback) {
133
+ const ok = await page.evaluate((sel) => {
134
+ const el = document.querySelector(sel);
135
+ if (!el) return false;
136
+ el.scrollIntoView({ block: 'center' });
137
+ el.click();
138
+ return true;
139
+ }, selector).catch(() => false);
140
+ if (ok) {
141
+ if (log) log('info', `[fb-pw] ${label} clicked via JS-dispatch fallback`);
142
+ await unmuteClickInterceptors(page);
143
+ return 'js';
144
+ }
145
+ }
146
+ await unmuteClickInterceptors(page);
147
+ throw lastErr || new Error(`${label}: click failed`);
148
+ }
149
+
50
150
  // Same inventory dump as iter-2 (kept for failure diagnostics).
51
151
  async function dumpInventory(page, log, tag) {
52
152
  try {
@@ -119,7 +219,19 @@ async function dumpFailure(page, tag, log) {
119
219
  fs.mkdirSync(dir, { recursive: true });
120
220
  const png = path.join(dir, `fb-${tag}-${Date.now()}.png`);
121
221
  await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
122
- log('warn', `[fb-pw] dump ${tag} — url=${page.url()} screenshot=${png}`);
222
+ let debugScreenshot = null;
223
+ if (fs.existsSync(png) && typeof log.uploadDebugScreenshot === 'function') {
224
+ try {
225
+ debugScreenshot = await log.uploadDebugScreenshot(png, { tag });
226
+ } catch (e) {
227
+ log('warn', `[fb-pw] debug screenshot upload failed: ${e.message}`);
228
+ }
229
+ }
230
+ log(
231
+ 'warn',
232
+ `[fb-pw] dump ${tag} — url=${page.url()} screenshot=${debugScreenshot?.signed_url || png}`,
233
+ debugScreenshot ? { debug_screenshot: debugScreenshot } : { debug_screenshot_local_path: png },
234
+ );
123
235
  } catch {}
124
236
  }
125
237
 
@@ -283,7 +395,7 @@ async function run({ page, payload, log }) {
283
395
  } = payload || {};
284
396
  if (!video_url) throw new Error('No video_url provided');
285
397
 
286
- log('info', '[fb-pw] selectors version=2026.06.19d-reels-link-fallback');
398
+ log('info', '[fb-pw] selectors version=2026.08.02-overlay-proof-cta');
287
399
 
288
400
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
289
401
 
@@ -929,8 +1041,12 @@ async function run({ page, payload, log }) {
929
1041
  if (!bestEl) bestEl = tryScope(null);
930
1042
  if (!bestEl) return null;
931
1043
  // Tag the element so Playwright can find it back. data-attribute is
932
- // safe + ignored by FB's React reconciliation.
1044
+ // safe + ignored by FB's React reconciliation. Clear stale markers
1045
+ // FIRST: a click that threw never reached the cleanup below, so an
1046
+ // old node could still carry the marker → the locator would resolve
1047
+ // to 2 elements and every later click dies on strict mode.
933
1048
  const marker = '__fbpw_target__';
1049
+ document.querySelectorAll(`[${marker}]`).forEach((n) => n.removeAttribute(marker));
934
1050
  bestEl.setAttribute(marker, '1');
935
1051
  return { selector: `[${marker}='1']`, tag: bestEl.tagName.toLowerCase() };
936
1052
  }, { verb: v, bottomHalf: requireBottomHalf });
@@ -938,17 +1054,23 @@ async function run({ page, payload, log }) {
938
1054
  const loc = page.locator(found.selector);
939
1055
  return {
940
1056
  hit: {
941
- click: async (opts) => {
942
- // scrollIntoViewIfNeeded handles off-screen primary actions
943
- // (e.g. Chia sẻ ngay at y=-274 after a step jump). Then
944
- // Playwright's click auto-waits visible+stable.
945
- try { await loc.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
946
- await loc.click({ timeout: opts?.timeout || 5000 });
1057
+ click: async (opts = {}) => {
1058
+ // resilientClick handles scroll-into-view (off-screen primary
1059
+ // actions like Chia sẻ ngay at y=-274 after a step jump) and,
1060
+ // more importantly, FB's toast/hover-card overlays that cover
1061
+ // the CTA and make Playwright's hit-target check fail forever.
1062
+ await resilientClick(page, loc, found.selector, {
1063
+ timeout: opts?.timeout || 10_000,
1064
+ log,
1065
+ label: opts?.label || v,
1066
+ jsFallback: opts?.jsFallback !== false,
1067
+ landedCheck: opts?.landedCheck || null,
1068
+ });
947
1069
  // Remove the marker so subsequent findByVerbs gets a fresh
948
1070
  // element instead of clinging to the now-stale node.
949
- await page.evaluate((sel) => {
950
- document.querySelectorAll(sel).forEach((el) => el.removeAttribute(sel.replace(/[\[\]'=1]/g, '')));
951
- }, found.selector).catch(() => {});
1071
+ await page.evaluate(() => {
1072
+ document.querySelectorAll('[__fbpw_target__]').forEach((el) => el.removeAttribute('__fbpw_target__'));
1073
+ }).catch(() => {});
952
1074
  },
953
1075
  isVisible: async () => loc.isVisible().catch(() => false),
954
1076
  isEnabled: async () => loc.isEnabled().catch(() => true),
@@ -1565,6 +1687,23 @@ async function run({ page, payload, log }) {
1565
1687
  // the user re-posted → duplicate reel. So: if the button vanished
1566
1688
  // after we clicked, it went through.
1567
1689
  const publishBtnGone = async () => !(await findByVerbs(publishVerbs, { requireBottomHalf: true }));
1690
+ // Marker-free twin of publishBtnGone, for use DURING a click retry:
1691
+ // findByVerbs re-tags __fbpw_target__, which would yank the marker
1692
+ // out from under the locator we're mid-click on.
1693
+ const publishBtnGoneDom = async () => page.evaluate((verbs) => {
1694
+ const scopes = [...document.querySelectorAll("[role='dialog']")];
1695
+ for (const s of (scopes.length ? scopes : [document.body])) {
1696
+ for (const b of s.querySelectorAll("button, [role='button']")) {
1697
+ if (!verbs.includes((b.innerText || '').trim())) continue;
1698
+ const r = b.getBoundingClientRect();
1699
+ if (r.width < 8 || r.height < 8) continue;
1700
+ if (r.y < window.innerHeight * 0.4) continue;
1701
+ if (b.getAttribute('aria-disabled') === 'true' || b.disabled) continue;
1702
+ return false;
1703
+ }
1704
+ }
1705
+ return true;
1706
+ }, publishVerbs).catch(() => false);
1568
1707
  for (let attempt = 0; attempt < 3 && !clickedPublish; attempt++) {
1569
1708
  const target = attempt === 0 ? pub : await findByVerbs(publishVerbs, { requireBottomHalf: true });
1570
1709
  if (!target) {
@@ -1573,7 +1712,16 @@ async function run({ page, payload, log }) {
1573
1712
  }
1574
1713
  attemptedClick = true;
1575
1714
  try {
1576
- await target.hit.click({ timeout: 15000 });
1715
+ // jsFallback:false — publish must never fire twice. The guarded
1716
+ // JS-dispatch below (attempt 3, after publishBtnGone checks) owns
1717
+ // that escalation; the generic one inside resilientClick would
1718
+ // bypass those guards. Overlay-muting + landedCheck still apply.
1719
+ await target.hit.click({
1720
+ timeout: 15000,
1721
+ label: `Đăng(${step + 1})`,
1722
+ jsFallback: false,
1723
+ landedCheck: publishBtnGoneDom,
1724
+ });
1577
1725
  clickedPublish = true;
1578
1726
  } catch (ce) {
1579
1727
  log('warn', `[fb-pw] publish click attempt ${attempt + 1}/3 threw: ${ce.message.slice(0, 90)}`);
@@ -1615,6 +1763,10 @@ async function run({ page, payload, log }) {
1615
1763
  for (const b of btns) {
1616
1764
  const t = (b.innerText || '').trim();
1617
1765
  if (t === v) {
1766
+ // Clear stale markers first — a previous confirm attempt
1767
+ // that threw left its tag behind, and two tagged nodes
1768
+ // make the locator fail on strict mode.
1769
+ document.querySelectorAll('[__fbpw_confirm__]').forEach((n) => n.removeAttribute('__fbpw_confirm__'));
1618
1770
  b.setAttribute('__fbpw_confirm__', '1');
1619
1771
  return { selector: "[__fbpw_confirm__='1']", verb: v };
1620
1772
  }
@@ -1628,8 +1780,18 @@ async function run({ page, payload, log }) {
1628
1780
  if (confirmHit) {
1629
1781
  log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
1630
1782
  const cLoc = page.locator(confirmHit.selector);
1631
- try { await cLoc.scrollIntoViewIfNeeded({ timeout: 2000 }); } catch {}
1632
- await cLoc.click({ timeout: 5000 }).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.slice(0, 80)}`));
1783
+ // Was a plain 5s click: it failed on nearly EVERY run ("confirm
1784
+ // click failed: Timeout 5000ms exceeded") because the new-post
1785
+ // toast covers this dialog's CTA too. Escalate the same way —
1786
+ // mute the overlay, then JS-dispatch. The confirm button lives in
1787
+ // a dialog that disappears once it fires, so the fallback can't
1788
+ // double-publish (querySelector returns null on the second pass).
1789
+ await resilientClick(page, cLoc, confirmHit.selector, {
1790
+ timeout: 8000,
1791
+ log,
1792
+ label: `confirm "${confirmHit.verb}"`,
1793
+ attempts: 2,
1794
+ }).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.split('\n')[0].slice(0, 80)}`));
1633
1795
  await page.waitForTimeout(3000);
1634
1796
  } else {
1635
1797
  log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
@@ -1708,8 +1870,38 @@ async function run({ page, payload, log }) {
1708
1870
  throw new Error(`FB composer step ${step + 1}: neither publish nor Tiếp button found`);
1709
1871
  }
1710
1872
  log('info', `[fb-pw] click "${next.verb}" via "${next.sel}" (step ${step + 1})`);
1873
+ // The composer's dialog aria-label names the current wizard step ("Tạo
1874
+ // thước phim" → "Chỉnh sửa thước phim" → …). Snapshot it so a click that
1875
+ // registered-then-timed-out is recognised instead of re-fired.
1876
+ const composerStepLabel = async () => page.evaluate(() => {
1877
+ const out = [];
1878
+ document.querySelectorAll("[role='dialog']").forEach((d) => {
1879
+ const r = d.getBoundingClientRect();
1880
+ if (r.width < 8 || r.height < 8) return;
1881
+ out.push(d.getAttribute('aria-label') || '');
1882
+ });
1883
+ return out.join('|');
1884
+ }).catch(() => '');
1885
+ const stepLabelBefore = await composerStepLabel();
1711
1886
  try {
1712
- await waitAndClick(next.hit, { timeoutMs: 180_000, log, label: `Tiếp(${step + 1})` });
1887
+ await waitAndClick(next.hit, {
1888
+ timeoutMs: 180_000,
1889
+ log,
1890
+ label: `Tiếp(${step + 1})`,
1891
+ // 5s was too tight: FB's new-notification toast parks itself over the
1892
+ // composer's bottom CTA and Playwright burns the whole budget on
1893
+ // blocked retries. Give the click room + let it escalate (mute the
1894
+ // overlay, then JS-dispatch), with landedCheck so we never advance
1895
+ // the wizard twice.
1896
+ clickOpts: {
1897
+ timeout: 12_000,
1898
+ label: `Tiếp(${step + 1})`,
1899
+ landedCheck: async () => {
1900
+ const now = await composerStepLabel();
1901
+ return !!now && now !== stepLabelBefore;
1902
+ },
1903
+ },
1904
+ });
1713
1905
  } catch (e) {
1714
1906
  await dumpInventory(page, log, `tiep-not-clickable-${step + 1}`);
1715
1907
  await dumpFailure(page, `tiep-not-clickable-${step + 1}`, log);
@@ -2145,3 +2337,6 @@ async function run({ page, payload, log }) {
2145
2337
  }
2146
2338
 
2147
2339
  module.exports = { run };
2340
+ // Exposed so the overlay-click escalation can be exercised against a synthetic
2341
+ // page (toast covering the CTA) without driving the whole upload flow.
2342
+ module.exports.__testables = { resilientClick, muteClickInterceptors, unmuteClickInterceptors };
@@ -47,7 +47,19 @@ async function dumpFailure(page, tag, log) {
47
47
  fs.mkdirSync(dir, { recursive: true });
48
48
  const png = path.join(dir, `fbphoto-${tag}-${Date.now()}.png`);
49
49
  await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
50
- log('warn', `[fbphoto] screenshot: ${png}`);
50
+ let debugScreenshot = null;
51
+ if (fs.existsSync(png) && typeof log.uploadDebugScreenshot === 'function') {
52
+ try {
53
+ debugScreenshot = await log.uploadDebugScreenshot(png, { tag });
54
+ } catch (e) {
55
+ log('warn', `[fbphoto] debug screenshot upload failed: ${e.message}`);
56
+ }
57
+ }
58
+ log(
59
+ 'warn',
60
+ `[fbphoto] screenshot: ${debugScreenshot?.signed_url || png}`,
61
+ debugScreenshot ? { debug_screenshot: debugScreenshot } : { debug_screenshot_local_path: png },
62
+ );
51
63
  } catch { /* ignore */ }
52
64
  }
53
65
 
@@ -119,7 +119,19 @@ async function dumpFailure(page, tag, log) {
119
119
  const url = page.url();
120
120
  const title = await page.title().catch(() => '');
121
121
  const bodyHead = await page.evaluate(() => (document.body?.innerText || '').slice(0, 400)).catch(() => '');
122
- log('warn', `[yt-pw] page dump on ${tag} — url=${url} | title=${title.slice(0, 80)} | head=${bodyHead.replace(/\s+/g, ' ').slice(0, 200)} | screenshot=${png}`);
122
+ let debugScreenshot = null;
123
+ if (fs.existsSync(png) && typeof log.uploadDebugScreenshot === 'function') {
124
+ try {
125
+ debugScreenshot = await log.uploadDebugScreenshot(png, { tag });
126
+ } catch (e) {
127
+ log('warn', `[yt-pw] debug screenshot upload failed: ${e.message}`);
128
+ }
129
+ }
130
+ log(
131
+ 'warn',
132
+ `[yt-pw] page dump on ${tag} — url=${url} | title=${title.slice(0, 80)} | head=${bodyHead.replace(/\s+/g, ' ').slice(0, 200)} | screenshot=${debugScreenshot?.signed_url || png}`,
133
+ debugScreenshot ? { debug_screenshot: debugScreenshot } : { debug_screenshot_local_path: png },
134
+ );
123
135
 
124
136
  // Snapshot of every VISIBLE interactive element — so when Studio renames
125
137
  // an id or swaps a tag we see what's there instead.