'use strict'; const assert = require('node:assert/strict'), fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); const { chromium } = require('playwright'), { freePort, start, stop } = require('./support.cjs'); const root = path.resolve(__dirname, '../..'); const artifacts = path.join(root, 'temp/rednote-browser'); fs.mkdirSync(artifacts, { recursive: true }); const runRoot = fs.mkdtempSync(path.join(root, 'temp/rednote-dual-')); const ports = {}, children = []; let browser, proxy; function disk(dir) { const out = {}; function visit(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); if (entry.isDirectory()) visit(full); else out[path.relative(dir, full)] = fs.readFileSync(full).toString('base64'); } } visit(dir); return out; } async function screenshot(page, name, fullPage = false) { await page.evaluate(() => document.fonts.ready); await page.screenshot({ path: path.join(artifacts, name + '.png'), fullPage, animations: 'disabled' }); } async function consent(page, capture = false) { await page.click('#s-splash [data-action="open-privacy"]'); if (capture) { for (const size of [{ width: 320, height: 568 }, { width: 375, height: 726 }]) { await page.setViewportSize(size); const layout = await page.evaluate(() => { const body = document.querySelector('#p-privacy .popup-body').getBoundingClientRect(); const row = document.querySelector('#privacy-row').getBoundingClientRect(); const button = document.querySelector('#p-privacy .popup-btn').getBoundingClientRect(); return { separated: body.bottom <= row.top && row.bottom <= button.top, visible: button.bottom <= innerHeight }; }); assert(layout.separated && layout.visible, 'persistence disclosure must not overlap consent or hide Continue'); } await screenshot(page, 'rednote-consent'); } await page.locator('#privacy-check').click(); await page.click('#p-privacy [data-action="agree-continue"]'); await page.waitForSelector('#s-profile.active'); } (async () => { try { for (const key of ['proxy', 'state', 'rednote']) ports[key] = await freePort(); const base = 'http://127.0.0.1:' + ports.proxy; const stateData = path.join(runRoot, 'state'), rednoteData = path.join(runRoot, 'rednote'); const wechat = require('../../h5/test/wechat-fixture.cjs')(path.join(stateData, 'accounts'))[0]; for (const mode of ['state', 'rednote']) children.push(await start({ mode, dataRoot: path.join(runRoot, mode), port: ports[mode], publicBase: base + '/' + mode + '/' })); proxy = require('../scripts/proxy.cjs')({ statePort: ports.state, rednotePort: ports.rednote }); await new Promise(resolve => proxy.listen(ports.proxy, '127.0.0.1', resolve)); const baseline = disk(stateData); browser = await chromium.launch({ headless: true }); let context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true }); await context.addCookies([{ name: 'juzi_session', value: wechat.token, domain: '127.0.0.1', path: '/state/' }]); const state = await context.newPage(), rednote = await context.newPage(), errors = [], rednoteRequests = []; for (const page of [state, rednote]) page.on('pageerror', error => errors.push(error.message)); rednote.on('request', request => { if (request.url().includes('/api/')) rednoteRequests.push(request.url()); }); await state.goto(base + '/state/'); await state.waitForSelector('#s-splash.active'); assert.equal(await state.locator('#s-splash [data-auth-status]').textContent(), '微信已登录'); const stateKind = await state.evaluate(() => localStorage.getItem('juzi.auth.kind.v1')); await rednote.goto(base + '/rednote/'); await rednote.waitForSelector('#s-splash.active'); assert.equal(await rednote.locator('[data-action="login-wechat"]').count(), 0); assert.equal((await context.cookies()).filter(c => c.name === 'juzi_rednote_session').length, 0, 'opening the page does not persist an identity before consent'); await screenshot(rednote, 'rednote-home'); await rednote.click('#s-splash [data-action="history"]'); await rednote.waitForSelector('#s-history.active'); assert((await rednote.locator('#identity-note').textContent()).includes('同意隐私')); await rednote.goBack(); await rednote.waitForSelector('#s-splash.active'); await consent(rednote, true); const alice = await rednote.evaluate(() => AccountClient.user()); assert.equal(alice.kind, 'browser_persistent'); const cookie = (await context.cookies()).find(c => c.name === 'juzi_rednote_session'); assert.equal(cookie.path, '/rednote/'); assert(cookie.httpOnly && cookie.expires > Date.now() / 1000); await rednote.evaluate(() => ProfileStore.create(() => localStorage).write(AccountClient.user(), { birth: '1996-01', gender: '女', region: { province: '北京市', city: '北京市' }, skinIndex: 2 })); const stored = await context.storageState(); await context.close(); context = await browser.newContext({ storageState: stored, viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true }); const restored = await context.newPage(); restored.on('pageerror', e => errors.push(e.message)); restored.on('request', r => { if (r.url().includes('/api/')) rednoteRequests.push(r.url()); }); await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active'); assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id); await consent(restored); assert.equal(await restored.locator('#f-birth').inputValue(), '1996-01'); assert.equal(await restored.locator('#f-gender').inputValue(), '女'); await screenshot(restored, 'rednote-profile'); const fx = { window: {} }; vm.runInNewContext(fs.readFileSync(path.join(root, 'h5/assets/mock/report-fixture.js'), 'utf8'), fx); const photo = fx.window.MOCK_FIXTURE.photos[0]; const payload = await (await context.request.post(base + '/rednote/api/skin-analyze', { data: { front_image: photo, left_image: photo, right_image: photo, profile: { real_age: 30, daily_skin_type: '中性肌肤' }, channel: 'taobao' } })).json(); assert.equal(payload.report_saved, true); await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active'); await restored.click('#s-splash [data-action="history"]'); await restored.waitForSelector('.history-detail'); assert(!(await restored.locator('#identity-note').textContent()).includes('微信')); await screenshot(restored, 'rednote-history'); await restored.click('.history-detail'); await restored.waitForSelector('#s-report.active'); assert.equal(await restored.locator('#stat-score b').textContent(), String(payload.vm.total)); assert.equal(await restored.locator('#stat-age b').textContent(), String(payload.vm.skinAgeDisplay)); await screenshot(restored, 'rednote-report', true); const stateAgain = await context.newPage(); await stateAgain.goto(base + '/state/'); await stateAgain.waitForSelector('#s-splash.active'); assert.equal(await stateAgain.evaluate(() => AccountClient.user().id), wechat.user.id); assert.equal(await stateAgain.evaluate(() => localStorage.getItem('juzi.auth.kind.v1')), stateKind); assert((await stateAgain.evaluate(() => Object.keys(localStorage))).some(key => key.startsWith('juzi.rednote.profile.v1.'))); const admin = await browser.newPage({ viewport: { width: 1280, height: 900 } }), adminRequests = []; admin.on('pageerror', e => errors.push(e.message)); admin.on('request', r => { if (r.url().includes('/api/')) adminRequests.push(r.url()); }); await admin.goto(base + '/rednote/admin.html'); await admin.waitForSelector('#tbl-records .thumb'); await admin.locator('#tbl-records tbody tr').first().click(); await admin.waitForSelector('#drawer:not(.hidden)'); await admin.waitForSelector('#d-body a[download]'); assert((await admin.locator('#d-body a[href$="/raw"]').getAttribute('href')).startsWith('/rednote/api/admin/')); assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/'))); assert(adminRequests.some(url => url.includes('/api/admin/photos/'))); await screenshot(admin, 'rednote-admin', true); await admin.click('#btn-close'); await admin.click('[data-tab="stats"]'); const [download] = await Promise.all([admin.waitForEvent('download'), admin.click('#btn-export')]); assert(download.url().startsWith(base + '/rednote/api/admin/export')); assert(fs.readFileSync(await download.path(), 'utf8').includes(',rednote')); await admin.click('[data-tab="records"]'); assert(adminRequests.every(url => url.startsWith(base + '/rednote/api/'))); await restored.goto(base + '/rednote/'); await restored.waitForSelector('#s-splash.active'); restored.once('dialog', dialog => dialog.dismiss()); await restored.click('#s-splash [data-action="logout-wechat"]'); assert.equal(await restored.evaluate(() => AccountClient.user().id), alice.id); restored.once('dialog', dialog => dialog.accept()); await restored.click('#s-splash [data-action="logout-wechat"]'); await restored.waitForFunction(() => AccountClient.user().kind === 'browser'); assert.equal((await context.request.get(base + '/rednote/api/reports')).status(), 401); await restored.goBack(); assert.equal(await restored.locator('#s-report.active').count(), 0, 'back navigation cannot restore the old private report after clearing identity'); assert.equal((await context.request.get(base + '/state/api/auth/session')).status(), 200); assert.equal((await (await context.request.get(base + '/state/api/auth/session')).json()).user.id, wechat.user.id); assert(rednoteRequests.every(url => url.startsWith(base + '/rednote/api/'))); assert.deepEqual(disk(stateData), baseline, 'rednote operations never modify original state identities/reports/data'); assert.deepEqual(errors, []); console.log('PASS: consent-only creation, persistent browser restore, profile cache, saved/history reports, admin API/photo subpaths, source isolation, independent logout and unchanged state data; screenshots in temp/rednote-browser'); } finally { if (browser) await browser.close(); if (proxy) await new Promise(resolve => proxy.close(resolve)); for (const child of children) await stop(child); fs.rmSync(runRoot, { recursive: true, force: true }); } })().catch(error => { console.error(error); process.exitCode = 1; });