Compare commits

...

269 Commits

Author SHA1 Message Date
spouliot 7cbae31916 Fix invoice ProjectName not pre-filling on edit; add to Details view
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:58:09 -04:00
spouliot 9367e358d9 Add Project Name field to invoice create and edit forms
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:50:02 -04:00
spouliot 9f1460c9c0 Make Low Stock stat card clickable to filter inventory by low stock items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:04 -04:00
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00
spouliot 456d054229 Fix prospect quote conversion losing the job; add reply-to in email footer
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.

NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:35:48 -04:00
spouliot f38a1e3273 Add Reply-To diagnostic logging to GetEmailFromAsync
Logs a Warning when no Reply-To email is configured for a company
(so the logs show why replies land at the platform sender address)
and a Debug entry when one is set, making future send issues
diagnosable without needing the SendGrid Activity API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:08:45 -04:00
spouliot 03b425a12f Update blast rate tests to match nozzle-primary formula
Remove the CFM=0→zero test (CFM no longer in the formula path).
Update expected values to match the new nozzle-primary tables and
corrected TierDefaults CFM/nozzle pairings. Add WetBlasting and
RustAndScale substrate coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:21:39 -04:00
spouliot 8453449833 Recalibrate blast rate formula from industry reference tables
Replace the inaccurate CFM-based formula with nozzle-primary tables
sourced from industry standard abrasive blast cleaning references:
- Pressure pot: midpoints averaged from two reference tables
  (#4 nozzle: 115 sqft/hr, #5: 175 sqft/hr, etc.)
- Siphon cabinet: dedicated siphon cabinet reference table
  (#4 nozzle: 125 sqft/hr, #3: 75 sqft/hr, etc.)
- SiphonPot: 80% of pressure pot rate (open gravity feed, no enclosure)
- WetBlasting: 60% of pressure pot rate (water-media reduces velocity)

CFM is removed from the rate formula entirely — nozzle size determines
throughput and CFM draw, so CFM is a consequence of nozzle choice, not
an independent variable. Override field still bypasses formula for shops
that have measured their own throughput.

Also corrects TierDefaults nozzle/CFM pairings which were mismatched
(e.g. Small tier had 40 CFM assigned to a #5 nozzle that needs 150 CFM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:12:45 -04:00
spouliot ad986561c9 Fix AI quote blast rate: single formula path, correct client preview
Root cause: company-settings-lookups.js had its own baseByCfm/multiplier
tables that were completely different from ShopCapabilityCalculator.cs,
so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt
received the server-computed rate (e.g. 9 sqft/hr).

- Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that
  calls ShopCapabilityCalculator directly; now the single formula path
- Delete all client-side formula code (baseByCfm, multiplier tables,
  deriveBlastRate) — ~30 lines removed
- Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms
  debounce instead of computing locally
- Blast setup table uses setup.derivedRate from GetBlastSetups (already
  server-computed) instead of recalculating client-side
- QuotesController.AiAnalyzeItem: when no blast setup is explicitly
  selected, fall back to the company's default blast setup so the
  configured rate is always used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:57:46 -04:00
spouliot 0d5553f3b2 Fix dark mode hover colors in coat/powder dropdown menus
Replace all hardcoded light-mode colors (#f0f4ff, #e8eeff, #fff8e1, #fff)
with Bootstrap CSS variables (--bs-secondary-bg, --bs-primary-bg-subtle,
--bs-warning-bg-subtle, --bs-body-bg) so dropdown containers and hover
states render correctly in both light and dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:12:24 -04:00
spouliot 87bbf158a4 Fix material usage logging: remaining weight mode, edit modal, and consolidate duplicate logic
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage
  (scan page) and LogMaterial (jobs modal, moved from JobsController) call it —
  no more duplicate save/GL logic across two controllers
- Log Material modal: replace radio buttons with prominent toggle buttons so the
  active mode (Amount Used vs Amount Remaining) is always visually obvious; add
  always-visible preview line showing exactly what will be logged before saving
- Edit Usage modal: add quantity field (pre-populated from existing transaction)
  with delta adjustment to InventoryItem.QuantityOnHand on save; include
  completed/terminal jobs in the dropdown so entries can be corrected after a
  job is marked done
- Scan page job picker: include jobs completed within the last 7 days (marked
  with '(completed)') so usage can be logged after a job is finished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:31:02 -04:00
spouliot f453a95f28 Add hover tooltips on job list rows showing description and PO number
Adds CustomerPO to JobListDto (maps by convention), then builds a
Bootstrap tooltip per row with description · PO: xxx, skipping blank
fields. Rows with neither get no tooltip. Helps identify jobs at a
glance without opening the details page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:53:52 -04:00
spouliot d9e98a55d2 Fix customer email inputs to allow comma-separated addresses
type="email" triggers jQuery Validate's email rule which rejects commas,
blocking multi-address input despite the multiple attribute. Switching to
type="text" defers validation to the server-side SplitEmails/MailAddress
logic in the DTO which already handles comma-separated lists correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:16:30 -04:00
spouliot 99deca3b62 Default imported formula templates to active regardless of export state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:14:48 -04:00
spouliot 23e64829bb Fix formula export/import: embed fields as real JSON array, not escaped string
Previously FieldsJson was serialized as an escaped string in the export
file, which was fragile and unreadable. Now parsed into a JsonElement and
embedded as a proper JSON array under the key "fields". Import reads it
back with GetRawText() to reconstruct the stored string. This prevents
the null/empty fields bug caused by manually-edited or round-tripped files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:06:38 -04:00
spouliot cd4c233b60 Fix formula export casing: use camelCase to match import property lookups
System.Text.Json defaults to PascalCase for anonymous types, producing
"Name"/"OutputMode" etc., while the import used TryGetProperty("name")
causing every template to fail with "no name". Adding CamelCase naming
policy aligns the export format with what the import expects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:52:58 -04:00
spouliot 6c07216c64 Fix custom formula item pricing: multiply by quantity, not divide
ManualUnitPrice holds the per-item formula result. The previous code
incorrectly treated it as the batch total and divided by Quantity,
causing the unit price to shrink as quantity increased. Now follows
the same pattern as every other ManualUnitPrice path in this method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:27:11 -04:00
spouliot b23bea6db0 Add formula template export/import and unsaved-changes guard
- Export: GET /CompanySettings/ExportCustomItemTemplates downloads all
  company templates as an indented JSON backup (strips internal IDs/paths)
- Import: POST /CompanySettings/ImportCustomItemTemplates restores from
  that file; runs full field + formula validation, skips name duplicates,
  returns per-item results (imported / skipped / errors)
- Unsaved-changes guard: cfModal now intercepts backdrop/ESC/X when the
  form is dirty and prompts before discarding work
- Export and Import buttons added to the Custom Formulas card header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:24:02 -04:00
spouliot cf07356147 Fix all NU1605 errors: suppress via Directory.Build.props instead of per-package pins
NCalc2 -> Antlr4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 triggers 6+
NU1605 downgrade warnings on linux-x64 publish (System.IO.FileSystem.Primitives,
System.Text.Encoding.Extensions, System.Diagnostics.Tracing, Microsoft.Win32.Primitives,
System.IO.FileSystem, System.Net.Primitives). All are harmless — .NET 8 supplies
these natively. Directory.Build.props suppresses NU1605 solution-wide cleanly.
Removes the individual System.Runtime.InteropServices pin added in previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:41:56 -04:00
spouliot 39b103a482 Fix NU1605 package downgrade: pin System.Runtime.InteropServices 4.3.0
NCalc2 -> Antlr4 -> NETStandard.Library transitive dependency chain requires
System.Runtime.InteropServices >= 4.3.0, but the resolved version was 4.1.0.
Explicit pin in Application.csproj resolves the Jenkins publish failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:27:26 -04:00
spouliot 4aae2df5b5 Merge dev into master
Includes: Community Formula Library, Custom Formula Templates, Employee Timeclock,
Formula Library ratings, Job Profitability report, Quote Revision History,
flat-rate coat wizard UX improvements, customer import dedup fixes, inventory
incoming powder fixes, Custom Powder Order line item, and various bug fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:16:10 -04:00
spouliot 3416c242f1 Update help docs and AI knowledge base for item wizard changes
- Quotes help: expand Item Types section to all 7 types (was showing only 3)
- Quotes help: update Coatings section to document Flat-Rate coat spec feature
- Quotes help: add Color Name tip to Custom Powder Order section
- HelpKnowledgeBase: update item types list to all 7 types with accurate descriptions
- HelpKnowledgeBase: add Color Name / blur-autofill note to Custom Powder Order entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:05:47 -04:00
spouliot 7e31846777 Fix flat-rate coat wizard UX, &mdash; literal, select caret overlap, and walkthrough modal sizing
- Flat-rate items now default coat type to Custom so Color Name field is immediately visible
- Catalog search blur copies typed text to Color Name when no catalog result was selected
- Item card shows 'No color specified' badge when coat has powder-to-order but no color name
- Color Name label marked required with '(shows on quote)' hint
- Coat name select min-width prevents text overlapping Bootstrap caret arrow
- Remove extra unbalanced </div> from renderSalesFields
- Fix literal &mdash; in quote simple-mode hint (textContent → innerHTML)
- Formula walkthrough modal fixed at 700px so all steps render at identical window size

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:01:33 -04:00
spouliot ed35362c7a Add Formula Library ratings, Job Profitability report, and Quote Revision History improvements
- Formula Library ratings: thumbs up/down per company per formula; toggle on/off; sorts by net score; own formulas not rateable; FormulaLibraryRating entity + migration AddFormulaLibraryRatings
- Job Profitability report: actual labor cost (logged hours x StandardLaborRate) vs powder cost vs billed price per job; gross margin % color-coded; time-tracked-only filter; totals footer
- Quote Revision History: track Total price changes on every save; log Sent/Resent events with recipient email; replace flat table with grouped timeline UI (icons per event type, total-change badge on header)
- Setup Wizard: cap CompletedCount at TotalSteps so old 10-step data no longer shows 10/5
- Formula Library card: fix badge overflow on long titles; add Rate: label to make voting buttons discoverable
- Help docs and AI knowledge base updated for all three features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:02:07 -04:00
spouliot 81119035c7 Restructure formula template modal for correct mobile flow
Reorganises the Add/Edit Formula Template modal into four logical rows
so that on mobile the sections stack in the natural authoring order:
  1. Name / Description | Output Mode / Rate
  2. Fields (left) | Formula + Test (right)
  3. Diagram | AI Generator
  4. Notes + Active (full width)

Previously, Fields appeared after the Formula on mobile because the left
column (containing Formula) stacked before the right column (containing
Fields). Also compacted Default Rate and Rate Label into a 2-column
mini-row so they sit side by side on all screen sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:00:39 -04:00
spouliot 0deef574c3 Fix formula pages mobile responsiveness; fix missing mobile tabs
- Custom Formulas and Timeclock tabs were completely missing from the mobile
  dropdown selector, making them unreachable on phones; also adds AI Profile
  and Online Payments which were similarly absent
- Formula library header: flex-column on mobile so title and button stack
  cleanly instead of colliding
- Filter bar: icon-only button gets a visible label on mobile; added col-12
  so it renders full-width correctly at xs
- Import modal: add modal-dialog-scrollable so body scrolls on small screens;
  wrap field table in table-responsive to prevent horizontal overflow
- Settings card header: flex-column on mobile + flex-wrap on button group
  so the three buttons don't overflow off the right edge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:48:33 -04:00
spouliot efc4e9dadf Fix NCalc case sensitivity and add formula validation
- Normalize IF/Abs/Pow/etc. to lowercase before evaluation so AI-generated
  or manually typed uppercase function names no longer cause "Function not
  found" errors
- Add NormalizeAndValidate() which normalizes then does a parse-only check
  on save — invalid formulas are rejected with a clear error before storing
- Update AI system prompt to list all functions in lowercase and explicitly
  call out case-sensitivity; add if() to the supported function list
- Add collapsible NCalc quick-reference panel in the formula editor showing
  all operators, functions (lowercase), built-in variables, and an example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:09:43 -04:00
spouliot ca7e905832 Add Community Formula Library feature
Companies can now share their custom formula templates to a platform-wide
community library. Other tenants can browse, preview, and import formulas
as independent local copies. Includes attribution (source company name),
"Inspired by" lineage for re-contributed formulas, import counts, own-formula
badge, cascade diagram nullification, and AI assistant + help docs updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:54:51 -04:00
spouliot 32d09b38f1 Fix invisible formula/AI badges: move custom bg-* colors to site.css
bg-purple (formula, AI badges) was only defined in company-settings-lookups.css
which is not loaded on Quote/Job pages. The badge element rendered with no
background, appearing as a blank space in front of the item description.

Moved all 13 custom color utilities (purple, pink, cyan, teal, indigo, lime,
brown, gray, orange, yellow, green, blue, red) to site.css so they are
available globally. company-settings-lookups.css retains its definitions for
now (harmless duplication; can be cleaned up later).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:25:36 -04:00
spouliot 3cee1307fc Fix customer import: normalize blank email to null, not empty string
The UNIQUE index on (CompanyId, Email) uses HasFilter([Email] IS NOT NULL),
so NULL allows multiple rows but empty string '' does not — every blank-email
customer after the first was hitting a duplicate-key violation at save time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:21:56 -04:00
spouliot be89327c01 Fix customer import dedup: use composite keys, not exclusive tiers
New logic:
  Tier 1 - email present: email match -> skip (unchanged)
  Tier 2 - email absent + phone present: name + phone composite -> skip
  Tier 3 - email and phone absent: name + city/state/zip composite -> warn, import anyway

Tier 2 requires BOTH name and phone to match so two people sharing an
office line don't falsely collide. Tier 3 warns but imports because
location data is too imprecise to hard-skip on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:02:10 -04:00
spouliot 8f955851e5 Improve customer import duplicate detection to 3-tier strategy
Tier 1 (email): existing behavior, now uses HashSet instead of O(n²) .Any()
Tier 2 (phone): when email is absent, deduplicate by normalised phone number
  (last 10 digits of MobilePhone then Phone) against both DB and within-batch
Tier 3 (name): when both email and phone are absent, warn but still import

Fixes customers with no email being silently skipped or left undetected as
duplicates. NormalizePhone strips formatting so (423) 331-9834 and
423-331-9834 match correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:48:40 -04:00
spouliot 972123c7a2 Fix incoming powder inventory: defer creation to approval, deduplicate, fix category
Three bugs fixed:

1. Wrong timing — inventory items with IsIncoming=true were auto-created during
   quote save (in QuotePricingAssemblyService). Now deferred to quote approval so
   inventory only reflects powders the shop is actually going to process.

2. Duplicate records — same powder on multiple items in one quote created multiple
   inventory records. Now grouped by PowderCatalogItemId: one record per unique
   catalog powder, all matching coats linked to the same record.

3. Wrong category — category resolution used first IsCoating=true by DisplayOrder,
   which could land items in Cerakote or other unintended categories. Now prefers
   CategoryCode==POWDER explicitly, with DisplayOrder fallback.

Changes:
- QuoteItemCoat: add PowderCatalogItemId int? — persists catalog reference at quote
  save time so the approval path knows what to create
- QuotePricingAssemblyService.BuildQuoteItemCoatsAsync: store PowderCatalogItemId
  on coat instead of calling CreateIncomingInventoryItemAsync immediately
- QuotePricingAssemblyService.CreateIncomingInventoryItemAsync: signature changed
  from (coatDto, companyId) to (catalogItemId, companyId); category lookup prefers
  POWDER code; no longer clears PowderCostPerLb on the DTO
- QuotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync: new
  public method called at approval — loads pending coats, groups by catalog ID,
  creates one inventory item per group, links all coats in each group
- IQuotePricingAssemblyService: exposes EnsureIncomingInventoryForApprovedQuoteAsync
- QuotesController.ApproveQuote: calls EnsureIncomingInventory after save
- QuotesController.ChangeQuoteStatus: calls EnsureIncomingInventory on Approved
- QuoteApprovalController: injects IQuotePricingAssemblyService; calls
  EnsureIncomingInventory in ApproveInternal (customer-facing portal path)
- InventoryController.CreateIncomingFromCatalog: same category fix (prefers POWDER)
- Migration: AddPowderCatalogItemIdToCoat (nullable int on QuoteItemCoats)
- Tests: updated AddAsIncoming test to verify deferred behavior; new deduplication test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:12:24 -04:00
spouliot 9dd36238bb Add timeclock break/lunch tracking, manual entries, and attendance period picker
- ClockEntryType enum (Work/Break/Lunch) on EmployeeClockEntry; default 0 = Work
  so all existing entries are unaffected
- Migration AddClockEntryType applied
- Break and Lunch buttons on clock status card (only when AllowMultiplePunchesPerDay
  is enabled); GoOnBreak closes current Work segment and opens Break/Lunch segment
- Return to Work button when on break/lunch; closes break segment, opens new Work
- Status badges on clock card and Who'\''s In grid: Working / On Break / At Lunch
- Break/Lunch hours excluded from all day totals, week totals, metrics, and CSV
- Manager: Manual Entry modal to create a time entry for any company employee
- Attendance report defaults to current ISO week; Week/Month mode toggle with
  auto-submitting dropdowns (last 12 weeks or months); period label shown inline
- Attendance CSV: Type column added; day/week totals blank on Break/Lunch rows;
  filename uses period label
- Week subtotal rows suppressed in single-week view (shown in month view only)
- Help article and AI knowledge base updated for all new features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:30:39 -04:00
spouliot 8ae61b6c78 Add missing AddTimeclockSettings migration
Migration was scaffolded in the background while the dev server held the PDB lock
and silently failed to write the file. Re-ran and applied now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:20:53 -04:00
spouliot 97745f9a65 Add Timeclock settings tab in Company Settings with multi-kiosk support
Settings tab (Company Settings > Timeclock):
- Enable/disable timeclock toggle (hides nav link and attendance report when off)
- Allow multiple clock-ins per day toggle
- Auto clock-out after X hours (auto-closes forgotten open entries on next punch)
- Kiosk devices table: lists activated tablets with name, activated date, last seen;
  Deactivate button removes that device's access immediately

Multi-kiosk support (replaces single TimeclockKioskToken on Company):
- New TimeclockKioskDevice entity (one row per tablet, unique token, DeviceName, LastSeenAt)
- KioskActivate GET shows a form for optional device name before activating
- KioskDeactivate POST accepts device ID, deletes specific row (not all devices)
- Kiosk validation (Kiosk, KioskEmployees, KioskPunch) queries device table with
  ignoreQueryFilters since no user is logged in on kiosk requests
- LastSeenAt updated on each Kiosk page load

Enforcement:
- ClockIn and KioskPunch both auto-close stale entries if AutoClockOutHours is set
- ClockIn and KioskPunch both block second same-day punch if AllowMultiplePunches=false
- TimeclockEnabled=false hides nav link (SubscriptionMiddleware sets Items key) and
  returns Forbid on kiosk punch
- Migration: AddTimeclockSettings (adds 3 columns to Companies, new TimeclockKioskDevices table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:12:46 -04:00
spouliot e124fd5c8b Fix duplicate @section Scripts crash on Edit User page
Merging the kiosk PIN script and the role-permissions script into a single
@section Scripts block fixes the InvalidOperationException thrown when Razor
encounters two sections with the same name in the same view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:28:08 -04:00
spouliot 6c2fe6e1c4 Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export
- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries)
- KioskPin added to ApplicationUser; TimeclockKioskToken added to Company
- TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete,
  tablet kiosk with device-cookie auth, PIN management via Users edit page
- Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out
- Attendance report at /Reports/Attendance with weekly subtotal rows
- Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment)
- AllowCustomFormulas wired through PlatformSubscriptionController + subscription views
- Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating)
- Help article (Help/Timeclock.cshtml) and AI knowledge base updated
- Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:53:13 -04:00
spouliot f625be01a3 Fix Facility Overhead not appearing on Quote Details view
Both PricingBreakdown constructions in QuotesController.Details were
missing FacilityOverheadCost and FacilityOverheadRatePerHour mappings,
so the view condition (FacilityOverheadCost > 0) was always false even
though the overhead was correctly stored on the Quote entity and included
in the total. The quote-to-job conversion block already had them; now all
three are consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:11:52 -04:00
spouliot e6c4cfb38b Fix all FK constraint violations when purging soft-deleted Jobs
Before deleting Jobs, now:
- Deletes non-nullable child rows: ReworkRecords, PowderUsageLogs, OvenBatchItems
- Nulls out nullable FK refs: Invoices, Deposits, Appointments, BillLineItems,
  Expenses, InventoryTransactions
DB-cascade / SET NULL relationships (JobChangeHistory, JobStatusHistory,
JobTimeEntry, JobItems, JobNotes, JobPhotos, KioskSession, NotificationLog)
are excluded — the DB handles them automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:04:13 -04:00
spouliot 5b5247624c Fix data purge FK violation on Appointments and apply pending migration
DataPurgeController was deleting Jobs without first clearing the nullable
Appointments.JobId FK, causing FK_Appointments_Jobs_JobId violations.
Fix nulls out the FK on any linked appointments before the DELETE runs.

Also applies migration AddAllowCustomFormulas (AllowCustomFormulas column
on SubscriptionPlanConfigs for custom formula pricing feature gating).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:54:48 -04:00
spouliot 91a176ce5c Add Description field to job CSV import template
JobImportDto was missing Description despite Job entity having the field.
Downloadable template now includes a Description column; the importer maps
it directly to Job.Description with a fallback chain of Description ->
SpecialInstructions -> "Imported job".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:41:58 -04:00
spouliot a7ad0e1de8 Add Custom Powder Order line item and fix CSV import FinalPrice crash
Custom powder/incoming powder material cost now flows into a separate
auto-generated 'Custom Powder Order' line item instead of rolling into
individual item prices, so users can add shipping charges before the
customer sees the total. A dashed yellow preview card in the wizard
shows the material cost and lets users edit the total (including shipping)
before saving. After first save the price is user-owned.

Also fixes a fatal CSV import crash when FinalPrice contains a non-numeric
value (e.g. 'false' from a spreadsheet formula): the job CSV importer now
streams rows one at a time with a lenient decimal converter, treating bad
values as $0 with a per-row warning instead of aborting the entire import.

Updated HelpKnowledgeBase.cs and Help articles (Jobs, Quotes) with
Custom Powder Order behavior and a new Data Import / Export section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:37:46 -04:00
spouliot e4a256a6c4 Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService):
- Trials with no grace period now go directly Active -> Expired instead
  of briefly entering GracePeriod for a day, which was causing repeated
  'Grace Period Started' admin notification emails
- Remove redundant isTrial variable (query already filters to non-Stripe
  companies, so all processed companies are trials by definition)
- Save per-company inside the loop so a single SaveChangesAsync failure
  no longer discards all other companies' status changes and notification
  log entries (which was the other cause of repeated emails)

HTML entities in page titles (33 views):
- Replace &ndash; / &mdash; with plain ' - ' in ViewData["Title"] C#
  strings; Razor HTML-encodes these when rendering @ViewData["Title"],
  causing browsers to display the literal text '&ndash;' instead of a dash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:58:37 -04:00
spouliot e476b4744d Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService):
- Trials with no grace period now go directly Active -> Expired instead
  of briefly entering GracePeriod for a day, which was causing repeated
  'Grace Period Started' admin notification emails
- Remove redundant isTrial variable (query already filters to non-Stripe
  companies, so all processed companies are trials by definition)
- Save per-company inside the loop so a single SaveChangesAsync failure
  no longer discards all other companies' status changes and notification
  log entries (which was the other cause of repeated emails)

HTML entities in page titles (33 views):
- Replace &ndash; / &mdash; with plain ' - ' in ViewData["Title"] C#
  strings; Razor HTML-encodes these when rendering @ViewData["Title"],
  causing browsers to display the literal text '&ndash;' instead of a dash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:43:41 -04:00
spouliot 04d16109ae Simplify location display on inventory QR label
Plain text 'Location: <value>' in larger bold font instead of
pill badge with map pin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:10 -04:00
spouliot f0f3717681 Fix three bugs: vendor duplicate check, page size dropdown, label location
- Vendor Create: reject duplicate company names (case-insensitive) before
  saving; works for both the standalone form and the inline quick-add modal
- _Pagination: define changePageSize() JS function (was called but never
  existed, breaking page size dropdown on every paginated list)
- Inventory Label: show bin/location on printed QR code labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:07 -04:00
spouliot e23b006139 Add color family filter to inventory index
Adds an 'All Colors' dropdown to the inventory filter bar populated from
the ColorFamilies values already stored on inventory items. Selecting a
family (e.g. 'Red') returns only items tagged with that family.

Also refactors the 16-branch if/else filter builder into a single
composable predicate, making future filter additions trivial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:05 -04:00
spouliot 0f35946973 Fix dark mode: main settings nav tab buttons showing white UA background
The #settingsTabs <button> elements had no explicit background-color,
letting browser UA button styling (white) bleed through in dark mode.
Added transparent overrides so the dark body background shows instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:02 -04:00
spouliot 19e1ce858f Simplify location display on inventory QR label
Plain text 'Location: <value>' in larger bold font instead of
pill badge with map pin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:08:59 -04:00
spouliot 026e646295 Fix three bugs: vendor duplicate check, page size dropdown, label location
- Vendor Create: reject duplicate company names (case-insensitive) before
  saving; works for both the standalone form and the inline quick-add modal
- _Pagination: define changePageSize() JS function (was called but never
  existed, breaking page size dropdown on every paginated list)
- Inventory Label: show bin/location on printed QR code labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:58:23 -04:00
spouliot b7fcefa765 Add color family filter to inventory index
Adds an 'All Colors' dropdown to the inventory filter bar populated from
the ColorFamilies values already stored on inventory items. Selecting a
family (e.g. 'Red') returns only items tagged with that family.

Also refactors the 16-branch if/else filter builder into a single
composable predicate, making future filter additions trivial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:25:14 -04:00
spouliot 1722cd4124 Fix dark mode: main settings nav tab buttons showing white UA background
The #settingsTabs <button> elements had no explicit background-color,
letting browser UA button styling (white) bleed through in dark mode.
Added transparent overrides so the dark body background shows instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:31:33 -04:00
spouliot c3742e1585 Fix NCalc type error when evaluating AI-generated formulas
NCalc2 operates on double internally; passing decimal parameters caused
'Operator * cannot be applied to double and decimal' at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:27:42 -04:00
spouliot 1a6f855c05 Merge feature/custom-formula-templates into dev 2026-05-24 11:42:22 -04:00
spouliot d28e639d1b Add formula template walkthrough and UX improvements
- 7-step guided walkthrough modal (concept, output modes, fields, formula,
  testing, box example, cylinder example); auto-shown first time the tab
  opens with no templates; always accessible via "How it works" button
- Variable pill badges below formula input showing all valid field names + rate;
  update live as fields are added/renamed; clickable to insert at cursor
- Fix: Add Field no longer shows validation error on blank new rows; validation
  only fires once the user has typed something
- Help article: added Common Formula Patterns section with box, cylinder, and
  flat panel worked examples (fields table + formula + expected output)
- HelpKnowledgeBase updated with pattern examples and walkthrough note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:40:54 -04:00
spouliot 10f668fd73 Merge dev into master for prod deploy 2026-05-24 10:46:23 -04:00
spouliot 19b7a9a473 Fix equipment creation blocked by maintenance interval validation
RecommendedMaintenanceIntervalDays was a non-nullable int with [Range(1,3650)],
so submitting the form without filling it in bound to 0 and failed validation.
Made nullable on the entity, both DTOs, and the one controller callsite that
calls .AddDays() (now uses .Value). Migration applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:38:05 -04:00
spouliot 4650ba3d4d Fix custom formula wizard bugs and add field name validation
- Fix Add Field blanking inputs: cfFields was IIFE-scoped so inline oninput
  handlers couldn't reach it; expose cfUpdateField on window
- Fix ManualUnitPrice dropped in buildItemFromData: condition excluded
  isCustomFormulaItem, causing FixedRate items to reprice from scratch
- Fix formula card missing on job pages: load CustomFormulaTemplates in
  PopulateJobItemDropDownsAsync so Details, EditItems, and Edit all get it;
  add customFormulaTemplates + formulaEvalUrl to Details and EditItems pageMeta
- Add NCalc field name validation: client-side inline feedback (is-invalid +
  message on oninput) and pre-save sweep; server-side ValidateTemplateFields
  on Create and Update; rules: letter-start, letters/digits/underscores only,
  no duplicates, "rate" reserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:28:41 -04:00
spouliot 1eba50cf0f Add Custom Formula Item Templates with AI generation and wizard integration
Introduces per-company reusable NCalc2 pricing formula templates for complex
fabricated items (roof curbs, enclosures, welded frames). Templates support
two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt
(formula yields sq ft fed into the standard coating engine). Includes:

- CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo
- IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on
  QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService
  overloads and all existingItemsData JSON projections + pageMeta blocks
- ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula
  generator (natural language + optional diagram image) and NCalc2 evaluator
- CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete,
  UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi
- Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js
- item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc
  (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields
- Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details
- Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action,
  HelpKnowledgeBase entry; 225/225 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:09:22 -04:00
spouliot e443457139 Update help docs and AI assistant for QR modal and vendor supply categories
- HelpKnowledgeBase.cs: QR label now opens a preview modal (not new tab); mention list-page QR button; add vendor supply categories knowledge
- Help/Inventory.cshtml: Update QR printing steps for modal workflow; document list-page QR button
- Help/Vendors.cshtml: Add Supply Categories section with filtering behaviour and fallback; add nav link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:48:00 -04:00
spouliot edf56c1164 Fix iframe blocking: SAMEORIGIN + frame-ancestors 'self'
X-Frame-Options: DENY blocked all iframe embeds including our own QR label
modal. Changed to SAMEORIGIN and added frame-ancestors 'self' to CSP so
same-origin iframes (Label page) load correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:02:23 -04:00
spouliot b9cd693421 Fix QR label modal: allow self in frame-src CSP, fix Bootstrap API call
frame-src was missing 'self' so the Label iframe was blocked by CSP.
bootstrap.Modal.getOrCreate does not exist; correct method is getOrCreateInstance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:59:14 -04:00
spouliot d77b3778ac Add vendor supply categories with inventory auto-filter
Vendors can now be tagged with one or more inventory categories (Powder,
Chemical, etc.) via checkboxes on the Create/Edit form. The inventory
Create/Edit vendor dropdown automatically filters to matching vendors when
a category is selected; falls back to all vendors if none are tagged.
Includes migration AddVendorCategories (VendorInventoryCategories join table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:52:34 -04:00
spouliot a7bf97a2df Add Print QR Label action to inventory list (desktop + mobile)
Opens the same iframe modal used on the Details page; the iframe src is
set dynamically so a single shared modal handles every row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:35:13 -04:00
spouliot 05935b110a Open QR Label in modal instead of a new browser tab
Adds an embed mode to the Label view (hides standalone nav controls) and
an iframe-based modal on Inventory Details. The modal footer Print button
calls contentWindow.print() so the print dialog opens without spawning a
new window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:32:57 -04:00
spouliot 64a9c1531b Fix &mdash; HTML entity rendering across 60 views
Razor's @() expression auto-encodes &, turning &mdash; into &amp;mdash; which
rendered as literal text in the browser. Wrapped all such expressions in
@Html.Raw() so the em-dash entity is passed through unescaped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:27:45 -04:00
spouliot f018653c18 Add rework pricing type (Fixed vs Per-Item) and inline rework flow on Job Details
Adds a PricingType enum to ReworkRecord (FixedPrice | PerItem), surfaces the
choice in the rework modal on Job Details, and wires the resulting unit/total
price display. Includes migration AddReworkPricingType, updated repository
query for rework history, and help article updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:27:34 -04:00
spouliot b7ab85ff92 Merge dev into master: QR scan URL fixes and http scheme failsafe 2026-05-22 17:41:01 -04:00
spouliot 15b070398b Change URL scheme fallback from https to http
Manufacturer product pages are often not on secure connections; http:// is the
safer default to avoid connection failures on non-SSL sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:40:14 -04:00
spouliot 14f220347b Add scheme failsafe to all inventory URL link buttons
If a stored URL is missing http:// or https:// the browser treats it as relative
and appends it to the app URL. Guard in three places:

- inventory-catalog-lookup.js syncLinkButton: ensureAbsoluteUrl() prepends https://
- inventory-label-scan.js syncLink: same guard for scan-filled URL fields
- Details.cshtml SafeUrl() Razor helper on SpecPageUrl, SdsUrl, TdsUrl links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:38:09 -04:00
spouliot baec0b33f7 Fix QR scan stripping scheme from product URL
LookupAsync builds SpecPageUrl from the ProductUrlTemplate via TryBuildDirectUrl.
If the template is stored without a scheme the link is scheme-less and browsers
treat it as relative, appending it to the app URL.

The scanned QR URL is always fully-qualified and always the correct product page
(it came from the manufacturer's bag), so use it unconditionally as SpecPageUrl
on the pattern-matched QR path instead of only when SpecPageUrl was null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:26:56 -04:00
spouliot ce7b00b68c Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix 2026-05-22 15:22:38 -04:00
spouliot dfb1d34af3 Add inventory bin filter, print bin, mobile login fixes, and QR scan fix
- Inventory: location filter dropdown + Print Bin page (line #, name, color, SKU)
- Fix: Prismatic Powders QR scan now extracts manufacturer/SKU/color from URL path
  and uses full LookupAsync pipeline instead of relying on page fetch alone
- Fix: iOS Safari 'Login / data Zero KB' download -- add OnRejected HTML response to rate limiter
- Fix: mobile session logout -- ConfigureApplicationCookie with 30-day MaxAge persistent cookie
- Help: new 'Location Filtering & Bin Print' section in Inventory help article
- Help: HelpKnowledgeBase updated with bin filter and print bin details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:19:11 -04:00
spouliot c5c1244177 Merge dev into master
- Inline item editing on Job/Quote/Invoice Details pages
- Live pricing summary and Job Costing card updates on save
- PatchItem legacy fallback for jobs without PricingBreakdownJson
- GetCostingBreakdown revenue from FinalPrice (not invoice total)
- Help docs: Inline Price Editing sections added to all three detail pages
- AI knowledge base updated with inline editing and costing revenue behavior
- AGENTS.md tracked; .gitignore updated for Claude Code settings and build logs
- Resolve conflict in Payment/Index.cshtml (em dash entity style)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:35:29 -04:00
spouliot 8c86eba4f2 Untrack .claude/settings.local.json (covered by .gitignore)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:34:19 -04:00
spouliot d4dddfa727 Track AGENTS.md; ignore Claude Code settings and build logs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:33:25 -04:00
spouliot 1bb07162cd Inline item editing on Job Details with live pricing and costing updates
- PatchItem: add case-insensitive JSON deserialization; add legacy fallback
  that computes a live breakdown from job items when PricingBreakdownJson is null
- PatchItem: return itemsSubtotal, subtotalBeforeDiscount, subtotalAfterDiscount,
  taxAmount in JSON response for immediate DOM updates
- GetCostingBreakdown: use job.FinalPrice as revenue (not invoice total) so
  costing figures reflect inline edits before an invoice exists
- Details.cshtml: add data-pb attributes to visible pricing rows; add
  job-final-price-display class to visible Total element
- Details.cshtml: wire afterSave callback to call costing.load() after each edit
- inline-item-edit.js: add afterSave hook in commit(); clean up debug logging
- Help docs: add Inline Price Editing sections to Jobs, Quotes, and Invoices
  help articles; add inline editing + job costing revenue notes to AI knowledge base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:56:36 -04:00
spouliot ec925f9e08 Temp: add console.debug to updateTotals for diagnosis 2026-05-20 23:09:14 -04:00
spouliot 600196f679 Add ws://localhost:* to dev CSP connect-src for browser refresh
aspnetcore-browser-refresh.js uses plain ws:// (not wss://) so it was
blocked by the CSP which only listed wss://localhost:*. Both are needed
in dev: ws:// for the dotnet watch browser refresh socket, wss:// for
SignalR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:03:08 -04:00
spouliot eb13283e76 Fix inline edit not updating pricing breakdown on Job Details
Jobs/PatchItem now returns the full breakdown (itemsSubtotal,
subtotalBeforeDiscount, subtotalAfterDiscount, taxAmount) so all rows
in the pricing card update live without a page refresh.

Added data-pb attributes to the matching spans in the pricing panel.
Updated window.inlineItemEdit.totals config for jobs to map each
response key to its DOM selector.

updateTotals in inline-item-edit.js is now fully generic — cfg.totals
keys must match server response property names directly, eliminating
the old hardcoded tax/taxAmount and balance/balanceDue mismatches.
Updated Quote and Invoice configs accordingly (tax→taxAmount,
balance→balanceDue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:58:26 -04:00
spouliot 30c644a8ec Fix service worker TypeError on localhost; inline edit config timing
sw.js: Remove fetch event interception entirely. The passthrough
e.respondWith(fetch(request)) call was throwing TypeError on localhost
HTTPS due to certificate trust differences in the SW context, causing
JS/CSS resource loads to fail. The SW exists for PWA installability
only — no interception is needed to satisfy that requirement.

inline-item-edit.js: Move window.inlineItemEdit config read inside
DOMContentLoaded so script load order vs. config assignment in
@section Scripts doesn't matter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:48:31 -04:00
spouliot 0e480adbf6 Fix inline item editing never activating on details pages
The script IIFE was reading window.inlineItemEdit at load time, before
the inline <script> block in @section Scripts had executed to set it.
Config read moved inside DOMContentLoaded so it fires after all inline
scripts in the section have run, regardless of src vs. inline order.
cfg is now passed as a parameter to makeEditable and attachListeners
instead of being captured from the outer IIFE scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:37:27 -04:00
spouliot eaab0af51f Fix facility overhead missing from invoices on quote-based jobs
For quote-based jobs, invoice creation now reads fee components (oven,
facility overhead, shop supplies, rush fee) from the job's
PricingBreakdownJson snapshot rather than the source quote. The
FacilityOverheadCost column was added to Quotes in May 2026; older
quotes have 0 there even though overhead was included in their total,
causing invoices to silently drop the overhead charge. The job snapshot
is updated on every save so it always reflects the current pricing.
Tax rate and discount still come from the source quote as agreed terms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:18:52 -04:00
spouliot 51a5268bc2 Fix Log Material dropdown invisible in dark mode
Replace hardcoded #fff / #f8f9fa / #dee2e6 / #e8eeff colors with Bootstrap
CSS variables so the dropdown respects the active theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:40:44 -04:00
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
Replace all corruption variants with HTML entities across 226 view files:
- 3-char UTF-8-as-Win1252 sequences (ae-corruption)
- Standalone smart/curly quotes that break C# Razor expressions
- Partially re-corrupted variants where the 3rd byte was normalised to ASCII

tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the
script itself never contains a literal non-ASCII character; supports -DryRun

.githooks/pre-commit: blocks commits containing the ae-corruption byte
signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the
hook is repo-committed and active for all future work on this machine.

Build clean; 225 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:37:10 -04:00
spouliot 21b39161a3 Fix encoding corruption in Bills and Expenses views
Replace literal Unicode chars (em dash, ellipsis, angle quotes, box-drawing)
with HTML entities to prevent corruption from AI tools and Windows encoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:06:31 -04:00
spouliot b241daf15e Add packing slip PDF to invoice details page
Generates a no-price packing slip (items, color, qty + signature line) via
QuestPDF. New DownloadPackingSlip action reuses existing invoice data pipeline;
Packing Slip button opens inline in a new tab same as Print/PDF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:52:46 -04:00
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
spouliot 81dc34bab4 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:11:03 -04:00
spouliot b9e9449c8b Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:42:54 -04:00
spouliot fd38785942 Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:27:10 -04:00
spouliot 33277de727 Payment hardening: InvariantCulture on JS literals, remove dead CustomerEmail
Razor numeric expressions emitted into JS literals (MAX_TOTAL,
SURCHARGE_VALUE) now use InvariantCulture, matching the pattern already
used on the deposit page. Without this, a server culture with comma
decimal separators would silently truncate values like 2.5% to 2.

CustomerEmail removed from PaymentPageViewModel and
DepositPaymentPageViewModel — it was populated from the DB on every
payment page load but never consumed after receipt_email was removed
from the Stripe PaymentIntent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:20:47 -04:00
spouliot 4ac62551f4 Fix online payment surcharge — input and validation based on total
The payment amount input was capped at BalanceDue (e.g. $5.00) but the
customer was being charged TotalWithSurcharge (e.g. $5.15), causing
validation to reject any attempt to pay the correct total amount.

Input now defaults to and accepts up to TotalWithSurcharge. On submit,
the JS back-calculates the base amount before sending to the server so
the server-side surcharge addition produces the same PaymentIntent total
that the Stripe Elements were initialized with — eliminating the
amount-mismatch error from Stripe on confirmation.

Also calls elements.update() when the amount changes so partial payments
don't cause an Elements/PaymentIntent amount mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:16:50 -04:00
spouliot 7fa385aeb8 Inline item editing on details pages; fix Stripe receipt_email
Allow description, quantity, and price to be edited inline on Quote,
Job, and Invoice details pages without re-opening the wizard. Coating
and prep service rows remain read-only by design. Invoice editing is
gated to Draft/Sent/Overdue statuses; totals update live in the DOM.

Remove receipt_email from Stripe PaymentIntent creation so customers
can use any email they choose at checkout — Stripe validates format
and sends the receipt to whatever the customer enters in the Payment
Element, eliminating the risk of a stored email mismatch blocking a
payment from processing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:49:04 -04:00
spouliot 8452ea3fcd Merge remote-tracking branch 'origin/master' into dev 2026-05-20 10:37:45 -04:00
spouliot 9b34ff564e Update AI assistant and help docs for recent changes
HelpKnowledgeBase:
- Jobs list: document On Floor default view and 5 filter pills (All/On Floor/Overdue/Ready/Completed) with global counts
- Creating a job: add Oven & Batch Settings step
- Completing a job: new entry explaining Complete Job modal, per-color powder grouping, QR scan credit
- Invoice from job: note that coat colors appear in line item descriptions

Help/Jobs.cshtml:
- Overview: mention On Floor default and filter pills
- Creating a job: add oven/batch settings step in the numbered list
- New "Completing a Job" section: modal fields, powder grouping by color, QR credit, SMS behavior
- Invoice from job step: mention coat color in line item descriptions
- Add "Completing a Job" to page nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:15:58 -04:00
spouliot 24f3df1bbc Jobs list defaults to On Floor; add Completed filter pill; fix encoding bugs
- /Jobs now redirects to ?statusGroup=active so completed jobs don't clutter the default view
- Add Completed pill (filters Completed + ReadyForPickup + Delivered)
- Pill badge counts are now global DB counts, not page-local item counts
- Ready pill badge now shows ReadyForPickup-only count
- All pill links to ?statusGroup=all to bypass the redirect
- Fix double-encoded &amp; in Completed filter alert label
- Fix corrupted em dash (â€") in Customers/Details billing email fallback text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:11:33 -04:00
spouliot 551116d7e5 Mobile layout fix for Job Details items; coat color on invoice line items
- Job Details: hide desktop item tables on mobile (d-none d-lg-block) so only
  the existing mobile-card layout shows on small screens — prevents 20-line rows
  on a narrow phone display
- Invoices Create (ForJob path): load job item coats and derive ColorName from
  coat colors when the item itself has no explicit color set; multiple coats join
  as 'Color1 / Color2' — lets customers distinguish repeated items (e.g. multiple
  caliper sets) on the invoice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:34:54 -04:00
spouliot 8768e9813b Merge dev into master for release v2026.05.19b 2026-05-19 18:40:57 -04:00
spouliot 4a7087cc0c Fix NoExtraLayerCharge dropped in DeleteItem pricing recalculation
After deleting a job item, the remaining-items DTO projection was missing
NoExtraLayerCharge, causing PricingCalculationService to treat all coats as
extra-charge when recalculating the job total post-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:37:29 -04:00
spouliot 59b152c89f Fix noExtraLayerCharge missing from Job Details wizard item projection
WizardExistingItems coat serialization in Details GET omitted noExtraLayerCharge,
so editing a line item from the Details page always lost the no-charge flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:36:04 -04:00
spouliot 441898b52f Fix NoExtraLayerCharge not persisting on quotes and job EditItems reload
- QuotePricingAssemblyService.BuildQuoteItemCoat: map NoExtraLayerCharge from
  CreateQuoteItemCoatDto to QuoteItemCoat on every quote save (was always omitted)
- JobsController.EditItems GET: include NoExtraLayerCharge in coat mapping when
  reloading existing items for the wizard (was dropped, causing revert on second edit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:33:50 -04:00
spouliot 3e30397302 Sync master back to dev (IF EXISTS migration hotfix) 2026-05-19 18:24:39 -04:00
spouliot 31c5746e5b Guard ShopWorker drops in AddAppointmentReminderSentAt migration with IF EXISTS
Prod and dev databases diverged on whether ShopWorker tables and indexes
exist, causing unconditional DROP statements to fail on prod. Replaced
all individual DropForeignKey/DropTable/DropIndex/DropColumn calls with
a single SQL block using IF EXISTS guards so the migration runs safely
regardless of DB state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:30 -04:00
spouliot 3f9ac27afa Merge dev into master for release v2026.05.19 2026-05-19 16:37:26 -04:00
spouliot df504674e9 Add oven/batch settings to job create and edit forms
CreateJobDto and UpdateJobDto now carry OvenCostId, OvenBatches, and
OvenCycleMinutes. The Create POST sets these on the new Job entity and
passes them to the pricing engine; the Edit GET populates them from the
existing job so the form reflects saved values, and the Edit POST writes
them back before repricing.

Both Jobs/Create.cshtml and Jobs/Edit.cshtml now include an Oven & Batch
Settings card (matching the quote form) with oven selector, batch count,
and cycle time inputs. The wizard init block now passes the selected
OvenCostId instead of null so live auto-pricing reflects the oven cost.

ViewBag.DefaultOvenCycleMinutes added to PopulateCreateEditWizardViewBagsAsync
so the placeholder in both views shows the company default.

Also fixed: NoExtraLayerCharge was missing from the Edit GET coat DTO
mapping (would have caused the flag to reset to false on next edit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:54 -04:00
spouliot 07796b05c8 Clear ReminderSentAt when appointment is rescheduled
Edit POST now detects if ScheduledStartTime changed (via previousStart
comparison after AutoMapper merge) and nulls ReminderSentAt so the
background service will fire the reminder again at the new time.
Calendar drag-drop (UpdateEventTime) always clears ReminderSentAt since
rescheduling is its only purpose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:03:58 -04:00
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00
spouliot 8a0a564885 Merge dev into master for release v2026.05.18 2026-05-18 19:08:11 -04:00
spouliot dd4785b048 Fix empty-state button/text on list pages when search returns no results
Show 'Add Your First X' and onboarding copy only when the list is truly
empty. When a search or filter is active with no results, show 'Add X'
and 'No X match your search/filters' instead.

Affected: Customers (table + mobile views), Equipment, Inventory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:49:23 -04:00
jenkins e185e3b7e3 Add XML doc comments to pricing assembly services
Added comprehensive XML documentation to JobItemAssemblyService and
QuotePricingAssemblyService — the most complex area of the codebase.
Comments explain the three-overload pattern, seed class rationale,
powder-to-order formula and industry default fallbacks, AI prediction
override tracking, and the incoming inventory auto-creation workflow.
PricingCalculationService was already well-documented; no changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:22:32 -04:00
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00
spouliot 485f0b69c8 Format Log Material dropdown as 'Manufacturer - Name (UoM)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:51:32 -04:00
spouliot f380c152ca Promote job powders to top of Log Material dropdown
Powders already assigned to this job's coats appear under a 'This Job'
section header, then a divider, then 'All Inventory' — so the most
relevant choices are always one click away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:46 -04:00
spouliot 79c8c7e6a4 Add manufacturer to Log Material item combobox
Shows manufacturer name as muted secondary text in each dropdown row
and includes it in the search filter, so users can find a powder by
brand when multiple items share a similar name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:46:48 -04:00
spouliot 6cf355071b Replace Log Material item dropdown with searchable combobox
Inventory lists grow over time; a plain <select> becomes unusable. The
new combobox filters as you type, supports keyboard navigation
(Arrow/Enter/Escape), and shows current stock on selection — matching
the pattern used by the powder picker in the item wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:41:14 -04:00
spouliot ebd474ae81 Fix log material dropdown showing undefined - camelCase JSON serialization
System.Text.Json defaults to PascalCase; JS reads camelCase. Add
JsonNamingPolicy.CamelCase to the InventoryItemsForModal serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:15:23 -04:00
spouliot 3c390a2e05 Merge branch 'dev' - invoice fixes, log material modal, complete job UX 2026-05-16 15:38:05 -04:00
spouliot 0df2353d4f Complete Job modal: ask powder usage once per color, not per item/coat
The modal was showing one row per coat per item, so a job with 5 items
each with 2 coats of the same powder produced 10 identical input rows.

Now groups by unique InventoryItemId and shows one row per powder color
for the whole job. The controller distributes the entered total across
coats proportionally by their estimated PowderToOrder so per-coat
reporting data is preserved. A single inventory transaction is created
per powder (net of any pre-logged scan credit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:30:30 -04:00
spouliot be0a5b26e2 Update AI assistant and help docs for invoice and material logging changes
- HelpKnowledgeBase: invoice-from-job now mentions discount carried over,
  Discount Applied display row, and negative line items; new entry for
  PC-based Log Material modal on job details
- Help/Invoices.cshtml: from-job steps updated with discount/terms/due date
  pre-fill detail; sending section corrects due date source (quote/customer)
- Help/Jobs.cshtml: new "Logging Material Usage from a PC" section documenting
  the Log Material modal alongside the existing QR scan instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:15:20 -04:00
spouliot 36680eced9 Add manual Log Material modal to job details page
PC users were blocked to QR scan only for logging material usage. Now a
"Log Material" button opens an inline modal with:
- Inventory item dropdown (name + unit of measure, current stock shown on select)
- Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining)
- Reason: Job Usage or Waste/Spillage
- Notes field
Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the
InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction,
posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:10:54 -04:00
spouliot 27aa4e0ea6 Invoice create: show discount row in totals, allow negative line items
- Add "Discount Applied" display row (red, hidden when zero) between subtotal
  and tax so users can see the discount being deducted at a glance
- Remove min="0" from UnitPrice and TotalPrice inputs (server-rendered and JS
  template) so negative adjustment lines can be entered without form rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:41:47 -04:00
spouliot b2d6fae400 Fix failing test: revert quote-based discount to use sourceQuote.DiscountAmount
The quote discount must come from the agreed quote price, not the job's pricing
snapshot (which may have DiscountAmount=0 for legacy or unset reasons). The job
snapshot fix only applies to direct jobs where no source quote exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:29:12 -04:00
spouliot 3a1928f9bf Fix invoice creation from job: discount ignored, wrong due date, wrong terms
- DueDate was computed from DefaultTurnaroundDays (a shop ops setting) instead
  of from the payment terms string; now uses PaymentTermsParser throughout
- Discount was never applied for direct jobs (PricingBreakdownJson was read for
  fees but DiscountAmount was silently skipped)
- Quote-based jobs used sourceQuote.DiscountAmount, ignoring any discount edits
  made to the job after quote conversion; now prefers the job's pricing snapshot
- Payment terms and due date now inherit from sourceQuote.Terms → customer.PaymentTerms
  → company default, so the invoice reflects the agreed or customer-specific terms
- EarlyPaymentDiscount fields now populated from inherited terms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:45:40 -04:00
spouliot df9863a0bb Merge branch 'dev' 2026-05-15 21:13:04 -04:00
spouliot 6cefdff18c Ignore TODO.txt from source control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:48 -04:00
spouliot 91a5dbe30c Reorganize Operating Costs tab into individual section cards
Replaces single large card with six labeled section cards (Rates & Costs,
Facility Overhead, Equipment, Pricing & Profit, Rush Charges, Complexity)
to reduce visual density and improve scannability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:04 -04:00
spouliot b2a1b9a0be Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:04 -04:00
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00
spouliot 7020797a25 Merge dev: tax-exempt pricing fixes, job details Unicode cleanup
- Fix tax-exempt customers being charged tax on all job save/recalc paths (7 call sites in JobsController)
- Fix JS falsy-zero bug in quote preview tax calculation (item-wizard.js)
- Fix quote preview not recalculating on customer change (Create.cshtml)
- Add AddQuotePricingSnapshotFields migration (missing from prior session)
- Fix intake button rendering &#10003; as literal text (Html.Raw fix)
- Clean up corrupted Unicode box-drawing chars in Job Details view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:52:39 -04:00
spouliot 3b5511a703 Fix corrupted Unicode characters and intake button rendering in Job Details
- Replace mojibake box-drawing chars (U+2500 encoded as Windows-1252) with
  plain ASCII dashes throughout all comments in Details.cshtml
- Fix intake button showing literal '&#10003;' text: the entity was inside a
  C# string so Razor HTML-encoded the '&'; switched to Html.Raw() so the
  checkmark renders correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:43:36 -04:00
spouliot 8df37ca760 Fix tax-exempt customers charged tax on all job save paths
Jobs used company default TaxPercent for every pricing recalculation
(Create, Edit, UpdateItems, DeleteJobItem) without checking the customer's
IsTaxExempt flag. Added GetEffectiveTaxPercentAsync helper and wired it
into all seven call sites so tax-exempt customers are never billed tax
regardless of which path triggers the recalc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:15:43 -04:00
spouliot 7239f55308 Fix tax-exempt customers always charged tax in quote preview
parseFloat('0') is falsy in JS, so '0 || pageMeta.taxPercent' was
falling through to the company default rate even when the TaxPercent
field was correctly set to 0 for a tax-exempt customer. Use an
explicit field presence check instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:07 -04:00
spouliot 09e077897b Fix quote preview not recalculating when tax-exempt customer is selected
When a customer was changed to/from a tax-exempt customer, the hidden
TaxPercent field was correctly updated to 0 but the live pricing preview
was not re-run, so the display showed a stale total with tax applied.
Selecting a tax-exempt customer now immediately triggers a recalc so
the on-screen total matches the amount that will be saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:58:20 -04:00
spouliot 051c86810e Add missing AddQuotePricingSnapshotFields migration
Seven new decimal columns on Quotes table that were added to the entity
in the pricing audit but the migration was never created (name collision
with a prior attempt in the previous session caused the scaffold to fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:48:46 -04:00
spouliot 6721de91e4 Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- Store complete PricingBreakdownJson snapshot on Job at every save point so
  the Details page reads stored data rather than re-running the pricing engine
- Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts,
  SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot
- Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr)
- Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads
- Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths
- Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson)
- Fix Notes and CatalogItemId not copied to InvoiceItem
- Add 14 unit tests in PricingStageFlowTests covering all three pricing stages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:03:06 -04:00
spouliot 226a6237a6 Fix corrupted Unicode characters in Jobs/Details.cshtml
All � replacement characters replaced with correct HTML entities
(&mdash;, &ndash;, &bull;, &times;, &hellip;) and restored a
corrupted class attribute with missing double quotes on the Intake button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:51:00 -04:00
spouliot cf6acc125f Complete mobile card view coverage for all remaining pages
- CSS fix: change blanket .table-responsive hide to only trigger when
  a .mobile-card-view sibling exists (.mobile-card-view ~ .table-responsive
  and :has() rule) — auto-fixes 60+ forms/reports/detail/help pages that
  were showing blank on mobile by making their tables scroll instead
- Add mobile card views to remaining list pages:
  JobsPriority (overdue jobs, main board, maintenance sections)
  NotificationLogs (email/SMS log entries)
  AiUsageReport (per-company AI usage breakdown)
  GiftCertificates/BulkResult (batch certificate list)
  Inventory/SamplePanels (Need to Order + On Wall tabs)
  BannedIps (active bans + lifted/expired bans)
  OnboardingProgress (per-company activation funnel)
  ReleaseNotes/Manage (versioned changelog entries)
  StorageMigration/Results (file migration status list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:31:38 -04:00
spouliot f467862877 Add mobile card views to 12 high-priority list pages
Pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px. Added .mobile-card-view sections to: GiftCertificates, PurchaseOrders,
CreditMemos, VendorCredits, JournalEntries, Appointments, InAppNotifications,
BankReconciliations, FixedAssets, RecurringTemplates, SmsAgreements, SmsConsentAudit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:07:52 -04:00
spouliot 7ad7d84016 Add mobile card views to Invoices and Intakes list pages
Both pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px but neither page had a .mobile-card-view section. Added card-per-row
mobile layout to match the Customers page pattern — tappable cards with status
badges, key fields, and action buttons sized for touch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:51:22 -04:00
spouliot 75b0a8afe2 Fix kiosk inactivity timer for remote sessions; make Intakes table mobile-responsive
Remote sessions (customer's phone) no longer get the 45-second inactivity redirect
that requires a KioskDevice cookie — would have landed them on an error page.
Intakes staff table hides non-essential columns on small screens so the primary
customer/status/actions columns are visible without horizontal scrolling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:00:43 -04:00
spouliot 38748c2152 Add BatchId to GiftCertificate for persistent bulk batch tracking
BatchId (Guid?) is stamped on every certificate in a bulk run so the batch
is permanently addressable. BulkResult is now a bookmarkable GET by batchId
rather than TempData, so users can return to re-download at any time.
BatchDownloadPdf is a GET link (no form POST needed). Index shows a Batch
badge on bulk certs that links directly back to the batch result page.

Migration: AddGiftCertificateBatchId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:32:56 -04:00
spouliot 4ec55e7290 Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:09:22 -04:00
spouliot 3eda91f170 Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes
to their HTML entity equivalents (&mdash; &ndash; &times; &hellip; &lsquo; &rsquo;)
in all .cshtml files, skipping <script> blocks. Prevents encoding corruption
from AI tools and Windows encoding mismatches that caused recurring symbol bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:16:17 -04:00
spouliot cefdf3e35c Add remaining-weight input mode to inventory scan/usage page
Users can now toggle between 'Amount Used' and 'Remaining Weight' on the
QR scan page. In remaining-weight mode, usage is calculated as
(current stock - remaining) before submit — no controller changes needed.
Includes live hint showing calculated usage and new balance as they type,
with validation preventing negative usage or remaining > current stock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:12:28 -04:00
spouliot f34ee749be Fix garbled encoding symbols in oven display, bill/invoice tooltips, and profile timezone dropdown
- Replace mojibake × with × in oven batch cost row across Jobs Create/Edit/EditItems and Quotes Create/Edit
- Fix Qty × Unit Price tooltip in Bills/Edit and Invoices/Edit
- Fix all â€" (garbled em dash) and São Paulo in Profile timezone dropdown option labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:54:35 -04:00
spouliot 357ef84001 Fix online users page always showing /InAppNotifications/Recent as current page
The notification bell polls /InAppNotifications/Recent (a JSON endpoint) every time
it loads. Because the middleware throttles updates to once per 60s, the update fired
on whichever request first arrived after the throttle expired — usually the bell poll
rather than a real page navigation. Fix: skip any response whose Content-Type is
application/json so only full page navigations update the current-page field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:16:54 -04:00
spouliot 7a1a697dc2 Merge dev into master: fix oven batch conversion, invoice quantity, AI photo pricing, enforce pricing flag propagation 2026-05-14 16:56:26 -04:00
spouliot 539c6c2559 Fix oven batch conversion, invoice quantity, AI photo pricing, and enforce pricing flag propagation
- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null)
- Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1)
- Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion
- Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit
- Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests
- Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations
- Document pricing flag propagation checklist in CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:54:22 -04:00
spouliot a947494cbd Merge dev into master: churned account filter, powder catalog lookup fixes 2026-05-14 14:19:27 -04:00
spouliot 7e79a13cb1 Fix powder catalog lookup: exact match auto-fills, partials show picker modal
- CatalogLookup now returns all partial color name matches ranked by
  specificity (exact vendor+color first, same-vendor partial, cross-vendor)
  with isExact flag so JS can decide to auto-fill vs show modal
- Removed cross-vendor fallback that was silently overwriting manufacturer
  field with wrong brand when vendor-scoped search found nothing
- Picker modal now includes "Not listed — search online" option that
  triggers AI lookup as an escape hatch from the catalog results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:18:52 -04:00
spouliot 2ad6df1195 Hide churned trial accounts from company/health screens by default
- Companies list and Company Health now hide Expired/Canceled accounts
  whose subscription ended 14+ days ago; show/hide toggle via banner
- KPI cards on Company Health exclude churned tenants when hidden
- showChurned param threads through sort, pagination, search, and filter forms
- Powder catalog: fix missing UnitPrice on user-contributed entries;
  add back-sync to fill catalog gaps on existing matches; wire
  AiAugmentFromUrl and manual inventory Create into catalog contribute path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:59:12 -04:00
spouliot dc3cd75ea4 Merge dev into master — prod deploy 2026-05-14
- Real-time SMS consent status update on customer record
- Fix kiosk SMS consent routing loop and stuck tablet
- Fix notification bell, SMS consent kiosk flow, and button alignment
- Add staff-presented SMS consent flow on customer record
- Customer intake kiosk (SignalR → polling, inactivity reset, signature pad, anonymous logo endpoint)
- Invoice SMS notifications
- Kiosk help article and AI knowledge base updates
2026-05-14 08:17:24 -04:00
spouliot a73f14fa7f Real-time SMS consent status update on customer record
When kiosk consent is completed, the staff-facing customer Details page
now updates the SMS badge instantly via SignalR — no page refresh needed.
Added customerId to the NewInAppNotification SignalR payload so the
KioskConsent handler can match the current URL and swap the badge in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:40:47 -04:00
spouliot 0af31c39b3 Fix kiosk SMS consent routing loop and stuck tablet
- Route param renamed customerId→id so /Kiosk/SmsConsent/15307 binds correctly
  (default MVC route uses {id}; mismatched name caused GetByIdAsync(0)→404→loop)
- Cache entry cleared in GET (not just POST) so returning to Welcome after seeing
  the form never redirects again
- Added POST /Kiosk/CancelSmsConsent for staff to free the kiosk if they pushed
  consent accidentally — Customer Details shows a Cancel button after pushing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:25:37 -04:00
spouliot e1256503be Fix notification bell, SMS consent kiosk flow, and button alignment
Notification bell:
- Bell now polls /InAppNotifications/Recent every 60s as a SignalR fallback
- Bell dropdown refresh on open so count is always current when staff looks at it

SMS consent → kiosk flow:
- Staff clicks "Get SMS Consent" on Customer Details → AJAX POST to
  /Kiosk/PushSmsConsent stores customer in IMemoryCache (10 min TTL)
- Kiosk PollSession returns smsConsentPending + customerId so tablet navigates
  to /Kiosk/SmsConsent/{customerId} automatically
- Customer reads TCPA consent on tablet, taps I Agree or No Thanks
- On agree: NotifyBySms/SmsConsentedAt/SmsConsentMethod set; in-app notification
  fires; cache cleared; tablet returns to Welcome
- Removed Customers/SmsConsent (staff-browser version); moved view to Kiosk/

Button alignment:
- kiosk.css: added display:flex + align-items:center + justify-content:center to
  all kiosk body buttons so content is centred vertically in tall button outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:13:57 -04:00
spouliot b69ff6db3a Add staff-presented SMS consent flow on customer record
- New GET/POST Customers/SmsConsent/{id}: full-screen kiosk-layout page staff
  opens and hands to the customer to read TCPA consent language and tap I Agree
- On agreement: sets Customer.NotifyBySms, SmsConsentedAt (UTC), SmsConsentMethod
  = "InPerson", clears SmsOptedOutAt
- Redirects back if customer has already consented (no double-consent)
- Customer Details: "Get SMS Consent" badge link shown when NotifyBySms is false;
  SMS on badge shows consent date on hover when consented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:50:49 -04:00
spouliot 66231822af Update kiosk help article and AI knowledge base for output setting
- Help article: new "Kiosk Output Setting" section explaining Quote vs Job modes and
  the Company Settings → Kiosk tab; Overview updated; Reviewing Submissions now lists
  "View Quote" and "View Job" separately; notification label corrected (Remote vs Walk-in)
- AI knowledge base: CUSTOMER INTAKE KIOSK section updated — output setting documented,
  submission outcome reflects Quote/Job branch, notification labels corrected, workflow
  entries split into Quote-mode and Job-mode variants, troubleshooting updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:39:39 -04:00
spouliot d5ad9fa073 Add KioskIntakeOutput company setting and fix kiosk submission bugs
- New CompanyPreferences.KioskIntakeOutput setting ("Quote" default / "Job"): controls
  what the kiosk creates on submission; shown as a card-style radio toggle in
  Company Settings → Kiosk tab
- KioskSession.LinkedQuoteId added so quote-first sessions link back to the draft quote
- Migration AddKioskIntakeOutputSetting applies both schema changes
- ProcessSubmissionAsync branches on setting: creates Draft quote (quote-first) or
  Pending job (job-first); save order fixed (CompleteAsync before using DB-assigned Id as FK)
- Terms.cshtml pricing paragraph is now dynamic: "subject to formal quote" for Quote mode,
  "team member will reach out about pricing" for Job mode
- Customer Intakes list: "View Quote" button appears when LinkedQuoteId is set
- Notification label fixed: Remote sessions now say "Remote Intake", not "Walk-in Intake"
- Inactivity reset shortened to 45 s on intake steps
- Signature pad: hosted locally (no CDN), canvas resize deferred via requestAnimationFrame
- AI photo upload: client-side compression to ≤1200px + AbortController 120 s timeout
- Help article and AI knowledge base updated with kiosk feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:35:37 -04:00
spouliot d134dd51e5 Add Customer Intake Kiosk help article and knowledge base entry
- New Help article at /Help/CustomerIntakeKiosk covering setup, in-person
  and remote intake flows, what happens on submission, reviewing intakes,
  and troubleshooting (signature pad, connection issues, seed data)
- Add kiosk entry to _HelpNav under Operations
- Update HelpKnowledgeBase: nav overview, full kiosk section, two new
  common workflow entries (walk-in kiosk and remote intake)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:02:34 -04:00
spouliot 1df7c13abd Sweep kiosk intake submission for FK/null bugs
- Fix Jobs.Id FK violation: save job first with CompleteAsync() to get
  its DB-assigned Id, THEN set session.LinkedJobId and save again.
  Previously job.Id was still 0 when written to the nullable FK column.
- Replace ?? 1 fallbacks on JobStatusId/JobPriorityId with explicit
  InvalidOperationException — hardcoded 1 may not exist in the company's
  lookup tables; now fails loud with a clear message instead of an FK error.
- Add ValidateSessionState check to Terms POST so expired/already-submitted
  sessions don't re-run ProcessSubmissionAsync and create duplicate jobs.
- Null-guard session.JobDescription before slicing for notification snippet.
- Tighten catch block: wrap the fallback CompleteAsync in its own try/catch
  so a secondary failure doesn't mask the original error in logs.
- Swap Job.Description / SpecialInstructions: Description now holds the
  actual job description text; SpecialInstructions records the intake source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:53:10 -04:00
spouliot 4a8778504f Fix FK violation on kiosk intake submission: set JobPriorityId
ProcessSubmissionAsync was creating a Job without JobPriorityId, leaving
it as 0 which violates the FK to JobPriorityLookups. Look up the NORMAL
priority the same way JobsController does everywhere else.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:36:53 -04:00
spouliot f1d7054b3e Fix AI quote reliability on mobile: compress photos + add fetch timeout
- Compress photos client-side before uploading (1200px max, JPEG 85%):
  full-res phone photos (5-15 MB) → ~150-250 KB, dramatically reducing
  upload time on slow mobile connections and Anthropic processing time
- Add 120s AbortController to both AiAnalyzeItem fetch calls so a stalled
  mobile connection produces a clear 'timed out' error instead of spinning forever
- After 30s show 'Still analyzing… this can take a minute on mobile' to
  reassure users the request is in progress
- Reset loading text on retry so the slow-connection hint doesn't persist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:22:32 -04:00
spouliot 46b950baf2 Kiosk intake: 45-second inactivity reset to Welcome screen
_KioskLayout inactivity timer now reads ViewBag.InactivityTimeoutMs
(defaults to 5 min). PopulateKioskViewBagFromSession sets it to 45 s
on every intake step so an abandoned form auto-returns to the waiting
screen. Welcome screen and Confirmation page are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:16:34 -04:00
spouliot 4e9c9d321a Fix kiosk signature pad: host locally, fix canvas resize timing
- Download signature_pad 4.1.7 to wwwroot/lib/signature-pad/ to eliminate
  CDN SRI hash failures and network dependencies on the tablet
- Wrap resizeCanvas in requestAnimationFrame so offsetWidth is non-zero
  when measured (browser layout pass must complete first)
- Add guard for SignaturePad not defined (shows user-visible error instead
  of silent JS crash)
- Add scrollIntoView on signature validation error for better tablet UX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:14:49 -04:00
spouliot 0c8723ef84 Fix sw.js: exclude /hubs/ and PollSession from SW interception
SW fetch() wraps SSE responses in a buffered Response, preventing SignalR
streaming — handshakes time out after 15s as a result. Exclude /hubs/ and
/Kiosk/PollSession so the browser handles them directly without SW wrapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:45:03 -04:00
spouliot 377bb1ce38 Replace kiosk SignalR with polling — Azure App Service blocks anonymous hub handshakes
SignalR WebSocket and SSE both receive immediate 'Handshake was canceled' from the
server-side hub context. The 15-second delay between negotiate and SSE connect
reveals the handshake timer has expired before the transport opens — caused by Azure
App Service's ingress proxy resetting anonymous long-lived connections.

Replacement: /Kiosk/PollSession (anonymous GET, no-cache) queried every 3 seconds.
Returns the most recent Active InPerson session created in the last 60 seconds.
The kiosk navigates when hasSession=true. Status dot: gray->green on first success,
yellow on network error, blue when navigating. Removed signalr.min.js from kiosk layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:37:28 -04:00
spouliot 2acf54e1a9 Fix kiosk SignalR: skip WebSocket transport, add hubs/Kiosk to subscription bypass
1. kiosk-welcome.js: force SSE|LongPolling transport on the kiosk hub.
   Azure App Service's ingress proxy cancels anonymous WebSocket handshakes
   before the SignalR protocol exchange completes. SSE and long polling
   work fine for the low-frequency StartIntake push this hub needs.

2. SubscriptionMiddleware: add /hubs/ and /Kiosk/ to SkipPaths so a
   subscription redirect can never fire on a hub or kiosk request and
   abort the connection mid-handshake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:20:06 -04:00
spouliot 0b24c320cd Fix kiosk intake routing, view names, and SignalR diagnostics
Three bugs identified:
1. Routing: /Kiosk/Intake/{token}/{action} had no matching route — 4-segment
   URL fell through the default 3-segment {controller}/{action}/{id?} route.
   Added explicit kiosk_intake route in Program.cs.

2. View names: Contact/Job/Terms/Confirmation actions returned View(model)
   which resolved to Views/Kiosk/{Action}.cshtml — those files don't exist.
   Views live in Views/Kiosk/Intake/. Fixed all six return statements.

3. Diagnostics: conn dot now starts gray ("Connecting...") and turns green
   only when SignalR actually connects. Red + message if no company ID or
   connection fails. Makes it easy to confirm the hub connection is live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:28:16 -04:00
spouliot 350f2d7658 Fix duplicate logo on kiosk Welcome screen; enlarge welcome logo
Layout rendered a small logo at top of every kiosk page. Welcome.cshtml
also rendered its own centered logo, resulting in two logos. Suppress
the layout logo on the Welcome screen via HideLayoutLogo ViewBag flag.
Bump kiosk-welcome-logo from 120x280 to 200x420 for better presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:21:27 -04:00
spouliot 856d202b78 Fix kiosk SignalR group: set ViewBag.CompanyId so tablet joins correct hub group
Without this, data-company-id was empty and the JS connected to kiosk- (no ID),
so StartSession signals never reached the tablet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:18:39 -04:00
spouliot 8caaa84eac Hide Start Intake button when kiosk not activated; relabel remote link
- Start Intake button only shows when company has an active kiosk token
- Remote Link button renamed to "Send Intake Link" for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:00:09 -04:00
spouliot e70f7ee9f1 Fix kiosk logo: add anonymous Logo endpoint proxying blob storage
CompanySettings/Logo requires tenant context and fails on anonymous
kiosk pages. Added Kiosk/Logo which resolves the company from the
KioskDevice cookie and proxies the blob directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:55:44 -04:00
spouliot 6a918c2afc Add invoice SMS notifications and customer intake kiosk
Invoice SMS:
- Send Invoice modal now prompts Email/SMS/Both based on customer contact data
- New /invoice/{token} customer-facing view page with full line items and pay button
- PublicViewToken (permanent) added to Invoice; separate from expiring PaymentLinkToken
- InvoiceSent SMS default template added; customizable via Notification Templates settings
- {{viewUrl}} placeholder documented in template editor

Customer Intake Kiosk:
- Tablet kiosk flow: Contact → Job → Terms/Signature → Confirmation
- Remote link mode for off-site customers (lighter form, no signature)
- KioskHub (AllowAnonymous SignalR) for staff-to-tablet push without login
- Staff activates tablet via cookie; sends remote link manually
- Submitted sessions create Customer + Job automatically; fires in-app notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:25:27 -04:00
spouliot 27bfd4db4d Close all GL entry gaps across the accounting surface
- Stripe payments/refunds/chargebacks now post DR/CR entries (PaymentController)
- Vendor credit void now reverses the posted GL lines (VendorCreditsController)
- Gift certificate issue/redeem/void post GL to account 2500 GC Liability;
  FinancialReportService Trial Balance + Balance Sheet include GC liability and
  breakage income; P&L shows deferred revenue deduction and breakage income line
- Customer deposits now post DR Checking / CR 2300 on record, reverse on delete;
  invoice auto-apply uses DR 2300 / CR AR (not a second bank debit); draft
  invoice delete reverses deposit-apply GL before the AR reversal
- Deposit.DepositAccountId column added; account 2300 seeded via migration
- InvoicesController.ApplyCredit now posts DR Sales Discounts / CR AR,
  consistent with CreditMemosController.Apply
- IssueRefund (cash/card) posts DR AR / CR Bank and sets Refund.DepositAccountId;
  refund modal gains a bank account selector hidden for store-credit path
- CancelRefund (cash/card) reverses the IssueRefund GL entries
- LedgerService GetAccountLedgerAsync + ComputePriorBalanceAsync now include
  Refunds, CreditMemoApplications, VendorCreditApplications, GC Liability (2500),
  and Customer Deposits (2300) so account ledger view and RecalculateAllAsync
  produce correct balances
- Three EF migrations applied: SeedSalesDiscountsAccount, AccountingGapsPhase2,
  AccountingDepositsGL
- Unit tests updated for new IAccountBalanceService constructor params (200/200)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:42:46 -04:00
spouliot 787d1504ef Label onboarding status and path badges on company detail
The two floating badges in the Onboarding > Setup Progress card had no
context — "Not Started" and a path name appeared without explanation.
Added inline "Status:" and "Path:" labels so both badges are immediately
readable without needing a tooltip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:32:08 -04:00
spouliot 726bebdce9 Consolidate company admin screens: health badge on list, tabbed detail page
Companies/Index:
- Added Health badge column (Healthy / At Risk / Critical / Never Active)
  with the numeric score in a tooltip; computed from the same signals as
  CompanyHealth/Index using the new shared CompanyHealthHelper

Companies/Details:
- Converted flat card layout to five tabs: Overview, Users, Subscription,
  Onboarding, Health; URL hash is preserved so the active tab survives
  page refresh and back navigation
- Subscription tab shows plan/status/dates with an expiry countdown and a
  "Manage Subscription & Features" button to the full Manage page
- Onboarding tab shows wizard completion, milestone progress bar, and
  first-activity dates (previously only on the standalone page)
- Health tab shows score gauge, risk badge, and individual risk signals
  with a link through to the full CompanyHealth dashboard
- JS moved to wwwroot/js/companies-details.js (avoids inline-script failures)

Infrastructure:
- Extracted ComputeHealth / ToRiskLevel / ChurnRisk to CompanyHealthHelper.cs
  (same Controllers namespace); CompanyHealthController delegates to it
- CompanyCountSummary extended with Jobs30Counts, Jobs90Counts, LastLoginDates
  (3 extra GROUP BY queries scoped to the current page IDs, not all companies)
- CompanyListDto gains HealthScore, HealthRisk, LastLoginDate

Navigation:
- Removed "Onboarding Progress" hub card from People & Activity; the data
  is now surfaced directly on the Companies/Details Onboarding tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:22:14 -04:00
spouliot 786b78e502 Add Online Now shortcut to Platform Admin nav with live user count
Adds a direct link to UserActivity/Online immediately below the People
& Activity hub entry so the current active-user count is visible at a
glance without navigating into the hub first. The count badge is
rendered using the already-injected OnlineUserTracker.GetActiveCount()
call and is hidden when the count is zero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:49:37 -04:00
spouliot cb1b6dceb6 PR 5 follow-up: boolean radio buttons and seed missing settings
- Replace true/false text display with Yes/No radio button groups for
  boolean platform settings; toggling auto-submits the form so no Edit
  modal is needed for flags
- IsBool() helper detects *Enabled, *AppliesToTrials, *TrialsEnabled keys
- Hide Edit button for boolean settings (radio buttons are the control)
- Add AI group icon (bi-robot) and description to the group header switch
- Add Max key detection to InputType/InputHint for number inputs
- Migration AddMissingPlatformSettings seeds 6 previously missing rows
  (SmsEnabled, TrialsEnabled, GracePeriodDays, GracePeriodAppliesToTrials,
  MaxTenants, AiCatalogPriceCheckEnabled) using IF NOT EXISTS guards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:40:16 -04:00
spouliot fb31fa7eb3 PR 5: Platform Settings UI redesign
- Section headers now show a group-specific icon, colored icon tile, and a one-line description explaining what each group controls (General, Notifications, Subscriptions, Quotes, Data Retention)
- Each setting now displays UpdatedAt and UpdatedBy metadata below the current value so operators can see when and by whom a setting was last changed
- Edit modal now uses type-appropriate inputs: number (with min=0, step=1) for *Days keys, email for *Email keys, url for BaseUrl, text otherwise; each type shows a contextual hint
- Key name shown in monospace below the label on desktop for operator reference
- Added SuccessMessage TempData alert at the top of the page
- No backend or DB changes — view-only redesign

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:19:52 -04:00
spouliot 637be701ea PR 4: Production guardrails for Seed Data and Storage Migration
- SeedData/Index: added prominent danger banner when running in Production (environment include="Production") so operators are clearly warned before writing to the live database; page remains accessible since seeding is occasionally valid in prod for new company onboarding
- StorageMigration/Index: added warning banner in Production explaining the tool is not needed now that Azure Blob migration is complete
- PlatformAdminController: hide Storage Migration hub card in Production via ShowStorageMigration flag (same WEBSITE_SITE_NAME pattern as ShowRawLogFiles); Seed Data card remains visible so prod onboarding stays reachable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:08:14 -04:00
spouliot e9cd67f5d9 PR 3: Observability back-links and terminology cleanup
- Added an Observability back-link at the top of all 7 Observability-area pages (AuditLog, SystemLogs, SystemInfo, AiUsageReport, UsageQuota, BannedIps, Diagnostics/Index) consistent with the Maintenance back-link pattern from PR 2
- Renamed company-level nav label and view title from "Notification Log" to "Email & SMS Log" to distinguish it clearly from the platform-level "Platform Notifications", "Audit Log", and "System Logs" surfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:55:58 -04:00
spouliot 433090effd PR 2: normalize DiagnosticsController auth; add Maintenance back-links
- DiagnosticsController: replaced raw [Authorize(Roles = "SuperAdmin,Administrator")] with [Authorize(Policy = SuperAdminOnly)] to match every other platform-admin controller; added PowderCoating.Shared.Constants using directive
- DataPurge, DataExport, StorageMigration, SeedData: added "← Maintenance" breadcrumb link at the top of each page so operators know they are in the guarded maintenance area and can navigate back to the hub

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:50:11 -04:00
spouliot 4ca90f561e Fix price override ignored for catalog items; fix time entry delete UI
- QuotePricingAssemblyService: catalog no-coat items now respect PowderCostOverride instead of always using DefaultPrice
- PricingCalculationService: removed duplicate catalog fast-path in CalculateQuoteTotalsAsync that bypassed CalculateQuoteItemPriceAsync (and thus ignored PowderCostOverride); all items now go through the single authoritative path
- Jobs/Details.cshtml: timeTracking.del() and timeTracking.save() now call costing.load() after success so the time entry row and costing breakdown stay in sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:38:49 -04:00
spouliot f95397204c Fix platform admin subtle badge styling 2026-05-12 10:16:20 -04:00
spouliot 31d305b66a Group platform admin tools into hub pages
- add grouped platform admin hub pages, view models, and shared card UI\n- simplify the super admin nav and dashboard quick links around the new hubs\n- fix the AiQuoteService EstimatedMinutes assignment so the infrastructure project builds cleanly
2026-05-12 09:03:18 -04:00
spouliot 42a8c089d5 Fix AI photo quote always returning shop minimum price
Two bugs caused AI estimates to collapse to the shop minimum floor:

1. Coating rate with no guard: when a shop hadn't calibrated their
   coating gun (rate = 0), the prompt injected '~0 sqft/hr' paired
   with 'MUST use shop-specific rates' — Claude returned near-zero
   estimatedMinutes, zeroing labor cost and triggering the floor.
   Fixed to mirror the existing blast-rate guard: rate=0 now sends
   a fallback instruction to use conservative industry-average times.

2. Per-item minutes divided by quantity: both the system prompt and
   user prompt explicitly tell Claude to return estimatedMinutes 'per
   single item', but CalculatePricingPreview() was dividing by qty
   anyway. For qty > 1 this halved (or more) the labor cost, again
   pushing toward the floor. Removed the incorrect divide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:10:05 -04:00
spouliot 2c353f2e7f Three small UX fixes: customer contact validation, pagination dropdown width
- Customer validation now accepts Mobile Phone as a valid contact method;
  previously only Email or Phone satisfied the requirement
- Create customer hint text updated to match the new validation rule
- Pagination page-size dropdown gets min-width: 5rem so the number has
  breathing room from the caret arrow across all paginated lists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 18:20:43 -04:00
spouliot c02a5584b4 Hide Converted quotes from default listing to reduce clutter
The Quotes index now excludes Converted status by default so the list
stays focused on active work. Converted quotes remain accessible via
the status filter dropdown. Selecting any explicit status filter
(including Converted) bypasses the exclusion as expected.

Also consolidated the two GetQuoteStatusLookupsAsync calls into one
at the top of the action since the cache makes it free.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:45:09 -04:00
spouliot 17da692dce Fix two production billing bugs: invoice missing oven cost, quote stuck in Draft after send
Bug 1 — Invoice total didn't match job total for direct jobs:
- Root cause: all three item-save paths in JobsController passed null for
  ovenCostId, so FinalPrice/ShopSuppliesAmount were stored without oven cost
  while the Details page recalculated live with OvenCostId and showed higher.
- Add OvenBatchCost stored field to Job entity (migration AddJobOvenBatchCost,
  default 0 for existing rows).
- Fix Create, Edit, and UpdateItems to pass job.OvenCostId and save OvenBatchCost.
- Fix InvoicesController.Create GET for direct jobs to use stored OvenBatchCost
  and ShopSuppliesAmount as separate labeled lines instead of recalculating
  shop supplies from scratch (which excluded the oven cost base).

Bug 2 — Quote status stayed Draft after "Send Quote via Email":
- ResendQuote advanced the approval token and sent the email but never
  updated the status. Added Draft → Sent advancement (same guard used by
  the SMS send path) so the status updates on successful email send.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 10:39:49 -04:00
spouliot 656f830898 Add permission descriptions and role defaults to CompanyUsers Create/Edit
- Added form-text blurbs under every permission checkbox on both pages
  so admins know exactly what each permission unlocks at a glance
- Replaced single Accountant default with a full roleDefaults map covering
  Viewer, Worker, Accountant, and Manager roles
- Create page applies defaults on load and on role change (fresh form)
- Edit page preserves saved permissions on load; only resets to defaults
  when the role is explicitly changed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 08:26:21 -04:00
spouliot dde66c807f Update help docs and AI knowledge base for Accountant role and new permissions
- Settings.cshtml: add Accountant to roles table with description; add
  Fine-Grained Permissions subsection with a full table of all 16 permissions
  including the new Can Manage Bills & AP and Can Manage Accounting entries
- HelpKnowledgeBase.cs: add Accountant to ROLE AWARENESS section at top;
  add Accountant to USER MANAGEMENT roles list with auto-checked permissions
  note; add Fine-grained permissions paragraph documenting CanManageBills,
  CanManageAccounting, and Accountant role defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:44:09 -04:00
spouliot feff0fa73d Add Accountant role and CanManageBills/CanManageAccounting permissions
- AppConstants: add Accountant to CompanyRoles; add CanManageBills and
  CanManageAccounting to Policies
- ApplicationUser: add CanManageBills and CanManageAccounting bool fields
- UserManagementDtos: expose new fields in all three DTOs
- ClaimsPrincipalFactory: emit ManageBills and ManageAccounting claims
- Program.cs: add CanManageBills and CanManageAccounting policies;
  update CanManageInvoices, CanViewReports, CanManagePurchaseOrders,
  and CanManageVendors to auto-pass for Accountant role
- BillsController: replace CanManageInventory with CanManageBills on
  all write actions (correct policy — bills are not inventory)
- BankReconciliationsController: replace CanManageJobs with
  CanManageAccounting on write actions
- CompanyUsersController: add Accountant to validCompanyRoles (both
  Create/Edit), legacyRole switch, and all permission assignment blocks
- Create/Edit views: add Accountant option to role dropdown; add
  CanManageBills and CanManageAccounting checkboxes; JS auto-checks
  financial permissions when Accountant role is selected
- Migration AddAccountantRolePermissions: adds columns + backfills
  CanManageBills=1 and CanManageAccounting=1 for all CompanyAdmin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:42:53 -04:00
spouliot 59beba2e15 Update help docs and AI knowledge base for 4 new AI bookkeeping features
- Reports.cshtml: added AI Payment Risk Prediction, Ask Your Financials,
  and Bank Rec Auto-Match subsections under AI-Powered Reports; updated
  on-this-page nav with sub-links for all three
- AccountsPayable.cshtml: added Recurring Bill Detection section explaining
  pattern cards, frequency types, confidence badges, next expected date,
  and the 2-occurrence minimum
- HelpKnowledgeBase.cs: added Recurring Bill Detection to BILLS section;
  added AI Payment Risk Prediction and Ask Your Financials to REPORTS
  available-reports list; added features 12–15 to AI FEATURES section
  (Recurring Detection, Payment Risk, Financial Query, Bank Rec Auto-Match)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:30:39 -04:00
spouliot 959e323f3a Add 4 AI bookkeeping features
Feature 7: Bank Rec Auto-Match — AiSuggestMatches endpoint scores uncleared
transactions vs statement ending balance; AI Auto-Match panel in Reconcile.cshtml
with confidence highlights and Apply All button.

Feature 8: Late Payment Prediction — PredictLatePayments endpoint scores open AR
customers by risk (high/medium/low) using historical avg-days-to-pay + late rate;
rendered as badge table in AR Aging view via ar-aging-ai.js.

Feature 9: Natural Language Financial Queries — FinancialQuery GET page + RunFinancialQuery
POST; 12-month context snapshot pre-loaded; answers grounded in real data with
supporting facts, follow-up suggestions, session history, and example chips.

Feature 10: Recurring Bill Detection — RunRecurringDetection scans 12 months of bills
for vendor payment patterns (monthly/quarterly/annual); card grid view in Bills/RecurringDetection.cshtml
with confidence badges, next-expected-date, and suggested actions.

Supporting: 4 new DTO groups in AccountingAiDtos.cs, 4 method signatures in
IAccountingAiService.cs, 4 implementations in AccountingAiService.cs, 4 new
AiFeatures constants, 2 new Landing page AI report cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:22:49 -04:00
spouliot e2f9e9ae4f Button consistency sweep + mobile responsiveness patches
- Standardize modal dismiss/cancel buttons to btn-outline-secondary across 70+ views
- Remove btn-sm from page-level Create and Back buttons (Index + Detail pages)
- Fix Edit buttons on Details pages: btn-secondary -> btn-warning
- Fix form Cancel/Back links: btn-secondary -> btn-outline-secondary
- Add 10 CSS patches to site.css for mobile/tablet responsiveness:
  top-navbar overflow prevention, page-header flex-wrap at 575px,
  table action button min-height override, notification dropdown width cap,
  tablet content padding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:04:10 -04:00
spouliot 328b195127 Design consistency audit fixes: alerts, cards, dark mode, utilities
Alert sweep (113 alerts, 79 files):
  All persistent static banners now carry alert-permanent so the
  layout's 5-second auto-dismiss cannot swallow guidance, warnings,
  or validation errors. Transient dismissible toasts left untouched.

CSS fixes (site.css):
  .card.shadow-sm      — strips rogue border from ~40 drifted cards
  .card-header.bg-white — rebinds to var(--bs-body-bg) so card
                          headers follow dark/light theme correctly
  Typography utilities  — .text-2xs (.68rem), .text-xs (.73rem)
  Token color classes   — .text-ember, .text-ok, .text-bad,
                          .text-warn, .text-cool, .bg-paper-2
  Layout utilities      — .mw-xs/sm/md/lg replace inline max-width
  Comment              — documents text-ember vs text-primary intent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:05:29 -04:00
spouliot f6d457fe0e Condense Operations sidebar: remove 5 items, tighten padding
Layer 1 — relocate off nav:
  Shop Display + Shop Mobile → Jobs page header (split dropdown on Blank Work Order)
  Powder Insights → Inventory page header button

Layer 2 — remove orphan section headers:
  "Main Menu" (only had Dashboard under it)
  "Reports" (only had Reports link under it)

Layer 3 — CSS density:
  nav-link padding 0.75rem → 0.55rem vertical
  nav-section-title padding 1rem/0.5rem → 0.65rem/0.3rem

Operations mode: 27 elements → 22. Scrollbar eliminated on standard screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:57:09 -04:00
spouliot c65445b94e Move Job Templates from sidebar nav to Jobs page header button
Templates button sits alongside Board and Blank Work Order in the
Jobs index action bar. Nav item and the now-redundant "& Templates"
section title are removed, trimming one more item from the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:49:10 -04:00
spouliot ccb094e57a Fix nav mode strip: underline-tab style + stable scrollbar gutter
Replace button-style strip with proper underline tabs — active side gets
an ember bottom-border matching the sidebar's existing active-link
language. Add scrollbar-gutter: stable so the sidebar interior width
does not shift when the scrollbar appears/disappears between modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:45:55 -04:00
spouliot 0204430fa5 Add Operations/Finance mode switcher to sidebar nav
Two-tab strip between Dashboard and the Operations section lets users
toggle between the shop-management view and the accounting view.
FOUC-prevention: server-side controller detection stamps data-nav-mode
on <html> before first paint; CSS hides the inactive side instantly.
JS (nav-mode.js) auto-switches to Finance when navigating to an
accounting controller, restores saved preference everywhere else.
Vendors appears in both modes; all 39 nav items carry data-nav tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:40:14 -04:00
spouliot 4fd9c52aaf Phase G: Add Budgeting and Year-End Close
Budgeting:
- Budget + BudgetLine entities with Jan–Dec monthly columns per GL account
- BudgetsController: Index, Create, Edit, SetDefault, Copy, Delete
- Copy action rolls a budget forward to a new fiscal year
- Budget vs. Actual report (BudgetVsActual): compares monthly budget amounts to
  real P&L by calling GetProfitAndLossAsync once per month; variance shown as
  favorable/unfavorable; year + budget selectors in header
- Views: Budgets/Index, Create, Edit with inline annual totals via budget-edit.js
- Nav link + report card on Landing

Year-End Close:
- YearEndClose entity records each closed year + JE reference for audit trail
- AccountsController.YearEndClose GET (history + form) + CloseYear POST
- Close zeroes all Revenue and Expense/COGS account balances into Retained Earnings
  via IAccountBalanceService and posts a supporting JE dated Dec 31
- Idempotency: rejects attempt to close an already-closed year
- Pre-close checklist in view to guide the workflow
- Nav link under Finance

Migration AddBudgetsAndYearEndClose applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 13:01:56 -04:00
spouliot fde24b09c9 Phase F: Add Invoice Write-Off, Fixed Assets, Period Locking, and 1099 Tracking
- Invoice Write-Off: WriteOff POST action in InvoicesController posts bad-debt JE
  (DR bad debt expense / CR AR), reduces customer balance, marks invoice WrittenOff;
  write-off modal added to Invoice Details view with expense account selector
- Fixed Assets: FixedAsset + FixedAssetDepreciationEntry entities with straight-line
  depreciation; FixedAssetsController (Index/Create/Edit/Details/PostDepreciation/Delete);
  PostDepreciation auto-generates one JE per asset per period, skips already-posted,
  fully-depreciated, and disposed assets; full CRUD views + nav link
- Period Locking: Company.BookLockedThrough field; AccountingPeriodValidator static helper;
  lock check added to JE Post and Bill Create (blocks backdating into closed periods);
  SetPeriodLock action + date picker UI in Company Settings Accounting section
- 1099 Tracking: Is1099Vendor flag on Vendor entity + DTOs; checkbox in Create/Edit views;
  TaxReporting1099 report action + view lists payments by year, flags vendors >= $600;
  report card added to Reports Landing
- Migration AddFixedAssetsLockAnd1099 applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 12:19:32 -04:00
spouliot a255893ada Add Credit Memos standalone management module
CreditMemosController with Index, Details, Create, Apply, and Void actions.
All business logic (atomic apply transaction, RemainingBalance cap,
customer.CreditBalance adjustment, auto-Paid invoice when BalanceDue hits zero)
mirrors the invoice-centric IssueCreditMemo/ApplyCredit/VoidCreditMemo actions in
InvoicesController but redirects back to the credit memo rather than an invoice.

Views: Index (stats bar, status+search filter, table), Details (two-col layout
with application history table and Bootstrap Apply/Void confirm modals),
Create (customer dropdown, amount, reason, notes, optional expiry).

Apply modal populates amount automatically from min(remaining credit,
invoice balance due) via credit-memo.js data-attribute wiring (no inline scripts).

Nav: Credit Memos added to Billing & Payments section in _Layout.

Build: 0 errors. Unit tests: 200/200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:48:35 -04:00
spouliot d94612cc9c Fix 4 post-review issues found in accounting module audit
- Drop orphan VendorCreditId1 column from VendorCreditApplications (was
  scaffolded by EF because WithMany() lacked inverse navigation name;
  fixed WithMany() → WithMany(vc => vc.Applications) in ApplicationDbContext)
- Wire EarlyPaymentDiscount fields through full data path: added
  EarlyPaymentDiscountPercent/Days to CreateInvoiceDto, hidden inputs to
  Invoice Create view, and JS to populate from customer AJAX response
- Add missing [HttpGet] attribute to TaxRatesController.Index
- Document GenerateNow architecture exception with XML rationale

Migration DropOrphanVendorCreditId1 applied. Build: 0 errors, 168 warnings.
Unit tests: 200/200 passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:32:44 -04:00
spouliot 14026818e2 Phase H: Add Cash Flow Statement (direct / cash-basis method)
- CashFlowStatementDto (Operating, Investing, Financing sections; BeginningCash/EndingCash)
- CashFlowLineDto for Investing/Financing line items
- GetCashFlowStatementAsync on IFinancialReportService + implementation in FinancialReportService
- GenerateCashFlowStatementPdfAsync on IPdfService + QuestPDF implementation in PdfService
- ReportsController.CashFlowStatement GET + CashFlowStatementPdf GET with inline/download mode
- CashFlowStatement.cshtml view with date filter, 3-section cards, summary sidebar, methodology note
- Reports Landing page: Cash Flow Statement card added to Accounting section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:14:47 -04:00
spouliot 42eff3357e Phase G: Add Recurring Transactions (BackgroundService + CRUD UI)
- RecurringTemplate entity with Frequency/IntervalCount/NextFireDate/EndDate/MaxOccurrences/TemplateData JSON
- RecurringFrequency + RecurringTemplateType enums
- RecurringTransactionService BackgroundService: hourly check, creates Draft bills or immediate expenses, advances NextFireDate, auto-deactivates on limits
- RecurringTemplatesController: Index/Create/Edit/ToggleActive/Delete/GenerateNow (on-demand fire)
- Three views + external JS for type-toggle and dynamic bill line items
- Finance sidebar nav: Recurring Transactions
- Migration: AddRecurringTemplates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:08:36 -04:00
spouliot d3a5d827f9 Phase F: Customer/Vendor Statements, Payment Terms Parser, Tax Rates
F1: GetCustomerStatementAsync/GetVendorStatementAsync on IFinancialReportService;
    StatementLineDto; CustomerStatementDto/VendorStatementDto; Statement action on
    CustomersController + VendorsController; Statement views + PDF download via
    StatementPdfHelper (QuestPDF); Statement button on Customer/Vendor Details pages.

F2: PaymentTermsParser static helper (CalculateDueDate, ParseEarlyPaymentDiscount);
    EarlyPaymentDiscountPercent/Days on Invoice entity; GetCustomerPaymentTerms AJAX
    endpoint on InvoicesController auto-populates Terms + due date on customer select;
    early payment discount notice on Invoice Create.

F3: TaxRate entity (Name/Rate/State/IsDefault/IsActive, tenant-filtered);
    IUnitOfWork.TaxRates + UnitOfWork + ApplicationDbContext; TaxRatesController
    (Index/Create/Edit/Delete/ToggleActive, CompanyAdminOnly); GetTaxRateForCustomer
    AJAX endpoint; Tax Rates in Settings gear menu.

Also fixes AddVendorCredits migration: VendorCreditApplications FKs changed from
CASCADE to NoAction to resolve SQL Server error 1785 (multiple cascade paths).
Migration: AddPaymentTermsAndTaxRates applied locally; 200/200 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 10:55:22 -04:00
spouliot 1229081436 Phase E: Add Bank Reconciliation
- IsCleared + ClearedDate added to Payment, BillPayment, Expense entities
- BankReconciliation entity (account, statement date, beginning/ending balance, status)
- BankReconciliationStatus enum (InProgress, Completed)
- Migration AddBankReconciliation: new BankReconciliations table + IsCleared/ClearedDate columns
- IUnitOfWork/UnitOfWork wired with BankReconciliations repo
- BankReconciliationsController: Index, Create, Reconcile, ToggleCleared (AJAX), Complete, Report
- Reconcile view: deposit/payment checkboxes with live running balance and difference via JS
- Complete is gated: only enabled when difference == $0.00
- Nav: Bank Reconciliation added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:10:38 -04:00
spouliot cf9dcfb4c1 Phase D: Add Vendor Credits (AP cycle completion)
- VendorCredit, VendorCreditLineItem, VendorCreditApplication entities
- VendorCreditStatus enum (Open, PartiallyApplied, Applied, Voided)
- Migration AddVendorCredits: three new tables
- IUnitOfWork/UnitOfWork wired with all three repositories
- VendorCreditsController: Index (status tabs), Create, Details, Post, Apply, Void
- Post action: DR AP, CR each expense line (reverses original expense)
- Apply action: links credit to bill, updates Bill.AmountPaid and bill status
- Views: Index (summary cards + table), Create (dynamic line grid), Details (apply panel)
- Nav: Vendor Credits added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 00:03:14 -04:00
spouliot a33687f7bd Phase C: Add Manual Journal Entries (double-entry GL)
- JournalEntry + JournalEntryLine entities with Draft/Posted/Reversed lifecycle
- JournalEntryStatus enum (Draft, Posted, Reversed)
- Migration AddJournalEntries: two new tables with self-referencing reversal FK
- IUnitOfWork/UnitOfWork wired with JournalEntries + JournalEntryLines repos
- ApplicationDbContext: DbSets, tenant query filters, reversal FK config
- LedgerService: JE lines added as 10th source in GetAccountLedgerAsync and ComputePriorBalanceAsync
- JournalEntriesController: Index (All/Draft/Posted tabs), Create, Details, Post, Reverse, Delete
- Views: Index, Create (dynamic balanced line grid with running debit/credit totals), Details
- journal-entry-create.js: dynamic line management with balance indicator
- Nav: Journal Entries added to Finance section in _Layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:56:03 -04:00
spouliot 0afb474c3e Add Phase B: Inventory COGS auto-posting to GL on JobUsage transactions
When powder is consumed via a job (JobsController) or scan (InventoryController.LogUsage),
debit the item's CogsAccountId and credit its InventoryAccountId for the cost of the
quantity consumed (using AverageCost if available, else UnitCost). No-op when either
GL account is not configured on the InventoryItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:39:23 -04:00
spouliot 7e1676cfd7 Add Phase A accounting features: AP Aging, Trial Balance, Cash vs Accrual
- AP Aging report (GetApAgingAsync, controller actions, view, PDF export)
  mirrors AR Aging — groups open bills by vendor, buckets by days past due date
- Trial Balance report (GetTrialBalanceAsync, view, PDF export)
  uses Account.CurrentBalance, groups by AccountType, validates debits == credits
- Cash vs Accrual accounting method setting on Company entity
  switchable at any time — report-time only, no GL re-posting on change
  P&L cash: revenue = payments received; expenses = bills/expenses paid in period
  Balance Sheet cash: omits AR and AP lines (no receivables/payables concept)
  AccountingMethod badge shown on P&L and Balance Sheet views
- Migration A (AddAccountingMethod) applied, default = Accrual for all existing companies
- AP Aging and Trial Balance added to Reports Landing page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 23:34:54 -04:00
spouliot 379b0de885 Refactor: centralize accounting helpers, status constants, and query deduplication
- AccountingDropdownHelper: wired into BillsController and ExpensesController,
  replacing 35-40 lines of duplicated DB queries per controller
- AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all
  magic status strings across Jobs, Quotes, Appointments, OvenScheduler,
  AiQuickQuote, QuoteApproval, and AccountingDropdownHelper
- AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure
  helper; removed duplicate private method from AccountBalanceService and
  LedgerService (~50 lines deleted)
- AccountDataExportController: extracted 9 Fetch*Async methods (superset of
  includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each
  entity is queried once regardless of whether XLSX or CSV format is requested
- BillsController.Create and ExpensesController.Create wrapped in
  ExecuteInTransactionAsync; blob uploads moved after commit to keep
  financial data atomic and prevent orphaned blobs from rolling back
- Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table
  GetAllAsync to prefix-filtered FindAsync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:42:39 -04:00
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00
spouliot 61866e1d1e Add carried-over jobs section to Daily Board and fix tip visibility
Non-terminal jobs scheduled for past dates now appear in a red 'Carried
Over' section at the top of today's board so they can't silently disappear.
Also added alert-permanent to the board tip so the layout doesn't auto-dismiss it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 15:34:39 -04:00
spouliot bc9de38da3 Fix Cash account subtype missing from debit-normal balance check
AccountSubType.Cash was not included in IsNormalDebitBalance in both
AccountBalanceService and LedgerService, causing Cash accounts to be
treated as credit-normal. Payments deposited to a Cash account were
debited in the wrong direction, producing a negative balance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:13:24 -04:00
spouliot 2694863d07 Fix health check URL to use production custom domain
Jenkins agent cannot resolve linuxpcl.azurewebsites.net due to DNS
restrictions on the build machine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 23:08:30 -04:00
spouliot 8646fa83c8 Fix PowerShell syntax error in zip creation step
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:56:12 -04:00
spouliot 796d084ea6 Fix zip entry paths to use forward slashes for Linux compatibility
ZipFile.CreateFromDirectory on Windows produces backslash paths in zip
entries. Linux treats backslash as a literal filename character so
wwwroot\css\site.css is never found at wwwroot/css/site.css. Build the
zip manually with Replace backslash→forward slash on each entry name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:46:24 -04:00
spouliot 6d23c63912 Stop app before deploy to prevent rsync file lock failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 22:11:04 -04:00
spouliot 3803d16731 Fix prod Jenkins pipeline: add tests, restart, and health check stage
- Add Test stage (unit tests only) before migrations so bad code fails fast
- Add az webapp restart after async deploy to ensure new code is loaded
- Add Health Check stage that polls app for up to 3 minutes post-deploy
- Restore xcopy of wwwroot (needed for linux-x64 publish on Windows)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:44:51 -04:00
spouliot 29fd7163dc Merge dev into master: billing email, SMS consent, incoming powder, invoice/job fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:13:54 -04:00
spouliot 9a52e7fae5 Ad-hoc quote email, accounting improvements, AI lookup fix, and misc service updates
- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file;
  QuotesController passes overrideEmail through to NotificationService
- Quotes/Details view: SMS consent display, email/SMS send button state based on consent
- Accounting module: AccountingDisplayHelpers for consistent ledger formatting;
  AccountsController + Accounts views improvements; AccountingEnums additions
- Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController
- InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path
  (LookupByUrlAsync already has it built in — was double-fetching)
- PdfService: quote/invoice PDF updates
- PricingCalculationService: minor pricing logic fix
- QuoteProfile: mapping updates for new quote fields
- ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:48:00 -04:00
spouliot 0d980e651a Add pricing breakdown and powder pre-fill to Job Details; surface voided invoice history
- Job Details: collapsible internal pricing breakdown card mirrors quote details breakdown
  (items subtotal, shop supplies, discount, rush fee, tax, total)
- Job Details: voided invoice history section shows previous invoices instead of hiding them
- Complete Job modal: pre-fills powder usage from QR-scanned / manually logged entries so
  staff don't double-log; consumes pre-logged credit per InventoryItemId before deducting net delta
- JobProfile: map ShopSuppliesAmount, ShopSuppliesPercent, IsRushJob, DiscountType/Value/Reason
  so the pricing breakdown has the data it needs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:44 -04:00
spouliot 3278152d83 Fix invoice re-creation after void; add payment terms selector and shop supplies line
- Voided invoices no longer block creating a new invoice for the same job: voided invoice's
  JobId FK is cleared so the unique index slot is freed for the replacement
- Invoice Details view shows voided invoices as history rather than hiding them
- Payment terms: standardized SelectList (Due on Receipt, Net 15/30/45/60/90, 2% 10 Net 30,
  COD) with custom-term preservation; invoice-due-date.js auto-updates Due Date on term change
- Shop supplies on direct (no-quote) jobs: InvoicesController derives the shop supplies line
  from the company rate when the job has no source quote to read the pre-agreed amount from
- Job entity: ShopSuppliesAmount + ShopSuppliesPercent fields preserved through job lifecycle
- Migration: AddShopSuppliesAmountToJob

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:34 -04:00
spouliot fc35fd123c Add IsIncoming inventory flag and catalog-to-incoming powder flow in item wizard
- InventoryItem.IsIncoming: marks powder ordered but not yet received; enables QR code
  printing on work orders while the shipment is in transit
- InventoryController.CreateIncomingFromCatalog: POST endpoint creates a 0-balance inventory
  record from a PowderCatalogItem and returns it in wizard-compatible shape
- item-wizard.js: custom coat tab now searches the platform powder catalog as a fallback;
  catalog results show an 'Add as Incoming Order' option; createIncomingFromCatalog POSTs
  to server and selects the new item without a page refresh
- QuoteItemCoatDto: CatalogItemId + AddAsIncoming fields so the wizard can signal server-side
  incoming-item creation during quote save
- Inventory Create/Edit/Index views: IsIncoming badge and field
- IInventoryAiLookupService: minor interface update
- Migration: AddInventoryIsIncoming

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:19 -04:00
spouliot f40d58ac2e Add TCPA-compliant SMS consent tracking for prospect quotes
- Quote entity: ProspectSmsConsent (bool) + ProspectSmsConsentedAt (DateTime?) fields
- QuoteDtos: consent fields on Create/Update/Convert DTOs with TCPA guidance text
- Quote Create/Edit views: SMS consent checkbox shown when mobile number is entered
- Quote ConvertToCustomer view: staff must re-confirm consent carries over to customer record
- QuoteApproval: consent state exposed in ViewModel and ApprovalPage for transparency
- Consent timestamp cleared when prospect quote is linked to an existing customer
- Migration: AddProspectSmsConsent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:47:04 -04:00
spouliot fb979bc88d Add BillingEmail field for commercial customers; support comma-separated multi-email
- Customer entity + DTO: new BillingEmail field (accounting/invoicing address)
- Email fields now accept comma-separated lists; DTO validates each address individually
- NotificationService: SendToEmailListAsync helper fans out to all addresses in a list;
  NotifyQuoteSentAsync accepts optional overrideEmail so staff can send to an ad-hoc address
- Migration: AddCustomerBillingEmail
- Customer Create/Edit/Details views updated to show Billing Email field
- customer-billing-email.js: client-side helpers for billing email input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 20:46:53 -04:00
spouliot 12f784f34c Add Unit Price column to quote PDF
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:41:44 -04:00
spouliot 90f93b6e2f Fall back to ProspectEmail for CustomerEmail on prospect quotes
Prospect quotes have no CustomerId so Customer is null — email is stored
in ProspectEmail directly on the quote. The send-button visibility check
was always seeing null and showing the 'no contact info' warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:31:12 -04:00
spouliot 135fd6f8d7 Clarify no-contact warning to say 'mobile number' not 'phone'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:06:24 -04:00
spouliot ff231d9dd2 Set quote status to Converted and show job number link on quote details
- CreateJobFromQuote now sets QuoteStatusId to CONVERTED after creating the job
- Added ConvertedToJobNumber to QuoteDto, populated in Details action
- 'View Job' button on Quote Details now shows the job number (e.g. 'View Job JOB-2505-0001')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:47:45 -04:00
spouliot e3c76ce7ce Fix missing customer contact fields on QuoteDto mapping
CustomerEmail, CustomerMobilePhone, CustomerNotifyBySms, and
CustomerNotifyByEmail were added to QuoteDto but never mapped in
QuoteProfile, causing the email/SMS visibility logic on Quote Details
to always see null and show the 'no contact info' warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 20:14:38 -04:00
spouliot 2cfe093780 Share Mark Complete modal as partial view; hide install button after PWA install
- Extract _CompleteJobModal.cshtml partial; Details.cshtml uses PartialAsync
- Job board COMPLETED drop fetches partial via AJAX and shows modal in-place
- Add GET Jobs/CompleteJobModal action to load job data for the board modal
- install-app.js: persist installed state in localStorage; clears automatically when browser re-fires beforeinstallprompt after uninstall

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 19:55:37 -04:00
spouliot bbedaedeaa Redirect board COMPLETED drop to Details page for full completion flow
Dragging a card to the Completed column on the job board previously called
MoveCard directly, skipping email/SMS notifications, CompletedDate, powder
deduction, and the completion modal entirely.

Now detects the COMPLETED status code on the target column, reverts the
visual drag, and navigates to the job Details page with #completeModal in
the hash. Details.cshtml auto-opens the Mark Complete modal on arrival,
so the user goes through the same code path as the Complete Job button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:54:13 -04:00
spouliot acbd9f60be Hide email controls when no email on file; show SMS hint for quote/job events
- Quotes Create/Edit: hide 'Send via email' checkbox when customer has no
  email; show badge 'send via SMS from details' or 'SMS consent required'
  when customer has a mobile number. JS responds to customer dropdown change.
- Quotes Details: hide 'Send Quote via Email' button and approval email
  checkbox; hide SMS button when no mobile; show consent-required note.
- Jobs Details (Mark Complete modal): hide email checkbox; show
  'SMS notification will be sent' badge or consent-required note.
- Jobs Index (status modal): hide email row when customer has no email.
- Jobs Edit: hide 'Notify customer if status changes' when no email.
- Invoices Details: hide Send/Re-send buttons when no email (vs. disabled).

DTOs: added CustomerEmail + CustomerNotifyByEmail to JobDto/JobListDto;
added CustomerNotifyByEmail/CustomerMobilePhone/CustomerNotifyBySms to
QuoteDto. Mapped in JobProfile and QuotesController customer blocks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:32:08 -04:00
spouliot d3863c713b Add QuoteApprovedByCustomer notification type; fix wrong type logged on approval
QuoteDeclinedByCustomer was used for both approve and decline responses,
so approval notifications showed the wrong type in the log. Added a distinct
QuoteApprovedByCustomer = 16 enum value, wired up the correct type in
NotificationService, added default templates in both the service fallback
dictionary and SeedData, and updated placeholder hints in CompanySettings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:13:34 -04:00
spouliot 4085ff7c73 Advance quote to Sent status when approval link sent via SMS
The SMS path was sending the message but never updating QuoteStatusId or
SentDate, leaving the quote in Draft. Now mirrors the email send path:
transitions Draft → Sent and stamps SentDate on first send only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:04:25 -04:00
spouliot 96ae3639ae Fix duplicate name on quote PDF for non-commercial customers
Non-commercial individuals have their full name stored in both CompanyName
and ContactFirstName/ContactLastName, causing the PDF to render the name
twice. Skip the company name line when it matches the assembled contact name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 17:01:37 -04:00
spouliot 8c30d5bf5a Restore IncludePrepCost pricing for catalog items with abnormal prep
The flag should default off (catalog DefaultPrice used as-is) but remain
functional so users can opt in when a job needs exceptional prep time
(e.g. 120-min outgassing vs. the typical 30 min baked into the catalog price).

Previous commit removed this entirely — restoring with correct default behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:47:48 -04:00
spouliot 9292f3169c Fix catalog item pricing and rogue margin-points text
Catalog DefaultPrice is always the base price — removed the IncludePrepCost
gate that was adding prep service labor on top of catalog items. PrepServices
on catalog items exist for scheduling purposes only, not pricing.

Also fixed Razor syntax bug in Details.cshtml where @(expr).ToString("F1")
rendered the raw decimal followed by the literal string ".ToString("F1")"
instead of the formatted value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:43:42 -04:00
spouliot 810d5a5dc1 Update idempotent EF migration script for prod deploy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:23:22 -04:00
spouliot 8a09a91234 Merge dev into master 2026-05-06 16:20:49 -04:00
spouliot d8622b3187 Fix catalog item repricing on oven-only quote edits
QuoteItem was missing IncludePrepCost, so the Edit GET always deserialized
it as true (DTO default). On save, prep service labor was added on top of
the catalog base price, silently bumping prices whenever any quote field
(e.g. oven cycle minutes) was changed without touching items.

Migration defaults new column to false for catalog items and true for
non-catalog items (matching the wizard's historical defaults).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:04:45 -04:00
spouliot 2d8827ad5c Fix 'Oven (1 batch × 0 min)' display when OvenCycleMinutes is null
Quote.OvenCycleMinutes is nullable — null means 'use company default'.
The Details and DownloadPdf actions were converting null → 0 before passing
it to the view, so the pricing summary showed '× 0 min' even though the
oven cost was correctly calculated from DefaultOvenCycleMinutes.

Fix: resolve null against operatingCosts.DefaultOvenCycleMinutes in both
controller actions (DownloadPdf now loads operating costs for this). Added
a defensive > 0 guard in the view so the minutes clause is omitted entirely
if it still comes through as 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:35:38 -04:00
spouliot 4d10175ce3 Add oven batch cost to AI Quick Quote (1 batch, DefaultOvenCycleMinutes or 50 min)
Previously the quick quote omitted the oven charge entirely, so saved quotes
were under-priced relative to full quotes from the same items.

Pricing: CalculatePricing now calculates ovenBatchCost = (cycleMin/60) × OvenOperatingCostPerHour
using DefaultOvenCycleMinutes (fallback 50 min), then adds it to the total as a quote-level
charge matching how PricingCalculationService handles oven costs.

Save path: SaveQuickQuoteRequest gains OvenBatchCost + OvenCycleMinutes; the Quote record
now stores OvenBatchCost, OvenCycleMinutes, and Total = ItemsSubtotal + OvenBatchCost.

Display: results card shows a sub-line under the estimate price:
"incl. oven 1 batch 50 min: $12.00"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 15:20:10 -04:00
spouliot 9c4c20e8bd Fix false-positive 'source quote was modified' banner after job conversion
The banner fires when quote.UpdatedAt > job.QuoteSnapshotUpdatedAt. The
snapshot was captured before saving quote.ConvertedToJobId, so the EF
interceptor's automatic UpdatedAt stamp on that save always made the quote
appear newer than the snapshot — triggering the banner on every freshly
converted job even with no actual changes.

Fix: after saving ConvertedToJobId, re-stamp QuoteSnapshotUpdatedAt to the
quote's final UpdatedAt value and save the job once more. The snapshot now
includes the conversion write, so the comparison is equal (not "after") and
the banner stays hidden until the quote genuinely changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:46:39 -04:00
spouliot ecb285657a Fix sandblast-only toggle overflow and $0 AI quote pricing
Overflow: replaced Bootstrap form-check with an explicit flex row so the
two-line label (title + subtitle) never bleeds outside the card boundary.

$0 pricing: when sandblast-only was toggled on an AI item, manualUnitPrice
was cleared and isAiItem set to false. The pricing engine then returned $0
because no prep services with minutes were configured. Fix: preserve the AI
price when toggling sandblast-only, and keep isAiItem=true so the server
routes through the AI-price path (manualUnitPrice) rather than trying to
recalculate from prep labor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:29:03 -04:00
spouliot 0054b7d108 Fix NullReferenceException on Quote Details when quote total is zero
PricingBreakdown was only populated when quote.Total > 0, but the Details
view unconditionally dereferences PricingBreakdown.ItemsSubtotal. Sandblast-
only quotes can legitimately have a $0 total (no powder/oven costs), leaving
PricingBreakdown null and crashing the Details render.

Removed the Total > 0 guard from both Details action overloads — always
populate PricingBreakdown from the stored snapshot fields (all values are 0
for an unpriced or sandblast-only quote, which is safe for display).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 14:16:24 -04:00
spouliot 63a85b6ce9 Fix sandblast-only oven charge and wizard overflow; fix Jenkins test failures
Sandblast-only oven charge root cause:
renderCoatsList() is called on every step-3 render. When the sandblast-only
toggle was checked it cleared wz.data.coats to [], but renderCoatsList()
then saw an empty list and auto-called addCoatRow(), silently pushing a
Base Coat back into wz.data.coats. The server saw Coats.Any() = true and
included the item in the oven fraction calculation, producing an unexpected
oven batch charge. Fixed by bailing out of renderCoatsList() when
sandblastOnly is active, and added a matching safety net in
buildItemFromWizard() that forces coats:[] when sandblastOnly is true.

Also fixed sandblast-only toggle label overflow: subtitle span changed to
d-block so it wraps beneath the bold label instead of running inline.

Test fixes:
- DepositsController and GiftCertificatesController tests updated with the
  required ICompanyLogoService mock parameter added to the logo fix commit.
- Two PricingCalculationServiceTests updated to include a coat entry on
  each item, matching the service's updated requirement that only items
  with coating layers are considered for oven fraction calculation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:56:20 -04:00
spouliot 71caa93461 Fix unit test build failures after logo service and pricing changes
DepositsController and GiftCertificatesController gained a required
ICompanyLogoService constructor parameter in the PDF logo fix; their
test factories were not updated and failed to compile on Jenkins.
Added Mock.Of<ICompanyLogoService>() to both factory methods and the
missing using directive to DepositsControllerTests.

PricingCalculationService now only charges oven cost for items that
have explicit coating layers (Coats collection non-empty), because
sandblast/prep-only and labor items do not go in the oven. Two tests
that tested the old "all items count toward oven fraction" logic were
updated to include a single coat entry on each item, which restores
the expected oven fraction math without changing the tested behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:38:18 -04:00
spouliot 2e73cfab54 Miscellaneous UI and pricing updates from prior sessions
- PricingCalculationService: powder coverage and specific gravity math fixes
- Dashboard/Index: minor widget updates
- Jobs/Details, Jobs/Intake: shop floor and intake view improvements
- Quotes/Details: detail view updates
- GiftCertificates/Details: detail view update
- job-photos.js: photo gallery improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:37 -04:00
spouliot 74414c6c71 Add AI overload retry with model fallback and consolidate wizard errors
Anthropic returns overloaded_error (HTTP 529) during high-demand periods.
Previously this failed immediately with a generic error. Now the service
retries Sonnet once after 5s, then falls back to Haiku (a separate
capacity pool) after another 3s before giving up. If all three attempts
are overloaded the user sees a clear "high demand" message rather than a
generic error. Non-overload errors still log at Error level.

Also consolidated AI wizard error display in item-wizard.js: photo upload
failures were using browser alert() while analyze failures used the inline
red alert bar. All errors now go through aiShowError() so they always
appear consistently as the red bar below the Analyze button. Removed the
alert() fallback from aiShowError() itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:27 -04:00
spouliot a8fb56e8ec Fix company logo missing from PDFs and add AI photo save logging
When a tenant uploads a logo it is stored in Azure Blob Storage and
LogoData (the legacy DB byte[]) is cleared. All PDF controllers were
still reading the now-null LogoData, so logos never appeared on any
PDF after upload. Fixed by injecting ICompanyLogoService into all six
affected controllers (Quotes, Invoices, Deposits, GiftCertificates,
PurchaseOrders, CatalogItems) and loading the blob-stored logo first
before falling back to the legacy DB field.

Also added structured logging to the AI photo promotion path in
QuotesController Create/Edit POST so upload failures are visible in
production logs instead of silently swallowed.

Added onclick safety net to the Create and Edit quote submit buttons
so dynamically-injected hidden fields (AiPhotoTempIds) are written
before iOS Safari collects the form data on submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:18 -04:00
spouliot ca4fb959aa Add Sales Tax Liability report with PDF and CSV export
Invoice-basis report showing taxable vs non-taxable sales, tax billed
by GL account, monthly trend table/chart, and full invoice detail grid.
Non-taxable invoice rows shaded grey for easy scanning. Quick-preset
date buttons (This Month, Last Month, YTD, Last Year) for common filing
periods. CSV export formatted for accountants and tax-filing software.
Gated behind AllowAccounting() like other financial reports.

- SalesTaxReportDto + 3 supporting DTOs in FinancialReportDtos.cs
- GetSalesTaxReportAsync on IFinancialReportService + implementation
- GenerateSalesTaxReportPdfAsync on IPdfService + QuestPDF implementation
- SalesTax / SalesTaxPdf / SalesTaxCsv actions in ReportsController
- Views/Reports/SalesTax.cshtml with Chart.js monthly trend chart
- Landing page card added to Finance section
- HelpKnowledgeBase and Help/Reports.cshtml updated with full docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:27:08 -04:00
spouliot 7e0699d5bd Add smart install prompt for supported browsers 2026-05-06 09:05:00 -04:00
spouliot f383339465 Store powder specific gravity and fix coverage math 2026-05-06 08:46:41 -04:00
spouliot 11a1b91be1 Add platform powder catalog management UI with full CRUD and AI lookup
- PowderCatalogController: Create, Edit, ToggleDiscontinued actions; searchable/filterable/sortable Index with pagination; AiLookup and AiAugmentFromUrl endpoints backed by IInventoryAiLookupService
- New views: Create, Edit, _Form partial (with AI-assisted field population), overhauled Index grid with completeness quality badges and responsive mobile cards
- New ViewModels: PowderCatalogIndexViewModel, PowderCatalogFormViewModel, PowderCatalogListItemViewModel
- AI lookup improvements: SpecificGravity field added to InventoryAiLookupResult; ApplyPowderFallbacks derives CoverageSqFtPerLb from specific gravity when docs omit it; DefaultTransferEfficiency (65%) applied everywhere transfer efficiency is null
- powder-catalog-ai-lookup.js: client-side AI lookup and URL augment wiring for the catalog form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 00:27:44 -04:00
spouliot b0d8d5c612 Merge dev: powder usage logging, time entry fix, Data Protection keys
- Fix time entry 500: parseInt destroyed GUID user IDs
- Inventory ledger: show edit pencil for Adjustment rows (scan-without-job)
- Inventory ledger: scan-based logs now appear in Powder Usage By Job tab
- Store Data Protection keys in SQL Server (non-production); migration AddDataProtectionKeys
- Fix mojibake characters across multiple views
- Fix subscription grace period tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:35:58 -04:00
spouliot 713efbc2b6 Store Data Protection keys in SQL Server (non-production)
Replaces the local filesystem path (which required IIS app pool write
access to inetpub\wwwroot\DataProtection-Keys) with SQL Server storage
via IDataProtectionKeyContext. Keys now survive deploys and IIS recycles
without any server-side folder permission setup.

Production continues to use Azure Blob Storage unchanged.

- Add Microsoft.AspNetCore.DataProtection.EntityFrameworkCore 8.0.11 to
  Web and Infrastructure projects
- ApplicationDbContext implements IDataProtectionKeyContext
- Migration AddDataProtectionKeys creates DataProtectionKeys table
- Program.cs: non-production path uses PersistKeysToDbContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:08:31 -04:00
spouliot c7a60a1fad Use Azure Blob Storage for Data Protection keys on non-local deployments
When Storage:ConnectionString is configured (dev/staging servers), store
Data Protection keys in Azure Blob Storage (dataprotection-dev/keys.xml)
instead of the local filesystem. Local developer workstations without a
storage connection string continue to use the filesystem fallback.

Fixes UnauthorizedAccessException on the dev IIS server caused by the app
pool identity not having permission to create the DataProtection-Keys
directory after it was wiped during a deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:54:37 -04:00
spouliot c45a6826bd Fix time entry 500 and inventory edit pencil visibility
- Remove parseInt() from time entry worker select — GUIDs were destroyed
  to NaN → sent as null → FindByIdAsync(null) threw 500
- Ledger pencil: also show for Adjustment rows (no PO) so scan-without-job
  entries get an edit button, not just JobUsage rows
- InventoryController: always write JobUsage type for scan-based logs;
  accept Adjustment in edit endpoints; promote Adjustment→JobUsage when
  a job is assigned via edit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:46:05 -04:00
spouliot 010d0437c2 Fix grace period tests: set StripeSubscriptionId on test companies
Trial companies (no StripeSubscriptionId) get 0 grace days by design.
The GracePeriod and Expired status tests need a paid subscription to
exercise the 14-day grace window correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 21:27:38 -04:00
1878 changed files with 919699 additions and 9061 deletions
-177
View File
@@ -1,177 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dir:*)",
"Bash(dotnet restore:*)",
"Bash(dotnet clean:*)",
"Bash(findstr:*)",
"Bash(dotnet ef migrations add:*)",
"Bash(dotnet ef migrations remove:*)",
"Bash(ls:*)",
"Bash(dotnet ef database update:*)",
"Bash(sqlcmd:*)",
"Bash(dotnet ef migrations script:*)",
"Bash(dotnet run:*)",
"Bash(timeout /t 15 dotnet run:*)",
"Bash(timeout /t 10 /nobreak)",
"Bash(ping:*)",
"Bash(start /B dotnet run:*)",
"Bash(test:*)",
"Bash(dotnet ef migrations:*)",
"Bash(grep:*)",
"Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && head -20 {} | grep -E \"\"class|Authorize\"\"')",
"Bash(powershell:*)",
"Bash(dotnet tool install:*)",
"Bash(dotnet tool update:*)",
"Bash(xargs:*)",
"Bash(powershell -Command \"cd src\\\\PowderCoating.Web; dotnet ef migrations add UpdateQuoteForProspects --project ..\\\\PowderCoating.Infrastructure\")",
"Bash(powershell -Command:*)",
"Bash(taskkill:*)",
"Bash(netstat:*)",
"Bash(libman restore:*)",
"Bash(./start-app.bat)",
"Bash(dotnet-ef migrations add:*)",
"Bash(dotnet-ef database update:*)",
"Bash(./stop-app.bat)",
"Bash(timeout /t 3 /nobreak)",
"Bash(curl:*)",
"Bash(if [ -f \"stop-app.bat\" ])",
"Bash(then cmd.exe /c stop-app.bat)",
"Bash(else echo \"stop-app.bat not found\")",
"Bash(fi)",
"Bash(powershell.exe -Command \"Unblock-File -Path 'src/PowderCoating.Web/dotnet-tools.json'\":*)",
"Bash(powershell.exe -Command \"Get-Process | Where-Object {$_ProcessName -like ''*PowderCoating*''} | Stop-Process -Force\")",
"Bash(powershell.exe:*)",
"Bash(Select-String -Pattern \"error|Error\")",
"Bash(Select-String -NotMatch \"warning\")",
"Bash(tasklist:*)",
"Bash(dotnet add package:*)",
"Bash(start-process dotnet run:*)",
"Bash(Select-Object -ExpandProperty Id)",
"Bash(find:*)",
"Bash(cmd.exe:*)",
"Bash(dotnet ef dbcontext:*)",
"Bash(handle \"PowderCoating.Web.pdb\")",
"Bash(timeout:*)",
"Bash(del /F \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\obj\\\\Debug\\\\net8.0\\\\PowderCoating.Web.pdb\")",
"Bash(Select-String -Pattern \"Build succeeded|Build FAILED|error\")",
"Bash(Select-Object -Last 10)",
"Bash(del \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.cs\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.Designer.cs\")",
"Bash(Select-String:*)",
"Bash(Select-Object -Last 5)",
"Bash(start-app.bat)",
"Bash(dotnet script:*)",
"Bash(dotnet list:*)",
"Bash(dotnet new:*)",
"Bash(stop-app.bat)",
"Bash(dotnet watch run:*)",
"Bash(cmd /c \"taskkill /F /PID 42108\")",
"Bash(cmd /c start-app.bat)",
"Bash(\"Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs\":*)",
"Bash(/y/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs:*)",
"Bash(/tmp/remove_tempdata.pl:*)",
"Bash(chmod:*)",
"Bash(perl:*)",
"Bash(done)",
"Bash(cmd:*)",
"Bash(tail:*)",
"Bash(del:*)",
"Bash(dotnet add:*)",
"Bash(python3:*)",
"Bash(Stop-Process:*)",
"Bash(mv:*)",
"Bash(dotnet tool:*)",
"Bash(where libman:*)",
"Bash(find \"Y:/PCC/PowderCoatingApp\" -type f \\\\\\( -name \"*template*\" -o -name \"*import*\" -o -name \"*export*\" \\\\\\) -iname \"*.csv\" -o -iname \"*.xlsx\" -o -iname \"*.xls\" 2>/dev/null | head -50)",
"Bash(grep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Create.cshtml\" | head -20\ngrep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Edit.cshtml\" 2>/dev/null | head -20)",
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -20)",
"Bash(cd /tmp/sdktest && rm -rf bin obj && cat Program.cs)",
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -5)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(wmic process:*)",
"Bash(grep -rn \"AI Photo\\\\|ai.*photo\\\\|photo.*quote\\\\|item-type\\\\|AiPhotoQuotes\\\\|ai_photo\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\\" | grep -i \"photo\\\\|ai\" | head -20)",
"Bash(sed -i 's|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",\\\\n \"aiPhotoQuotesEnabled\": @Json.Serialize\\(\\(bool\\)\\(ViewBag.AiPhotoQuotesEnabled ?? true\\)\\),|g' \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\Edit.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Create.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Edit.cshtml\")",
"Bash(cp:*)",
"Bash(dotnet fsi -e \":*)",
"Read(//y/tmp/**)",
"Bash(cp /c/Users/spoul/.nuget/packages/stripe.net/50.4.1/stripe.net.50.4.1.nupkg stripe.zip)",
"Bash(unzip -o stripe.zip *.cs -d stripe_src)",
"Bash(dotnet ef:*)",
"Bash(Payment)",
"Bash(Deposit \")",
"Bash(node:*)",
"WebFetch(domain:quickbooks.intuit.com)",
"WebFetch(domain:www.saasant.com)",
"WebFetch(domain:www.liveflow.com)",
"WebFetch(domain:www.gentlefrog.com)",
"WebFetch(domain:blog.coupler.io)",
"WebFetch(domain:litextension.com)",
"WebFetch(domain:www.dancingnumbers.com)",
"WebFetch(domain:www.bizbooks.pro)",
"WebFetch(domain:support.saasant.com)",
"WebFetch(domain:support.getcount.com)",
"WebFetch(domain:planergy.com)",
"WebFetch(domain:www.wizxpert.com)",
"WebFetch(domain:www.trykeep.com)",
"WebFetch(domain:gentlefrog.com)",
"WebFetch(domain:www.syscloud.com)",
"WebFetch(domain:interopay.zendesk.com)",
"WebFetch(domain:docs.d-tools.cloud)",
"WebFetch(domain:paygration.com)",
"Bash([ ! -d \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/$controller\" ])",
"Bash(bash /tmp/check_actions.sh)",
"Bash(bash /tmp/verify_endpoints.sh)",
"Bash(bash /tmp/verify_services.sh)",
"Read(//y/PCC/Deployments/**)",
"Bash(mkdir -p \"Y:/PCC/Deployments\")",
"Bash(dotnet-script -e \"using System.Reflection; var a = Assembly.LoadFrom\\(\\\\\"Anthropic.SDK.dll\\\\\"\\); var types = a.GetTypes\\(\\).Where\\(t => t.Name.Contains\\(\\\\\"Document\\\\\"\\) || t.Name.Contains\\(\\\\\"Content\\\\\"\\)\\).Select\\(t => t.Name\\).OrderBy\\(n => n\\); foreach\\(var t in types\\) Console.WriteLine\\(t\\);\")",
"Bash(sort -t'-' -k3 -r)",
"Bash(wsl grep:*)",
"Bash(find src:*)",
"Bash(dotnet csharp *)",
"Read(//c/Users/spoul/.nuget/packages/stripe.net/50.4.1/lib/netstandard2.0/**)",
"Bash(dotnet publish *)",
"Bash(Compress-Archive -Path * -DestinationPath \"..\\\\deploy.zip\" -Force)",
"Bash(az webapp *)",
"Read(//y/PCC/**)",
"Bash(Get-Date -Format 'yyyyMMdd_HHmmss')",
"PowerShell(Get-Content *)",
"PowerShell(dotnet build *)",
"PowerShell(New-Item *)",
"PowerShell(& \"Y:\\\\PCC\\\\PowderCoatingApp\\\\scripts\\\\generate-migration-script.ps1\")",
"PowerShell(if \\(Test-Path \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"\\) { $f = Get-Item \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"; Write-Host \"File exists: $\\($f.Length\\) bytes\" } else { Write-Host \"File not created\" })",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"Bash(git commit *)",
"Bash(git checkout *)",
"Bash(git merge *)",
"Bash(dotnet package *)",
"Bash(dotnet test *)",
"Bash(git rm *)",
"Bash(git stash *)",
"Bash(dotnet ef *)",
"Bash(sqlcmd -S \".\\\\SQLEXPRESS\" -d PowderCoatingDb -Q \"SELECT Id, DisplayName, IsCoating, IsActive FROM InventoryCategoryLookups ORDER BY DisplayOrder\" -W)",
"Skill(schedule)",
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" log --oneline -10)",
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" status --short)",
"Bash(git *)",
"Bash(get-childitem -Recurse -Filter \"QuotesController.cs\")",
"Bash(Select-Object -ExpandProperty FullName)",
"Bash(dotnet user-secrets *)",
"Bash(Get-ChildItem -Path \"Y:\\\\PCC\\\\PowderCoatingApp\" -Directory)",
"Bash(Select-Object Name)",
"Bash(Get-Content *)",
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); print\\(f'Total records: {len\\(data\\)}'\\); print\\('First record:'\\); print\\(json.dumps\\(data[0], indent=2\\)\\)\")",
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); keys=list\\(data.keys\\(\\)\\); print\\('Top-level keys:', keys[:10]\\); first=data[keys[0]]; print\\('First record key:', keys[0]\\); print\\(json.dumps\\(first, indent=2\\)\\)\")",
"PowerShell(Get-ChildItem *)",
"PowerShell(Select-String *)",
"Bash(Select-Object -First 20)",
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
"WebFetch(domain:www.powdercoatinglogix.com)"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Pre-commit hook: block commits containing corrupted Unicode in .cshtml files.
#
# All corruption variants start with the UTF-8 byte sequence for a-circumflex
# followed by euro-sign (bytes C3 A2 E2 82 AC), which is the first two chars
# of every known corruption pattern. Grep for that byte sequence in staged files.
STAGED=$(git diff --cached --name-only | grep '\.cshtml$')
if [ -z "$STAGED" ]; then
exit 0
fi
# $'\xc3\xa2\xe2\x82\xac' = UTF-8 bytes for a-circumflex + euro-sign
CORRUPT=$(echo "$STAGED" | xargs grep -l $'\xc3\xa2\xe2\x82\xac' 2>/dev/null)
if [ -n "$CORRUPT" ]; then
echo ""
echo "ERROR: Corrupted Unicode characters detected in staged .cshtml files:"
echo "$CORRUPT" | sed 's/^/ /'
echo ""
echo "Fix by running: .\\tools\\Fix-Encoding.ps1"
echo "Then re-stage the files and commit again."
echo ""
exit 1
fi
exit 0
+9
View File
@@ -1,6 +1,11 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Claude Code tool settings and build logs
.claude/settings.local.json
.claude/settings.json
BuildLog*.txt
# User-specific files
*.rsuser
*.suo
@@ -129,3 +134,7 @@ DataProtection-Keys/
# Secrets
appsettings.secrets.json
*.pfx
# Local task tracking
TODO.txt
TODO.txt.bak
+571
View File
@@ -0,0 +1,571 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API).
## Essential Commands
### Building and Running
```bash
# Build entire solution
dotnet build
# Run web application (MVC)
cd src/PowderCoating.Web
dotnet run
# Access at: https://localhost:58461
# Run web with auto-reload
dotnet watch run
# Run API
cd src/PowderCoating.Api
dotnet run
# Swagger UI at root URL
# Run tests
dotnet test # All tests
dotnet test tests/PowderCoating.UnitTests # Unit tests only
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
```
### Database Operations
```bash
# All EF commands run from Web project directory
cd src/PowderCoating.Web
# Create migration (must specify Infrastructure project)
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
# Apply migrations
dotnet ef database update --project ../PowderCoating.Infrastructure
# Reset database (WARNING: deletes all data)
dotnet ef database drop --project ../PowderCoating.Infrastructure
dotnet ef database update --project ../PowderCoating.Infrastructure
# List migrations
dotnet ef migrations list --project ../PowderCoating.Infrastructure
# Remove last migration (if not applied)
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
```
### Default Credentials
```
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
```
## Architecture Overview
### Clean Architecture Layers
**Domain Layer (PowderCoating.Core)**
- Contains business entities, enums, and repository interfaces
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
- All entities inherit from BaseEntity and support soft delete
- No dependencies on other projects
**Application Layer (PowderCoating.Application)**
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
- AutoMapper profiles with reverse mappings
- Service interfaces (IFileService, etc.)
- No UI or infrastructure dependencies
**Infrastructure Layer (PowderCoating.Infrastructure)**
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
- Generic `Repository<T>` implementing `IRepository<T>`
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
**Presentation Layers**
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
### Key Design Patterns
**Repository Pattern**
- Generic `Repository<T>` in Infrastructure
- All CRUD operations, search, pagination, eager loading support
- Soft delete with `SoftDeleteAsync()` method
**Unit of Work Pattern**
- Coordinates multiple repositories
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
- Lazy instantiation of repositories
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
**Dependency Injection**
- All dependencies registered in `Program.cs`
- Controllers inject `IUnitOfWork` and `IMapper`
- Services are scoped to request lifetime
**Global Query Filters**
- Soft deletes: All queries automatically filter `IsDeleted == false`
- Multi-tenancy: Non-SuperAdmin users see only their company data
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
### Multi-Tenancy Implementation
- `CompanyId` foreign key on all business entities
- `ITenantContext` injected into DbContext resolves current company
- SuperAdmin role can view all companies
- Global query filters enforce company isolation at database level
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
## Data Access Rules (ENFORCE THESE)
> **`ApplicationDbContext` is NEVER injected into a controller.**
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one:
**Tier 1 — Simple CRUD**`IUnitOfWork.EntityName` (generic `IRepository<T>`)
```csharp
var items = await _unitOfWork.CatalogItems.GetAllAsync();
await _unitOfWork.Announcements.AddAsync(entity);
await _unitOfWork.CompleteAsync();
```
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
```csharp
// Include chains and domain-specific queries belong in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
```
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
**Tier 3 — Aggregate/reporting queries** → injected read services
```csharp
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
var aging = await _financialReports.GetArAgingAsync(companyId);
```
Services: `IFinancialReportService`, `IOperationalReportService`
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
If you think you need a new exception, you almost certainly don't. Check the spec first.
---
## Data Access Patterns
### Common Controller Pattern
```csharp
public class ExampleController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<IActionResult> Index()
{
var entities = await _unitOfWork.Examples.GetAllAsync();
var dtos = _mapper.Map<List<ExampleDto>>(entities);
return View(dtos);
}
[HttpPost]
public async Task<IActionResult> Create(CreateExampleDto dto)
{
var entity = _mapper.Map<Example>(dto);
await _unitOfWork.Examples.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
public async Task<IActionResult> Delete(int id)
{
await _unitOfWork.Examples.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
}
```
### Using Unit of Work Repositories
All entity repositories are available via `IUnitOfWork` properties:
- `_unitOfWork.Customers`
- `_unitOfWork.Jobs`
- `_unitOfWork.JobItems`
- `_unitOfWork.Quotes`
- `_unitOfWork.InventoryItems`
- `_unitOfWork.Equipment`
- `_unitOfWork.MaintenanceRecords`
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
### Eager Loading Related Data
```csharp
// Load customer with related data
var customer = await _unitOfWork.Customers.GetByIdAsync(
id,
c => c.Jobs,
c => c.Quotes,
c => c.PricingTier
);
// Find with predicate and includes
var activeJobs = await _unitOfWork.Jobs.FindAsync(
j => j.Status != JobStatus.Completed,
j => j.Customer,
j => j.JobItems
);
```
### Pagination
```csharp
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
pageNumber: 1,
pageSize: 25,
j => j.Status == JobStatus.InPreparation,
j => j.Customer
);
```
## Important Domain Concepts
### Job Lifecycle
Jobs progress through 16 statuses:
1. **Pending** → Initial state
2. **Quoted** → Quote generated
3. **Approved** → Customer approved
4. **InPreparation** → Job prep started
5. **Sandblasting** → Surface prep
6. **MaskingTaping** → Masking areas
7. **Cleaning** → Pre-coat cleaning
8. **InOven** → Pre-heating
9. **Coating** → Applying powder
10. **Curing** → Heat curing
11. **QualityCheck** → Inspection
12. **Completed** → Work finished
13. **ReadyForPickup** → Awaiting customer
14. **Delivered** → Job delivered
15. **OnHold** → Paused
16. **Cancelled** → Cancelled
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
### Customer Types
- **Commercial**: B2B customers with pricing tiers, credit limits
- **Non-Commercial**: Individual customers, typically simpler pricing
### Inventory Management
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
- All transactions tracked in `InventoryTransaction` entity
- Reorder points trigger low-stock alerts
### Equipment & Maintenance
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
**Maintenance Priority**: Low, Normal, High, Critical
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
## Configuration Files
### Web Application (src/PowderCoating.Web/appsettings.json)
```json
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
},
"AppSettings": {
"CompanyName": "Powder Coating Logix",
"DefaultQuoteValidityDays": 30,
"DefaultPaymentTerms": "Net 30",
"TaxRate": 0.0,
"Currency": "USD",
"TrialPeriodDays": 7,
"QuoteApprovalTokenDays": 30
},
"AI": {
"Anthropic": {
"ApiKey": "your-anthropic-api-key-here"
}
},
"SendGrid": { ... },
"Stripe": { ... },
"Storage": { ... }
}
```
**AI uses Anthropic Codex Sonnet 4.6** (`Codex-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
### API (src/PowderCoating.Api/appsettings.json)
```json
{
"JwtSettings": {
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
"Issuer": "PowderCoatingAPI",
"Audience": "PowderCoatingMobileApp",
"ExpirationMinutes": 1440
}
}
```
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
Default ports:
- HTTPS: 58461
- HTTP: 58462
## Authentication & Authorization
### System Roles
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
- **Administrator**: Company admin
- **Manager**: Operations management
- **Employee**: Create/edit jobs and quotes
- **ShopFloor**: Update job status
- **ReadOnly**: View-only access
### Custom Authorization Policies
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
- `RequireAdministratorRole`
- `CanManageJobs`
- `CanManageInventory`
- `CanManageUsers`
- `CanViewData`
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
### JWT Authentication (API Only)
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
## AutoMapper Configuration
AutoMapper is registered as singleton in `Program.cs`:
```csharp
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
{
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
}).CreateMapper());
```
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
## Logging
Serilog configured to write:
- Console (structured logs)
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
Access via constructor injection:
```csharp
private readonly ILogger<ExampleController> _logger;
```
## Common Development Tasks
### Adding a New Entity
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
2. Add DbSet to `ApplicationDbContext`
3. Register repository property in `IUnitOfWork` interface
4. Add lazy-loaded property in `UnitOfWork` implementation
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
### Adding a New Controller
1. Create DTOs in `Application/DTOs/`
2. Create AutoMapper profile in `Application/Mappings/`
3. Create controller in `Web/Controllers/`
4. Create views in `Web/Views/[ControllerName]/`
5. Add navigation link in `Views/Shared/_Layout.cshtml`
### Working with Soft Deletes
```csharp
// Soft delete (sets IsDeleted = true)
await _unitOfWork.Customers.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
// Physical delete (use sparingly)
await _unitOfWork.Customers.DeleteAsync(entity);
await _unitOfWork.CompleteAsync();
// Include deleted records in query
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
```
### Bypassing Multi-Tenancy Filters
Only for SuperAdmin users:
```csharp
// See all companies' data
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
```
## Implemented Modules
All modules below are fully implemented with controllers, views, and migrations applied.
### Operations
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Codex Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
### Inventory & Purchasing
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
- **Vendors** — supplier management, payment terms, linked to inventory items
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
### Shop Management
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
- **Catalog Items** — pre-priced service catalog with default prices
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
### Billing & Payments
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
### Platform (SuperAdmin only)
- **Platform Users** — create/manage SuperAdmin accounts
- **Companies** — view/manage all tenant companies
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
### Other
- **Help Center** — 14 fully-written articles at `Views/Help/`
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
- **Gift Certificates** — issue, redeem, track balance
- **Announcements** — platform-wide announcements to tenants
### Key Pricing Rules
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:**
1. Add the property to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional
### Branding
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
## Known Issues
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
## File Upload Configuration
Limits defined in `AppConstants.cs`:
- Max file size: 10 MB
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
## Testing Strategy
- **Unit Tests**: Test business logic in isolation
- **Integration Tests**: Test full request pipeline with test database
- Use xUnit framework
- Mock `IUnitOfWork` in unit tests
## Extending the System
### Adding AI Features
AI uses Anthropic Codex Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`.
1. Create service interface in `Application/Interfaces/`
2. Implement in `Infrastructure/Services/` calling the Anthropic client
3. Inject into controllers via DI
### SignalR Hubs
Two hubs are already implemented and mapped in `Program.cs`:
- `NotificationHub``/hubs/notifications` (company-scoped push notifications)
- `ShopHub``/hubs/shop` (real-time shop floor updates)
To add a new hub:
1. Create hub class in `Web/Hubs/`
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
3. Use JavaScript client in views to connect
### Adding API Endpoints
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
2. Return `ActionResult<T>` types
3. Use `[Authorize]` for protected endpoints
4. Document with XML comments for Swagger
## Project Dependencies
Key NuGet packages:
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
- **Entity Framework Core 8.0.11**: ORM and database access
- **Serilog.AspNetCore 8.0.3**: Structured logging
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
## Security Considerations
- Password requirements: 8+ chars, uppercase, lowercase, digit
- HTTPS enforced in production
- SQL injection prevented by EF Core parameterization
- XSS protection via Razor encoding
- CSRF tokens on all forms (automatic with ASP.NET Core)
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
## Active design work
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/AGENTS.md` for that work.
+21
View File
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:**
1. Add the property to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional
### Branding
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<!--
NCalc2 2.1.0 -> Antlr4 4.6.4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 pulls in
old package versions that trigger NU1605 downgrade warnings when publishing for linux-x64.
These are harmless false positives — .NET 8 supplies all of these natively at runtime.
Suppressing NU1605 here is cleaner than pinning every affected transitive package individually.
-->
<NoWarn>$(NoWarn);NU1605</NoWarn>
</PropertyGroup>
</Project>
Vendored
+49 -1
View File
@@ -24,6 +24,17 @@ pipeline {
}
}
stage('Test') {
steps {
bat 'dotnet test tests\\PowderCoating.UnitTests --no-build -c Release --logger "trx;LogFileName=results.trx" --results-directory TestResults'
}
post {
always {
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
}
}
}
stage('Run Migrations') {
steps {
bat 'dotnet tool install --global dotnet-ef 2>nul || dotnet tool update --global dotnet-ef 2>nul'
@@ -42,7 +53,18 @@ pipeline {
stage('Deploy to Azure') {
steps {
bat 'powershell -Command "Add-Type -Assembly System.IO.Compression.FileSystem; if (Test-Path deploy.zip) { Remove-Item deploy.zip }; [System.IO.Compression.ZipFile]::CreateFromDirectory(\'publish\', \'deploy.zip\')"'
powershell '''
Add-Type -Assembly System.IO.Compression.FileSystem
if (Test-Path deploy.zip) { Remove-Item deploy.zip }
$publishDir = (Resolve-Path "publish").Path
$zip = [System.IO.Compression.ZipFile]::Open("deploy.zip", "Create")
Get-ChildItem -Path $publishDir -Recurse -File | ForEach-Object {
$entryName = $_.FullName.Substring($publishDir.Length + 1).Replace("\\", "/")
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, $entryName, "Optimal") | Out-Null
}
$zip.Dispose()
Write-Host "deploy.zip created with forward-slash entry paths"
'''
withCredentials([azureServicePrincipal(
credentialsId: 'azure-pcl',
subscriptionIdVariable: 'AZ_SUB_ID',
@@ -57,6 +79,32 @@ pipeline {
}
}
}
stage('Health Check') {
steps {
powershell '''
$url = "https://app.powdercoatinglogix.com/"
$timeout = 180
$elapsed = 0
Write-Host "Polling $url for up to $timeout seconds..."
do {
Start-Sleep -Seconds 10
$elapsed += 10
try {
$r = Invoke-WebRequest $url -UseBasicParsing -TimeoutSec 10
if ($r.StatusCode -lt 400) {
Write-Host "App responded HTTP $($r.StatusCode) after ${elapsed}s"
exit 0
}
} catch {
Write-Host "[${elapsed}s] Not yet responding: $_"
}
} while ($elapsed -lt $timeout)
Write-Error "App did not come healthy within $timeout seconds"
exit 1
'''
}
}
}
post {
-193
View File
@@ -1,193 +0,0 @@
Shop Management App TO DO List
==============================
-Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Fix up approve/decline messages between customer and user on quote approval feature
Done and need testing
=====================
-Add sorting to all grids
-Add searching to all grids
-Add Workers to the system
-Allow jobs to be assigned to workers
-Add Shop Job Board display to show in the shop
-Added quick edits on a few pages
-Fix job page customer drop down. It's only showing business names and not individuals
-Add country drop down on customer edit and add pages
-Conver customer once quote accepted not complete
-Add Dashboard page
-Low Inventory Warnings display
-Overdue jobs
-Todays Jobs
-new quote button on customer page doesnt pre-select customer
-Add customer job history page
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
-Date format can be customized per profile
-Timezone can now be changed per profile
-Have company logos stored in the database with the other company information
-Add Company Name under Logo in navbar
-Make logo bigger
-Update create quote page to show names of individual customers or company name depending on which type it is
-Validate that the company has entered operating costs before allowing the quote page to be loaded
-Make phone number and contact required on quotes for new prospects
-Move the create quote button to the right side of the screen to be consistent with other pages
-Add setting for tax exempt on customer
-Added tax certificate upload as well
-Add shop minimum to quoting system and company settings
-Add Rush Job Fee (customizable in company settings)
-Add ability to quick change the status on the job listing and record who changed the status.
-Deactivating company should NOT allow any users to login at all.
-Allow superadmins to create company users/managers
-Add a print quote button
-Add a download PDF button for quotes
-When adding users, also create worker records
-Add quick update to all view pages
-Add Mobile layouts
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
-Add ability to upload job photos
-Allow photo uploads for jobs before and after photos
-Added Log Viewer
-Added Seed Data option for super admins that will assist during testing
-Add an item list with prices for repeat parts and such
-Add manual data seeding that super admins can use to seed a company one at a time if needed
-Add Log Viewer for Super Admins
-Quotes cleaned up quite a bit and calculations and style changed
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
-Job Items now appear on the Job Screen with the line items from the quote
-Job items can be edited
-Add a way to convert a quote into a job
-Add multiple item types to add to a quote
1. Pre-Defined item that we can choose from our product list
2. Batch items where we enter the square footage manually as well as the quantity
-Add Quickbooks import for customers and price lists (Desktop and Online)
-Custom Order Powder not saving or displaying properly on quuote page
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
-Add Randomizer Wheel
-Add Quickbooks format export for
-Customers
-Product Catalog
-Invoices
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
-Add a Shop Supplies operating cost that will be used on quote calculations
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
-Update everywhere that uses tax rate to read and use this setting
-Add ability to export a full price list for known items
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
-Update the inventory screen to not duplicate color name fields and the like
-Add option for metric system
-Add Bulk Upload for
-Powder
-Product Catalog
-Customer Data
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
-Allow shops to put employee days off on the calendar as well
-Fix and Verify user permissions are honored
-Run a full security check on the application
-Add support for multi stage coatings on an item
-Fix Seed Data routines to track errors better and continue past error imports
-Add ability to complete a job and enter actual time and materials used
-Add export for all data to CSV format
-Check calendar resizing with the browser. It's off a bit
-Add ability to apply discounts
-Remove powder from inventory when completeing a job
-Add color change ability for appointment types
-Add code to honor the rush charge on a quote
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
-Add ability to add sq ft to product catalog item for powder estimation
-Add better UX design for validation errors and such
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
Option 2: Add inline validation (more complex)
- Show error messages right next to the problematic field
- Better UX but requires adding validation spans to dynamic fields
Option 3: Toast notifications (requires new library/code)
- Modern popup notifications for success/error messages
- Would need to add a toast library (like Toastr) and wire it up
-Add Import/Export for Company Settings
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
-Allow recurring scheduled maintenance
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
-Make sure maintenence shows on the calendar list view.
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
-Add support for multiple ovens in operating costs
-Display oven selected on quote and job detail pages
-Allow user to choose an oven on a quote, and have it follow through to a job
-Check for any old and outdated code and DB fields!
-Add ability to email a quote
-Add email capabilities
-Add search on super admin companies screen
-Set limits on job photos per app tier
-Check subscription signup page to make sure the selected subscription is actually saved.
-Don't seed the product catalog on a new user
-Check to make sure subscription page has quotes and all fields on it
-Allow customizing of the quote sheets and invoices (If we do them)
-Add feature to allow username changes
-Fix quickbooks imports based on files Colton sent
-Add thicker border around input fields to signify they are text boxes
-Check to make sure emails get sent when a quote is created
-Add buttons to send emails manually if needed
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
-Add ability to modify items on jobs
-Swap quoting page to use modals to add items to segregate it a bit better.
-Build account ledger/transaction summary view
-Add security for financial pages
-Allow opening balances for accounts
-Create P&L and other reports
-Allow receipet upload on expenses and bills
-Download PDF for invoices throws and error
-Emailing invoice doesn't seem to trigger
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
-When doing anything that sends mail, prompt the user to alert them a message will be sent
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
-Check Workflow steps in wizard, might need adjusting
-Account Summary, use permanent alert for info message at bottom
-Add steps so that the new user can customize the data lookups and re-order them
-Reorder menu to work better
-Add ability to print a job invoice once completed
-Add ability to email a job invoice
-Integrate invoicing/billing/reports
-Add customer portal to approve quotes from a link for now. We can do a full login later.
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
-Add tagging options for quotes and jobs (user driven)
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
description of WHY the user should add some tags though.
-Inventory forecasting might be worth looking into
-Build some AI powder usage predictions into the system
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
-Update dashboard to show some $$$ fields
-Update Setup Wizard
-Update the Setup Checklist
-Modify system to keep running balances of all accounts
- Make sure ALL job updates refresh the Shop Display
-Add multiple item types to add to a quote
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
-Integration with stripe or square to accept online paymens from our users customers.
-AI Assistant for help
-Allow customer filtering on quotes and jobs
-New job page blanks when validation fails
-Can we keep track of which users have completed the setup wizard?
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Add images to product catalog items for easily identification of parts
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add Oven and Add Blasting Setup don't work in Setup Wizard
-When scanning inventory QR Code, there is no cancel button
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
-Add SMS capabilities
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
-Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
Logins:
rich@r2r.com/Ragz2Richs123!
rich@cannon.com/Cannon123!
-193
View File
@@ -1,193 +0,0 @@
Shop Management App TO DO List
==============================
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
-Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
-Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Fix up approve/decline messages between customer and user on quote approval feature
Done and need testing
=====================
-Add sorting to all grids
-Add searching to all grids
-Add Workers to the system
-Allow jobs to be assigned to workers
-Add Shop Job Board display to show in the shop
-Added quick edits on a few pages
-Fix job page customer drop down. It's only showing business names and not individuals
-Add country drop down on customer edit and add pages
-Conver customer once quote accepted not complete
-Add Dashboard page
-Low Inventory Warnings display
-Overdue jobs
-Todays Jobs
-new quote button on customer page doesnt pre-select customer
-Add customer job history page
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
-Date format can be customized per profile
-Timezone can now be changed per profile
-Have company logos stored in the database with the other company information
-Add Company Name under Logo in navbar
-Make logo bigger
-Update create quote page to show names of individual customers or company name depending on which type it is
-Validate that the company has entered operating costs before allowing the quote page to be loaded
-Make phone number and contact required on quotes for new prospects
-Move the create quote button to the right side of the screen to be consistent with other pages
-Add setting for tax exempt on customer
-Added tax certificate upload as well
-Add shop minimum to quoting system and company settings
-Add Rush Job Fee (customizable in company settings)
-Add ability to quick change the status on the job listing and record who changed the status.
-Deactivating company should NOT allow any users to login at all.
-Allow superadmins to create company users/managers
-Add a print quote button
-Add a download PDF button for quotes
-When adding users, also create worker records
-Add quick update to all view pages
-Add Mobile layouts
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
-Add ability to upload job photos
-Allow photo uploads for jobs before and after photos
-Added Log Viewer
-Added Seed Data option for super admins that will assist during testing
-Add an item list with prices for repeat parts and such
-Add manual data seeding that super admins can use to seed a company one at a time if needed
-Add Log Viewer for Super Admins
-Quotes cleaned up quite a bit and calculations and style changed
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
-Job Items now appear on the Job Screen with the line items from the quote
-Job items can be edited
-Add a way to convert a quote into a job
-Add multiple item types to add to a quote
1. Pre-Defined item that we can choose from our product list
2. Batch items where we enter the square footage manually as well as the quantity
-Add Quickbooks import for customers and price lists (Desktop and Online)
-Custom Order Powder not saving or displaying properly on quuote page
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
-Add Randomizer Wheel
-Add Quickbooks format export for
-Customers
-Product Catalog
-Invoices
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
-Add a Shop Supplies operating cost that will be used on quote calculations
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
-Update everywhere that uses tax rate to read and use this setting
-Add ability to export a full price list for known items
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
-Update the inventory screen to not duplicate color name fields and the like
-Add option for metric system
-Add Bulk Upload for
-Powder
-Product Catalog
-Customer Data
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
-Allow shops to put employee days off on the calendar as well
-Fix and Verify user permissions are honored
-Run a full security check on the application
-Add support for multi stage coatings on an item
-Fix Seed Data routines to track errors better and continue past error imports
-Add ability to complete a job and enter actual time and materials used
-Add export for all data to CSV format
-Check calendar resizing with the browser. It's off a bit
-Add ability to apply discounts
-Remove powder from inventory when completeing a job
-Add color change ability for appointment types
-Add code to honor the rush charge on a quote
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
-Add ability to add sq ft to product catalog item for powder estimation
-Add better UX design for validation errors and such
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
Option 2: Add inline validation (more complex)
- Show error messages right next to the problematic field
- Better UX but requires adding validation spans to dynamic fields
Option 3: Toast notifications (requires new library/code)
- Modern popup notifications for success/error messages
- Would need to add a toast library (like Toastr) and wire it up
-Add Import/Export for Company Settings
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
-Allow recurring scheduled maintenance
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
-Make sure maintenence shows on the calendar list view.
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
-Add support for multiple ovens in operating costs
-Display oven selected on quote and job detail pages
-Allow user to choose an oven on a quote, and have it follow through to a job
-Check for any old and outdated code and DB fields!
-Add ability to email a quote
-Add email capabilities
-Add search on super admin companies screen
-Set limits on job photos per app tier
-Check subscription signup page to make sure the selected subscription is actually saved.
-Don't seed the product catalog on a new user
-Check to make sure subscription page has quotes and all fields on it
-Allow customizing of the quote sheets and invoices (If we do them)
-Add feature to allow username changes
-Fix quickbooks imports based on files Colton sent
-Add thicker border around input fields to signify they are text boxes
-Check to make sure emails get sent when a quote is created
-Add buttons to send emails manually if needed
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
-Add ability to modify items on jobs
-Swap quoting page to use modals to add items to segregate it a bit better.
-Build account ledger/transaction summary view
-Add security for financial pages
-Allow opening balances for accounts
-Create P&L and other reports
-Allow receipet upload on expenses and bills
-Download PDF for invoices throws and error
-Emailing invoice doesn't seem to trigger
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
-When doing anything that sends mail, prompt the user to alert them a message will be sent
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
-Check Workflow steps in wizard, might need adjusting
-Account Summary, use permanent alert for info message at bottom
-Add steps so that the new user can customize the data lookups and re-order them
-Reorder menu to work better
-Add ability to print a job invoice once completed
-Add ability to email a job invoice
-Integrate invoicing/billing/reports
-Add customer portal to approve quotes from a link for now. We can do a full login later.
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
-Add tagging options for quotes and jobs (user driven)
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
description of WHY the user should add some tags though.
-Inventory forecasting might be worth looking into
-Build some AI powder usage predictions into the system
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
-Update dashboard to show some $$$ fields
-Update Setup Wizard
-Update the Setup Checklist
-Modify system to keep running balances of all accounts
- Make sure ALL job updates refresh the Shop Display
-Add multiple item types to add to a quote
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
-Integration with stripe or square to accept online paymens from our users customers.
-AI Assistant for help
-Allow customer filtering on quotes and jobs
-New job page blanks when validation fails
-Can we keep track of which users have completed the setup wizard?
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Add images to product catalog items for easily identification of parts
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add Oven and Add Blasting Setup don't work in Setup Wizard
-When scanning inventory QR Code, there is no cancel button
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
-Add SMS capabilities
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
Logins:
rich@r2r.com/Ragz2Richs123!
rich@cannon.com/Cannon123!
+47
View File
@@ -0,0 +1,47 @@
I looked through the reporting code, and I do not see a dedicated sales tax report.
What exists today:
- The report menu has Financial Summary, AR Aging, Balance Sheet, and Sales & Income, but no sales-tax-specific report card in /Y:/PCC/
PowderCoatingApp/src/PowderCoating.Web/Views/Reports/Landing.cshtml:147 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/
Landing.cshtml:211.
- The reporting service only exposes four finance reports in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/
IFinancialReportService.cs:13 through /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs:22.
- Sales & Income does surface tax, but only as a total and per-invoice column in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/
Services/FinancialReportService.cs:406, /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs:419,
and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/SalesAndIncome.cshtml:268.
- The good news is the underlying data is already there: invoices store TaxAmount and SalesTaxAccountId in /Y:/PCC/PowderCoatingApp/src/
PowderCoating.Core/Entities/Invoice.cs:23 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/Invoice.cs:55, and the chart of
accounts seeds 2200 Sales Tax Payable in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs:59.
Plan Id recommend:
1. Add a new Sales Tax report under Reports > Finance with date range filters, matching the existing report pattern in /Y:/PCC/PowderCoatingApp/
src/PowderCoating.Web/Controllers/ReportsController.cs:993.
2. Build a SalesTaxReportDto plus GetSalesTaxReportAsync(...) in the reporting interface/service.
3. Phase 1 report contents:
- Total taxable sales
- Total non-taxed sales
- Total sales tax billed
- Breakdown by sales-tax liability account
- Breakdown by month
- Invoice detail grid: invoice date, invoice #, customer, subtotal, tax %, tax amount, total, amount paid, balance due, status, tax account
4. Add PDF export, and probably CSV too, since this is the kind of report people hand to accountants.
5. Put a report card on the Reports landing page and gate it the same way as the other accounting reports.
Two important caveats before we build it:
- The biggest decision is whether this should be invoice-basis or cash-basis. The current model naturally supports invoice-basis first.
- Refunds and credit memos do not appear to store a separate tax adjustment value in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/
Refund.cs:1 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/CreditMemo.cs:1, so phase 1 would be best framed as “sales tax billed
on invoices,” with net tax adjustments as a phase 2 enhancement.
My recommendation is:
- Build phase 1 as an invoice-basis Sales Tax Liability report
- Label zero-tax invoices as non-taxed sales
- Add cash-basis and refund/credit tax adjustments later if you need filing-grade accuracy for more complex cases
If you want, I can turn this plan into the actual report next.
+32
View File
@@ -0,0 +1,32 @@
I looked through the reporting code, and I do not see a dedicated sales tax report.
What exists today:
- The report menu has Financial Summary, AR Aging, Balance Sheet, and Sales & Income, but no sales-tax-specific report card in /Y:/PCC/
PowderCoatingApp/src/PowderCoating.Web/Views/Reports/Landing.cshtml:147 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/
Landing.cshtml:211.
- The reporting service only exposes four finance reports in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/
IFinancialReportService.cs:13 through /Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Interfaces/IFinancialReportService.cs:22.
- Sales & Income does surface tax, but only as a total and per-invoice column in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/
Services/FinancialReportService.cs:406, /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/FinancialReportService.cs:419,
and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Reports/SalesAndIncome.cshtml:268.
- The good news is the underlying data is already there: invoices store TaxAmount and SalesTaxAccountId in /Y:/PCC/PowderCoatingApp/src/
PowderCoating.Core/Entities/Invoice.cs:23 and /Y:/PCC/PowderCoatingApp/src/PowderCoating.Core/Entities/Invoice.cs:55, and the chart of
accounts seeds 2200 Sales Tax Payable in /Y:/PCC/PowderCoatingApp/src/PowderCoating.Infrastructure/Services/SeedDataService.Accounts.cs:59.
Plan Id recommend:
1. Add a new Sales Tax report under Reports > Finance with date range filters, matching the existing report pattern in /Y:/PCC/PowderCoatingApp/
src/PowderCoating.Web/Controllers/ReportsController.cs:993.
2. Build a SalesTaxReportDto plus GetSalesTaxReportAsync(...) in the reporting interface/service.
3. Phase 1 report contents:
- Total taxable sales
- Total non-taxed sales
- Total sales tax billed
- Breakdown by sales-tax liability account
- Breakdown by month
- Invoice detail grid: invoice date, invoice #, customer, subtotal, tax %, tax amount, total, amount paid, balance due, status, tax account
4. Add PDF export, and probably CSV too, since this is the kind of report people hand to accountants.
5. Put a report card on the Reports landing page and gate it the same way as the other accounting reports.
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../playwright/cli.js" "$@"
else
exec node "$basedir/../playwright/cli.js" "$@"
fi
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../playwright-core/cli.js" "$@"
else
exec node "$basedir/../playwright-core/cli.js" "$@"
fi
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\playwright-core\cli.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../playwright-core/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../playwright-core/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../playwright-core/cli.js" $args
} else {
& "node$exe" "$basedir/../playwright-core/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+17
View File
@@ -0,0 +1,17 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\playwright\cli.js" %*
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../playwright/cli.js" $args
} else {
& "$basedir/node$exe" "$basedir/../playwright/cli.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../playwright/cli.js" $args
} else {
& "node$exe" "$basedir/../playwright/cli.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
+38
View File
@@ -0,0 +1,38 @@
{
"name": "web-scraping",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Playwright
Copyright (c) Microsoft Corporation
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
+3
View File
@@ -0,0 +1,3 @@
# playwright-core
This package contains the no-browser flavor of [Playwright](http://github.com/microsoft/playwright).
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
{
"comment": "Do not edit this file, use utils/roll_browser.js",
"browsers": [
{
"name": "chromium",
"revision": "1217",
"installByDefault": true,
"browserVersion": "147.0.7727.15",
"title": "Chrome for Testing"
},
{
"name": "chromium-headless-shell",
"revision": "1217",
"installByDefault": true,
"browserVersion": "147.0.7727.15",
"title": "Chrome Headless Shell"
},
{
"name": "chromium-tip-of-tree",
"revision": "1417",
"installByDefault": false,
"browserVersion": "148.0.7755.0",
"title": "Chrome Canary for Testing"
},
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1417",
"installByDefault": false,
"browserVersion": "148.0.7755.0",
"title": "Chrome Canary Headless Shell"
},
{
"name": "firefox",
"revision": "1511",
"installByDefault": true,
"browserVersion": "148.0.2",
"title": "Firefox"
},
{
"name": "firefox-beta",
"revision": "1505",
"installByDefault": false,
"browserVersion": "148.0b9",
"title": "Firefox Beta"
},
{
"name": "webkit",
"revision": "2272",
"installByDefault": true,
"revisionOverrides": {
"mac14": "2251",
"mac14-arm64": "2251",
"debian11-x64": "2105",
"debian11-arm64": "2105",
"ubuntu20.04-x64": "2092",
"ubuntu20.04-arm64": "2092"
},
"browserVersion": "26.4",
"title": "WebKit"
},
{
"name": "ffmpeg",
"revision": "1011",
"installByDefault": true,
"revisionOverrides": {
"mac12": "1010",
"mac12-arm64": "1010"
}
},
{
"name": "winldd",
"revision": "1007",
"installByDefault": false
},
{
"name": "android",
"revision": "1001",
"installByDefault": false
}
]
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { program } = require('./lib/cli/programWithTestStub');
program.parse(process.argv);
+17
View File
@@ -0,0 +1,17 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './types/types';
+32
View File
@@ -0,0 +1,32 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const minimumMajorNodeVersion = 18;
const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const [major] = [+semver[0]];
if (major < minimumMajorNodeVersion) {
console.error(
'You are running Node.js ' +
currentNodeVersion +
'.\n' +
`Playwright requires Node.js ${minimumMajorNodeVersion} or higher. \n` +
'Please update your version of Node.js.'
);
process.exit(1);
}
module.exports = require('./lib/inprocess');
+28
View File
@@ -0,0 +1,28 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import playwright from './index.js';
export const chromium = playwright.chromium;
export const firefox = playwright.firefox;
export const webkit = playwright.webkit;
export const selectors = playwright.selectors;
export const devices = playwright.devices;
export const errors = playwright.errors;
export const request = playwright.request;
export const _electron = playwright._electron;
export const _android = playwright._android;
export default playwright;
@@ -0,0 +1,65 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var androidServerImpl_exports = {};
__export(androidServerImpl_exports, {
AndroidServerLauncherImpl: () => AndroidServerLauncherImpl
});
module.exports = __toCommonJS(androidServerImpl_exports);
var import_playwrightServer = require("./remote/playwrightServer");
var import_playwright = require("./server/playwright");
var import_crypto = require("./server/utils/crypto");
var import_utilsBundle = require("./utilsBundle");
var import_progress = require("./server/progress");
class AndroidServerLauncherImpl {
async launchServer(options = {}) {
const playwright = (0, import_playwright.createPlaywright)({ sdkLanguage: "javascript", isServer: true });
const controller = new import_progress.ProgressController();
let devices = await controller.run((progress) => playwright.android.devices(progress, {
host: options.adbHost,
port: options.adbPort,
omitDriverInstall: options.omitDriverInstall
}));
if (devices.length === 0)
throw new Error("No devices found");
if (options.deviceSerialNumber) {
devices = devices.filter((d) => d.serial === options.deviceSerialNumber);
if (devices.length === 0)
throw new Error(`No device with serial number '${options.deviceSerialNumber}' was found`);
}
if (devices.length > 1)
throw new Error(`More than one device found. Please specify deviceSerialNumber`);
const device = devices[0];
const path = options.wsPath ? options.wsPath.startsWith("/") ? options.wsPath : `/${options.wsPath}` : `/${(0, import_crypto.createGuid)()}`;
const server = new import_playwrightServer.PlaywrightServer({ mode: "launchServer", path, maxConnections: 1, preLaunchedAndroidDevice: device });
const wsEndpoint = await server.listen(options.port, options.host);
const browserServer = new import_utilsBundle.ws.EventEmitter();
browserServer.wsEndpoint = () => wsEndpoint;
browserServer.close = () => device.close();
browserServer.kill = () => device.close();
device.on("close", () => {
server.close();
browserServer.emit("close");
});
return browserServer;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
AndroidServerLauncherImpl
});
@@ -0,0 +1,77 @@
"use strict";
if (process.env.PW_INSTRUMENT_MODULES) {
const Module = require("module");
const originalLoad = Module._load;
const root = { name: "<root>", selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
let current = root;
const stack = [];
Module._load = function(request, _parent, _isMain) {
const node = { name: request, selfMs: 0, totalMs: 0, childrenMs: 0, children: [] };
current.children.push(node);
stack.push(current);
current = node;
const start = performance.now();
let result;
try {
result = originalLoad.apply(this, arguments);
} catch (e) {
current = stack.pop();
current.children.pop();
throw e;
}
const duration = performance.now() - start;
node.totalMs = duration;
node.selfMs = Math.max(0, duration - node.childrenMs);
current = stack.pop();
current.childrenMs += duration;
return result;
};
process.on("exit", () => {
function printTree(node, prefix, isLast, lines2, depth) {
if (node.totalMs < 1 && depth > 0)
return;
const connector = depth === 0 ? "" : isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
const time = `${node.totalMs.toFixed(1).padStart(8)}ms`;
const self = node.children.length ? ` (self: ${node.selfMs.toFixed(1)}ms)` : "";
lines2.push(`${time} ${prefix}${connector}${node.name}${self}`);
const childPrefix = prefix + (depth === 0 ? "" : isLast ? " " : "\u2502 ");
const sorted2 = node.children.slice().sort((a, b) => b.totalMs - a.totalMs);
for (let i = 0; i < sorted2.length; i++)
printTree(sorted2[i], childPrefix, i === sorted2.length - 1, lines2, depth + 1);
}
let totalModules = 0;
function count(n) {
totalModules++;
n.children.forEach(count);
}
root.children.forEach(count);
const lines = [];
const sorted = root.children.slice().sort((a, b) => b.totalMs - a.totalMs);
for (let i = 0; i < sorted.length; i++)
printTree(sorted[i], "", i === sorted.length - 1, lines, 0);
const totalMs = root.children.reduce((s, c) => s + c.totalMs, 0);
process.stderr.write(`
--- Module load tree: ${totalModules} modules, ${totalMs.toFixed(0)}ms total ---
` + lines.join("\n") + "\n");
const flat = /* @__PURE__ */ new Map();
function gather(n) {
const existing = flat.get(n.name);
if (existing) {
existing.selfMs += n.selfMs;
existing.totalMs += n.totalMs;
existing.count++;
} else {
flat.set(n.name, { selfMs: n.selfMs, totalMs: n.totalMs, count: 1 });
}
n.children.forEach(gather);
}
root.children.forEach(gather);
const top50 = [...flat.entries()].sort((a, b) => b[1].selfMs - a[1].selfMs).slice(0, 50);
const flatLines = top50.map(
([mod, { selfMs, totalMs: totalMs2, count: count2 }]) => `${selfMs.toFixed(1).padStart(8)}ms self ${totalMs2.toFixed(1).padStart(8)}ms total (x${String(count2).padStart(3)}) ${mod}`
);
process.stderr.write(`
--- Top 50 modules by self time ---
` + flatLines.join("\n") + "\n");
});
}
@@ -0,0 +1,120 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var browserServerImpl_exports = {};
__export(browserServerImpl_exports, {
BrowserServerLauncherImpl: () => BrowserServerLauncherImpl
});
module.exports = __toCommonJS(browserServerImpl_exports);
var import_playwrightServer = require("./remote/playwrightServer");
var import_helper = require("./server/helper");
var import_playwright = require("./server/playwright");
var import_crypto = require("./server/utils/crypto");
var import_debug = require("./server/utils/debug");
var import_stackTrace = require("./utils/isomorphic/stackTrace");
var import_time = require("./utils/isomorphic/time");
var import_utilsBundle = require("./utilsBundle");
var validatorPrimitives = __toESM(require("./protocol/validatorPrimitives"));
var import_progress = require("./server/progress");
class BrowserServerLauncherImpl {
constructor(browserName) {
this._browserName = browserName;
}
async launchServer(options = {}) {
const playwright = (0, import_playwright.createPlaywright)({ sdkLanguage: "javascript", isServer: true });
const metadata = { id: "", startTime: 0, endTime: 0, type: "Internal", method: "", params: {}, log: [], internal: true };
const validatorContext = {
tChannelImpl: (names, arg, path2) => {
throw new validatorPrimitives.ValidationError(`${path2}: channels are not expected in launchServer`);
},
binary: "buffer",
isUnderTest: import_debug.isUnderTest
};
let launchOptions = {
...options,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : void 0,
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
env: options.env ? envObjectToArray(options.env) : void 0,
timeout: options.timeout ?? import_time.DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT
};
let browser;
try {
const controller = new import_progress.ProgressController(metadata);
browser = await controller.run(async (progress) => {
if (options._userDataDir !== void 0) {
const validator = validatorPrimitives.scheme["BrowserTypeLaunchPersistentContextParams"];
launchOptions = validator({ ...launchOptions, userDataDir: options._userDataDir }, "", validatorContext);
const context = await playwright[this._browserName].launchPersistentContext(progress, options._userDataDir, launchOptions);
return context._browser;
} else {
const validator = validatorPrimitives.scheme["BrowserTypeLaunchParams"];
launchOptions = validator(launchOptions, "", validatorContext);
return await playwright[this._browserName].launch(progress, launchOptions, toProtocolLogger(options.logger));
}
});
} catch (e) {
const log = import_helper.helper.formatBrowserLogs(metadata.log);
(0, import_stackTrace.rewriteErrorMessage)(e, `${e.message} Failed to launch browser.${log}`);
throw e;
}
const path = options.wsPath ? options.wsPath.startsWith("/") ? options.wsPath : `/${options.wsPath}` : `/${(0, import_crypto.createGuid)()}`;
const server = new import_playwrightServer.PlaywrightServer({ mode: options._sharedBrowser ? "launchServerShared" : "launchServer", path, maxConnections: Infinity, preLaunchedBrowser: browser });
const wsEndpoint = await server.listen(options.port, options.host);
const browserServer = new import_utilsBundle.ws.EventEmitter();
browserServer.process = () => browser.options.browserProcess.process;
browserServer.wsEndpoint = () => wsEndpoint;
browserServer.close = () => browser.options.browserProcess.close();
browserServer[Symbol.asyncDispose] = browserServer.close;
browserServer.kill = () => browser.options.browserProcess.kill();
browserServer._disconnectForTest = () => server.close();
browserServer._userDataDirForTest = browser._userDataDirForTest;
browser.options.browserProcess.onclose = (exitCode, signal) => {
server.close();
browserServer.emit("close", exitCode, signal);
};
return browserServer;
}
}
function toProtocolLogger(logger) {
return logger ? (direction, message) => {
if (logger.isEnabled("protocol", "verbose"))
logger.log("protocol", "verbose", (direction === "send" ? "SEND \u25BA " : "\u25C0 RECV ") + JSON.stringify(message), [], {});
} : void 0;
}
function envObjectToArray(env) {
const result = [];
for (const name in env) {
if (!Object.is(env[name], void 0))
result.push({ name, value: String(env[name]) });
}
return result;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
BrowserServerLauncherImpl
});
@@ -0,0 +1,308 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var browserActions_exports = {};
__export(browserActions_exports, {
codegen: () => codegen,
open: () => open,
pdf: () => pdf,
screenshot: () => screenshot
});
module.exports = __toCommonJS(browserActions_exports);
var import_fs = __toESM(require("fs"));
var import_os = __toESM(require("os"));
var import_path = __toESM(require("path"));
var playwright = __toESM(require("../.."));
var import_utils = require("../utils");
var import_utilsBundle = require("../utilsBundle");
async function launchContext(options, extraOptions) {
validateOptions(options);
const browserType = lookupBrowserType(options);
const launchOptions = extraOptions;
if (options.channel)
launchOptions.channel = options.channel;
launchOptions.handleSIGINT = false;
const contextOptions = (
// Copy the device descriptor since we have to compare and modify the options.
options.device ? { ...playwright.devices[options.device] } : {}
);
if (!extraOptions.headless)
contextOptions.deviceScaleFactor = import_os.default.platform() === "darwin" ? 2 : 1;
if (browserType.name() === "webkit" && process.platform === "linux") {
delete contextOptions.hasTouch;
delete contextOptions.isMobile;
}
if (contextOptions.isMobile && browserType.name() === "firefox")
contextOptions.isMobile = void 0;
if (options.blockServiceWorkers)
contextOptions.serviceWorkers = "block";
if (options.proxyServer) {
launchOptions.proxy = {
server: options.proxyServer
};
if (options.proxyBypass)
launchOptions.proxy.bypass = options.proxyBypass;
}
if (options.viewportSize) {
try {
const [width, height] = options.viewportSize.split(",").map((n) => +n);
if (isNaN(width) || isNaN(height))
throw new Error("bad values");
contextOptions.viewport = { width, height };
} catch (e) {
throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"');
}
}
if (options.geolocation) {
try {
const [latitude, longitude] = options.geolocation.split(",").map((n) => parseFloat(n.trim()));
contextOptions.geolocation = {
latitude,
longitude
};
} catch (e) {
throw new Error('Invalid geolocation format, should be "lat,long". For example --geolocation="37.819722,-122.478611"');
}
contextOptions.permissions = ["geolocation"];
}
if (options.userAgent)
contextOptions.userAgent = options.userAgent;
if (options.lang)
contextOptions.locale = options.lang;
if (options.colorScheme)
contextOptions.colorScheme = options.colorScheme;
if (options.timezone)
contextOptions.timezoneId = options.timezone;
if (options.loadStorage)
contextOptions.storageState = options.loadStorage;
if (options.ignoreHttpsErrors)
contextOptions.ignoreHTTPSErrors = true;
if (options.saveHar) {
contextOptions.recordHar = { path: import_path.default.resolve(process.cwd(), options.saveHar), mode: "minimal" };
if (options.saveHarGlob)
contextOptions.recordHar.urlFilter = options.saveHarGlob;
contextOptions.serviceWorkers = "block";
}
let browser;
let context;
if (options.userDataDir) {
context = await browserType.launchPersistentContext(options.userDataDir, { ...launchOptions, ...contextOptions });
browser = context.browser();
} else {
browser = await browserType.launch(launchOptions);
context = await browser.newContext(contextOptions);
}
let closingBrowser = false;
async function closeBrowser() {
if (closingBrowser)
return;
closingBrowser = true;
if (options.saveStorage)
await context.storageState({ path: options.saveStorage }).catch((e) => null);
if (options.saveHar)
await context.close();
await browser.close();
}
context.on("page", (page) => {
page.on("dialog", () => {
});
page.on("close", () => {
const hasPage = browser.contexts().some((context2) => context2.pages().length > 0);
if (hasPage)
return;
closeBrowser().catch(() => {
});
});
});
process.on("SIGINT", async () => {
await closeBrowser();
(0, import_utils.gracefullyProcessExitDoNotHang)(130);
});
const timeout = options.timeout ? parseInt(options.timeout, 10) : 0;
context.setDefaultTimeout(timeout);
context.setDefaultNavigationTimeout(timeout);
delete launchOptions.headless;
delete launchOptions.executablePath;
delete launchOptions.handleSIGINT;
delete contextOptions.deviceScaleFactor;
return { browser, browserName: browserType.name(), context, contextOptions, launchOptions, closeBrowser };
}
async function openPage(context, url) {
let page = context.pages()[0];
if (!page)
page = await context.newPage();
if (url) {
if (import_fs.default.existsSync(url))
url = "file://" + import_path.default.resolve(url);
else if (!url.startsWith("http") && !url.startsWith("file://") && !url.startsWith("about:") && !url.startsWith("data:"))
url = "http://" + url;
await page.goto(url);
}
return page;
}
async function open(options, url) {
const { context } = await launchContext(options, { headless: !!process.env.PWTEST_CLI_HEADLESS, executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH });
await context._exposeConsoleApi();
await openPage(context, url);
}
async function codegen(options, url) {
const { target: language, output: outputFile, testIdAttribute: testIdAttributeName } = options;
const tracesDir = import_path.default.join(import_os.default.tmpdir(), `playwright-recorder-trace-${Date.now()}`);
const { context, browser, launchOptions, contextOptions, closeBrowser } = await launchContext(options, {
headless: !!process.env.PWTEST_CLI_HEADLESS,
executablePath: process.env.PWTEST_CLI_EXECUTABLE_PATH,
tracesDir
});
const donePromise = new import_utils.ManualPromise();
maybeSetupTestHooks(browser, closeBrowser, donePromise);
import_utilsBundle.dotenv.config({ path: "playwright.env" });
await context._enableRecorder({
language,
launchOptions,
contextOptions,
device: options.device,
saveStorage: options.saveStorage,
mode: "recording",
testIdAttributeName,
outputFile: outputFile ? import_path.default.resolve(outputFile) : void 0,
handleSIGINT: false
});
await openPage(context, url);
donePromise.resolve();
}
async function maybeSetupTestHooks(browser, closeBrowser, donePromise) {
if (!process.env.PWTEST_CLI_IS_UNDER_TEST)
return;
const logs = [];
require("playwright-core/lib/utilsBundle").debug.log = (...args) => {
const line = require("util").format(...args) + "\n";
logs.push(line);
process.stderr.write(line);
};
browser.on("disconnected", () => {
const hasCrashLine = logs.some((line) => line.includes("process did exit:") && !line.includes("process did exit: exitCode=0, signal=null"));
if (hasCrashLine) {
process.stderr.write("Detected browser crash.\n");
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
}
});
const close = async () => {
await donePromise;
await closeBrowser();
};
if (process.env.PWTEST_CLI_EXIT_AFTER_TIMEOUT) {
setTimeout(close, +process.env.PWTEST_CLI_EXIT_AFTER_TIMEOUT);
return;
}
let stdin = "";
process.stdin.on("data", (data) => {
stdin += data.toString();
if (stdin.startsWith("exit")) {
process.stdin.destroy();
close();
}
});
}
async function waitForPage(page, captureOptions) {
if (captureOptions.waitForSelector) {
console.log(`Waiting for selector ${captureOptions.waitForSelector}...`);
await page.waitForSelector(captureOptions.waitForSelector);
}
if (captureOptions.waitForTimeout) {
console.log(`Waiting for timeout ${captureOptions.waitForTimeout}...`);
await page.waitForTimeout(parseInt(captureOptions.waitForTimeout, 10));
}
}
async function screenshot(options, captureOptions, url, path2) {
const { context } = await launchContext(options, { headless: true });
console.log("Navigating to " + url);
const page = await openPage(context, url);
await waitForPage(page, captureOptions);
console.log("Capturing screenshot into " + path2);
await page.screenshot({ path: path2, fullPage: !!captureOptions.fullPage });
await page.close();
}
async function pdf(options, captureOptions, url, path2) {
if (options.browser !== "chromium")
throw new Error("PDF creation is only working with Chromium");
const { context } = await launchContext({ ...options, browser: "chromium" }, { headless: true });
console.log("Navigating to " + url);
const page = await openPage(context, url);
await waitForPage(page, captureOptions);
console.log("Saving as pdf into " + path2);
await page.pdf({ path: path2, format: captureOptions.paperFormat });
await page.close();
}
function lookupBrowserType(options) {
let name = options.browser;
if (options.device) {
const device = playwright.devices[options.device];
name = device.defaultBrowserType;
}
let browserType;
switch (name) {
case "chromium":
browserType = playwright.chromium;
break;
case "webkit":
browserType = playwright.webkit;
break;
case "firefox":
browserType = playwright.firefox;
break;
case "cr":
browserType = playwright.chromium;
break;
case "wk":
browserType = playwright.webkit;
break;
case "ff":
browserType = playwright.firefox;
break;
}
if (browserType)
return browserType;
import_utilsBundle.program.help();
}
function validateOptions(options) {
if (options.device && !(options.device in playwright.devices)) {
const lines = [`Device descriptor not found: '${options.device}', available devices are:`];
for (const name in playwright.devices)
lines.push(` "${name}"`);
throw new Error(lines.join("\n"));
}
if (options.colorScheme && !["light", "dark"].includes(options.colorScheme))
throw new Error('Invalid color scheme, should be one of "light", "dark"');
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
codegen,
open,
pdf,
screenshot
});
@@ -0,0 +1,98 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var driver_exports = {};
__export(driver_exports, {
launchBrowserServer: () => launchBrowserServer,
printApiJson: () => printApiJson,
runDriver: () => runDriver,
runServer: () => runServer
});
module.exports = __toCommonJS(driver_exports);
var import_fs = __toESM(require("fs"));
var playwright = __toESM(require("../.."));
var import_pipeTransport = require("../server/utils/pipeTransport");
var import_playwrightServer = require("../remote/playwrightServer");
var import_server = require("../server");
var import_processLauncher = require("../server/utils/processLauncher");
function printApiJson() {
console.log(JSON.stringify(require("../../api.json")));
}
function runDriver() {
const dispatcherConnection = new import_server.DispatcherConnection();
new import_server.RootDispatcher(dispatcherConnection, async (rootScope, { sdkLanguage }) => {
const playwright2 = (0, import_server.createPlaywright)({ sdkLanguage });
return new import_server.PlaywrightDispatcher(rootScope, playwright2);
});
const transport = new import_pipeTransport.PipeTransport(process.stdout, process.stdin);
transport.onmessage = (message) => dispatcherConnection.dispatch(JSON.parse(message));
const isJavaScriptLanguageBinding = !process.env.PW_LANG_NAME || process.env.PW_LANG_NAME === "javascript";
const replacer = !isJavaScriptLanguageBinding && String.prototype.toWellFormed ? (key, value) => {
if (typeof value === "string")
return value.toWellFormed();
return value;
} : void 0;
dispatcherConnection.onmessage = (message) => transport.send(JSON.stringify(message, replacer));
transport.onclose = () => {
dispatcherConnection.onmessage = () => {
};
(0, import_processLauncher.gracefullyProcessExitDoNotHang)(0);
};
process.on("SIGINT", () => {
});
}
async function runServer(options) {
const {
port,
host,
path = "/",
maxConnections = Infinity,
extension,
artifactsDir
} = options;
const server = new import_playwrightServer.PlaywrightServer({ mode: extension ? "extension" : "default", path, maxConnections, artifactsDir });
const wsEndpoint = await server.listen(port, host);
process.on("exit", () => server.close().catch(console.error));
console.log("Listening on " + wsEndpoint);
process.stdin.on("close", () => (0, import_processLauncher.gracefullyProcessExitDoNotHang)(0));
}
async function launchBrowserServer(browserName, configFile) {
let options = {};
if (configFile)
options = JSON.parse(import_fs.default.readFileSync(configFile).toString());
const browserType = playwright[browserName];
const server = await browserType.launchServer(options);
console.log(server.wsEndpoint());
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
launchBrowserServer,
printApiJson,
runDriver,
runServer
});
@@ -0,0 +1,171 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var installActions_exports = {};
__export(installActions_exports, {
installBrowsers: () => installBrowsers,
installDeps: () => installDeps,
markDockerImage: () => markDockerImage,
registry: () => import_server.registry,
uninstallBrowsers: () => uninstallBrowsers
});
module.exports = __toCommonJS(installActions_exports);
var import_path = __toESM(require("path"));
var import_server = require("../server");
var import_utils = require("../utils");
var import_utils2 = require("../utils");
var import_ascii = require("../server/utils/ascii");
function printInstalledBrowsers(browsers) {
const browserPaths = /* @__PURE__ */ new Set();
for (const browser of browsers)
browserPaths.add(browser.browserPath);
console.log(` Browsers:`);
for (const browserPath of [...browserPaths].sort())
console.log(` ${browserPath}`);
console.log(` References:`);
const references = /* @__PURE__ */ new Set();
for (const browser of browsers)
references.add(browser.referenceDir);
for (const reference of [...references].sort())
console.log(` ${reference}`);
}
function printGroupedByPlaywrightVersion(browsers) {
const dirToVersion = /* @__PURE__ */ new Map();
for (const browser of browsers) {
if (dirToVersion.has(browser.referenceDir))
continue;
const packageJSON = require(import_path.default.join(browser.referenceDir, "package.json"));
const version = packageJSON.version;
dirToVersion.set(browser.referenceDir, version);
}
const groupedByPlaywrightMinorVersion = /* @__PURE__ */ new Map();
for (const browser of browsers) {
const version = dirToVersion.get(browser.referenceDir);
let entries = groupedByPlaywrightMinorVersion.get(version);
if (!entries) {
entries = [];
groupedByPlaywrightMinorVersion.set(version, entries);
}
entries.push(browser);
}
const sortedVersions = [...groupedByPlaywrightMinorVersion.keys()].sort((a, b) => {
const aComponents = a.split(".");
const bComponents = b.split(".");
const aMajor = parseInt(aComponents[0], 10);
const bMajor = parseInt(bComponents[0], 10);
if (aMajor !== bMajor)
return aMajor - bMajor;
const aMinor = parseInt(aComponents[1], 10);
const bMinor = parseInt(bComponents[1], 10);
if (aMinor !== bMinor)
return aMinor - bMinor;
return aComponents.slice(2).join(".").localeCompare(bComponents.slice(2).join("."));
});
for (const version of sortedVersions) {
console.log(`
Playwright version: ${version}`);
printInstalledBrowsers(groupedByPlaywrightMinorVersion.get(version));
}
}
async function markDockerImage(dockerImageNameTemplate) {
(0, import_utils2.assert)(dockerImageNameTemplate, "dockerImageNameTemplate is required");
await (0, import_server.writeDockerVersion)(dockerImageNameTemplate);
}
async function installBrowsers(args, options) {
if ((0, import_utils.isLikelyNpxGlobal)()) {
console.error((0, import_ascii.wrapInASCIIBox)([
`WARNING: It looks like you are running 'npx playwright install' without first`,
`installing your project's dependencies.`,
``,
`To avoid unexpected behavior, please install your dependencies first, and`,
`then run Playwright's install command:`,
``,
` npm install`,
` npx playwright install`,
``,
`If your project does not yet depend on Playwright, first install the`,
`applicable npm package (most commonly @playwright/test), and`,
`then run Playwright's install command to download the browsers:`,
``,
` npm install @playwright/test`,
` npx playwright install`,
``
].join("\n"), 1));
}
if (options.shell === false && options.onlyShell)
throw new Error(`Only one of --no-shell and --only-shell can be specified`);
const shell = options.shell === false ? "no" : options.onlyShell ? "only" : void 0;
const executables = import_server.registry.resolveBrowsers(args, { shell });
if (options.withDeps)
await import_server.registry.installDeps(executables, !!options.dryRun);
if (options.dryRun && options.list)
throw new Error(`Only one of --dry-run and --list can be specified`);
if (options.dryRun) {
for (const executable of executables) {
console.log(import_server.registry.calculateDownloadTitle(executable));
console.log(` Install location: ${executable.directory ?? "<system>"}`);
if (executable.downloadURLs?.length) {
const [url, ...fallbacks] = executable.downloadURLs;
console.log(` Download url: ${url}`);
for (let i = 0; i < fallbacks.length; ++i)
console.log(` Download fallback ${i + 1}: ${fallbacks[i]}`);
}
console.log(``);
}
} else if (options.list) {
const browsers = await import_server.registry.listInstalledBrowsers();
printGroupedByPlaywrightVersion(browsers);
} else {
await import_server.registry.install(executables, { force: options.force });
await import_server.registry.validateHostRequirementsForExecutablesIfNeeded(executables, process.env.PW_LANG_NAME || "javascript").catch((e) => {
e.name = "Playwright Host validation warning";
console.error(e);
});
}
}
async function uninstallBrowsers(options) {
delete process.env.PLAYWRIGHT_SKIP_BROWSER_GC;
await import_server.registry.uninstall(!!options.all).then(({ numberOfBrowsersLeft }) => {
if (!options.all && numberOfBrowsersLeft > 0) {
console.log("Successfully uninstalled Playwright browsers for the current Playwright installation.");
console.log(`There are still ${numberOfBrowsersLeft} browsers left, used by other Playwright installations.
To uninstall Playwright browsers for all installations, re-run with --all flag.`);
}
});
}
async function installDeps(args, options) {
await import_server.registry.installDeps(import_server.registry.resolveBrowsers(args, {}), !!options.dryRun);
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
installBrowsers,
installDeps,
markDockerImage,
registry,
uninstallBrowsers
});
@@ -0,0 +1,225 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var program_exports = {};
__export(program_exports, {
program: () => import_utilsBundle2.program
});
module.exports = __toCommonJS(program_exports);
var import_bootstrap = require("../bootstrap");
var import_utils = require("../utils");
var import_traceCli = require("../tools/trace/traceCli");
var import_utilsBundle = require("../utilsBundle");
var import_utilsBundle2 = require("../utilsBundle");
const packageJSON = require("../../package.json");
import_utilsBundle.program.version("Version " + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version)).name(buildBasePlaywrightCLICommand(process.env.PW_LANG_NAME));
import_utilsBundle.program.command("mark-docker-image [dockerImageNameTemplate]", { hidden: true }).description("mark docker image").allowUnknownOption(true).action(async function(dockerImageNameTemplate) {
const { markDockerImage } = require("./installActions");
markDockerImage(dockerImageNameTemplate).catch(logErrorAndExit);
});
commandWithOpenOptions("open [url]", "open page in browser specified via -b, --browser", []).action(async function(url, options) {
const { open } = require("./browserActions");
open(options, url).catch(logErrorAndExit);
}).addHelpText("afterAll", `
Examples:
$ open
$ open -b webkit https://example.com`);
commandWithOpenOptions(
"codegen [url]",
"open page and generate code for user actions",
[
["-o, --output <file name>", "saves the generated script to a file"],
["--target <language>", `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java, java-junit`, codegenId()],
["--test-id-attribute <attributeName>", "use the specified attribute to generate data test ID selectors"]
]
).action(async function(url, options) {
const { codegen } = require("./browserActions");
await codegen(options, url);
}).addHelpText("afterAll", `
Examples:
$ codegen
$ codegen --target=python
$ codegen -b webkit https://example.com`);
import_utilsBundle.program.command("install [browser...]").description("ensure browsers necessary for this version of Playwright are installed").option("--with-deps", "install system dependencies for browsers").option("--dry-run", "do not execute installation, only print information").option("--list", "prints list of browsers from all playwright installations").option("--force", "force reinstall of already installed browsers").option("--only-shell", "only install headless shell when installing chromium").option("--no-shell", "do not install chromium headless shell").action(async function(args, options) {
try {
const { installBrowsers } = require("./installActions");
await installBrowsers(args, options);
} catch (e) {
console.log(`Failed to install browsers
${e}`);
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
}
}).addHelpText("afterAll", `
Examples:
- $ install
Install default browsers.
- $ install chrome firefox
Install custom browsers, supports chromium, firefox, webkit, chromium-headless-shell.`);
import_utilsBundle.program.command("uninstall").description("Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.").option("--all", "Removes all browsers used by any Playwright installation from the system.").action(async (options) => {
const { uninstallBrowsers } = require("./installActions");
uninstallBrowsers(options).catch(logErrorAndExit);
});
import_utilsBundle.program.command("install-deps [browser...]").description("install dependencies necessary to run browsers (will ask for sudo permissions)").option("--dry-run", "Do not execute installation commands, only print them").action(async function(args, options) {
try {
const { installDeps } = require("./installActions");
await installDeps(args, options);
} catch (e) {
console.log(`Failed to install browser dependencies
${e}`);
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
}
}).addHelpText("afterAll", `
Examples:
- $ install-deps
Install dependencies for default browsers.
- $ install-deps chrome firefox
Install dependencies for specific browsers, supports chromium, firefox, webkit, chromium-headless-shell.`);
const browsers = [
{ alias: "cr", name: "Chromium", type: "chromium" },
{ alias: "ff", name: "Firefox", type: "firefox" },
{ alias: "wk", name: "WebKit", type: "webkit" }
];
for (const { alias, name, type } of browsers) {
commandWithOpenOptions(`${alias} [url]`, `open page in ${name}`, []).action(async function(url, options) {
const { open } = require("./browserActions");
open({ ...options, browser: type }, url).catch(logErrorAndExit);
}).addHelpText("afterAll", `
Examples:
$ ${alias} https://example.com`);
}
commandWithOpenOptions(
"screenshot <url> <filename>",
"capture a page screenshot",
[
["--wait-for-selector <selector>", "wait for selector before taking a screenshot"],
["--wait-for-timeout <timeout>", "wait for timeout in milliseconds before taking a screenshot"],
["--full-page", "whether to take a full page screenshot (entire scrollable area)"]
]
).action(async function(url, filename, command) {
const { screenshot } = require("./browserActions");
screenshot(command, command, url, filename).catch(logErrorAndExit);
}).addHelpText("afterAll", `
Examples:
$ screenshot -b webkit https://example.com example.png`);
commandWithOpenOptions(
"pdf <url> <filename>",
"save page as pdf",
[
["--paper-format <format>", "paper format: Letter, Legal, Tabloid, Ledger, A0, A1, A2, A3, A4, A5, A6"],
["--wait-for-selector <selector>", "wait for given selector before saving as pdf"],
["--wait-for-timeout <timeout>", "wait for given timeout in milliseconds before saving as pdf"]
]
).action(async function(url, filename, options) {
const { pdf } = require("./browserActions");
pdf(options, options, url, filename).catch(logErrorAndExit);
}).addHelpText("afterAll", `
Examples:
$ pdf https://example.com example.pdf`);
import_utilsBundle.program.command("run-driver", { hidden: true }).action(async function(options) {
const { runDriver } = require("./driver");
runDriver();
});
import_utilsBundle.program.command("run-server", { hidden: true }).option("--port <port>", "Server port").option("--host <host>", "Server host").option("--path <path>", "Endpoint Path", "/").option("--max-clients <maxClients>", "Maximum clients").option("--mode <mode>", 'Server mode, either "default" or "extension"').option("--artifacts-dir <artifactsDir>", "Artifacts directory").action(async function(options) {
const { runServer } = require("./driver");
runServer({
port: options.port ? +options.port : void 0,
host: options.host,
path: options.path,
maxConnections: options.maxClients ? +options.maxClients : Infinity,
extension: options.mode === "extension" || !!process.env.PW_EXTENSION_MODE,
artifactsDir: options.artifactsDir
}).catch(logErrorAndExit);
});
import_utilsBundle.program.command("print-api-json", { hidden: true }).action(async function(options) {
const { printApiJson } = require("./driver");
printApiJson();
});
import_utilsBundle.program.command("launch-server", { hidden: true }).requiredOption("--browser <browserName>", 'Browser name, one of "chromium", "firefox" or "webkit"').option("--config <path-to-config-file>", "JSON file with launchServer options").action(async function(options) {
const { launchBrowserServer } = require("./driver");
launchBrowserServer(options.browser, options.config);
});
import_utilsBundle.program.command("show-trace [trace]").option("-b, --browser <browserType>", "browser to use, one of cr, chromium, ff, firefox, wk, webkit", "chromium").option("-h, --host <host>", "Host to serve trace on; specifying this option opens trace in a browser tab").option("-p, --port <port>", "Port to serve trace on, 0 for any free port; specifying this option opens trace in a browser tab").option("--stdin", "Accept trace URLs over stdin to update the viewer").description("show trace viewer").action(async function(trace, options) {
if (options.browser === "cr")
options.browser = "chromium";
if (options.browser === "ff")
options.browser = "firefox";
if (options.browser === "wk")
options.browser = "webkit";
const openOptions = {
host: options.host,
port: +options.port,
isServer: !!options.stdin
};
const { runTraceInBrowser, runTraceViewerApp } = require("../server/trace/viewer/traceViewer");
if (options.port !== void 0 || options.host !== void 0)
runTraceInBrowser(trace, openOptions).catch(logErrorAndExit);
else
runTraceViewerApp(trace, options.browser, openOptions).catch(logErrorAndExit);
}).addHelpText("afterAll", `
Examples:
$ show-trace
$ show-trace https://example.com/trace.zip`);
(0, import_traceCli.addTraceCommands)(import_utilsBundle.program, logErrorAndExit);
import_utilsBundle.program.command("cli", { hidden: true }).allowExcessArguments(true).allowUnknownOption(true).action(async (options) => {
const { program: cliProgram } = require("../tools/cli-client/program");
process.argv.splice(process.argv.indexOf("cli"), 1);
cliProgram().catch(logErrorAndExit);
});
function logErrorAndExit(e) {
if (process.env.PWDEBUGIMPL)
console.error(e);
else
console.error(e.name + ": " + e.message);
(0, import_utils.gracefullyProcessExitDoNotHang)(1);
}
function codegenId() {
return process.env.PW_LANG_NAME || "playwright-test";
}
function commandWithOpenOptions(command, description, options) {
let result = import_utilsBundle.program.command(command).description(description);
for (const option of options)
result = result.option(option[0], ...option.slice(1));
return result.option("-b, --browser <browserType>", "browser to use, one of cr, chromium, ff, firefox, wk, webkit", "chromium").option("--block-service-workers", "block service workers").option("--channel <channel>", 'Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc').option("--color-scheme <scheme>", 'emulate preferred color scheme, "light" or "dark"').option("--device <deviceName>", 'emulate device, for example "iPhone 11"').option("--geolocation <coordinates>", 'specify geolocation coordinates, for example "37.819722,-122.478611"').option("--ignore-https-errors", "ignore https errors").option("--load-storage <filename>", "load context storage state from the file, previously saved with --save-storage").option("--lang <language>", 'specify language / locale, for example "en-GB"').option("--proxy-server <proxy>", 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"').option("--proxy-bypass <bypass>", 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"').option("--save-har <filename>", "save HAR file with all network activity at the end").option("--save-har-glob <glob pattern>", "filter entries in the HAR by matching url against this glob pattern").option("--save-storage <filename>", "save context storage state at the end, for later use with --load-storage").option("--timezone <time zone>", 'time zone to emulate, for example "Europe/Rome"').option("--timeout <timeout>", "timeout for Playwright actions in milliseconds, no timeout by default").option("--user-agent <ua string>", "specify user agent string").option("--user-data-dir <directory>", "use the specified user data directory instead of a new context").option("--viewport-size <size>", 'specify browser viewport size in pixels, for example "1280, 720"');
}
function buildBasePlaywrightCLICommand(cliTargetLang) {
switch (cliTargetLang) {
case "python":
return `playwright`;
case "java":
return `mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="...options.."`;
case "csharp":
return `pwsh bin/Debug/netX/playwright.ps1`;
default: {
const packageManagerCommand = (0, import_utils.getPackageManagerExecCommand)();
return `${packageManagerCommand} playwright`;
}
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
program
});
@@ -0,0 +1,74 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var programWithTestStub_exports = {};
__export(programWithTestStub_exports, {
program: () => import_program2.program
});
module.exports = __toCommonJS(programWithTestStub_exports);
var import_processLauncher = require("../server/utils/processLauncher");
var import_utils = require("../utils");
var import_program = require("./program");
var import_program2 = require("./program");
function printPlaywrightTestError(command) {
const packages = [];
for (const pkg of ["playwright", "playwright-chromium", "playwright-firefox", "playwright-webkit"]) {
try {
require.resolve(pkg);
packages.push(pkg);
} catch (e) {
}
}
if (!packages.length)
packages.push("playwright");
const packageManager = (0, import_utils.getPackageManager)();
if (packageManager === "yarn") {
console.error(`Please install @playwright/test package before running "yarn playwright ${command}"`);
console.error(` yarn remove ${packages.join(" ")}`);
console.error(" yarn add -D @playwright/test");
} else if (packageManager === "pnpm") {
console.error(`Please install @playwright/test package before running "pnpm exec playwright ${command}"`);
console.error(` pnpm remove ${packages.join(" ")}`);
console.error(" pnpm add -D @playwright/test");
} else {
console.error(`Please install @playwright/test package before running "npx playwright ${command}"`);
console.error(` npm uninstall ${packages.join(" ")}`);
console.error(" npm install -D @playwright/test");
}
}
const kExternalPlaywrightTestCommands = [
["test", "Run tests with Playwright Test."],
["show-report", "Show Playwright Test HTML report."],
["merge-reports", "Merge Playwright Test Blob reports"]
];
function addExternalPlaywrightTestCommands() {
for (const [command, description] of kExternalPlaywrightTestCommands) {
const playwrightTest = import_program.program.command(command).allowUnknownOption(true).allowExcessArguments(true);
playwrightTest.description(`${description} Available in @playwright/test package.`);
playwrightTest.action(async () => {
printPlaywrightTestError(command);
(0, import_processLauncher.gracefullyProcessExitDoNotHang)(1);
});
}
}
if (!process.env.PW_LANG_NAME)
addExternalPlaywrightTestCommands();
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
program
});
@@ -0,0 +1,361 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var android_exports = {};
__export(android_exports, {
Android: () => Android,
AndroidDevice: () => AndroidDevice,
AndroidInput: () => AndroidInput,
AndroidSocket: () => AndroidSocket,
AndroidWebView: () => AndroidWebView
});
module.exports = __toCommonJS(android_exports);
var import_eventEmitter = require("./eventEmitter");
var import_browserContext = require("./browserContext");
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
var import_events = require("./events");
var import_waiter = require("./waiter");
var import_timeoutSettings = require("./timeoutSettings");
var import_rtti = require("../utils/isomorphic/rtti");
var import_time = require("../utils/isomorphic/time");
var import_timeoutRunner = require("../utils/isomorphic/timeoutRunner");
var import_connect = require("./connect");
class Android extends import_channelOwner.ChannelOwner {
static from(android) {
return android._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform);
}
setDefaultTimeout(timeout) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
async devices(options = {}) {
const { devices } = await this._channel.devices(options);
return devices.map((d) => AndroidDevice.from(d));
}
async launchServer(options = {}) {
if (!this._serverLauncher)
throw new Error("Launching server is not supported");
return await this._serverLauncher.launchServer(options);
}
async connect(endpoint, options = {}) {
return await this._wrapApiCall(async () => {
const deadline = options.timeout ? (0, import_time.monotonicTime)() + options.timeout : 0;
const headers = { "x-playwright-browser": "android", ...options.headers };
const connectParams = { endpoint, headers, slowMo: options.slowMo, timeout: options.timeout || 0 };
const connection = await (0, import_connect.connectToEndpoint)(this._connection, connectParams);
let device;
connection.on("close", () => {
device?._didClose();
});
const result = await (0, import_timeoutRunner.raceAgainstDeadline)(async () => {
const playwright = await connection.initializePlaywright();
if (!playwright._initializer.preConnectedAndroidDevice) {
connection.close();
throw new Error("Malformed endpoint. Did you use Android.launchServer method?");
}
device = AndroidDevice.from(playwright._initializer.preConnectedAndroidDevice);
device._shouldCloseConnectionOnClose = true;
device.on(import_events.Events.AndroidDevice.Close, () => connection.close());
return device;
}, deadline);
if (!result.timedOut) {
return result.result;
} else {
connection.close();
throw new Error(`Timeout ${options.timeout}ms exceeded`);
}
});
}
}
class AndroidDevice extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._webViews = /* @__PURE__ */ new Map();
this._shouldCloseConnectionOnClose = false;
this._android = parent;
this.input = new AndroidInput(this);
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform, parent._timeoutSettings);
this._channel.on("webViewAdded", ({ webView }) => this._onWebViewAdded(webView));
this._channel.on("webViewRemoved", ({ socketName }) => this._onWebViewRemoved(socketName));
this._channel.on("close", () => this._didClose());
}
static from(androidDevice) {
return androidDevice._object;
}
_onWebViewAdded(webView) {
const view = new AndroidWebView(this, webView);
this._webViews.set(webView.socketName, view);
this.emit(import_events.Events.AndroidDevice.WebView, view);
}
_onWebViewRemoved(socketName) {
const view = this._webViews.get(socketName);
this._webViews.delete(socketName);
if (view)
view.emit(import_events.Events.AndroidWebView.Close);
}
setDefaultTimeout(timeout) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
serial() {
return this._initializer.serial;
}
model() {
return this._initializer.model;
}
webViews() {
return [...this._webViews.values()];
}
async webView(selector, options) {
const predicate = (v) => {
if (selector.pkg)
return v.pkg() === selector.pkg;
if (selector.socketName)
return v._socketName() === selector.socketName;
return false;
};
const webView = [...this._webViews.values()].find(predicate);
if (webView)
return webView;
return await this.waitForEvent("webview", { ...options, predicate });
}
async wait(selector, options = {}) {
await this._channel.wait({ androidSelector: toSelectorChannel(selector), ...options, timeout: this._timeoutSettings.timeout(options) });
}
async fill(selector, text, options = {}) {
await this._channel.fill({ androidSelector: toSelectorChannel(selector), text, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async press(selector, key, options = {}) {
await this.tap(selector, options);
await this.input.press(key);
}
async tap(selector, options = {}) {
await this._channel.tap({ androidSelector: toSelectorChannel(selector), ...options, timeout: this._timeoutSettings.timeout(options) });
}
async drag(selector, dest, options = {}) {
await this._channel.drag({ androidSelector: toSelectorChannel(selector), dest, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async fling(selector, direction, options = {}) {
await this._channel.fling({ androidSelector: toSelectorChannel(selector), direction, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async longTap(selector, options = {}) {
await this._channel.longTap({ androidSelector: toSelectorChannel(selector), ...options, timeout: this._timeoutSettings.timeout(options) });
}
async pinchClose(selector, percent, options = {}) {
await this._channel.pinchClose({ androidSelector: toSelectorChannel(selector), percent, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async pinchOpen(selector, percent, options = {}) {
await this._channel.pinchOpen({ androidSelector: toSelectorChannel(selector), percent, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async scroll(selector, direction, percent, options = {}) {
await this._channel.scroll({ androidSelector: toSelectorChannel(selector), direction, percent, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async swipe(selector, direction, percent, options = {}) {
await this._channel.swipe({ androidSelector: toSelectorChannel(selector), direction, percent, ...options, timeout: this._timeoutSettings.timeout(options) });
}
async info(selector) {
return (await this._channel.info({ androidSelector: toSelectorChannel(selector) })).info;
}
async screenshot(options = {}) {
const { binary } = await this._channel.screenshot();
if (options.path)
await this._platform.fs().promises.writeFile(options.path, binary);
return binary;
}
async [Symbol.asyncDispose]() {
await this.close();
}
async close() {
try {
if (this._shouldCloseConnectionOnClose)
this._connection.close();
else
await this._channel.close();
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
_didClose() {
this.emit(import_events.Events.AndroidDevice.Close, this);
}
async shell(command) {
const { result } = await this._channel.shell({ command });
return result;
}
async open(command) {
return AndroidSocket.from((await this._channel.open({ command })).socket);
}
async installApk(file, options) {
await this._channel.installApk({ file: await loadFile(this._platform, file), args: options && options.args });
}
async push(file, path, options) {
await this._channel.push({ file: await loadFile(this._platform, file), path, mode: options ? options.mode : void 0 });
}
async launchBrowser(options = {}) {
const contextOptions = await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options);
const result = await this._channel.launchBrowser(contextOptions);
const context = import_browserContext.BrowserContext.from(result.context);
const selectors = this._android._playwright.selectors;
selectors._contextsForSelectors.add(context);
context.once(import_events.Events.BrowserContext.Close, () => selectors._contextsForSelectors.delete(context));
await context._initializeHarFromOptions(options.recordHar);
return context;
}
async waitForEvent(event, optionsOrPredicate = {}) {
return await this._wrapApiCall(async () => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = import_waiter.Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== import_events.Events.AndroidDevice.Close)
waiter.rejectOnEvent(this, import_events.Events.AndroidDevice.Close, () => new import_errors.TargetClosedError());
const result = await waiter.waitForEvent(this, event, predicate);
waiter.dispose();
return result;
});
}
}
class AndroidSocket extends import_channelOwner.ChannelOwner {
static from(androidDevice) {
return androidDevice._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._channel.on("data", ({ data }) => this.emit(import_events.Events.AndroidSocket.Data, data));
this._channel.on("close", () => this.emit(import_events.Events.AndroidSocket.Close));
}
async write(data) {
await this._channel.write({ data });
}
async close() {
await this._channel.close();
}
async [Symbol.asyncDispose]() {
await this.close();
}
}
async function loadFile(platform, file) {
if ((0, import_rtti.isString)(file))
return await platform.fs().promises.readFile(file);
return file;
}
class AndroidInput {
constructor(device) {
this._device = device;
}
async type(text) {
await this._device._channel.inputType({ text });
}
async press(key) {
await this._device._channel.inputPress({ key });
}
async tap(point) {
await this._device._channel.inputTap({ point });
}
async swipe(from, segments, steps) {
await this._device._channel.inputSwipe({ segments, steps });
}
async drag(from, to, steps) {
await this._device._channel.inputDrag({ from, to, steps });
}
}
function toSelectorChannel(selector) {
const {
checkable,
checked,
clazz,
clickable,
depth,
desc,
enabled,
focusable,
focused,
hasChild,
hasDescendant,
longClickable,
pkg,
res,
scrollable,
selected,
text
} = selector;
const toRegex = (value) => {
if (value === void 0)
return void 0;
if ((0, import_rtti.isRegExp)(value))
return value.source;
return "^" + value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d") + "$";
};
return {
checkable,
checked,
clazz: toRegex(clazz),
pkg: toRegex(pkg),
desc: toRegex(desc),
res: toRegex(res),
text: toRegex(text),
clickable,
depth,
enabled,
focusable,
focused,
hasChild: hasChild ? { androidSelector: toSelectorChannel(hasChild.selector) } : void 0,
hasDescendant: hasDescendant ? { androidSelector: toSelectorChannel(hasDescendant.selector), maxDepth: hasDescendant.maxDepth } : void 0,
longClickable,
scrollable,
selected
};
}
class AndroidWebView extends import_eventEmitter.EventEmitter {
constructor(device, data) {
super(device._platform);
this._device = device;
this._data = data;
}
pid() {
return this._data.pid;
}
pkg() {
return this._data.pkg;
}
_socketName() {
return this._data.socketName;
}
async page() {
if (!this._pagePromise)
this._pagePromise = this._fetchPage();
return await this._pagePromise;
}
async _fetchPage() {
const { context } = await this._device._channel.connectToWebView({ socketName: this._data.socketName });
return import_browserContext.BrowserContext.from(context).pages()[0];
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Android,
AndroidDevice,
AndroidInput,
AndroidSocket,
AndroidWebView
});
@@ -0,0 +1,137 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var api_exports = {};
__export(api_exports, {
APIRequest: () => import_fetch.APIRequest,
APIRequestContext: () => import_fetch.APIRequestContext,
APIResponse: () => import_fetch.APIResponse,
Android: () => import_android.Android,
AndroidDevice: () => import_android.AndroidDevice,
AndroidInput: () => import_android.AndroidInput,
AndroidSocket: () => import_android.AndroidSocket,
AndroidWebView: () => import_android.AndroidWebView,
Browser: () => import_browser.Browser,
BrowserContext: () => import_browserContext.BrowserContext,
BrowserType: () => import_browserType.BrowserType,
CDPSession: () => import_cdpSession.CDPSession,
Clock: () => import_clock.Clock,
ConsoleMessage: () => import_consoleMessage.ConsoleMessage,
Coverage: () => import_coverage.Coverage,
Debugger: () => import_debugger.Debugger,
Dialog: () => import_dialog.Dialog,
Download: () => import_download.Download,
Electron: () => import_electron.Electron,
ElectronApplication: () => import_electron.ElectronApplication,
ElementHandle: () => import_elementHandle.ElementHandle,
FileChooser: () => import_fileChooser.FileChooser,
Frame: () => import_frame.Frame,
FrameLocator: () => import_locator.FrameLocator,
JSHandle: () => import_jsHandle.JSHandle,
Keyboard: () => import_input.Keyboard,
Locator: () => import_locator.Locator,
Mouse: () => import_input.Mouse,
Page: () => import_page.Page,
Playwright: () => import_playwright.Playwright,
Request: () => import_network.Request,
Response: () => import_network.Response,
Route: () => import_network.Route,
Selectors: () => import_selectors.Selectors,
TimeoutError: () => import_errors.TimeoutError,
Touchscreen: () => import_input.Touchscreen,
Tracing: () => import_tracing.Tracing,
Video: () => import_video.Video,
WebError: () => import_webError.WebError,
WebSocket: () => import_network.WebSocket,
WebSocketRoute: () => import_network.WebSocketRoute,
Worker: () => import_worker.Worker
});
module.exports = __toCommonJS(api_exports);
var import_android = require("./android");
var import_browser = require("./browser");
var import_browserContext = require("./browserContext");
var import_browserType = require("./browserType");
var import_clock = require("./clock");
var import_consoleMessage = require("./consoleMessage");
var import_coverage = require("./coverage");
var import_debugger = require("./debugger");
var import_dialog = require("./dialog");
var import_download = require("./download");
var import_electron = require("./electron");
var import_locator = require("./locator");
var import_elementHandle = require("./elementHandle");
var import_fileChooser = require("./fileChooser");
var import_errors = require("./errors");
var import_frame = require("./frame");
var import_input = require("./input");
var import_jsHandle = require("./jsHandle");
var import_network = require("./network");
var import_fetch = require("./fetch");
var import_page = require("./page");
var import_selectors = require("./selectors");
var import_tracing = require("./tracing");
var import_video = require("./video");
var import_worker = require("./worker");
var import_cdpSession = require("./cdpSession");
var import_playwright = require("./playwright");
var import_webError = require("./webError");
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
APIRequest,
APIRequestContext,
APIResponse,
Android,
AndroidDevice,
AndroidInput,
AndroidSocket,
AndroidWebView,
Browser,
BrowserContext,
BrowserType,
CDPSession,
Clock,
ConsoleMessage,
Coverage,
Debugger,
Dialog,
Download,
Electron,
ElectronApplication,
ElementHandle,
FileChooser,
Frame,
FrameLocator,
JSHandle,
Keyboard,
Locator,
Mouse,
Page,
Playwright,
Request,
Response,
Route,
Selectors,
TimeoutError,
Touchscreen,
Tracing,
Video,
WebError,
WebSocket,
WebSocketRoute,
Worker
});
@@ -0,0 +1,79 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var artifact_exports = {};
__export(artifact_exports, {
Artifact: () => Artifact
});
module.exports = __toCommonJS(artifact_exports);
var import_channelOwner = require("./channelOwner");
var import_stream = require("./stream");
var import_fileUtils = require("./fileUtils");
class Artifact extends import_channelOwner.ChannelOwner {
static from(channel) {
return channel._object;
}
async pathAfterFinished() {
if (this._connection.isRemote())
throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`);
return (await this._channel.pathAfterFinished()).value;
}
async saveAs(path) {
if (!this._connection.isRemote()) {
await this._channel.saveAs({ path });
return;
}
const result = await this._channel.saveAsStream();
const stream = import_stream.Stream.from(result.stream);
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, path);
await new Promise((resolve, reject) => {
stream.stream().pipe(this._platform.fs().createWriteStream(path)).on("finish", resolve).on("error", reject);
});
}
async failure() {
return (await this._channel.failure()).error || null;
}
async createReadStream() {
const result = await this._channel.stream();
const stream = import_stream.Stream.from(result.stream);
return stream.stream();
}
async readIntoBuffer() {
const stream = await this.createReadStream();
return await new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => {
chunks.push(chunk);
});
stream.on("end", () => {
resolve(Buffer.concat(chunks));
});
stream.on("error", reject);
});
}
async cancel() {
return await this._channel.cancel();
}
async delete() {
return await this._channel.delete();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Artifact
});
@@ -0,0 +1,169 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var browser_exports = {};
__export(browser_exports, {
Browser: () => Browser
});
module.exports = __toCommonJS(browser_exports);
var import_artifact = require("./artifact");
var import_browserContext = require("./browserContext");
var import_cdpSession = require("./cdpSession");
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
var import_events = require("./events");
var import_fileUtils = require("./fileUtils");
class Browser extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._contexts = /* @__PURE__ */ new Set();
this._isConnected = true;
this._shouldCloseConnectionOnClose = false;
this._options = {};
this._name = initializer.name;
this._browserName = initializer.browserName;
this._channel.on("context", ({ context }) => this._didCreateContext(import_browserContext.BrowserContext.from(context)));
this._channel.on("close", () => this._didClose());
this._closedPromise = new Promise((f) => this.once(import_events.Events.Browser.Disconnected, f));
}
static from(browser) {
return browser._object;
}
browserType() {
return this._browserType;
}
async newContext(options = {}) {
return await this._innerNewContext(options, false);
}
async _newContextForReuse(options = {}) {
return await this._innerNewContext(options, true);
}
async _disconnectFromReusedContext(reason) {
const context = [...this._contexts].find((context2) => context2._forReuse);
if (!context)
return;
await this._instrumentation.runBeforeCloseBrowserContext(context);
for (const page of context.pages())
page._onClose();
context._onClose();
await this._channel.disconnectFromReusedContext({ reason });
}
async _innerNewContext(userOptions = {}, forReuse) {
const options = this._browserType._playwright.selectors._withSelectorOptions(userOptions);
await this._instrumentation.runBeforeCreateBrowserContext(options);
const contextOptions = await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options);
const response = forReuse ? await this._channel.newContextForReuse(contextOptions) : await this._channel.newContext(contextOptions);
const context = import_browserContext.BrowserContext.from(response.context);
if (forReuse)
context._forReuse = true;
if (options.logger)
context._logger = options.logger;
await context._initializeHarFromOptions(options.recordHar);
await this._instrumentation.runAfterCreateBrowserContext(context);
return context;
}
_connectToBrowserType(browserType, browserOptions, logger) {
this._browserType = browserType;
this._options = browserOptions;
this._logger = logger;
for (const context of this._contexts)
this._setupBrowserContext(context);
}
_didCreateContext(context) {
context._browser = this;
this._contexts.add(context);
if (this._browserType)
this._setupBrowserContext(context);
}
_setupBrowserContext(context) {
context._logger = this._logger;
context.tracing._tracesDir = this._options.tracesDir;
this._browserType._contexts.add(context);
this._browserType._playwright.selectors._contextsForSelectors.add(context);
context.setDefaultTimeout(this._browserType._playwright._defaultContextTimeout);
context.setDefaultNavigationTimeout(this._browserType._playwright._defaultContextNavigationTimeout);
}
contexts() {
return [...this._contexts];
}
version() {
return this._initializer.version;
}
async bind(title, options = {}) {
const { endpoint } = await this._channel.startServer({ title, ...options });
return { endpoint };
}
async unbind() {
await this._channel.stopServer();
}
async newPage(options = {}) {
return await this._wrapApiCall(async () => {
const context = await this.newContext(options);
const page = await context.newPage();
page._ownedContext = context;
context._ownerPage = page;
return page;
}, { title: "Create page" });
}
isConnected() {
return this._isConnected;
}
async newBrowserCDPSession() {
return import_cdpSession.CDPSession.from((await this._channel.newBrowserCDPSession()).session);
}
async startTracing(page, options = {}) {
this._path = options.path;
await this._channel.startTracing({ ...options, page: page ? page._channel : void 0 });
}
async stopTracing() {
const artifact = import_artifact.Artifact.from((await this._channel.stopTracing()).artifact);
const buffer = await artifact.readIntoBuffer();
await artifact.delete();
if (this._path) {
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, this._path);
await this._platform.fs().promises.writeFile(this._path, buffer);
this._path = void 0;
}
return buffer;
}
async [Symbol.asyncDispose]() {
await this.close();
}
async close(options = {}) {
this._closeReason = options.reason;
try {
if (this._shouldCloseConnectionOnClose)
this._connection.close();
else
await this._channel.close(options);
await this._closedPromise;
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
_didClose() {
this._isConnected = false;
this.emit(import_events.Events.Browser.Disconnected, this);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Browser
});
@@ -0,0 +1,563 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var browserContext_exports = {};
__export(browserContext_exports, {
BrowserContext: () => BrowserContext,
prepareBrowserContextParams: () => prepareBrowserContextParams,
toClientCertificatesProtocol: () => toClientCertificatesProtocol
});
module.exports = __toCommonJS(browserContext_exports);
var import_artifact = require("./artifact");
var import_cdpSession = require("./cdpSession");
var import_channelOwner = require("./channelOwner");
var import_clientHelper = require("./clientHelper");
var import_clock = require("./clock");
var import_consoleMessage = require("./consoleMessage");
var import_debugger = require("./debugger");
var import_dialog = require("./dialog");
var import_disposable = require("./disposable");
var import_errors = require("./errors");
var import_events = require("./events");
var import_fetch = require("./fetch");
var import_frame = require("./frame");
var import_harRouter = require("./harRouter");
var network = __toESM(require("./network"));
var import_page = require("./page");
var import_tracing = require("./tracing");
var import_waiter = require("./waiter");
var import_webError = require("./webError");
var import_worker = require("./worker");
var import_timeoutSettings = require("./timeoutSettings");
var import_fileUtils = require("./fileUtils");
var import_headers = require("../utils/isomorphic/headers");
var import_urlMatch = require("../utils/isomorphic/urlMatch");
var import_rtti = require("../utils/isomorphic/rtti");
var import_stackTrace = require("../utils/isomorphic/stackTrace");
class BrowserContext extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._pages = /* @__PURE__ */ new Set();
this._routes = [];
this._webSocketRoutes = [];
// Browser is null for browser contexts created outside of normal browser, e.g. android or electron.
this._browser = null;
this._bindings = /* @__PURE__ */ new Map();
this._forReuse = false;
this._serviceWorkers = /* @__PURE__ */ new Set();
this._harRecorders = /* @__PURE__ */ new Map();
this._closingStatus = "none";
this._harRouters = [];
this._options = initializer.options;
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform);
this.debugger = import_debugger.Debugger.from(initializer.debugger);
this.tracing = import_tracing.Tracing.from(initializer.tracing);
this.request = import_fetch.APIRequestContext.from(initializer.requestContext);
this.request._timeoutSettings = this._timeoutSettings;
this.clock = new import_clock.Clock(this);
this._channel.on("bindingCall", ({ binding }) => this._onBinding(import_page.BindingCall.from(binding)));
this._channel.on("close", () => this._onClose());
this._channel.on("page", ({ page }) => this._onPage(import_page.Page.from(page)));
this._channel.on("route", ({ route }) => this._onRoute(network.Route.from(route)));
this._channel.on("webSocketRoute", ({ webSocketRoute }) => this._onWebSocketRoute(network.WebSocketRoute.from(webSocketRoute)));
this._channel.on("serviceWorker", ({ worker }) => {
const serviceWorker = import_worker.Worker.from(worker);
serviceWorker._context = this;
this._serviceWorkers.add(serviceWorker);
this.emit(import_events.Events.BrowserContext.ServiceWorker, serviceWorker);
});
this._channel.on("console", (event) => {
const worker = import_worker.Worker.fromNullable(event.worker);
const page = import_page.Page.fromNullable(event.page);
const consoleMessage = new import_consoleMessage.ConsoleMessage(this._platform, event, page, worker);
worker?.emit(import_events.Events.Worker.Console, consoleMessage);
page?.emit(import_events.Events.Page.Console, consoleMessage);
if (worker && this._serviceWorkers.has(worker)) {
const scope = this._serviceWorkerScope(worker);
for (const page2 of this._pages) {
if (scope && page2.url().startsWith(scope))
page2.emit(import_events.Events.Page.Console, consoleMessage);
}
}
this.emit(import_events.Events.BrowserContext.Console, consoleMessage);
});
this._channel.on("pageError", ({ error, page }) => {
const pageObject = import_page.Page.from(page);
const parsedError = (0, import_errors.parseError)(error);
this.emit(import_events.Events.BrowserContext.WebError, new import_webError.WebError(pageObject, parsedError));
if (pageObject)
pageObject.emit(import_events.Events.Page.PageError, parsedError);
});
this._channel.on("dialog", ({ dialog }) => {
const dialogObject = import_dialog.Dialog.from(dialog);
let hasListeners = this.emit(import_events.Events.BrowserContext.Dialog, dialogObject);
const page = dialogObject.page();
if (page)
hasListeners = page.emit(import_events.Events.Page.Dialog, dialogObject) || hasListeners;
if (!hasListeners) {
if (dialogObject.type() === "beforeunload")
dialog.accept({}).catch(() => {
});
else
dialog.dismiss().catch(() => {
});
}
});
this._channel.on("request", ({ request, page }) => this._onRequest(network.Request.from(request), import_page.Page.fromNullable(page)));
this._channel.on("requestFailed", ({ request, failureText, responseEndTiming, page }) => this._onRequestFailed(network.Request.from(request), responseEndTiming, failureText, import_page.Page.fromNullable(page)));
this._channel.on("requestFinished", (params) => this._onRequestFinished(params));
this._channel.on("response", ({ response, page }) => this._onResponse(network.Response.from(response), import_page.Page.fromNullable(page)));
this._channel.on("recorderEvent", ({ event, data, page, code }) => {
if (event === "actionAdded")
this._onRecorderEventSink?.actionAdded?.(import_page.Page.from(page), data, code);
else if (event === "actionUpdated")
this._onRecorderEventSink?.actionUpdated?.(import_page.Page.from(page), data, code);
else if (event === "signalAdded")
this._onRecorderEventSink?.signalAdded?.(import_page.Page.from(page), data);
});
this._closedPromise = new Promise((f) => this.once(import_events.Events.BrowserContext.Close, f));
this._setEventToSubscriptionMapping(/* @__PURE__ */ new Map([
[import_events.Events.BrowserContext.Console, "console"],
[import_events.Events.BrowserContext.Dialog, "dialog"],
[import_events.Events.BrowserContext.Request, "request"],
[import_events.Events.BrowserContext.Response, "response"],
[import_events.Events.BrowserContext.RequestFinished, "requestFinished"],
[import_events.Events.BrowserContext.RequestFailed, "requestFailed"]
]));
}
static from(context) {
return context._object;
}
static fromNullable(context) {
return context ? BrowserContext.from(context) : null;
}
async _initializeHarFromOptions(recordHar) {
if (!recordHar)
return;
const defaultContent = recordHar.path.endsWith(".zip") ? "attach" : "embed";
await this._recordIntoHAR(recordHar.path, null, {
url: recordHar.urlFilter,
updateContent: recordHar.content ?? (recordHar.omitContent ? "omit" : defaultContent),
updateMode: recordHar.mode ?? "full"
});
}
_onPage(page) {
this._pages.add(page);
this.emit(import_events.Events.BrowserContext.Page, page);
if (page._opener && !page._opener.isClosed())
page._opener.emit(import_events.Events.Page.Popup, page);
}
_onRequest(request, page) {
this.emit(import_events.Events.BrowserContext.Request, request);
if (page)
page.emit(import_events.Events.Page.Request, request);
}
_onResponse(response, page) {
this.emit(import_events.Events.BrowserContext.Response, response);
if (page)
page.emit(import_events.Events.Page.Response, response);
}
_onRequestFailed(request, responseEndTiming, failureText, page) {
request._failureText = failureText || null;
request._setResponseEndTiming(responseEndTiming);
this.emit(import_events.Events.BrowserContext.RequestFailed, request);
if (page)
page.emit(import_events.Events.Page.RequestFailed, request);
}
_onRequestFinished(params) {
const { responseEndTiming } = params;
const request = network.Request.from(params.request);
const response = network.Response.fromNullable(params.response);
const page = import_page.Page.fromNullable(params.page);
request._setResponseEndTiming(responseEndTiming);
this.emit(import_events.Events.BrowserContext.RequestFinished, request);
if (page)
page.emit(import_events.Events.Page.RequestFinished, request);
if (response)
response._finishedPromise.resolve(null);
}
async _onRoute(route) {
route._context = this;
const page = route.request()._safePage();
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
if (page?._closeWasCalled || this.isClosed())
return;
if (!routeHandler.matches(route.request().url()))
continue;
const index = this._routes.indexOf(routeHandler);
if (index === -1)
continue;
if (routeHandler.willExpire())
this._routes.splice(index, 1);
const handled = await routeHandler.handle(route);
if (!this._routes.length)
this._updateInterceptionPatterns({ internal: true }).catch(() => {
});
if (handled)
return;
}
await route._innerContinue(
true
/* isFallback */
).catch(() => {
});
}
async _onWebSocketRoute(webSocketRoute) {
const routeHandler = this._webSocketRoutes.find((route) => route.matches(webSocketRoute.url()));
if (routeHandler)
await routeHandler.handle(webSocketRoute);
else
webSocketRoute.connectToServer();
}
async _onBinding(bindingCall) {
const func = this._bindings.get(bindingCall._initializer.name);
if (!func)
return;
await bindingCall.call(func);
}
_serviceWorkerScope(serviceWorker) {
try {
let url = new URL(".", serviceWorker.url()).href;
if (!url.endsWith("/"))
url += "/";
return url;
} catch {
return null;
}
}
setDefaultNavigationTimeout(timeout) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
}
setDefaultTimeout(timeout) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
browser() {
return this._browser;
}
pages() {
return [...this._pages];
}
isClosed() {
return this._closingStatus !== "none";
}
async newPage() {
if (this._ownerPage)
throw new Error("Please use browser.newContext()");
return import_page.Page.from((await this._channel.newPage()).page);
}
async cookies(urls) {
if (!urls)
urls = [];
if (urls && typeof urls === "string")
urls = [urls];
return (await this._channel.cookies({ urls })).cookies;
}
async addCookies(cookies) {
await this._channel.addCookies({ cookies });
}
async clearCookies(options = {}) {
await this._channel.clearCookies({
name: (0, import_rtti.isString)(options.name) ? options.name : void 0,
nameRegexSource: (0, import_rtti.isRegExp)(options.name) ? options.name.source : void 0,
nameRegexFlags: (0, import_rtti.isRegExp)(options.name) ? options.name.flags : void 0,
domain: (0, import_rtti.isString)(options.domain) ? options.domain : void 0,
domainRegexSource: (0, import_rtti.isRegExp)(options.domain) ? options.domain.source : void 0,
domainRegexFlags: (0, import_rtti.isRegExp)(options.domain) ? options.domain.flags : void 0,
path: (0, import_rtti.isString)(options.path) ? options.path : void 0,
pathRegexSource: (0, import_rtti.isRegExp)(options.path) ? options.path.source : void 0,
pathRegexFlags: (0, import_rtti.isRegExp)(options.path) ? options.path.flags : void 0
});
}
async grantPermissions(permissions, options) {
await this._channel.grantPermissions({ permissions, ...options });
}
async clearPermissions() {
await this._channel.clearPermissions();
}
async setGeolocation(geolocation) {
await this._channel.setGeolocation({ geolocation: geolocation || void 0 });
}
async setExtraHTTPHeaders(headers) {
network.validateHeaders(headers);
await this._channel.setExtraHTTPHeaders({ headers: (0, import_headers.headersObjectToArray)(headers) });
}
async setOffline(offline) {
await this._channel.setOffline({ offline });
}
async setHTTPCredentials(httpCredentials) {
await this._channel.setHTTPCredentials({ httpCredentials: httpCredentials || void 0 });
}
async addInitScript(script, arg) {
const source = await (0, import_clientHelper.evaluationScript)(this._platform, script, arg);
return import_disposable.DisposableObject.from((await this._channel.addInitScript({ source })).disposable);
}
async exposeBinding(name, callback, options = {}) {
const result = await this._channel.exposeBinding({ name, needsHandle: options.handle });
this._bindings.set(name, callback);
return import_disposable.DisposableObject.from(result.disposable);
}
async exposeFunction(name, callback) {
const result = await this._channel.exposeBinding({ name });
const binding = (source, ...args) => callback(...args);
this._bindings.set(name, binding);
return import_disposable.DisposableObject.from(result.disposable);
}
async route(url, handler, options = {}) {
this._routes.unshift(new network.RouteHandler(this._platform, this._options.baseURL, url, handler, options.times));
await this._updateInterceptionPatterns({ title: "Route requests" });
return new import_disposable.DisposableStub(() => this.unroute(url, handler));
}
async routeWebSocket(url, handler) {
this._webSocketRoutes.unshift(new network.WebSocketRouteHandler(this._options.baseURL, url, handler));
await this._updateWebSocketInterceptionPatterns({ title: "Route WebSockets" });
}
async _recordIntoHAR(har, page, options = {}) {
const { harId } = await this._channel.harStart({
page: page?._channel,
options: {
zip: har.endsWith(".zip"),
content: options.updateContent ?? "attach",
urlGlob: (0, import_rtti.isString)(options.url) ? options.url : void 0,
urlRegexSource: (0, import_rtti.isRegExp)(options.url) ? options.url.source : void 0,
urlRegexFlags: (0, import_rtti.isRegExp)(options.url) ? options.url.flags : void 0,
mode: options.updateMode ?? "minimal"
}
});
this._harRecorders.set(harId, { path: har, content: options.updateContent ?? "attach" });
}
async routeFromHAR(har, options = {}) {
const localUtils = this._connection.localUtils();
if (!localUtils)
throw new Error("Route from har is not supported in thin clients");
if (options.update) {
await this._recordIntoHAR(har, null, options);
return;
}
const harRouter = await import_harRouter.HarRouter.create(localUtils, har, options.notFound || "abort", { urlMatch: options.url });
this._harRouters.push(harRouter);
await harRouter.addContextRoute(this);
}
_disposeHarRouters() {
this._harRouters.forEach((router) => router.dispose());
this._harRouters = [];
}
async unrouteAll(options) {
await this._unrouteInternal(this._routes, [], options?.behavior);
this._disposeHarRouters();
}
async unroute(url, handler) {
const removed = [];
const remaining = [];
for (const route of this._routes) {
if ((0, import_urlMatch.urlMatchesEqual)(route.url, url) && (!handler || route.handler === handler))
removed.push(route);
else
remaining.push(route);
}
await this._unrouteInternal(removed, remaining, "default");
}
async _unrouteInternal(removed, remaining, behavior) {
this._routes = remaining;
if (behavior && behavior !== "default") {
const promises = removed.map((routeHandler) => routeHandler.stop(behavior));
await Promise.all(promises);
}
await this._updateInterceptionPatterns({ title: "Unroute requests" });
}
async _updateInterceptionPatterns(options) {
const patterns = network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._wrapApiCall(() => this._channel.setNetworkInterceptionPatterns({ patterns }), options);
}
async _updateWebSocketInterceptionPatterns(options) {
const patterns = network.WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes);
await this._wrapApiCall(() => this._channel.setWebSocketInterceptionPatterns({ patterns }), options);
}
_effectiveCloseReason() {
return this._closeReason || this._browser?._closeReason;
}
async waitForEvent(event, optionsOrPredicate = {}) {
return await this._wrapApiCall(async () => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = import_waiter.Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== import_events.Events.BrowserContext.Close)
waiter.rejectOnEvent(this, import_events.Events.BrowserContext.Close, () => new import_errors.TargetClosedError(this._effectiveCloseReason()));
const result = await waiter.waitForEvent(this, event, predicate);
waiter.dispose();
return result;
});
}
async storageState(options = {}) {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, void 0, 2), "utf8");
}
return state;
}
async setStorageState(storageState) {
const state = await prepareStorageState(this._platform, storageState);
await this._channel.setStorageState({ storageState: state });
}
backgroundPages() {
return [];
}
serviceWorkers() {
return [...this._serviceWorkers];
}
async newCDPSession(page) {
if (!(page instanceof import_page.Page) && !(page instanceof import_frame.Frame))
throw new Error("page: expected Page or Frame");
const result = await this._channel.newCDPSession(page instanceof import_page.Page ? { page: page._channel } : { frame: page._channel });
return import_cdpSession.CDPSession.from(result.session);
}
_onClose() {
this._closingStatus = "closed";
this._browser?._contexts.delete(this);
this._browser?._browserType._contexts.delete(this);
this._browser?._browserType._playwright.selectors._contextsForSelectors.delete(this);
this._disposeHarRouters();
this.tracing._resetStackCounter();
this.emit(import_events.Events.BrowserContext.Close, this);
}
async [Symbol.asyncDispose]() {
await this.close();
}
async close(options = {}) {
if (this.isClosed())
return;
this._closeReason = options.reason;
this._closingStatus = "closing";
await this.request.dispose(options);
await this._instrumentation.runBeforeCloseBrowserContext(this);
await this._wrapApiCall(async () => {
for (const [harId, harParams] of this._harRecorders) {
const har = await this._channel.harExport({ harId });
const artifact = import_artifact.Artifact.from(har.artifact);
const isCompressed = harParams.content === "attach" || harParams.path.endsWith(".zip");
const needCompressed = harParams.path.endsWith(".zip");
if (isCompressed && !needCompressed) {
const localUtils = this._connection.localUtils();
if (!localUtils)
throw new Error("Uncompressed har is not supported in thin clients");
await artifact.saveAs(harParams.path + ".tmp");
await localUtils.harUnzip({ zipFile: harParams.path + ".tmp", harFile: harParams.path });
} else {
await artifact.saveAs(harParams.path);
}
await artifact.delete();
}
}, { internal: true });
await this._channel.close(options);
await this._closedPromise;
}
async _enableRecorder(params, eventSink) {
if (eventSink)
this._onRecorderEventSink = eventSink;
await this._channel.enableRecorder(params);
}
async _disableRecorder() {
this._onRecorderEventSink = void 0;
await this._channel.disableRecorder();
}
async _exposeConsoleApi() {
await this._channel.exposeConsoleApi();
}
}
async function prepareStorageState(platform, storageState) {
if (typeof storageState !== "string")
return storageState;
try {
return JSON.parse(await platform.fs().promises.readFile(storageState, "utf8"));
} catch (e) {
(0, import_stackTrace.rewriteErrorMessage)(e, `Error reading storage state from ${storageState}:
` + e.message);
throw e;
}
}
async function prepareBrowserContextParams(platform, options) {
if (options.videoSize && !options.videosPath)
throw new Error(`"videoSize" option requires "videosPath" to be specified`);
if (options.extraHTTPHeaders)
network.validateHeaders(options.extraHTTPHeaders);
const contextParams = {
...options,
viewport: options.viewport === null ? void 0 : options.viewport,
noDefaultViewport: options.viewport === null,
extraHTTPHeaders: options.extraHTTPHeaders ? (0, import_headers.headersObjectToArray)(options.extraHTTPHeaders) : void 0,
storageState: options.storageState ? await prepareStorageState(platform, options.storageState) : void 0,
serviceWorkers: options.serviceWorkers,
colorScheme: options.colorScheme === null ? "no-override" : options.colorScheme,
reducedMotion: options.reducedMotion === null ? "no-override" : options.reducedMotion,
forcedColors: options.forcedColors === null ? "no-override" : options.forcedColors,
contrast: options.contrast === null ? "no-override" : options.contrast,
acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads),
clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates)
};
if (!contextParams.recordVideo && options.videosPath) {
contextParams.recordVideo = {
dir: options.videosPath,
size: options.videoSize
};
}
if (contextParams.recordVideo && contextParams.recordVideo.dir)
contextParams.recordVideo.dir = platform.path().resolve(contextParams.recordVideo.dir);
return contextParams;
}
function toAcceptDownloadsProtocol(acceptDownloads) {
if (acceptDownloads === void 0)
return void 0;
if (acceptDownloads)
return "accept";
return "deny";
}
async function toClientCertificatesProtocol(platform, certs) {
if (!certs)
return void 0;
const bufferizeContent = async (value, path) => {
if (value)
return value;
if (path)
return await platform.fs().promises.readFile(path);
};
return await Promise.all(certs.map(async (cert) => ({
origin: cert.origin,
cert: await bufferizeContent(cert.cert, cert.certPath),
key: await bufferizeContent(cert.key, cert.keyPath),
pfx: await bufferizeContent(cert.pfx, cert.pfxPath),
passphrase: cert.passphrase
})));
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
BrowserContext,
prepareBrowserContextParams,
toClientCertificatesProtocol
});
@@ -0,0 +1,153 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var browserType_exports = {};
__export(browserType_exports, {
BrowserType: () => BrowserType
});
module.exports = __toCommonJS(browserType_exports);
var import_browser = require("./browser");
var import_browserContext = require("./browserContext");
var import_channelOwner = require("./channelOwner");
var import_clientHelper = require("./clientHelper");
var import_assert = require("../utils/isomorphic/assert");
var import_headers = require("../utils/isomorphic/headers");
var import_connect = require("./connect");
var import_timeoutSettings = require("./timeoutSettings");
class BrowserType extends import_channelOwner.ChannelOwner {
constructor() {
super(...arguments);
this._contexts = /* @__PURE__ */ new Set();
}
static from(browserType) {
return browserType._object;
}
executablePath() {
if (!this._initializer.executablePath)
throw new Error("Browser is not supported on current platform");
return this._initializer.executablePath;
}
name() {
return this._initializer.name;
}
async launch(options = {}) {
(0, import_assert.assert)(!options.userDataDir, "userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead");
(0, import_assert.assert)(!options.port, "Cannot specify a port without launching as a server.");
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
options = { ...this._playwright._defaultLaunchOptions, ...options };
const launchOptions = {
...options,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : void 0,
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
env: options.env ? (0, import_clientHelper.envObjectToArray)(options.env) : void 0,
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).launchTimeout(options)
};
return await this._wrapApiCall(async () => {
const browser = import_browser.Browser.from((await this._channel.launch(launchOptions)).browser);
browser._connectToBrowserType(this, options, logger);
return browser;
});
}
async launchServer(options = {}) {
if (!this._serverLauncher)
throw new Error("Launching server is not supported");
options = { ...this._playwright._defaultLaunchOptions, ...options };
return await this._serverLauncher.launchServer(options);
}
async launchPersistentContext(userDataDir, options = {}) {
(0, import_assert.assert)(!options.port, "Cannot specify a port without launching as a server.");
options = this._playwright.selectors._withSelectorOptions({
...this._playwright._defaultLaunchOptions,
...options
});
await this._instrumentation.runBeforeCreateBrowserContext(options);
const logger = options.logger || this._playwright._defaultLaunchOptions?.logger;
const contextParams = await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options);
const persistentParams = {
...contextParams,
ignoreDefaultArgs: Array.isArray(options.ignoreDefaultArgs) ? options.ignoreDefaultArgs : void 0,
ignoreAllDefaultArgs: !!options.ignoreDefaultArgs && !Array.isArray(options.ignoreDefaultArgs),
env: options.env ? (0, import_clientHelper.envObjectToArray)(options.env) : void 0,
channel: options.channel,
userDataDir: this._platform.path().isAbsolute(userDataDir) || !userDataDir ? userDataDir : this._platform.path().resolve(userDataDir),
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).launchTimeout(options)
};
const context = await this._wrapApiCall(async () => {
const result = await this._channel.launchPersistentContext(persistentParams);
const browser = import_browser.Browser.from(result.browser);
browser._connectToBrowserType(this, options, logger);
const context2 = import_browserContext.BrowserContext.from(result.context);
await context2._initializeHarFromOptions(options.recordHar);
return context2;
});
await this._instrumentation.runAfterCreateBrowserContext(context);
return context;
}
async connect(optionsOrEndpoint, options) {
if (typeof optionsOrEndpoint === "string")
return await this._connect({ ...options, endpoint: optionsOrEndpoint });
(0, import_assert.assert)(optionsOrEndpoint.wsEndpoint, "options.wsEndpoint is required");
return await this._connect({ ...options, endpoint: optionsOrEndpoint.wsEndpoint });
}
async _connect(params) {
const logger = params.logger;
return await this._wrapApiCall(async () => {
const browser = await (0, import_connect.connectToBrowser)(this._playwright, { browserName: this.name(), ...params });
browser._connectToBrowserType(this, {}, logger);
return browser;
});
}
async connectOverCDP(endpointURLOrOptions, options) {
if (typeof endpointURLOrOptions === "string")
return await this._connectOverCDP(endpointURLOrOptions, options);
const endpointURL = "endpointURL" in endpointURLOrOptions ? endpointURLOrOptions.endpointURL : endpointURLOrOptions.wsEndpoint;
(0, import_assert.assert)(endpointURL, "Cannot connect over CDP without wsEndpoint.");
return await this.connectOverCDP(endpointURL, endpointURLOrOptions);
}
async _connectOverCDP(endpointURL, params = {}) {
if (this.name() !== "chromium")
throw new Error("Connecting over CDP is only supported in Chromium.");
const headers = params.headers ? (0, import_headers.headersObjectToArray)(params.headers) : void 0;
const result = await this._channel.connectOverCDP({
endpointURL,
headers,
slowMo: params.slowMo,
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).timeout(params),
isLocal: params.isLocal
});
const browser = import_browser.Browser.from(result.browser);
browser._connectToBrowserType(this, {}, params.logger);
if (result.defaultContext)
await this._instrumentation.runAfterCreateBrowserContext(import_browserContext.BrowserContext.from(result.defaultContext));
return browser;
}
async _connectOverCDPTransport(transport) {
if (this.name() !== "chromium")
throw new Error("Connecting over CDP is only supported in Chromium.");
const result = await this._channel.connectOverCDPTransport({ transport });
const browser = import_browser.Browser.from(result.browser);
browser._connectToBrowserType(this, {}, void 0);
if (result.defaultContext)
await this._instrumentation.runAfterCreateBrowserContext(import_browserContext.BrowserContext.from(result.defaultContext));
return browser;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
BrowserType
});
@@ -0,0 +1,55 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var cdpSession_exports = {};
__export(cdpSession_exports, {
CDPSession: () => CDPSession
});
module.exports = __toCommonJS(cdpSession_exports);
var import_channelOwner = require("./channelOwner");
class CDPSession extends import_channelOwner.ChannelOwner {
static from(cdpSession) {
return cdpSession._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._channel.on("event", (event) => {
this.emit(event.method, event.params);
this.emit("event", event);
});
this._channel.on("close", () => {
this.emit("close", this);
});
this.on = super.on;
this.addListener = super.addListener;
this.off = super.removeListener;
this.removeListener = super.removeListener;
this.once = super.once;
}
async send(method, params) {
const result = await this._channel.send({ method, params });
return result.result;
}
async detach() {
return await this._channel.detach();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
CDPSession
});
@@ -0,0 +1,194 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var channelOwner_exports = {};
__export(channelOwner_exports, {
ChannelOwner: () => ChannelOwner
});
module.exports = __toCommonJS(channelOwner_exports);
var import_eventEmitter = require("./eventEmitter");
var import_validator = require("../protocol/validator");
var import_protocolMetainfo = require("../utils/isomorphic/protocolMetainfo");
var import_clientStackTrace = require("./clientStackTrace");
var import_stackTrace = require("../utils/isomorphic/stackTrace");
class ChannelOwner extends import_eventEmitter.EventEmitter {
constructor(parent, type, guid, initializer) {
const connection = parent instanceof ChannelOwner ? parent._connection : parent;
super(connection._platform);
this._objects = /* @__PURE__ */ new Map();
this._eventToSubscriptionMapping = /* @__PURE__ */ new Map();
this._wasCollected = false;
this.setMaxListeners(0);
this._connection = connection;
this._type = type;
this._guid = guid;
this._parent = parent instanceof ChannelOwner ? parent : void 0;
this._instrumentation = this._connection._instrumentation;
this._connection._objects.set(guid, this);
if (this._parent) {
this._parent._objects.set(guid, this);
this._logger = this._parent._logger;
}
this._channel = this._createChannel(new import_eventEmitter.EventEmitter(connection._platform));
this._initializer = initializer;
}
_setEventToSubscriptionMapping(mapping) {
this._eventToSubscriptionMapping = mapping;
}
_updateSubscription(event, enabled) {
const protocolEvent = this._eventToSubscriptionMapping.get(String(event));
if (protocolEvent)
this._channel.updateSubscription({ event: protocolEvent, enabled }).catch(() => {
});
}
on(event, listener) {
if (!this.listenerCount(event))
this._updateSubscription(event, true);
super.on(event, listener);
return this;
}
addListener(event, listener) {
if (!this.listenerCount(event))
this._updateSubscription(event, true);
super.addListener(event, listener);
return this;
}
prependListener(event, listener) {
if (!this.listenerCount(event))
this._updateSubscription(event, true);
super.prependListener(event, listener);
return this;
}
off(event, listener) {
super.off(event, listener);
if (!this.listenerCount(event))
this._updateSubscription(event, false);
return this;
}
removeListener(event, listener) {
super.removeListener(event, listener);
if (!this.listenerCount(event))
this._updateSubscription(event, false);
return this;
}
_adopt(child) {
child._parent._objects.delete(child._guid);
this._objects.set(child._guid, child);
child._parent = this;
}
_dispose(reason) {
if (this._parent)
this._parent._objects.delete(this._guid);
this._connection._objects.delete(this._guid);
this._wasCollected = reason === "gc";
for (const object of [...this._objects.values()])
object._dispose(reason);
this._objects.clear();
}
_debugScopeState() {
return {
_guid: this._guid,
objects: Array.from(this._objects.values()).map((o) => o._debugScopeState())
};
}
_validatorToWireContext() {
return {
tChannelImpl: tChannelImplToWire,
binary: this._connection.rawBuffers() ? "buffer" : "toBase64",
isUnderTest: () => this._platform.isUnderTest()
};
}
_createChannel(base) {
const channel = new Proxy(base, {
get: (obj, prop) => {
if (typeof prop === "string") {
const validator = (0, import_validator.maybeFindValidator)(this._type, prop, "Params");
const { internal } = (0, import_protocolMetainfo.getMetainfo)({ type: this._type, method: prop }) || {};
if (validator) {
return async (params) => {
return await this._wrapApiCall(async (apiZone) => {
const validatedParams = validator(params, "", this._validatorToWireContext());
if (!apiZone.internal && !apiZone.reported) {
apiZone.reported = true;
this._instrumentation.onApiCallBegin(apiZone, { type: this._type, method: prop, params });
logApiCall(this._platform, this._logger, `=> ${apiZone.apiName} started`);
return await this._connection.sendMessageToServer(this, prop, validatedParams, apiZone);
}
return await this._connection.sendMessageToServer(this, prop, validatedParams, { internal: true });
}, { internal });
};
}
}
return obj[prop];
}
});
channel._object = this;
return channel;
}
async _wrapApiCall(func, options) {
const logger = this._logger;
const existingApiZone = this._platform.zones.current().data();
if (existingApiZone)
return await func(existingApiZone);
const stackTrace = (0, import_clientStackTrace.captureLibraryStackTrace)(this._platform);
const apiZone = { title: options?.title, apiName: stackTrace.apiName, frames: stackTrace.frames, internal: options?.internal ?? false, reported: false, userData: void 0, stepId: void 0 };
try {
const result = await this._platform.zones.current().push(apiZone).run(async () => await func(apiZone));
if (!options?.internal) {
logApiCall(this._platform, logger, `<= ${apiZone.apiName} succeeded`);
this._instrumentation.onApiCallEnd(apiZone);
}
return result;
} catch (e) {
const innerError = (this._platform.showInternalStackFrames() || this._platform.isUnderTest()) && e.stack ? "\n<inner error>\n" + e.stack : "";
if (apiZone.apiName && !apiZone.apiName.includes("<anonymous>"))
e.message = apiZone.apiName + ": " + e.message;
const stackFrames = "\n" + (0, import_stackTrace.stringifyStackFrames)(stackTrace.frames).join("\n") + innerError;
if (stackFrames.trim())
e.stack = e.message + stackFrames;
else
e.stack = "";
if (!options?.internal) {
apiZone.error = e;
logApiCall(this._platform, logger, `<= ${apiZone.apiName} failed`);
this._instrumentation.onApiCallEnd(apiZone);
}
throw e;
}
}
toJSON() {
return {
_type: this._type,
_guid: this._guid
};
}
}
function logApiCall(platform, logger, message) {
if (logger && logger.isEnabled("api", "info"))
logger.log("api", "info", message, [], { color: "cyan" });
platform.log("api", message);
}
function tChannelImplToWire(names, arg, path, context) {
if (arg._object instanceof ChannelOwner && (names === "*" || names.includes(arg._object._type)))
return { guid: arg._object._guid };
throw new import_validator.ValidationError(`${path}: expected channel ${names.toString()}`);
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ChannelOwner
});
@@ -0,0 +1,64 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var clientHelper_exports = {};
__export(clientHelper_exports, {
addSourceUrlToScript: () => addSourceUrlToScript,
envObjectToArray: () => envObjectToArray,
evaluationScript: () => evaluationScript
});
module.exports = __toCommonJS(clientHelper_exports);
var import_rtti = require("../utils/isomorphic/rtti");
function envObjectToArray(env) {
const result = [];
for (const name in env) {
if (!Object.is(env[name], void 0))
result.push({ name, value: String(env[name]) });
}
return result;
}
async function evaluationScript(platform, fun, arg, addSourceUrl = true) {
if (typeof fun === "function") {
const source = fun.toString();
const argString = Object.is(arg, void 0) ? "undefined" : JSON.stringify(arg);
return `(${source})(${argString})`;
}
if (arg !== void 0)
throw new Error("Cannot evaluate a string with arguments");
if ((0, import_rtti.isString)(fun))
return fun;
if (fun.content !== void 0)
return fun.content;
if (fun.path !== void 0) {
let source = await platform.fs().promises.readFile(fun.path, "utf8");
if (addSourceUrl)
source = addSourceUrlToScript(source, fun.path);
return source;
}
throw new Error("Either path or content property must be present");
}
function addSourceUrlToScript(source, path) {
return `${source}
//# sourceURL=${path.replace(/\n/g, "")}`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
addSourceUrlToScript,
envObjectToArray,
evaluationScript
});
@@ -0,0 +1,55 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var clientInstrumentation_exports = {};
__export(clientInstrumentation_exports, {
createInstrumentation: () => createInstrumentation
});
module.exports = __toCommonJS(clientInstrumentation_exports);
function createInstrumentation() {
const listeners = [];
return new Proxy({}, {
get: (obj, prop) => {
if (typeof prop !== "string")
return obj[prop];
if (prop === "addListener")
return (listener) => listeners.push(listener);
if (prop === "removeListener")
return (listener) => listeners.splice(listeners.indexOf(listener), 1);
if (prop === "removeAllListeners")
return () => listeners.splice(0, listeners.length);
if (prop.startsWith("run")) {
return async (...params) => {
for (const listener of listeners)
await listener[prop]?.(...params);
};
}
if (prop.startsWith("on")) {
return (...params) => {
for (const listener of listeners)
listener[prop]?.(...params);
};
}
return obj[prop];
}
});
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
createInstrumentation
});
@@ -0,0 +1,69 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var clientStackTrace_exports = {};
__export(clientStackTrace_exports, {
captureLibraryStackTrace: () => captureLibraryStackTrace
});
module.exports = __toCommonJS(clientStackTrace_exports);
var import_stackTrace = require("../utils/isomorphic/stackTrace");
function captureLibraryStackTrace(platform) {
const stack = (0, import_stackTrace.captureRawStack)();
let parsedFrames = stack.map((line) => {
const frame = (0, import_stackTrace.parseStackFrame)(line, platform.pathSeparator, platform.showInternalStackFrames());
if (!frame || !frame.file)
return null;
const isPlaywrightLibrary = !!platform.coreDir && frame.file.startsWith(platform.coreDir);
const parsed = {
frame,
frameText: line,
isPlaywrightLibrary
};
return parsed;
}).filter(Boolean);
let apiName = "";
for (let i = 0; i < parsedFrames.length - 1; i++) {
const parsedFrame = parsedFrames[i];
if (parsedFrame.isPlaywrightLibrary && !parsedFrames[i + 1].isPlaywrightLibrary) {
apiName = apiName || normalizeAPIName(parsedFrame.frame.function);
break;
}
}
function normalizeAPIName(name) {
if (!name)
return "";
const match = name.match(/(API|JS|CDP|[A-Z])(.*)/);
if (!match)
return name;
return match[1].toLowerCase() + match[2];
}
const filterPrefixes = platform.boxedStackPrefixes();
parsedFrames = parsedFrames.filter((f) => {
if (filterPrefixes.some((prefix) => f.frame.file.startsWith(prefix)))
return false;
return true;
});
return {
frames: parsedFrames.map((p) => p.frame),
apiName
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
captureLibraryStackTrace
});
@@ -0,0 +1,68 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var clock_exports = {};
__export(clock_exports, {
Clock: () => Clock
});
module.exports = __toCommonJS(clock_exports);
class Clock {
constructor(browserContext) {
this._browserContext = browserContext;
}
async install(options = {}) {
await this._browserContext._channel.clockInstall(options.time !== void 0 ? parseTime(options.time) : {});
}
async fastForward(ticks) {
await this._browserContext._channel.clockFastForward(parseTicks(ticks));
}
async pauseAt(time) {
await this._browserContext._channel.clockPauseAt(parseTime(time));
}
async resume() {
await this._browserContext._channel.clockResume({});
}
async runFor(ticks) {
await this._browserContext._channel.clockRunFor(parseTicks(ticks));
}
async setFixedTime(time) {
await this._browserContext._channel.clockSetFixedTime(parseTime(time));
}
async setSystemTime(time) {
await this._browserContext._channel.clockSetSystemTime(parseTime(time));
}
}
function parseTime(time) {
if (typeof time === "number")
return { timeNumber: time };
if (typeof time === "string")
return { timeString: time };
if (!isFinite(time.getTime()))
throw new Error(`Invalid date: ${time}`);
return { timeNumber: time.getTime() };
}
function parseTicks(ticks) {
return {
ticksNumber: typeof ticks === "number" ? ticks : void 0,
ticksString: typeof ticks === "string" ? ticks : void 0
};
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Clock
});
@@ -0,0 +1,143 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var connect_exports = {};
__export(connect_exports, {
connectToBrowser: () => connectToBrowser,
connectToEndpoint: () => connectToEndpoint
});
module.exports = __toCommonJS(connect_exports);
var import_time = require("../utils/isomorphic/time");
var import_timeoutRunner = require("../utils/isomorphic/timeoutRunner");
var import_browser = require("./browser");
var import_connection = require("./connection");
var import_events = require("./events");
async function connectToBrowser(playwright, params) {
const deadline = params.timeout ? (0, import_time.monotonicTime)() + params.timeout : 0;
const nameParam = params.browserName ? { "x-playwright-browser": params.browserName } : {};
const headers = { ...nameParam, ...params.headers };
const connectParams = {
endpoint: params.endpoint,
headers,
exposeNetwork: params.exposeNetwork,
slowMo: params.slowMo,
timeout: params.timeout || 0
};
if (params.__testHookRedirectPortForwarding)
connectParams.socksProxyRedirectPortForTest = params.__testHookRedirectPortForwarding;
const connection = await connectToEndpoint(playwright._connection, connectParams);
let browser;
connection.on("close", () => {
for (const context of browser?.contexts() || []) {
for (const page of context.pages())
page._onClose();
context._onClose();
}
setTimeout(() => browser?._didClose(), 0);
});
const result = await (0, import_timeoutRunner.raceAgainstDeadline)(async () => {
if (params.__testHookBeforeCreateBrowser)
await params.__testHookBeforeCreateBrowser();
const playwright2 = await connection.initializePlaywright();
if (!playwright2._initializer.preLaunchedBrowser) {
connection.close();
throw new Error("Malformed endpoint. Did you use BrowserType.launchServer method?");
}
playwright2.selectors = playwright2.selectors;
browser = import_browser.Browser.from(playwright2._initializer.preLaunchedBrowser);
browser._shouldCloseConnectionOnClose = true;
browser.on(import_events.Events.Browser.Disconnected, () => connection.close());
return browser;
}, deadline);
if (!result.timedOut) {
return result.result;
} else {
connection.close();
throw new Error(`Timeout ${params.timeout}ms exceeded`);
}
}
async function connectToEndpoint(parentConnection, params) {
const localUtils = parentConnection.localUtils();
const transport = localUtils ? new JsonPipeTransport(localUtils) : new WebSocketTransport();
const connectHeaders = await transport.connect(params);
const connection = new import_connection.Connection(parentConnection._platform, localUtils, parentConnection._instrumentation, connectHeaders);
connection.markAsRemote();
connection.on("close", () => transport.close());
let closeError;
const onTransportClosed = (reason) => {
connection.close(reason || closeError);
};
transport.onClose((reason) => onTransportClosed(reason));
connection.onmessage = (message) => transport.send(message).catch(() => onTransportClosed());
transport.onMessage((message) => {
try {
connection.dispatch(message);
} catch (e) {
closeError = String(e);
transport.close().catch(() => {
});
}
});
return connection;
}
class JsonPipeTransport {
constructor(owner) {
this._owner = owner;
}
async connect(params) {
const { pipe, headers: connectHeaders } = await this._owner._channel.connect(params);
this._pipe = pipe;
return connectHeaders;
}
async send(message) {
await this._pipe.send({ message });
}
onMessage(callback) {
this._pipe.on("message", ({ message }) => callback(message));
}
onClose(callback) {
this._pipe.on("closed", ({ reason }) => callback(reason));
}
async close() {
await this._pipe.close().catch(() => {
});
}
}
class WebSocketTransport {
async connect(params) {
this._ws = new window.WebSocket(params.endpoint);
return [];
}
async send(message) {
this._ws.send(JSON.stringify(message));
}
onMessage(callback) {
this._ws.addEventListener("message", (event) => callback(JSON.parse(event.data)));
}
onClose(callback) {
this._ws.addEventListener("close", () => callback());
}
async close() {
this._ws.close();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
connectToBrowser,
connectToEndpoint
});
@@ -0,0 +1,322 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var connection_exports = {};
__export(connection_exports, {
Connection: () => Connection
});
module.exports = __toCommonJS(connection_exports);
var import_eventEmitter = require("./eventEmitter");
var import_android = require("./android");
var import_artifact = require("./artifact");
var import_browser = require("./browser");
var import_browserContext = require("./browserContext");
var import_browserType = require("./browserType");
var import_cdpSession = require("./cdpSession");
var import_channelOwner = require("./channelOwner");
var import_clientInstrumentation = require("./clientInstrumentation");
var import_debugger = require("./debugger");
var import_dialog = require("./dialog");
var import_disposable = require("./disposable");
var import_electron = require("./electron");
var import_elementHandle = require("./elementHandle");
var import_errors = require("./errors");
var import_fetch = require("./fetch");
var import_frame = require("./frame");
var import_jsHandle = require("./jsHandle");
var import_jsonPipe = require("./jsonPipe");
var import_localUtils = require("./localUtils");
var import_network = require("./network");
var import_page = require("./page");
var import_playwright = require("./playwright");
var import_stream = require("./stream");
var import_tracing = require("./tracing");
var import_worker = require("./worker");
var import_writableStream = require("./writableStream");
var import_validator = require("../protocol/validator");
var import_stackTrace = require("../utils/isomorphic/stackTrace");
class Root extends import_channelOwner.ChannelOwner {
constructor(connection) {
super(connection, "Root", "", {});
}
async initialize() {
return import_playwright.Playwright.from((await this._channel.initialize({
sdkLanguage: "javascript"
})).playwright);
}
}
class DummyChannelOwner extends import_channelOwner.ChannelOwner {
}
class Connection extends import_eventEmitter.EventEmitter {
constructor(platform, localUtils, instrumentation, headers = []) {
super(platform);
this._objects = /* @__PURE__ */ new Map();
this.onmessage = (message) => {
};
this._lastId = 0;
this._callbacks = /* @__PURE__ */ new Map();
this._isRemote = false;
this._rawBuffers = false;
this._tracingCount = 0;
this._instrumentation = instrumentation || (0, import_clientInstrumentation.createInstrumentation)();
this._localUtils = localUtils;
this._rootObject = new Root(this);
this.headers = headers;
}
markAsRemote() {
this._isRemote = true;
}
isRemote() {
return this._isRemote;
}
useRawBuffers() {
this._rawBuffers = true;
}
rawBuffers() {
return this._rawBuffers;
}
localUtils() {
return this._localUtils;
}
async initializePlaywright() {
return await this._rootObject.initialize();
}
getObjectWithKnownName(guid) {
return this._objects.get(guid);
}
setIsTracing(isTracing) {
if (isTracing)
this._tracingCount++;
else
this._tracingCount--;
}
async sendMessageToServer(object, method, params, options) {
if (this._closedError)
throw this._closedError;
if (object._wasCollected)
throw new Error("The object has been collected to prevent unbounded heap growth.");
const guid = object._guid;
const type = object._type;
const id = ++this._lastId;
const message = { id, guid, method, params };
if (this._platform.isLogEnabled("channel")) {
this._platform.log("channel", "SEND> " + JSON.stringify(message));
}
const location = options.frames?.[0] ? { file: options.frames[0].file, line: options.frames[0].line, column: options.frames[0].column } : void 0;
const metadata = { title: options.title, location, internal: options.internal, stepId: options.stepId };
if (this._tracingCount && options.frames && type !== "LocalUtils")
this._localUtils?.addStackToTracingNoReply({ callData: { stack: options.frames ?? [], id } }).catch(() => {
});
this._platform.zones.empty.run(() => this.onmessage({ ...message, metadata }));
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject, title: options.title, type, method }));
}
_validatorFromWireContext() {
return {
tChannelImpl: this._tChannelImplFromWire.bind(this),
binary: this._rawBuffers ? "buffer" : "fromBase64",
isUnderTest: () => this._platform.isUnderTest()
};
}
dispatch(message) {
if (this._closedError)
return;
const { id, guid, method, params, result, error, log } = message;
if (id) {
if (this._platform.isLogEnabled("channel"))
this._platform.log("channel", "<RECV " + JSON.stringify(message));
const callback = this._callbacks.get(id);
if (!callback)
throw new Error(`Cannot find command to respond: ${id}`);
this._callbacks.delete(id);
if (error && !result) {
const parsedError = (0, import_errors.parseError)(error);
(0, import_stackTrace.rewriteErrorMessage)(parsedError, parsedError.message + formatCallLog(this._platform, log));
callback.reject(parsedError);
} else {
const validator2 = (0, import_validator.findValidator)(callback.type, callback.method, "Result");
callback.resolve(validator2(result, "", this._validatorFromWireContext()));
}
return;
}
if (this._platform.isLogEnabled("channel"))
this._platform.log("channel", "<EVENT " + JSON.stringify(message));
if (method === "__create__") {
this._createRemoteObject(guid, params.type, params.guid, params.initializer);
return;
}
const object = this._objects.get(guid);
if (!object)
throw new Error(`Cannot find object to "${method}": ${guid}`);
if (method === "__adopt__") {
const child = this._objects.get(params.guid);
if (!child)
throw new Error(`Unknown new child: ${params.guid}`);
object._adopt(child);
return;
}
if (method === "__dispose__") {
object._dispose(params.reason);
return;
}
const validator = (0, import_validator.findValidator)(object._type, method, "Event");
object._channel.emit(method, validator(params, "", this._validatorFromWireContext()));
}
close(cause) {
if (this._closedError)
return;
this._closedError = new import_errors.TargetClosedError(cause);
for (const callback of this._callbacks.values())
callback.reject(this._closedError);
this._callbacks.clear();
this.emit("close");
}
_tChannelImplFromWire(names, arg, path, context) {
if (arg && typeof arg === "object" && typeof arg.guid === "string") {
const object = this._objects.get(arg.guid);
if (!object)
throw new Error(`Object with guid ${arg.guid} was not bound in the connection`);
if (names !== "*" && !names.includes(object._type))
throw new import_validator.ValidationError(`${path}: expected channel ${names.toString()}`);
return object._channel;
}
throw new import_validator.ValidationError(`${path}: expected channel ${names.toString()}`);
}
_createRemoteObject(parentGuid, type, guid, initializer) {
const parent = this._objects.get(parentGuid);
if (!parent)
throw new Error(`Cannot find parent object ${parentGuid} to create ${guid}`);
let result;
const validator = (0, import_validator.findValidator)(type, "", "Initializer");
initializer = validator(initializer, "", this._validatorFromWireContext());
switch (type) {
case "Android":
result = new import_android.Android(parent, type, guid, initializer);
break;
case "AndroidSocket":
result = new import_android.AndroidSocket(parent, type, guid, initializer);
break;
case "AndroidDevice":
result = new import_android.AndroidDevice(parent, type, guid, initializer);
break;
case "APIRequestContext":
result = new import_fetch.APIRequestContext(parent, type, guid, initializer);
break;
case "Artifact":
result = new import_artifact.Artifact(parent, type, guid, initializer);
break;
case "BindingCall":
result = new import_page.BindingCall(parent, type, guid, initializer);
break;
case "Browser":
result = new import_browser.Browser(parent, type, guid, initializer);
break;
case "BrowserContext":
result = new import_browserContext.BrowserContext(parent, type, guid, initializer);
break;
case "BrowserType":
result = new import_browserType.BrowserType(parent, type, guid, initializer);
break;
case "CDPSession":
result = new import_cdpSession.CDPSession(parent, type, guid, initializer);
break;
case "Debugger":
result = new import_debugger.Debugger(parent, type, guid, initializer);
break;
case "Dialog":
result = new import_dialog.Dialog(parent, type, guid, initializer);
break;
case "Disposable":
result = new import_disposable.DisposableObject(parent, type, guid, initializer);
break;
case "Electron":
result = new import_electron.Electron(parent, type, guid, initializer);
break;
case "ElectronApplication":
result = new import_electron.ElectronApplication(parent, type, guid, initializer);
break;
case "ElementHandle":
result = new import_elementHandle.ElementHandle(parent, type, guid, initializer);
break;
case "Frame":
result = new import_frame.Frame(parent, type, guid, initializer);
break;
case "JSHandle":
result = new import_jsHandle.JSHandle(parent, type, guid, initializer);
break;
case "JsonPipe":
result = new import_jsonPipe.JsonPipe(parent, type, guid, initializer);
break;
case "LocalUtils":
result = new import_localUtils.LocalUtils(parent, type, guid, initializer);
if (!this._localUtils)
this._localUtils = result;
break;
case "Page":
result = new import_page.Page(parent, type, guid, initializer);
break;
case "Playwright":
result = new import_playwright.Playwright(parent, type, guid, initializer);
break;
case "Request":
result = new import_network.Request(parent, type, guid, initializer);
break;
case "Response":
result = new import_network.Response(parent, type, guid, initializer);
break;
case "Route":
result = new import_network.Route(parent, type, guid, initializer);
break;
case "Stream":
result = new import_stream.Stream(parent, type, guid, initializer);
break;
case "SocksSupport":
result = new DummyChannelOwner(parent, type, guid, initializer);
break;
case "Tracing":
result = new import_tracing.Tracing(parent, type, guid, initializer);
break;
case "WebSocket":
result = new import_network.WebSocket(parent, type, guid, initializer);
break;
case "WebSocketRoute":
result = new import_network.WebSocketRoute(parent, type, guid, initializer);
break;
case "Worker":
result = new import_worker.Worker(parent, type, guid, initializer);
break;
case "WritableStream":
result = new import_writableStream.WritableStream(parent, type, guid, initializer);
break;
default:
throw new Error("Missing type " + type);
}
return result;
}
}
function formatCallLog(platform, log) {
if (!log || !log.some((l) => !!l))
return "";
return `
Call log:
${platform.colors.dim(log.join("\n"))}
`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Connection
});
@@ -0,0 +1,61 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var consoleMessage_exports = {};
__export(consoleMessage_exports, {
ConsoleMessage: () => ConsoleMessage
});
module.exports = __toCommonJS(consoleMessage_exports);
var import_jsHandle = require("./jsHandle");
class ConsoleMessage {
constructor(platform, event, page, worker) {
this._page = page;
this._worker = worker;
this._event = event;
if (platform.inspectCustom)
this[platform.inspectCustom] = () => this._inspect();
}
worker() {
return this._worker;
}
page() {
return this._page;
}
type() {
return this._event.type;
}
text() {
return this._event.text;
}
args() {
return this._event.args.map(import_jsHandle.JSHandle.from);
}
location() {
return this._event.location;
}
timestamp() {
return this._event.timestamp;
}
_inspect() {
return this.text();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ConsoleMessage
});
@@ -0,0 +1,44 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var coverage_exports = {};
__export(coverage_exports, {
Coverage: () => Coverage
});
module.exports = __toCommonJS(coverage_exports);
class Coverage {
constructor(channel) {
this._channel = channel;
}
async startJSCoverage(options = {}) {
await this._channel.startJSCoverage(options);
}
async stopJSCoverage() {
return (await this._channel.stopJSCoverage()).entries;
}
async startCSSCoverage(options = {}) {
await this._channel.startCSSCoverage(options);
}
async stopCSSCoverage() {
return (await this._channel.stopCSSCoverage()).entries;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Coverage
});
@@ -0,0 +1,57 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var debugger_exports = {};
__export(debugger_exports, {
Debugger: () => Debugger
});
module.exports = __toCommonJS(debugger_exports);
var import_channelOwner = require("./channelOwner");
var import_events = require("./events");
class Debugger extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._pausedDetails = null;
this._channel.on("pausedStateChanged", ({ pausedDetails }) => {
this._pausedDetails = pausedDetails ?? null;
this.emit(import_events.Events.Debugger.PausedStateChanged);
});
}
static from(channel) {
return channel._object;
}
async requestPause() {
await this._channel.requestPause();
}
async resume() {
await this._channel.resume();
}
async next() {
await this._channel.next();
}
async runTo(location) {
await this._channel.runTo({ location });
}
pausedDetails() {
return this._pausedDetails;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Debugger
});
@@ -0,0 +1,63 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var dialog_exports = {};
__export(dialog_exports, {
Dialog: () => Dialog
});
module.exports = __toCommonJS(dialog_exports);
var import_channelOwner = require("./channelOwner");
var import_page = require("./page");
var import_errors = require("./errors");
class Dialog extends import_channelOwner.ChannelOwner {
static from(dialog) {
return dialog._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._page = import_page.Page.fromNullable(initializer.page);
}
page() {
return this._page;
}
type() {
return this._initializer.type;
}
message() {
return this._initializer.message;
}
defaultValue() {
return this._initializer.defaultValue;
}
async accept(promptText) {
await this._channel.accept({ promptText });
}
async dismiss() {
try {
await this._channel.dismiss();
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Dialog
});
@@ -0,0 +1,76 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var disposable_exports = {};
__export(disposable_exports, {
DisposableObject: () => DisposableObject,
DisposableStub: () => DisposableStub,
disposeAll: () => disposeAll
});
module.exports = __toCommonJS(disposable_exports);
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
class DisposableObject extends import_channelOwner.ChannelOwner {
static from(channel) {
return channel._object;
}
async [Symbol.asyncDispose]() {
await this.dispose();
}
async dispose() {
try {
await this._channel.dispose();
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
}
class DisposableStub {
constructor(dispose) {
this._dispose = dispose;
}
async [Symbol.asyncDispose]() {
await this.dispose();
}
async dispose() {
if (!this._dispose)
return;
try {
const dispose = this._dispose;
this._dispose = void 0;
await dispose();
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
}
async function disposeAll(disposables) {
const copy = [...disposables];
disposables.length = 0;
await Promise.all(copy.map((d) => d.dispose()));
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
DisposableObject,
DisposableStub,
disposeAll
});
@@ -0,0 +1,62 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var download_exports = {};
__export(download_exports, {
Download: () => Download
});
module.exports = __toCommonJS(download_exports);
class Download {
constructor(page, url, suggestedFilename, artifact) {
this._page = page;
this._url = url;
this._suggestedFilename = suggestedFilename;
this._artifact = artifact;
}
page() {
return this._page;
}
url() {
return this._url;
}
suggestedFilename() {
return this._suggestedFilename;
}
async path() {
return await this._artifact.pathAfterFinished();
}
async saveAs(path) {
return await this._artifact.saveAs(path);
}
async failure() {
return await this._artifact.failure();
}
async createReadStream() {
return await this._artifact.createReadStream();
}
async cancel() {
return await this._artifact.cancel();
}
async delete() {
return await this._artifact.delete();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Download
});
@@ -0,0 +1,139 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var electron_exports = {};
__export(electron_exports, {
Electron: () => Electron,
ElectronApplication: () => ElectronApplication
});
module.exports = __toCommonJS(electron_exports);
var import_browserContext = require("./browserContext");
var import_channelOwner = require("./channelOwner");
var import_clientHelper = require("./clientHelper");
var import_consoleMessage = require("./consoleMessage");
var import_errors = require("./errors");
var import_events = require("./events");
var import_jsHandle = require("./jsHandle");
var import_waiter = require("./waiter");
var import_timeoutSettings = require("./timeoutSettings");
class Electron extends import_channelOwner.ChannelOwner {
static from(electron) {
return electron._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
}
async launch(options = {}) {
options = this._playwright.selectors._withSelectorOptions(options);
const params = {
...await (0, import_browserContext.prepareBrowserContextParams)(this._platform, options),
env: (0, import_clientHelper.envObjectToArray)(options.env ? options.env : this._platform.env),
tracesDir: options.tracesDir,
artifactsDir: options.artifactsDir,
timeout: new import_timeoutSettings.TimeoutSettings(this._platform).launchTimeout(options)
};
const app = ElectronApplication.from((await this._channel.launch(params)).electronApplication);
this._playwright.selectors._contextsForSelectors.add(app._context);
app.once(import_events.Events.ElectronApplication.Close, () => this._playwright.selectors._contextsForSelectors.delete(app._context));
await app._context._initializeHarFromOptions(options.recordHar);
app._context.tracing._tracesDir = options.tracesDir;
return app;
}
}
class ElectronApplication extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._windows = /* @__PURE__ */ new Set();
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform);
this._context = import_browserContext.BrowserContext.from(initializer.context);
for (const page of this._context._pages)
this._onPage(page);
this._context.on(import_events.Events.BrowserContext.Page, (page) => this._onPage(page));
this._channel.on("close", () => {
this.emit(import_events.Events.ElectronApplication.Close);
});
this._channel.on("console", (event) => this.emit(import_events.Events.ElectronApplication.Console, new import_consoleMessage.ConsoleMessage(this._platform, event, null, null)));
this._setEventToSubscriptionMapping(/* @__PURE__ */ new Map([
[import_events.Events.ElectronApplication.Console, "console"]
]));
}
static from(electronApplication) {
return electronApplication._object;
}
process() {
return this._connection.toImpl?.(this)?.process();
}
_onPage(page) {
this._windows.add(page);
this.emit(import_events.Events.ElectronApplication.Window, page);
page.once(import_events.Events.Page.Close, () => this._windows.delete(page));
}
windows() {
return [...this._windows];
}
async firstWindow(options) {
if (this._windows.size)
return this._windows.values().next().value;
return await this.waitForEvent("window", options);
}
context() {
return this._context;
}
async [Symbol.asyncDispose]() {
await this.close();
}
async close() {
try {
await this._context.close();
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
async waitForEvent(event, optionsOrPredicate = {}) {
return await this._wrapApiCall(async () => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = import_waiter.Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== import_events.Events.ElectronApplication.Close)
waiter.rejectOnEvent(this, import_events.Events.ElectronApplication.Close, () => new import_errors.TargetClosedError());
const result = await waiter.waitForEvent(this, event, predicate);
waiter.dispose();
return result;
});
}
async browserWindow(page) {
const result = await this._channel.browserWindow({ page: page._channel });
return import_jsHandle.JSHandle.from(result.handle);
}
async evaluate(pageFunction, arg) {
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async evaluateHandle(pageFunction, arg) {
const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return import_jsHandle.JSHandle.from(result.handle);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Electron,
ElectronApplication
});
@@ -0,0 +1,281 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var elementHandle_exports = {};
__export(elementHandle_exports, {
ElementHandle: () => ElementHandle,
convertInputFiles: () => convertInputFiles,
convertSelectOptionValues: () => convertSelectOptionValues,
determineScreenshotType: () => determineScreenshotType
});
module.exports = __toCommonJS(elementHandle_exports);
var import_frame = require("./frame");
var import_jsHandle = require("./jsHandle");
var import_assert = require("../utils/isomorphic/assert");
var import_fileUtils = require("./fileUtils");
var import_rtti = require("../utils/isomorphic/rtti");
var import_writableStream = require("./writableStream");
var import_mimeType = require("../utils/isomorphic/mimeType");
class ElementHandle extends import_jsHandle.JSHandle {
static from(handle) {
return handle._object;
}
static fromNullable(handle) {
return handle ? ElementHandle.from(handle) : null;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._frame = parent;
this._elementChannel = this._channel;
}
asElement() {
return this;
}
async ownerFrame() {
return import_frame.Frame.fromNullable((await this._elementChannel.ownerFrame()).frame);
}
async contentFrame() {
return import_frame.Frame.fromNullable((await this._elementChannel.contentFrame()).frame);
}
async getAttribute(name) {
const value = (await this._elementChannel.getAttribute({ name })).value;
return value === void 0 ? null : value;
}
async inputValue() {
return (await this._elementChannel.inputValue()).value;
}
async textContent() {
const value = (await this._elementChannel.textContent()).value;
return value === void 0 ? null : value;
}
async innerText() {
return (await this._elementChannel.innerText()).value;
}
async innerHTML() {
return (await this._elementChannel.innerHTML()).value;
}
async isChecked() {
return (await this._elementChannel.isChecked()).value;
}
async isDisabled() {
return (await this._elementChannel.isDisabled()).value;
}
async isEditable() {
return (await this._elementChannel.isEditable()).value;
}
async isEnabled() {
return (await this._elementChannel.isEnabled()).value;
}
async isHidden() {
return (await this._elementChannel.isHidden()).value;
}
async isVisible() {
return (await this._elementChannel.isVisible()).value;
}
async dispatchEvent(type, eventInit = {}) {
await this._elementChannel.dispatchEvent({ type, eventInit: (0, import_jsHandle.serializeArgument)(eventInit) });
}
async scrollIntoViewIfNeeded(options = {}) {
await this._elementChannel.scrollIntoViewIfNeeded({ ...options, timeout: this._frame._timeout(options) });
}
async hover(options = {}) {
await this._elementChannel.hover({ ...options, timeout: this._frame._timeout(options) });
}
async click(options = {}) {
return await this._elementChannel.click({ ...options, timeout: this._frame._timeout(options) });
}
async dblclick(options = {}) {
return await this._elementChannel.dblclick({ ...options, timeout: this._frame._timeout(options) });
}
async tap(options = {}) {
return await this._elementChannel.tap({ ...options, timeout: this._frame._timeout(options) });
}
async selectOption(values, options = {}) {
const result = await this._elementChannel.selectOption({ ...convertSelectOptionValues(values), ...options, timeout: this._frame._timeout(options) });
return result.values;
}
async fill(value, options = {}) {
return await this._elementChannel.fill({ value, ...options, timeout: this._frame._timeout(options) });
}
async selectText(options = {}) {
await this._elementChannel.selectText({ ...options, timeout: this._frame._timeout(options) });
}
async setInputFiles(files, options = {}) {
const frame = await this.ownerFrame();
if (!frame)
throw new Error("Cannot set input files to detached element");
const converted = await convertInputFiles(this._platform, files, frame.page().context());
await this._elementChannel.setInputFiles({ ...converted, ...options, timeout: this._frame._timeout(options) });
}
async focus() {
await this._elementChannel.focus();
}
async type(text, options = {}) {
await this._elementChannel.type({ text, ...options, timeout: this._frame._timeout(options) });
}
async press(key, options = {}) {
await this._elementChannel.press({ key, ...options, timeout: this._frame._timeout(options) });
}
async check(options = {}) {
return await this._elementChannel.check({ ...options, timeout: this._frame._timeout(options) });
}
async uncheck(options = {}) {
return await this._elementChannel.uncheck({ ...options, timeout: this._frame._timeout(options) });
}
async setChecked(checked, options) {
if (checked)
await this.check(options);
else
await this.uncheck(options);
}
async boundingBox() {
const value = (await this._elementChannel.boundingBox()).value;
return value === void 0 ? null : value;
}
async screenshot(options = {}) {
const mask = options.mask;
const copy = { ...options, mask: void 0, timeout: this._frame._timeout(options) };
if (!copy.type)
copy.type = determineScreenshotType(options);
if (mask) {
copy.mask = mask.map((locator) => ({
frame: locator._frame._channel,
selector: locator._selector
}));
}
const result = await this._elementChannel.screenshot(copy);
if (options.path) {
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, result.binary);
}
return result.binary;
}
async $(selector) {
return ElementHandle.fromNullable((await this._elementChannel.querySelector({ selector })).element);
}
async $$(selector) {
const result = await this._elementChannel.querySelectorAll({ selector });
return result.elements.map((h) => ElementHandle.from(h));
}
async $eval(selector, pageFunction, arg) {
const result = await this._elementChannel.evalOnSelector({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async $$eval(selector, pageFunction, arg) {
const result = await this._elementChannel.evalOnSelectorAll({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async waitForElementState(state, options = {}) {
return await this._elementChannel.waitForElementState({ state, ...options, timeout: this._frame._timeout(options) });
}
async waitForSelector(selector, options = {}) {
const result = await this._elementChannel.waitForSelector({ selector, ...options, timeout: this._frame._timeout(options) });
return ElementHandle.fromNullable(result.element);
}
}
function convertSelectOptionValues(values) {
if (values === null)
return {};
if (!Array.isArray(values))
values = [values];
if (!values.length)
return {};
for (let i = 0; i < values.length; i++)
(0, import_assert.assert)(values[i] !== null, `options[${i}]: expected object, got null`);
if (values[0] instanceof ElementHandle)
return { elements: values.map((v) => v._elementChannel) };
if ((0, import_rtti.isString)(values[0]))
return { options: values.map((valueOrLabel) => ({ valueOrLabel })) };
return { options: values };
}
function filePayloadExceedsSizeLimit(payloads) {
return payloads.reduce((size, item) => size + (item.buffer ? item.buffer.byteLength : 0), 0) >= import_fileUtils.fileUploadSizeLimit;
}
async function resolvePathsAndDirectoryForInputFiles(platform, items) {
let localPaths;
let localDirectory;
for (const item of items) {
const stat = await platform.fs().promises.stat(item);
if (stat.isDirectory()) {
if (localDirectory)
throw new Error("Multiple directories are not supported");
localDirectory = platform.path().resolve(item);
} else {
localPaths ??= [];
localPaths.push(platform.path().resolve(item));
}
}
if (localPaths?.length && localDirectory)
throw new Error("File paths must be all files or a single directory");
return [localPaths, localDirectory];
}
async function convertInputFiles(platform, files, context) {
const items = Array.isArray(files) ? files.slice() : [files];
if (items.some((item) => typeof item === "string")) {
if (!items.every((item) => typeof item === "string"))
throw new Error("File paths cannot be mixed with buffers");
const [localPaths, localDirectory] = await resolvePathsAndDirectoryForInputFiles(platform, items);
if (context._connection.isRemote()) {
const files2 = localDirectory ? (await platform.fs().promises.readdir(localDirectory, { withFileTypes: true, recursive: true })).filter((f) => f.isFile()).map((f) => platform.path().join(f.parentPath, f.name)) : localPaths;
const { writableStreams, rootDir } = await context._wrapApiCall(async () => context._channel.createTempFiles({
rootDirName: localDirectory ? platform.path().basename(localDirectory) : void 0,
items: await Promise.all(files2.map(async (file) => {
const lastModifiedMs = (await platform.fs().promises.stat(file)).mtimeMs;
return {
name: localDirectory ? platform.path().relative(localDirectory, file) : platform.path().basename(file),
lastModifiedMs
};
}))
}), { internal: true });
for (let i = 0; i < files2.length; i++) {
const writable = import_writableStream.WritableStream.from(writableStreams[i]);
await platform.streamFile(files2[i], writable.stream());
}
return {
directoryStream: rootDir,
streams: localDirectory ? void 0 : writableStreams
};
}
return {
localPaths,
localDirectory
};
}
const payloads = items;
if (filePayloadExceedsSizeLimit(payloads))
throw new Error("Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.");
return { payloads };
}
function determineScreenshotType(options) {
if (options.path) {
const mimeType = (0, import_mimeType.getMimeTypeForPath)(options.path);
if (mimeType === "image/png")
return "png";
else if (mimeType === "image/jpeg")
return "jpeg";
throw new Error(`path: unsupported mime type "${mimeType}"`);
}
return options.type;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ElementHandle,
convertInputFiles,
convertSelectOptionValues,
determineScreenshotType
});
@@ -0,0 +1,77 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var errors_exports = {};
__export(errors_exports, {
TargetClosedError: () => TargetClosedError,
TimeoutError: () => TimeoutError,
isTargetClosedError: () => isTargetClosedError,
parseError: () => parseError,
serializeError: () => serializeError
});
module.exports = __toCommonJS(errors_exports);
var import_serializers = require("../protocol/serializers");
var import_rtti = require("../utils/isomorphic/rtti");
class TimeoutError extends Error {
constructor(message) {
super(message);
this.name = "TimeoutError";
}
}
class TargetClosedError extends Error {
constructor(cause) {
super(cause || "Target page, context or browser has been closed");
}
}
function isTargetClosedError(error) {
return error instanceof TargetClosedError;
}
function serializeError(e) {
if ((0, import_rtti.isError)(e))
return { error: { message: e.message, stack: e.stack, name: e.name } };
return { value: (0, import_serializers.serializeValue)(e, (value) => ({ fallThrough: value })) };
}
function parseError(error) {
if (!error.error) {
if (error.value === void 0)
throw new Error("Serialized error must have either an error or a value");
return (0, import_serializers.parseSerializedValue)(error.value, void 0);
}
if (error.error.name === "TimeoutError") {
const e2 = new TimeoutError(error.error.message);
e2.stack = error.error.stack || "";
return e2;
}
if (error.error.name === "TargetClosedError") {
const e2 = new TargetClosedError(error.error.message);
e2.stack = error.error.stack || "";
return e2;
}
const e = new Error(error.error.message);
e.stack = error.error.stack || "";
e.name = error.error.name;
return e;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
TargetClosedError,
TimeoutError,
isTargetClosedError,
parseError,
serializeError
});
@@ -0,0 +1,314 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var eventEmitter_exports = {};
__export(eventEmitter_exports, {
EventEmitter: () => EventEmitter
});
module.exports = __toCommonJS(eventEmitter_exports);
class EventEmitter {
constructor(platform) {
this._events = void 0;
this._eventsCount = 0;
this._maxListeners = void 0;
this._pendingHandlers = /* @__PURE__ */ new Map();
this._platform = platform;
if (this._events === void 0 || this._events === Object.getPrototypeOf(this)._events) {
this._events = /* @__PURE__ */ Object.create(null);
this._eventsCount = 0;
}
this._maxListeners = this._maxListeners || void 0;
this.on = this.addListener;
this.off = this.removeListener;
}
setMaxListeners(n) {
if (typeof n !== "number" || n < 0 || Number.isNaN(n))
throw new RangeError('The value of "n" is out of range. It must be a non-negative number. Received ' + n + ".");
this._maxListeners = n;
return this;
}
getMaxListeners() {
return this._maxListeners === void 0 ? this._platform.defaultMaxListeners() : this._maxListeners;
}
emit(type, ...args) {
const events = this._events;
if (events === void 0)
return false;
const handler = events?.[type];
if (handler === void 0)
return false;
if (typeof handler === "function") {
this._callHandler(type, handler, args);
} else {
const len = handler.length;
const listeners = handler.slice();
for (let i = 0; i < len; ++i)
this._callHandler(type, listeners[i], args);
}
return true;
}
_callHandler(type, handler, args) {
const promise = Reflect.apply(handler, this, args);
if (!(promise instanceof Promise))
return;
let set = this._pendingHandlers.get(type);
if (!set) {
set = /* @__PURE__ */ new Set();
this._pendingHandlers.set(type, set);
}
set.add(promise);
promise.catch((e) => {
if (this._rejectionHandler)
this._rejectionHandler(e);
else
throw e;
}).finally(() => set.delete(promise));
}
addListener(type, listener) {
return this._addListener(type, listener, false);
}
on(type, listener) {
return this._addListener(type, listener, false);
}
_addListener(type, listener, prepend) {
checkListener(listener);
let events = this._events;
let existing;
if (events === void 0) {
events = this._events = /* @__PURE__ */ Object.create(null);
this._eventsCount = 0;
} else {
if (events.newListener !== void 0) {
this.emit("newListener", type, unwrapListener(listener));
events = this._events;
}
existing = events[type];
}
if (existing === void 0) {
existing = events[type] = listener;
++this._eventsCount;
} else {
if (typeof existing === "function") {
existing = events[type] = prepend ? [listener, existing] : [existing, listener];
} else if (prepend) {
existing.unshift(listener);
} else {
existing.push(listener);
}
const m = this.getMaxListeners();
if (m > 0 && existing.length > m && !existing.warned) {
existing.warned = true;
const w = new Error("Possible EventEmitter memory leak detected. " + existing.length + " " + String(type) + " listeners added. Use emitter.setMaxListeners() to increase limit");
w.name = "MaxListenersExceededWarning";
w.emitter = this;
w.type = type;
w.count = existing.length;
if (!this._platform.isUnderTest()) {
console.warn(w);
}
}
}
return this;
}
prependListener(type, listener) {
return this._addListener(type, listener, true);
}
once(type, listener) {
checkListener(listener);
this.on(type, new OnceWrapper(this, type, listener).wrapperFunction);
return this;
}
prependOnceListener(type, listener) {
checkListener(listener);
this.prependListener(type, new OnceWrapper(this, type, listener).wrapperFunction);
return this;
}
removeListener(type, listener) {
checkListener(listener);
const events = this._events;
if (events === void 0)
return this;
const list = events[type];
if (list === void 0)
return this;
if (list === listener || list.listener === listener) {
if (--this._eventsCount === 0) {
this._events = /* @__PURE__ */ Object.create(null);
} else {
delete events[type];
if (events.removeListener)
this.emit("removeListener", type, list.listener ?? listener);
}
} else if (typeof list !== "function") {
let position = -1;
let originalListener;
for (let i = list.length - 1; i >= 0; i--) {
if (list[i] === listener || wrappedListener(list[i]) === listener) {
originalListener = wrappedListener(list[i]);
position = i;
break;
}
}
if (position < 0)
return this;
if (position === 0)
list.shift();
else
list.splice(position, 1);
if (list.length === 1)
events[type] = list[0];
if (events.removeListener !== void 0)
this.emit("removeListener", type, originalListener || listener);
}
return this;
}
off(type, listener) {
return this.removeListener(type, listener);
}
removeAllListeners(type, options) {
this._removeAllListeners(type);
if (!options)
return this;
if (options.behavior === "wait") {
const errors = [];
this._rejectionHandler = (error) => errors.push(error);
return this._waitFor(type).then(() => {
if (errors.length)
throw errors[0];
});
}
if (options.behavior === "ignoreErrors")
this._rejectionHandler = () => {
};
return Promise.resolve();
}
_removeAllListeners(type) {
const events = this._events;
if (!events)
return;
if (!events.removeListener) {
if (type === void 0) {
this._events = /* @__PURE__ */ Object.create(null);
this._eventsCount = 0;
} else if (events[type] !== void 0) {
if (--this._eventsCount === 0)
this._events = /* @__PURE__ */ Object.create(null);
else
delete events[type];
}
return;
}
if (type === void 0) {
const keys = Object.keys(events);
let key;
for (let i = 0; i < keys.length; ++i) {
key = keys[i];
if (key === "removeListener")
continue;
this._removeAllListeners(key);
}
this._removeAllListeners("removeListener");
this._events = /* @__PURE__ */ Object.create(null);
this._eventsCount = 0;
return;
}
const listeners = events[type];
if (typeof listeners === "function") {
this.removeListener(type, listeners);
} else if (listeners !== void 0) {
for (let i = listeners.length - 1; i >= 0; i--)
this.removeListener(type, listeners[i]);
}
}
listeners(type) {
return this._listeners(this, type, true);
}
rawListeners(type) {
return this._listeners(this, type, false);
}
listenerCount(type) {
const events = this._events;
if (events !== void 0) {
const listener = events[type];
if (typeof listener === "function")
return 1;
if (listener !== void 0)
return listener.length;
}
return 0;
}
eventNames() {
return this._eventsCount > 0 && this._events ? Reflect.ownKeys(this._events) : [];
}
async _waitFor(type) {
let promises = [];
if (type) {
promises = [...this._pendingHandlers.get(type) || []];
} else {
promises = [];
for (const [, pending] of this._pendingHandlers)
promises.push(...pending);
}
await Promise.all(promises);
}
_listeners(target, type, unwrap) {
const events = target._events;
if (events === void 0)
return [];
const listener = events[type];
if (listener === void 0)
return [];
if (typeof listener === "function")
return unwrap ? [unwrapListener(listener)] : [listener];
return unwrap ? unwrapListeners(listener) : listener.slice();
}
}
function checkListener(listener) {
if (typeof listener !== "function")
throw new TypeError('The "listener" argument must be of type Function. Received type ' + typeof listener);
}
class OnceWrapper {
constructor(eventEmitter, eventType, listener) {
this._fired = false;
this._eventEmitter = eventEmitter;
this._eventType = eventType;
this._listener = listener;
this.wrapperFunction = this._handle.bind(this);
this.wrapperFunction.listener = listener;
}
_handle(...args) {
if (this._fired)
return;
this._fired = true;
this._eventEmitter.removeListener(this._eventType, this.wrapperFunction);
return this._listener.apply(this._eventEmitter, args);
}
}
function unwrapListener(l) {
return wrappedListener(l) ?? l;
}
function unwrapListeners(arr) {
return arr.map((l) => wrappedListener(l) ?? l);
}
function wrappedListener(l) {
return l.listener;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
EventEmitter
});
@@ -0,0 +1,103 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var events_exports = {};
__export(events_exports, {
Events: () => Events
});
module.exports = __toCommonJS(events_exports);
const Events = {
AndroidDevice: {
WebView: "webview",
Close: "close"
},
AndroidSocket: {
Data: "data",
Close: "close"
},
AndroidWebView: {
Close: "close"
},
Browser: {
Disconnected: "disconnected"
},
Debugger: {
PausedStateChanged: "pausedstatechanged"
},
BrowserContext: {
Console: "console",
Close: "close",
Dialog: "dialog",
Page: "page",
// Can't use just 'error' due to node.js special treatment of error events.
// @see https://nodejs.org/api/events.html#events_error_events
WebError: "weberror",
BackgroundPage: "backgroundpage",
// Deprecated in v1.56, never emitted anymore.
ServiceWorker: "serviceworker",
Request: "request",
Response: "response",
RequestFailed: "requestfailed",
RequestFinished: "requestfinished"
},
BrowserServer: {
Close: "close"
},
Page: {
Close: "close",
Crash: "crash",
Console: "console",
Dialog: "dialog",
Download: "download",
FileChooser: "filechooser",
DOMContentLoaded: "domcontentloaded",
// Can't use just 'error' due to node.js special treatment of error events.
// @see https://nodejs.org/api/events.html#events_error_events
PageError: "pageerror",
Request: "request",
Response: "response",
RequestFailed: "requestfailed",
RequestFinished: "requestfinished",
FrameAttached: "frameattached",
FrameDetached: "framedetached",
FrameNavigated: "framenavigated",
Load: "load",
Popup: "popup",
WebSocket: "websocket",
Worker: "worker"
},
WebSocket: {
Close: "close",
Error: "socketerror",
FrameReceived: "framereceived",
FrameSent: "framesent"
},
Worker: {
Close: "close",
Console: "console"
},
ElectronApplication: {
Close: "close",
Console: "console",
Window: "window"
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Events
});
@@ -0,0 +1,367 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var fetch_exports = {};
__export(fetch_exports, {
APIRequest: () => APIRequest,
APIRequestContext: () => APIRequestContext,
APIResponse: () => APIResponse
});
module.exports = __toCommonJS(fetch_exports);
var import_browserContext = require("./browserContext");
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
var import_network = require("./network");
var import_tracing = require("./tracing");
var import_assert = require("../utils/isomorphic/assert");
var import_fileUtils = require("./fileUtils");
var import_headers = require("../utils/isomorphic/headers");
var import_rtti = require("../utils/isomorphic/rtti");
var import_timeoutSettings = require("./timeoutSettings");
class APIRequest {
constructor(playwright) {
this._contexts = /* @__PURE__ */ new Set();
this._playwright = playwright;
}
async newContext(options = {}) {
options = { ...options };
await this._playwright._instrumentation.runBeforeCreateRequestContext(options);
const storageState = typeof options.storageState === "string" ? JSON.parse(await this._playwright._platform.fs().promises.readFile(options.storageState, "utf8")) : options.storageState;
const context = APIRequestContext.from((await this._playwright._channel.newRequest({
...options,
extraHTTPHeaders: options.extraHTTPHeaders ? (0, import_headers.headersObjectToArray)(options.extraHTTPHeaders) : void 0,
storageState,
tracesDir: this._playwright._defaultLaunchOptions?.tracesDir,
// We do not expose tracesDir in the API, so do not allow options to accidentally override it.
clientCertificates: await (0, import_browserContext.toClientCertificatesProtocol)(this._playwright._platform, options.clientCertificates)
})).request);
this._contexts.add(context);
context._request = this;
context._timeoutSettings.setDefaultTimeout(options.timeout ?? this._playwright._defaultContextTimeout);
context._tracing._tracesDir = this._playwright._defaultLaunchOptions?.tracesDir;
await context._instrumentation.runAfterCreateRequestContext(context);
return context;
}
}
class APIRequestContext extends import_channelOwner.ChannelOwner {
static from(channel) {
return channel._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._tracing = import_tracing.Tracing.from(initializer.tracing);
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform);
}
async [Symbol.asyncDispose]() {
await this.dispose();
}
async dispose(options = {}) {
this._closeReason = options.reason;
await this._instrumentation.runBeforeCloseRequestContext(this);
try {
await this._channel.dispose(options);
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
this._tracing._resetStackCounter();
this._request?._contexts.delete(this);
}
async delete(url, options) {
return await this.fetch(url, {
...options,
method: "DELETE"
});
}
async head(url, options) {
return await this.fetch(url, {
...options,
method: "HEAD"
});
}
async get(url, options) {
return await this.fetch(url, {
...options,
method: "GET"
});
}
async patch(url, options) {
return await this.fetch(url, {
...options,
method: "PATCH"
});
}
async post(url, options) {
return await this.fetch(url, {
...options,
method: "POST"
});
}
async put(url, options) {
return await this.fetch(url, {
...options,
method: "PUT"
});
}
async fetch(urlOrRequest, options = {}) {
const url = (0, import_rtti.isString)(urlOrRequest) ? urlOrRequest : void 0;
const request = (0, import_rtti.isString)(urlOrRequest) ? void 0 : urlOrRequest;
return await this._innerFetch({ url, request, ...options });
}
async _innerFetch(options = {}) {
return await this._wrapApiCall(async () => {
if (this._closeReason)
throw new import_errors.TargetClosedError(this._closeReason);
(0, import_assert.assert)(options.request || typeof options.url === "string", "First argument must be either URL string or Request");
(0, import_assert.assert)((options.data === void 0 ? 0 : 1) + (options.form === void 0 ? 0 : 1) + (options.multipart === void 0 ? 0 : 1) <= 1, `Only one of 'data', 'form' or 'multipart' can be specified`);
(0, import_assert.assert)(options.maxRedirects === void 0 || options.maxRedirects >= 0, `'maxRedirects' must be greater than or equal to '0'`);
(0, import_assert.assert)(options.maxRetries === void 0 || options.maxRetries >= 0, `'maxRetries' must be greater than or equal to '0'`);
const url = options.url !== void 0 ? options.url : options.request.url();
const method = options.method || options.request?.method();
let encodedParams = void 0;
if (typeof options.params === "string")
encodedParams = options.params;
else if (options.params instanceof URLSearchParams)
encodedParams = options.params.toString();
const headersObj = options.headers || options.request?.headers();
const headers = headersObj ? (0, import_headers.headersObjectToArray)(headersObj) : void 0;
let jsonData;
let formData;
let multipartData;
let postDataBuffer;
if (options.data !== void 0) {
if ((0, import_rtti.isString)(options.data)) {
if (isJsonContentType(headers))
jsonData = isJsonParsable(options.data) ? options.data : JSON.stringify(options.data);
else
postDataBuffer = Buffer.from(options.data, "utf8");
} else if (Buffer.isBuffer(options.data)) {
postDataBuffer = options.data;
} else if (typeof options.data === "object" || typeof options.data === "number" || typeof options.data === "boolean") {
jsonData = JSON.stringify(options.data);
} else {
throw new Error(`Unexpected 'data' type`);
}
} else if (options.form) {
if (globalThis.FormData && options.form instanceof FormData) {
formData = [];
for (const [name, value] of options.form.entries()) {
if (typeof value !== "string")
throw new Error(`Expected string for options.form["${name}"], found File. Please use options.multipart instead.`);
formData.push({ name, value });
}
} else {
formData = objectToArray(options.form);
}
} else if (options.multipart) {
multipartData = [];
if (globalThis.FormData && options.multipart instanceof FormData) {
const form = options.multipart;
for (const [name, value] of form.entries()) {
if ((0, import_rtti.isString)(value)) {
multipartData.push({ name, value });
} else {
const file = {
name: value.name,
mimeType: value.type,
buffer: Buffer.from(await value.arrayBuffer())
};
multipartData.push({ name, file });
}
}
} else {
for (const [name, value] of Object.entries(options.multipart))
multipartData.push(await toFormField(this._platform, name, value));
}
}
if (postDataBuffer === void 0 && jsonData === void 0 && formData === void 0 && multipartData === void 0)
postDataBuffer = options.request?.postDataBuffer() || void 0;
const fixtures = {
__testHookLookup: options.__testHookLookup
};
const result = await this._channel.fetch({
url,
params: typeof options.params === "object" ? objectToArray(options.params) : void 0,
encodedParams,
method,
headers,
postData: postDataBuffer,
jsonData,
formData,
multipartData,
timeout: this._timeoutSettings.timeout(options),
failOnStatusCode: options.failOnStatusCode,
ignoreHTTPSErrors: options.ignoreHTTPSErrors,
maxRedirects: options.maxRedirects,
maxRetries: options.maxRetries,
...fixtures
});
return new APIResponse(this, result.response);
});
}
async storageState(options = {}) {
const state = await this._channel.storageState({ indexedDB: options.indexedDB });
if (options.path) {
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, JSON.stringify(state, void 0, 2), "utf8");
}
return state;
}
}
async function toFormField(platform, name, value) {
const typeOfValue = typeof value;
if (isFilePayload(value)) {
const payload = value;
if (!Buffer.isBuffer(payload.buffer))
throw new Error(`Unexpected buffer type of 'data.${name}'`);
return { name, file: filePayloadToJson(payload) };
} else if (typeOfValue === "string" || typeOfValue === "number" || typeOfValue === "boolean") {
return { name, value: String(value) };
} else {
return { name, file: await readStreamToJson(platform, value) };
}
}
function isJsonParsable(value) {
if (typeof value !== "string")
return false;
try {
JSON.parse(value);
return true;
} catch (e) {
if (e instanceof SyntaxError)
return false;
else
throw e;
}
}
class APIResponse {
constructor(context, initializer) {
this._request = context;
this._initializer = initializer;
this._headers = new import_network.RawHeaders(this._initializer.headers);
if (context._platform.inspectCustom)
this[context._platform.inspectCustom] = () => this._inspect();
}
ok() {
return this._initializer.status >= 200 && this._initializer.status <= 299;
}
url() {
return this._initializer.url;
}
status() {
return this._initializer.status;
}
statusText() {
return this._initializer.statusText;
}
headers() {
return this._headers.headers();
}
headersArray() {
return this._headers.headersArray();
}
async body() {
return await this._request._wrapApiCall(async () => {
try {
const result = await this._request._channel.fetchResponseBody({ fetchUid: this._fetchUid() });
if (result.binary === void 0)
throw new Error("Response has been disposed");
return result.binary;
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
throw new Error("Response has been disposed");
throw e;
}
}, { internal: true });
}
async text() {
const content = await this.body();
return content.toString("utf8");
}
async json() {
const content = await this.text();
return JSON.parse(content);
}
async [Symbol.asyncDispose]() {
await this.dispose();
}
async dispose() {
await this._request._channel.disposeAPIResponse({ fetchUid: this._fetchUid() });
}
_inspect() {
const headers = this.headersArray().map(({ name, value }) => ` ${name}: ${value}`);
return `APIResponse: ${this.status()} ${this.statusText()}
${headers.join("\n")}`;
}
_fetchUid() {
return this._initializer.fetchUid;
}
async _fetchLog() {
const { log } = await this._request._channel.fetchLog({ fetchUid: this._fetchUid() });
return log;
}
}
function filePayloadToJson(payload) {
return {
name: payload.name,
mimeType: payload.mimeType,
buffer: payload.buffer
};
}
async function readStreamToJson(platform, stream) {
const buffer = await new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", (err) => reject(err));
});
const streamPath = Buffer.isBuffer(stream.path) ? stream.path.toString("utf8") : stream.path;
return {
name: platform.path().basename(streamPath),
buffer
};
}
function isJsonContentType(headers) {
if (!headers)
return false;
for (const { name, value } of headers) {
if (name.toLocaleLowerCase() === "content-type")
return value === "application/json";
}
return false;
}
function objectToArray(map) {
if (!map)
return void 0;
const result = [];
for (const [name, value] of Object.entries(map)) {
if (value !== void 0)
result.push({ name, value: String(value) });
}
return result;
}
function isFilePayload(value) {
return typeof value === "object" && value["name"] && value["mimeType"] && value["buffer"];
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
APIRequest,
APIRequestContext,
APIResponse
});
@@ -0,0 +1,46 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var fileChooser_exports = {};
__export(fileChooser_exports, {
FileChooser: () => FileChooser
});
module.exports = __toCommonJS(fileChooser_exports);
class FileChooser {
constructor(page, elementHandle, isMultiple) {
this._page = page;
this._elementHandle = elementHandle;
this._isMultiple = isMultiple;
}
element() {
return this._elementHandle;
}
isMultiple() {
return this._isMultiple;
}
page() {
return this._page;
}
async setFiles(files, options) {
return await this._elementHandle.setInputFiles(files, options);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
FileChooser
});
@@ -0,0 +1,34 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var fileUtils_exports = {};
__export(fileUtils_exports, {
fileUploadSizeLimit: () => fileUploadSizeLimit,
mkdirIfNeeded: () => mkdirIfNeeded
});
module.exports = __toCommonJS(fileUtils_exports);
const fileUploadSizeLimit = 50 * 1024 * 1024;
async function mkdirIfNeeded(platform, filePath) {
await platform.fs().promises.mkdir(platform.path().dirname(filePath), { recursive: true }).catch(() => {
});
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
fileUploadSizeLimit,
mkdirIfNeeded
});
@@ -0,0 +1,404 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var frame_exports = {};
__export(frame_exports, {
Frame: () => Frame,
verifyLoadState: () => verifyLoadState
});
module.exports = __toCommonJS(frame_exports);
var import_eventEmitter = require("./eventEmitter");
var import_channelOwner = require("./channelOwner");
var import_clientHelper = require("./clientHelper");
var import_elementHandle = require("./elementHandle");
var import_events = require("./events");
var import_jsHandle = require("./jsHandle");
var import_locator = require("./locator");
var network = __toESM(require("./network"));
var import_types = require("./types");
var import_waiter = require("./waiter");
var import_assert = require("../utils/isomorphic/assert");
var import_locatorUtils = require("../utils/isomorphic/locatorUtils");
var import_urlMatch = require("../utils/isomorphic/urlMatch");
var import_timeoutSettings = require("./timeoutSettings");
class Frame extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._parentFrame = null;
this._url = "";
this._name = "";
this._detached = false;
this._childFrames = /* @__PURE__ */ new Set();
this._eventEmitter = new import_eventEmitter.EventEmitter(parent._platform);
this._eventEmitter.setMaxListeners(0);
this._parentFrame = Frame.fromNullable(initializer.parentFrame);
if (this._parentFrame)
this._parentFrame._childFrames.add(this);
this._name = initializer.name;
this._url = initializer.url;
this._loadStates = new Set(initializer.loadStates);
this._channel.on("loadstate", (event) => {
if (event.add) {
this._loadStates.add(event.add);
this._eventEmitter.emit("loadstate", event.add);
}
if (event.remove)
this._loadStates.delete(event.remove);
if (!this._parentFrame && event.add === "load" && this._page)
this._page.emit(import_events.Events.Page.Load, this._page);
if (!this._parentFrame && event.add === "domcontentloaded" && this._page)
this._page.emit(import_events.Events.Page.DOMContentLoaded, this._page);
});
this._channel.on("navigated", (event) => {
this._url = event.url;
this._name = event.name;
this._eventEmitter.emit("navigated", event);
if (!event.error && this._page)
this._page.emit(import_events.Events.Page.FrameNavigated, this);
});
}
static from(frame) {
return frame._object;
}
static fromNullable(frame) {
return frame ? Frame.from(frame) : null;
}
page() {
return this._page;
}
_timeout(options) {
const timeoutSettings = this._page?._timeoutSettings || new import_timeoutSettings.TimeoutSettings(this._platform);
return timeoutSettings.timeout(options || {});
}
_navigationTimeout(options) {
const timeoutSettings = this._page?._timeoutSettings || new import_timeoutSettings.TimeoutSettings(this._platform);
return timeoutSettings.navigationTimeout(options || {});
}
async goto(url, options = {}) {
const waitUntil = verifyLoadState("waitUntil", options.waitUntil === void 0 ? "load" : options.waitUntil);
return network.Response.fromNullable((await this._channel.goto({ url, ...options, waitUntil, timeout: this._navigationTimeout(options) })).response);
}
_setupNavigationWaiter(options) {
const waiter = new import_waiter.Waiter(this._page, "");
if (this._page.isClosed())
waiter.rejectImmediately(this._page._closeErrorWithReason());
waiter.rejectOnEvent(this._page, import_events.Events.Page.Close, () => this._page._closeErrorWithReason());
waiter.rejectOnEvent(this._page, import_events.Events.Page.Crash, new Error("Navigation failed because page crashed!"));
waiter.rejectOnEvent(this._page, import_events.Events.Page.FrameDetached, new Error("Navigating frame was detached!"), (frame) => frame === this);
const timeout = this._page._timeoutSettings.navigationTimeout(options);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded.`);
return waiter;
}
async waitForNavigation(options = {}) {
return await this._page._wrapApiCall(async () => {
const waitUntil = verifyLoadState("waitUntil", options.waitUntil === void 0 ? "load" : options.waitUntil);
const waiter = this._setupNavigationWaiter(options);
const toUrl = typeof options.url === "string" ? ` to "${options.url}"` : "";
waiter.log(`waiting for navigation${toUrl} until "${waitUntil}"`);
const navigatedEvent = await waiter.waitForEvent(this._eventEmitter, "navigated", (event) => {
if (event.error)
return true;
waiter.log(` navigated to "${event.url}"`);
return (0, import_urlMatch.urlMatches)(this._page?.context()._options.baseURL, event.url, options.url);
});
if (navigatedEvent.error) {
const e = new Error(navigatedEvent.error);
e.stack = "";
await waiter.waitForPromise(Promise.reject(e));
}
if (!this._loadStates.has(waitUntil)) {
await waiter.waitForEvent(this._eventEmitter, "loadstate", (s) => {
waiter.log(` "${s}" event fired`);
return s === waitUntil;
});
}
const request = navigatedEvent.newDocument ? network.Request.fromNullable(navigatedEvent.newDocument.request) : null;
const response = request ? await waiter.waitForPromise(request._finalRequest()._internalResponse()) : null;
waiter.dispose();
return response;
}, { title: "Wait for navigation" });
}
async waitForLoadState(state = "load", options = {}) {
state = verifyLoadState("state", state);
return await this._page._wrapApiCall(async () => {
const waiter = this._setupNavigationWaiter(options);
if (this._loadStates.has(state)) {
waiter.log(` not waiting, "${state}" event already fired`);
} else {
await waiter.waitForEvent(this._eventEmitter, "loadstate", (s) => {
waiter.log(` "${s}" event fired`);
return s === state;
});
}
waiter.dispose();
}, { title: `Wait for load state "${state}"` });
}
async waitForURL(url, options = {}) {
if ((0, import_urlMatch.urlMatches)(this._page?.context()._options.baseURL, this.url(), url))
return await this.waitForLoadState(options.waitUntil, options);
await this.waitForNavigation({ url, ...options });
}
async frameElement() {
return import_elementHandle.ElementHandle.from((await this._channel.frameElement()).element);
}
async evaluateHandle(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return import_jsHandle.JSHandle.from(result.handle);
}
async evaluate(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async _evaluateExposeUtilityScript(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async $(selector, options) {
const result = await this._channel.querySelector({ selector, ...options });
return import_elementHandle.ElementHandle.fromNullable(result.element);
}
async waitForSelector(selector, options = {}) {
if (options.visibility)
throw new Error("options.visibility is not supported, did you mean options.state?");
if (options.waitFor && options.waitFor !== "visible")
throw new Error("options.waitFor is not supported, did you mean options.state?");
const result = await this._channel.waitForSelector({ selector, ...options, timeout: this._timeout(options) });
return import_elementHandle.ElementHandle.fromNullable(result.element);
}
async dispatchEvent(selector, type, eventInit, options = {}) {
await this._channel.dispatchEvent({ selector, type, eventInit: (0, import_jsHandle.serializeArgument)(eventInit), ...options, timeout: this._timeout(options) });
}
async $eval(selector, pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 3);
const result = await this._channel.evalOnSelector({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async $$eval(selector, pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 3);
const result = await this._channel.evalOnSelectorAll({ selector, expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async $$(selector) {
const result = await this._channel.querySelectorAll({ selector });
return result.elements.map((e) => import_elementHandle.ElementHandle.from(e));
}
async _queryCount(selector, options) {
return (await this._channel.queryCount({ selector, ...options })).value;
}
async content() {
return (await this._channel.content()).value;
}
async setContent(html, options = {}) {
const waitUntil = verifyLoadState("waitUntil", options.waitUntil === void 0 ? "load" : options.waitUntil);
await this._channel.setContent({ html, ...options, waitUntil, timeout: this._navigationTimeout(options) });
}
name() {
return this._name || "";
}
url() {
return this._url;
}
parentFrame() {
return this._parentFrame;
}
childFrames() {
return Array.from(this._childFrames);
}
isDetached() {
return this._detached;
}
async addScriptTag(options = {}) {
const copy = { ...options };
if (copy.path) {
copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString();
copy.content = (0, import_clientHelper.addSourceUrlToScript)(copy.content, copy.path);
}
return import_elementHandle.ElementHandle.from((await this._channel.addScriptTag({ ...copy })).element);
}
async addStyleTag(options = {}) {
const copy = { ...options };
if (copy.path) {
copy.content = (await this._platform.fs().promises.readFile(copy.path)).toString();
copy.content += "/*# sourceURL=" + copy.path.replace(/\n/g, "") + "*/";
}
return import_elementHandle.ElementHandle.from((await this._channel.addStyleTag({ ...copy })).element);
}
async click(selector, options = {}) {
return await this._channel.click({ selector, ...options, timeout: this._timeout(options) });
}
async dblclick(selector, options = {}) {
return await this._channel.dblclick({ selector, ...options, timeout: this._timeout(options) });
}
async dragAndDrop(source, target, options = {}) {
return await this._channel.dragAndDrop({ source, target, ...options, timeout: this._timeout(options) });
}
async tap(selector, options = {}) {
return await this._channel.tap({ selector, ...options, timeout: this._timeout(options) });
}
async fill(selector, value, options = {}) {
return await this._channel.fill({ selector, value, ...options, timeout: this._timeout(options) });
}
async _highlight(selector) {
return await this._channel.highlight({ selector });
}
locator(selector, options) {
return new import_locator.Locator(this, selector, options);
}
getByTestId(testId) {
return this.locator((0, import_locatorUtils.getByTestIdSelector)((0, import_locator.testIdAttributeName)(), testId));
}
getByAltText(text, options) {
return this.locator((0, import_locatorUtils.getByAltTextSelector)(text, options));
}
getByLabel(text, options) {
return this.locator((0, import_locatorUtils.getByLabelSelector)(text, options));
}
getByPlaceholder(text, options) {
return this.locator((0, import_locatorUtils.getByPlaceholderSelector)(text, options));
}
getByText(text, options) {
return this.locator((0, import_locatorUtils.getByTextSelector)(text, options));
}
getByTitle(text, options) {
return this.locator((0, import_locatorUtils.getByTitleSelector)(text, options));
}
getByRole(role, options = {}) {
return this.locator((0, import_locatorUtils.getByRoleSelector)(role, options));
}
frameLocator(selector) {
return new import_locator.FrameLocator(this, selector);
}
async focus(selector, options = {}) {
await this._channel.focus({ selector, ...options, timeout: this._timeout(options) });
}
async textContent(selector, options = {}) {
const value = (await this._channel.textContent({ selector, ...options, timeout: this._timeout(options) })).value;
return value === void 0 ? null : value;
}
async innerText(selector, options = {}) {
return (await this._channel.innerText({ selector, ...options, timeout: this._timeout(options) })).value;
}
async innerHTML(selector, options = {}) {
return (await this._channel.innerHTML({ selector, ...options, timeout: this._timeout(options) })).value;
}
async getAttribute(selector, name, options = {}) {
const value = (await this._channel.getAttribute({ selector, name, ...options, timeout: this._timeout(options) })).value;
return value === void 0 ? null : value;
}
async inputValue(selector, options = {}) {
return (await this._channel.inputValue({ selector, ...options, timeout: this._timeout(options) })).value;
}
async isChecked(selector, options = {}) {
return (await this._channel.isChecked({ selector, ...options, timeout: this._timeout(options) })).value;
}
async isDisabled(selector, options = {}) {
return (await this._channel.isDisabled({ selector, ...options, timeout: this._timeout(options) })).value;
}
async isEditable(selector, options = {}) {
return (await this._channel.isEditable({ selector, ...options, timeout: this._timeout(options) })).value;
}
async isEnabled(selector, options = {}) {
return (await this._channel.isEnabled({ selector, ...options, timeout: this._timeout(options) })).value;
}
async isHidden(selector, options = {}) {
return (await this._channel.isHidden({ selector, ...options })).value;
}
async isVisible(selector, options = {}) {
return (await this._channel.isVisible({ selector, ...options })).value;
}
async hover(selector, options = {}) {
await this._channel.hover({ selector, ...options, timeout: this._timeout(options) });
}
async selectOption(selector, values, options = {}) {
return (await this._channel.selectOption({ selector, ...(0, import_elementHandle.convertSelectOptionValues)(values), ...options, timeout: this._timeout(options) })).values;
}
async setInputFiles(selector, files, options = {}) {
const converted = await (0, import_elementHandle.convertInputFiles)(this._platform, files, this.page().context());
await this._channel.setInputFiles({ selector, ...converted, ...options, timeout: this._timeout(options) });
}
async type(selector, text, options = {}) {
await this._channel.type({ selector, text, ...options, timeout: this._timeout(options) });
}
async press(selector, key, options = {}) {
await this._channel.press({ selector, key, ...options, timeout: this._timeout(options) });
}
async check(selector, options = {}) {
await this._channel.check({ selector, ...options, timeout: this._timeout(options) });
}
async uncheck(selector, options = {}) {
await this._channel.uncheck({ selector, ...options, timeout: this._timeout(options) });
}
async setChecked(selector, checked, options) {
if (checked)
await this.check(selector, options);
else
await this.uncheck(selector, options);
}
async waitForTimeout(timeout) {
await this._channel.waitForTimeout({ waitTimeout: timeout });
}
async waitForFunction(pageFunction, arg, options = {}) {
if (typeof options.polling === "string")
(0, import_assert.assert)(options.polling === "raf", "Unknown polling option: " + options.polling);
const result = await this._channel.waitForFunction({
...options,
pollingInterval: options.polling === "raf" ? void 0 : options.polling,
expression: String(pageFunction),
isFunction: typeof pageFunction === "function",
arg: (0, import_jsHandle.serializeArgument)(arg),
timeout: this._timeout(options)
});
return import_jsHandle.JSHandle.from(result.handle);
}
async title() {
return (await this._channel.title()).value;
}
async _expect(expression, options) {
const params = { expression, ...options, isNot: !!options.isNot };
params.expectedValue = (0, import_jsHandle.serializeArgument)(options.expectedValue);
const result = await this._channel.expect(params);
if (result.received !== void 0)
result.received = (0, import_jsHandle.parseResult)(result.received);
return result;
}
}
function verifyLoadState(name, waitUntil) {
if (waitUntil === "networkidle0")
waitUntil = "networkidle";
if (!import_types.kLifecycleEvents.has(waitUntil))
throw new Error(`${name}: expected one of (load|domcontentloaded|networkidle|commit)`);
return waitUntil;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Frame,
verifyLoadState
});
@@ -0,0 +1,99 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var harRouter_exports = {};
__export(harRouter_exports, {
HarRouter: () => HarRouter
});
module.exports = __toCommonJS(harRouter_exports);
class HarRouter {
static async create(localUtils, file, notFoundAction, options) {
const { harId, error } = await localUtils.harOpen({ file });
if (error)
throw new Error(error);
return new HarRouter(localUtils, harId, notFoundAction, options);
}
constructor(localUtils, harId, notFoundAction, options) {
this._localUtils = localUtils;
this._harId = harId;
this._options = options;
this._notFoundAction = notFoundAction;
}
async _handle(route) {
const request = route.request();
const response = await this._localUtils.harLookup({
harId: this._harId,
url: request.url(),
method: request.method(),
headers: await request.headersArray(),
postData: request.postDataBuffer() || void 0,
isNavigationRequest: request.isNavigationRequest()
});
if (response.action === "redirect") {
route._platform.log("api", `HAR: ${route.request().url()} redirected to ${response.redirectURL}`);
await route._redirectNavigationRequest(response.redirectURL);
return;
}
if (response.action === "fulfill") {
if (response.status === -1)
return;
const transformedHeaders = response.headers.reduce((headersMap, { name, value }) => {
if (name.toLowerCase() !== "set-cookie") {
headersMap[name] = value;
} else {
if (!headersMap["set-cookie"])
headersMap["set-cookie"] = value;
else
headersMap["set-cookie"] += `
${value}`;
}
return headersMap;
}, {});
await route.fulfill({
status: response.status,
headers: transformedHeaders,
body: response.body
});
return;
}
if (response.action === "error")
route._platform.log("api", "HAR: " + response.message);
if (this._notFoundAction === "abort") {
await route.abort();
return;
}
await route.fallback();
}
async addContextRoute(context) {
await context.route(this._options.urlMatch || "**/*", (route) => this._handle(route));
}
async addPageRoute(page) {
await page.route(this._options.urlMatch || "**/*", (route) => this._handle(route));
}
async [Symbol.asyncDispose]() {
await this.dispose();
}
dispose() {
this._localUtils.harClose({ harId: this._harId }).catch(() => {
});
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
HarRouter
});
@@ -0,0 +1,84 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var input_exports = {};
__export(input_exports, {
Keyboard: () => Keyboard,
Mouse: () => Mouse,
Touchscreen: () => Touchscreen
});
module.exports = __toCommonJS(input_exports);
class Keyboard {
constructor(page) {
this._page = page;
}
async down(key) {
await this._page._channel.keyboardDown({ key });
}
async up(key) {
await this._page._channel.keyboardUp({ key });
}
async insertText(text) {
await this._page._channel.keyboardInsertText({ text });
}
async type(text, options = {}) {
await this._page._channel.keyboardType({ text, ...options });
}
async press(key, options = {}) {
await this._page._channel.keyboardPress({ key, ...options });
}
}
class Mouse {
constructor(page) {
this._page = page;
}
async move(x, y, options = {}) {
await this._page._channel.mouseMove({ x, y, ...options });
}
async down(options = {}) {
await this._page._channel.mouseDown({ ...options });
}
async up(options = {}) {
await this._page._channel.mouseUp(options);
}
async click(x, y, options = {}) {
await this._page._channel.mouseClick({ x, y, ...options });
}
async dblclick(x, y, options = {}) {
await this._page._wrapApiCall(async () => {
await this.click(x, y, { ...options, clickCount: 2 });
}, { title: "Double click" });
}
async wheel(deltaX, deltaY) {
await this._page._channel.mouseWheel({ deltaX, deltaY });
}
}
class Touchscreen {
constructor(page) {
this._page = page;
}
async tap(x, y) {
await this._page._channel.touchscreenTap({ x, y });
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Keyboard,
Mouse,
Touchscreen
});
@@ -0,0 +1,105 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var jsHandle_exports = {};
__export(jsHandle_exports, {
JSHandle: () => JSHandle,
assertMaxArguments: () => assertMaxArguments,
parseResult: () => parseResult,
serializeArgument: () => serializeArgument
});
module.exports = __toCommonJS(jsHandle_exports);
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
var import_serializers = require("../protocol/serializers");
class JSHandle extends import_channelOwner.ChannelOwner {
static from(handle) {
return handle._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._preview = this._initializer.preview;
this._channel.on("previewUpdated", ({ preview }) => this._preview = preview);
}
async evaluate(pageFunction, arg) {
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: serializeArgument(arg) });
return parseResult(result.value);
}
async evaluateHandle(pageFunction, arg) {
const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: serializeArgument(arg) });
return JSHandle.from(result.handle);
}
async getProperty(propertyName) {
const result = await this._channel.getProperty({ name: propertyName });
return JSHandle.from(result.handle);
}
async getProperties() {
const map = /* @__PURE__ */ new Map();
for (const { name, value } of (await this._channel.getPropertyList()).properties)
map.set(name, JSHandle.from(value));
return map;
}
async jsonValue() {
return parseResult((await this._channel.jsonValue()).value);
}
asElement() {
return null;
}
async [Symbol.asyncDispose]() {
await this.dispose();
}
async dispose() {
try {
await this._channel.dispose();
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e))
return;
throw e;
}
}
toString() {
return this._preview;
}
}
function serializeArgument(arg) {
const handles = [];
const pushHandle = (channel) => {
handles.push(channel);
return handles.length - 1;
};
const value = (0, import_serializers.serializeValue)(arg, (value2) => {
if (value2 instanceof JSHandle)
return { h: pushHandle(value2._channel) };
return { fallThrough: value2 };
});
return { value, handles };
}
function parseResult(value) {
return (0, import_serializers.parseSerializedValue)(value, void 0);
}
function assertMaxArguments(count, max) {
if (count > max)
throw new Error("Too many arguments. If you need to pass more than 1 argument to the function wrap them in an object.");
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
JSHandle,
assertMaxArguments,
parseResult,
serializeArgument
});
@@ -0,0 +1,39 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var jsonPipe_exports = {};
__export(jsonPipe_exports, {
JsonPipe: () => JsonPipe
});
module.exports = __toCommonJS(jsonPipe_exports);
var import_channelOwner = require("./channelOwner");
class JsonPipe extends import_channelOwner.ChannelOwner {
static from(jsonPipe) {
return jsonPipe._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
}
channel() {
return this._channel;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
JsonPipe
});
@@ -0,0 +1,60 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var localUtils_exports = {};
__export(localUtils_exports, {
LocalUtils: () => LocalUtils
});
module.exports = __toCommonJS(localUtils_exports);
var import_channelOwner = require("./channelOwner");
class LocalUtils extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this.devices = {};
for (const { name, descriptor } of initializer.deviceDescriptors)
this.devices[name] = descriptor;
}
async zip(params) {
return await this._channel.zip(params);
}
async harOpen(params) {
return await this._channel.harOpen(params);
}
async harLookup(params) {
return await this._channel.harLookup(params);
}
async harClose(params) {
return await this._channel.harClose(params);
}
async harUnzip(params) {
return await this._channel.harUnzip(params);
}
async tracingStarted(params) {
return await this._channel.tracingStarted(params);
}
async traceDiscarded(params) {
return await this._channel.traceDiscarded(params);
}
async addStackToTracingNoReply(params) {
return await this._channel.addStackToTracingNoReply(params);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
LocalUtils
});
@@ -0,0 +1,367 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var locator_exports = {};
__export(locator_exports, {
FrameLocator: () => FrameLocator,
Locator: () => Locator,
setTestIdAttribute: () => setTestIdAttribute,
testIdAttributeName: () => testIdAttributeName
});
module.exports = __toCommonJS(locator_exports);
var import_elementHandle = require("./elementHandle");
var import_locatorGenerators = require("../utils/isomorphic/locatorGenerators");
var import_locatorUtils = require("../utils/isomorphic/locatorUtils");
var import_stringUtils = require("../utils/isomorphic/stringUtils");
var import_rtti = require("../utils/isomorphic/rtti");
var import_time = require("../utils/isomorphic/time");
class Locator {
constructor(frame, selector, options) {
this._frame = frame;
this._selector = selector;
if (options?.hasText)
this._selector += ` >> internal:has-text=${(0, import_stringUtils.escapeForTextSelector)(options.hasText, false)}`;
if (options?.hasNotText)
this._selector += ` >> internal:has-not-text=${(0, import_stringUtils.escapeForTextSelector)(options.hasNotText, false)}`;
if (options?.has) {
const locator = options.has;
if (locator._frame !== frame)
throw new Error(`Inner "has" locator must belong to the same frame.`);
this._selector += ` >> internal:has=` + JSON.stringify(locator._selector);
}
if (options?.hasNot) {
const locator = options.hasNot;
if (locator._frame !== frame)
throw new Error(`Inner "hasNot" locator must belong to the same frame.`);
this._selector += ` >> internal:has-not=` + JSON.stringify(locator._selector);
}
if (options?.visible !== void 0)
this._selector += ` >> visible=${options.visible ? "true" : "false"}`;
if (this._frame._platform.inspectCustom)
this[this._frame._platform.inspectCustom] = () => this._inspect();
}
async _withElement(task, options) {
const timeout = this._frame._timeout({ timeout: options.timeout });
const deadline = timeout ? (0, import_time.monotonicTime)() + timeout : 0;
return await this._frame._wrapApiCall(async () => {
const result = await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, state: "attached", timeout });
const handle = import_elementHandle.ElementHandle.fromNullable(result.element);
if (!handle)
throw new Error(`Could not resolve ${this._selector} to DOM Element`);
try {
return await task(handle, deadline ? deadline - (0, import_time.monotonicTime)() : 0);
} finally {
await handle.dispose();
}
}, { title: options.title, internal: options.internal });
}
_equals(locator) {
return this._frame === locator._frame && this._selector === locator._selector;
}
page() {
return this._frame.page();
}
async boundingBox(options) {
return await this._withElement((h) => h.boundingBox(), { title: "Bounding box", timeout: options?.timeout });
}
async check(options = {}) {
return await this._frame.check(this._selector, { strict: true, ...options });
}
async click(options = {}) {
return await this._frame.click(this._selector, { strict: true, ...options });
}
async dblclick(options = {}) {
await this._frame.dblclick(this._selector, { strict: true, ...options });
}
async dispatchEvent(type, eventInit = {}, options) {
return await this._frame.dispatchEvent(this._selector, type, eventInit, { strict: true, ...options });
}
async dragTo(target, options = {}) {
return await this._frame.dragAndDrop(this._selector, target._selector, {
strict: true,
...options
});
}
async evaluate(pageFunction, arg, options) {
return await this._withElement((h) => h.evaluate(pageFunction, arg), { title: "Evaluate", timeout: options?.timeout });
}
async evaluateAll(pageFunction, arg) {
return await this._frame.$$eval(this._selector, pageFunction, arg);
}
async evaluateHandle(pageFunction, arg, options) {
return await this._withElement((h) => h.evaluateHandle(pageFunction, arg), { title: "Evaluate", timeout: options?.timeout });
}
async fill(value, options = {}) {
return await this._frame.fill(this._selector, value, { strict: true, ...options });
}
async clear(options = {}) {
await this._frame._wrapApiCall(() => this.fill("", options), { title: "Clear" });
}
async _highlight() {
return await this._frame._highlight(this._selector);
}
async highlight() {
return await this._frame._highlight(this._selector);
}
locator(selectorOrLocator, options) {
if ((0, import_rtti.isString)(selectorOrLocator))
return new Locator(this._frame, this._selector + " >> " + selectorOrLocator, options);
if (selectorOrLocator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`);
return new Locator(this._frame, this._selector + " >> internal:chain=" + JSON.stringify(selectorOrLocator._selector), options);
}
getByTestId(testId) {
return this.locator((0, import_locatorUtils.getByTestIdSelector)(testIdAttributeName(), testId));
}
getByAltText(text, options) {
return this.locator((0, import_locatorUtils.getByAltTextSelector)(text, options));
}
getByLabel(text, options) {
return this.locator((0, import_locatorUtils.getByLabelSelector)(text, options));
}
getByPlaceholder(text, options) {
return this.locator((0, import_locatorUtils.getByPlaceholderSelector)(text, options));
}
getByText(text, options) {
return this.locator((0, import_locatorUtils.getByTextSelector)(text, options));
}
getByTitle(text, options) {
return this.locator((0, import_locatorUtils.getByTitleSelector)(text, options));
}
getByRole(role, options = {}) {
return this.locator((0, import_locatorUtils.getByRoleSelector)(role, options));
}
frameLocator(selector) {
return new FrameLocator(this._frame, this._selector + " >> " + selector);
}
filter(options) {
return new Locator(this._frame, this._selector, options);
}
async elementHandle(options) {
return await this._frame.waitForSelector(this._selector, { strict: true, state: "attached", ...options });
}
async elementHandles() {
return await this._frame.$$(this._selector);
}
contentFrame() {
return new FrameLocator(this._frame, this._selector);
}
describe(description) {
return new Locator(this._frame, this._selector + " >> internal:describe=" + JSON.stringify(description));
}
description() {
return (0, import_locatorGenerators.locatorCustomDescription)(this._selector) || null;
}
first() {
return new Locator(this._frame, this._selector + " >> nth=0");
}
last() {
return new Locator(this._frame, this._selector + ` >> nth=-1`);
}
nth(index) {
return new Locator(this._frame, this._selector + ` >> nth=${index}`);
}
and(locator) {
if (locator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`);
return new Locator(this._frame, this._selector + ` >> internal:and=` + JSON.stringify(locator._selector));
}
or(locator) {
if (locator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`);
return new Locator(this._frame, this._selector + ` >> internal:or=` + JSON.stringify(locator._selector));
}
async focus(options) {
return await this._frame.focus(this._selector, { strict: true, ...options });
}
async blur(options) {
await this._frame._channel.blur({ selector: this._selector, strict: true, ...options, timeout: this._frame._timeout(options) });
}
// options are only here for testing
async count(_options) {
return await this._frame._queryCount(this._selector, _options);
}
async normalize() {
const { resolvedSelector } = await this._frame._channel.resolveSelector({ selector: this._selector });
return new Locator(this._frame, resolvedSelector);
}
async getAttribute(name, options) {
return await this._frame.getAttribute(this._selector, name, { strict: true, ...options });
}
async hover(options = {}) {
return await this._frame.hover(this._selector, { strict: true, ...options });
}
async innerHTML(options) {
return await this._frame.innerHTML(this._selector, { strict: true, ...options });
}
async innerText(options) {
return await this._frame.innerText(this._selector, { strict: true, ...options });
}
async inputValue(options) {
return await this._frame.inputValue(this._selector, { strict: true, ...options });
}
async isChecked(options) {
return await this._frame.isChecked(this._selector, { strict: true, ...options });
}
async isDisabled(options) {
return await this._frame.isDisabled(this._selector, { strict: true, ...options });
}
async isEditable(options) {
return await this._frame.isEditable(this._selector, { strict: true, ...options });
}
async isEnabled(options) {
return await this._frame.isEnabled(this._selector, { strict: true, ...options });
}
async isHidden(options) {
return await this._frame.isHidden(this._selector, { strict: true, ...options });
}
async isVisible(options) {
return await this._frame.isVisible(this._selector, { strict: true, ...options });
}
async press(key, options = {}) {
return await this._frame.press(this._selector, key, { strict: true, ...options });
}
async screenshot(options = {}) {
const mask = options.mask;
return await this._withElement((h, timeout) => h.screenshot({ ...options, mask, timeout }), { title: "Screenshot", timeout: options.timeout });
}
async ariaSnapshot(options = {}) {
const result = await this._frame._channel.ariaSnapshot({ timeout: this._frame._timeout(options), mode: options.mode, selector: this._selector, depth: options.depth });
return result.snapshot;
}
async scrollIntoViewIfNeeded(options = {}) {
return await this._withElement((h, timeout) => h.scrollIntoViewIfNeeded({ ...options, timeout }), { title: "Scroll into view", timeout: options.timeout });
}
async selectOption(values, options = {}) {
return await this._frame.selectOption(this._selector, values, { strict: true, ...options });
}
async selectText(options = {}) {
return await this._withElement((h, timeout) => h.selectText({ ...options, timeout }), { title: "Select text", timeout: options.timeout });
}
async setChecked(checked, options) {
if (checked)
await this.check(options);
else
await this.uncheck(options);
}
async setInputFiles(files, options = {}) {
return await this._frame.setInputFiles(this._selector, files, { strict: true, ...options });
}
async tap(options = {}) {
return await this._frame.tap(this._selector, { strict: true, ...options });
}
async textContent(options) {
return await this._frame.textContent(this._selector, { strict: true, ...options });
}
async type(text, options = {}) {
return await this._frame.type(this._selector, text, { strict: true, ...options });
}
async pressSequentially(text, options = {}) {
return await this.type(text, options);
}
async uncheck(options = {}) {
return await this._frame.uncheck(this._selector, { strict: true, ...options });
}
async all() {
return new Array(await this.count()).fill(0).map((e, i) => this.nth(i));
}
async allInnerTexts() {
return await this._frame.$$eval(this._selector, (ee) => ee.map((e) => e.innerText));
}
async allTextContents() {
return await this._frame.$$eval(this._selector, (ee) => ee.map((e) => e.textContent || ""));
}
async waitFor(options) {
await this._frame._channel.waitForSelector({ selector: this._selector, strict: true, omitReturnValue: true, ...options, timeout: this._frame._timeout(options) });
}
async _expect(expression, options) {
return this._frame._expect(expression, {
...options,
selector: this._selector
});
}
_inspect() {
return this.toString();
}
toString() {
return (0, import_locatorGenerators.asLocatorDescription)("javascript", this._selector);
}
}
class FrameLocator {
constructor(frame, selector) {
this._frame = frame;
this._frameSelector = selector;
}
locator(selectorOrLocator, options) {
if ((0, import_rtti.isString)(selectorOrLocator))
return new Locator(this._frame, this._frameSelector + " >> internal:control=enter-frame >> " + selectorOrLocator, options);
if (selectorOrLocator._frame !== this._frame)
throw new Error(`Locators must belong to the same frame.`);
return new Locator(this._frame, this._frameSelector + " >> internal:control=enter-frame >> " + selectorOrLocator._selector, options);
}
getByTestId(testId) {
return this.locator((0, import_locatorUtils.getByTestIdSelector)(testIdAttributeName(), testId));
}
getByAltText(text, options) {
return this.locator((0, import_locatorUtils.getByAltTextSelector)(text, options));
}
getByLabel(text, options) {
return this.locator((0, import_locatorUtils.getByLabelSelector)(text, options));
}
getByPlaceholder(text, options) {
return this.locator((0, import_locatorUtils.getByPlaceholderSelector)(text, options));
}
getByText(text, options) {
return this.locator((0, import_locatorUtils.getByTextSelector)(text, options));
}
getByTitle(text, options) {
return this.locator((0, import_locatorUtils.getByTitleSelector)(text, options));
}
getByRole(role, options = {}) {
return this.locator((0, import_locatorUtils.getByRoleSelector)(role, options));
}
owner() {
return new Locator(this._frame, this._frameSelector);
}
frameLocator(selector) {
return new FrameLocator(this._frame, this._frameSelector + " >> internal:control=enter-frame >> " + selector);
}
first() {
return new FrameLocator(this._frame, this._frameSelector + " >> nth=0");
}
last() {
return new FrameLocator(this._frame, this._frameSelector + ` >> nth=-1`);
}
nth(index) {
return new FrameLocator(this._frame, this._frameSelector + ` >> nth=${index}`);
}
}
let _testIdAttributeName = "data-testid";
function testIdAttributeName() {
return _testIdAttributeName;
}
function setTestIdAttribute(attributeName) {
_testIdAttributeName = attributeName;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
FrameLocator,
Locator,
setTestIdAttribute,
testIdAttributeName
});
@@ -0,0 +1,750 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var network_exports = {};
__export(network_exports, {
RawHeaders: () => RawHeaders,
Request: () => Request,
Response: () => Response,
Route: () => Route,
RouteHandler: () => RouteHandler,
WebSocket: () => WebSocket,
WebSocketRoute: () => WebSocketRoute,
WebSocketRouteHandler: () => WebSocketRouteHandler,
validateHeaders: () => validateHeaders
});
module.exports = __toCommonJS(network_exports);
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
var import_events = require("./events");
var import_fetch = require("./fetch");
var import_frame = require("./frame");
var import_waiter = require("./waiter");
var import_worker = require("./worker");
var import_assert = require("../utils/isomorphic/assert");
var import_headers = require("../utils/isomorphic/headers");
var import_urlMatch = require("../utils/isomorphic/urlMatch");
var import_manualPromise = require("../utils/isomorphic/manualPromise");
var import_multimap = require("../utils/isomorphic/multimap");
var import_rtti = require("../utils/isomorphic/rtti");
var import_stackTrace = require("../utils/isomorphic/stackTrace");
var import_mimeType = require("../utils/isomorphic/mimeType");
class Request extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._redirectedFrom = null;
this._redirectedTo = null;
this._failureText = null;
this._response = null;
this._fallbackOverrides = {};
this._redirectedFrom = Request.fromNullable(initializer.redirectedFrom);
if (this._redirectedFrom)
this._redirectedFrom._redirectedTo = this;
this._provisionalHeaders = new RawHeaders(initializer.headers);
this._timing = {
startTime: 0,
domainLookupStart: -1,
domainLookupEnd: -1,
connectStart: -1,
secureConnectionStart: -1,
connectEnd: -1,
requestStart: -1,
responseStart: -1,
responseEnd: -1
};
}
static from(request) {
return request._object;
}
static fromNullable(request) {
return request ? Request.from(request) : null;
}
url() {
return this._fallbackOverrides.url || this._initializer.url;
}
resourceType() {
return this._initializer.resourceType;
}
method() {
return this._fallbackOverrides.method || this._initializer.method;
}
postData() {
return (this._fallbackOverrides.postDataBuffer || this._initializer.postData)?.toString("utf-8") || null;
}
postDataBuffer() {
return this._fallbackOverrides.postDataBuffer || this._initializer.postData || null;
}
postDataJSON() {
const postData = this.postData();
if (!postData)
return null;
const contentType = this.headers()["content-type"];
if (contentType?.includes("application/x-www-form-urlencoded")) {
const entries = {};
const parsed = new URLSearchParams(postData);
for (const [k, v] of parsed.entries())
entries[k] = v;
return entries;
}
try {
return JSON.parse(postData);
} catch (e) {
throw new Error("POST data is not a valid JSON object: " + postData);
}
}
/**
* @deprecated
*/
headers() {
if (this._fallbackOverrides.headers)
return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers).headers();
return this._provisionalHeaders.headers();
}
async _actualHeaders() {
if (this._fallbackOverrides.headers)
return RawHeaders._fromHeadersObjectLossy(this._fallbackOverrides.headers);
if (!this._actualHeadersPromise) {
this._actualHeadersPromise = this._wrapApiCall(async () => {
return new RawHeaders((await this._channel.rawRequestHeaders()).headers);
}, { internal: true });
}
return await this._actualHeadersPromise;
}
async allHeaders() {
return (await this._actualHeaders()).headers();
}
async headersArray() {
return (await this._actualHeaders()).headersArray();
}
async headerValue(name) {
return (await this._actualHeaders()).get(name);
}
async response() {
return Response.fromNullable((await this._channel.response()).response);
}
async _internalResponse() {
return Response.fromNullable((await this._channel.response()).response);
}
existingResponse() {
return this._response;
}
frame() {
if (!this._initializer.frame) {
(0, import_assert.assert)(this.serviceWorker());
throw new Error("Service Worker requests do not have an associated frame.");
}
const frame = import_frame.Frame.from(this._initializer.frame);
if (!frame._page) {
throw new Error([
"Frame for this navigation request is not available, because the request",
"was issued before the frame is created. You can check whether the request",
"is a navigation request by calling isNavigationRequest() method."
].join("\n"));
}
return frame;
}
_safePage() {
return import_frame.Frame.fromNullable(this._initializer.frame)?._page || null;
}
serviceWorker() {
return this._initializer.serviceWorker ? import_worker.Worker.from(this._initializer.serviceWorker) : null;
}
isNavigationRequest() {
return this._initializer.isNavigationRequest;
}
redirectedFrom() {
return this._redirectedFrom;
}
redirectedTo() {
return this._redirectedTo;
}
failure() {
if (this._failureText === null)
return null;
return {
errorText: this._failureText
};
}
timing() {
return this._timing;
}
async sizes() {
const response = await this.response();
if (!response)
throw new Error("Unable to fetch sizes for failed request");
return (await response._channel.sizes()).sizes;
}
_setResponseEndTiming(responseEndTiming) {
this._timing.responseEnd = responseEndTiming;
if (this._timing.responseStart === -1)
this._timing.responseStart = responseEndTiming;
}
_finalRequest() {
return this._redirectedTo ? this._redirectedTo._finalRequest() : this;
}
_applyFallbackOverrides(overrides) {
if (overrides.url)
this._fallbackOverrides.url = overrides.url;
if (overrides.method)
this._fallbackOverrides.method = overrides.method;
if (overrides.headers)
this._fallbackOverrides.headers = overrides.headers;
if ((0, import_rtti.isString)(overrides.postData))
this._fallbackOverrides.postDataBuffer = Buffer.from(overrides.postData, "utf-8");
else if (overrides.postData instanceof Buffer)
this._fallbackOverrides.postDataBuffer = overrides.postData;
else if (overrides.postData)
this._fallbackOverrides.postDataBuffer = Buffer.from(JSON.stringify(overrides.postData), "utf-8");
}
_fallbackOverridesForContinue() {
return this._fallbackOverrides;
}
_targetClosedScope() {
return this.serviceWorker()?._closedScope || this._safePage()?._closedOrCrashedScope || new import_manualPromise.LongStandingScope();
}
}
class Route extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._handlingPromise = null;
this._didThrow = false;
}
static from(route) {
return route._object;
}
request() {
return Request.from(this._initializer.request);
}
async _raceWithTargetClose(promise) {
return await this.request()._targetClosedScope().safeRace(promise);
}
async _startHandling() {
this._handlingPromise = new import_manualPromise.ManualPromise();
return await this._handlingPromise;
}
async fallback(options = {}) {
this._checkNotHandled();
this.request()._applyFallbackOverrides(options);
this._reportHandled(false);
}
async abort(errorCode) {
await this._handleRoute(async () => {
await this._raceWithTargetClose(this._channel.abort({ errorCode }));
});
}
async _redirectNavigationRequest(url) {
await this._handleRoute(async () => {
await this._raceWithTargetClose(this._channel.redirectNavigationRequest({ url }));
});
}
async fetch(options = {}) {
return await this._wrapApiCall(async () => {
return await this._context.request._innerFetch({ request: this.request(), data: options.postData, ...options });
});
}
async fulfill(options = {}) {
await this._handleRoute(async () => {
await this._innerFulfill(options);
});
}
async _handleRoute(callback) {
this._checkNotHandled();
try {
await callback();
this._reportHandled(true);
} catch (e) {
this._didThrow = true;
throw e;
}
}
async _innerFulfill(options = {}) {
let fetchResponseUid;
let { status: statusOption, headers: headersOption, body } = options;
if (options.json !== void 0) {
(0, import_assert.assert)(options.body === void 0, "Can specify either body or json parameters");
body = JSON.stringify(options.json);
}
if (options.response instanceof import_fetch.APIResponse) {
statusOption ??= options.response.status();
headersOption ??= options.response.headers();
if (body === void 0 && options.path === void 0) {
if (options.response._request._connection === this._connection)
fetchResponseUid = options.response._fetchUid();
else
body = await options.response.body();
}
}
let isBase64 = false;
let length = 0;
if (options.path) {
const buffer = await this._platform.fs().promises.readFile(options.path);
body = buffer.toString("base64");
isBase64 = true;
length = buffer.length;
} else if ((0, import_rtti.isString)(body)) {
isBase64 = false;
length = Buffer.byteLength(body);
} else if (body) {
length = body.length;
body = body.toString("base64");
isBase64 = true;
}
const headers = {};
for (const header of Object.keys(headersOption || {}))
headers[header.toLowerCase()] = String(headersOption[header]);
if (options.contentType)
headers["content-type"] = String(options.contentType);
else if (options.json)
headers["content-type"] = "application/json";
else if (options.path)
headers["content-type"] = (0, import_mimeType.getMimeTypeForPath)(options.path) || "application/octet-stream";
if (length && !("content-length" in headers))
headers["content-length"] = String(length);
await this._raceWithTargetClose(this._channel.fulfill({
status: statusOption || 200,
headers: (0, import_headers.headersObjectToArray)(headers),
body,
isBase64,
fetchResponseUid
}));
}
async continue(options = {}) {
await this._handleRoute(async () => {
this.request()._applyFallbackOverrides(options);
await this._innerContinue(
false
/* isFallback */
);
});
}
_checkNotHandled() {
if (!this._handlingPromise)
throw new Error("Route is already handled!");
}
_reportHandled(done) {
const chain = this._handlingPromise;
this._handlingPromise = null;
chain.resolve(done);
}
async _innerContinue(isFallback) {
const options = this.request()._fallbackOverridesForContinue();
return await this._raceWithTargetClose(this._channel.continue({
url: options.url,
method: options.method,
headers: options.headers ? (0, import_headers.headersObjectToArray)(options.headers) : void 0,
postData: options.postDataBuffer,
isFallback
}));
}
}
class WebSocketRoute extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._connected = false;
this._server = {
onMessage: (handler) => {
this._onServerMessage = handler;
},
onClose: (handler) => {
this._onServerClose = handler;
},
connectToServer: () => {
throw new Error(`connectToServer must be called on the page-side WebSocketRoute`);
},
url: () => {
return this._initializer.url;
},
close: async (options = {}) => {
await this._channel.closeServer({ ...options, wasClean: true }).catch(() => {
});
},
send: (message) => {
if ((0, import_rtti.isString)(message))
this._channel.sendToServer({ message, isBase64: false }).catch(() => {
});
else
this._channel.sendToServer({ message: message.toString("base64"), isBase64: true }).catch(() => {
});
},
async [Symbol.asyncDispose]() {
await this.close();
}
};
this._channel.on("messageFromPage", ({ message, isBase64 }) => {
if (this._onPageMessage)
this._onPageMessage(isBase64 ? Buffer.from(message, "base64") : message);
else if (this._connected)
this._channel.sendToServer({ message, isBase64 }).catch(() => {
});
});
this._channel.on("messageFromServer", ({ message, isBase64 }) => {
if (this._onServerMessage)
this._onServerMessage(isBase64 ? Buffer.from(message, "base64") : message);
else
this._channel.sendToPage({ message, isBase64 }).catch(() => {
});
});
this._channel.on("closePage", ({ code, reason, wasClean }) => {
if (this._onPageClose)
this._onPageClose(code, reason);
else
this._channel.closeServer({ code, reason, wasClean }).catch(() => {
});
});
this._channel.on("closeServer", ({ code, reason, wasClean }) => {
if (this._onServerClose)
this._onServerClose(code, reason);
else
this._channel.closePage({ code, reason, wasClean }).catch(() => {
});
});
}
static from(route) {
return route._object;
}
url() {
return this._initializer.url;
}
async close(options = {}) {
await this._channel.closePage({ ...options, wasClean: true }).catch(() => {
});
}
connectToServer() {
if (this._connected)
throw new Error("Already connected to the server");
this._connected = true;
this._channel.connect().catch(() => {
});
return this._server;
}
send(message) {
if ((0, import_rtti.isString)(message))
this._channel.sendToPage({ message, isBase64: false }).catch(() => {
});
else
this._channel.sendToPage({ message: message.toString("base64"), isBase64: true }).catch(() => {
});
}
onMessage(handler) {
this._onPageMessage = handler;
}
onClose(handler) {
this._onPageClose = handler;
}
async [Symbol.asyncDispose]() {
await this.close();
}
async _afterHandle() {
if (this._connected)
return;
await this._channel.ensureOpened().catch(() => {
});
}
}
class WebSocketRouteHandler {
constructor(baseURL, url, handler) {
this._baseURL = baseURL;
this.url = url;
this.handler = handler;
}
static prepareInterceptionPatterns(handlers) {
const patterns = [];
let all = false;
for (const handler of handlers) {
const serialized = (0, import_urlMatch.serializeURLMatch)(handler.url);
if (serialized)
patterns.push(serialized);
else
all = true;
}
if (all)
return [{ glob: "**/*" }];
return patterns;
}
matches(wsURL) {
return (0, import_urlMatch.urlMatches)(this._baseURL, wsURL, this.url, true);
}
async handle(webSocketRoute) {
const handler = this.handler;
await handler(webSocketRoute);
await webSocketRoute._afterHandle();
}
}
class Response extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._finishedPromise = new import_manualPromise.ManualPromise();
this._provisionalHeaders = new RawHeaders(initializer.headers);
this._request = Request.from(this._initializer.request);
this._request._response = this;
Object.assign(this._request._timing, this._initializer.timing);
}
static from(response) {
return response._object;
}
static fromNullable(response) {
return response ? Response.from(response) : null;
}
url() {
return this._initializer.url;
}
ok() {
return this._initializer.status === 0 || this._initializer.status >= 200 && this._initializer.status <= 299;
}
status() {
return this._initializer.status;
}
statusText() {
return this._initializer.statusText;
}
fromServiceWorker() {
return this._initializer.fromServiceWorker;
}
/**
* @deprecated
*/
headers() {
return this._provisionalHeaders.headers();
}
async _actualHeaders() {
if (!this._actualHeadersPromise) {
this._actualHeadersPromise = (async () => {
return new RawHeaders((await this._channel.rawResponseHeaders()).headers);
})();
}
return await this._actualHeadersPromise;
}
async allHeaders() {
return (await this._actualHeaders()).headers();
}
async headersArray() {
return (await this._actualHeaders()).headersArray().slice();
}
async headerValue(name) {
return (await this._actualHeaders()).get(name);
}
async headerValues(name) {
return (await this._actualHeaders()).getAll(name);
}
async finished() {
return await this.request()._targetClosedScope().race(this._finishedPromise);
}
async body() {
return (await this._channel.body()).binary;
}
async text() {
const content = await this.body();
return content.toString("utf8");
}
async json() {
const content = await this.text();
return JSON.parse(content);
}
request() {
return this._request;
}
frame() {
return this._request.frame();
}
async serverAddr() {
return (await this._channel.serverAddr()).value || null;
}
async securityDetails() {
return (await this._channel.securityDetails()).value || null;
}
async httpVersion() {
return (await this._channel.httpVersion()).value;
}
}
class WebSocket extends import_channelOwner.ChannelOwner {
static from(webSocket) {
return webSocket._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._isClosed = false;
this._page = parent;
this._channel.on("frameSent", (event) => {
if (event.opcode === 1)
this.emit(import_events.Events.WebSocket.FrameSent, { payload: event.data });
else if (event.opcode === 2)
this.emit(import_events.Events.WebSocket.FrameSent, { payload: Buffer.from(event.data, "base64") });
});
this._channel.on("frameReceived", (event) => {
if (event.opcode === 1)
this.emit(import_events.Events.WebSocket.FrameReceived, { payload: event.data });
else if (event.opcode === 2)
this.emit(import_events.Events.WebSocket.FrameReceived, { payload: Buffer.from(event.data, "base64") });
});
this._channel.on("socketError", ({ error }) => this.emit(import_events.Events.WebSocket.Error, error));
this._channel.on("close", () => {
this._isClosed = true;
this.emit(import_events.Events.WebSocket.Close, this);
});
}
url() {
return this._initializer.url;
}
isClosed() {
return this._isClosed;
}
async waitForEvent(event, optionsOrPredicate = {}) {
return await this._wrapApiCall(async () => {
const timeout = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = import_waiter.Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== import_events.Events.WebSocket.Error)
waiter.rejectOnEvent(this, import_events.Events.WebSocket.Error, new Error("Socket error"));
if (event !== import_events.Events.WebSocket.Close)
waiter.rejectOnEvent(this, import_events.Events.WebSocket.Close, new Error("Socket closed"));
waiter.rejectOnEvent(this._page, import_events.Events.Page.Close, () => this._page._closeErrorWithReason());
const result = await waiter.waitForEvent(this, event, predicate);
waiter.dispose();
return result;
});
}
}
function validateHeaders(headers) {
for (const key of Object.keys(headers)) {
const value = headers[key];
if (!Object.is(value, void 0) && !(0, import_rtti.isString)(value))
throw new Error(`Expected value of header "${key}" to be String, but "${typeof value}" is found.`);
}
}
class RouteHandler {
constructor(platform, baseURL, url, handler, times = Number.MAX_SAFE_INTEGER) {
this.handledCount = 0;
this._ignoreException = false;
this._activeInvocations = /* @__PURE__ */ new Set();
this._baseURL = baseURL;
this._times = times;
this.url = url;
this.handler = handler;
this._savedZone = platform.zones.current().pop();
}
static prepareInterceptionPatterns(handlers) {
const patterns = [];
let all = false;
for (const handler of handlers) {
const serialized = (0, import_urlMatch.serializeURLMatch)(handler.url);
if (serialized)
patterns.push(serialized);
else
all = true;
}
if (all)
return [{ glob: "**/*" }];
return patterns;
}
matches(requestURL) {
return (0, import_urlMatch.urlMatches)(this._baseURL, requestURL, this.url);
}
async handle(route) {
return await this._savedZone.run(async () => this._handleImpl(route));
}
async _handleImpl(route) {
const handlerInvocation = { complete: new import_manualPromise.ManualPromise(), route };
this._activeInvocations.add(handlerInvocation);
try {
return await this._handleInternal(route);
} catch (e) {
if (this._ignoreException)
return false;
if ((0, import_errors.isTargetClosedError)(e)) {
(0, import_stackTrace.rewriteErrorMessage)(e, `"${e.message}" while running route callback.
Consider awaiting \`await page.unrouteAll({ behavior: 'ignoreErrors' })\`
before the end of the test to ignore remaining routes in flight.`);
}
throw e;
} finally {
handlerInvocation.complete.resolve();
this._activeInvocations.delete(handlerInvocation);
}
}
async stop(behavior) {
if (behavior === "ignoreErrors") {
this._ignoreException = true;
} else {
const promises = [];
for (const activation of this._activeInvocations) {
if (!activation.route._didThrow)
promises.push(activation.complete);
}
await Promise.all(promises);
}
}
async _handleInternal(route) {
++this.handledCount;
const handledPromise = route._startHandling();
const handler = this.handler;
const [handled] = await Promise.all([
handledPromise,
handler(route, route.request())
]);
return handled;
}
willExpire() {
return this.handledCount + 1 >= this._times;
}
}
class RawHeaders {
constructor(headers) {
this._headersMap = new import_multimap.MultiMap();
this._headersArray = headers;
for (const header of headers)
this._headersMap.set(header.name.toLowerCase(), header.value);
}
static _fromHeadersObjectLossy(headers) {
const headersArray = Object.entries(headers).map(([name, value]) => ({
name,
value
})).filter((header) => header.value !== void 0);
return new RawHeaders(headersArray);
}
get(name) {
const values = this.getAll(name);
if (!values || !values.length)
return null;
return values.join(name.toLowerCase() === "set-cookie" ? "\n" : ", ");
}
getAll(name) {
return [...this._headersMap.get(name.toLowerCase())];
}
headers() {
const result = {};
for (const name of this._headersMap.keys())
result[name] = this.get(name);
return result;
}
headersArray() {
return this._headersArray;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
RawHeaders,
Request,
Response,
Route,
RouteHandler,
WebSocket,
WebSocketRoute,
WebSocketRouteHandler,
validateHeaders
});
@@ -0,0 +1,731 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var page_exports = {};
__export(page_exports, {
BindingCall: () => BindingCall,
Page: () => Page
});
module.exports = __toCommonJS(page_exports);
var import_artifact = require("./artifact");
var import_channelOwner = require("./channelOwner");
var import_clientHelper = require("./clientHelper");
var import_coverage = require("./coverage");
var import_disposable = require("./disposable");
var import_download = require("./download");
var import_elementHandle = require("./elementHandle");
var import_errors = require("./errors");
var import_events = require("./events");
var import_fileChooser = require("./fileChooser");
var import_frame = require("./frame");
var import_harRouter = require("./harRouter");
var import_input = require("./input");
var import_jsHandle = require("./jsHandle");
var import_network = require("./network");
var import_video = require("./video");
var import_screencast = require("./screencast");
var import_waiter = require("./waiter");
var import_worker = require("./worker");
var import_timeoutSettings = require("./timeoutSettings");
var import_assert = require("../utils/isomorphic/assert");
var import_fileUtils = require("./fileUtils");
var import_headers = require("../utils/isomorphic/headers");
var import_stringUtils = require("../utils/isomorphic/stringUtils");
var import_urlMatch = require("../utils/isomorphic/urlMatch");
var import_manualPromise = require("../utils/isomorphic/manualPromise");
var import_rtti = require("../utils/isomorphic/rtti");
var import_consoleMessage = require("./consoleMessage");
class Page extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._frames = /* @__PURE__ */ new Set();
this._workers = /* @__PURE__ */ new Set();
this._closed = false;
this._closedOrCrashedScope = new import_manualPromise.LongStandingScope();
this._routes = [];
this._webSocketRoutes = [];
this._bindings = /* @__PURE__ */ new Map();
this._closeWasCalled = false;
this._harRouters = [];
this._locatorHandlers = /* @__PURE__ */ new Map();
this._instrumentation.onPage(this);
this._browserContext = parent;
this._timeoutSettings = new import_timeoutSettings.TimeoutSettings(this._platform, this._browserContext._timeoutSettings);
this.keyboard = new import_input.Keyboard(this);
this.mouse = new import_input.Mouse(this);
this.request = this._browserContext.request;
this.touchscreen = new import_input.Touchscreen(this);
this.clock = this._browserContext.clock;
this._mainFrame = import_frame.Frame.from(initializer.mainFrame);
this._mainFrame._page = this;
this._frames.add(this._mainFrame);
this._viewportSize = initializer.viewportSize;
this._closed = initializer.isClosed;
this._opener = Page.fromNullable(initializer.opener);
this._video = new import_video.Video(this, this._connection, initializer.video ? import_artifact.Artifact.from(initializer.video) : void 0);
this.screencast = new import_screencast.Screencast(this);
this._channel.on("bindingCall", ({ binding }) => this._onBinding(BindingCall.from(binding)));
this._channel.on("close", () => this._onClose());
this._channel.on("crash", () => this._onCrash());
this._channel.on("download", ({ url, suggestedFilename, artifact }) => {
const artifactObject = import_artifact.Artifact.from(artifact);
this.emit(import_events.Events.Page.Download, new import_download.Download(this, url, suggestedFilename, artifactObject));
});
this._channel.on("fileChooser", ({ element, isMultiple }) => this.emit(import_events.Events.Page.FileChooser, new import_fileChooser.FileChooser(this, import_elementHandle.ElementHandle.from(element), isMultiple)));
this._channel.on("frameAttached", ({ frame }) => this._onFrameAttached(import_frame.Frame.from(frame)));
this._channel.on("frameDetached", ({ frame }) => this._onFrameDetached(import_frame.Frame.from(frame)));
this._channel.on("locatorHandlerTriggered", ({ uid }) => this._onLocatorHandlerTriggered(uid));
this._channel.on("route", ({ route }) => this._onRoute(import_network.Route.from(route)));
this._channel.on("webSocketRoute", ({ webSocketRoute }) => this._onWebSocketRoute(import_network.WebSocketRoute.from(webSocketRoute)));
this._channel.on("viewportSizeChanged", ({ viewportSize }) => this._viewportSize = viewportSize);
this._channel.on("webSocket", ({ webSocket }) => this.emit(import_events.Events.Page.WebSocket, import_network.WebSocket.from(webSocket)));
this._channel.on("worker", ({ worker }) => this._onWorker(import_worker.Worker.from(worker)));
this.coverage = new import_coverage.Coverage(this._channel);
this.once(import_events.Events.Page.Close, () => this._closedOrCrashedScope.close(this._closeErrorWithReason()));
this.once(import_events.Events.Page.Crash, () => this._closedOrCrashedScope.close(new import_errors.TargetClosedError()));
this._setEventToSubscriptionMapping(/* @__PURE__ */ new Map([
[import_events.Events.Page.Console, "console"],
[import_events.Events.Page.Dialog, "dialog"],
[import_events.Events.Page.Request, "request"],
[import_events.Events.Page.Response, "response"],
[import_events.Events.Page.RequestFinished, "requestFinished"],
[import_events.Events.Page.RequestFailed, "requestFailed"],
[import_events.Events.Page.FileChooser, "fileChooser"]
]));
}
static from(page) {
return page._object;
}
static fromNullable(page) {
return page ? Page.from(page) : null;
}
_onFrameAttached(frame) {
frame._page = this;
this._frames.add(frame);
if (frame._parentFrame)
frame._parentFrame._childFrames.add(frame);
this.emit(import_events.Events.Page.FrameAttached, frame);
}
_onFrameDetached(frame) {
this._frames.delete(frame);
frame._detached = true;
if (frame._parentFrame)
frame._parentFrame._childFrames.delete(frame);
this.emit(import_events.Events.Page.FrameDetached, frame);
}
async _onRoute(route) {
route._context = this.context();
const routeHandlers = this._routes.slice();
for (const routeHandler of routeHandlers) {
if (this._closeWasCalled || this._browserContext.isClosed())
return;
if (!routeHandler.matches(route.request().url()))
continue;
const index = this._routes.indexOf(routeHandler);
if (index === -1)
continue;
if (routeHandler.willExpire())
this._routes.splice(index, 1);
const handled = await routeHandler.handle(route);
if (!this._routes.length)
this._updateInterceptionPatterns({ internal: true }).catch(() => {
});
if (handled)
return;
}
await this._browserContext._onRoute(route);
}
async _onWebSocketRoute(webSocketRoute) {
const routeHandler = this._webSocketRoutes.find((route) => route.matches(webSocketRoute.url()));
if (routeHandler)
await routeHandler.handle(webSocketRoute);
else
await this._browserContext._onWebSocketRoute(webSocketRoute);
}
async _onBinding(bindingCall) {
const func = this._bindings.get(bindingCall._initializer.name);
if (func) {
await bindingCall.call(func);
return;
}
await this._browserContext._onBinding(bindingCall);
}
_onWorker(worker) {
this._workers.add(worker);
worker._page = this;
this.emit(import_events.Events.Page.Worker, worker);
}
_onClose() {
this._closed = true;
this._browserContext._pages.delete(this);
this._disposeHarRouters();
this.emit(import_events.Events.Page.Close, this);
}
_onCrash() {
this.emit(import_events.Events.Page.Crash, this);
}
context() {
return this._browserContext;
}
async opener() {
if (!this._opener || this._opener.isClosed())
return null;
return this._opener;
}
mainFrame() {
return this._mainFrame;
}
frame(frameSelector) {
const name = (0, import_rtti.isString)(frameSelector) ? frameSelector : frameSelector.name;
const url = (0, import_rtti.isObject)(frameSelector) ? frameSelector.url : void 0;
(0, import_assert.assert)(name || url, "Either name or url matcher should be specified");
return this.frames().find((f) => {
if (name)
return f.name() === name;
return (0, import_urlMatch.urlMatches)(this._browserContext._options.baseURL, f.url(), url);
}) || null;
}
frames() {
return [...this._frames];
}
setDefaultNavigationTimeout(timeout) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
}
setDefaultTimeout(timeout) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
video() {
if (!this._browserContext._options.recordVideo)
return null;
return this._video;
}
async pickLocator() {
const { selector } = await this._channel.pickLocator({});
return this.locator(selector);
}
async cancelPickLocator() {
await this._channel.cancelPickLocator({});
}
async $(selector, options) {
return await this._mainFrame.$(selector, options);
}
async waitForSelector(selector, options) {
return await this._mainFrame.waitForSelector(selector, options);
}
async dispatchEvent(selector, type, eventInit, options) {
return await this._mainFrame.dispatchEvent(selector, type, eventInit, options);
}
async evaluateHandle(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
return await this._mainFrame.evaluateHandle(pageFunction, arg);
}
async $eval(selector, pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 3);
return await this._mainFrame.$eval(selector, pageFunction, arg);
}
async $$eval(selector, pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 3);
return await this._mainFrame.$$eval(selector, pageFunction, arg);
}
async $$(selector) {
return await this._mainFrame.$$(selector);
}
async addScriptTag(options = {}) {
return await this._mainFrame.addScriptTag(options);
}
async addStyleTag(options = {}) {
return await this._mainFrame.addStyleTag(options);
}
async exposeFunction(name, callback) {
const result = await this._channel.exposeBinding({ name });
const binding = (source, ...args) => callback(...args);
this._bindings.set(name, binding);
return import_disposable.DisposableObject.from(result.disposable);
}
async exposeBinding(name, callback, options = {}) {
const result = await this._channel.exposeBinding({ name, needsHandle: options.handle });
this._bindings.set(name, callback);
return import_disposable.DisposableObject.from(result.disposable);
}
async setExtraHTTPHeaders(headers) {
(0, import_network.validateHeaders)(headers);
await this._channel.setExtraHTTPHeaders({ headers: (0, import_headers.headersObjectToArray)(headers) });
}
url() {
return this._mainFrame.url();
}
async content() {
return await this._mainFrame.content();
}
async setContent(html, options) {
return await this._mainFrame.setContent(html, options);
}
async goto(url, options) {
return await this._mainFrame.goto(url, options);
}
async reload(options = {}) {
const waitUntil = (0, import_frame.verifyLoadState)("waitUntil", options.waitUntil === void 0 ? "load" : options.waitUntil);
return import_network.Response.fromNullable((await this._channel.reload({ ...options, waitUntil, timeout: this._timeoutSettings.navigationTimeout(options) })).response);
}
async addLocatorHandler(locator, handler, options = {}) {
if (locator._frame !== this._mainFrame)
throw new Error(`Locator must belong to the main frame of this page`);
if (options.times === 0)
return;
const { uid } = await this._channel.registerLocatorHandler({ selector: locator._selector, noWaitAfter: options.noWaitAfter });
this._locatorHandlers.set(uid, { locator, handler, times: options.times });
}
async _onLocatorHandlerTriggered(uid) {
let remove = false;
try {
const handler = this._locatorHandlers.get(uid);
if (handler && handler.times !== 0) {
if (handler.times !== void 0)
handler.times--;
await handler.handler(handler.locator);
}
remove = handler?.times === 0;
} finally {
if (remove)
this._locatorHandlers.delete(uid);
this._channel.resolveLocatorHandlerNoReply({ uid, remove }).catch(() => {
});
}
}
async removeLocatorHandler(locator) {
for (const [uid, data] of this._locatorHandlers) {
if (data.locator._equals(locator)) {
this._locatorHandlers.delete(uid);
await this._channel.unregisterLocatorHandler({ uid }).catch(() => {
});
}
}
}
async waitForLoadState(state, options) {
return await this._mainFrame.waitForLoadState(state, options);
}
async waitForNavigation(options) {
return await this._mainFrame.waitForNavigation(options);
}
async waitForURL(url, options) {
return await this._mainFrame.waitForURL(url, options);
}
async waitForRequest(urlOrPredicate, options = {}) {
const predicate = async (request) => {
if ((0, import_rtti.isString)(urlOrPredicate) || (0, import_rtti.isRegExp)(urlOrPredicate))
return (0, import_urlMatch.urlMatches)(this._browserContext._options.baseURL, request.url(), urlOrPredicate);
return await urlOrPredicate(request);
};
const trimmedUrl = trimUrl(urlOrPredicate);
const logLine = trimmedUrl ? `waiting for request ${trimmedUrl}` : void 0;
return await this._waitForEvent(import_events.Events.Page.Request, { predicate, timeout: options.timeout }, logLine);
}
async waitForResponse(urlOrPredicate, options = {}) {
const predicate = async (response) => {
if ((0, import_rtti.isString)(urlOrPredicate) || (0, import_rtti.isRegExp)(urlOrPredicate))
return (0, import_urlMatch.urlMatches)(this._browserContext._options.baseURL, response.url(), urlOrPredicate);
return await urlOrPredicate(response);
};
const trimmedUrl = trimUrl(urlOrPredicate);
const logLine = trimmedUrl ? `waiting for response ${trimmedUrl}` : void 0;
return await this._waitForEvent(import_events.Events.Page.Response, { predicate, timeout: options.timeout }, logLine);
}
async waitForEvent(event, optionsOrPredicate = {}) {
return await this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`);
}
_closeErrorWithReason() {
return new import_errors.TargetClosedError(this._closeReason || this._browserContext._effectiveCloseReason());
}
async _waitForEvent(event, optionsOrPredicate, logLine) {
return await this._wrapApiCall(async () => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = import_waiter.Waiter.createForEvent(this, event);
if (logLine)
waiter.log(logLine);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== import_events.Events.Page.Crash)
waiter.rejectOnEvent(this, import_events.Events.Page.Crash, new Error("Page crashed"));
if (event !== import_events.Events.Page.Close)
waiter.rejectOnEvent(this, import_events.Events.Page.Close, () => this._closeErrorWithReason());
const result = await waiter.waitForEvent(this, event, predicate);
waiter.dispose();
return result;
});
}
async goBack(options = {}) {
const waitUntil = (0, import_frame.verifyLoadState)("waitUntil", options.waitUntil === void 0 ? "load" : options.waitUntil);
return import_network.Response.fromNullable((await this._channel.goBack({ ...options, waitUntil, timeout: this._timeoutSettings.navigationTimeout(options) })).response);
}
async goForward(options = {}) {
const waitUntil = (0, import_frame.verifyLoadState)("waitUntil", options.waitUntil === void 0 ? "load" : options.waitUntil);
return import_network.Response.fromNullable((await this._channel.goForward({ ...options, waitUntil, timeout: this._timeoutSettings.navigationTimeout(options) })).response);
}
async requestGC() {
await this._channel.requestGC();
}
async emulateMedia(options = {}) {
await this._channel.emulateMedia({
media: options.media === null ? "no-override" : options.media,
colorScheme: options.colorScheme === null ? "no-override" : options.colorScheme,
reducedMotion: options.reducedMotion === null ? "no-override" : options.reducedMotion,
forcedColors: options.forcedColors === null ? "no-override" : options.forcedColors,
contrast: options.contrast === null ? "no-override" : options.contrast
});
}
async setViewportSize(viewportSize) {
this._viewportSize = viewportSize;
await this._channel.setViewportSize({ viewportSize });
}
viewportSize() {
return this._viewportSize || null;
}
async evaluate(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
return await this._mainFrame.evaluate(pageFunction, arg);
}
async addInitScript(script, arg) {
const source = await (0, import_clientHelper.evaluationScript)(this._platform, script, arg);
return import_disposable.DisposableObject.from((await this._channel.addInitScript({ source })).disposable);
}
async route(url, handler, options = {}) {
this._routes.unshift(new import_network.RouteHandler(this._platform, this._browserContext._options.baseURL, url, handler, options.times));
await this._updateInterceptionPatterns({ title: "Route requests" });
return new import_disposable.DisposableStub(() => this.unroute(url, handler));
}
async routeFromHAR(har, options = {}) {
const localUtils = this._connection.localUtils();
if (!localUtils)
throw new Error("Route from har is not supported in thin clients");
if (options.update) {
await this._browserContext._recordIntoHAR(har, this, options);
return;
}
const harRouter = await import_harRouter.HarRouter.create(localUtils, har, options.notFound || "abort", { urlMatch: options.url });
this._harRouters.push(harRouter);
await harRouter.addPageRoute(this);
}
async routeWebSocket(url, handler) {
this._webSocketRoutes.unshift(new import_network.WebSocketRouteHandler(this._browserContext._options.baseURL, url, handler));
await this._updateWebSocketInterceptionPatterns({ title: "Route WebSockets" });
}
_disposeHarRouters() {
this._harRouters.forEach((router) => router.dispose());
this._harRouters = [];
}
async unrouteAll(options) {
await this._unrouteInternal(this._routes, [], options?.behavior);
this._disposeHarRouters();
}
async unroute(url, handler) {
const removed = [];
const remaining = [];
for (const route of this._routes) {
if ((0, import_urlMatch.urlMatchesEqual)(route.url, url) && (!handler || route.handler === handler))
removed.push(route);
else
remaining.push(route);
}
await this._unrouteInternal(removed, remaining, "default");
}
async _unrouteInternal(removed, remaining, behavior) {
this._routes = remaining;
if (behavior && behavior !== "default") {
const promises = removed.map((routeHandler) => routeHandler.stop(behavior));
await Promise.all(promises);
}
await this._updateInterceptionPatterns({ title: "Unroute requests" });
}
async _updateInterceptionPatterns(options) {
const patterns = import_network.RouteHandler.prepareInterceptionPatterns(this._routes);
await this._wrapApiCall(() => this._channel.setNetworkInterceptionPatterns({ patterns }), options);
}
async _updateWebSocketInterceptionPatterns(options) {
const patterns = import_network.WebSocketRouteHandler.prepareInterceptionPatterns(this._webSocketRoutes);
await this._wrapApiCall(() => this._channel.setWebSocketInterceptionPatterns({ patterns }), options);
}
async screenshot(options = {}) {
const mask = options.mask;
const copy = { ...options, mask: void 0, timeout: this._timeoutSettings.timeout(options) };
if (!copy.type)
copy.type = (0, import_elementHandle.determineScreenshotType)(options);
if (mask) {
copy.mask = mask.map((locator) => ({
frame: locator._frame._channel,
selector: locator._selector
}));
}
const result = await this._channel.screenshot(copy);
if (options.path) {
await (0, import_fileUtils.mkdirIfNeeded)(this._platform, options.path);
await this._platform.fs().promises.writeFile(options.path, result.binary);
}
return result.binary;
}
async _expectScreenshot(options) {
const mask = options?.mask ? options?.mask.map((locator2) => ({
frame: locator2._frame._channel,
selector: locator2._selector
})) : void 0;
const locator = options.locator ? {
frame: options.locator._frame._channel,
selector: options.locator._selector
} : void 0;
return await this._channel.expectScreenshot({
...options,
isNot: !!options.isNot,
locator,
mask
});
}
async title() {
return await this._mainFrame.title();
}
async bringToFront() {
await this._channel.bringToFront();
}
async [Symbol.asyncDispose]() {
await this.close();
}
async close(options = {}) {
this._closeReason = options.reason;
if (!options.runBeforeUnload)
this._closeWasCalled = true;
try {
if (this._ownedContext)
await this._ownedContext.close();
else
await this._channel.close(options);
} catch (e) {
if ((0, import_errors.isTargetClosedError)(e) && !options.runBeforeUnload)
return;
throw e;
}
}
isClosed() {
return this._closed;
}
async click(selector, options) {
return await this._mainFrame.click(selector, options);
}
async dragAndDrop(source, target, options) {
return await this._mainFrame.dragAndDrop(source, target, options);
}
async dblclick(selector, options) {
await this._mainFrame.dblclick(selector, options);
}
async tap(selector, options) {
return await this._mainFrame.tap(selector, options);
}
async fill(selector, value, options) {
return await this._mainFrame.fill(selector, value, options);
}
async clearConsoleMessages() {
await this._channel.clearConsoleMessages();
}
async consoleMessages(options) {
const { messages } = await this._channel.consoleMessages({ filter: options?.filter });
return messages.map((message) => new import_consoleMessage.ConsoleMessage(this._platform, message, this, null));
}
async clearPageErrors() {
await this._channel.clearPageErrors();
}
async pageErrors(options) {
const { errors } = await this._channel.pageErrors({ filter: options?.filter });
return errors.map((error) => (0, import_errors.parseError)(error));
}
locator(selector, options) {
return this.mainFrame().locator(selector, options);
}
getByTestId(testId) {
return this.mainFrame().getByTestId(testId);
}
getByAltText(text, options) {
return this.mainFrame().getByAltText(text, options);
}
getByLabel(text, options) {
return this.mainFrame().getByLabel(text, options);
}
getByPlaceholder(text, options) {
return this.mainFrame().getByPlaceholder(text, options);
}
getByText(text, options) {
return this.mainFrame().getByText(text, options);
}
getByTitle(text, options) {
return this.mainFrame().getByTitle(text, options);
}
getByRole(role, options = {}) {
return this.mainFrame().getByRole(role, options);
}
frameLocator(selector) {
return this.mainFrame().frameLocator(selector);
}
async focus(selector, options) {
return await this._mainFrame.focus(selector, options);
}
async textContent(selector, options) {
return await this._mainFrame.textContent(selector, options);
}
async innerText(selector, options) {
return await this._mainFrame.innerText(selector, options);
}
async innerHTML(selector, options) {
return await this._mainFrame.innerHTML(selector, options);
}
async getAttribute(selector, name, options) {
return await this._mainFrame.getAttribute(selector, name, options);
}
async inputValue(selector, options) {
return await this._mainFrame.inputValue(selector, options);
}
async isChecked(selector, options) {
return await this._mainFrame.isChecked(selector, options);
}
async isDisabled(selector, options) {
return await this._mainFrame.isDisabled(selector, options);
}
async isEditable(selector, options) {
return await this._mainFrame.isEditable(selector, options);
}
async isEnabled(selector, options) {
return await this._mainFrame.isEnabled(selector, options);
}
async isHidden(selector, options) {
return await this._mainFrame.isHidden(selector, options);
}
async isVisible(selector, options) {
return await this._mainFrame.isVisible(selector, options);
}
async hover(selector, options) {
return await this._mainFrame.hover(selector, options);
}
async selectOption(selector, values, options) {
return await this._mainFrame.selectOption(selector, values, options);
}
async setInputFiles(selector, files, options) {
return await this._mainFrame.setInputFiles(selector, files, options);
}
async type(selector, text, options) {
return await this._mainFrame.type(selector, text, options);
}
async press(selector, key, options) {
return await this._mainFrame.press(selector, key, options);
}
async check(selector, options) {
return await this._mainFrame.check(selector, options);
}
async uncheck(selector, options) {
return await this._mainFrame.uncheck(selector, options);
}
async setChecked(selector, checked, options) {
return await this._mainFrame.setChecked(selector, checked, options);
}
async waitForTimeout(timeout) {
return await this._mainFrame.waitForTimeout(timeout);
}
async waitForFunction(pageFunction, arg, options) {
return await this._mainFrame.waitForFunction(pageFunction, arg, options);
}
async requests() {
const { requests } = await this._channel.requests();
return requests.map((request) => import_network.Request.from(request));
}
workers() {
return [...this._workers];
}
async pause(_options) {
if (this._platform.isJSDebuggerAttached())
return;
const defaultNavigationTimeout = this._browserContext._timeoutSettings.defaultNavigationTimeout();
const defaultTimeout = this._browserContext._timeoutSettings.defaultTimeout();
this._browserContext.setDefaultNavigationTimeout(0);
this._browserContext.setDefaultTimeout(0);
this._instrumentation?.onWillPause({ keepTestTimeout: !!_options?.__testHookKeepTestTimeout });
await this._closedOrCrashedScope.safeRace(this.context()._channel.pause());
this._browserContext.setDefaultNavigationTimeout(defaultNavigationTimeout);
this._browserContext.setDefaultTimeout(defaultTimeout);
}
async pdf(options = {}) {
const transportOptions = { ...options };
if (transportOptions.margin)
transportOptions.margin = { ...transportOptions.margin };
if (typeof options.width === "number")
transportOptions.width = options.width + "px";
if (typeof options.height === "number")
transportOptions.height = options.height + "px";
for (const margin of ["top", "right", "bottom", "left"]) {
const index = margin;
if (options.margin && typeof options.margin[index] === "number")
transportOptions.margin[index] = transportOptions.margin[index] + "px";
}
const result = await this._channel.pdf(transportOptions);
if (options.path) {
const platform = this._platform;
await platform.fs().promises.mkdir(platform.path().dirname(options.path), { recursive: true });
await platform.fs().promises.writeFile(options.path, result.pdf);
}
return result.pdf;
}
async ariaSnapshot(options = {}) {
const result = await this.mainFrame()._channel.ariaSnapshot({ timeout: this._timeoutSettings.timeout(options), track: options._track, mode: options.mode, depth: options.depth });
return result.snapshot;
}
async _setDockTile(image) {
await this._channel.setDockTile({ image });
}
}
class BindingCall extends import_channelOwner.ChannelOwner {
static from(channel) {
return channel._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
}
async call(func) {
try {
const frame = import_frame.Frame.from(this._initializer.frame);
const source = {
context: frame._page.context(),
page: frame._page,
frame
};
let result;
if (this._initializer.handle)
result = await func(source, import_jsHandle.JSHandle.from(this._initializer.handle));
else
result = await func(source, ...this._initializer.args.map(import_jsHandle.parseResult));
this._channel.resolve({ result: (0, import_jsHandle.serializeArgument)(result) }).catch(() => {
});
} catch (e) {
this._channel.reject({ error: (0, import_errors.serializeError)(e) }).catch(() => {
});
}
}
}
function trimUrl(param) {
if ((0, import_rtti.isRegExp)(param))
return `/${(0, import_stringUtils.trimStringWithEllipsis)(param.source, 50)}/${param.flags}`;
if ((0, import_rtti.isString)(param))
return `"${(0, import_stringUtils.trimStringWithEllipsis)(param, 50)}"`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
BindingCall,
Page
});
@@ -0,0 +1,74 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var platform_exports = {};
__export(platform_exports, {
emptyPlatform: () => emptyPlatform
});
module.exports = __toCommonJS(platform_exports);
var import_colors = require("../utils/isomorphic/colors");
const noopZone = {
push: () => noopZone,
pop: () => noopZone,
run: (func) => func(),
data: () => void 0
};
const emptyPlatform = {
name: "empty",
boxedStackPrefixes: () => [],
calculateSha1: async () => {
throw new Error("Not implemented");
},
colors: import_colors.webColors,
createGuid: () => {
throw new Error("Not implemented");
},
defaultMaxListeners: () => 10,
env: {},
fs: () => {
throw new Error("Not implemented");
},
inspectCustom: void 0,
isDebugMode: () => false,
isJSDebuggerAttached: () => false,
isLogEnabled(name) {
return false;
},
isUnderTest: () => false,
log(name, message) {
},
path: () => {
throw new Error("Function not implemented.");
},
pathSeparator: "/",
showInternalStackFrames: () => false,
streamFile(path, writable) {
throw new Error("Streams are not available");
},
streamReadable: (channel) => {
throw new Error("Streams are not available");
},
streamWritable: (channel) => {
throw new Error("Streams are not available");
},
zones: { empty: noopZone, current: () => noopZone }
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
emptyPlatform
});
@@ -0,0 +1,71 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var playwright_exports = {};
__export(playwright_exports, {
Playwright: () => Playwright
});
module.exports = __toCommonJS(playwright_exports);
var import_android = require("./android");
var import_browser = require("./browser");
var import_browserType = require("./browserType");
var import_channelOwner = require("./channelOwner");
var import_electron = require("./electron");
var import_errors = require("./errors");
var import_fetch = require("./fetch");
var import_selectors = require("./selectors");
class Playwright extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this.request = new import_fetch.APIRequest(this);
this.chromium = import_browserType.BrowserType.from(initializer.chromium);
this.chromium._playwright = this;
this.firefox = import_browserType.BrowserType.from(initializer.firefox);
this.firefox._playwright = this;
this.webkit = import_browserType.BrowserType.from(initializer.webkit);
this.webkit._playwright = this;
this._android = import_android.Android.from(initializer.android);
this._android._playwright = this;
this._electron = import_electron.Electron.from(initializer.electron);
this._electron._playwright = this;
this.devices = this._connection.localUtils()?.devices ?? {};
this.selectors = new import_selectors.Selectors(this._connection._platform);
this.errors = { TimeoutError: import_errors.TimeoutError };
}
static from(channel) {
return channel._object;
}
_browserTypes() {
return [this.chromium, this.firefox, this.webkit];
}
_preLaunchedBrowser() {
const browser = import_browser.Browser.from(this._initializer.preLaunchedBrowser);
browser._connectToBrowserType(this[browser._name], {}, void 0);
return browser;
}
_allContexts() {
return this._browserTypes().flatMap((type) => [...type._contexts]);
}
_allPages() {
return this._allContexts().flatMap((context) => context.pages());
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Playwright
});
@@ -0,0 +1,88 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var screencast_exports = {};
__export(screencast_exports, {
Screencast: () => Screencast
});
module.exports = __toCommonJS(screencast_exports);
var import_artifact = require("./artifact");
var import_disposable = require("./disposable");
class Screencast {
constructor(page) {
this._started = false;
this._onFrame = null;
this._page = page;
this._page._channel.on("screencastFrame", ({ data }) => {
void this._onFrame?.({ data });
});
}
async start(options = {}) {
if (this._started)
throw new Error("Screencast is already started");
this._started = true;
if (options.onFrame)
this._onFrame = options.onFrame;
const result = await this._page._channel.screencastStart({
size: options.size,
quality: options.quality,
sendFrames: !!options.onFrame,
record: !!options.path
});
if (result.artifact) {
this._artifact = import_artifact.Artifact.from(result.artifact);
this._savePath = options.path;
}
return new import_disposable.DisposableStub(() => this.stop());
}
async stop() {
await this._page._wrapApiCall(async () => {
this._started = false;
this._onFrame = null;
await this._page._channel.screencastStop();
if (this._savePath)
await this._artifact?.saveAs(this._savePath);
this._artifact = void 0;
this._savePath = void 0;
});
}
async showActions(options) {
await this._page._channel.screencastShowActions({ duration: options?.duration, position: options?.position, fontSize: options?.fontSize });
return new import_disposable.DisposableStub(() => this._page._channel.screencastHideActions());
}
async hideActions() {
await this._page._channel.screencastHideActions();
}
async showOverlay(html, options) {
const { id } = await this._page._channel.screencastShowOverlay({ html, duration: options?.duration });
return new import_disposable.DisposableStub(() => this._page._channel.screencastRemoveOverlay({ id }));
}
async showChapter(title, options) {
await this._page._channel.screencastChapter({ title, ...options });
}
async showOverlays() {
await this._page._channel.screencastSetOverlayVisible({ visible: true });
}
async hideOverlays() {
await this._page._channel.screencastSetOverlayVisible({ visible: false });
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Screencast
});
@@ -0,0 +1,57 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var selectors_exports = {};
__export(selectors_exports, {
Selectors: () => Selectors
});
module.exports = __toCommonJS(selectors_exports);
var import_clientHelper = require("./clientHelper");
var import_locator = require("./locator");
class Selectors {
constructor(platform) {
this._selectorEngines = [];
this._contextsForSelectors = /* @__PURE__ */ new Set();
this._platform = platform;
}
async register(name, script, options = {}) {
if (this._selectorEngines.some((engine) => engine.name === name))
throw new Error(`selectors.register: "${name}" selector engine has been already registered`);
const source = await (0, import_clientHelper.evaluationScript)(this._platform, script, void 0, false);
const selectorEngine = { ...options, name, source };
for (const context of this._contextsForSelectors)
await context._channel.registerSelectorEngine({ selectorEngine });
this._selectorEngines.push(selectorEngine);
}
setTestIdAttribute(attributeName) {
this._testIdAttributeName = attributeName;
(0, import_locator.setTestIdAttribute)(attributeName);
for (const context of this._contextsForSelectors) {
context._options.testIdAttributeName = attributeName;
context._channel.setTestIdAttributeName({ testIdAttributeName: attributeName }).catch(() => {
});
}
}
_withSelectorOptions(options) {
return { ...options, selectorEngines: this._selectorEngines, testIdAttributeName: this._testIdAttributeName };
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Selectors
});
@@ -0,0 +1,39 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var stream_exports = {};
__export(stream_exports, {
Stream: () => Stream
});
module.exports = __toCommonJS(stream_exports);
var import_channelOwner = require("./channelOwner");
class Stream extends import_channelOwner.ChannelOwner {
static from(Stream2) {
return Stream2._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
}
stream() {
return this._platform.streamReadable(this._channel);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Stream
});
@@ -0,0 +1,79 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var timeoutSettings_exports = {};
__export(timeoutSettings_exports, {
TimeoutSettings: () => TimeoutSettings
});
module.exports = __toCommonJS(timeoutSettings_exports);
var import_time = require("../utils/isomorphic/time");
class TimeoutSettings {
constructor(platform, parent) {
this._parent = parent;
this._platform = platform;
}
setDefaultTimeout(timeout) {
this._defaultTimeout = timeout;
}
setDefaultNavigationTimeout(timeout) {
this._defaultNavigationTimeout = timeout;
}
defaultNavigationTimeout() {
return this._defaultNavigationTimeout;
}
defaultTimeout() {
return this._defaultTimeout;
}
navigationTimeout(options) {
if (typeof options.timeout === "number")
return options.timeout;
if (this._defaultNavigationTimeout !== void 0)
return this._defaultNavigationTimeout;
if (this._platform.isDebugMode())
return 0;
if (this._defaultTimeout !== void 0)
return this._defaultTimeout;
if (this._parent)
return this._parent.navigationTimeout(options);
return import_time.DEFAULT_PLAYWRIGHT_TIMEOUT;
}
timeout(options) {
if (typeof options.timeout === "number")
return options.timeout;
if (this._platform.isDebugMode())
return 0;
if (this._defaultTimeout !== void 0)
return this._defaultTimeout;
if (this._parent)
return this._parent.timeout(options);
return import_time.DEFAULT_PLAYWRIGHT_TIMEOUT;
}
launchTimeout(options) {
if (typeof options.timeout === "number")
return options.timeout;
if (this._platform.isDebugMode())
return 0;
if (this._parent)
return this._parent.launchTimeout(options);
return import_time.DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
TimeoutSettings
});
@@ -0,0 +1,126 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var tracing_exports = {};
__export(tracing_exports, {
Tracing: () => Tracing
});
module.exports = __toCommonJS(tracing_exports);
var import_artifact = require("./artifact");
var import_channelOwner = require("./channelOwner");
var import_disposable = require("./disposable");
class Tracing extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
this._includeSources = false;
this._additionalSources = /* @__PURE__ */ new Set();
this._isLive = false;
this._isTracing = false;
}
static from(channel) {
return channel._object;
}
async start(options = {}) {
await this._wrapApiCall(async () => {
this._includeSources = !!options.sources;
this._isLive = !!options.live;
await this._channel.tracingStart({
name: options.name,
snapshots: options.snapshots,
screenshots: options.screenshots,
live: options.live
});
const { traceName } = await this._channel.tracingStartChunk({ name: options.name, title: options.title });
await this._startCollectingStacks(traceName, this._isLive);
});
}
async startChunk(options = {}) {
await this._wrapApiCall(async () => {
const { traceName } = await this._channel.tracingStartChunk(options);
await this._startCollectingStacks(traceName, this._isLive);
});
}
async group(name, options = {}) {
if (options.location)
this._additionalSources.add(options.location.file);
await this._channel.tracingGroup({ name, location: options.location });
return new import_disposable.DisposableStub(() => this.groupEnd());
}
async groupEnd() {
await this._channel.tracingGroupEnd();
}
async _startCollectingStacks(traceName, live) {
if (!this._isTracing) {
this._isTracing = true;
this._connection.setIsTracing(true);
}
const result = await this._connection.localUtils()?.tracingStarted({ tracesDir: this._tracesDir, traceName, live });
this._stacksId = result?.stacksId;
}
async stopChunk(options = {}) {
await this._wrapApiCall(async () => {
await this._doStopChunk(options.path);
});
}
async stop(options = {}) {
await this._wrapApiCall(async () => {
await this._doStopChunk(options.path);
await this._channel.tracingStop();
});
}
async _doStopChunk(filePath) {
this._resetStackCounter();
const additionalSources = [...this._additionalSources];
this._additionalSources.clear();
if (!filePath) {
await this._channel.tracingStopChunk({ mode: "discard" });
if (this._stacksId)
await this._connection.localUtils().traceDiscarded({ stacksId: this._stacksId });
return;
}
const localUtils = this._connection.localUtils();
if (!localUtils)
throw new Error("Cannot save trace in thin clients");
const isLocal = !this._connection.isRemote();
if (isLocal) {
const result2 = await this._channel.tracingStopChunk({ mode: "entries" });
await localUtils.zip({ zipFile: filePath, entries: result2.entries, mode: "write", stacksId: this._stacksId, includeSources: this._includeSources, additionalSources });
return;
}
const result = await this._channel.tracingStopChunk({ mode: "archive" });
if (!result.artifact) {
if (this._stacksId)
await localUtils.traceDiscarded({ stacksId: this._stacksId });
return;
}
const artifact = import_artifact.Artifact.from(result.artifact);
await artifact.saveAs(filePath);
await artifact.delete();
await localUtils.zip({ zipFile: filePath, entries: [], mode: "append", stacksId: this._stacksId, includeSources: this._includeSources, additionalSources });
}
_resetStackCounter() {
if (this._isTracing) {
this._isTracing = false;
this._connection.setIsTracing(false);
}
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Tracing
});
@@ -0,0 +1,28 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var types_exports = {};
__export(types_exports, {
kLifecycleEvents: () => kLifecycleEvents
});
module.exports = __toCommonJS(types_exports);
const kLifecycleEvents = /* @__PURE__ */ new Set(["load", "domcontentloaded", "networkidle", "commit"]);
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
kLifecycleEvents
});
@@ -0,0 +1,52 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var video_exports = {};
__export(video_exports, {
Video: () => Video
});
module.exports = __toCommonJS(video_exports);
var import_eventEmitter = require("./eventEmitter");
class Video extends import_eventEmitter.EventEmitter {
constructor(page, connection, artifact) {
super(page._platform);
this._isRemote = false;
this._isRemote = connection.isRemote();
this._artifact = artifact;
}
async path() {
if (this._isRemote)
throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`);
if (!this._artifact)
throw new Error("Video recording has not been started.");
return this._artifact._initializer.absolutePath;
}
async saveAs(path) {
if (!this._artifact)
throw new Error("Video recording has not been started.");
return await this._artifact.saveAs(path);
}
async delete() {
if (this._artifact)
await this._artifact.delete();
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Video
});
@@ -0,0 +1,142 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var waiter_exports = {};
__export(waiter_exports, {
Waiter: () => Waiter
});
module.exports = __toCommonJS(waiter_exports);
var import_errors = require("./errors");
var import_stackTrace = require("../utils/isomorphic/stackTrace");
class Waiter {
constructor(channelOwner, event) {
this._failures = [];
this._logs = [];
this._waitId = channelOwner._platform.createGuid();
this._channelOwner = channelOwner;
this._savedZone = channelOwner._platform.zones.current().pop();
this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: "before", event } }).catch(() => {
});
this._dispose = [
() => this._channelOwner._wrapApiCall(async () => {
await this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: "after", error: this._error } });
}, { internal: true }).catch(() => {
})
];
}
static createForEvent(channelOwner, event) {
return new Waiter(channelOwner, event);
}
async waitForEvent(emitter, event, predicate) {
const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate);
return await this.waitForPromise(promise, dispose);
}
rejectOnEvent(emitter, event, error, predicate) {
const { promise, dispose } = waitForEvent(emitter, event, this._savedZone, predicate);
this._rejectOn(promise.then(() => {
throw typeof error === "function" ? error() : error;
}), dispose);
}
rejectOnTimeout(timeout, message) {
if (!timeout)
return;
const { promise, dispose } = waitForTimeout(timeout);
this._rejectOn(promise.then(() => {
throw new import_errors.TimeoutError(message);
}), dispose);
}
rejectImmediately(error) {
this._immediateError = error;
}
dispose() {
for (const dispose of this._dispose)
dispose();
}
async waitForPromise(promise, dispose) {
try {
if (this._immediateError)
throw this._immediateError;
const result = await Promise.race([promise, ...this._failures]);
if (dispose)
dispose();
return result;
} catch (e) {
if (dispose)
dispose();
this._error = e.message;
this.dispose();
(0, import_stackTrace.rewriteErrorMessage)(e, e.message + formatLogRecording(this._logs));
throw e;
}
}
log(s) {
this._logs.push(s);
this._channelOwner._wrapApiCall(async () => {
await this._channelOwner._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: "log", message: s } });
}, { internal: true }).catch(() => {
});
}
_rejectOn(promise, dispose) {
this._failures.push(promise);
if (dispose)
this._dispose.push(dispose);
}
}
function waitForEvent(emitter, event, savedZone, predicate) {
let listener;
const promise = new Promise((resolve, reject) => {
listener = async (eventArg) => {
await savedZone.run(async () => {
try {
if (predicate && !await predicate(eventArg))
return;
emitter.removeListener(event, listener);
resolve(eventArg);
} catch (e) {
emitter.removeListener(event, listener);
reject(e);
}
});
};
emitter.addListener(event, listener);
});
const dispose = () => emitter.removeListener(event, listener);
return { promise, dispose };
}
function waitForTimeout(timeout) {
let timeoutId;
const promise = new Promise((resolve) => timeoutId = setTimeout(resolve, timeout));
const dispose = () => clearTimeout(timeoutId);
return { promise, dispose };
}
function formatLogRecording(log) {
if (!log.length)
return "";
const header = ` logs `;
const headerLength = 60;
const leftLength = (headerLength - header.length) / 2;
const rightLength = headerLength - header.length - leftLength;
return `
${"=".repeat(leftLength)}${header}${"=".repeat(rightLength)}
${log.join("\n")}
${"=".repeat(headerLength)}`;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Waiter
});
@@ -0,0 +1,39 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var webError_exports = {};
__export(webError_exports, {
WebError: () => WebError
});
module.exports = __toCommonJS(webError_exports);
class WebError {
constructor(page, error) {
this._page = page;
this._error = error;
}
page() {
return this._page;
}
error() {
return this._error;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
WebError
});
@@ -0,0 +1,85 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var worker_exports = {};
__export(worker_exports, {
Worker: () => Worker
});
module.exports = __toCommonJS(worker_exports);
var import_channelOwner = require("./channelOwner");
var import_errors = require("./errors");
var import_events = require("./events");
var import_jsHandle = require("./jsHandle");
var import_manualPromise = require("../utils/isomorphic/manualPromise");
var import_timeoutSettings = require("./timeoutSettings");
var import_waiter = require("./waiter");
class Worker extends import_channelOwner.ChannelOwner {
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
// Set for service workers.
this._closedScope = new import_manualPromise.LongStandingScope();
this._setEventToSubscriptionMapping(/* @__PURE__ */ new Map([
[import_events.Events.Worker.Console, "console"]
]));
this._channel.on("close", () => {
if (this._page)
this._page._workers.delete(this);
if (this._context)
this._context._serviceWorkers.delete(this);
this.emit(import_events.Events.Worker.Close, this);
});
this.once(import_events.Events.Worker.Close, () => this._closedScope.close(this._page?._closeErrorWithReason() || new import_errors.TargetClosedError()));
}
static fromNullable(worker) {
return worker ? Worker.from(worker) : null;
}
static from(worker) {
return worker._object;
}
url() {
return this._initializer.url;
}
async evaluate(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
const result = await this._channel.evaluateExpression({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return (0, import_jsHandle.parseResult)(result.value);
}
async evaluateHandle(pageFunction, arg) {
(0, import_jsHandle.assertMaxArguments)(arguments.length, 2);
const result = await this._channel.evaluateExpressionHandle({ expression: String(pageFunction), isFunction: typeof pageFunction === "function", arg: (0, import_jsHandle.serializeArgument)(arg) });
return import_jsHandle.JSHandle.from(result.handle);
}
async waitForEvent(event, optionsOrPredicate = {}) {
return await this._wrapApiCall(async () => {
const timeoutSettings = this._page?._timeoutSettings ?? this._context?._timeoutSettings ?? new import_timeoutSettings.TimeoutSettings(this._platform);
const timeout = timeoutSettings.timeout(typeof optionsOrPredicate === "function" ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === "function" ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = import_waiter.Waiter.createForEvent(this, event);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded while waiting for event "${event}"`);
if (event !== import_events.Events.Worker.Close)
waiter.rejectOnEvent(this, import_events.Events.Worker.Close, () => new import_errors.TargetClosedError());
const result = await waiter.waitForEvent(this, event, predicate);
waiter.dispose();
return result;
});
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
Worker
});
@@ -0,0 +1,39 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var writableStream_exports = {};
__export(writableStream_exports, {
WritableStream: () => WritableStream
});
module.exports = __toCommonJS(writableStream_exports);
var import_channelOwner = require("./channelOwner");
class WritableStream extends import_channelOwner.ChannelOwner {
static from(Stream) {
return Stream._object;
}
constructor(parent, type, guid, initializer) {
super(parent, type, guid, initializer);
}
stream() {
return this._platform.streamWritable(this._channel);
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
WritableStream
});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,336 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var webSocketMockSource_exports = {};
__export(webSocketMockSource_exports, {
source: () => source
});
module.exports = __toCommonJS(webSocketMockSource_exports);
const source = `
var __commonJS = obj => {
let required = false;
let result;
return function __require() {
if (!required) {
required = true;
let fn;
for (const name in obj) { fn = obj[name]; break; }
const module = { exports: {} };
fn(module.exports, module);
result = module.exports;
}
return result;
}
};
var __export = (target, all) => {for (var name in all) target[name] = all[name];};
var __toESM = mod => ({ ...mod, 'default': mod });
var __toCommonJS = mod => ({ ...mod, __esModule: true });
// packages/injected/src/webSocketMock.ts
var webSocketMock_exports = {};
__export(webSocketMock_exports, {
inject: () => inject
});
module.exports = __toCommonJS(webSocketMock_exports);
function inject(globalThis) {
if (globalThis.__pwWebSocketDispatch)
return;
function generateId() {
const bytes = new Uint8Array(32);
globalThis.crypto.getRandomValues(bytes);
const hex = "0123456789abcdef";
return [...bytes].map((value) => {
const high = Math.floor(value / 16);
const low = value % 16;
return hex[high] + hex[low];
}).join("");
}
function bufferToData(b) {
let s = "";
for (let i = 0; i < b.length; i++)
s += String.fromCharCode(b[i]);
return { data: globalThis.btoa(s), isBase64: true };
}
function stringToBuffer(s) {
s = globalThis.atob(s);
const b = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++)
b[i] = s.charCodeAt(i);
return b.buffer;
}
function messageToData(message, cb) {
if (message instanceof globalThis.Blob)
return message.arrayBuffer().then((buffer) => cb(bufferToData(new Uint8Array(buffer))));
if (typeof message === "string")
return cb({ data: message, isBase64: false });
if (ArrayBuffer.isView(message))
return cb(bufferToData(new Uint8Array(message.buffer, message.byteOffset, message.byteLength)));
return cb(bufferToData(new Uint8Array(message)));
}
function dataToMessage(data, binaryType) {
if (!data.isBase64)
return data.data;
const buffer = stringToBuffer(data.data);
return binaryType === "arraybuffer" ? buffer : new Blob([buffer]);
}
const binding = globalThis.__pwWebSocketBinding;
const NativeWebSocket = globalThis.WebSocket;
const idToWebSocket = /* @__PURE__ */ new Map();
globalThis.__pwWebSocketDispatch = (request) => {
const ws = idToWebSocket.get(request.id);
if (!ws)
return;
if (request.type === "connect")
ws._apiConnect();
if (request.type === "passthrough")
ws._apiPassThrough();
if (request.type === "ensureOpened")
ws._apiEnsureOpened();
if (request.type === "sendToPage")
ws._apiSendToPage(dataToMessage(request.data, ws.binaryType));
if (request.type === "closePage")
ws._apiClosePage(request.code, request.reason, request.wasClean);
if (request.type === "sendToServer")
ws._apiSendToServer(dataToMessage(request.data, ws.binaryType));
if (request.type === "closeServer")
ws._apiCloseServer(request.code, request.reason, request.wasClean);
};
const _WebSocketMock = class _WebSocketMock extends EventTarget {
constructor(url, protocols) {
var _a, _b;
super();
// WebSocket.CLOSED
this.CONNECTING = 0;
// WebSocket.CONNECTING
this.OPEN = 1;
// WebSocket.OPEN
this.CLOSING = 2;
// WebSocket.CLOSING
this.CLOSED = 3;
// WebSocket.CLOSED
this._oncloseListener = null;
this._onerrorListener = null;
this._onmessageListener = null;
this._onopenListener = null;
this.bufferedAmount = 0;
this.extensions = "";
this.protocol = "";
this.readyState = 0;
this._origin = "";
this._passthrough = false;
this._wsBufferedMessages = [];
this._binaryType = "blob";
this.url = new URL(url, globalThis.window.document.baseURI).href.replace(/^http/, "ws");
this._origin = (_b = (_a = URL.parse(this.url)) == null ? void 0 : _a.origin) != null ? _b : "";
this._protocols = protocols;
this._id = generateId();
idToWebSocket.set(this._id, this);
binding({ type: "onCreate", id: this._id, url: this.url });
}
// --- native WebSocket implementation ---
get binaryType() {
return this._binaryType;
}
set binaryType(type) {
this._binaryType = type;
if (this._ws)
this._ws.binaryType = type;
}
get onclose() {
return this._oncloseListener;
}
set onclose(listener) {
if (this._oncloseListener)
this.removeEventListener("close", this._oncloseListener);
this._oncloseListener = listener;
if (this._oncloseListener)
this.addEventListener("close", this._oncloseListener);
}
get onerror() {
return this._onerrorListener;
}
set onerror(listener) {
if (this._onerrorListener)
this.removeEventListener("error", this._onerrorListener);
this._onerrorListener = listener;
if (this._onerrorListener)
this.addEventListener("error", this._onerrorListener);
}
get onopen() {
return this._onopenListener;
}
set onopen(listener) {
if (this._onopenListener)
this.removeEventListener("open", this._onopenListener);
this._onopenListener = listener;
if (this._onopenListener)
this.addEventListener("open", this._onopenListener);
}
get onmessage() {
return this._onmessageListener;
}
set onmessage(listener) {
if (this._onmessageListener)
this.removeEventListener("message", this._onmessageListener);
this._onmessageListener = listener;
if (this._onmessageListener)
this.addEventListener("message", this._onmessageListener);
}
send(message) {
if (this.readyState === _WebSocketMock.CONNECTING)
throw new DOMException(\`Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.\`);
if (this.readyState !== _WebSocketMock.OPEN)
throw new DOMException(\`WebSocket is already in CLOSING or CLOSED state.\`);
if (this._passthrough) {
if (this._ws)
this._apiSendToServer(message);
} else {
messageToData(message, (data) => binding({ type: "onMessageFromPage", id: this._id, data }));
}
}
close(code, reason) {
if (code !== void 0 && code !== 1e3 && (code < 3e3 || code > 4999))
throw new DOMException(\`Failed to execute 'close' on 'WebSocket': The close code must be either 1000, or between 3000 and 4999. \${code} is neither.\`);
if (this.readyState === _WebSocketMock.OPEN || this.readyState === _WebSocketMock.CONNECTING)
this.readyState = _WebSocketMock.CLOSING;
if (this._passthrough)
this._apiCloseServer(code, reason, true);
else
binding({ type: "onClosePage", id: this._id, code, reason, wasClean: true });
}
// --- methods called from the routing API ---
_apiEnsureOpened() {
if (!this._ws)
this._ensureOpened();
}
_apiSendToPage(message) {
this._ensureOpened();
if (this.readyState !== _WebSocketMock.OPEN)
throw new DOMException(\`WebSocket is already in CLOSING or CLOSED state.\`);
this.dispatchEvent(new MessageEvent("message", { data: message, origin: this._origin, cancelable: true }));
}
_apiSendToServer(message) {
if (!this._ws)
throw new Error("Cannot send a message before connecting to the server");
if (this._ws.readyState === _WebSocketMock.CONNECTING)
this._wsBufferedMessages.push(message);
else
this._ws.send(message);
}
_apiConnect() {
if (this._ws)
throw new Error("Can only connect to the server once");
this._ws = new NativeWebSocket(this.url, this._protocols);
this._ws.binaryType = this._binaryType;
this._ws.onopen = () => {
for (const message of this._wsBufferedMessages)
this._ws.send(message);
this._wsBufferedMessages = [];
this._ensureOpened();
};
this._ws.onclose = (event) => {
this._onWSClose(event.code, event.reason, event.wasClean);
};
this._ws.onmessage = (event) => {
if (this._passthrough)
this._apiSendToPage(event.data);
else
messageToData(event.data, (data) => binding({ type: "onMessageFromServer", id: this._id, data }));
};
this._ws.onerror = () => {
const event = new Event("error", { cancelable: true });
this.dispatchEvent(event);
};
}
// This method connects to the server, and passes all messages through,
// as if WebSocketMock was not engaged.
_apiPassThrough() {
this._passthrough = true;
this._apiConnect();
}
_apiCloseServer(code, reason, wasClean) {
if (!this._ws) {
this._onWSClose(code, reason, wasClean);
return;
}
if (this._ws.readyState === _WebSocketMock.CONNECTING || this._ws.readyState === _WebSocketMock.OPEN)
this._ws.close(code, reason);
}
_apiClosePage(code, reason, wasClean) {
if (this.readyState === _WebSocketMock.CLOSED)
return;
this.readyState = _WebSocketMock.CLOSED;
this.dispatchEvent(new CloseEvent("close", { code, reason, wasClean, cancelable: true }));
this._maybeCleanup();
if (this._passthrough)
this._apiCloseServer(code, reason, wasClean);
else
binding({ type: "onClosePage", id: this._id, code, reason, wasClean });
}
// --- internals ---
_ensureOpened() {
var _a;
if (this.readyState !== _WebSocketMock.CONNECTING)
return;
this.extensions = ((_a = this._ws) == null ? void 0 : _a.extensions) || "";
if (this._ws)
this.protocol = this._ws.protocol;
else if (Array.isArray(this._protocols))
this.protocol = this._protocols[0] || "";
else
this.protocol = this._protocols || "";
this.readyState = _WebSocketMock.OPEN;
this.dispatchEvent(new Event("open", { cancelable: true }));
}
_onWSClose(code, reason, wasClean) {
if (this._passthrough)
this._apiClosePage(code, reason, wasClean);
else
binding({ type: "onCloseServer", id: this._id, code, reason, wasClean });
if (this._ws) {
this._ws.onopen = null;
this._ws.onclose = null;
this._ws.onmessage = null;
this._ws.onerror = null;
this._ws = void 0;
this._wsBufferedMessages = [];
}
this._maybeCleanup();
}
_maybeCleanup() {
if (this.readyState === _WebSocketMock.CLOSED && !this._ws)
idToWebSocket.delete(this._id);
}
};
_WebSocketMock.CONNECTING = 0;
// WebSocket.CONNECTING
_WebSocketMock.OPEN = 1;
// WebSocket.OPEN
_WebSocketMock.CLOSING = 2;
// WebSocket.CLOSING
_WebSocketMock.CLOSED = 3;
let WebSocketMock = _WebSocketMock;
globalThis.WebSocket = class WebSocket extends WebSocketMock {
};
}
`;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
source
});
@@ -0,0 +1,60 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var inProcessFactory_exports = {};
__export(inProcessFactory_exports, {
createInProcessPlaywright: () => createInProcessPlaywright
});
module.exports = __toCommonJS(inProcessFactory_exports);
var import_androidServerImpl = require("./androidServerImpl");
var import_browserServerImpl = require("./browserServerImpl");
var import_server = require("./server");
var import_nodePlatform = require("./server/utils/nodePlatform");
var import_connection = require("./client/connection");
function createInProcessPlaywright() {
const playwright = (0, import_server.createPlaywright)({ sdkLanguage: process.env.PW_LANG_NAME || "javascript" });
const clientConnection = new import_connection.Connection(import_nodePlatform.nodePlatform);
clientConnection.useRawBuffers();
const dispatcherConnection = new import_server.DispatcherConnection(
true
/* local */
);
dispatcherConnection.onmessage = (message) => clientConnection.dispatch(message);
clientConnection.onmessage = (message) => dispatcherConnection.dispatch(message);
const rootScope = new import_server.RootDispatcher(dispatcherConnection);
new import_server.PlaywrightDispatcher(rootScope, playwright);
const playwrightAPI = clientConnection.getObjectWithKnownName("Playwright");
playwrightAPI.chromium._serverLauncher = new import_browserServerImpl.BrowserServerLauncherImpl("chromium");
playwrightAPI.firefox._serverLauncher = new import_browserServerImpl.BrowserServerLauncherImpl("firefox");
playwrightAPI.webkit._serverLauncher = new import_browserServerImpl.BrowserServerLauncherImpl("webkit");
playwrightAPI._android._serverLauncher = new import_androidServerImpl.AndroidServerLauncherImpl();
dispatcherConnection.onmessage = (message) => setImmediate(() => clientConnection.dispatch(message));
clientConnection.onmessage = (message) => setImmediate(() => dispatcherConnection.dispatch(message));
clientConnection.toImpl = (x) => {
if (x instanceof import_connection.Connection)
return x === clientConnection ? dispatcherConnection : void 0;
if (!x)
return dispatcherConnection._dispatcherByGuid.get("");
return dispatcherConnection._dispatcherByGuid.get(x._guid)._object;
};
return playwrightAPI;
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
createInProcessPlaywright
});
@@ -0,0 +1,3 @@
"use strict";
var import_inProcessFactory = require("./inProcessFactory");
module.exports = (0, import_inProcessFactory.createInProcessPlaywright)();
@@ -0,0 +1,78 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var mcpBundle_exports = {};
__export(mcpBundle_exports, {
CallToolRequestSchema: () => CallToolRequestSchema,
Client: () => Client,
ListRootsRequestSchema: () => ListRootsRequestSchema,
ListToolsRequestSchema: () => ListToolsRequestSchema,
PingRequestSchema: () => PingRequestSchema,
ProgressNotificationSchema: () => ProgressNotificationSchema,
SSEClientTransport: () => SSEClientTransport,
SSEServerTransport: () => SSEServerTransport,
Server: () => Server,
StdioClientTransport: () => StdioClientTransport,
StdioServerTransport: () => StdioServerTransport,
StreamableHTTPClientTransport: () => StreamableHTTPClientTransport,
StreamableHTTPServerTransport: () => StreamableHTTPServerTransport,
zodToJsonSchema: () => zodToJsonSchema
});
module.exports = __toCommonJS(mcpBundle_exports);
var bundle = __toESM(require("./mcpBundleImpl"));
const zodToJsonSchema = bundle.zodToJsonSchema;
const Client = bundle.Client;
const Server = bundle.Server;
const SSEClientTransport = bundle.SSEClientTransport;
const SSEServerTransport = bundle.SSEServerTransport;
const StdioClientTransport = bundle.StdioClientTransport;
const StdioServerTransport = bundle.StdioServerTransport;
const StreamableHTTPServerTransport = bundle.StreamableHTTPServerTransport;
const StreamableHTTPClientTransport = bundle.StreamableHTTPClientTransport;
const CallToolRequestSchema = bundle.CallToolRequestSchema;
const ListRootsRequestSchema = bundle.ListRootsRequestSchema;
const ProgressNotificationSchema = bundle.ProgressNotificationSchema;
const ListToolsRequestSchema = bundle.ListToolsRequestSchema;
const PingRequestSchema = bundle.PingRequestSchema;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
CallToolRequestSchema,
Client,
ListRootsRequestSchema,
ListToolsRequestSchema,
PingRequestSchema,
ProgressNotificationSchema,
SSEClientTransport,
SSEServerTransport,
Server,
StdioClientTransport,
StdioServerTransport,
StreamableHTTPClientTransport,
StreamableHTTPServerTransport,
zodToJsonSchema
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var outofprocess_exports = {};
__export(outofprocess_exports, {
start: () => start
});
module.exports = __toCommonJS(outofprocess_exports);
var childProcess = __toESM(require("child_process"));
var import_path = __toESM(require("path"));
var import_connection = require("./client/connection");
var import_pipeTransport = require("./server/utils/pipeTransport");
var import_manualPromise = require("./utils/isomorphic/manualPromise");
var import_nodePlatform = require("./server/utils/nodePlatform");
async function start(env = {}) {
const client = new PlaywrightClient(env);
const playwright = await client._playwright;
playwright.driverProcess = client._driverProcess;
return { playwright, stop: () => client.stop() };
}
class PlaywrightClient {
constructor(env) {
this._closePromise = new import_manualPromise.ManualPromise();
this._driverProcess = childProcess.fork(import_path.default.join(__dirname, "..", "cli.js"), ["run-driver"], {
stdio: "pipe",
detached: true,
env: {
...process.env,
...env
}
});
this._driverProcess.unref();
this._driverProcess.stderr.on("data", (data) => process.stderr.write(data));
const connection = new import_connection.Connection(import_nodePlatform.nodePlatform);
const transport = new import_pipeTransport.PipeTransport(this._driverProcess.stdin, this._driverProcess.stdout);
connection.onmessage = (message) => transport.send(JSON.stringify(message));
transport.onmessage = (message) => connection.dispatch(JSON.parse(message));
transport.onclose = () => this._closePromise.resolve();
this._playwright = connection.initializePlaywright();
}
async stop() {
this._driverProcess.stdin.destroy();
this._driverProcess.stdout.destroy();
this._driverProcess.stderr.destroy();
await this._closePromise;
}
}
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
start
});
@@ -0,0 +1,197 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var serializers_exports = {};
__export(serializers_exports, {
parseSerializedValue: () => parseSerializedValue,
serializePlainValue: () => serializePlainValue,
serializeValue: () => serializeValue
});
module.exports = __toCommonJS(serializers_exports);
function parseSerializedValue(value, handles) {
return innerParseSerializedValue(value, handles, /* @__PURE__ */ new Map(), []);
}
function innerParseSerializedValue(value, handles, refs, accessChain) {
if (value.ref !== void 0)
return refs.get(value.ref);
if (value.n !== void 0)
return value.n;
if (value.s !== void 0)
return value.s;
if (value.b !== void 0)
return value.b;
if (value.v !== void 0) {
if (value.v === "undefined")
return void 0;
if (value.v === "null")
return null;
if (value.v === "NaN")
return NaN;
if (value.v === "Infinity")
return Infinity;
if (value.v === "-Infinity")
return -Infinity;
if (value.v === "-0")
return -0;
}
if (value.d !== void 0)
return new Date(value.d);
if (value.u !== void 0)
return new URL(value.u);
if (value.bi !== void 0)
return BigInt(value.bi);
if (value.e !== void 0) {
const error = new Error(value.e.m);
error.name = value.e.n;
error.stack = value.e.s;
return error;
}
if (value.r !== void 0)
return new RegExp(value.r.p, value.r.f);
if (value.ta !== void 0) {
const ctor = typedArrayKindToConstructor[value.ta.k];
return new ctor(value.ta.b.buffer, value.ta.b.byteOffset, value.ta.b.length / ctor.BYTES_PER_ELEMENT);
}
if (value.a !== void 0) {
const result = [];
refs.set(value.id, result);
for (let i = 0; i < value.a.length; i++)
result.push(innerParseSerializedValue(value.a[i], handles, refs, [...accessChain, i]));
return result;
}
if (value.o !== void 0) {
const result = {};
refs.set(value.id, result);
for (const { k, v } of value.o)
result[k] = innerParseSerializedValue(v, handles, refs, [...accessChain, k]);
return result;
}
if (value.h !== void 0) {
if (handles === void 0)
throw new Error("Unexpected handle");
return handles[value.h];
}
throw new Error(`Attempting to deserialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}`);
}
function serializeValue(value, handleSerializer) {
return innerSerializeValue(value, handleSerializer, { lastId: 0, visited: /* @__PURE__ */ new Map() }, []);
}
function serializePlainValue(arg) {
return serializeValue(arg, (value) => ({ fallThrough: value }));
}
function innerSerializeValue(value, handleSerializer, visitorInfo, accessChain) {
const handle = handleSerializer(value);
if ("fallThrough" in handle)
value = handle.fallThrough;
else
return handle;
if (typeof value === "symbol")
return { v: "undefined" };
if (Object.is(value, void 0))
return { v: "undefined" };
if (Object.is(value, null))
return { v: "null" };
if (Object.is(value, NaN))
return { v: "NaN" };
if (Object.is(value, Infinity))
return { v: "Infinity" };
if (Object.is(value, -Infinity))
return { v: "-Infinity" };
if (Object.is(value, -0))
return { v: "-0" };
if (typeof value === "boolean")
return { b: value };
if (typeof value === "number")
return { n: value };
if (typeof value === "string")
return { s: value };
if (typeof value === "bigint")
return { bi: value.toString() };
if (isError(value))
return { e: { n: value.name, m: value.message, s: value.stack || "" } };
if (isDate(value))
return { d: value.toJSON() };
if (isURL(value))
return { u: value.toJSON() };
if (isRegExp(value))
return { r: { p: value.source, f: value.flags } };
const typedArrayKind = constructorToTypedArrayKind.get(value.constructor);
if (typedArrayKind)
return { ta: { b: Buffer.from(value.buffer, value.byteOffset, value.byteLength), k: typedArrayKind } };
const id = visitorInfo.visited.get(value);
if (id)
return { ref: id };
if (Array.isArray(value)) {
const a = [];
const id2 = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id2);
for (let i = 0; i < value.length; ++i)
a.push(innerSerializeValue(value[i], handleSerializer, visitorInfo, [...accessChain, i]));
return { a, id: id2 };
}
if (typeof value === "object") {
const o = [];
const id2 = ++visitorInfo.lastId;
visitorInfo.visited.set(value, id2);
for (const name of Object.keys(value))
o.push({ k: name, v: innerSerializeValue(value[name], handleSerializer, visitorInfo, [...accessChain, name]) });
return { o, id: id2 };
}
throw new Error(`Attempting to serialize unexpected value${accessChainToDisplayString(accessChain)}: ${value}`);
}
function accessChainToDisplayString(accessChain) {
const chainString = accessChain.map((accessor, i) => {
if (typeof accessor === "string")
return i ? `.${accessor}` : accessor;
return `[${accessor}]`;
}).join("");
return chainString.length > 0 ? ` at position "${chainString}"` : "";
}
function isRegExp(obj) {
return obj instanceof RegExp || Object.prototype.toString.call(obj) === "[object RegExp]";
}
function isDate(obj) {
return obj instanceof Date || Object.prototype.toString.call(obj) === "[object Date]";
}
function isURL(obj) {
return obj instanceof URL || Object.prototype.toString.call(obj) === "[object URL]";
}
function isError(obj) {
const proto = obj ? Object.getPrototypeOf(obj) : null;
return obj instanceof Error || proto?.name === "Error" || proto && isError(proto);
}
const typedArrayKindToConstructor = {
i8: Int8Array,
ui8: Uint8Array,
ui8c: Uint8ClampedArray,
i16: Int16Array,
ui16: Uint16Array,
i32: Int32Array,
ui32: Uint32Array,
f32: Float32Array,
f64: Float64Array,
bi64: BigInt64Array,
bui64: BigUint64Array
};
const constructorToTypedArrayKind = new Map(Object.entries(typedArrayKindToConstructor).map(([k, v]) => [v, k]));
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
parseSerializedValue,
serializePlainValue,
serializeValue
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var validatorPrimitives_exports = {};
__export(validatorPrimitives_exports, {
ValidationError: () => ValidationError,
createMetadataValidator: () => createMetadataValidator,
findValidator: () => findValidator,
maybeFindValidator: () => maybeFindValidator,
scheme: () => scheme,
tAny: () => tAny,
tArray: () => tArray,
tBinary: () => tBinary,
tBoolean: () => tBoolean,
tChannel: () => tChannel,
tEnum: () => tEnum,
tFloat: () => tFloat,
tInt: () => tInt,
tObject: () => tObject,
tOptional: () => tOptional,
tString: () => tString,
tType: () => tType,
tUndefined: () => tUndefined
});
module.exports = __toCommonJS(validatorPrimitives_exports);
class ValidationError extends Error {
}
const scheme = {};
function findValidator(type, method, kind) {
const validator = maybeFindValidator(type, method, kind);
if (!validator)
throw new ValidationError(`Unknown scheme for ${kind}: ${type}.${method}`);
return validator;
}
function maybeFindValidator(type, method, kind) {
const schemeName = type + (kind === "Initializer" ? "" : method[0].toUpperCase() + method.substring(1)) + kind;
return scheme[schemeName];
}
function createMetadataValidator() {
return tOptional(scheme["Metadata"]);
}
const tFloat = (arg, path, context) => {
if (arg instanceof Number)
return arg.valueOf();
if (typeof arg === "number")
return arg;
throw new ValidationError(`${path}: expected float, got ${typeof arg}`);
};
const tInt = (arg, path, context) => {
let value;
if (arg instanceof Number)
value = arg.valueOf();
else if (typeof arg === "number")
value = arg;
else
throw new ValidationError(`${path}: expected integer, got ${typeof arg}`);
if (!Number.isInteger(value))
throw new ValidationError(`${path}: expected integer, got float ${value}`);
return value;
};
const tBoolean = (arg, path, context) => {
if (arg instanceof Boolean)
return arg.valueOf();
if (typeof arg === "boolean")
return arg;
throw new ValidationError(`${path}: expected boolean, got ${typeof arg}`);
};
const tString = (arg, path, context) => {
if (arg instanceof String)
return arg.valueOf();
if (typeof arg === "string")
return arg;
throw new ValidationError(`${path}: expected string, got ${typeof arg}`);
};
const tBinary = (arg, path, context) => {
if (context.binary === "fromBase64") {
if (arg instanceof String)
return Buffer.from(arg.valueOf(), "base64");
if (typeof arg === "string")
return Buffer.from(arg, "base64");
throw new ValidationError(`${path}: expected base64-encoded buffer, got ${typeof arg}`);
}
if (context.binary === "toBase64") {
if (!(arg instanceof Buffer))
throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`);
return arg.toString("base64");
}
if (context.binary === "buffer") {
if (!(arg instanceof Buffer) && !(arg instanceof Object))
throw new ValidationError(`${path}: expected Buffer, got ${typeof arg}`);
return arg;
}
throw new ValidationError(`Unsupported binary behavior "${context.binary}"`);
};
const tUndefined = (arg, path, context) => {
if (Object.is(arg, void 0))
return arg;
throw new ValidationError(`${path}: expected undefined, got ${typeof arg}`);
};
const tAny = (arg, path, context) => {
return arg;
};
const tOptional = (v) => {
return (arg, path, context) => {
if (Object.is(arg, void 0))
return arg;
return v(arg, path, context);
};
};
const tArray = (v) => {
return (arg, path, context) => {
if (!Array.isArray(arg))
throw new ValidationError(`${path}: expected array, got ${typeof arg}`);
return arg.map((x, index) => v(x, path + "[" + index + "]", context));
};
};
const tObject = (s) => {
return (arg, path, context) => {
if (Object.is(arg, null))
throw new ValidationError(`${path}: expected object, got null`);
if (typeof arg !== "object")
throw new ValidationError(`${path}: expected object, got ${typeof arg}`);
const result = {};
for (const [key, v] of Object.entries(s)) {
const value = v(arg[key], path ? path + "." + key : key, context);
if (!Object.is(value, void 0))
result[key] = value;
}
if (context.isUnderTest()) {
for (const [key, value] of Object.entries(arg)) {
if (key.startsWith("__testHook"))
result[key] = value;
}
}
return result;
};
};
const tEnum = (e) => {
return (arg, path, context) => {
if (!e.includes(arg))
throw new ValidationError(`${path}: expected one of (${e.join("|")})`);
return arg;
};
};
const tChannel = (names) => {
return (arg, path, context) => {
return context.tChannelImpl(names, arg, path, context);
};
};
const tType = (name) => {
return (arg, path, context) => {
const v = scheme[name];
if (!v)
throw new ValidationError(path + ': unknown type "' + name + '"');
return v(arg, path, context);
};
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ValidationError,
createMetadataValidator,
findValidator,
maybeFindValidator,
scheme,
tAny,
tArray,
tBinary,
tBoolean,
tChannel,
tEnum,
tFloat,
tInt,
tObject,
tOptional,
tString,
tType,
tUndefined
});

Some files were not shown because too many files have changed in this diff Show More