#!/usr/bin/env node // Local production bundle with isolated API fixtures; never touches online lessons. import { chromium } from 'playwright'; import { createServer } from 'node:http'; import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, extname } from 'node:path'; import assert from 'node:assert/strict'; const root = resolve(process.env.XIAOSHU_BROWSER_BUILD || 'dist/xiaoshu-mobile/browser'); const out = resolve(process.env.XIAOSHU_BROWSER_OUTPUT || 'tmp/course-status-cache-qa'); mkdirSync(out, { recursive: true }); const server = createServer((req, res) => { let file = resolve(root, '.' + decodeURIComponent(new URL(req.url, 'http://local').pathname)); if (!file.startsWith(root + '/') || !existsSync(file) || !extname(file)) file = root + '/index.html'; res.setHeader('Content-Type', ({ '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png' })[extname(file)] || 'application/octet-stream'); res.end(readFileSync(file)); }); await new Promise(done => server.listen(0, '127.0.0.1', done)); const origin = 'http://127.0.0.1:' + server.address().port; const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' }); const statuses = new Map([[901, 0], [902, 0]]), calls = [], errors = []; const order = id => ({ GeneralID: id, Title: '测试学员(高中语法写作课)', szyh: 100, pl: 300, plxm: '测试老师', kcid: 0, bindingId: '', courseCategoryKey: 'high_writing', courseCategoryName: '高中语法写作课', durationMinutes: 60, dszt: statuses.get(id), bxrq: today, yysj: today + (id === 901 ? ' 08:00' : ' 09:00'), sdsd: id === 901 ? '08:00' : '09:00', updatedAt: '2026-10-29T00:00:00Z', allowedActions: statuses.get(id) === 0 ? ['start'] : statuses.get(id) === 10 ? ['complete'] : ['editFeedback'], feedback: statuses.get(id) === 30 ? { feedbackStatus: 'pending' } : null }); const browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }); try { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); await context.addInitScript(() => { if (!localStorage.getItem('xiaoshu.angular.session')) localStorage.setItem('xiaoshu.angular.session', JSON.stringify({ token: 'r:isolated-status-fixture', user: { userId: 300, groupId: 3, honeyName: '测试老师' } })); }); await context.route('**/*', async route => { const url = new URL(route.request().url()); if (url.origin !== origin) return route.abort(); if (!url.pathname.startsWith('/cloud-functions/')) return route.continue(); const { params: p } = route.request().postDataJSON(); calls.push(p); let result = []; if (p.action === 'app_companion_overview') result = { courses: [], schedules: [order(901), order(902)], dates: [{ date: today, finished: [...statuses.values()].filter(s => s >= 30).length, planned: [...statuses.values()].filter(s => s < 30).length }], studentOptions: [], page: { itemCount: 2, pageCount: 1 } }; if (p.action === 'e_order_detail') result = [order(Number(p.id))]; if (p.action === 'app_live_versions') result = { version: JSON.stringify([...statuses]), scopeVersions: {}, syncStatus: { state: 'current' } }; if (p.action === 'user_get') result = { userId: 100, groupId: 1, honeyName: '测试学员' }; if (p.action === 'e_order_update_v2') { statuses.set(Number(JSON.parse(p.content).GeneralID), 10); result = { status: 10 }; } if (p.action === 'app_lesson_complete') { statuses.set(Number(p.orderId), 30); result = { status: 30 }; } await route.fulfill({ json: { retcode: 0, result } }); }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); page.on('dialog', dialog => dialog.accept()); const rows = page.locator('article[data-schedule-id]'); await page.goto(origin + '/pages/home/shop'); await rows.filter({ hasText: '未开始' }).nth(1).waitFor(); // Simulate the old persistent snapshot while the database already has both completions. await page.evaluate(() => { const saved = JSON.parse(localStorage.getItem('xiaoshu.learning.homeDaily.v1')); delete saved.companionCacheVersion; localStorage.setItem('xiaoshu.learning.homeDaily.v1', JSON.stringify(saved)); }); statuses.set(901, 30); statuses.set(902, 30); await page.reload(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor(); await page.screenshot({ path: out + '/upgraded-cache.png', fullPage: true }); statuses.set(901, 0); statuses.set(902, 0); await page.evaluate(() => localStorage.removeItem('xiaoshu.learning.homeDaily.v1')); await page.reload(); await rows.filter({ hasText: '未开始' }).nth(1).waitFor(); await rows.first().click(); await page.getByRole('button', { name: '开始课程', exact: true }).click(); await page.getByRole('button', { name: '结束课程', exact: true }).click(); await page.locator('.appointment-actions').getByRole('button', { name: '补充反馈', exact: true }).waitFor(); await page.goBack(); await rows.filter({ hasText: '已结课' }).waitFor(); assert.equal(await rows.filter({ hasText: '未开始' }).count(), 1); // End the second lesson in another tab while the first tab keeps the timetable open. const other = await context.newPage(); other.on('dialog', dialog => dialog.accept()); await other.goto(origin + '/pages/shop/get_order?id=902'); await other.getByRole('button', { name: '开始课程', exact: true }).click(); await other.getByRole('button', { name: '结束课程', exact: true }).click(); await other.locator('.appointment-actions').getByRole('button', { name: '补充反馈', exact: true }).waitFor(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor(); await other.close(); await page.reload(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor(); await page.screenshot({ path: out + '/completed-after-return.png', fullPage: true }); assert.deepEqual(errors, []); assert.equal(calls.filter(p => p.action === 'app_lesson_complete').length, 2); const result = { passed: true, checks: ['旧版当天缓存升级后读取最新已结课状态', '写作课结课返回列表立即更新', '另一标签页结课后打开的课表更新', '整页刷新后两个课次仍显示已结课'], environment: 'local production bundle + isolated API fixtures' }; writeFileSync(out + '/result.json', JSON.stringify(result, null, 2)); console.log(JSON.stringify(result)); } finally { await browser.close(); await new Promise(done => server.close(done)); }