feat(h5): remember profile selections and add finger-following skin swipe
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// 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: 'browser' };
|
||||
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 (/\.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();
|
||||
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: 'browser' };
|
||||
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');
|
||||
}
|
||||
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');
|
||||
assert.deepEqual(errors, []); assert.deepEqual(blockedWrites, [], 'no backend writes or paid requests');
|
||||
console.log('PASS: actual profile defaults/wheels/reload/account isolation; real mobile touch follows finger/snap/flick/edges/cancel/vertical scroll; arrows/dots/keyboard; three responsive widths; 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 });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const Profile = require('../js/profile-store');
|
||||
const regions = [['北京市', ['北京市']], ['浙江省', ['杭州市', '宁波市']]];
|
||||
const now = new Date(2026, 9, 12);
|
||||
const user = { id: 'fixture-a', kind: 'browser' }, other = { id: 'fixture-b', kind: 'browser' };
|
||||
const valid = { birth: '1991-11', gender: '男', region: { province: '浙江省', city: '宁波市' }, skinIndex: 3 };
|
||||
const empty = { birth: '', gender: '', region: null, skinIndex: 0 };
|
||||
assert.equal(Profile.age(valid.birth, now), 34);
|
||||
assert.equal(Profile.age(valid.birth, new Date(2026, 10, 1)), 35);
|
||||
assert.equal(Profile.age(valid.birth, new Date(2027, 9, 1)), 35);
|
||||
for (const birth of [null, '', '1991-1', '1991-00', '1991-13', '1991-11-01', '2026-01', '2027-01', '1926-01']) {
|
||||
assert.equal(Profile.age(birth, now), null, String(birth));
|
||||
}
|
||||
assert.equal(Profile.age('2025-10', now), 1);
|
||||
assert.equal(Profile.age('1926-11', now), 99);
|
||||
assert.deepEqual(Profile.clean(null, regions, 5, now), empty);
|
||||
assert.deepEqual(Profile.clean({ ...valid, token: 'not-persisted', report: {} }, regions, 5, now), valid);
|
||||
assert.deepEqual(Profile.clean({ birth: 'bad', gender: '<script>', region: { province: '北京市', city: '宁波市' }, skinIndex: 5 }, regions, 5, now), empty);
|
||||
for (const skinIndex of [-1, 1.5, '2', NaN]) assert.equal(Profile.clean({ skinIndex }, regions, 5, now).skinIndex, 0);
|
||||
assert.equal(Profile.clean({ gender: '女' }, regions, 5, now).gender, '女', 'partial selections can be saved');
|
||||
assert.equal(Profile.key(null), null);
|
||||
assert.equal(Profile.key({ id: '', kind: 'browser' }), null);
|
||||
assert.equal(Profile.key({ id: 'x', kind: 'unknown' }), null);
|
||||
assert.equal(Profile.key({ id: 'x'.repeat(129), kind: 'browser' }), null);
|
||||
assert.equal(Profile.key({ id: 'a/b', kind: 'wechat' }), 'juzi.profile.v1.wechat.a%2Fb');
|
||||
const data = new Map();
|
||||
const storage = { getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) };
|
||||
const store = Profile.create(() => storage);
|
||||
assert.equal(store.read(user), null);
|
||||
store.write(user, valid);
|
||||
assert.deepEqual(Profile.create(() => storage).read(user), valid, 'survives a page reload');
|
||||
assert.equal(store.read(other), null, 'different accounts are isolated');
|
||||
assert.equal(store.read({ ...user, kind: 'wechat' }), null, 'login kinds are isolated');
|
||||
store.write(other, empty); assert.deepEqual(store.read(user), valid);
|
||||
store.write(null, valid); assert.equal(data.size, 2, 'no unauthenticated writes');
|
||||
for (const raw of ['broken', 'x'.repeat(4097)]) {
|
||||
data.set(Profile.key(user), raw);
|
||||
assert.equal(Profile.create(() => storage).read(user), null, 'corrupt/oversized data is harmless');
|
||||
}
|
||||
const restricted = Profile.create(() => { throw Error('SecurityError'); });
|
||||
assert.equal(restricted.read(user), null);
|
||||
restricted.write(user, valid); assert.deepEqual(restricted.read(user), valid);
|
||||
assert.equal(restricted.read(other), null);
|
||||
const quota = Profile.create(() => ({ getItem: () => JSON.stringify(empty), setItem: () => { throw Error('QuotaExceededError'); } }));
|
||||
quota.write(user, valid); assert.deepEqual(quota.read(user), valid, 'stale disk data cannot overwrite latest in-page selections');
|
||||
console.log('PASS: profile validation, dynamic age, reload persistence, account isolation, corrupt/restricted/quota-limited storage, no credentials/report fields');
|
||||
Reference in New Issue
Block a user