/** 隔离创作者数据库的真实浏览器验收。先运行 test-creator-runtime.py,再启动同库 dev:web。 * CREATOR_PLAYWRIGHT_DIR 指向临时安装的 playwright;浏览器、截图与登录信息均不入库。 */ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { createRequire } = require('node:module'); const state = JSON.parse(fs.readFileSync('.app/dev-stack.json', 'utf8')); assert.equal(state.database, 'creator-hub-validation'); const web = state.services.web.url; const api = state.services['api-server'].url; for (const url of [web, api]) assert.equal(new URL(url).hostname, '127.0.0.1'); const runtimeRequire = process.env.CREATOR_PLAYWRIGHT_DIR ? createRequire( path.resolve(process.env.CREATOR_PLAYWRIGHT_DIR, 'package.json'), ) : require; const { chromium } = runtimeRequire('playwright'); async function json(endpoint, token, method = 'GET', body) { const response = await fetch(api + endpoint, { method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, }); assert.ok(response.ok, `${method} ${endpoint}: ${response.status}`); const result = await response.json(); return result.data ?? result; } async function login(n) { return json('/api/auth/entry', null, 'POST', { purePhoneNumber: String(13900006000 + n), password: 'Creator-test-2026', }); } async function waitRows(page, count) { await page.waitForFunction( (n) => document.querySelectorAll('.creator-list > li').length === n, count, ); } (async () => { const [a, b] = await Promise.all([login(1), login(2)]); const browser = await chromium.launch({ headless: true }); const errors = []; try { const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, }); await context.addInitScript( (token) => localStorage.setItem('genarrative.auth.access-token.v1', token), a.token, ); const page = await context.newPage(); page.on('pageerror', (error) => errors.push(error.message)); await page.goto(web + '/creators'); await page.locator('.game-card').first().waitFor(); assert.equal(await page.locator('.game-card').count(), 48); assert.deepEqual( await page .getByRole('navigation', { name: '平台导航', exact: true }) .getByRole('button') .allTextContents(), ['创作', '项目', '游戏', '创作者主页', '我的'], ); assert.equal( await page .locator('.creator-profile') .getByRole('button', { name: '取消关注' }) .count(), 0, ); await page.screenshot({ path: '/tmp/creator-home-desktop.png' }); await page.locator('.game-card').first().click(); await page.locator('.game-detail-page').waitFor(); await page.locator('.game-detail-page .creator-user-link').click(); await page.locator('.creator-counts').waitFor(); assert.equal(new URL(page.url()).searchParams.get('id'), a.user.id); await page .getByRole('navigation', { name: '平台导航', exact: true }) .getByRole('button', { name: '创作者主页' }) .click(); assert.equal(new URL(page.url()).search, ''); // 同舞台切回本人,URL 改变也会重绘。 await page.locator('.creator-counts a').first().click(); await waitRows(page, 20); await page.getByRole('button', { name: '加载更多' }).click(); await waitRows(page, 40); await page.locator('.platform-tab-panel').evaluate((el) => { el.scrollTop = 650; }); await page.waitForTimeout(100); await page.locator('.creator-list .creator-user-link').nth(7).click(); await page.locator('.creator-counts').waitFor(); await page.goBack(); await waitRows(page, 40); assert.ok( await page .locator('.platform-tab-panel') .evaluate((el) => el.scrollTop > 200), ); const first = page.locator('.creator-list > li').first(); const target = new URL( await first.locator('a').getAttribute('href'), web, ).searchParams.get('id'); await first.getByRole('button', { name: '取消关注' }).click(); await first.getByRole('button', { name: '重新关注' }).waitFor(); await first.getByRole('button', { name: '重新关注' }).click(); await first.getByRole('button', { name: '取消关注' }).waitFor(); await first.getByRole('button', { name: '取消关注' }).click(); await first.getByRole('button', { name: '重新关注' }).waitFor(); await page.getByRole('button', { name: '刷新列表' }).click(); await page.waitForFunction( (id) => !document.querySelector(`.creator-list a[href="/creators?id=${id}"]`), target, ); await json(`/api/creators/${target}/follow`, a.token, 'PUT'); // 同一个列表舞台切换到粉丝,验证双向独立和移除确认。 await page .getByRole('navigation', { name: '关系列表' }) .getByRole('link', { name: '粉丝', exact: true }) .click(); await page.getByRole('heading', { name: /我的粉丝/ }).waitFor(); await waitRows(page, 20); const fan = page.locator('.creator-list > li').first(); const fanId = new URL( await fan.locator('a').getAttribute('href'), web, ).searchParams.get('id'); await fan.getByRole('button', { name: '取消关注' }).click(); await fan.getByRole('button', { name: '回关' }).waitFor(); await fan.getByRole('button', { name: '回关' }).click(); await fan.getByRole('button', { name: '取消关注' }).waitFor(); await fan.getByRole('button', { name: '移除粉丝' }).click(); const dialog = page.getByRole('dialog'); await dialog.getByRole('button', { name: '取消', exact: true }).click(); await fan.getByRole('button', { name: '移除粉丝' }).click(); await dialog.getByRole('button', { name: '移除粉丝', exact: true }).click(); await page.waitForFunction( (id) => !document.querySelector(`.creator-list a[href="/creators?id=${id}"]`), fanId, ); const relation = await json(`/api/creators/${fanId}/relationship`, a.token); assert.equal(relation.relationship.isFollowing, true); assert.equal(relation.relationship.isFollowedBy, false); console.log( 'PASS desktop: navigation, author/game links, pagination/back/scroll, unfollow retention, follow-back and remove', ); // 上一步可能移除了 B 的入边,重建只读列表验收所需的双方关系。 await json(`/api/creators/${a.user.id}/follow`, b.token, 'PUT'); await json(`/api/creators/${b.user.id}/follow`, a.token, 'PUT'); // 他人两类列表只有主页链接,不读取访问者与列表用户的关系。 for (const tab of ['following', 'followers']) { const requests = []; const record = (request) => { if (request.url().includes('/relationship')) requests.push(request.url()); }; page.on('request', record); await page.goto(`${web}/creators/connections?id=${b.user.id}&tab=${tab}`); await page.locator('.creator-list > li').first().waitFor(); assert.equal(await page.locator('.creator-list button').count(), 0); assert.equal(requests.length, 0); page.off('request', record); await page.locator('.creator-list a').first().click(); await page.locator('.creator-counts').waitFor(); } await page.goto(`${web}/creators?id=${b.user.id}`); await page.locator('.creator-relation button').first().waitFor(); const follow = page.locator('.creator-relation button').first(); await page.waitForFunction( () => !document.querySelector('.creator-relation button')?.disabled, ); const oldLabel = await follow.innerText(); await follow.click(); await page.waitForFunction( (label) => document.querySelector('.creator-relation button')?.textContent !== label && !document.querySelector('.creator-relation button')?.disabled, oldLabel, ); await follow.click(); console.log( 'PASS public lists: both read-only, no relationship requests, all users link to creator pages', ); await page.setViewportSize({ width: 375, height: 812 }); await page.goto(web + '/creators'); await page.locator('.creator-profile').waitFor(); const welcome = page.getByRole('button', { name: '好', exact: true }); if (await welcome.isVisible()) await welcome.click(); assert.deepEqual( await page .getByRole('navigation', { name: '移动平台导航' }) .getByRole('button') .allTextContents(), ['游戏', '创作者主页', '我的'], ); assert.ok( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ); await page.screenshot({ path: '/tmp/creator-home-mobile.png' }); await page.locator('.creator-counts a').last().click(); await page.locator('.creator-list > li').first().waitFor(); assert.ok( await page.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), ); await page.screenshot({ path: '/tmp/creator-followers-mobile.png' }); await page.locator('.creator-list a').first().focus(); await page.keyboard.press('Enter'); await page.locator('.creator-counts').waitFor(); const guest = await browser.newContext({ viewport: { width: 375, height: 812 }, }); const guestPage = await guest.newPage(); await guestPage.goto(`${web}/creators?id=${b.user.id}`); await guestPage.locator('.creator-profile').waitFor(); const guestWelcome = guestPage.getByRole('button', { name: '好', exact: true, }); if (await guestWelcome.isVisible()) await guestWelcome.click(); for (const tab of ['following', 'followers']) { await guestPage.goto( `${web}/creators/connections?id=${b.user.id}&tab=${tab}`, ); await guestPage.locator('.creator-list > li').first().waitFor(); if (await guestWelcome.isVisible()) await guestWelcome.click(); assert.equal(await guestPage.locator('.creator-list button').count(), 0); await guestPage .getByRole('button', { name: '返回', exact: true }) .click(); await guestPage.locator('.creator-profile').waitFor(); assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id); } await guestPage.getByRole('button', { name: '关注', exact: true }).click(); await guestPage.getByRole('dialog').waitFor(); assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id); assert.deepEqual(errors, []); console.log( 'PASS mobile 375px: three navigation entries, no horizontal overflow, keyboard links, guest login without follow', ); } finally { await browser.close(); } })().catch((error) => { console.error(error); process.exitCode = 1; });