assistant/scripts/test-platform-paths-ui.mjs

137 lines
15 KiB
JavaScript

// Memory-only UI acceptance. No user browser, platform, model or real API calls.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { chromium } from 'playwright';
import { createServer } from '../apps/admin/node_modules/vite/dist/node/index.js';
const root = path.resolve(import.meta.dirname, '..');
const output = path.join(root, '.tmp', `platform-paths-ui-${Date.now()}`);
fs.mkdirSync(output, { recursive: true });
const checks = [], errors = [], forbidden = [], requests = [], taskPageArtifactRequests = [];
const profileId = 'abcdef123456abcdef123456';
const origin = 'https://www.xiaohongshu.com';
const secret = 'PRIVATE_ORIGINAL_不应显示的原文与昵称';
const authorSelector = '#noteContainer > div:nth-of-type(4) > div:nth-of-type(1) > div > div:nth-of-type(1) > a';
const baseTask = { id: 'learn_inventory', name: '小红书功能路径盘点', goal: '观察可见入口', platform: 'xiaohongshu', entry_url: `${origin}/explore`, parameters: {}, status: 'observing', account_key: secret, created_at: '2026-10-25T08:00:00Z' };
const element = (selector, label, extra = {}) => ({ selector, label, sensitive: false, in_viewport: true, kind: 'read', ...extra });
const capture = (id, feature, url, elements, extra = {}) => ({ id, note: `coverage:${feature}`, url, elements, text: secret, title: secret, created_at: '2026-10-25T09:00:00Z', screenshot_url: `/api/learning/artifacts/evidence_${id}`, ...extra });
const observations = [
capture('home', 'home.explore', `${origin}/explore`, [element('#search-input', '搜索')]),
capture('profile', 'profile.other', `${origin}/user/profile/${profileId}`, [element('.user-name', secret)]),
capture('follow', 'profile.follow_entry', `${origin}/user/profile/${profileId}`, [element('.reds-button', '关注', { kind: 'click', tag: 'button' })]),
capture('message', 'profile.message_entry', `${origin}/user/profile/${profileId}`, [element('.reds-button-secondary', '私信', { kind: 'click', tag: 'button' })]),
capture('wrong_page', 'search.notes', `${origin}/explore`, [element('#search-input', '搜索')]),
capture('note_only', 'comment.replies', `${origin}/explore/${profileId}`, [element('.content', '普通正文')]),
capture('hidden', 'search.filters', `${origin}/search_result?keyword=private`, [element('.filter', '筛选', { in_viewport: false })]),
capture('wrong_origin', 'notifications', `https://example.test/notification`, [element('.notification', '通知')]),
capture('unsafe', 'messages', `${origin}/explore`, [element('.chat-entry', '消息')], { screenshot_url: 'https://example.test/private.png' }),
capture('no_shot', 'own.profile', `${origin}/explore`, [element('.user-side-bar', '我')], { screenshot_url: undefined }),
capture('not_own', 'own.works', `${origin}/user/profile/${profileId}`, [element('.note-tab', '笔记')]),
capture('failure', 'menu.help', `${origin}/explore`, [element('.help', '帮助')], { action_failure: { moment: 'after', phase: 'click', code: 'TIMEOUT', attempt_id: 'attempt' } }),
capture('sensitive', 'menu.settings', `${origin}/explore`, [element('.settings', '设置', { sensitive: true })]),
capture('external', 'menu.creator', `${origin}/explore`, [element('.creator-entry', '创作中心')]),
capture('navigation', 'home.ai|home.red|home.live|publish.entry|messages', `${origin}/explore`, [element('a:nth-of-type(1)', '点点 ai'), element('a:nth-of-type(2)', 'RED'), element('a:nth-of-type(3)', '直播'), element('a:nth-of-type(4)', '发布'), element('a:nth-of-type(5)', '2 消息')]),
capture('ai_search', 'search.users', `${origin}/search_result_ai?keyword=private`, [element('.user-tab', '用户')]),
capture('unknown_tag', 'menu.settings|invalid.id', `${origin}/explore`, [element('.settings', '设置')]),
capture('duplicate_tag', 'menu.more|menu.more', `${origin}/explore`, [element('.more-menu', '更多')]),
capture('oversize_tag', 'home.explore|home.ai|home.red|home.live|publish.entry|messages|menu.help|menu.settings|menu.more', `${origin}/explore`, [element('.help', '帮助')]),
capture('author_entry', 'note.author_profile', `${origin}/explore/${profileId}`, [element(authorSelector, secret, { kind: 'click', tag: 'a', unique: true })]),
capture('commenter_not_author', 'note.author_profile', `${origin}/explore/${profileId}`, [element(`#comment-${profileId} > div:nth-of-type(4) > div:nth-of-type(1) > div > div:nth-of-type(1) > a`, 'author', { kind: 'click', tag: 'a', unique: true })]),
capture('author_wrong_page', 'note.author_profile', `${origin}/user/profile/${profileId}`, [element(authorSelector, secret, { kind: 'click', tag: 'a', unique: true })]),
capture('author_hidden', 'note.author_profile', `${origin}/explore/${profileId}`, [element(authorSelector, secret, { kind: 'click', tag: 'a', unique: true, in_viewport: false })]),
capture('arbitrary_author_nickname', 'note.author_profile', `${origin}/explore/${profileId}`, [element('.ordinary-link', 'author user-info user/profile', { kind: 'click', tag: 'a', unique: true })]),
capture('selector_as_nickname', 'note.author_profile', `${origin}/explore/${profileId}`, [element('.ordinary-link', authorSelector, { kind: 'click', tag: 'a', unique: true })]),
capture('author_not_unique', 'note.author_profile', `${origin}/explore/${profileId}`, [element(authorSelector, secret, { kind: 'click', tag: 'a', unique: false })]),
];
const routeTask = { ...baseTask, id: 'learn_routes', name: '只读链路', goal: '只读能力', observations: [] };
const candidates = ['search', 'detail'].map((id, index) => ({ candidate_id: id, task_id: routeTask.id, name: id, version: 1, digest: (index ? 'b' : 'a').repeat(64), platform: 'xiaohongshu', execution_profile: 'playwright-xhs-native-ui-v1', source_origin: origin, ability: index ? 'read_note_comments' : 'search_notes', static_status: 'passed', static_errors: [], trial_status: 'passed', maintenance_status: 'experimental', published: false }));
const ref = candidate => ({ candidate_id: candidate.candidate_id, digest: candidate.digest, task_id: candidate.task_id });
const stage = (id, candidate) => ({ id, task_id: routeTask.id, candidate_id: candidate, kind: 'browser', status: 'passed', mode: 'exploration' });
const goodRoute = { id: 'validation_route', task_id: routeTask.id, candidate_id: 'search', kind: 'route', status: 'passed', mode: 'exploration', route_status: 'completed', published: false, search_validation_id: 'stage_search', detail_validation_id: 'stage_detail', route: { platform: 'xiaohongshu', task_type: 'search_note_comments', search: ref(candidates[0]), detail: ref(candidates[1]) }, keyword: secret, selected: { note_url: `${origin}/explore/${profileId}`, note_title: secret } };
const validations = [stage('stage_search', 'search'), stage('stage_detail', 'detail'), goodRoute, { ...goodRoute, id: 'validation_forged', detail_validation_id: 'missing' }];
let server, browser, page, detailFails = false, taskNavigationStarted = false;
try {
server = await createServer({ configFile: path.join(root, 'apps/admin/vite.config.ts'), server: { host: '127.0.0.1', port: 0, strictPort: false } });
await server.listen(); const base = `http://127.0.0.1:${server.httpServer.address().port}`;
browser = await chromium.launch({ channel: process.platform === 'win32' ? 'msedge' : 'chromium', headless: true });
page = await browser.newPage({ viewport: { width: 1280, height: 1000 } });
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', async route => {
const request = route.request(), url = new URL(request.url());
if (url.origin !== base) { forbidden.push(url.origin + url.pathname); return route.abort(); }
if (!url.pathname.startsWith('/api/')) return route.continue();
if (url.pathname.startsWith('/api/learning/artifacts/') && taskNavigationStarted) {
taskPageArtifactRequests.push(url.pathname);
return route.fulfill({ contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64') });
}
const item = { method: request.method(), path: url.pathname }; requests.push(item);
if (item.method !== 'GET') { forbidden.push(item); return route.abort(); }
let data, status = 200;
if (url.pathname === '/api/auth/status') data = { initialized: true, authenticated: true, csrf_token: 'fixture', user: { username: 'maintainer' } };
else if (url.pathname === '/api/learning/tasks') data = { items: [baseTask, routeTask, { ...baseTask, id: 'ordinary', name: '普通任务', goal: '其他' }, { ...baseTask, id: 'other_platform', platform: 'douyin' }] };
else if (url.pathname === '/api/learning/routes') data = { items: candidates };
else if (url.pathname === '/api/learning/validations') data = { items: validations };
else if (url.pathname === '/api/learning/tasks/learn_inventory') { data = detailFails ? { detail: '夹具读回失败' } : { ...baseTask, observations }; status = detailFails ? 503 : 200; }
else { forbidden.push(item); return route.abort(); }
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) });
});
await page.goto(`${base}/#/platform-paths`);
await page.getByTestId('platform-path-home.explore').waitFor();
assert.equal(await page.getByTestId('nav-platform-paths').getAttribute('aria-current'), 'page');
assert.equal(requests.some(item => item.path.endsWith('/ordinary') || item.path.endsWith('/other_platform') || item.path.endsWith('/learn_routes')), false);
checks.push('independent learning navigation; only XHS tasks named for functional inventory load detail');
assert.match(await page.getByTestId('platform-path-home.explore').innerText(), /已观察页面[\s\S]*截图证据/);
assert.match(await page.getByTestId('platform-coverage-summary').innerText(), /已观察\s*12\s*项/);
assert.equal(await page.locator('[aria-label="发现首页下级入口"]').count(), 1);
checks.push('visible functional hierarchy and observed count derive from matching official page, element and artifact evidence');
for (const id of ['search.notes', 'comment.replies', 'search.filters', 'notifications', 'own.profile', 'own.works', 'menu.help', 'menu.settings', 'menu.more']) assert.match(await page.getByTestId(`platform-path-${id}`).innerText(), /待探索/);
assert.match(await page.getByTestId('platform-path-search.users').innerText(), /已观察入口/);
assert.match(await page.getByTestId('platform-path-note.author_profile').innerText(), /已观察入口[\s\S]*1 份观察/);
checks.push('bounded composite tags and AI-search URL supported; author entry needs unique visible fixed note-layout anchor; commenter, non-note, hidden, arbitrary nickname and nonunique anchors add no evidence; other invalid evidence remains pending');
for (const id of ['profile.follow_entry', 'profile.message_entry']) assert.match(await page.getByTestId(`platform-path-${id}`).innerText(), /已观察入口[\s\S]*不代表已关注、已发送或链路已固化/);
assert.match(await page.getByTestId('platform-path-menu.creator').innerText(), /外站未进入/);
checks.push('follow/message observations remain entry-only and creator offsite is explicitly unentered');
assert.match(await page.getByTestId('platform-reusable-evidence').innerText(), /1 条同任务/);
assert.equal(await page.getByTestId('platform-reusable-evidence').getByRole('button').count(), 1);
checks.push('route evidence requires matching immutable candidate pair and actual same-task passed stage references');
const text = await page.getByTestId('platform-paths').innerText();
assert.equal(text.includes(secret), false); assert.equal(text.includes(profileId), false);
assert.equal(await page.getByTestId('platform-paths').locator('img').count(), 0);
assert.equal(requests.some(item => /artifacts|browser|analysis|confirm/.test(item.path)), false);
checks.push('original text, nicknames, personal URL IDs and private images stay out of inventory rendering and requests');
await page.getByLabel('路径观察状态').click(); await page.locator('.ant-select-dropdown:visible').getByText('已观察', { exact: true }).click();
await page.getByTestId('platform-path-search.notes').waitFor({ state: 'hidden' });
assert.equal(await page.locator('[data-testid^="platform-path-"]').count(), 12);
await page.getByLabel('搜索平台路径').fill('私信按钮');
assert.equal(await page.locator('[data-testid^="platform-path-"]').count(), 1);
await page.getByLabel('搜索平台路径').fill('');
await page.getByLabel('路径观察状态').click(); await page.locator('.ant-select-dropdown:visible').getByText('全部状态', { exact: true }).click();
checks.push('status and functional search filters preserve accurate evidence status');
detailFails = true; await page.getByTestId('platform-paths-refresh').click(); await page.getByText('1 个盘点任务证据未读回', { exact: true }).waitFor();
assert.match(await page.getByTestId('platform-path-home.explore').innerText(), /待探索/);
detailFails = false; await page.getByTestId('platform-paths-refresh').click(); await page.getByTestId('platform-proof-home.explore').waitFor();
checks.push('failed evidence refresh does not retain stale observed status; explicit refresh restores supported observations');
taskNavigationStarted = true;
await page.getByTestId('platform-path-profile.follow_entry').getByRole('button', { name: '查看学习证据' }).click();
assert.match(page.url(), /#\/tasks\/learn_inventory$/);
// Navigation changed the URL; stop task-detail API loading by returning to the
// inventory before testing layout. The destination itself is checked above.
await page.evaluate(() => { location.hash = '/platform-paths'; }); await page.getByTestId('platform-proof-home.explore').waitFor();
checks.push('each observed item links to its actual learning task without storing or showing personal identifiers');
await page.screenshot({ path: path.join(output, 'platform-paths-desktop.png'), fullPage: false });
await page.setViewportSize({ width: 390, height: 900 });
const geometry = await page.getByTestId('platform-paths').evaluate(element => { const rect = element.getBoundingClientRect(); return { left: rect.left, right: rect.right, overflow: element.scrollWidth > element.clientWidth + 2 }; });
assert.ok(geometry.left >= -1 && geometry.right <= 391); assert.equal(geometry.overflow, false);
await page.getByTestId('platform-path-profile.message_entry').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(output, 'platform-paths-mobile.png'), fullPage: false });
checks.push('390px hierarchy, long boundary copy and controls remain within viewport');
assert.deepEqual(errors, []); assert.deepEqual(forbidden, []);
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify({ passed: true, checks, errors, forbidden, requests, taskPageArtifactRequests, geometry, real_api_requests: 0, platform_requests: 0, model_calls: 0, writes: 0 }, null, 2));
console.log(JSON.stringify({ passed: true, checks: checks.length, report: path.relative(root, path.join(output, 'summary.json')) }));
} catch (error) {
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify({ passed: false, checks, errors, forbidden, error: error.stack, requests }, null, 2));
await page?.screenshot({ path: path.join(output, 'failure.png'), fullPage: true }).catch(() => {});
console.log(JSON.stringify({ passed: false, checks: checks.length, error: error.message, report: path.relative(root, path.join(output, 'summary.json')) })); process.exitCode = 1;
} finally { await browser?.close(); await server?.close(); }