353 lines
14 KiB
JavaScript
353 lines
14 KiB
JavaScript
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
// Reusable UI regression with synthetic API data; never connects to a live ledger.
|
|
// Start Vite first. Set WORTHPATH_PLAYWRIGHT_MODULE if Playwright is not resolvable.
|
|
const { chromium } = require(process.env.WORTHPATH_PLAYWRIGHT_MODULE || 'playwright');
|
|
const baseUrl = process.env.WORTHPATH_UI_URL || 'http://127.0.0.1:5175';
|
|
const output =
|
|
process.env.WORTHPATH_UI_OUTPUT || fs.mkdtempSync(path.join(os.tmpdir(), 'worthpath-ui-'));
|
|
fs.mkdirSync(output, { recursive: true });
|
|
const day = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Hong_Kong' }).format(new Date());
|
|
const user = {
|
|
username: 'UI预览',
|
|
role: 'admin',
|
|
baseCurrency: 'CNY',
|
|
hiddenMenus: [],
|
|
showNotes: true,
|
|
idleMinutes: 0,
|
|
revealed: false,
|
|
requireHiddenPassword: false,
|
|
};
|
|
let failNext = false;
|
|
let accounts = ['日常账户', '储蓄账户', '信用卡'].map((name, index) => ({
|
|
id: `fixture-${index}`,
|
|
name,
|
|
kind: 'account',
|
|
side: index === 2 ? 'liability' : 'asset',
|
|
category: index === 2 ? 'credit_card' : 'bank',
|
|
currency: 'CNY',
|
|
amount: String((index + 1) * 100),
|
|
groupName: index === 2 ? '信用账户' : '日常',
|
|
hidden: false,
|
|
archived: false,
|
|
included: true,
|
|
outgoing: [],
|
|
notes: '',
|
|
converted: '100',
|
|
activityDate: index === 0 ? day : null,
|
|
}));
|
|
(async () => {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
channel: process.env.WORTHPATH_BROWSER_CHANNEL || 'msedge',
|
|
});
|
|
try {
|
|
const page = await browser.newPage({
|
|
viewport: { width: 390, height: 844 },
|
|
isMobile: true,
|
|
hasTouch: true,
|
|
deviceScaleFactor: 1,
|
|
});
|
|
const errors = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
await page.route('**/api/**', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const endpoint = url.pathname.replace('/api', '');
|
|
if (failNext && endpoint === '/positions') {
|
|
failNext = false;
|
|
await route.fulfill({ status: 400, json: { error: '浏览器验收错误' } });
|
|
return;
|
|
}
|
|
let body = {};
|
|
if (endpoint === '/auth/me') body = user;
|
|
else if (endpoint === '/settings') body = { ...user, rates: [], fxStatus: null };
|
|
else if (endpoint === '/overview')
|
|
body = {
|
|
date: day,
|
|
assets: '600',
|
|
liabilities: '300',
|
|
net: '300',
|
|
complete: true,
|
|
missing: [],
|
|
items: [],
|
|
baseCurrency: 'CNY',
|
|
revealed: false,
|
|
};
|
|
else if (endpoint === '/positions')
|
|
body = url.searchParams.get('kind') === 'account' ? accounts : [];
|
|
else if (endpoint === '/schedules/run') body = { executed: 0, errors: [], hasMore: false };
|
|
else if (endpoint === '/schedules') body = [];
|
|
else if (endpoint === '/admin/users') body = { items: [], total: 0 };
|
|
else if (endpoint === '/calendar')
|
|
body = {
|
|
items: Array.from({ length: 31 }, (_, i) => ({
|
|
date: day.slice(0, 7) + '-' + String(i + 1).padStart(2, '0'),
|
|
income: '0',
|
|
expense: '0',
|
|
complete: true,
|
|
})),
|
|
baseCurrency: 'CNY',
|
|
};
|
|
else if (['/trend', '/history', '/transfers', '/calendar/day'].includes(endpoint))
|
|
body = { items: [], nextCursor: null, revealed: false };
|
|
await route.fulfill({ json: body });
|
|
});
|
|
await page.goto(baseUrl);
|
|
await page.locator('.primary-nav').waitFor();
|
|
const nav = page.locator('.primary-nav');
|
|
assert.deepEqual(await nav.locator('button').allTextContents(), [
|
|
'总览',
|
|
'日历',
|
|
'账号',
|
|
'设置',
|
|
]);
|
|
const checkWidth = async (label) => {
|
|
await page
|
|
.locator('[role="status"]')
|
|
.filter({ hasText: '正在刷新数据' })
|
|
.waitFor({ state: 'hidden' });
|
|
const widths = await page.evaluate(() => ({
|
|
page: document.documentElement.scrollWidth,
|
|
viewport: innerWidth,
|
|
}));
|
|
assert.ok(
|
|
widths.page <= widths.viewport,
|
|
`${label} horizontal overflow: ${JSON.stringify(widths)}`,
|
|
);
|
|
};
|
|
await checkWidth('overview');
|
|
await nav.getByRole('button', { name: '账号', exact: true }).click();
|
|
await page.getByRole('heading', { name: '日常账户', exact: true }).waitFor();
|
|
await checkWidth('accounts');
|
|
await page.screenshot({
|
|
path: path.join(output, 'worthpath-mobile-accounts.png'),
|
|
fullPage: true,
|
|
});
|
|
await page.getByRole('button', { name: '快速记账', exact: true }).click();
|
|
assert.equal(
|
|
await page.getByRole('button', { name: '标记当天已记账', exact: true }).count(),
|
|
0,
|
|
);
|
|
await page.getByRole('button', { name: '隐藏当天有记录', exact: true }).click();
|
|
assert.equal(await page.getByRole('heading', { name: '日常账户', exact: true }).count(), 0);
|
|
await page.getByRole('button', { name: '显示当天有记录', exact: true }).click();
|
|
assert.equal(await page.getByRole('heading', { name: '日常账户', exact: true }).count(), 1);
|
|
for (const width of [320, 375, 390, 430, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 844 });
|
|
await checkWidth(`quick ${width}`);
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.screenshot({
|
|
path: path.join(output, 'worthpath-mobile-quick.png'),
|
|
fullPage: true,
|
|
});
|
|
await page
|
|
.locator('.section-nav')
|
|
.getByRole('button', { name: '独立资产', exact: true })
|
|
.click();
|
|
assert.equal(await nav.locator('[aria-current="page"]').textContent(), '账号');
|
|
await page.locator('.section-nav').getByRole('button', { name: '债务', exact: true }).click();
|
|
await checkWidth('debt');
|
|
await nav.getByRole('button', { name: '设置', exact: true }).click();
|
|
await page.locator('.settings-category select').selectOption('privacy');
|
|
await checkWidth('settings');
|
|
await page.screenshot({
|
|
path: path.join(output, 'worthpath-mobile-settings.png'),
|
|
fullPage: true,
|
|
});
|
|
await page
|
|
.locator('.section-nav')
|
|
.getByRole('button', { name: '定时计划', exact: true })
|
|
.click();
|
|
await checkWidth('plans');
|
|
assert.equal(await nav.locator('[aria-current="page"]').textContent(), '设置');
|
|
await page
|
|
.locator('.section-nav')
|
|
.getByRole('button', { name: '管理员后台', exact: true })
|
|
.click();
|
|
await checkWidth('admin');
|
|
await nav.getByRole('button', { name: '日历', exact: true }).click();
|
|
await page.locator('.calendar-day').first().waitFor();
|
|
await checkWidth('calendar');
|
|
await page.screenshot({
|
|
path: path.join(output, 'worthpath-mobile-calendar.png'),
|
|
fullPage: true,
|
|
});
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
assert.equal(
|
|
await nav
|
|
.locator('button')
|
|
.first()
|
|
.evaluate((el) => getComputedStyle(el).transitionDuration),
|
|
'0s',
|
|
);
|
|
|
|
await page.emulateMedia({ reducedMotion: 'no-preference', colorScheme: 'light' });
|
|
await nav.getByRole('button', { name: '账号', exact: true }).click();
|
|
await page.getByRole('button', { name: '退出快速记账', exact: true }).click();
|
|
await page.getByRole('button', { name: '快速记账', exact: true }).click();
|
|
await page.locator('.position-card').first().click();
|
|
const modal = page.locator('dialog.native-modal[open]');
|
|
await modal.waitFor();
|
|
assert.equal(await modal.evaluate((el) => el.contains(document.activeElement)), true);
|
|
await page.keyboard.press('Escape');
|
|
await modal.waitFor({ state: 'hidden' });
|
|
assert.equal(await page.evaluate(() => document.documentElement.dataset.input), 'keyboard');
|
|
assert.equal(
|
|
await nav
|
|
.locator('button')
|
|
.first()
|
|
.evaluate((el) => getComputedStyle(el).transitionDuration),
|
|
'0s',
|
|
);
|
|
const demo = accounts;
|
|
const worst = accounts.map((account, i) => ({
|
|
...account,
|
|
name: [
|
|
'家庭长期退休储备与跨境投资专用账户(用于记录定期存款、货币基金、证券组合及年度家庭应急备用金,请勿与日常消费账户混用)'
|
|
.repeat(2)
|
|
.slice(0, 100),
|
|
'Christopher Alexander Montgomery III · Hong Kong investment reserve',
|
|
'J',
|
|
][i],
|
|
groupName: ['跨境投资与长期退休储备账户(不参与日常消费流水)', '消费', '信用'][i],
|
|
amount: ['9999999999999999.98765432', '-42.5', '0'][i],
|
|
notes:
|
|
i === 0
|
|
? 'https://example.com/workspaces/household/accounts/international-reserve?tab=transactions&filter=long-term'
|
|
: '',
|
|
}));
|
|
const fixtures = {
|
|
Demo: demo,
|
|
'Worst case': worst,
|
|
Empty: [],
|
|
One: [worst[0]],
|
|
'1,000 rows': Array.from({ length: 1000 }, (_, i) => ({
|
|
...demo[i % 3],
|
|
id: 'bulk-' + i,
|
|
name: '家庭账户 ' + (i + 1),
|
|
})),
|
|
};
|
|
// Dev-only fixture picker: injected into this isolated browser session, never production.
|
|
await page.exposeFunction('chooseFixture', async (name) => {
|
|
accounts = fixtures[name];
|
|
});
|
|
await page.addInitScript(() =>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const panel = document.createElement('div');
|
|
panel.id = 'fixture-picker';
|
|
panel.style.cssText =
|
|
'position:fixed;bottom:100px;left:50%;transform:translateX(-50%);z-index:9999;background:#ddd;padding:4px;border-radius:8px;display:flex;gap:3px;font:11px system-ui;max-width:95%;';
|
|
for (const name of ['Demo', 'Worst case', 'Empty', 'One', '1,000 rows']) {
|
|
const button = document.createElement('button');
|
|
button.textContent = name;
|
|
button.style.cssText =
|
|
'font:inherit;border:0;border-radius:5px;padding:6px;background:white;color:#1d1d1f;';
|
|
button.onclick = async () => {
|
|
await window.chooseFixture(name);
|
|
const url = new URL(location.href);
|
|
url.searchParams.set('data', name);
|
|
location.href = url;
|
|
};
|
|
panel.append(button);
|
|
}
|
|
document.body.append(panel);
|
|
}),
|
|
);
|
|
for (const [name, values] of Object.entries(fixtures)) {
|
|
accounts = values;
|
|
await page.evaluate(() => localStorage.setItem('worthpath.locale', 'zh-Hans'));
|
|
await page.goto(baseUrl + '/?data=' + encodeURIComponent(name));
|
|
await page.locator('.primary-nav').waitFor();
|
|
await page.locator('.primary-nav').getByRole('button', { name: '账号', exact: true }).click();
|
|
await page.getByRole('button', { name: '快速记账', exact: true }).click();
|
|
await page
|
|
.locator('[role="status"]')
|
|
.filter({ hasText: '正在刷新数据' })
|
|
.waitFor({ state: 'hidden' });
|
|
for (const width of [320, 390, 1440]) {
|
|
await page.setViewportSize({ width, height: 844 });
|
|
await checkWidth(name + ' ' + width);
|
|
}
|
|
assert.equal(await page.locator('.position-card').count(), values.length);
|
|
if (name === 'Worst case') {
|
|
for (const scheme of ['light', 'dark']) {
|
|
await page.emulateMedia({ colorScheme: scheme });
|
|
for (const locale of ['zh-Hans', 'zh-Hant', 'en']) {
|
|
await page.locator('.language-picker select').selectOption(locale);
|
|
await page.setViewportSize({ width: 320, height: 844 });
|
|
await checkWidth(scheme + ' ' + locale);
|
|
await page.evaluate(() => {
|
|
document.documentElement.style.fontSize = '200%';
|
|
});
|
|
await checkWidth('200% ' + scheme + ' ' + locale);
|
|
await page.evaluate(() => {
|
|
document.documentElement.style.fontSize = '';
|
|
});
|
|
}
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.locator('#fixture-picker').evaluate((el) => {
|
|
el.style.visibility = 'hidden';
|
|
});
|
|
await page.screenshot({
|
|
path: path.join(output, 'worthpath-mobile-worst-dark.png'),
|
|
fullPage: true,
|
|
});
|
|
await page.locator('#fixture-picker').evaluate((el) => {
|
|
el.style.visibility = '';
|
|
});
|
|
}
|
|
}
|
|
console.log(
|
|
'PASS: native dialog focus/Escape, keyboard no motion; plausible schema-limit names and amounts, empty/one/1,000 records, light/dark, three locales, 200% text.',
|
|
);
|
|
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await cdp.send('Emulation.setEmulatedMedia', {
|
|
features: [{ name: 'prefers-reduced-transparency', value: 'reduce' }],
|
|
});
|
|
assert.equal(
|
|
await page.locator('.sidebar').evaluate((el) => getComputedStyle(el).backdropFilter),
|
|
'none',
|
|
);
|
|
await cdp.send('Emulation.setEmulatedMedia', {
|
|
features: [{ name: 'prefers-contrast', value: 'more' }],
|
|
});
|
|
assert.equal(
|
|
await page.locator('.topbar').evaluate((el) => getComputedStyle(el).backdropFilter),
|
|
'none',
|
|
);
|
|
assert.equal(
|
|
await page.locator('html').evaluate((el) => getComputedStyle(el).scrollbarWidth),
|
|
'auto',
|
|
);
|
|
await cdp.send('Emulation.setEmulatedMedia', { features: [] });
|
|
assert.equal(
|
|
await page.locator('html').evaluate((el) => getComputedStyle(el).scrollbarWidth),
|
|
'thin',
|
|
);
|
|
failNext = true;
|
|
await page.locator('.topbar [aria-label="刷新数据"]').click();
|
|
const toast = page.locator('.toast').last();
|
|
await toast.waitFor();
|
|
assert.equal(await toast.evaluate((el) => getComputedStyle(el).animationName), 'none');
|
|
await toast.getByRole('button').click();
|
|
await toast.waitFor({ state: 'hidden' });
|
|
console.log(
|
|
'PASS: reduced transparency, higher contrast and interruptible toast entry/dismissal.',
|
|
);
|
|
assert.deepEqual(errors, []);
|
|
console.log(
|
|
'PASS: four entries, child navigation, daily activity filtering, six viewport widths, calendar/settings/admin and reduced motion; zero browser errors. Screenshots use synthetic data.',
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|