test(e2e): add Initiative 4 end-to-end + integration specs
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m12s
Build & Push Docker Images / build-and-push (push) Successful in 8m24s

Sales-process coverage (launch-readiness Initiative 4):
- exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit
  total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity
  (in-app vs Documenso, shared EoiContext); mobile-viewport journey.
- realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages.
- integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay ->
  single file/audit write); storage backend swap (s3 <-> filesystem) with a
  real on-disk filesystem round-trip.
- visual: Reports UI snapshot cases (baselines captured separately).

1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not
bundled into the app image) - no app behavior modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 14:10:35 +02:00
parent 2e8c4b43bf
commit 7591231c47
9 changed files with 1490 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
import { test, expect } from '@playwright/test';
import { login, apiHeaders, navigateTo, PORT_SLUG } from '../smoke/helpers';
import {
seedFullyLoadedDeal,
changeStage,
readStage,
type SeededDeal,
} from '../fixtures/sales-journey';
/**
* End-to-end sales journey — deterministic half.
*
* Walks an interest through the full 7-stage pipeline
* (`src/lib/constants.ts → PIPELINE_STAGES`) and asserts every
* transition, plus the milestone side-effects that the CRM surfaces
* without needing any external service:
*
* enquiry → qualified → nurturing → eoi → reservation
* → deposit_paid → contract
*
* The Documenso-dependent legs (real EOI / reservation / contract
* signing webhooks) are covered separately by
* `tests/e2e/realapi/sales-journey-signing.spec.ts`, which skips when
* the Documenso env is absent. Here we drive the same stage transitions
* deterministically through the `/stage` API + record the deposit and
* create the tenancy + flip the berth to sold via their own endpoints,
* so the spec is self-contained and reproducible in CI.
*/
test.describe('exhaustive: end-to-end sales journey (deterministic)', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
test('walks an interest through every pipeline stage', async ({ page }) => {
const headers = await apiHeaders(page);
const deal: SeededDeal = await seedFullyLoadedDeal(page, 'Journey');
// Fresh interest starts in Enquiry.
expect(await readStage(page.request, headers, deal.interestId)).toBe('enquiry');
// The yacht is linked at seed time, so leaving Enquiry is allowed;
// the berth is priced, so the priced-stage gate passes too. Walk the
// canonical forward path one legal transition at a time.
const path = [
'qualified',
'nurturing',
'eoi',
'reservation',
'deposit_paid',
'contract',
] as const;
for (const stage of path) {
await changeStage(page.request, headers, deal.interestId, stage);
expect(
await readStage(page.request, headers, deal.interestId),
`interest should now be at ${stage}`,
).toBe(stage);
}
});
test('illegal stage skip is rejected by the transition table', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Journey Skip');
// enquiry → contract is not in STAGE_TRANSITIONS['enquiry']; without
// an override the /stage route must reject it.
const res = await page.request.patch(`/api/v1/interests/${deal.interestId}/stage`, {
headers,
data: { pipelineStage: 'contract' },
});
expect(res.ok(), 'illegal skip should be rejected').toBe(false);
expect(res.status()).toBe(400);
// Interest stays put.
expect(await readStage(page.request, headers, deal.interestId)).toBe('enquiry');
});
test('records a deposit payment against the interest', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Journey Deposit');
// Advance to a stage where a deposit makes sense.
for (const stage of ['qualified', 'eoi', 'reservation'] as const) {
await changeStage(page.request, headers, deal.interestId, stage);
}
const depositRes = await page.request.post(`/api/v1/interests/${deal.interestId}/payments`, {
headers,
data: {
interestId: deal.interestId,
paymentType: 'deposit',
amount: '25000',
currency: 'EUR',
receivedAt: new Date().toISOString(),
},
});
expect(
depositRes.ok(),
`record deposit: ${depositRes.status()} ${await depositRes.text()}`,
).toBe(true);
// The payments read endpoint returns a running deposit total.
const listRes = await page.request.get(`/api/v1/interests/${deal.interestId}/payments`, {
headers,
});
expect(listRes.ok()).toBe(true);
const body = (await listRes.json()) as {
data: { payments: Array<{ amount: string; paymentType: string }>; depositTotal: string };
};
expect(body.data.payments.length).toBeGreaterThanOrEqual(1);
expect(Number(body.data.depositTotal)).toBeGreaterThanOrEqual(25000);
// Now the deposit is recorded, move the deal to deposit_paid.
await changeStage(page.request, headers, deal.interestId, 'deposit_paid');
expect(await readStage(page.request, headers, deal.interestId)).toBe('deposit_paid');
});
test('creating a tenancy and marking the berth sold completes the deal', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Journey Sold');
// Walk all the way to contract.
for (const stage of ['qualified', 'eoi', 'reservation', 'deposit_paid', 'contract'] as const) {
await changeStage(page.request, headers, deal.interestId, stage);
}
expect(await readStage(page.request, headers, deal.interestId)).toBe('contract');
// Create the (pending) berth tenancy tying client + yacht + berth.
const tenancyRes = await page.request.post(`/api/v1/berths/${deal.berthId}/tenancies`, {
headers,
data: {
berthId: deal.berthId,
clientId: deal.clientId,
yachtId: deal.yachtId,
interestId: deal.interestId,
startDate: new Date().toISOString(),
tenureType: 'permanent',
},
});
expect(
tenancyRes.ok(),
`create tenancy: ${tenancyRes.status()} ${await tenancyRes.text()}`,
).toBe(true);
const tenancy = (await tenancyRes.json()) as { data: { id: string; status: string } };
expect(tenancy.data.id).toBeTruthy();
// Flip the berth to sold (the contract-signed berth-rule does this
// automatically on the signing webhook; here we drive the same
// status transition explicitly).
const statusRes = await page.request.patch(`/api/v1/berths/${deal.berthId}/status`, {
headers,
data: {
status: 'sold',
reason: 'Contract signed — deal won',
interestId: deal.interestId,
},
});
expect(statusRes.ok(), `berth → sold: ${statusRes.status()} ${await statusRes.text()}`).toBe(
true,
);
const sold = (await statusRes.json()) as { data: { status: string } };
expect(sold.data.status).toBe('sold');
});
test('UI: interest detail page renders a stage indicator', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Journey UI');
await changeStage(page.request, headers, deal.interestId, 'qualified');
await navigateTo(page, `/interests/${deal.interestId}`);
await page.waitForURL(new RegExp(`/${PORT_SLUG}/interests/${deal.interestId}`), {
timeout: 10_000,
});
await page.waitForLoadState('networkidle');
// The detail header surfaces the stage; "Qualified" is the label for
// the stage we just set (STAGE_LABELS.qualified).
await expect(page.getByText(/qualified/i).first()).toBeVisible({ timeout: 10_000 });
});
});

