From e7f31b6fc8b767ec6316ba928d9c182895328eca Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Mon, 14 Sep 2026 17:30:17 +0800 Subject: [PATCH] fix(site): [SITE-VCONSOLE-01] cancel stale console lifecycle work --- docs/THIRD_PARTY_NOTICES.md | 4 +- docs/assets/js/vconsole.min.js | 2 +- package.json | 11 +- .../vconsole-lifecycle-validation.json | 1670 +++++++++++++++++ .../2026-09-14-SITE-VCONSOLE-01-lifecycle.md | 56 + refactor/plan.md | 10 +- refactor/progress.md | 14 + refactor/risk-table.md | 2 +- refactor/risks.json | 16 +- refactor/tasks.json | 23 +- refactor/third-party.json | 23 +- scripts/build-vconsole.mjs | 12 + scripts/site-vendor/README.md | 8 +- scripts/site-vendor/manifest.json | 7 +- scripts/site-vendor/notices.ts | 2 +- scripts/site-vendor/vconsole/README.md | 57 + scripts/site-vendor/vconsole/build.ts | 40 + scripts/site-vendor/vconsole/lifecycle.ts | 63 + scripts/site-vendor/vconsole/upstream.js | 10 + scripts/tsconfig.docs.json | 3 +- test/browser/vconsole-lifecycle.spec.js | 176 ++ test/vconsole-lifecycle.test.js | 141 ++ 22 files changed, 2318 insertions(+), 32 deletions(-) create mode 100644 refactor/baselines/vconsole-lifecycle-validation.json create mode 100644 refactor/changes/2026-09-14-SITE-VCONSOLE-01-lifecycle.md create mode 100644 scripts/build-vconsole.mjs create mode 100644 scripts/site-vendor/vconsole/README.md create mode 100644 scripts/site-vendor/vconsole/build.ts create mode 100644 scripts/site-vendor/vconsole/lifecycle.ts create mode 100644 scripts/site-vendor/vconsole/upstream.js create mode 100644 test/browser/vconsole-lifecycle.spec.js create mode 100644 test/vconsole-lifecycle.test.js diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md index f0c31a4f6..27fd68e14 100644 --- a/docs/THIRD_PARTY_NOTICES.md +++ b/docs/THIRD_PARTY_NOTICES.md @@ -2,13 +2,13 @@ Frozen source identities and verbatim upstream notice texts for Monaco/vConsole. This is not clearance of all bundled components, the console bundle, fonts, media or other site assets. -Generated by yarn build:site-notices. Runtime asset URLs and bytes are unchanged. +Generated by yarn build:site-notices. Runtime asset URLs are retained; reviewed local patches are identified below. ## vconsole 3.15.0 Source: https://registry.npmjs.org/vconsole/-/vconsole-3.15.0.tgz -The 3.15.0 upstream LICENSE declares MIT but omits the promised full license text. The copied file preserves this upstream evidence; completeness and bundled dependency notices remain under review. +The 3.15.0 upstream LICENSE declares MIT but omits the promised full license text. The copied file preserves this upstream evidence; completeness and bundled dependency notices remain under review. ArtPlayer applies a reproducible local lifecycle patch to log frame cancellation and delayed panel ownership; the upstream bundle is frozen in scripts/site-vendor/vconsole/upstream.js and generated by yarn build:vconsole. Public asset URL and UMD/CSS contracts are retained. - licenses/vconsole/LICENSE diff --git a/docs/assets/js/vconsole.min.js b/docs/assets/js/vconsole.min.js index fe00a39cf..3a54699a8 100644 --- a/docs/assets/js/vconsole.min.js +++ b/docs/assets/js/vconsole.min.js @@ -7,4 +7,4 @@ * http://opensource.org/licenses/MIT * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define("VConsole",[],n):"object"==typeof exports?exports.VConsole=n():t.VConsole=n()}(this||self,(function(){return function(){var __webpack_modules__={4264:function(t,n,e){t.exports=e(7588)},5036:function(t,n,e){e(1719),e(5677),e(6394),e(5334),e(6969),e(2021),e(8328),e(2129);var o=e(1287);t.exports=o.Promise},2582:function(t,n,e){e(1646),e(6394),e(2004),e(462),e(8407),e(2429),e(1172),e(8288),e(1274),e(8201),e(6626),e(3211),e(9952),e(15),e(9831),e(7521),e(2972),e(6956),e(5222),e(2257);var o=e(1287);t.exports=o.Symbol},8257:function(t,n,e){var o=e(7583),r=e(9212),i=e(5637),a=o.TypeError;t.exports=function(t){if(r(t))return t;throw a(i(t)+" is not a function")}},1186:function(t,n,e){var o=e(7583),r=e(2097),i=e(5637),a=o.TypeError;t.exports=function(t){if(r(t))return t;throw a(i(t)+" is not a constructor")}},9882:function(t,n,e){var o=e(7583),r=e(9212),i=o.String,a=o.TypeError;t.exports=function(t){if("object"==typeof t||r(t))return t;throw a("Can't set "+i(t)+" as a prototype")}},6288:function(t,n,e){var o=e(3649),r=e(3590),i=e(4615),a=o("unscopables"),c=Array.prototype;null==c[a]&&i.f(c,a,{configurable:!0,value:r(null)}),t.exports=function(t){c[a][t]=!0}},4761:function(t,n,e){var o=e(7583),r=e(2447),i=o.TypeError;t.exports=function(t,n){if(r(n,t))return t;throw i("Incorrect invocation")}},2569:function(t,n,e){var o=e(7583),r=e(794),i=o.String,a=o.TypeError;t.exports=function(t){if(r(t))return t;throw a(i(t)+" is not an object")}},5766:function(t,n,e){var o=e(2977),r=e(6782),i=e(1825),a=function(t){return function(n,e,a){var c,u=o(n),s=i(u),l=r(a,s);if(t&&e!=e){for(;s>l;)if((c=u[l++])!=c)return!0}else for(;s>l;l++)if((t||l in u)&&u[l]===e)return t||l||0;return!t&&-1}};t.exports={includes:a(!0),indexOf:a(!1)}},4805:function(t,n,e){var o=e(2938),r=e(7386),i=e(5044),a=e(1324),c=e(1825),u=e(4822),s=r([].push),l=function(t){var n=1==t,e=2==t,r=3==t,l=4==t,f=6==t,d=7==t,v=5==t||f;return function(h,p,g,m){for(var _,b,y=a(h),w=i(y),E=o(p,g),L=c(w),T=0,x=m||u,C=n?x(h,L):e||d?x(h,0):void 0;L>T;T++)if((v||T in w)&&(b=E(_=w[T],T,y),t))if(n)C[T]=b;else if(b)switch(t){case 3:return!0;case 5:return _;case 6:return T;case 2:s(C,_)}else switch(t){case 4:return!1;case 7:s(C,_)}return f?-1:r||l?l:C}};t.exports={forEach:l(0),map:l(1),filter:l(2),some:l(3),every:l(4),find:l(5),findIndex:l(6),filterReject:l(7)}},9269:function(t,n,e){var o=e(6544),r=e(3649),i=e(4061),a=r("species");t.exports=function(t){return i>=51||!o((function(){var n=[];return(n.constructor={})[a]=function(){return{foo:1}},1!==n[t](Boolean).foo}))}},4546:function(t,n,e){var o=e(7583),r=e(6782),i=e(1825),a=e(5999),c=o.Array,u=Math.max;t.exports=function(t,n,e){for(var o=i(t),s=r(n,o),l=r(void 0===e?o:e,o),f=c(u(l-s,0)),d=0;s0&&o[0]<4?1:+(o[0]+o[1])),!r&&a&&(!(o=a.match(/Edge\/(\d+)/))||o[1]>=74)&&(o=a.match(/Chrome\/(\d+)/))&&(r=+o[1]),t.exports=r},5690:function(t){t.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},1178:function(t,n,e){var o=e(6544),r=e(4677);t.exports=!o((function(){var t=Error("a");return!("stack"in t)||(Object.defineProperty(t,"stack",r(1,7)),7!==t.stack)}))},7263:function(t,n,e){var o=e(7583),r=e(6683).f,i=e(57),a=e(1270),c=e(460),u=e(3478),s=e(4451);t.exports=function(t,n){var e,l,f,d,v,h=t.target,p=t.global,g=t.stat;if(e=p?o:g?o[h]||c(h,{}):(o[h]||{}).prototype)for(l in n){if(d=n[l],f=t.noTargetGet?(v=r(e,l))&&v.value:e[l],!s(p?l:h+(g?".":"#")+l,t.forced)&&void 0!==f){if(typeof d==typeof f)continue;u(d,f)}(t.sham||f&&f.sham)&&i(d,"sham",!0),a(e,l,d,t)}}},6544:function(t){t.exports=function(t){try{return!!t()}catch(t){return!0}}},1611:function(t,n,e){var o=e(8987),r=Function.prototype,i=r.apply,a=r.call;t.exports="object"==typeof Reflect&&Reflect.apply||(o?a.bind(i):function(){return a.apply(i,arguments)})},2938:function(t,n,e){var o=e(7386),r=e(8257),i=e(8987),a=o(o.bind);t.exports=function(t,n){return r(t),void 0===n?t:i?a(t,n):function(){return t.apply(n,arguments)}}},8987:function(t,n,e){var o=e(6544);t.exports=!o((function(){var t=function(){}.bind();return"function"!=typeof t||t.hasOwnProperty("prototype")}))},8262:function(t,n,e){var o=e(8987),r=Function.prototype.call;t.exports=o?r.bind(r):function(){return r.apply(r,arguments)}},4340:function(t,n,e){var o=e(8494),r=e(2870),i=Function.prototype,a=o&&Object.getOwnPropertyDescriptor,c=r(i,"name"),u=c&&"something"===function(){}.name,s=c&&(!o||o&&a(i,"name").configurable);t.exports={EXISTS:c,PROPER:u,CONFIGURABLE:s}},7386:function(t,n,e){var o=e(8987),r=Function.prototype,i=r.bind,a=r.call,c=o&&i.bind(a,a);t.exports=o?function(t){return t&&c(t)}:function(t){return t&&function(){return a.apply(t,arguments)}}},5897:function(t,n,e){var o=e(7583),r=e(9212),i=function(t){return r(t)?t:void 0};t.exports=function(t,n){return arguments.length<2?i(o[t]):o[t]&&o[t][n]}},8272:function(t,n,e){var o=e(3058),r=e(911),i=e(339),a=e(3649)("iterator");t.exports=function(t){if(null!=t)return r(t,a)||r(t,"@@iterator")||i[o(t)]}},6307:function(t,n,e){var o=e(7583),r=e(8262),i=e(8257),a=e(2569),c=e(5637),u=e(8272),s=o.TypeError;t.exports=function(t,n){var e=arguments.length<2?u(t):n;if(i(e))return a(r(e,t));throw s(c(t)+" is not iterable")}},911:function(t,n,e){var o=e(8257);t.exports=function(t,n){var e=t[n];return null==e?void 0:o(e)}},7583:function(t,n,e){var o=function(t){return t&&t.Math==Math&&t};t.exports=o("object"==typeof globalThis&&globalThis)||o("object"==typeof window&&window)||o("object"==typeof self&&self)||o("object"==typeof e.g&&e.g)||function(){return this}()||Function("return this")()},2870:function(t,n,e){var o=e(7386),r=e(1324),i=o({}.hasOwnProperty);t.exports=Object.hasOwn||function(t,n){return i(r(t),n)}},4639:function(t){t.exports={}},2716:function(t,n,e){var o=e(7583);t.exports=function(t,n){var e=o.console;e&&e.error&&(1==arguments.length?e.error(t):e.error(t,n))}},482:function(t,n,e){var o=e(5897);t.exports=o("document","documentElement")},275:function(t,n,e){var o=e(8494),r=e(6544),i=e(6668);t.exports=!o&&!r((function(){return 7!=Object.defineProperty(i("div"),"a",{get:function(){return 7}}).a}))},5044:function(t,n,e){var o=e(7583),r=e(7386),i=e(6544),a=e(9624),c=o.Object,u=r("".split);t.exports=i((function(){return!c("z").propertyIsEnumerable(0)}))?function(t){return"String"==a(t)?u(t,""):c(t)}:c},9734:function(t,n,e){var o=e(7386),r=e(9212),i=e(1314),a=o(Function.toString);r(i.inspectSource)||(i.inspectSource=function(t){return a(t)}),t.exports=i.inspectSource},4402:function(t,n,e){var o=e(794),r=e(57);t.exports=function(t,n){o(n)&&"cause"in n&&r(t,"cause",n.cause)}},2743:function(t,n,e){var o,r,i,a=e(9491),c=e(7583),u=e(7386),s=e(794),l=e(57),f=e(2870),d=e(1314),v=e(9137),h=e(4639),p="Object already initialized",g=c.TypeError,m=c.WeakMap;if(a||d.state){var _=d.state||(d.state=new m),b=u(_.get),y=u(_.has),w=u(_.set);o=function(t,n){if(y(_,t))throw new g(p);return n.facade=t,w(_,t,n),n},r=function(t){return b(_,t)||{}},i=function(t){return y(_,t)}}else{var E=v("state");h[E]=!0,o=function(t,n){if(f(t,E))throw new g(p);return n.facade=t,l(t,E,n),n},r=function(t){return f(t,E)?t[E]:{}},i=function(t){return f(t,E)}}t.exports={set:o,get:r,has:i,enforce:function(t){return i(t)?r(t):o(t,{})},getterFor:function(t){return function(n){var e;if(!s(n)||(e=r(n)).type!==t)throw g("Incompatible receiver, "+t+" required");return e}}}},114:function(t,n,e){var o=e(3649),r=e(339),i=o("iterator"),a=Array.prototype;t.exports=function(t){return void 0!==t&&(r.Array===t||a[i]===t)}},4521:function(t,n,e){var o=e(9624);t.exports=Array.isArray||function(t){return"Array"==o(t)}},9212:function(t){t.exports=function(t){return"function"==typeof t}},2097:function(t,n,e){var o=e(7386),r=e(6544),i=e(9212),a=e(3058),c=e(5897),u=e(9734),s=function(){},l=[],f=c("Reflect","construct"),d=/^\s*(?:class|function)\b/,v=o(d.exec),h=!d.exec(s),p=function(t){if(!i(t))return!1;try{return f(s,l,t),!0}catch(t){return!1}},g=function(t){if(!i(t))return!1;switch(a(t)){case"AsyncFunction":case"GeneratorFunction":case"AsyncGeneratorFunction":return!1}try{return h||!!v(d,u(t))}catch(t){return!0}};g.sham=!0,t.exports=!f||r((function(){var t;return p(p.call)||!p(Object)||!p((function(){t=!0}))||t}))?g:p},4451:function(t,n,e){var o=e(6544),r=e(9212),i=/#|\.prototype\./,a=function(t,n){var e=u[c(t)];return e==l||e!=s&&(r(n)?o(n):!!n)},c=a.normalize=function(t){return String(t).replace(i,".").toLowerCase()},u=a.data={},s=a.NATIVE="N",l=a.POLYFILL="P";t.exports=a},794:function(t,n,e){var o=e(9212);t.exports=function(t){return"object"==typeof t?null!==t:o(t)}},6268:function(t){t.exports=!1},5871:function(t,n,e){var o=e(7583),r=e(5897),i=e(9212),a=e(2447),c=e(7786),u=o.Object;t.exports=c?function(t){return"symbol"==typeof t}:function(t){var n=r("Symbol");return i(n)&&a(n.prototype,u(t))}},4026:function(t,n,e){var o=e(7583),r=e(2938),i=e(8262),a=e(2569),c=e(5637),u=e(114),s=e(1825),l=e(2447),f=e(6307),d=e(8272),v=e(7093),h=o.TypeError,p=function(t,n){this.stopped=t,this.result=n},g=p.prototype;t.exports=function(t,n,e){var o,m,_,b,y,w,E,L=e&&e.that,T=!(!e||!e.AS_ENTRIES),x=!(!e||!e.IS_ITERATOR),C=!(!e||!e.INTERRUPTED),O=r(n,L),I=function(t){return o&&v(o,"normal",t),new p(!0,t)},D=function(t){return T?(a(t),C?O(t[0],t[1],I):O(t[0],t[1])):C?O(t,I):O(t)};if(x)o=t;else{if(!(m=d(t)))throw h(c(t)+" is not iterable");if(u(m)){for(_=0,b=s(t);b>_;_++)if((y=D(t[_]))&&l(g,y))return y;return new p(!1)}o=f(t,m)}for(w=o.next;!(E=i(w,o)).done;){try{y=D(E.value)}catch(t){v(o,"throw",t)}if("object"==typeof y&&y&&l(g,y))return y}return new p(!1)}},7093:function(t,n,e){var o=e(8262),r=e(2569),i=e(911);t.exports=function(t,n,e){var a,c;r(t);try{if(!(a=i(t,"return"))){if("throw"===n)throw e;return e}a=o(a,t)}catch(t){c=!0,a=t}if("throw"===n)throw e;if(c)throw a;return r(a),e}},2365:function(t,n,e){"use strict";var o,r,i,a=e(6544),c=e(9212),u=e(3590),s=e(729),l=e(1270),f=e(3649),d=e(6268),v=f("iterator"),h=!1;[].keys&&("next"in(i=[].keys())?(r=s(s(i)))!==Object.prototype&&(o=r):h=!0),null==o||a((function(){var t={};return o[v].call(t)!==t}))?o={}:d&&(o=u(o)),c(o[v])||l(o,v,(function(){return this})),t.exports={IteratorPrototype:o,BUGGY_SAFARI_ITERATORS:h}},339:function(t){t.exports={}},1825:function(t,n,e){var o=e(97);t.exports=function(t){return o(t.length)}},2095:function(t,n,e){var o,r,i,a,c,u,s,l,f=e(7583),d=e(2938),v=e(6683).f,h=e(8117).set,p=e(7020),g=e(3256),m=e(6846),_=e(5354),b=f.MutationObserver||f.WebKitMutationObserver,y=f.document,w=f.process,E=f.Promise,L=v(f,"queueMicrotask"),T=L&&L.value;T||(o=function(){var t,n;for(_&&(t=w.domain)&&t.exit();r;){n=r.fn,r=r.next;try{n()}catch(t){throw r?a():i=void 0,t}}i=void 0,t&&t.enter()},p||_||m||!b||!y?!g&&E&&E.resolve?((s=E.resolve(void 0)).constructor=E,l=d(s.then,s),a=function(){l(o)}):_?a=function(){w.nextTick(o)}:(h=d(h,f),a=function(){h(o)}):(c=!0,u=y.createTextNode(""),new b(o).observe(u,{characterData:!0}),a=function(){u.data=c=!c})),t.exports=T||function(t){var n={fn:t,next:void 0};i&&(i.next=n),r||(r=n,a()),i=n}},783:function(t,n,e){var o=e(7583);t.exports=o.Promise},8640:function(t,n,e){var o=e(4061),r=e(6544);t.exports=!!Object.getOwnPropertySymbols&&!r((function(){var t=Symbol();return!String(t)||!(Object(t)instanceof Symbol)||!Symbol.sham&&o&&o<41}))},9491:function(t,n,e){var o=e(7583),r=e(9212),i=e(9734),a=o.WeakMap;t.exports=r(a)&&/native code/.test(i(a))},5084:function(t,n,e){"use strict";var o=e(8257),r=function(t){var n,e;this.promise=new t((function(t,o){if(void 0!==n||void 0!==e)throw TypeError("Bad Promise constructor");n=t,e=o})),this.resolve=o(n),this.reject=o(e)};t.exports.f=function(t){return new r(t)}},2764:function(t,n,e){var o=e(8320);t.exports=function(t,n){return void 0===t?arguments.length<2?"":n:o(t)}},3590:function(t,n,e){var o,r=e(2569),i=e(8728),a=e(5690),c=e(4639),u=e(482),s=e(6668),l=e(9137),f=l("IE_PROTO"),d=function(){},v=function(t){return"' })) + await page.goto('/vconsole-lifecycle.html') + const result = await page.evaluate(async (recreate) => { + const raf = window.requestAnimationFrame.bind(window) + const cancel = window.cancelAnimationFrame.bind(window) + const callbacks = [] + const cancellations = [] + window.requestAnimationFrame = (callback) => { + const id = raf(callback) + callbacks.push({ id, callback }) + return id + } + window.cancelAnimationFrame = (id) => { + cancellations.push(id) + cancel(id) + } + const first = new window.VConsole({ defaultPlugins: [] }) + console.log('stale-before-destroy') + first.destroy() + const late = callbacks.slice() + let next + if (recreate) { + await new Promise((resolve) => { + next = new window.VConsole({ defaultPlugins: [], onReady: resolve }) + }) + next.show() + console.log('fresh-after-recreate') + } + // Native frames allow the original queued callback to reproduce the failure. + await new Promise(resolve => raf(() => raf(resolve))) + // A cancelled callback delivered late must also be inert after recreation. + const staleErrors = [] + for (const entry of late) { + if (cancellations.includes(entry.id)) { + try { + entry.callback(performance.now()) + } + catch (error) { + staleErrors.push(String(error)) + } + } + } + const logs = document.querySelector('#__vconsole')?.textContent || '' + window.requestAnimationFrame = raf + window.cancelAnimationFrame = cancel + next?.destroy() + await new Promise(resolve => raf(resolve)) + return { scheduled: callbacks.length, cancelled: cancellations.length, staleErrors, logs, roots: document.querySelectorAll('#__vconsole').length } + }, recreate) + await testInfo.attach('vconsole-lifecycle', { contentType: 'application/json', body: JSON.stringify({ result, errors, browser: browser.version(), artifactSha256: createHash('sha256').update(fs.readFileSync(artifact)).digest('hex') }) }) + expect(result.scheduled).toBeGreaterThan(0) + expect(result.cancelled).toBeGreaterThan(0) + expect(errors).toEqual([]) + expect(result.staleErrors).toEqual([]) + expect(result.roots).toBe(0) + if (recreate) { + expect(result.logs).toContain('fresh-after-recreate') + expect(result.logs).not.toContain('stale-before-destroy') + } + }) +} + +test('vConsole removed plugin panels cannot attach to a replacement with the same id', async ({ page }) => { + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: '' })) + await page.goto('/vconsole-lifecycle.html') + const result = await page.evaluate(async () => { + let instance + await new Promise((resolve) => { + instance = new window.VConsole({ defaultPlugins: [], onReady: resolve }) + }) + const makePlugin = (text) => { + const plugin = new window.VConsole.VConsolePlugin('replacement', 'Replacement') + plugin.on('renderTab', callback => callback(`
${text}
`)) + return plugin + } + instance.addPlugin(makePlugin('stale-panel')) + instance.removePlugin('replacement') + instance.addPlugin(makePlugin('fresh-panel')) + await new Promise(resolve => setTimeout(resolve, 30)) + const panels = Array.from(document.querySelectorAll('.panel-ownership-probe'), element => element.textContent) + instance.destroy() + return panels + }) + expect(result).toEqual(['fresh-panel']) + expect(errors).toEqual([]) +}) + +for (const mode of ['amd', 'commonjs']) { + test(`vConsole retains ${mode} factory and private patch state`, async ({ page }) => { + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + const setup = mode === 'amd' + ? 'window.define=function(name,deps,factory){window.testModuleName=name;window.testConstructor=factory()};window.define.amd=true;' + : 'window.module={exports:{}};window.exports=window.module.exports;' + await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: `` })) + await page.goto('/vconsole-lifecycle.html') + const result = await page.evaluate(async (mode) => { + const Constructor = mode === 'amd' ? window.testConstructor : window.module.exports + let instance + await new Promise((resolve) => { + instance = new Constructor({ defaultPlugins: [], onReady: resolve }) + }) + instance.show() + console.log('factory-contract-log') + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + const logs = document.querySelector('#__vconsole').textContent + instance.destroy() + return { logs, global: typeof window.VConsole, helper: typeof window.__artplayerVConsoleLogLifecycle, roots: document.querySelectorAll('#__vconsole').length } + }, mode) + expect(result.logs).toContain('factory-contract-log') + expect(result.global).toBe('undefined') + expect(result.helper).toBe('undefined') + expect(result.roots).toBe(0) + expect(errors).toEqual([]) + }) +} + +test('vConsole scroller resumes pending layout work safely after destruction', async ({ page }, testInfo) => { + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + await page.route('**/vconsole-lifecycle.html', route => route.fulfill({ contentType: 'text/html', body: '' })) + await page.goto('/vconsole-lifecycle.html') + await page.evaluate(async () => { + await new Promise((resolve) => { + window.scrollerInstance = new window.VConsole({ defaultPlugins: [], onReady: resolve }) + }) + window.scrollerInstance.show() + console.log('scroller-ready') + }) + await expect(page.getByText('scroller-ready', { exact: true })).toBeVisible() + const result = await page.evaluate(async () => { + const instance = window.scrollerInstance + const nativeTimer = window.setTimeout.bind(window) + const pending = [] + window.setTimeout = (callback, delay, ...args) => { + if (delay === 0) { + pending.push(() => callback(...args)) + return 0 + } + return nativeTimer(callback, delay, ...args) + } + document.querySelector('.vc-scroller-viewport').style.height = '137px' + for (let index = 0; index < 20; index++) + console.log(`layout-row-${index}`) + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))) + const rows = document.querySelectorAll('.vc-log-row').length + instance.destroy() + window.setTimeout = nativeTimer + for (const callback of pending) + callback() + await new Promise(resolve => nativeTimer(resolve, 30)) + return { pending: pending.length, rows, roots: document.querySelectorAll('#__vconsole').length } + }) + await testInfo.attach('vconsole-scroller', { contentType: 'application/json', body: JSON.stringify({ result, errors, artifactSha256: createHash('sha256').update(fs.readFileSync(artifact)).digest('hex') }) }) + expect(result.pending).toBeGreaterThan(0) + expect(result.rows).toBeGreaterThan(0) + expect(result.roots).toBe(0) + expect(errors).toEqual([]) +}) diff --git a/test/vconsole-lifecycle.test.js b/test/vconsole-lifecycle.test.js new file mode 100644 index 000000000..03d852f8b --- /dev/null +++ b/test/vconsole-lifecycle.test.js @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +// eslint-disable-next-line test/no-import-node-test -- Exercise scheduling ownership and cleanup failure semantics. +import test from 'node:test' +import { generateVconsole, hooks } from '../scripts/site-vendor/vconsole/build.ts' +import { enqueue, renderTab, unbind } from '../scripts/site-vendor/vconsole/lifecycle.ts' + +function fixture() { + const queued = new Map() + const cancelled = [] + const flushed = [] + let sequence = 0 + let restored = 0 + const host = { + ADDED_LOG_PLUGIN_ID: ['default'], + logQueue: [], + flushLogScheduled: false, + _flushLogs() { + flushed.push(this.logQueue) + this.logQueue = [] + }, + unmockConsole() { restored++ }, + } + const frames = { + requestAnimationFrame(callback) { + queued.set(++sequence, callback) + return sequence + }, + cancelAnimationFrame(id) { cancelled.push(id) }, + } + return { host, frames, queued, cancelled, flushed, restored: () => restored } +} + +test('vConsole batches in original order and releases the scheduling flag before flushing', () => { + const { host, frames, queued, flushed } = fixture() + enqueue(host, 'one', frames) + enqueue(host, 'two', frames) + assert.equal(queued.size, 1) + queued.get(1)() + assert.equal(host.flushLogScheduled, false) + assert.deepEqual(flushed, [['one', 'two']]) + enqueue(host, 'three', frames) + queued.get(2)() + assert.deepEqual(flushed, [['one', 'two'], ['three']]) +}) + +test('vConsole cancelled callbacks cannot flush or consume a new instance queue', () => { + const { host, frames, queued, cancelled, flushed, restored } = fixture() + enqueue(host, 'old', frames) + host.ADDED_LOG_PLUGIN_ID = [] + unbind(host, frames) + enqueue(host, 'captured old logger after destroy', frames) + assert.deepEqual(host.logQueue, []) + assert.deepEqual(cancelled, [1]) + assert.equal(restored(), 1) + host.ADDED_LOG_PLUGIN_ID = ['default'] + enqueue(host, 'new', frames) + queued.get(1)() + assert.equal(host.flushLogScheduled, true) + assert.deepEqual(host.logQueue, ['new']) + queued.get(2)() + assert.deepEqual(flushed, [['new']]) +}) + +test('vConsole releases ownership and restores console even if native cancellation throws', () => { + const { host, frames, queued, restored, flushed } = fixture() + const failure = new Error('cancel failed') + enqueue(host, 'old', frames) + frames.cancelAnimationFrame = () => { + throw failure + } + assert.throws(() => unbind(host, frames), error => error === failure) + assert.equal(restored(), 1) + assert.equal(host.flushLogScheduled, false) + queued.get(1)() + assert.deepEqual(flushed, []) +}) + +test('vConsole allows reentrant logging while a prior batch is being flushed', () => { + const { host, frames, queued } = fixture() + const batches = [] + host._flushLogs = () => { + batches.push(host.logQueue) + host.logQueue = [] + if (batches.length === 1) + enqueue(host, 'reentrant', frames) + } + enqueue(host, 'initial', frames) + queued.get(1)() + queued.get(2)() + assert.deepEqual(batches, [['initial'], ['reentrant']]) +}) + +test('vConsole delayed panels retain their original instance and plugin ownership', async () => { + const plugin = { id: 'custom' } + const host = { isInited: true, pluginList: { custom: plugin } } + const rendered = [] + renderTab(host, plugin, () => rendered.push('removed')) + host.pluginList.custom = { id: 'custom' } + renderTab(host, host.pluginList.custom, () => rendered.push('replacement')) + const destroyed = { isInited: true, pluginList: { custom: plugin } } + renderTab(destroyed, plugin, () => rendered.push('destroyed')) + destroyed.isInited = false + await new Promise(resolve => setTimeout(resolve, 10)) + assert.deepEqual(rendered, ['replacement']) +}) + +test('vConsole generation is deterministic and preserves all bytes outside pinned hooks', async () => { + const root = path.resolve('.') + const candidate = await generateVconsole(root) + assert.equal(candidate, await generateVconsole(root)) + const upstream = fs.readFileSync('scripts/site-vendor/vconsole/upstream.js', 'utf8') + const original = upstream.split(hooks.factory) + assert.equal(original.length, 2) + const suffix = original[1] + .replace(hooks.signal, 'e._signalLog=function(t){__artplayerVConsoleLogLifecycle.enqueue(this,t,window)}') + .replace(hooks.unbind, '0===this.ADDED_LOG_PLUGIN_ID.length&&__artplayerVConsoleLogLifecycle.unbind(this,window)') + .replace(hooks.panel, 'o&&__artplayerVConsoleLogLifecycle.renderTab(e,t,(function(){var e=document.querySelector("#__vc_plug_"+t.id);n.HD(o)?e.innerHTML+=o:n.mf(o.appendTo)?o.appendTo(e):n.kK(o)&&e.insertAdjacentElement("beforeend",o)}))') + .replace(hooks.resizeResume, 'case 9:if(!s)return n.abrupt("return");F(p),X(p,W.getPosition(),k),0!==k&&H(N&&L),K();') + .replace(hooks.itemResume, 'case 12:if(!s)return o.abrupt("return");X(p,W.getPosition(),k),e(6,s.style.height=P+"px",s),K();') + assert(candidate.startsWith(`${original[0]}(this||self,(function(){var __artplayerVConsoleLogLifecycle=`)) + assert(candidate.endsWith(`return function(){${suffix}`)) + assert.equal(candidate, fs.readFileSync('docs/assets/js/vconsole.min.js', 'utf8')) +}) + +test('vConsole generator rejects changed upstream input before producing a candidate', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-vconsole-')) + try { + const directory = path.join(root, 'scripts/site-vendor/vconsole') + fs.mkdirSync(directory, { recursive: true }) + fs.writeFileSync(path.join(directory, 'upstream.js'), 'unexpected source') + await assert.rejects(generateVconsole(root), /Frozen vConsole source changed/) + } + finally { + assert.equal(path.dirname(root), path.resolve(os.tmpdir())) + assert(path.basename(root).startsWith('artplayer-vconsole-')) + fs.rmSync(root, { recursive: true, force: true }) + } +})