'use strict'; 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 scriptPath = path.resolve(__dirname, '../../assets/js/admin-tickets.js'); const strings = { saving: 'Saving', saveFailed: 'Save failed', uncertain: 'Unknown result', required: 'Required', tooLong: 'Too long', discard: 'Discard?' }; const REST = 'https://site.test/wp-json/aksrt/v1/admin/tickets'; function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=41', page = 'detail', storage = {} } = {}) { const ids = new Map(); const document = { activeElement: null }; class Element { constructor(tag, attrs = {}) { this.tagName = tag.toUpperCase(); this.attrs = { ...attrs }; this.dataset = {}; for (const [name, value] of Object.entries(attrs)) { const match = name.match(/^data-(.+)$/); if (match) this.dataset[match[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = String(value); } this.children = []; this.parentElement = null; this.listeners = {}; this.id = attrs.id || ''; this.name = attrs.name || ''; this.type = attrs.type || (this.tagName === 'TEXTAREA' ? 'textarea' : ''); this.action = attrs.action || ''; this.required = Object.hasOwn(attrs, 'required'); this.hidden = Object.hasOwn(attrs, 'hidden'); this.maxLength = attrs.maxlength === undefined ? -1 : Number(attrs.maxlength); this.disabled = false; this.checked = false; this.validity = ''; this.textContent = ''; this.selectionStart = 0; this.selectionEnd = 0; this.scrollTop = 0; this.scrollHeight = 100; this.clientHeight = 40; this._value = String(attrs.value ?? ''); this.selectedIndex = 0; const classes = new Set(String(attrs.class || '').split(/\s+/).filter(Boolean)); this.classList = { contains: (name) => classes.has(name), toggle: (name, on) => { if (on) classes.add(name); else classes.delete(name); }, }; if (this.id) ids.set(this.id, this); } get options() { return this.children.filter((child) => child.tagName === 'OPTION'); } get value() { return this.tagName === 'SELECT' ? this.options[this.selectedIndex]?.value ?? '' : this._value; } set value(value) { this._value = String(value); if (this.tagName === 'SELECT') this.selectedIndex = this.options.findIndex((option) => option.value === this._value); } get form() { return this.closest('form'); } append(child) { child.parentElement = this; this.children.push(child); return child; } addEventListener(type, callback) { (this.listeners[type] ||= []).push(callback); } fire(type, values = {}) { const event = { target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...values }; (this.listeners[type] || []).forEach((callback) => callback(event)); return event; } querySelectorAll(selector) { const parts = expandIs(selector).split(',').map((part) => part.trim()).filter(Boolean); const found = new Set(); const visit = (node) => node.children.forEach((child) => { if (parts.some((part) => matches(child, part))) found.add(child); visit(child); }); visit(this); return [...found]; } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } closest(selector) { for (let node = this; node; node = node.parentElement) if (node.matches(selector)) return node; return null; } matches(selector) { return expandIs(selector).split(',').some((part) => matches(this, part.trim())); } getAttribute(name) { return this.attrs[name] ?? null; } setAttribute(name, value) { this.attrs[name] = String(value); } focus() { document.activeElement = this; } setCustomValidity(message) { this.validity = message; } setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; } reportValidity() { return this.querySelectorAll('input, select, textarea').every((control) => { if (control.validity) return false; if (!control.required) return true; return control.type === 'checkbox' ? control.checked : control.value.trim() !== ''; }); } } // 把 `A :is(x, y), B` 展开成 `A x, B, A y, B`;结果去重。 function expandIs(selector) { const match = selector.match(/^(.*?):is\(([^)]*)\)(.*)$/); return match ? match[2].split(',').map((part) => match[1] + part.trim() + match[3]).join(', ') : selector; } function matches(element, selector) { const parts = selector.split(/\s+/); if (!matchesSimple(element, parts.pop())) return false; let ancestor = element.parentElement; while (parts.length) { const part = parts.pop(); while (ancestor && !matchesSimple(ancestor, part)) ancestor = ancestor.parentElement; if (!ancestor) return false; ancestor = ancestor.parentElement; } return true; } function matchesSimple(element, selector) { const tag = selector.match(/^[a-z][\w-]*/i)?.[0]; if (tag && element.tagName !== tag.toUpperCase()) return false; for (const [, className] of selector.matchAll(/\.([\w-]+)/g)) if (!element.classList.contains(className)) return false; for (const [, name, expected] of selector.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g)) { if (!Object.hasOwn(element.attrs, name)) return false; if (expected !== undefined && String(element.attrs[name]) !== expected) return false; } return true; } const el = (tag, attrs, parent) => parent.append(new Element(tag, attrs)); const select = (parent, attrs, choices, selected = '') => { const node = el('select', attrs, parent); choices.forEach(([value, extra = {}]) => el('option', { value, ...extra }, node)); node.value = selected; return node; }; function makeDialog(id) { const dialog = el('dialog', { id, class: 'aksrt-ticket-dialog' }, root); dialog.open = false; dialog.showModal = () => { dialog.open = true; }; dialog.close = () => { if (!dialog.open) return; dialog.open = false; dialog.fire('close'); }; return dialog; } const root = new Element('div', { class: 'aksrt-tickets-admin' }); const feedback = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, root); el('p', {}, feedback); el('button', { type: 'button', 'data-aksrt-ticket-reload': '', hidden: '' }, feedback); const nodes = { root, feedback }; if (page === 'detail') { const detail = el('div', { 'data-aksrt-ticket-detail': '41' }, root); nodes.latest = el('button', { type: 'button', 'data-aksrt-ticket-latest': '', hidden: '' }, detail); nodes.conversation = el('div', { class: 'aksrt-ticket-conversation__scroll' }, detail); const reply = nodes.reply = el('form', { action: `${REST}/41/reply`, 'data-aksrt-ticket-form': 'reply' }, detail); nodes.replyText = el('textarea', { id: 'aksrt-ticket-reply-41', name: 'content', required: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, reply); nodes.replyCount = el('span', { 'data-aksrt-ticket-counter': 'aksrt-ticket-reply-41' }, reply); nodes.prereply = select(reply, { 'data-aksrt-ticket-prereply': 'aksrt-ticket-reply-41' }, [[''], ['short'], ['01234567890123456789012345']]); el('button', { type: 'submit' }, reply); const section = el('section', {}, detail); nodes.assign = el('button', { type: 'button', 'data-aksrt-ticket-assign': '' }, section); const status = nodes.status = el('form', { action: `${REST}/41/status`, 'data-aksrt-ticket-form': 'status' }, section); nodes.statusSelect = select(status, { name: 'status', 'data-aksrt-ticket-status': '' }, [['pending', { 'data-closed': '0' }], ['handling', { 'data-closed': '0' }], ['closed', { 'data-closed': '1' }]], 'pending'); select(status, { name: 'ticket_type_id', required: '' }, [['3'], ['6']], '3'); nodes.admin = select(status, { name: 'admin_id' }, [['0'], ['7'], ['44']], '7'); nodes.warning = el('div', { 'data-aksrt-ticket-close-warning': '', hidden: '' }, status); nodes.confirm = el('input', { type: 'checkbox', 'data-aksrt-ticket-close-confirm': '' }, nodes.warning); nodes.statusSubmit = el('button', { type: 'submit' }, status); nodes.noteOpener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-note-dialog' }, detail); const noteDialog = nodes.noteDialog = makeDialog('aksrt-ticket-note-dialog'); const note = nodes.note = el('form', { action: `${REST}/41/notes`, 'data-aksrt-ticket-form': 'note' }, noteDialog); nodes.noteClose = el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, note); nodes.noteFeedback = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, note); el('p', {}, nodes.noteFeedback); el('button', { type: 'button', 'data-aksrt-ticket-reload': '', hidden: '' }, nodes.noteFeedback); nodes.noteText = el('textarea', { id: 'aksrt-ticket-note-41', name: 'content', required: '', autofocus: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, note); el('button', { type: 'submit' }, note); note.reset = () => { nodes.noteText.value = ''; }; nodes.replyText.attrs['data-aksrt-ticket-send-shortcut'] = ''; reply.requestSubmit = () => reply.fire('submit') && root.fire('submit', { target: reply }); } else { nodes.opener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-create-dialog' }, root); const dialog = nodes.dialog = makeDialog('aksrt-ticket-create-dialog'); const create = nodes.create = el('form', { action: REST, 'data-aksrt-ticket-form': 'create' }, dialog); nodes.closeButton = el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, create); el('input', { type: 'number', name: 'user_id', required: '' }, create); select(create, { name: 'ticket_type_id', required: '' }, [[''], ['3']], ''); el('input', { type: 'text', name: 'title', required: '' }, create); el('textarea', { id: 'aksrt-ticket-create-content', name: 'content', required: '', maxlength: '500', 'data-aksrt-ticket-editor': '' }, create); el('button', { type: 'submit' }, create); create.reset = () => create.querySelectorAll('input, select, textarea').forEach((control) => { control.value = ''; }); } document.querySelector = (selector) => (selector === '.aksrt-tickets-admin' ? root : null); document.getElementById = (id) => ids.get(id) || null; const store = new Map(Object.entries(storage)); const window = new Element('window'); Object.assign(window, { AKSRTAdminTickets: { restNonce: 'rest-nonce', currentUser: 44, strings }, location: { href, assigned: null, reloads: 0, assign(url) { this.assigned = url; }, reload() { this.reloads += 1; } }, history: { state: null, replaceState(_state, _title, url) { window.location.href = url; } }, sessionStorage: { getItem: (key) => store.get(key) ?? null, setItem: (key, value) => store.set(key, String(value)), removeItem: (key) => store.delete(key) }, confirmAnswer: false, confirmCalls: [], confirm(message) { this.confirmCalls.push(message); return this.confirmAnswer; }, }); const fetchCalls = []; const fetch = (url, options) => new Promise((resolve, reject) => fetchCalls.push({ url, options, resolve, reject })); vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), { document, window, fetch, URL, AbortController, setTimeout, clearTimeout }, { filename: scriptPath }); return { ...nodes, document, window, store, fetchCalls }; } const reply = (data, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => data }); const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); const type = (rt, field, value) => { field.value = value; rt.root.fire('input', { target: field }); }; const submit = (rt, form) => rt.root.fire('submit', { target: form }); const unload = (rt) => rt.window.fire('beforeunload', { returnValue: undefined }); test('a successful reply posts JSON to REST, then reloads the workspace with a result code and stashes other drafts', async () => { const rt = makeRuntime({ href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=41&status=pending&paged=3' }); type(rt, rt.noteText, 'pending note'); type(rt, rt.replyText, 'hello'); const event = submit(rt, rt.reply); assert.equal(event.defaultPrevented, true); assert.equal(rt.fetchCalls.length, 1); const call = rt.fetchCalls[0]; assert.equal(call.url, `${REST}/41/reply`); assert.equal(call.options.method, 'POST'); assert.equal(call.options.headers['X-WP-Nonce'], 'rest-nonce'); assert.deepEqual(JSON.parse(call.options.body), { content: 'hello' }); assert.equal(rt.replyText.disabled, true, 'form locked while saving'); submit(rt, rt.reply); assert.equal(rt.fetchCalls.length, 1, 'duplicate submit ignored'); call.resolve(reply({ success: true, data: { reply_id: 9 } })); await tick(); const next = new URL(rt.window.location.assigned); assert.equal(next.searchParams.get('id'), '41'); assert.equal(next.searchParams.get('aksrt_ticket_msg'), 'reply'); assert.equal(next.searchParams.get('status'), 'pending'); assert.equal(next.searchParams.get('paged'), '3'); assert.deepEqual(JSON.parse(rt.store.get('aksrt-ticket-drafts-41')), { note: { content: 'pending note' } }); assert.equal(unload(rt).defaultPrevented, false, 'navigation after save is not blocked'); }); test('after the reload, stashed drafts are restored once and the result code is stripped from the URL', () => { const rt = makeRuntime({ href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=41&aksrt_ticket_msg=reply', storage: { 'aksrt-ticket-drafts-41': JSON.stringify({ note: { content: 'keep me' } }) }, }); assert.equal(rt.noteText.value, 'keep me'); assert.equal(rt.store.has('aksrt-ticket-drafts-41'), false); assert.equal(new URL(rt.window.location.href).searchParams.has('aksrt_ticket_msg'), false); assert.equal(new URL(rt.window.location.href).searchParams.get('id'), '41'); assert.equal(unload(rt).defaultPrevented, true, 'a restored draft still counts as unsaved'); }); test('create dialog opens from the header button, guards dirty close, resets and returns focus', () => { const rt = makeRuntime({ page: 'create', href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets' }); rt.opener.isConnected = true; rt.root.fire('click', { target: rt.opener }); assert.equal(rt.dialog.open, true); const title = rt.create.querySelector('[name="title"]'); type(rt, title, 'Unsaved'); rt.root.fire('click', { target: rt.closeButton }); assert.equal(rt.dialog.open, true, 'declined discard keeps the dialog open'); assert.deepEqual(rt.window.confirmCalls, [strings.discard]); const esc = rt.dialog.fire('cancel'); assert.equal(esc.defaultPrevented, true, 'Esc goes through the same guard'); assert.equal(rt.dialog.open, true); rt.window.confirmAnswer = true; rt.root.fire('click', { target: rt.closeButton }); assert.equal(rt.dialog.open, false); assert.equal(title.value, '', 'closing resets the form'); assert.equal(rt.document.activeElement, rt.opener); rt.window.confirmCalls.length = 0; rt.root.fire('click', { target: rt.opener }); rt.root.fire('click', { target: rt.closeButton }); assert.equal(rt.dialog.open, false, 'a clean dialog closes without asking'); assert.equal(rt.window.confirmCalls.length, 0); }); test('create posts to the collection route and opens the new ticket workspace with the list context kept', async () => { const rt = makeRuntime({ page: 'create', href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&status=pending&paged=2' }); rt.root.fire('click', { target: rt.opener }); const form = rt.create; form.querySelector('[name="user_id"]').value = '12'; form.querySelector('[name="ticket_type_id"]').value = '3'; type(rt, form.querySelector('[name="title"]'), 'Login fails'); type(rt, form.querySelector('[name="content"]'), 'Details'); submit(rt, form); assert.equal(rt.fetchCalls[0].url, REST); assert.deepEqual(JSON.parse(rt.fetchCalls[0].options.body), { user_id: '12', ticket_type_id: '3', title: 'Login fails', content: 'Details' }); rt.fetchCalls[0].resolve(reply({ success: true, data: { id: 77 } })); await tick(); const next = new URL(rt.window.location.assigned); assert.equal(next.searchParams.has('view'), false); assert.equal(next.searchParams.get('id'), '77'); assert.equal(next.searchParams.get('aksrt_ticket_msg'), 'created'); assert.equal(next.searchParams.get('status'), 'pending'); assert.equal(next.searchParams.get('paged'), '2'); }); test('whitespace-only content is rejected before any request', () => { const rt = makeRuntime(); type(rt, rt.replyText, ' '); submit(rt, rt.reply); assert.equal(rt.fetchCalls.length, 0); assert.equal(rt.replyText.validity, strings.required); }); test('a confirmed validation error keeps the form editable and shows the server message without a reload button', async () => { const rt = makeRuntime(); type(rt, rt.replyText, 'hello'); submit(rt, rt.reply); rt.fetchCalls[0].resolve(reply({ code: 'aksrt_ticket_reply_content', message: 'Bad content', data: { status: 400 } }, 400)); await tick(); assert.equal(rt.window.location.assigned, null); assert.equal(rt.feedback.hidden, false); assert.equal(rt.feedback.querySelector('p').textContent, 'Bad content'); assert.equal(rt.feedback.querySelector('[data-aksrt-ticket-reload]').hidden, true); assert.equal(rt.replyText.disabled, false); assert.equal(rt.replyText.value, 'hello'); submit(rt, rt.reply); assert.equal(rt.fetchCalls.length, 2, 'retry allowed after a confirmed failure'); }); for (const [label, settle] of [ ['an unknown network result', (call) => call.reject(new TypeError('network'))], ['a 409 state conflict', (call) => call.resolve(reply({ code: 'aksrt_ticket_closed', message: 'Closed', data: { status: 409 } }, 409))], ['a 404 missing ticket', (call) => call.resolve(reply({ code: 'aksrt_ticket_missing', message: 'Missing', data: { status: 404 } }, 404))], ]) { test(`${label} locks every write form and only offers a page reload`, async () => { const rt = makeRuntime(); type(rt, rt.replyText, 'hello'); submit(rt, rt.reply); settle(rt.fetchCalls[0]); await tick(); const reload = rt.feedback.querySelector('[data-aksrt-ticket-reload]'); assert.equal(reload.hidden, false); for (const control of [rt.replyText, rt.noteText, rt.statusSelect, rt.statusSubmit, rt.assign]) assert.equal(control.disabled, true); submit(rt, rt.note); assert.equal(rt.fetchCalls.length, 1, 'no write after the result became uncertain'); rt.root.fire('click', { target: reload }); assert.equal(rt.window.location.reloads, 1); assert.equal(unload(rt).defaultPrevented, false, 'the explicit reload is not blocked'); }); } test('closing requires the confirmation checkbox', async () => { const rt = makeRuntime(); rt.statusSelect.value = 'handling'; rt.root.fire('change', { target: rt.statusSelect }); assert.equal(rt.warning.hidden, true); rt.statusSelect.value = 'closed'; rt.root.fire('change', { target: rt.statusSelect }); assert.equal(rt.warning.hidden, false); assert.equal(rt.confirm.required, true); submit(rt, rt.status); assert.equal(rt.fetchCalls.length, 0); rt.confirm.checked = true; submit(rt, rt.status); assert.deepEqual(JSON.parse(rt.fetchCalls[0].options.body), { status: 'closed', ticket_type_id: '3', admin_id: '7' }); }); test('“assign to me” selects the current admin, moves pending to handling and focuses save', () => { const rt = makeRuntime(); rt.root.fire('click', { target: rt.assign }); assert.equal(rt.admin.value, '44'); assert.equal(rt.statusSelect.value, 'handling'); assert.equal(rt.document.activeElement, rt.statusSubmit); assert.equal(rt.fetchCalls.length, 0, 'assigning only fills the form'); }); test('an overlong prereply is rejected without changing the editor; a fitting one is inserted', () => { const rt = makeRuntime(); rt.prereply.value = '01234567890123456789012345'; rt.root.fire('change', { target: rt.prereply }); assert.equal(rt.replyText.value, ''); assert.equal(rt.feedback.querySelector('p').textContent, strings.tooLong); assert.equal(rt.prereply.value, ''); rt.prereply.value = 'short'; rt.root.fire('change', { target: rt.prereply }); assert.equal(rt.replyText.value, 'short'); assert.equal(rt.replyCount.textContent, '5 / 24'); }); test('leaving with unsaved input is guarded; a clean page leaves freely', () => { const rt = makeRuntime(); assert.equal(unload(rt).defaultPrevented, false); type(rt, rt.replyText, 'draft'); assert.equal(unload(rt).defaultPrevented, true); });