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
+10
View File
@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## 1.11.0 - 2026-09-15
Changes since 1.10.1:
### Added
- Added export of a scenario as a standalone, plug-and-play Playwright e2e test project (zip archive with `package.json`, `playwright.config.ts`, `test.spec.ts`, `README.md`, `.env`, `credentials.json`, `snippets/*.js`, and any attached scenario files) that runs entirely independently of the liqa backend. Available as a "Export e2e test" button on the scenario detail page, `GET /scenarios/:id/export-e2e`, and the `export_e2e_test` MCP tool.
- The exported test only bundles the credentials, snippets, and attached files actually referenced (directly or transitively) by the scenario's steps.
- Added a "Used Snippets" section on the scenario detail page listing the snippets referenced by the scenario's steps, linking to each snippet's detail page. Backed by `GET /scenarios/:id/used-snippets`.
## 1.10.1 - 2026-09-15
Changes since 1.10.0:
+6
View File
@@ -261,6 +261,12 @@ export const scenarios = {
body: JSON.stringify(payload),
});
},
exportE2eTestUrl(id: string): string {
return `${BASE_URL}/scenarios/${id}/export-e2e`;
},
usedSnippets(id: string): Promise<Snippet[]> {
return request(`/scenarios/${id}/used-snippets`);
},
};
// ── Runs ─────────────────────────────────────────────────────────────────────
+2
View File
@@ -149,6 +149,7 @@
"action_edit": "Edit",
"action_runs": "Runs",
"action_export": "Export",
"action_export_e2e": "Export e2e test",
"action_import": "Import",
"import_error": "Failed to import scenario",
"action_save": "Create",
@@ -165,6 +166,7 @@
"form_name_placeholder": "e.g. Login flow",
"form_name_required": "Name is required",
"credentials_heading": "Credentials",
"used_snippets_heading": "Used Snippets",
"cred_empty": "No credentials assigned.",
"cred_col_alias": "Alias",
"cred_col_name": "Credential",
@@ -12,6 +12,7 @@ import {
Upload,
GripVertical,
ClipboardList,
FileArchive,
} from 'lucide-react';
import {
environments,
@@ -28,6 +29,7 @@ import type {
Credential,
Environment,
FileMetadata,
Snippet,
} from '../../api';
import {
Breadcrumbs,
@@ -94,6 +96,8 @@ export function ScenarioDetailPage() {
const [uploadExpiresAt, setUploadExpiresAt] = useState('');
const [uploading, setUploading] = useState(false);
const [usedSnippets, setUsedSnippets] = useState<Snippet[]>([]);
const loadFiles = useCallback(async () => {
if (!id) return;
setFilesLoading(true);
@@ -116,6 +120,10 @@ export function ScenarioDetailPage() {
})
.finally(() => setLoading(false));
void loadFiles();
scenarios
.usedSnippets(id)
.then(setUsedSnippets)
.catch(() => {});
credentialsApi
.list(1, 200)
.then((r) => setAllCredentials(r.data))
@@ -142,6 +150,10 @@ export function ScenarioDetailPage() {
const s = await scenarios.get(id);
setScenario(s);
setScenarioCreds(s.scenarioCredentials ?? []);
scenarios
.usedSnippets(id)
.then(setUsedSnippets)
.catch(() => {});
};
const handleRun = async () => {
@@ -456,6 +468,16 @@ export function ScenarioDetailPage() {
<Upload size={14} />
{t('scenarios.action_export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() =>
handleFileDownload(scenarios.exportE2eTestUrl(id!), `${scenario.name}-e2e-test.zip`)
}
>
<FileArchive size={14} />
{t('scenarios.action_export_e2e')}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate(`/scenarios/${id}/edit`)}>
<Pencil size={14} />
{t('scenarios.action_edit')}
@@ -651,6 +673,36 @@ export function ScenarioDetailPage() {
)}
</div>
{/* ── Used snippets section ───────────────────────────────────────── */}
{usedSnippets.length > 0 && (
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
<h2 className={styles.sectionHeading}>{t('scenarios.used_snippets_heading')}</h2>
</div>
<Table<Snippet>
data={usedSnippets}
rowKey={(s) => s.id}
loading={false}
emptyMessage=""
onRowClick={(s) => navigate(`/snippets/${s.id}`)}
columns={
[
{
key: 'alias',
header: t('snippets.field_alias'),
render: (s) => s.alias,
},
{
key: 'title',
header: t('snippets.field_title'),
render: (s) => s.title,
},
] satisfies TableColumn<Snippet>[]
}
/>
</div>
)}
{/* ── Files section ───────────────────────────────────────────────── */}
<div className={styles.stepsSection}>
<div className={styles.sectionToolbar}>
+667 -6
View File
@@ -1,12 +1,12 @@
{
"name": "liqa",
"version": "1.7.1",
"version": "1.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "liqa",
"version": "1.7.1",
"version": "1.10.1",
"workspaces": [
"server",
"client"
@@ -4665,6 +4665,16 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/readdir-glob": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz",
"integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@@ -5565,6 +5575,18 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -5760,6 +5782,201 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
"integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^5.0.2",
"async": "^3.2.4",
"buffer-crc32": "^1.0.0",
"readable-stream": "^4.0.0",
"readdir-glob": "^1.1.2",
"tar-stream": "^3.0.0",
"zip-stream": "^6.0.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/archiver-utils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
"integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
"license": "MIT",
"dependencies": {
"glob": "^10.0.0",
"graceful-fs": "^4.2.0",
"is-stream": "^2.0.1",
"lazystream": "^1.0.0",
"lodash": "^4.17.15",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/archiver-utils/node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/archiver-utils/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/archiver-utils/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/archiver-utils/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/archiver-utils/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/archiver-utils/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/archiver-utils/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/archiver/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/archiver/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/archiver/node_modules/tar-stream": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.1.tgz",
"integrity": "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -5842,6 +6059,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -5874,6 +6097,20 @@
"node": ">=4"
}
},
"node_modules/b4a": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz",
"integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/babel-jest": {
"version": "30.3.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz",
@@ -5989,6 +6226,86 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bare-events": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz",
"integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.1.tgz",
"integrity": "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.28.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-path": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz",
"integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==",
"license": "Apache-2.0"
},
"node_modules/bare-stream": {
"version": "2.13.4",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz",
"integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.8.1",
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -6181,6 +6498,15 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -6680,6 +7006,62 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compress-commons": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz",
"integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"crc32-stream": "^6.0.0",
"is-stream": "^2.0.1",
"normalize-path": "^3.0.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/compress-commons/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/compress-commons/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -6765,6 +7147,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6809,6 +7197,71 @@
}
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc32-stream": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz",
"integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==",
"license": "MIT",
"dependencies": {
"crc-32": "^1.2.0",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/crc32-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/crc32-stream/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
@@ -7951,16 +8404,33 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -8178,6 +8648,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8721,7 +9197,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/handlebars": {
@@ -9336,7 +9811,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10523,6 +10997,54 @@
"json-buffer": "3.0.1"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^2.0.5"
},
"engines": {
"node": ">= 0.6.3"
}
},
"node_modules/lazystream/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/lazystream/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/lazystream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -12011,7 +12533,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -12637,6 +13158,21 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -12939,6 +13475,36 @@
"node": ">= 6"
}
},
"node_modules/readdir-glob": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.1.0"
}
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -13813,6 +14379,17 @@
"node": ">=10.0.0"
}
},
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
"integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -14140,6 +14717,15 @@
"node": ">=6"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/terser": {
"version": "5.46.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
@@ -14275,6 +14861,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -15974,6 +16569,60 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
"integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==",
"license": "MIT",
"dependencies": {
"archiver-utils": "^5.0.0",
"compress-commons": "^6.0.2",
"readable-stream": "^4.0.0"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/zip-stream/node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/zip-stream/node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
@@ -16031,6 +16680,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",
@@ -16048,6 +16698,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",
@@ -16067,6 +16718,16 @@
"typescript-eslint": "^8.58.1"
}
},
"server/node_modules/@types/archiver": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
"integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/readdir-glob": "*"
}
},
"server/node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "liqa",
"version": "1.10.1",
"version": "1.11.0",
"private": true,
"workspaces": [
"server",
+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",