Files
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

352 lines
13 KiB
TypeScript

/**
* Insights Playwright Setup for Playwright Tests
*
* This library helps you create Insights workspaces (organizations, projects, teams)
* and test data before running Playwright tests.
*
* Key concepts:
* - Organization: Top-level account (e.g., "Acme Corp")
* - Project: Container within an org (e.g., "Web App", "Mobile App")
* - Team: Environment within project where data lives (e.g., "Production", "Staging")
* - User: Configurable via LOGIN_USERNAME/LOGIN_PASSWORD env vars (defaults: [email protected]/12345678)
*/
import { APIRequestContext, APIResponse, Page, request as playwrightRequest } from '@playwright/test'
import { LOGIN_PASSWORD } from './playwright-test-core'
export interface TestSetupResponse {
success: boolean
test_name: string
result?: any
error?: string
available_tests?: string[]
}
export interface PlaywrightSetupVariable {
name: string
type: 'String' | 'Number' | 'Boolean' | 'List' | 'Date'
default_value?: any
}
export interface PlaywrightSetupInsight {
name: string
query: Record<string, any>
variable_indexes?: number[]
}
export interface PlaywrightSetupDashboard {
name: string
insight_indexes?: number[]
filters?: Record<string, any>
variable_overrides?: Record<string, any>
}
export interface PlaywrightSetupEvent {
event: string
distinct_id: string
timestamp: string // ISO 8601 timestamp
properties?: Record<string, any>
}
export interface PlaywrightSetupPerson {
distinct_ids: string[]
properties?: Record<string, any>
}
export interface PlaywrightSetupExperiment {
name: string
feature_flag_key: string
start_date?: string // ISO 8601 — if set, experiment is created as RUNNING
metrics?: Record<string, any>[]
metrics_secondary?: Record<string, any>[]
}
export interface PlaywrightWorkspaceSetupData {
organization_name?: string
use_current_time?: boolean
skip_onboarding?: boolean
no_demo_data?: boolean
staff?: boolean
insight_variables?: PlaywrightSetupVariable[]
insights?: PlaywrightSetupInsight[]
dashboards?: PlaywrightSetupDashboard[]
events?: PlaywrightSetupEvent[]
persons?: PlaywrightSetupPerson[]
experiments?: PlaywrightSetupExperiment[]
}
export interface PlaywrightSetupCreatedVariable {
id: string
code_name: string
}
export interface PlaywrightSetupCreatedInsight {
id: number
short_id: string
}
export interface PlaywrightSetupCreatedDashboard {
id: number
}
export interface PlaywrightSetupCreatedExperiment {
id: number
feature_flag_key: string
}
export interface PlaywrightWorkspaceSetupResult {
organization_id: string
team_id: string
organization_name: string
team_name: string
user_id: string
user_email: string
personal_api_key: string
created_variables?: PlaywrightSetupCreatedVariable[]
created_insights?: PlaywrightSetupCreatedInsight[]
created_dashboards?: PlaywrightSetupCreatedDashboard[]
created_experiments?: PlaywrightSetupCreatedExperiment[]
}
export interface PlaywrightSetupOptions {
/** Custom data to pass to the setup function */
data?: Record<string, any>
/** Whether to throw an error if setup fails (default: true) */
throwOnError?: boolean
/** Base URL for the API (defaults to baseURL from config) */
baseURL?: string
/** Number of retry attempts on transient failures (default: 3) */
maxRetries?: number
}
class NonRetryableError extends Error {
constructor(message: string) {
super(message)
this.name = 'NonRetryableError'
}
}
/**
* Main class for setting up Insights workspaces in tests
*/
export class PlaywrightSetup {
private _requestPromise: Promise<APIRequestContext> | null = null
private baseURL: string
constructor(baseURL?: string) {
// Use baseURL from Playwright config if provided, otherwise fall back to environment variable
this.baseURL = baseURL || process.env.BASE_URL || 'http://localhost:8080'
}
private getRequest(): Promise<APIRequestContext> {
if (!this._requestPromise) {
this._requestPromise = playwrightRequest.newContext({ baseURL: this.baseURL })
}
return this._requestPromise
}
async dispose(): Promise<void> {
if (this._requestPromise) {
const request = await this._requestPromise
this._requestPromise = null
await request.dispose()
}
}
/**
* Call the Django setup endpoint with automatic retry on transient failures.
* Retries up to `maxRetries` times (default 3) with exponential backoff
* (2s, 4s between attempts) to handle intermittent API timeouts in CI.
*/
async callSetupEndpoint(setupType: string, options: PlaywrightSetupOptions = {}): Promise<TestSetupResponse> {
const { data = {}, throwOnError = true, baseURL, maxRetries = 3 } = options
const url = `${baseURL || this.baseURL}/v1/setup_test/${setupType}/`
let lastError: Error | undefined
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const request = await this.getRequest()
const response = await request.post(url, { data })
const responseText = await response.text()
let result: TestSetupResponse
try {
result = JSON.parse(responseText)
} catch (parseError) {
console.error(`[PlaywrightSetup] Failed to parse response as JSON:`, parseError)
throw new Error(`Invalid JSON response from setup endpoint: ${responseText}`)
}
if (!response.ok()) {
// Server errors (5xx) are retryable; client errors (4xx) are not
if (response.status() >= 500 && attempt < maxRetries) {
console.warn(
`[PlaywrightSetup] Server error ${response.status()} on attempt ${attempt}/${maxRetries} for '${setupType}', retrying...`
)
await this.delay(2000 * Math.pow(2, attempt - 1))
continue
}
if (throwOnError) {
console.error(`[PlaywrightSetup] Setup failed - Status: ${response.status()}, Result:`, result)
throw new NonRetryableError(
`Playwright setup failed for '${setupType}': ${result.error || 'Unknown error'}`
)
}
}
return result
} catch (error) {
// Non-retryable errors (e.g. 4xx) should not be retried
if (error instanceof NonRetryableError) {
throw error
}
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < maxRetries) {
const delayMs = 2000 * Math.pow(2, attempt - 1)
console.warn(
`[PlaywrightSetup] Attempt ${attempt}/${maxRetries} failed for '${setupType}': ${lastError.message}. Retrying in ${delayMs}ms...`
)
await this.delay(delayMs)
continue
}
console.error(
`[PlaywrightSetup] All ${maxRetries} attempts failed for '${setupType}':`,
lastError.message
)
if (throwOnError) {
throw new Error(`Failed to call setup endpoint after ${maxRetries} attempts: ${lastError.message}`)
}
return {
success: false,
test_name: setupType,
error: lastError.message,
}
}
}
// Should not be reached, but satisfies TypeScript
throw lastError || new Error(`Failed to call setup endpoint after ${maxRetries} attempts`)
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Creates a complete Insights workspace: Organization → Project → Team + [email protected] user
*
* This is the main setup method - creates everything you need for most tests.
* The test user will be a member of the organization.
*/
async createWorkspace(
dataOrName?: string | Partial<PlaywrightWorkspaceSetupData>
): Promise<PlaywrightWorkspaceSetupResult> {
const data = typeof dataOrName === 'string' ? { organization_name: dataOrName } : (dataOrName ?? {})
const result = await this.callSetupEndpoint('organization_with_team', {
data: data as PlaywrightWorkspaceSetupData,
})
if (!result.success) {
console.error(`[PlaywrightSetup] Workspace creation failed:`, result)
throw new Error(`Failed to create workspace: ${result.error}`)
}
const workspace = result.result as PlaywrightWorkspaceSetupResult
// Validate required fields (using snake_case field names from schema)
const requiredFields = ['organization_id', 'team_id', 'personal_api_key'] as const
const missingFields = requiredFields.filter((field) => !workspace[field])
if (missingFields.length > 0) {
console.error(`[PlaywrightSetup] Workspace missing required fields:`, missingFields)
console.error(`[PlaywrightSetup] Full workspace object:`, workspace)
throw new Error(`Workspace creation returned incomplete data. Missing fields: ${missingFields.join(', ')}`)
}
return workspace
}
async login(page: Page, workspace: PlaywrightWorkspaceSetupResult): Promise<void> {
const maxAttempts = 3
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let response: APIResponse
try {
response = await page.request.post(`${this.baseURL}/v1/login/`, {
data: { email: workspace.user_email, password: LOGIN_PASSWORD },
})
} catch (e) {
if (attempt === maxAttempts) {
throw e
}
await new Promise((r) => setTimeout(r, 500 * attempt))
continue
}
if (response.ok()) {
return
}
if (attempt === maxAttempts || response.status() < 500) {
throw new Error(`Login failed with status ${response.status()}`)
}
await new Promise((r) => setTimeout(r, 500 * attempt))
}
}
/**
* Login using workspace credentials and navigate to the team's project page
*
* Call this after creating a workspace to automatically login and navigate.
* The user will end up on /project/{teamId} ready to test.
*/
async loginAndNavigateToTeam(page: Page, workspace: PlaywrightWorkspaceSetupResult): Promise<void> {
await this.login(page, workspace)
await page.goto(`${this.baseURL}/project/${workspace.team_id}`)
}
/**
* Seed a single InsightsFunctionTemplate row. Templates are global (not team-scoped) and
* Playwright CI doesn't run sync_insights_function_templates, so any test that exercises
* the workflow editor or script function configuration must seed the templates it needs.
* Idempotent across parallel workers.
*/
async seedInsightsFunctionTemplate(data: {
template_id: string
name?: string
status?: 'alpha' | 'beta' | 'stable' | 'deprecated' | 'hidden' | 'coming_soon' | 'client_side'
template_type?: 'destination' | 'transformation' | 'source_webhook' | 'site_destination' | 'site_app'
inputs_schema?: Array<Record<string, any>>
}): Promise<{ template_id: string; created: boolean }> {
const result = await this.callSetupEndpoint('insights_function_template', { data })
if (!result.success) {
throw new Error(`Failed to seed script function template: ${result.error}`)
}
return result.result as { template_id: string; created: boolean }
}
}
/**
* Helper function to create a PlaywrightSetup instance
*/
export function createPlaywrightSetup(baseURL?: string): PlaywrightSetup {
return new PlaywrightSetup(baseURL)
}
/**
* One-off workspace creation (for simple cases)
*/
export async function createTestWorkspace(setupType: string, data?: Record<string, any>): Promise<TestSetupResponse> {
const playwrightSetup = createPlaywrightSetup()
try {
return await playwrightSetup.callSetupEndpoint(setupType, { data })
} finally {
await playwrightSetup.dispose()
}
}