import { AppShell, defineModule } from "@tailor-platform/app-shell"; import { render, waitFor, within, type RenderOptions } from "@testing-library/react"; import type { ReactElement, ReactNode } from "react"; import { AuthFetchContext } from "@/lib/auth-fetch"; // AppShell theme + jsdom: polyfill only when this module loads (action-panel tests import it). // `localStorage` access can throw `SecurityError` on opaque origins, so probe defensively. function hasUsableLocalStorage(): boolean { try { return typeof globalThis.localStorage?.getItem === "function"; } catch { return false; } } if (!hasUsableLocalStorage()) { // jsdom may expose `localStorage` as an accessor-only descriptor, so plain // assignment can throw `TypeError`. `Object.defineProperty` installs the // polyfill regardless of the prior descriptor shape. const memory = new Map(); const polyfill: Storage = { getItem: (key: string) => memory.get(key) ?? null, setItem: (key: string, value: string) => { memory.set(key, String(value)); }, removeItem: (key: string) => { memory.delete(key); }, clear: () => memory.clear(), key: (index: number) => [...memory.keys()][index] ?? null, get length() { return memory.size; }, }; Object.defineProperty(globalThis, "localStorage", { configurable: true, writable: true, value: polyfill, }); } if (typeof window.matchMedia !== "function") { Object.defineProperty(window, "matchMedia", { writable: true, configurable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => { /* MediaQueryList mock */ }, removeListener: () => { /* MediaQueryList mock */ }, addEventListener: () => { /* MediaQueryList mock */ }, removeEventListener: () => { /* MediaQueryList mock */ }, dispatchEvent: () => false, }), }); } import { Provider as UrqlProvider, createClient, type Exchange, type Operation } from "urql"; import { pipe, map, tap } from "wonka"; // ------------------------------------------------------------------------- // Recording exchange — captures every Operation the component dispatches // ------------------------------------------------------------------------- export interface RecordedOperation { kind: Operation["kind"]; /** e.g. "SubmitPurchaseOrder" — from `mutation SubmitPurchaseOrder(...) { ... }` */ name: string | null; variables: Record; } export interface MutationRecorder { operations: RecordedOperation[]; mutations: RecordedOperation[]; } // Minimal shape of the AST nodes we care about. The full GraphQL DefinitionNode // union has entries without a `name` field, so narrowing off `kind` with a // type predicate hits "not assignable" errors. Casting through this narrow // shape keeps the helper safe without pulling in the `graphql` dependency. interface OperationDefinitionLike { kind: string; name?: { value?: string }; } function extractOperationName(op: Operation): string | null { // `op.query` is urql's `DocumentInput`, whose union includes `PersistedDocument` // (no `definitions`), so accessing `.definitions` on it directly fails to type-check. // Cast through a narrow shape that exposes the optional `definitions` array. const query = op.query as { definitions?: readonly OperationDefinitionLike[] }; for (const def of query.definitions ?? []) { if (def.kind === "OperationDefinition") { return def.name?.value ?? null; } } return null; } /** Minimal GraphQL-shaped payloads so hooks don’t throw on `undefined.edges`. */ function cannedGraphQLData(operationName: string | null): Record { switch (operationName) { case "BPAccountAddressOptions": return { partnerAddresses: { pageInfo: { hasNextPage: false, endCursor: null }, edges: [ { node: { id: "address-1", line1: "Warehouse", line2: null, city: null, state: null, postalCode: null, country: null, }, }, ], }, }; case "PurchaseOrderRevisionsForDrawer": return { purchaseOrderRevisions: { edges: [] } }; case "ItemsByIdsForRevisionHistory": return { items: { edges: [] } }; case "SitesByIdsForRevisionHistory": return { sites: { edges: [] } }; case "UsersByIdsForRevisionHistory": return { users: { edges: [] } }; case "PurchaseOrderLinesByIdsForRevisionHistory": return { purchaseOrderLines: { edges: [] } }; case "CompaniesForValuationPolicyCreateDrawer": case "CompaniesForItemValuationDrawer": case "CompaniesForAssignValuation": return { companies: { edges: [{ node: { id: "co-1", legalName: "Test Company" } }] } }; case "ItemEditModal": return { item: { id: "item-1", name: "Test Item", barcode: "BARCODE-1" }, }; case "PurchaseItemForItem": return { purchaseItems: { edges: [{ node: { id: "pi-1", requiresPhysicalReceipt: true } }], }, }; case "SalesItemForItem": return { salesItems: { edges: [{ node: { id: "si-1", requiresPhysicalFulfillment: true } }], }, }; case "ItemValuationsForItem": return { itemValuations: { total: 1, pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null, }, edges: [ { node: { id: "iv1", createdAt: "2026-01-01T00:00:00.000Z", company: { id: "co-1", legalName: "Test Company" }, valuationPolicy: { id: "vp-1", name: "Standard Policy", costingMethod: "STANDARD" }, }, }, ], }, standardCosts: { edges: [{ node: { id: "sc1", unitCost: "12.50", version: 1 } }], }, valuationPolicies: { edges: [{ node: { id: "vp-1", defaultCompanyId: "co-1", costingMethod: "STANDARD" } }], }, }; case "PoliciesForItemValuationDrawer": return { valuationPolicies: { edges: [ { node: { id: "vp-1", name: "Standard Policy", companyId: "co-1", costingMethod: "STANDARD", }, }, ], }, }; case "ItemStandardCostForItem": return { standardCosts: { edges: [{ node: { id: "sc1", unitCost: "12.50" } }] } }; // Supplier invoice tolerance card + edit drawer share this shape. The // percentage field carries a fraction (0.05 → "5%"); the unit-price // percentage is null to exercise the "Not set (strict match)" branch. case "SupplierInvoiceTolerance": case "InvoiceToleranceConfigForDrawer": return { invoiceToleranceConfigs: { edges: [ { node: { id: "itc1", quantityAbsoluteTolerance: "5.000000", quantityPercentageTolerance: "0.050000", unitPriceAbsoluteTolerance: "0.500000", unitPricePercentageTolerance: null, }, }, ], }, }; case "SupplierAccountSearch": return { supplierAccounts: { edges: [ { node: { id: "supplier-1", name: "Default Expense Supplier", companyId: "co-1", status: "ACTIVE", defaultExpenseAccountId: "expense-1", }, }, ], }, }; case "PoliciesForAssignValuation": return { valuationPolicies: { edges: [ { node: { id: "vp-1", name: "Standard Policy", companyId: "co-1", costingMethod: "STANDARD", }, }, ], }, }; case "CompanyDefaultValuationPolicy": return { valuationPolicies: { edges: [ { node: { id: "vp-1", name: "Standard Policy", costingMethod: "STANDARD", defaultCompanyId: "co-1", }, }, ], }, }; case "CompanyValuationPolicies": return { valuationPolicies: { total: 1, pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null, }, edges: [ { node: { id: "vp-1", name: "Standard Policy", costingMethod: "STANDARD", defaultCompanyId: "co-1", createdAt: "2026-01-01T00:00:00.000Z", }, }, ], }, }; case "ValuationPolicyForDrawer": return { valuationPolicy: { id: "vp-1", name: "Standard Policy", costingMethod: "STANDARD", defaultCompanyId: "co-1", companyId: "co-1", inventoryAccountId: "ac1", accrualAccountId: "ac2", cogsAccountId: "ac3", ppvAccountId: "ac4", adjustmentAccountId: "ac5", standardCostAdjustmentAccountId: "ac6", invoicePriceVarianceAccountId: "ac7", consumedPriceVarianceAccountId: "ac8", }, }; case "AccountsByIdsForValuationDrawer": return { accounts: { edges: [1, 2, 3, 4, 5, 6, 7, 8].map((n) => ({ node: { id: `ac${n}`, code: `${1300 + n}`, name: `Account ${n}`, accountType: "ASSET", status: "ACTIVE", }, })), }, }; case "PoliciesForChangeDefault": return { valuationPolicies: { edges: [ { node: { id: "vp-1", name: "Standard Policy", costingMethod: "STANDARD", defaultCompanyId: "co-1", }, }, { node: { id: "vp-2", name: "FIFO Policy", costingMethod: "FIFO", defaultCompanyId: null, }, }, ], }, }; // Cost drawer: a FIFO-assigned item so the per-method switch renders the // cost-layer view (proves method-based branching). case "ItemValuationsForCostDrawer": return { itemValuations: { edges: [ { node: { id: "iv1", createdAt: "2026-01-01T00:00:00.000Z", company: { id: "co-1", legalName: "Test Company" }, valuationPolicy: { id: "vp-1", name: "FIFO Policy", costingMethod: "FIFO" }, }, }, ], }, }; case "CostLayersForCostDrawer": return { costLayers: { total: 1, pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null, }, edges: [ { node: { id: "cl1", effectiveDate: "2026-01-01", quantity: "10", remainingQuantity: "4", fifoCosts: { edges: [{ node: { id: "fc1", unitCost: "12.50" } }] }, }, }, ], }, }; // Journal list (grouped-by-entry BFF): a company + period for the filters, // and two balanced entries (two lines each) so grouping headers + line rows // both render. case "CompaniesForJournal": return { companies: { edges: [{ node: { id: "co-1", legalName: "Test Company" } }] } }; case "PeriodsForJournal": return { accountingPeriods: { edges: [ { node: { id: "ap-1", name: "2026-01", fiscalYear: { id: "fy-1", name: "FY2026" } } }, ], }, }; case "AccountsForJournal": return { accounts: { edges: [ { node: { id: "a1", code: "1300", name: "Inventory Asset" } }, { node: { id: "a4", code: "1000", name: "Cash" } }, ], }, }; case "JournalLinesGrouped": return { journalLinesGroupedByEntryId: { hasNextPage: false, entryCount: 2, total: 2, rows: [ { lineId: "l1", journalEntryId: "je-1", accountId: "a1", accountCode: "1300", accountName: "Inventory Asset", debitAmount: "70", creditAmount: null, lineDescription: "Receipt", entryDate: "2026-01-15T00:00:00.000Z", status: "POSTED", entryDescription: "Inventory receipt", sourceDocumentType: "INVENTORY_LEDGER", sourceDocumentId: "ish-1", postedAt: "2026-01-15T00:00:00.000Z", }, { lineId: "l2", journalEntryId: "je-1", accountId: "a2", accountCode: "2100", accountName: "GR/IR Clearing", debitAmount: null, creditAmount: "70", lineDescription: "Receipt", entryDate: "2026-01-15T00:00:00.000Z", status: "POSTED", entryDescription: "Inventory receipt", sourceDocumentType: "INVENTORY_LEDGER", sourceDocumentId: "ish-1", postedAt: "2026-01-15T00:00:00.000Z", }, { lineId: "l3", journalEntryId: "je-2", accountId: "a3", accountCode: "5000", accountName: "Supplies Expense", debitAmount: "100", creditAmount: null, lineDescription: "Manual", entryDate: "2026-01-10T00:00:00.000Z", status: "DRAFT", entryDescription: "Manual entry", sourceDocumentType: null, sourceDocumentId: null, postedAt: null, }, { lineId: "l4", journalEntryId: "je-2", accountId: "a4", accountCode: "1000", accountName: "Cash", debitAmount: null, creditAmount: "100", lineDescription: "Manual", entryDate: "2026-01-10T00:00:00.000Z", status: "DRAFT", entryDescription: "Manual entry", sourceDocumentType: null, sourceDocumentId: null, postedAt: null, }, ], }, }; // Journal entry detail modal (built-in journalEntry(id) lookup). case "JournalEntryForModal": return { journalEntry: { id: "je-1", status: "POSTED", entryDate: "2026-01-15T00:00:00.000Z", description: "Inventory receipt", postedAt: "2026-01-15T00:00:00.000Z", sourceDocumentType: "INVENTORY_LEDGER", reversalOfId: null, createdAt: "2026-01-15T00:00:00.000Z", accountingPeriod: { id: "ap-1", name: "2026-01" }, lines: { edges: [ { node: { id: "l1", accountId: "a1", debitAmount: "70", creditAmount: null, description: "Receipt", account: { id: "a1", code: "1300", name: "Inventory Asset" }, }, }, { node: { id: "l2", accountId: "a2", debitAmount: null, creditAmount: "70", description: "Receipt", account: { id: "a2", code: "2100", name: "GR/IR Clearing" }, }, }, ], }, }, }; default: return {}; } } /** * A urql exchange that records every operation and returns a canned success * response. Use this to assert what the component dispatches (mutation name + * variables) without running a real backend. */ function createRecordingExchange(recorder: MutationRecorder): Exchange { return () => (ops$) => pipe( ops$, // Pair each operation with its extracted name once — both the recorder // tap and the canned-response map reuse the same value (no duplicated // AST walk, and impossible for the recorder and response to drift apart). map((op) => ({ op, name: extractOperationName(op) })), tap(({ op, name }) => { const recorded: RecordedOperation = { kind: op.kind, name, variables: op.variables ?? {}, }; recorder.operations.push(recorded); if (op.kind === "mutation") recorder.mutations.push(recorded); }), map(({ op, name }) => ({ operation: op, data: cannedGraphQLData(name), error: undefined, extensions: undefined, hasNext: false, stale: false, })), ); } function createRecorder(): MutationRecorder { return { operations: [], mutations: [] }; } /** Satisfies AppShell’s route config; shell merges RTL `children` into the root layout. */ const testAppShellModules = [ defineModule({ path: "__vitest__", meta: { title: "Vitest" }, resources: [], component: () => null, }), ]; // ------------------------------------------------------------------------- // Providers // ------------------------------------------------------------------------- function AllProviders({ children, recorder }: { children: ReactNode; recorder: MutationRecorder }) { const client = createClient({ url: "http://test.invalid/graphql", exchanges: [createRecordingExchange(recorder)], // Avoid urql short-circuiting mutations before they hit the exchange // chain; we want every dispatch to reach our tap() so it gets recorded. requestPolicy: "network-only", // The recording exchange returns a synchronous canned response via // `fromValue`-like behaviour, so no real network call is made. suspense: false, }); return ( {/* Mirrors App.tsx: components using useAuthFetch (e.g. file attachments) need an AuthFetchContext. The stub is never called in these tests — it only satisfies the hook's presence check. */} {/* ActionPanel needs CommandPaletteProvider (inside AppShell); modules avoids configuration error */} {children} ); } const testAuthFetch: typeof fetch = () => Promise.resolve(new Response(null, { status: 200 })); /** * Render a component with AppShell (command palette + router) + urql Provider backed by a recording * exchange. Returns the usual RTL result plus `recorder`, which captures every * mutation the component dispatches (name + variables). * * Async: AppShell's data router defers painting until the root loader settles. * By default we wait until at least one `