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:
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user