check-course-status-cache-browser.mjs 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. #!/usr/bin/env node
  2. // Local production bundle with isolated API fixtures; never touches online lessons.
  3. import { chromium } from 'playwright';
  4. import { createServer } from 'node:http';
  5. import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
  6. import { resolve, extname } from 'node:path';
  7. import assert from 'node:assert/strict';
  8. const root = resolve(process.env.XIAOSHU_BROWSER_BUILD || 'dist/xiaoshu-mobile/browser');
  9. const out = resolve(process.env.XIAOSHU_BROWSER_OUTPUT || 'tmp/course-status-cache-qa');
  10. mkdirSync(out, { recursive: true });
  11. const server = createServer((req, res) => {
  12. let file = resolve(root, '.' + decodeURIComponent(new URL(req.url, 'http://local').pathname));
  13. if (!file.startsWith(root + '/') || !existsSync(file) || !extname(file)) file = root + '/index.html';
  14. 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');
  15. res.end(readFileSync(file));
  16. });
  17. await new Promise(done => server.listen(0, '127.0.0.1', done));
  18. const origin = 'http://127.0.0.1:' + server.address().port;
  19. const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
  20. const statuses = new Map([[901, 0], [902, 0]]), calls = [], errors = [];
  21. 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 });
  22. const browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' });
  23. try {
  24. const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
  25. await context.addInitScript(() => {
  26. if (!localStorage.getItem('xiaoshu.angular.session')) localStorage.setItem('xiaoshu.angular.session', JSON.stringify({ token: 'r:isolated-status-fixture', user: { userId: 300, groupId: 3, honeyName: '测试老师' } }));
  27. });
  28. await context.route('**/*', async route => {
  29. const url = new URL(route.request().url());
  30. if (url.origin !== origin) return route.abort();
  31. if (!url.pathname.startsWith('/cloud-functions/')) return route.continue();
  32. const { params: p } = route.request().postDataJSON(); calls.push(p);
  33. let result = [];
  34. 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 } };
  35. if (p.action === 'e_order_detail') result = [order(Number(p.id))];
  36. if (p.action === 'app_live_versions') result = { version: JSON.stringify([...statuses]), scopeVersions: {}, syncStatus: { state: 'current' } };
  37. if (p.action === 'user_get') result = { userId: 100, groupId: 1, honeyName: '测试学员' };
  38. if (p.action === 'e_order_update_v2') { statuses.set(Number(JSON.parse(p.content).GeneralID), 10); result = { status: 10 }; }
  39. if (p.action === 'app_lesson_complete') { statuses.set(Number(p.orderId), 30); result = { status: 30 }; }
  40. await route.fulfill({ json: { retcode: 0, result } });
  41. });
  42. const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
  43. page.on('dialog', dialog => dialog.accept());
  44. const rows = page.locator('article[data-schedule-id]');
  45. await page.goto(origin + '/pages/home/shop');
  46. await rows.filter({ hasText: '未开始' }).nth(1).waitFor();
  47. // Simulate the old persistent snapshot while the database already has both completions.
  48. await page.evaluate(() => {
  49. const saved = JSON.parse(localStorage.getItem('xiaoshu.learning.homeDaily.v1'));
  50. delete saved.companionCacheVersion;
  51. localStorage.setItem('xiaoshu.learning.homeDaily.v1', JSON.stringify(saved));
  52. });
  53. statuses.set(901, 30); statuses.set(902, 30);
  54. await page.reload(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor();
  55. await page.screenshot({ path: out + '/upgraded-cache.png', fullPage: true });
  56. statuses.set(901, 0); statuses.set(902, 0);
  57. await page.evaluate(() => localStorage.removeItem('xiaoshu.learning.homeDaily.v1'));
  58. await page.reload(); await rows.filter({ hasText: '未开始' }).nth(1).waitFor();
  59. await rows.first().click();
  60. await page.getByRole('button', { name: '开始课程', exact: true }).click();
  61. await page.getByRole('button', { name: '结束课程', exact: true }).click();
  62. await page.locator('.appointment-actions').getByRole('button', { name: '补充反馈', exact: true }).waitFor();
  63. await page.goBack(); await rows.filter({ hasText: '已结课' }).waitFor();
  64. assert.equal(await rows.filter({ hasText: '未开始' }).count(), 1);
  65. // End the second lesson in another tab while the first tab keeps the timetable open.
  66. const other = await context.newPage(); other.on('dialog', dialog => dialog.accept());
  67. await other.goto(origin + '/pages/shop/get_order?id=902');
  68. await other.getByRole('button', { name: '开始课程', exact: true }).click();
  69. await other.getByRole('button', { name: '结束课程', exact: true }).click();
  70. await other.locator('.appointment-actions').getByRole('button', { name: '补充反馈', exact: true }).waitFor();
  71. await rows.filter({ hasText: '已结课' }).nth(1).waitFor();
  72. await other.close();
  73. await page.reload(); await rows.filter({ hasText: '已结课' }).nth(1).waitFor();
  74. await page.screenshot({ path: out + '/completed-after-return.png', fullPage: true });
  75. assert.deepEqual(errors, []);
  76. assert.equal(calls.filter(p => p.action === 'app_lesson_complete').length, 2);
  77. const result = { passed: true, checks: ['旧版当天缓存升级后读取最新已结课状态', '写作课结课返回列表立即更新', '另一标签页结课后打开的课表更新', '整页刷新后两个课次仍显示已结课'], environment: 'local production bundle + isolated API fixtures' };
  78. writeFileSync(out + '/result.json', JSON.stringify(result, null, 2));
  79. console.log(JSON.stringify(result));
  80. } finally { await browser.close(); await new Promise(done => server.close(done)); }