fix(leads): disable sign-in submit until exhibit loads; add licensed-user auth tests
Prevents silent no-op when user clicks submit before lq__exhibit_obj is ready (exhibit not yet written to Dexie). Button now shows 'Loading...' spinner while the exhibit record is resolving, eliminating the two-tap workaround needed on first page load. Also adds 7 Playwright tests for licensed user sign-in (leads_licensed_signin.test.ts) covering success path, wrong credentials, email/identity tagging on captured leads, identity isolation between staff members, and returning-session bypass. Helpers: attach_leads_routes/setup_leads_test_page now accept exhibit_overrides (e.g. license_li_json) to inject licensed users into mocked API responses. seed_leads_loc import added to leads_auth.test.ts multi-exhibit test. Total leads test coverage: 29 tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -213,8 +213,13 @@ function complete_signin(key: string, type: string) {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-lg preset-filled-primary shadow-primary-500/20 group w-full font-bold shadow-lg"
|
class="btn btn-lg preset-filled-primary shadow-primary-500/20 group w-full font-bold shadow-lg"
|
||||||
disabled={status === 'submitting'}>
|
disabled={status === 'submitting' || !$lq__exhibit_obj}>
|
||||||
{#if status === 'submitting'}
|
{#if !$lq__exhibit_obj}
|
||||||
|
<!-- Exhibit record still loading from Dexie/API — block submission
|
||||||
|
until it's ready so handle_signin() doesn't return silently. -->
|
||||||
|
<LoaderCircle size="1.5em" class="mr-2 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
{:else if status === 'submitting'}
|
||||||
<LoaderCircle size="1.5em" class="mr-2 animate-spin" />
|
<LoaderCircle size="1.5em" class="mr-2 animate-spin" />
|
||||||
Signing In...
|
Signing In...
|
||||||
{:else if status === 'success'}
|
{:else if status === 'success'}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ export async function attach_leads_routes(
|
|||||||
opts: {
|
opts: {
|
||||||
staff_passcode?: string;
|
staff_passcode?: string;
|
||||||
exhibit_name?: string;
|
exhibit_name?: string;
|
||||||
|
exhibit_overrides?: Record<string, unknown>;
|
||||||
tracking_li?: any[];
|
tracking_li?: any[];
|
||||||
badge_li?: any[];
|
badge_li?: any[];
|
||||||
event_data_overrides?: Record<string, any>;
|
event_data_overrides?: Record<string, any>;
|
||||||
@@ -358,6 +359,7 @@ export async function attach_leads_routes(
|
|||||||
const {
|
const {
|
||||||
staff_passcode = exhibit_staff_passcode,
|
staff_passcode = exhibit_staff_passcode,
|
||||||
exhibit_name = 'Test Booth — ACME Corp',
|
exhibit_name = 'Test Booth — ACME Corp',
|
||||||
|
exhibit_overrides = {} as Record<string, unknown>,
|
||||||
tracking_li = [],
|
tracking_li = [],
|
||||||
badge_li = [minimal_badge()],
|
badge_li = [minimal_badge()],
|
||||||
event_data_overrides = {},
|
event_data_overrides = {},
|
||||||
@@ -387,7 +389,7 @@ export async function attach_leads_routes(
|
|||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(
|
||||||
minimal_exhibit(exhibit_id, { staff_passcode, name: exhibit_name })
|
minimal_exhibit(exhibit_id, { staff_passcode, name: exhibit_name, ...exhibit_overrides })
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -492,6 +494,7 @@ export async function setup_leads_test_page(
|
|||||||
leads_overrides?: Record<string, any>;
|
leads_overrides?: Record<string, any>;
|
||||||
staff_passcode?: string;
|
staff_passcode?: string;
|
||||||
exhibit_name?: string;
|
exhibit_name?: string;
|
||||||
|
exhibit_overrides?: Record<string, unknown>;
|
||||||
tracking_li?: any[];
|
tracking_li?: any[];
|
||||||
badge_li?: any[];
|
badge_li?: any[];
|
||||||
event_data_overrides?: Record<string, any>;
|
event_data_overrides?: Record<string, any>;
|
||||||
@@ -499,10 +502,18 @@ export async function setup_leads_test_page(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { access = {}, auth_kv = {}, leads_overrides = {}, ...route_opts } = opts;
|
const { access = {}, auth_kv = {}, leads_overrides = {}, ...route_opts } = opts;
|
||||||
|
|
||||||
|
// Build auth_exhibit_kv with timestamps for ae_leads_loc (the current store).
|
||||||
|
// seed_events_loc also seeds the old ae_events_loc for backwards compatibility,
|
||||||
|
// but is_signed_in now reads from leads_loc (ae_leads_loc) exclusively.
|
||||||
|
const auth_exhibit_kv_with_ts: Record<string, { key: string; type: string; updated_on: string }> = {};
|
||||||
|
for (const [eid, entry] of Object.entries(auth_kv)) {
|
||||||
|
auth_exhibit_kv_with_ts[eid] = { ...entry, updated_on: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
page.on('pageerror', (err) => console.error(`BROWSER ERROR: ${err.message}`));
|
||||||
await attach_leads_routes(page, event_id, exhibit_id, route_opts);
|
await attach_leads_routes(page, event_id, exhibit_id, route_opts);
|
||||||
await seed_ae_loc(page, access);
|
await seed_ae_loc(page, access);
|
||||||
await seed_leads_loc(page, leads_overrides);
|
await seed_leads_loc(page, { ...leads_overrides, auth_exhibit_kv: auth_exhibit_kv_with_ts });
|
||||||
await seed_events_loc(page, auth_kv, leads_overrides);
|
await seed_events_loc(page, auth_kv, leads_overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
setup_leads_test_page,
|
setup_leads_test_page,
|
||||||
attach_leads_routes,
|
attach_leads_routes,
|
||||||
seed_ae_loc,
|
seed_ae_loc,
|
||||||
|
seed_leads_loc,
|
||||||
seed_events_loc,
|
seed_events_loc,
|
||||||
minimal_exhibit,
|
minimal_exhibit,
|
||||||
} from './_helpers/leads_helpers';
|
} from './_helpers/leads_helpers';
|
||||||
@@ -219,7 +220,14 @@ test.describe('Leads — Auth Gate', () => {
|
|||||||
// Seed ae_loc (regular shared-passcode user — no manager bypass)
|
// Seed ae_loc (regular shared-passcode user — no manager bypass)
|
||||||
await seed_ae_loc(page, { allow_access: true });
|
await seed_ae_loc(page, { allow_access: true });
|
||||||
|
|
||||||
// Pre-seed BOTH exhibits as authorized — independent KV entries
|
// Pre-seed BOTH exhibits as authorized — independent KV entries.
|
||||||
|
// Must seed ae_leads_loc (new PersistedState store) in addition to ae_events_loc.
|
||||||
|
await seed_leads_loc(page, {
|
||||||
|
auth_exhibit_kv: {
|
||||||
|
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared', updated_on: new Date().toISOString() },
|
||||||
|
[exhibit_id_b]: { key: 'BOOTHB99', type: 'shared', updated_on: new Date().toISOString() },
|
||||||
|
},
|
||||||
|
});
|
||||||
await seed_events_loc(page, {
|
await seed_events_loc(page, {
|
||||||
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' },
|
[exhibit_id]: { key: exhibit_staff_passcode, type: 'shared' },
|
||||||
[exhibit_id_b]: { key: 'BOOTHB99', type: 'shared' },
|
[exhibit_id_b]: { key: 'BOOTHB99', type: 'shared' },
|
||||||
|
|||||||
294
tests/leads_licensed_signin.test.ts
Normal file
294
tests/leads_licensed_signin.test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { testing_event_id } from './_helpers/env';
|
||||||
|
import {
|
||||||
|
testing_exhibit_id,
|
||||||
|
exhibit_staff_passcode,
|
||||||
|
setup_leads_test_page,
|
||||||
|
} from './_helpers/leads_helpers';
|
||||||
|
|
||||||
|
const event_id = testing_event_id;
|
||||||
|
const exhibit_id = testing_exhibit_id;
|
||||||
|
const exhibit_url = `/events/${event_id}/leads/exhibit/${exhibit_id}`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test license entries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A licensed staff member whose leads should be tagged with their email. */
|
||||||
|
const licensed_user = {
|
||||||
|
full_name: 'Alice Staff',
|
||||||
|
email: 'alice@acmecorp.com',
|
||||||
|
passcode: 'ALICE99',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A second licensed user — verifies identity isolation between staff members. */
|
||||||
|
const licensed_user_b = {
|
||||||
|
full_name: 'Bob Staff',
|
||||||
|
email: 'bob@acmecorp.com',
|
||||||
|
passcode: 'BOB88',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* license_li_json as a JSON string (matches how the API returns it and how
|
||||||
|
* the sign-in component parses it inside handle_signin).
|
||||||
|
*/
|
||||||
|
const license_li_json = JSON.stringify([licensed_user, licensed_user_b]);
|
||||||
|
|
||||||
|
// Exhibit overrides that inject the license list into the mocked API response.
|
||||||
|
const exhibit_with_licenses = { license_li_json };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const signin_form = (page: Parameters<typeof expect>[0]) =>
|
||||||
|
(page as import('@playwright/test').Page).locator('.exhibit-signin');
|
||||||
|
|
||||||
|
const header_action_btn = (page: Parameters<typeof expect>[0]) =>
|
||||||
|
(page as import('@playwright/test').Page).locator(
|
||||||
|
'header button.preset-filled-primary'
|
||||||
|
);
|
||||||
|
|
||||||
|
const passcode_input = (page: Parameters<typeof expect>[0]) =>
|
||||||
|
(page as import('@playwright/test').Page).locator(
|
||||||
|
'input[placeholder="Enter shared code..."]'
|
||||||
|
);
|
||||||
|
|
||||||
|
const search_mode_seed = { tab_add_mode: { [exhibit_id]: 'search' } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the exhibit URL and wait for lq__exhibit_obj to be live in the
|
||||||
|
* sign-in component before returning.
|
||||||
|
*
|
||||||
|
* The sign-in component's handle_signin() returns early if lq__exhibit_obj is
|
||||||
|
* null. trusted_access causes handle_signin to auto-fill the shared passcode
|
||||||
|
* input once lq__exhibit_obj loads — this is the same readiness signal used
|
||||||
|
* in the shared passcode sign-in tests.
|
||||||
|
*
|
||||||
|
* Note: trusted_access does NOT bypass the auth gate (only manager_access
|
||||||
|
* does), so the sign-in form will still appear for licensed user tests.
|
||||||
|
*/
|
||||||
|
async function goto_and_wait_for_exhibit_ready(
|
||||||
|
page: import('@playwright/test').Page
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto(exhibit_url);
|
||||||
|
// Auto-fill fires once lq__exhibit_obj is live — this is our readiness signal.
|
||||||
|
await expect(passcode_input(page)).toHaveValue(exhibit_staff_passcode, {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test suite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('Leads — Licensed User Sign-In', () => {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. Licensed user tab is visible on the sign-in form
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('sign-in form has a Licensed User tab', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
access: { allow_access: true, trusted_access: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(exhibit_url);
|
||||||
|
|
||||||
|
await expect(signin_form(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.locator('button:has-text("Licensed User")')).toBeVisible({
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. Correct email + passcode signs in successfully
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('correct email and passcode signs in — form disappears, lead list shown', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
access: { allow_access: true, trusted_access: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await goto_and_wait_for_exhibit_ready(page);
|
||||||
|
|
||||||
|
// Switch to Licensed User tab
|
||||||
|
await page.locator('button:has-text("Licensed User")').click();
|
||||||
|
|
||||||
|
await page.locator('input[type="email"]').fill(licensed_user.email);
|
||||||
|
await page.locator('input[placeholder="Your code..."]').fill(licensed_user.passcode);
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
// After the 800 ms UX delay + reactivity, form should disappear
|
||||||
|
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(header_action_btn(page)).toBeVisible({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 3. Wrong passcode shows error, form stays open
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('wrong passcode shows error message, form stays visible', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
access: { allow_access: true, trusted_access: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await goto_and_wait_for_exhibit_ready(page);
|
||||||
|
|
||||||
|
await page.locator('button:has-text("Licensed User")').click();
|
||||||
|
await page.locator('input[type="email"]').fill(licensed_user.email);
|
||||||
|
await page.locator('input[placeholder="Your code..."]').fill('WRONGCODE');
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=Invalid email or personal passcode')
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(signin_form(page)).toBeVisible({ timeout: 3_000 });
|
||||||
|
await expect(header_action_btn(page)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 4. Unknown email shows error, form stays open
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('unknown email shows error message, form stays visible', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
access: { allow_access: true, trusted_access: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await goto_and_wait_for_exhibit_ready(page);
|
||||||
|
|
||||||
|
await page.locator('button:has-text("Licensed User")').click();
|
||||||
|
await page.locator('input[type="email"]').fill('unknown@notregistered.com');
|
||||||
|
await page.locator('input[placeholder="Your code..."]').fill('ANYCODE');
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.locator('text=Invalid email or personal passcode')
|
||||||
|
).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(signin_form(page)).toBeVisible({ timeout: 3_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 5. Licensed user captures a lead — external_person_id = their email
|
||||||
|
//
|
||||||
|
// This is the core business rule: every lead captured by a licensed user
|
||||||
|
// must be tagged with their email so "My Leads" filtering works per staff
|
||||||
|
// member. The POST body is inspected directly to verify the identity field.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('lead captured by licensed user is tagged with their email address', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
access: { allow_access: true, trusted_access: true },
|
||||||
|
leads_overrides: search_mode_seed,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goto_and_wait_for_exhibit_ready(page);
|
||||||
|
|
||||||
|
// Sign in as licensed user
|
||||||
|
await page.locator('button:has-text("Licensed User")').click();
|
||||||
|
await page.locator('input[type="email"]').fill(licensed_user.email);
|
||||||
|
await page.locator('input[placeholder="Your code..."]').fill(licensed_user.passcode);
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Navigate to Add Lead tab
|
||||||
|
await header_action_btn(page).click();
|
||||||
|
|
||||||
|
const search_input = page.locator(
|
||||||
|
'input[placeholder="Attendee name, email, or badge ID..."]'
|
||||||
|
);
|
||||||
|
await expect(search_input).toBeVisible({ timeout: 8_000 });
|
||||||
|
await search_input.fill('Scott');
|
||||||
|
await page.locator('button:has-text("Search")').click();
|
||||||
|
await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 8_000 });
|
||||||
|
|
||||||
|
const create_promise = page.waitForRequest(
|
||||||
|
(r) =>
|
||||||
|
r.url().includes('event_exhibit_tracking') &&
|
||||||
|
r.method() === 'POST',
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator('.results-list button.preset-filled-success').click();
|
||||||
|
|
||||||
|
const create_req = await create_promise;
|
||||||
|
const body = JSON.parse(create_req.postData() ?? '{}');
|
||||||
|
|
||||||
|
// The lead must be tagged with the licensed user's email, not 'shared_passcode'
|
||||||
|
expect(body.external_person_id).toBe(licensed_user.email);
|
||||||
|
expect(body.group).toBe(licensed_user.email);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 6. Two different licensed users get different external_person_id values
|
||||||
|
//
|
||||||
|
// Regression guard: Bob's leads must be tagged with his email, not Alice's
|
||||||
|
// and not 'shared_passcode'.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('different licensed users are tagged with their own email addresses', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
access: { allow_access: true, trusted_access: true },
|
||||||
|
leads_overrides: search_mode_seed,
|
||||||
|
});
|
||||||
|
|
||||||
|
await goto_and_wait_for_exhibit_ready(page);
|
||||||
|
|
||||||
|
// Sign in as Bob (the second licensed user)
|
||||||
|
await page.locator('button:has-text("Licensed User")').click();
|
||||||
|
await page.locator('input[type="email"]').fill(licensed_user_b.email);
|
||||||
|
await page.locator('input[placeholder="Your code..."]').fill(licensed_user_b.passcode);
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
|
||||||
|
await expect(signin_form(page)).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await header_action_btn(page).click();
|
||||||
|
|
||||||
|
const search_input = page.locator(
|
||||||
|
'input[placeholder="Attendee name, email, or badge ID..."]'
|
||||||
|
);
|
||||||
|
await expect(search_input).toBeVisible({ timeout: 8_000 });
|
||||||
|
await search_input.fill('Scott');
|
||||||
|
await page.locator('button:has-text("Search")').click();
|
||||||
|
await expect(page.locator('text=Scott Idem')).toBeVisible({ timeout: 8_000 });
|
||||||
|
|
||||||
|
const create_promise = page.waitForRequest(
|
||||||
|
(r) =>
|
||||||
|
r.url().includes('event_exhibit_tracking') &&
|
||||||
|
r.method() === 'POST',
|
||||||
|
{ timeout: 5_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator('.results-list button.preset-filled-success').click();
|
||||||
|
|
||||||
|
const create_req = await create_promise;
|
||||||
|
const body = JSON.parse(create_req.postData() ?? '{}');
|
||||||
|
|
||||||
|
// Must be Bob's email, not Alice's and not 'shared_passcode'
|
||||||
|
expect(body.external_person_id).toBe(licensed_user_b.email);
|
||||||
|
expect(body.external_person_id).not.toBe(licensed_user.email);
|
||||||
|
expect(body.external_person_id).not.toBe('shared_passcode');
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 7. Pre-seeded licensed auth skips sign-in (returning user)
|
||||||
|
//
|
||||||
|
// A returning user whose session is already in auth_exhibit_kv with
|
||||||
|
// type='licensed' should see the lead list directly — no sign-in form.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
test('pre-seeded licensed auth skips sign-in form', async ({ page }) => {
|
||||||
|
await setup_leads_test_page(page, event_id, exhibit_id, {
|
||||||
|
exhibit_overrides: exhibit_with_licenses,
|
||||||
|
auth_kv: {
|
||||||
|
// Simulate a returning session: already signed in as Alice
|
||||||
|
[exhibit_id]: { key: licensed_user.email, type: 'licensed' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(exhibit_url);
|
||||||
|
|
||||||
|
await expect(signin_form(page)).not.toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(header_action_btn(page)).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user