verify-clipboard-fallback.mjs 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /**
  2. * 验证「复制」在 **HTTP(非安全上下文)** 下仍可用。
  3. *
  4. * 为什么要有它:`navigator.clipboard` **只在安全上下文存在**(HTTPS / localhost)。
  5. * 用户 2026-09-29 要部署到 HTTP ⇒ 原来的写法
  6. * `navigator.clipboard.writeText(x).catch(() => { …execCommand 兜底… })`
  7. * 会在取 `.writeText` 时**同步抛 TypeError**,`.catch()` 根本轮不到 ⇒ 复制按钮直接报错。
  8. * 现在改成「显式判断 + try/catch + execCommand 兜底」。
  9. *
  10. * 怎么跑(纯 node,不需要打包):
  11. * node harness/tools/verify-clipboard-fallback.mjs
  12. *
  13. * 做法:从 `FeedbackWidget.vue` 里**抽出真实的 `writeClipboard` 源码**(用 esbuild 去掉 TS 类型),
  14. * 配三组桩跑:① HTTP(没有 clipboard)② 正常 HTTPS ③ clipboard 存在但 reject。
  15. */
  16. import { readFileSync } from 'node:fs';
  17. import { transform } from 'esbuild';
  18. let pass = 0;
  19. let fail = 0;
  20. const check = (label, ok, extra = '') => {
  21. if (ok) {
  22. pass += 1;
  23. console.log(` ok ${label}${extra ? ` —— ${extra}` : ''}`);
  24. } else {
  25. fail += 1;
  26. console.log(` FAIL ${label}${extra ? ` —— ${extra}` : ''}`);
  27. }
  28. };
  29. const vue = readFileSync('src/components/Common/FeedbackWidget.vue', 'utf8');
  30. const start = vue.indexOf('async function writeClipboard');
  31. if (start === -1) {
  32. console.error('❌ 没在 FeedbackWidget.vue 里找到 writeClipboard()');
  33. process.exit(1);
  34. }
  35. // 花括号配平取出整个函数体(跳过字符串/模板里的花括号;这个函数很简单,够用)
  36. let depth = 0;
  37. let end = -1;
  38. for (let i = vue.indexOf('{', start); i < vue.length; i += 1) {
  39. const ch = vue[i];
  40. if (ch === '{') depth += 1;
  41. else if (ch === '}') {
  42. depth -= 1;
  43. if (depth === 0) { end = i + 1; break; }
  44. }
  45. }
  46. const tsSource = vue.slice(start, end);
  47. check('从 SFC 里抽到了 writeClipboard 源码', tsSource.includes('execCommand'), `长度 ${tsSource.length}`);
  48. const jsSource = (await transform(tsSource, { loader: 'ts', format: 'esm' })).code;
  49. /** 造一组最小的 document 桩,记录 execCommand 是否被调用 */
  50. const makeDocument = () => {
  51. const calls = { execCommand: 0, appended: 0, removed: 0 };
  52. const el = { value: '', style: {}, select: () => {}, };
  53. const doc = {
  54. createElement: () => el,
  55. body: {
  56. appendChild: () => { calls.appended += 1; },
  57. removeChild: () => { calls.removed += 1; },
  58. },
  59. execCommand: (cmd) => { calls.execCommand += 1; return cmd === 'copy'; },
  60. };
  61. return { doc, calls };
  62. };
  63. const run = async (navigatorStub) => {
  64. const { doc, calls } = makeDocument();
  65. const factory = new Function('navigator', 'document', `${jsSource}\nreturn writeClipboard;`);
  66. const fn = factory(navigatorStub, doc);
  67. let threw = null;
  68. try { await fn('待复制的内容'); } catch (e) { threw = e; }
  69. return { calls, threw };
  70. };
  71. console.log('\n【1】HTTP(非安全上下文):navigator.clipboard 不存在 → 必须走 execCommand 兜底且不抛错');
  72. {
  73. const { calls, threw } = await run({});
  74. check('没有抛错', threw === null, threw ? String(threw).slice(0, 60) : '');
  75. check('走了 execCommand 兜底', calls.execCommand === 1, `execCommand 调用 ${calls.execCommand} 次`);
  76. check('临时 textarea 加了又移除(不漏在 DOM 里)', calls.appended === 1 && calls.removed === 1,
  77. `append ${calls.appended} / remove ${calls.removed}`);
  78. }
  79. console.log('\n【2】HTTPS(安全上下文):clipboard.writeText 可用 → 用它,不碰 execCommand');
  80. {
  81. let written = '';
  82. const { calls, threw } = await run({ clipboard: { writeText: async (t) => { written = t; } } });
  83. check('没有抛错', threw === null);
  84. check('用了 clipboard.writeText', written === '待复制的内容');
  85. check('没有走 execCommand', calls.execCommand === 0);
  86. }
  87. console.log('\n【3】clipboard 存在但抛错(如权限被拒)→ 回退到 execCommand');
  88. {
  89. const { calls, threw } = await run({
  90. clipboard: { writeText: () => { throw new Error('NotAllowedError'); } },
  91. });
  92. check('没有抛错(同步抛错也被接住)', threw === null, threw ? String(threw).slice(0, 60) : '');
  93. check('回退到 execCommand', calls.execCommand === 1);
  94. }
  95. console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
  96. process.exit(fail ? 1 : 0);