From fbeb709aeb570e01b52d0449af1293664a611b51 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 18:54:55 +0800 Subject: [PATCH 1/8] =?UTF-8?q?test(twitter):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=80=99=E9=80=89=E4=BA=A4=E6=8E=A5=E5=B9=B6=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E9=87=8D=E5=BC=80=E5=90=8E=E7=9A=84=E6=8C=81=E4=B9=85=E9=98=85?= =?UTF-8?q?=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写入前核对运行前提与关联 Peer,保留分阶段运行记录 - 用新页面读回正文并核对固定快照的 MF 资源 - 记录 fixture 与信息库清理责任,保留显式专项入口 --- docs/40-deployment/development-runtime.md | 29 +++- scripts/tests/twitter-info-base.spec.ts | 176 +++++++++++++++++++++- 2 files changed, 199 insertions(+), 6 deletions(-) diff --git a/docs/40-deployment/development-runtime.md b/docs/40-deployment/development-runtime.md index a7164825..c45f27aa 100644 --- a/docs/40-deployment/development-runtime.md +++ b/docs/40-deployment/development-runtime.md @@ -162,5 +162,30 @@ pnpm verify:twitter Twitter 验收需要预先启动且兼容的 Core、PostgREST、Web 和 Twitter MF 产物,并设置 `INKCRE_E2E_CORE_URL`、`INKCRE_E2E_POSTGREST_URL`、`INKCRE_E2E_WEB_URL`、 -`INKCRE_E2E_JWT_SECRET`。它会安装或启用浏览器 Peer 的 Twitter 扩展、写入测试数据并提交词法索引 Job, -不会自动清理数据;应使用独立验收信息库。截图保存在 `test-results/twitter-acceptance/`。 +`INKCRE_E2E_JWT_SECRET`,以及 `INKCRE_E2E_CORE_SOURCE_SHA` 和 `INKCRE_E2E_INFO_BASE_OWNER`。 +后两项分别来自 Python 交付 owner 报告的目标源码,以及本次独占信息库的生命周期责任标识(例如 Python PR)。 +`--list` 不要求这些输入;实际运行缺输入会在写入前失败。 + +一次跨仓候选交接按以下顺序进行: + +1. 选择独占的 Python Preview/信息库,取得其成功交付的 Core、PostgREST、SHA 和获授权凭据。 + owner 在自己的 Python Peer 选择、启用该候选 Registry 的 Twitter Python Distribution,并确认账户页可达。 + 不改其他 Peer 的选择;Python facade 与 Web MF facade 分别由各自 Peer 使用。 +2. 从 TS Preview 摘要选择本次不可变 deployment URL,确认其 `/preview-candidate.json` 包含需要的 Twitter MF。 + `INKCRE_E2E_WEB_URL` 使用这个 URL,脚本据此配置本次新 Web Peer 的 Registry。 +3. 将上述六项输入注入运行进程,执行 `pnpm verify:twitter`。不要 source dotenv 文件或把凭据写进提交、命令输出、 + 截图和 trace。Core Cookie 连接需要真实安全浏览器上下文;本专项保留 Secure Cookie 和关闭 trace 的设置。 +4. 查看本次测试目录的 `run.json`:它保留准备/提交/读取/索引/重开阶段、结果、Web 实际构建身份、Core 的 + 声明源码与实际 Peer/version、数据库合同/head、实际 MF entry URL/摘要及资源 URL、fixture native IDs 与 Job/Block IDs。 + Core SHA 是部署 owner 的输入声明,脚本不把它冒充运行时独立读取的 SHA。失败时已写记录仍保留并标出阶段。 +5. owner 在不再需要现场后清理整个独占信息库,例如关闭本次 Python PR 触发既有 Preview 清理。脚本不会根据 + 标题、时间范围或前缀批删共享数据;保留现场时,下一轮使用新的 fixture stamp,记录也说明清理责任。 + +脚本先检查 Core readiness、信息库中匹配该 Core endpoint 的 live Peer,以及 Web 构建身份,再开始账户连接。 +它会安装或启用本次 Web Peer 的 Twitter MF、写入测试数据并提交词法索引 Job,最后关闭原页并在新页从持久信息库 +重开正文。页面重开没有注入初始配置,使用该浏览器实际保存的连接和 Peer 意图。截图和运行记录保存在 +`test-results/twitter-acceptance/`,其中 `reopened.png` 对应这次重开。 + +这项证据涵盖 Chromium 的实际 HTTP/Cookie/数据库/MF 路径;原版 GM 的检测、提交和续签仍需实际 userscript +观察。Core 进程冷重启与真实视频播放没有由该脚本执行。源码 source lane 的快速迭代仍使用前述本地入口;本专项 +要求已准备并可溯源的 Web 交付产物。 diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index f1a14325..d0b6af04 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -1,5 +1,6 @@ -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' import { expect, test } from '@playwright/test' import { SignJWT } from 'jose' @@ -13,6 +14,12 @@ function getArchiveBase(): URL { type BlockRow = { id: number; resolver: string; content: string } type RelationRow = { from_: number; to_: number; content: string } type LexicalJob = { id: number; status: string; state: Record } +type CorePeerRow = { + id: string + application_version: string + lease_expires_at: string + capabilities: Array<{ id: string; inbound: { parameters: { url?: string } } }> +} const CORE_LEXICAL_MAINTAIN_JOB = 'core.feature_retrieval.lexical.maintain.v1' @@ -33,6 +40,7 @@ async function databaseToken(): Promise { async function databaseRows(path: string): Promise { const response = await fetch(new URL(path, postgrestUrl), { headers: { Authorization: `Bearer ${await databaseToken()}` }, + signal: AbortSignal.timeout(15_000), }) const body = await response.text() expect(response.ok, body).toBe(true) @@ -92,13 +100,124 @@ async function expectPostSearchResult( return result } +test.afterEach(async ({ page: _page }, testInfo) => { + const path = testInfo.outputPath('run.json') + let record: Record + try { + record = JSON.parse(await readFile(path, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + await writeFile(path, `${JSON.stringify({ ...record, result: testInfo.status }, null, 2)}\n`) + await testInfo.attach('candidate and fixture record', { path, contentType: 'application/json' }) +}) + test('Core self-hosted connection writes native records that the real Twitter MF can read', async ({ page, -}) => { + context, +}, testInfo) => { test.setTimeout(180_000) + for (const key of [ + 'INKCRE_E2E_CORE_URL', + 'INKCRE_E2E_POSTGREST_URL', + 'INKCRE_E2E_WEB_URL', + 'INKCRE_E2E_JWT_SECRET', + 'INKCRE_E2E_CORE_SOURCE_SHA', + 'INKCRE_E2E_INFO_BASE_OWNER', + ]) { + if (!process.env[key]) + throw new Error(`${key} is required before this journey can write fixtures`) + } + if (!/^[a-f0-9]{40}$/.test(process.env.INKCRE_E2E_CORE_SOURCE_SHA!)) { + throw new Error( + 'INKCRE_E2E_CORE_SOURCE_SHA must identify the Core candidate reported by its deployment owner' + ) + } + for (const value of [coreUrl, postgrestUrl, process.env.INKCRE_E2E_WEB_URL!]) { + const url = new URL(value) + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + 'Acceptance endpoints must be HTTP(S) URLs without embedded credentials, query or fragment' + ) + } + } const archiveBase = getArchiveBase() const webOrigin = new URL(process.env.INKCRE_E2E_WEB_URL!).origin const stamp = `${Date.now()}` + const record: Record = { + started_at: new Date().toISOString(), + core_url: coreUrl, + postgrest_url: postgrestUrl, + web_url: process.env.INKCRE_E2E_WEB_URL, + declared_core_source_revision: process.env.INKCRE_E2E_CORE_SOURCE_SHA, + info_base_owner: process.env.INKCRE_E2E_INFO_BASE_OWNER, + fixture_stamp: stamp, + cleanup: + 'The isolated info-base owner removes this run data; this script does not delete shared rows.', + } + await mkdir(testInfo.outputDir, { recursive: true }) + async function recordPhase(phase: string, observations: Record = {}) { + Object.assign(record, observations, { phase }) + await writeFile(testInfo.outputPath('run.json'), `${JSON.stringify(record, null, 2)}\n`) + } + await recordPhase('preflight') + const ready = await fetch(new URL('readyz', coreBase()), { signal: AbortSignal.timeout(15_000) }) + if (!ready.ok) throw new Error(`Core readiness returned HTTP ${ready.status}`) + const readiness = await ready.json() + const candidateResponse = await fetch(new URL('preview-candidate.json', webOrigin), { + signal: AbortSignal.timeout(15_000), + }) + if (!candidateResponse.ok) + throw new Error('Web candidate metadata is required; select a prepared Preview snapshot') + const candidate = (await candidateResponse.json()) as { + source_revision: string + build_id: string + distributions: Array<{ manifest: string; sha256: string }> + } + const twitterCandidate = candidate.distributions?.find((item) => + item.manifest.startsWith('/extensions/inkcre/twitter/') + ) + if (!/^[a-f0-9]{40}$/.test(candidate.source_revision) || !twitterCandidate) { + throw new Error('Web snapshot must identify its source and Twitter MF distribution') + } + let corePeers: CorePeerRow[] = [] + await expect + .poll( + async () => { + const peers = await databaseRows( + 'peers?select=id,application_version,lease_expires_at,capabilities' + ) + corePeers = peers.filter( + (peer) => + Date.parse(peer.lease_expires_at) > Date.now() && + peer.capabilities.some( + (capability) => + capability.id === 'core.extension.management.v2' && + capability.inbound?.parameters?.url === + new URL('extension-management/v2', coreBase()).href + ) + ) + return corePeers.length + }, + { + timeout: 30_000, + message: 'PostgREST must expose the live Core Peer selected for this journey', + } + ) + .toBe(1) + await recordPhase('connecting', { + core_peer: { id: corePeers[0].id, application_version: corePeers[0].application_version }, + database_contract: readiness.contract, + migration: readiness.migration, + web_candidate: candidate, + }) const userId = `${stamp}01` const postId = `${stamp}02` const quoteId = `${stamp}03` @@ -124,7 +243,7 @@ test('Core self-hosted connection writes native records that the real Twitter MF encodeURIComponent(jwtSecret), JSON.stringify(jwtSecret).slice(1, -1), ] - page.on('request', (request) => { + context.on('request', (request) => { // Fulfilled media routes may never deliver extra headers; audit these synchronously. const payload = [request.url(), JSON.stringify(request.headers()), request.postData() ?? ''] secretTransmitted ||= payload.some((value) => @@ -274,6 +393,19 @@ test('Core self-hosted connection writes native records that the real Twitter MF summary: { accepted: number; failed: number; total: number } } expect(submitted.summary).toMatchObject({ accepted: 8, failed: 0, total: 8 }) + await recordPhase('submitted', { + native_ids: [ + userId, + postId, + quoteId, + replyId, + mediaId, + failedMediaId, + videoId, + alternateMediaId, + ], + submit_summary: submitted.summary, + }) const query = await page.request.post(new URL('api/v1/archive/query', archiveBase).toString(), { headers: { 'x-tweet-db-client': 'x-monkey' }, @@ -316,8 +448,12 @@ test('Core self-hosted connection writes native records that the real Twitter MF { webOrigin, postgrestUrl, jwtSecret, peerId } ) const twitterRemoteEntries: import('@playwright/test').Response[] = [] - page.on('response', (response) => { + const moduleResources: Array<{ url: string; status: number }> = [] + context.on('response', (response) => { const url = new URL(response.url()) + if (url.pathname.includes('/extensions/inkcre/twitter/')) { + moduleResources.push({ url: response.url(), status: response.status() }) + } if ( url.pathname.includes('/extensions/inkcre/twitter/') && url.pathname.endsWith('/remoteEntry.js') @@ -361,6 +497,16 @@ test('Core self-hosted connection writes native records that the real Twitter MF const remoteEntry = twitterRemoteEntries.at(-1)! expect(remoteEntry.ok()).toBe(true) expect(new URL(remoteEntry.url()).origin).toBe(webOrigin) + await recordPhase('reading', { + web_peer_id: peerId, + registry_origin: webOrigin, + remote_entry: { + url: remoteEntry.url(), + sha256: createHash('sha256') + .update(await remoteEntry.body()) + .digest('hex'), + }, + }) const posts = await databaseRows( 'blocks?select=id,resolver,content&resolver=eq.extensions.twitter.tweet.v2&order=id.desc&limit=100' ) @@ -558,6 +704,16 @@ test('Core self-hosted connection writes native records that the real Twitter MF ) const indexJob = await maintainLexicalIndex(page) + await recordPhase('indexed', { + lexical_job_id: indexJob.id, + post_block_id: postBlock!.id, + media_block_ids: [ + mediaBlock!.id, + failedMediaBlock!.id, + videoBlock!.id, + alternateMediaBlock!.id, + ], + }) expect(indexJob.state).toMatchObject({ failed: 0, unavailable: 0 }) for (const query of [postId, `acceptance_${stamp}`, 'Rich text survives with its author']) { await expectPostSearchResult(page, query, postBlock!.id) @@ -579,4 +735,16 @@ test('Core self-hosted connection writes native records that the real Twitter MF path: 'test-results/twitter-acceptance/twitter-x-monkey-final.png', fullPage: true, }) + const reopened = await context.newPage() + await page.close() + await reopened.goto(new URL(`/info-base/graph/blocks/${postBlock!.id}/content`, webOrigin).href) + await expect(reopened.locator('.solved-content-popup')).toContainText( + 'Rich text survives with its author and context.', + { timeout: 30_000 } + ) + await expect(reopened.locator('.solved-content-popup')).toContainText('Archive Acceptance Author') + expect(moduleResources.every((resource) => new URL(resource.url).origin === webOrigin)).toBe(true) + expect(secretTransmitted, 'Application requests must not transmit JWT_SECRET.').toBe(false) + await reopened.screenshot({ path: testInfo.outputPath('reopened.png'), fullPage: true }) + await recordPhase('reopened', { module_resources: moduleResources, reopened_url: reopened.url() }) }) From 8545103b03b14cdb5ff9d7473cc960adf5d4f1e8 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:12:15 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(twitter):=20=E4=BF=9D=E7=95=99=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=8E=B0=E5=9C=BA=E5=B9=B6=E4=BF=9D=E6=8A=A4=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=87=AD=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 Node 与页面 fetch 避免失败 Call log 输出签名头或 Cookie - 提交前记录 native IDs,Job 创建后立即记录索引身份 - 校准 readiness 嵌套结构与原生安装重启的交接条件 --- docs/40-deployment/development-runtime.md | 3 + scripts/tests/twitter-info-base.spec.ts | 148 ++++++++++++++++------ 2 files changed, 110 insertions(+), 41 deletions(-) diff --git a/docs/40-deployment/development-runtime.md b/docs/40-deployment/development-runtime.md index c45f27aa..e7dc5580 100644 --- a/docs/40-deployment/development-runtime.md +++ b/docs/40-deployment/development-runtime.md @@ -170,6 +170,9 @@ Twitter 验收需要预先启动且兼容的 Core、PostgREST、Web 和 Twitter 1. 选择独占的 Python Preview/信息库,取得其成功交付的 Core、PostgREST、SHA 和获授权凭据。 owner 在自己的 Python Peer 选择、启用该候选 Registry 的 Twitter Python Distribution,并确认账户页可达。 + 首次选择需要安装 wheel 时,宿主可能报告 `operation: restarting`;等待 readiness 恢复且该操作清空后再启用, + 不要把此时启用得到的 HTTP 409 当成安装失败。 + Core 镜像更新后,未启用的选择可能仍存在而 wheel 已不在新环境中;此时重新选择该精确版本完成安装,再等待重启并启用。 不改其他 Peer 的选择;Python facade 与 Web MF facade 分别由各自 Peer 使用。 2. 从 TS Preview 摘要选择本次不可变 deployment URL,确认其 `/preview-candidate.json` 包含需要的 Twitter MF。 `INKCRE_E2E_WEB_URL` 使用这个 URL,脚本据此配置本次新 Web Peer 的 Registry。 diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index d0b6af04..de6f7a8d 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto' import { readFileSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' import { expect, test } from '@playwright/test' import { SignJWT } from 'jose' @@ -47,25 +48,48 @@ async function databaseRows(path: string): Promise { return JSON.parse(body) as Row[] } -async function maintainLexicalIndex(page: import('@playwright/test').Page): Promise { +async function archiveRequest( + page: import('@playwright/test').Page, + url: string, + data?: unknown +): Promise<{ status: number; body: string }> { + return page.evaluate( + async ({ url, data }) => { + const response = await fetch(url, { + method: data === undefined ? 'GET' : 'POST', + headers: { 'x-tweet-db-client': 'x-monkey', 'Content-Type': 'application/json' }, + body: data === undefined ? undefined : JSON.stringify(data), + signal: AbortSignal.timeout(15_000), + }) + return { status: response.status, body: await response.text() } + }, + { url, data } + ) +} + +async function maintainLexicalIndex(onCreated: (id: number) => Promise): Promise { const authorization = { Authorization: `Bearer ${await databaseToken()}` } - const created = await page.request.post(new URL('jobs', coreBase()).toString(), { - headers: authorization, - data: { + const created = await fetch(new URL('jobs', coreBase()), { + method: 'POST', + headers: { ...authorization, 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: CORE_LEXICAL_MAINTAIN_JOB, parameters: { options: { max_records: 1000 } }, - }, + }), + signal: AbortSignal.timeout(15_000), }) const createdBody = await created.text() - expect(created.status(), createdBody).toBe(201) + expect(created.status, createdBody).toBe(201) const initial = JSON.parse(createdBody) as LexicalJob + await onCreated(initial.id) const read = async (): Promise => { - const response = await page.request.get(new URL(`jobs/${initial.id}`, coreBase()).toString(), { + const response = await fetch(new URL(`jobs/${initial.id}`, coreBase()), { headers: authorization, + signal: AbortSignal.timeout(15_000), }) const body = await response.text() - expect(response.ok(), body).toBe(true) + expect(response.ok, body).toBe(true) return JSON.parse(body) as LexicalJob } const deadline = Date.now() + 120_000 @@ -113,6 +137,44 @@ test.afterEach(async ({ page: _page }, testInfo) => { await testInfo.attach('candidate and fixture record', { path, contentType: 'application/json' }) }) +test('HTTP transport failures do not log signed credentials or browser Cookies', async ({ + page, + context, +}) => { + const sentinel = 'FAKE_ACCEPTANCE_CREDENTIAL_SENTINEL' + let sawAuthorization = false + let sawCookie = false + const server = createServer((request, response) => { + if (request.url === '/disconnect') { + sawAuthorization ||= request.headers.authorization === `Bearer ${sentinel}` + sawCookie ||= request.headers.cookie?.includes(sentinel) ?? false + request.socket.destroy() + } else response.end('Acceptance transport check') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as { port: number } + const base = `http://127.0.0.1:${address.port}` + try { + const signedError = await fetch(`${base}/disconnect`, { + headers: { Authorization: `Bearer ${sentinel}` }, + }).catch((error: Error) => error) + expect(signedError).toBeInstanceOf(Error) + expect(String(signedError)).not.toContain(sentinel) + await context.addCookies([{ name: 'acceptance', value: sentinel, url: base }]) + await page.goto(base) + const cookieError = await archiveRequest(page, `${base}/disconnect`).catch( + (error: Error) => error + ) + expect(cookieError).toBeInstanceOf(Error) + expect(String(cookieError)).not.toContain(sentinel) + expect(sawAuthorization).toBe(true) + expect(sawCookie).toBe(true) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } +}) + test('Core self-hosted connection writes native records that the real Twitter MF can read', async ({ page, context, @@ -171,6 +233,7 @@ test('Core self-hosted connection writes native records that the real Twitter MF const ready = await fetch(new URL('readyz', coreBase()), { signal: AbortSignal.timeout(15_000) }) if (!ready.ok) throw new Error(`Core readiness returned HTTP ${ready.status}`) const readiness = await ready.json() + expect(readiness.database.status, 'Core must report an admitted database').toBe('ok') const candidateResponse = await fetch(new URL('preview-candidate.json', webOrigin), { signal: AbortSignal.timeout(15_000), }) @@ -214,8 +277,8 @@ test('Core self-hosted connection writes native records that the real Twitter MF .toBe(1) await recordPhase('connecting', { core_peer: { id: corePeers[0].id, application_version: corePeers[0].application_version }, - database_contract: readiness.contract, - migration: readiness.migration, + database_contract: readiness.database.contract, + migration: readiness.database.migration, web_candidate: candidate, }) const userId = `${stamp}01` @@ -282,11 +345,9 @@ test('Core self-hosted connection writes native records that the real Twitter MF expect(connectHeaders.origin).toBe(archiveBase.origin) expect(Boolean(connectHeaders.authorization?.startsWith('Bearer '))).toBe(true) await expect(page.getByText('Connected', { exact: true })).toBeVisible() - const session = await page.request.get(new URL('api/v1/session', archiveBase).toString(), { - headers: { 'x-tweet-db-client': 'x-monkey' }, - }) - expect(session.ok()).toBe(true) - expect(((await session.json()) as { authenticated: boolean }).authenticated).toBe(true) + const session = await archiveRequest(page, new URL('api/v1/session', archiveBase).toString()) + expect(session.status, session.body).toBe(200) + expect((JSON.parse(session.body) as { authenticated: boolean }).authenticated).toBe(true) const sessionCookie = ( await page.context().cookies(new URL('account', archiveBase).toString()) ).find((cookie) => cookie.name === 'inkcre_twitter_archive') @@ -312,9 +373,22 @@ test('Core self-hosted connection writes native records that the real Twitter MF mask: [secretInput], }) - const submit = await page.request.post(new URL('api/v1/archive/submit', archiveBase).toString(), { - headers: { 'x-tweet-db-client': 'x-monkey' }, - data: { + await recordPhase('submitting', { + native_ids: [ + userId, + postId, + quoteId, + replyId, + mediaId, + failedMediaId, + videoId, + alternateMediaId, + ], + }) + const submit = await archiveRequest( + page, + new URL('api/v1/archive/submit', archiveBase).toString(), + { users: [ { id: userId, @@ -385,38 +459,28 @@ test('Core self-hosted connection writes native records that the real Twitter MF alt: 'Alternate CDN image metadata', }, ], - }, - }) - const submitBody = await submit.text() - expect(submit.status(), submitBody).toBe(200) - const submitted = JSON.parse(submitBody) as { + } + ) + expect(submit.status, submit.body).toBe(200) + const submitted = JSON.parse(submit.body) as { summary: { accepted: number; failed: number; total: number } } expect(submitted.summary).toMatchObject({ accepted: 8, failed: 0, total: 8 }) await recordPhase('submitted', { - native_ids: [ - userId, - postId, - quoteId, - replyId, - mediaId, - failedMediaId, - videoId, - alternateMediaId, - ], submit_summary: submitted.summary, }) - const query = await page.request.post(new URL('api/v1/archive/query', archiveBase).toString(), { - headers: { 'x-tweet-db-client': 'x-monkey' }, - data: { + const query = await archiveRequest( + page, + new URL('api/v1/archive/query', archiveBase).toString(), + { users: [userId], posts: [postId], media: [mediaId, failedMediaId, videoId, alternateMediaId], - }, - }) - expect(query.status()).toBe(200) - const queried = (await query.json()) as { + } + ) + expect(query.status, query.body).toBe(200) + const queried = JSON.parse(query.body) as { posts: Array<{ id: string; status: string; data?: unknown }> } expect(queried.posts).toEqual([ @@ -703,7 +767,9 @@ test('Core self-hosted connection writes native records that the real Twitter MF ) ) - const indexJob = await maintainLexicalIndex(page) + const indexJob = await maintainLexicalIndex((id) => + recordPhase('indexing', { lexical_job_id: id }) + ) await recordPhase('indexed', { lexical_job_id: indexJob.id, post_block_id: postBlock!.id, From d188c726bd7fd2b7aae36f75dd16f946a1a31809 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:15:41 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(twitter):=20=E6=8C=89=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E9=AA=8C=E8=AF=81=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用原帖 URL 辨识搜索结果,不依赖已折叠的技术详情 - 保留 native ID、作者、正文与最终内容路由断言 --- scripts/tests/twitter-info-base.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index de6f7a8d..3dfdca14 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -109,7 +109,7 @@ async function maintainLexicalIndex(onCreated: (id: number) => Promise): P async function expectPostSearchResult( page: import('@playwright/test').Page, query: string, - blockId: number + postId: string ): Promise { if (new URL(page.url()).pathname !== '/') await page.goto('/') const search = page.getByRole('searchbox', { name: 'Search the info base' }) @@ -119,7 +119,7 @@ async function expectPostSearchResult( await page.getByRole('button', { name: 'Search', exact: true }).click() const result = page .locator('.info-base-list-view__matches li') - .filter({ hasText: `#${blockId} · extensions.twitter.tweet.v2` }) + .filter({ hasText: `https://x.com/i/status/${postId}` }) await expect(result).toBeVisible({ timeout: 30_000 }) return result } @@ -782,17 +782,17 @@ test('Core self-hosted connection writes native records that the real Twitter MF }) expect(indexJob.state).toMatchObject({ failed: 0, unavailable: 0 }) for (const query of [postId, `acceptance_${stamp}`, 'Rich text survives with its author']) { - await expectPostSearchResult(page, query, postBlock!.id) + await expectPostSearchResult(page, query, postId) } const bodySearch = await expectPostSearchResult( page, 'Rich text survives with its author', - postBlock!.id + postId ) await bodySearch.locator('.info-base-list-view__match').click() - await expect(page).toHaveURL((url) => url.pathname === `/info-base/list/blocks/${postBlock!.id}`) - await expect(page.locator('.block-inspector-popup')).toContainText(`#${postBlock!.id}`) - await page.locator('.block-inspector-popup').getByRole('button', { name: 'View content' }).click() + await expect(page).toHaveURL( + (url) => url.pathname === `/info-base/list/blocks/${postBlock!.id}/content` + ) await expect(page.locator('.solved-content-popup')).toContainText( 'Rich text survives with its author and context.' ) From 8abbe91c6284b66b09ae051e5c0f67087b7421c2 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:16:55 +0800 Subject: [PATCH 4/8] =?UTF-8?q?test(twitter):=20=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E6=90=9C=E7=B4=A2=E5=AF=BC=E8=88=AA=E5=90=8E?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 防止上一查询的同一帖子结果误通过下一查询断言 --- scripts/tests/twitter-info-base.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index 3dfdca14..9882b8d8 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -117,6 +117,7 @@ async function expectPostSearchResult( await expect(page.locator('.app-connection')).toBeHidden({ timeout: 30_000 }) await search.fill(query) await page.getByRole('button', { name: 'Search', exact: true }).click() + await expect(page).toHaveURL((url) => url.searchParams.get('q') === query) const result = page .locator('.info-base-list-view__matches li') .filter({ hasText: `https://x.com/i/status/${postId}` }) From ab619f20f6af8b470239b174fba6ddd4f22690a9 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:20:11 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(twitter):=20=E4=BD=BF=E7=94=A8=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=AF=A6=E6=83=85=E4=B8=AD=E7=9A=84=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=20Block=20=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搜索摘录会随查询裁切 URL,不再用摘录文本辨认帖子 - 通过折叠详情定位对应 Block,仍验证可见结果及正文导航 --- scripts/tests/twitter-info-base.spec.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index 9882b8d8..0236b896 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -109,7 +109,7 @@ async function maintainLexicalIndex(onCreated: (id: number) => Promise): P async function expectPostSearchResult( page: import('@playwright/test').Page, query: string, - postId: string + blockId: number ): Promise { if (new URL(page.url()).pathname !== '/') await page.goto('/') const search = page.getByRole('searchbox', { name: 'Search the info base' }) @@ -120,7 +120,10 @@ async function expectPostSearchResult( await expect(page).toHaveURL((url) => url.searchParams.get('q') === query) const result = page .locator('.info-base-list-view__matches li') - .filter({ hasText: `https://x.com/i/status/${postId}` }) + // Excerpts vary with the query; the collapsed Details retain the exact Block identity. + .filter({ + has: page.locator('details button').filter({ hasText: new RegExp(`^#${blockId}\\s+·`) }), + }) await expect(result).toBeVisible({ timeout: 30_000 }) return result } @@ -783,12 +786,12 @@ test('Core self-hosted connection writes native records that the real Twitter MF }) expect(indexJob.state).toMatchObject({ failed: 0, unavailable: 0 }) for (const query of [postId, `acceptance_${stamp}`, 'Rich text survives with its author']) { - await expectPostSearchResult(page, query, postId) + await expectPostSearchResult(page, query, postBlock!.id) } const bodySearch = await expectPostSearchResult( page, 'Rich text survives with its author', - postId + postBlock!.id ) await bodySearch.locator('.info-base-list-view__match').click() await expect(page).toHaveURL( From 9c0c8eaf52be5bd7705864a57c69eaa5b91379de Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:24:43 +0800 Subject: [PATCH 6/8] =?UTF-8?q?test(twitter):=20=E5=AE=B9=E5=BF=8D?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=8F=92=E6=A7=BD=E7=9A=84=E9=A6=96=E9=83=A8?= =?UTF-8?q?=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 精确 Block 身份匹配忽略 Vue 插槽生成的空白字符 --- scripts/tests/twitter-info-base.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index 0236b896..82b556d6 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -122,7 +122,7 @@ async function expectPostSearchResult( .locator('.info-base-list-view__matches li') // Excerpts vary with the query; the collapsed Details retain the exact Block identity. .filter({ - has: page.locator('details button').filter({ hasText: new RegExp(`^#${blockId}\\s+·`) }), + has: page.locator('details button').filter({ hasText: new RegExp(`^\\s*#${blockId}\\s+·`) }), }) await expect(result).toBeVisible({ timeout: 30_000 }) return result From 5ce14d3e55bc528cd253613ccb3f33c7102d2966 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:29:56 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(twitter):=20=E9=AA=8C=E8=AF=81=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E6=A3=80=E6=9F=A5=E5=99=A8=E8=B7=AF=E5=BE=84=E5=B9=B6?= =?UTF-8?q?=E9=A2=84=E7=AE=97=E6=96=B0=E9=A1=B5=E8=AF=BB=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Twitter Resolver 尚无 recall target,保留经检查器打开正文的真实路径 - 使用可访问 dialog 定位检查器,并记录搜索与重开阶段 - 以实际托管耗时为依据给新增重开步骤留出专项总预算 --- docs/40-deployment/development-runtime.md | 2 +- scripts/tests/twitter-info-base.spec.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/40-deployment/development-runtime.md b/docs/40-deployment/development-runtime.md index e7dc5580..5aecf5be 100644 --- a/docs/40-deployment/development-runtime.md +++ b/docs/40-deployment/development-runtime.md @@ -178,7 +178,7 @@ Twitter 验收需要预先启动且兼容的 Core、PostgREST、Web 和 Twitter `INKCRE_E2E_WEB_URL` 使用这个 URL,脚本据此配置本次新 Web Peer 的 Registry。 3. 将上述六项输入注入运行进程,执行 `pnpm verify:twitter`。不要 source dotenv 文件或把凭据写进提交、命令输出、 截图和 trace。Core Cookie 连接需要真实安全浏览器上下文;本专项保留 Secure Cookie 和关闭 trace 的设置。 -4. 查看本次测试目录的 `run.json`:它保留准备/提交/读取/索引/重开阶段、结果、Web 实际构建身份、Core 的 +4. 查看本次测试目录的 `run.json`:它保留准备/提交/读取/索引/搜索/重开阶段、结果、Web 实际构建身份、Core 的 声明源码与实际 Peer/version、数据库合同/head、实际 MF entry URL/摘要及资源 URL、fixture native IDs 与 Job/Block IDs。 Core SHA 是部署 owner 的输入声明,脚本不把它冒充运行时独立读取的 SHA。失败时已写记录仍保留并标出阶段。 5. owner 在不再需要现场后清理整个独占信息库,例如关闭本次 Python PR 触发既有 Preview 清理。脚本不会根据 diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index 82b556d6..77599518 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -183,7 +183,7 @@ test('Core self-hosted connection writes native records that the real Twitter MF page, context, }, testInfo) => { - test.setTimeout(180_000) + test.setTimeout(240_000) for (const key of [ 'INKCRE_E2E_CORE_URL', 'INKCRE_E2E_POSTGREST_URL', @@ -785,6 +785,7 @@ test('Core self-hosted connection writes native records that the real Twitter MF ], }) expect(indexJob.state).toMatchObject({ failed: 0, unavailable: 0 }) + await recordPhase('searching') for (const query of [postId, `acceptance_${stamp}`, 'Rich text survives with its author']) { await expectPostSearchResult(page, query, postBlock!.id) } @@ -794,9 +795,10 @@ test('Core self-hosted connection writes native records that the real Twitter MF postBlock!.id ) await bodySearch.locator('.info-base-list-view__match').click() - await expect(page).toHaveURL( - (url) => url.pathname === `/info-base/list/blocks/${postBlock!.id}/content` - ) + await expect(page).toHaveURL((url) => url.pathname === `/info-base/list/blocks/${postBlock!.id}`) + const inspector = page.getByRole('dialog', { name: 'Block Inspector', exact: true }) + await expect(inspector).toContainText(`#${postBlock!.id}`) + await inspector.getByRole('button', { name: 'View content', exact: true }).click() await expect(page.locator('.solved-content-popup')).toContainText( 'Rich text survives with its author and context.' ) @@ -805,6 +807,7 @@ test('Core self-hosted connection writes native records that the real Twitter MF path: 'test-results/twitter-acceptance/twitter-x-monkey-final.png', fullPage: true, }) + await recordPhase('reopening') const reopened = await context.newPage() await page.close() await reopened.goto(new URL(`/info-base/graph/blocks/${postBlock!.id}/content`, webOrigin).href) From 968915a727f2c5af6c4d4caece354ebc5e10f9d8 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 19:36:48 +0800 Subject: [PATCH 8/8] =?UTF-8?q?test(twitter):=20=E5=9C=A8=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=89=8D=E7=A1=AE=E8=AE=A4=E5=8E=9F=E7=94=9F=E5=88=86?= =?UTF-8?q?=E5=8F=91=E8=BF=90=E8=A1=8C=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 白名单记录当前 Core Peer 的所选与已加载版本及运行状态 - 在账户连接和 fixture 之前拒绝未就绪的原生分发 - 人工写入专项显式关闭 CI 自动重试,保留一次失败现场 --- docs/40-deployment/development-runtime.md | 5 +- scripts/tests/twitter-acceptance.config.ts | 1 + scripts/tests/twitter-info-base.spec.ts | 54 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/40-deployment/development-runtime.md b/docs/40-deployment/development-runtime.md index 5aecf5be..eb877a13 100644 --- a/docs/40-deployment/development-runtime.md +++ b/docs/40-deployment/development-runtime.md @@ -179,12 +179,13 @@ Twitter 验收需要预先启动且兼容的 Core、PostgREST、Web 和 Twitter 3. 将上述六项输入注入运行进程,执行 `pnpm verify:twitter`。不要 source dotenv 文件或把凭据写进提交、命令输出、 截图和 trace。Core Cookie 连接需要真实安全浏览器上下文;本专项保留 Secure Cookie 和关闭 trace 的设置。 4. 查看本次测试目录的 `run.json`:它保留准备/提交/读取/索引/搜索/重开阶段、结果、Web 实际构建身份、Core 的 - 声明源码与实际 Peer/version、数据库合同/head、实际 MF entry URL/摘要及资源 URL、fixture native IDs 与 Job/Block IDs。 + 声明源码与实际 Peer/version、原生 Distribution 的选择/加载/运行状态、数据库合同/head、实际 MF entry URL/摘要及资源 URL、fixture native IDs 与 Job/Block IDs。 Core SHA 是部署 owner 的输入声明,脚本不把它冒充运行时独立读取的 SHA。失败时已写记录仍保留并标出阶段。 5. owner 在不再需要现场后清理整个独占信息库,例如关闭本次 Python PR 触发既有 Preview 清理。脚本不会根据 标题、时间范围或前缀批删共享数据;保留现场时,下一轮使用新的 fixture stamp,记录也说明清理责任。 -脚本先检查 Core readiness、信息库中匹配该 Core endpoint 的 live Peer,以及 Web 构建身份,再开始账户连接。 +脚本先检查 Core readiness、信息库中匹配该 Core endpoint 的 live Peer、该 Peer 已加载并运行所选 Python Distribution, +以及 Web 构建身份,再开始账户连接。原生 status 只证明选择与运行状态,不独立证明 wheel 字节来自候选 Registry。 它会安装或启用本次 Web Peer 的 Twitter MF、写入测试数据并提交词法索引 Job,最后关闭原页并在新页从持久信息库 重开正文。页面重开没有注入初始配置,使用该浏览器实际保存的连接和 Peer 意图。截图和运行记录保存在 `test-results/twitter-acceptance/`,其中 `reopened.png` 对应这次重开。 diff --git a/scripts/tests/twitter-acceptance.config.ts b/scripts/tests/twitter-acceptance.config.ts index 872b60fc..7e6f02e5 100644 --- a/scripts/tests/twitter-acceptance.config.ts +++ b/scripts/tests/twitter-acceptance.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ ...baseConfig, testDir: '.', outputDir: '../../test-results/twitter-acceptance', + retries: 0, projects: [ { name: 'twitter-info-base', diff --git a/scripts/tests/twitter-info-base.spec.ts b/scripts/tests/twitter-info-base.spec.ts index 77599518..ef03ef38 100644 --- a/scripts/tests/twitter-info-base.spec.ts +++ b/scripts/tests/twitter-info-base.spec.ts @@ -279,6 +279,60 @@ test('Core self-hosted connection writes native records that the real Twitter MF } ) .toBe(1) + const nativeResponse = await fetch(new URL('extension-management/v2', coreBase()), { + method: 'POST', + headers: { + Authorization: `Bearer ${await databaseToken()}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ action: 'status', extension: 'inkcre/twitter' }), + signal: AbortSignal.timeout(15_000), + }) + if (!nativeResponse.ok) + throw new Error(`Native Distribution status returned HTTP ${nativeResponse.status}`) + const nativeStatus = await nativeResponse.json() + const nativeRows = nativeStatus.extensions.filter( + (row: { name: string }) => row.name === 'inkcre/twitter' + ) + const nativeDistributions = nativeRows.map( + (row: { + name: string + distribution: string + version: string + loaded_version: string | null + enabled: boolean + running: boolean + restart_required: boolean + operation: string | null + }) => ({ + peer_id: nativeStatus.peer_id, + name: row.name, + distribution: row.distribution, + version: row.version, + loaded_version: row.loaded_version, + enabled: row.enabled, + running: row.running, + restart_required: row.restart_required, + operation: row.operation, + }) + ) + await recordPhase('preflight', { native_distributions: nativeDistributions }) + expect(nativeStatus.peer_id, 'Native status must belong to the observed Core Peer').toBe( + corePeers[0].id + ) + expect(nativeDistributions, 'The owner must select and enable the Python Distribution').toEqual([ + { + peer_id: corePeers[0].id, + name: 'inkcre/twitter', + distribution: 'python', + version: expect.stringMatching(/\S/), + loaded_version: nativeDistributions[0]?.version, + enabled: true, + running: true, + restart_required: false, + operation: null, + }, + ]) await recordPhase('connecting', { core_peer: { id: corePeers[0].id, application_version: corePeers[0].application_version }, database_contract: readiness.database.contract,