Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/seed-e2e-readiness.ts
T

1286 lines
49 KiB
TypeScript

#!/usr/bin/env tsx
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import bcrypt from "bcryptjs";
import { entitlementPlanIds } from "@toolctrl/entitlements";
import { syncDefaultRolesAndPermissions } from "@toolctrl/db/permissions-seed.server";
import { getDbClient, type DbClient } from "@toolctrl/db/client.server";
import { deriveLegacyApiKeyHashMetadata } from "@toolctrl/db/api-keys.server";
import type { PluginManifest } from "@toolctrl/plugin-runtime";
import type { SequenceStep } from "@toolctrl/email-sequences";
export const E2E_READINESS_USER_EMAIL = "e2e.readiness@example.test";
export const E2E_READINESS_USER_PASSWORD = "ReadinessPass123!";
export const E2E_READINESS_NO_TENANT_EMAIL = "e2e.no-tenant@example.test";
export const E2E_READINESS_NO_TENANT_PASSWORD = "NoTenantPass123!";
export const E2E_READINESS_PLUGIN_READONLY_EMAIL = "e2e.plugin-readonly@example.test";
export const E2E_READINESS_PLUGIN_READONLY_PASSWORD = "PluginReadOnlyPass123!";
export const E2E_READINESS_PLUGIN_READONLY_ROLE_NAME = "User";
// Dedicated operator-admin user for admin-host pages (email sequences, etc.).
// The shared tenant user must NOT be an operator admin (it would redirect to the
// admin host on login and break every tenant-app login flow), so admin-host
// tests log in as this separate user instead. It gets an AdminUser row (so the
// admin layout's `user.admin` gate passes) plus the global SuperAdmin role (which
// grants `admin.marketing.leads.view`). No TOTP is enrolled, so admin MFA
// enforcement allows it straight through.
export const E2E_READINESS_ADMIN_EMAIL = "e2e.admin@example.test";
export const E2E_READINESS_ADMIN_PASSWORD = E2E_READINESS_USER_PASSWORD;
export const E2E_READINESS_TENANT_SLUG = "e2e-readiness";
export const E2E_READINESS_TENANT_NAME = "E2E Readiness";
export const E2E_READINESS_WORKFLOW_ID = "wf_123";
export const E2E_READINESS_WORKFLOW_NAME = "E2E Readiness Manual Workflow";
export const E2E_READINESS_WORKFLOW_EXECUTION_ID = "wfe_123";
export const E2E_READINESS_WORKFLOW_LOG_BLOCK_EXECUTION_ID = "wfbe_log_123";
export const E2E_READINESS_WEBHOOK_WORKFLOW_ID = "wf_webhook_123";
export const E2E_READINESS_WEBHOOK_WORKFLOW_NAME = "E2E Readiness Webhook Workflow";
export const E2E_READINESS_WAITING_WORKFLOW_ID = "wf_waiting_123";
export const E2E_READINESS_WAITING_WORKFLOW_NAME = "E2E Readiness Waiting Workflow";
export const E2E_READINESS_WAITING_EXECUTION_ID = "wfe_waiting_123";
export const E2E_READINESS_WAITING_MANUAL_BLOCK_EXECUTION_ID = "wfbe_waiting_manual_123";
export const E2E_READINESS_PLUGIN_ID = "com.toolctrl.e2e-readiness";
export const E2E_READINESS_PLUGIN_VERSION = "1.0.0";
export const E2E_READINESS_LIFECYCLE_PLUGIN_ID = "com.toolctrl.e2e-lifecycle";
export const E2E_READINESS_LIFECYCLE_PLUGIN_VERSION = "1.0.0";
// Must match WORKSPACE_ROLES_FEATURE_FLAG in workspaceRoles.server.ts.
export const E2E_READINESS_WORKSPACE_ROLES_FLAG = "workspace-roles";
export const E2E_READINESS_API_KEY_ALIAS = "E2E Readiness API Key";
export const E2E_READINESS_API_LOG_ENDPOINT = "/v1/e2e-readiness";
export const E2E_READINESS_API_LOG_METHOD = "GET";
export const E2E_READINESS_PORTAL_SUBDOMAIN = "e2e-readiness";
export const E2E_READINESS_PORTAL_TITLE = "E2E Readiness Portal";
export const E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID = "e2e_readiness_plan";
export const E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID = "e2e_readiness_price_monthly";
export const E2E_READINESS_UPGRADE_PLAN_STRIPE_ID = "e2e_readiness_upgrade_plan";
export const E2E_READINESS_UPGRADE_PRICE_STRIPE_ID = "e2e_readiness_upgrade_price_monthly";
// Entitlement plan granted to the seeded tenant's active subscription product so
// per-feature gates (workflow engine, API, advanced builder, ...) resolve as allowed
// instead of redirecting to /plan-required. "business" grants the widest feature set.
export const E2E_READINESS_SUBSCRIPTION_ENTITLEMENT_PLAN_ID = entitlementPlanIds.business;
export const E2E_READINESS_EMAIL_SEQUENCE_IDS = {
welcome: "seq_e2e_welcome",
trialExpiring: "seq_e2e_trial_expiring",
staleLead: "seq_e2e_stale_lead",
customerWelcome: "seq_e2e_customer_welcome",
} as const;
export const E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_ASSERTION = "operator-gated";
export const E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_BLOCKER =
"Workflow outbox-drain readiness is operator-gated: set E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1 only when the general worker is running and the seeded manual workflow can reach the expected success execution status.";
type EnvRecord = Record<string, string | undefined>;
type ReadinessFixture = {
tenantSlug: string;
workflowId: string;
webhookWorkflowId: string;
waitingWorkflowId: string;
waitingExecutionId: string;
pluginBasePath: string;
};
export type E2eWorkflowBlockDefinition = {
key: string;
type: string;
description: string;
isTrigger: boolean;
isBlock: boolean;
input: unknown;
};
export type E2eWorkflowDefinition = {
id: string;
name: string;
description: string;
blocks: E2eWorkflowBlockDefinition[];
edges: { from: string; to: string }[];
inputExample: { title: string; input: Record<string, unknown> };
};
export type E2eEmailSequenceDefinition = {
id: string;
name: string;
description: string;
tenantId: string | null;
triggerEvent: string;
steps: SequenceStep[];
conditions: null;
isEnabled: boolean;
};
export function assertCanRunE2eReadinessSeed(env: EnvRecord = process.env): void {
if (env.NODE_ENV === "production" && env.E2E_SEED_ALLOW_PRODUCTION !== "1") {
throw new Error(
"Refusing to run the E2E readiness seed in production. Set E2E_SEED_ALLOW_PRODUCTION=1 to explicitly allow seeding a production-like validation environment. The seed is fixture-scoped: it only creates/refreshes the dedicated e2e-readiness tenant and its test users, plugins, and workflows."
);
}
if (env.E2E_LOCAL_READINESS_SEED !== "1") {
throw new Error("Set E2E_LOCAL_READINESS_SEED=1 to seed local E2E readiness fixtures.");
}
if (!env.DATABASE_URL?.trim()) {
throw new Error("DATABASE_URL is required for the E2E readiness seed.");
}
}
export function appFixturePath(tenantSlug: string, routePath: string): string {
return `/${tenantSlug}/${routePath.replace(/^\/+/, "")}`;
}
export function buildE2eReadinessPluginManifest(): PluginManifest {
return {
schemaVersion: 1,
id: E2E_READINESS_PLUGIN_ID,
version: E2E_READINESS_PLUGIN_VERSION,
name: "E2E Readiness Plugin",
description: "Local deterministic plugin fixture for E2E readiness checks.",
entry: "plugin.wasm",
wasmSha256: "0".repeat(64),
signature: "1".repeat(128),
signedBy: "2".repeat(64),
allowedHosts: [],
capabilities: [{ name: "log" }, { name: "audit" }],
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
ui: {
settings: {
entry: "settings/index.html",
sha256: createHash("sha256").update(buildE2eReadinessPluginSettingsHtml()).digest("hex"),
},
},
};
}
export function buildE2eReadinessPluginSettingsHtml(): string {
return [
'<main data-e2e-plugin-settings-fixture="ready">',
"<h1>E2E Plugin Settings</h1>",
'<button type="button" id="e2e-plugin-settings-save">Save</button>',
"</main>",
"<script>",
"(function () {",
" var pluginId = document.querySelector('meta[name=\"plugin-id\"]')?.getAttribute('content') || '';",
" var channelId = document.querySelector('meta[name=\"plugin-settings-channel\"]')?.getAttribute('content') || '';",
" var protocolVersion = Number(document.querySelector('meta[name=\"plugin-settings-protocol\"]')?.getAttribute('content') || '1');",
" function postReady() {",
" if (!pluginId || !channelId) return;",
" parent.postMessage({",
" type: 'plugin.settings.ready',",
" version: protocolVersion,",
" pluginId: pluginId,",
" channelId: channelId,",
" requestId: 'seed-fixture-ready',",
" }, '*');",
" }",
" postReady();",
" document.getElementById('e2e-plugin-settings-save')?.addEventListener('click', function () {",
" if (!pluginId || !channelId) return;",
" parent.postMessage({",
" type: 'plugin.settings.set',",
" version: protocolVersion,",
" pluginId: pluginId,",
" channelId: channelId,",
" requestId: 'seed-fixture-save',",
" key: 'theme',",
" value: 'ready',",
" }, '*');",
" });",
"}());",
"</script>",
"",
].join("\n");
}
export function buildE2eReadinessLifecyclePluginManifest(): PluginManifest {
return {
schemaVersion: 1,
id: E2E_READINESS_LIFECYCLE_PLUGIN_ID,
version: E2E_READINESS_LIFECYCLE_PLUGIN_VERSION,
name: "E2E Lifecycle Plugin",
description: "Local deterministic plugin fixture for real enable/disable E2E checks.",
entry: "plugin.wasm",
wasmSha256: "3".repeat(64),
signature: "4".repeat(128),
signedBy: "5".repeat(64),
allowedHosts: [],
capabilities: [{ name: "log" }, { name: "audit" }],
// The manifest schema requires at least one hook (manifest.ts: hooks.min(1)).
// An empty array stores an invalid manifest, which makes listPlugins throw on
// parse and crashes the whole plugins settings page with a 500.
hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }],
};
}
export function buildE2eReadinessEnv(fixture: ReadinessFixture): Record<string, string> {
const workflowBase = appFixturePath(fixture.tenantSlug, `workflow-engine/workflows/${fixture.workflowId}`);
const webhookWorkflowBase = appFixturePath(fixture.tenantSlug, `workflow-engine/workflows/${fixture.webhookWorkflowId}`);
const waitingWorkflowBase = appFixturePath(fixture.tenantSlug, `workflow-engine/workflows/${fixture.waitingWorkflowId}`);
return {
E2E_TENANT_EMAIL: E2E_READINESS_USER_EMAIL,
E2E_TENANT_PASSWORD: E2E_READINESS_USER_PASSWORD,
E2E_NO_TENANT_EMAIL: E2E_READINESS_NO_TENANT_EMAIL,
E2E_NO_TENANT_PASSWORD: E2E_READINESS_NO_TENANT_PASSWORD,
E2E_PLUGIN_READONLY_EMAIL: E2E_READINESS_PLUGIN_READONLY_EMAIL,
E2E_PLUGIN_READONLY_PASSWORD: E2E_READINESS_PLUGIN_READONLY_PASSWORD,
E2E_ADMIN_EMAIL: E2E_READINESS_ADMIN_EMAIL,
E2E_ADMIN_PASSWORD: E2E_READINESS_ADMIN_PASSWORD,
E2E_TENANT_ACCOUNT_NAME: E2E_READINESS_TENANT_NAME,
E2E_TENANT_ACCOUNT_SLUG: fixture.tenantSlug,
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0",
E2E_TENANT_DASHBOARD_PATH: appFixturePath(fixture.tenantSlug, "dashboard"),
E2E_TENANT_SETTINGS_ACCOUNT_PATH: appFixturePath(fixture.tenantSlug, "settings/account"),
E2E_TENANT_SETTINGS_PROFILE_PATH: appFixturePath(fixture.tenantSlug, "settings/profile"),
E2E_TENANT_SUBSCRIPTION_PATH: appFixturePath(fixture.tenantSlug, "settings/subscription"),
E2E_TENANT_MEMBERS_PATH: appFixturePath(fixture.tenantSlug, "settings/members"),
E2E_TENANT_ROLES_AND_PERMISSIONS_PATH: appFixturePath(fixture.tenantSlug, "settings/roles-and-permissions"),
E2E_TENANT_NOTIFICATIONS_PATH: appFixturePath(fixture.tenantSlug, "settings/notifications"),
E2E_TENANT_LOGS_PATH: appFixturePath(fixture.tenantSlug, "settings/logs"),
E2E_TENANT_LOG_EVENTS_PATH: appFixturePath(fixture.tenantSlug, "settings/logs/events"),
E2E_TENANT_AUDIT_EXPORT_PATH: appFixturePath(fixture.tenantSlug, "settings/audit-export"),
E2E_TENANT_PRIVACY_PATH: appFixturePath(fixture.tenantSlug, "settings/privacy"),
E2E_TENANT_CHATBOT_PATH: appFixturePath(fixture.tenantSlug, "settings/chatbot"),
E2E_TENANT_AI_USAGE_PATH: appFixturePath(fixture.tenantSlug, "settings/ai-usage"),
E2E_TENANT_API_PATH: appFixturePath(fixture.tenantSlug, "settings/api"),
E2E_TENANT_API_KEYS_PATH: appFixturePath(fixture.tenantSlug, "settings/api/keys"),
E2E_TENANT_API_DOCS_PATH: appFixturePath(fixture.tenantSlug, "settings/api/docs"),
E2E_TENANT_API_LOGS_PATH: appFixturePath(fixture.tenantSlug, "settings/api/logs"),
E2E_TENANT_POLICIES_PATH: appFixturePath(fixture.tenantSlug, "settings/policies"),
E2E_TENANT_PORTAL_ANALYTICS_PATH: appFixturePath(fixture.tenantSlug, `portals/${E2E_READINESS_PORTAL_SUBDOMAIN}/analytics`),
E2E_TENANT_PORTAL_SETTINGS_PATH: appFixturePath(fixture.tenantSlug, `portals/${E2E_READINESS_PORTAL_SUBDOMAIN}/settings`),
// Host-aware deployments serve admin pages on the admin host without the
// `/admin` prefix; the app host 308-redirects `/email-sequences` to the admin
// host, which page.goto follows (the readiness session cookie spans the
// app-base domain). The literal `/admin/email-sequences` is a 404 on the app host.
E2E_ADMIN_EMAIL_SEQUENCES_PATH: "/email-sequences",
E2E_PLUGIN_SETTINGS_PATH: appFixturePath(fixture.tenantSlug, "settings/plugins"),
E2E_WORKFLOW_ENGINE_PATH: appFixturePath(fixture.tenantSlug, "workflow-engine/workflows"),
E2E_WORKFLOW_BUILDER_PATH: workflowBase,
E2E_WORKFLOW_RUN_API_PATH: `${workflowBase}/run/api`,
E2E_WORKFLOW_RUN_MANUAL_PATH: `${workflowBase}/run/manual`,
E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: webhookWorkflowBase,
E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: `${webhookWorkflowBase}/run/manual`,
E2E_WORKFLOW_WAITING_BUILDER_PATH: waitingWorkflowBase,
E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH: `${waitingWorkflowBase}/run/manual`,
E2E_WORKFLOW_WAITING_RUN_API_PATH: `${waitingWorkflowBase}/run/api`,
E2E_WORKFLOW_WAITING_EXECUTIONS_PATH: `${waitingWorkflowBase}/executions`,
E2E_WORKFLOW_WAITING_EXECUTION_ID: fixture.waitingExecutionId,
E2E_ALLOW_WORKFLOW_RUN: "1",
E2E_ALLOW_WORKFLOW_WEBHOOK_RUN: "0",
E2E_WORKFLOW_WEBHOOK_TARGET_URL: "",
E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "0",
E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS: "success",
E2E_WORKFLOW_OUTBOX_DRAIN_ASSERTION: E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_ASSERTION,
E2E_WORKFLOW_OUTBOX_DRAIN_BLOCKER: E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_BLOCKER,
PLUGIN_BINARY_BASE_PATH: fixture.pluginBasePath,
};
}
export function renderE2eReadinessEnvFile(values: Record<string, string>): string {
return `${Object.entries(values)
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join("\n")}\n`;
}
export function buildE2eReadinessManualWorkflowDefinition(): E2eWorkflowDefinition {
return {
id: E2E_READINESS_WORKFLOW_ID,
name: E2E_READINESS_WORKFLOW_NAME,
description: "Local E2E readiness workflow with a manual trigger and log block.",
blocks: [
{
key: "manual",
type: "manual",
description: "Manual E2E readiness trigger",
isTrigger: true,
isBlock: false,
input: {},
},
{
key: "log",
type: "log",
description: "Record E2E readiness input",
isTrigger: false,
isBlock: true,
input: { message: "E2E readiness {{$params.message}}" },
},
],
edges: [{ from: "manual", to: "log" }],
inputExample: { title: "Readiness payload", input: { message: "ready" } },
};
}
export function buildE2eReadinessWebhookWorkflowDefinition(): E2eWorkflowDefinition {
return {
id: E2E_READINESS_WEBHOOK_WORKFLOW_ID,
name: E2E_READINESS_WEBHOOK_WORKFLOW_NAME,
description: "Local E2E readiness workflow for manual webhook/HTTP request coverage.",
blocks: [
{
key: "manual",
type: "manual",
description: "Manual E2E webhook trigger",
isTrigger: true,
isBlock: false,
input: {},
},
{
key: "httpRequest",
type: "httpRequest",
description: "Send readiness webhook",
isTrigger: false,
isBlock: true,
input: {
url: "{{$params.webhookUrl}}",
method: "POST",
headers: { "content-type": "application/json" },
body: '{"message":"{{$params.message}}"}',
throwsError: true,
},
},
{
key: "log",
type: "log",
description: "Record webhook readiness response",
isTrigger: false,
isBlock: true,
input: { message: "Webhook readiness status {{$httpRequest.statusCode}}" },
},
],
edges: [
{ from: "manual", to: "httpRequest" },
{ from: "httpRequest", to: "log" },
],
inputExample: {
title: "Webhook readiness payload",
input: { webhookUrl: "http://127.0.0.1:1/e2e-readiness-webhook", message: "ready" },
},
};
}
export function buildE2eReadinessWaitingWorkflowDefinition(): E2eWorkflowDefinition {
return {
id: E2E_READINESS_WAITING_WORKFLOW_ID,
name: E2E_READINESS_WAITING_WORKFLOW_NAME,
description: "Local E2E readiness workflow that pauses on wait-for-input.",
blocks: [
{
key: "manual",
type: "manual",
description: "Manual E2E waiting trigger",
isTrigger: true,
isBlock: false,
input: {},
},
{
key: "wait",
type: "waitForInput",
description: "E2E approval input",
isTrigger: false,
isBlock: true,
input: { title: "E2E approval", placeholder: "Type approval" },
},
{
key: "variable",
type: "variable",
description: "Store E2E approval input",
isTrigger: false,
isBlock: true,
input: { variableName: "approvalMessage", value: "approved {{waitForInput.input.approved}}" },
},
{
key: "log",
type: "log",
description: "Record E2E approval continuation",
isTrigger: false,
isBlock: true,
input: { message: "continued {{$vars.approvalMessage}}" },
},
],
edges: [
{ from: "manual", to: "wait" },
{ from: "wait", to: "variable" },
{ from: "variable", to: "log" },
],
inputExample: { title: "Waiting readiness payload", input: { input: { approved: false } } },
};
}
export function buildE2eReadinessEmailSequenceDefinitions(tenantId: string | null = null): E2eEmailSequenceDefinition[] {
return [
{
id: E2E_READINESS_EMAIL_SEQUENCE_IDS.welcome,
name: "E2E Welcome Sequence",
description: "Local readiness sequence for tenant.signup trigger coverage.",
tenantId,
triggerEvent: "tenant.signup",
isEnabled: true,
conditions: null,
steps: [
{
stepIndex: 0,
delayHours: 0,
template: "welcome",
subject: "Welcome to E2E Readiness",
variables: {
headline: "Your workspace is ready",
bodyParagraphs: ["This deterministic fixture keeps the sequence list, editor, and runtime parser testable."],
ctaLabel: "Open dashboard",
ctaUrl: "/e2e-readiness/dashboard",
},
},
],
},
{
id: E2E_READINESS_EMAIL_SEQUENCE_IDS.trialExpiring,
name: "E2E Trial Expiring Sequence",
description: "Local readiness sequence for tenant.trial_expiring trigger coverage.",
tenantId,
triggerEvent: "tenant.trial_expiring",
isEnabled: true,
conditions: null,
steps: [
{
stepIndex: 0,
delayHours: 0,
template: "trial-reminder",
subject: "Your E2E readiness trial ends soon",
variables: {
headline: "Trial reminder",
bodyParagraphs: ["This fixture exercises the trial-expiring sequence path without live provider secrets."],
ctaLabel: "Review subscription",
ctaUrl: "/e2e-readiness/settings/subscription",
},
},
],
},
{
id: E2E_READINESS_EMAIL_SEQUENCE_IDS.staleLead,
name: "E2E Stale Lead Sequence",
description: "Local readiness sequence for lead.stale_60d trigger coverage.",
tenantId,
triggerEvent: "lead.stale_60d",
isEnabled: true,
conditions: null,
steps: [
{
stepIndex: 0,
delayHours: 0,
template: "re-activation",
subject: "Still interested in E2E Readiness?",
variables: {
headline: "Let's pick this back up",
bodyParagraphs: ["This fixture keeps stale-lead trigger handling inspectable in local readiness runs."],
ctaLabel: "Resume setup",
ctaUrl: "/e2e-readiness/dashboard",
},
},
],
},
{
id: E2E_READINESS_EMAIL_SEQUENCE_IDS.customerWelcome,
name: "E2E Customer Welcome Sequence",
description: "Local readiness sequence for lead.converted_to_customer trigger coverage.",
tenantId,
triggerEvent: "lead.converted_to_customer",
isEnabled: true,
conditions: null,
steps: [
{
stepIndex: 0,
delayHours: 0,
template: "generic-step",
subject: "E2E Readiness customer setup",
variables: {
headline: "Customer setup",
bodyParagraphs: ["This fixture gives the admin sequence dashboard stable conversion-sequence data."],
ctaLabel: "Open account",
ctaUrl: "/e2e-readiness/settings/account",
},
},
],
},
];
}
async function upsertReadinessUser(db: DbClient, input: { email: string; password: string; firstName: string; lastName: string }) {
const passwordHash = await bcrypt.hash(input.password, 10);
return db.user.upsert({
where: { email: input.email },
create: {
email: input.email,
passwordHash,
firstName: input.firstName,
lastName: input.lastName,
active: true,
locale: "en",
},
update: {
passwordHash,
firstName: input.firstName,
lastName: input.lastName,
active: true,
locale: "en",
},
});
}
async function seedUserAndTenant(db: DbClient) {
const user = await upsertReadinessUser(db, {
email: E2E_READINESS_USER_EMAIL,
password: E2E_READINESS_USER_PASSWORD,
firstName: "E2E",
lastName: "Readiness",
});
const tenant = await db.tenant.upsert({
where: { slug: E2E_READINESS_TENANT_SLUG },
create: {
slug: E2E_READINESS_TENANT_SLUG,
name: E2E_READINESS_TENANT_NAME,
active: true,
},
update: {
name: E2E_READINESS_TENANT_NAME,
active: true,
deactivatedReason: null,
},
});
await ensureTenantMembership(db, user.id, tenant.id);
// This tenant user must NOT be an operator admin: operator admins are redirected to
// the admin host on login, which breaks every tenant-app login flow (auth-flow,
// registration) and adds admin-only chrome (a low-contrast "Switch to admin" row) to
// the roles page. A prior seed iteration granted it AdminUser + the global SuperAdmin
// role, so actively REMOVE both here (stopping the grant is not enough — the rows
// persist). Admin-host pages need a separate dedicated admin user.
await db.adminUser.deleteMany({ where: { userId: user.id } });
const globalSuperAdminRole = await db.role.findFirst({ where: { tenantId: null, name: "SuperAdmin" }, select: { id: true } });
if (globalSuperAdminRole) {
await db.userRole.deleteMany({ where: { userId: user.id, tenantId: null, roleId: globalSuperAdminRole.id } });
}
return { user, tenant };
}
async function pruneLeakedE2eAccounts(db: DbClient, readinessUserId: string, readinessTenantId: string) {
// The E2E_ALLOW_*_CREATE registration/account-create tests create real tenants
// owned by the readiness + no-tenant users and never delete them, so the users
// accumulate memberships and the app renders the multi-account picker. A clean
// fixture has the readiness user in exactly the readiness tenant and the no-tenant
// user in none. Delete every other tenant these users belong to (cascade clears
// memberships) plus the throwaway registration users + their tenants, so a reseed
// heals the leak.
const noTenantUser = await db.user.findUnique({ where: { email: E2E_READINESS_NO_TENANT_EMAIL }, select: { id: true } });
const userIds = [readinessUserId, ...(noTenantUser ? [noTenantUser.id] : [])];
const memberships = await db.tenantUser.findMany({
where: { userId: { in: userIds }, tenantId: { not: readinessTenantId } },
select: { tenantId: true },
});
const leakedTenantIds = [...new Set(memberships.map((m) => m.tenantId))];
if (leakedTenantIds.length > 0) {
await db.tenant.deleteMany({ where: { id: { in: leakedTenantIds } } });
}
const throwawayUsers = await db.user.findMany({
where: {
OR: [{ email: { startsWith: "registration-provisioning-" } }, { email: { startsWith: "registration-verification-" } }],
},
select: { id: true },
});
const throwawayIds = throwawayUsers.map((entry) => entry.id);
if (throwawayIds.length > 0) {
const owned = await db.tenantUser.findMany({ where: { userId: { in: throwawayIds } }, select: { tenantId: true } });
const ownedTenantIds = [...new Set(owned.map((entry) => entry.tenantId))].filter((id) => id !== readinessTenantId);
if (ownedTenantIds.length > 0) {
await db.tenant.deleteMany({ where: { id: { in: ownedTenantIds } } });
}
await db.user.deleteMany({ where: { id: { in: throwawayIds } } });
}
}
async function seedNoTenantUser(db: DbClient) {
const user = await upsertReadinessUser(db, {
email: E2E_READINESS_NO_TENANT_EMAIL,
password: E2E_READINESS_NO_TENANT_PASSWORD,
firstName: "E2E No Tenant",
lastName: "Readiness",
});
await db.user.update({
where: { id: user.id },
data: { defaultTenantId: null },
});
await db.userRole.deleteMany({ where: { userId: user.id } });
await db.tenantUser.deleteMany({ where: { userId: user.id } });
return user;
}
async function seedAdminUser(db: DbClient) {
// Roles/permissions must exist before we can resolve + grant the global
// SuperAdmin role; syncDefaultRolesAndPermissions is idempotent.
await syncDefaultRolesAndPermissions({ client: db });
const user = await upsertReadinessUser(db, {
email: E2E_READINESS_ADMIN_EMAIL,
password: E2E_READINESS_ADMIN_PASSWORD,
firstName: "E2E",
lastName: "Admin",
});
// Operator admins are global (no tenant). Keep this user tenant-less so it does
// not accumulate the multi-account picker or interfere with the tenant fixtures.
await db.user.update({ where: { id: user.id }, data: { defaultTenantId: null } });
await db.tenantUser.deleteMany({ where: { userId: user.id } });
// AdminUser row makes the admin layout's `user.admin` gate pass.
await db.adminUser.upsert({
where: { userId: user.id },
create: { userId: user.id },
update: {},
});
// Global SuperAdmin role grants admin permissions (e.g. admin.marketing.leads.view
// for the email-sequences pages).
const superAdminRole = await db.role.findFirst({ where: { tenantId: null, name: "SuperAdmin" }, select: { id: true } });
if (!superAdminRole) {
throw new Error("Global SuperAdmin role is required for the E2E admin fixture.");
}
const existing = await db.userRole.findFirst({
where: { userId: user.id, tenantId: null, roleId: superAdminRole.id },
select: { id: true },
});
if (!existing) {
await db.userRole.create({ data: { userId: user.id, tenantId: null, roleId: superAdminRole.id } });
}
return user;
}
async function ensureTenantMembership(db: DbClient, userId: string, tenantId: string) {
await db.user.update({
where: { id: userId },
data: { defaultTenantId: tenantId },
});
const membership = await db.tenantUser.findFirst({
where: { tenantId, userId },
select: { id: true },
});
if (membership) {
await db.tenantUser.update({
where: { id: membership.id },
data: { type: 0, joined: 0, status: 2 },
});
} else {
await db.tenantUser.create({
data: { tenantId, userId, type: 0, joined: 0, status: 2 },
});
}
}
async function seedRoles(db: DbClient, userId: string, tenantId: string) {
await syncDefaultRolesAndPermissions({ client: db });
const roles = await db.role.findMany({
where: { tenantId: null, name: { in: ["SuperUser", "Admin", "Billing Admin", "User"] } },
select: { id: true },
});
for (const role of roles) {
const existing = await db.userRole.findFirst({
where: { userId, tenantId, roleId: role.id },
select: { id: true },
});
if (!existing) {
await db.userRole.create({ data: { userId, tenantId, roleId: role.id } });
}
}
// NOTE: do NOT grant the global SuperAdmin admin role here — it pairs with the
// AdminUser operator grant (see seedUserAndTenant) which redirects this user to the
// admin host on login and breaks tenant-app login flows. Admin-host tests need a
// dedicated admin user instead.
}
async function seedFeatureFlags(db: DbClient) {
// The workspace roles builder (/settings/roles-and-permissions/roles) is gated
// behind the "workspace-roles" feature flag (see workspaceRoles.server.ts);
// without an enabled global flag the roles sub-route throws a 404. Seed it
// enabled so the e2e tenant can reach the roles tab.
const existing = await db.featureFlag.findFirst({ where: { name: E2E_READINESS_WORKSPACE_ROLES_FLAG } });
if (existing) {
if (!existing.enabled) {
await db.featureFlag.update({ where: { id: existing.id }, data: { enabled: true } });
}
return;
}
await db.featureFlag.create({
data: {
name: E2E_READINESS_WORKSPACE_ROLES_FLAG,
key: E2E_READINESS_WORKSPACE_ROLES_FLAG,
description: "E2E readiness: enable the workspace roles builder.",
scope: "global",
enabled: true,
},
});
}
async function seedPluginReadonlyUser(db: DbClient, tenantId: string) {
await syncDefaultRolesAndPermissions({ client: db });
const user = await upsertReadinessUser(db, {
email: E2E_READINESS_PLUGIN_READONLY_EMAIL,
password: E2E_READINESS_PLUGIN_READONLY_PASSWORD,
firstName: "E2E Plugin",
lastName: "Readonly",
});
await ensureTenantMembership(db, user.id, tenantId);
const role = await db.role.findFirst({
where: { tenantId: null, name: E2E_READINESS_PLUGIN_READONLY_ROLE_NAME },
select: { id: true },
});
if (!role) {
throw new Error(`Default ${E2E_READINESS_PLUGIN_READONLY_ROLE_NAME} role is required for the E2E plugin read-only fixture.`);
}
await db.userRole.deleteMany({ where: { userId: user.id, tenantId } });
await db.userRole.create({ data: { userId: user.id, tenantId, roleId: role.id } });
return user;
}
async function seedSubscription(db: DbClient, tenantId: string) {
const now = new Date();
const periodEnd = new Date(now);
periodEnd.setUTCMonth(periodEnd.getUTCMonth() + 1);
const product =
(await db.subscriptionProduct.findFirst({ where: { stripeId: E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID } })) ??
(await db.subscriptionProduct.create({
data: {
stripeId: E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID,
order: 1,
title: "E2E Readiness Plan",
active: true,
model: 0,
public: true,
description: "Local E2E readiness subscription plan.",
billingAddressCollection: "auto",
hasQuantity: false,
canBuyAgain: false,
},
}));
await db.subscriptionProduct.update({
where: { id: product.id },
data: { active: true, public: true, title: "E2E Readiness Plan", description: "Local E2E readiness subscription plan." },
});
// Grant an unlimited "users" plan feature. members/new wraps its invite form in
// CheckPlanFeatureLimit, which hides the form when the Users feature resolves to
// NOT_INCLUDED (no feature row). type 4 = SubscriptionFeatureLimitType.UNLIMITED.
const existingUsersFeature = await db.subscriptionFeature.findFirst({ where: { subscriptionProductId: product.id, name: "users" } });
if (!existingUsersFeature) {
await db.subscriptionFeature.create({
data: { subscriptionProductId: product.id, order: 1, title: "Users", name: "users", type: 4, value: 0 },
});
}
const price =
(await db.subscriptionPrice.findFirst({ where: { subscriptionProductId: product.id, stripeId: E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID } })) ??
(await db.subscriptionPrice.create({
data: {
subscriptionProductId: product.id,
stripeId: E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID,
type: 0,
billingPeriod: 3,
price: "19",
currency: "usd",
trialDays: 0,
active: true,
},
}));
await db.subscriptionPrice.update({
where: { id: price.id },
data: { price: "19", currency: "usd", billingPeriod: 3, active: true },
});
const upgradeProduct =
(await db.subscriptionProduct.findFirst({ where: { stripeId: E2E_READINESS_UPGRADE_PLAN_STRIPE_ID } })) ??
(await db.subscriptionProduct.create({
data: {
stripeId: E2E_READINESS_UPGRADE_PLAN_STRIPE_ID,
order: 2,
title: "E2E Readiness Upgrade Plan",
active: true,
model: 0,
public: true,
description: "Local E2E readiness checkout plan that stays unassigned to the seeded tenant.",
billingAddressCollection: "auto",
hasQuantity: false,
canBuyAgain: false,
},
}));
await db.subscriptionProduct.update({
where: { id: upgradeProduct.id },
data: {
active: true,
public: true,
order: 2,
title: "E2E Readiness Upgrade Plan",
description: "Local E2E readiness checkout plan that stays unassigned to the seeded tenant.",
},
});
const upgradePrice =
(await db.subscriptionPrice.findFirst({
where: { subscriptionProductId: upgradeProduct.id, stripeId: E2E_READINESS_UPGRADE_PRICE_STRIPE_ID },
})) ??
(await db.subscriptionPrice.create({
data: {
subscriptionProductId: upgradeProduct.id,
stripeId: E2E_READINESS_UPGRADE_PRICE_STRIPE_ID,
type: 0,
billingPeriod: 3,
price: "29",
currency: "usd",
trialDays: 0,
active: true,
},
}));
await db.subscriptionPrice.update({
where: { id: upgradePrice.id },
data: { price: "29", currency: "usd", billingPeriod: 3, active: true },
});
const tenantSubscription = await db.tenantSubscription.upsert({
where: { tenantId },
create: { tenantId, stripeCustomerId: null },
update: {},
});
await db.tenantSubscriptionProduct.deleteMany({
where: { tenantSubscriptionId: tenantSubscription.id, subscriptionProductId: upgradeProduct.id },
});
const tenantProduct =
(await db.tenantSubscriptionProduct.findFirst({
where: { tenantSubscriptionId: tenantSubscription.id, subscriptionProductId: product.id, fromCheckoutSessionId: "e2e-readiness" },
})) ??
(await db.tenantSubscriptionProduct.create({
data: {
tenantSubscriptionId: tenantSubscription.id,
subscriptionProductId: product.id,
customEntitlementPlanId: E2E_READINESS_SUBSCRIPTION_ENTITLEMENT_PLAN_ID,
fromCheckoutSessionId: "e2e-readiness",
quantity: 1,
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
}));
await db.tenantSubscriptionProduct.update({
where: { id: tenantProduct.id },
data: {
customEntitlementPlanId: E2E_READINESS_SUBSCRIPTION_ENTITLEMENT_PLAN_ID,
cancelledAt: null,
endsAt: null,
quantity: 1,
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
});
const tenantPrice = await db.tenantSubscriptionProductPrice.findFirst({
where: { tenantSubscriptionProductId: tenantProduct.id, subscriptionPriceId: price.id },
select: { id: true },
});
if (!tenantPrice) {
await db.tenantSubscriptionProductPrice.create({
data: { tenantSubscriptionProductId: tenantProduct.id, subscriptionPriceId: price.id },
});
}
}
async function seedApiReadiness(db: DbClient, tenantId: string, userId: string) {
const key = "tc_e2e_readiness_api_key_not_for_auth";
const apiKey = await db.apiKey.upsert({
where: { tenantId_alias: { tenantId, alias: E2E_READINESS_API_KEY_ALIAS } },
create: {
tenantId,
createdByUserId: userId,
alias: E2E_READINESS_API_KEY_ALIAS,
key,
...deriveLegacyApiKeyHashMetadata(key, undefined, new Date("2026-05-01T00:00:00.000Z")),
expires: null,
active: true,
},
update: {
key,
...deriveLegacyApiKeyHashMetadata(key, undefined, new Date("2026-05-01T00:00:00.000Z")),
expires: null,
active: true,
},
});
await db.apiKeyLog.deleteMany({
where: {
tenantId,
apiKeyId: apiKey.id,
endpoint: E2E_READINESS_API_LOG_ENDPOINT,
method: E2E_READINESS_API_LOG_METHOD,
params: JSON.stringify({ source: "e2e-readiness" }),
},
});
await db.apiKeyLog.create({
data: {
tenantId,
apiKeyId: apiKey.id,
ip: "127.0.0.1",
endpoint: E2E_READINESS_API_LOG_ENDPOINT,
method: E2E_READINESS_API_LOG_METHOD,
params: JSON.stringify({ source: "e2e-readiness" }),
status: 200,
duration: 42,
alias: apiKey.alias,
tenantName: E2E_READINESS_TENANT_NAME,
type: "e2e-readiness",
},
});
return apiKey;
}
async function seedPlugin(db: DbClient, tenantId: string, userId: string, pluginBasePath: string) {
const manifest = buildE2eReadinessPluginManifest();
const pluginBundlePath = path.join(pluginBasePath, manifest.id, manifest.version, "settings");
await mkdir(pluginBundlePath, { recursive: true });
await writeFile(path.join(pluginBundlePath, "index.html"), buildE2eReadinessPluginSettingsHtml(), "utf8");
const plugin = await db.plugin.upsert({
where: { tenantId_pluginId: { tenantId, pluginId: manifest.id } },
create: {
tenantId,
pluginId: manifest.id,
version: manifest.version,
manifest: manifest as never,
wasmSha256: manifest.wasmSha256,
signaturePublicKey: manifest.signedBy,
enabled: true,
},
update: {
version: manifest.version,
manifest: manifest as never,
wasmSha256: manifest.wasmSha256,
signaturePublicKey: manifest.signedBy,
enabled: true,
},
});
await db.tenantPluginCapabilityGrant.upsert({
where: { tenantId_pluginId_capabilityName: { tenantId, pluginId: manifest.id, capabilityName: "log" } },
create: { tenantId, pluginId: manifest.id, capabilityName: "log", grantedByUserId: userId },
update: { scopeJson: null, grantedByUserId: userId, grantedAt: new Date() },
});
await db.tenantPluginCapabilityGrant.deleteMany({
where: { tenantId, pluginId: manifest.id, capabilityName: "audit" },
});
return plugin;
}
async function seedLifecyclePlugin(db: DbClient, tenantId: string, userId: string) {
const manifest = buildE2eReadinessLifecyclePluginManifest();
const plugin = await db.plugin.upsert({
where: { tenantId_pluginId: { tenantId, pluginId: manifest.id } },
create: {
tenantId,
pluginId: manifest.id,
version: manifest.version,
manifest: manifest as never,
wasmSha256: manifest.wasmSha256,
signaturePublicKey: manifest.signedBy,
enabled: true,
},
update: {
version: manifest.version,
manifest: manifest as never,
wasmSha256: manifest.wasmSha256,
signaturePublicKey: manifest.signedBy,
enabled: true,
},
});
// Fully provision the lifecycle plugin (capabilities AND hook consents) so it has
// no missing capabilities. A disabled plugin with any missing capability hides its
// enable form (review-before-enable), which would trap the deactivate→re-enable
// lifecycle tests with "lifecycle controls unavailable".
const lifecycleCapabilityNames = [...manifest.capabilities.map((capability) => capability.name), ...(manifest.hooks ?? []).map((hook) => `hook:${hook.name}`)];
for (const capabilityName of lifecycleCapabilityNames) {
await db.tenantPluginCapabilityGrant.upsert({
where: { tenantId_pluginId_capabilityName: { tenantId, pluginId: manifest.id, capabilityName } },
create: { tenantId, pluginId: manifest.id, capabilityName, grantedByUserId: userId },
update: { scopeJson: null, grantedByUserId: userId, grantedAt: new Date() },
});
}
return plugin;
}
async function seedPortal(db: DbClient, tenantId: string, userId: string) {
// The portals feature ships gated off (PORTALS_ENABLED) with its routes always
// mounted; this fixture lets the readiness lane exercise the tenant portal
// management surfaces (analytics + settings) once the env opt-in is set. A
// minimal published portal is enough — the pages render their empty-state /
// form without any Stripe/analytics data.
await db.portal.upsert({
where: { subdomain: E2E_READINESS_PORTAL_SUBDOMAIN },
create: {
tenantId,
createdByUserId: userId,
subdomain: E2E_READINESS_PORTAL_SUBDOMAIN,
title: E2E_READINESS_PORTAL_TITLE,
isPublished: true,
},
update: {
tenantId,
createdByUserId: userId,
title: E2E_READINESS_PORTAL_TITLE,
isPublished: true,
},
});
}
async function seedWorkflowFromDefinition(db: DbClient, tenantId: string, userId: string, definition: E2eWorkflowDefinition) {
await db.workflow.deleteMany({ where: { tenantId, OR: [{ id: definition.id }, { name: definition.name }] } });
const workflow = await db.workflow.create({
data: {
id: definition.id,
tenantId,
createdByUserId: userId,
name: definition.name,
description: definition.description,
status: "live",
appliesToAllTenants: false,
},
});
const createdBlocks = new Map<string, { id: string }>();
for (const block of definition.blocks) {
const createdBlock = await db.workflowBlock.create({
data: {
workflowId: workflow.id,
type: block.type,
description: block.description,
isTrigger: block.isTrigger,
isBlock: block.isBlock,
input: JSON.stringify(block.input),
},
});
createdBlocks.set(block.key, createdBlock);
}
for (const edge of definition.edges) {
const fromBlock = createdBlocks.get(edge.from);
const toBlock = createdBlocks.get(edge.to);
if (!fromBlock || !toBlock) {
throw new Error(`Invalid E2E readiness workflow edge: ${edge.from} -> ${edge.to}`);
}
await db.workflowBlockToBlock.create({ data: { fromBlockId: fromBlock.id, toBlockId: toBlock.id } });
}
await db.workflowInputExample.create({
data: {
workflowId: workflow.id,
title: definition.inputExample.title,
input: JSON.stringify(definition.inputExample.input, null, 2),
},
});
return { workflow, blocks: createdBlocks };
}
async function seedWorkflow(db: DbClient, tenantId: string, userId: string) {
return seedWorkflowFromDefinition(db, tenantId, userId, buildE2eReadinessManualWorkflowDefinition());
}
async function seedWebhookWorkflow(db: DbClient, tenantId: string, userId: string) {
return seedWorkflowFromDefinition(db, tenantId, userId, buildE2eReadinessWebhookWorkflowDefinition());
}
async function seedWaitingWorkflow(db: DbClient, tenantId: string, userId: string) {
return seedWorkflowFromDefinition(db, tenantId, userId, buildE2eReadinessWaitingWorkflowDefinition());
}
async function seedManualWorkflowExecution(db: DbClient, tenantId: string, userId: string, workflowId: string, logBlockId: string) {
const finishedAt = new Date();
const input = JSON.stringify({ message: "ready" }, null, 2);
const output = JSON.stringify({ log: "E2E readiness ready" }, null, 2);
await db.workflowExecution.deleteMany({ where: { id: E2E_READINESS_WORKFLOW_EXECUTION_ID } });
await db.workflowExecution.create({
data: {
id: E2E_READINESS_WORKFLOW_EXECUTION_ID,
workflowId,
tenantId,
type: "manual",
status: "success",
input,
output,
duration: 42,
endedAt: finishedAt,
createdByUserId: userId,
appliesToAllTenants: false,
blockRuns: {
create: {
id: E2E_READINESS_WORKFLOW_LOG_BLOCK_EXECUTION_ID,
workflowBlockId: logBlockId,
status: "success",
startedAt: finishedAt,
endedAt: finishedAt,
duration: 42,
input,
output,
},
},
},
});
}
async function seedWaitingWorkflowExecution(db: DbClient, tenantId: string, userId: string, workflowId: string, manualBlockId: string, waitBlockId: string) {
const startedAt = new Date();
const input = JSON.stringify({ input: { approved: false } }, null, 2);
const output = JSON.stringify({ waitForInput: { input: { approved: false } } }, null, 2);
await db.workflowExecution.deleteMany({ where: { id: E2E_READINESS_WAITING_EXECUTION_ID } });
await db.workflowExecution.create({
data: {
id: E2E_READINESS_WAITING_EXECUTION_ID,
workflowId,
tenantId,
type: "manual",
status: "waitingBlock",
input,
output,
duration: null,
endedAt: null,
waitingBlockId: waitBlockId,
createdByUserId: userId,
appliesToAllTenants: false,
blockRuns: {
create: {
id: E2E_READINESS_WAITING_MANUAL_BLOCK_EXECUTION_ID,
workflowBlockId: manualBlockId,
status: "success",
startedAt,
endedAt: startedAt,
duration: 1,
input,
output: JSON.stringify({ started: true }, null, 2),
},
},
},
});
}
function cloneJson(value: unknown) {
return JSON.parse(JSON.stringify(value));
}
async function seedEmailSequences(db: DbClient, tenantId: string) {
for (const sequence of buildE2eReadinessEmailSequenceDefinitions(tenantId)) {
await db.emailSequence.upsert({
where: { id: sequence.id },
create: {
id: sequence.id,
tenantId: sequence.tenantId,
name: sequence.name,
description: sequence.description,
triggerEvent: sequence.triggerEvent,
stepsJson: cloneJson(sequence.steps),
conditionsJson: sequence.conditions,
isEnabled: sequence.isEnabled,
},
update: {
tenantId: sequence.tenantId,
name: sequence.name,
description: sequence.description,
triggerEvent: sequence.triggerEvent,
stepsJson: cloneJson(sequence.steps),
conditionsJson: sequence.conditions,
isEnabled: sequence.isEnabled,
},
});
}
}
export async function seedE2eReadiness(env: EnvRecord = process.env) {
assertCanRunE2eReadinessSeed(env);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pluginBasePath = path.resolve(env.PLUGIN_BINARY_BASE_PATH ?? path.join(repoRoot, ".cache/e2e-readiness/plugins"));
const db = getDbClient();
try {
const { user, tenant } = await seedUserAndTenant(db);
await pruneLeakedE2eAccounts(db, user.id, tenant.id);
await seedRoles(db, user.id, tenant.id);
await seedFeatureFlags(db);
await seedNoTenantUser(db);
await seedAdminUser(db);
await seedPluginReadonlyUser(db, tenant.id);
await seedSubscription(db, tenant.id);
await seedEmailSequences(db, tenant.id);
await seedApiReadiness(db, tenant.id, user.id);
await seedPlugin(db, tenant.id, user.id, pluginBasePath);
await seedLifecyclePlugin(db, tenant.id, user.id);
await seedPortal(db, tenant.id, user.id);
const manualWorkflow = await seedWorkflow(db, tenant.id, user.id);
const webhookWorkflow = await seedWebhookWorkflow(db, tenant.id, user.id);
const waitingWorkflow = await seedWaitingWorkflow(db, tenant.id, user.id);
const logBlock = manualWorkflow.blocks.get("log");
if (!logBlock) {
throw new Error("E2E readiness manual workflow log block was not created.");
}
await seedManualWorkflowExecution(db, tenant.id, user.id, manualWorkflow.workflow.id, logBlock.id);
const waitingManualBlock = waitingWorkflow.blocks.get("manual");
const waitingWaitBlock = waitingWorkflow.blocks.get("wait");
if (!waitingManualBlock || !waitingWaitBlock) {
throw new Error("E2E readiness waiting workflow blocks were not created.");
}
await seedWaitingWorkflowExecution(db, tenant.id, user.id, waitingWorkflow.workflow.id, waitingManualBlock.id, waitingWaitBlock.id);
const values = buildE2eReadinessEnv({
tenantSlug: tenant.slug,
workflowId: manualWorkflow.workflow.id,
webhookWorkflowId: webhookWorkflow.workflow.id,
waitingWorkflowId: waitingWorkflow.workflow.id,
waitingExecutionId: E2E_READINESS_WAITING_EXECUTION_ID,
pluginBasePath,
});
const envPath = path.join(repoRoot, ".env.e2e-readiness");
// Writing the local .env.e2e-readiness convenience file is best-effort: when
// the seed runs in-cluster (e.g. a Coolify post-deploy command against a
// production-like validation DB) the working directory is read-only, but the
// DB fixtures have already been committed at this point. A failed env-file
// write must not fail the seed; the fixture values are deterministic anyway.
let envFileWritten = true;
try {
await writeFile(envPath, renderE2eReadinessEnvFile(values), "utf8");
} catch (error) {
envFileWritten = false;
process.stderr.write(`Skipped writing ${envPath}: ${error instanceof Error ? error.message : String(error)}\n`);
}
return { envPath, values, envFileWritten };
} finally {
await db.$disconnect();
}
}
const isDirectExecution = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isDirectExecution) {
seedE2eReadiness()
.then(({ envPath, values }) => {
process.stdout.write(`E2E readiness fixtures seeded.\n`);
process.stdout.write(`Env file: ${envPath}\n`);
process.stdout.write(renderE2eReadinessEnvFile(values));
})
.catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}