test(e2e): sprint 3 acceptance tests — US-13 to US-16
Four new spec files covering the MITRE multi-technique feature: - us13: API contract (techniques array, dedup, unknown ID → 400, SOC 403, auto-transition) - us14: tag UI (empty state, add/remove auto-save, SimulationList column, order, styling) - us15: matrix modal (tactic tree, layout, select/expand/search, Apply/Cancel/Escape/backdrop, a11y) - us16: sprint 2 regression (workflow, badge, SOC RBAC, picker still works) Updated sprint 2 specs (us8, us10) to use technique_ids array and Quick search button instead of deprecated scalar mitre_technique_id/name fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
506
e2e/tests/us15-mitre-matrix-modal.spec.ts
Normal file
506
e2e/tests/us15-mitre-matrix-modal.spec.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* US-15 — redteam explores and selects techniques via the MITRE ATT&CK matrix modal.
|
||||
* Covers AC-15.1 → AC-15.5.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
adminToken,
|
||||
createEngagement,
|
||||
deleteEngagement,
|
||||
deleteUserByUsername,
|
||||
ensureUser,
|
||||
login,
|
||||
makeClient,
|
||||
} from '../fixtures/api';
|
||||
import { seedTokenInStorage } from '../fixtures/auth';
|
||||
|
||||
const REDTEAM_USER = 'us15-redteam';
|
||||
const PASS = 'us15-pass-strong';
|
||||
|
||||
interface Simulation {
|
||||
id: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function createSimulation(
|
||||
token: string,
|
||||
engagementId: number,
|
||||
name = 'US-15 sim',
|
||||
): Promise<Simulation> {
|
||||
const client = makeClient(token);
|
||||
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
|
||||
if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
|
||||
return r.data as Simulation;
|
||||
}
|
||||
|
||||
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||
await makeClient(token).delete(`/simulations/${simId}`);
|
||||
}
|
||||
|
||||
test.describe('US-15 — MITRE matrix modal', () => {
|
||||
let redteamToken: string;
|
||||
let engagementId: number;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||
const eng = await createEngagement(redteamToken, {
|
||||
name: 'US-15 Engagement',
|
||||
start_date: '2026-01-01',
|
||||
});
|
||||
engagementId = eng.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
const tok = await adminToken();
|
||||
await deleteEngagement(tok, engagementId);
|
||||
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
|
||||
test('AC-15.1 — GET /api/mitre/matrix returns tactic tree with correct structure', async () => {
|
||||
const client = makeClient(redteamToken);
|
||||
const r = await client.get('/mitre/matrix');
|
||||
expect(r.status).toBe(200);
|
||||
expect(Array.isArray(r.data)).toBe(true);
|
||||
|
||||
// At least the 12 canonical MITRE Enterprise tactics
|
||||
expect(r.data.length).toBeGreaterThanOrEqual(12);
|
||||
|
||||
const first = r.data[0];
|
||||
expect(first).toHaveProperty('tactic_id');
|
||||
expect(first).toHaveProperty('tactic_name');
|
||||
expect(Array.isArray(first.techniques)).toBe(true);
|
||||
|
||||
// First tactic must be "Initial Access" (canonical order)
|
||||
expect(first.tactic_name).toBe('Initial Access');
|
||||
|
||||
// Each technique has id, name, subtechniques array
|
||||
const tech = first.techniques[0];
|
||||
expect(tech).toHaveProperty('id');
|
||||
expect(tech).toHaveProperty('name');
|
||||
expect(Array.isArray(tech.subtechniques)).toBe(true);
|
||||
|
||||
// A technique with known sub-techniques: T1059 is in Execution
|
||||
const execTactic = (r.data as { tactic_name: string; techniques: { id: string; subtechniques: { id: string; name: string }[] }[] }[]).find(
|
||||
(t) => t.tactic_name === 'Execution',
|
||||
);
|
||||
expect(execTactic).toBeTruthy();
|
||||
const t1059 = execTactic!.techniques.find((t) => t.id === 'T1059');
|
||||
expect(t1059).toBeTruthy();
|
||||
expect(t1059!.subtechniques.length).toBeGreaterThan(0);
|
||||
// T1059.001 should be a known sub-technique
|
||||
const sub = t1059!.subtechniques.find((s) => s.id === 'T1059.001');
|
||||
expect(sub).toBeTruthy();
|
||||
expect(sub!.name).toBeTruthy();
|
||||
});
|
||||
|
||||
test('AC-15.1 — tactic canonical order is correct (Initial Access first, Impact last)', async () => {
|
||||
const client = makeClient(redteamToken);
|
||||
const r = await client.get('/mitre/matrix');
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
const tacticNames = (r.data as { tactic_name: string }[]).map((t) => t.tactic_name);
|
||||
expect(tacticNames[0]).toBe('Initial Access');
|
||||
expect(tacticNames[tacticNames.length - 1]).toBe('Impact');
|
||||
|
||||
// Verify Exfiltration appears before Impact
|
||||
const exfilIdx = tacticNames.indexOf('Exfiltration');
|
||||
const impactIdx = tacticNames.indexOf('Impact');
|
||||
expect(exfilIdx).toBeGreaterThan(-1);
|
||||
expect(exfilIdx).toBeLessThan(impactIdx);
|
||||
});
|
||||
|
||||
test('AC-15.2 — modal layout: columns per tactic, tactic header, technique cells', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 layout');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
// Open the matrix modal via "Add technique"
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Modal title
|
||||
await expect(dialog.getByRole('heading', { name: /mitre att&?ck matrix/i })).toBeVisible();
|
||||
|
||||
// Search / filter input present and focused
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// At least one tactic column visible — check for "Initial Access" and "Execution"
|
||||
await expect(dialog).toContainText('Initial Access');
|
||||
await expect(dialog).toContainText('Execution');
|
||||
|
||||
// T1059 technique cell visible in Execution column
|
||||
await expect(dialog).toContainText('T1059');
|
||||
|
||||
// Cancel button present
|
||||
await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.2 — selecting technique updates Apply button counter', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 select');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Use search to isolate T1059 so there's only the label button visible
|
||||
// The chevron has aria-label "Expand T1059"; we use filter to get the label button
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await searchInput.fill('T1059');
|
||||
// Wait for filter to apply — only T1059 and its subtechniques should be visible
|
||||
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||
|
||||
// The label button (selection) is the one containing the technique name text
|
||||
// Filter explicitly excludes the chevron (aria-label="Expand T1059")
|
||||
const techLabelBtn = dialog
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first();
|
||||
await techLabelBtn.click();
|
||||
|
||||
// Apply button should now show count = 1
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
|
||||
// Click again to deselect
|
||||
await techLabelBtn.click();
|
||||
|
||||
// When 0 selected and no initial selection: footer shows disabled "Clear all"
|
||||
await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: /apply \d+ technique/i })).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.2 — subtechnique expand/collapse via chevron', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 expand');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Expand T1059 via chevron button (▸ Expand T1059)
|
||||
const expandBtn = dialog.getByRole('button', { name: /expand T1059/i });
|
||||
await expect(expandBtn).toBeVisible();
|
||||
await expandBtn.click();
|
||||
|
||||
// Sub-technique T1059.001 should now be visible
|
||||
await expect(dialog).toContainText('T1059.001');
|
||||
|
||||
// Collapse it
|
||||
const collapseBtn = dialog.getByRole('button', { name: /collapse T1059/i });
|
||||
await collapseBtn.click();
|
||||
await expect(dialog).not.toContainText('T1059.001');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.2 — search filters techniques, auto-expands parent when sub matches', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 search');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
|
||||
// Search by sub-technique ID — parent should auto-expand
|
||||
await searchInput.fill('T1059.001');
|
||||
await expect(dialog).toContainText('T1059.001');
|
||||
|
||||
// Search by name (case-insensitive)
|
||||
await searchInput.fill('powershell');
|
||||
await expect(dialog).toContainText('PowerShell');
|
||||
|
||||
// Clear search — techniques come back
|
||||
await searchInput.fill('');
|
||||
await expect(dialog).toContainText('T1059');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.2 — tactic header shows selected count when techniques selected', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 counter');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Initially no "selected" counter visible
|
||||
await expect(dialog).not.toContainText('1 selected');
|
||||
|
||||
// Use search to isolate T1059 so we can click the label button, not the chevron
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await searchInput.fill('T1059');
|
||||
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||
|
||||
// The label button contains the technique name; the chevron has aria-label="Expand T1059"
|
||||
await dialog
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Tactic header for Execution should now show "1 selected"
|
||||
await expect(dialog).toContainText('1 selected');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.3 — Apply auto-saves techniques and closes modal', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 apply');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
|
||||
// Select T1059 via label button (not chevron) — filter to isolate
|
||||
await searchInput.fill('T1059');
|
||||
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||
await dialog
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Select T1566 (Phishing) — no subtechniques, so only one button
|
||||
await searchInput.fill('T1566');
|
||||
await expect(dialog).toContainText('T1566');
|
||||
await dialog.getByRole('button', { name: /phishing/i }).first().click();
|
||||
|
||||
// Apply (2 techniques selected)
|
||||
const applyBtn = dialog.getByRole('button', { name: /apply \d+ technique/i });
|
||||
await expect(applyBtn).toBeVisible();
|
||||
await applyBtn.click();
|
||||
|
||||
// Modal closes
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Auto-save toast appears
|
||||
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Tags appear in the tag list
|
||||
const tagList = page.getByTestId('techniques-tag-list');
|
||||
await expect(tagList).toContainText('T1059');
|
||||
await expect(tagList).toContainText('T1566');
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.3 — modal receives current selection as initial state', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 initial');
|
||||
|
||||
// Seed T1059 via API before opening the UI
|
||||
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||
technique_ids: ['T1059'],
|
||||
});
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Apply button should already show 1 technique (from initial selection)
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
|
||||
// Cancel to discard
|
||||
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.3 — Cancel discards local changes (no PATCH fired)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 cancel');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Select a technique via label button (filter to avoid hitting chevron)
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await searchInput.fill('T1059');
|
||||
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||
await dialog
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
|
||||
// Cancel instead of Apply
|
||||
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// No toast, no PATCH fired — empty state message still visible (0 techniques)
|
||||
await expect(page.getByText(/techniques updated/i)).not.toBeVisible();
|
||||
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.4 — Escape key closes modal (Cancel behaviour)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 escape');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Select something to confirm Cancel semantics on Escape
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await searchInput.fill('T1059');
|
||||
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||
await dialog
|
||||
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
// No PATCH fired — empty state message still visible (no techniques added)
|
||||
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.4 — backdrop click closes modal (Cancel behaviour)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 backdrop');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click outside the modal container (top-left corner of viewport, which is the backdrop)
|
||||
await page.mouse.click(5, 5);
|
||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.5 — a11y: role=dialog + aria-labelledby, search input focused on open', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 a11y');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// role="dialog" is set (getByRole('dialog') already asserts this)
|
||||
// aria-modal attribute
|
||||
const ariaModal = await dialog.getAttribute('aria-modal');
|
||||
expect(ariaModal).toBe('true');
|
||||
|
||||
// aria-labelledby points to the modal title
|
||||
const labelledBy = await dialog.getAttribute('aria-labelledby');
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = page.locator(`#${labelledBy}`);
|
||||
await expect(titleEl).toContainText(/mitre att&?ck matrix/i);
|
||||
|
||||
// Search input is focused immediately after open
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await expect(searchInput).toBeFocused({ timeout: 2_000 });
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
|
||||
test('AC-15.5 — a11y: Tab wraps within modal (focus trap)', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus-trap');
|
||||
|
||||
await seedTokenInStorage(context, redteamToken);
|
||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||
|
||||
await page.getByRole('button', { name: /add technique/i }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Tab through enough elements to hit the wrap point
|
||||
// (we don't know exact count, but Shift+Tab from the first focused element
|
||||
// should stay inside the modal — not land outside)
|
||||
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||
await expect(searchInput).toBeFocused({ timeout: 2_000 });
|
||||
|
||||
// Shift+Tab from the first element (search) should wrap to Cancel or Apply
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
// The focused element must still be inside the dialog
|
||||
const focusedOutsideDialog = await page.evaluate(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
return dialog ? !dialog.contains(document.activeElement) : true;
|
||||
});
|
||||
expect(focusedOutsideDialog).toBe(false);
|
||||
|
||||
await deleteSimulation(redteamToken, sim.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user