'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 root = path.resolve(__dirname, '..', '..'); const source = fs.readFileSync(path.join(root, 'assets/js/signin.js'), 'utf8'); class Element { constructor(tag, html = '', className = '', labels = {}) { this.tagName = tag.toUpperCase(); this._html = html; this.className = className; this.dataset = {}; this.attrs = {}; this.listeners = {}; this.replaced = null; this.disabled = false; this.title = ''; this._label = null; this.labels = labels; this.classList = { contains: (name) => this.className.split(/\s+/).includes(name), add: (name) => { if (!this.classList.contains(name)) this.className = (this.className + ' ' + name).trim(); }, }; this.parseLabel(); } get innerHTML() { return this._html; } set innerHTML(value) { this._html = value; this.parseLabel(); } parseLabel() { const match = this._html.match(/(.*?)<\/span>/); if (!match) { this._label = null; return; } const parent = this; let value = match[1]; let hasHook = true; this._label = { get textContent() { return value; }, set textContent(text) { value = text; parent._html = parent._html.replace(/.*?<\/span>/, '' + text + ''); }, removeAttribute(name) { if (name === 'data-signin-label') { hasHook = false; parent._html = parent._html.replace(' data-signin-label', ''); } }, }; } querySelector(selector) { return selector === '[data-signin-label]' ? this._label : null; } addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } getAttribute(name) { return this.attrs[name] ?? this.labels[name] ?? ''; } setAttribute(name, value) { this.attrs[name] = String(value); } removeAttribute(name) { delete this.attrs[name]; } replaceWith(node) { this.replaced = node; } click() { (this.listeners.click || []).forEach((callback) => callback({ preventDefault() {} })); } } function makeButton(className, label, icon = '', doneLabel = '已签到') { return new Element('button', icon + '' + label + '', className, { 'data-signin-done-label': doneLabel, }); } function deferred() { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } function setup({ buttons = [makeButton('btn uc-header__signin-btn', '签到')], captchaSignin = false, currencyName = '', today = Promise.resolve({ ok: true, status: 200, json: { enabled: true, signed: false, streak: 1, reward_if_signin: 2 } }), signin = Promise.resolve({ ok: true, status: 200, json: { message: '签到成功,积分 +2', balance: 12 } }), captchaRun } = {}) { const calls = []; const notices = []; const balance = { textContent: '10', title: '10' }; const status = { innerHTML: '', textContent: '' }; const modals = []; const listeners = {}; const mount = { nodeType: 1 }; const doc = { querySelectorAll(selector) { if (selector === '[data-signin-btn]') return buttons.filter((button) => !button.replaced); if (selector === '[data-credits-value]') return [balance]; if (selector === '[data-signin-status]') return [status]; return []; }, querySelector(selector) { return selector === '[data-signin-captcha]' ? mount : null; }, createElement(tag) { return new Element(tag); }, }; const jq = { off() { return this; }, on(event, callback) { listeners[event] = callback; return this; } }; const window = { jioData: { captchaSignin, signinDetailsUrl: '/wp-json/jio/v1/signin/details', currencyName }, JioModalRuntime: { open: (...args) => modals.push(args), openRemote: (...args) => modals.push(args) }, JioRequest: { request(endpoint, options) { calls.push({ endpoint, options }); return endpoint === '/signin/today' ? today : signin; } }, }; const captchaCalls = []; if (captchaRun) window.JioCaptcha = { run(scene, scope) { captchaCalls.push(['run', scene, scope]); return captchaRun; }, reset(scene, scope) { captchaCalls.push(['reset', scene, scope]); }, }; vm.runInNewContext(source, { window, document: doc, jQuery: () => jq, notyf: (...args) => notices.push(args), Promise, JSON, Number, tbquire: (_deps, ready) => { ready(); } }); return { buttons, calls, notices, listeners, window, captchaCalls, mount, balance, status, modals }; } const settle = () => new Promise((resolve) => setImmediate(resolve)); test('status line follows the custom currency name instead of hardcoding 积分', async () => { // 后台「积分配置 → 自定义货币名称」改名后,签到状态行必须跟着变, // 否则这里仍显示「积分」。 const button = makeButton('btn', '签到'); const success = deferred(); const h = setup({ buttons: [button], currencyName: '金币', signin: success.promise }); await settle(); button.click(); await settle(); success.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 12, streak: 3, reward: 2 } }); await settle(); assert.equal(h.status.innerHTML, '已签到 3 天 +2 金币'); }); test('status line falls back to 积分 when no custom currency name is injected', async () => { const button = makeButton('btn', '签到'); const success = deferred(); const h = setup({ buttons: [button], signin: success.promise }); await settle(); button.click(); await settle(); success.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 12, streak: 3, reward: 2 } }); await settle(); assert.equal(h.status.innerHTML, '已签到 3 天 +2 积分'); }); test('uses reference loading, success toast, and replaces all entry points with detail links', async () => { const first = makeButton('img-badge jb-yellow', '签到', ''); const second = makeButton('btn uc-header__signin-btn', '签到', '', '今日已签到'); const success = deferred(); const h = setup({ buttons: [first, second], signin: success.promise }); await settle(); const originalOther = second.innerHTML; first.click(); assert.equal(first.disabled, true); assert.equal(first.innerHTML, '请稍后'); assert.equal(first.getAttribute('aria-busy'), 'true'); assert.ok(Object.hasOwn(first.attrs, 'disabled')); assert.equal(second.innerHTML, originalOther, 'non-clicked entries keep their content while the request runs'); assert.equal(second.disabled, false); first.click(); second.click(); await settle(); assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 1); success.resolve({ ok: true, status: 200, json: { message: '签到成功,积分 +2', balance: 12, streak: 2, reward: 2 } }); await settle(); assert.deepEqual(h.notices.find((notice) => notice[1] === 'success')?.slice(0, 2), ['签到成功,积分 +2', 'success']); for (const original of [first, second]) { assert.equal(original.replaced?.getAttribute('data-jio-modal'), 'remote'); assert.equal(original.replaced?.getAttribute('data-jio-modal-url'), '/wp-json/jio/v1/signin/details'); assert.match(original.replaced.innerHTML, /已签到/); assert.doesNotMatch(original.replaced.innerHTML, /data-signin-label/); assert.match(original.replaced.className, /checkin-details-link/); assert.match(original.replaced.innerHTML, /fa-calendar-check/); assert.equal(Object.hasOwn(original.replaced.attrs, 'disabled'), false); assert.equal(original.replaced.getAttribute('aria-busy'), ''); } assert.doesNotMatch(first.replaced.className, /is-done/); assert.match(second.replaced.innerHTML, /今日已签到/); assert.equal(second.replaced.className.includes('uc-header__signin-btn'), true); assert.equal(h.calls.filter((call) => call.endpoint === '/signin/today').length, 1, 'success does not issue another status request'); assert.equal(h.modals.length, 0, 'success does not open a modal'); assert.equal(h.balance.textContent, '12'); // 状态行在奖励数值后追加自定义货币名称(未注入时回退「积分」)。 assert.equal(h.status.innerHTML, '已签到 2 天 +2 积分'); }); test('keeps the profile done style on its replacement link', async () => { const button = makeButton('btn uc-header__signin-btn', '签到'); const success = deferred(); const h = setup({ buttons: [button], signin: success.promise }); await settle(); button.click(); success.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1 } }); await settle(); assert.match(button.replaced.className, /is-done/); }); test('restores complete button markup after a rejected sign-in response', async () => { const button = makeButton('img-badge jb-yellow', '签到', ''); const initial = button.innerHTML; const h = setup({ buttons: [button], signin: Promise.resolve({ ok: false, status: 500, json: { message: '签到失败' } }) }); await settle(); button.click(); await settle(); assert.equal(button.innerHTML, initial); assert.equal(button.disabled, false); assert.equal(button.getAttribute('aria-busy'), ''); assert.equal(button.replaced, null); }); test('preserves the existing main/captcha ordering and resets captcha after submission', async () => { const button = makeButton('btn', '签到'); const success = deferred(); const captcha = deferred(); const h = setup({ buttons: [button], captchaSignin: true, signin: success.promise, captchaRun: captcha.promise }); await settle(); button.click(); await settle(); assert.deepEqual(h.captchaCalls[0]?.slice(0, 2), ['run', 'signin']); assert.equal(h.calls.some((call) => call.endpoint === '/signin'), false); captcha.resolve({ proof: 'ok' }); await settle(); assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 1); success.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1 } }); await settle(); assert.deepEqual(h.captchaCalls.map((call) => call[0]), ['run', 'reset']); }); test('allows retry after a rejected request', async () => { const button = makeButton('btn', '签到'); const today = deferred(); const h = setup({ buttons: [button], today: today.promise, signin: Promise.reject(Object.assign(new Error('network down'), { status: 0 })) }); button.click(); await settle(); today.resolve({ ok: true, status: 200, json: { enabled: true, signed: false, streak: 2, reward_if_signin: 3 } }); await settle(); assert.equal(button.innerHTML.includes('请稍后'), false); assert.equal(button.disabled, false); assert.equal(button.replaced, null); button.click(); await settle(); assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 2); }); test('keeps the loading button when a previous status read completes during submission', async () => { const button = makeButton('img-badge jb-yellow', '签到'); const today = deferred(); const signin = deferred(); const h = setup({ buttons: [button], today: today.promise, signin: signin.promise }); button.click(); await settle(); today.resolve({ ok: true, status: 200, json: { enabled: true, signed: true, streak: 2, reward_today: 3 } }); await settle(); assert.equal(button.replaced, null); assert.match(button.innerHTML, /loading mr6/); assert.equal(button.disabled, true); assert.equal(h.status.innerHTML, ''); signin.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1, streak: 2, reward: 3 } }); await settle(); assert.ok(button.replaced); }); test('ignores an old today response after successful sign-in', async () => { const button = makeButton('btn', '签到'); const today = deferred(); const signin = deferred(); const h = setup({ buttons: [button], today: today.promise, signin: signin.promise }); button.click(); await settle(); signin.resolve({ ok: true, status: 200, json: { message: '签到成功', balance: 1, streak: 3, reward: 2 } }); await settle(); today.resolve({ ok: true, status: 200, json: { enabled: true, signed: false, streak: 2, reward_if_signin: 3 } }); await settle(); assert.ok(button.replaced, 'success replaced the action with the details link'); assert.match(button.replaced.innerHTML, /已签到/); assert.equal(h.status.innerHTML, '已签到 3 天 +2 积分'); }); test('leaves a disabled sign-in feature disabled without turning it into a signed state', async () => { const button = makeButton('btn', '签到'); const h = setup({ buttons: [button], today: Promise.resolve({ ok: true, status: 200, json: { enabled: false, signed: false } }) }); await settle(); assert.equal(button.disabled, true); assert.equal(button.replaced, null); assert.doesNotMatch(button.className, /is-done/); assert.equal(button.innerHTML.includes('已签到'), false); }); test('does not send the request when captcha is absent or cancelled and keeps original markup', async () => { const missingButton = makeButton('btn', '签到'); const missing = setup({ buttons: [missingButton], captchaSignin: true }); missing.window.JioCaptcha = undefined; // No mount is needed in the harness to verify the missing provider path. missingButton.click(); await settle(); assert.equal(missingButton.disabled, false); assert.equal(missing.calls.some((call) => call.endpoint === '/signin'), false); const cancelledButton = makeButton('btn', '签到'); const cancelled = setup({ buttons: [cancelledButton], captchaSignin: true, captchaRun: Promise.resolve(null) }); const original = cancelledButton.innerHTML; cancelledButton.click(); await settle(); assert.equal(cancelledButton.innerHTML, original); assert.equal(cancelledButton.disabled, false); assert.equal(cancelled.calls.some((call) => call.endpoint === '/signin'), false); }); test('does not bind the same button twice through repeated auto_fun scans', async () => { const button = makeButton('btn', '签到'); const h = setup({ buttons: [button] }); await settle(); h.listeners['auto_fun.jio-signin'](); assert.equal(button.listeners.click.length, 1); button.click(); await settle(); assert.equal(h.calls.filter((call) => call.endpoint === '/signin').length, 1); });