feat(scenario): export scenarios as standalone playwright e2e test projects
- Added E2eExportService to package scenarios as plug-and-play zip archives containing package.json, playwright.config.ts, test.spec.ts, .env, credentials.json, snippets, and referenced scenario files - Only bundles credentials, snippets, and files actually referenced (directly or transitively) by the scenario's steps, reducing archive size - Exported test runs entirely offline using a context shim that mirrors liqa's API (page, getCredential, runSnippet, getScenarioFiles, downloadFile, assert, etc.) - Added GET /scenarios/:id/export-e2e HTTP endpoint and export_e2e_test MCP tool - Added getUsedSnippets() endpoint to list snippets referenced by a scenario - Added "Used Snippets" section on scenario detail page - Added archiver@^7.0.1 dependency for zip archive creation - Bumped version to 1.11.0
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"acorn": "^8.16.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
@@ -46,6 +47,7 @@
|
||||
"@nestjs/cli": "^11.0.17",
|
||||
"@nestjs/testing": "^11.1.18",
|
||||
"@types/acorn": "^4.0.6",
|
||||
"@types/archiver": "^6.0.4",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/debug": "^4.1.13",
|
||||
"@types/express": "^5.0.6",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Extracts the alias literals passed to `runSnippet("alias", ...)` calls in `code`. */
|
||||
export function extractRunSnippetAliases(code: string): string[] {
|
||||
const aliases: string[] = [];
|
||||
const regex = /runSnippet\s*\(\s*(['"`])((?:(?!\1).)*)\1/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
aliases.push(match[2]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the transitive closure of snippet aliases referenced (directly, or via
|
||||
* other snippets) by `stepCodes`, against the full alias -> code map. Aliases with
|
||||
* no matching entry in `snippetCodeByAlias` are dropped. Returned in discovery order.
|
||||
*/
|
||||
export function resolveUsedSnippetAliases(
|
||||
stepCodes: string[],
|
||||
snippetCodeByAlias: Record<string, string>,
|
||||
): string[] {
|
||||
const used: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const queue = stepCodes.flatMap((code) => extractRunSnippetAliases(code));
|
||||
while (queue.length > 0) {
|
||||
const alias = queue.shift()!;
|
||||
if (seen.has(alias)) continue;
|
||||
seen.add(alias);
|
||||
const code = snippetCodeByAlias[alias];
|
||||
if (code == null) continue;
|
||||
used.push(alias);
|
||||
queue.push(...extractRunSnippetAliases(code));
|
||||
}
|
||||
return used;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { CredentialService } from "../credential/credential.service";
|
||||
import { BrowserService } from "../browser/browser.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { ScenarioService } from "../scenario/scenario.service";
|
||||
import { E2eExportService } from "../scenario/e2e-export.service";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
|
||||
import pkg from "../../package.json";
|
||||
@@ -25,6 +26,7 @@ export class McpService {
|
||||
private readonly browserService: BrowserService,
|
||||
private readonly codeExecutor: CodeExecutorService,
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly e2eExportService: E2eExportService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
@@ -1062,6 +1064,40 @@ export class McpService {
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"export_e2e_test",
|
||||
{
|
||||
description:
|
||||
"Export a scenario as a standalone, plug-and-play Playwright e2e test project (package.json, playwright.config.ts, test.spec.ts, .env), zipped and returned as base64",
|
||||
inputSchema: {
|
||||
id: z.uuid().describe("Scenario ID to export"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const { filename, buffer } =
|
||||
await this.e2eExportService.buildE2eTestPackage(id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
filename,
|
||||
contentBase64: buffer.toString("base64"),
|
||||
encoding: "base64",
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Scenario Files ────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import archiver = require("archiver");
|
||||
import { resolveUsedSnippetAliases } from "../common/snippet-usage";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
|
||||
const DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
|
||||
const DEFAULT_STEP_TIMEOUT_SEC = 60;
|
||||
|
||||
function slugify(name: string): string {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug || "scenario";
|
||||
}
|
||||
|
||||
function jsStringLiteral(code: string): string {
|
||||
return JSON.stringify(code);
|
||||
}
|
||||
|
||||
function sanitizeAlias(alias: string): string {
|
||||
const sanitized = alias.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return sanitized || "snippet";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a standalone, plug-and-play Playwright test project (package.json,
|
||||
* playwright.config.ts, test.spec.ts, .env, README) from a scenario's steps,
|
||||
* zipped for download. Each step's execCode is embedded verbatim and run
|
||||
* against a `context` shim that mirrors the shape scripts already use in
|
||||
* liqa (env, credentials, snippets, assert, ...).
|
||||
*/
|
||||
@Injectable()
|
||||
export class E2eExportService {
|
||||
constructor(
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
async buildE2eTestPackage(scenarioId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const scenario = await this.scenarioService.findOne(scenarioId);
|
||||
const [credentialMap, snippetMap, attachedFiles] = await Promise.all([
|
||||
this.scenarioService.buildCredentialMap(scenarioId),
|
||||
this.snippetService.buildSnippetMap(),
|
||||
this.scenarioService.listScenarioFiles(scenarioId, 1000, 0),
|
||||
]);
|
||||
const envData = scenario.environment?.data ?? {};
|
||||
|
||||
const slug = slugify(scenario.name);
|
||||
const files: Record<string, string | Buffer> = {
|
||||
"package.json": this.buildPackageJson(slug),
|
||||
"playwright.config.ts": this.buildPlaywrightConfig(scenario.timeoutSeconds),
|
||||
".env": this.buildEnvFile(envData),
|
||||
"credentials.json": JSON.stringify(credentialMap, null, 2) + "\n",
|
||||
".gitignore": [
|
||||
"node_modules/",
|
||||
"test-results/",
|
||||
"playwright-report/",
|
||||
"downloads/",
|
||||
".env",
|
||||
"credentials.json",
|
||||
].join("\n") + "\n",
|
||||
"README.md": this.buildReadme(scenario.name, scenario.description),
|
||||
};
|
||||
|
||||
// Bundle any files attached to the scenario so getScenarioFiles works offline.
|
||||
const fileManifest: { id: string; name: string; mimeType: string; size: number; sha256: string; zipPath: string }[] =
|
||||
[];
|
||||
for (const item of attachedFiles.items as {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
}[]) {
|
||||
const { contentBuffer } = await this.scenarioService.getScenarioFileContentAsBuffer(
|
||||
scenarioId,
|
||||
item.id,
|
||||
);
|
||||
const zipPath = `files/${item.id}__${item.name}`;
|
||||
files[zipPath] = contentBuffer;
|
||||
fileManifest.push({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
mimeType: item.mimeType,
|
||||
size: item.size,
|
||||
sha256: item.sha256,
|
||||
zipPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Bundle only the snippets actually referenced (directly or transitively via
|
||||
// other snippets) by this scenario's steps, as individual .js files under snippets/.
|
||||
const usedAliases = resolveUsedSnippetAliases(
|
||||
scenario.steps.map((s) => s.execCode ?? ""),
|
||||
snippetMap,
|
||||
);
|
||||
const snippetManifest: { alias: string; filename: string }[] = [];
|
||||
const usedSnippetFilenames = new Set<string>();
|
||||
for (const alias of usedAliases) {
|
||||
const code = snippetMap[alias];
|
||||
const base = sanitizeAlias(alias);
|
||||
let filename = `${base}.js`;
|
||||
let suffix = 2;
|
||||
while (usedSnippetFilenames.has(filename)) {
|
||||
filename = `${base}-${suffix}.js`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedSnippetFilenames.add(filename);
|
||||
files[`snippets/${filename}`] = code;
|
||||
snippetManifest.push({ alias, filename });
|
||||
}
|
||||
|
||||
files["test.spec.ts"] = this.buildTestSpec(scenario, snippetManifest, fileManifest);
|
||||
|
||||
const buffer = await this.zip(files);
|
||||
return { filename: `${slug}-e2e-test.zip`, buffer };
|
||||
}
|
||||
|
||||
private buildPackageJson(slug: string): string {
|
||||
return (
|
||||
JSON.stringify(
|
||||
{
|
||||
name: `${slug}-e2e-test`,
|
||||
version: "1.0.0",
|
||||
private: true,
|
||||
scripts: {
|
||||
test: "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
},
|
||||
devDependencies: {
|
||||
"@playwright/test": "^1.59.1",
|
||||
dotenv: "^17.4.2",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
private buildPlaywrightConfig(scenarioTimeoutSeconds: number | null): string {
|
||||
const timeoutMs = (scenarioTimeoutSeconds ?? DEFAULT_SCENARIO_TIMEOUT_SEC) * 1000;
|
||||
return `import { defineConfig } from "@playwright/test";
|
||||
import * as dotenv from "dotenv";
|
||||
import * as path from "path";
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, ".env") });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
timeout: ${timeoutMs},
|
||||
use: {
|
||||
headless: true,
|
||||
},
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
private buildEnvFile(envData: Record<string, string>): string {
|
||||
const lines = Object.entries(envData).map(([key, value]) => `${key}=${value}`);
|
||||
return (
|
||||
[
|
||||
"# Environment values copied from the scenario's liqa environment.",
|
||||
"# This file may contain secrets - do not commit it.",
|
||||
...lines,
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
private buildReadme(scenarioName: string, description: string | null): string {
|
||||
const descriptionSection = description?.trim() ? `${description.trim()}\n\n` : "";
|
||||
return `# ${scenarioName} - standalone e2e test
|
||||
|
||||
${descriptionSection}## Setup
|
||||
|
||||
\`\`\`sh
|
||||
npm install
|
||||
npx playwright install --with-deps chromium
|
||||
\`\`\`
|
||||
|
||||
## Run
|
||||
|
||||
\`\`\`sh
|
||||
npm test
|
||||
\`\`\`
|
||||
|
||||
Environment values are loaded from \`.env\` (as plain process env vars). Credential
|
||||
data resolved from this scenario's aliases is stored in \`credentials.json\` and
|
||||
loaded by \`context.getCredential\`. Snippet bodies are stored as individual \`.js\`
|
||||
files under \`snippets/\` and loaded by \`context.runSnippet\`. Files attached to
|
||||
the scenario are bundled under \`files/\` and served locally by
|
||||
\`context.getScenarioFiles\`. \`context.downloadFile\` performs a real HTTP (or
|
||||
\`data:\` URL) download and saves the result under \`downloads/\`.
|
||||
`;
|
||||
}
|
||||
|
||||
private buildTestSpec(
|
||||
scenario: { name: string; timeoutSeconds: number | null; steps: { order: number; title: string | null; execCode: string | null; timeoutSeconds: number | null }[] },
|
||||
snippetManifest: { alias: string; filename: string }[],
|
||||
fileManifest: { id: string; name: string; mimeType: string; size: number; sha256: string; zipPath: string }[],
|
||||
): string {
|
||||
const steps = [...scenario.steps].sort((a, b) => a.order - b.order);
|
||||
const scenarioTimeoutSec = scenario.timeoutSeconds ?? DEFAULT_SCENARIO_TIMEOUT_SEC;
|
||||
|
||||
const stepBlocks = steps
|
||||
.map((step, index) => {
|
||||
const title = (step.title ?? `Step ${index + 1}`).replace(/`/g, "\\`");
|
||||
const timeoutSec = step.timeoutSeconds ?? scenarioTimeoutSec ?? DEFAULT_STEP_TIMEOUT_SEC;
|
||||
const code = step.execCode ?? "";
|
||||
return ` await test.step(\`${title}\`, async () => {
|
||||
const __run = new Function(
|
||||
"context",
|
||||
"console",
|
||||
"result",
|
||||
"expect",
|
||||
\`return (async (context) => { ${code.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")} })(context)\`,
|
||||
);
|
||||
const __timeoutMs = ${timeoutSec * 1000};
|
||||
const __output = await Promise.race([
|
||||
__run(context, console, undefined, expect),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(\`Step timed out after ${timeoutSec}s\`)), __timeoutMs),
|
||||
),
|
||||
]);
|
||||
stepOutputs.push({ order: ${step.order}, output: __output });
|
||||
});`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `import { test, expect } from "@playwright/test";
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import { URL } from "url";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
// Credential data resolved from this scenario's aliases at export time.
|
||||
// Stored in credentials.json (gitignored) - contains sensitive values.
|
||||
const CREDENTIALS: Record<string, unknown> = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, "credentials.json"), "utf-8"),
|
||||
);
|
||||
|
||||
// Snippet bodies are stored as individual .js files under snippets/, available
|
||||
// via context.runSnippet(alias, ...args).
|
||||
const SNIPPET_FILES: { alias: string; filename: string }[] = ${JSON.stringify(snippetManifest, null, 2)};
|
||||
|
||||
// Files attached to the scenario, bundled alongside this test under files/.
|
||||
const FILES: { id: string; name: string; mimeType: string; size: number; sha256: string; zipPath: string }[] =
|
||||
${JSON.stringify(fileManifest, null, 2)};
|
||||
|
||||
interface StepOutput {
|
||||
order: number;
|
||||
output: unknown;
|
||||
}
|
||||
|
||||
// ── Standalone downloadFile: real HTTP/data: URL fetch, no liqa backend involved ──
|
||||
|
||||
const MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
function decodeDataUrl(urlStr: string): Buffer {
|
||||
const match = /^data:([^,]*),(.*)$/s.exec(urlStr);
|
||||
if (!match) {
|
||||
throw new Error("Malformed data: URL");
|
||||
}
|
||||
const [, meta, data] = match;
|
||||
if (meta.endsWith(";base64")) {
|
||||
return Buffer.from(data, "base64");
|
||||
}
|
||||
return Buffer.from(decodeURIComponent(data), "utf-8");
|
||||
}
|
||||
|
||||
function fetchBytes(
|
||||
urlStr: string,
|
||||
options?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<Buffer> {
|
||||
if (urlStr.startsWith("data:")) {
|
||||
return Promise.resolve(decodeDataUrl(urlStr));
|
||||
}
|
||||
const url = new URL(urlStr);
|
||||
const protocol = url.protocol === "https:" ? https : http;
|
||||
const method = options?.method ?? "GET";
|
||||
const headers = options?.headers ?? {};
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = protocol.request(
|
||||
{ hostname: url.hostname, port: url.port, path: url.pathname + url.search, method, headers, timeout: DOWNLOAD_TIMEOUT_MS },
|
||||
(res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
fetchBytes(res.headers.location, options).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
if (res.statusCode && res.statusCode !== 200) {
|
||||
reject(new Error(\`HTTP \${res.statusCode}: \${res.statusMessage}\`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_DOWNLOAD_SIZE) {
|
||||
req.destroy();
|
||||
reject(new Error(\`File exceeds maximum size of \${MAX_DOWNLOAD_SIZE} bytes\`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
res.on("error", reject);
|
||||
},
|
||||
);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("Download timeout"));
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (options?.body && (method === "POST" || method === "PUT")) {
|
||||
req.write(options.body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
csv: "text/csv",
|
||||
txt: "text/plain",
|
||||
zip: "application/zip",
|
||||
};
|
||||
|
||||
function inferMimeType(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return MIME_TYPES[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
function buildContext(page: Page, browser: BrowserContext, stepOutputs: StepOutput[]) {
|
||||
const context: Record<string, unknown> = {
|
||||
page,
|
||||
browser,
|
||||
env: { ...process.env },
|
||||
getEnv(key: string): string {
|
||||
const value = process.env[key];
|
||||
if (value == null) {
|
||||
throw new Error(\`Environment value "\${key}" is not defined\`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
getCredential(alias: string): unknown {
|
||||
if (!(alias in CREDENTIALS)) {
|
||||
throw new Error(\`Credential alias "\${alias}" not found in this export\`);
|
||||
}
|
||||
return CREDENTIALS[alias];
|
||||
},
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
error: (...args: unknown[]) => console.error(...args),
|
||||
assert: async (fn: () => boolean | Promise<boolean>, description: string): Promise<void> => {
|
||||
let passed: boolean;
|
||||
let failureReason: string | undefined;
|
||||
try {
|
||||
passed = (await fn()) === true;
|
||||
} catch (err) {
|
||||
passed = false;
|
||||
failureReason = (err as Error).message;
|
||||
}
|
||||
if (passed) {
|
||||
console.log(\`Assertion passed: \${description}\`);
|
||||
return;
|
||||
}
|
||||
const message = failureReason
|
||||
? \`Assertion failed: \${description} (\${failureReason})\`
|
||||
: \`Assertion failed: \${description}\`;
|
||||
console.error(message);
|
||||
throw new Error(message);
|
||||
},
|
||||
getStepOutput: async (order: number): Promise<unknown> => {
|
||||
const targetOrder = order < 0 ? stepOutputs.length + order + 1 : order;
|
||||
return stepOutputs.find((s) => s.order === targetOrder)?.output ?? null;
|
||||
},
|
||||
runSnippet: async (alias: string, ...args: unknown[]): Promise<unknown> => {
|
||||
const entry = SNIPPET_FILES.find((s) => s.alias === alias);
|
||||
if (!entry) {
|
||||
throw new Error(\`Snippet alias "\${alias}" not found\`);
|
||||
}
|
||||
const snippetCode = fs.readFileSync(path.resolve(__dirname, "snippets", entry.filename), "utf-8");
|
||||
const snippetFn = new Function(
|
||||
"context",
|
||||
"console",
|
||||
"snippetArgs",
|
||||
"expect",
|
||||
\`return (async (context, ...args) => { \${snippetCode} })(context, ...snippetArgs)\`,
|
||||
);
|
||||
return snippetFn(context, console, args, expect);
|
||||
},
|
||||
dumpDom: async (): Promise<never> => {
|
||||
throw new Error("dumpDom is not available in this standalone export");
|
||||
},
|
||||
getScenarioFiles: async (opts?: { limit?: number; offset?: number }) => {
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limit = opts?.limit ?? FILES.length;
|
||||
const items = FILES.slice(offset, offset + limit).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
sha256: f.sha256,
|
||||
path: path.resolve(__dirname, f.zipPath),
|
||||
}));
|
||||
return { items, total: FILES.length };
|
||||
},
|
||||
downloadFile: async (
|
||||
url: string,
|
||||
opts?: { method?: string; headers?: Record<string, string>; body?: string; filename?: string },
|
||||
) => {
|
||||
const buffer = await fetchBytes(url, opts);
|
||||
const filename =
|
||||
opts?.filename ??
|
||||
(url.startsWith("data:") ? "download" : new URL(url).pathname.split("/").pop() || "download");
|
||||
const downloadsDir = path.resolve(__dirname, "downloads");
|
||||
fs.mkdirSync(downloadsDir, { recursive: true });
|
||||
const filePath = path.join(downloadsDir, filename);
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return {
|
||||
id: randomUUID(),
|
||||
name: filename,
|
||||
mimeType: inferMimeType(filename),
|
||||
size: buffer.length,
|
||||
sha256: crypto.createHash("sha256").update(buffer).digest("hex"),
|
||||
path: filePath,
|
||||
};
|
||||
},
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
test.describe(${jsStringLiteral(scenario.name)}, () => {
|
||||
test(${jsStringLiteral(scenario.name)}, async ({ page, context: browserContext }) => {
|
||||
const stepOutputs: StepOutput[] = [];
|
||||
const context = buildContext(page, browserContext, stepOutputs);
|
||||
|
||||
${stepBlocks}
|
||||
});
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
private zip(files: Record<string, string | Buffer>): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const archive = archiver("zip", { zlib: { level: 9 } });
|
||||
const chunks: Buffer[] = [];
|
||||
archive.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
archive.on("error", (err) => reject(err));
|
||||
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
archive.append(content, { name });
|
||||
}
|
||||
void archive.finalize();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,15 @@ import { ExportEntity } from "./dto/scenario-export.dto";
|
||||
import { UpdateScenarioStepDto } from "./dto/update-scenario-step.dto";
|
||||
import { UpdateScenarioDto } from "./dto/update-scenario.dto";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { E2eExportService } from "./e2e-export.service";
|
||||
|
||||
@ApiTags("scenarios")
|
||||
@Controller("scenarios")
|
||||
export class ScenarioController {
|
||||
constructor(private readonly scenarioService: ScenarioService) {}
|
||||
constructor(
|
||||
private readonly scenarioService: ScenarioService,
|
||||
private readonly e2eExportService: E2eExportService,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -130,6 +134,23 @@ export class ScenarioController {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@Get(":id/export-e2e")
|
||||
@ApiOperation({
|
||||
summary: "Export a scenario as a standalone, plug-and-play Playwright e2e test (zip)",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Zip archive" })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
async exportE2eTest(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.e2eExportService.buildE2eTestPackage(id);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
// ── Steps ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Post(":id/steps")
|
||||
@@ -178,6 +199,16 @@ export class ScenarioController {
|
||||
return this.scenarioService.removeStep(id, stepId);
|
||||
}
|
||||
|
||||
@Get(":id/used-snippets")
|
||||
@ApiOperation({
|
||||
summary: "List snippets referenced (directly or transitively) by this scenario's steps",
|
||||
})
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario not found" })
|
||||
getUsedSnippets(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.scenarioService.getUsedSnippets(id);
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────────────────
|
||||
|
||||
@Get(":id/credentials")
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ScenarioStepEntity } from "./scenario-step.entity";
|
||||
import { ScenarioController } from "./scenario.controller";
|
||||
import { ScenarioEntity } from "./scenario.entity";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { E2eExportService } from "./e2e-export.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -40,7 +41,7 @@ import { ScenarioService } from "./scenario.service";
|
||||
FileModule,
|
||||
],
|
||||
controllers: [ScenarioController],
|
||||
providers: [ScenarioService, ScenarioSchedulerService],
|
||||
exports: [ScenarioService],
|
||||
providers: [ScenarioService, ScenarioSchedulerService, E2eExportService],
|
||||
exports: [ScenarioService, E2eExportService],
|
||||
})
|
||||
export class ScenarioModule {}
|
||||
|
||||
@@ -12,11 +12,14 @@ import {
|
||||
PaginatedResult,
|
||||
PaginationQueryDto,
|
||||
} from "../common/dto/pagination.dto";
|
||||
import { resolveUsedSnippetAliases } from "../common/snippet-usage";
|
||||
import { CredentialEntity } from "../credential/credential.entity";
|
||||
import { EnvironmentEntity } from "../environment/environment.entity";
|
||||
import { FileStorageService } from "../file/file-storage.service";
|
||||
import { ScenarioFileEntity } from "../file/scenario-file.entity";
|
||||
import { ScenarioRunFileEntity } from "../file/scenario-run-file.entity";
|
||||
import { SnippetEntity } from "../snippet/snippet.entity";
|
||||
import { SnippetService } from "../snippet/snippet.service";
|
||||
import { AddScenarioCredentialDto } from "./dto/add-scenario-credential.dto";
|
||||
import { CreateScenarioStepDto } from "./dto/create-scenario-step.dto";
|
||||
import { CreateScenarioDto } from "./dto/create-scenario.dto";
|
||||
@@ -65,6 +68,7 @@ export class ScenarioService {
|
||||
@InjectRepository(ScenarioRunFileEntity)
|
||||
private readonly scenarioRunFileRepo: Repository<ScenarioRunFileEntity>,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly snippetService: SnippetService,
|
||||
) {}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
@@ -332,6 +336,25 @@ export class ScenarioService {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the snippets referenced (directly, or transitively via other snippets)
|
||||
* by this scenario's step code, in discovery order - for linking to them from the UI.
|
||||
*/
|
||||
async getUsedSnippets(scenarioId: string): Promise<SnippetEntity[]> {
|
||||
const scenario = await this.findOne(scenarioId);
|
||||
const snippetMap = await this.snippetService.buildSnippetMap();
|
||||
const usedAliases = resolveUsedSnippetAliases(
|
||||
scenario.steps.map((s) => s.execCode ?? ""),
|
||||
snippetMap,
|
||||
);
|
||||
if (usedAliases.length === 0) return [];
|
||||
const snippets = await this.snippetService.findByAliases(usedAliases);
|
||||
const byAlias = new Map(snippets.map((s) => [s.alias, s]));
|
||||
return usedAliases
|
||||
.map((alias) => byAlias.get(alias))
|
||||
.filter((s): s is SnippetEntity => s != null);
|
||||
}
|
||||
|
||||
// ── Runs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async findRuns(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
import {
|
||||
PaginatedResult,
|
||||
PaginationQueryDto,
|
||||
@@ -111,6 +111,12 @@ export class SnippetService implements OnModuleInit {
|
||||
return Object.fromEntries(data.map((s) => [s.alias, s.code]));
|
||||
}
|
||||
|
||||
/** Returns the snippet entities matching the given aliases (used by executor/executor consumers). */
|
||||
async findByAliases(aliases: string[]): Promise<SnippetEntity[]> {
|
||||
if (aliases.length === 0) return [];
|
||||
return this.repo.find({ where: { alias: In(aliases) } });
|
||||
}
|
||||
|
||||
exportSnippet(snippet: SnippetEntity): SnippetExportDto {
|
||||
return {
|
||||
kind: "snippet",
|
||||
|
||||
Reference in New Issue
Block a user