|
|
@@ -0,0 +1,81 @@
|
|
|
+#!/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)); }
|