Compare commits
8
Commits
502a3e4f2c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aad413852 | ||
|
|
47529b2c1e | ||
|
|
69d16c9a94 | ||
|
|
85a87b5fb2 | ||
|
|
ce85aa02f1 | ||
|
|
f1aac987db | ||
|
|
715afd2151 | ||
|
|
622dfdbfb0 |
@@ -2,6 +2,73 @@
|
||||
|
||||
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:
|
||||
|
||||
### Added
|
||||
|
||||
- `context.runSnippet()` now logs "Snippet started"/"finished"/"failed" entries, matching the automatic step start/pass/fail logging.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the Markdown log export format: each log entry is now its own `## [level] timestamp` section with the message as its body, instead of a single large table.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Run logs could persist out of call order (e.g. a snippet's "started" log appearing after its "finished" log) because writes were fire-and-forget and raced against each other. Log entries now carry a per-run sequence number assigned in call order, and queries/exports sort by it.
|
||||
|
||||
## 1.10.0 - 2026-09-15
|
||||
|
||||
Changes since 1.9.1:
|
||||
|
||||
### Added
|
||||
|
||||
- Added a "Download logs" button on the scenario run detail page: opens a modal to choose Markdown (default) or CSV, then downloads the run's logs in that format.
|
||||
- Added `GET /scenarios/:id/run/:runId/logs/export?format=csv|md` backend endpoint to export a run's logs.
|
||||
|
||||
## 1.9.1 - 2026-09-15
|
||||
|
||||
Changes since 1.9.0:
|
||||
|
||||
### Added
|
||||
|
||||
- Scenario run logs now include an automatic entry when a step starts and when it finishes (pass or fail), so step boundaries are visible in run logs even for steps that don't call `context.log`/`context.assert` themselves.
|
||||
|
||||
## 1.9.0 - 2026-09-15
|
||||
|
||||
Changes since 1.8.0:
|
||||
|
||||
### Added
|
||||
|
||||
- Added `context.assert(fn, description)` for scenario/snippet script steps: runs the given function, requires it to return `true`, logs the outcome (`log` on success, `error` on failure or exception), and throws to stop the step on failure.
|
||||
|
||||
### Changed
|
||||
|
||||
- Documented `context.assert` in `docs/scenario.md`.
|
||||
|
||||
## 1.8.0 - 2026-09-15
|
||||
|
||||
Changes since 1.7.1:
|
||||
|
||||
### Added
|
||||
|
||||
- `context.downloadFile()` now accepts `data:` URIs, so scenario steps can save in-script generated content (e.g. a credential JWT) as a run artifact without a network fetch.
|
||||
|
||||
### Changed
|
||||
|
||||
- Documented the full scenario step `context` file API (`getScenarioFiles`, `downloadFile`) in `docs/scenario.md`.
|
||||
|
||||
## 1.7.1 - 2026-04-21
|
||||
|
||||
Changes since 1.7.0:
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -292,6 +298,9 @@ export const runs = {
|
||||
const qs = q?.trim() ? `?q=${encodeURIComponent(q.trim())}` : '';
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}${qs}`);
|
||||
},
|
||||
exportLogs(scenarioId: string, runId: string, format: 'csv' | 'md'): Promise<string> {
|
||||
return request(`/scenarios/${scenarioId}/run/${runId}/logs/export?format=${format}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Scenario Credentials ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #888);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.radioRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radioRow input[type='radio'] {
|
||||
cursor: pointer;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Download } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import styles from './ExportLogsModal.module.css';
|
||||
|
||||
export type LogExportFormat = 'md' | 'csv';
|
||||
|
||||
export interface ExportLogsModalProps {
|
||||
open: boolean;
|
||||
format: LogExportFormat;
|
||||
onFormatChange: (format: LogExportFormat) => void;
|
||||
onClose: () => void;
|
||||
onExport: () => void;
|
||||
isExporting: boolean;
|
||||
}
|
||||
|
||||
export function ExportLogsModal({
|
||||
open,
|
||||
format,
|
||||
onFormatChange,
|
||||
onClose,
|
||||
onExport,
|
||||
isExporting,
|
||||
}: ExportLogsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('runs.export_logs_modal_title')}
|
||||
onClose={() => !isExporting && onClose()}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={isExporting}>
|
||||
<X size={14} />
|
||||
{t('common.button_cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onExport} disabled={isExporting}>
|
||||
<Download size={14} />
|
||||
{t('runs.action_export')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>{t('runs.export_logs_format')}</h4>
|
||||
<label className={styles.radioRow}>
|
||||
<input
|
||||
type="radio"
|
||||
name="log-export-format"
|
||||
checked={format === 'md'}
|
||||
onChange={() => onFormatChange('md')}
|
||||
/>
|
||||
<span>{t('runs.export_logs_format_md')}</span>
|
||||
</label>
|
||||
<label className={styles.radioRow}>
|
||||
<input
|
||||
type="radio"
|
||||
name="log-export-format"
|
||||
checked={format === 'csv'}
|
||||
onChange={() => onFormatChange('csv')}
|
||||
/>
|
||||
<span>{t('runs.export_logs_format_csv')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,5 @@ export { ExportScenarioModal } from './ExportScenarioModal';
|
||||
export type { ExportScenarioModalProps } from './ExportScenarioModal';
|
||||
export { RunScenarioModal } from './RunScenarioModal';
|
||||
export type { RunScenarioModalProps } from './RunScenarioModal';
|
||||
export { ExportLogsModal } from './ExportLogsModal';
|
||||
export type { ExportLogsModalProps, LogExportFormat } from './ExportLogsModal';
|
||||
|
||||
@@ -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",
|
||||
@@ -257,7 +259,13 @@
|
||||
"step_description": "Result",
|
||||
"log_level": "Level",
|
||||
"log_message": "Message",
|
||||
"log_time": "Time"
|
||||
"log_time": "Time",
|
||||
"action_download_logs": "Download logs",
|
||||
"export_logs_modal_title": "Export logs",
|
||||
"export_logs_format": "Format",
|
||||
"export_logs_format_md": "Markdown (.md)",
|
||||
"export_logs_format_csv": "CSV (.csv)",
|
||||
"action_export": "Export"
|
||||
},
|
||||
"snippets": {
|
||||
"title": "Snippets",
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
FileMetadata,
|
||||
} from '../../api';
|
||||
import { scenarios } from '../../api';
|
||||
import { ExportLogsModal, type LogExportFormat } from '../../components/modals';
|
||||
import {
|
||||
AutoRefreshIndicator,
|
||||
Badge,
|
||||
@@ -84,6 +85,9 @@ export function RunDetailPage() {
|
||||
const [artifacts, setArtifacts] = useState<FileMetadata[]>([]);
|
||||
const [artifactsTotal, setArtifactsTotal] = useState(0);
|
||||
const [artifactsLoading, setArtifactsLoading] = useState(false);
|
||||
const [exportLogsOpen, setExportLogsOpen] = useState(false);
|
||||
const [exportLogsFormat, setExportLogsFormat] = useState<LogExportFormat>('md');
|
||||
const [exportingLogs, setExportingLogs] = useState(false);
|
||||
const logSearchRef = useRef('');
|
||||
const LOG_PAGE_SIZE = 25;
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -106,6 +110,27 @@ export function RunDetailPage() {
|
||||
const isFileExpired = (file: FileMetadata) =>
|
||||
file.expiresAt != null && new Date(file.expiresAt).getTime() <= Date.now();
|
||||
|
||||
const handleExportLogs = async () => {
|
||||
if (!id || !runId) return;
|
||||
setExportingLogs(true);
|
||||
try {
|
||||
const content = await runs.exportLogs(id, runId, exportLogsFormat);
|
||||
const mimeType = exportLogsFormat === 'csv' ? 'text/csv' : 'text/markdown';
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `run-${runId}-logs.${exportLogsFormat}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportLogsOpen(false);
|
||||
} catch {
|
||||
// error toast is already emitted by the API client
|
||||
} finally {
|
||||
setExportingLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const manualRefresh = () => {
|
||||
if (!id || !runId) return;
|
||||
runs
|
||||
@@ -436,11 +461,17 @@ export function RunDetailPage() {
|
||||
<div className={styles.stepsSection}>
|
||||
<div className={styles.sectionHeadingRow}>
|
||||
<h2 className={styles.sectionHeading}>{t('runs.logs_heading')}</h2>
|
||||
<Search
|
||||
value={logSearch}
|
||||
onChange={setLogSearch}
|
||||
placeholder={t('runs.logs_search_placeholder')}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Search
|
||||
value={logSearch}
|
||||
onChange={setLogSearch}
|
||||
placeholder={t('runs.logs_search_placeholder')}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={() => setExportLogsOpen(true)}>
|
||||
<Download size={14} />
|
||||
{t('runs.action_download_logs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
columns={logColumns}
|
||||
@@ -523,6 +554,15 @@ export function RunDetailPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExportLogsModal
|
||||
open={exportLogsOpen}
|
||||
format={exportLogsFormat}
|
||||
onFormatChange={setExportLogsFormat}
|
||||
onClose={() => setExportLogsOpen(false)}
|
||||
onExport={handleExportLogs}
|
||||
isExporting={exportingLogs}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -35,7 +35,10 @@ Available in scope:
|
||||
- `context.getStepOutput(order)`: prior step output by absolute order (`1`, `2`, ...) or relative (`-1` = previous step)
|
||||
- `context.dumpDom(selector?)`: simplified DOM snapshot
|
||||
- `context.log(...args)`, `context.warn(...args)`, `context.error(...args)`: structured step logs
|
||||
- `context.assert(() => boolean, description)`: runs the arrow function and requires it to return `true`. Logs `description` (as a `log` entry on success, as an `error` entry on failure or exception), then throws to stop the step if the function returned anything other than `true` or threw
|
||||
- `context.runSnippet(name, ...args)`: execute stored snippet code with the same context
|
||||
- `context.getScenarioFiles(opts?)`: list files uploaded to the scenario (`limit`, `offset`)
|
||||
- `context.downloadFile(url, opts?)`: fetch `url` and save the result as a run artifact (requires a real scenario run — throws when invoked ad hoc, e.g. via the `exec_code` MCP tool). `opts` may include `method`, `headers`, `body`, `filename`. `url` also accepts a `data:` URI (`data:<mediaType>;base64,<data>` or `data:<mediaType>,<percent-encoded data>`) to save content generated in-script (e.g. a credential JWT) directly, without an actual network fetch.
|
||||
- `console`: proxied to run logs (`log`, `warn`, `error`, etc.)
|
||||
|
||||
`validateCode` additionally receives `result` in scope, which is the value returned by `execCode`.
|
||||
|
||||
Generated
+667
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.7.1",
|
||||
"version": "1.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "liqa",
|
||||
"version": "1.7.1",
|
||||
"version": "1.12.0",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "liqa",
|
||||
"version": "1.7.1",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -42,6 +42,15 @@ export interface ScriptContext {
|
||||
log: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
/**
|
||||
* Runs `fn` and requires it to return `true`. Logs the outcome under
|
||||
* `description`, then throws if `fn` returned anything other than `true`
|
||||
* or threw an exception.
|
||||
*/
|
||||
assert: (
|
||||
fn: () => boolean | Promise<boolean>,
|
||||
description: string,
|
||||
) => Promise<void>;
|
||||
/** Runs a named snippet with the same context, plus any extra positional args. */
|
||||
runSnippet: (alias: string, ...args: unknown[]) => Promise<unknown>;
|
||||
/** Returns metadata + absolute disk path for files attached to the current scenario. */
|
||||
@@ -141,6 +150,28 @@ export class CodeExecutorService {
|
||||
log: (...args: unknown[]) => scriptLog("log", toStr(args)),
|
||||
warn: (...args: unknown[]) => scriptLog("warn", toStr(args)),
|
||||
error: (...args: unknown[]) => scriptLog("error", toStr(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) {
|
||||
scriptLog("log", `Assertion passed: ${description}`);
|
||||
return;
|
||||
}
|
||||
const message = failureReason
|
||||
? `Assertion failed: ${description} (${failureReason})`
|
||||
: `Assertion failed: ${description}`;
|
||||
scriptLog("error", message);
|
||||
throw new Error(message);
|
||||
},
|
||||
getStepOutput: getStepOutput ?? (() => Promise.resolve(null)),
|
||||
getCredential: (alias: string): unknown => {
|
||||
if (!(alias in credMap)) {
|
||||
@@ -168,6 +199,7 @@ export class CodeExecutorService {
|
||||
if (snippetCode == null) {
|
||||
throw new Error(`Snippet alias "${alias}" not found`);
|
||||
}
|
||||
scriptLog("log", `Snippet "${alias}" started`);
|
||||
const snippetFn = new Function(
|
||||
"context",
|
||||
"console",
|
||||
@@ -175,7 +207,22 @@ export class CodeExecutorService {
|
||||
"expect",
|
||||
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
|
||||
);
|
||||
return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
|
||||
try {
|
||||
const snippetResult = await snippetFn(
|
||||
scriptContext,
|
||||
fakeConsole,
|
||||
args,
|
||||
playwrightExpect,
|
||||
);
|
||||
scriptLog("log", `Snippet "${alias}" finished`);
|
||||
return snippetResult;
|
||||
} catch (err) {
|
||||
scriptLog(
|
||||
"error",
|
||||
`Snippet "${alias}" failed: ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
getScenarioFiles: async (opts?: { limit?: number; offset?: number }) => {
|
||||
if (!fileService || !scenarioId) {
|
||||
|
||||
@@ -5,6 +5,18 @@ import { URL } from "url";
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
||||
const TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
export async function downloadFile(
|
||||
urlStr: string,
|
||||
options?: {
|
||||
@@ -13,6 +25,10 @@ export async function downloadFile(
|
||||
body?: string;
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
if (urlStr.startsWith("data:")) {
|
||||
return decodeDataUrl(urlStr);
|
||||
}
|
||||
|
||||
const url = new URL(urlStr);
|
||||
const protocol = url.protocol === "https:" ? https : http;
|
||||
const method = options?.method ?? "GET";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,11 @@ export class ScenarioRunLogEntity {
|
||||
@Column({ type: "text" })
|
||||
message: string;
|
||||
|
||||
// Assigned in call order per run, since concurrent unawaited writes can
|
||||
// otherwise persist out of order (createdAt alone isn't a reliable tiebreaker).
|
||||
@Column({ type: "int", default: 0 })
|
||||
seq: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
private readonly runScenarioTimeouts = new Map<string, number | null>();
|
||||
// Cache scenarioId per run
|
||||
private readonly runScenarioIds = new Map<string, string>();
|
||||
// Monotonic log sequence per run, since persistLog writes are unawaited and
|
||||
// can otherwise resolve out of call order
|
||||
private readonly runLogSeq = new Map<string, number>();
|
||||
|
||||
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
|
||||
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
|
||||
@@ -138,8 +141,10 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
): void {
|
||||
const seq = (this.runLogSeq.get(runId) ?? 0) + 1;
|
||||
this.runLogSeq.set(runId, seq);
|
||||
void this.runLogRepo.save(
|
||||
this.runLogRepo.create({ runId, stepRunId, level, message }),
|
||||
this.runLogRepo.create({ runId, stepRunId, level, message, seq }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,6 +221,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.runEnvironments.delete(run.id);
|
||||
this.runScenarioTimeouts.delete(run.id);
|
||||
this.runScenarioIds.delete(run.id);
|
||||
this.runLogSeq.delete(run.id);
|
||||
this.logger.error(
|
||||
`Run #${run.id}: setup failed — ${String(err)}`,
|
||||
);
|
||||
@@ -279,6 +285,7 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.runEnvironments.delete(runId);
|
||||
this.runScenarioTimeouts.delete(runId);
|
||||
this.runScenarioIds.delete(runId);
|
||||
this.runLogSeq.delete(runId);
|
||||
await this.maybePreserveSession(runId);
|
||||
}
|
||||
}
|
||||
@@ -345,6 +352,12 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`StepRun #${stepRun.id} (run #${stepRun.runId}, step #${step.id} order=${step.order}) → in_progress`,
|
||||
);
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"log",
|
||||
`Step ${step.order} started${step.title ? `: ${step.title}` : ""}`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (!step.execCode) throw new Error("step has no execCode");
|
||||
@@ -453,6 +466,12 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
output !== null && output !== undefined ? JSON.stringify(output) : null;
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(`StepRun #${stepRun.id} → pass`);
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"log",
|
||||
`Step ${(stepRun.scenarioStep as ScenarioStepEntity | undefined)?.order ?? "?"} passed`,
|
||||
);
|
||||
|
||||
// Find the next waiting step in this run (next by order)
|
||||
const nextStep = await this.runStepRepo.findOne({
|
||||
@@ -489,6 +508,14 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
stepRun.description = description;
|
||||
await this.runStepRepo.save(stepRun);
|
||||
this.logger.log(`StepRun #${stepRun.id} → fail: ${description}`);
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"error",
|
||||
`Step ${(stepRun.scenarioStep as ScenarioStepEntity | undefined)?.order ?? "?"} failed: ${description}`,
|
||||
);
|
||||
|
||||
await this.dumpPageOnFailure(stepRun);
|
||||
|
||||
// Cancel all remaining waiting/pending step runs in this run
|
||||
await this.runStepRepo
|
||||
@@ -509,4 +536,51 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
||||
await this.runRepo.update(stepRun.runId, { status: "fail" });
|
||||
this.logger.log(`Run #${stepRun.runId} → fail`);
|
||||
}
|
||||
|
||||
// ── Page dump on failure ────────────────────────────────────────────────────
|
||||
|
||||
private async dumpPageOnFailure(
|
||||
stepRun: ScenarioRunStepEntity,
|
||||
): Promise<void> {
|
||||
const handle = this.runBrowsers.get(stepRun.runId);
|
||||
if (!handle) return; // no browser was ever opened for this run (e.g. setup error)
|
||||
|
||||
const step = stepRun.scenarioStep as ScenarioStepEntity | undefined;
|
||||
const namePrefix = `step-${step?.order ?? stepRun.id}-failure`;
|
||||
|
||||
try {
|
||||
const screenshot = await handle.page.screenshot({ fullPage: true });
|
||||
await this.fileStorageService.createAndSaveRunArtifact(
|
||||
stepRun.runId,
|
||||
screenshot,
|
||||
`${namePrefix}.png`,
|
||||
"image/png",
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`StepRun #${stepRun.id}: failed to capture failure screenshot — ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const html = await handle.page.content();
|
||||
await this.fileStorageService.createAndSaveRunArtifact(
|
||||
stepRun.runId,
|
||||
Buffer.from(html, "utf-8"),
|
||||
`${namePrefix}.html`,
|
||||
"text/html",
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`StepRun #${stepRun.id}: failed to capture failure page HTML — ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.persistLog(
|
||||
stepRun.runId,
|
||||
stepRun.id,
|
||||
"log",
|
||||
`Step ${step?.order ?? "?"} failed: page dump uploaded to run files`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -260,6 +291,29 @@ export class ScenarioController {
|
||||
return this.scenarioService.findRun(id, runId, q);
|
||||
}
|
||||
|
||||
@Get(":id/run/:runId/logs/export")
|
||||
@ApiOperation({ summary: "Export run logs as CSV or Markdown" })
|
||||
@ApiResponse({ status: 200 })
|
||||
@ApiResponse({ status: 404, description: "Scenario or run not found" })
|
||||
async exportRunLogs(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("runId", ParseUUIDPipe) runId: string,
|
||||
@Query("format") format: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const fmt = format === "csv" ? "csv" : "md";
|
||||
const content = await this.scenarioService.exportRunLogs(id, runId, fmt);
|
||||
const contentType =
|
||||
fmt === "csv" ? "text/csv; charset=utf-8" : "text/markdown; charset=utf-8";
|
||||
const ext = fmt === "csv" ? "csv" : "md";
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="run-${runId}-logs.${ext}"`,
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
@Post(":id/run/:runId/wait")
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -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(
|
||||
@@ -402,11 +425,49 @@ export class ScenarioService {
|
||||
);
|
||||
const logs = await this.runLogRepo.find({
|
||||
where: q?.trim() ? { runId, message: Like(`%${q.trim()}%`) } : { runId },
|
||||
order: { createdAt: "ASC" },
|
||||
order: { seq: "ASC", createdAt: "ASC" },
|
||||
});
|
||||
return Object.assign(run, { logs });
|
||||
}
|
||||
|
||||
async exportRunLogs(
|
||||
scenarioId: string,
|
||||
runId: string,
|
||||
format: "csv" | "md",
|
||||
): Promise<string> {
|
||||
await this.findOne(scenarioId); // 404 guard
|
||||
const run = await this.runRepo.findOne({
|
||||
where: { id: runId, scenarioId },
|
||||
});
|
||||
if (!run)
|
||||
throw new NotFoundException(
|
||||
`Run ${runId} not found in scenario ${scenarioId}`,
|
||||
);
|
||||
const logs = await this.runLogRepo.find({
|
||||
where: { runId },
|
||||
order: { seq: "ASC", createdAt: "ASC" },
|
||||
});
|
||||
|
||||
if (format === "csv") {
|
||||
const escapeCsv = (value: string): string =>
|
||||
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
const rows = [
|
||||
["Timestamp", "Level", "Message"].map(escapeCsv).join(","),
|
||||
...logs.map((l) =>
|
||||
[l.createdAt.toISOString(), l.level, l.message]
|
||||
.map((v) => escapeCsv(String(v)))
|
||||
.join(","),
|
||||
),
|
||||
];
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
const sections = logs.map(
|
||||
(l) => `## [${l.level}] ${l.createdAt.toISOString()}\n\n${l.message}`,
|
||||
);
|
||||
return [`# Run ${runId} logs`, ...sections].join("\n\n");
|
||||
}
|
||||
|
||||
async waitForRun(
|
||||
scenarioId: string,
|
||||
runId: string,
|
||||
|
||||
@@ -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