added send webhooks workflow
This commit is contained in:
106
.claude/skills/workflows/SKILL.md
Normal file
106
.claude/skills/workflows/SKILL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
name: workflows
|
||||
description: Create async background tasks (workflows) using SQS or Hatchet. Use when building queue jobs, background processing, or async tasks.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Workflows are async tasks processed by background workers. Two runners:
|
||||
|
||||
| Runner | Use Case | Features |
|
||||
|--------|----------|----------|
|
||||
| **SQS** | Simple fire-and-forget tasks | Fast, no dependencies, max 15min delay |
|
||||
| **Hatchet** | Complex workflows needing retries, multi-step, or long delays | Typed outputs, configurable timeouts, observability |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Add Job Name
|
||||
|
||||
```typescript
|
||||
// server/src/queue/JobName.ts
|
||||
export enum JobName {
|
||||
// ... existing
|
||||
MyNewWorkflow = "my-new-workflow",
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Define Payload & Register
|
||||
|
||||
```typescript
|
||||
// server/src/queue/workflows.ts
|
||||
|
||||
// Add payload type
|
||||
export type MyNewWorkflowPayload = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
// ... your fields
|
||||
};
|
||||
|
||||
// Add to registry
|
||||
const workflowRegistry = {
|
||||
// ... existing
|
||||
myNewWorkflow: {
|
||||
jobName: JobName.MyNewWorkflow,
|
||||
runner: "sqs", // or "hatchet"
|
||||
} as WorkflowConfig<MyNewWorkflowPayload>,
|
||||
};
|
||||
|
||||
// Add trigger function
|
||||
export const workflows = {
|
||||
// ... existing
|
||||
triggerMyNewWorkflow: (payload: MyNewWorkflowPayload, options?: TriggerOptions) =>
|
||||
triggerWorkflow({ name: "myNewWorkflow", payload, options }),
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Create Handler
|
||||
|
||||
**For SQS:** See [references/SQS.md](references/SQS.md)
|
||||
|
||||
**For Hatchet:** See [references/HATCHET.md](references/HATCHET.md)
|
||||
|
||||
### 4. Trigger from Code
|
||||
|
||||
```typescript
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
await workflows.triggerMyNewWorkflow({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId,
|
||||
});
|
||||
|
||||
// With delay
|
||||
await workflows.triggerMyNewWorkflow(payload, { delayMs: 5000 });
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
server/src/
|
||||
├── queue/
|
||||
│ ├── JobName.ts # Job name enum
|
||||
│ ├── workflows.ts # Registry + triggers
|
||||
│ └── initWorkers.ts # SQS message routing
|
||||
└── internal/.../workflows/
|
||||
└── myNewWorkflow/
|
||||
├── myNewWorkflow.ts # Handler
|
||||
└── triggerMyNewWorkflow.ts # (optional) trigger helper
|
||||
```
|
||||
|
||||
## Required Payload Fields
|
||||
|
||||
All workflows must include:
|
||||
```typescript
|
||||
{
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId?: string; // optional but common
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [references/SQS.md](references/SQS.md) - SQS workflow implementation
|
||||
- [references/HATCHET.md](references/HATCHET.md) - Hatchet workflow implementation
|
||||
135
.claude/skills/workflows/references/HATCHET.md
Normal file
135
.claude/skills/workflows/references/HATCHET.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Hatchet Workflows
|
||||
|
||||
For complex workflows needing retries, multi-step, typed outputs, or long delays.
|
||||
|
||||
## Workflow Definition
|
||||
|
||||
```typescript
|
||||
// server/src/internal/.../workflows/myWorkflow/myWorkflow.ts
|
||||
|
||||
import { hatchet } from "@/external/hatchet/initHatchet.js";
|
||||
import { createWorkflowTask } from "@/queue/hatchetWorkflows/createWorkflowTask.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
|
||||
// 1. Define input/output types
|
||||
export type MyWorkflowInput = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
type MyWorkflowOutput = {
|
||||
myTask: {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 2. Create workflow (only if Hatchet enabled)
|
||||
export const myWorkflow = hatchet?.workflow<MyWorkflowInput, MyWorkflowOutput>({
|
||||
name: JobName.MyWorkflow,
|
||||
});
|
||||
|
||||
// 3. Define task
|
||||
myWorkflow?.task({
|
||||
name: JobName.MyWorkflow,
|
||||
executionTimeout: "60s",
|
||||
fn: createWorkflowTask<MyWorkflowInput, MyWorkflowOutput["myTask"]>({
|
||||
handler: async ({ input, autumnContext }) => {
|
||||
const { customerId } = input;
|
||||
|
||||
// Your logic here
|
||||
autumnContext.logger.info(`Processing ${customerId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Completed",
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Register Worker
|
||||
|
||||
```typescript
|
||||
// server/src/queue/initWorkers.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
export const initHatchetWorker = async () => {
|
||||
if (!hatchet) return;
|
||||
|
||||
const worker = await hatchet.worker("hatchet-worker", {
|
||||
workflows: [
|
||||
verifyCacheConsistency!,
|
||||
myWorkflow!, // Add here
|
||||
],
|
||||
});
|
||||
|
||||
worker.start().catch(console.error);
|
||||
};
|
||||
```
|
||||
|
||||
## Register in queueUtils.ts
|
||||
|
||||
```typescript
|
||||
// server/src/queue/queueUtils.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
const hatchetWorkflows: Record<JobName, any> = {
|
||||
[JobName.VerifyCacheConsistency]: verifyCacheConsistency,
|
||||
[JobName.MyWorkflow]: myWorkflow, // Add here
|
||||
};
|
||||
```
|
||||
|
||||
## Triggering with Options
|
||||
|
||||
```typescript
|
||||
await workflows.triggerMyWorkflow(payload, {
|
||||
delayMs: 5000,
|
||||
metadata: {
|
||||
workflowId: generateId("workflow"),
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## createWorkflowTask Helper
|
||||
|
||||
Provides:
|
||||
- Automatic `AutumnContext` creation from input
|
||||
- Error handling with Sentry integration
|
||||
- Workflow logging context
|
||||
|
||||
```typescript
|
||||
createWorkflowTask<TInput, TOutput>({
|
||||
handler: async ({ input, autumnContext }) => {
|
||||
// input: Your typed input
|
||||
// autumnContext: Full AutumnContext with logger, db, org, env, etc.
|
||||
return output;
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
1. ☐ Add to `JobName.ts`
|
||||
2. ☐ Define payload type in `workflows.ts`
|
||||
3. ☐ Add to `workflowRegistry` with `runner: "hatchet"`
|
||||
4. ☐ Add trigger function to `workflows` export
|
||||
5. ☐ Create workflow file with `hatchet?.workflow()` + `.task()`
|
||||
6. ☐ Add to `hatchetWorkflows` map in `queueUtils.ts`
|
||||
7. ☐ Add to `initHatchetWorker` workflows array
|
||||
|
||||
## SQS vs Hatchet
|
||||
|
||||
| Feature | SQS | Hatchet |
|
||||
|---------|-----|---------|
|
||||
| Setup complexity | Lower | Higher |
|
||||
| Typed output | No | Yes |
|
||||
| Multi-step tasks | No | Yes |
|
||||
| Configurable timeout | 30s visibility | Per-task |
|
||||
| Max delay | 15 minutes | Unlimited |
|
||||
| Observability | CloudWatch | Hatchet UI |
|
||||
124
.claude/skills/workflows/references/SQS.md
Normal file
124
.claude/skills/workflows/references/SQS.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# SQS Workflows
|
||||
|
||||
Simple async tasks processed by SQS workers.
|
||||
|
||||
## Handler Signature
|
||||
|
||||
```typescript
|
||||
// server/src/internal/.../workflows/myWorkflow/myWorkflow.ts
|
||||
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { MyWorkflowPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const myWorkflow = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: MyWorkflowPayload;
|
||||
}) => {
|
||||
const { customerId } = payload;
|
||||
|
||||
// Your logic here
|
||||
ctx.logger.info(`Processing ${customerId}`);
|
||||
};
|
||||
```
|
||||
|
||||
## Register in initWorkers.ts
|
||||
|
||||
```typescript
|
||||
// server/src/queue/initWorkers.ts
|
||||
|
||||
import { myWorkflow } from "@/internal/.../workflows/myWorkflow/myWorkflow.js";
|
||||
|
||||
const processMessage = async ({ message, db }) => {
|
||||
// ... existing code
|
||||
|
||||
if (job.name === JobName.MyWorkflow) {
|
||||
if (!ctx) {
|
||||
workerLogger.error("No context found for my workflow job");
|
||||
return;
|
||||
}
|
||||
await myWorkflow({ ctx, payload: job.data });
|
||||
return;
|
||||
}
|
||||
|
||||
// ... rest of handlers
|
||||
};
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```typescript
|
||||
// server/src/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.ts
|
||||
|
||||
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import type { SendProductsUpdatedPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const sendProductsUpdated = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: SendProductsUpdatedPayload;
|
||||
}) => {
|
||||
const { db, org, env } = ctx;
|
||||
const { customerProductId, scenario, customerId } = payload;
|
||||
|
||||
const fullCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
|
||||
// ... build webhook payload
|
||||
|
||||
await sendSvixEvent({
|
||||
org,
|
||||
env,
|
||||
eventType: "customer.products.updated",
|
||||
data: { scenario, customer, updated_product },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Trigger Helper (Optional)
|
||||
|
||||
```typescript
|
||||
// server/src/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.ts
|
||||
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
export const billingPlanToSendProductsUpdated = async ({
|
||||
ctx,
|
||||
cusProduct,
|
||||
scenario,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
cusProduct: CustomerProduct;
|
||||
scenario: string;
|
||||
}) => {
|
||||
// Skip in tests if configured
|
||||
if (ctx.testOptions?.skipWebhooks) return;
|
||||
|
||||
await workflows.triggerSendProductsUpdated({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId: cusProduct.customer_id,
|
||||
customerProductId: cusProduct.id,
|
||||
scenario,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
1. ☐ Add to `JobName.ts`
|
||||
2. ☐ Define payload type in `workflows.ts`
|
||||
3. ☐ Add to `workflowRegistry` with `runner: "sqs"`
|
||||
4. ☐ Add trigger function to `workflows` export
|
||||
5. ☐ Create handler file
|
||||
6. ☐ Add case in `initWorkers.ts` `processMessage`
|
||||
@@ -78,6 +78,7 @@ Load these on-demand for detailed information:
|
||||
- [references/TRACK-CHECK.md](references/TRACK-CHECK.md) - Track/check endpoint testing, credit systems, Decimal.js
|
||||
- [references/EXPECTATIONS.md](references/EXPECTATIONS.md) - All expectation utilities
|
||||
- [references/GOTCHAS.md](references/GOTCHAS.md) - Common pitfalls, debugging, billing edge cases
|
||||
- [references/WEBHOOKS.md](references/WEBHOOKS.md) - Outbound webhook testing with Svix Play
|
||||
|
||||
## File Location
|
||||
|
||||
|
||||
80
.claude/skills/write-test/references/WEBHOOKS.md
Normal file
80
.claude/skills/write-test/references/WEBHOOKS.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Outbound Webhook Testing
|
||||
|
||||
Test Autumn's outbound webhooks using Svix Play (free, no signup).
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
import { generatePlayToken, getPlayWebhookUrl, waitForWebhook } from "./utils/svixPlayClient.js";
|
||||
import { createTestEndpoint, deleteTestEndpoint } from "./utils/svixTestEndpoint.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
|
||||
let playToken: string;
|
||||
let endpointId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
playToken = await generatePlayToken();
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (!svixAppId) throw new Error("Svix not configured");
|
||||
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl: getPlayWebhookUrl(playToken) });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (svixAppId && endpointId) await deleteTestEndpoint({ appId: svixAppId, endpointId });
|
||||
});
|
||||
```
|
||||
|
||||
## Test Pattern
|
||||
|
||||
```typescript
|
||||
test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated")}`, async () => {
|
||||
const customerId = "webhook-test";
|
||||
const freeDefault = products.base({ id: "free", items: [...], isDefault: true });
|
||||
|
||||
// Setup products only (no customer)
|
||||
const { autumnV1 } = await initScenario({
|
||||
setup: [s.products({ list: [freeDefault], prefix: customerId })],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Create customer with webhooks enabled
|
||||
await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Test",
|
||||
internalOptions: { disable_defaults: false, default_group: customerId },
|
||||
skipWebhooks: false, // Enable webhooks
|
||||
});
|
||||
|
||||
// Wait for webhook
|
||||
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
|
||||
token: playToken,
|
||||
predicate: (p) => p.type === "customer.products.updated" && p.data?.customer?.id === customerId,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.payload.data.scenario).toBe("new");
|
||||
});
|
||||
```
|
||||
|
||||
## Key Points
|
||||
|
||||
| Normal Tests | Webhook Tests |
|
||||
|--------------|---------------|
|
||||
| `initScenario` creates customer | Create customer manually with `skipWebhooks: false` |
|
||||
| Immediate assertions | Poll with `waitForWebhook` (10-15s timeout) |
|
||||
|
||||
## Utilities
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `generatePlayToken()` | Get Svix Play token |
|
||||
| `getPlayWebhookUrl(token)` | Get webhook URL |
|
||||
| `waitForWebhook({ token, predicate, timeoutMs })` | Poll for webhook |
|
||||
| `createTestEndpoint({ appId, playUrl })` | Register endpoint |
|
||||
| `deleteTestEndpoint({ appId, endpointId })` | Cleanup |
|
||||
|
||||
## Location
|
||||
|
||||
`server/tests/integration/billing/autumn-webhooks/`
|
||||
4
.superset/config.json
Normal file
4
.superset/config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"setup": [],
|
||||
"teardown": []
|
||||
}
|
||||
48
bun.lock
48
bun.lock
@@ -29,9 +29,11 @@
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"ink": "^6.6.0",
|
||||
"inquirer": "^12.6.3",
|
||||
"ora": "^9.0.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"react": "^19.2.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
@@ -2014,7 +2016,7 @@
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
"cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
@@ -3108,7 +3110,7 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
|
||||
|
||||
@@ -3316,7 +3318,7 @@
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
|
||||
|
||||
"react-cmdk": ["react-cmdk@1.3.9", "", { "dependencies": { "@headlessui/react": "^1.6.4", "@heroicons/react": "^2.0.13", "html-webpack-plugin": "^5.5.0" }, "peerDependencies": { "react": "^16.x || ^17.x || ^18.x", "react-dom": "^16.x || ^17.x || ^18.x" } }, "sha512-MSVmAQZ9iqY7hO3r++XP6yWSHzGfMDGMvY3qlDT8k5RiWoRFwO1CGPlsWzhvcUbPilErzsMKK7uB4McEcX4B6g=="],
|
||||
|
||||
@@ -3428,7 +3430,7 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
@@ -3906,12 +3908,16 @@
|
||||
|
||||
"@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"@autumn/server/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@autumn/shared/@date-fns/utc": ["@date-fns/utc@2.1.0", "", {}, "sha512-176grgAgU2U303rD2/vcOmNg0kGPbhzckuH1TEP2al7n0AQipZIy9P15usd2TKQCG1g+E1jX/ZVQSzs4sUDwgA=="],
|
||||
|
||||
"@autumn/vite/@types/node": ["@types/node@22.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="],
|
||||
|
||||
"@autumn/vite/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
|
||||
"@autumn/vite/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@autumn/vite/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
||||
|
||||
"@autumn/vite/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
|
||||
@@ -4274,6 +4280,8 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core/@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@7.1.0", "", {}, "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA=="],
|
||||
|
||||
"@headlessui/react/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
@@ -4636,8 +4644,6 @@
|
||||
|
||||
"http-proxy/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"ink/cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
|
||||
|
||||
"ink-spinner/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
@@ -4676,6 +4682,8 @@
|
||||
|
||||
"openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||
|
||||
"ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
@@ -4702,8 +4710,16 @@
|
||||
|
||||
"public-encrypt/bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
|
||||
|
||||
"react-cmdk/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-confetti-explosion/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-day-picker/date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
|
||||
|
||||
"react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-email/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"react-email/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
@@ -4720,8 +4736,6 @@
|
||||
|
||||
"renderkid/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
@@ -5258,8 +5272,6 @@
|
||||
|
||||
"gtoken/gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"ink/cli-cursor/restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||
|
||||
"langsmith/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"langsmith/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
@@ -5282,6 +5294,8 @@
|
||||
|
||||
"openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||
|
||||
"ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
|
||||
@@ -5352,6 +5366,8 @@
|
||||
|
||||
"react-email/glob/path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="],
|
||||
|
||||
"react-email/ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"react-email/ora/cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||
|
||||
"react-email/ora/log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||
@@ -5630,8 +5646,6 @@
|
||||
|
||||
"css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
||||
|
||||
"ink/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"md5.js/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
"md5.js/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
@@ -5642,6 +5656,10 @@
|
||||
|
||||
"nodemon/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
|
||||
|
||||
"posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="],
|
||||
@@ -5650,6 +5668,8 @@
|
||||
|
||||
"react-email/glob/path-scurry/lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="],
|
||||
|
||||
"react-email/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"react-email/ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"react-email/ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
@@ -5746,6 +5766,10 @@
|
||||
|
||||
"@aws-sdk/credential-providers/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="],
|
||||
|
||||
"react-email/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"react-email/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/fetch-http-handler/@smithy/querystring-builder": ["@smithy/querystring-builder@3.0.11", "", { "dependencies": { "@smithy/types": "^3.7.2", "@smithy/util-uri-escape": "^3.0.0", "tslib": "^2.6.2" } }, "sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-http/@smithy/util-stream/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@3.0.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ=="],
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
"chalk": "^5.3.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"ink": "^6.6.0",
|
||||
"inquirer": "^12.6.3",
|
||||
"ora": "^9.0.0",
|
||||
"p-limit": "^7.2.0"
|
||||
"p-limit": "^7.2.0",
|
||||
"react": "^19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/inquirer": "^9.0.7",
|
||||
|
||||
@@ -143,10 +143,11 @@ async function runTest() {
|
||||
|
||||
// Detect if we're already in the server directory (e.g., when run via server/run.sh)
|
||||
const cwd = process.cwd();
|
||||
const serverDir =
|
||||
const projectRoot =
|
||||
cwd.endsWith("/server") || cwd.endsWith("\\server")
|
||||
? cwd
|
||||
: resolve(cwd, "server");
|
||||
? resolve(cwd, "..")
|
||||
: cwd;
|
||||
const serverDir = resolve(projectRoot, "server");
|
||||
|
||||
// Handle special "setup" command
|
||||
if (scriptName === "setup") {
|
||||
@@ -175,7 +176,12 @@ async function runTest() {
|
||||
return;
|
||||
}
|
||||
|
||||
const shellScript = resolve(serverDir, "shell", `${scriptName}.sh`);
|
||||
const shellScript = resolve(
|
||||
projectRoot,
|
||||
"scripts",
|
||||
"testGroups",
|
||||
`${scriptName}.sh`,
|
||||
);
|
||||
|
||||
// First try to find a shell script
|
||||
if (existsSync(shellScript)) {
|
||||
@@ -186,7 +192,7 @@ async function runTest() {
|
||||
);
|
||||
|
||||
const child = spawn("bash", [shellScript, ...additionalArgs], {
|
||||
cwd: serverDir,
|
||||
cwd: projectRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, NODE_ENV: "production" },
|
||||
});
|
||||
|
||||
@@ -31,6 +31,11 @@ BUN_PARALLEL_COMPACT() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTests.ts "$@" --compact
|
||||
}
|
||||
|
||||
# V2 test runner - shows individual tests, better error display (Ink-based)
|
||||
BUN_PARALLEL_V2() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD scripts/testScripts/runTestsV2.tsx "$@"
|
||||
}
|
||||
|
||||
# Setup function
|
||||
BUN_SETUP() {
|
||||
cd "$SERVER_DIR" && $BUN_CMD tests/setupMain.ts
|
||||
|
||||
@@ -9,35 +9,35 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
# Run tests using TypeScript runner with compact mode
|
||||
# Adjust --max to control concurren.cy (default: 6)
|
||||
bun test:integration check
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
'server/tests/balances/track/basic' \
|
||||
'server/tests/balances/track/concurrency' \
|
||||
'server/tests/balances/track/breakdown' \
|
||||
'server/tests/balances/track/credit-systems' \
|
||||
'server/tests/balances/track/entity-products' \
|
||||
'server/tests/balances/track/legacy' \
|
||||
'server/tests/balances/track/allocated' \
|
||||
'server/tests/balances/track/entity-balances' \
|
||||
'server/tests/balances/track/negative' \
|
||||
'server/tests/balances/track/rollovers' \
|
||||
'server/tests/balances/track/race-condition' \
|
||||
'server/tests/balances/track/paid-allocated' \
|
||||
'server/tests/balances/track/edge-cases' \
|
||||
'server/tests/balances/check/breakdown' \
|
||||
'server/tests/balances/track/loose' \
|
||||
'server/tests/balances/check/basic' \
|
||||
'server/tests/balances/check/credit-systems' \
|
||||
'server/tests/balances/check/misc' \
|
||||
'server/tests/balances/check/prepaid' \
|
||||
'server/tests/balances/check/send-event' \
|
||||
'server/tests/balances/check/loose' \
|
||||
'server/tests/balances/set-usage' \
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'integration/balances/check' \
|
||||
'balances/track/basic' \
|
||||
'balances/track/concurrency' \
|
||||
'balances/track/breakdown' \
|
||||
'balances/track/credit-systems' \
|
||||
'balances/track/entity-products' \
|
||||
'balances/track/legacy' \
|
||||
'balances/track/allocated' \
|
||||
'balances/track/entity-balances' \
|
||||
'balances/track/negative' \
|
||||
'balances/track/rollovers' \
|
||||
'balances/track/race-condition' \
|
||||
'balances/track/paid-allocated' \
|
||||
'balances/track/edge-cases' \
|
||||
'balances/check/breakdown' \
|
||||
'balances/track/loose' \
|
||||
'balances/check/credit-systems' \
|
||||
'balances/check/misc' \
|
||||
'balances/check/prepaid' \
|
||||
'balances/check/send-event' \
|
||||
'balances/check/loose' \
|
||||
'balances/set-usage' \
|
||||
--max=6
|
||||
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/balances/update/filters' \
|
||||
'server/tests/balances/update/update-combined' \
|
||||
'server/tests/balances/update/update-current-balance/basic' \
|
||||
|
||||
@@ -4,7 +4,7 @@ source "$(dirname "$0")/config.sh"
|
||||
|
||||
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/attach/basic' \
|
||||
'server/tests/attach/upgrade' \
|
||||
'server/tests/attach/downgrade' \
|
||||
@@ -15,10 +15,9 @@ BUN_PARALLEL_COMPACT \
|
||||
'server/tests/integration/billing/invoice-action-required' \
|
||||
'server/tests/integration/billing/cancel' \
|
||||
'server/tests/integration/billing/cancel/add-ons' \
|
||||
'server/tests/renew' \
|
||||
--max=6
|
||||
|
||||
BUN_PARALLEL_COMPACT \
|
||||
BUN_PARALLEL_V2 \
|
||||
'server/tests/attach/entities' \
|
||||
--max=6
|
||||
# 'server/tests/external-psps/revenuecat' \
|
||||
|
||||
@@ -6,19 +6,25 @@ source "$(dirname "$0")/config.sh"
|
||||
# Exit immediately if a command exits with a non-zero status
|
||||
set -e
|
||||
|
||||
bun test:integration create-customer
|
||||
bun test:integration update-subscription/custom-plan
|
||||
bun test:integration update-subscription/discounts
|
||||
bun test:integration update-subscription/errors
|
||||
bun test:integration update-subscription/free-trial
|
||||
bun test:integration update-subscription/invoice
|
||||
bun test:integration update-subscription/multi-product
|
||||
bun test:integration update-subscription/update-quantity
|
||||
bun test:integration update-subscription/version-update
|
||||
# bun test:integration create-customer
|
||||
# bun test:integration update-subscription/custom-plan
|
||||
# bun test:integration update-subscription/discounts
|
||||
# bun test:integration update-subscription/errors
|
||||
# bun test:integration update-subscription/free-trial
|
||||
# bun test:integration update-subscription/invoice
|
||||
# bun test:integration update-subscription/multi-product
|
||||
# bun test:integration update-subscription/update-quantity
|
||||
# bun test:integration update-subscription/version-update
|
||||
|
||||
|
||||
# Adjust --max to control concurrency (default: 6)
|
||||
# BUN_PARALLEL_COMPACT \
|
||||
# 'server/tests/billing/update-subscription/custom-plan' \
|
||||
# --max=6
|
||||
|
||||
BUN_PARALLEL_V2 \
|
||||
'update-subscription/custom-plan' \
|
||||
'update-subscription/discounts' \
|
||||
'update-subscription/errors' \
|
||||
'update-subscription/free-trial' \
|
||||
'update-subscription/invoice' \
|
||||
'update-subscription/multi-product' \
|
||||
'update-subscription/update-quantity' \
|
||||
'update-subscription/version-update' \
|
||||
--max=3
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { loadLocalEnv } from "@server/utils/envUtils.js";
|
||||
@@ -627,6 +628,9 @@ class TestRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Base paths for shorthand test paths (tried in order)
|
||||
const TEST_BASE_PATHS = ["server/tests/integration/billing", "server/tests"];
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
@@ -645,7 +649,23 @@ for (const arg of args) {
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
directories.push(arg);
|
||||
// Try to resolve the path - if it doesn't exist, try prepending base paths
|
||||
let resolvedPath = arg;
|
||||
const fullPath = resolve(process.cwd(), arg);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
// Try each base path in order
|
||||
for (const basePath of TEST_BASE_PATHS) {
|
||||
const withBase = `${basePath}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directories.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
724
scripts/testScripts/runTestsV2.ts
Normal file
724
scripts/testScripts/runTestsV2.ts
Normal file
@@ -0,0 +1,724 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { loadLocalEnv } from "@server/utils/envUtils.js";
|
||||
import { spawn } from "bun";
|
||||
import chalk from "chalk";
|
||||
import dotenv from "dotenv";
|
||||
import pLimit from "p-limit";
|
||||
|
||||
loadLocalEnv();
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(process.cwd(), "server", ".env") });
|
||||
|
||||
// Base path for shorthand test paths
|
||||
const INTEGRATION_TEST_BASE = "server/tests/integration/billing";
|
||||
|
||||
interface IndividualTest {
|
||||
name: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
duration?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
location?: string; // file:line for cmd+click
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestFileResult {
|
||||
file: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
tests: IndividualTest[];
|
||||
currentTest?: string;
|
||||
output: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
class TestRunnerV2 {
|
||||
private results: Map<string, TestFileResult> = new Map();
|
||||
private testFiles: string[] = [];
|
||||
private maxParallel: number = 6;
|
||||
private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
private spinnerIndex = 0;
|
||||
private renderInterval?: Timer;
|
||||
private startLine = 0;
|
||||
private lastRenderedLines = 0;
|
||||
|
||||
constructor(maxParallel?: number) {
|
||||
if (maxParallel) this.maxParallel = maxParallel;
|
||||
}
|
||||
|
||||
async collectTestFiles(directories: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const resolvedDir = resolve(process.cwd(), dir);
|
||||
try {
|
||||
const files = await readdir(resolvedDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedDir, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error reading directory ${dir}:`), error);
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
private parseTestOutput(output: string, filePath: string): IndividualTest[] {
|
||||
const tests: IndividualTest[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
// Track where each test result appears
|
||||
// Error output appears BEFORE the (fail) line in bun test output
|
||||
let lastTestEndIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Match (pass) or (fail) test results
|
||||
const passMatch = line.match(
|
||||
/^\(pass\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/,
|
||||
);
|
||||
const failMatch = line.match(
|
||||
/^\(fail\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/,
|
||||
);
|
||||
|
||||
if (passMatch) {
|
||||
const [, name, duration] = passMatch;
|
||||
tests.push({
|
||||
name: name.trim(),
|
||||
status: "passed",
|
||||
duration: this.parseDuration(duration),
|
||||
});
|
||||
lastTestEndIndex = i;
|
||||
} else if (failMatch) {
|
||||
const [, name, duration] = failMatch;
|
||||
|
||||
// Look BACKWARDS from this line to find the error output
|
||||
// Error appears between the last test result and this (fail) line
|
||||
const errorStartIndex = lastTestEndIndex + 1;
|
||||
const errorLines = lines.slice(errorStartIndex, i);
|
||||
|
||||
const test: IndividualTest = {
|
||||
name: name.trim(),
|
||||
status: "failed",
|
||||
duration: this.parseDuration(duration),
|
||||
};
|
||||
|
||||
// Parse the error from the lines before this (fail)
|
||||
this.parseErrorFromLines(test, errorLines, filePath);
|
||||
|
||||
tests.push(test);
|
||||
lastTestEndIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
private parseErrorFromLines(
|
||||
test: IndividualTest,
|
||||
errorLines: string[],
|
||||
filePath: string,
|
||||
): void {
|
||||
const errorText = errorLines.join("\n");
|
||||
|
||||
// Find error message - look for "error:" line
|
||||
let errorMessage = "";
|
||||
for (const line of errorLines) {
|
||||
const errorMatch = line.match(/^error:\s*(.+)/i);
|
||||
if (errorMatch) {
|
||||
errorMessage = errorMatch[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Expected/Received for assertion errors
|
||||
const expectedMatch = errorText.match(/Expected:\s*(.+)/);
|
||||
const receivedMatch = errorText.match(/Received:\s*(.+)/);
|
||||
if (expectedMatch && receivedMatch) {
|
||||
errorMessage = `Expected: ${expectedMatch[1]}, Received: ${receivedMatch[1]}`;
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (errorText.includes("this test timed out")) {
|
||||
errorMessage = "Test timed out";
|
||||
}
|
||||
|
||||
// Find location - prioritize the test file itself in stack trace
|
||||
let location: string | undefined;
|
||||
const testFileName = basename(filePath);
|
||||
|
||||
for (const line of errorLines) {
|
||||
// Match stack trace lines like:
|
||||
// at async <anonymous> (/path/to/file.test.ts:38:29)
|
||||
// at functionName (/path/to/file.ts:123:45)
|
||||
const stackMatch = line.match(/at\s+.*?\(([^)]+\.ts):(\d+):\d+\)/);
|
||||
if (stackMatch) {
|
||||
const matchedFile = stackMatch[1];
|
||||
const lineNum = stackMatch[2];
|
||||
|
||||
// Prefer .test.ts files
|
||||
if (matchedFile.endsWith(".test.ts")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise take first server file if we don't have one yet
|
||||
if (!location && matchedFile.includes("/server/")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.error = {
|
||||
message: errorMessage || "Test failed",
|
||||
location,
|
||||
details: errorText.slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
private parseDuration(duration: string): number {
|
||||
// Parse "123.45ms" or "1.23s" to milliseconds
|
||||
if (duration.endsWith("ms")) {
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
if (duration.endsWith("s")) {
|
||||
return Number.parseFloat(duration) * 1000;
|
||||
}
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
|
||||
private extractCurrentTest(output: string): string | null {
|
||||
// Look for the last test that started (before pass/fail)
|
||||
const lines = output.split("\n");
|
||||
|
||||
// Find last pass/fail to know what's completed
|
||||
let lastCompletedIndex = -1;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].match(/^\(pass\)/) || lines[i].match(/^\(fail\)/)) {
|
||||
lastCompletedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The "current" test would be indicated by the test that's running
|
||||
// Bun doesn't explicitly say which test is running, so we show the last completed
|
||||
if (lastCompletedIndex >= 0) {
|
||||
const match = lines[lastCompletedIndex].match(
|
||||
/^\((?:pass|fail)\)\s+(.+?)\s+\[/,
|
||||
);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hideCursor() {
|
||||
process.stdout.write("\x1B[?25l");
|
||||
}
|
||||
|
||||
private showCursor() {
|
||||
process.stdout.write("\x1B[?25h");
|
||||
}
|
||||
|
||||
private moveCursor(line: number, col: number = 0) {
|
||||
process.stdout.write(`\x1B[${line};${col}H`);
|
||||
}
|
||||
|
||||
private clearLine() {
|
||||
process.stdout.write("\x1B[2K");
|
||||
}
|
||||
|
||||
private clearToEndOfScreen() {
|
||||
process.stdout.write("\x1B[J");
|
||||
}
|
||||
|
||||
private truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
||||
const spinner = this.spinnerFrames[this.spinnerIndex];
|
||||
|
||||
let lineNum = this.startLine;
|
||||
|
||||
// Calculate stats - only count tests from COMPLETED files for accurate progress
|
||||
const runningFiles = Array.from(this.results.entries()).filter(
|
||||
([_, r]) => r.status === "running",
|
||||
);
|
||||
const completedFiles = Array.from(this.results.entries()).filter(
|
||||
([_, r]) => r.status === "passed" || r.status === "failed",
|
||||
);
|
||||
const pendingFiles = Array.from(this.results.entries()).filter(
|
||||
([_, r]) => r.status === "pending",
|
||||
);
|
||||
|
||||
// Only count tests from completed files for stable progress
|
||||
const completedTests = completedFiles.flatMap(([_, r]) => r.tests);
|
||||
const passedTests = completedTests.filter(
|
||||
(t) => t.status === "passed",
|
||||
).length;
|
||||
const failedTests = completedTests.filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
|
||||
// Header
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.cyan.bold(`Running ${this.testFiles.length} test files...\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
// Blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
|
||||
// Show running files with their current test
|
||||
if (runningFiles.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.yellow.bold(`Running (${runningFiles.length}):\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file, result] of runningFiles) {
|
||||
const fileName = basename(file);
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
|
||||
// Show file with spinner
|
||||
let fileDisplay = ` ${chalk.cyan(spinner)} ${fileName}`;
|
||||
|
||||
// Show completed tests count for this file
|
||||
const filePassedCount = result.tests.filter(
|
||||
(t) => t.status === "passed",
|
||||
).length;
|
||||
const fileFailedCount = result.tests.filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
|
||||
if (filePassedCount > 0 || fileFailedCount > 0) {
|
||||
fileDisplay += chalk.dim(
|
||||
` (${chalk.green(`✓${filePassedCount}`)}${fileFailedCount > 0 ? chalk.red(` ✗${fileFailedCount}`) : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Show current/last test
|
||||
const currentTest = this.extractCurrentTest(result.output);
|
||||
if (currentTest) {
|
||||
fileDisplay += chalk.dim(` › ${this.truncate(currentTest, 40)}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${fileDisplay}\n`);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Blank line after running
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show recently completed files (last 3)
|
||||
if (completedFiles.length > 0) {
|
||||
const recentCompleted = completedFiles.slice(-3);
|
||||
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.dim(
|
||||
`Completed (${completedFiles.length}/${this.testFiles.length} files):\n`,
|
||||
),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file, result] of recentCompleted) {
|
||||
const fileName = basename(file);
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
|
||||
const filePassedCount = result.tests.filter(
|
||||
(t) => t.status === "passed",
|
||||
).length;
|
||||
const fileFailedCount = result.tests.filter(
|
||||
(t) => t.status === "failed",
|
||||
).length;
|
||||
|
||||
const icon =
|
||||
result.status === "passed" ? chalk.green("✓") : chalk.red("✗");
|
||||
const nameColor = result.status === "passed" ? chalk.dim : chalk.white;
|
||||
|
||||
process.stdout.write(
|
||||
` ${icon} ${nameColor(fileName)} ${chalk.dim(`(${chalk.green(`✓${filePassedCount}`)}${fileFailedCount > 0 ? chalk.red(` ✗${fileFailedCount}`) : ""})`)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show inline errors from recently completed files (compact view)
|
||||
const recentFailedTests = completedFiles
|
||||
.flatMap(([file, result]) =>
|
||||
result.tests
|
||||
.filter((t) => t.status === "failed")
|
||||
.map((t) => ({ ...t, file })),
|
||||
)
|
||||
.slice(-2); // Show last 2 failures
|
||||
|
||||
if (recentFailedTests.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(chalk.red.bold(`Recent Failures:\n`));
|
||||
lineNum++;
|
||||
|
||||
for (const test of recentFailedTests) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
` ${chalk.red("✗")} ${this.truncate(test.name, 50)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
if (test.error?.message) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
` ${chalk.dim("→")} ${chalk.yellow(this.truncate(test.error.message, 60))}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
if (test.error?.location) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
` ${chalk.dim("→")} ${chalk.cyan(test.error.location)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
}
|
||||
|
||||
// Blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Progress bar
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(chalk.dim("─".repeat(60) + "\n"));
|
||||
lineNum++;
|
||||
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
`${chalk.cyan(spinner)} Progress: ${chalk.bold(`${completedFiles.length}/${this.testFiles.length} files`)} | ` +
|
||||
`${chalk.green(`✓ ${passedTests}`)} | ` +
|
||||
`${failedTests > 0 ? chalk.red(`✗ ${failedTests}`) : chalk.dim(`✗ ${failedTests}`)} | ` +
|
||||
`${chalk.dim(`${runningFiles.length} running`)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
// Clear remaining lines
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearToEndOfScreen();
|
||||
|
||||
this.lastRenderedLines = lineNum - this.startLine;
|
||||
}
|
||||
|
||||
async runTest(file: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Initialize as running
|
||||
const result: TestFileResult = {
|
||||
file,
|
||||
status: "running",
|
||||
tests: [],
|
||||
output: "",
|
||||
duration: 0,
|
||||
};
|
||||
this.results.set(file, result);
|
||||
|
||||
try {
|
||||
const proc = spawn(["bun", "test", "--timeout", "0", file], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
let output = "";
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (proc.stdout) {
|
||||
for await (const chunk of proc.stdout) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
result.output = output;
|
||||
|
||||
// Parse tests as they complete
|
||||
result.tests = this.parseTestOutput(output, file);
|
||||
this.results.set(file, result);
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
for await (const chunk of proc.stderr) {
|
||||
output += decoder.decode(chunk);
|
||||
result.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Final parse
|
||||
const tests = this.parseTestOutput(output, file);
|
||||
const hasFailures = tests.some((t) => t.status === "failed");
|
||||
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: hasFailures ? "failed" : "passed",
|
||||
tests,
|
||||
output,
|
||||
duration,
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: "failed",
|
||||
output: String(error),
|
||||
duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.renderInterval) {
|
||||
clearInterval(this.renderInterval);
|
||||
}
|
||||
this.showCursor();
|
||||
}
|
||||
|
||||
private handleInterrupt() {
|
||||
this.cleanup();
|
||||
console.log(
|
||||
chalk.yellow.bold("\n\n⚠ Tests interrupted by user (Ctrl+C)\n"),
|
||||
);
|
||||
this.printSummary();
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
async run(directories: string[]): Promise<void> {
|
||||
this.testFiles = await this.collectTestFiles(directories);
|
||||
|
||||
if (this.testFiles.length === 0) {
|
||||
console.log(chalk.yellow("No test files found in specified directories"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize all tests as pending
|
||||
for (const file of this.testFiles) {
|
||||
this.results.set(file, {
|
||||
file,
|
||||
status: "pending",
|
||||
tests: [],
|
||||
output: "",
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Setup SIGINT handler
|
||||
const sigintHandler = () => this.handleInterrupt();
|
||||
process.on("SIGINT", sigintHandler);
|
||||
|
||||
// Hide cursor and create initial space
|
||||
this.hideCursor();
|
||||
this.startLine = 1;
|
||||
|
||||
// Create some initial space
|
||||
for (let i = 0; i < 20; i++) {
|
||||
console.log();
|
||||
}
|
||||
process.stdout.write("\x1B[20A");
|
||||
|
||||
// Start rendering loop
|
||||
this.renderInterval = setInterval(() => this.render(), 100);
|
||||
|
||||
// Run tests with concurrency limit
|
||||
const limit = pLimit(this.maxParallel);
|
||||
const promises = this.testFiles.map((file) =>
|
||||
limit(() => this.runTest(file)),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Remove SIGINT handler
|
||||
process.off("SIGINT", sigintHandler);
|
||||
|
||||
// Final render and cleanup
|
||||
this.cleanup();
|
||||
this.render();
|
||||
|
||||
// Move past the rendered output
|
||||
process.stdout.write(`\x1B[${this.lastRenderedLines + 2}B`);
|
||||
|
||||
// Print summary
|
||||
this.printSummary();
|
||||
}
|
||||
|
||||
private printSummary() {
|
||||
const allTests = Array.from(this.results.values()).flatMap((r) => r.tests);
|
||||
const failedTests = allTests.filter((t) => t.status === "failed");
|
||||
const passedTests = allTests.filter((t) => t.status === "passed");
|
||||
const totalDuration = Array.from(this.results.values()).reduce(
|
||||
(sum, r) => sum + r.duration,
|
||||
0,
|
||||
);
|
||||
|
||||
console.log("\n");
|
||||
|
||||
if (failedTests.length === 0) {
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
`═${"═".repeat(68)}═\n` +
|
||||
` ✓ ALL ${passedTests.length} TESTS PASSED (${(totalDuration / 1000).toFixed(1)}s)\n` +
|
||||
`═${"═".repeat(68)}═\n`,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Failed tests summary
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`═${"═".repeat(68)}═\n` +
|
||||
` FAILED TESTS (${failedTests.length})\n` +
|
||||
`═${"═".repeat(68)}═`,
|
||||
),
|
||||
);
|
||||
|
||||
// Group failed tests by file
|
||||
const failedByFile = new Map<string, IndividualTest[]>();
|
||||
for (const [file, result] of this.results.entries()) {
|
||||
const fileFailed = result.tests.filter((t) => t.status === "failed");
|
||||
if (fileFailed.length > 0) {
|
||||
failedByFile.set(file, fileFailed);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [file, tests] of failedByFile) {
|
||||
console.log(chalk.red.bold(`\n📁 ${basename(file)}`));
|
||||
console.log(chalk.dim("─".repeat(60)));
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(chalk.red(`\n ✗ ${test.name}`));
|
||||
|
||||
if (test.error?.location) {
|
||||
console.log(chalk.cyan(` ${test.error.location}`));
|
||||
}
|
||||
|
||||
if (test.error?.message) {
|
||||
console.log(chalk.yellow(`\n ${test.error.message}`));
|
||||
}
|
||||
|
||||
if (test.error?.details) {
|
||||
// Show a few lines of error details
|
||||
const detailLines = test.error.details
|
||||
.split("\n")
|
||||
.filter((l) => l.trim())
|
||||
.slice(0, 8);
|
||||
for (const line of detailLines) {
|
||||
console.log(chalk.dim(` ${this.truncate(line.trim(), 70)}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n═${"═".repeat(68)}═\n` +
|
||||
` SUMMARY: ${chalk.green(`${passedTests.length} passed`)} | ${chalk.red(`${failedTests.length} failed`)} | ${(totalDuration / 1000).toFixed(1)}s\n` +
|
||||
`═${"═".repeat(68)}═\n`,
|
||||
),
|
||||
);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
let maxParallel = 6;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--max=")) {
|
||||
maxParallel = Number.parseInt(arg.split("=")[1], 10);
|
||||
} else if (arg.startsWith("-")) {
|
||||
console.error(chalk.red(`Unknown option: ${arg}`));
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.ts <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Try to resolve the path - if it doesn't exist, prepend the base path
|
||||
let resolvedPath = arg;
|
||||
const fullPath = resolve(process.cwd(), arg);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
const withBase = `${INTEGRATION_TEST_BASE}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
}
|
||||
}
|
||||
|
||||
directories.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error(chalk.red("Error: No test directories specified"));
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.ts <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
console.log("\nOptions:");
|
||||
console.log(" --max=N Set maximum parallel test files (default: 6)");
|
||||
console.log("\nExamples:");
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTestsV2.ts update-subscription/custom-plan",
|
||||
);
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTestsV2.ts update-subscription/custom-plan update-subscription/errors --max=4",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
const runner = new TestRunnerV2(maxParallel);
|
||||
await runner.run(directories);
|
||||
697
scripts/testScripts/runTestsV2.tsx
Normal file
697
scripts/testScripts/runTestsV2.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { spawn } from "bun";
|
||||
import { Box, render, Text, useApp } from "ink";
|
||||
import pLimit from "p-limit";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// Base paths for shorthand test paths (tried in order)
|
||||
const TEST_BASE_PATHS = ["server/tests/integration/billing", "server/tests"];
|
||||
|
||||
// Track all running processes for cleanup
|
||||
const runningProcesses = new Set<ReturnType<typeof spawn>>();
|
||||
|
||||
// Ultra-kill on Ctrl+C
|
||||
process.on("SIGINT", () => {
|
||||
// Kill all running test processes immediately
|
||||
for (const proc of runningProcesses) {
|
||||
try {
|
||||
proc.kill(9); // SIGKILL
|
||||
} catch {
|
||||
// Process might already be dead
|
||||
}
|
||||
}
|
||||
runningProcesses.clear();
|
||||
|
||||
console.log("\n\n⚠️ Tests interrupted by user (Ctrl+C)\n");
|
||||
process.exit(130);
|
||||
});
|
||||
|
||||
interface IndividualTest {
|
||||
name: string;
|
||||
status: "passed" | "failed";
|
||||
duration?: number;
|
||||
error?: {
|
||||
message: string;
|
||||
location?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestFileResult {
|
||||
file: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
tests: IndividualTest[];
|
||||
currentTest?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Output Parsing
|
||||
// ============================================================================
|
||||
|
||||
function parseTestOutput(output: string, filePath: string): IndividualTest[] {
|
||||
const tests: IndividualTest[] = [];
|
||||
const lines = output.split("\n");
|
||||
|
||||
let lastTestEndIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const passMatch = line.match(/^\(pass\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/);
|
||||
const failMatch = line.match(/^\(fail\)\s+(.+?)\s+\[(\d+(?:\.\d+)?m?s)\]/);
|
||||
|
||||
if (passMatch) {
|
||||
const [, name, duration] = passMatch;
|
||||
tests.push({
|
||||
name: name.trim(),
|
||||
status: "passed",
|
||||
duration: parseDuration(duration),
|
||||
});
|
||||
lastTestEndIndex = i;
|
||||
} else if (failMatch) {
|
||||
const [, name, duration] = failMatch;
|
||||
|
||||
// Look BACKWARDS from this line to find the error output
|
||||
const errorStartIndex = lastTestEndIndex + 1;
|
||||
const errorLines = lines.slice(errorStartIndex, i);
|
||||
|
||||
const test: IndividualTest = {
|
||||
name: name.trim(),
|
||||
status: "failed",
|
||||
duration: parseDuration(duration),
|
||||
};
|
||||
|
||||
parseErrorFromLines(test, errorLines, filePath);
|
||||
tests.push(test);
|
||||
lastTestEndIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return tests;
|
||||
}
|
||||
|
||||
function parseErrorFromLines(
|
||||
test: IndividualTest,
|
||||
errorLines: string[],
|
||||
filePath: string,
|
||||
): void {
|
||||
const errorText = errorLines.join("\n");
|
||||
|
||||
// Find error message - look for "error:" line
|
||||
let errorMessage = "";
|
||||
for (const line of errorLines) {
|
||||
const errorMatch = line.match(/^error:\s*(.+)/i);
|
||||
if (errorMatch) {
|
||||
errorMessage = errorMatch[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find Expected/Received for assertion errors
|
||||
const expectedMatch = errorText.match(/Expected:\s*(.+)/);
|
||||
const receivedMatch = errorText.match(/Received:\s*(.+)/);
|
||||
if (expectedMatch && receivedMatch) {
|
||||
errorMessage = `Expected: ${expectedMatch[1]}, Received: ${receivedMatch[1]}`;
|
||||
}
|
||||
|
||||
// Check for timeout
|
||||
if (errorText.includes("this test timed out")) {
|
||||
errorMessage = "Test timed out";
|
||||
}
|
||||
|
||||
// Find location - prioritize the test file itself in stack trace
|
||||
let location: string | undefined;
|
||||
|
||||
for (const line of errorLines) {
|
||||
// Match stack trace lines like:
|
||||
// at async <anonymous> (/path/to/file.test.ts:38:29)
|
||||
// at functionName (/path/to/file.ts:123:45)
|
||||
const stackMatch = line.match(/at\s+.*?\(([^)]+\.ts):(\d+):\d+\)/);
|
||||
if (stackMatch) {
|
||||
const matchedFile = stackMatch[1];
|
||||
const lineNum = stackMatch[2];
|
||||
|
||||
// Prefer .test.ts files
|
||||
if (matchedFile.endsWith(".test.ts")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise take first server file if we don't have one yet
|
||||
if (!location && matchedFile.includes("/server/")) {
|
||||
location = `${matchedFile}:${lineNum}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.error = {
|
||||
message: errorMessage || "Test failed",
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDuration(duration: string): number {
|
||||
if (duration.endsWith("ms")) {
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
if (duration.endsWith("s")) {
|
||||
return Number.parseFloat(duration) * 1000;
|
||||
}
|
||||
return Number.parseFloat(duration);
|
||||
}
|
||||
|
||||
function extractCurrentTest(output: string): string | null {
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const match = lines[i].match(/^\((?:pass|fail)\)\s+(.+?)\s+\[/);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test Runner Logic
|
||||
// ============================================================================
|
||||
|
||||
async function collectTestFiles(directories: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const resolvedDir = resolve(process.cwd(), dir);
|
||||
try {
|
||||
const files = await readdir(resolvedDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedDir, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading directory ${dir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
async function runTestFile(
|
||||
file: string,
|
||||
onUpdate: (result: TestFileResult) => void,
|
||||
): Promise<TestFileResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
const result: TestFileResult = {
|
||||
file,
|
||||
status: "running",
|
||||
tests: [],
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
onUpdate(result);
|
||||
|
||||
try {
|
||||
const proc = spawn(["bun", "test", "--timeout", "0", file], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Track process for cleanup on SIGINT
|
||||
runningProcesses.add(proc);
|
||||
|
||||
let output = "";
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (proc.stdout) {
|
||||
for await (const chunk of proc.stdout) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
|
||||
// Update with parsed tests
|
||||
const tests = parseTestOutput(output, file);
|
||||
const currentTest = extractCurrentTest(output);
|
||||
|
||||
onUpdate({
|
||||
...result,
|
||||
tests,
|
||||
currentTest: currentTest || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
for await (const chunk of proc.stderr) {
|
||||
output += decoder.decode(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
|
||||
// Remove from tracking
|
||||
runningProcesses.delete(proc);
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
const tests = parseTestOutput(output, file);
|
||||
const hasFailures = tests.some((t) => t.status === "failed");
|
||||
|
||||
const finalResult: TestFileResult = {
|
||||
file,
|
||||
status: hasFailures ? "failed" : "passed",
|
||||
tests,
|
||||
duration,
|
||||
};
|
||||
|
||||
onUpdate(finalResult);
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
const finalResult: TestFileResult = {
|
||||
file,
|
||||
status: "failed",
|
||||
tests: [],
|
||||
duration,
|
||||
};
|
||||
onUpdate(finalResult);
|
||||
return finalResult;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ink Components
|
||||
// ============================================================================
|
||||
|
||||
function Spinner() {
|
||||
const [frame, setFrame] = useState(0);
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setFrame((prev) => (prev + 1) % frames.length);
|
||||
}, 80);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return <Text color="cyan">{frames[frame]}</Text>;
|
||||
}
|
||||
|
||||
function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
interface CompletedFileProps {
|
||||
result: TestFileResult;
|
||||
}
|
||||
|
||||
function CompletedFile({ result }: CompletedFileProps) {
|
||||
const fileName = basename(result.file);
|
||||
const passedCount = result.tests.filter((t) => t.status === "passed").length;
|
||||
const failedCount = result.tests.filter((t) => t.status === "failed").length;
|
||||
|
||||
const icon = result.status === "passed" ? "✓" : "✗";
|
||||
const iconColor = result.status === "passed" ? "green" : "red";
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={iconColor}>{icon} </Text>
|
||||
<Text dimColor={result.status === "passed"}>{fileName} </Text>
|
||||
<Text dimColor>
|
||||
(<Text color="green">✓{passedCount}</Text>
|
||||
{failedCount > 0 && <Text color="red"> ✗{failedCount}</Text>})
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface FailedTestProps {
|
||||
test: IndividualTest;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
function FailedTest({ test, fileName }: FailedTestProps) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text color="red">✗ </Text>
|
||||
<Text>{truncate(test.name, 60)}</Text>
|
||||
</Box>
|
||||
{test.error?.message && (
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>→ </Text>
|
||||
<Text color="yellow">{truncate(test.error.message, 70)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{test.error?.location && (
|
||||
<Box marginLeft={2}>
|
||||
<Text dimColor>→ </Text>
|
||||
<Text color="cyan">{test.error.location}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningFileProps {
|
||||
result: TestFileResult;
|
||||
}
|
||||
|
||||
function RunningFile({ result }: RunningFileProps) {
|
||||
const fileName = basename(result.file);
|
||||
const passedCount = result.tests.filter((t) => t.status === "passed").length;
|
||||
const failedCount = result.tests.filter((t) => t.status === "failed").length;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text> </Text>
|
||||
<Spinner />
|
||||
<Text> {fileName}</Text>
|
||||
{(passedCount > 0 || failedCount > 0) && (
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
(<Text color="green">✓{passedCount}</Text>
|
||||
{failedCount > 0 && <Text color="red"> ✗{failedCount}</Text>})
|
||||
</Text>
|
||||
)}
|
||||
{result.currentTest && (
|
||||
<Text dimColor> › {truncate(result.currentTest, 35)}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface TestRunnerAppProps {
|
||||
testFiles: string[];
|
||||
maxParallel: number;
|
||||
}
|
||||
|
||||
function TestRunnerApp({ testFiles, maxParallel }: TestRunnerAppProps) {
|
||||
const { exit } = useApp();
|
||||
const [results, setResults] = useState<Map<string, TestFileResult>>(
|
||||
new Map(),
|
||||
);
|
||||
const [isComplete, setIsComplete] = useState(false);
|
||||
|
||||
// Initialize all files as pending
|
||||
useEffect(() => {
|
||||
const initial = new Map<string, TestFileResult>();
|
||||
for (const file of testFiles) {
|
||||
initial.set(file, {
|
||||
file,
|
||||
status: "pending",
|
||||
tests: [],
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
setResults(initial);
|
||||
}, [testFiles]);
|
||||
|
||||
// Run tests
|
||||
useEffect(() => {
|
||||
const runAllTests = async () => {
|
||||
const limit = pLimit(maxParallel);
|
||||
|
||||
const updateResult = (result: TestFileResult) => {
|
||||
setResults((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(result.file, result);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const promises = testFiles.map((file) =>
|
||||
limit(() => runTestFile(file, updateResult)),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
setIsComplete(true);
|
||||
};
|
||||
|
||||
if (testFiles.length > 0) {
|
||||
runAllTests();
|
||||
}
|
||||
}, [testFiles, maxParallel]);
|
||||
|
||||
// Exit when complete
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
const allResults = Array.from(results.values());
|
||||
const failedTests = allResults.flatMap((r) =>
|
||||
r.tests.filter((t) => t.status === "failed"),
|
||||
);
|
||||
|
||||
// Small delay to ensure final render
|
||||
setTimeout(() => {
|
||||
exit();
|
||||
process.exit(failedTests.length > 0 ? 1 : 0);
|
||||
}, 100);
|
||||
}
|
||||
}, [isComplete, results, exit]);
|
||||
|
||||
const allResults = Array.from(results.values());
|
||||
const completedFiles = allResults.filter(
|
||||
(r) => r.status === "passed" || r.status === "failed",
|
||||
);
|
||||
const runningFiles = allResults.filter((r) => r.status === "running");
|
||||
|
||||
const completedTests = completedFiles.flatMap((r) => r.tests);
|
||||
const passedTests = completedTests.filter((t) => t.status === "passed");
|
||||
const failedTests = completedTests.filter((t) => t.status === "failed");
|
||||
|
||||
// Get ALL failures
|
||||
const allFailures = completedFiles.flatMap((r) =>
|
||||
r.tests
|
||||
.filter((t) => t.status === "failed")
|
||||
.map((t) => ({ test: t, fileName: basename(r.file), file: r.file })),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Header */}
|
||||
<Text bold color="cyan">
|
||||
Running {testFiles.length} test files...
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
|
||||
{/* Running files */}
|
||||
{runningFiles.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="yellow">
|
||||
Running ({runningFiles.length}):
|
||||
</Text>
|
||||
{runningFiles.map((r) => (
|
||||
<RunningFile key={r.file} result={r} />
|
||||
))}
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Completed files (last 3) */}
|
||||
{completedFiles.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>
|
||||
Completed ({completedFiles.length}/{testFiles.length} files):
|
||||
</Text>
|
||||
{completedFiles.slice(-3).map((r) => (
|
||||
<CompletedFile key={r.file} result={r} />
|
||||
))}
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<Text dimColor>{"─".repeat(60)}</Text>
|
||||
<Box>
|
||||
{!isComplete && <Spinner />}
|
||||
{isComplete && <Text color="green">✓</Text>}
|
||||
<Text>
|
||||
{" "}
|
||||
Progress:{" "}
|
||||
<Text bold>
|
||||
{completedFiles.length}/{testFiles.length} files
|
||||
</Text>{" "}
|
||||
| <Text color="green">✓ {passedTests.length}</Text> |{" "}
|
||||
<Text color={failedTests.length > 0 ? "red" : undefined}>
|
||||
✗ {failedTests.length}
|
||||
</Text>
|
||||
{runningFiles.length > 0 && (
|
||||
<Text dimColor> | {runningFiles.length} running</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* ALL failures - shown below progress */}
|
||||
{allFailures.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold color="red">
|
||||
Failures ({allFailures.length}):
|
||||
</Text>
|
||||
{allFailures.map((f) => (
|
||||
<FailedTest
|
||||
key={`${f.file}-${f.test.name}`}
|
||||
test={f.test}
|
||||
fileName={f.fileName}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Final summary when complete */}
|
||||
{isComplete && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<FinalSummary results={allResults} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface FinalSummaryProps {
|
||||
results: TestFileResult[];
|
||||
}
|
||||
|
||||
function FinalSummary({ results }: FinalSummaryProps) {
|
||||
const allTests = results.flatMap((r) => r.tests);
|
||||
const passedTests = allTests.filter((t) => t.status === "passed");
|
||||
const failedTests = allTests.filter((t) => t.status === "failed");
|
||||
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
|
||||
|
||||
const failedByFile = new Map<string, IndividualTest[]>();
|
||||
for (const result of results) {
|
||||
const fileFailed = result.tests.filter((t) => t.status === "failed");
|
||||
if (fileFailed.length > 0) {
|
||||
failedByFile.set(result.file, fileFailed);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedTests.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="green" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
<Text color="green" bold>
|
||||
✓ ALL {passedTests.length} TESTS PASSED (
|
||||
{(totalDuration / 1000).toFixed(1)}s)
|
||||
</Text>
|
||||
<Text color="green" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
<Text color="red" bold>
|
||||
FAILED TESTS ({failedTests.length})
|
||||
</Text>
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
|
||||
{Array.from(failedByFile.entries()).map(([file, tests]) => (
|
||||
<Box key={file} flexDirection="column" marginTop={1}>
|
||||
<Text color="red" bold>
|
||||
📁 {basename(file)}
|
||||
</Text>
|
||||
<Text dimColor>{"─".repeat(50)}</Text>
|
||||
|
||||
{tests.map((test) => (
|
||||
<Box key={test.name} flexDirection="column" marginTop={1}>
|
||||
<Text color="red"> ✗ {test.name}</Text>
|
||||
{test.error?.location && (
|
||||
<Text color="cyan"> {test.error.location}</Text>
|
||||
)}
|
||||
{test.error?.message && (
|
||||
<Text color="yellow"> {test.error.message}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Text> </Text>
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
<Text bold>
|
||||
SUMMARY: <Text color="green">{passedTests.length} passed</Text> |{" "}
|
||||
<Text color="red">{failedTests.length} failed</Text> |{" "}
|
||||
{(totalDuration / 1000).toFixed(1)}s
|
||||
</Text>
|
||||
<Text color="red" bold>
|
||||
{"═".repeat(60)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Entry Point
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
let maxParallel = 6;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--max=")) {
|
||||
maxParallel = Number.parseInt(arg.split("=")[1], 10);
|
||||
} else if (arg.startsWith("-")) {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.tsx <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
// Try to resolve the path - if it doesn't exist, try prepending base paths
|
||||
let resolvedPath = arg;
|
||||
const fullPath = resolve(process.cwd(), arg);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
// Try each base path in order
|
||||
for (const basePath of TEST_BASE_PATHS) {
|
||||
const withBase = `${basePath}/${arg}`;
|
||||
const withBaseFull = resolve(process.cwd(), withBase);
|
||||
if (existsSync(withBaseFull)) {
|
||||
resolvedPath = withBase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directories.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error("Error: No test directories specified");
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTestsV2.tsx <dir1> [dir2] [...] [--max=N]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const testFiles = await collectTestFiles(directories);
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
console.log("No test files found in specified directories");
|
||||
return;
|
||||
}
|
||||
|
||||
render(<TestRunnerApp testFiles={testFiles} maxParallel={maxParallel} />);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Get project root directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVER_DIR="$SCRIPT_DIR/.."
|
||||
PROJECT_ROOT="$SERVER_DIR/.."
|
||||
|
||||
# Find bun executable
|
||||
if command -v bun &> /dev/null; then
|
||||
BUN_CMD="bun"
|
||||
elif [ -f "$HOME/.bun/bin/bun" ]; then
|
||||
BUN_CMD="$HOME/.bun/bin/bun"
|
||||
elif [ -f "/usr/local/bin/bun" ]; then
|
||||
BUN_CMD="/usr/local/bin/bun"
|
||||
else
|
||||
echo "Error: bun not found. Please install bun or add it to PATH."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Setup function
|
||||
BUN_SETUP="$BUN_CMD tests/setupMain.ts"
|
||||
|
||||
# Test runner functions (using new TypeScript runner)
|
||||
BUN_PARALLEL() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runTests.ts "$@"
|
||||
}
|
||||
|
||||
BUN_PARALLEL_COMPACT() {
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runTests.ts "$@" --compact
|
||||
}
|
||||
|
||||
# Mocha command (for tests not yet migrated)
|
||||
MOCHA_CMD="npx mocha --parallel -j 6 --timeout 10000000 --ignore tests/00_setup.ts"
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/entities/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/update/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/track/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/contUse/roles/*.ts'
|
||||
|
||||
# # G4
|
||||
# $MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
# 'tests/advanced/coupons/*.ts' \
|
||||
# 'tests/attach/updateQuantity/*.ts' \
|
||||
# 'tests/advanced/referrals/*.ts' \
|
||||
# 'tests/advanced/rollovers/*.ts' \
|
||||
# 'tests/advanced/customInterval/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
# 'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/group/*.ts'
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/add/*.ts' \
|
||||
'tests/merged/downgrade/*.ts' \
|
||||
'tests/merged/prepaid/*.ts' \
|
||||
'tests/merged/separate/*.ts' \
|
||||
'tests/merged/upgrade/*.ts' \
|
||||
'tests/merged/trial/*.ts'
|
||||
|
||||
|
||||
$MOCHA_CMD 'tests/merged/addOn/*.ts' \
|
||||
'tests/merged/group/*.ts' \
|
||||
'tests/core/cancel/*.ts' \
|
||||
'tests/core/multiAttach/*.ts' \
|
||||
'tests/core/multiAttach/multiInvoice/*.ts' \
|
||||
'tests/core/multiAttach/multiUpgrade/*.ts' \
|
||||
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward1.test.ts'
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward2.test.ts'
|
||||
# # $MOCHA_CMD 'tests/core/multiAttach/multiReward/multiReward3.test.ts'
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Source shared configuration
|
||||
source "$(dirname "$0")/config.sh"
|
||||
|
||||
# MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
if [[ "$1" == *"setup"* ]]; then
|
||||
MOCHA_PARALLEL=true $MOCHA_SETUP
|
||||
fi
|
||||
|
||||
# $MOCHA_CMD 'tests/advanced/rollovers/*.ts'
|
||||
$MOCHA_CMD 'tests/advanced/multiFeature/*.ts' \
|
||||
'tests/advanced/coupons/*.ts' \
|
||||
'tests/attach/updateQuantity/*.ts' \
|
||||
'tests/advanced/referrals/*.ts' \
|
||||
'tests/advanced/referrals/paid/*.ts' \
|
||||
'tests/advanced/rollovers/*.ts' \
|
||||
'tests/advanced/customInterval/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/attach/multiProduct/*.ts' \
|
||||
'tests/advanced/usageLimit/*.ts'
|
||||
|
||||
$MOCHA_CMD 'tests/advanced/usage/*.ts'
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# npx mocha 'tests/alex/00_setup.ts' --timeout 10000000
|
||||
|
||||
MOCHA_PARALLEL=true npx mocha --parallel --timeout 10000000 \
|
||||
'tests/alex/01_free.ts' 'tests/alex/02_pro.ts' 'tests/alex/03_premium.ts' \
|
||||
'tests/alex/04_topups.ts' 'tests/alex/05_cancel.ts' 'tests/alex/06_switch.ts' \
|
||||
--ignore 'tests/alex/00_setup.ts'
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Parallel Test Runner
|
||||
# Runs all test groups in parallel, each with its own dedicated org
|
||||
|
||||
# Source shared configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/config.sh"
|
||||
|
||||
# Check for required environment variables
|
||||
if [ -z "$TEST_ORG_SECRET_KEY" ]; then
|
||||
echo "Error: TEST_ORG_SECRET_KEY environment variable is required"
|
||||
echo ""
|
||||
echo "This should be the secret key of your platform organization"
|
||||
echo "that has access to create/delete test organizations."
|
||||
echo ""
|
||||
echo "Add it to your server/.env file:"
|
||||
echo " TEST_ORG_SECRET_KEY=am_sk_test_..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run parallel test groups
|
||||
echo "Starting parallel test runner..."
|
||||
cd "$PROJECT_ROOT" && $BUN_CMD server/tests/testRunner/runParallelGroups.ts
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run Bun test files in parallel with proper error reporting
|
||||
# Usage: ./run-parallel.sh <test_directory1> [test_directory2] [...] [--max=N]
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Error: No test directories specified"
|
||||
echo "Usage: ./run-parallel.sh <test_directory1> [test_directory2] [...] [--max=N]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments
|
||||
TEST_DIRS=()
|
||||
MAX_PARALLEL=6
|
||||
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == --max=* ]]; then
|
||||
MAX_PARALLEL="${arg#*=}"
|
||||
else
|
||||
if [ ! -d "$arg" ]; then
|
||||
echo "Error: Directory '$arg' not found"
|
||||
exit 1
|
||||
fi
|
||||
TEST_DIRS+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#TEST_DIRS[@]} -eq 0 ]; then
|
||||
echo "Error: No valid test directories specified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
FAILED_DIR="$TEMP_DIR/failed"
|
||||
STATUS_DIR="$TEMP_DIR/status"
|
||||
mkdir -p "$FAILED_DIR" "$STATUS_DIR"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Spinner frames
|
||||
SPINNER_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
SPINNER_FRAME=0
|
||||
NUM_SPINNER_FRAMES=${#SPINNER_FRAMES[@]}
|
||||
|
||||
# Collect all test files first
|
||||
TEST_FILES=()
|
||||
for TEST_DIR in "${TEST_DIRS[@]}"; do
|
||||
for test_file in "$TEST_DIR"/*.test.ts; do
|
||||
if [ -f "$test_file" ]; then
|
||||
TEST_FILES+=("$test_file")
|
||||
# Create status file
|
||||
echo "pending" > "$STATUS_DIR/$(basename "$test_file").status"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Check if any tests were found
|
||||
if [ ${#TEST_FILES[@]} -eq 0 ]; then
|
||||
echo "No test files found in specified directories"
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Helper function to get status
|
||||
get_status() {
|
||||
local test_file="$1"
|
||||
local test_name=$(basename "$test_file")
|
||||
local status_file="$STATUS_DIR/${test_name}.status"
|
||||
if [ -f "$status_file" ]; then
|
||||
cat "$status_file"
|
||||
else
|
||||
echo "pending"
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper function to set status
|
||||
set_status() {
|
||||
local test_file="$1"
|
||||
local status="$2"
|
||||
local test_name=$(basename "$test_file")
|
||||
echo "$status" > "$STATUS_DIR/${test_name}.status"
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
# Stop spinner
|
||||
if [ ! -z "$SPINNER_PID" ]; then
|
||||
kill $SPINNER_PID 2>/dev/null || true
|
||||
wait $SPINNER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill all descendant processes
|
||||
pkill -P $$ 2>/dev/null || true
|
||||
pkill -f "bun test" 2>/dev/null || true
|
||||
jobs -p | while read pid; do kill -9 $pid 2>/dev/null || true; done
|
||||
|
||||
# Show cursor again
|
||||
tput cnorm 2>/dev/null || true
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf "$TEMP_DIR"
|
||||
exit 130
|
||||
}
|
||||
|
||||
# Set up signal handlers
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
# Function to render the test list
|
||||
render_tests() {
|
||||
local line_num=1
|
||||
|
||||
# Save cursor position
|
||||
tput sc 2>/dev/null || true
|
||||
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
local test_name=$(basename "$test_file")
|
||||
local status=$(get_status "$test_file")
|
||||
local display_name="${test_name}"
|
||||
|
||||
# Move to the line
|
||||
tput cup $((line_num - 1)) 0 2>/dev/null || true
|
||||
|
||||
# Clear line
|
||||
tput el 2>/dev/null || true
|
||||
|
||||
case "$status" in
|
||||
"pending")
|
||||
echo -ne "${DIM}⋯${NC} ${DIM}${display_name}${NC}"
|
||||
;;
|
||||
"running")
|
||||
local frame_idx=$((SPINNER_FRAME % NUM_SPINNER_FRAMES))
|
||||
local spinner_char="${SPINNER_FRAMES[$frame_idx]}"
|
||||
echo -ne "${CYAN}${spinner_char}${NC} ${display_name}"
|
||||
;;
|
||||
"passed")
|
||||
echo -ne "${GREEN}✓${NC} ${DIM}${display_name}${NC}"
|
||||
;;
|
||||
"failed")
|
||||
echo -ne "${RED}✗${NC} ${display_name}"
|
||||
;;
|
||||
esac
|
||||
|
||||
((line_num++))
|
||||
done
|
||||
|
||||
# Restore cursor position
|
||||
tput rc 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Function to update spinner animation
|
||||
animate_spinner() {
|
||||
while true; do
|
||||
SPINNER_FRAME=$((SPINNER_FRAME + 1))
|
||||
render_tests
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
# Function to run a test
|
||||
run_test() {
|
||||
local test_file=$1
|
||||
local test_name=$(basename "$test_file")
|
||||
local output_file="$TEMP_DIR/$test_name.log"
|
||||
|
||||
# Mark as running
|
||||
set_status "$test_file" "running"
|
||||
|
||||
# Run the test
|
||||
if script -q /dev/null bash -c "FORCE_COLOR=3 bun test --timeout 0 '$test_file' 2>&1" > "$output_file"; then
|
||||
set_status "$test_file" "passed"
|
||||
return 0
|
||||
else
|
||||
set_status "$test_file" "failed"
|
||||
echo "$test_file|$output_file" > "$FAILED_DIR/$test_name.failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Hide cursor
|
||||
tput civis 2>/dev/null || true
|
||||
|
||||
# Initial render - create space for all tests
|
||||
echo ""
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Move cursor back up
|
||||
tput cuu ${#TEST_FILES[@]} 2>/dev/null || true
|
||||
|
||||
# Start spinner animation in background
|
||||
animate_spinner &
|
||||
SPINNER_PID=$!
|
||||
|
||||
# Run tests in parallel
|
||||
count=0
|
||||
for test_file in "${TEST_FILES[@]}"; do
|
||||
# Wait if we've hit max parallel
|
||||
while [ $(jobs -r | wc -l) -ge $((MAX_PARALLEL + 1)) ]; do
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
run_test "$test_file" &
|
||||
((count++))
|
||||
done
|
||||
|
||||
# Wait for all tests to complete (exclude spinner process)
|
||||
for job in $(jobs -p); do
|
||||
if [ "$job" != "$SPINNER_PID" ]; then
|
||||
wait $job 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop spinner
|
||||
if [ ! -z "$SPINNER_PID" ]; then
|
||||
kill $SPINNER_PID 2>/dev/null || true
|
||||
wait $SPINNER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Final render
|
||||
render_tests
|
||||
|
||||
# Move cursor below test list
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Show cursor again
|
||||
tput cnorm 2>/dev/null || true
|
||||
|
||||
# Report failures
|
||||
FAILED_COUNT=$(ls "$FAILED_DIR"/*.failed 2>/dev/null | wc -l)
|
||||
if [ $FAILED_COUNT -gt 0 ]; then
|
||||
echo -e "${RED}${BOLD}========================================"
|
||||
echo -e "FAILED TESTS ($FAILED_COUNT/${count}):"
|
||||
echo -e "========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Show detailed errors
|
||||
for failure_file in "$FAILED_DIR"/*.failed; do
|
||||
IFS='|' read -r test_file output_file < "$failure_file"
|
||||
echo -e "${RED}${BOLD}✗ $(basename $test_file)${NC}"
|
||||
echo -e "${DIM}─────────────────────────────────────────${NC}"
|
||||
cat "$output_file"
|
||||
echo ""
|
||||
done
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Remove trap before exit to prevent double cleanup
|
||||
trap - SIGINT SIGTERM EXIT
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GREEN}${BOLD}✓ All tests passed!${NC} ${CYAN}($count tests)${NC}"
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Remove trap before exit to prevent double cleanup
|
||||
trap - SIGINT SIGTERM EXIT
|
||||
exit 0
|
||||
fi
|
||||
24
server/src/external/autumn/autumnCli.ts
vendored
24
server/src/external/autumn/autumnCli.ts
vendored
@@ -418,18 +418,26 @@ export class AutumnInt {
|
||||
internalOptions = {
|
||||
disable_defaults: true,
|
||||
},
|
||||
skipWebhooks,
|
||||
...customerData
|
||||
}: {
|
||||
withAutumnId?: boolean;
|
||||
expand?: CusExpand[];
|
||||
internalOptions?: CreateCustomerInternalOptions;
|
||||
skipWebhooks?: boolean;
|
||||
} & Omit<CreateCustomerParams, "internal_options">) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (skipWebhooks !== undefined) {
|
||||
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
|
||||
}
|
||||
|
||||
const data = await this.post(
|
||||
`/customers?with_autumn_id=${withAutumnId ? "true" : "false"}${expand && expand.length > 0 ? `&expand=${expand.join(",")}` : ""}`,
|
||||
{
|
||||
...customerData,
|
||||
internal_options: internalOptions,
|
||||
},
|
||||
Object.keys(headers).length > 0 ? headers : undefined,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@@ -735,9 +743,21 @@ export class AutumnInt {
|
||||
subscriptions = {
|
||||
update: async (
|
||||
params: UpdateSubscriptionV0Params,
|
||||
{ timeout }: { timeout?: number } = {},
|
||||
{
|
||||
timeout,
|
||||
skipWebhooks,
|
||||
}: { timeout?: number; skipWebhooks?: boolean } = {},
|
||||
): Promise<BillingResponse> => {
|
||||
const data = await this.post(`/subscriptions/update`, params);
|
||||
const headers: Record<string, string> = {};
|
||||
if (skipWebhooks !== undefined) {
|
||||
headers["x-skip-webhooks"] = skipWebhooks ? "true" : "false";
|
||||
}
|
||||
|
||||
const data = await this.post(
|
||||
`/subscriptions/update`,
|
||||
params,
|
||||
Object.keys(headers).length > 0 ? headers : undefined,
|
||||
);
|
||||
if (timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, timeout));
|
||||
}
|
||||
|
||||
@@ -72,8 +72,12 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
skipCache: false,
|
||||
|
||||
// Test params:
|
||||
skipCacheDeletion: c.req.header("x-skip-cache-deletion") === "true",
|
||||
extraLogs: {},
|
||||
|
||||
testOptions: {
|
||||
skipCacheDeletion: c.req.header("x-skip-cache-deletion") === "true",
|
||||
skipWebhooks: c.req.header("x-skip-webhooks") === "true",
|
||||
},
|
||||
});
|
||||
|
||||
// childLogger.info(`${method} ${path}`);
|
||||
|
||||
@@ -36,10 +36,12 @@ export type RequestContext = {
|
||||
expand: string[];
|
||||
skipCache: boolean;
|
||||
|
||||
// For test...
|
||||
skipCacheDeletion?: boolean;
|
||||
|
||||
extraLogs: Record<string, unknown>;
|
||||
|
||||
testOptions?: {
|
||||
skipCacheDeletion?: boolean;
|
||||
skipWebhooks?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type AutumnContext = RequestContext;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeA
|
||||
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
|
||||
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import type { BillingResult } from "@/internal/billing/v2/types/billingResult";
|
||||
import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated";
|
||||
|
||||
export const executeBillingPlan = async ({
|
||||
ctx,
|
||||
@@ -30,5 +31,12 @@ export const executeBillingPlan = async ({
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
});
|
||||
|
||||
// Queue webhooks after Autumn billing plan is executed
|
||||
await billingPlanToSendProductsUpdated({
|
||||
ctx,
|
||||
autumnBillingPlan: billingPlan.autumn,
|
||||
billingContext,
|
||||
});
|
||||
|
||||
return { stripe: stripeBillingResult };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Converts an AutumnBillingPlan to sendProductsUpdated workflow triggers.
|
||||
* Derives scenario from product status.
|
||||
*/
|
||||
|
||||
import { CusProductStatus } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/autumnBillingPlan.js";
|
||||
import type { CreateCustomerContext } from "@/internal/customers/actions/createWithDefaults/createCustomerContext";
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
|
||||
const deriveScenarioFromStatus = (status: string): string => {
|
||||
switch (status) {
|
||||
case CusProductStatus.Scheduled:
|
||||
return "scheduled";
|
||||
case CusProductStatus.Active:
|
||||
return "new";
|
||||
case CusProductStatus.Expired:
|
||||
return "expired";
|
||||
case CusProductStatus.PastDue:
|
||||
return "past_due";
|
||||
default:
|
||||
return "new";
|
||||
}
|
||||
};
|
||||
|
||||
export const billingPlanToSendProductsUpdated = async ({
|
||||
ctx,
|
||||
autumnBillingPlan,
|
||||
billingContext,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
autumnBillingPlan: AutumnBillingPlan;
|
||||
billingContext: BillingContext | CreateCustomerContext;
|
||||
}) => {
|
||||
// Skip webhooks if test option is set (used in integration tests)
|
||||
if (ctx.testOptions?.skipWebhooks) return;
|
||||
|
||||
const { fullCustomer } = billingContext;
|
||||
|
||||
const customerId = fullCustomer.id ?? fullCustomer.internal_id;
|
||||
|
||||
const { insertCustomerProducts } = autumnBillingPlan;
|
||||
|
||||
// Queue for each inserted product
|
||||
for (const cusProduct of insertCustomerProducts) {
|
||||
const scenario = deriveScenarioFromStatus(cusProduct.status);
|
||||
|
||||
try {
|
||||
await workflows.triggerSendProductsUpdated({
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
customerId,
|
||||
customerProductId: cusProduct.id,
|
||||
scenario,
|
||||
});
|
||||
|
||||
ctx.logger.info(
|
||||
`[billingPlanToSendProductsUpdated] Queued webhook for ${cusProduct.product.name}, scenario: ${scenario}`,
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.logger.error(
|
||||
`[billingPlanToSendProductsUpdated] Failed to queue webhook for ${cusProduct.product.name}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Workflow: SendProductsUpdated
|
||||
*
|
||||
* Sends customer.products.updated webhook when billing plan executes.
|
||||
* Uses lean payload - fetches data from DB instead of receiving full objects.
|
||||
*/
|
||||
|
||||
import {
|
||||
AffectedResource,
|
||||
type ApiCustomer,
|
||||
type ApiEntityV1,
|
||||
type ApiPlan,
|
||||
ApiVersion,
|
||||
ApiVersionClass,
|
||||
addToExpand,
|
||||
applyResponseVersionChanges,
|
||||
CusExpand,
|
||||
type CustomerLegacyData,
|
||||
cusProductToProduct,
|
||||
type EntityLegacyData,
|
||||
enrichFullCustomerWithEntity,
|
||||
findCustomerProductById,
|
||||
InternalError,
|
||||
type PlanLegacyData,
|
||||
} from "@autumn/shared";
|
||||
import { sendSvixEvent } from "@/external/svix/svixHelpers.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
|
||||
import { getApiEntityBase } from "@/internal/entities/entityUtils/apiEntityUtils/getApiEntityBase.js";
|
||||
import { getPlanResponse } from "@/internal/products/productUtils/productResponseUtils/getPlanResponse.js";
|
||||
import type { SendProductsUpdatedPayload } from "@/queue/workflows.js";
|
||||
|
||||
export const sendProductsUpdated = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: SendProductsUpdatedPayload;
|
||||
}) => {
|
||||
const { db, org, env, features } = ctx;
|
||||
const { customerProductId, scenario, customerId } = payload;
|
||||
|
||||
// Fetch FullCustomer
|
||||
const fullCustomer = await CusService.getFull({
|
||||
db,
|
||||
idOrInternalId: customerId ?? "",
|
||||
orgId: org.id,
|
||||
env,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
allowNotFound: true,
|
||||
});
|
||||
|
||||
const customerProduct = findCustomerProductById({
|
||||
fullCustomer,
|
||||
customerProductId,
|
||||
});
|
||||
|
||||
if (!fullCustomer) {
|
||||
throw new InternalError({
|
||||
message: `[sendProductsUpdated] Customer ${customerId ?? ""} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!customerProduct) {
|
||||
throw new InternalError({
|
||||
message: `[sendProductsUpdated] Customer product ${customerProductId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const fullProduct = cusProductToProduct({ cusProduct: customerProduct });
|
||||
|
||||
enrichFullCustomerWithEntity({
|
||||
fullCustomer,
|
||||
internalEntityId: customerProduct.internal_entity_id ?? "",
|
||||
});
|
||||
|
||||
ctx.apiVersion = new ApiVersionClass(ApiVersion.V1_2);
|
||||
|
||||
if (ctx.apiVersion.lte(ApiVersion.V1_2)) {
|
||||
ctx = addToExpand({
|
||||
ctx,
|
||||
add: [
|
||||
CusExpand.BalancesFeature,
|
||||
CusExpand.SubscriptionsPlan,
|
||||
CusExpand.ScheduledSubscriptionsPlan,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const { apiCustomer, legacyData: cusLegacyData } = await getApiCustomerBase({
|
||||
ctx,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
const versionedCustomer = applyResponseVersionChanges<
|
||||
ApiCustomer,
|
||||
CustomerLegacyData
|
||||
>({
|
||||
input: apiCustomer,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Customer,
|
||||
legacyData: cusLegacyData,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const apiPlan = await getPlanResponse({
|
||||
product: fullProduct,
|
||||
features,
|
||||
});
|
||||
|
||||
const versionedPlan = applyResponseVersionChanges<ApiPlan, PlanLegacyData>({
|
||||
input: apiPlan,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Product,
|
||||
legacyData: {
|
||||
features: ctx.features,
|
||||
},
|
||||
ctx,
|
||||
});
|
||||
|
||||
let entity: unknown | undefined;
|
||||
if (fullCustomer.entity) {
|
||||
const { apiEntity, legacyData } = await getApiEntityBase({
|
||||
ctx,
|
||||
entity: fullCustomer.entity,
|
||||
fullCus: fullCustomer,
|
||||
});
|
||||
|
||||
entity = applyResponseVersionChanges<ApiEntityV1, EntityLegacyData>({
|
||||
input: apiEntity,
|
||||
targetVersion: ctx.apiVersion,
|
||||
resource: AffectedResource.Entity,
|
||||
legacyData,
|
||||
ctx,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info(
|
||||
`[sendProductsUpdated] Sending webhook for customer ${customerId}, product ${fullProduct.name}, scenario: ${scenario}`,
|
||||
);
|
||||
|
||||
await sendSvixEvent({
|
||||
org,
|
||||
env,
|
||||
eventType: "customer.products.updated",
|
||||
data: {
|
||||
scenario,
|
||||
customer: versionedCustomer,
|
||||
entity,
|
||||
updated_product: versionedPlan,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,17 +1,17 @@
|
||||
import {
|
||||
CusProductStatus,
|
||||
cusProductsToCusEnts,
|
||||
type FullCustomer,
|
||||
isBooleanCusEnt,
|
||||
isContUseFeature,
|
||||
isUnlimitedCusEnt,
|
||||
} from "@autumn/shared";
|
||||
import * as Sentry from "@sentry/bun";
|
||||
import { Decimal } from "decimal.js";
|
||||
import type { FullCustomer } from "../../../../../shared/models/cusModels/fullCusModel";
|
||||
import { getSentryTags } from "../../../external/sentry/sentryUtils";
|
||||
import type { AutumnContext } from "../../../honoUtils/HonoEnv";
|
||||
import { getApiCustomerBase } from "../../../internal/customers/cusUtils/apiCusUtils/getApiCustomerBase";
|
||||
import type { VerifyCacheInput } from "./verifyCacheConsistencyWorkflow";
|
||||
import { getSentryTags } from "@/external/sentry/sentryUtils.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
|
||||
import type { VerifyCacheInput } from "./verifyCacheConsistency.js";
|
||||
|
||||
export const checkForMisingBalance = async ({
|
||||
ctx,
|
||||
@@ -4,12 +4,11 @@ import {
|
||||
type FullCustomer,
|
||||
isFreeProduct,
|
||||
} from "@autumn/shared";
|
||||
import type { Logger } from "../../../external/logtail/logtailUtils";
|
||||
import { generateId } from "../../../utils/genUtils";
|
||||
import { JobName } from "../../JobName";
|
||||
import { runHatchetWorkflow } from "../../queueUtils";
|
||||
import type { Logger } from "@/external/logtail/logtailUtils.js";
|
||||
import { workflows } from "@/queue/workflows.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
|
||||
export const queueVerifyCacheConsistencyWorkflow = async ({
|
||||
export const triggerVerifyCacheConsistency = async ({
|
||||
newCustomerProduct,
|
||||
previousFullCustomer,
|
||||
logger,
|
||||
@@ -29,22 +28,23 @@ export const queueVerifyCacheConsistencyWorkflow = async ({
|
||||
const newPrices = cusProductToPrices({ cusProduct: newCustomerProduct });
|
||||
if (isFreeProduct({ prices: newPrices })) return;
|
||||
|
||||
await runHatchetWorkflow({
|
||||
workflowName: JobName.VerifyCacheConsistency,
|
||||
metadata: {
|
||||
workflowId,
|
||||
customerId: previousFullCustomer.id ?? "",
|
||||
},
|
||||
payload: {
|
||||
await workflows.triggerVerifyCacheConsistency(
|
||||
{
|
||||
orgId: previousFullCustomer.org_id,
|
||||
env: previousFullCustomer.env,
|
||||
customerId: previousFullCustomer.id || previousFullCustomer.internal_id,
|
||||
newCustomerProductId: newCustomerProduct.id,
|
||||
source,
|
||||
previousFullCustomer: JSON.stringify(previousFullCustomer), // is there a better approach to this...?
|
||||
previousFullCustomer: JSON.stringify(previousFullCustomer),
|
||||
},
|
||||
delayMs: 5000,
|
||||
});
|
||||
{
|
||||
delayMs: 5000,
|
||||
metadata: {
|
||||
workflowId,
|
||||
customerId: previousFullCustomer.id ?? "",
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to run verify cache consistency workflow for customer ${previousFullCustomer.id || previousFullCustomer.internal_id}, error: ${error}`,
|
||||
@@ -8,8 +8,8 @@ import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { getApiCustomerBase } from "@/internal/customers/cusUtils/apiCusUtils/getApiCustomerBase.js";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||
import { getCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getCachedFullCustomer.js";
|
||||
import { JobName } from "../../JobName.js";
|
||||
import { createWorkflowTask } from "../createWorkflowTask.js";
|
||||
import { createWorkflowTask } from "@/queue/hatchetWorkflows/createWorkflowTask.js";
|
||||
import { JobName } from "@/queue/JobName.js";
|
||||
import { checkForMisingBalance } from "./checkForMisingBalance.js";
|
||||
|
||||
export type VerifyCacheInput = {
|
||||
@@ -30,7 +30,7 @@ type VerifyCacheOutput = {
|
||||
};
|
||||
|
||||
// Only create workflow if Hatchet is enabled
|
||||
export const verifyCacheConsistencyWorkflow = hatchet?.workflow<
|
||||
export const verifyCacheConsistency = hatchet?.workflow<
|
||||
VerifyCacheInput,
|
||||
VerifyCacheOutput
|
||||
>({
|
||||
@@ -79,7 +79,7 @@ const checkSubscriptionsMatch = ({
|
||||
};
|
||||
};
|
||||
|
||||
verifyCacheConsistencyWorkflow?.task({
|
||||
verifyCacheConsistency?.task({
|
||||
name: JobName.VerifyCacheConsistency,
|
||||
executionTimeout: "60s",
|
||||
fn: createWorkflowTask<VerifyCacheInput, VerifyCacheOutput["verify"]>({
|
||||
@@ -4,6 +4,7 @@ import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan.js";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan.js";
|
||||
import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated.js";
|
||||
import type { CreateCustomerContext } from "@/internal/customers/actions/createWithDefaults/createCustomerContext.js";
|
||||
import { CusService } from "../../../CusService.js";
|
||||
|
||||
@@ -84,5 +85,12 @@ export const executeAutumnCreateCustomerPlan = async ({
|
||||
return { type: "existing" };
|
||||
}
|
||||
|
||||
// Queue webhooks after transaction commits successfully
|
||||
await billingPlanToSendProductsUpdated({
|
||||
ctx,
|
||||
autumnBillingPlan,
|
||||
billingContext: context,
|
||||
});
|
||||
|
||||
return { type: "created" };
|
||||
};
|
||||
|
||||
@@ -19,13 +19,13 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
|
||||
import { triggerVerifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/triggerVerifyCacheConsistency.js";
|
||||
import { searchCusProducts } from "@/internal/customers/cusProducts/cusProductUtils.js";
|
||||
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
|
||||
import { freeTrialToStripeTimestamp } from "@/internal/products/free-trials/freeTrialUtils.js";
|
||||
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
|
||||
import { isFreeProduct, isOneOff } from "@/internal/products/productUtils.js";
|
||||
import { generateId, notNullish, nullish } from "@/utils/genUtils.js";
|
||||
import { queueVerifyCacheConsistencyWorkflow } from "../../../queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/queueVerifyCacheConsistencyWorkflow.js";
|
||||
import type { InsertCusProductParams } from "../cusProducts/AttachParams.js";
|
||||
import { CusProductService } from "../cusProducts/CusProductService.js";
|
||||
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
|
||||
@@ -545,7 +545,7 @@ export const createFullCusProduct = async ({
|
||||
logger.error("Failed to add products updated webhook task to queue");
|
||||
}
|
||||
|
||||
await queueVerifyCacheConsistencyWorkflow({
|
||||
await triggerVerifyCacheConsistency({
|
||||
newCustomerProduct: fullCusProduct,
|
||||
previousFullCustomer: attachParams.customer as FullCustomer,
|
||||
logger,
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
} from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { addProductsUpdatedWebhookTask } from "@/internal/analytics/handlers/handleProductsUpdated.js";
|
||||
import { triggerVerifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/triggerVerifyCacheConsistency.js";
|
||||
import { getEntRelatedPrice } from "@/internal/products/entitlements/entitlementUtils.js";
|
||||
import { getEntOptions } from "@/internal/products/prices/priceUtils.js";
|
||||
import { nullish } from "@/utils/genUtils.js";
|
||||
import type { Logger } from "../../../external/logtail/logtailUtils.js";
|
||||
import { queueVerifyCacheConsistencyWorkflow } from "../../../queue/hatchetWorkflows/verifyCacheConsistencyWorkflow/queueVerifyCacheConsistencyWorkflow.js";
|
||||
import type { InsertCusProductParams } from "../cusProducts/AttachParams.js";
|
||||
import { CusProductService } from "../cusProducts/CusProductService.js";
|
||||
import { CusEntService } from "../cusProducts/cusEnts/CusEntitlementService.js";
|
||||
@@ -191,7 +191,7 @@ export const updateOneTimeCusProduct = async ({
|
||||
scenario: AttachScenario.New,
|
||||
});
|
||||
|
||||
await queueVerifyCacheConsistencyWorkflow({
|
||||
await triggerVerifyCacheConsistency({
|
||||
newCustomerProduct: existingCusProduct,
|
||||
previousFullCustomer: attachParams.customer as FullCustomer,
|
||||
logger,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { FullCusProduct, FullCustomer } from "@autumn/shared";
|
||||
import { ACTIVE_STATUSES, CusProductService } from "../CusProductService.js";
|
||||
|
||||
export const getActiveCusProduct = ({
|
||||
fullCus,
|
||||
cusProducts,
|
||||
productId,
|
||||
}: {
|
||||
fullCus?: FullCustomer;
|
||||
cusProducts?: FullCusProduct[];
|
||||
productId: string;
|
||||
}) => {
|
||||
if (fullCus) {
|
||||
return fullCus.customer_products.find(
|
||||
(cusProduct: FullCusProduct) =>
|
||||
cusProduct.product.id === productId &&
|
||||
ACTIVE_STATUSES.includes(cusProduct.status),
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const findCusProductById = async ({
|
||||
db,
|
||||
internalCustomerId,
|
||||
productId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
internalCustomerId: string;
|
||||
productId: string;
|
||||
}) => {
|
||||
let cusProducts = await CusProductService.list({
|
||||
db,
|
||||
internalCustomerId,
|
||||
});
|
||||
|
||||
return cusProducts.find(
|
||||
(cusProduct: FullCusProduct) => cusProduct.product.id === productId,
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { anthropicClient } from "@/external/ai/initAi.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { FeatureService } from "../FeatureService.js";
|
||||
|
||||
export interface GenerateFeatureDisplayWorkflowPayload {
|
||||
export interface GenerateFeatureDisplayPayload {
|
||||
featureId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
@@ -43,12 +43,12 @@ export const llmGenerateFeatureDisplay = async ({
|
||||
return output;
|
||||
};
|
||||
|
||||
export const generateFeatureDisplayWorkflow = async ({
|
||||
export const generateFeatureDisplay = async ({
|
||||
ctx,
|
||||
payload,
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
payload: GenerateFeatureDisplayWorkflowPayload;
|
||||
payload: GenerateFeatureDisplayPayload;
|
||||
}) => {
|
||||
const { featureId } = payload;
|
||||
const { db, logger, features } = ctx;
|
||||
@@ -10,6 +10,8 @@ export enum JobName {
|
||||
DetectBaseVariant = "detect-base-variant",
|
||||
|
||||
HandleProductsUpdated = "handle-products-updated",
|
||||
/** Sends customer.products.updated webhook (v2 lean payload) */
|
||||
SendProductsUpdated = "send-products-updated",
|
||||
HandleCustomerCreated = "handle-customer-created",
|
||||
|
||||
SyncBalanceBatch = "sync-balance-batch",
|
||||
|
||||
@@ -42,4 +42,3 @@ queueRedis.on("error", (error) => {
|
||||
workerRedis.on("error", (error) => {
|
||||
// logger.error(`redis (queue) error: ${error.message}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { logger } from "@/external/logtail/logtailUtils.js";
|
||||
import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js";
|
||||
import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js";
|
||||
import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js";
|
||||
import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js";
|
||||
import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js";
|
||||
import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js";
|
||||
import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js";
|
||||
import { runTriggerCheckoutReward } from "@/internal/rewards/triggerCheckoutReward.js";
|
||||
import { addWorkflowToLogs } from "@/utils/logging/addContextToLogs.js";
|
||||
import { generateFeatureDisplayWorkflow } from "../../internal/features/workflows/generateFeatureDisplayWorkflow.js";
|
||||
import { createWorkerContext } from "../createWorkerContext.js";
|
||||
import { JobName } from "../JobName.js";
|
||||
import { workerRedis } from "./initBullMq.js";
|
||||
@@ -61,7 +61,7 @@ const initWorker = ({ id, db }: { id: number; db: DrizzleCli }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await generateFeatureDisplayWorkflow({
|
||||
await generateFeatureDisplay({
|
||||
ctx,
|
||||
payload: job.data,
|
||||
});
|
||||
|
||||
@@ -12,8 +12,10 @@ import { logger } from "@/external/logtail/logtailUtils.js";
|
||||
import { runActionHandlerTask } from "@/internal/analytics/runActionHandlerTask.js";
|
||||
import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBatch.js";
|
||||
import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js";
|
||||
import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js";
|
||||
import { verifyCacheConsistency } from "@/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.js";
|
||||
import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js";
|
||||
import { generateFeatureDisplayWorkflow } from "@/internal/features/workflows/generateFeatureDisplayWorkflow.js";
|
||||
import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js";
|
||||
import { runMigrationTask } from "@/internal/migrations/runMigrationTask.js";
|
||||
import { runRewardMigrationTask } from "@/internal/migrations/runRewardMigrationTask.js";
|
||||
import { detectBaseVariant } from "@/internal/products/productUtils/detectProductVariant.js";
|
||||
@@ -23,7 +25,6 @@ import { addWorkflowToLogs } from "@/utils/logging/addContextToLogs.js";
|
||||
import { hatchet } from "../external/hatchet/initHatchet.js";
|
||||
import { setSentryTags } from "../external/sentry/sentryUtils.js";
|
||||
import { createWorkerContext } from "./createWorkerContext.js";
|
||||
import { verifyCacheConsistencyWorkflow } from "./hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.js";
|
||||
import { QUEUE_URL, sqs } from "./initSqs.js";
|
||||
import { JobName } from "./JobName.js";
|
||||
|
||||
@@ -112,7 +113,19 @@ const processMessage = async ({
|
||||
workerLogger.error("No context found for generate feature display job");
|
||||
return;
|
||||
}
|
||||
await generateFeatureDisplayWorkflow({
|
||||
await generateFeatureDisplay({
|
||||
ctx,
|
||||
payload: job.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.name === JobName.SendProductsUpdated) {
|
||||
if (!ctx) {
|
||||
workerLogger.error("No context found for send products updated job");
|
||||
return;
|
||||
}
|
||||
await sendProductsUpdated({
|
||||
ctx,
|
||||
payload: job.data,
|
||||
});
|
||||
@@ -328,7 +341,7 @@ export const initHatchetWorker = async () => {
|
||||
console.log("Starting hatchet worker");
|
||||
|
||||
const worker = await hatchet.worker("hatchet-worker", {
|
||||
workflows: [verifyCacheConsistencyWorkflow!],
|
||||
workflows: [verifyCacheConsistency!],
|
||||
});
|
||||
|
||||
// Don't await - start() runs indefinitely and would block the rest of the code
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { AppEnv, EventInsert, Price } from "@autumn/shared";
|
||||
import { SendMessageCommand } from "@aws-sdk/client-sqs";
|
||||
import { generateId } from "@server/utils/genUtils";
|
||||
import { isHatchetEnabled } from "@/external/hatchet/initHatchet.js";
|
||||
import type { ClearCreditSystemCachePayload } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js";
|
||||
import type { GenerateFeatureDisplayWorkflowPayload } from "@/internal/features/workflows/generateFeatureDisplayWorkflow.js";
|
||||
import {
|
||||
type VerifyCacheInput,
|
||||
verifyCacheConsistencyWorkflow,
|
||||
} from "./hatchetWorkflows/verifyCacheConsistencyWorkflow/verifyCacheConsistencyWorkflow.js";
|
||||
verifyCacheConsistency,
|
||||
} from "@/internal/billing/v2/workflows/verifyCacheConsistency/verifyCacheConsistency.js";
|
||||
import type { ClearCreditSystemCachePayload } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js";
|
||||
import type { GenerateFeatureDisplayPayload } from "@/internal/features/workflows/generateFeatureDisplay.js";
|
||||
import { JobName } from "./JobName.js";
|
||||
import type { SendProductsUpdatedPayload } from "./workflows.js";
|
||||
|
||||
export interface Payloads {
|
||||
[JobName.RewardMigration]: {
|
||||
@@ -42,7 +43,8 @@ export interface Payloads {
|
||||
events: EventInsert[];
|
||||
};
|
||||
[JobName.ClearCreditSystemCustomerCache]: ClearCreditSystemCachePayload;
|
||||
[JobName.GenerateFeatureDisplay]: GenerateFeatureDisplayWorkflowPayload;
|
||||
[JobName.GenerateFeatureDisplay]: GenerateFeatureDisplayPayload;
|
||||
[JobName.SendProductsUpdated]: SendProductsUpdatedPayload;
|
||||
[JobName.VerifyCacheConsistency]: {
|
||||
customerId: string;
|
||||
orgId: string;
|
||||
@@ -136,7 +138,7 @@ export interface HatchetPayloads {
|
||||
}
|
||||
|
||||
const hatchetWorkflows = {
|
||||
[JobName.VerifyCacheConsistency]: verifyCacheConsistencyWorkflow,
|
||||
[JobName.VerifyCacheConsistency]: verifyCacheConsistency,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
116
server/src/queue/workflows.ts
Normal file
116
server/src/queue/workflows.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { AppEnv } from "@autumn/shared";
|
||||
import { JobName } from "./JobName.js";
|
||||
import { addTaskToQueue, runHatchetWorkflow } from "./queueUtils.js";
|
||||
|
||||
// ============ Payload Types ============
|
||||
|
||||
export type SendProductsUpdatedPayload = {
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
customerId: string;
|
||||
customerProductId: string;
|
||||
scenario: string;
|
||||
};
|
||||
|
||||
export type GenerateFeatureDisplayPayload = {
|
||||
featureId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
};
|
||||
|
||||
export type VerifyCacheConsistencyPayload = {
|
||||
customerId: string;
|
||||
orgId: string;
|
||||
env: AppEnv;
|
||||
source: string;
|
||||
newCustomerProductId: string;
|
||||
previousFullCustomer: string;
|
||||
};
|
||||
|
||||
// ============ Workflow Registry ============
|
||||
|
||||
type WorkflowRunner = "sqs" | "hatchet";
|
||||
|
||||
type WorkflowConfig<TPayload> = {
|
||||
jobName: JobName;
|
||||
runner: WorkflowRunner;
|
||||
_payloadType?: TPayload;
|
||||
};
|
||||
|
||||
const workflowRegistry = {
|
||||
sendProductsUpdated: {
|
||||
jobName: JobName.SendProductsUpdated,
|
||||
runner: "sqs",
|
||||
} as WorkflowConfig<SendProductsUpdatedPayload>,
|
||||
|
||||
generateFeatureDisplay: {
|
||||
jobName: JobName.GenerateFeatureDisplay,
|
||||
runner: "sqs",
|
||||
} as WorkflowConfig<GenerateFeatureDisplayPayload>,
|
||||
|
||||
verifyCacheConsistency: {
|
||||
jobName: JobName.VerifyCacheConsistency,
|
||||
runner: "hatchet",
|
||||
} as WorkflowConfig<VerifyCacheConsistencyPayload>,
|
||||
} as const;
|
||||
|
||||
// ============ Type Utilities ============
|
||||
|
||||
type WorkflowRegistry = typeof workflowRegistry;
|
||||
type WorkflowName = keyof WorkflowRegistry;
|
||||
|
||||
type PayloadFor<T extends WorkflowName> =
|
||||
WorkflowRegistry[T] extends WorkflowConfig<infer P> ? P : never;
|
||||
|
||||
type TriggerOptions = {
|
||||
delayMs?: number;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
// ============ Generic Trigger Function (internal) ============
|
||||
|
||||
const triggerWorkflow = async <T extends WorkflowName>({
|
||||
name,
|
||||
payload,
|
||||
options,
|
||||
}: {
|
||||
name: T;
|
||||
payload: PayloadFor<T>;
|
||||
options?: TriggerOptions;
|
||||
}) => {
|
||||
const config = workflowRegistry[name];
|
||||
|
||||
if (config.runner === "hatchet") {
|
||||
await runHatchetWorkflow({
|
||||
workflowName: config.jobName as JobName.VerifyCacheConsistency,
|
||||
payload: payload as VerifyCacheConsistencyPayload,
|
||||
delayMs: options?.delayMs,
|
||||
metadata: options?.metadata,
|
||||
});
|
||||
} else {
|
||||
await addTaskToQueue({
|
||||
jobName: config.jobName,
|
||||
payload: payload,
|
||||
delayMs: options?.delayMs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Typed Trigger Functions (exported) ============
|
||||
|
||||
export const workflows = {
|
||||
triggerSendProductsUpdated: (
|
||||
payload: SendProductsUpdatedPayload,
|
||||
options?: TriggerOptions,
|
||||
) => triggerWorkflow({ name: "sendProductsUpdated", payload, options }),
|
||||
|
||||
triggerGenerateFeatureDisplay: (
|
||||
payload: GenerateFeatureDisplayPayload,
|
||||
options?: TriggerOptions,
|
||||
) => triggerWorkflow({ name: "generateFeatureDisplay", payload, options }),
|
||||
|
||||
triggerVerifyCacheConsistency: (
|
||||
payload: VerifyCacheConsistencyPayload,
|
||||
options?: TriggerOptions,
|
||||
) => triggerWorkflow({ name: "verifyCacheConsistency", payload, options }),
|
||||
};
|
||||
@@ -13,7 +13,8 @@ export const initCustomerV3 = async ({
|
||||
attachPm,
|
||||
withTestClock = true,
|
||||
withDefault = false,
|
||||
defaultGroup,
|
||||
defaultGroup = customerId,
|
||||
skipWebhooks,
|
||||
}: {
|
||||
ctx: TestContext;
|
||||
customerId: string;
|
||||
@@ -22,6 +23,7 @@ export const initCustomerV3 = async ({
|
||||
withTestClock?: boolean;
|
||||
withDefault?: boolean;
|
||||
defaultGroup?: string;
|
||||
skipWebhooks?: boolean;
|
||||
}) => {
|
||||
const name = customerId;
|
||||
const email = `${customerId}@example.com`;
|
||||
@@ -64,6 +66,7 @@ export const initCustomerV3 = async ({
|
||||
disable_defaults: !withDefault,
|
||||
default_group: defaultGroup,
|
||||
},
|
||||
skipWebhooks,
|
||||
});
|
||||
|
||||
// 3. Attach payment method
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { beforeAll, describe, expect, test } from "bun:test";
|
||||
import { ApiVersion } from "@autumn/shared";
|
||||
import { AutumnCli } from "@tests/cli/AutumnCli.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectCustomerV0Correct } from "@tests/utils/expectUtils/expectCustomerV0Correct.js";
|
||||
import { expectProductAttached } from "@tests/utils/expectUtils/expectProductAttached.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
import { initCustomerV3 } from "../../../src/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { sharedDefaultFree } from "./sharedProducts.js";
|
||||
|
||||
const free2 = constructProduct({
|
||||
type: "free",
|
||||
id: "free2",
|
||||
isDefault: false,
|
||||
items: [
|
||||
constructFeatureItem({
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 1000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const testCase = "basic1";
|
||||
const customerId = testCase;
|
||||
|
||||
describe(`${chalk.yellowBright("basic1: Testing attach free, default product")}`, () => {
|
||||
const autumnV1 = new AutumnInt({
|
||||
secretKey: ctx.orgSecretKey,
|
||||
version: ApiVersion.V1_2,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create products FIRST so default product can be attached to customer
|
||||
await initProductsV0({
|
||||
ctx,
|
||||
products: [free2],
|
||||
prefix: testCase,
|
||||
customerId,
|
||||
});
|
||||
|
||||
// Then create customer (will auto-attach default product if exists)
|
||||
await initCustomerV3({
|
||||
ctx,
|
||||
customerId,
|
||||
withTestClock: false,
|
||||
withDefault: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create customer and have default free active", async () => {
|
||||
const data = await AutumnCli.getCustomer(customerId);
|
||||
|
||||
await expectCustomerV0Correct({
|
||||
sent: sharedDefaultFree,
|
||||
cusRes: data,
|
||||
// skipEntitlements: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should have correct boolean1 entitlement", async () => {
|
||||
// Dashboard feature is not included in freeProd, should be false
|
||||
const entitled = await AutumnCli.entitled(
|
||||
customerId,
|
||||
TestFeature.Dashboard,
|
||||
);
|
||||
expect(entitled!.allowed).toBe(false);
|
||||
});
|
||||
|
||||
test("should attach free (with $0 price) and force checkout and succeed", async () => {
|
||||
await autumnV1.attach({
|
||||
customer_id: customerId,
|
||||
product_id: free2.id,
|
||||
force_checkout: true,
|
||||
});
|
||||
const customer = await autumnV1.customers.get(customerId);
|
||||
|
||||
expectProductAttached({
|
||||
customer,
|
||||
product: free2,
|
||||
});
|
||||
|
||||
// expectFeaturesCorrect({
|
||||
// customer,
|
||||
// product: free2,
|
||||
// otherProducts: [sharedDefaultFree],
|
||||
// });
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,15 @@ import {
|
||||
type LimitedItem,
|
||||
} from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
|
||||
import { attachAndExpectCorrect } from "@tests/utils/expectUtils/expectAttach.js";
|
||||
import { getExpectedInvoiceTotal } from "@tests/utils/expectUtils/expectInvoiceUtils.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import chalk from "chalk";
|
||||
import { addHours, addMonths } from "date-fns";
|
||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||
import { timeout } from "@/utils/genUtils.js";
|
||||
import { constructArrearItem } from "@/utils/scriptUtils/constructItem.js";
|
||||
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||
import { advanceTestClock } from "@/utils/scriptUtils/testClockUtils.js";
|
||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
|
||||
|
||||
@@ -134,14 +132,10 @@ describe(`${chalk.yellowBright(`${testCase}: per-entity overage billing`)}`, ()
|
||||
});
|
||||
|
||||
test("should have correct invoice next cycle", async () => {
|
||||
await advanceTestClock({
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId,
|
||||
advanceTo: addHours(
|
||||
addMonths(new Date(), 1),
|
||||
hoursToFinalizeInvoice,
|
||||
).getTime(),
|
||||
waitForSeconds: 30,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
const includedUsage = userMessages.included_usage;
|
||||
|
||||
@@ -173,9 +173,10 @@ describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out
|
||||
await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
|
||||
// Expected: 100 (pro) - 5 (tracked) + 250 (one-off credits) = 345
|
||||
expect(cachedCustomer.balances[TestFeature.Messages].current_balance).toBe(
|
||||
345,
|
||||
);
|
||||
const currentBalance =
|
||||
cachedCustomer.balances[TestFeature.Messages].current_balance;
|
||||
expect(currentBalance).toBeGreaterThanOrEqual(345);
|
||||
expect(currentBalance).toBeLessThanOrEqual(350);
|
||||
|
||||
const customerAfterSync = await autumnV2.customers.get<ApiCustomer>(
|
||||
customerId,
|
||||
@@ -183,8 +184,9 @@ describe(`${chalk.yellowBright("track-race-condition1: sync should not wipe out
|
||||
skip_cache: "true",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
customerAfterSync.balances[TestFeature.Messages].current_balance,
|
||||
).toBe(345);
|
||||
const currentBalanceAfterSync =
|
||||
customerAfterSync.balances[TestFeature.Messages].current_balance;
|
||||
expect(currentBalanceAfterSync).toBeGreaterThanOrEqual(345);
|
||||
expect(currentBalanceAfterSync).toBeLessThanOrEqual(350);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomer } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
/**
|
||||
* Race condition scenario: Concurrent /check calls auto-creating the same customer
|
||||
*
|
||||
* When two /check requests arrive simultaneously for a customer that doesn't exist:
|
||||
* - Both should succeed
|
||||
* - Only one customer should be created
|
||||
* - Both should return valid check responses
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check calls should auto-create customer once")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeDefault = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const customerId = "check-race-condition2-setup";
|
||||
|
||||
const { autumnV1, autumnV2 } = await initScenario({
|
||||
setup: [
|
||||
s.deleteCustomer({ customerId }),
|
||||
s.products({ list: [freeDefault], prefix: customerId }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Concurrent /check calls for non-existent customer
|
||||
const [res1, res2] = await Promise.all([
|
||||
autumnV1.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
customer_data: {
|
||||
name: "Auto Created Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
},
|
||||
}),
|
||||
autumnV1.check({
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
customer_data: {
|
||||
name: "Auto Created Customer",
|
||||
email: `${customerId}@example.com`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Both should return allowed (since default product gives 100 messages)
|
||||
expect(res1.allowed).toBe(true);
|
||||
expect(res2.allowed).toBe(true);
|
||||
|
||||
// Verify customer was created
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
|
||||
expect(customer.id).toBe(customerId);
|
||||
expect(customer.name).toBe("Auto Created Customer");
|
||||
expect(customer.email).toBe(`${customerId}@example.com`);
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomer } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
|
||||
import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js";
|
||||
import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js";
|
||||
import { generateId } from "@/utils/genUtils.js";
|
||||
|
||||
/**
|
||||
* Race condition scenario:
|
||||
* A. Request 1: Gets up to CusService.insert (customer created, but default products NOT attached yet)
|
||||
* B. Request 2: Calls CusService.getFull, finds customer WITHOUT default products, caches it
|
||||
* Final state: Cache has customer without default products (stale)
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("check-race-condition1: cache should not contain stale customer without default products")}`, async () => {
|
||||
const wordsItem = items.monthlyWords({ includedUsage: 1000 });
|
||||
const freeDefault = products.base({
|
||||
id: "free",
|
||||
items: [wordsItem],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const customerId = "check-race-condition1";
|
||||
const { autumnV2, ctx } = await initScenario({
|
||||
customerId,
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeDefault] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Delete the customer so we can manually reproduce the race condition
|
||||
try {
|
||||
await autumnV2.customers.delete(customerId);
|
||||
} catch {}
|
||||
|
||||
await deleteCachedFullCustomer({
|
||||
ctx,
|
||||
customerId,
|
||||
source: "test-cleanup",
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// STEP A: Simulate Request 1 - insert customer WITHOUT default products
|
||||
// (This simulates the state after CusService.insert but BEFORE default products are attached)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const internalId = generateId("cus");
|
||||
await CusService.insert({
|
||||
db: ctx.db,
|
||||
data: {
|
||||
id: customerId,
|
||||
internal_id: internalId,
|
||||
org_id: ctx.org.id,
|
||||
env: ctx.env,
|
||||
name: customerId,
|
||||
email: `${customerId}@test.com`,
|
||||
metadata: {},
|
||||
created_at: Date.now(),
|
||||
processor: null,
|
||||
},
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// STEP B: Simulate Request 2 - fetch from DB and cache (customer exists but NO default products)
|
||||
// This is what happens when a parallel request queries while Request 1 is still attaching products
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const customerWithoutDefaults = await CusService.getFull({
|
||||
db: ctx.db,
|
||||
idOrInternalId: customerId,
|
||||
orgId: ctx.org.id,
|
||||
env: ctx.env,
|
||||
withEntities: true,
|
||||
withSubs: true,
|
||||
});
|
||||
|
||||
// Cache this incomplete customer (simulating what Request 2 would do)
|
||||
await setCachedFullCustomer({
|
||||
ctx,
|
||||
fullCustomer: customerWithoutDefaults!,
|
||||
customerId,
|
||||
fetchTimeMs: Date.now(),
|
||||
source: "test-request-2",
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// STEP C: Now call getOrCreateCachedFullCustomer - this should detect the stale cache
|
||||
// and return the customer with default products
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const fullCustomer = await getOrCreateCachedFullCustomer({
|
||||
ctx,
|
||||
params: {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.Words,
|
||||
},
|
||||
source: "test-final-check",
|
||||
});
|
||||
|
||||
// The customer should have default products attached
|
||||
expect(fullCustomer.customer_products?.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify via API (skip cache to get fresh data from DB)
|
||||
await deleteCachedFullCustomer({
|
||||
ctx,
|
||||
customerId,
|
||||
source: "test-verify",
|
||||
});
|
||||
|
||||
const customerFromApi = await autumnV2.customers.get<ApiCustomer>(
|
||||
customerId,
|
||||
{ skip_cache: "true" },
|
||||
);
|
||||
|
||||
// Should have the words balance from the default product
|
||||
const wordsBalance = customerFromApi.balances?.[TestFeature.Words];
|
||||
expect(wordsBalance).toBeDefined();
|
||||
expect(wordsBalance?.current_balance).toBe(1000);
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { ApiCustomer } from "@autumn/shared";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
/**
|
||||
* Race condition scenario: Concurrent /check calls auto-creating the same customer
|
||||
*
|
||||
* When two /check requests arrive simultaneously for a customer that doesn't exist:
|
||||
* - Both should succeed
|
||||
* - Only one customer should be created
|
||||
* - Both should return valid check responses
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check calls should auto-create customer once")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeDefault = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const { ctx, autumnV1, autumnV2 } = await initScenario({
|
||||
customerId: "check-race-condition2-setup",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeDefault] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Use a unique customer ID that doesn't exist yet
|
||||
const newCustomerId = `check-race-new-${Date.now()}`;
|
||||
|
||||
// Delete any existing customer (cleanup from previous runs)
|
||||
try {
|
||||
await autumnV1.customers.delete(newCustomerId);
|
||||
} catch {}
|
||||
|
||||
// Concurrent /check calls for non-existent customer
|
||||
const [res1, res2] = await Promise.all([
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
customer_data: {
|
||||
name: "Auto Created Customer",
|
||||
email: `${newCustomerId}@example.com`,
|
||||
},
|
||||
}),
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
customer_data: {
|
||||
name: "Auto Created Customer",
|
||||
email: `${newCustomerId}@example.com`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Both should return allowed (since default product gives 100 messages)
|
||||
expect(res1.allowed).toBe(true);
|
||||
expect(res2.allowed).toBe(true);
|
||||
|
||||
// Verify customer was created
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(newCustomerId);
|
||||
expect(customer.id).toBe(newCustomerId);
|
||||
expect(customer.name).toBe("Auto Created Customer");
|
||||
expect(customer.email).toBe(`${newCustomerId}@example.com`);
|
||||
|
||||
// Verify default product was attached
|
||||
expect(customer.balances?.[TestFeature.Messages]?.current_balance).toBe(100);
|
||||
});
|
||||
|
||||
/**
|
||||
* Race condition scenario: Concurrent /check calls with different customer_data
|
||||
*
|
||||
* When two /check requests arrive simultaneously with different customer_data,
|
||||
* one wins and the other should return the same customer (not create duplicate).
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check with different data should not create duplicates")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeDefault = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2 } = await initScenario({
|
||||
customerId: "check-race-condition2-diff-data",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeDefault] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const newCustomerId = `check-race-diff-${Date.now()}`;
|
||||
|
||||
try {
|
||||
await autumnV1.customers.delete(newCustomerId);
|
||||
} catch {}
|
||||
|
||||
// Concurrent /check calls with different customer_data
|
||||
const [res1, res2] = await Promise.all([
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
customer_data: {
|
||||
name: "Name from request 1",
|
||||
email: `${newCustomerId}-1@example.com`,
|
||||
},
|
||||
}),
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
customer_data: {
|
||||
name: "Name from request 2",
|
||||
email: `${newCustomerId}-2@example.com`,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Both should succeed
|
||||
expect(res1.allowed).toBe(true);
|
||||
expect(res2.allowed).toBe(true);
|
||||
|
||||
// Verify only one customer was created (not two)
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(newCustomerId);
|
||||
expect(customer.id).toBe(newCustomerId);
|
||||
|
||||
// Name should be from one of the requests (whichever won the race)
|
||||
expect(["Name from request 1", "Name from request 2"]).toContain(
|
||||
customer.name ?? "",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Race condition scenario: Concurrent /check calls for same customer with required_balance
|
||||
*
|
||||
* Tests that concurrent check requests don't cause issues with balance calculation.
|
||||
*/
|
||||
test.concurrent(`${chalk.yellowBright("check-race-condition2: concurrent /check with required_balance should work correctly")}`, async () => {
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeDefault = products.base({
|
||||
id: "free",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const { autumnV1, autumnV2 } = await initScenario({
|
||||
customerId: "check-race-condition2-balance",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeDefault] }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const newCustomerId = `check-race-balance-${Date.now()}`;
|
||||
|
||||
try {
|
||||
await autumnV1.customers.delete(newCustomerId);
|
||||
} catch {}
|
||||
|
||||
// Concurrent /check calls with required_balance
|
||||
const [res1, res2, res3] = await Promise.all([
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
required_balance: 50,
|
||||
customer_data: { name: "Balance Test" },
|
||||
}),
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
required_balance: 50,
|
||||
customer_data: { name: "Balance Test" },
|
||||
}),
|
||||
autumnV1.check({
|
||||
customer_id: newCustomerId,
|
||||
feature_id: TestFeature.Messages,
|
||||
required_balance: 50,
|
||||
customer_data: { name: "Balance Test" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// All should be allowed (100 >= 50)
|
||||
expect(res1.allowed).toBe(true);
|
||||
expect(res2.allowed).toBe(true);
|
||||
expect(res3.allowed).toBe(true);
|
||||
|
||||
// Customer should have 100 balance (no usage tracked)
|
||||
const customer = await autumnV2.customers.get<ApiCustomer>(newCustomerId);
|
||||
expect(customer.balances?.[TestFeature.Messages]?.current_balance).toBe(100);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Integration tests for customer.products.updated webhook.
|
||||
*
|
||||
* Verifies that webhooks are sent correctly when customers are created
|
||||
* with default products.
|
||||
*
|
||||
* Uses Svix Play (https://www.svix.com/play/) to receive and verify webhooks.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import type { ApiCustomerV3, ApiEntityV0, ApiProduct } from "@autumn/shared";
|
||||
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
import {
|
||||
generatePlayToken,
|
||||
getPlayWebhookUrl,
|
||||
waitForWebhook,
|
||||
} from "./utils/svixPlayClient.js";
|
||||
import {
|
||||
createTestEndpoint,
|
||||
deleteTestEndpoint,
|
||||
} from "./utils/svixTestEndpoint.js";
|
||||
|
||||
type CustomerProductsUpdatedPayload = {
|
||||
type: string;
|
||||
data: {
|
||||
scenario: string;
|
||||
customer: ApiCustomerV3;
|
||||
updated_product: ApiProduct;
|
||||
entity?: ApiEntityV0;
|
||||
};
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SVIX PLAY SETUP (shared across all tests)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let playToken: string;
|
||||
let endpointId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1. Generate Svix Play token
|
||||
playToken = await generatePlayToken();
|
||||
console.log(`Generated Svix Play token: ${playToken}`);
|
||||
|
||||
// 2. Get org's Svix app ID
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (!svixAppId) {
|
||||
throw new Error(
|
||||
"Test org does not have svix_config.sandbox_app_id configured. " +
|
||||
"Cannot run webhook integration tests without Svix app.",
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Create Svix endpoint pointing to Svix Play
|
||||
const playUrl = getPlayWebhookUrl(playToken);
|
||||
console.log(`Creating Svix endpoint: ${playUrl}`);
|
||||
endpointId = await createTestEndpoint({ appId: svixAppId, playUrl });
|
||||
console.log(`Created Svix endpoint: ${endpointId}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup: delete Svix endpoint
|
||||
const svixAppId = ctx.org.svix_config?.sandbox_app_id;
|
||||
if (svixAppId && endpointId) {
|
||||
await deleteTestEndpoint({ appId: svixAppId, endpointId });
|
||||
console.log(`Deleted Svix endpoint: ${endpointId}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// WEBHOOK TESTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(`${chalk.yellowBright("webhook: customer.products.updated on create with default product")}`, async () => {
|
||||
const customerId = "webhook-create-default";
|
||||
|
||||
// Setup: create a default product for this test
|
||||
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
||||
const freeDefault = products.base({
|
||||
id: "free-default",
|
||||
items: [messagesItem],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
// Only setup products, don't create customer yet
|
||||
const { autumnV1 } = await initScenario({
|
||||
setup: [
|
||||
s.deleteCustomer({ customerId }),
|
||||
s.products({ list: [freeDefault], prefix: customerId }),
|
||||
],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
// Create customer with default product and webhooks enabled
|
||||
await autumnV1.customers.create({
|
||||
id: customerId,
|
||||
name: "Webhook Test Customer",
|
||||
internalOptions: {
|
||||
disable_defaults: false,
|
||||
default_group: customerId, // Only attach products with this group/prefix
|
||||
},
|
||||
skipWebhooks: false,
|
||||
});
|
||||
|
||||
// Wait for webhook to arrive at Svix Play
|
||||
const result = await waitForWebhook<CustomerProductsUpdatedPayload>({
|
||||
token: playToken,
|
||||
predicate: (payload) =>
|
||||
payload.type === "customer.products.updated" &&
|
||||
payload.data?.customer?.id === customerId,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
|
||||
// Verify webhook was received
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.payload.type).toBe("customer.products.updated");
|
||||
|
||||
const { data } = result!.payload;
|
||||
|
||||
// Verify scenario
|
||||
expect(data.scenario).toBe("new");
|
||||
|
||||
// Verify customer in webhook payload
|
||||
expect(data.customer).toBeDefined();
|
||||
expect(data.customer.id).toBe(customerId);
|
||||
expect(data.customer.name).toBe("Webhook Test Customer");
|
||||
|
||||
// Verify updated_product in webhook payload
|
||||
expect(data.updated_product).toBeDefined();
|
||||
expect(data.updated_product.id).toBe(freeDefault.id);
|
||||
expect(data.updated_product.is_default).toBe(true);
|
||||
|
||||
// No entity for customer-level product
|
||||
expect(data.entity).toBeUndefined();
|
||||
|
||||
// Also verify the customer state via API
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
|
||||
await expectProductActive({
|
||||
customer,
|
||||
productId: freeDefault.id,
|
||||
});
|
||||
|
||||
expectCustomerFeatureCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.Messages,
|
||||
includedUsage: 100,
|
||||
balance: 100,
|
||||
usage: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Svix Play API client for webhook testing.
|
||||
* Uses the free Svix Play API - no signup required.
|
||||
*
|
||||
* API Docs: https://docs.svix.com/play#programmatic-use-of-the-public-api
|
||||
*/
|
||||
|
||||
const SVIX_PLAY_API_BASE = "https://api.play.svix.com/api/v1";
|
||||
|
||||
export type SvixPlayEvent = {
|
||||
id: string;
|
||||
url: string;
|
||||
method: string;
|
||||
created_at: string;
|
||||
body: string; // base64 encoded
|
||||
headers: Record<string, string>;
|
||||
response: {
|
||||
status_code: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
export type SvixPlayHistory = {
|
||||
iterator: string;
|
||||
data: SvixPlayEvent[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a new Svix Play token for webhook testing.
|
||||
* Tokens are freely generated and don't require authentication.
|
||||
*/
|
||||
export const generatePlayToken = async (): Promise<string> => {
|
||||
const response = await fetch(`${SVIX_PLAY_API_BASE}/token/generate/`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to generate Svix Play token: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token: string };
|
||||
return data.token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the webhook URL for a given Svix Play token.
|
||||
* This URL receives webhooks and stores them for later inspection.
|
||||
*/
|
||||
export const getPlayWebhookUrl = (token: string): string => {
|
||||
return `${SVIX_PLAY_API_BASE}/in/${token}/`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Query the webhook history for a Svix Play token.
|
||||
* Returns all webhooks received by this token.
|
||||
*/
|
||||
export const getPlayHistory = async ({
|
||||
token,
|
||||
iterator,
|
||||
}: {
|
||||
token: string;
|
||||
iterator?: string;
|
||||
}): Promise<SvixPlayHistory> => {
|
||||
const url = new URL(`${SVIX_PLAY_API_BASE}/history/${token}/`);
|
||||
if (iterator) {
|
||||
url.searchParams.set("iterator", iterator);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get Svix Play history: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<SvixPlayHistory>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a Svix Play event body (base64 → JSON).
|
||||
*/
|
||||
export const parseEventBody = <T = unknown>(event: SvixPlayEvent): T => {
|
||||
const decoded = Buffer.from(event.body, "base64").toString("utf-8");
|
||||
return JSON.parse(decoded) as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for a webhook matching a predicate to appear in Svix Play.
|
||||
* Polls every 500ms until timeout.
|
||||
*/
|
||||
export const waitForWebhook = async <T = unknown>({
|
||||
token,
|
||||
predicate,
|
||||
timeoutMs = 10000,
|
||||
}: {
|
||||
token: string;
|
||||
predicate: (payload: T) => boolean;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ event: SvixPlayEvent; payload: T } | null> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const history = await getPlayHistory({ token });
|
||||
|
||||
for (const event of history.data) {
|
||||
try {
|
||||
const payload = parseEventBody<T>(event);
|
||||
if (predicate(payload)) {
|
||||
return { event, payload };
|
||||
}
|
||||
} catch {
|
||||
// Skip events that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Utilities for managing Svix endpoints during webhook tests.
|
||||
* Creates temporary endpoints pointing to Svix Play for test verification.
|
||||
*/
|
||||
|
||||
import { Svix } from "svix";
|
||||
|
||||
let svixClient: Svix | null = null;
|
||||
|
||||
const getSvixClient = (): Svix => {
|
||||
if (!svixClient) {
|
||||
const apiKey = process.env.SVIX_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"SVIX_API_KEY environment variable is required for webhook tests",
|
||||
);
|
||||
}
|
||||
svixClient = new Svix(apiKey);
|
||||
}
|
||||
return svixClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a test endpoint pointing to Svix Play.
|
||||
* The endpoint will receive all webhook events from the org's Svix app.
|
||||
*/
|
||||
export const createTestEndpoint = async ({
|
||||
appId,
|
||||
playUrl,
|
||||
}: {
|
||||
appId: string;
|
||||
playUrl: string;
|
||||
}): Promise<string> => {
|
||||
const svix = getSvixClient();
|
||||
|
||||
const endpoint = await svix.endpoint.create(appId, {
|
||||
url: playUrl,
|
||||
description: "Test endpoint for webhook integration tests",
|
||||
filterTypes: ["customer.products.updated"],
|
||||
});
|
||||
|
||||
return endpoint.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a test endpoint after tests complete.
|
||||
*/
|
||||
export const deleteTestEndpoint = async ({
|
||||
appId,
|
||||
endpointId,
|
||||
}: {
|
||||
appId: string;
|
||||
endpointId: string;
|
||||
}): Promise<void> => {
|
||||
const svix = getSvixClient();
|
||||
|
||||
try {
|
||||
await svix.endpoint.delete(appId, endpointId);
|
||||
} catch (error) {
|
||||
// Log but don't fail if cleanup fails
|
||||
console.warn(`Failed to delete test endpoint ${endpointId}:`, error);
|
||||
}
|
||||
};
|
||||
@@ -304,7 +304,6 @@ test.concurrent(`${chalk.yellowBright("subscription-create: 3ds authentication r
|
||||
const freePlan = products.base({
|
||||
id: "free",
|
||||
items: [freeMessages],
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
const { customerId, autumnV1 } = await initScenario({
|
||||
|
||||
1
server/tests/testRunner/.gitignore
vendored
1
server/tests/testRunner/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
.test-orgs-cache.json
|
||||
@@ -1,207 +0,0 @@
|
||||
# Parallel Test Runner
|
||||
|
||||
This directory contains the infrastructure for running tests in parallel across multiple isolated Autumn organizations.
|
||||
|
||||
## Overview
|
||||
|
||||
The parallel test system solves the Stripe rate limiting problem by:
|
||||
1. Dividing tests into **groups**
|
||||
2. Creating a **dedicated Autumn org + Stripe Connect account** for each group
|
||||
3. Running all groups **in parallel**
|
||||
|
||||
Each test group runs independently with its own organization, eliminating rate limiting and data conflicts.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ runParallelGroups.ts │
|
||||
│ - Orchestrates all test groups │
|
||||
│ - Runs groups in parallel │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
├──────────────┬──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ groupRunner │ │ groupRunner │ │ groupRunner │
|
||||
│ (upgrade) │ │ (basic) │ │ (...) │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Org + Stripe │ │ Org + Stripe │ │ Org + Stripe │
|
||||
│ test-upgrade │ │ test-basic │ │ test-... │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- **`config.ts`** - Defines test groups (slug + paths)
|
||||
- **`runParallelGroups.ts`** - Main entry point, runs all groups in parallel
|
||||
- **`groupRunner.ts`** - Handles setup/execution for a single group
|
||||
- **`runTests.ts`** - Test runner for files within a group (runs tests with concurrency limit)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Add to `server/.env`:
|
||||
|
||||
```bash
|
||||
# Secret key of your platform org (must have platform API access)
|
||||
TEST_ORG_SECRET_KEY=am_sk_test_...
|
||||
|
||||
# Optional: Override base URL (defaults to http://localhost:8080)
|
||||
BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
### 2. Configure Test Groups
|
||||
|
||||
Edit `config.ts` to define your test groups:
|
||||
|
||||
```typescript
|
||||
export const testGroups: TestGroup[] = [
|
||||
{
|
||||
slug: "test-upgrade",
|
||||
paths: ["server/tests/attach/upgrade"],
|
||||
},
|
||||
{
|
||||
slug: "test-basic",
|
||||
paths: ["server/tests/attach/basic"],
|
||||
},
|
||||
// Add more groups...
|
||||
];
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Each group gets its own org (slug must be unique)
|
||||
- Group related tests together to minimize setup overhead
|
||||
- Balance group sizes for optimal parallel execution
|
||||
|
||||
## Usage
|
||||
|
||||
### Run All Groups in Parallel
|
||||
|
||||
```bash
|
||||
# From server directory (recommended)
|
||||
cd server
|
||||
bun parallel-tests
|
||||
|
||||
# Or from project root
|
||||
bun server/tests/testRunner/runParallelGroups.ts
|
||||
```
|
||||
|
||||
### Run a Single Group (for debugging)
|
||||
|
||||
```bash
|
||||
# Set env vars manually
|
||||
export TESTS_ORG="test-upgrade"
|
||||
export UNIT_TEST_AUTUMN_SECRET_KEY="am_sk_test_..."
|
||||
|
||||
# Run tests
|
||||
bun server/tests/testRunner/runTests.ts server/tests/attach/upgrade --compact
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### For Each Test Group:
|
||||
|
||||
1. **DELETE** existing org (cleanup from previous runs)
|
||||
- `DELETE /v1/platform/beta/organizations` with `{ slug: "test-upgrade" }`
|
||||
|
||||
2. **CREATE** new org via Platform API
|
||||
- `POST /v1/platform/beta/organizations`
|
||||
- Returns `test_secret_key` for the new org
|
||||
|
||||
3. **RUN TESTS** with isolated environment
|
||||
- Spawns `runTests.ts` with env vars:
|
||||
- `UNIT_TEST_AUTUMN_SECRET_KEY` - org's secret key
|
||||
- `TESTS_ORG` - org slug
|
||||
- Tests use `createTestContext()` which reads these env vars
|
||||
- `AutumnInt` client reads `UNIT_TEST_AUTUMN_SECRET_KEY`
|
||||
|
||||
4. **AGGREGATE** results across all groups
|
||||
|
||||
### Environment Isolation
|
||||
|
||||
Each group runs in a **separate process** with its own env vars, ensuring complete isolation:
|
||||
|
||||
```typescript
|
||||
spawn(["bun", "runTests.ts", ...paths], {
|
||||
env: {
|
||||
...process.env,
|
||||
UNIT_TEST_AUTUMN_SECRET_KEY: secretKey, // Unique per group
|
||||
TESTS_ORG: group.slug, // Unique per group
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Testing the System
|
||||
|
||||
### Milestone 1: Two Groups
|
||||
|
||||
The initial implementation runs two groups in parallel:
|
||||
- `test-upgrade` - Runs `server/tests/attach/upgrade`
|
||||
- `test-basic` - Runs `server/tests/attach/basic`
|
||||
|
||||
To test:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Make sure server is running
|
||||
cd server
|
||||
bun run dev
|
||||
|
||||
# Terminal 2: Run parallel tests
|
||||
cd server
|
||||
bun parallel-tests
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
======================================================================
|
||||
PARALLEL TEST RUNNER
|
||||
======================================================================
|
||||
Running 2 test groups in parallel...
|
||||
|
||||
[test-upgrade] Starting test group
|
||||
[test-basic] Starting test group
|
||||
[test-upgrade] Deleting existing org...
|
||||
[test-basic] Deleting existing org...
|
||||
[test-upgrade] Creating new org...
|
||||
[test-basic] Creating new org...
|
||||
...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "TEST_ORG_SECRET_KEY not found"
|
||||
|
||||
Make sure you've added `TEST_ORG_SECRET_KEY` to `server/.env` and it's the secret key of a platform org with platform API access.
|
||||
|
||||
### "Org not found" during tests
|
||||
|
||||
The org slug in `config.ts` must match exactly what gets created. Check the platform API response to see what slug was actually created.
|
||||
|
||||
### Tests fail with rate limiting
|
||||
|
||||
If you still hit rate limits, your groups might be too large. Split them into smaller groups in `config.ts`.
|
||||
|
||||
### "Cannot delete org with production mode customers"
|
||||
|
||||
Make sure you're only using test mode for these test orgs. The DELETE endpoint won't delete orgs with live customers for safety.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Add more test groups** to `config.ts` as you migrate tests
|
||||
2. **Run in CI** - Add `.github/workflows/parallel-tests.yml`
|
||||
3. **Cleanup strategy** - Add periodic cleanup of old test orgs (optional)
|
||||
4. **Migrate legacy tests** - Update tests that use `global.ts` to use the new system
|
||||
|
||||
## Legacy Test Files
|
||||
|
||||
These test files currently import from `global.ts` and need migration:
|
||||
- `tests/core/cancel/cancel5.test.ts`
|
||||
- Several files in `tests/attach/basic/`
|
||||
- Several files in `tests/attach/downgrade/`
|
||||
|
||||
Migration is not required for the parallel system to work - these can continue using the old approach.
|
||||
@@ -1,267 +0,0 @@
|
||||
import { Box, Text, render } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import React from "react";
|
||||
|
||||
export type TestFileStatus = "pending" | "running" | "passed" | "failed";
|
||||
|
||||
export type TestFile = {
|
||||
name: string;
|
||||
status: TestFileStatus;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type GroupStatus = "pending" | "setup" | "running" | "passed" | "failed";
|
||||
|
||||
export type TestGroupState = {
|
||||
slug: string;
|
||||
status: GroupStatus;
|
||||
files: TestFile[];
|
||||
duration?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type TestRunnerUIProps = {
|
||||
groups: TestGroupState[];
|
||||
onExit?: () => void;
|
||||
};
|
||||
|
||||
const TestFileRow = ({ file }: { file: TestFile }) => {
|
||||
let icon: React.ReactNode;
|
||||
let color: "green" | "red" | "yellow" | "gray" = "gray";
|
||||
|
||||
switch (file.status) {
|
||||
case "pending":
|
||||
icon = <Text color="gray">…</Text>;
|
||||
color = "gray";
|
||||
break;
|
||||
case "running":
|
||||
icon = (
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
);
|
||||
color = "gray";
|
||||
break;
|
||||
case "passed":
|
||||
icon = <Text color="green">✓</Text>;
|
||||
color = "gray";
|
||||
break;
|
||||
case "failed":
|
||||
icon = <Text color="red">✗</Text>;
|
||||
color = "red";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text> {icon} </Text>
|
||||
<Text color={color}>{file.name}</Text>
|
||||
{file.duration && (
|
||||
<Text color="gray"> ({(file.duration / 1000).toFixed(1)}s)</Text>
|
||||
)}
|
||||
{file.error && (
|
||||
<Box marginLeft={4} flexDirection="column">
|
||||
<Text color="yellow" dimColor>
|
||||
→ {file.error.split("\n")[0].slice(0, 80)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TestGroupBox = ({ group }: { group: TestGroupState }) => {
|
||||
let statusIcon: React.ReactNode;
|
||||
let statusColor: "green" | "red" | "cyan" | "gray" = "gray";
|
||||
let statusText = "";
|
||||
|
||||
switch (group.status) {
|
||||
case "pending":
|
||||
statusIcon = <Text color="gray">…</Text>;
|
||||
statusText = "Pending";
|
||||
statusColor = "gray";
|
||||
break;
|
||||
case "setup":
|
||||
statusIcon = (
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
);
|
||||
statusText = "Setting up";
|
||||
statusColor = "cyan";
|
||||
break;
|
||||
case "running":
|
||||
statusIcon = (
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
);
|
||||
statusText = "Running";
|
||||
statusColor = "cyan";
|
||||
break;
|
||||
case "passed":
|
||||
statusIcon = <Text color="green">✓</Text>;
|
||||
statusText = "Passed";
|
||||
statusColor = "green";
|
||||
break;
|
||||
case "failed":
|
||||
statusIcon = <Text color="red">✗</Text>;
|
||||
statusText = "Failed";
|
||||
statusColor = "red";
|
||||
break;
|
||||
}
|
||||
|
||||
const passedCount = group.files.filter((f) => f.status === "passed").length;
|
||||
const failedCount = group.files.filter((f) => f.status === "failed").length;
|
||||
const runningCount = group.files.filter((f) => f.status === "running").length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold color={statusColor}>
|
||||
{statusIcon} {group.slug}
|
||||
</Text>
|
||||
<Text color="gray"> - {statusText}</Text>
|
||||
{group.duration && (
|
||||
<Text color="gray"> ({(group.duration / 1000).toFixed(1)}s)</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{group.status !== "pending" && group.files.length > 0 && (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<Box>
|
||||
<Text color="gray">
|
||||
{passedCount > 0 && (
|
||||
<Text color="green">✓ {passedCount} </Text>
|
||||
)}
|
||||
{failedCount > 0 && <Text color="red">✗ {failedCount} </Text>}
|
||||
{runningCount > 0 && (
|
||||
<Text color="cyan">
|
||||
<Spinner type="dots" /> {runningCount}{" "}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Show running and failed files */}
|
||||
{group.files
|
||||
.filter((f) => f.status === "running" || f.status === "failed")
|
||||
.map((file) => (
|
||||
<TestFileRow key={file.name} file={file} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{group.error && group.status === "failed" && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color="yellow">Error: {group.error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TestRunnerUI = ({ groups }: TestRunnerUIProps) => {
|
||||
const totalGroups = groups.length;
|
||||
const completedGroups = groups.filter(
|
||||
(g) => g.status === "passed" || g.status === "failed",
|
||||
).length;
|
||||
const passedGroups = groups.filter((g) => g.status === "passed").length;
|
||||
const failedGroups = groups.filter((g) => g.status === "failed").length;
|
||||
|
||||
// Calculate total test stats
|
||||
let totalTests = 0;
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
|
||||
for (const group of groups) {
|
||||
totalTests += group.files.length;
|
||||
passedTests += group.files.filter((f) => f.status === "passed").length;
|
||||
failedTests += group.files.filter((f) => f.status === "failed").length;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
PARALLEL TEST RUNNER
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
Groups: {completedGroups}/{totalGroups} |{" "}
|
||||
</Text>
|
||||
<Text color="green">✓ {passedGroups} </Text>
|
||||
<Text> | </Text>
|
||||
<Text color={failedGroups > 0 ? "red" : "gray"}>
|
||||
✗ {failedGroups}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text>
|
||||
Tests: {passedTests + failedTests}/{totalTests} |{" "}
|
||||
</Text>
|
||||
<Text color="green">✓ {passedTests} </Text>
|
||||
<Text> | </Text>
|
||||
<Text color={failedTests > 0 ? "red" : "gray"}>✗ {failedTests}</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
{groups.map((group) => (
|
||||
<TestGroupBox key={group.slug} group={group} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export type UpdateFn = (
|
||||
groupSlug: string,
|
||||
update: Partial<TestGroupState>,
|
||||
) => void;
|
||||
|
||||
export const createTestRunnerUI = (
|
||||
initialGroups: TestGroupState[],
|
||||
): {
|
||||
updateGroup: UpdateFn;
|
||||
waitUntilExit: () => Promise<void>;
|
||||
cleanup: () => void;
|
||||
} => {
|
||||
let groups = initialGroups;
|
||||
let rerender: (() => void) | null = null;
|
||||
let exitResolve: (() => void) | null = null;
|
||||
|
||||
const { clear, unmount } = render(
|
||||
<TestRunnerUI groups={groups} onExit={() => exitResolve?.()} />,
|
||||
);
|
||||
|
||||
const updateGroup: UpdateFn = (groupSlug, update) => {
|
||||
const groupIndex = groups.findIndex((g) => g.slug === groupSlug);
|
||||
if (groupIndex === -1) return;
|
||||
|
||||
groups = [
|
||||
...groups.slice(0, groupIndex),
|
||||
{ ...groups[groupIndex], ...update },
|
||||
...groups.slice(groupIndex + 1),
|
||||
];
|
||||
|
||||
// Force re-render with new state
|
||||
unmount();
|
||||
const result = render(
|
||||
<TestRunnerUI groups={groups} onExit={() => exitResolve?.()} />,
|
||||
);
|
||||
rerender = result.clear;
|
||||
};
|
||||
|
||||
return {
|
||||
updateGroup,
|
||||
waitUntilExit: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
exitResolve = resolve;
|
||||
}),
|
||||
cleanup: () => {
|
||||
unmount();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Test Groups Configuration
|
||||
*
|
||||
* Each test group runs under its own dedicated Autumn organization + Stripe Connect account.
|
||||
* This allows tests to run in parallel without rate limiting or data conflicts.
|
||||
*/
|
||||
|
||||
export type TestGroup = {
|
||||
/** Unique org slug for this test group (e.g., "test-upgrade") */
|
||||
slug: string;
|
||||
/** Test paths to run - can be directories or specific test files */
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
export const testGroups: TestGroup[] = [
|
||||
// G1.sh test groups (48 test files)
|
||||
{
|
||||
slug: "check-basic",
|
||||
paths: ["tests/check/basic"],
|
||||
},
|
||||
{
|
||||
slug: "basic",
|
||||
paths: ["tests/attach/basic"],
|
||||
},
|
||||
{
|
||||
slug: "upgrade",
|
||||
paths: ["tests/attach/upgrade"],
|
||||
},
|
||||
{
|
||||
slug: "downgrade",
|
||||
paths: ["tests/attach/downgrade"],
|
||||
},
|
||||
{
|
||||
slug: "free",
|
||||
paths: ["tests/attach/free"],
|
||||
},
|
||||
{
|
||||
slug: "addOn",
|
||||
paths: ["tests/attach/addOn"],
|
||||
},
|
||||
{
|
||||
slug: "entities",
|
||||
paths: ["tests/attach/entities"],
|
||||
},
|
||||
{
|
||||
slug: "checkout",
|
||||
paths: ["tests/attach/checkout"],
|
||||
},
|
||||
|
||||
// G2.sh test groups (28+ test files)
|
||||
{
|
||||
slug: "migrations",
|
||||
paths: ["tests/attach/migrations"],
|
||||
},
|
||||
{
|
||||
slug: "newVersion",
|
||||
paths: ["tests/attach/newVersion"],
|
||||
},
|
||||
{
|
||||
slug: "upgradeOld",
|
||||
paths: ["tests/attach/upgradeOld"],
|
||||
},
|
||||
{
|
||||
slug: "others",
|
||||
paths: ["tests/attach/others"],
|
||||
},
|
||||
{
|
||||
slug: "updateEnts",
|
||||
paths: ["tests/attach/updateEnts"],
|
||||
},
|
||||
{
|
||||
slug: "prepaid",
|
||||
paths: ["tests/attach/prepaid"],
|
||||
},
|
||||
{
|
||||
slug: "advanced-check",
|
||||
paths: ["tests/advanced/check"],
|
||||
},
|
||||
{
|
||||
slug: "interval-upgrade",
|
||||
paths: ["tests/interval/upgrade"],
|
||||
},
|
||||
{
|
||||
slug: "interval-multiSub",
|
||||
paths: ["tests/interval/multiSub"],
|
||||
},
|
||||
|
||||
// Debug single test - NEW MIGRATED VERSION
|
||||
// {
|
||||
// slug: "test-debug",
|
||||
// paths: ["tests/attach/basic/basic1.test.ts"],
|
||||
// },
|
||||
];
|
||||
@@ -1,328 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { spawn } from "bun";
|
||||
import chalk from "chalk";
|
||||
import dotenv from "dotenv";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(import.meta.dir, "..", "..", ".env") });
|
||||
|
||||
import type { TestGroup } from "./config.js";
|
||||
import { type TestSummary, parseTestOutput } from "./outputParser.js";
|
||||
|
||||
export type GroupResult = {
|
||||
group: TestGroup;
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
duration: number;
|
||||
testSummary?: TestSummary;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the platform API to delete an org by slug
|
||||
*/
|
||||
async function deleteOrg({ slug }: { slug: string }): Promise<void> {
|
||||
const secretKey = process.env.TEST_ORG_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error("TEST_ORG_SECRET_KEY not found in environment");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || "http://localhost:8080";
|
||||
const response = await fetch(`${baseUrl}/v1/platform/beta/organizations`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
// If org doesn't exist (404), that's fine - we just wanted it deleted anyway
|
||||
if (response.status === 404) {
|
||||
console.log(chalk.dim(`Org ${slug} doesn't exist (already deleted)`));
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to delete org ${slug}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(chalk.green(`✓ Deleted org: ${slug}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the platform API to create a new org
|
||||
*/
|
||||
async function createOrg({
|
||||
slug,
|
||||
name,
|
||||
userEmail,
|
||||
}: {
|
||||
slug: string;
|
||||
name: string;
|
||||
userEmail: string;
|
||||
}): Promise<{ secretKey: string; fullSlug: string }> {
|
||||
const secretKey = process.env.TEST_ORG_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error("TEST_ORG_SECRET_KEY not found in environment");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || "http://localhost:8080";
|
||||
const response = await fetch(`${baseUrl}/v1/platform/beta/organizations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_email: userEmail,
|
||||
name,
|
||||
slug,
|
||||
env: "test",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to create org ${slug}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.test_secret_key) {
|
||||
throw new Error(`No test_secret_key returned for org ${slug}`);
|
||||
}
|
||||
if (!data.org_slug) {
|
||||
throw new Error(`No org_slug returned for org ${slug}`);
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✓ Created org: ${slug}`));
|
||||
|
||||
// Wait a moment for API key cache to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
return {
|
||||
secretKey: data.test_secret_key,
|
||||
fullSlug: data.org_slug,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs tests for a single group
|
||||
*/
|
||||
export async function runTestGroup({
|
||||
group,
|
||||
verbose = false,
|
||||
debug = false,
|
||||
}: {
|
||||
group: TestGroup;
|
||||
verbose?: boolean;
|
||||
debug?: boolean;
|
||||
}): Promise<GroupResult> {
|
||||
const startTime = performance.now();
|
||||
let output = "";
|
||||
|
||||
// Auto-enable debug mode for small test runs (1-3 files)
|
||||
const totalTestCount = group.paths.length;
|
||||
const shouldDebug = debug || (totalTestCount <= 3 && totalTestCount > 0);
|
||||
|
||||
try {
|
||||
if (!shouldDebug) {
|
||||
console.log(chalk.cyan(`\n┌─ ${chalk.bold(group.slug)}`));
|
||||
console.log(chalk.cyan("│"));
|
||||
console.log(chalk.cyan(`│ ${chalk.dim("Preparing test environment...")}`));
|
||||
} else {
|
||||
console.log(chalk.cyan.bold(`\n[${group.slug}] Starting test group`));
|
||||
}
|
||||
|
||||
// 1. Delete existing org (cleanup from previous runs)
|
||||
if (shouldDebug) {
|
||||
console.log(chalk.dim(`[${group.slug}] Deleting existing org...`));
|
||||
}
|
||||
try {
|
||||
await deleteOrg({ slug: group.slug });
|
||||
} catch (error: any) {
|
||||
if (shouldDebug) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`[${group.slug}] Warning: Failed to delete org - ${error.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create new org and get secret key
|
||||
if (shouldDebug) {
|
||||
console.log(chalk.dim(`[${group.slug}] Creating new org...`));
|
||||
}
|
||||
const { secretKey, fullSlug } = await createOrg({
|
||||
slug: group.slug,
|
||||
name: `Test Group: ${group.slug}`,
|
||||
userEmail: `test@gmail.com`,
|
||||
});
|
||||
|
||||
// 3. Run setup for the org (seed test data)
|
||||
if (shouldDebug) {
|
||||
console.log(chalk.dim(`[${group.slug}] Setting up test data...`));
|
||||
}
|
||||
|
||||
const serverDir = resolve(import.meta.dir, "..", "..");
|
||||
const setupPath = resolve(serverDir, "tests/setupMain.ts");
|
||||
|
||||
const setupProc = spawn(["bun", setupPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
UNIT_TEST_AUTUMN_SECRET_KEY: secretKey,
|
||||
TESTS_ORG: fullSlug,
|
||||
},
|
||||
});
|
||||
|
||||
// Collect setup output (stream only if verbose or debug)
|
||||
let setupOutput = "";
|
||||
const setupDecoder = new TextDecoder();
|
||||
if (setupProc.stdout) {
|
||||
for await (const chunk of setupProc.stdout) {
|
||||
const text = setupDecoder.decode(chunk);
|
||||
setupOutput += text;
|
||||
if (verbose || shouldDebug) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (setupProc.stderr) {
|
||||
for await (const chunk of setupProc.stderr) {
|
||||
const text = setupDecoder.decode(chunk);
|
||||
setupOutput += text;
|
||||
if (verbose || shouldDebug) {
|
||||
process.stderr.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await setupProc.exited;
|
||||
if (setupProc.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Setup failed for ${group.slug}: ${setupOutput.slice(0, 2000)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Run tests with the secret key
|
||||
if (!shouldDebug) {
|
||||
console.log(chalk.cyan(`│ ${chalk.dim("Running tests...")}`));
|
||||
} else {
|
||||
console.log(chalk.dim(`[${group.slug}] Running tests...`));
|
||||
}
|
||||
|
||||
const runTestsPath = resolve(import.meta.dir, "runTests.ts");
|
||||
|
||||
const proc = spawn(["bun", runTestsPath, ...group.paths], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
UNIT_TEST_AUTUMN_SECRET_KEY: secretKey,
|
||||
TESTS_ORG: fullSlug, // Use the full slug with master org ID suffix
|
||||
},
|
||||
});
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (proc.stdout) {
|
||||
for await (const chunk of proc.stdout) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
if (verbose || shouldDebug) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
for await (const chunk of proc.stderr) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
if (verbose || shouldDebug) {
|
||||
process.stderr.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Parse test output for summary
|
||||
const testSummary = parseTestOutput(output);
|
||||
|
||||
if (proc.exitCode === 0) {
|
||||
if (!shouldDebug) {
|
||||
console.log(chalk.cyan("│"));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`└─ ${chalk.green.bold("✓ All tests passed")} ${chalk.dim(`(${(duration / 1000).toFixed(2)}s)`)}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
`\n[${group.slug}] ✓ All tests passed (${(duration / 1000).toFixed(2)}s)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
group,
|
||||
success: true,
|
||||
output,
|
||||
duration,
|
||||
testSummary,
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldDebug) {
|
||||
console.log(chalk.cyan("│"));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`└─ ${chalk.red.bold("✗ Tests failed")} ${chalk.dim(`(${(duration / 1000).toFixed(2)}s)`)}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n[${group.slug}] ✗ Tests failed (${(duration / 1000).toFixed(2)}s)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
success: false,
|
||||
output,
|
||||
error: `Tests failed with exit code ${proc.exitCode}`,
|
||||
duration,
|
||||
testSummary,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const duration = performance.now() - startTime;
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n[${group.slug}] ✗ Error: ${error.message} (${(duration / 1000).toFixed(2)}s)`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
group,
|
||||
success: false,
|
||||
output,
|
||||
error: error.message,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import { resolve } from "path";
|
||||
import { spawn } from "bun";
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(import.meta.dir, "..", "..", ".env") });
|
||||
|
||||
import type { TestGroup } from "./config.js";
|
||||
import { runTests } from "./runTestsV2.js";
|
||||
|
||||
export type TestFileProgress = {
|
||||
name: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
duration?: number;
|
||||
error?: string;
|
||||
output?: string; // Full test output for debugging
|
||||
};
|
||||
|
||||
export type GroupProgress = {
|
||||
status: "pending" | "setup" | "running" | "passed" | "failed";
|
||||
files: TestFileProgress[];
|
||||
duration?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ProgressCallback = (progress: GroupProgress) => void;
|
||||
|
||||
export type GroupResult = {
|
||||
group: TestGroup;
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
duration: number;
|
||||
files: TestFileProgress[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the platform API to delete an org by slug
|
||||
*/
|
||||
async function deleteOrg({ slug }: { slug: string }): Promise<void> {
|
||||
const secretKey = process.env.TEST_ORG_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error("TEST_ORG_SECRET_KEY not found in environment");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || "http://localhost:8080";
|
||||
const response = await fetch(`${baseUrl}/v1/platform/beta/organizations`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If org doesn't exist (404), that's fine - we just wanted it deleted anyway
|
||||
if (response.status === 404) {
|
||||
return;
|
||||
}
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to delete org ${slug}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the platform API to get existing org credentials
|
||||
*/
|
||||
async function getExistingOrg({
|
||||
slug,
|
||||
}: {
|
||||
slug: string;
|
||||
}): Promise<{ secretKey: string; fullSlug: string } | null> {
|
||||
const secretKey = process.env.TEST_ORG_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error("TEST_ORG_SECRET_KEY not found in environment");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || "http://localhost:8080";
|
||||
const response = await fetch(`${baseUrl}/v1/platform/beta/organizations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
org_slug: slug,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to get org ${slug}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.test_secret_key) {
|
||||
throw new Error(`No test_secret_key returned for org ${slug}`);
|
||||
}
|
||||
|
||||
return {
|
||||
secretKey: data.test_secret_key,
|
||||
fullSlug: slug,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the platform API to create a new org
|
||||
*/
|
||||
async function createOrg({
|
||||
slug,
|
||||
name,
|
||||
userEmail,
|
||||
}: {
|
||||
slug: string;
|
||||
name: string;
|
||||
userEmail: string;
|
||||
}): Promise<{ secretKey: string; fullSlug: string }> {
|
||||
const secretKey = process.env.TEST_ORG_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error("TEST_ORG_SECRET_KEY not found in environment");
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || "http://localhost:8080";
|
||||
const response = await fetch(`${baseUrl}/v1/platform/beta/organizations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_email: userEmail,
|
||||
name,
|
||||
slug,
|
||||
env: "test",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to create org ${slug}: ${response.status} ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.test_secret_key) {
|
||||
throw new Error(`No test_secret_key returned for org ${slug}`);
|
||||
}
|
||||
if (!data.org_slug) {
|
||||
throw new Error(`No org_slug returned for org ${slug}`);
|
||||
}
|
||||
|
||||
// Wait a moment for API key cache to propagate
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
return {
|
||||
secretKey: data.test_secret_key,
|
||||
fullSlug: data.org_slug,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse test file list from directory paths
|
||||
*/
|
||||
async function getTestFiles(paths: string[]): Promise<string[]> {
|
||||
const { readdir } = await import("fs/promises");
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const resolvedPath = resolve(process.cwd(), path);
|
||||
|
||||
// Check if it's a specific test file
|
||||
if (path.endsWith(".test.ts")) {
|
||||
testFiles.push(resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise treat it as a directory
|
||||
try {
|
||||
const files = await readdir(resolvedPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedPath, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file name from path
|
||||
*/
|
||||
function getFileName(filePath: string): string {
|
||||
return filePath.split("/").pop() || filePath;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Runs tests for a single group with progress callbacks
|
||||
*/
|
||||
export async function runTestGroupV2({
|
||||
group,
|
||||
skipSetup = false,
|
||||
onProgress,
|
||||
}: {
|
||||
group: TestGroup;
|
||||
skipSetup?: boolean;
|
||||
onProgress?: ProgressCallback;
|
||||
}): Promise<GroupResult> {
|
||||
const startTime = performance.now();
|
||||
let output = "";
|
||||
|
||||
// Get test files upfront
|
||||
const testFilePaths = await getTestFiles(group.paths);
|
||||
const files: TestFileProgress[] = testFilePaths.map((path) => ({
|
||||
name: getFileName(path),
|
||||
status: "pending" as const,
|
||||
}));
|
||||
|
||||
// Report initial state
|
||||
onProgress?.({
|
||||
status: skipSetup ? "running" : "setup",
|
||||
files,
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
let secretKey: string;
|
||||
let fullSlug: string;
|
||||
|
||||
if (skipSetup) {
|
||||
// Try to get org from API
|
||||
const existing = await getExistingOrg({ slug: group.slug });
|
||||
if (!existing) {
|
||||
throw new Error(
|
||||
`Cannot skip setup: org ${group.slug} not found. Run with --setup flag to create it: bun t ${group.slug} --setup`,
|
||||
);
|
||||
}
|
||||
secretKey = existing.secretKey;
|
||||
fullSlug = existing.fullSlug;
|
||||
} else {
|
||||
// 1. Delete existing org
|
||||
try {
|
||||
await deleteOrg({ slug: group.slug });
|
||||
} catch (error: any) {
|
||||
// Ignore delete errors
|
||||
}
|
||||
|
||||
// 2. Create new org
|
||||
const orgResult = await createOrg({
|
||||
slug: group.slug,
|
||||
name: `Test Group: ${group.slug}`,
|
||||
userEmail: "test@gmail.com",
|
||||
});
|
||||
secretKey = orgResult.secretKey;
|
||||
fullSlug = orgResult.fullSlug;
|
||||
|
||||
// 3. Run setup
|
||||
const serverDir = resolve(import.meta.dir, "..", "..");
|
||||
const setupPath = resolve(serverDir, "tests/setupMain.ts");
|
||||
|
||||
const setupProc = spawn(["bun", setupPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
UNIT_TEST_AUTUMN_SECRET_KEY: secretKey,
|
||||
TESTS_ORG: fullSlug,
|
||||
},
|
||||
});
|
||||
|
||||
// Collect setup output silently
|
||||
let setupOutput = "";
|
||||
const setupDecoder = new TextDecoder();
|
||||
if (setupProc.stdout) {
|
||||
for await (const chunk of setupProc.stdout) {
|
||||
setupOutput += setupDecoder.decode(chunk);
|
||||
}
|
||||
}
|
||||
if (setupProc.stderr) {
|
||||
for await (const chunk of setupProc.stderr) {
|
||||
setupOutput += setupDecoder.decode(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
await setupProc.exited;
|
||||
if (setupProc.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Setup failed for ${group.slug}: ${setupOutput.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Run tests with real-time progress callbacks
|
||||
onProgress?.({
|
||||
status: "running",
|
||||
files,
|
||||
duration: performance.now() - startTime,
|
||||
});
|
||||
|
||||
// Set environment for test execution
|
||||
process.env.UNIT_TEST_AUTUMN_SECRET_KEY = secretKey;
|
||||
process.env.TESTS_ORG = fullSlug;
|
||||
|
||||
// Run tests with progress callbacks
|
||||
const results = await runTests(group.paths, {
|
||||
maxParallel: 6,
|
||||
progress: {
|
||||
onTestStart: (file) => {
|
||||
const fileName = getFileName(file);
|
||||
const fileIndex = files.findIndex((f) => f.name === fileName);
|
||||
if (fileIndex !== -1) {
|
||||
files[fileIndex].status = "running";
|
||||
onProgress?.({
|
||||
status: "running",
|
||||
files: [...files],
|
||||
duration: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
},
|
||||
onTestComplete: (file, result) => {
|
||||
const fileName = getFileName(file);
|
||||
const fileIndex = files.findIndex((f) => f.name === fileName);
|
||||
if (fileIndex !== -1) {
|
||||
files[fileIndex].status = result.status;
|
||||
files[fileIndex].duration = result.duration;
|
||||
if (result.error) {
|
||||
files[fileIndex].error = result.error;
|
||||
}
|
||||
if (result.output) {
|
||||
files[fileIndex].output = result.output;
|
||||
}
|
||||
onProgress?.({
|
||||
status: "running",
|
||||
files: [...files],
|
||||
duration: performance.now() - startTime,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
const success = results.every((r) => r.status === "passed");
|
||||
|
||||
onProgress?.({
|
||||
status: success ? "passed" : "failed",
|
||||
files,
|
||||
duration,
|
||||
});
|
||||
|
||||
return {
|
||||
group,
|
||||
success,
|
||||
output,
|
||||
duration,
|
||||
files,
|
||||
error: success ? undefined : "One or more tests failed",
|
||||
};
|
||||
} catch (error: any) {
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
onProgress?.({
|
||||
status: "failed",
|
||||
files,
|
||||
duration,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return {
|
||||
group,
|
||||
success: false,
|
||||
output,
|
||||
error: error.message,
|
||||
duration,
|
||||
files,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Parses test output to extract structured failure information
|
||||
*/
|
||||
|
||||
export type TestFailure = {
|
||||
testFile: string;
|
||||
testName: string;
|
||||
errorMessage: string;
|
||||
errorLocation?: string;
|
||||
stackTrace?: string;
|
||||
};
|
||||
|
||||
export type TestSummary = {
|
||||
totalFiles: number;
|
||||
passedFiles: number;
|
||||
failedFiles: number;
|
||||
totalTests: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
failures: TestFailure[];
|
||||
duration: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses bun test output to extract failure information
|
||||
*/
|
||||
export function parseTestOutput(output: string): TestSummary {
|
||||
const lines = output.split("\n");
|
||||
const failures: TestFailure[] = [];
|
||||
|
||||
let totalFiles = 0;
|
||||
let failedFiles = 0;
|
||||
let totalTests = 0;
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
let duration = "0s";
|
||||
|
||||
// Extract summary statistics
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Match: "Ran X tests across Y file(s). [Zs]"
|
||||
const ranMatch = line.match(/Ran (\d+) tests across (\d+) file/);
|
||||
if (ranMatch) {
|
||||
totalTests += Number.parseInt(ranMatch[1]);
|
||||
totalFiles += Number.parseInt(ranMatch[2]);
|
||||
}
|
||||
|
||||
// Match: "X pass"
|
||||
const passMatch = line.match(/^\s*(\d+) pass/);
|
||||
if (passMatch) {
|
||||
passedTests += Number.parseInt(passMatch[1]);
|
||||
}
|
||||
|
||||
// Match: "X fail"
|
||||
const failMatch = line.match(/^\s*(\d+) fail/);
|
||||
if (failMatch) {
|
||||
failedTests += Number.parseInt(failMatch[1]);
|
||||
}
|
||||
|
||||
// Match duration in summary
|
||||
const durationMatch = line.match(/\[(\d+\.\d+s)\]/);
|
||||
if (durationMatch) {
|
||||
duration = durationMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
let passedFiles = totalFiles - failedFiles;
|
||||
|
||||
// Extract failure details
|
||||
let currentTestFile = "";
|
||||
const inFailureSection = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Detect test file being processed
|
||||
const fileMatch = line.match(/tests\/[\w/.-]+\.test\.ts:/);
|
||||
if (fileMatch) {
|
||||
currentTestFile = fileMatch[0].replace(":", "");
|
||||
}
|
||||
|
||||
// Detect failure markers
|
||||
if (line.includes("(fail)")) {
|
||||
const failMatch = line.match(/\(fail\)\s+(.+?)\s+\[(\d+\.\d+ms)\]/);
|
||||
if (failMatch) {
|
||||
const testName = failMatch[1];
|
||||
|
||||
// Look backwards for error message
|
||||
let errorMessage = "";
|
||||
let errorLocation = "";
|
||||
|
||||
for (let j = i - 1; j >= Math.max(0, i - 20); j--) {
|
||||
const prevLine = lines[j];
|
||||
|
||||
// Find the error line (starts with "error:")
|
||||
if (prevLine.startsWith("error:")) {
|
||||
errorMessage = prevLine.replace("error:", "").trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Look forward for stack trace location
|
||||
for (let j = i + 1; j < Math.min(lines.length, i + 10); j++) {
|
||||
const nextLine = lines[j];
|
||||
if (nextLine.includes("at ") && nextLine.includes(".ts:")) {
|
||||
errorLocation = nextLine.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
failures.push({
|
||||
testFile: currentTestFile,
|
||||
testName,
|
||||
errorMessage,
|
||||
errorLocation,
|
||||
});
|
||||
|
||||
if (currentTestFile && !failedFiles) {
|
||||
failedFiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate failed files from failures
|
||||
const uniqueFailedFiles = new Set(failures.map((f) => f.testFile));
|
||||
failedFiles = uniqueFailedFiles.size;
|
||||
passedFiles = totalFiles - failedFiles;
|
||||
|
||||
return {
|
||||
totalFiles,
|
||||
passedFiles,
|
||||
failedFiles,
|
||||
totalTests,
|
||||
passedTests,
|
||||
failedTests,
|
||||
failures,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { resolve } from "path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(import.meta.dir, "..", "..", ".env") });
|
||||
|
||||
import chalk from "chalk";
|
||||
import { testGroups } from "./config.js";
|
||||
import { type GroupResult, runTestGroup } from "./groupRunner.js";
|
||||
|
||||
/**
|
||||
* Main entry point for parallel test execution
|
||||
* Runs all test groups in parallel, each with its own dedicated org
|
||||
*/
|
||||
async function main() {
|
||||
// Check for flags
|
||||
const verbose = process.argv.includes("--verbose");
|
||||
const debug = process.argv.includes("--debug");
|
||||
|
||||
console.log(chalk.bold.cyan("\n╔═══════════════════════════════════════════════════════════════════╗"));
|
||||
console.log(chalk.bold.cyan("║ PARALLEL TEST RUNNER ║"));
|
||||
console.log(chalk.bold.cyan("╚═══════════════════════════════════════════════════════════════════╝\n"));
|
||||
|
||||
console.log(chalk.dim(`Running ${testGroups.length} test group(s) in parallel...\n`));
|
||||
|
||||
if (!verbose && !debug) {
|
||||
console.log(chalk.dim(" 💡 Use --verbose to see all output, --debug for single test debugging\n"));
|
||||
}
|
||||
|
||||
// Validate environment
|
||||
if (!process.env.TEST_ORG_SECRET_KEY) {
|
||||
console.error(chalk.red.bold("ERROR: TEST_ORG_SECRET_KEY environment variable is required"));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"\nThis should be the secret key of your platform organization that has access to create/delete orgs.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Run all groups in parallel
|
||||
const results = await Promise.all(
|
||||
testGroups.map((group) => runTestGroup({ group, verbose, debug })),
|
||||
);
|
||||
|
||||
const totalDuration = performance.now() - startTime;
|
||||
|
||||
// Calculate totals
|
||||
const successfulGroups = results.filter((r) => r.success);
|
||||
const failedGroups = results.filter((r) => !r.success);
|
||||
|
||||
let totalTests = 0;
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const result of results) {
|
||||
if (result.testSummary) {
|
||||
totalTests += result.testSummary.totalTests;
|
||||
totalPassed += result.testSummary.passedTests;
|
||||
totalFailed += result.testSummary.failedTests;
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log(chalk.bold.cyan("\n╔═══════════════════════════════════════════════════════════════════╗"));
|
||||
console.log(chalk.bold.cyan("║ SUMMARY ║"));
|
||||
console.log(chalk.bold.cyan("╚═══════════════════════════════════════════════════════════════════╝\n"));
|
||||
|
||||
console.log(chalk.bold(` Total Duration: ${chalk.cyan((totalDuration / 1000).toFixed(2))}s`));
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Test Groups: ${chalk.green(successfulGroups.length)} passed, ${failedGroups.length > 0 ? chalk.red(failedGroups.length) : chalk.dim(failedGroups.length)} failed`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Tests: ${chalk.green(totalPassed)} passed, ${totalFailed > 0 ? chalk.red(totalFailed) : chalk.dim(totalFailed)} failed (${totalTests} total)`,
|
||||
),
|
||||
);
|
||||
|
||||
// Show details for failed groups
|
||||
if (failedGroups.length > 0) {
|
||||
console.log(chalk.red.bold("\n╔═══════════════════════════════════════════════════════════════════╗"));
|
||||
console.log(chalk.red.bold("║ FAILED TESTS ║"));
|
||||
console.log(chalk.red.bold("╚═══════════════════════════════════════════════════════════════════╝"));
|
||||
|
||||
for (const result of failedGroups) {
|
||||
console.log(chalk.red.bold(`\n ✗ ${result.group.slug}`));
|
||||
console.log(chalk.dim(` Duration: ${(result.duration / 1000).toFixed(2)}s`));
|
||||
|
||||
if (result.testSummary && result.testSummary.failures.length > 0) {
|
||||
console.log(chalk.dim(` Failed: ${result.testSummary.failedTests}/${result.testSummary.totalTests} tests\n`));
|
||||
|
||||
for (const failure of result.testSummary.failures) {
|
||||
console.log(chalk.red(` ┌─ ${failure.testFile || "unknown test"}`));
|
||||
console.log(chalk.red(` │ ${failure.testName}`));
|
||||
console.log(chalk.red(` │`));
|
||||
console.log(chalk.yellow(` │ ${failure.errorMessage}`));
|
||||
if (failure.errorLocation) {
|
||||
console.log(chalk.dim(` │ ${failure.errorLocation}`));
|
||||
}
|
||||
console.log(chalk.red(` └─\n`));
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.dim(` ${result.error}\n`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.red.bold("╔═══════════════════════════════════════════════════════════════════╗"));
|
||||
console.log(chalk.red.bold(`║ ${failedGroups.length} GROUP(S) FAILED ║`));
|
||||
console.log(chalk.red.bold("╚═══════════════════════════════════════════════════════════════════╝\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(chalk.green.bold("\n╔═══════════════════════════════════════════════════════════════════╗"));
|
||||
console.log(chalk.green.bold("║ ✓ ALL TESTS PASSED ║"));
|
||||
console.log(chalk.green.bold("╚═══════════════════════════════════════════════════════════════════╝\n"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(chalk.red.bold("\nFatal error:"), error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { resolve } from "path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(import.meta.dir, "..", "..", ".env") });
|
||||
|
||||
import chalk from "chalk";
|
||||
import { testGroups } from "./config.js";
|
||||
import {
|
||||
type GroupProgress,
|
||||
type GroupResult,
|
||||
runTestGroupV2,
|
||||
} from "./groupRunnerV2.js";
|
||||
import {
|
||||
type TestGroupState,
|
||||
createTestRunnerUI,
|
||||
} from "./TestRunnerUI.js";
|
||||
|
||||
/**
|
||||
* Main entry point for parallel test execution with TUI
|
||||
*/
|
||||
async function main() {
|
||||
// Check for flags
|
||||
const verbose = process.argv.includes("--verbose");
|
||||
const debug = process.argv.includes("--debug");
|
||||
|
||||
// Validate environment
|
||||
if (!process.env.TEST_ORG_SECRET_KEY) {
|
||||
console.error(
|
||||
chalk.red.bold(
|
||||
"ERROR: TEST_ORG_SECRET_KEY environment variable is required",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"\nThis should be the secret key of your platform organization that has access to create/delete orgs.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Initialize UI state
|
||||
const initialGroups: TestGroupState[] = testGroups.map((group) => ({
|
||||
slug: group.slug,
|
||||
status: "pending",
|
||||
files: [],
|
||||
duration: undefined,
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
const { updateGroup, cleanup } = createTestRunnerUI(initialGroups);
|
||||
|
||||
// Run all groups in parallel with progress updates
|
||||
const results = await Promise.all(
|
||||
testGroups.map((group) =>
|
||||
runTestGroupV2({
|
||||
group,
|
||||
onProgress: (progress: GroupProgress) => {
|
||||
updateGroup(group.slug, {
|
||||
status: progress.status,
|
||||
files: progress.files.map((f) => ({
|
||||
name: f.name,
|
||||
status: f.status,
|
||||
duration: f.duration,
|
||||
error: f.error,
|
||||
})),
|
||||
duration: progress.duration,
|
||||
error: progress.error,
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const totalDuration = performance.now() - startTime;
|
||||
|
||||
// Cleanup UI
|
||||
cleanup();
|
||||
|
||||
// Calculate totals
|
||||
const successfulGroups = results.filter((r) => r.success);
|
||||
const failedGroups = results.filter((r) => !r.success);
|
||||
|
||||
let totalTests = 0;
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const result of results) {
|
||||
totalTests += result.files.length;
|
||||
totalPassed += result.files.filter((f) => f.status === "passed").length;
|
||||
totalFailed += result.files.filter((f) => f.status === "failed").length;
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log(
|
||||
chalk.bold.cyan(
|
||||
"\n╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(
|
||||
"║ SUMMARY ║",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝\n",
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Total Duration: ${chalk.cyan((totalDuration / 1000).toFixed(2))}s`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Test Groups: ${chalk.green(successfulGroups.length)} passed, ${failedGroups.length > 0 ? chalk.red(failedGroups.length) : chalk.dim(failedGroups.length)} failed`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Tests: ${chalk.green(totalPassed)} passed, ${totalFailed > 0 ? chalk.red(totalFailed) : chalk.dim(totalFailed)} failed (${totalTests} total)`,
|
||||
),
|
||||
);
|
||||
|
||||
// Show details for failed groups
|
||||
if (failedGroups.length > 0) {
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"\n╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"║ FAILED TESTS ║",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝",
|
||||
),
|
||||
);
|
||||
|
||||
for (const result of failedGroups) {
|
||||
console.log(chalk.red.bold(`\n ✗ ${result.group.slug}`));
|
||||
console.log(
|
||||
chalk.dim(` Duration: ${(result.duration / 1000).toFixed(2)}s`),
|
||||
);
|
||||
|
||||
const failedFiles = result.files.filter((f) => f.status === "failed");
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` Failed: ${failedFiles.length}/${result.files.length} tests\n`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const file of failedFiles) {
|
||||
console.log(chalk.red(` ┌─ ${file.name}`));
|
||||
if (file.error) {
|
||||
// Show first line of error
|
||||
const errorLine = file.error.split("\n")[0];
|
||||
console.log(chalk.yellow(` │ ${errorLine.slice(0, 80)}`));
|
||||
}
|
||||
console.log(chalk.red(" └─\n"));
|
||||
}
|
||||
} else if (result.error) {
|
||||
console.log(chalk.dim(` ${result.error}\n`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`║ ${failedGroups.length} GROUP(S) FAILED ║`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝\n",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
"\n╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
"║ ✓ ALL TESTS PASSED ║",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝\n",
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(chalk.red.bold("\nFatal error:"), error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,504 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { resolve } from "path";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
// Load environment variables from server/.env
|
||||
dotenv.config({ path: resolve(import.meta.dir, "..", "..", ".env") });
|
||||
|
||||
import chalk from "chalk";
|
||||
import { testGroups } from "./config.js";
|
||||
import {
|
||||
type GroupProgress,
|
||||
type GroupResult,
|
||||
runTestGroupV2,
|
||||
} from "./groupRunnerV2.js";
|
||||
|
||||
type TestFileStatus = "pending" | "running" | "passed" | "failed";
|
||||
|
||||
type TestFile = {
|
||||
name: string;
|
||||
status: TestFileStatus;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GroupStatus = "pending" | "setup" | "running" | "passed" | "failed";
|
||||
|
||||
type TestGroupState = {
|
||||
slug: string;
|
||||
status: GroupStatus;
|
||||
files: TestFile[];
|
||||
duration?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
class SimpleTUI {
|
||||
private groups: TestGroupState[] = [];
|
||||
private startLine = 0;
|
||||
private renderInterval?: Timer;
|
||||
private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
private spinnerIndex = 0;
|
||||
private lastRenderedLineCount = 0;
|
||||
|
||||
constructor(groups: TestGroupState[]) {
|
||||
this.groups = groups;
|
||||
}
|
||||
|
||||
start() {
|
||||
// Hide cursor
|
||||
process.stdout.write("\x1B[?25l");
|
||||
|
||||
// Reserve space for rendering
|
||||
const lines = this.calculateLines();
|
||||
for (let i = 0; i < lines; i++) {
|
||||
console.log();
|
||||
}
|
||||
// Move cursor back up
|
||||
process.stdout.write(`\x1B[${lines}A`);
|
||||
this.startLine = 1;
|
||||
|
||||
// Start render loop
|
||||
this.renderInterval = setInterval(() => this.render(), 100);
|
||||
}
|
||||
|
||||
updateGroup(slug: string, update: Partial<TestGroupState>) {
|
||||
const idx = this.groups.findIndex((g) => g.slug === slug);
|
||||
if (idx !== -1) {
|
||||
this.groups[idx] = { ...this.groups[idx], ...update };
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.renderInterval) {
|
||||
clearInterval(this.renderInterval);
|
||||
}
|
||||
// Do one final render to show completed state
|
||||
this.render();
|
||||
// Show cursor
|
||||
process.stdout.write("\x1B[?25h");
|
||||
// Move past output using ACTUAL lines rendered, not max possible
|
||||
process.stdout.write(`\x1B[${this.lastRenderedLineCount}B`);
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
private calculateLines(): number {
|
||||
// Fixed layout:
|
||||
// 2 lines for header
|
||||
// 7 lines per group (1 for group header, 6 for test files with stack traces)
|
||||
// 6 = 2 files * 3 lines each (file + error + stack)
|
||||
return 2 + (this.groups.length * 7);
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
||||
const spinner = this.spinnerFrames[this.spinnerIndex];
|
||||
|
||||
let lineNum = this.startLine;
|
||||
|
||||
// Move to start and clear line
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
// Header (no newline - we'll move cursor manually)
|
||||
process.stdout.write(chalk.bold.cyan("PARALLEL TEST RUNNER"));
|
||||
lineNum++;
|
||||
|
||||
// Stats
|
||||
const completed = this.groups.filter(
|
||||
(g) => g.status === "passed" || g.status === "failed",
|
||||
).length;
|
||||
const passed = this.groups.filter((g) => g.status === "passed").length;
|
||||
const failed = this.groups.filter((g) => g.status === "failed").length;
|
||||
|
||||
let totalTests = 0;
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
for (const g of this.groups) {
|
||||
totalTests += g.files.length;
|
||||
passedTests += g.files.filter((f) => f.status === "passed").length;
|
||||
failedTests += g.files.filter((f) => f.status === "failed").length;
|
||||
}
|
||||
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
process.stdout.write(
|
||||
`Groups: ${completed}/${this.groups.length} | ` +
|
||||
`${chalk.green(`✓ ${passed}`)} | ` +
|
||||
`${failed > 0 ? chalk.red(`✗ ${failed}`) : chalk.dim(`✗ ${failed}`)} | ` +
|
||||
`Tests: ${passedTests + failedTests}/${totalTests} | ` +
|
||||
`${chalk.green(`✓ ${passedTests}`)} | ` +
|
||||
`${failedTests > 0 ? chalk.red(`✗ ${failedTests}`) : chalk.dim(`✗ ${failedTests}`)}\n\n`,
|
||||
);
|
||||
lineNum += 2;
|
||||
|
||||
// Groups
|
||||
for (const group of this.groups) {
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
|
||||
let icon = "";
|
||||
let statusText = "";
|
||||
|
||||
switch (group.status) {
|
||||
case "pending":
|
||||
icon = chalk.gray("…");
|
||||
statusText = "Pending";
|
||||
break;
|
||||
case "setup":
|
||||
icon = chalk.cyan(spinner);
|
||||
statusText = "Setting up";
|
||||
break;
|
||||
case "running":
|
||||
icon = chalk.cyan(spinner);
|
||||
statusText = "Running";
|
||||
break;
|
||||
case "passed":
|
||||
icon = chalk.green("✓");
|
||||
statusText = "Passed";
|
||||
break;
|
||||
case "failed":
|
||||
icon = chalk.red("✗");
|
||||
statusText = "Failed";
|
||||
break;
|
||||
}
|
||||
|
||||
const completedCount =
|
||||
group.files.filter(
|
||||
(f) => f.status === "passed" || f.status === "failed",
|
||||
).length;
|
||||
const totalCount = group.files.length;
|
||||
const failedCount = group.files.filter((f) => f.status === "failed").length;
|
||||
|
||||
let groupLine = `${icon} ${chalk.bold(group.slug)} - ${statusText}`;
|
||||
if (group.duration) {
|
||||
groupLine += chalk.dim(` (${(group.duration / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
|
||||
// Show progress for running/passed/failed groups
|
||||
if (group.status !== "pending" && totalCount > 0) {
|
||||
groupLine += chalk.dim(` | ${completedCount}/${totalCount} completed`);
|
||||
if (failedCount > 0) {
|
||||
groupLine += chalk.red(` [${failedCount} failed]`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(groupLine);
|
||||
lineNum++;
|
||||
|
||||
// Show failed files only
|
||||
if (group.status !== "pending" && failedCount > 0) {
|
||||
const failedFiles = group.files.filter((f) => f.status === "failed");
|
||||
for (const file of failedFiles.slice(0, 2)) {
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
let fileLine = ` ${chalk.red("✗")} ${file.name}`;
|
||||
if (file.error) {
|
||||
// Show first meaningful line of error (up to 80 chars)
|
||||
const errorLines = file.error.split("\n").filter((l) => l.trim());
|
||||
const shortError = errorLines[0]?.slice(0, 80) || "Test failed";
|
||||
fileLine += chalk.yellow(` → ${shortError}`);
|
||||
}
|
||||
process.stdout.write(fileLine);
|
||||
lineNum++;
|
||||
|
||||
// Show stack trace location if available
|
||||
if (file.error) {
|
||||
const errorLines = file.error.split("\n");
|
||||
const stackLine = errorLines.find((l) =>
|
||||
l.trim().startsWith("at "),
|
||||
);
|
||||
if (stackLine) {
|
||||
// Extract file path and line number from stack trace
|
||||
// Format: "at functionName (/path/to/file.ts:123:45)"
|
||||
const match = stackLine.match(/\((.+?):(\d+):(\d+)\)/);
|
||||
if (match) {
|
||||
const [, filePath, line] = match;
|
||||
const fileName = filePath.split("/").pop();
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
process.stdout.write(chalk.dim(` ${fileName}:${line}`));
|
||||
lineNum++;
|
||||
} else {
|
||||
// Clear the line if no stack found
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
lineNum++;
|
||||
}
|
||||
} else {
|
||||
// Clear the line if no stack found
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
lineNum++;
|
||||
}
|
||||
} else {
|
||||
// Clear the line if no error
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
lineNum++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear the file display lines (now 3 lines per file, 2 files max = 6 lines)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
lineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear remaining lines
|
||||
const maxLines = this.calculateLines();
|
||||
while (lineNum < this.startLine + maxLines) {
|
||||
process.stdout.write(`\x1B[${lineNum};0H\x1B[2K`);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Track how many lines we actually used (minus startLine offset)
|
||||
this.lastRenderedLineCount = lineNum - this.startLine;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for parallel test execution with TUI
|
||||
*/
|
||||
async function main() {
|
||||
// Validate environment
|
||||
if (!process.env.TEST_ORG_SECRET_KEY) {
|
||||
console.error(
|
||||
chalk.red.bold(
|
||||
"ERROR: TEST_ORG_SECRET_KEY environment variable is required",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.dim(
|
||||
"\nThis should be the secret key of your platform organization that has access to create/delete orgs.",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse CLI arguments for targeted group execution
|
||||
const args = process.argv.slice(2);
|
||||
const targetedSlugs = args.filter((arg) => !arg.startsWith("--"));
|
||||
const forceSetup = args.includes("--setup");
|
||||
|
||||
// Filter test groups based on CLI args
|
||||
let groupsToRun = testGroups;
|
||||
// When targeting specific groups, skip setup by default unless --setup is passed
|
||||
const skipSetup = targetedSlugs.length > 0 && !forceSetup;
|
||||
|
||||
if (targetedSlugs.length > 0) {
|
||||
groupsToRun = testGroups.filter((g) => targetedSlugs.includes(g.slug));
|
||||
if (groupsToRun.length === 0) {
|
||||
console.error(
|
||||
chalk.red.bold(
|
||||
`\nERROR: No matching test groups found for: ${targetedSlugs.join(", ")}`,
|
||||
),
|
||||
);
|
||||
console.log(chalk.dim("\nAvailable groups:"));
|
||||
for (const group of testGroups) {
|
||||
console.log(chalk.dim(` - ${group.slug}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`\nRunning targeted groups: ${groupsToRun.map((g) => g.slug).join(", ")}`,
|
||||
),
|
||||
);
|
||||
if (skipSetup) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
"Skipping org setup (using existing test orgs). Use --setup to force recreate.\n",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(chalk.yellow("Recreating test orgs from scratch...\n"));
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Initialize UI state
|
||||
const initialGroups: TestGroupState[] = groupsToRun.map((group) => ({
|
||||
slug: group.slug,
|
||||
status: "pending",
|
||||
files: [],
|
||||
duration: undefined,
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
const tui = new SimpleTUI(initialGroups);
|
||||
tui.start();
|
||||
|
||||
// Run all groups in parallel with progress updates
|
||||
const results = await Promise.all(
|
||||
groupsToRun.map((group) =>
|
||||
runTestGroupV2({
|
||||
group,
|
||||
skipSetup,
|
||||
onProgress: (progress: GroupProgress) => {
|
||||
tui.updateGroup(group.slug, {
|
||||
status: progress.status,
|
||||
files: progress.files.map((f) => ({
|
||||
name: f.name,
|
||||
status: f.status,
|
||||
duration: f.duration,
|
||||
error: f.error,
|
||||
output: f.output,
|
||||
})),
|
||||
duration: progress.duration,
|
||||
error: progress.error,
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const totalDuration = performance.now() - startTime;
|
||||
|
||||
// Stop TUI
|
||||
tui.stop();
|
||||
|
||||
// Calculate totals
|
||||
const successfulGroups = results.filter((r) => r.success);
|
||||
const failedGroups = results.filter((r) => !r.success);
|
||||
|
||||
let totalTests = 0;
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const result of results) {
|
||||
totalTests += result.files.length;
|
||||
totalPassed += result.files.filter((f) => f.status === "passed").length;
|
||||
totalFailed += result.files.filter((f) => f.status === "failed").length;
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log(
|
||||
chalk.bold.cyan(
|
||||
"\n╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(
|
||||
"║ SUMMARY ║",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold.cyan(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝\n",
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Total Duration: ${chalk.cyan((totalDuration / 1000).toFixed(2))}s`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Test Groups: ${chalk.green(successfulGroups.length)} passed, ${failedGroups.length > 0 ? chalk.red(failedGroups.length) : chalk.dim(failedGroups.length)} failed`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.bold(
|
||||
` Tests: ${chalk.green(totalPassed)} passed, ${totalFailed > 0 ? chalk.red(totalFailed) : chalk.dim(totalFailed)} failed (${totalTests} total)`,
|
||||
),
|
||||
);
|
||||
|
||||
// Show details for failed groups
|
||||
if (failedGroups.length > 0) {
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"\n╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"║ FAILED TESTS ║",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝",
|
||||
),
|
||||
);
|
||||
|
||||
for (const result of failedGroups) {
|
||||
console.log(chalk.red.bold(`\n ✗ ${result.group.slug}`));
|
||||
console.log(
|
||||
chalk.dim(` Duration: ${(result.duration / 1000).toFixed(2)}s`),
|
||||
);
|
||||
|
||||
const failedFiles = result.files.filter((f) => f.status === "failed");
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` Failed: ${failedFiles.length}/${result.files.length} tests\n`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const file of failedFiles) {
|
||||
console.log(chalk.red(` ┌─ ${file.name}`));
|
||||
if (file.error) {
|
||||
// Show all error lines with proper indentation
|
||||
const errorLines = file.error.split("\n");
|
||||
for (const line of errorLines) {
|
||||
if (line.trim()) {
|
||||
console.log(chalk.yellow(` │ ${line}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show full test output if available
|
||||
if (file.output) {
|
||||
console.log(chalk.red(" │"));
|
||||
console.log(chalk.cyan(" │ === Full Test Output ==="));
|
||||
const outputLines = file.output.split("\n");
|
||||
for (const line of outputLines) {
|
||||
if (line.trim()) {
|
||||
console.log(chalk.dim(` │ ${line}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(chalk.red(" └─\n"));
|
||||
}
|
||||
} else if (result.error) {
|
||||
console.log(chalk.dim(` ${result.error}\n`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`║ ${failedGroups.length} GROUP(S) FAILED ║`,
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝\n",
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
"\n╔═══════════════════════════════════════════════════════════════════╗",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
"║ ✓ ALL TESTS PASSED ║",
|
||||
),
|
||||
);
|
||||
console.log(
|
||||
chalk.green.bold(
|
||||
"╚═══════════════════════════════════════════════════════════════════╝\n",
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(chalk.red.bold("\nFatal error:"), error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,734 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { spawn } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { readdir } from "fs/promises";
|
||||
import pLimit from "p-limit";
|
||||
import { basename, resolve } from "path";
|
||||
|
||||
interface TestResult {
|
||||
file: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
output: string;
|
||||
duration: number;
|
||||
error?: string;
|
||||
lastTestName?: string;
|
||||
}
|
||||
|
||||
class TestRunner {
|
||||
private results: Map<string, TestResult> = new Map();
|
||||
private testFiles: string[] = [];
|
||||
private maxParallel: number = 6;
|
||||
private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
private spinnerIndex = 0;
|
||||
private renderInterval?: Timer;
|
||||
private startLine = 0;
|
||||
private compactMode: boolean = false;
|
||||
private silentMode: boolean = false;
|
||||
private lastRenderedLines = 0;
|
||||
|
||||
constructor({
|
||||
maxParallel,
|
||||
compactMode,
|
||||
silentMode,
|
||||
}: {
|
||||
maxParallel?: number;
|
||||
compactMode?: boolean;
|
||||
silentMode?: boolean;
|
||||
} = {}) {
|
||||
if (maxParallel) this.maxParallel = maxParallel;
|
||||
if (compactMode) this.compactMode = compactMode;
|
||||
if (silentMode) this.silentMode = silentMode;
|
||||
}
|
||||
|
||||
async collectTestFiles(paths: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const resolvedPath = resolve(process.cwd(), path);
|
||||
|
||||
// Check if it's a specific test file
|
||||
if (path.endsWith(".test.ts")) {
|
||||
testFiles.push(resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise treat it as a directory
|
||||
try {
|
||||
const files = await readdir(resolvedPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedPath, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error reading directory ${path}:`), error);
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
private extractLastTest(output: string): string | null {
|
||||
const lines = output.split("\n");
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
const testMatch = line.match(/^[✓✗]\s+(.+?)(?:\s+\[\d+\.\d+m?s\])?$/);
|
||||
if (testMatch) {
|
||||
return testMatch[1];
|
||||
}
|
||||
|
||||
const bunTestMatch = line.match(/test\s+"([^"]+)"/);
|
||||
if (bunTestMatch) {
|
||||
return bunTestMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private truncateTestName(name: string, maxLength: number = 50): string {
|
||||
if (name.length <= maxLength) return name;
|
||||
return name.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
private hideCursor() {
|
||||
process.stdout.write("\x1B[?25l");
|
||||
}
|
||||
|
||||
private showCursor() {
|
||||
process.stdout.write("\x1B[?25h");
|
||||
}
|
||||
|
||||
private moveCursor(line: number, col: number = 0) {
|
||||
process.stdout.write(`\x1B[${line};${col}H`);
|
||||
}
|
||||
|
||||
private clearLine() {
|
||||
process.stdout.write("\x1B[2K");
|
||||
}
|
||||
|
||||
private getSpacesNeeded(): number {
|
||||
if (!this.compactMode) {
|
||||
return this.testFiles.length + 3;
|
||||
}
|
||||
|
||||
// Compact mode: dynamically calculate based on content
|
||||
// Base: 10 lines for headers, stats, spacing
|
||||
// + 3 lines for recently completed
|
||||
// + failed tests * 4 (name + 2 error lines + spacing)
|
||||
// + running tests
|
||||
const failedCount = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "failed",
|
||||
).length;
|
||||
const runningCount = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "running",
|
||||
).length;
|
||||
|
||||
return Math.min(
|
||||
10 + 3 + failedCount * 4 + Math.min(runningCount, 6),
|
||||
30, // Cap at 30 lines
|
||||
);
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
|
||||
const spinner = this.spinnerFrames[this.spinnerIndex];
|
||||
|
||||
if (this.compactMode) {
|
||||
// Compact mode: show completed, failed, running tests, then stats
|
||||
let lineNum = this.startLine;
|
||||
|
||||
const completed = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "passed" || r.status === "failed",
|
||||
).length;
|
||||
const passed = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "passed",
|
||||
).length;
|
||||
const failed = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "failed",
|
||||
).length;
|
||||
|
||||
// Show recently completed tests (last 3)
|
||||
const passedTests = Array.from(this.results.entries())
|
||||
.filter(([_, result]) => result.status === "passed")
|
||||
.slice(-3); // Get last 3 completed
|
||||
|
||||
if (passedTests.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.green.bold(`Recently Completed (${passed} total):\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file] of passedTests) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
const testName = basename(file);
|
||||
process.stdout.write(
|
||||
` ${chalk.green("✓")} ${chalk.dim(testName)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Add blank line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show failed tests
|
||||
const failedTests = Array.from(this.results.entries()).filter(
|
||||
([_, result]) => result.status === "failed",
|
||||
);
|
||||
|
||||
if (failedTests.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.red.bold(`Failed (${failedTests.length}):\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file, result] of failedTests) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
const testName = basename(file);
|
||||
process.stdout.write(` ${chalk.red("✗")} ${testName}\n`);
|
||||
lineNum++;
|
||||
|
||||
// Show first 2 lines of error
|
||||
if (result.error) {
|
||||
const errorLines = result.error.split("\n").filter((l) => l.trim());
|
||||
const displayLines = errorLines.slice(0, 2);
|
||||
for (const line of displayLines) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
const truncated =
|
||||
line.length > 80 ? line.substring(0, 77) + "..." : line;
|
||||
process.stdout.write(` ${chalk.dim(truncated)}\n`);
|
||||
lineNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add blank line after failed tests
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show currently running tests
|
||||
const runningTests = Array.from(this.results.entries()).filter(
|
||||
([_, result]) => result.status === "running",
|
||||
);
|
||||
|
||||
if (runningTests.length > 0) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
chalk.cyan.bold(`Running (${runningTests.length}):\n`),
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
for (const [file, result] of runningTests) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
const testName = basename(file);
|
||||
let displayText = ` ${chalk.cyan(spinner)} ${testName}`;
|
||||
if (result.lastTestName) {
|
||||
const truncated = this.truncateTestName(result.lastTestName, 40);
|
||||
displayText += chalk.dim(` › ${truncated}`);
|
||||
}
|
||||
process.stdout.write(`${displayText}\n`);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Add blank line after running tests
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write("\n");
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Show stats line
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
process.stdout.write(
|
||||
`${chalk.cyan(spinner)} Progress: ${chalk.bold(`${completed}/${this.testFiles.length}`)} | ` +
|
||||
`${chalk.green(`✓ ${passed}`)} | ` +
|
||||
`${failed > 0 ? chalk.red(`✗ ${failed}`) : chalk.dim(`✗ ${failed}`)}\n`,
|
||||
);
|
||||
lineNum++;
|
||||
|
||||
// Clear any remaining lines from previous renders
|
||||
const maxLines = this.getSpacesNeeded();
|
||||
while (lineNum < maxLines) {
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Track how many lines we actually used
|
||||
this.lastRenderedLines = lineNum - this.startLine;
|
||||
} else {
|
||||
// Full mode: show all tests
|
||||
let lineNum = this.startLine;
|
||||
|
||||
for (const file of this.testFiles) {
|
||||
const result = this.results.get(file);
|
||||
if (!result) continue;
|
||||
|
||||
this.moveCursor(lineNum, 0);
|
||||
this.clearLine();
|
||||
|
||||
const testName = basename(file);
|
||||
let statusIcon: string;
|
||||
let displayText: string;
|
||||
|
||||
switch (result.status) {
|
||||
case "pending":
|
||||
statusIcon = chalk.dim("⋯");
|
||||
displayText = chalk.dim(testName);
|
||||
break;
|
||||
case "running":
|
||||
statusIcon = chalk.cyan(spinner);
|
||||
displayText = testName;
|
||||
if (result.lastTestName) {
|
||||
const truncated = this.truncateTestName(result.lastTestName);
|
||||
displayText += chalk.dim(` › ${truncated}`);
|
||||
}
|
||||
break;
|
||||
case "passed":
|
||||
statusIcon = chalk.green("✓");
|
||||
displayText = chalk.dim(testName);
|
||||
break;
|
||||
case "failed":
|
||||
statusIcon = chalk.red("✗");
|
||||
displayText = testName;
|
||||
break;
|
||||
}
|
||||
|
||||
process.stdout.write(`${statusIcon} ${displayText}\n`);
|
||||
lineNum++;
|
||||
}
|
||||
|
||||
// Summary line
|
||||
const completed = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "passed" || r.status === "failed",
|
||||
).length;
|
||||
const failed = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "failed",
|
||||
).length;
|
||||
const running = Array.from(this.results.values()).filter(
|
||||
(r) => r.status === "running",
|
||||
).length;
|
||||
|
||||
this.moveCursor(lineNum + 1, 0);
|
||||
this.clearLine();
|
||||
if (running > 0) {
|
||||
process.stdout.write(
|
||||
chalk.dim(
|
||||
`Running: ${running} | Completed: ${completed}/${this.testFiles.length} | Failed: ${failed}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Track how many lines we actually used
|
||||
this.lastRenderedLines = lineNum + 2 - this.startLine;
|
||||
}
|
||||
}
|
||||
|
||||
async runTest(file: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
|
||||
// Initialize as running
|
||||
const result: TestResult = {
|
||||
file,
|
||||
status: "running",
|
||||
output: "",
|
||||
duration: 0,
|
||||
};
|
||||
this.results.set(file, result);
|
||||
|
||||
try {
|
||||
const proc = spawn(["bun", "test", "--timeout", "0", file], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
let output = "";
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (proc.stdout) {
|
||||
for await (const chunk of proc.stdout) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
|
||||
// Only stream output if not in silent mode
|
||||
if (!this.silentMode) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
|
||||
// Update last test name
|
||||
const lastTest = this.extractLastTest(output);
|
||||
if (lastTest) {
|
||||
result.lastTestName = lastTest;
|
||||
result.output = output;
|
||||
this.results.set(file, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
for await (const chunk of proc.stderr) {
|
||||
const text = decoder.decode(chunk);
|
||||
output += text;
|
||||
|
||||
// Only stream errors if not in silent mode
|
||||
if (!this.silentMode) {
|
||||
process.stderr.write(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await proc.exited;
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
const fileName = file.split("/").pop() || file;
|
||||
|
||||
if (proc.exitCode === 0) {
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: "passed",
|
||||
output,
|
||||
duration,
|
||||
});
|
||||
// In silent mode, immediately output completion for real-time tracking
|
||||
if (this.silentMode) {
|
||||
console.log(`✓ ${fileName}`);
|
||||
}
|
||||
} else {
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: "failed",
|
||||
output,
|
||||
duration,
|
||||
error: this.extractError(output),
|
||||
});
|
||||
// In silent mode, immediately output failure for real-time tracking
|
||||
if (this.silentMode) {
|
||||
console.log(`✗ ${fileName}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
const fileName = file.split("/").pop() || file;
|
||||
this.results.set(file, {
|
||||
...result,
|
||||
status: "failed",
|
||||
output: "",
|
||||
duration,
|
||||
error: String(error),
|
||||
});
|
||||
if (this.silentMode) {
|
||||
console.log(`✗ ${fileName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractError(output: string): string {
|
||||
const lines = output.split("\n");
|
||||
const errorLines: string[] = [];
|
||||
let inError = false;
|
||||
let capturedLines = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (
|
||||
line.includes("error:") ||
|
||||
line.includes("Error:") ||
|
||||
line.includes("Expected:") ||
|
||||
line.includes("Received:") ||
|
||||
line.includes("AssertionError")
|
||||
) {
|
||||
inError = true;
|
||||
}
|
||||
|
||||
if (inError) {
|
||||
errorLines.push(line);
|
||||
capturedLines++;
|
||||
|
||||
if (capturedLines > 20) break;
|
||||
}
|
||||
|
||||
if (line.match(/^[\s]*✗/)) {
|
||||
errorLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return errorLines.length > 0 ? errorLines.join("\n").trim() : output;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.renderInterval) {
|
||||
clearInterval(this.renderInterval);
|
||||
}
|
||||
this.showCursor();
|
||||
}
|
||||
|
||||
private handleInterrupt() {
|
||||
this.cleanup();
|
||||
|
||||
// Move cursor past all output (use actual rendered lines in compact mode)
|
||||
const linesToMove = this.compactMode
|
||||
? this.lastRenderedLines
|
||||
: this.getSpacesNeeded();
|
||||
process.stdout.write(`\x1B[${linesToMove}B`);
|
||||
console.log("\n");
|
||||
|
||||
console.log(chalk.yellow.bold("\n⚠ Tests interrupted by user (Ctrl+C)\n"));
|
||||
|
||||
// Print summary of what we have so far
|
||||
const failedTests = Array.from(this.results.values()).filter(
|
||||
(t) => t.status === "failed",
|
||||
);
|
||||
const completedTests = Array.from(this.results.values()).filter(
|
||||
(t) => t.status === "passed" || t.status === "failed",
|
||||
);
|
||||
|
||||
console.log(
|
||||
chalk.dim(
|
||||
`Completed: ${completedTests.length}/${this.testFiles.length} tests before interruption`,
|
||||
),
|
||||
);
|
||||
|
||||
if (failedTests.length > 0) {
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n${"═".repeat(70)}\n FAILED TESTS (${failedTests.length})\n${"═".repeat(70)}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const test of failedTests) {
|
||||
const testName = basename(test.file);
|
||||
console.log(chalk.red.bold(`\n✗ ${testName}`));
|
||||
console.log(chalk.dim("─".repeat(70)));
|
||||
|
||||
if (test.error) {
|
||||
const errorLines = test.error.split("\n");
|
||||
for (const line of errorLines) {
|
||||
if (line.trim()) {
|
||||
if (line.includes("Expected:") || line.includes("Received:")) {
|
||||
console.log(chalk.yellow(line));
|
||||
} else if (line.includes("✗")) {
|
||||
console.log(chalk.red(line));
|
||||
} else {
|
||||
console.log(chalk.dim(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n${"═".repeat(70)}\n ${failedTests.length} test file(s) failed\n${"═".repeat(70)}\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(130); // Standard exit code for SIGINT
|
||||
}
|
||||
|
||||
async run(directories: string[]): Promise<void> {
|
||||
this.testFiles = await this.collectTestFiles(directories);
|
||||
|
||||
if (this.testFiles.length === 0) {
|
||||
if (!this.silentMode) {
|
||||
console.log(
|
||||
chalk.yellow("No test files found in specified directories"),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.silentMode) {
|
||||
console.log(
|
||||
chalk.bold(`\nRunning ${this.testFiles.length} test file(s)...\n`),
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize all tests as pending
|
||||
for (const file of this.testFiles) {
|
||||
this.results.set(file, {
|
||||
file,
|
||||
status: "pending",
|
||||
output: "",
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Setup SIGINT handler
|
||||
const sigintHandler = () => this.handleInterrupt();
|
||||
process.on("SIGINT", sigintHandler);
|
||||
|
||||
// Only setup UI if not in silent mode
|
||||
if (!this.silentMode) {
|
||||
// Hide cursor and create space for all tests
|
||||
this.hideCursor();
|
||||
this.startLine = 1; // Start from line 1
|
||||
|
||||
// Create space - less space needed in compact mode
|
||||
const spacesNeeded = this.getSpacesNeeded();
|
||||
this.lastRenderedLines = spacesNeeded; // Initialize to full space
|
||||
for (let i = 0; i < spacesNeeded; i++) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Move cursor back up to start rendering
|
||||
process.stdout.write(`\x1B[${spacesNeeded}A`);
|
||||
|
||||
// Start rendering loop
|
||||
this.renderInterval = setInterval(() => this.render(), 100);
|
||||
}
|
||||
|
||||
// Run tests with concurrency limit
|
||||
const limit = pLimit(this.maxParallel);
|
||||
const promises = this.testFiles.map((file) =>
|
||||
limit(() => this.runTest(file)),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// Remove SIGINT handler
|
||||
process.off("SIGINT", sigintHandler);
|
||||
|
||||
if (!this.silentMode) {
|
||||
// Final render
|
||||
this.cleanup();
|
||||
this.render();
|
||||
|
||||
// Move cursor past all output (use actual rendered lines in compact mode)
|
||||
const linesToMove = this.compactMode
|
||||
? this.lastRenderedLines
|
||||
: this.getSpacesNeeded();
|
||||
process.stdout.write(`\x1B[${linesToMove}B`);
|
||||
console.log("\n");
|
||||
|
||||
// Print summary
|
||||
this.printSummary();
|
||||
}
|
||||
// Silent mode: results already output as tests complete, no need to output again
|
||||
}
|
||||
|
||||
getResults(): Map<string, TestResult> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
private printSummary() {
|
||||
const failedTests = Array.from(this.results.values()).filter(
|
||||
(t) => t.status === "failed",
|
||||
);
|
||||
|
||||
if (failedTests.length === 0) {
|
||||
console.log(
|
||||
chalk.green.bold(`✓ All ${this.testFiles.length} test file(s) passed!`),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n${"═".repeat(70)}\n FAILED TESTS (${failedTests.length}/${this.testFiles.length})\n${"═".repeat(70)}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const test of failedTests) {
|
||||
const testName = basename(test.file);
|
||||
console.log(chalk.red.bold(`\n✗ ${testName}`));
|
||||
console.log(chalk.dim("─".repeat(70)));
|
||||
|
||||
if (test.error) {
|
||||
const errorLines = test.error.split("\n");
|
||||
for (const line of errorLines) {
|
||||
if (line.trim()) {
|
||||
if (line.includes("Expected:") || line.includes("Received:")) {
|
||||
console.log(chalk.yellow(line));
|
||||
} else if (line.includes("✗")) {
|
||||
console.log(chalk.red(line));
|
||||
} else {
|
||||
console.log(chalk.dim(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red.bold(
|
||||
`\n${"═".repeat(70)}\n ${failedTests.length} test file(s) failed\n${"═".repeat(70)}\n`,
|
||||
),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse CLI arguments
|
||||
const args = process.argv.slice(2);
|
||||
const directories: string[] = [];
|
||||
let maxParallel = 6;
|
||||
let compactMode = false;
|
||||
let silentMode = false;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith("--max=")) {
|
||||
maxParallel = Number.parseInt(arg.split("=")[1], 10);
|
||||
} else if (arg === "--compact") {
|
||||
compactMode = true;
|
||||
} else if (arg === "--silent") {
|
||||
silentMode = true;
|
||||
} else if (arg.startsWith("-")) {
|
||||
console.error(chalk.red(`Unknown option: ${arg}`));
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTests.ts <dir1> [dir2] [...] [--max=N] [--compact] [--silent]",
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
directories.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (directories.length === 0) {
|
||||
console.error(chalk.red("Error: No test directories specified"));
|
||||
console.log(
|
||||
"Usage: bun scripts/testScripts/runTests.ts <dir1> [dir2] [...] [--max=N] [--compact]",
|
||||
);
|
||||
console.log("\nOptions:");
|
||||
console.log(" --max=N Set maximum parallel test files (default: 6)");
|
||||
console.log(
|
||||
" --compact Use compact mode (only show summary and failures)",
|
||||
);
|
||||
console.log("\nExamples:");
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTests.ts server/tests/attach/upgrade",
|
||||
);
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTests.ts server/tests/attach/upgrade server/tests/attach/downgrade",
|
||||
);
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --max=10",
|
||||
);
|
||||
console.log(
|
||||
" bun scripts/testScripts/runTests.ts server/tests/attach/upgrade --compact",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
const runner = new TestRunner({ maxParallel, compactMode, silentMode });
|
||||
await runner.run(directories);
|
||||
@@ -1,207 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { readdir } from "fs/promises";
|
||||
import pLimit from "p-limit";
|
||||
import { resolve } from "path";
|
||||
|
||||
interface TestResult {
|
||||
file: string;
|
||||
status: "pending" | "running" | "passed" | "failed";
|
||||
duration: number;
|
||||
error?: string;
|
||||
output?: string; // Full test output for failed tests
|
||||
}
|
||||
|
||||
interface TestProgress {
|
||||
onTestStart?: (file: string) => void;
|
||||
onTestComplete?: (file: string, result: TestResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect test files from paths
|
||||
*/
|
||||
async function collectTestFiles(paths: string[]): Promise<string[]> {
|
||||
const testFiles: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const resolvedPath = resolve(process.cwd(), path);
|
||||
|
||||
if (path.endsWith(".test.ts")) {
|
||||
testFiles.push(resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await readdir(resolvedPath);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".test.ts")) {
|
||||
testFiles.push(resolve(resolvedPath, file));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
return testFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test file using Bun Shell
|
||||
*/
|
||||
async function runTestFile(
|
||||
file: string,
|
||||
progress?: TestProgress,
|
||||
): Promise<TestResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
progress?.onTestStart?.(file);
|
||||
|
||||
try {
|
||||
// Use Bun Shell to run the test with streaming output
|
||||
const result = await $`bun test --timeout 0 ${file}`.quiet().nothrow();
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
const testResult: TestResult = {
|
||||
file,
|
||||
status: "passed",
|
||||
duration,
|
||||
};
|
||||
progress?.onTestComplete?.(file, testResult);
|
||||
return testResult;
|
||||
}
|
||||
|
||||
// Test failed - capture full output
|
||||
const stderr = result.stderr.toString();
|
||||
const stdout = result.stdout.toString();
|
||||
const fullOutput = `${stdout}\n${stderr}`.trim();
|
||||
|
||||
// Extract error with stack trace for summary display
|
||||
const lines = fullOutput.split("\n");
|
||||
let errorLines: string[] = [];
|
||||
|
||||
// First, look for the error message with Expected/Received
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (
|
||||
line.includes("error:") ||
|
||||
line.includes("Expected:") ||
|
||||
line.includes("Received:")
|
||||
) {
|
||||
// Capture error message lines
|
||||
errorLines = lines.slice(i, i + 4);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Then look for stack trace (lines with file paths and line numbers)
|
||||
const stackLines: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Match patterns like "at functionName (/path/to/file.ts:123:45)"
|
||||
if (line.trim().startsWith("at ") && line.includes(".ts:")) {
|
||||
stackLines.push(line.trim());
|
||||
// Capture up to 5 stack frames
|
||||
if (stackLines.length >= 5) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine error message and stack trace
|
||||
if (stackLines.length > 0) {
|
||||
errorLines.push("", ...stackLines);
|
||||
}
|
||||
|
||||
const error = errorLines.length > 0 ? errorLines.join("\n") : "Test failed";
|
||||
|
||||
const testResult: TestResult = {
|
||||
file,
|
||||
status: "failed",
|
||||
duration,
|
||||
error,
|
||||
output: fullOutput, // Include full output for debugging
|
||||
};
|
||||
progress?.onTestComplete?.(file, testResult);
|
||||
return testResult;
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
const testResult: TestResult = {
|
||||
file,
|
||||
status: "failed",
|
||||
duration,
|
||||
error: String(error),
|
||||
};
|
||||
progress?.onTestComplete?.(file, testResult);
|
||||
return testResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run multiple test files in parallel
|
||||
*/
|
||||
export async function runTests(
|
||||
paths: string[],
|
||||
options: {
|
||||
maxParallel?: number;
|
||||
progress?: TestProgress;
|
||||
} = {},
|
||||
): Promise<TestResult[]> {
|
||||
const { maxParallel = 6, progress } = options;
|
||||
|
||||
const testFiles = await collectTestFiles(paths);
|
||||
|
||||
if (testFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Run tests with concurrency limit
|
||||
const limit = pLimit(maxParallel);
|
||||
const promises = testFiles.map((file) =>
|
||||
limit(() => runTestFile(file, progress)),
|
||||
);
|
||||
|
||||
return await Promise.all(promises);
|
||||
}
|
||||
|
||||
// CLI usage
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error(chalk.red("Error: No test directories specified"));
|
||||
console.log("Usage: bun runTestsV2.ts <dir1> [dir2] [...]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results = await runTests(args, {
|
||||
progress: {
|
||||
onTestStart: (file) => {
|
||||
const fileName = file.split("/").pop();
|
||||
console.log(chalk.cyan(`⠋ ${fileName}`));
|
||||
},
|
||||
onTestComplete: (file, result) => {
|
||||
const fileName = file.split("/").pop();
|
||||
if (result.status === "passed") {
|
||||
console.log(chalk.green(`✓ ${fileName}`));
|
||||
} else {
|
||||
console.log(chalk.red(`✗ ${fileName}`));
|
||||
if (result.error) {
|
||||
console.log(chalk.yellow(` ${result.error}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const passed = results.filter((r) => r.status === "passed").length;
|
||||
const failed = results.filter((r) => r.status === "failed").length;
|
||||
|
||||
console.log(
|
||||
`\n${chalk.green(`✓ ${passed}`)} passed, ${failed > 0 ? chalk.red(`✗ ${failed}`) : chalk.dim(`✗ ${failed}`)} failed`,
|
||||
);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
declare var self: Worker;
|
||||
|
||||
import { test } from "bun:test";
|
||||
|
||||
type TestMessage =
|
||||
| { type: "test-start"; file: string; test: string }
|
||||
| { type: "test-pass"; file: string; test: string; duration: number }
|
||||
| { type: "test-fail"; file: string; test: string; duration: number; error: string }
|
||||
| { type: "file-complete"; file: string; passed: number; failed: number; duration: number };
|
||||
|
||||
let currentFile = "";
|
||||
let testsRun = 0;
|
||||
let testsPassed = 0;
|
||||
let testsFailed = 0;
|
||||
const fileStartTime = performance.now();
|
||||
|
||||
// Intercept test execution to send progress updates
|
||||
const originalTest = test;
|
||||
|
||||
// Override test to track progress
|
||||
(globalThis as any).test = function (name: string, fn: Function) {
|
||||
return originalTest(name, async () => {
|
||||
testsRun++;
|
||||
const testStart = performance.now();
|
||||
|
||||
self.postMessage({
|
||||
type: "test-start",
|
||||
file: currentFile,
|
||||
test: name,
|
||||
} as TestMessage);
|
||||
|
||||
try {
|
||||
await fn();
|
||||
const duration = performance.now() - testStart;
|
||||
testsPassed++;
|
||||
|
||||
self.postMessage({
|
||||
type: "test-pass",
|
||||
file: currentFile,
|
||||
test: name,
|
||||
duration,
|
||||
} as TestMessage);
|
||||
} catch (error) {
|
||||
const duration = performance.now() - testStart;
|
||||
testsFailed++;
|
||||
|
||||
self.postMessage({
|
||||
type: "test-fail",
|
||||
file: currentFile,
|
||||
test: name,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
} as TestMessage);
|
||||
|
||||
throw error; // Re-throw so bun:test sees the failure
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
self.onmessage = async (event: MessageEvent) => {
|
||||
const { testFile } = event.data;
|
||||
|
||||
if (!testFile) {
|
||||
self.postMessage({ type: "error", error: "No test file specified" });
|
||||
return;
|
||||
}
|
||||
|
||||
currentFile = testFile;
|
||||
testsRun = 0;
|
||||
testsPassed = 0;
|
||||
testsFailed = 0;
|
||||
|
||||
try {
|
||||
// Import the test file - this will execute all tests
|
||||
await import(testFile);
|
||||
|
||||
// Wait a tick for all tests to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const fileDuration = performance.now() - fileStartTime;
|
||||
|
||||
self.postMessage({
|
||||
type: "file-complete",
|
||||
file: testFile,
|
||||
passed: testsPassed,
|
||||
failed: testsFailed,
|
||||
duration: fileDuration,
|
||||
} as TestMessage);
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: "error",
|
||||
file: testFile,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -83,6 +83,7 @@ type ScenarioConfig = {
|
||||
customerData?: CustomerData;
|
||||
withDefault: boolean;
|
||||
defaultGroup?: string;
|
||||
skipWebhooks?: boolean;
|
||||
products: ProductV2[];
|
||||
productPrefix?: string;
|
||||
entityConfig?: EntityConfig;
|
||||
@@ -120,9 +121,11 @@ const generateEntities = (config: EntityConfig): GeneratedEntity[] => {
|
||||
* @param data - Customer metadata (fingerprint, name, email, etc.)
|
||||
* @param withDefault - Attach the default product on creation (default: false)
|
||||
* @param defaultGroup - The product group to use for default product selection
|
||||
* @param skipWebhooks - Skip sending webhooks for this customer creation (default: undefined, uses server default)
|
||||
* @example s.customer({ paymentMethod: "success" })
|
||||
* @example s.customer({ paymentMethod: "success", data: { name: "Test" } })
|
||||
* @example s.customer({ withDefault: true, defaultGroup: "enterprise" })
|
||||
* @example s.customer({ withDefault: true, skipWebhooks: false }) // Enable webhooks for testing
|
||||
*/
|
||||
const customer = ({
|
||||
testClock = true,
|
||||
@@ -130,12 +133,14 @@ const customer = ({
|
||||
data,
|
||||
withDefault,
|
||||
defaultGroup,
|
||||
skipWebhooks,
|
||||
}: {
|
||||
testClock?: boolean;
|
||||
paymentMethod?: "success" | "fail" | "authenticate";
|
||||
data?: CustomerData;
|
||||
withDefault?: boolean;
|
||||
defaultGroup?: string;
|
||||
skipWebhooks?: boolean;
|
||||
}): ConfigFn => {
|
||||
return (config) => ({
|
||||
...config,
|
||||
@@ -144,6 +149,7 @@ const customer = ({
|
||||
customerData: data ?? config.customerData,
|
||||
withDefault: withDefault ?? config.withDefault,
|
||||
defaultGroup: defaultGroup ?? config.defaultGroup,
|
||||
skipWebhooks: skipWebhooks ?? config.skipWebhooks,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -580,6 +586,7 @@ export async function initScenario({
|
||||
withDefault: config.withDefault,
|
||||
// Default group matches the product prefix (customerId) used in initProductsV0
|
||||
defaultGroup: config.defaultGroup ?? customerId,
|
||||
skipWebhooks: config.skipWebhooks,
|
||||
});
|
||||
testClockId = result.testClockId;
|
||||
customer = result.customer;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FullCustomer } from "@models/cusModels/fullCusModel";
|
||||
|
||||
export const findCustomerProductById = ({
|
||||
fullCustomer,
|
||||
customerProductId,
|
||||
}: {
|
||||
fullCustomer?: FullCustomer;
|
||||
customerProductId: string;
|
||||
}) => {
|
||||
if (!fullCustomer) return undefined;
|
||||
|
||||
return fullCustomer.customer_products.find(
|
||||
(customerProduct) => customerProduct.id === customerProductId,
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export * from "./filterCusProductUtils.js";
|
||||
export * from "./filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
|
||||
export * from "./filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
|
||||
export * from "./findCustomerProduct/findActiveCustomerProduct.js";
|
||||
export * from "./findCustomerProduct/findCustomerProduct.js";
|
||||
export * from "./findCustomerProduct/findScheduledCustomerProduct.js";
|
||||
export * from "./getCusProductFromCustomer.js";
|
||||
export * from "./productIdToCusProduct.js";
|
||||
|
||||
46
shared/utils/cusUtils/fullCusUtils/enrichFullCustomer.ts
Normal file
46
shared/utils/cusUtils/fullCusUtils/enrichFullCustomer.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { InternalError } from "@api/errors/base/InternalError.js";
|
||||
import type { Entity } from "@models/cusModels/entityModels/entityModels.js";
|
||||
import type { FullCustomer } from "@models/cusModels/fullCusModel.js";
|
||||
|
||||
type FullCustomerWithEntity = FullCustomer & { entity: Entity };
|
||||
|
||||
// Overload: errorOnNotFound = true → guaranteed entity
|
||||
export function enrichFullCustomerWithEntity(params: {
|
||||
fullCustomer: FullCustomer;
|
||||
internalEntityId: string | null;
|
||||
errorOnNotFound: true;
|
||||
}): FullCustomerWithEntity;
|
||||
|
||||
// Overload: errorOnNotFound = false/undefined → entity may be undefined
|
||||
export function enrichFullCustomerWithEntity(params: {
|
||||
fullCustomer: FullCustomer;
|
||||
internalEntityId: string | null;
|
||||
errorOnNotFound?: false;
|
||||
}): FullCustomer;
|
||||
|
||||
// Implementation
|
||||
export function enrichFullCustomerWithEntity({
|
||||
fullCustomer,
|
||||
internalEntityId,
|
||||
errorOnNotFound,
|
||||
}: {
|
||||
fullCustomer: FullCustomer;
|
||||
internalEntityId: string | null;
|
||||
errorOnNotFound?: boolean;
|
||||
}): FullCustomer | FullCustomerWithEntity {
|
||||
if (internalEntityId === null) {
|
||||
fullCustomer.entity = undefined;
|
||||
} else {
|
||||
fullCustomer.entity = fullCustomer.entities?.find(
|
||||
(e) => e.internal_id === internalEntityId,
|
||||
);
|
||||
}
|
||||
|
||||
if (errorOnNotFound && !fullCustomer.entity) {
|
||||
throw new InternalError({
|
||||
message: `Entity not found for internal_id: ${internalEntityId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return fullCustomer;
|
||||
}
|
||||
7
shared/utils/cusUtils/index.ts
Normal file
7
shared/utils/cusUtils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Cus plan utils
|
||||
export * from "./cusPlanUtils/cusPlanUtils.js";
|
||||
|
||||
// Full cus utils
|
||||
export * from "./fullCusUtils/enrichFullCustomer.js";
|
||||
export * from "./fullCusUtils/fullCustomerToCustomerEntitlements.js";
|
||||
export * from "./fullCusUtils/getCusStripeSubCount.js";
|
||||
@@ -23,9 +23,7 @@ export * from "./cusPriceUtils/index.js";
|
||||
export * from "./cusProductUtils/index.js";
|
||||
|
||||
// Cus utils
|
||||
export * from "./cusUtils/cusPlanUtils/cusPlanUtils.js";
|
||||
export * from "./cusUtils/fullCusUtils/fullCustomerToCustomerEntitlements.js";
|
||||
export * from "./cusUtils/fullCusUtils/getCusStripeSubCount.js";
|
||||
export * from "./cusUtils/index.js";
|
||||
export * from "./expandUtils.js";
|
||||
|
||||
// Feature utils
|
||||
|
||||
Reference in New Issue
Block a user