'use strict'; /** * Release notes come from a third-party GitHub repository. The extension * update flow displays that Markdown as plain text in its confirmation modal, * so HTML-looking content must never become active DOM. A broken extension * also stays visible as an inert error row without hiding healthy updates. * * node --test test/extensionUpdateModal.test.js */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const ROOT = path.join(__dirname, '..'); const CONTROLS_SOURCE = fs.readFileSync( path.join(ROOT, 'public/js/modules/app-extension-updates.js'), 'utf8', ); function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>'); } function loadControls(extensions = []) { const buttons = new Map(); let overlay; function fakeElement(tagName = 'div') { let text = null; let html = ''; const listeners = {}; return { tagName: tagName.toUpperCase(), className: '', children: [], style: {}, addEventListener(type, handler) { listeners[type] = handler; }, click() { return listeners.click(); }, appendChild(element) { this.children.push(element); }, replaceChildren() { this.children = []; }, get childElementCount() { return this.children.length; }, set textContent(value) { text = String(value); }, get textContent() { return text || ''; }, set innerHTML(value) { text = null; html = String(value); }, get innerHTML() { return text === null ? html : escapeHtml(text); }, querySelectorAll(selector) { const matches = []; for (const child of this.children) { if (selector.startsWith('.') ? child.className.split(' ').includes(selector.slice(1)) : child.tagName === selector.toUpperCase()) matches.push(child); matches.push(...child.querySelectorAll(selector)); } return matches; }, querySelector(selector) { if (!buttons.has(selector)) { const listeners = {}; buttons.set(selector, { addEventListener(type, handler) { listeners[type] = handler; }, click() { listeners.click(); }, focus() {}, }); } return buttons.get(selector); }, remove() {}, }; } const elements = new Map([ ['extension-update-check', fakeElement('button')], ['extension-update-status', fakeElement()], ['extension-update-results', fakeElement()], ]); const document = { body: { appendChild(element) { overlay = element; } }, createElement: tagName => fakeElement(tagName), getElementById: id => elements.get(id), addEventListener() {}, removeEventListener() {}, }; const context = vm.createContext({ module: { exports: {} }, exports: {}, document, fetch: async () => ({ ok: true, json: async () => ({ extensions }) }), setTimeout: handler => { handler(); return 1; }, t: key => ({ 'modals.common.cancel': 'Cancel', 'modals.common.confirm': 'Confirm', 'modals.common.warning': 'Warning', }[key] || key), }); vm.runInContext( CONTROLS_SOURCE.replace(/^export default/m, 'module.exports ='), context, { filename: 'app-extension-updates.js' }, ); const app = Object.create(context.module.exports); app._escapeHtml = value => escapeHtml(value).replace(/"/g, '"').replace(/'/g, '''); return { app, buttons, elements, getOverlay: () => overlay }; } test('GitHub release notes are inert text in the update confirmation', async () => { const { app, buttons, getOverlay } = loadControls(); const notes = '# Changes\n\n'; const result = app._showExtensionUpdateConfirm( 'Update extension', notes, 'Only install code you trust.', 'I understand, update', ); const html = getOverlay().innerHTML; assert.doesNotMatch(html, / { const brokenFile = '.plugin.js'; const error = 'Invalid @version '; const { app, elements } = loadControls([ { type: 'plugin', file: brokenFile, error, offers: [] }, { type: 'theme', file: 'Healthy.theme.css', version: '1.0.0', repo: 'owner/healthy', offers: [{ action: 'install', version: '1.1.0', releaseUrl: 'https://github.com/owner/healthy/releases/tag/v1.1.0', token: 'offer' }], }, ]); app._setupExtensionUpdates(); await elements.get('extension-update-check').click(); const cards = elements.get('extension-update-results').children; assert.equal(cards.length, 2); const broken = cards[0]; const name = broken.querySelectorAll('.plugin-card-name')[0]; assert.equal(name.textContent, brokenFile); assert.equal(name.innerHTML, escapeHtml(brokenFile)); const descriptions = broken.querySelectorAll('.plugin-card-desc'); assert.equal(descriptions.length, 1, 'missing repository does not produce an empty description'); assert.equal(descriptions[0].textContent, error); assert.equal(descriptions[0].innerHTML, escapeHtml(error)); assert.equal(broken.querySelectorAll('button').length, 0); assert.equal(broken.querySelectorAll('a').length, 0); const healthy = cards[1]; assert.equal(healthy.querySelectorAll('.plugin-card-name')[0].textContent, 'Healthy.theme.css ยท 1.0.0'); assert.equal(healthy.querySelectorAll('.plugin-card-desc')[0].textContent, 'owner/healthy'); assert.equal(healthy.querySelectorAll('button').length, 1); assert.equal(healthy.querySelectorAll('a').length, 1); assert.equal(elements.get('extension-update-check').disabled, false); });