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:
Andrii Arsenin
2026-09-16 12:33:16 +03:00
parent 85a87b5fb2
commit 69d16c9a94
14 changed files with 1348 additions and 11 deletions
+2
View File
@@ -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",
+34
View File
@@ -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;
}
+36
View File
@@ -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(
+473
View File
@@ -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();
});
}
}
+32 -1
View File
@@ -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")
+3 -2
View File
@@ -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 {}
+23
View File
@@ -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(
+7 -1
View File
@@ -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",