'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const test = require('node:test'); const { MiniDocument, MiniElement } = require('../helpers/mini-dom.cjs'); const root = path.resolve(__dirname, '..', '..'); class ReportElement extends MiniElement { constructor(tagName) { super(tagName); this._value = null; this.files = []; } get value() { return this._value ?? this.getAttribute('value') ?? ''; } set value(value) { this._value = String(value ?? ''); } get name() { return this.getAttribute('name') || ''; } get type() { return this.getAttribute('type') || ''; } get checked() { return this.hasAttribute('checked'); } set checked(value) { value ? this.setAttribute('checked', '') : this.removeAttribute('checked'); } get disabled() { return this.hasAttribute('disabled'); } set disabled(value) { value ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); } get hidden() { return this.hasAttribute('hidden'); } set hidden(value) { value ? this.setAttribute('hidden', '') : this.removeAttribute('hidden'); } focus() { this.ownerDocument.activeElement = this; } click() { this.dispatch('click'); } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector) { const groups = String(selector).split(','); return [...new Set(groups.flatMap((group) => { const checked = /:checked\b/.test(group); const disabled = /:disabled\b/.test(group); const plainSelector = group.replace(/:(?:checked|disabled)\b/g, '').trim(); return super.querySelectorAll(plainSelector).filter((element) => (!checked || element.checked) && (!disabled || element.disabled)); }))]; } } class ReportDocument extends MiniDocument { constructor() { super(); this.documentElement = this; this.activeElement = null; } createElement(tagName) { const element = new ReportElement(tagName); element.ownerDocument = this; return element; } } class MiniFormData { constructor() { this.values = []; } append(name, value) { this.values.push([name, value]); } get(name) { return this.values.find(([key]) => key === name)?.[1] ?? null; } } function createRuntime({ imgEnabled = true, categories = [ { name: '垃圾信息' }, { name: '人身攻击' }, ] } = {}) { const document = new ReportDocument(); const modals = []; const fetchCalls = []; const notyfCalls = []; const closeCalls = []; let activeModal = null; let autoInit = null; function fetch(url, options = {}) { let resolveRequest; let rejectRequest; const promise = new Promise((resolve, reject) => { resolveRequest = resolve; rejectRequest = reject; }); const call = { url: String(url), options, resolve(data) { resolveRequest({ json: () => Promise.resolve(data) }); }, reject(error) { rejectRequest(error); }, }; fetchCalls.push(call); if (options.method === 'DELETE') call.resolve({ success: true }); return promise; } const modalRuntime = { open(options) { const shell = document.createElement('div'); shell.className = options.className; shell.innerHTML = options.html; document.body.appendChild(shell); activeModal = shell; modals.push({ shell, options }); return shell; }, getActive() { return activeModal; }, close(reason, shell = activeModal) { closeCalls.push({ reason, shell }); if (!shell) return; if (activeModal === shell) activeModal = null; shell.remove(); document.dispatch('aksrt:modal:close', { detail: { shell } }); }, }; const window = { aksrtData: { apiUrl: '/wp-json/aksrt/v1', restNonce: 'test-nonce', reportCategories: categories, report: { imgEnabled }, }, AksrtModalRuntime: modalRuntime, fetch, }; const jquery = () => ({ off() { return this; }, on(event, callback) { if (event === 'auto_fun.aksrt-report') autoInit = callback; return this; }, }); vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/js/report.js'), 'utf8'), { window, document, jQuery: jquery, FormData: MiniFormData, Promise, console, notyf: (...args) => notyfCalls.push(args), }); assert.equal(typeof autoInit, 'function'); autoInit(); return { document, window, modalRuntime, modals, fetchCalls, notyfCalls, closeCalls, get activeModal() { return activeModal; }, openReport(target = { target_type: 'post', target_id: 42, target_name: 'Article' }) { const opener = document.createElement('button'); opener.setAttribute('data-aksrt-report', JSON.stringify(target)); document.body.appendChild(opener); const event = opener.dispatch('click'); return { ...modals.at(-1), opener, event }; }, }; } function selectReason(modal, value) { const radios = modal.shell.querySelectorAll('[name="report_reason"]'); const selected = radios.find((radio) => radio.value === String(value)); assert.ok(selected, `missing reason ${value}`); radios.forEach((radio) => { radio.checked = radio === selected; }); selected.dispatch('change'); return selected; } function submit(modal) { return modal.shell.querySelector('.aksrt-report-form').dispatch('submit'); } function callsFor(runtime, endpoint) { return runtime.fetchCalls.filter((call) => call.url.includes(endpoint)); } function payloadFor(call) { assert.equal(typeof call.options.body, 'string'); return JSON.parse(call.options.body); } async function flushPromises() { await new Promise((resolve) => setImmediate(resolve)); } test('opens from an article button, associates the target, omits URL input, and escapes rendered names', () => { const runtime = createRuntime({ categories: [{ name: 'reason' }] }); const targetName = ' &'; const modal = runtime.openReport({ target_type: 'post', target_id: 73, target_name: targetName }); const target = modal.shell.querySelector('.aksrt-report-target__name'); const reason = modal.shell.querySelector('.aksrt-report-reason span'); assert.equal(modal.event.defaultPrevented, true); assert.equal(modal.options.title, '举报文章'); assert.equal(modal.options.focusReturn, modal.opener); assert.equal(runtime.activeModal, modal.shell); assert.ok(target.textContent.includes('<img')); assert.equal(target.querySelector('img'), null); assert.equal(reason.querySelector('svg'), null); assert.equal(modal.shell.querySelector('[name="report_url"]'), null); assert.equal(modal.shell.querySelector('input[type="url"]'), null); assert.ok(modal.shell.querySelector('.aksrt-report-target')); assert.equal(modal.shell.querySelector('.aksrt-report-target__label').textContent, '关联对象:'); assert.equal(modal.shell.querySelector('.aksrt-report-target__icon'), null); assert.equal(target.getAttribute('title'), target.textContent); }); test('requires a radio reason and other text, then sends canonical fields with description', async () => { const runtime = createRuntime(); const modal = runtime.openReport(); const form = modal.shell.querySelector('.aksrt-report-form'); const reasonError = form.querySelector('[data-aksrt-report-reason-error]'); const otherBox = form.querySelector('.aksrt-report-other'); const otherInput = form.querySelector('[name="reason_other"]'); const description = form.querySelector('[name="report_desc"]'); submit(modal); assert.equal(reasonError.hidden, false); assert.equal(reasonError.textContent, '请选择一项举报原因'); assert.equal(runtime.document.activeElement, form.querySelector('[name="report_reason"]')); assert.equal(callsFor(runtime, '/report/submit').length, 0); selectReason(modal, 'other'); assert.equal(otherBox.hidden, false); assert.equal(otherInput.disabled, false); submit(modal); assert.equal(reasonError.textContent, '请填写举报原因'); assert.equal(otherInput.getAttribute('aria-invalid'), 'true'); assert.equal(runtime.document.activeElement, otherInput); assert.equal(callsFor(runtime, '/report/submit').length, 0); otherInput.value = '自定义原因'; otherInput.dispatch('input'); description.value = '补充背景'; description.dispatch('input'); submit(modal); const call = callsFor(runtime, '/report/submit')[0]; const payload = payloadFor(call); assert.deepEqual(Object.keys(payload).sort(), ['category', 'reason', 'target_id', 'target_type']); assert.deepEqual(payload, { target_type: 'post', target_id: 42, category: '其他原因', reason: '自定义原因\n\n补充背景', }); call.resolve({ success: true, message: '已提交' }); await flushPromises(); }); test('preset reason ignores hidden custom text while including description in reason', () => { const runtime = createRuntime(); const modal = runtime.openReport(); const otherInput = modal.shell.querySelector('[name="reason_other"]'); const description = modal.shell.querySelector('[name="report_desc"]'); otherInput.value = 'hidden custom text'; description.value = 'More context'; selectReason(modal, 1); assert.equal(modal.shell.querySelector('.aksrt-report-other').hidden, true); assert.equal(otherInput.disabled, true); submit(modal); const payload = payloadFor(callsFor(runtime, '/report/submit')[0]); assert.deepEqual(payload, { target_type: 'post', target_id: 42, category: '人身攻击', reason: 'More context', }); }); test('allows a preset-only report without copying the category into content', async () => { const runtime = createRuntime(); const modal = runtime.openReport(); selectReason(modal, 0); submit(modal); const request = callsFor(runtime, '/report/submit')[0]; assert.deepEqual(payloadFor(request), { target_type: 'post', target_id: 42, category: '垃圾信息', reason: '', }); request.resolve({ success: true }); await flushPromises(); }); test('keeps a single other option and preserves explicitly entered category words in content', () => { const runtime = createRuntime({ categories: [{ name: '其他原因' }, { name: '垃圾信息' }] }); const modal = runtime.openReport(); const radios = modal.shell.querySelectorAll('[name="report_reason"]'); assert.equal(radios.length, 2); selectReason(modal, 0); modal.shell.querySelector('[name="report_desc"]').value = '垃圾信息中包含了违规内容'; submit(modal); assert.deepEqual(payloadFor(callsFor(runtime, '/report/submit')[0]), { target_type: 'post', target_id: 42, category: '垃圾信息', reason: '垃圾信息中包含了违规内容', }); }); test('omits all image controls when image evidence is disabled', () => { const runtime = createRuntime({ imgEnabled: false }); const modal = runtime.openReport(); assert.equal(modal.shell.querySelector('[data-aksrt-report-file]'), null); assert.equal(modal.shell.querySelector('[data-aksrt-report-add]'), null); assert.equal(modal.shell.querySelector('[data-aksrt-report-images]'), null); assert.equal(modal.shell.querySelector('[data-aksrt-report-image-count]'), null); }); test('counts in-flight images once, accepts exactly three, and blocks submit until ready', async () => { const runtime = createRuntime(); const modal = runtime.openReport(); selectReason(modal, 0); const fileInput = modal.shell.querySelector('[data-aksrt-report-file]'); fileInput.files = [1, 2, 3].map((index) => ({ name: `evidence-${index}.png`, type: 'image/png' })); fileInput.dispatch('change'); assert.equal(callsFor(runtime, '/report/upload-image').length, 3); assert.equal(modal.shell.querySelector('[data-aksrt-report-image-count]').textContent, '3 / 3'); assert.equal(modal.shell.querySelector('[data-aksrt-report-submit]').disabled, true); assert.equal(modal.shell.querySelector('[data-aksrt-report-status]').textContent, '图片正在上传,完成后即可提交'); submit(modal); assert.equal(callsFor(runtime, '/report/submit').length, 0); callsFor(runtime, '/report/upload-image').forEach((call, index) => call.resolve({ success: true, data: { id: 800 + index, url: `https://evidence.test/${index + 1}.png` }, })); await flushPromises(); assert.equal(modal.shell.querySelectorAll('.aksrt-report-image').length, 3); assert.equal(modal.shell.querySelector('[data-aksrt-report-image-count]').textContent, '3 / 3'); assert.equal(modal.shell.querySelector('[data-aksrt-report-add]').disabled, true); fileInput.files = [{ name: 'fourth.png', type: 'image/png' }]; fileInput.dispatch('change'); assert.equal(callsFor(runtime, '/report/upload-image').length, 3); submit(modal); const call = callsFor(runtime, '/report/submit')[0]; const payload = payloadFor(call); for (const index of [1, 2, 3]) assert.ok(payload.reason.includes(`https://evidence.test/${index}.png`)); call.resolve({ success: true }); await flushPromises(); }); test('retries a failed upload and deletes its pending attachment when removed', async () => { const runtime = createRuntime(); const modal = runtime.openReport(); const fileInput = modal.shell.querySelector('[data-aksrt-report-file]'); fileInput.files = [{ name: 'retry.png', type: 'image/png' }]; fileInput.dispatch('change'); callsFor(runtime, '/report/upload-image')[0].resolve({ success: false, message: 'Upload failed' }); await flushPromises(); const image = modal.shell.querySelector('.aksrt-report-image'); assert.equal(image.getAttribute('data-upload-state'), 'error'); assert.match(modal.shell.querySelector('[data-aksrt-report-error]').textContent, /Upload failed/); image.querySelector('[data-aksrt-report-retry]').dispatch('click'); const retry = callsFor(runtime, '/report/upload-image')[1]; assert.ok(retry); retry.resolve({ success: true, data: { id: 901, url: 'https://evidence.test/retry.png' } }); await flushPromises(); assert.equal(image.getAttribute('data-upload-state'), 'ready'); image.querySelector('[data-aksrt-report-remove]').dispatch('click'); const deletion = callsFor(runtime, '/upload/pending/901')[0]; assert.ok(deletion); assert.equal(deletion.options.method, 'DELETE'); assert.equal(modal.shell.querySelectorAll('.aksrt-report-image').length, 0); assert.equal(modal.shell.querySelector('[data-aksrt-report-image-count]').textContent, '0 / 3'); }); test('server failure preserves values, reenables controls, and allows a retry', async () => { const runtime = createRuntime(); const modal = runtime.openReport(); selectReason(modal, 'other'); const form = modal.shell.querySelector('.aksrt-report-form'); const otherInput = form.querySelector('[name="reason_other"]'); const description = form.querySelector('[name="report_desc"]'); const submitButton = form.querySelector('[data-aksrt-report-submit]'); otherInput.value = 'A custom report reason'; description.value = 'Additional context'; submit(modal); const failedRequest = callsFor(runtime, '/report/submit')[0]; assert.equal(submitButton.disabled, true); assert.equal(otherInput.disabled, true); failedRequest.resolve({ success: false, message: 'Server rejected report' }); await flushPromises(); assert.equal(otherInput.value, 'A custom report reason'); assert.equal(description.value, 'Additional context'); assert.equal(form.querySelector('[name="report_reason"]:checked').value, 'other'); assert.equal(otherInput.disabled, false); assert.equal(description.disabled, false); assert.equal(submitButton.disabled, false); assert.equal(form.querySelector('[data-aksrt-report-error]').hidden, true); assert.deepEqual(runtime.notyfCalls.at(-1).slice(0, 3), ['Server rejected report', 'danger', 5]); submit(modal); const retry = callsFor(runtime, '/report/submit')[1]; assert.equal(payloadFor(retry).reason, 'A custom report reason\n\nAdditional context'); retry.resolve({ success: true }); await flushPromises(); }); test('uses a persistent shared toast per submit attempt without inline submitting text', async () => { const runtime = createRuntime(); const modal = runtime.openReport(); selectReason(modal, 0); const form = modal.shell.querySelector('.aksrt-report-form'); const button = form.querySelector('[data-aksrt-report-submit]'); const status = form.querySelector('[data-aksrt-report-status]'); submit(modal); const firstToast = runtime.notyfCalls.at(-1); assert.deepEqual(firstToast.slice(0, 3), ['正在提交举报…', 'load', '']); assert.ok(firstToast[3].startsWith(form.id + '-submit-')); assert.equal(button.textContent, '提交举报'); assert.equal(button.disabled, true); assert.equal(status.textContent, ''); assert.equal(form.getAttribute('aria-busy'), 'true'); submit(modal); assert.equal(runtime.notyfCalls.length, 1); callsFor(runtime, '/report/submit')[0].resolve({ success: false, message: 'Retry report' }); await flushPromises(); assert.deepEqual(runtime.notyfCalls.at(-1), ['Retry report', 'danger', 5, firstToast[3]]); assert.equal(button.disabled, false); assert.equal(status.textContent, ''); submit(modal); const secondToast = runtime.notyfCalls.at(-1); assert.equal(secondToast[1], 'load'); assert.notEqual(secondToast[3], firstToast[3]); callsFor(runtime, '/report/submit')[1].resolve({ success: true, message: 'Report accepted' }); await flushPromises(); assert.deepEqual(runtime.notyfCalls.at(-1), ['Report accepted', 'success', 5, secondToast[3]]); }); test('blocks duplicate submits and closing an old submitting dialog cannot close the new one', async () => { const runtime = createRuntime(); const oldModal = runtime.openReport(); selectReason(oldModal, 0); submit(oldModal); submit(oldModal); assert.equal(callsFor(runtime, '/report/submit').length, 1); const newModal = runtime.openReport({ target_type: 'comment', target_id: 88, target_name: 'Comment' }); runtime.modalRuntime.close('dismiss', oldModal.shell); callsFor(runtime, '/report/submit')[0].resolve({ success: true }); await flushPromises(); assert.equal(runtime.activeModal, newModal.shell); assert.equal(runtime.closeCalls.length, 1); assert.equal(runtime.closeCalls[0].shell, oldModal.shell); }); test('cleans pending attachments on close and when an upload completes after close', async () => { const runtime = createRuntime(); const readyModal = runtime.openReport(); const readyFile = readyModal.shell.querySelector('[data-aksrt-report-file]'); readyFile.files = [{ name: 'ready.png', type: 'image/png' }]; readyFile.dispatch('change'); callsFor(runtime, '/report/upload-image')[0].resolve({ success: true, data: { id: 1001, url: 'https://evidence.test/ready.png' }, }); await flushPromises(); runtime.modalRuntime.close('cancel', readyModal.shell); const readyDeletion = callsFor(runtime, '/upload/pending/1001')[0]; assert.ok(readyDeletion); assert.equal(readyDeletion.options.method, 'DELETE'); const pendingModal = runtime.openReport(); const pendingFile = pendingModal.shell.querySelector('[data-aksrt-report-file]'); pendingFile.files = [{ name: 'pending.png', type: 'image/png' }]; pendingFile.dispatch('change'); const pendingUpload = callsFor(runtime, '/report/upload-image')[1]; runtime.modalRuntime.close('cancel', pendingModal.shell); assert.equal(callsFor(runtime, '/upload/pending/1002').length, 0); pendingUpload.resolve({ success: true, data: { id: 1002, url: 'https://evidence.test/pending.png' }, }); await flushPromises(); const lateDeletion = callsFor(runtime, '/upload/pending/1002')[0]; assert.ok(lateDeletion); assert.equal(lateDeletion.options.method, 'DELETE'); }); test('keeps a live child modal open and marks the finished report after success', async () => { const runtime = createRuntime(); const report = runtime.openReport(); selectReason(report, 0); submit(report); const request = callsFor(runtime, '/report/submit')[0]; const child = runtime.modalRuntime.open({ className: 'aksrt-child-modal', html: '

Child dialog

' }); request.resolve({ success: true }); await flushPromises(); const form = report.shell.querySelector('.aksrt-report-form'); assert.equal(runtime.activeModal, child); assert.equal(runtime.closeCalls.length, 0); assert.equal(form.getAttribute('aria-busy'), 'false'); assert.equal(form.querySelector('[data-aksrt-report-submit]').textContent, '举报已提交'); assert.ok(report.shell.querySelector('[data-aksrt-report-status]')); }); test('retains claimed evidence on post-close success and deletes it on post-close failure', async () => { const runtime = createRuntime(); async function submitClosedReport(id, result) { const modal = runtime.openReport(); selectReason(modal, 0); const fileInput = modal.shell.querySelector('[data-aksrt-report-file]'); fileInput.files = [{ name: `${id}.png`, type: 'image/png' }]; fileInput.dispatch('change'); callsFor(runtime, '/report/upload-image').at(-1).resolve({ success: true, data: { id, url: `https://evidence.test/${id}.png` }, }); await flushPromises(); submit(modal); const request = callsFor(runtime, '/report/submit').at(-1); runtime.modalRuntime.close('dismiss', modal.shell); request.resolve(result); await flushPromises(); } await submitClosedReport(1101, { success: true }); assert.equal(callsFor(runtime, '/upload/pending/1101').length, 0); await submitClosedReport(1102, { success: false, message: 'Rejected' }); const deletion = callsFor(runtime, '/upload/pending/1102')[0]; assert.ok(deletion); assert.equal(deletion.options.method, 'DELETE'); });