'use strict'; const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); const test = require('node:test'); const root = path.resolve(__dirname, '../..'); const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8'); let rendered; function harness() { if (!rendered) { const result = spawnSync('php', [path.join(__dirname, 'invite-admin-harness.php')], { encoding: 'utf8' }); assert.equal(result.status, 0, `${result.stderr}${result.stdout}`); rendered = JSON.parse(result.stdout); } return rendered; } function dialog(html, id) { const match = html.match(new RegExp(`]*\\bid="${id}"[^>]*>[\\s\\S]*?<\\/dialog>`)); assert.ok(match, `missing ${id} dialog`); return match[0]; } function input(html, name) { const match = html.match(new RegExp(`]*\\bname="${name}"[^>]*>`)); assert.ok(match, `missing input ${name}`); return match[0]; } function fieldset(html, dependency) { const tags = [...html.matchAll(/]*data-jio-inv-dep="([^"]+)"[^>]*>/g)]; const found = tags.find(([, value]) => value === dependency); assert.ok(found, `missing conditional fieldset ${dependency}`); return found[0]; } function attr(tag, name, value) { assert.match(tag, new RegExp(`\\b${name}="${value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}"`)); } function hasBooleanAttr(tag, name) { assert.match(tag, new RegExp(`(?:\\s|^)${name}(?:\\s|>|$)`)); } test('filters, selection, table and pagination share one list card', () => { const { html } = harness(); const cards = [...html.matchAll(/]*class="[^"]*\bjio-native-card\b[^"]*"[^>]*>[\s\S]*?<\/section>/g)]; assert.equal(cards.length, 1); const card = cards[0][0]; assert.match(card, /jio-inv-list-card/); assert.match(card, /

