// Real app + actual touch/mouse dispatch with varying UA/capabilities. // Chromium UA regression, not a substitute for HarmonyOS hardware verification. 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)); const dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'juzi-harmony-test-')); let child, browser; let base = process.env.H5_BASE_URL; try { if (!base) { base = 'http://127.0.0.1:18116/'; child = spawn(process.execPath, ['server.js'], { cwd: h5, env: { ...process.env, PORT: '18116', HOST: '127.0.0.1', JUZI_DATA_ROOT: dataRoot, PUBLIC_BASE_URL: '', WECHAT_APP_ID: '', WECHAT_APP_SECRET: '', SKIN_API_KEY: 'fixture', SKIN_API_SECRET: 'fixture' }, stdio: ['ignore', 'pipe', 'pipe'] }); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('test startup timeout')), 10000); child.stdout.on('data', b => { if (b.toString().includes('已启动')) { clearTimeout(timer); resolve(); } }); child.once('exit', code => { clearTimeout(timer); reject(Error('server exited ' + code)); }); }); } browser = await chromium.launch({ headless: true }); const android = 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36'; const desktop = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36'; const cases = [ { name: 'Android touch', ua: android, touch: true }, { name: 'OpenHarmony 5 touch', ua: 'Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36 ArkWeb/4.1.6.1 Mobile', touch: true }, { name: 'OpenHarmony 6 WeChat touch', ua: 'Mozilla/5.0 (Phone;OpenHarmony 6.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 ArkWeb/6.0.0.42 Mobile MicroMessenger/8.0', touch: true }, { name: 'Desktop UA on touch hardware', ua: desktop, touch: true }, { name: 'Desktop mouse without TouchEvent', ua: desktop, touch: false }, { name: 'Android UA without touch hardware', ua: android, touch: false } ]; for (const fixture of cases) { const context = await browser.newContext({ viewport: { width: 375, height: 726 }, isMobile: fixture.touch, hasTouch: fixture.touch, userAgent: fixture.ua }); const page = await context.newPage(), errors = [], writes = []; page.setDefaultTimeout(30000); page.on('pageerror', e => errors.push(e.message)); if (!fixture.touch) await page.addInitScript(() => { delete window.TouchEvent; }); await page.route('**/*', route => { const req = route.request(); if (req.method() !== 'GET') { writes.push(req.url()); 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: { id: 'harmony-picker-fixture', kind: 'wechat' } } }); return route.continue(); }); await page.route('**/mobile-select.iife.min.js', async route => { const response = await route.fetch(); await route.fulfill({ response, body: await response.text() + ';var OriginalMobileSelect=MobileSelect;window.__pickers={};MobileSelect=function(c){var p=new OriginalMobileSelect(c);window.__pickers[c.trigger]=p;return p;};' }); }); await page.goto(base, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#s-splash.active'); await page.click('[data-action="open-privacy"]'); await page.click('#privacy-check'); await page.click('[data-action="agree-continue"]'); await page.waitForSelector('#s-profile.active'); const cdp = await context.newCDPSession(page); for (const trigger of ['#pick-birth', '#pick-region']) { await page.click(trigger); await page.waitForTimeout(350); const events = await page.evaluate(t => __pickers[t].eventHandleMap.panel.event, trigger); assert.deepEqual(events, fixture.touch ? ['touchstart', 'touchend', 'touchmove'] : ['mousedown', 'mousemove', 'mouseup'], fixture.name); assert.equal(await page.locator('.ms-mobile-select.ms-show').evaluate(el => !!el.closest('#s-profile')), false, 'profile scroll lock cannot intercept picker touch'); for (const column of [0, 1]) { if (trigger === '#pick-region' && column === 1) await page.evaluate(() => __pickers['#pick-region'].locatePosition(0, REGION_DATA.findIndex(p => p[0] === '浙江省'))); const before = await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger); const wheel = page.locator('.ms-mobile-select.ms-show .ms-wheel').nth(column), box = await wheel.boundingBox(); const x = box.x + box.width / 2, y = box.y + box.height / 2; if (fixture.touch) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] }); for (let i = 1; i <= 4; i++) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - i * 20, id: 1 }] }); await page.waitForTimeout(25); } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); } else { await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x, y - 80, { steps: 4 }); await page.mouse.up(); } await page.waitForTimeout(250); assert.notEqual(await page.evaluate(t => JSON.stringify(__pickers[t].getCurValue()), trigger), before, fixture.name + '/' + trigger + '/' + column + ' must move'); } const chosen = await page.evaluate(t => __pickers[t].getCurValue(), trigger); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.waitForTimeout(250); if (trigger === '#pick-birth') { const value = parseInt(chosen[0]) + '-' + String(parseInt(chosen[1])).padStart(2, '0'); assert.equal(await page.locator('#f-birth').inputValue(), value); } else { const value = await page.locator('#f-region').inputValue(); assert(value.includes(chosen[0].value) && value.includes(chosen[1].value)); } await page.click(trigger); await page.waitForTimeout(300); assert.deepEqual(await page.evaluate(t => __pickers[t].getCurValue(), trigger), chosen, 'reopening retains confirmed values'); await page.click('.ms-mobile-select.ms-show .ms-cancel'); await page.waitForTimeout(250); } await page.click('#pick-gender'); await page.click('.ms-mobile-select.ms-show .ms-ensure'); await page.click('[data-action="submit-profile"]'); await page.waitForSelector('#p-photoguide.show'); assert.deepEqual(errors, []); assert.deepEqual(writes, []); console.log('PASS ' + fixture.name + ': year/month/province/city drag, cascade, confirm/reopen, profile validation'); await context.close(); } } finally { if (browser) await browser.close(); if (child && child.exitCode == null) { const stopped = once(child, 'exit'); child.kill(); await stopped; } fs.rmSync(dataRoot, { recursive: true, force: true }); }