skillhub/web/e2e/helpers/test-data-builder.ts
tenten-shih 77e271f24c
Merge pull request #269 from iflytek/pr/exclude-playwright-report
fix register validation, review visibility, and search e2e coverage
2026-04-09 14:37:46 +05:30

391 lines
11 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import type { APIRequestContext, Page, TestInfo } from '@playwright/test'
type CleanupTask = () => Promise<void>
export interface SeededNamespace {
id: number
slug: string
displayName: string
}
export interface SeededSkill {
skillId: number
namespace: string
slug: string
version: string
status: string
}
export interface SeededReviewData {
namespace: SeededNamespace
skill: SeededSkill
}
interface ReviewTaskSummary {
id: number
namespace: string
skillSlug: string
status: string
submittedBy: string
version: string
}
interface ApiEnvelope<T> {
code: number
msg: string
data: T
}
interface ApiFailure extends Error {
status?: number
code?: number
}
export interface SeedSkillOptions {
name?: string
description?: string
version?: string
readmeHeading?: string
}
function asApiErrorBody(value: unknown): string {
if (!value || typeof value !== 'object') {
return ''
}
const maybe = value as { msg?: unknown }
return typeof maybe.msg === 'string' ? maybe.msg : ''
}
function uniqueSuffix(testInfo?: TestInfo): string {
const worker = testInfo?.parallelIndex ?? 0
return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48)
const description = options?.description || 'E2E generated skill for real-request tests'
const version = options?.version || '1.0.0'
const readmeHeading = options?.readmeHeading || skillName
const skillMd = `---
name: ${skillName}
description: ${description}
version: ${version}
---
# ${readmeHeading}
Generated by Playwright E2E.
`
return {
readmeHeading,
skillMd,
skillName,
}
}
function buildSkillPackageZipBuffer(suffix: string, options?: SeedSkillOptions): Buffer {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-'))
try {
const packageDir = path.join(tempRoot, `pkg-${suffix}`)
const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`)
const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options)
execFileSync('mkdir', ['-p', packageDir])
writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8')
writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8')
execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir })
return readFileSync(zipPath)
} finally {
rmSync(tempRoot, { recursive: true, force: true })
}
}
function createSkillPackageZipFile(suffix: string, options?: SeedSkillOptions): { filePath: string; cleanup: () => void } {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'skillhub-e2e-file-'))
const packageDir = path.join(tempRoot, `pkg-${suffix}`)
const zipPath = path.join(tempRoot, `pkg-${suffix}.zip`)
const { readmeHeading, skillMd } = buildSkillPackageContent(suffix, options)
execFileSync('mkdir', ['-p', packageDir])
writeFileSync(path.join(packageDir, 'SKILL.md'), skillMd, 'utf8')
writeFileSync(path.join(packageDir, 'README.md'), `# ${readmeHeading}\n`, 'utf8')
execFileSync('zip', ['-q', '-r', zipPath, 'SKILL.md', 'README.md'], { cwd: packageDir })
return {
filePath: zipPath,
cleanup: () => {
rmSync(tempRoot, { recursive: true, force: true })
},
}
}
async function parseEnvelope<T>(response: Awaited<ReturnType<APIRequestContext['fetch']>>): Promise<T> {
const text = await response.text()
let parsed: ApiEnvelope<T> | null = null
try {
parsed = JSON.parse(text) as ApiEnvelope<T>
} catch {
throw new Error(`Non-JSON response: status=${response.status()} body=${text.slice(0, 200)}`)
}
if (!response.ok() || parsed.code !== 0) {
const error = new Error(
`API failed: status=${response.status()} code=${parsed.code} msg=${asApiErrorBody(parsed) || parsed.msg}`,
) as ApiFailure
error.status = response.status()
error.code = parsed.code
throw error
}
return parsed.data
}
export class E2eTestDataBuilder {
private readonly request: APIRequestContext
private readonly suffix: string
private readonly cleanupTasks: CleanupTask[] = []
private ensuredNamespace?: SeededNamespace
constructor(
private readonly page: Page,
testInfo?: TestInfo,
) {
this.request = page.context().request
this.suffix = uniqueSuffix(testInfo)
}
async init(): Promise<void> {
// Prime CSRF/session cookie path used by write endpoints.
await this.request.get('/api/v1/auth/providers')
}
async cleanup(): Promise<void> {
for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) {
try {
await this.cleanupTasks[i]()
} catch {
// Best-effort cleanup for E2E environments.
}
}
}
async createNamespace(base = 'e2e-team'): Promise<SeededNamespace> {
const rawSlug = `${base}-${this.suffix}`
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
const slug = rawSlug.slice(0, 64)
const displayName = `E2E ${slug}`
const created = await parseEnvelope<SeededNamespace>(
await this.request.post('/api/v1/namespaces', {
data: {
slug,
displayName,
description: `E2E namespace ${slug}`,
},
}),
)
this.cleanupTasks.push(async () => {
await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, {
data: { reason: 'e2e cleanup' },
})
})
return created
}
async listMyNamespaces(): Promise<SeededNamespace[]> {
return parseEnvelope<SeededNamespace[]>(
await this.request.get('/api/web/me/namespaces'),
)
}
async ensureWritableNamespace(): Promise<SeededNamespace> {
if (this.ensuredNamespace) {
return this.ensuredNamespace
}
try {
const created = await this.createNamespace('e2e-team')
this.ensuredNamespace = created
return created
} catch (error) {
const failure = error as ApiFailure
if (failure.status !== 403) {
throw error
}
}
const namespaces = await this.listMyNamespaces()
const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0]
if (!writable) {
throw new Error('No namespace available for e2e data seeding')
}
this.ensuredNamespace = writable
return writable
}
private async getMySkillInNamespace(namespaceSlug: string): Promise<SeededSkill | null> {
const page = await parseEnvelope<{
items: Array<{
id: number
namespace: string
slug: string
headlineVersion?: { version: string; status: string }
}>
}>(
await this.request.get('/api/web/me/skills?page=0&size=50'),
)
const hit = page.items.find((item) => item.namespace === namespaceSlug)
if (!hit || !hit.headlineVersion) {
return null
}
return {
skillId: hit.id,
namespace: hit.namespace,
slug: hit.slug,
version: hit.headlineVersion.version,
status: hit.headlineVersion.status,
}
}
async waitForSearchResult(query: string, expectedSlug?: string): Promise<void> {
const encodedQuery = encodeURIComponent(query)
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const page = await parseEnvelope<{
items: Array<{ slug: string }>
}>(
await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`),
)
if (!expectedSlug || page.items.some((item) => item.slug === expectedSlug)) {
return
}
} catch {
// Search indexing can lag briefly behind publish in local environments.
}
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
}
throw new Error(`Timed out waiting for search result "${query}"${expectedSlug ? ` (${expectedSlug})` : ''}`)
}
async waitForSearchResults(query: string, expectedSlugs: string[]): Promise<void> {
const pending = new Set(expectedSlugs)
if (pending.size === 0) {
return
}
const encodedQuery = encodeURIComponent(query)
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const page = await parseEnvelope<{
items: Array<{ slug: string }>
}>(
await this.request.get(`/api/web/skills?q=${encodedQuery}&sort=relevance&page=0&size=50`),
)
for (const item of page.items) {
pending.delete(item.slug)
}
if (pending.size === 0) {
return
}
} catch {
// Search indexing can lag briefly behind publish in local environments.
}
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
}
throw new Error(`Timed out waiting for search results "${query}" (${Array.from(pending).join(', ')})`)
}
async waitForPendingReview(namespaceSlug: string, skillSlug: string, version: string): Promise<number> {
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const page = await parseEnvelope<{
items: ReviewTaskSummary[]
}>(
await this.request.get('/api/web/reviews?status=PENDING&page=0&size=100&sortDirection=DESC'),
)
const matched = page.items.find((item) =>
item.namespace === namespaceSlug &&
item.skillSlug === skillSlug &&
item.version === version &&
item.status === 'PENDING',
)
if (matched) {
return matched.id
}
} catch {
// Review list can lag behind publish very briefly.
}
await new Promise((resolve) => setTimeout(resolve, 300 * (attempt + 1)))
}
throw new Error(`Timed out waiting for pending review ${namespaceSlug}/${skillSlug}@${version}`)
}
async approveReview(reviewTaskId: number, comment = 'Approved by Playwright E2E'): Promise<void> {
await parseEnvelope<ReviewTaskSummary>(
await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, {
data: { comment },
}),
)
}
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise<SeededSkill> {
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
const result = await parseEnvelope<SeededSkill>(
await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, {
multipart: {
file: {
name: 'sample-skill.zip',
mimeType: 'application/zip',
buffer: zipBuffer,
},
visibility: 'PUBLIC',
},
}),
)
this.cleanupTasks.push(async () => {
await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`)
})
return result
}
createSkillPackageFile(options?: SeedSkillOptions): string {
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
const { filePath, cleanup } = createSkillPackageZipFile(unique, options)
this.cleanupTasks.push(async () => {
cleanup()
})
return filePath
}
async createReviewData(): Promise<SeededReviewData> {
const namespace = await this.ensureWritableNamespace()
const skill = await this.publishSkill(namespace.slug)
return { namespace, skill }
}
}