Files
Genarrative/scripts/test-creator-web.cjs
lhk229 51cb05f418
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
补充创作者主页与关注粉丝工程文档 (#638)
本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。

桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。

工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。

验证:
- 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。
- npm run check:encoding 通过。
- npm run check:doc-index 通过。
- git diff --check 与暂存差异检查通过。
- 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。

Reviewed-on: #638
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-10-05 22:05:11 +08:00

259 lines
11 KiB
JavaScript

/** 隔离创作者数据库的真实浏览器验收。先运行 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;
});