邀请码列表<\/h2>/); for (const marker of ['class="subsubsub"', 'class="jio-native-filters"', '清除筛选', 'data-jio-inv-selection', 'jio-inv-table', 'class="tablenav bottom"']) { assert.ok(card.includes(marker), `${marker} belongs to the list card`); } // 批量操作行同时带共享的 jio-native-bulk 类,按 class 列表中的词匹配,不要求整串相等。 assert.match(card, /class="[^"]*\bjio-inv-bulk\b[^"]*"/, 'jio-inv-bulk belongs to the list card'); assert.doesNotMatch(html, /jio-inv-filter-summary|关键词:/); assert.doesNotMatch(read('assets', 'css', 'admin', 'invite-codes.css'), /jio-inv-filter-summary/); }); test('search and bulk actions remain independent sibling forms inside the list card', () => { const { html } = harness(); let depth = 0; let count = 0; for (const [tag] of html.matchAll(/<\/?form\b[^>]*>/g)) { if (tag.startsWith('= 0 && depth <= 1, 'forms must never nest'); } assert.equal(depth, 0); assert.equal(count, 4); const searchEnd = html.indexOf('', html.indexOf('class="jio-native-filters"')); const listStart = html.lastIndexOf('', listStart); assert.ok(html.indexOf('class="tablenav bottom"', listStart) < listEnd); assert.ok(listEnd < html.indexOf('', listStart)); }); test('page renders add and export dialogs beside, not inside, the list form', () => { const { html } = harness(); const listMarker = html.indexOf('data-jio-inv-list'); assert.ok(listMarker > 0, 'list form is rendered'); const listStart = html.lastIndexOf('', listMarker); const addStart = html.indexOf('= 0 && listEnd > listStart); assert.ok(listEnd < addIndex, 'the list form closes before the add dialog'); assert.ok(addIndex < exportIndex, 'add and export dialogs are rendered as adjacent page siblings'); assert.ok(html.indexOf('', addIndex) < exportIndex); assert.ok(addStart < 0 || addStart === addIndex, 'dialog extraction begins at its opening tag'); assert.doesNotMatch(addDialog, /]*>[^]* { const { html } = harness(); const list = html.slice(html.lastIndexOf('', html.indexOf('data-jio-inv-list')) + 7); const add = dialog(html, 'jio-inv-add'); const exportForm = dialog(html, 'jio-inv-export'); assert.match(list, /
{ const { html } = harness(); assert.match(html, /X&Y"<img src=x onerror=alert\(1\)>/); assert.match(html, /<script>date<\/script>/); assert.match(html, /Admin"><script>alert\(1\)<\/script>/); assert.match(html, /owner"><img src=x onerror=alert\(1\)>/); assert.match(html, /Completed/); assert.doesNotMatch(html, /<(?:script|img)\b|javascript:/i); assert.match(html, /
/, 'the reward cell preserves the permitted line break'); }); test('conditional form sections start hidden and disabled where inactive', () => { const { html } = harness(); const auto = fieldset(html, 'add_type=auto'); const importing = fieldset(html, 'add_type=import'); const codeLength = fieldset(html, 'auto_top_s=1'); const vipTime = fieldset(html, 'vip=1|2'); const textOptions = fieldset(html, 'export_format=text'); assert.doesNotMatch(auto, /\bhidden\b|\bdisabled\b/); for (const pane of [importing, codeLength, vipTime, textOptions]) { hasBooleanAttr(pane, 'hidden'); hasBooleanAttr(pane, 'disabled'); } }); test('numeric fields expose browser constraints and VIP days apply to both levels', () => { const { html } = harness(); const quantity = input(html, 'auto_num'); attr(quantity, 'min', '1'); attr(quantity, 'max', '1000'); attr(quantity, 'step', '1'); hasBooleanAttr(quantity, 'required'); const length = input(html, 'auto_rand_password_limit'); attr(length, 'min', '4'); attr(length, 'max', '50'); attr(length, 'step', '1'); const balance = input(html, 'balance'); attr(balance, 'min', '0'); attr(balance, 'step', '0\.01'); const points = input(html, 'points'); attr(points, 'min', '0'); attr(points, 'step', '1'); const days = input(html, 'vip_time'); attr(days, 'min', '1'); attr(days, 'step', '1'); hasBooleanAttr(days, 'required'); assert.match(html, /name="vip" value="1"/); assert.match(html, /name="vip" value="2"/); const vipPane = fieldset(html, 'vip=1|2'); const vipPaneStart = html.indexOf(vipPane); assert.match(html.slice(vipPaneStart, html.indexOf('', vipPaneStart)), /name="vip_time"/); }); test('export UI and service consistently name the spreadsheet format CSV', () => { const { html } = harness(); const exportForm = dialog(html, 'jio-inv-export'); assert.match(exportForm, /name="export_format" value="csv" checked/); assert.match(exportForm, /Excel 表格(\.csv)/); assert.match(exportForm, /文本文档(\.txt)/); const service = read('src', 'Payment', 'InviteCode.php'); assert.match(service, /\$_GET\['export_format'\] \?\? 'csv'\) === 'text' \? 'text' : 'csv'/); assert.match(service, /\[__\('邀请码', 'jio'\), __\('使用奖励', 'jio'\), __\('创建时间', 'jio'\), __\('更新时间', 'jio'\), __\('状态', 'jio'\), __\('标识', 'jio'\)\]/); assert.match(service, /jio-invite-codes\.csv/); assert.doesNotMatch(service, /\bxls\b|export_format[^\n]*xls/); }); test('search form and filter links preserve sanitized keyword and remark context', () => { const { html } = harness(); const marker = html.indexOf('class="jio-native-filters"'); const start = html.lastIndexOf('', marker); const form = html.slice(start, end + 7); assert.match(form, //); assert.match(form, //); assert.match(form, //); assert.match(form, /]*name="s" value="search alert\(2\) & phrase"/); const statusMatch = html.match(/]*>全部<\/a>/); assert.ok(statusMatch, 'status filter link is rendered'); const statusUrl = new URL(statusMatch[1].replaceAll('&', '&')); assert.equal(statusUrl.pathname, '/wp-admin/users.php'); assert.equal(statusUrl.searchParams.get('status'), 'all'); assert.equal(statusUrl.searchParams.get('s'), 'search alert(2) & phrase'); assert.equal(statusUrl.searchParams.get('remark'), 'campaign & east'); const sortMatch = html.match(/ { const { over_limit_is_error, leading_blank_imported, import_insert_count } = harness(); assert.equal(over_limit_is_error, true); assert.equal(leading_blank_imported, 1000); assert.equal(import_insert_count, 1000, 'the over-limit request inserts nothing; leading blank rows do not count'); }); test('page uses no list/add/export tab route and service returns to the canonical list', () => { const { html, return_url } = harness(); const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php'); const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php'); assert.doesNotMatch(page + forms + html, /page-title-action|[?&](?:tab|view)=(?:list|add|export)|tab=(?:list|add|export)/); assert.equal(return_url, 'https://example.test/wp-admin/users.php?page=jio_invite_codes'); assert.doesNotMatch(return_url, /(?:tab|view|add)=/); }); test('status-only list totals keep their filter and no longer return unused count buckets', () => { const { list_result, row_queries } = harness(); assert.equal(Object.hasOwn(list_result, 'counts'), false); const totalQuery = row_queries.find((sql) => /SELECT COUNT\(\*\) AS all_count/.test(sql)); assert.ok(totalQuery, 'the service issues a single total-count query'); assert.match(totalQuery, /WHERE used_count > 0/); assert.doesNotMatch(totalQuery, /COALESCE|unused_count|used_count AS/); assert.doesNotMatch(read('src', 'Payment', 'InviteCode.php'), /'counts'\s*=>/); }); test('invite CSS and JS are enqueued only for the invite submenu hook', () => { const { wrong_hook_assets, page_assets } = harness(); assert.deepEqual(wrong_hook_assets, []); assert.equal(page_assets.length, 2); assert.deepEqual(page_assets[0].slice(0, 4), [ 'style', 'jio-admin-invite-codes', 'https://example.test/theme/assets/css/admin/invite-codes.css', ['jio-admin-native-ui'], ]); assert.deepEqual(page_assets[1].slice(0, 4), [ 'script', 'jio-admin-invite-codes', 'https://example.test/theme/assets/js/admin-invite-codes.js', [], ]); });