View File

@@ -0,0 +1,172 @@
import { test, expect } from '@playwright/test';
import { login, apiHeaders } from '../smoke/helpers';
import { seedFullyLoadedDeal } from '../fixtures/sales-journey';
/**
* Multi-berth EOI berth-range rendering.
*
* The Documenso `Berth Number` field (and the in-app AcroForm field of
* the same name) renders the EOI bundle's moorings as a compact range:
* single-berth → the primary mooring verbatim; multi-berth → the
* collapsed range produced by `formatBerthRange()`
* (`src/lib/templates/berth-range.ts`):
*
* ['A1','A2','A3','B5','B6','B7'] → 'A1-A3, B5-B7'
*
* `EoiContext.eoiBerthRange` (`src/lib/services/eoi-context.ts`) is the
* single server-side place this is computed — it aggregates every
* `interest_berths` row flagged `is_in_eoi_bundle=true` and feeds them
* through `formatBerthRange`. This spec drives the `/eoi-context` read
* API (which returns the resolved `EoiContext`) so the assertion is made
* against the real server output, not a re-implementation of the
* formatter in the test.
*
* Note on the model: when an interest is created with a `berthId`, that
* berth becomes the *primary* link, and the primary is always forced
* into the EOI bundle (`upsertInterestBerthTx` invariant). Additional
* berths added via `POST /interests/[id]/berths` default to
* `is_in_eoi_bundle=true`, so they join the range too.
*/
interface EoiContextResponse {
data: {
eoiBerthRange: string;
berth: { mooringNumber: string } | null;
};
}
test.describe('exhaustive: multi-berth EOI berth-range rendering', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
/**
* Mirror of `formatBerthRange` for *expectation* construction only —
* we still assert against the server's value, but we need a local
* oracle to know what to expect from an arbitrary seeded mooring set.
* Kept deliberately simple (single-letter prefixes, consecutive-run
* collapse) since that's all the seeded data exercises.
*/
function expectedRange(moorings: string[]): string {
const parsed = moorings
.map((m) => {
const match = /^([A-Z]+)(\d+)$/.exec(m);
return match ? { prefix: match[1]!, n: Number(match[2]!), raw: m } : null;
})
.filter((x): x is { prefix: string; n: number; raw: string } => x !== null)
.sort((a, b) => (a.prefix === b.prefix ? a.n - b.n : a.prefix < b.prefix ? -1 : 1));
const segments: string[] = [];
let runStart = parsed[0] ?? null;
let runEnd = parsed[0] ?? null;
for (let i = 1; i < parsed.length; i++) {
const cur = parsed[i]!;
if (runEnd && cur.prefix === runEnd.prefix && cur.n === runEnd.n + 1) {
runEnd = cur;
continue;
}
if (runStart && runEnd) {
segments.push(runStart.raw === runEnd.raw ? runStart.raw : `${runStart.raw}-${runEnd.raw}`);
}
runStart = cur;
runEnd = cur;
}
if (runStart && runEnd) {
segments.push(runStart.raw === runEnd.raw ? runStart.raw : `${runStart.raw}-${runEnd.raw}`);
}
return segments.join(', ');
}
test('single-berth bundle renders the primary mooring verbatim', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Range Single');
const res = await page.request.get(`/api/v1/interests/${deal.interestId}/eoi-context`, {
headers,
});
expect(res.ok(), `eoi-context: ${res.status()} ${await res.text()}`).toBe(true);
const body = (await res.json()) as EoiContextResponse;
// formatBerthRange(['Z1234']) === 'Z1234' — single-berth is byte-identical
// to the legacy primary-only path.
expect(body.data.eoiBerthRange).toBe(deal.mooringNumber);
expect(body.data.berth?.mooringNumber).toBe(deal.mooringNumber);
});
test('multi-berth bundle renders a collapsed compact range', async ({ page }) => {
const headers = await apiHeaders(page);
const stamp = Date.now();
// Seed a client + yacht; we'll manage the berths explicitly so we can
// build a contiguous run that collapses into a range.
const clientRes = await page.request.post('/api/v1/clients', {
headers,
data: {
fullName: `Range Multi Client ${stamp}`,
contacts: [{ channel: 'email', value: `range-${stamp}@example.test`, isPrimary: true }],
},
});
expect(clientRes.ok(), `client: ${clientRes.status()}`).toBe(true);
const clientId = ((await clientRes.json()) as { data: { id: string } }).data.id;
const yachtRes = await page.request.post('/api/v1/yachts', {
headers,
data: { name: `Range Multi Yacht ${stamp}`, owner: { type: 'client', id: clientId } },
});
expect(yachtRes.ok(), `yacht: ${yachtRes.status()}`).toBe(true);
const yachtId = ((await yachtRes.json()) as { data: { id: string } }).data.id;
// Build a contiguous run so the formatter collapses it: M{base}..M{base+2}.
const base = (stamp % 9000) + 1000;
const moorings = [`M${base}`, `M${base + 1}`, `M${base + 2}`];
const berthIds: string[] = [];
for (const mooringNumber of moorings) {
const berthRes = await page.request.post('/api/v1/berths', {
headers,
data: {
mooringNumber,
area: 'Range Area',
price: 100000,
priceCurrency: 'EUR',
tenureType: 'permanent',
},
});
expect(berthRes.ok(), `berth ${mooringNumber}: ${berthRes.status()}`).toBe(true);
berthIds.push(((await berthRes.json()) as { data: { id: string } }).data.id);
}
// Create the interest with the first berth as primary (auto in-bundle).
const interestRes = await page.request.post('/api/v1/interests', {
headers,
data: { clientId, yachtId, berthId: berthIds[0], pipelineStage: 'enquiry' },
});
expect(interestRes.ok(), `interest: ${interestRes.status()}`).toBe(true);
const interestId = ((await interestRes.json()) as { data: { id: string } }).data.id;
// Add the remaining berths — they default to is_in_eoi_bundle=true.
for (const berthId of berthIds.slice(1)) {
const addRes = await page.request.post(`/api/v1/interests/${interestId}/berths`, {
headers,
data: { berthId, isSpecificInterest: false },
});
expect(addRes.ok(), `add berth: ${addRes.status()} ${await addRes.text()}`).toBe(true);
}
// Read back the resolved EoiContext and assert the server-computed
// range collapses the contiguous run, matching formatBerthRange().
const res = await page.request.get(`/api/v1/interests/${interestId}/eoi-context`, {
headers,
});
expect(res.ok(), `eoi-context: ${res.status()} ${await res.text()}`).toBe(true);
const body = (await res.json()) as EoiContextResponse;
const expected = expectedRange(moorings);
// e.g. 'M1234-M1236' — a single collapsed run.
expect(body.data.eoiBerthRange).toBe(expected);
expect(body.data.eoiBerthRange).toContain('-');
// All three moorings must be represented within the range string.
expect(body.data.eoiBerthRange).toContain(moorings[0]!);
expect(body.data.eoiBerthRange).toContain(moorings[moorings.length - 1]!);
});
});

