import { mkdirSync, 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' import { csrfHeaders } from './csrf' type CleanupTask = () => Promise export interface SeededNamespace { id: number slug: string displayName: string status?: string type?: string currentUserRole?: string canUnfreeze?: boolean canRestore?: boolean canDelete?: boolean } 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 NamespaceCandidate { userId: string displayName: string email?: string status: string } interface ApiEnvelope { code: number msg: string data: T } interface ApiFailure extends Error { status?: number code?: number } const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000 export interface SeedSkillOptions { name?: string description?: string version?: string readmeHeading?: string readmeBody?: string extraFiles?: Array<{ path: string content: 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)}` } async function runCleanupTaskWithTimeout(task: CleanupTask): Promise { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`)) }, cleanupTimeoutMs) void task() .then(() => { clearTimeout(timeout) resolve() }) .catch((error) => { clearTimeout(timeout) reject(error) }) }) } 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'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') for (const extraFile of options?.extraFiles ?? []) { const targetPath = path.join(packageDir, extraFile.path) mkdirSync(path.dirname(targetPath), { recursive: true }) writeFileSync(targetPath, extraFile.content, 'utf8') } execFileSync('zip', ['-q', '-r', zipPath, '.'], { 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'), options?.readmeBody ?? `# ${readmeHeading}\n`, 'utf8') for (const extraFile of options?.extraFiles ?? []) { const targetPath = path.join(packageDir, extraFile.path) mkdirSync(path.dirname(targetPath), { recursive: true }) writeFileSync(targetPath, extraFile.content, 'utf8') } execFileSync('zip', ['-q', '-r', zipPath, '.'], { cwd: packageDir }) return { filePath: zipPath, cleanup: () => { rmSync(tempRoot, { recursive: true, force: true }) }, } } async function parseEnvelope(response: Awaited>): Promise { const text = await response.text() let parsed: ApiEnvelope | null = null try { parsed = JSON.parse(text) as ApiEnvelope } 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 { // Prime CSRF/session cookie path used by write endpoints. await this.request.get('/api/v1/auth/providers') } async cleanup(): Promise { for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) { try { await runCleanupTaskWithTimeout(this.cleanupTasks[i]) } catch { // Best-effort cleanup for E2E environments. } } } async createNamespace(base = 'e2e-team'): Promise { 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( await this.request.post('/api/v1/namespaces', { data: { slug, displayName, description: `E2E namespace ${slug}`, }, headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { await this.request.post(`/api/web/namespaces/${encodeURIComponent(created.slug)}/archive`, { data: { reason: 'e2e cleanup' }, headers: await csrfHeaders(this.page), }) }) return created } async listMyNamespaces(): Promise { return parseEnvelope( await this.request.get('/api/web/me/namespaces'), ) } private isTeamNamespace(namespace: SeededNamespace): boolean { return namespace.type === 'TEAM' || namespace.slug !== 'global' } private isActiveNamespace(namespace: SeededNamespace): boolean { return namespace.status === 'ACTIVE' } private async activateNamespace(namespace: SeededNamespace): Promise { if (!this.isTeamNamespace(namespace)) { return null } if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { return parseEnvelope( await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`, { headers: await csrfHeaders(this.page), }), ) } if (namespace.status === 'ARCHIVED' && namespace.canRestore) { return parseEnvelope( await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`, { headers: await csrfHeaders(this.page), }), ) } return null } async ensureWritableNamespace(): Promise { 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 activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item)) if (activeTeam) { this.ensuredNamespace = activeTeam return activeTeam } const activeFallback = namespaces.find((item) => this.isActiveNamespace(item)) if (activeFallback) { this.ensuredNamespace = activeFallback return activeFallback } const activatable = namespaces.find((item) => this.isTeamNamespace(item) && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)), ) if (activatable) { const activated = await this.activateNamespace(activatable) if (activated) { this.ensuredNamespace = activated return activated } } const summary = namespaces .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`) .join(', ') throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`) } async ensureReviewableNamespace(): Promise { if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(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 activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item)) if (activeTeam) { this.ensuredNamespace = activeTeam return activeTeam } const activatable = namespaces.find((item) => this.isTeamNamespace(item) && ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)), ) if (activatable) { const activated = await this.activateNamespace(activatable) if (activated) { this.ensuredNamespace = activated return activated } } const summary = namespaces .map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`) .join(', ') throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`) } private async getMySkillInNamespace(namespaceSlug: string): Promise { 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 { 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 { 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 { 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 { let lastError: unknown for (let attempt = 0; attempt < 60; attempt += 1) { try { await parseEnvelope( await this.request.post(`/api/web/reviews/${reviewTaskId}/approve`, { data: { comment }, headers: await csrfHeaders(this.page), }), ) return } catch (error) { lastError = error const message = error instanceof Error ? error.message : '' const isScanInProgress = message.includes('扫描') || message.toLowerCase().includes('scan is still in progress') if (!isScanInProgress) { throw error } await new Promise((resolve) => setTimeout(resolve, 1_000)) } } throw lastError instanceof Error ? lastError : new Error('approveReview timed out') } async searchNamespaceMemberCandidates(slug: string, search: string): Promise { const query = new URLSearchParams({ search }) return parseEnvelope( await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`), ) } async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise { await parseEnvelope<{ userId: string; role: string }>( await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, { data: { userId, role }, headers: await csrfHeaders(this.page), }), ) } async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise { const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}` const zipBuffer = buildSkillPackageZipBuffer(unique, options) const result = await parseEnvelope( await this.request.post(`/api/web/skills/${encodeURIComponent(namespaceSlug)}/publish`, { multipart: { file: { name: 'sample-skill.zip', mimeType: 'application/zip', buffer: zipBuffer, }, visibility: 'PUBLIC', }, headers: await csrfHeaders(this.page), }), ) this.cleanupTasks.push(async () => { await this.request.delete(`/api/web/skills/${encodeURIComponent(result.namespace)}/${encodeURIComponent(result.slug)}`, { headers: await csrfHeaders(this.page), }) }) 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 { const namespace = await this.ensureReviewableNamespace() const skill = await this.publishSkill(namespace.slug) return { namespace, skill } } }