// Real app and mobile-select, synthetic accounts only. Safe against production: // all auth is intercepted, fonts are skipped, and any non-GET request is blocked. import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn, execSync } from 'node:child_process'; import { once } from 'node:events'; import { fileURLToPath, pathToFileURL } from 'node:url'; const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href); const h5 = fileURLToPath(new URL('../', import.meta.url)); let child, dataRoot, browser; let base = process.env.H5_BASE_URL; try { if (!base) { dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-profile-test-')); base = 'http://127.0.0.1:18112/'; child = spawn(process.execPath, ['server.js'], { cwd: h5, env: { ...process.env, PORT: '18112', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot, PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: '', SKIN_API_SECRET: '' }, stdio: ['ignore', 'pipe', 'pipe'] }); await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(Error('test startup timeout')), 10000); child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timeout); resolve(); } }); child.once('exit', code => { clearTimeout(timeout); reject(Error('server exited ' + code)); }); }); } browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: true, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36' }); const page = await context.newPage(), errors = [], blockedWrites = []; let user = { id: 'profile-fixture-a', kind: 'wechat' }; page.setDefaultTimeout(20000); page.on('pageerror', e => errors.push(e.message)); await page.route('**/*', route => { const req = route.request(); if (req.method() !== 'GET') { blockedWrites.push(new URL(req.url()).pathname); return route.abort(); } if (!process.env.H5_PROFILE_FONTS && /\.woff2?(\?|$)/.test(req.url())) return route.abort(); if (req.url().endsWith('/api/auth/session')) return route.fulfill({ json: { ok: true, user } }); return route.continue(); }); // Expose the actual vendor instances for choosing non-default wheel positions; // restoration/confirmation and carousel interactions still use the actual UI. await page.route('**/mobile-select.iife.min.js', async route => { const response = await route.fetch(); const script = await response.text(); await route.fulfill({ response, body: script + ` var OriginalMobileSelect=MobileSelect; window.__pickers={}; MobileSelect=function(config){var picker=new OriginalMobileSelect(config);window.__pickers[config.trigger]=picker;return picker;}; ` }); }); async function enter(reload = true) { if (reload) await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.click('[data-action="open-privacy"]'); if (!(await page.locator('#privacy-check').evaluate(el => el.classList.contains('checked')))) await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]'); await page.waitForSelector('#s-profile.active'); await page.waitForTimeout(400); } const values = () => page.evaluate(() => ({ birth: document.querySelector('#f-birth').value, age: document.querySelector('#f-age').value, gender: document.querySelector('#f-gender').value, region: document.querySelector('#f-region').value, labels: ['birth', 'gender', 'region'].map(k => document.querySelector('#pick-' + k + ' .pick-value').textContent), skin: Array.from(document.querySelectorAll('#skin-dots i')).findIndex(el => el.classList.contains('on')) })); async function pick(trigger, positions) { await page.click(trigger); if (positions) await page.evaluate(({ trigger, positions }) => positions.forEach((index, column) => window.__pickers[trigger].locatePosition(column, index)), { trigger, positions }); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(250); } await enter(); if (process.env.H5_PROFILE_FONTS) await page.evaluate(() => document.fonts.ready); let blank = await values(); assert.equal(blank.birth, ''); assert.equal(blank.gender, ''); assert.equal(blank.region, ''); assert.equal(blank.skin, 0); await pick('#pick-birth', [62, 10]); // current year minus 37, November await pick('#pick-gender', [1]); const regionPositions = await page.evaluate(() => { const province = REGION_DATA.findIndex(p => p[0] === '浙江省'); return [province, REGION_DATA[province][1].indexOf('宁波市')]; }); assert(regionPositions.every(i => i >= 0)); await pick('#pick-region', regionPositions); await page.click('.skin-arrow.next'); await page.waitForTimeout(400); const original = await values(); assert.equal(original.gender, '男'); assert.equal(original.region, '浙江省 宁波市'); assert.equal(original.skin, 1); assert.equal(original.age, String(await page.evaluate(birth => ProfileStore.age(birth), original.birth))); await enter(); assert.deepEqual(await values(), original, 'reload brings back last confirmed selections, without submitting'); for (const trigger of ['#pick-birth', '#pick-gender', '#pick-region']) await pick(trigger); assert.deepEqual(await values(), original, 'picker wheels themselves reopen at the remembered values'); user = { id: 'profile-fixture-b', kind: 'wechat' }; await enter(); assert.deepEqual(await values(), blank, 'another account does not inherit profile/picker/skin values'); await pick('#pick-gender'); assert.equal((await values()).gender, '女', 'new account wheel reset too'); user = { id: 'profile-fixture-a', kind: 'wechat' }; await page.evaluate(() => AccountClient.init()); await page.goBack(); await page.waitForSelector('#s-splash.active'); await enter(false); assert.deepEqual(await values(), original, 'switching back without a reload restores the right account'); // Chromium's real touch dispatcher exercises touch-action/pointer capture, not // just dispatched DOM events. Check the transform before the finger is lifted. const cdp = await context.newCDPSession(page); const geometry = () => page.locator('#skin-carousel').evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + 60, width: r.width }; }); const offset = () => page.locator('#skin-track').evaluate(el => new DOMMatrix(getComputedStyle(el).transform).m41); async function touch(type, x, y, timestamp) { await cdp.send('Input.dispatchTouchEvent', { type, ...(timestamp ? { timestamp } : {}), touchPoints: type === 'touchEnd' || type === 'touchCancel' ? [] : [{ x, y, id: 1 }] }); } async function dragBy(dx, { pause = 0, cancel = false, dy = 0, flick = false } = {}) { const p = await geometry(); const at = flick ? Date.now() / 1000 : null; await touch('touchStart', p.x, p.y, at); await touch('touchMove', p.x + dx / 2, p.y + dy / 2, at && at + .02); await touch('touchMove', p.x + dx, p.y + dy, at && at + .04); const during = await offset(); if (pause) await page.waitForTimeout(pause); await touch(cancel ? 'touchCancel' : 'touchEnd', 0, 0, at && at + .05); await page.waitForTimeout(400); return during; } const p = await geometry(), before = await offset(); const during = await dragBy(-100); assert(Math.abs(during - (before - 100)) < 3, 'track follows the finger BEFORE release'); assert.equal((await values()).skin, 2); assert(Math.abs(await offset() + 2 * p.width) < 2, 'snaps precisely to the adjacent item'); await dragBy(25, { pause: 160 }); assert.equal((await values()).skin, 2, 'short slow drag snaps back'); await dragBy(-40, { flick: true }); assert.equal((await values()).skin, 3, 'short fast flick advances'); await dragBy(100, { cancel: true }); assert.equal((await values()).skin, 3, 'system cancellation snaps back'); assert.equal(await page.locator('#skin-track.dragging').count(), 0); await dragBy(2, { dy: -90 }); assert.equal((await values()).skin, 3, 'vertical scrolling never switches skin'); await page.locator('#skin-dots i').first().click(); await page.waitForTimeout(400); const edge = await dragBy(100); assert(edge > 0 && edge < 40, 'first item has elastic resistance'); assert.equal((await values()).skin, 0); assert(Math.abs(await offset()) < 2); await page.locator('#skin-dots i').last().click(); await page.waitForTimeout(400); await dragBy(-100); assert.equal((await values()).skin, 4, 'last item cannot overshoot'); await page.locator('#skin-carousel').focus(); await page.keyboard.press('ArrowLeft'); await page.waitForTimeout(400); assert.equal((await values()).skin, 3, 'keyboard still works'); for (const width of [320, 390, 480]) { await page.setViewportSize({ width, height: 726 }); await page.waitForTimeout(400); const size = await geometry(); assert(Math.abs(await offset() + 3 * size.width) < 2, 'resize keeps selection aligned at ' + width); assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no horizontal page overflow'); } for (const [width, height] of [[320, 568], [375, 667], [375, 726], [390, 664], [414, 896], [480, 726], [375, 440]]) { await page.setViewportSize({ width, height }); await page.waitForTimeout(400); const layout = await page.evaluate(() => { const profile = document.querySelector('#s-profile'), submit = document.querySelector('.btn-submit'); const skin = document.querySelector('.skin-card').getBoundingClientRect(); const image = document.querySelector('.skin-item img').getBoundingClientRect(); const desc = document.querySelector('.skin-desc').getBoundingClientRect(); const viewport = document.querySelector('#skin-carousel').getBoundingClientRect(); const textBottom = Math.max(...[...document.querySelectorAll('.skin-desc')].map(el => { const range = document.createRange(); range.selectNodeContents(el); return Math.max(range.getBoundingClientRect().bottom, el.getBoundingClientRect().bottom); })); return { overflow: getComputedStyle(profile).overflowY, touch: getComputedStyle(profile).touchAction, client: profile.clientHeight, scroll: profile.scrollHeight, top: profile.scrollTop, submit: submit.getBoundingClientRect().bottom, image: image.height, desc: desc.bottom, skin: skin.bottom, textBottom, viewportBottom: viewport.bottom }; }); assert.equal(layout.overflow, 'hidden'); assert.equal(layout.touch, 'none'); assert(layout.scroll <= layout.client + 1 && layout.submit <= height, 'all profile controls fit without clipping/scroll: ' + JSON.stringify({ width, height, ...layout })); assert(layout.image > 25 && layout.desc <= layout.skin, 'short screens retain skin illustration and description'); assert(layout.textBottom <= layout.viewportBottom + 1, 'all five skin descriptions fit inside the clipped carousel: ' + JSON.stringify({ width, height, ...layout })); if (process.env.H5_ARTIFACT_DIR) { fs.mkdirSync(process.env.H5_ARTIFACT_DIR, { recursive: true }); await page.screenshot({ path: path.join(process.env.H5_ARTIFACT_DIR, 'profile-fixed-' + width + '-' + height + '.png') }); } const point = await geometry(); await touch('touchStart', point.x, point.y); await touch('touchMove', point.x + 2, point.y + 70); await touch('touchEnd', 0, 0); await page.mouse.wheel(0, 300); await page.waitForTimeout(100); assert.equal(await page.locator('#s-profile').evaluate(el => el.scrollTop), 0, 'touch/wheel cannot pull the page down'); assert.equal(await page.evaluate(() => document.documentElement.scrollTop), 0); await dragBy(-70); assert.equal((await values()).skin, 4, 'horizontal swipe still works after vertical gesture'); await dragBy(70); assert.equal((await values()).skin, 3); } // The screen's scroll lock must not prevent the separate picker from moving. await page.click('#pick-birth'); await page.waitForTimeout(400); assert.equal(await page.locator('.ms-mobile-select.ms-show').evaluate(el => !!el.closest('#s-profile')), false); const wheel = page.locator('.ms-mobile-select.ms-show .ms-wheel').first(), wheelBox = await wheel.boundingBox(); const wheelValue = await page.evaluate(() => window.__pickers['#pick-birth'].getCurValue()); const wx = wheelBox.x + wheelBox.width / 2, wy = wheelBox.y + wheelBox.height / 2; await touch('touchStart', wx, wy); await touch('touchMove', wx, wy - 40); await touch('touchMove', wx, wy - 80); await touch('touchEnd', 0, 0); await page.waitForTimeout(300); assert.notDeepEqual(await page.evaluate(() => window.__pickers['#pick-birth'].getCurValue()), wheelValue, 'birth wheel still responds to real vertical touch'); await page.click('.ms-mobile-select.ms-show .ms-cancel'); await page.waitForTimeout(300); await enter(); assert.equal((await values()).skin, 3, 'skin selection also survives reload'); await page.click('[data-action="submit-profile"]'); await page.waitForSelector('#p-photoguide.show'); user = { id: 'profile-fixture-guest', kind: 'browser' }; await enter(); assert.deepEqual(await values(), blank, 'guests never inherit WeChat preferences'); await pick('#pick-gender', [1]); assert.equal((await values()).gender, '男'); await enter(); assert.deepEqual(await values(), blank, 'guest preferences do not survive reload'); assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests'); console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile finger-follow/snap/flick/edges/cancel; fixed profile/no vertical movement/controls fit in seven viewport sizes; real birth-wheel vertical touch still works; arrows/dots/keyboard/submission; no JS errors/backend writes'); } finally { if (browser) await browser.close(); if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; } if (dataRoot) fs.rmSync(dataRoot, { recursive: true, force: true }); }