View File

@@ -0,0 +1,182 @@
import { test, expect } from '@playwright/test';
import { login, apiHeaders } from '../smoke/helpers';
import { seedFullyLoadedDeal } from '../fixtures/sales-journey';
/**
* EOI generation parity — the two pathways agree on field content.
*
* The CRM fills an EOI two ways, both driven by the *same* `EoiContext`
* (`src/lib/services/eoi-context.ts`):
*
* 1. In-app pathway — `fillEoiFormFields()` (src/lib/pdf/fill-eoi-form.ts)
* fills `assets/eoi-template.pdf`'s AcroForm fields and flattens.
* 2. Documenso pathway — `buildDocumensoPayload()`
* (src/lib/services/documenso-payload.ts) emits `formValues` keyed
* by the *same field names*.
*
* Both consume identical derivation rules, so for the same EoiContext
* the rendered field content must match field-for-field:
*
* Name = client.fullName
* Email = client.primaryEmail
* Address = "street, city, REGION, postal, COUNTRY-ISO"
* Yacht Name = yacht.name (blank when no yacht)
* Length/Width/Draft = "<n> <unit>" (blank when unset)
* Berth Number = eoiBerthRange || primary mooring
* Purchase = true, Lease_10 = false (constant)
*
* `EoiContext` is computed once, server-side; the `/eoi-context` read
* API returns it verbatim. This spec asserts that the *single shared
* source* carries exactly the values both pathways are contractually
* required to render, which is the parity guarantee. (The per-pathway
* derivation functions themselves are unit-tested in
* `tests/unit/pdf/fill-eoi-form.test.ts` and
* `tests/unit/services/documenso-payload.test.ts`; this spec validates
* the live end-to-end context they both feed off is internally
* consistent for a real seeded deal.)
*/
interface EoiContextResponse {
data: {
client: {
fullName: string;
primaryEmail: string | null;
address: {
street: string;
city: string;
subdivision: string;
postalCode: string;
countryIso: string;
} | null;
};
yacht: { name: string } | null;
berth: { mooringNumber: string } | null;
eoiBerthRange: string;
};
}
/** Re-derive the `Berth Number` field value exactly as BOTH pathways do:
* `context.eoiBerthRange || (context.berth?.mooringNumber ?? '')`. */
function deriveBerthNumberField(ctx: EoiContextResponse['data']): string {
return ctx.eoiBerthRange || (ctx.berth?.mooringNumber ?? '');
}
/** Re-derive the `Address` field value exactly as both `formatAddress`
* implementations do (identical in fill-eoi-form.ts and documenso-payload.ts):
* street, city, subdivision-suffix, postal, country-ISO joined by ", ". */
function deriveAddressField(ctx: EoiContextResponse['data']): string {
const a = ctx.client.address;
if (!a) return '';
return [a.street, a.city, a.subdivision, a.postalCode, a.countryIso].filter(Boolean).join(', ');
}
test.describe('exhaustive: EOI pathway parity', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
test('shared EoiContext carries the canonical field set both pathways render', async ({
page,
}) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Parity');
const res = await page.request.get(`/api/v1/interests/${deal.interestId}/eoi-context`, {
headers,
});
expect(res.ok(), `eoi-context: ${res.status()} ${await res.text()}`).toBe(true);
const { data } = (await res.json()) as EoiContextResponse;
// ─── Section 2 (required, EOI hard-gate fields) ───
// Name + Email come straight from the client; both pathways set
// these identically with no transformation.
expect(data.client.fullName).toBeTruthy();
expect(data.client.primaryEmail).toMatch(/@example\.test$/);
// ─── Berth Number (the parity-sensitive field) ───
// Both pathways compute this with the identical expression.
const berthNumberField = deriveBerthNumberField(data);
expect(berthNumberField).toBe(deal.mooringNumber);
// Single-berth deal: eoiBerthRange === primary mooring (no range collapse).
expect(data.eoiBerthRange).toBe(deal.mooringNumber);
// ─── Address (identical formatAddress in both pathways) ───
// Seeded client has no address, so both pathways render an empty
// Address field — and crucially, render the *same* empty string.
const addressField = deriveAddressField(data);
expect(typeof addressField).toBe('string');
// ─── Yacht Name ───
// A yacht is linked at seed time, so both pathways render its name.
expect(data.yacht?.name).toBeTruthy();
});
test('Berth Number field is identical for the multi-berth range case', async ({ page }) => {
const headers = await apiHeaders(page);
const stamp = Date.now();
const clientRes = await page.request.post('/api/v1/clients', {
headers,
data: {
fullName: `Parity Multi ${stamp}`,
contacts: [{ channel: 'email', value: `parity-${stamp}@example.test`, isPrimary: true }],
},
});
expect(clientRes.ok()).toBe(true);
const clientId = ((await clientRes.json()) as { data: { id: string } }).data.id;
const yachtRes = await page.request.post('/api/v1/yachts', {
headers,
data: { name: `Parity Yacht ${stamp}`, owner: { type: 'client', id: clientId } },
});
expect(yachtRes.ok()).toBe(true);
const yachtId = ((await yachtRes.json()) as { data: { id: string } }).data.id;
const base = (stamp % 9000) + 1000;
const moorings = [`P${base}`, `P${base + 1}`];
const berthIds: string[] = [];
for (const mooringNumber of moorings) {
const berthRes = await page.request.post('/api/v1/berths', {
headers,
data: {
mooringNumber,
area: 'Parity Area',
price: 90000,
priceCurrency: 'EUR',
tenureType: 'permanent',
},
});
expect(berthRes.ok(), `berth ${mooringNumber}: ${berthRes.status()}`).toBe(true);
berthIds.push(((await berthRes.json()) as { data: { id: string } }).data.id);
}
const interestRes = await page.request.post('/api/v1/interests', {
headers,
data: { clientId, yachtId, berthId: berthIds[0], pipelineStage: 'enquiry' },
});
expect(interestRes.ok()).toBe(true);
const interestId = ((await interestRes.json()) as { data: { id: string } }).data.id;
const addRes = await page.request.post(`/api/v1/interests/${interestId}/berths`, {
headers,
data: { berthId: berthIds[1], isSpecificInterest: false },
});
expect(addRes.ok(), `add berth: ${addRes.status()} ${await addRes.text()}`).toBe(true);
const res = await page.request.get(`/api/v1/interests/${interestId}/eoi-context`, {
headers,
});
expect(res.ok()).toBe(true);
const { data } = (await res.json()) as EoiContextResponse;
// Both pathways render the collapsed range as the Berth Number field —
// the range wins over the bare primary mooring when the bundle has >1
// berth. The expression is identical on both sides.
const berthNumberField = deriveBerthNumberField(data);
expect(berthNumberField).toBe(`P${base}-P${base + 1}`);
// And it is the eoiBerthRange (not the primary mooring) that drives it.
expect(data.eoiBerthRange).toBe(berthNumberField);
expect(berthNumberField).not.toBe(data.berth?.mooringNumber);
});
});

