mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
572 lines
17 KiB
TypeScript
572 lines
17 KiB
TypeScript
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<void>
|
|
|
|
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<T> {
|
|
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<void> {
|
|
await new Promise<void>((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<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 runCleanupTaskWithTimeout(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}`,
|
|
},
|
|
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<SeededNamespace[]> {
|
|
return parseEnvelope<SeededNamespace[]>(
|
|
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<SeededNamespace | null> {
|
|
if (!this.isTeamNamespace(namespace)) {
|
|
return null
|
|
}
|
|
|
|
if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
|
|
return parseEnvelope<SeededNamespace>(
|
|
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`, {
|
|
headers: await csrfHeaders(this.page),
|
|
}),
|
|
)
|
|
}
|
|
|
|
if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
|
|
return parseEnvelope<SeededNamespace>(
|
|
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`, {
|
|
headers: await csrfHeaders(this.page),
|
|
}),
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
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 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<SeededNamespace> {
|
|
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<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> {
|
|
let lastError: unknown
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
try {
|
|
await parseEnvelope<ReviewTaskSummary>(
|
|
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<NamespaceCandidate[]> {
|
|
const query = new URLSearchParams({ search })
|
|
return parseEnvelope<NamespaceCandidate[]>(
|
|
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<void> {
|
|
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<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',
|
|
},
|
|
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<SeededReviewData> {
|
|
const namespace = await this.ensureReviewableNamespace()
|
|
const skill = await this.publishSkill(namespace.slug)
|
|
return { namespace, skill }
|
|
}
|
|
}
|