View File

@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test';
import { login, apiHeaders, navigateTo, PORT_SLUG } from '../smoke/helpers';
import { IPHONE_DEVICES } from '../fixtures/devices';
import { seedFullyLoadedDeal, changeStage, readStage } from '../fixtures/sales-journey';
/**
* Mobile parity for the core sales journey.
*
* Re-runs the deterministic pipeline walk at a phone viewport
* (iPhone 15/16, 393×852 — the "common" anchor in
* `tests/e2e/fixtures/devices.ts`) to confirm the stage transitions and
* the interest-detail surface behave identically on mobile. Uses the
* existing device descriptor + `test.use({ viewport, … })` rather than
* introducing a new Playwright project, since the exhaustive project
* already runs the rest of the journey on desktop.
*/
const phone = IPHONE_DEVICES.iphone16;
test.use({
viewport: phone.viewport,
deviceScaleFactor: phone.deviceScaleFactor,
isMobile: phone.isMobile,
hasTouch: phone.hasTouch,
userAgent: phone.userAgent,
});
test.describe(`exhaustive: sales journey on ${phone.name}`, () => {
test.beforeEach(async ({ page }) => {
await login(page, 'super_admin');
});
test('pipeline walk reaches contract on a mobile viewport', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Mobile Journey');
expect(await readStage(page.request, headers, deal.interestId)).toBe('enquiry');
for (const stage of [
'qualified',
'nurturing',
'eoi',
'reservation',
'deposit_paid',
'contract',
] as const) {
await changeStage(page.request, headers, deal.interestId, stage);
expect(
await readStage(page.request, headers, deal.interestId),
`mobile: interest should be at ${stage}`,
).toBe(stage);
}
});
test('interest detail renders its stage on a mobile viewport', async ({ page }) => {
const headers = await apiHeaders(page);
const deal = await seedFullyLoadedDeal(page, 'Mobile Detail');
await changeStage(page.request, headers, deal.interestId, 'qualified');
await navigateTo(page, `/interests/${deal.interestId}`);
await page.waitForURL(new RegExp(`/${PORT_SLUG}/interests/${deal.interestId}`), {
timeout: 10_000,
});
await page.waitForLoadState('networkidle');
// The viewport is genuinely mobile-width.
expect(page.viewportSize()?.width).toBe(phone.viewport.width);
// The stage label is present on the mobile-rendered detail page.
await expect(page.getByText(/qualified/i).first()).toBeVisible({ timeout: 10_000 });
});
test('interests list is reachable and renders on a mobile viewport', async ({ page }) => {
await navigateTo(page, '/interests');
await page.waitForLoadState('networkidle');
// The page heading is present regardless of whether the mobile layout
// shows a table or a card/board view.
await expect(page.getByText(/interests/i).first()).toBeVisible({ timeout: 15_000 });
await expect
.poll(
async () => {
const table = await page
.locator('table')
.first()
.isVisible()
.catch(() => false);
const cards = await page
.locator('[data-testid="pipeline-board"], [class*="board"], [class*="card"]')
.first()
.isVisible()
.catch(() => false);
return table || cards;
},
{ timeout: 15_000 },
)
.toBe(true);
});
});