From 8d8e128c1df6d7d4d1ed35f0945a4e0af9dd3963 Mon Sep 17 00:00:00 2001 From: Velang Date: Fri, 17 Jul 2026 15:08:50 -0700 Subject: [PATCH 01/12] fixing basic email cypress spec file --- .../Unity.AutoUI/cypress/e2e/basicEmail.cy.ts | 136 +++++++++++++----- .../cypress/regression/ApprovalFlow.cy.ts | 12 +- .../scripts/chefs-api-submission.cy.ts | 19 +-- 3 files changed, 119 insertions(+), 48 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 4eff6302d0..48077844b7 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -36,6 +36,33 @@ describe("Send an email", () => { const loginPage = LoginPageInstance(); const navPage = NavigationPageInstance(); + // The "Template Applied" swal2 dialog (see "Select Email Template") fires + // asynchronously and can land after that test has already finished waiting + // for it, leaking into whichever test runs next and blocking every field + // underneath it. Cheap and idempotent — call at the top of each subsequent + // test to mop up a straggler if one shows up. + function dismissStrayModalIfPresent() { + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + const modal = $body.find(".swal2-popup, .modal.show"); + if (modal.length === 0) { + return; + } + + const dismissButton = modal + .find("button") + .filter((_, element) => { + return /^(apply template|confirm|apply|ok)$/i.test( + (element.textContent || "").trim(), + ); + }) + .first(); + + if (dismissButton.length > 0) { + cy.wrap(dismissButton).click({ force: true }); + } + }); + } + function openSavedEmailFromHistoryBySubject(subject: string) { cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { const historyTableById = $body.find("#EmailHistoryTable"); @@ -156,7 +183,6 @@ describe("Send an email", () => { }); it("Open Emails tab", () => { - // Dismiss any swal2 modal that may be covering the tab cy.get("body").then(($body) => { if ($body.find(".swal2-container").length > 0) { cy.get(".swal2-container").then(($swal) => { @@ -175,114 +201,154 @@ describe("Send an email", () => { }); cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") .click(); - cy.contains("Emails", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT }).should( + "have.class", + "active", + ); cy.contains("Email History", { timeout: STANDARD_TIMEOUT }).should("exist"); }); it("Open New Email form", () => { cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") .click(); - cy.contains("Email To", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }).should("be.visible"); }); it("Select Email Template", () => { - cy.get("#template", { timeout: STANDARD_TIMEOUT }) - .should("exist") + // UAT/PROD are still on the pre-redesign composer (#template, no + // confirmation dialogs); DEV/TEST have the newer TinyMCE toolbar select + // (no id, only this title attribute) plus a two-step swal2 confirmation. + // Match either selector so this spec works across all four environments. + cy.get('select[title="Select a template to apply"], #template', { + timeout: STANDARD_TIMEOUT, + }) .should("be.visible") - .select(TEMPLATE_NAME); + .select(TEMPLATE_NAME, { force: true }); + + // Only the newer UI pops the "Apply Template?" / "Template Applied" + // swal2 confirmations — skip them if they don't show up. + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + if ($body.find(".swal2-popup").length > 0) { + cy.contains(".swal2-popup button", "Apply Template", { + timeout: STANDARD_TIMEOUT, + }).click(); - cy.get("#template") - .find("option:selected") - .should("have.text", TEMPLATE_NAME); + // Once its async content fetch resolves, the app shows a second, + // separate "Template Applied" success dialog (OK button only). + cy.contains(".swal2-popup button", "OK", { + timeout: STANDARD_TIMEOUT, + }).click(); + } + }); // #EmailBody is a hidden textarea backing the rich-text editor. // Template selection populates the visible RTE but does not auto-sync // the backing field — trigger the change manually if still empty. cy.get("#EmailBody", { timeout: STANDARD_TIMEOUT }).then(($el) => { if (($el.val() as string).trim() === "") { - cy.wrap($el).invoke("val", "Test email body").trigger("change"); + cy.wrap($el) + .invoke("val", "Test email body") + .trigger("change", { force: true }); } }); }); it("Set Email To address", () => { + dismissStrayModalIfPresent(); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_TO); + .clear({ force: true }) + .type(TEST_EMAIL_TO, { force: true }); cy.get("#EmailTo").should("have.value", TEST_EMAIL_TO); }); it("Set Email CC address", () => { + dismissStrayModalIfPresent(); + cy.get("#EmailCC", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_CC); + .clear({ force: true }) + .type(TEST_EMAIL_CC, { force: true }); cy.get("#EmailCC").should("have.value", TEST_EMAIL_CC); }); it("Set Email BCC address", () => { + dismissStrayModalIfPresent(); + + // The BCC row (#bcc-input-row) is hidden until the BCC toggle is clicked. + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + const bccRow = $body.find("#bcc-input-row"); + if (bccRow.length > 0 && !Cypress.$(bccRow[0]).is(":visible")) { + cy.get("#btn-show-bcc").click(); + } + }); + cy.get("#EmailBCC", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_BCC); + .clear({ force: true }) + .type(TEST_EMAIL_BCC, { force: true }); cy.get("#EmailBCC").should("have.value", TEST_EMAIL_BCC); }); it("Set Email Subject", () => { + dismissStrayModalIfPresent(); + cy.get("#EmailSubject", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_SUBJECT); + .clear({ force: true }) + .type(TEST_EMAIL_SUBJECT, { force: true }); cy.get("#EmailSubject").should("have.value", TEST_EMAIL_SUBJECT); }); it("Save the email", () => { - cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }) - .should("exist") + dismissStrayModalIfPresent(); + + cy.get("#btn-save-top, #btn-save", { timeout: STANDARD_TIMEOUT }) .scrollIntoView() .should("be.visible") - .click(); + .click({ force: true }); - cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); + // The composer stays open after saving (it doesn't hand control back to + // #btn-new-email — that stays hidden while a composer is active), so + // check for the history list refreshing instead. + cy.contains("Email History", { timeout: STANDARD_TIMEOUT }).should("exist"); }); it("Select saved email from Email History", () => { + dismissStrayModalIfPresent(); openSavedEmailFromHistoryBySubject(TEST_EMAIL_SUBJECT); + // Clicking the history row scrolled the table into view, not the + // composer above it — scroll back up before checking field visibility. + cy.get("#EmailForm", { timeout: STANDARD_TIMEOUT }).scrollIntoView(); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }).should("be.visible"); cy.get("#EmailCC").should("be.visible"); cy.get("#EmailBCC").should("be.visible"); cy.get("#EmailSubject").should("be.visible"); - cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }).should("exist"); - cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#btn-send-top, #btn-send", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#btn-save-top, #btn-save", { timeout: STANDARD_TIMEOUT }).should("exist"); }); it("Send the email", () => { - cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }) - .should("exist") + dismissStrayModalIfPresent(); + + cy.get("#btn-send-top, #btn-send", { timeout: STANDARD_TIMEOUT }) .scrollIntoView() .should("be.visible") .should("not.be.disabled") - .click(); + .click({ force: true }); }); it("Confirm send email in dialog", () => { diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 3cc858c15e..983529bcd5 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -170,13 +170,23 @@ const APPLICATIONS_PATH = "GrantApplications"; function openStatusActionsMenu(): void { waitForBlockingUiToClear(); detailsPage.dismissErrorModalIfPresent(); + + // On DEV the button can re-render (e.g. "Processing..." -> its real label) + // between assertions, detaching the subject held by a single chained + // .should().and(). Re-querying fresh for each assertion (per Cypress's + // own guidance for this error) picks up the current DOM node instead. + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .scrollIntoView(); + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) .filter(":visible") .first() - .scrollIntoView() .should("be.visible") .and("not.contain.text", "Processing...") .click({ force: true }); + cy.get(STATUS_ACTIONS.menu, { timeout: 20000 }).should("be.visible"); } diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts index d04fa560c4..0b7124aaf0 100644 --- a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -159,18 +159,13 @@ function completeChefsLogin(environment: ChefsEnvironment, timeout: number): voi cy.visit(`${environment.baseURL}/app`); - cy.get("#app > div > main > header > header > div > div.d-print-none", { - timeout, - }) - .should("exist") - .click(); - - cy.get( - "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", - { timeout }, - ) - .should("exist") - .click(); + // The header auth button hydrates asynchronously after an auth-state check; + // wait for its label rather than just its (empty) wrapper to exist. + cy.get("#loginButton", { timeout }).should("be.visible").click(); + + // CHEFS shows an identity-provider picker (IDIR / IDIR MFA / BC Services + // Card / BCeID); each button carries a stable data-test attribute. + cy.get('[data-test="idir"]', { timeout }).should("be.visible").click(); waitForIdentityRedirectOrAuthenticatedChefsPage(environment.baseURL, timeout); From 8509bb134013bf9a29a22846e3663a53ee9521da Mon Sep 17 00:00:00 2001 From: Velang Date: Tue, 21 Jul 2026 09:22:08 -0700 Subject: [PATCH 02/12] fixing Approval flow comments and issues --- .../cypress/regression/ApprovalFlow.cy.ts | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 983529bcd5..83523aa0b5 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -114,7 +114,7 @@ const APPLICATIONS_PATH = "GrantApplications"; dismissBlockingModalIfPresent(); listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId); @@ -155,7 +155,6 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.get("#nav-payment-info-tab").should("have.class", "active"); detailsPage.dismissErrorModalIfPresent(); - // Intercept the Refresh Site List API call and wait for it to complete cy.intercept("GET", "**/api/app/supplier/sites-by-supplier-number**").as("siteRefresh"); detailsPage.clickRefreshSiteList(); cy.wait("@siteRefresh"); @@ -168,8 +167,11 @@ const APPLICATIONS_PATH = "GrantApplications"; } function openStatusActionsMenu(): void { - waitForBlockingUiToClear(); + // Dismiss any transient error modal first — waitForBlockingUiToClear() only + // waits for blocking UI to go away on its own, it never clicks anything, so + // an error modal that requires a click to close would hang it until timeout. detailsPage.dismissErrorModalIfPresent(); + waitForBlockingUiToClear(); // On DEV the button can re-render (e.g. "Processing..." -> its real label) // between assertions, detaching the subject held by a single chained @@ -223,7 +225,20 @@ const APPLICATIONS_PATH = "GrantApplications"; } function confirmStatusActionIfNeeded(): void { - cy.wait(500); + // The confirmation modal (SweetAlert2 or Bootstrap "Confirm Action") renders after + // client-side validation that runs post-click — there's no network call to key a + // wait off of, and it can take longer than a single fixed delay to appear. Some + // actions (Start Review, Complete Review, Start Assessment) never show a modal at + // all, so we can't just wait for one to exist either. Poll for either outcome. + const pollDeadline = Date.now() + 4000; + cy.get("body", { timeout: 4000 }).should(($body) => { + const modalPresent = + $body.find(".swal2-popup .swal2-confirm").length > 0 || + $body.find(".modal.show .modal-content:contains('Confirm Action')") + .length > 0; + expect(modalPresent || Date.now() > pollDeadline).to.be.true; + }); + cy.get("body").then(($body) => { if ($body.find(".swal2-popup .swal2-confirm").length > 0) { cy.get(".swal2-popup .swal2-confirm", { timeout: 20000 }) @@ -325,14 +340,9 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.get("#ApprovalView_ApprovedAmount", { timeout: 30000 }) .should("be.visible") .and("not.be.disabled"); + reviewPage.enterApprovedAmount(TEST_CONFIG.approvedAmount); cy.get("body").then(($body) => { - if ($body.find("#ApprovalView_ApprovedAmount").length > 0) { - reviewPage.enterApprovedAmount(TEST_CONFIG.approvedAmount); - } else { - cy.log("Approved amount field not present yet; skipping amount entry"); - } - if ($body.find("#ApprovalView_FinalDecisionDate").length > 0) { reviewPage.setDecisionDateToToday(); } else { @@ -352,7 +362,7 @@ const APPLICATIONS_PATH = "GrantApplications"; listPage .waitForNoBlockingOverlay() - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId) .selectRowByText(submissionId); @@ -421,6 +431,9 @@ const APPLICATIONS_PATH = "GrantApplications"; /** Select the submissionId row and open the Approve Payments modal. */ function selectRowAndOpenApproveModal(): void { + // Same ordering as openStatusActionsMenu() — dismiss any transient error + // modal before the passive wait, since the wait never clicks anything. + detailsPage.dismissErrorModalIfPresent(); waitForBlockingUiToClear(); cy.contains("tr", submissionId, { timeout: 20000 }) .scrollIntoView() @@ -511,7 +524,7 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Search for submission", () => { expect(submissionId, "Submission ID should be set").to.exist; listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId); }); @@ -530,7 +543,7 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.log("Already on details page after assignment"); } else { listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId) .selectRowByText(submissionId) @@ -582,8 +595,7 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.get("body").then(($body) => { if ($body.find("#CreateButton").length > 0) { cy.get("#CreateButton").click({ force: true }); - // Give the new assessment row time to render before subsequent actions. - cy.wait(1000); // Needed because row creation animation can delay DOM readiness. + cy.wait(1000); // Row creation animation can delay DOM readiness. } else { cy.log("Create Assessment button not found - may already be created"); } @@ -598,7 +610,9 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Configure payment info", () => { cy.reload(); // Reload to get fresh data and avoid concurrency issues - // Wait briefly for async payment tab dependencies to stabilize after reload. + // Every other cy.reload() in this spec is followed by this — a transient + // auth/session error modal can appear post-reload and block the form below. + detailsPage.dismissErrorModalIfPresent(); cy.wait(2000); // Prevents save attempts before payment controls are initialized. detailsPage .goToPaymentInfoTab() @@ -723,7 +737,7 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Verify application status is Approved", () => { expect(submissionId, "Submission ID should be set").to.exist; listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId); From 2618e50d0793247767c21ecf239698d675de7dbc Mon Sep 17 00:00:00 2001 From: Stephan McColm Date: Wed, 22 Jul 2026 20:23:58 -0700 Subject: [PATCH 03/12] bug/33849_fix_basic_email_cypress_spec: stabilize Cypress login and approval flow retries - Wait for SiteMinder credentials or authenticated Unity state during login - Move ApplicationsActionBar login setup to beforeEach for retry recovery - Make ApprovalFlow approval confirmation idempotent and status-driven --- .../cypress/e2e/ApplicationsActionBar.cy.ts | 5 +- .../cypress/regression/ApprovalFlow.cy.ts | 39 ++++-- .../Unity.AutoUI/cypress/support/auth.ts | 111 ++++++++++++++---- 3 files changed, 120 insertions(+), 35 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts index 7bbdde7041..5ca15b0e3b 100644 --- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts @@ -164,7 +164,10 @@ describe("Unity Login and check data from CHEFS", () => { // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. before(() => { Cypress.config("includeShadowDom", true); - loginIfNeeded({ timeout: 20000 }); + }); + + beforeEach(() => { + loginIfNeeded({ timeout: 60000 }); }); it("Switch to Default Grants Program if available", () => { diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 83523aa0b5..b870a49147 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -714,17 +714,40 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Test approval workflow (confirm)", () => { cy.reload(); // Refresh to ensure all changes are reflected before approval detailsPage.dismissErrorModalIfPresent(); - clickStatusAction(STATUS_ACTIONS.completeAssessment); - confirmStatusActionIfNeeded(); + waitForBlockingUiToClear(); - cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) - .filter(":visible") - .first() + cy.get(BREADCRUMB_STATUS_SELECTOR, { timeout: 20000 }) .should("be.visible") - .and("not.contain.text", "Processing..."); + .invoke("text") + .then((statusText) => { + const currentStatus = statusText.trim().toLowerCase(); + + if (currentStatus === "approved") { + cy.log("Application is already approved"); + return; + } - clickStatusAction(STATUS_ACTIONS.approve); - detailsPage.waitForConfirmModal().clickConfirm(); + clickStatusActionIfEnabled( + STATUS_ACTIONS.completeAssessment, + "Complete Assessment", + ); + + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .and("not.contain.text", "Processing..."); + + clickStatusAction(STATUS_ACTIONS.approve); + confirmStatusActionIfNeeded(); + }); + + cy.get(BREADCRUMB_STATUS_SELECTOR, { timeout: 60000 }) + .should("be.visible") + .invoke("text") + .should((statusText) => { + expect(statusText.trim().toLowerCase()).to.equal("approved"); + }); }); // ============ Post-Approval Verification ============ diff --git a/applications/Unity.AutoUI/cypress/support/auth.ts b/applications/Unity.AutoUI/cypress/support/auth.ts index bd83e88c50..aa47482b8a 100644 --- a/applications/Unity.AutoUI/cypress/support/auth.ts +++ b/applications/Unity.AutoUI/cypress/support/auth.ts @@ -39,6 +39,48 @@ function isLoginPage($body: JQuery): boolean { return $body.find('button:contains("LOGIN")').length > 0; } +function hasCredentialForm($body: JQuery): boolean { + return ( + $body.find("#user, input[name='user'], input[name='username']").length > 0 && + $body.find("#password, input[name='password'], input[type='password']").length > 0 + ); +} + +function hasViewApplicationsButton($body: JQuery): boolean { + return $body.find('button:contains("VIEW APPLICATIONS")').length > 0; +} + +function waitForCredentialFormOrAuthenticatedPage(timeout: number): void { + cy.get("body", { timeout }).should(($body) => { + const pathname = $body[0]?.ownerDocument?.location?.pathname ?? ""; + + const isReady = + pathname.includes("/GrantApplications") || + hasViewApplicationsButton($body) || + hasCredentialForm($body); + + expect( + isReady, + `expected credential form, VIEW APPLICATIONS button, or /GrantApplications. Current path: ${pathname}`, + ).to.equal(true); + }); +} + +function getExistingSelector( + $body: JQuery, + selectors: string[], +): string { + const selector = selectors.find((candidate) => $body.find(candidate).length > 0); + + if (!selector) { + throw new Error( + `None of the expected selectors were found: ${selectors.join(", ")}`, + ); + } + + return selector; +} + /** * Handles the Keycloak IDIR selection and login form */ @@ -69,36 +111,53 @@ function handleKeycloakLogin( } }); + waitForCredentialFormOrAuthenticatedPage(timeout); + // Handle username/password form if it appears cy.get("body", { timeout }).then(($loginBody) => { - if ($loginBody.find("#user").length > 0) { - cy.log("Entering IDIR credentials"); - - const username = options.username || Cypress.env("test1username"); - const password = options.password || Cypress.env("test1password"); - - cy.get("#user", { timeout }) - .should("be.visible") - .type(username, { log: false }); - - cy.get("#password", { timeout }) - .should("be.visible") - .type(password, { log: false }); - - // Look for Continue button or submit the form - cy.get("body").then(($formBody) => { - if ($formBody.find('button:contains("Continue")').length > 0) { - cy.contains("button", "Continue", { timeout }).click(); - } else if ($formBody.find("input[type='submit']").length > 0) { - cy.get("input[type='submit']", { timeout }).click(); - } else { - cy.log("⚠️ No submit button found, attempting form submission"); - cy.get("#user").parents("form").submit(); - } - }); - } else { + if (!hasCredentialForm($loginBody)) { cy.log("✓ Already authenticated, skipping credentials"); + return; } + + cy.log("Entering IDIR credentials"); + + const username = options.username || Cypress.env("test1username"); + const password = options.password || Cypress.env("test1password"); + const usernameSelector = getExistingSelector($loginBody, [ + "#user", + "input[name='user']", + "input[name='username']", + ]); + const passwordSelector = getExistingSelector($loginBody, [ + "#password", + "input[name='password']", + "input[type='password']", + ]); + + cy.get(usernameSelector, { timeout }) + .should("be.visible") + .clear() + .type(username, { log: false }); + + cy.get(passwordSelector, { timeout }) + .should("be.visible") + .clear() + .type(password, { log: false }); + + // Look for Continue button or submit the form + cy.get("body").then(($formBody) => { + if ($formBody.find('button:contains("Continue")').length > 0) { + cy.contains("button", "Continue", { timeout }).click(); + } else if ($formBody.find("input[type='submit']").length > 0) { + cy.get("input[type='submit']", { timeout }).click(); + } else if ($formBody.find("button[type='submit']").length > 0) { + cy.get("button[type='submit']", { timeout }).click(); + } else { + cy.log("⚠️ No submit button found, attempting form submission"); + cy.get(usernameSelector).parents("form").submit(); + } + }); }); } From 7ab454d50b001f0bc64f72fe4c8b6545ae20c03e Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 23 Jul 2026 12:37:15 -0700 Subject: [PATCH 04/12] feature/AB#32049-GHAPIToken --- .../src/Unity.GrantManager.Web/GrantManagerWebModule.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 88885d73d9..3388846e2d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -332,8 +332,8 @@ private static void ConfigurePolicies(ServiceConfigurationContext context, IConf .ConfigureHttpClient(client => { string pat = - Environment.GetEnvironmentVariable("UNITY_GITHUB_PAT") - ?? configuration["UNITY_GITHUB_PAT"] + Environment.GetEnvironmentVariable("GH_API_TOKEN") + ?? configuration["GH_API_TOKEN"] ?? ""; if (!string.IsNullOrWhiteSpace(pat)) From 30bbcff7a258d3dd9d8a8df32315774de745b83e Mon Sep 17 00:00:00 2001 From: "Todosichuk, Daryl" Date: Thu, 23 Jul 2026 14:00:30 -0700 Subject: [PATCH 05/12] AB#33776 Scope cypress runners to use CYPRESS_OPENSHIFT_TOKEN and CYPRESS_OPENSHIFT_CLUSTER --- .github/workflows/cypress-e2e-runner.yml | 67 ++++++++++++++++-------- .github/workflows/cypress-prod.yml | 1 - .github/workflows/cypress-uat.yml | 3 -- 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/.github/workflows/cypress-e2e-runner.yml b/.github/workflows/cypress-e2e-runner.yml index 77d7f6fa06..63eab4dc70 100644 --- a/.github/workflows/cypress-e2e-runner.yml +++ b/.github/workflows/cypress-e2e-runner.yml @@ -5,16 +5,28 @@ name: Cypress E2E (runner) # Unlike Grants, this doesn't run Cypress on the GitHub-hosted runner directly — # the Unity Grant Manager Route is IP-allowlisted to the BC Gov network, which # GitHub-hosted runners can't reach. Instead this job launches an OpenShift Job -# (from the unity-cypress-job Template in d18498-tools) that runs the existing +# (from the unity-cypress-job Template in ce395f-tools) that runs the existing # Unity.AutoUI Cypress suite from inside the cluster, waits for it to finish, # and pulls the logs/screenshots back into this Actions run. # +# This deliberately targets ce395f-tools on the Gold cluster, not d18498-tools +# on Silver, even though the app environments (dev/test/uat/prod) still run on +# Silver during the cluster migration — the Job's own baseUrl (via the +# base_url input, or each cypress-config-*.json's default) is what it tests +# against, independent of which cluster runs the Job itself. Building the CI +# plumbing against the migration target now avoids redoing it later. +# # Test credentials are NOT GitHub secrets — the Job pulls them from the -# unity-cypress-config Secret in d18498-tools, synced from Vault +# unity-cypress-config Secret in ce395f-tools, synced from Vault # (GH_UGM_CYPRESS_CONFIG) via External Secrets. # -# OpenShift auth reuses the same oc-login pattern already used by -# docker-build-dev.yml/docker-build-test.yml/docker-build-main.yml. +# OpenShift auth reuses the pipeline ServiceAccount (ce395f-tools), which +# already holds ClusterRole/edit there — covers everything this job needs +# (templates, processedtemplates, jobs, pods/log, pods/exec), no additional +# RBAC required. Its token is CYPRESS_OPENSHIFT_TOKEN and its cluster is +# CYPRESS_OPENSHIFT_CLUSTER — both repo-level (not tied to a GitHub +# Environment, and deliberately separate from the shared OPENSHIFT_CLUSTER +# var docker-build-*.yml uses, since that one still points at Silver). on: workflow_call: @@ -32,27 +44,21 @@ on: required: false type: string default: "" - gh_environment: - description: "GitHub Environment to source OpenShift credentials from (defaults to env_name — dev/test have their own; uat/prod reuse 'main')" - required: false - type: string - default: "" permissions: contents: read env: - TOOLS_NAMESPACE: d18498-tools - JOB_TIMEOUT: 20m + TOOLS_NAMESPACE: ce395f-tools + JOB_TIMEOUT_SECONDS: 1200 jobs: cypress: name: Cypress E2E — ${{ inputs.env_name }} runs-on: ubuntu-latest - environment: ${{ inputs.gh_environment || inputs.env_name }} env: - OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_CLUSTER: ${{ vars.CYPRESS_OPENSHIFT_CLUSTER }} + OC_AUTH_TOKEN: ${{ secrets.CYPRESS_OPENSHIFT_TOKEN }} GH_TOKEN: ${{ secrets.GH_API_TOKEN }} steps: @@ -68,9 +74,9 @@ jobs: - name: Launch Cypress Job id: launch run: | - # The unity-cypress-job Template lives in the tenant-gitops-d18498 repo + # The unity-cypress-job Template lives in the tenant-gitops-ce395f repo # and is synced into the cluster by ArgoCD — process it by name directly - # from d18498-tools rather than checking out that repo here. + # from ce395f-tools rather than checking out that repo here. JOB_REF=$(oc process unity-cypress-job \ -p ENV=${{ inputs.env_name }} \ -p CYPRESS_CONFIG_KEY=${{ inputs.cypress_config_key }} \ @@ -85,15 +91,30 @@ jobs: - name: Wait for Cypress Job to finish id: wait run: | + # Don't use `oc wait --for=condition=complete/failed` here: a freshly + # created Job has no .status.conditions entries yet, and + # --for=condition=X (without an explicit =true) is satisfied the + # instant the condition is merely absent rather than false — so it + # can report "condition met" seconds after creation, long before the + # Job has actually run. Poll the numeric succeeded/failed fields + # instead, which are only set once a pod genuinely finishes. JOB_REF="${{ steps.launch.outputs.job_ref }}" - oc wait --for=condition=complete --timeout=$JOB_TIMEOUT "$JOB_REF" -n $TOOLS_NAMESPACE & - COMPLETE_PID=$! - oc wait --for=condition=failed --timeout=$JOB_TIMEOUT "$JOB_REF" -n $TOOLS_NAMESPACE & - FAILED_PID=$! - wait -n $COMPLETE_PID $FAILED_PID || true - kill $COMPLETE_PID $FAILED_PID 2>/dev/null || true + DEADLINE=$(( $(date +%s) + JOB_TIMEOUT_SECONDS )) + SUCCEEDED=0 + FAILED=0 + while true; do + SUCCEEDED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.succeeded}') + FAILED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.failed}') + if [ "${SUCCEEDED:-0}" -ge 1 ] 2>/dev/null || [ "${FAILED:-0}" -ge 1 ] 2>/dev/null; then + break + fi + if [ "$(date +%s)" -ge "$DEADLINE" ]; then + echo "Timed out after ${JOB_TIMEOUT_SECONDS}s waiting for $JOB_REF" + break + fi + sleep 10 + done - SUCCEEDED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.succeeded}') echo "succeeded=${SUCCEEDED:-0}" >> "$GITHUB_OUTPUT" - name: Show Cypress logs diff --git a/.github/workflows/cypress-prod.yml b/.github/workflows/cypress-prod.yml index 03ee93b499..a9e9fcde95 100644 --- a/.github/workflows/cypress-prod.yml +++ b/.github/workflows/cypress-prod.yml @@ -16,5 +16,4 @@ jobs: with: env_name: prod cypress_config_key: CYPRESS_CONFIG_PROD - gh_environment: main secrets: inherit diff --git a/.github/workflows/cypress-uat.yml b/.github/workflows/cypress-uat.yml index 4e3236b8f1..a33f814e2f 100644 --- a/.github/workflows/cypress-uat.yml +++ b/.github/workflows/cypress-uat.yml @@ -5,8 +5,6 @@ name: Cypress E2E — UAT # # Unity has no separate UAT build workflow — UAT deploys off the main build, # so this triggers off the same workflow_run as prod's build pipeline. -# There's also no "uat" GitHub Environment today, so oc-login reuses the -# "main" Environment's OpenShift credentials (same cluster-wide access). # # Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. @@ -26,5 +24,4 @@ jobs: with: env_name: uat cypress_config_key: CYPRESS_CONFIG_UAT - gh_environment: main secrets: inherit From 0e9f85d8531322eccaa93550f820a5540198350e Mon Sep 17 00:00:00 2001 From: Stephan McColm Date: Thu, 23 Jul 2026 14:27:06 -0700 Subject: [PATCH 06/12] feature/AB#33893_CHEFS_One_Click_Form_Tester - Inital commit for the Chrome browser extension. --- .../BUILD-VALIDATION.txt | 57 + .../chefs-one-click-form-tester/CHANGELOG.md | 216 + .../PACKAGE-MANIFEST.json | 177 + .../chefs-one-click-form-tester/README.md | 140 + .../SHA256SUMS.txt | 35 + .../TROUBLESHOOTING.md | 117 + .../attachments/chefs-attachment.csv | 3 + .../attachments/chefs-attachment.docx | Bin 0 -> 36848 bytes .../attachments/chefs-attachment.jpg | Bin 0 -> 12082 bytes .../attachments/chefs-attachment.json | 11 + .../attachments/chefs-attachment.pdf | Bin 0 -> 1612 bytes .../attachments/chefs-attachment.png | Bin 0 -> 2824 bytes .../attachments/chefs-attachment.txt | 2 + .../attachments/chefs-attachment.xlsx | Bin 0 -> 3617 bytes .../content-script.js | 3795 +++++++++++++++++ .../dashboard-model.js | 394 ++ .../chefs-one-click-form-tester/dashboard.css | 199 + .../dashboard.html | 93 + .../chefs-one-click-form-tester/dashboard.js | 784 ++++ .../examples/custom-format-rules.example.json | 25 + .../export-folder-picker.js | 159 + .../export-path.js | 58 + .../icons/icon-128.png | Bin 0 -> 1054 bytes .../icons/icon-16.png | Bin 0 -> 180 bytes .../icons/icon-32.png | Bin 0 -> 284 bytes .../icons/icon-48.png | Bin 0 -> 415 bytes .../chefs-one-click-form-tester/manifest.json | 52 + .../chefs-one-click-form-tester/options.css | 38 + .../chefs-one-click-form-tester/options.html | 154 + .../chefs-one-click-form-tester/options.js | 491 +++ .../page-bridge.js | 717 ++++ .../chefs-one-click-form-tester/popup.css | 102 + .../chefs-one-click-form-tester/popup.html | 49 + .../chefs-one-click-form-tester/popup.js | 262 ++ ...ustom-format-rules-v1-20260722-163940.json | 16 + .../service-worker.js | 1690 ++++++++ .../Unity.CHEFS/run-regression-suite.cmd | 55 + 37 files changed, 9891 insertions(+) create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/BUILD-VALIDATION.txt create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/CHANGELOG.md create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/TROUBLESHOOTING.md create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.csv create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.docx create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.jpg create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.json create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.pdf create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.png create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.txt create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.xlsx create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/content-script.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard-model.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.css create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.html create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/examples/custom-format-rules.example.json create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-folder-picker.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-path.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-128.png create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-16.png create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-32.png create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-48.png create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/manifest.json create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.css create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.html create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/page-bridge.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.css create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.html create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/rules/chefs-custom-format-rules-v1-20260722-163940.json create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/service-worker.js create mode 100644 applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/BUILD-VALIDATION.txt b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/BUILD-VALIDATION.txt new file mode 100644 index 0000000000..f55154320b --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/BUILD-VALIDATION.txt @@ -0,0 +1,57 @@ +CHEFS One-Click Form Tester +Version: 0.4.0 +Build: 2026.07.23.14 + +Validation results + +- PASS: Evaluation criteria version 9 defines DASH-01 through DASH-05 before evaluation. +- PASS: Round-009 evidence was inventoried and all eight new troubleshooting bundles were parsed before the v0.4.0 cut. +- PASS: Round-009 index 001 exposed an empty Form.io shell with no initialized instance, mounted fields or submit control. +- PASS: Readiness now requires mounted Form.io components and interactive controls across two samples 300 ms apart. +- PASS: An empty shell remains observable as waiting_for_form and does not create a misleading run. +- PASS: The existing 45-second never-ready bound and queue advancement remain green. +- PASS: Singleton completion opens the dashboard once only when the setting is enabled. +- PASS: Batch completion opens the dashboard once after the final queued run, not between runs. +- PASS: Duplicate finalization does not reopen the dashboard. +- PASS: An existing dashboard tab is refreshed and focused instead of duplicated. +- PASS: Dashboard automatic opening and aggregate history retention are disabled by default. +- PASS: A PID-canary fixture is reduced to the strict dashboard whitelist without retaining its run ID, title, field value, label, email/phone canary, filename, URL parameters, confirmation ID, failure text, raw events or stack. +- PASS: Run and suite references are hashed; the form reference contains only a UUID prefix. +- PASS: Unexpected keys make a stored dashboard summary ineligible. +- PASS: The dashboard HTML, CSS and JavaScript use no network data source or remote page resource. +- PASS: The default layout contract fixes document overflow and provides the header, four summary cards, chart workspace and findings panel at 1440×900. +- PASS: Simple, Analyst, Statistical and Experimental chart groups expose 15 chart choices. +- PASS: Advanced charts state their data thresholds and are disabled with an explicit reason when unavailable. +- PASS: Each available chart is paired with plain-language interpretation. +- PASS: Optional aggregate history is bounded at 200 schema-valid records and 90 days. +- PASS: Disabled retention supplies an empty history and completed-run processing clears the retained-history store. +- PASS: History can be cleared from Settings and from the dashboard. +- PASS: Existing EXPORT-01 through EXPORT-04 regressions remain green. +- PASS: Existing BATCH-01 through BATCH-06 and UX-02 regressions remain green. +- PASS: The project-owned DATA-02 generated-email regression remains green. +- PASS: All shipped JavaScript and evaluation scripts passed node --check. +- PASS: All packaged JSON files parsed. +- PASS: Manifest V3 identity matches v0.4.0 build 2026.07.23.14. +- PASS: Release package sizes and SHA-256 values were regenerated and verified. + +Workplace Tok + +1. Reload v0.4.0 in the Chrome profile already configured for the batch launcher. +2. Open Settings and enable Open results dashboard after completion. +3. Leave Retain PID-free aggregate history disabled for the first replay. +4. Run the project-root run-regression-suite.cmd. +5. Confirm index 001 waits beyond the empty shell, reaches CGG - Human and Social Services (TEST), and submits. +6. Confirm only one run is active throughout the suite and all eight runs execute in index order. +7. Confirm no dashboard opens between batch items. +8. Confirm one dashboard opens or refreshes after index 008 finalizes. +9. At a 1440×900 or larger browser viewport, confirm the header, four cards, chart and findings panel are visible without document scrolling. +10. Confirm Simple is the default view and unavailable advanced charts show clear history requirements. +11. Inspect the page for PID; it must show only opaque form/run references and aggregate metrics. +12. Open the popup and confirm Results Dashboard, Settings and Stop Batch use full-width actions where displayed. +13. Supply all eight ZIP exports plus dashboard and popup screenshots for round 010 Tok. + +Limitations + +- Live Form.io mount timing, submission paths, Chrome focus, workplace download policy and rendered dashboard fit require the Workplace Tok above. +- Statistical and experimental chart accuracy with genuine history requires future retained comparable runs; this build verifies their calculations and minimum-data gates with deterministic regression inputs. +- The local browser-preview runtime remained permission-blocked, so the 1440×900 layout contract is static/automated and awaits managed-Chrome screenshot confirmation. diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/CHANGELOG.md b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/CHANGELOG.md new file mode 100644 index 0000000000..9cccc35c2e --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/CHANGELOG.md @@ -0,0 +1,216 @@ +# Changelog + +## 0.4.0 - build 2026.07.23.14 + +Dashboard release and round-009 readiness correction. + +- Adds an internal results dashboard that fits its default layout into a widescreen viewport without document scrolling. +- Adds disabled-by-default automatic dashboard opening after singleton completion or once after the final batch item. +- Reuses, refreshes and focuses an existing dashboard tab instead of creating duplicates. +- Adds manual dashboard actions to the popup and Settings. +- Projects raw run data through a strict PID-free schema containing only opaque references, enumerated categories, timestamps and bounded numeric aggregates. +- Excludes field values, labels, names, emails, files, screenshots, raw URLs, confirmation IDs, arbitrary failure text, raw events, stack traces and browser identity. +- Adds Simple, Analyst, Statistical and Experimental chart groups. +- Gives advanced charts explicit comparable-history thresholds and unavailable reasons. +- Adds separately controlled aggregate history, disabled by default and bounded to 200 records / 90 days, with clear actions in Settings and the dashboard. +- Clears/withholds retained history when retention is disabled during completed-run processing. +- Rejects unexpected fields in stored dashboard summaries before they reach the page. +- Hashes run and suite references so user-provided identifiers are not echoed. +- Strengthens batch readiness to require mounted Form.io components and interactive controls across stable samples, rather than accepting an empty outer shell. +- Adds `DASH-01` through `DASH-05` automated regression coverage and a round-009 empty-shell readiness fixture. + +## 0.3.1 - build 2026.07.23.13 + +Corrective release based on round-008 managed-Chrome batch evidence. + +- Waits for a 1.5-second quiet period after the latest marked tab arrives before starting the suite. +- Activates the selected tab and verifies that its Form.io form is mounted before creating a run. +- Reports **Preparing marked tabs** and **waiting for CHEFS form** in the popup. +- Bounds a never-ready form at 45 seconds and advances without creating a misleading empty run. +- Stops applying the 90-second launcher timeout after a run record exists. +- Preserves strict sequential ownership for long-running forms such as REDIP. +- Probes a stale-looking content controller for live status before watchdog finalization. +- Keeps the unresponsive-controller stall fallback intact. +- Makes **Settings** and **Stop Batch** span the full popup width. +- Adds `BATCH-05`, `BATCH-06`, and `UX-02` regression coverage. + +## 0.3.0 - build 2026.07.23.12 + +- Adds a project-root `run-regression-suite.cmd` with the editable eight-form regression list evidenced by feedback round 004. +- Opens marked tabs without a hard-coded extension ID; tabs still open safely when the extension is absent. +- Adds a disabled-by-default **Batch regression launcher** settings section. +- Requires a per-install token, an exact configured origin, explicit Chrome host access and the existing environment check before queueing a marked tab. +- Removes the launcher token and suite marker from browser history before injecting the tester and stores only the cleaned form URL in queue records. +- Keeps production-like hosts subject to the existing explicit production policy. +- Persists a sequential queue in extension storage and activates the test tab before starting each run. +- Waits for terminal evidence and the automatic export attempt before advancing to the next form. +- Recovers queued work after a Manifest V3 service-worker restart. +- Resolves an automatic export interrupted by worker restart as a reported export failure before advancing, avoiding a wedged or silently skipped pending state. +- Adds popup active, queued and completed counts and a **Stop Batch** action. +- Bounds completed history and records rejected, closed, failed-to-start and timed-out items without wedging the queue. +- Adds `BATCH-01` through `BATCH-04` regressions for the launcher, security gates, persistence, sequencing, export ordering and stop/recovery behaviour. + +## 0.2.6 - build 2026.07.23.11 + +- Adds **Select** beside **Export Folder** as a convenience for feeding the existing Downloads-relative setting. +- Starts the folder picker in Downloads and derives a candidate from the selected folder name. +- Applies the existing relative-path validator before attempting location validation. +- Downloads a uniquely named temporary probe through the unchanged Chrome downloads path. +- Confirms the probe is visible through the selected directory handle before populating Export Folder. +- Removes the probe file and download-history entry after successful or rejected location validation. +- Accepts direct child folders of Downloads, including operating-system links when the browser exposes them as the selected destination. +- Rejects arbitrary or nested selections that cannot be proven to map to the derived Downloads-relative name. +- Leaves the previous field value unchanged after cancellation, unsafe selection, arbitrary location, policy failure or validation failure. +- Stores no directory handle and does not change manual export, automatic export, blank-folder or typed relative-path behaviour. +- Adds `EXPORT-04` regression coverage for valid selection, picker start location, probe routing, cleanup, arbitrary-location rejection, unsafe-name rejection, policy failure and cancellation. + +## 0.2.5 - build 2026.07.23.10 + +Corrective release based on round-005 Settings review. + +- Renames the setting to **Export Folder** and makes its default blank for portable multi-user installation. +- Removes machine-specific folder names and development-environment guidance from product defaults and operating instructions. +- Treats blank as the normal configuration and routes filenames directly to the browser's Downloads folder. +- Retains optional validated Downloads-relative folders for users who want subfolder organization. +- Migrates the v0.2.4 automatic-export preference while discarding its non-portable legacy folder value. +- Renames the checkbox to **Automatically export after each run**. +- Automatically exports finalized `submitted`, `completed`, `failed`, `stalled`, `blocked`, `safety_stop`, and `stopped` runs when enabled. +- Persists final snapshots and terminal checkpoints before failure and stopped-run exports. +- Waits for attempted failure screenshot capture before signalling automatic export. +- Finalizes background-watchdog stalls with a final snapshot and checkpoint before export. +- Extends regressions across blank defaults, blank-path routing, neutral UI, every terminal outcome, representative success and failure ZIP contents, screenshot ordering, idempotency, and export-failure isolation. + +## 0.2.4 - build 2026.07.23.9 + +- Adds an Export settings section with a validated Downloads-relative folder preference. +- Introduced a machine-specific initial folder that was rejected in round-005 and removed by v0.2.5. +- Routes `Export Last Run` through the preferred folder and retains the browser Save As confirmation. +- Adds `Automatically export after submitting`, disabled by default. +- Creates one prompt-free download request after a confirmed successful submission when automatic export is enabled. +- Waits for the final component snapshot and success checkpoint before initiating automatic export. +- Persists pending, successful, or failed automatic-export state without changing the submission result. +- Prevents repeated run-finalization messages from creating duplicate automatic downloads. +- Rejects absolute, drive-qualified, traversal, malformed, and Windows-invalid folder values. +- Added the initial `EXPORT-01`, `EXPORT-02`, and `EXPORT-03` regressions, later broadened by v0.2.5. + +## 0.2.3 - build 2026.07.22.8 + +- Replaces the rejected `chefs.invalid` email domain with `cedarridgecommunity.ca`. +- Aligns generated email addresses with the fictional Cedar Ridge Community Association identity. +- Keeps CHEFS product identity separate from synthetic applicant identity. +- Extends the generated-email regression to reject product branding and blunt fakery markers. + +## 0.2.2 - build 2026.07.22.7 + +- Replaces the generated `example.ca` email domain with the reserved, non-routable `chefs.invalid` domain. +- Preserves role-based local parts and per-run traceability. +- Adds a project-owned regression that executes the shipped `emailValue` method across representative field contexts. + +## 0.2.1 - build 2026.07.22.6 + +Corrective release based on regression run `A9D2A6`. + +- Prevents Form.io data-grid and edit-grid add-row controls from being indexed as submit landmarks, even when the rendered button uses `type="submit"`. +- Excludes generic add-row labels such as `Add Another`, `Add Row`, `Add Item`, and `New Row` from submit-button detection. +- Excludes lookup/search action controls from submit-button detection. +- Keeps the configured grid target at two total rows and prevents the submission stage from opening an unintended third row. +- Retains v0.2.0 custom mask rules, JSON import/export, automatic mask detection, and rule provenance. + +## 0.2.0 - build 2026.07.22.5 + +- Detects input masks from live Form.io component metadata and runtime Inputmask configuration. +- Generates minimally conforming values for standard CHEFS `9`, `a` and `*` mask tokens while preserving literal punctuation and spaces. +- Adds a Custom field formats table to Settings with enabled state, normalized label phrase, CHEFS mask and row removal. +- Gives matching user rules precedence over detected form masks on the first attempt. +- Falls back to a conflicting form-defined mask on a later attempt when the custom value is rejected. +- Adds schema-versioned JSON Import Rules and Export Rules actions with Merge and Replace import modes. +- Validates imported JSON, rule IDs, label phrases, duplicate rules and mask syntax before changing the settings table. +- Adds the active custom rule set and rule-set SHA-256 identity to each run record and troubleshooting bundle. +- Adds `custom-format-rules.json` to run exports. +- Adds mask and custom-rule provenance to component snapshots and `FILL_ATTEMPT` events. +- Adds `CUSTOM_RULE_SET_LOADED`, `CUSTOM_RULE_MATCHED`, `CUSTOM_RULE_VALUE_ACCEPTED`, `CUSTOM_RULE_VALUE_REJECTED`, `MASK_METADATA_DETECTED`, `MASK_RUNTIME_DETECTED`, `MASK_VALUE_GENERATED`, `MASK_VALUE_PERSISTED`, `MASK_VALUE_REJECTED` and `MASK_SYNTAX_UNSUPPORTED` diagnostics. +- Retains v0.1.9 submit-landmark and hidden-tab validation behaviour. + +## 0.1.9 - build 2026.07.22.4 + +- Records Form.io submit controls as persistent landmarks while scanning each tab. +- Remembers the tab that contains each submit control, even after that tab becomes hidden. +- Returns to the remembered submit tab when the final visited tab contains no submit control. +- Reacquires the live submit button after tab activation or Form.io redraw. +- Runs repairable Form.io validation errors before requiring a visible submit button. +- Recognizes rendered single-selection guidance such as `Please select only one` for checkbox-based choice groups. +- Prevents layout containers that inherit a child key from being treated as validation-repair fields. +- Adds submit-landmark and submit-tab diagnostic events. + +## 0.1.8 - build 2026.07.22.3 + +Corrective release based on REDIP run `6FC991`. + +- Reads checkbox-group selection limits from live Form.io validation metadata when available. +- Falls back to rendered guidance such as `Maximum 2 partners` or `You can only select up to 2 items` when schema metadata is unavailable. +- Selects only the permitted number of checkbox options and unchecks excess selections during repair. +- Preserves the fill-all behaviour for checkbox groups that do not define a maximum, while avoiding contradictory `None` or `Not applicable` choices when normal options exist. +- Corrects text-length resolution so the browser's default input `maxLength` value no longer overrides a CHEFS/Form.io validation limit. +- Reads minimum and maximum character and word limits from Form.io metadata, rendered guidance and live character counters. +- Trims generated text before dispatching it to the control. +- Collects component-level validation errors from hidden tabs and associates them with the actual CHEFS property key. +- Resets rejected fields for another fill attempt and reopens the tab containing each invalid component. +- Adds `CHECKBOX_SELECTION_LIMIT_DETECTED`, `CHECKBOX_SELECTION_REPAIRED`, `VALIDATION_REPAIR_PREPARED`, and validation-repair tab diagnostics. +- Adds resolved constraints to each `FILL_ATTEMPT` event and to component snapshots. + +## 0.1.6 - build 2026.07.22.1 + +Corrective build based on run `3C0FD3` against **REDIP - Economic Capacity (UAT)**. + +- Normalizes Form.io tab wrappers to their interactive anchor or button before clicking. +- Verifies that the requested tab became active before recording `TAB_ACTIVATED`. +- Records failed tab activation attempts and skips a tab only after two verified failures. +- Prevents false-positive tab visits caused by clicking a non-interactive `
  • ` wrapper. +- Defers Next-button wizard navigation until direct traversal of the visible tab set is complete. +- Adds tab-set and activation diagnostics. +- Replaces the fixed 40-pass ceiling with a tab-aware starting budget. +- Extends the pass budget when the boundary pass made progress or revealed another conditional field. +- Retains a 200-pass hard ceiling and the existing no-progress watchdog. + +## 0.1.5 - build 2026.07.21.6 + +Corrective build based on run `5712D1` against **Template - Custom Fields**. + +- Treats the configured grid value as a target total row count rather than a number of rows to add only when the grid is empty. +- Uses a default target of two rows for every reachable data grid or edit grid. +- Counts existing rows, respects detected row limits, and adds only the missing rows. +- Finds add-row controls by Form.io class or `ref` suffix before using text as a fallback. +- Reacquires the live grid wrapper after each row addition and waits for the rendered row count to increase. +- Rescans newly created rows so every field in every row is filled. +- Adds grid diagnostics for inspection, add attempts, success, unavailable targets and target completion. +- Adds grid row count and target row count to component snapshots. +- Adds a dedicated simple-day adapter that fills month, day and year together. +- A simple-day component is no longer counted as filled when only the month has a value. +- Changes the grid setting to a target of two to five rows and migrates earlier stored values below two. + +## 0.1.4 - build 2026.07.21.5 + +- Detects the standard CHEFS success route and success-page wording. +- Captures the confirmation ID and stops all further work after successful submission. +- Prevents broad page-level alert containers from being treated as validation errors. + +## 0.1.3 - build 2026.07.21.4 + +- Treats count questions as counts before considering nearby funding language. +- Yields between input, change and blur events. +- Adds control-event diagnostics and an independent stale-run watchdog. + +## 0.1.2 - build 2026.07.21.3 + +- Reacquires live Form.io file wrappers after CHEFS redraws file components. +- Detects rendered upload rows without polling detached wrappers. +- Adds stronger Choices.js interaction fallbacks. + +## 0.1.1 - build 2026.07.21.2 + +- Corrects phone, address, distinct-email, confirmation-checkbox and file-upload handling. +- Improves wrapper-key detection and component metadata logging. + +## 0.1.0 - build 2026.07.21.1 + +Initial complete one-button fill-and-submit implementation. diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json new file mode 100644 index 0000000000..65d8f31781 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json @@ -0,0 +1,177 @@ +{ + "extension": "CHEFS One-Click Form Tester", + "version": "0.4.0", + "build": "2026.07.23.14", + "files": [ + { + "path": "attachments/chefs-attachment.csv", + "sizeBytes": 101, + "sha256": "b1371c6eb4498332a725369dcad54a3597b7e1e30e4e039aae5b87b0a7908d03" + }, + { + "path": "attachments/chefs-attachment.docx", + "sizeBytes": 36848, + "sha256": "24e8557c69ce50601b7abe3d45b50d2b8a513349614f94820249029f0b52064d" + }, + { + "path": "attachments/chefs-attachment.jpg", + "sizeBytes": 12082, + "sha256": "7662f0224080976c58fe50e0bd921d7a8033b5be16965f2fad66cd9e4a54ed34" + }, + { + "path": "attachments/chefs-attachment.json", + "sizeBytes": 207, + "sha256": "3671c6bc4eb9ebb45c5c9cb88490680a3973fcb8da27562dd486350ef8dd11a1" + }, + { + "path": "attachments/chefs-attachment.pdf", + "sizeBytes": 1612, + "sha256": "881b9f20f63f14d69014fd7889466385879dfe50be0bacf22ad9f6b9581e34d5" + }, + { + "path": "attachments/chefs-attachment.png", + "sizeBytes": 2824, + "sha256": "a4597c544411706b5057179f494d551d02691a17aeb7f173a7161487c456f053" + }, + { + "path": "attachments/chefs-attachment.txt", + "sizeBytes": 61, + "sha256": "bddbab428397806f7c253225691dfb1cd9a7a789aca15635011a92ed93758b4f" + }, + { + "path": "attachments/chefs-attachment.xlsx", + "sizeBytes": 3617, + "sha256": "e21f172a5cb4aedbdabd742cbe0c2b2a6913bfc8a01dab5bff575262d24f348b" + }, + { + "path": "BUILD-VALIDATION.txt", + "sizeBytes": 4529, + "sha256": "5101cfc591a9294451006806d63031c62e6d7096c8708e62ec341d0a9b4672c4" + }, + { + "path": "CHANGELOG.md", + "sizeBytes": 15596, + "sha256": "c506ffbebc2bb549052873e8562e00552dc305cca8929b8aba0e319551c0e2dd" + }, + { + "path": "content-script.js", + "sizeBytes": 151244, + "sha256": "767e41f9eaf10ef5b7f9f1d72255680a3294be7280686605bd3a2f9e6ea72289" + }, + { + "path": "dashboard.css", + "sizeBytes": 4987, + "sha256": "721cfabfa7952fe57f602cb7483fbc481c759750f91b46c58d5046e65f300849" + }, + { + "path": "dashboard.html", + "sizeBytes": 3771, + "sha256": "72e4ea577a5464148bb190fa5a88b07ea9d9ff8417e44192472e7c6afceb432d" + }, + { + "path": "dashboard.js", + "sizeBytes": 32441, + "sha256": "1b1fb0d12aa8e1e14c1047d821ef26cf5d9275f0267935717dd37a3671ea6197" + }, + { + "path": "dashboard-model.js", + "sizeBytes": 16848, + "sha256": "125c4a64554b875574512c4db3eb8dfa9e67e0943077db10377fb14cf46a4c2c" + }, + { + "path": "examples/custom-format-rules.example.json", + "sizeBytes": 646, + "sha256": "503a1bcc6c2f24abf36e269a0765d9cdf10e28a1a3e8638d92168974d4bc432c" + }, + { + "path": "export-folder-picker.js", + "sizeBytes": 5248, + "sha256": "d96699e187c42272eb82b258385943c2b9627608cdc212eca0beec6e756a20a1" + }, + { + "path": "export-path.js", + "sizeBytes": 2089, + "sha256": "9de3d2eee1b0d81a58aca6255704a96741ab08baafdea7feb0506b77798ac957" + }, + { + "path": "icons/icon-128.png", + "sizeBytes": 1054, + "sha256": "c35c9898870d7317c25344dffbb948f6357e101985cb61da895860c96b4341dc" + }, + { + "path": "icons/icon-16.png", + "sizeBytes": 180, + "sha256": "ddea246297fdfc1ab06521d910b78355a98433161876322956c18c1fba944878" + }, + { + "path": "icons/icon-32.png", + "sizeBytes": 284, + "sha256": "8c1df9ee3e6335b1e12f6be494cab8182ac2d64dd19a3dc786a77d47b864edb1" + }, + { + "path": "icons/icon-48.png", + "sizeBytes": 415, + "sha256": "718bf7f374e341b922d4d803835f13e5fa915f7060885f24e0865e0be45e10a2" + }, + { + "path": "manifest.json", + "sizeBytes": 1198, + "sha256": "5a7278e01fa94133385bff137612a127a146c7343bb89c769043696b521cc52e" + }, + { + "path": "options.css", + "sizeBytes": 2551, + "sha256": "ea006a23d844e437b3be3705129aa235642c1ad15212b2253dd8b035686259bd" + }, + { + "path": "options.html", + "sizeBytes": 8061, + "sha256": "340a583d7284db44d286280065035ace3ac7e000d4026c799176e8009bc7814a" + }, + { + "path": "options.js", + "sizeBytes": 18768, + "sha256": "80cbbc44a2f18f7eb7473699a5c423a83d1163678d1b260f9b74a57770951a22" + }, + { + "path": "page-bridge.js", + "sizeBytes": 25351, + "sha256": "be727a65b4ad581b471793d725a5e4d36dc54d3ba1f7772e751d9d9da25f75a1" + }, + { + "path": "popup.css", + "sizeBytes": 1980, + "sha256": "570440c786c61b68d292f604d931368dc192a0748880aabcfae706d149b67a9a" + }, + { + "path": "popup.html", + "sizeBytes": 1859, + "sha256": "ca5324faa6955ae4e552cdfd799ab47e7899605c48261079ea90131b06eea90f" + }, + { + "path": "popup.js", + "sizeBytes": 9566, + "sha256": "cfdfc818e80f68b7f483fe2dc538431901bb7b6dfe78a62e0576f32d4af82b67" + }, + { + "path": "README.md", + "sizeBytes": 12340, + "sha256": "94c89693cc9505090f3d797dc5fb7b9289f1863659c473bb76b848af101d4e83" + }, + { + "path": "rules/chefs-custom-format-rules-v1-20260722-163940.json", + "sizeBytes": 360, + "sha256": "f073790149034d49759536107e5b1bbca07a3b5b2045ccc2efaed89a68169c5f" + }, + { + "path": "service-worker.js", + "sizeBytes": 55082, + "sha256": "2f1b4fac85d97eb14f63f5ae032e650cd09a9639e0dbe298387504d4354b8e5c" + }, + { + "path": "TROUBLESHOOTING.md", + "sizeBytes": 10330, + "sha256": "ca72956fd6ba6cd597fb202ae453b1f730476b2fffd95b4f1d4d5d4c709c8ba8" + } + ] +} diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md new file mode 100644 index 0000000000..4ad32f5c8b --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md @@ -0,0 +1,140 @@ +# CHEFS One-Click Form Tester + +Version: 0.4.0 +Build: 2026.07.23.14 +Browser: Google Chrome, Manifest V3 + +## Install or update + +1. Extract the release ZIP to a permanent folder. +2. Open `chrome://extensions` in Chrome. +3. Turn on **Developer mode**. +4. Remove the previous unpacked build, or select **Load unpacked** for the new folder. +5. Select the folder containing `manifest.json`. +6. Pin **CHEFS One-Click Form Tester** to the Chrome toolbar. + +## Run + +1. Open a fresh CHEFS form in an approved TEST, UAT or DEV environment. +2. Select the extension icon. +3. Select **Fill and Submit**. +4. Leave the form tab open while the run is active. +5. After the run ends, select **Export Last Run**, unless automatic export is enabled. + +The engine repeatedly scans the rendered form, fills every reachable user-facing field, waits for conditional changes, creates repeating rows, uploads packaged synthetic attachments and submits after the current path stabilizes. + +## v0.4.0 results dashboard and readiness correction + +Open **Settings** to enable **Open results dashboard after completion**. The setting is disabled by default. A singleton opens the dashboard after its terminal processing; a batch opens it once after the final queued run. If a dashboard tab already exists, the extension refreshes and focuses it instead of opening a duplicate. The popup and Settings also provide a manual **Results Dashboard** / **Open Dashboard** action. + +The dashboard is a fixed widescreen extension page with four summary cards, a primary chart and a plain-language findings panel. Its menu ranges from: + +- **Simple:** outcome, field progress, batch duration and phase timing. +- **Analyst:** pass trends, strategy latency, component outcomes and strategy heatmaps. +- **Statistical:** duration bell curve, control chart, complexity scatter and percentiles. +- **Experimental:** duration candlesticks, pass activity density and build distributions. + +Advanced choices never invent missing evidence. A chart that lacks enough comparable aggregate runs is marked unavailable and states its minimum-history requirement. + +The page receives only a strict PID-free projection: enumerated result categories, opaque references, timestamps and bounded numeric aggregates. It receives no field values, labels, names, emails, uploaded filenames or content, screenshots, raw URLs, confirmation IDs, arbitrary failure text, raw events, stack traces or browser identity. It loads no network resources. + +**Retain PID-free aggregate history for advanced charts** is a separate setting and is also disabled by default. When enabled, it retains at most 200 projected records and 90 days. History can be cleared from Settings or the dashboard. Turning retention off prevents retained history from being supplied to the page and clears it during the next completed-run update. + +Batch readiness now rejects an empty `.formio-form` shell. A marked tab must expose mounted Form.io components and interactive controls across two samples before tester injection. The existing 45-second observable timeout remains. + +## v0.3.1 batch startup and sequencing correction + +Round 008 exposed two separate orchestration defects. The first marked tab started before its Form.io form mounted and remained at **Finding CHEFS form** for 92 seconds. Later, REDIP legitimately exceeded the launcher's 90-second startup window, so the queue incorrectly released its slot and started later forms concurrently. + +v0.3.1 waits 1.5 seconds after the latest marked tab arrives before claiming index `001`, preventing the remaining batch-file tab launches from stealing focus. It then activates each selected tab and waits for the actual Form.io form root before creating a run. The popup reports **Preparing marked tabs** and **waiting for CHEFS form** instead of presenting the wait as an unexplained run initialization. + +The launcher timeout now applies only before a run exists. An established run retains the sole active queue slot until terminal finalization and automatic-export completion. The watchdog asks a stale-looking content controller for live status before declaring a stall, allowing long advanced-select operations to remain active while keeping the unresponsive-controller fallback bounded. + +The popup **Settings** and **Stop Batch** actions now span the full secondary-action width. + +## v0.3.0 batch regression launcher + +The project root contains `run-regression-suite.cmd`, an editable launcher preloaded with the eight-form regression suite evidenced by feedback round 004. It opens marked forms in a selected Chrome profile. When the batch launcher is configured, this extension activates and runs one marked tab at a time. It waits for the terminal run evidence and any automatic export attempt before advancing. + +Batch launching is disabled by default. Open **Settings** and configure **Batch regression launcher**: + +1. Generate a launcher token and copy it to `LAUNCHER_TOKEN` in the batch file. +2. Enter each exact approved regression origin. +3. Select **Grant Host Access** and approve Chrome's prompt. +4. Enable the launcher and save Settings. + +The token, exact configured origin, Chrome host permission and existing environment protection must all pass. A production-like host is not made safe merely by adding it to the batch list. Before injecting the tester, the extension removes the launcher marker from browser history and retains only the cleaned form URL in queue records. The extension popup reports active, queued and completed items and provides **Stop Batch**. + +The launcher contains no extension ID. If the extension is absent, disabled or not loaded in the chosen Chrome profile, the form tabs simply open. See the project-root `BATCH-REGRESSION.md` for setup, form-list editing and troubleshooting. + +## v0.2.6 Select Export Folder + +**Select** is a convenience for populating the existing **Export Folder** setting. It starts in Downloads and supports folders directly inside Downloads. The extension derives the folder name, applies the existing relative-path validation and sends a uniquely named temporary file through the normal downloads API. The field is populated only when that probe appears in the folder that was selected. + +The validation file and its download-history entry are removed automatically. Cancellation, an unsafe name, an arbitrary location, a nested location that cannot be represented, a browser-policy failure or a validation mismatch leaves the previous field value unchanged. + +Select stores no folder handle and does not change export behaviour. Blank continues to mean Downloads directly, a selected folder remains a Downloads-relative name, and validated nested relative paths can still be typed manually. + +## v0.2.5 export destination and automatic export + +Open **Settings** and use **Export Folder** to select an optional Downloads-relative destination. The field is blank by default, which saves exports directly to the browser's normal Downloads folder. To organize exports, enter a relative folder such as `CHEFS Exports`. Do not enter `Downloads\CHEFS Exports`, a drive letter, or an absolute path. + +**Export Last Run** retains the browser Save As confirmation and suggests the configured relative destination. Enable **Automatically export after each run** to request one download without an extension-requested confirmation window after the terminal evidence is stored. The browser or workplace policy can still require confirmation. + +Automatic export applies to submitted, completed, failed, blocked, stalled, stopped and safety-stop runs. Failure details, attempted failure screenshot capture, the final component snapshot and the terminal checkpoint are persisted before the export begins. An automatic-export failure is reported separately and does not change the run result. + +The extension rejects absolute paths, drive letters, parent traversal, repeated or trailing separators, Windows-reserved names and Windows-invalid filename characters. An operating-system link can route a configured Downloads subfolder elsewhere, but creating and maintaining such links remains outside the extension. + +## v0.2.3 synthetic email identity + +Generated email addresses use the fictional `cedarridgecommunity.ca` domain, matching the generated Cedar Ridge Community Association identity. They retain role and run identifiers for traceability without using product branding or blunt fakery markers. + +## v0.2.0 custom field formats + +v0.2.0 reads CHEFS/Form.io input masks before using label-based value guesses. Standard CHEFS mask tokens are generated automatically: `9` for numeric, `a` for alphabetic and `*` for alphanumeric. Literal punctuation and spaces are retained. + +Open **Settings** to manage **Custom field formats** when a program-area identifier needs a format that is missing from the form or needs a tester-controlled override. Each rule contains a normalized visible-label phrase and a CHEFS input mask. User rules take precedence on the first attempt; when a conflicting form-defined mask exists, the detected form mask is available as the retry fallback. + +Rules can be imported and exported as schema-versioned JSON. Import supports Merge and Replace modes. The active rule set, its SHA-256 identity, every rule match and every accepted or rejected masked value are recorded in the troubleshooting bundle. + +A reference file is included at `examples/custom-format-rules.example.json`. + +## v0.1.9 correction + +v0.1.9 treats the submit control as a persistent form landmark. When a form places Submit on an attestation tab and then adds a later resources-only tab, the extension remembers where Submit was found, returns to that tab after the fill loop, reacquires the live control, and submits. Pre-submit Form.io validation errors are repaired before submit-button discovery, including checkbox groups that render guidance such as `Please select only one`. + +## v0.1.8 correction + +REDIP run `6FC991` reached the final tab with 108 fields filled and five attachments completed, but submission exposed two validation defects. A checkbox group configured for a maximum of two partners had every option selected, and a 100-character conditional text field received a 282-character generated response. + +v0.1.8 resolves field constraints from the live Form.io component schema before using rendered guidance. Checkbox groups obey minimum and maximum selection counts, text values obey character and word limits, and submission validation can reopen hidden tabs and retry the rejected components. + +## v0.1.6 correction + +Run `3C0FD3` against **REDIP - Economic Capacity (UAT)** reached 92 filled fields, four completed attachments and no validation errors, but exhausted the fixed 40-pass limit immediately after a final conditional textarea became visible. The run also exposed that the tab detector was clicking the non-interactive `
  • ` wrapper rather than its child tab link, so the first 14 tab activations were logged without changing the active pane. + +v0.1.6 normalizes Form.io tab wrappers to their interactive anchor or button, verifies that the requested tab became active before recording success, and only falls back to Next-button navigation after direct tab traversal has been exhausted. + +The fill-pass budget is now adaptive. Forms with many tabs receive a larger starting budget, and the budget extends in small increments when the final allowed pass made progress or revealed another field. A separate hard ceiling and the existing no-progress watchdog remain in place. + +## Grid settings + +Open **Settings** to choose a target of two to five rows per data grid. A form-defined maximum takes precedence when it can be detected. + +## Troubleshooting bundle + +Every run is persisted while it executes. A partial or completed run remains exportable and can include: + +- `manifest.json` +- `run-summary.txt` +- `events.jsonl` +- `checkpoints.jsonl` +- component snapshots +- validation errors +- attachment records +- the active `custom-format-rules.json` rule set and SHA-256 identity +- failure details and screenshot when applicable + +## Environment protection + +Automatic submission is allowed by default only on CHEFS hosts that look like TEST, UAT or DEV environments, plus localhost. Other hosts are blocked. Additional exact hostnames and an explicit production override are available under **Settings**. diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt new file mode 100644 index 0000000000..cc4861be61 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt @@ -0,0 +1,35 @@ +b1371c6eb4498332a725369dcad54a3597b7e1e30e4e039aae5b87b0a7908d03 attachments/chefs-attachment.csv +24e8557c69ce50601b7abe3d45b50d2b8a513349614f94820249029f0b52064d attachments/chefs-attachment.docx +7662f0224080976c58fe50e0bd921d7a8033b5be16965f2fad66cd9e4a54ed34 attachments/chefs-attachment.jpg +3671c6bc4eb9ebb45c5c9cb88490680a3973fcb8da27562dd486350ef8dd11a1 attachments/chefs-attachment.json +881b9f20f63f14d69014fd7889466385879dfe50be0bacf22ad9f6b9581e34d5 attachments/chefs-attachment.pdf +a4597c544411706b5057179f494d551d02691a17aeb7f173a7161487c456f053 attachments/chefs-attachment.png +bddbab428397806f7c253225691dfb1cd9a7a789aca15635011a92ed93758b4f attachments/chefs-attachment.txt +e21f172a5cb4aedbdabd742cbe0c2b2a6913bfc8a01dab5bff575262d24f348b attachments/chefs-attachment.xlsx +5101cfc591a9294451006806d63031c62e6d7096c8708e62ec341d0a9b4672c4 BUILD-VALIDATION.txt +c506ffbebc2bb549052873e8562e00552dc305cca8929b8aba0e319551c0e2dd CHANGELOG.md +767e41f9eaf10ef5b7f9f1d72255680a3294be7280686605bd3a2f9e6ea72289 content-script.js +721cfabfa7952fe57f602cb7483fbc481c759750f91b46c58d5046e65f300849 dashboard.css +72e4ea577a5464148bb190fa5a88b07ea9d9ff8417e44192472e7c6afceb432d dashboard.html +1b1fb0d12aa8e1e14c1047d821ef26cf5d9275f0267935717dd37a3671ea6197 dashboard.js +125c4a64554b875574512c4db3eb8dfa9e67e0943077db10377fb14cf46a4c2c dashboard-model.js +503a1bcc6c2f24abf36e269a0765d9cdf10e28a1a3e8638d92168974d4bc432c examples/custom-format-rules.example.json +d96699e187c42272eb82b258385943c2b9627608cdc212eca0beec6e756a20a1 export-folder-picker.js +9de3d2eee1b0d81a58aca6255704a96741ab08baafdea7feb0506b77798ac957 export-path.js +c35c9898870d7317c25344dffbb948f6357e101985cb61da895860c96b4341dc icons/icon-128.png +ddea246297fdfc1ab06521d910b78355a98433161876322956c18c1fba944878 icons/icon-16.png +8c1df9ee3e6335b1e12f6be494cab8182ac2d64dd19a3dc786a77d47b864edb1 icons/icon-32.png +718bf7f374e341b922d4d803835f13e5fa915f7060885f24e0865e0be45e10a2 icons/icon-48.png +5a7278e01fa94133385bff137612a127a146c7343bb89c769043696b521cc52e manifest.json +ea006a23d844e437b3be3705129aa235642c1ad15212b2253dd8b035686259bd options.css +340a583d7284db44d286280065035ace3ac7e000d4026c799176e8009bc7814a options.html +80cbbc44a2f18f7eb7473699a5c423a83d1163678d1b260f9b74a57770951a22 options.js +4d42bd47b6f23d4bec5aea0ced4e7b299d9d8811286e2e937cf712d1083798c8 PACKAGE-MANIFEST.json +be727a65b4ad581b471793d725a5e4d36dc54d3ba1f7772e751d9d9da25f75a1 page-bridge.js +570440c786c61b68d292f604d931368dc192a0748880aabcfae706d149b67a9a popup.css +ca5324faa6955ae4e552cdfd799ab47e7899605c48261079ea90131b06eea90f popup.html +cfdfc818e80f68b7f483fe2dc538431901bb7b6dfe78a62e0576f32d4af82b67 popup.js +94c89693cc9505090f3d797dc5fb7b9289f1863659c473bb76b848af101d4e83 README.md +f073790149034d49759536107e5b1bbca07a3b5b2045ccc2efaed89a68169c5f rules/chefs-custom-format-rules-v1-20260722-163940.json +2f1b4fac85d97eb14f63f5ae032e650cd09a9639e0dbe298387504d4354b8e5c service-worker.js +ca72956fd6ba6cd597fb202ae453b1f730476b2fffd95b4f1d4d5d4c709c8ba8 TROUBLESHOOTING.md diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/TROUBLESHOOTING.md b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/TROUBLESHOOTING.md new file mode 100644 index 0000000000..0c7c15166b --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/TROUBLESHOOTING.md @@ -0,0 +1,117 @@ +# Troubleshooting + +## Batch tabs open but no run starts + +The project batch file must launch the Chrome profile that contains this unpacked extension. Confirm the profile folder in `chrome://version`, then set `CHROME_PROFILE` in `run-regression-suite.cmd`. + +Open extension **Settings** and confirm the batch launcher is enabled, the saved launcher token exactly matches `LAUNCHER_TOKEN`, the tab's exact origin is listed and **Grant Host Access** was approved. Existing non-production checks still apply. An invalid token is deliberately ignored; an origin or safety rejection appears in batch completed history. + +The popup briefly reports **Preparing marked tabs** while the batch file finishes opening its list. It then reports **waiting for CHEFS form** if the selected tab has only an outer shell or is still mounting Form.io components and controls. A form that does not become ready within 45 seconds is recorded as `form_not_ready`, and the queue advances. + +Once a run begins, it owns the queue slot for its full execution; a long form is not released by the launcher-start timeout. **Stop Batch** requests a stop for the active run and removes queued work without closing the tabs. See the project-root `BATCH-REGRESSION.md` for full setup. + +## The results dashboard did not open + +Automatic opening is disabled by default. Open **Settings**, enable **Open results dashboard after completion**, and save. A singleton opens after terminal processing. A batch opens only after the final queued item, so no dashboard should appear between batch runs. + +Use **Results Dashboard** in the popup or **Open Dashboard** in Settings to open it manually. An already-open dashboard tab is refreshed and focused rather than duplicated. + +## An advanced dashboard chart is unavailable + +The dashboard intentionally disables a chart when the current PID-free aggregates cannot support it. Use the reason shown beside the unavailable choice. Bell curves require 20 retained comparable runs; control charts require eight; candlesticks require at least eight comparable runs across two days. + +Aggregate history is disabled by default. Enable **Retain PID-free aggregate history for advanced charts** in Settings to accumulate eligible summaries. It retains at most 200 records and 90 days. Clear it from Settings or the dashboard at any time. + +The dashboard never receives troubleshooting-bundle detail such as field labels or values. Use **Export Last Run** when detailed diagnosis is needed. + +## Select rejects an Export Folder + +Select accepts a folder only when the browser can prove that it is directly inside Downloads. It creates a uniquely named temporary download, verifies that the file appears through the selected folder and removes the file and download-history entry. + +Select a direct child of Downloads. Clear **Export Folder** to use Downloads itself. For a validated nested relative path, type the path in the field instead. Browser settings or workplace policy that interrupt the validation download can prevent Select from completing; the prior field value remains unchanged. + +## An export does not arrive in the preferred folder + +Open **Settings** and confirm **Export Folder** is blank when exports should go directly to the browser's Downloads folder. For an optional subfolder, enter only a relative name such as `CHEFS Exports`. Do not include `Downloads\`, a drive letter or an absolute path. + +When an operating-system link routes the relative folder elsewhere, inspect that link separately and confirm its target still exists. A missing or broken link must be repaired outside the extension. Clearing **Export Folder** sends later exports to Downloads directly. + +## Automatic export did not start + +Automatic export runs after a success, failure, stall, block, safety stop or user stop once the extension has persisted the available terminal evidence. Confirm **Automatically export after each run** is enabled in **Settings**. Browser preferences or managed workplace policy can still display a confirmation window or reject the destination. + +Reopen the extension popup on the form tab. A failed automatic export is reported without changing the underlying run result. Use **Export Last Run** to retry manually. + +## A bureaucratic identifier needs a special format + +Current builds inspect the live Form.io schema and runtime Inputmask configuration before generating ordinary text. Standard masks such as `aaa-999999`, `(999) 999-9999` and `*****************` are handled automatically. + +When the form does not expose the necessary format, open **Settings**, add a Custom field format rule, enter a distinctive phrase from the visible field label and enter the CHEFS input mask. Label matching is case-insensitive, removes label markup and required markers, normalizes punctuation and uses phrase containment by default. + +The exported run includes `custom-format-rules.json`. Component snapshots record `maskSource`, `resolvedMask`, `customRule` and `maskGenerationStrategy`. Use the mask and custom-rule events in `events.jsonl` to determine whether the value came from a user rule, Form.io metadata or runtime Inputmask settings. + +If a custom rule conflicts with a detected form mask, attempt one uses the custom rule and a later attempt can use the form-defined mask. `CUSTOM_RULE_VALUE_REJECTED` records the detected fallback mask. + +Optional mask groups, alternation, quantifiers and custom mask definitions are not guessed. The run records `MASK_SYNTAX_UNSUPPORTED` and continues through the normal retry and validation-repair process. + +## Importing or sharing custom field format rules + +Use **Export Rules** to create schema-versioned JSON. Use **Import Rules** with Merge to retain local rules or Replace to replace the current table. The importer rejects malformed JSON, unsupported schema versions, missing phrases, masks without standard tokens, duplicate IDs and duplicate phrase-and-mask rules. Imported changes are not persistent until **Save Settings** is selected. + +## Submit exists on an earlier tab + +v0.1.9 records every Form.io submit control as a submit landmark, including the containing tab. If the fill loop finishes on a later information-only or resources-only tab, the extension activates the remembered submit tab and reacquires the live button before submission. Relevant events are `SUBMIT_LANDMARK_DISCOVERED`, `SUBMIT_LANDMARK_BECAME_VISIBLE`, `SUBMIT_TAB_ACTIVATION_ATTEMPT`, `SUBMIT_TAB_ACTIVATED`, and `SUBMIT_LANDMARK_REACQUIRED`. + +## Checkbox group says select only one + +When Form.io renders a checkbox-based choice group with an error such as `Please select only one`, v0.1.9 resolves the maximum selection count as one, unchecks the excess selections, and retries validation before searching for Submit. + +## A checkbox group says too many items were selected + +v0.1.8 reads Form.io `minSelectedCount` and `maxSelectedCount` validation metadata. When that metadata is unavailable, it reads rendered instructions such as `Maximum 2 partners` and `You can only select up to 2 items`. The extension unchecks excess options and records `CHECKBOX_SELECTION_LIMIT_DETECTED` and `CHECKBOX_SELECTION_REPAIRED`. + +## A text field contains a value but CHEFS says it is required or over length + +Current builds resolve minimum and maximum character and word limits from Form.io metadata, HTML attributes, rendered guidance and live character counters. Each `FILL_ATTEMPT` event records `resolvedConstraints`. Submission repair records the component key, reopens its hidden tab when necessary, and retries it with a value inside the detected limit. + +## File appears in CHEFS but the run reports an upload failure + +v0.1.7 added exact wrapper tracking for Form.io file controls whose default property key is also `simplefile`. It recognizes list and table file rows and remembers in-flight uploads so a slow upload is monitored instead of repeated. Relevant events are `UPLOAD_PENDING_RECHECK`, `UPLOAD_STILL_PENDING`, `UPLOAD_PENDING_TIMEOUT`, `UPLOAD_WRAPPER_REPLACED`, and `UPLOAD_COMPLETED`. + +## A run stops or stalls + +Open the extension popup on the affected tab and select **Export Last Run**. Do not refresh the form first when the partial form state may be useful. + +Provide the exported ZIP together with a brief visible observation. The bundle records the build, last successful action, current action, unresolved fields, validation messages, component snapshots and recent event history. + +## A horizontal tabbed form stops before the final tabs are complete + +v0.1.6 clicks the interactive anchor or button inside a Form.io tab wrapper and verifies that the tab became active. Logs record `TAB_SET_DISCOVERED`, `TAB_ACTIVATION_ATTEMPT`, `TAB_ACTIVATED`, `TAB_ACTIVATION_FAILED` and `TAB_SKIPPED_AFTER_FAILURE`. + +The pass budget scales with the number of tabs and can extend when a boundary pass reveals another conditional field. `FILL_PASS_BUDGET_SET` and `FILL_PASS_BUDGET_EXTENDED` show the applied limits. + +## A data grid receives only one row + +Current builds target two total rows by default. The run log records `GRID_INSPECTED`, `GRID_ROW_ADD_ATTEMPT`, `GRID_ROW_ADDED` and `GRID_TARGET_REACHED` events. Component snapshots also record the current and target row counts. + +When no enabled add-row control exists, the run records `GRID_TARGET_UNAVAILABLE` rather than repeatedly clicking the grid. + +## A Day component has only a month + +Current builds use a dedicated simple-day adapter. It fills month, day and year and does not count the component as filled unless every enabled part has a value. + +## CHEFS submitted, but the extension reports Blocked + +This was corrected in v0.1.4. Current builds detect the success route and success-page wording, capture the confirmation ID and stop the run as submitted. + +## A phone field appears partly masked + +Export the run bundle. The phone adapter records the input mask and requires ten rendered digits before the field is counted as filled. + +## A file appears in CHEFS but the extension still says Uploading + +Export the run bundle. Current builds reacquire file wrappers after Form.io redraws and inspect the replacement wrapper for the uploaded row. + +## The extension says the environment is blocked + +Open **Settings** and add the exact approved hostname. Production-like CHEFS hosts remain blocked unless the explicit override is enabled. diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.csv b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.csv new file mode 100644 index 0000000000..41e68dab84 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.csv @@ -0,0 +1,3 @@ +Category,Description,Amount +Supplies,Program materials,1250.00 +Travel,Local transportation,350.00 diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.docx b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.docx new file mode 100644 index 0000000000000000000000000000000000000000..2985ba67a35b7ea4d6696dd70e8ff0c3c6f1bcd4 GIT binary patch literal 36848 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 C9lJvS literal 0 HcmV?d00001 diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.jpg b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1a62428daf225f03be20e9ff32108125951d7c68 GIT binary patch literal 12082 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 Bm_q;n literal 0 HcmV?d00001 diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.json b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.json new file mode 100644 index 0000000000..5c17660d32 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.json @@ -0,0 +1,11 @@ +{ + "documentType": "CHEFS automated attachment", + "synthetic": true, + "createdFor": "Chrome extension form testing", + "records": [ + { + "item": "Program supply", + "amount": 1250.0 + } + ] +} \ No newline at end of file diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.pdf b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e55899ec824baedcca0a1126b377866e34c3e193 GIT binary patch literal 1612 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 blG_h7eGe}za1MYbT)@T2-Lcl;`n|saqT|CL literal 0 HcmV?d00001 diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.txt b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.txt new file mode 100644 index 0000000000..7f5c583843 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.txt @@ -0,0 +1,2 @@ +CHEFS automated form run attachment. +Synthetic content only. diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.xlsx b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/attachments/chefs-attachment.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6ac34e44eca87841cec22422efa341ec686c2310 GIT binary patch literal 3617 zcma)92{e>zAAd2HNReG)8bf6pvX*@(`+gHKF${(oGs9TQdZnxEOJpr_v+v4ovP9M^ zlr`60_O(>_M!tJ|e0}G9?>W!=oaemn|Mxuq?f3sbhB_1=W&i+a0NIKyt9yRm>WHKj zlnemQl4dUyjDW_v+(DyVM7&&4;XOpRCNKl2*GAd5sD$^DtMHVT5v*$VY-7GFv#l_z z^j*KWa_7Rj@L5rxcRV_)ljoUa!)Tx6QU$t07bf#^t24s>ma|7bwc_02xZ>%0A1Zzd%=693S7 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 setTimeout(resolve, ms)); + } + + function withTimeout(promise, timeoutMs, label) { + let timer; + const timeout = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms.`)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); + } + + function cleanText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function normalizeRulePhrase(value, caseSensitive) { + let normalized = cleanText(String(value || '') + .replace(/<[^>]*>/g, ' ') + .replace(/^[*:\-\s]+|[*:\-\s]+$/g, ' ')) + .replace(/[^A-Za-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!caseSensitive) { + normalized = normalized.toLowerCase(); + } + return normalized; + } + + function normalizeCustomRule(raw, index) { + const source = raw || {}; + return { + id: String(source.id || `rule-${index + 1}`), + enabled: source.enabled !== false, + labelMatch: cleanText(source.labelMatch || source.labelPhrase || source.label || ''), + matchMode: source.matchMode === 'exact' ? 'exact' : 'contains', + caseSensitive: Boolean(source.caseSensitive), + mask: String(source.mask || '').trim(), + notes: cleanText(source.notes || '') + }; + } + + function stableRuleJson(rules) { + return JSON.stringify((rules || []).map((rule) => ({ + id: rule.id, + enabled: rule.enabled !== false, + labelMatch: rule.labelMatch, + matchMode: rule.matchMode, + caseSensitive: Boolean(rule.caseSensitive), + mask: rule.mask, + notes: rule.notes || '' + })).sort((a, b) => a.id.localeCompare(b.id))); + } + + async function sha256Text(value) { + if (!crypto || !crypto.subtle) { + return ''; + } + const bytes = new TextEncoder().encode(String(value)); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)).map((item) => item.toString(16).padStart(2, '0')).join(''); + } + + function isVisible(element) { + if (!element || !element.isConnected) { + return false; + } + if (element.closest('.formio-hidden, [hidden], [aria-hidden="true"]')) { + return false; + } + const style = getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) { + return false; + } + return element.getClientRects().length > 0; + } + + function getFieldLabel(wrapper) { + const label = wrapper.querySelector(':scope > label, :scope > legend, label, legend'); + if (label) { + return cleanText(label.textContent).slice(0, 1000); + } + const heading = wrapper.querySelector('h1, h2, h3, h4, h5, h6'); + if (heading) { + return cleanText(heading.textContent).slice(0, 1000); + } + return ''; + } + + function getDescription(wrapper) { + const description = wrapper.querySelector('.form-text, [ref="description"], .help-block:not([ref="buttonMessage"])'); + return description ? cleanText(description.textContent).slice(0, 1200) : ''; + } + + function getInputKey(wrapper) { + const named = wrapper.querySelector('input[name^="data["], select[name^="data["], textarea[name^="data["]'); + if (named) { + const matches = Array.from(named.name.matchAll(/\[([^\]]+)\]/g)); + if (matches.length) { + return matches[matches.length - 1][1]; + } + } + return ''; + } + + function safeValueInfo(value, generated) { + const text = value === null || value === undefined ? '' : String(value); + return { + valueType: Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value, + valueLength: text.length, + generatedByExtension: Boolean(generated) + }; + } + + class ChefsTesterController { + constructor() { + this.running = false; + this.stopRequested = false; + this.runId = ''; + this.settings = {}; + this.customFormatRules = []; + this.customRuleSet = { schemaVersion: 1, enabledRuleCount: 0, ruleSetHash: '', lastModifiedAt: '' }; + this.environment = ''; + this.startedAt = 0; + this.currentPass = 0; + this.lastProgressAt = 0; + this.currentAction = 'Idle'; + this.lastSuccessfulAction = ''; + this.domRevision = 0; + this.lastScannedRevision = 0; + this.componentStates = new Map(); + this.gridHandled = new Set(); + this.fileHandled = new Set(); + this.fileUploadsInFlight = new Map(); + this.actionHandled = new Set(); + this.visitedTabs = new Set(); + this.tabActivationFailures = new Map(); + this.submitLandmarks = new Map(); + this.passBudget = CONFIG.maxFillPasses; + this.lastPassHadProgress = false; + this.visitedWizardSignatures = new Set(); + this.bridgeReady = false; + this.bridgeRequests = new Map(); + this.mutationObserver = null; + this.errorHandlersInstalled = false; + this.successFinalized = false; + this.progress = { + pass: 0, + discovered: 0, + filled: 0, + remaining: 0, + failed: 0, + unsupported: 0, + rowsAdded: 0, + attachmentsCompleted: 0, + attachmentsPending: 0, + submitAttempts: 0, + customRulesLoaded: 0, + customRuleMatches: 0, + customRuleAccepted: 0, + customRuleRejected: 0, + detectedMasksUsed: 0, + maskValuesRejected: 0 + }; + this.boundBridgeMessage = this.handleBridgeMessage.bind(this); + window.addEventListener('message', this.boundBridgeMessage); + } + + async runtimeMessage(message) { + try { + return await chrome.runtime.sendMessage(message); + } catch (error) { + return { ok: false, error: error && error.message ? error.message : String(error) }; + } + } + + makeRunId() { + const bytes = new Uint8Array(3); + crypto.getRandomValues(bytes); + return Array.from(bytes).map((value) => value.toString(16).padStart(2, '0')).join('').toUpperCase(); + } + + async log(eventName, data) { + if (!this.runId) { + return; + } + const event = Object.assign({ + time: new Date().toISOString(), + elapsedMs: this.startedAt ? Date.now() - this.startedAt : 0, + event: eventName, + pass: this.currentPass + }, data || {}); + await this.runtimeMessage({ + type: 'APPEND_EVENTS', + runId: this.runId, + events: [event] + }); + } + + async checkpoint(reason, extra) { + if (!this.runId) { + return; + } + const checkpoint = Object.assign({ + time: new Date().toISOString(), + reason, + pass: this.currentPass, + currentAction: this.currentAction, + lastSuccessfulAction: this.lastSuccessfulAction, + scrollX: window.scrollX, + scrollY: window.scrollY, + progress: Object.assign({}, this.progress) + }, extra || {}); + await this.runtimeMessage({ + type: 'ADD_CHECKPOINT', + runId: this.runId, + checkpoint + }); + } + + async updateRun(patch) { + if (!this.runId) { + return; + } + await this.runtimeMessage({ type: 'UPDATE_RUN', runId: this.runId, patch }); + } + + async setStatus(status, statusLabel, action, message) { + this.currentAction = action || this.currentAction; + await this.updateRun({ + status, + statusLabel, + currentAction: this.currentAction, + message: message || '', + progress: Object.assign({}, this.progress) + }); + } + + async markProgress(action) { + this.lastProgressAt = Date.now(); + this.lastSuccessfulAction = action; + await this.updateRun({ + lastSuccessfulAction: action, + currentAction: this.currentAction, + progress: Object.assign({}, this.progress) + }); + } + + installErrorHandlers() { + if (this.errorHandlersInstalled) { + return; + } + this.errorHandlersInstalled = true; + window.addEventListener('error', (event) => { + if (!this.running) { + return; + } + this.log('JAVASCRIPT_ERROR', { + message: event.message || 'Unhandled page error', + filename: event.filename || '', + line: event.lineno || 0, + column: event.colno || 0, + stack: event.error && event.error.stack ? event.error.stack : '' + }); + }); + window.addEventListener('unhandledrejection', (event) => { + if (!this.running) { + return; + } + const reason = event.reason; + this.log('UNHANDLED_REJECTION', { + message: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : '' + }); + }); + } + + installMutationObserver() { + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + } + this.mutationObserver = new MutationObserver((mutations) => { + this.domRevision += mutations.length || 1; + }); + this.mutationObserver.observe(document.documentElement, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class', 'style', 'hidden', 'disabled', 'aria-hidden', 'aria-invalid', 'aria-expanded'] + }); + } + + async injectBridge() { + try { + const probe = await this.bridgeCommand('PING', {}, 1500, true); + if (probe && probe.ready) { + this.bridgeReady = true; + return; + } + } catch (error) { + // Use the packaged-script fallback below. + } + if (!document.getElementById('chefs-tester-page-bridge')) { + const script = document.createElement('script'); + script.id = 'chefs-tester-page-bridge'; + script.src = chrome.runtime.getURL('page-bridge.js'); + script.async = false; + (document.head || document.documentElement).appendChild(script); + } + await withTimeout(new Promise((resolve) => { + if (this.bridgeReady) { + resolve(); + return; + } + const check = setInterval(() => { + if (this.bridgeReady) { + clearInterval(check); + resolve(); + } + }, 50); + }), 5000, 'Page bridge initialization').catch(async (error) => { + await this.log('BRIDGE_INITIALIZATION_FAILED', { message: error.message }); + }); + } + + handleBridgeMessage(event) { + if (event.source !== window || !event.data) { + return; + } + if (event.data.channel === 'CHEFS_TESTER_BRIDGE' && event.data.type === 'BRIDGE_READY') { + this.bridgeReady = true; + return; + } + if (event.data.channel !== 'CHEFS_TESTER_BRIDGE_RESPONSE') { + return; + } + const pending = this.bridgeRequests.get(event.data.requestId); + if (!pending) { + return; + } + this.bridgeRequests.delete(event.data.requestId); + if (event.data.ok) { + pending.resolve(event.data.result); + } else { + const error = new Error(event.data.error && event.data.error.message ? event.data.error.message : 'Page bridge command failed.'); + if (event.data.error && event.data.error.stack) { + error.stack = event.data.error.stack; + } + pending.reject(error); + } + } + + bridgeCommand(command, payload, timeoutMs, allowUnready) { + if (!this.bridgeReady && !allowUnready) { + return Promise.reject(new Error('Page bridge is not ready.')); + } + const requestId = `${this.runId}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const promise = new Promise((resolve, reject) => { + this.bridgeRequests.set(requestId, { resolve, reject }); + window.postMessage({ + channel: 'CHEFS_TESTER_BRIDGE_REQUEST', + requestId, + command, + payload: payload || {} + }, '*'); + }); + return withTimeout(promise, timeoutMs || CONFIG.bridgeTimeoutMs, `Bridge command ${command}`) + .finally(() => this.bridgeRequests.delete(requestId)); + } + + async waitForForm() { + await withTimeout(new Promise((resolve) => { + const existing = document.querySelector('.formio-form, [ref="webform"]'); + if (existing) { + resolve(existing); + return; + } + const observer = new MutationObserver(() => { + const form = document.querySelector('.formio-form, [ref="webform"]'); + if (form) { + observer.disconnect(); + resolve(form); + } + }); + observer.observe(document.documentElement, { subtree: true, childList: true }); + }), 20000, 'CHEFS form detection'); + } + + formTitle() { + const heading = document.querySelector('main h1, .main-wide h1, h1'); + return heading ? cleanText(heading.textContent) : document.title; + } + + formId() { + const url = new URL(location.href); + const match = url.pathname.match(/[0-9a-f]{8}-[0-9a-f-]{27,}/i); + return match ? match[0] : ''; + } + + async start(startMessage) { + if (this.running) { + return { ok: false, error: 'A run is already active in this tab.' }; + } + this.running = true; + this.stopRequested = false; + this.runId = this.makeRunId(); + this.settings = startMessage.settings || {}; + this.customFormatRules = (Array.isArray(this.settings.customFormatRules) ? this.settings.customFormatRules : []) + .map((rule, index) => normalizeCustomRule(rule, index)) + .filter((rule) => rule.enabled && rule.labelMatch && rule.mask); + const ruleSetHash = await sha256Text(stableRuleJson(this.customFormatRules)); + this.customRuleSet = { + schemaVersion: 1, + enabledRuleCount: this.customFormatRules.length, + ruleSetHash: ruleSetHash ? `sha256:${ruleSetHash}` : '', + lastModifiedAt: new Date().toISOString() + }; + this.environment = startMessage.environment || ''; + this.startedAt = Date.now(); + this.lastProgressAt = this.startedAt; + this.currentPass = 0; + this.componentStates.clear(); + this.gridHandled.clear(); + this.fileHandled.clear(); + this.actionHandled.clear(); + this.visitedTabs.clear(); + this.tabActivationFailures.clear(); + this.passBudget = CONFIG.maxFillPasses; + this.lastPassHadProgress = false; + this.visitedWizardSignatures.clear(); + this.successFinalized = false; + this.progress = { + pass: 0, + discovered: 0, + filled: 0, + remaining: 0, + failed: 0, + unsupported: 0, + rowsAdded: 0, + attachmentsCompleted: 0, + attachmentsPending: 0, + submitAttempts: 0, + customRulesLoaded: this.customRuleSet.enabledRuleCount, + customRuleMatches: 0, + customRuleAccepted: 0, + customRuleRejected: 0, + detectedMasksUsed: 0, + maskValuesRejected: 0 + }; + this.installErrorHandlers(); + this.installMutationObserver(); + + const createResponse = await this.runtimeMessage({ + type: 'CREATE_RUN', + runId: this.runId, + tabId: null, + extensionVersion: VERSION, + buildNumber: BUILD, + chromeVersion: navigator.userAgent, + formTitle: this.formTitle(), + formUrl: location.href, + formId: this.formId(), + startedAt: new Date().toISOString(), + customRuleSet: this.customRuleSet, + customFormatRules: this.customFormatRules + }); + if (!createResponse || !createResponse.ok) { + this.running = false; + throw new Error(createResponse && createResponse.error ? createResponse.error : 'The run record could not be created.'); + } + + await this.log('CUSTOM_RULE_SET_LOADED', { + schemaVersion: this.customRuleSet.schemaVersion, + enabledRuleCount: this.customRuleSet.enabledRuleCount, + ruleSetHash: this.customRuleSet.ruleSetHash + }); + await this.log('RUN_STARTED', { + environment: this.environment, + settings: { + rowsPerGrid: Math.max(2, this.settings.rowsPerGrid || 2), + captureScreenshot: this.settings.captureScreenshot !== false, + customFormatRuleCount: this.customRuleSet.enabledRuleCount, + customRuleSetHash: this.customRuleSet.ruleSetHash + } + }); + + this.executeRun().catch(async (error) => { + if (error && error.message === 'RUN_ALREADY_FAILED') { + return; + } + await this.failRun('failed', 'Failed', error, { reason: 'Unhandled run-controller error' }); + }); + return { ok: true, runId: this.runId }; + } + + async executeRun() { + await this.setStatus('initializing', 'Initializing', 'Finding CHEFS form'); + await this.injectBridge(); + await this.waitForForm(); + if (this.detectPaymentTransaction()) { + throw new Error('A payment transaction control was detected. Automatic submission was stopped.'); + } + + const initial = await this.scanComponents(); + await this.setSnapshot('initial', initial.snapshot); + await this.log('INITIAL_SCAN_COMPLETED', { + discovered: initial.snapshot.length, + visibleFillable: initial.fillable.length, + formioInstanceFound: initial.formioInstanceFound + }); + await this.checkpoint('Initial scan completed'); + + await this.fillUntilStable(); + if (!this.running) { + return; + } + if (this.stopRequested) { + await this.finishStopped(); + return; + } + await this.submitWithRecovery(); + } + + detectPaymentTransaction() { + return Boolean( + document.querySelector('input[autocomplete="cc-number"], input[name*="cardNumber" i], iframe[src*="stripe" i], iframe[src*="moneris" i]') || + Array.from(document.querySelectorAll('button')).some((button) => isVisible(button) && /pay\s+now|complete\s+payment/i.test(cleanText(button.textContent))) + ); + } + + async expandContainers() { + const collapsed = Array.from(document.querySelectorAll('[ref="header"][aria-expanded="false"], .card-header[aria-expanded="false"]')); + for (const header of collapsed) { + if (isVisible(header)) { + header.click(); + await delay(40); + } + } + } + + metadataMap(bridgeResult) { + const map = new Map(); + if (!bridgeResult || !Array.isArray(bridgeResult.components)) { + return map; + } + for (const item of bridgeResult.components) { + if (item && item.key && !map.has(item.key)) { + map.set(item.key, item); + } + if (item && item.domId && !map.has(`__dom:${item.domId}`)) { + map.set(`__dom:${item.domId}`, item); + } + } + return map; + } + + inferKey(wrapper, metadata) { + // Prefer the wrapper's own Form.io component class. Searching descendant + // inputs first causes layout containers to inherit the key of their first + // child, which creates duplicate and incorrectly protected descriptors. + for (const key of metadata.keys()) { + if (wrapper.classList.contains(`formio-component-${key}`)) { + return key; + } + } + const classTokens = Array.from(wrapper.classList) + .filter((token) => token.startsWith('formio-component-')) + .map((token) => token.slice('formio-component-'.length)) + .filter((token) => !['form', 'label-hidden', 'multiple', 'file'].includes(token)); + const likelyPropertyKey = classTokens.find((token) => /^s\d+_/i.test(token) || /^unityApplicantId$/i.test(token)); + if (likelyPropertyKey) { + return likelyPropertyKey; + } + const inputKey = getInputKey(wrapper); + if (inputKey) { + return inputKey; + } + return classTokens.length > 1 ? classTokens[classTokens.length - 1] : (classTokens[0] || `dom-${wrapper.id || Math.random().toString(16).slice(2)}`); + } + + inferType(wrapper, key, meta) { + if (meta && meta.type) { + return String(meta.type).toLowerCase(); + } + const rawTypes = Array.from(wrapper.classList) + .filter((token) => token.startsWith('formio-component-')) + .map((token) => token.slice('formio-component-'.length).toLowerCase()) + .filter((token) => !['label-hidden', 'multiple', 'file'].includes(token)); + const layoutType = rawTypes.find((token) => LAYOUT_TYPES.has(token)); + if (layoutType) { + return layoutType; + } + const normalizedKey = String(key || '').toLowerCase(); + const classes = rawTypes.filter((token) => token !== normalizedKey); + // A default-key component can legitimately repeat the same token for both + // its type and key, for example: + // formio-component-simplefile formio-component-simplefile. + // Preserve the recognized type instead of filtering both copies away. + const fallbackType = rawTypes.find((token) => token === normalizedKey) || rawTypes[0] || ''; + return String(classes[0] || (LAYOUT_TYPES.has(normalizedKey) ? normalizedKey : fallbackType)).toLowerCase(); + } + + stateId(wrapper, key) { + return `${key}::${wrapper.id || 'no-id'}`; + } + + cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === 'function') { + return window.CSS.escape(String(value || '')); + } + return String(value || '').replace(/[^A-Za-z0-9_-]/g, '\\$&'); + } + + liveWrapper(descriptor) { + if (descriptor && descriptor.wrapper && descriptor.wrapper.isConnected && isVisible(descriptor.wrapper)) { + return descriptor.wrapper; + } + + const wrapperId = descriptor && descriptor.wrapperId + ? descriptor.wrapperId + : descriptor && descriptor.wrapper && descriptor.wrapper.id + ? descriptor.wrapper.id + : ''; + if (wrapperId) { + const exact = document.getElementById(wrapperId); + if (exact && exact.isConnected && isVisible(exact)) { + descriptor.wrapper = exact; + descriptor.wrapperId = exact.id; + return exact; + } + } + + const key = descriptor && descriptor.key ? descriptor.key : ''; + if (!key) { + return descriptor ? descriptor.wrapper : null; + } + const candidates = Array.from(document.querySelectorAll(`.formio-component-${this.cssEscape(key)}`)); + const visible = candidates.find((candidate) => isVisible(candidate)); + const connected = visible || candidates.find((candidate) => candidate.isConnected) || null; + if (descriptor && connected) { + descriptor.wrapper = connected; + descriptor.wrapperId = connected.id || descriptor.wrapperId || ''; + } + return connected; + } + + isFileWrapper(wrapper, type) { + return Boolean( + wrapper && + ( + String(type || '').includes('file') || + wrapper.classList.contains('formio-component-file') || + wrapper.classList.contains('formio-component-simplefile') || + wrapper.querySelector('[ref="fileDrop"], .fileSelector') + ) + ); + } + + uploadedFileRows(wrapper, filename) { + if (!wrapper) { + return []; + } + const candidates = Array.from(wrapper.querySelectorAll( + '.list-group > .list-group-item:not(.list-group-header), ' + + '.list-group-item:not(.list-group-header), ' + + 'tbody tr:not(:first-child), [ref="fileLink"], [ref="fileName"], ' + + '.file-name, .file-list a, a[download]' + )); + const matches = candidates.filter((element) => { + const text = cleanText(element.textContent); + const hasRemoveControl = Boolean(element.querySelector && element.querySelector( + 'button[ref*="remove"], button[aria-label*="remove" i], .fa-times, .fa-times-circle-o' + )); + if (filename && text.includes(filename)) { + return true; + } + if (hasRemoveControl && text) { + return true; + } + if (!text) { + return false; + } + return !/^file\s*name\s*size$/i.test(text) && !/drop files to attach|browse to attach/i.test(text); + }); + return Array.from(new Set(matches.map((element) => + element.closest('.list-group-item:not(.list-group-header), tbody tr') || element + ))); + } + + inspectEmpty(wrapper, type) { + if (this.isFileWrapper(wrapper, type)) { + return this.uploadedFileRows(wrapper).length === 0; + } + if (type.includes('simpleday') || wrapper.querySelector('.formio-day-component-day, .formio-day-component-year')) { + const month = wrapper.querySelector('select[ref="month"], select[name="month"], select[id$="-month"]'); + const day = wrapper.querySelector('.formio-day-component-day, input[ref="day"], input[id$="-day"]'); + const year = wrapper.querySelector('.formio-day-component-year, input[ref="year"], input[id$="-year"]'); + const controls = [month, day, year].filter((control) => control && !control.disabled); + return controls.length === 0 || controls.some((control) => cleanText(control.value) === ''); + } + const radios = Array.from(wrapper.querySelectorAll('input[type="radio"]')).filter((input) => !input.disabled); + if (radios.length) { + return !radios.some((input) => input.checked); + } + const checkboxes = Array.from(wrapper.querySelectorAll('input[type="checkbox"]')).filter((input) => !input.disabled); + if (checkboxes.length) { + return !checkboxes.some((input) => input.checked); + } + const select = wrapper.querySelector('select'); + if (select) { + const selected = Array.from(select.selectedOptions || []).filter((option) => option.value && !/select|choose/i.test(cleanText(option.textContent))); + return selected.length === 0; + } + const textarea = wrapper.querySelector('textarea'); + if (textarea) { + return cleanText(textarea.value) === ''; + } + const inputs = Array.from(wrapper.querySelectorAll('input:not([type="hidden"]):not([type="button"]):not([type="submit"])')) + .filter((input) => !input.disabled); + if (inputs.length) { + return inputs.every((input) => cleanText(input.value) === ''); + } + const choiceText = wrapper.querySelector('.choices__list--single .choices__item, .choices__list--multiple .choices__item'); + if (choiceText) { + return cleanText(choiceText.textContent) === ''; + } + return true; + } + + isInvalid(wrapper) { + if (wrapper.querySelector('[aria-invalid="true"], .is-invalid')) { + return true; + } + const messages = Array.from(wrapper.querySelectorAll('.formio-errors, .invalid-feedback, .help-block')) + .map((element) => cleanText(element.textContent)) + .filter(Boolean); + return messages.length > 0 && wrapper.classList.contains('has-error'); + } + + isProtected(wrapper, key, type, meta) { + if (wrapper.classList.contains('formio-component-hidden')) { + return true; + } + if (LAYOUT_TYPES.has(type)) { + return true; + } + if (PROTECTED_TYPES.has(type) && type !== 'button') { + return true; + } + if (meta) { + if (meta.input === false || meta.hidden || meta.disabled || meta.readOnly || meta.calculateValue) { + return true; + } + if (meta.persistent === false || meta.persistent === 'client-only') { + return true; + } + } + const lowerKey = String(key).toLowerCase(); + if (/hidden|mappingtarget|extract|token|applicantagent|submissiondate/.test(lowerKey)) { + return true; + } + // User-facing acknowledgements often contain "confirmation" in their + // property names. Protect only actual system confirmation identifiers. + if (/^(confirmationid|confirmationnumber|submissionconfirmationid|submissionconfirmationnumber)$/.test(lowerKey)) { + return true; + } + const controls = wrapper.querySelectorAll('input, select, textarea, button'); + if (controls.length && Array.from(controls).every((control) => control.disabled || control.readOnly || control.type === 'hidden')) { + return true; + } + return false; + } + + hasOwnControl(wrapper) { + const controls = Array.from(wrapper.querySelectorAll('input, select, textarea, .choices, [ref="fileDrop"]')); + return controls.some((control) => control.closest('.formio-component') === wrapper); + } + + isFillableDescriptor(descriptor) { + if (!descriptor.visible || descriptor.protected) { + return false; + } + if (descriptor.type === 'button') { + return false; + } + if (descriptor.type.includes('datagrid') || descriptor.type.includes('editgrid')) { + return true; + } + if (this.hasOwnControl(descriptor.wrapper)) { + return true; + } + return this.isFileWrapper(descriptor.wrapper, descriptor.type); + } + + async scanComponents() { + await this.expandContainers(); + let bridgeResult = null; + try { + bridgeResult = await this.bridgeCommand('GET_COMPONENTS', {}, CONFIG.bridgeTimeoutMs); + } catch (error) { + await this.log('FORMIO_INSTANCE_SCAN_FAILED', { message: error.message }); + } + const metadata = this.metadataMap(bridgeResult); + const wrappers = Array.from(document.querySelectorAll('.formio-component[ref="component"], .formio-component')); + const descriptors = []; + const seen = new Set(); + + for (const wrapper of wrappers) { + if (seen.has(wrapper)) { + continue; + } + seen.add(wrapper); + const key = this.inferKey(wrapper, metadata); + const meta = metadata.get(`__dom:${wrapper.id}`) || metadata.get(key) || null; + const type = this.inferType(wrapper, key, meta); + const id = this.stateId(wrapper, key); + const visible = isVisible(wrapper); + const descriptor = { + id, + key, + type, + label: getFieldLabel(wrapper) || (meta && meta.label) || '', + description: getDescription(wrapper) || (meta && meta.description) || '', + visible, + enabled: !Boolean(wrapper.querySelector(':scope input:disabled, :scope select:disabled, :scope textarea:disabled')), + protected: this.isProtected(wrapper, key, type, meta), + required: Boolean((meta && meta.required) || wrapper.classList.contains('required') || wrapper.querySelector('[required]')), + empty: this.inspectEmpty(wrapper, type), + invalid: this.isInvalid(wrapper), + meta, + wrapper, + wrapperId: wrapper.id || '' + }; + const primaryControl = wrapper.querySelector('input:not([type="hidden"]), textarea, select'); + descriptor.maskPlan = this.resolveMaskPlan(descriptor, primaryControl, 1); + descriptor.fillable = this.isFillableDescriptor(descriptor); + descriptors.push(descriptor); + + if (!this.componentStates.has(id)) { + this.componentStates.set(id, { + id, + key, + type, + label: descriptor.label, + firstSeenPass: this.currentPass, + lastSeenPass: this.currentPass, + status: descriptor.protected ? 'protected' : descriptor.fillable ? 'discovered' : 'non-input', + attempts: 0, + fillStrategy: '', + visibleAtLastScan: visible, + lastError: '', + maskSource: descriptor.maskPlan ? descriptor.maskPlan.source : '', + resolvedMask: descriptor.maskPlan ? descriptor.maskPlan.mask : '', + customRuleId: descriptor.maskPlan && descriptor.maskPlan.rule ? descriptor.maskPlan.rule.id : '', + customRuleLabelMatch: descriptor.maskPlan && descriptor.maskPlan.rule ? descriptor.maskPlan.rule.labelMatch : '', + maskPlanLoggedSignatures: [] + }); + await this.log('COMPONENT_DISCOVERED', { + componentId: id, + key, + componentType: type, + label: descriptor.label, + visible, + fillable: descriptor.fillable, + protected: descriptor.protected, + required: descriptor.required + }); + } else { + const state = this.componentStates.get(id); + if (state.visibleAtLastScan !== visible) { + await this.log(visible ? 'COMPONENT_BECAME_VISIBLE' : 'COMPONENT_BECAME_HIDDEN', { + componentId: id, + key, + componentType: type, + label: descriptor.label + }); + } + state.visibleAtLastScan = visible; + state.lastSeenPass = this.currentPass; + } + } + + const fillable = descriptors.filter((item) => item.fillable && item.visible && !item.protected); + const snapshot = descriptors.map((item) => { + const state = this.componentStates.get(item.id) || {}; + return { + componentId: item.id, + key: item.key, + label: item.label, + description: item.description, + componentType: item.type, + visible: item.visible, + enabled: item.enabled, + protected: item.protected, + fillable: item.fillable, + required: item.required, + empty: item.empty, + invalid: item.invalid, + status: state.status || '', + attempts: state.attempts || 0, + fillStrategy: state.fillStrategy || '', + firstSeenPass: state.firstSeenPass, + lastSeenPass: state.lastSeenPass, + lastError: state.lastError || '', + placeholder: item.meta && item.meta.placeholder ? item.meta.placeholder : '', + inputMask: item.meta && item.meta.inputMask ? item.meta.inputMask : '', + runtimeInputMask: item.meta && item.meta.runtimeInputMask ? item.meta.runtimeInputMask : '', + maskSource: item.maskPlan ? item.maskPlan.source : '', + resolvedMask: item.maskPlan ? item.maskPlan.mask : '', + customRule: item.maskPlan && item.maskPlan.rule ? { + matched: true, + ruleId: item.maskPlan.rule.id, + labelMatch: item.maskPlan.rule.labelMatch, + matchMode: item.maskPlan.rule.matchMode, + configuredMask: item.maskPlan.rule.mask + } : { matched: false }, + maskGenerationStrategy: item.maskPlan ? 'input-mask' : '', + widgetType: item.meta && item.meta.widgetType ? item.meta.widgetType : '', + dataSrc: item.meta && item.meta.dataSrc ? item.meta.dataSrc : '', + multiple: Boolean(item.meta && item.meta.multiple), + minLength: item.meta && item.meta.minLength !== undefined ? item.meta.minLength : undefined, + maxLength: item.meta && item.meta.maxLength !== undefined ? item.meta.maxLength : undefined, + minWords: item.meta && item.meta.minWords !== undefined ? item.meta.minWords : undefined, + maxWords: item.meta && item.meta.maxWords !== undefined ? item.meta.maxWords : undefined, + minSelectedCount: item.meta && item.meta.minSelectedCount !== undefined ? item.meta.minSelectedCount : undefined, + maxSelectedCount: item.meta && item.meta.maxSelectedCount !== undefined ? item.meta.maxSelectedCount : undefined, + gridRowCount: item.type.includes('datagrid') || item.type.includes('editgrid') + ? this.gridRows(item.wrapper, item.type).length + : undefined, + gridTargetRows: item.type.includes('datagrid') || item.type.includes('editgrid') + ? this.gridTargetRows(item) + : undefined + }; + }); + this.progress.discovered = this.componentStates.size; + this.progress.remaining = fillable.filter((item) => item.empty || item.invalid).length; + this.progress.filled = Array.from(this.componentStates.values()).filter((state) => state.status === 'filled').length; + this.progress.failed = Array.from(this.componentStates.values()).filter((state) => state.status === 'failed' || state.status === 'blocked').length; + this.progress.unsupported = Array.from(this.componentStates.values()).filter((state) => state.status === 'unsupported').length; + await this.indexSubmitLandmarks(); + return { + descriptors, + fillable, + snapshot, + formioInstanceFound: Boolean(bridgeResult && bridgeResult.formFound) + }; + } + + async setSnapshot(name, snapshot) { + await this.runtimeMessage({ type: 'SET_SNAPSHOT', runId: this.runId, name, snapshot }); + } + + async fillUntilStable() { + let stablePasses = 0; + const initialTabs = this.getTabEntries(); + this.passBudget = Math.min( + CONFIG.hardMaxFillPasses, + Math.max(CONFIG.maxFillPasses, 20 + (initialTabs.length * CONFIG.tabPassesPerTab)) + ); + await this.log('FILL_PASS_BUDGET_SET', { + passBudget: this.passBudget, + hardPassLimit: CONFIG.hardMaxFillPasses, + tabCount: initialTabs.length + }); + if (initialTabs.length) { + await this.log('TAB_SET_DISCOVERED', { + tabCount: initialTabs.length, + tabs: initialTabs.map((entry) => ({ id: entry.id, label: entry.label, active: this.tabIsActive(entry) })) + }); + } + + while (this.currentPass < CONFIG.hardMaxFillPasses) { + if (this.currentPass >= this.passBudget) { + const boundaryScan = await this.scanComponents(); + const unresolvedAtBoundary = boundaryScan.fillable.filter((item) => item.empty || item.invalid); + if (unresolvedAtBoundary.length && this.lastPassHadProgress && this.passBudget < CONFIG.hardMaxFillPasses) { + const oldBudget = this.passBudget; + this.passBudget = Math.min(CONFIG.hardMaxFillPasses, this.passBudget + CONFIG.passExtensionIncrement); + await this.log('FILL_PASS_BUDGET_EXTENDED', { + oldBudget, + newBudget: this.passBudget, + unresolved: unresolvedAtBoundary.length, + reason: 'The final allowed pass made progress or revealed additional fields.' + }); + } else { + break; + } + } + if (await this.finalizeIfSubmitted(this.progress.submitAttempts, 'fill-loop-start')) { + return; + } + if (this.stopRequested) { + return; + } + if (Date.now() - this.lastProgressAt > CONFIG.overallStallMs) { + const scan = await this.scanComponents(); + const unresolved = scan.fillable.filter((item) => item.empty || item.invalid); + if (unresolved.length) { + await this.failRun('stalled', 'Stalled', new Error('No successful form progress was recorded within the stall window.'), { + reason: 'No progress', + unresolved: unresolved.map((item) => ({ key: item.key, label: item.label, type: item.type })) + }); + throw new Error('RUN_ALREADY_FAILED'); + } + this.lastProgressAt = Date.now(); + } + + this.currentPass += 1; + this.progress.pass = this.currentPass; + const revisionBefore = this.domRevision; + await this.setStatus('scanning', 'Scanning', `Scanning pass ${this.currentPass}`); + await this.log('PASS_STARTED', { domRevision: this.domRevision }); + const scan = await this.scanComponents(); + await this.setSnapshot('lastKnown', scan.snapshot); + await this.updateRun({ progress: Object.assign({}, this.progress) }); + + const candidates = scan.fillable.filter((descriptor) => { + const state = this.componentStates.get(descriptor.id); + return (descriptor.empty || descriptor.invalid) && state && state.attempts < CONFIG.maxFieldAttempts; + }); + + await this.setStatus('filling', 'Filling', `Filling pass ${this.currentPass}`); + let actions = 0; + + const gridCandidates = scan.fillable.filter((item) => item.type.includes('datagrid') || item.type.includes('editgrid')); + for (const descriptor of gridCandidates) { + actions += await this.handleGrid(descriptor); + } + if (actions > 0) { + this.lastPassHadProgress = true; + await this.setStatus('settling', 'Settling', 'Waiting for grid rows'); + await delay(CONFIG.settleDelayMs); + stablePasses = 0; + continue; + } + + for (const descriptor of candidates) { + if (this.stopRequested) { + return; + } + if (!descriptor.wrapper.isConnected || !isVisible(descriptor.wrapper)) { + continue; + } + if (descriptor.type.includes('datagrid') || descriptor.type.includes('editgrid')) { + continue; + } + const result = await this.fillDescriptor(descriptor); + actions += result ? 1 : 0; + } + + actions += await this.handleLookupActions(); + await this.setStatus('settling', 'Settling', 'Waiting for conditional form changes'); + await delay(CONFIG.settleDelayMs); + if (await this.finalizeIfSubmitted(this.progress.submitAttempts, 'fill-loop-settle')) { + return; + } + + const revisionChanged = this.domRevision !== revisionBefore; + let navigationProgress = false; + if (actions > 0 || revisionChanged) { + stablePasses = 0; + } else { + const activatedTab = await this.activateNextUnvisitedTab(); + if (activatedTab) { + navigationProgress = true; + this.lastPassHadProgress = true; + stablePasses = 0; + await delay(CONFIG.settleDelayMs); + continue; + } + const advancedWizard = await this.advanceWizard(); + if (advancedWizard) { + navigationProgress = true; + this.lastPassHadProgress = true; + stablePasses = 0; + await delay(CONFIG.settleDelayMs); + continue; + } + stablePasses += 1; + } + this.lastPassHadProgress = actions > 0 || revisionChanged || navigationProgress; + + const afterScan = await this.scanComponents(); + await this.setSnapshot('lastKnown', afterScan.snapshot); + await this.checkpoint('Fill pass completed', { + actions, + stablePasses, + domRevisionBefore: revisionBefore, + domRevisionAfter: this.domRevision + }); + await this.log('PASS_COMPLETED', { + actions, + stablePasses, + remaining: this.progress.remaining, + domChanged: revisionChanged + }); + + if (stablePasses >= CONFIG.stablePassesRequired) { + break; + } + } + + if (this.currentPass >= this.passBudget || this.currentPass >= CONFIG.hardMaxFillPasses) { + const finalBudgetScan = await this.scanComponents(); + const unresolvedAfterBudget = finalBudgetScan.fillable.filter((item) => item.empty || item.invalid); + const unvisitedTabsAfterBudget = this.getTabEntries().filter((entry) => !this.visitedTabs.has(entry.id)); + if (!unresolvedAfterBudget.length && !unvisitedTabsAfterBudget.length) { + await this.log('FILL_PASS_BUDGET_REACHED_WITH_FORM_FULL', { + passBudget: this.passBudget, + pass: this.currentPass + }); + return; + } + await this.failRun('safety_stop', 'Safety stop', new Error('The maximum fill-pass limit was reached.'), { + reason: 'Maximum fill passes reached', + passBudget: this.passBudget, + hardPassLimit: CONFIG.hardMaxFillPasses, + unresolved: unresolvedAfterBudget.map((item) => ({ key: item.key, label: item.label, type: item.type })), + unvisitedTabs: unvisitedTabsAfterBudget.map((entry) => ({ id: entry.id, label: entry.label })) + }); + throw new Error('RUN_ALREADY_FAILED'); + } + } + + async fillDescriptor(descriptor) { + const state = this.componentStates.get(descriptor.id); + if (!state || state.attempts >= CONFIG.maxFieldAttempts) { + return false; + } + state.attempts += 1; + const strategy = this.chooseStrategy(descriptor); + state.fillStrategy = strategy; + this.currentAction = `Filling ${descriptor.key}`; + await this.updateRun({ currentAction: this.currentAction, progress: Object.assign({}, this.progress) }); + const primaryControl = descriptor.wrapper.querySelector('input:not([type="hidden"]), textarea, select'); + const maskPlan = this.resolveMaskPlan(descriptor, primaryControl, state.attempts); + if (maskPlan) { + state.maskSource = maskPlan.source; + state.resolvedMask = maskPlan.mask; + state.customRuleId = maskPlan.rule ? maskPlan.rule.id : ''; + state.customRuleLabelMatch = maskPlan.rule ? maskPlan.rule.labelMatch : ''; + const maskPlanSignature = `${maskPlan.source}:${maskPlan.mask}:${maskPlan.rule ? maskPlan.rule.id : ''}`; + state.maskPlanLoggedSignatures = Array.isArray(state.maskPlanLoggedSignatures) ? state.maskPlanLoggedSignatures : []; + if (!state.maskPlanLoggedSignatures.includes(maskPlanSignature)) { + if (maskPlan.rule) { + this.progress.customRuleMatches += 1; + await this.log('CUSTOM_RULE_MATCHED', { + componentId: descriptor.id, + key: descriptor.key, + label: descriptor.label, + ruleId: maskPlan.rule.id, + labelMatch: maskPlan.rule.labelMatch, + matchMode: maskPlan.rule.matchMode, + configuredMask: maskPlan.rule.mask, + fallbackDetectedMask: maskPlan.fallbackDetectedMask ? maskPlan.fallbackDetectedMask.mask : '' + }); + } else { + this.progress.detectedMasksUsed += 1; + await this.log(maskPlan.source === 'runtime-inputmask' ? 'MASK_RUNTIME_DETECTED' : 'MASK_METADATA_DETECTED', { + componentId: descriptor.id, + key: descriptor.key, + label: descriptor.label, + maskSource: maskPlan.source, + mask: maskPlan.mask + }); + } + state.maskPlanLoggedSignatures.push(maskPlanSignature); + } + } + await this.log('FILL_ATTEMPT', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + label: descriptor.label, + strategy, + attempt: state.attempts, + resolvedConstraints: this.constraints(descriptor, primaryControl), + formatPlan: maskPlan ? { + source: maskPlan.source, + mask: maskPlan.mask, + ruleId: maskPlan.rule ? maskPlan.rule.id : '', + labelMatch: maskPlan.rule ? maskPlan.rule.labelMatch : '' + } : null + }); + await this.checkpoint('Fill attempt started', { + fieldKey: descriptor.key, + componentId: descriptor.id, + strategy, + attempt: state.attempts + }); + + try { + const actionTimeout = strategy === 'formio-file-upload' ? CONFIG.uploadActionTimeoutMs : CONFIG.actionTimeoutMs; + const outcome = await withTimeout(this.performFill(descriptor, strategy, state.attempts), actionTimeout, `Filling ${descriptor.key}`); + if (outcome && outcome.success) { + state.status = 'filled'; + state.lastError = ''; + this.progress.filled = Array.from(this.componentStates.values()).filter((item) => item.status === 'filled').length; + if (maskPlan) { + if (maskPlan.rule) { + this.progress.customRuleAccepted += 1; + await this.log('CUSTOM_RULE_VALUE_ACCEPTED', { + componentId: descriptor.id, + key: descriptor.key, + ruleId: maskPlan.rule.id, + configuredMask: maskPlan.mask, + attempt: state.attempts + }); + } else { + await this.log('MASK_VALUE_PERSISTED', { + componentId: descriptor.id, + key: descriptor.key, + maskSource: maskPlan.source, + mask: maskPlan.mask, + attempt: state.attempts + }); + } + } + await this.log('FILL_SUCCEEDED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + strategy, + attempt: state.attempts, + value: outcome.valueInfo || null + }); + await this.markProgress(`Filled ${descriptor.key}`); + await this.checkpoint('Fill succeeded', { fieldKey: descriptor.key, componentId: descriptor.id }); + return true; + } + throw new Error(outcome && outcome.message ? outcome.message : 'The generated value did not persist.'); + } catch (error) { + state.lastError = error.message || String(error); + state.status = state.attempts >= CONFIG.maxFieldAttempts ? 'blocked' : 'retry'; + if (maskPlan) { + this.progress.maskValuesRejected += 1; + if (maskPlan.rule) { + this.progress.customRuleRejected += 1; + await this.log('CUSTOM_RULE_VALUE_REJECTED', { + componentId: descriptor.id, + key: descriptor.key, + ruleId: maskPlan.rule.id, + configuredMask: maskPlan.mask, + detectedFallbackMask: maskPlan.fallbackDetectedMask ? maskPlan.fallbackDetectedMask.mask : '', + attempt: state.attempts, + message: state.lastError + }); + } else { + await this.log('MASK_VALUE_REJECTED', { + componentId: descriptor.id, + key: descriptor.key, + maskSource: maskPlan.source, + mask: maskPlan.mask, + attempt: state.attempts, + message: state.lastError + }); + } + } + await this.log(state.attempts >= CONFIG.maxFieldAttempts ? 'FILL_REJECTED' : 'VALUE_DID_NOT_PERSIST', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + strategy, + attempt: state.attempts, + message: state.lastError, + stack: error.stack || '' + }); + await this.checkpoint('Fill attempt failed', { + fieldKey: descriptor.key, + componentId: descriptor.id, + message: state.lastError + }); + return false; + } + } + + chooseStrategy(descriptor) { + if (this.isFileWrapper(descriptor.wrapper, descriptor.type)) { + return 'formio-file-upload'; + } + if (descriptor.type.includes('simpleday') || descriptor.wrapper.querySelector('.formio-day-component-day, .formio-day-component-year')) { + return 'day-component'; + } + if (descriptor.wrapper.querySelector('input[type="radio"]')) { + return 'radio-choice'; + } + if (descriptor.wrapper.querySelector('input[type="checkbox"]')) { + return 'checkbox-choice'; + } + if (descriptor.wrapper.querySelector('.choices')) { + return 'choices-select'; + } + if (descriptor.wrapper.querySelector('select')) { + return 'native-select'; + } + const maskControl = this.primaryTextControl(descriptor); + if (this.resolveMaskPlan(descriptor, maskControl, 1)) { + return 'masked-input'; + } + if (descriptor.wrapper.querySelector('textarea')) { + return 'textarea'; + } + if (descriptor.wrapper.querySelector('input[type="date"], input[type="datetime-local"], input[type="time"], .flatpickr-input')) { + return 'date-time'; + } + if (/phone|telephone|mobile|fax/i.test(`${descriptor.type} ${descriptor.key} ${descriptor.label}`)) { + return 'phone-input'; + } + if (descriptor.wrapper.querySelector('input')) { + return 'input'; + } + return 'formio-set-value'; + } + + async performFill(descriptor, strategy, attempt) { + switch (strategy) { + case 'formio-file-upload': + return this.fillFile(descriptor); + case 'radio-choice': + return this.fillRadio(descriptor); + case 'checkbox-choice': + return this.fillCheckbox(descriptor); + case 'choices-select': + return this.fillChoices(descriptor); + case 'native-select': + return this.fillNativeSelect(descriptor); + case 'day-component': + return this.fillDayComponent(descriptor); + case 'masked-input': + return this.fillMaskedInput(descriptor, attempt); + case 'textarea': + return this.fillTextarea(descriptor, attempt); + case 'date-time': + return this.fillDateTime(descriptor, attempt); + case 'phone-input': + return this.fillPhone(descriptor, attempt); + case 'input': + return this.fillInput(descriptor, attempt); + default: + return this.fillViaBridge(descriptor, this.generateValue(descriptor, attempt)); + } + } + + optionScore(optionText, descriptor) { + const option = cleanText(optionText).toLowerCase(); + const context = `${descriptor.label} ${descriptor.description} ${descriptor.key}`.toLowerCase(); + if (!option || /please\s+select|select\.\.\.|choose|--/.test(option)) { + return -1000; + } + let score = 10; + if (/none|not applicable|prefer not|unknown/.test(option)) { + score -= 80; + } + if (/british columbia/.test(option)) { + score += 90; + } + if (/victoria/.test(option)) { + score += 70; + } + if (/other/.test(option)) { + score += 35; + } + const priorContext = /previous|previously|returning|existing|already|received.*grant|applied.*before|lookup/.test(context); + if (option === 'no') { + score += priorContext ? 120 : 10; + } + if (option === 'yes') { + score += priorContext ? -20 : 80; + } + if (/local/.test(option)) { + score += 20; + } + return score; + } + + chooseOption(elements, descriptor) { + return elements + .map((element, index) => ({ element, index, score: this.optionScore(element.textContent || element.label || element.value, descriptor) })) + .sort((a, b) => b.score - a.score || a.index - b.index)[0]; + } + + async fillRadio(descriptor) { + const radios = Array.from(descriptor.wrapper.querySelectorAll('input[type="radio"]')).filter((input) => !input.disabled); + const choices = radios.map((input) => { + const label = input.closest('label'); + return { input, textContent: label ? label.textContent : input.value }; + }); + const chosen = this.chooseOption(choices, descriptor); + if (!chosen) { + return { success: false, message: 'No enabled radio option was found.' }; + } + chosen.element.input.click(); + await delay(120); + return { + success: chosen.element.input.checked, + valueInfo: safeValueInfo(chosen.element.input.value, true) + }; + } + + checkboxSelectionBounds(descriptor, inputs) { + const constraints = this.constraints(descriptor, null); + const context = cleanText(`${descriptor.label || ''} ${descriptor.description || ''} ${descriptor.wrapper ? descriptor.wrapper.textContent : ''}`).toLowerCase(); + const maximumMatch = context.match(/(?:maximum|max\.?|up to|only select up to)\s*[:=-]?\s*(\d+)\s*(?:partners?|items?|options?|selections?)?/i) || + context.match(/can only select up to\s*(\d+)/i); + const minimumMatch = context.match(/(?:minimum|min\.?|at least|select at least)\s*[:=-]?\s*(\d+)\s*(?:partners?|items?|options?|selections?)?/i); + let maximum = constraints.maxSelectedCount; + let minimum = constraints.minSelectedCount; + let maximumSource = maximum !== null ? 'formio-schema' : ''; + let minimumSource = minimum !== null ? 'formio-schema' : ''; + + if ((maximum === null || !Number.isFinite(maximum)) && maximumMatch) { + maximum = Number(maximumMatch[1]); + maximumSource = 'rendered-guidance'; + } + if ((maximum === null || !Number.isFinite(maximum)) && /please select only one|select only one|only one (?:item|option|selection)|single selection/i.test(context)) { + maximum = 1; + maximumSource = 'rendered-single-selection-guidance'; + } + if ((minimum === null || !Number.isFinite(minimum)) && minimumMatch) { + minimum = Number(minimumMatch[1]); + minimumSource = 'rendered-guidance'; + } + if (!Number.isFinite(minimum)) { + minimum = descriptor.required ? 1 : 0; + minimumSource = descriptor.required ? 'required-default' : 'optional-default'; + } + if (!Number.isFinite(maximum) || maximum <= 0) { + maximum = inputs.length; + maximumSource = 'all-available'; + } + maximum = Math.max(0, Math.min(inputs.length, Math.floor(maximum))); + minimum = Math.max(0, Math.min(maximum, Math.floor(minimum))); + return { minimum, maximum, minimumSource, maximumSource }; + } + + async fillCheckbox(descriptor) { + const wrapper = this.liveWrapper(descriptor) || descriptor.wrapper; + const inputs = Array.from(wrapper.querySelectorAll('input[type="checkbox"]')).filter((input) => !input.disabled); + if (!inputs.length) { + return { success: false, message: 'No enabled checkbox was found.' }; + } + + const bounds = this.checkboxSelectionBounds(descriptor, inputs); + const options = inputs.map((input, index) => { + const labelElement = input.closest('label'); + const text = cleanText(labelElement ? labelElement.textContent : input.value); + const disfavoured = /none|not applicable|prefer not|unknown/i.test(text) && inputs.length > 1; + return { + input, + index, + text, + disfavoured, + score: this.optionScore(text, descriptor) + }; + }); + const preferred = options + .filter((option) => !option.disfavoured) + .sort((a, b) => b.score - a.score || a.index - b.index); + const fallback = options + .filter((option) => option.disfavoured) + .sort((a, b) => b.score - a.score || a.index - b.index); + const ordered = preferred.concat(fallback); + const preferredCapacity = preferred.length > 0 ? preferred.length : fallback.length; + const desiredCount = Math.max(bounds.minimum, Math.min(bounds.maximum, preferredCapacity)); + const chosen = new Set(ordered.slice(0, desiredCount).map((option) => option.input)); + const checkedBefore = inputs.filter((input) => input.checked).length; + + await this.log('CHECKBOX_SELECTION_LIMIT_DETECTED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + optionCount: inputs.length, + minimumSelected: bounds.minimum, + maximumSelected: bounds.maximum, + minimumSource: bounds.minimumSource, + maximumSource: bounds.maximumSource + }); + + let changed = false; + for (const input of inputs) { + const shouldBeChecked = chosen.has(input); + if (input.checked !== shouldBeChecked) { + input.click(); + changed = true; + await delay(60); + } + } + + const liveWrapper = this.liveWrapper(descriptor) || wrapper; + const liveInputs = Array.from(liveWrapper.querySelectorAll('input[type="checkbox"]')).filter((input) => !input.disabled); + const checkedAfter = liveInputs.filter((input) => input.checked).length; + if (checkedBefore !== checkedAfter || checkedAfter > bounds.maximum) { + await this.log('CHECKBOX_SELECTION_REPAIRED', { + componentId: descriptor.id, + key: descriptor.key, + checkedBefore, + checkedAfter, + maximumSelected: bounds.maximum + }); + } + return { + success: checkedAfter >= bounds.minimum && checkedAfter <= bounds.maximum, + message: checkedAfter > bounds.maximum + ? `The checkbox group still contains ${checkedAfter} selections, above the maximum of ${bounds.maximum}.` + : checkedAfter < bounds.minimum + ? `The checkbox group contains ${checkedAfter} selections, below the minimum of ${bounds.minimum}.` + : '', + valueInfo: safeValueInfo(checkedAfter, true), + changed + }; + } + + choiceValuePresent(wrapper) { + if (!wrapper) { + return false; + } + const select = wrapper.querySelector('select'); + if (select) { + const selected = Array.from(select.selectedOptions || []) + .filter((option) => option.value !== '' && !/please\s+select|choose/i.test(cleanText(option.textContent))); + if (selected.length) { + return true; + } + } + const rendered = Array.from(wrapper.querySelectorAll( + '.choices__list--single [data-item], .choices__list--multiple [data-item], ' + + '.choices__list--single .choices__item:not([data-choice]), ' + + '.choices__list--multiple .choices__item:not([data-choice])' + )).filter((item) => { + const text = cleanText(item.textContent); + return text && !/please\s+select|choose|select\.\.\./i.test(text); + }); + return rendered.length > 0; + } + + dispatchPointerSequence(element) { + const eventTypes = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']; + for (const type of eventTypes) { + try { + const EventCtor = type.startsWith('pointer') && typeof PointerEvent === 'function' + ? PointerEvent + : MouseEvent; + element.dispatchEvent(new EventCtor(type, { + bubbles: true, + cancelable: true, + composed: true, + button: 0, + buttons: type.endsWith('down') ? 1 : 0, + pointerType: 'mouse' + })); + } catch (error) { + element.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, composed: true })); + } + } + } + + async fillChoices(descriptor) { + let wrapper = this.liveWrapper(descriptor); + if (!wrapper) { + return { success: false, message: 'The live Choices wrapper was not found.' }; + } + const choicesRoot = wrapper.querySelector('.choices'); + const control = wrapper.querySelector( + '.choices .form-control.ui.fluid.selection.dropdown, .choices[role="combobox"], .choices__inner' + ); + if (!choicesRoot || !control) { + return { success: false, message: 'Choices control was not found.' }; + } + + control.focus(); + this.dispatchPointerSequence(control); + await delay(140); + + wrapper = this.liveWrapper(descriptor) || wrapper; + const options = Array.from(wrapper.querySelectorAll( + '.choices__item--choice[data-choice-selectable], .choices__item--choice:not(.is-disabled)' + )).filter((option) => !option.classList.contains('is-disabled')); + const chosen = this.chooseOption(options, descriptor); + + if (!chosen) { + const select = wrapper.querySelector('select'); + if (select) { + return this.fillNativeSelect(descriptor); + } + return { success: false, message: 'No selectable Choices option was found.' }; + } + + const chosenText = cleanText(chosen.element.textContent); + this.dispatchPointerSequence(chosen.element); + await delay(260); + wrapper = this.liveWrapper(descriptor) || wrapper; + + if (!this.choiceValuePresent(wrapper)) { + const search = wrapper.querySelector('.choices__input--cloned'); + if (search) { + search.focus(); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + setter.call(search, chosenText); + search.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + search.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowDown', + code: 'ArrowDown', + keyCode: 40, + which: 40, + bubbles: true, + cancelable: true + })); + search.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + })); + await delay(260); + wrapper = this.liveWrapper(descriptor) || wrapper; + } + } + + if (!this.choiceValuePresent(wrapper)) { + const refreshedOptions = Array.from(wrapper.querySelectorAll( + '.choices__item--choice[data-choice-selectable], .choices__item--choice:not(.is-disabled)' + )).filter((option) => !option.classList.contains('is-disabled')); + const exact = refreshedOptions.find((option) => cleanText(option.textContent) === chosenText); + if (exact) { + exact.click(); + await delay(220); + wrapper = this.liveWrapper(descriptor) || wrapper; + } + } + + if (!this.choiceValuePresent(wrapper)) { + const select = wrapper.querySelector('select'); + const dataValue = chosen.element.getAttribute('data-value'); + if (select && dataValue !== null) { + let option = Array.from(select.options).find((item) => item.value === dataValue); + if (!option && dataValue !== '[object Object]') { + option = new Option(chosenText, dataValue, true, true); + select.add(option); + } + if (option) { + option.selected = true; + select.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + select.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + select.dispatchEvent(new Event('blur', { bubbles: true, composed: true })); + await delay(220); + wrapper = this.liveWrapper(descriptor) || wrapper; + } + } + } + + return { + success: this.choiceValuePresent(wrapper), + valueInfo: safeValueInfo(chosenText, true) + }; + } + + async fillNativeSelect(descriptor) { + const select = descriptor.wrapper.querySelector('select'); + if (!select || select.disabled) { + return { success: false, message: 'No enabled select control was found.' }; + } + const options = Array.from(select.options).filter((option) => !option.disabled && option.value !== ''); + const chosen = this.chooseOption(options, descriptor); + if (!chosen) { + return { success: false, message: 'No usable select option was found.' }; + } + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set; + setter.call(select, chosen.element.value); + select.dispatchEvent(new Event('input', { bubbles: true })); + select.dispatchEvent(new Event('change', { bubbles: true })); + select.dispatchEvent(new Event('blur', { bubbles: true })); + await delay(160); + return { + success: select.value !== '', + valueInfo: safeValueInfo(cleanText(chosen.element.textContent), true) + }; + } + + clampNumber(value, minimum, maximum) { + let result = Number(value); + if (Number.isFinite(minimum)) { + result = Math.max(result, minimum); + } + if (Number.isFinite(maximum)) { + result = Math.min(result, maximum); + } + return result; + } + + async fillDayComponent(descriptor) { + const wrapper = this.liveWrapper(descriptor) || descriptor.wrapper; + if (!wrapper) { + return { success: false, message: 'The live day component wrapper was not found.' }; + } + const month = wrapper.querySelector('select[ref="month"], select[name="month"], select[id$="-month"]'); + const day = wrapper.querySelector('.formio-day-component-day, input[ref="day"], input[id$="-day"]'); + const year = wrapper.querySelector('.formio-day-component-year, input[ref="year"], input[id$="-year"]'); + if (!month || !day || !year) { + return { success: false, message: 'The day component did not expose month, day and year controls.' }; + } + + const monthOptions = Array.from(month.options || []).filter((option) => !option.disabled && option.value !== ''); + const preferredMonth = monthOptions.find((option) => String(option.value) === '6') || monthOptions[0]; + if (!preferredMonth) { + return { success: false, message: 'The day component did not contain a usable month.' }; + } + + const dayMin = Number(day.min); + const dayMax = Number(day.max); + const yearMin = Number(year.min); + const yearMax = Number(year.max); + const generatedDay = this.clampNumber(20, Number.isFinite(dayMin) ? dayMin : 1, Number.isFinite(dayMax) ? dayMax : 31); + const generatedYear = this.clampNumber(new Date().getFullYear(), Number.isFinite(yearMin) ? yearMin : undefined, Number.isFinite(yearMax) ? yearMax : undefined); + const diagnostic = { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }; + + await this.nativeSetValue(month, preferredMonth.value, Object.assign({}, diagnostic, { controlPart: 'month' })); + await this.nativeSetValue(day, String(generatedDay), Object.assign({}, diagnostic, { controlPart: 'day' })); + await this.nativeSetValue(year, String(generatedYear), Object.assign({}, diagnostic, { controlPart: 'year' })); + await delay(180); + + const live = this.liveWrapper(descriptor) || wrapper; + const complete = !this.inspectEmpty(live, descriptor.type); + return { + success: complete, + message: complete ? '' : 'Month, day and year did not all persist.', + valueInfo: safeValueInfo(`${preferredMonth.value}/${generatedDay}/${generatedYear}`, true) + }; + } + + async nativeSetValue(input, value, diagnostic) { + const prototype = input instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : input instanceof HTMLSelectElement + ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + const valueDescriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + const context = diagnostic || {}; + if (context.key) { + this.currentAction = `Applying value to ${context.key}`; + await this.updateRun({ + currentAction: this.currentAction, + progress: Object.assign({}, this.progress) + }); + await this.log('CONTROL_EVENT_SEQUENCE_STARTED', { + componentId: context.componentId || '', + key: context.key, + componentType: context.componentType || '', + controlPart: context.controlPart || '', + controlType: String(input.type || input.tagName || '').toLowerCase(), + value: safeValueInfo(value, true) + }); + } + if (valueDescriptor && valueDescriptor.set) { + valueDescriptor.set.call(input, value); + } else { + input.value = value; + } + await delay(0); + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + await delay(0); + input.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + await delay(0); + input.dispatchEvent(new Event('blur', { bubbles: true, composed: true })); + await delay(0); + if (context.key) { + await this.log('CONTROL_EVENT_SEQUENCE_COMPLETED', { + componentId: context.componentId || '', + key: context.key, + componentType: context.componentType || '', + controlPart: context.controlPart || '', + controlType: String(input.type || input.tagName || '').toLowerCase() + }); + } + } + + primaryTextControl(descriptor) { + return descriptor && descriptor.wrapper + ? descriptor.wrapper.querySelector('input:not([type="hidden"]):not([type="button"]):not([type="submit"]), textarea') + : null; + } + + customRuleForDescriptor(descriptor) { + if (!cleanText(descriptor && descriptor.label || '')) { + return null; + } + for (const rule of this.customFormatRules) { + const normalizedLabel = normalizeRulePhrase(descriptor && descriptor.label || '', rule.caseSensitive); + const normalizedRule = normalizeRulePhrase(rule.labelMatch, rule.caseSensitive); + if (!normalizedRule) { + continue; + } + const matched = rule.matchMode === 'exact' + ? normalizedLabel === normalizedRule + : normalizedLabel.includes(normalizedRule); + if (matched) { + return rule; + } + } + return null; + } + + runtimeMaskFromControl(control) { + if (!control) { + return ''; + } + try { + if (control.inputmask && control.inputmask.opts && control.inputmask.opts.mask) { + const value = control.inputmask.opts.mask; + return Array.isArray(value) ? String(value[0] || '') : String(value || ''); + } + } catch (error) { + // Runtime Inputmask expandos may be isolated from the content script. + } + const direct = control.getAttribute && (control.getAttribute('data-inputmask-mask') || control.getAttribute('data-mask')); + if (direct) { + return String(direct).trim(); + } + const dataInputmask = control.getAttribute && control.getAttribute('data-inputmask'); + if (dataInputmask) { + const match = String(dataInputmask).match(/(?:mask\s*[:=]\s*['"])([^'"]+)/i); + if (match) { + return match[1]; + } + } + return ''; + } + + detectedMask(descriptor, control) { + const meta = descriptor.meta || {}; + const candidates = [ + { source: 'formio-component', value: meta.inputMask }, + { source: 'runtime-inputmask', value: meta.runtimeInputMask }, + { source: 'runtime-inputmask', value: this.runtimeMaskFromControl(control) } + ]; + for (const candidate of candidates) { + const value = Array.isArray(candidate.value) ? candidate.value[0] : candidate.value; + const text = typeof value === 'string' ? value.trim() : ''; + if (text && /[9a*]/.test(text)) { + return { source: candidate.source, mask: text }; + } + } + return null; + } + + resolveMaskPlan(descriptor, control, attempt) { + const customRule = this.customRuleForDescriptor(descriptor); + const detected = this.detectedMask(descriptor, control); + const attemptNumber = Number(attempt || 1); + if (customRule && (attemptNumber === 1 || !detected || detected.mask === customRule.mask)) { + return { + source: 'custom-rule', + mask: customRule.mask, + rule: customRule, + fallbackDetectedMask: detected && detected.mask !== customRule.mask ? detected : null + }; + } + if (detected) { + return { source: detected.source, mask: detected.mask, rule: null, fallbackFromCustomRule: Boolean(customRule) }; + } + if (customRule) { + return { source: 'custom-rule', mask: customRule.mask, rule: customRule, fallbackDetectedMask: null }; + } + return null; + } + + maskTokenCharacters(descriptor, attempt) { + const seed = `${this.runId}${descriptor.key}${attempt || 1}`.toUpperCase().replace(/[^A-Z0-9]/g, ''); + const digits = (seed.replace(/[^0-9]/g, '') + '12345678901234567890'); + const letters = (seed.replace(/[^A-Z]/g, '').replace(/[IOQ]/g, '') + 'ABCDEFGHJKLMNPRSTUVWXYZ'); + const alphaNumeric = `${letters}${digits}`; + return { digits, letters, alphaNumeric }; + } + + generateMaskValue(mask, descriptor, attempt) { + const source = String(mask || ''); + const unsupported = source.match(/[\[\]{}|?]/); + if (unsupported) { + return { supported: false, value: '', tokenCount: 0, reason: `Unsupported mask syntax: ${unsupported[0]}` }; + } + const streams = this.maskTokenCharacters(descriptor, attempt); + let digitIndex = 0; + let letterIndex = 0; + let alphaIndex = 0; + let escaped = false; + let value = ''; + let tokenCount = 0; + for (const character of source) { + if (escaped) { + value += character; + escaped = false; + continue; + } + if (character === '\\') { + escaped = true; + continue; + } + if (character === '9') { + value += streams.digits[digitIndex % streams.digits.length]; + digitIndex += 1; + tokenCount += 1; + } else if (character === 'a') { + value += streams.letters[letterIndex % streams.letters.length]; + letterIndex += 1; + tokenCount += 1; + } else if (character === '*') { + value += streams.alphaNumeric[alphaIndex % streams.alphaNumeric.length]; + alphaIndex += 1; + tokenCount += 1; + } else { + value += character; + } + } + if (escaped) { + return { supported: false, value: '', tokenCount, reason: 'The mask ends with an incomplete escape.' }; + } + return { supported: tokenCount > 0, value, tokenCount, reason: tokenCount > 0 ? '' : 'The mask has no supported tokens.' }; + } + + maskRegex(mask) { + let pattern = '^'; + let escaped = false; + for (const character of String(mask || '')) { + if (escaped) { + pattern += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '9') { + pattern += '[0-9]'; + } else if (character === 'a') { + pattern += '[A-Za-z]'; + } else if (character === '*') { + pattern += '[A-Za-z0-9]'; + } else { + pattern += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + } + pattern += '$'; + try { + return new RegExp(pattern); + } catch (error) { + return null; + } + } + + maskedValueAccepted(control, mask, generated, bridgeResult) { + const rendered = cleanText(control && control.value || bridgeResult && bridgeResult.renderedValue || ''); + const regex = this.maskRegex(mask); + const noPlaceholders = rendered && !/[_]/.test(rendered); + const tokenCount = (String(mask).match(/[9a*]/g) || []).length; + const renderedTokenCount = (rendered.match(/[A-Za-z0-9]/g) || []).length; + const validByRegex = Boolean(regex && regex.test(rendered)); + const validByTokenCount = noPlaceholders && renderedTokenCount >= tokenCount; + const inputmaskComplete = bridgeResult && bridgeResult.inputmaskComplete; + const htmlValid = !control || typeof control.checkValidity !== 'function' || control.checkValidity(); + return Boolean((validByRegex || validByTokenCount || inputmaskComplete) && htmlValid && rendered); + } + + async fillMaskedInput(descriptor, attempt) { + const control = this.primaryTextControl(descriptor); + const plan = this.resolveMaskPlan(descriptor, control, attempt); + if (!plan) { + return { success: false, message: 'No input mask was available.' }; + } + const generated = this.generateMaskValue(plan.mask, descriptor, attempt); + if (!generated.supported) { + await this.log('MASK_SYNTAX_UNSUPPORTED', { + componentId: descriptor.id, + key: descriptor.key, + maskSource: plan.source, + mask: plan.mask, + message: generated.reason + }); + return { success: false, message: generated.reason }; + } + await this.log('MASK_VALUE_GENERATED', { + componentId: descriptor.id, + key: descriptor.key, + maskSource: plan.source, + mask: plan.mask, + ruleId: plan.rule ? plan.rule.id : '', + tokenCount: generated.tokenCount, + value: safeValueInfo(generated.value, true) + }); + + let bridgeResult = null; + try { + bridgeResult = await this.bridgeCommand('SET_MASKED_VALUE', { + key: descriptor.key, + wrapperId: descriptor.wrapperId, + value: generated.value, + mask: plan.mask + }, CONFIG.bridgeTimeoutMs); + } catch (error) { + await this.log('MASK_BRIDGE_SET_FAILED', { + componentId: descriptor.id, + key: descriptor.key, + message: error.message + }); + } + + const liveWrapper = this.liveWrapper(descriptor) || descriptor.wrapper; + const liveControl = liveWrapper.querySelector('input:not([type="hidden"]), textarea') || control; + if (!this.maskedValueAccepted(liveControl, plan.mask, generated.value, bridgeResult) && liveControl) { + liveControl.focus(); + await this.nativeSetValue(liveControl, generated.value, { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }); + await delay(140); + } + const accepted = this.maskedValueAccepted(liveControl, plan.mask, generated.value, bridgeResult); + if (!accepted) { + return { + success: false, + message: `The value generated for mask ${plan.mask} did not persist as a complete valid value.` + }; + } + return { + success: true, + valueInfo: Object.assign(safeValueInfo(generated.value, true), { + maskSource: plan.source, + mask: plan.mask, + ruleId: plan.rule ? plan.rule.id : '', + tokenCount: generated.tokenCount + }), + maskPlan: plan + }; + } + + constraints(descriptor, control) { + const meta = descriptor.meta || {}; + const number = (value) => value === undefined || value === null || value === '' ? null : Number(value); + const firstNumber = (...values) => { + for (const value of values) { + const parsed = number(value); + if (parsed !== null && Number.isFinite(parsed)) { + return parsed; + } + } + return null; + }; + const hasAttribute = (name) => Boolean(control && control.hasAttribute && control.hasAttribute(name)); + const wrapperText = cleanText(`${descriptor.label || ''} ${descriptor.description || ''} ${descriptor.wrapper ? descriptor.wrapper.textContent : ''}`); + const explicitMaxCharacters = wrapperText.match(/(?:maximum|max\.?|up to)\s*[:=-]?\s*(\d+)\s*characters?\b/i); + const explicitMinCharacters = wrapperText.match(/(?:minimum|min\.?|at least)\s*[:=-]?\s*(\d+)\s*characters?\b/i); + const explicitMaxWords = wrapperText.match(/(?:maximum|max\.?|up to)\s*[:=-]?\s*(\d+)\s*words?\b/i); + const explicitMinWords = wrapperText.match(/(?:minimum|min\.?|at least)\s*[:=-]?\s*(\d+)\s*words?\b/i); + const remainingText = descriptor.wrapper + ? cleanText((descriptor.wrapper.querySelector('[ref="charcount"], .form-text.text-right .text-muted, .form-text.text-right') || {}).textContent || '') + : ''; + const remainingMatch = remainingText.match(/(-?\d+)\s*characters?\s+remaining/i); + let counterMaximum = null; + if (remainingMatch && control && typeof control.value === 'string') { + counterMaximum = control.value.length + Number(remainingMatch[1]); + if (!Number.isFinite(counterMaximum) || counterMaximum <= 0) { + counterMaximum = null; + } + } + return { + minLength: firstNumber( + hasAttribute('minlength') ? control.minLength : null, + meta.minLength, + explicitMinCharacters ? explicitMinCharacters[1] : null + ), + maxLength: firstNumber( + hasAttribute('maxlength') ? control.maxLength : null, + meta.maxLength, + explicitMaxCharacters ? explicitMaxCharacters[1] : null, + counterMaximum + ), + minWords: firstNumber(meta.minWords, explicitMinWords ? explicitMinWords[1] : null), + maxWords: firstNumber(meta.maxWords, explicitMaxWords ? explicitMaxWords[1] : null), + minSelectedCount: firstNumber(meta.minSelectedCount), + maxSelectedCount: firstNumber(meta.maxSelectedCount), + min: firstNumber(control && control.min !== '' ? control.min : null, meta.min), + max: firstNumber(control && control.max !== '' ? control.max : null, meta.max), + pattern: (control && control.pattern) || meta.pattern || '', + placeholder: (control && control.placeholder) || meta.placeholder || '', + inputMask: meta.inputMask || '', + runtimeInputMask: meta.runtimeInputMask || this.runtimeMaskFromControl(control) || '' + }; + } + + fitText(text, constraints) { + let value = String(text); + const minLength = constraints.minLength && constraints.minLength > 0 ? constraints.minLength : 0; + const maxLength = constraints.maxLength && constraints.maxLength > 0 ? constraints.maxLength : 0; + const minWords = constraints.minWords && constraints.minWords > 0 ? constraints.minWords : 0; + const maxWords = constraints.maxWords && constraints.maxWords > 0 ? constraints.maxWords : 0; + + while (value.length < minLength) { + value += ` Additional program information for run ${this.runId}.`; + } + while (minWords && cleanText(value).split(/\s+/).filter(Boolean).length < minWords) { + value += ` Additional synthetic details for run ${this.runId}.`; + } + if (maxWords) { + const words = cleanText(value).split(/\s+/).filter(Boolean); + if (words.length > maxWords) { + value = words.slice(0, maxWords).join(' '); + } + } + if (maxLength && value.length > maxLength) { + value = value.slice(0, maxLength).trimEnd(); + } + if (!value && maxLength !== 0) { + value = 'Test'; + } + return value; + } + + emailValue(descriptor) { + const context = `${descriptor.key} ${descriptor.label}`.toLowerCase(); + let role = 'contact'; + if (/alternative|alternate/.test(context)) { + role = 'alternative'; + } else if (/president|chair/.test(context)) { + role = 'chair'; + } else if (/contact\s*1|contact1/.test(context)) { + role = 'contact1'; + } else if (/contact\s*2|contact2/.test(context)) { + role = 'contact2'; + } else if (/contact\s*3|contact3/.test(context)) { + role = 'contact3'; + } else if (/contact\s*4|contact4/.test(context)) { + role = 'contact4'; + } else { + const keySlug = String(descriptor.key || 'contact') + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[^a-z0-9]+/gi, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + role = keySlug.slice(-32) || 'contact'; + } + return `${role}.${this.runId.toLowerCase()}@cedarridgecommunity.ca`; + } + + generateText(descriptor, attempt, control) { + const context = `${descriptor.key} ${descriptor.label} ${descriptor.description}`.toLowerCase(); + const constraints = this.constraints(descriptor, control); + let value; + if (/first\s*name/.test(context)) { + value = 'Jordan'; + } else if (/last\s*name|surname/.test(context)) { + value = 'Campbell'; + } else if (/contact\s*name|applicant\s*name|full\s*name|your\s*name/.test(context)) { + value = `Jordan Campbell ${this.runId}`; + } else if (/organization|organisation|society|legal\s*name|business\s*name/.test(context) && /name/.test(context)) { + value = `Cedar Ridge Community Association ${this.runId}`; + } else if (/email/.test(context)) { + value = this.emailValue(descriptor); + } else if (/phone|telephone|mobile|fax/.test(context)) { + value = attempt === 1 ? '2505550142' : attempt === 2 ? '(250) 555-0142' : '6045550188'; + } else if (/postal|zip/.test(context)) { + value = attempt > 1 ? 'V8W2B7' : 'V8W 2B7'; + } else if (/(?:address.*(?:unit|suite|apartment)|(?:unit|suite|apartment).*address)/.test(context)) { + value = '200'; + } else if (/address.*(?:line\s*2|line2|address\s*2)|(?:line\s*2|line2).*address/.test(context)) { + value = 'Building A'; + } else if (/address.*(?:line\s*1|line1|address\s*1)|(?:line\s*1|line1).*address|street\s*address/.test(context)) { + value = '123 Douglas Street'; + } else if (/city|municipality/.test(context) && !/describe|list|serve/.test(context)) { + value = 'Victoria'; + } else if (/province|state/.test(context)) { + value = 'British Columbia'; + } else if (/country/.test(context)) { + value = 'Canada'; + } else if (/mailing\s*address|physical\s*address|business\s*address|organization\s*address|organisation\s*address/.test(context)) { + value = '123 Douglas Street'; + } else if (/website|web\s*site|url/.test(context)) { + value = 'https://www2.gov.bc.ca'; + } else if (/business\s*number|cra/.test(context)) { + value = '123456789RC0001'; + } else if (/society\s*number|incorporation\s*number|registration\s*number/.test(context)) { + value = 'S12345'; + } else if (/submission\s*(?:number|#)/.test(context)) { + value = this.runId.padEnd(8, 'A').slice(0, 8); + } else if (/title|position|role/.test(context)) { + value = 'Program Manager'; + } else if (/project\s*name|program\s*name|initiative\s*name/.test(context)) { + value = `Community Access Program ${this.runId}`; + } else if (/description|explain|provide details|summary|purpose|activities|outcome|need|rationale|comments|notes/.test(context)) { + value = `Automated CHEFS run ${this.runId}. The organization delivers recurring community services, coordinates trained staff and volunteers, and tracks participation, service quality, and financial results. The requested information is synthetic and is intended solely to exercise this form field.`; + } else { + value = `Automated entry ${this.runId}`; + } + return this.fitText(value, constraints); + } + + generateNumber(descriptor, attempt, control) { + const context = `${descriptor.type} ${descriptor.key} ${descriptor.label} ${descriptor.description}`.toLowerCase(); + const constraints = this.constraints(descriptor, control); + const isIdentifier = /(?:applicant|unity|business|society|registration|incorporation|submission)\s*(?:id|number|#)|l&g|lng/.test(context); + const isCountQuestion = /how\s+many|number\s+of|count\b|total\s+(?:programs|projects|contacts|items|rows|people)/.test(context) && + !isIdentifier && + !/phone|telephone|mobile|fax|postal/.test(context); + let value; + if (/how\s+many\s+programs|number\s+of\s+programs|program\s+count/.test(context)) { + value = 1; + } else if (isCountQuestion) { + value = 1; + } else if (/year/.test(context) && !/amount|budget|currency/.test(context)) { + value = 2024; + } else if (/month/.test(context)) { + value = 10; + } else if (/day/.test(context)) { + value = 31; + } else if (/percentage|percent|%/.test(context)) { + value = 25; + } else if (/employee|staff|volunteer|participant|member|people|attendee/.test(context)) { + value = 25 + attempt; + } else if (/currency|amount|budget|revenue|expense|cost|dollar|funds?\s+requested|funding\s+amount|requested\s+funding/.test(context)) { + value = 12500 + (attempt - 1) * 500; + } else if (isIdentifier) { + value = 900001 + attempt; + } else { + value = 10 + attempt; + } + if (constraints.min !== null && Number.isFinite(constraints.min) && value < constraints.min) { + value = constraints.min; + } + if (constraints.max !== null && Number.isFinite(constraints.max) && value > constraints.max) { + value = constraints.min !== null && Number.isFinite(constraints.min) + ? constraints.min + Math.max(1, (constraints.max - constraints.min) / 2) + : constraints.max; + } + return value; + } + + generateDate(descriptor, attempt, control) { + const context = `${descriptor.key} ${descriptor.label} ${descriptor.description}`.toLowerCase(); + const date = new Date(); + if (/birth/.test(context)) { + date.setFullYear(1990, 5, 15); + } else if (/start|future|begin/.test(context)) { + date.setDate(date.getDate() + 30 + attempt); + } else if (/end|completion|finish/.test(context)) { + date.setDate(date.getDate() + 120 + attempt); + } else { + date.setDate(date.getDate() - 30 - attempt); + } + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const type = control ? control.type : ''; + const placeholder = cleanText((control && control.placeholder) || '').toLowerCase(); + if (type === 'datetime-local') { + return `${yyyy}-${mm}-${dd}T10:30`; + } + if (type === 'time') { + return '10:30'; + } + if (type === 'date') { + return `${yyyy}-${mm}-${dd}`; + } + if (/mm\/dd\/yyyy/.test(placeholder)) { + return `${mm}/${dd}/${yyyy}`; + } + if (/dd\/mm\/yyyy/.test(placeholder)) { + return `${dd}/${mm}/${yyyy}`; + } + return `${yyyy}-${mm}-${dd}`; + } + + generateValue(descriptor, attempt, control) { + const inputType = control ? String(control.type || '').toLowerCase() : ''; + const context = `${descriptor.type} ${descriptor.key} ${descriptor.label}`.toLowerCase(); + if (/phone|telephone|mobile|fax/.test(context)) { + return this.generateText(descriptor, attempt, control); + } + if (['number', 'range'].includes(inputType) || /number|currency|decimal|percent/.test(descriptor.type)) { + return this.generateNumber(descriptor, attempt, control); + } + if (['date', 'datetime-local', 'time', 'month'].includes(inputType) || /date|time/.test(descriptor.type)) { + return this.generateDate(descriptor, attempt, control); + } + return this.generateText(descriptor, attempt, control); + } + + async fillTextarea(descriptor, attempt) { + const textarea = descriptor.wrapper.querySelector('textarea:not(:disabled):not([readonly])'); + if (!textarea) { + return { success: false, message: 'No writable textarea was found.' }; + } + const value = this.generateText(descriptor, attempt, textarea); + textarea.focus(); + await this.nativeSetValue(textarea, value, { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }); + await delay(140); + if (cleanText(textarea.value) !== '') { + return { success: true, valueInfo: safeValueInfo(value, true) }; + } + return this.fillViaBridge(descriptor, value); + } + + async fillDateTime(descriptor, attempt) { + const input = descriptor.wrapper.querySelector('input:not(:disabled):not([readonly]):not([type="hidden"])'); + const value = this.generateDate(descriptor, attempt, input); + if (input) { + input.focus(); + await this.nativeSetValue(input, value, { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }); + await delay(180); + if (cleanText(input.value) !== '') { + return { success: true, valueInfo: safeValueInfo(value, true) }; + } + } + const bridgeValue = /T/.test(value) ? new Date(value).toISOString() : value; + return this.fillViaBridge(descriptor, bridgeValue); + } + + phoneDigitCount(input) { + if (!input) { + return 0; + } + try { + if (input.inputmask && typeof input.inputmask.unmaskedvalue === 'function') { + return String(input.inputmask.unmaskedvalue() || '').replace(/\D/g, '').length; + } + } catch (error) { + // Fall back to the rendered value. + } + return String(input.value || '').replace(/\D/g, '').length; + } + + async fillPhone(descriptor, attempt) { + const input = descriptor.wrapper.querySelector('input:not(:disabled):not([readonly]):not([type="hidden"])'); + const digits = attempt >= 3 ? '6045550188' : '2505550142'; + const formatted = attempt === 2 ? '(250) 555-0142' : digits; + + if (input) { + input.focus(); + await this.nativeSetValue(input, '', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }); + await delay(60); + await this.nativeSetValue(input, formatted, { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }); + await delay(260); + const digitCount = this.phoneDigitCount(input); + if (digitCount >= 10) { + return { + success: true, + valueInfo: Object.assign(safeValueInfo(formatted, true), { digitCount, semanticType: 'phone' }) + }; + } + } + + try { + const bridgeValue = attempt === 1 ? digits : '(250) 555-0142'; + const bridgeResult = await this.bridgeCommand('SET_VALUE', { key: descriptor.key, value: bridgeValue }, CONFIG.bridgeTimeoutMs); + await delay(260); + const digitCount = this.phoneDigitCount(input); + const bridgeHasValue = Boolean(bridgeResult && bridgeResult.hasValue); + if (digitCount >= 10 || (bridgeHasValue && !input)) { + return { + success: true, + valueInfo: Object.assign(safeValueInfo(bridgeValue, true), { digitCount, semanticType: 'phone' }) + }; + } + } catch (error) { + return { success: false, message: `Phone input rejected the generated number: ${error.message}` }; + } + + return { + success: false, + message: `Phone input remained incomplete after entry. Rendered digit count: ${this.phoneDigitCount(input)}.` + }; + } + + async fillInput(descriptor, attempt) { + const inputs = Array.from(descriptor.wrapper.querySelectorAll('input:not(:disabled):not([readonly]):not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"])')); + if (!inputs.length) { + return this.fillViaBridge(descriptor, this.generateValue(descriptor, attempt)); + } + let success = false; + let lastValue = ''; + for (const input of inputs) { + const value = this.generateValue(descriptor, attempt, input); + lastValue = value; + input.focus(); + await this.nativeSetValue(input, String(value), { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type + }); + await delay(100); + success = success || cleanText(input.value) !== ''; + } + if (success) { + return { success: true, valueInfo: safeValueInfo(lastValue, true) }; + } + return this.fillViaBridge(descriptor, lastValue); + } + + async fillViaBridge(descriptor, value) { + if (!descriptor.key || descriptor.key.startsWith('dom-')) { + return { success: false, message: 'The component key is unavailable for Form.io setValue.' }; + } + const result = await this.bridgeCommand('SET_VALUE', { key: descriptor.key, value }, CONFIG.bridgeTimeoutMs); + return { + success: Boolean(result && (result.hasValue || result.changed)), + valueInfo: safeValueInfo(value, true) + }; + } + + attachmentChoice(descriptor) { + const context = `${descriptor.key} ${descriptor.label} ${descriptor.description} ${(descriptor.meta && descriptor.meta.filePattern) || ''}`.toLowerCase(); + if (/xlsx|spreadsheet|excel|budget/.test(context)) { + return { filename: 'chefs-attachment.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }; + } + if (/docx|word/.test(context)) { + return { filename: 'chefs-attachment.docx', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }; + } + if (/csv/.test(context)) { + return { filename: 'chefs-attachment.csv', mimeType: 'text/csv' }; + } + if (/json/.test(context)) { + return { filename: 'chefs-attachment.json', mimeType: 'application/json' }; + } + if (/png|image|photo/.test(context)) { + return { filename: 'chefs-attachment.png', mimeType: 'image/png' }; + } + if (/jpe?g/.test(context)) { + return { filename: 'chefs-attachment.jpg', mimeType: 'image/jpeg' }; + } + if (/txt|text/.test(context)) { + return { filename: 'chefs-attachment.txt', mimeType: 'text/plain' }; + } + return { filename: 'chefs-attachment.pdf', mimeType: 'application/pdf' }; + } + + bytesToBase64(bytes) { + let binary = ''; + const chunkSize = 0x8000; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return btoa(binary); + } + + async sha256Hex(bytes) { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)).map((value) => value.toString(16).padStart(2, '0')).join(''); + } + + createDragEvent(type, dataTransfer) { + try { + return new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer }); + } catch (error) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + return event; + } + } + + async uploadFileByDomDrop(descriptor, file) { + let wrapper = this.liveWrapper(descriptor); + const dropTarget = wrapper && (wrapper.querySelector('[ref="fileDrop"], .fileSelector') || wrapper); + if (!dropTarget) { + throw new Error('The file drop target was not found.'); + } + + const baselineCount = this.uploadedFileRows(wrapper).length; + const pending = this.fileUploadsInFlight.get(descriptor.id); + if (!pending) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + this.fileUploadsInFlight.set(descriptor.id, { + filename: file.name, + startedAt: Date.now(), + baselineCount, + wrapperId: descriptor.wrapperId || (wrapper && wrapper.id) || '' + }); + dropTarget.dispatchEvent(this.createDragEvent('dragenter', dataTransfer)); + dropTarget.dispatchEvent(this.createDragEvent('dragover', dataTransfer)); + dropTarget.dispatchEvent(this.createDragEvent('drop', dataTransfer)); + } else { + await this.log('UPLOAD_PENDING_RECHECK', { + componentId: descriptor.id, + key: descriptor.key, + filename: pending.filename, + pendingForMs: Date.now() - pending.startedAt + }); + } + + const started = Date.now(); + let wrapperReplaced = false; + let lastPendingLogAt = 0; + const expectedBaseline = pending ? pending.baselineCount : baselineCount; + + while (Date.now() - started < CONFIG.uploadTimeoutMs) { + const live = this.liveWrapper(descriptor); + if (live && wrapper && live !== wrapper) { + wrapperReplaced = true; + wrapper = live; + await this.log('UPLOAD_WRAPPER_REPLACED', { + componentId: descriptor.id, + key: descriptor.key, + filename: file.name, + wrapperId: descriptor.wrapperId || (wrapper && wrapper.id) || '' + }); + } else if (live) { + wrapper = live; + } + + const allRows = this.uploadedFileRows(wrapper); + const namedRows = this.uploadedFileRows(wrapper, file.name); + if ( + namedRows.length || + allRows.length > expectedBaseline || + (wrapper && cleanText(wrapper.textContent).includes(file.name)) + ) { + this.fileUploadsInFlight.delete(descriptor.id); + return { + hasValue: true, + valueCount: Math.max(1, allRows.length, namedRows.length), + method: wrapperReplaced ? 'dom-drop-rerendered-wrapper' : 'dom-drop' + }; + } + + const now = Date.now(); + if (now - lastPendingLogAt >= 10000) { + lastPendingLogAt = now; + const loader = wrapper && wrapper.querySelector('[ref="fileProcessingLoader"], .loader-wrapper, .progress, [role="progressbar"]'); + await this.log('UPLOAD_STILL_PENDING', { + componentId: descriptor.id, + key: descriptor.key, + filename: file.name, + pendingForMs: now - (this.fileUploadsInFlight.get(descriptor.id)?.startedAt || started), + loaderVisible: Boolean(loader && isVisible(loader)), + renderedFileRows: allRows.length + }); + } + + const errorElement = wrapper && wrapper.querySelector('.formio-errors, .invalid-feedback'); + const message = cleanText(errorElement ? errorElement.textContent : ''); + if (message) { + this.fileUploadsInFlight.delete(descriptor.id); + throw new Error(message); + } + await delay(250); + } + + // Keep the in-flight marker so a later pass monitors the existing upload + // instead of dropping the same file a second time. + throw new Error('The DOM drop is still pending and no uploaded file row has appeared yet.'); + } + + async fillFile(descriptor) { + let liveWrapper = this.liveWrapper(descriptor); + if (liveWrapper && this.uploadedFileRows(liveWrapper).length > 0) { + this.fileHandled.add(descriptor.id); + this.fileUploadsInFlight.delete(descriptor.id); + return { success: true, valueInfo: safeValueInfo('existing-file', false) }; + } + if (this.fileHandled.has(descriptor.id)) { + return { success: false, message: 'The file component was previously marked complete but is now empty.' }; + } + + const chosen = this.attachmentChoice(descriptor); + const response = await fetch(chrome.runtime.getURL(`attachments/${chosen.filename}`)); + if (!response.ok) { + throw new Error(`Packaged attachment could not be read: ${chosen.filename}`); + } + const buffer = await response.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const hash = await this.sha256Hex(bytes); + const file = new File([bytes], chosen.filename, { + type: chosen.mimeType, + lastModified: Date.now() + }); + this.progress.attachmentsPending += 1; + await this.log('UPLOAD_STARTED', { + componentId: descriptor.id, + key: descriptor.key, + filename: chosen.filename, + mimeType: chosen.mimeType, + sizeBytes: bytes.length, + sha256: hash + }); + await this.updateRun({ progress: Object.assign({}, this.progress), currentAction: `Uploading ${descriptor.key}` }); + + let result = null; + let bridgeError = null; + try { + result = await this.bridgeCommand('UPLOAD_FILE', { + key: descriptor.key, + wrapperId: descriptor.wrapperId || (liveWrapper && liveWrapper.id) || '', + filename: chosen.filename, + mimeType: chosen.mimeType, + base64: this.bytesToBase64(bytes) + }, CONFIG.uploadTimeoutMs); + } catch (error) { + bridgeError = error; + await this.log('UPLOAD_API_FALLBACK', { + componentId: descriptor.id, + key: descriptor.key, + message: error.message, + stack: error.stack || '' + }); + } + + try { + if (!result || (!result.hasValue && !result.valueCount)) { + result = await this.uploadFileByDomDrop(descriptor, file); + } + const started = Date.now(); + while (Date.now() - started < CONFIG.uploadTimeoutMs) { + liveWrapper = this.liveWrapper(descriptor); + if (liveWrapper && !this.inspectEmpty(liveWrapper, descriptor.type)) { + break; + } + await delay(250); + } + liveWrapper = this.liveWrapper(descriptor); + if (!liveWrapper || this.inspectEmpty(liveWrapper, descriptor.type)) { + throw new Error(bridgeError + ? `File upload API and DOM drop did not produce a stored file. API error: ${bridgeError.message}` + : 'The Form.io file component did not report or render a stored file value.'); + } + + this.fileHandled.add(descriptor.id); + this.progress.attachmentsPending = Math.max(0, this.progress.attachmentsPending - 1); + this.progress.attachmentsCompleted += 1; + await this.runtimeMessage({ + type: 'ADD_ATTACHMENT_RECORD', + runId: this.runId, + attachment: { + time: new Date().toISOString(), + key: descriptor.key, + filename: chosen.filename, + mimeType: chosen.mimeType, + sizeBytes: bytes.length, + sha256: hash, + outcome: 'completed', + valueCount: result.valueCount || 1, + method: result.method || result.uploadMethod || 'formio-api' + } + }); + await this.log('UPLOAD_COMPLETED', { + componentId: descriptor.id, + key: descriptor.key, + filename: chosen.filename, + valueCount: result.valueCount || 1, + method: result.method || result.uploadMethod || 'formio-api' + }); + return { success: true, valueInfo: safeValueInfo(chosen.filename, true) }; + } catch (error) { + this.progress.attachmentsPending = Math.max(0, this.progress.attachmentsPending - 1); + const stillPending = /still pending/i.test(error.message || ''); + if (stillPending) { + await this.log('UPLOAD_PENDING_TIMEOUT', { + componentId: descriptor.id, + key: descriptor.key, + filename: chosen.filename, + message: error.message + }); + return { success: false, message: error.message }; + } + this.fileUploadsInFlight.delete(descriptor.id); + await this.runtimeMessage({ + type: 'ADD_ATTACHMENT_RECORD', + runId: this.runId, + attachment: { + time: new Date().toISOString(), + key: descriptor.key, + filename: chosen.filename, + mimeType: chosen.mimeType, + sizeBytes: bytes.length, + sha256: hash, + outcome: 'failed', + message: error.message + } + }); + await this.log('UPLOAD_FAILED', { + componentId: descriptor.id, + key: descriptor.key, + filename: chosen.filename, + message: error.message, + stack: error.stack || '' + }); + throw error; + } + } + + gridRows(wrapper, type) { + if (!wrapper) { + return []; + } + const selectors = type.includes('editgrid') + ? '.editgrid-row, [ref^="editgrid-"][ref$="-row"]' + : '.datagrid-table > tbody > tr, [ref^="datagrid-"][ref$="-row"]'; + return Array.from(new Set(Array.from(wrapper.querySelectorAll(selectors)))) + .filter((row) => row.isConnected && !row.closest('[hidden], .formio-hidden')); + } + + gridAddButton(wrapper) { + if (!wrapper) { + return null; + } + const direct = wrapper.querySelector('button.formio-button-add-row, [ref$="-addRow"]'); + if (direct && isVisible(direct) && !direct.disabled) { + return direct; + } + return Array.from(wrapper.querySelectorAll('button, a.btn')) + .find((element) => isVisible(element) && !element.disabled && GRID_ADD_ACTION.test(cleanText(element.textContent))) || null; + } + + gridTargetRows(descriptor) { + const configured = Math.max(2, Math.min(5, Number(this.settings.rowsPerGrid) || 2)); + const minimum = descriptor.meta && Number.isFinite(Number(descriptor.meta.minLength)) + ? Number(descriptor.meta.minLength) + : 0; + const maximum = descriptor.meta && Number.isFinite(Number(descriptor.meta.maxLength)) + ? Number(descriptor.meta.maxLength) + : 5; + return Math.max(minimum, Math.min(configured, maximum > 0 ? maximum : configured)); + } + + async handleGrid(descriptor) { + let wrapper = this.liveWrapper(descriptor) || descriptor.wrapper; + if (!wrapper) { + return 0; + } + const targetRows = this.gridTargetRows(descriptor); + let rowCount = this.gridRows(wrapper, descriptor.type).length; + if (this.gridHandled.has(descriptor.id)) { + return 0; + } + await this.log('GRID_INSPECTED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + existingRows: rowCount, + targetRows + }); + if (rowCount >= targetRows) { + this.gridHandled.add(descriptor.id); + return 0; + } + + let added = 0; + while (rowCount < targetRows) { + wrapper = this.liveWrapper(descriptor) || wrapper; + const button = this.gridAddButton(wrapper); + if (!button) { + if (rowCount > 0) { + await this.log('GRID_TARGET_UNAVAILABLE', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + reason: 'No enabled add-row control was found.', + existingRows: rowCount, + targetRows + }); + this.gridHandled.add(descriptor.id); + break; + } + const state = this.componentStates.get(descriptor.id); + if (state) { + state.status = 'unsupported'; + state.lastError = 'No add-row control was found.'; + } + await this.log('COMPONENT_UNSUPPORTED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + reason: 'No add-row control was found.', + existingRows: rowCount, + targetRows + }); + this.gridHandled.add(descriptor.id); + break; + } + + const before = rowCount; + await this.log('GRID_ROW_ADD_ATTEMPT', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + existingRows: before, + targetRows + }); + button.click(); + + const started = Date.now(); + let after = before; + while (Date.now() - started < 5000) { + await delay(100); + wrapper = this.liveWrapper(descriptor) || wrapper; + after = this.gridRows(wrapper, descriptor.type).length; + if (after > before) { + break; + } + } + if (after <= before) { + await this.log('GRID_ROW_ADD_FAILED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + existingRows: before, + targetRows + }); + break; + } + + rowCount = after; + added += after - before; + this.progress.rowsAdded += after - before; + await this.log('GRID_ROW_ADDED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + rowsAdded: after - before, + rowCount, + targetRows + }); + } + + if (rowCount >= targetRows) { + this.gridHandled.add(descriptor.id); + await this.log('GRID_TARGET_REACHED', { + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + rowCount, + targetRows + }); + } + if (added > 0) { + await this.markProgress(`Added ${added} row(s) to ${descriptor.key}`); + } + return added; + } + + async handleLookupActions() { + const buttons = Array.from(document.querySelectorAll('.formio-form button[type="button"], .formio-form a.btn')) + .filter((button) => isVisible(button)); + let actions = 0; + for (const button of buttons) { + const text = cleanText(button.textContent || button.title); + if (!text || ACTION_EXCLUDE.test(text) || GRID_ADD_ACTION.test(text) || !LOOKUP_ACTION.test(text)) { + continue; + } + const id = `${button.name || ''}::${text}::${button.id || ''}`; + if (this.actionHandled.has(id)) { + continue; + } + this.actionHandled.add(id); + this.currentAction = `Running action: ${text}`; + await this.log('LOOKUP_STARTED', { actionId: id, label: text }); + try { + button.click(); + await delay(1800); + await this.log('LOOKUP_COMPLETED', { actionId: id, label: text }); + await this.markProgress(`Ran action ${text}`); + actions += 1; + } catch (error) { + await this.log('LOOKUP_FAILED', { actionId: id, label: text, message: error.message }); + } + } + return actions; + } + + getTabEntries() { + const rawTabs = Array.from(document.querySelectorAll( + '.formio-form .formio-component-tabs .nav-tabs [role="tab"], ' + + '.formio-form .formio-component-tabs .nav-tabs .nav-link, ' + + '.formio-form [role="tablist"] [role="tab"], ' + + '.formio-form [role="tablist"] .nav-link' + )); + const seenControls = new Set(); + const entries = []; + for (const raw of rawTabs) { + const host = raw.matches('[role="tab"]') ? raw : (raw.closest('[role="tab"]') || raw); + const control = raw.matches('a, button') + ? raw + : (raw.querySelector('a.nav-link, button.nav-link, a[href], button') || raw); + if (!control || seenControls.has(control) || !isVisible(host) || control.classList.contains('disabled') || control.disabled) { + continue; + } + seenControls.add(control); + const label = cleanText(control.textContent || host.textContent); + const target = control.getAttribute('href') || control.getAttribute('data-bs-target') || control.getAttribute('data-target') || control.getAttribute('aria-controls') || ''; + const id = target || control.id || host.id || label; + if (!id) { + continue; + } + entries.push({ id, label, host, control, target }); + } + return entries; + } + + tabIsActive(entry) { + return Boolean( + entry && ( + entry.control.getAttribute('aria-selected') === 'true' || + entry.host.getAttribute('aria-selected') === 'true' || + entry.control.classList.contains('active') || + entry.host.classList.contains('active') + ) + ); + } + + async waitForTabActivation(tabId, timeoutMs) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const current = this.getTabEntries().find((entry) => entry.id === tabId); + if (current && this.tabIsActive(current)) { + return true; + } + await delay(100); + } + return false; + } + + async activateNextUnvisitedTab() { + const tabs = this.getTabEntries(); + for (const tab of tabs) { + if (this.tabIsActive(tab) && !this.visitedTabs.has(tab.id)) { + this.visitedTabs.add(tab.id); + await this.log('TAB_RECOGNIZED_ACTIVE', { tabId: tab.id, label: tab.label }); + } + } + + for (const tab of tabs) { + if (this.visitedTabs.has(tab.id) || this.tabIsActive(tab)) { + continue; + } + const failures = this.tabActivationFailures.get(tab.id) || 0; + await this.log('TAB_ACTIVATION_ATTEMPT', { + tabId: tab.id, + label: tab.label, + attempt: failures + 1, + controlTag: tab.control.tagName.toLowerCase(), + controlHref: tab.control.getAttribute('href') || '' + }); + tab.control.click(); + const activated = await this.waitForTabActivation(tab.id, 3000); + if (activated) { + this.visitedTabs.add(tab.id); + this.tabActivationFailures.delete(tab.id); + await this.log('TAB_ACTIVATED', { tabId: tab.id, label: tab.label, verified: true }); + await this.markProgress(`Activated tab ${tab.label}`); + return true; + } + + const nextFailureCount = failures + 1; + this.tabActivationFailures.set(tab.id, nextFailureCount); + await this.log('TAB_ACTIVATION_FAILED', { + tabId: tab.id, + label: tab.label, + attempt: nextFailureCount + }); + if (nextFailureCount >= 2) { + this.visitedTabs.add(tab.id); + await this.log('TAB_SKIPPED_AFTER_FAILURE', { + tabId: tab.id, + label: tab.label, + attempts: nextFailureCount + }); + } + return false; + } + return false; + } + + visibleFieldSignature() { + return Array.from(document.querySelectorAll('.formio-component')) + .filter((element) => isVisible(element)) + .map((element) => element.id || Array.from(element.classList).find((value) => value.startsWith('formio-component-s')) || '') + .filter(Boolean) + .slice(0, 100) + .join('|'); + } + + async advanceWizard() { + const tabs = this.getTabEntries(); + const unvisitedTabs = tabs.filter((entry) => !this.visitedTabs.has(entry.id)); + if (unvisitedTabs.length) { + return false; + } + const signature = this.visibleFieldSignature(); + if (this.visitedWizardSignatures.has(signature)) { + return false; + } + this.visitedWizardSignatures.add(signature); + const buttons = Array.from(document.querySelectorAll('.formio-form button, .formio-form a.btn')) + .filter((button) => isVisible(button) && !button.disabled); + const next = buttons.find((button) => /^(next|continue|save\s+and\s+continue)$/i.test(cleanText(button.textContent)) || button.getAttribute('ref') === 'next'); + if (!next) { + return false; + } + next.click(); + await this.log('WIZARD_NEXT', { label: cleanText(next.textContent) }); + await this.markProgress(`Advanced wizard using ${cleanText(next.textContent)}`); + return true; + } + + tabEntryForWrapper(wrapper) { + if (!wrapper) { + return null; + } + const tabsRoot = wrapper.closest('.formio-component-tabs'); + const pane = wrapper.closest('[ref="tab-tabs"], .tab-pane[role="tabpanel"]'); + if (!tabsRoot || !pane) { + return null; + } + const panes = Array.from(tabsRoot.querySelectorAll( + ':scope > .card > [ref="tab-tabs"], :scope > [ref="tab-tabs"], ' + + ':scope > .card > .tab-pane[role="tabpanel"], :scope > .tab-pane[role="tabpanel"]' + )); + const paneIndex = panes.indexOf(pane); + if (paneIndex < 0) { + return null; + } + const entries = this.getTabEntries().filter((entry) => tabsRoot.contains(entry.host)); + return entries[paneIndex] || null; + } + + async prepareValidationRepair(errors) { + const scan = await this.scanComponents(); + const errorKeys = new Set((errors || []).map((error) => error.key).filter(Boolean)); + const invalidDescriptors = scan.descriptors.filter((descriptor) => descriptor.invalid || (descriptor.fillable && errorKeys.has(descriptor.key))); + const repairTabs = []; + const seenTabs = new Set(); + const resetFields = []; + + for (const descriptor of invalidDescriptors) { + const state = this.componentStates.get(descriptor.id); + if (state) { + state.attempts = 0; + state.status = 'retry'; + state.lastError = (errors || []).find((error) => error.key === descriptor.key)?.message || 'Rejected during submission validation.'; + } + resetFields.push({ + componentId: descriptor.id, + key: descriptor.key, + componentType: descriptor.type, + visible: descriptor.visible + }); + const tab = this.tabEntryForWrapper(descriptor.wrapper); + if (tab && !seenTabs.has(tab.id)) { + seenTabs.add(tab.id); + repairTabs.push(tab); + this.visitedTabs.delete(tab.id); + } + } + + await this.log('VALIDATION_REPAIR_PREPARED', { + errorCount: (errors || []).length, + resetFieldCount: resetFields.length, + resetFields, + repairTabs: repairTabs.map((tab) => ({ id: tab.id, label: tab.label })) + }); + + const firstTab = repairTabs[0]; + if (firstTab && !this.tabIsActive(firstTab)) { + await this.log('VALIDATION_REPAIR_TAB_ACTIVATION_ATTEMPT', { + tabId: firstTab.id, + label: firstTab.label + }); + firstTab.control.click(); + const activated = await this.waitForTabActivation(firstTab.id, 3000); + await this.log(activated ? 'VALIDATION_REPAIR_TAB_ACTIVATED' : 'VALIDATION_REPAIR_TAB_ACTIVATION_FAILED', { + tabId: firstTab.id, + label: firstTab.label + }); + } + this.lastProgressAt = Date.now(); + return { invalidDescriptors, repairTabs }; + } + + collectValidationErrors() { + if (this.confirmationState().confirmed) { + return []; + } + const errors = []; + const now = new Date().toISOString(); + const componentWrappers = Array.from(document.querySelectorAll('.formio-component')); + + for (const wrapper of componentWrappers) { + const ownInvalidControl = Array.from(wrapper.querySelectorAll('[aria-invalid="true"], .is-invalid')) + .some((element) => element.closest('.formio-component') === wrapper); + const ownMessageElements = Array.from(wrapper.querySelectorAll( + '.formio-errors .error, .formio-errors, .invalid-feedback .error, .invalid-feedback' + )).filter((element) => element.closest('.formio-component') === wrapper); + const messages = ownMessageElements.map((element) => cleanText(element.textContent)).filter(Boolean); + if (!ownInvalidControl && !messages.length && !wrapper.classList.contains('has-error')) { + continue; + } + const message = Array.from(new Set(messages)).join(' '); + if (!message) { + continue; + } + const key = getInputKey(wrapper) || ''; + const type = this.inferType(wrapper, key, null); + errors.push({ + time: now, + pass: this.currentPass, + key, + type, + message: message.slice(0, 2000), + componentId: wrapper.id || '' + }); + } + + const pageSelectors = [ + '.alert-danger', + '.alert-error', + '.v-alert--type-error', + '[role="alert"].alert-danger', + '[role="alert"].alert-error', + '[role="alert"][data-test*="error" i]' + ]; + const pageElements = Array.from(document.querySelectorAll(pageSelectors.join(', '))); + for (const element of pageElements) { + if (!isVisible(element) || element.closest('.formio-component')) { + continue; + } + const message = cleanText(element.textContent); + if (!message) { + continue; + } + errors.push({ + time: now, + pass: this.currentPass, + key: '', + type: '', + message: message.slice(0, 2000), + componentId: '' + }); + } + + const unique = []; + const seen = new Set(); + for (const error of errors) { + const signature = `${error.key}|${error.message}`; + if (!seen.has(signature)) { + seen.add(signature); + unique.push(error); + } + } + return unique; + } + + isGridAddControl(button) { + if (!button) { + return false; + } + const label = cleanText(button.textContent); + const ref = button.getAttribute('ref') || ''; + return button.classList.contains('formio-button-add-row') || + /-addRow$/i.test(ref) || + GRID_ADD_ACTION.test(label); + } + + isSubmitButtonCandidate(button) { + if (!button || button.disabled) { + return false; + } + const wrapper = button.closest('.formio-component'); + const label = cleanText(button.textContent); + const name = button.getAttribute('name') || ''; + const wrapperClass = wrapper ? wrapper.className : ''; + if (this.isGridAddControl(button) || ACTION_EXCLUDE.test(label) || LOOKUP_ACTION.test(label)) { + return false; + } + const explicitSubmit = /(?:^|\s)formio-component-submit(?:\s|$)/.test(wrapperClass) || + /^data\[submit\]$/i.test(name) || + String(button.type || '').toLowerCase() === 'submit'; + const submitLikeLabel = /^(submit|submit application|send application|complete application|complete submission|send request|submit request|apply)$/i.test(label); + return explicitSubmit || submitLikeLabel; + } + + submitButtonCandidates() { + return Array.from(document.querySelectorAll('.formio-form button')) + .filter((button) => this.isSubmitButtonCandidate(button)); + } + + submitLandmarkKey(button) { + const wrapper = button ? button.closest('.formio-component') : null; + return [ + wrapper && wrapper.id ? wrapper.id : '', + button && button.getAttribute('name') ? button.getAttribute('name') : '', + cleanText(button ? button.textContent : '') + ].join('|'); + } + + async indexSubmitLandmarks() { + const candidates = this.submitButtonCandidates(); + for (const button of candidates) { + const wrapper = button.closest('.formio-component'); + const tab = this.tabEntryForWrapper(wrapper); + const key = this.submitLandmarkKey(button); + const visible = isVisible(button); + const existing = this.submitLandmarks.get(key); + const landmark = { + key, + wrapperId: wrapper && wrapper.id ? wrapper.id : '', + buttonName: button.getAttribute('name') || '', + buttonLabel: cleanText(button.textContent), + buttonType: String(button.type || '').toLowerCase(), + tabId: tab ? tab.id : '', + tabLabel: tab ? tab.label : '', + firstSeenPass: existing ? existing.firstSeenPass : this.currentPass, + lastSeenPass: this.currentPass, + lastVisiblePass: visible ? this.currentPass : (existing ? existing.lastVisiblePass : 0), + visible + }; + this.submitLandmarks.set(key, landmark); + if (!existing) { + await this.log('SUBMIT_LANDMARK_DISCOVERED', landmark); + } else if (existing.visible !== visible) { + await this.log(visible ? 'SUBMIT_LANDMARK_BECAME_VISIBLE' : 'SUBMIT_LANDMARK_BECAME_HIDDEN', landmark); + } + } + return candidates; + } + + resolveSubmitLandmark(landmark) { + if (!landmark) { + return null; + } + if (landmark.wrapperId) { + const wrapper = document.getElementById(landmark.wrapperId); + if (wrapper) { + const match = Array.from(wrapper.querySelectorAll('button')).find((button) => this.isSubmitButtonCandidate(button)); + if (match) { + return match; + } + } + } + return this.submitButtonCandidates().find((button) => { + const sameName = landmark.buttonName && button.getAttribute('name') === landmark.buttonName; + const sameLabel = landmark.buttonLabel && cleanText(button.textContent) === landmark.buttonLabel; + return sameName || sameLabel; + }) || null; + } + + findSubmitButton() { + return this.submitButtonCandidates() + .find((button) => isVisible(button) && !button.disabled) || null; + } + + async ensureSubmitButtonVisible() { + await this.indexSubmitLandmarks(); + let visible = this.findSubmitButton(); + if (visible) { + return visible; + } + + const landmarks = Array.from(this.submitLandmarks.values()) + .sort((a, b) => (b.lastVisiblePass || 0) - (a.lastVisiblePass || 0) || (b.lastSeenPass || 0) - (a.lastSeenPass || 0)); + for (const landmark of landmarks) { + const button = this.resolveSubmitLandmark(landmark); + const wrapper = button ? button.closest('.formio-component') : (landmark.wrapperId ? document.getElementById(landmark.wrapperId) : null); + const tab = (landmark.tabId ? this.getTabEntries().find((entry) => entry.id === landmark.tabId) : null) || this.tabEntryForWrapper(wrapper); + if (!tab) { + continue; + } + if (!this.tabIsActive(tab)) { + await this.log('SUBMIT_TAB_ACTIVATION_ATTEMPT', { + tabId: tab.id, + label: tab.label, + landmarkKey: landmark.key, + submitLabel: landmark.buttonLabel + }); + tab.control.click(); + const activated = await this.waitForTabActivation(tab.id, 3000); + await this.log(activated ? 'SUBMIT_TAB_ACTIVATED' : 'SUBMIT_TAB_ACTIVATION_FAILED', { + tabId: tab.id, + label: tab.label, + landmarkKey: landmark.key, + submitLabel: landmark.buttonLabel + }); + if (!activated) { + continue; + } + await delay(250); + } + await this.indexSubmitLandmarks(); + visible = this.findSubmitButton(); + if (visible) { + await this.log('SUBMIT_LANDMARK_REACQUIRED', { + tabId: tab.id, + tabLabel: tab.label, + submitLabel: cleanText(visible.textContent), + buttonName: visible.getAttribute('name') || '' + }); + return visible; + } + } + return null; + } + + confirmationState(overrides) { + const options = overrides || {}; + const text = cleanText(Object.prototype.hasOwnProperty.call(options, 'text') + ? options.text + : (document.body ? (document.body.innerText || document.body.textContent) : '')); + const url = new URL(options.url || location.href); + const successPath = /\/form\/success(?:\/|$)/i.test(url.pathname) || /\/submission\/success(?:\/|$)/i.test(url.pathname); + const successPhrase = /\byour form has been submitted successfully\b|\bform submitted successfully\b|\bsubmission (?:was|has been) received\b|\bthank you for your submission\b/i.test(text); + const labeledMatch = text.match(/confirmation\s*(?:id|number|#)?\s*[:#-]?\s*([0-9A-F]{8})\b/i); + let confirmationId = labeledMatch ? labeledMatch[1].toUpperCase() : null; + let confirmationSource = labeledMatch ? 'page-label' : ''; + + if (!confirmationId && (successPath || successPhrase)) { + const queryCandidates = [ + url.searchParams.get('confirmationId'), + url.searchParams.get('confirmation'), + url.searchParams.get('s') + ].filter(Boolean); + for (const candidate of queryCandidates) { + const match = String(candidate).match(/^([0-9a-f]{8})(?:-[0-9a-f-]+)?$/i); + if (match) { + confirmationId = match[1].toUpperCase(); + confirmationSource = 'success-url'; + break; + } + } + } + + if (!confirmationId && (successPath || successPhrase)) { + const standaloneMatch = text.match(/\b([0-9A-F]{8})\b/i); + if (standaloneMatch) { + confirmationId = standaloneMatch[1].toUpperCase(); + confirmationSource = 'success-page-token'; + } + } + + const detectedBy = []; + if (successPath) { + detectedBy.push('success-path'); + } + if (successPhrase) { + detectedBy.push('success-phrase'); + } + if (confirmationId) { + detectedBy.push(confirmationSource || 'confirmation-id'); + } + + return { + confirmed: Boolean(successPath || successPhrase), + confirmationId, + detectedBy, + url: location.href + }; + } + + async finalizeSubmitted(confirmation, attempt, phase) { + if (this.successFinalized) { + return true; + } + this.successFinalized = true; + const success = confirmation || this.confirmationState(); + const confirmationId = success && success.confirmationId ? success.confirmationId : null; + this.progress.remaining = 0; + this.currentAction = 'Submission completed'; + this.lastSuccessfulAction = 'CHEFS submission completed'; + + await this.log('SUCCESS_STATE_DETECTED', { + attempt: attempt || this.progress.submitAttempts || 0, + phase: phase || 'unknown', + confirmationId, + detectedBy: success && success.detectedBy ? success.detectedBy : [], + successUrl: location.href + }); + await this.log('CONFIRMATION_DETECTED', { confirmationId }); + await this.log('SUBMIT_SUCCEEDED', { + attempt: attempt || this.progress.submitAttempts || 0, + confirmationId, + phase: phase || 'unknown' + }); + + await this.updateRun({ + status: 'submitted', + statusLabel: 'Submitted', + currentAction: this.currentAction, + lastSuccessfulAction: this.lastSuccessfulAction, + confirmationId, + message: '', + failure: null, + endedAt: new Date().toISOString(), + progress: Object.assign({}, this.progress) + }); + + this.running = false; + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + } + + try { + const finalScan = await this.scanComponents(); + await this.setSnapshot('final', finalScan.snapshot || []); + } catch (error) { + await this.log('FINAL_SUCCESS_SNAPSHOT_FAILED', { message: error.message || String(error) }); + } + await this.checkpoint('Submission success detected', { + confirmationId, + successUrl: location.href, + detectedBy: success && success.detectedBy ? success.detectedBy : [] + }); + await this.runtimeMessage({ + type: 'RUN_FINALIZED', + runId: this.runId, + finalizedAt: new Date().toISOString() + }); + return true; + } + + async finalizeIfSubmitted(attempt, phase) { + const confirmation = this.confirmationState(); + if (!confirmation.confirmed) { + return false; + } + await this.finalizeSubmitted(confirmation, attempt, phase); + return true; + } + + async clickModalConfirmationIfPresent() { + const dialogs = Array.from(document.querySelectorAll('[role="dialog"], .modal.show, .v-dialog')) + .filter((dialog) => isVisible(dialog)); + for (const dialog of dialogs) { + const button = Array.from(dialog.querySelectorAll('button')) + .find((candidate) => isVisible(candidate) && /confirm|submit|yes,?\s+submit/i.test(cleanText(candidate.textContent)) && !/cancel|close/i.test(cleanText(candidate.textContent))); + if (button) { + button.click(); + await this.log('SUBMIT_CONFIRMATION_CLICKED', { label: cleanText(button.textContent) }); + return true; + } + } + return false; + } + + async waitForSubmitOutcome() { + const started = Date.now(); + let lastErrorSignature = ''; + while (Date.now() - started < CONFIG.submitOutcomeTimeoutMs) { + if (this.stopRequested) { + return { type: 'stopped' }; + } + const confirmation = this.confirmationState(); + if (confirmation.confirmed) { + return { + type: 'confirmed', + confirmationId: confirmation.confirmationId, + detectedBy: confirmation.detectedBy || [] + }; + } + await this.clickModalConfirmationIfPresent(); + const errors = this.collectValidationErrors(); + const signature = errors.map((error) => `${error.key}:${error.message}`).join('|'); + if (errors.length && signature && signature === lastErrorSignature) { + return { type: 'validation', errors }; + } + if (errors.length) { + lastErrorSignature = signature; + } + await delay(500); + } + const errors = this.collectValidationErrors(); + if (errors.length) { + return { type: 'validation', errors }; + } + return { type: 'timeout' }; + } + + async submitWithRecovery() { + if (await this.finalizeIfSubmitted(this.progress.submitAttempts, 'before-submit-recovery')) { + return; + } + + for (let attempt = 1; attempt <= CONFIG.maxSubmitAttempts; attempt += 1) { + if (this.stopRequested) { + await this.finishStopped(); + return; + } + if (await this.finalizeIfSubmitted(attempt, 'submit-attempt-start')) { + return; + } + + this.progress.submitAttempts = attempt; + await this.setStatus('validating', 'Validating', 'Checking form before submission'); + let bridgeValidity = null; + try { + bridgeValidity = await this.bridgeCommand('CHECK_VALIDITY', {}, CONFIG.bridgeTimeoutMs); + } catch (error) { + await this.log('FORMIO_VALIDATION_API_FAILED', { message: error.message }); + } + if (await this.finalizeIfSubmitted(attempt, 'after-formio-validation')) { + return; + } + await this.log('VALIDATION_CHECKED', { + formioValid: bridgeValidity ? bridgeValidity.valid : null, + formioErrors: bridgeValidity && bridgeValidity.errors ? bridgeValidity.errors : [] + }); + + const bridgeErrors = bridgeValidity && Array.isArray(bridgeValidity.errors) + ? bridgeValidity.errors.map((error) => ({ + time: new Date().toISOString(), + pass: this.currentPass, + key: error && error.key ? error.key : '', + type: error && error.type ? error.type : '', + message: cleanText(error && (error.message || error.error || error.text) ? (error.message || error.error || error.text) : ''), + componentId: error && error.componentId ? error.componentId : '' + })).filter((error) => error.message) + : []; + const domErrors = this.collectValidationErrors(); + const preSubmitErrors = []; + const preSubmitErrorSignatures = new Set(); + for (const error of bridgeErrors.concat(domErrors)) { + const signature = `${error.key || ''}|${error.message || ''}`; + if (!preSubmitErrorSignatures.has(signature)) { + preSubmitErrorSignatures.add(signature); + preSubmitErrors.push(error); + } + } + if (preSubmitErrors.length) { + await this.runtimeMessage({ + type: 'ADD_VALIDATION_ERRORS', + runId: this.runId, + errors: preSubmitErrors + }); + for (const error of preSubmitErrors) { + await this.log('VALIDATION_ERROR', Object.assign({ phase: 'pre-submit' }, error)); + } + await this.log('PRE_SUBMIT_VALIDATION_REPAIR_STARTED', { + errorCount: preSubmitErrors.length, + formioValid: bridgeValidity ? bridgeValidity.valid : null + }); + await this.setStatus('filling', 'Repairing', 'Repairing fields rejected before submission'); + await this.prepareValidationRepair(preSubmitErrors); + await this.fillUntilStable(); + if (!this.running) { + return; + } + continue; + } + + const preSubmit = await this.scanComponents(); + if (await this.finalizeIfSubmitted(attempt, 'after-pre-submit-scan')) { + return; + } + await this.setSnapshot('lastKnown', preSubmit.snapshot); + await this.checkpoint('Pre-submit checkpoint', { submitAttempt: attempt }); + + const button = await this.ensureSubmitButtonVisible(); + if (!button) { + if (await this.finalizeIfSubmitted(attempt, 'submit-button-missing')) { + return; + } + await this.failRun('blocked', 'Blocked', new Error('A visible Form.io submit button could not be found.'), { + reason: 'Submit button unavailable' + }); + return; + } + this.currentAction = `Submitting attempt ${attempt}`; + await this.setStatus('submitting', 'Submitting', this.currentAction); + await this.log('SUBMIT_ATTEMPT', { + attempt, + label: cleanText(button.textContent), + buttonName: button.name || '', + buttonType: button.type || '' + }); + button.click(); + const outcome = await this.waitForSubmitOutcome(); + if (outcome.type === 'confirmed') { + await this.finalizeSubmitted({ + confirmed: true, + confirmationId: outcome.confirmationId || null, + detectedBy: outcome.detectedBy || ['submit-outcome'], + url: location.href + }, attempt, 'submit-outcome'); + return; + } + if (outcome.type === 'validation') { + if (await this.finalizeIfSubmitted(attempt, 'before-validation-repair')) { + return; + } + await this.runtimeMessage({ + type: 'ADD_VALIDATION_ERRORS', + runId: this.runId, + errors: outcome.errors + }); + for (const error of outcome.errors) { + await this.log('VALIDATION_ERROR', error); + } + this.lastProgressAt = Date.now(); + await this.setStatus('filling', 'Repairing', 'Repairing fields rejected by validation'); + await this.prepareValidationRepair(outcome.errors); + await this.fillUntilStable(); + if (!this.running) { + return; + } + continue; + } + if (outcome.type === 'stopped') { + await this.finishStopped(); + return; + } + if (await this.finalizeIfSubmitted(attempt, 'submit-outcome-timeout')) { + return; + } + await this.failRun('stalled', 'Stalled', new Error('Submission produced neither confirmation nor actionable validation feedback.'), { + reason: 'Submission outcome timeout', + submitAttempt: attempt + }); + return; + } + if (await this.finalizeIfSubmitted(this.progress.submitAttempts, 'maximum-submit-attempts')) { + return; + } + this.currentAction = 'Submission blocked by unresolved validation errors'; + await this.failRun('blocked', 'Blocked', new Error('The form did not submit after the maximum number of validation-repair attempts.'), { + reason: 'Maximum submission attempts reached' + }); + } + + async failRun(status, statusLabel, error, details) { + if (this.runId && await this.finalizeIfSubmitted(this.progress.submitAttempts, 'failure-guard')) { + return; + } + if (!this.runId) { + this.running = false; + return; + } + const scan = await this.scanComponents().catch(() => ({ snapshot: [], fillable: [] })); + await this.setSnapshot('lastKnown', scan.snapshot || []); + await this.setSnapshot('final', scan.snapshot || []); + const validationErrors = this.collectValidationErrors(); + if (validationErrors.length) { + await this.runtimeMessage({ type: 'ADD_VALIDATION_ERRORS', runId: this.runId, errors: validationErrors }); + } + const failure = Object.assign({ + time: new Date().toISOString(), + message: error && error.message ? error.message : String(error), + stack: error && error.stack ? error.stack : '', + currentAction: this.currentAction, + lastSuccessfulAction: this.lastSuccessfulAction, + pass: this.currentPass, + scrollX: window.scrollX, + scrollY: window.scrollY, + url: location.href, + visibleValidationErrors: validationErrors, + unresolvedFields: (scan.fillable || []) + .filter((item) => item.empty || item.invalid) + .map((item) => ({ key: item.key, label: item.label, type: item.type, empty: item.empty, invalid: item.invalid })) + }, details || {}); + await this.log(status === 'stalled' ? 'STALL_DETECTED' : 'RUN_FAILED', failure); + await this.runtimeMessage({ + type: 'SET_FAILURE', + runId: this.runId, + status, + statusLabel, + failure + }); + await this.updateRun({ + status, + statusLabel, + currentAction: this.currentAction, + endedAt: new Date().toISOString(), + progress: Object.assign({}, this.progress) + }); + await this.checkpoint('Run failure finalized', { + status, + statusLabel, + reason: failure.reason || failure.message + }); + await this.runtimeMessage({ + type: 'RUN_FINALIZED', + runId: this.runId, + finalizedAt: new Date().toISOString() + }); + this.running = false; + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + } + } + + async finishStopped() { + const scan = await this.scanComponents().catch(() => ({ snapshot: [] })); + await this.setSnapshot('lastKnown', scan.snapshot || []); + await this.setSnapshot('final', scan.snapshot || []); + await this.log('RUN_STOPPED', { currentAction: this.currentAction }); + await this.updateRun({ + status: 'stopped', + statusLabel: 'Stopped', + currentAction: 'Stopped by user', + endedAt: new Date().toISOString(), + progress: Object.assign({}, this.progress) + }); + await this.checkpoint('Stopped run finalized', { + status: 'stopped', + reason: 'Stopped by user' + }); + await this.runtimeMessage({ + type: 'RUN_FINALIZED', + runId: this.runId, + finalizedAt: new Date().toISOString() + }); + this.running = false; + if (this.mutationObserver) { + this.mutationObserver.disconnect(); + } + } + + stop() { + this.stopRequested = true; + return { ok: true }; + } + } + + const controller = new ChefsTesterController(); + window.__CHEFS_TESTER_CONTENT_CONTROLLER__ = controller; + + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'CHEFS_TESTER_START') { + controller.start(message) + .then(sendResponse) + .catch((error) => sendResponse({ ok: false, error: error.message || String(error) })); + return true; + } + if (message.type === 'CHEFS_TESTER_STOP') { + sendResponse(controller.stop()); + return false; + } + if (message.type === 'CHEFS_TESTER_STATUS') { + sendResponse({ + ok: true, + running: controller.running, + runId: controller.runId, + currentAction: controller.currentAction, + progress: controller.progress + }); + return false; + } + return false; + }); +})(); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard-model.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard-model.js new file mode 100644 index 0000000000..68d9921172 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard-model.js @@ -0,0 +1,394 @@ +'use strict'; + +(function exposeDashboardModel(globalScope) { + const SCHEMA_VERSION = 1; + const HISTORY_LIMIT = 200; + const HISTORY_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; + const RESULT_VALUES = new Set([ + 'submitted', 'completed', 'failed', 'stalled', 'blocked', 'safety_stop', 'stopped' + ]); + const FAILURE_VALUES = new Set([ + 'none', 'submission', 'validation', 'watchdog', 'safety', 'user_stop', + 'blocked', 'runtime' + ]); + const STRATEGY_VALUES = new Set([ + 'input', 'textarea', 'checkbox', 'radio', 'select', 'choices-select', + 'button', 'formio-set-value', 'formio-file-upload', 'simple-day', + 'contenteditable', 'unknown' + ]); + const COMPONENT_TYPES = new Set([ + 'textfield', 'simpletextfield', 'textarea', 'simpletextarea', 'number', + 'simplenumber', 'email', 'simpleemail', 'phoneNumber', 'simplephonenumber', + 'checkbox', 'simplecheckbox', 'selectboxes', 'simpleselectboxes', 'radio', + 'simpleradio', 'select', 'simpleselect', 'simpleselectadvanced', 'datetime', + 'simpledatetime', 'day', 'simpleday', 'file', 'simplefile', 'datagrid', + 'editgrid', 'tabs', 'button', 'unknown' + ]); + + function finiteNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) ? number : (fallback === undefined ? 0 : fallback); + } + + function boundedInteger(value, maximum) { + return Math.max(0, Math.min(maximum || Number.MAX_SAFE_INTEGER, Math.round(finiteNumber(value, 0)))); + } + + function safeIso(value) { + const time = Date.parse(String(value || '')); + return Number.isFinite(time) ? new Date(time).toISOString() : null; + } + + function safeResult(value) { + const result = String(value || '').toLowerCase(); + return RESULT_VALUES.has(result) ? result : 'failed'; + } + + function opaqueRunRef(value) { + const text = String(value || ''); + if (!text) return 'run-unknown'; + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `run-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } + + function opaqueFormRef(urlText) { + try { + const url = new URL(String(urlText || '')); + const value = String(url.searchParams.get('f') || ''); + const match = value.match(/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i); + return match ? `form-${match[0].slice(0, 8).toLowerCase()}` : 'form-unknown'; + } catch (error) { + return 'form-unknown'; + } + } + + function safeVersion(value) { + const clean = String(value || '').match(/^\d+\.\d+\.\d+$/); + return clean ? clean[0] : ''; + } + + function safeBuild(value) { + const clean = String(value || '').match(/^\d{4}\.\d{2}\.\d{2}\.\d+$/); + return clean ? clean[0] : ''; + } + + function safeStrategy(value) { + const strategy = String(value || '').toLowerCase(); + return STRATEGY_VALUES.has(strategy) ? strategy : 'unknown'; + } + + function safeComponentType(value) { + const type = String(value || ''); + return COMPONENT_TYPES.has(type) ? type : 'unknown'; + } + + function failureCategory(run) { + const status = safeResult(run && run.status); + if (status === 'submitted' || status === 'completed') { + return 'none'; + } + const reason = String(run && run.failure && run.failure.reason || '').toLowerCase(); + if (reason.includes('submit')) return 'submission'; + if (reason.includes('validation')) return 'validation'; + if (reason.includes('watchdog') || status === 'stalled') return 'watchdog'; + if (reason.includes('payment') || status === 'safety_stop') return 'safety'; + if (status === 'stopped') return 'user_stop'; + if (status === 'blocked') return 'blocked'; + return 'runtime'; + } + + function durationMs(run) { + const start = Date.parse(String(run && run.startedAt || '')); + const end = Date.parse(String( + run && (run.endedAt || run.finalizedAt || run.updatedAt) || '' + )); + return Number.isFinite(start) && Number.isFinite(end) + ? Math.max(0, Math.min(24 * 60 * 60 * 1000, end - start)) + : 0; + } + + function buildPassSeries(run) { + const byPass = new Map(); + for (const checkpoint of Array.isArray(run && run.checkpoints) ? run.checkpoints : []) { + const pass = boundedInteger(checkpoint && checkpoint.pass, 500); + if (!pass || !checkpoint || checkpoint.reason !== 'Fill pass completed') { + continue; + } + const progress = checkpoint.progress || {}; + byPass.set(pass, { + pass, + filled: boundedInteger(progress.filled, 100000), + remaining: boundedInteger(progress.remaining, 100000), + actions: boundedInteger(checkpoint.actions, 100000), + elapsedMs: Math.max(0, Date.parse(String(checkpoint.time || '')) - + Date.parse(String(run.startedAt || ''))) + }); + } + return Array.from(byPass.values()).sort((left, right) => left.pass - right.pass).slice(0, 500); + } + + function buildStrategyStats(run) { + const pending = new Map(); + const stats = new Map(); + const events = Array.isArray(run && run.events) ? run.events : []; + for (const event of events) { + const eventType = String(event && event.event || ''); + const strategy = safeStrategy(event && event.strategy); + const identity = `${String(event && event.componentId || '')}:${boundedInteger(event && event.attempt, 100)}`; + if (eventType === 'FILL_ATTEMPT') { + pending.set(identity, { + strategy, + time: Date.parse(String(event.time || '')) + }); + if (!stats.has(strategy)) { + stats.set(strategy, { strategy, attempts: 0, successes: 0, failures: 0, latencyMs: [] }); + } + stats.get(strategy).attempts += 1; + } else if (eventType === 'FILL_SUCCEEDED' || eventType === 'FILL_FAILED') { + const attempt = pending.get(identity); + const resolvedStrategy = attempt ? attempt.strategy : strategy; + if (!stats.has(resolvedStrategy)) { + stats.set(resolvedStrategy, { + strategy: resolvedStrategy, + attempts: 0, + successes: 0, + failures: 0, + latencyMs: [] + }); + } + const item = stats.get(resolvedStrategy); + if (eventType === 'FILL_SUCCEEDED') item.successes += 1; + if (eventType === 'FILL_FAILED') item.failures += 1; + const end = Date.parse(String(event.time || '')); + if (attempt && Number.isFinite(attempt.time) && Number.isFinite(end)) { + item.latencyMs.push(Math.max(0, Math.min(10 * 60 * 1000, end - attempt.time))); + item.latencyMs = item.latencyMs.slice(-200); + } + pending.delete(identity); + } + } + return Array.from(stats.values()).sort((left, right) => right.attempts - left.attempts).slice(0, 20); + } + + function buildComponentStats(run) { + const snapshot = run && run.snapshots && + (run.snapshots.final || run.snapshots.lastKnown || run.snapshots.initial); + const statuses = {}; + const types = {}; + for (const component of Array.isArray(snapshot) ? snapshot : []) { + const status = String(component && component.status || 'unknown').toLowerCase(); + const safeStatus = [ + 'filled', 'protected', 'failed', 'unsupported', 'empty', 'pending', 'unknown' + ].includes(status) ? status : 'unknown'; + const type = safeComponentType(component && component.componentType); + statuses[safeStatus] = boundedInteger(statuses[safeStatus] || 0, 100000) + 1; + types[type] = boundedInteger(types[type] || 0, 100000) + 1; + } + return { statuses, types }; + } + + function buildPhaseDurations(run) { + const start = Date.parse(String(run && run.startedAt || '')); + const end = start + durationMs(run); + const events = (Array.isArray(run && run.events) ? run.events : []) + .map((event) => ({ + event: String(event && event.event || ''), + time: Date.parse(String(event && event.time || '')) + })) + .filter((event) => Number.isFinite(event.time)) + .sort((left, right) => left.time - right.time); + const firstTime = (names) => { + const found = events.find((event) => names.includes(event.event)); + return found ? found.time : null; + }; + const scan = firstTime(['INITIAL_SCAN_COMPLETED']); + const validation = firstTime(['VALIDATION_CHECKED', 'SUBMIT_ATTEMPT']); + const submission = firstTime(['SUBMIT_ATTEMPT', 'SUBMIT_CLICKED']); + const boundaries = [ + { key: 'initialization', start, end: scan || validation || submission || end }, + { key: 'filling', start: scan || start, end: validation || submission || end }, + { key: 'validation', start: validation || submission || end, end: submission || end }, + { key: 'submission', start: submission || end, end } + ]; + return Object.fromEntries(boundaries.map((phase) => [ + phase.key, + Math.max(0, Math.min(durationMs(run), finiteNumber(phase.end) - finiteNumber(phase.start))) + ])); + } + + function buildRunSummary(run, context) { + const progress = run && run.progress || {}; + const componentStats = buildComponentStats(run); + const result = safeResult(run && run.status); + const summary = { + schemaVersion: SCHEMA_VERSION, + runRef: opaqueRunRef(run && run.runId), + formRef: opaqueFormRef(run && run.formUrl), + extensionVersion: safeVersion(run && run.extensionVersion), + buildNumber: safeBuild(run && run.buildNumber), + result, + failureCategory: failureCategory(run), + startedAt: safeIso(run && run.startedAt), + endedAt: safeIso(run && (run.endedAt || run.finalizedAt || run.updatedAt)), + durationMs: durationMs(run), + confirmationCaptured: Boolean(run && run.confirmationId), + metrics: { + passes: boundedInteger(progress.pass, 10000), + discovered: boundedInteger(progress.discovered, 100000), + filled: boundedInteger(progress.filled, 100000), + remaining: boundedInteger(progress.remaining, 100000), + failed: boundedInteger(progress.failed, 100000), + unsupported: boundedInteger(progress.unsupported, 100000), + rowsAdded: boundedInteger(progress.rowsAdded, 100000), + attachmentsCompleted: boundedInteger(progress.attachmentsCompleted, 100000), + attachmentsPending: boundedInteger(progress.attachmentsPending, 100000), + submitAttempts: boundedInteger(progress.submitAttempts, 1000), + validationErrors: boundedInteger( + Array.isArray(run && run.validationErrors) ? run.validationErrors.length : 0, + 100000 + ) + }, + phases: buildPhaseDurations(run), + passSeries: buildPassSeries(run), + strategies: buildStrategyStats(run), + componentOutcomes: componentStats.statuses, + componentTypes: componentStats.types, + issueCounts: { + fieldFailures: boundedInteger(progress.failed, 100000), + unsupported: boundedInteger(progress.unsupported, 100000), + validation: boundedInteger( + Array.isArray(run && run.validationErrors) ? run.validationErrors.length : 0, + 100000 + ), + screenshotFailure: (Array.isArray(run && run.events) ? run.events : []) + .some((event) => event && event.event === 'SCREENSHOT_CAPTURE_FAILED') ? 1 : 0 + }, + batch: context && context.suiteId ? { + suiteRef: opaqueRunRef(context.suiteId), + index: (String(context.index || '').match(/^\d{1,6}/) || [''])[0] + } : null + }; + return JSON.parse(JSON.stringify(summary)); + } + + function hasOnlyKeys(value, allowed) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).every((key) => allowed.has(key)); + } + + function hasExactKeys(value, allowed) { + return hasOnlyKeys(value, allowed) && Object.keys(value).length === allowed.size; + } + + function numericObject(value, allowed) { + return hasExactKeys(value, allowed) && + Object.values(value).every((item) => Number.isFinite(Number(item))); + } + + function isDashboardSummary(record) { + const topLevel = new Set([ + 'schemaVersion', 'runRef', 'formRef', 'extensionVersion', 'buildNumber', + 'result', 'failureCategory', 'startedAt', 'endedAt', 'durationMs', + 'confirmationCaptured', 'metrics', 'phases', 'passSeries', 'strategies', + 'componentOutcomes', 'componentTypes', 'issueCounts', 'batch' + ]); + const metricKeys = new Set([ + 'passes', 'discovered', 'filled', 'remaining', 'failed', 'unsupported', + 'rowsAdded', 'attachmentsCompleted', 'attachmentsPending', 'submitAttempts', + 'validationErrors' + ]); + const phaseKeys = new Set(['initialization', 'filling', 'validation', 'submission']); + const passKeys = new Set(['pass', 'filled', 'remaining', 'actions', 'elapsedMs']); + const strategyKeys = new Set([ + 'strategy', 'attempts', 'successes', 'failures', 'latencyMs' + ]); + const outcomeKeys = new Set([ + 'filled', 'protected', 'failed', 'unsupported', 'empty', 'pending', 'unknown' + ]); + const issueKeys = new Set([ + 'fieldFailures', 'unsupported', 'validation', 'screenshotFailure' + ]); + if ( + !hasExactKeys(record, topLevel) || + record.schemaVersion !== SCHEMA_VERSION || + !/^run-[0-9a-f]{8}$/.test(String(record.runRef || '')) || + !/^form-(?:[0-9a-f]{8}|unknown)$/.test(String(record.formRef || '')) || + !RESULT_VALUES.has(String(record.result || '')) || + !FAILURE_VALUES.has(String(record.failureCategory || '')) || + !/^(?:|\d+\.\d+\.\d+)$/.test(String(record.extensionVersion || '')) || + !/^(?:|\d{4}\.\d{2}\.\d{2}\.\d+)$/.test(String(record.buildNumber || '')) || + !Number.isFinite(Date.parse(String(record.startedAt || ''))) || + !Number.isFinite(Date.parse(String(record.endedAt || ''))) || + !Number.isFinite(Number(record.durationMs)) || + typeof record.confirmationCaptured !== 'boolean' || + !numericObject(record.metrics, metricKeys) || + !numericObject(record.phases, phaseKeys) || + !numericObject(record.issueCounts, issueKeys) || + !hasOnlyKeys(record.componentOutcomes, outcomeKeys) || + !Object.values(record.componentOutcomes).every((item) => Number.isFinite(Number(item))) || + !hasOnlyKeys(record.componentTypes, COMPONENT_TYPES) || + !Object.values(record.componentTypes).every((item) => Number.isFinite(Number(item))) || + !Array.isArray(record.passSeries) || + !Array.isArray(record.strategies) + ) { + return false; + } + if (!record.passSeries.every((item) => numericObject(item, passKeys))) return false; + if (!record.strategies.every((item) => + hasExactKeys(item, strategyKeys) && + STRATEGY_VALUES.has(String(item.strategy || '')) && + ['attempts', 'successes', 'failures'].every((key) => Number.isFinite(Number(item[key]))) && + Array.isArray(item.latencyMs) && + item.latencyMs.every((value) => Number.isFinite(Number(value))) + )) return false; + if (record.batch !== null && !( + hasExactKeys(record.batch, new Set(['suiteRef', 'index'])) && + /^run-[0-9a-f]{8}$/.test(String(record.batch.suiteRef || '')) && + /^\d{0,6}$/.test(String(record.batch.index || '')) + )) return false; + return pidForbiddenKeys(record, 'dashboard', []).length === 0; + } + + function trimHistory(records, nowValue) { + const now = Number.isFinite(Number(nowValue)) ? Number(nowValue) : Date.now(); + const minimum = now - HISTORY_MAX_AGE_MS; + const unique = new Map(); + for (const record of Array.isArray(records) ? records : []) { + if (!isDashboardSummary(record)) continue; + const ended = Date.parse(String(record.endedAt || '')); + if (!Number.isFinite(ended) || ended < minimum || ended > now + 60000) continue; + unique.set(record.runRef, record); + } + return Array.from(unique.values()) + .sort((left, right) => Date.parse(left.endedAt) - Date.parse(right.endedAt)) + .slice(-HISTORY_LIMIT); + } + + function pidForbiddenKeys(value, path, findings) { + const forbidden = /(^|_)(value|label|name|email|address|filename|content|screenshot|stack|url|confirmationid|useragent|events|checkpoints|snapshot)(_|$)/i; + if (Array.isArray(value)) { + value.forEach((item, index) => pidForbiddenKeys(item, `${path}[${index}]`, findings)); + return findings; + } + if (!value || typeof value !== 'object') return findings; + for (const [key, child] of Object.entries(value)) { + if (forbidden.test(key)) findings.push(`${path}.${key}`); + pidForbiddenKeys(child, `${path}.${key}`, findings); + } + return findings; + } + + globalScope.ChefsDashboardModel = Object.freeze({ + SCHEMA_VERSION, + HISTORY_LIMIT, + HISTORY_MAX_AGE_MS, + buildRunSummary, + isDashboardSummary, + trimHistory, + pidForbiddenKeys: (value) => pidForbiddenKeys(value, 'dashboard', []) + }); +})(globalThis); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.css b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.css new file mode 100644 index 0000000000..3ea1fe749a --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.css @@ -0,0 +1,199 @@ +:root { + color-scheme: light; + font-family: Inter, "BC Sans", Arial, Helvetica, sans-serif; + color: #172b4d; + background: #edf2f7; +} +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } +body { min-width: 1100px; } +main { + height: 100vh; + padding: 20px 24px; + display: grid; + grid-template-rows: 78px 132px minmax(0, 1fr); + gap: 16px; +} +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.eyebrow { + margin: 0 0 4px; + color: #4c6b88; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +h1, h2 { margin: 0; color: #003366; } +h1 { font-size: 28px; line-height: 1.05; } +h2 { font-size: 19px; } +.context { margin: 5px 0 0; color: #52667a; font-size: 14px; } +.header-controls { + display: grid; + grid-template-columns: auto minmax(150px, 240px) auto 140px auto; + align-items: center; + gap: 8px; +} +label { color: #435b70; font-size: 12px; font-weight: 800; } +select, button { + min-height: 38px; + border: 1px solid #9fb1c1; + border-radius: 6px; + background: #fff; + color: #172b4d; + padding: 7px 10px; + font: inherit; +} +button { cursor: pointer; font-weight: 700; } +button:hover { background: #f6f8fa; } +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} +.summary-card { + border: 1px solid #c8d4df; + border-radius: 10px; + background: #fff; + padding: 16px 18px; + box-shadow: 0 2px 8px rgba(0, 51, 102, 0.06); + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} +.summary-card span { + color: #597086; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.summary-card strong { + margin: 7px 0 4px; + color: #003366; + font-size: clamp(26px, 2.5vw, 38px); + line-height: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.summary-card small { color: #667b8f; font-size: 12px; } +.result-card.success { border-top: 6px solid #2e8540; } +.result-card.failure { border-top: 6px solid #c62828; } +.result-card.warning { border-top: 6px solid #e69f00; } +.workspace { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 2.35fr) minmax(300px, 0.85fr); + gap: 16px; +} +.chart-card, .findings-card { + min-height: 0; + border: 1px solid #c8d4df; + border-radius: 10px; + background: #fff; + box-shadow: 0 2px 8px rgba(0, 51, 102, 0.06); +} +.chart-card { + padding: 16px 18px 12px; + display: grid; + grid-template-rows: 52px minmax(0, 1fr) 34px; + position: relative; +} +.chart-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.chart-toolbar > label { margin-left: auto; } +#chartSelect { width: min(360px, 40vw); } +#chart { + width: 100%; + height: 100%; + min-height: 250px; + overflow: visible; +} +.chart-description { + margin: 7px 0 0; + color: #52667a; + font-size: 13px; + line-height: 1.35; +} +.chart-unavailable { + position: absolute; + inset: 82px 18px 48px; + z-index: 2; + display: grid; + place-items: center; + border: 2px dashed #b7c5d1; + border-radius: 8px; + background: rgba(248, 250, 252, 0.96); + color: #435b70; + padding: 24px; + text-align: center; + font-weight: 700; +} +.hidden { display: none !important; } +.findings-card { + padding: 18px; + display: grid; + grid-template-rows: auto minmax(78px, auto) minmax(0, 1fr) auto auto; + gap: 12px; +} +.interpretation { + margin: 0; + border-left: 4px solid #1d70b8; + background: #eef6fc; + padding: 11px 12px; + color: #173c5e; + font-size: 14px; + line-height: 1.4; +} +.facts { + margin: 0; + min-height: 0; + display: grid; + align-content: start; +} +.facts div { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + border-bottom: 1px solid #e3e9ef; + padding: 7px 0; +} +.facts dt { color: #5d7185; font-size: 12px; } +.facts dd { margin: 0; color: #173c5e; font-size: 12px; font-weight: 800; } +.privacy-note { + border-radius: 7px; + background: #f1f7f2; + color: #245b2d; + padding: 10px 11px; + font-size: 12px; +} +.privacy-note p { margin: 4px 0 0; line-height: 1.35; } +.secondary { width: 100%; } +.axis { stroke: #8ca0b3; stroke-width: 1; } +.gridline { stroke: #dce5ec; stroke-width: 1; } +.chart-label { fill: #3e566d; font-size: 12px; } +.chart-value { fill: #173c5e; font-size: 12px; font-weight: 800; } +.series-primary { fill: #1d70b8; stroke: #1d70b8; } +.series-secondary { fill: #f0a202; stroke: #f0a202; } +.series-success { fill: #2e8540; stroke: #2e8540; } +.series-danger { fill: #c62828; stroke: #c62828; } +.series-failure { fill: #c62828; stroke: #c62828; } +.series-warning { fill: #f0a202; stroke: #f0a202; } +.series-muted { fill: #9fb1c1; stroke: #9fb1c1; } +@media (max-height: 760px) { + main { padding: 12px 16px; grid-template-rows: 66px 106px minmax(0, 1fr); gap: 10px; } + .summary-card { padding: 10px 14px; } + .summary-card strong { font-size: 26px; } + .chart-card { padding-top: 12px; } + .facts div { padding: 5px 0; } +} diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.html b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.html new file mode 100644 index 0000000000..94b70b12cd --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.html @@ -0,0 +1,93 @@ + + + + + + CHEFS Tester Results Dashboard + + + +
    +
    +
    +

    CHEFS One-Click Form Tester

    +

    Results Dashboard

    +

    No completed run is available yet.

    +
    +
    + + + + + +
    +
    + +
    +
    + Result + + No run selected +
    +
    + Duration + + Total execution time +
    +
    + Fields + + Filled / discovered +
    +
    + Issues + + Failures, validation, unsupported +
    +
    + +
    +
    +
    +
    +

    Simple

    +

    Outcome overview

    +
    + + +
    + + +

    Select a completed run to see results.

    +
    + + +
    +
    + + + diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.js new file mode 100644 index 0000000000..82b7045ef3 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/dashboard.js @@ -0,0 +1,784 @@ +'use strict'; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +let dashboardState = null; +let selectedRun = null; + +const elements = { + contextText: document.getElementById('contextText'), + runSelect: document.getElementById('runSelect'), + viewSelect: document.getElementById('viewSelect'), + chartSelect: document.getElementById('chartSelect'), + refreshButton: document.getElementById('refreshButton'), + clearHistoryButton: document.getElementById('clearHistoryButton'), + resultCard: document.querySelector('.result-card'), + resultValue: document.getElementById('resultValue'), + resultDetail: document.getElementById('resultDetail'), + durationValue: document.getElementById('durationValue'), + durationDetail: document.getElementById('durationDetail'), + fieldsValue: document.getElementById('fieldsValue'), + fieldsDetail: document.getElementById('fieldsDetail'), + issuesValue: document.getElementById('issuesValue'), + issuesDetail: document.getElementById('issuesDetail'), + chartGroupLabel: document.getElementById('chartGroupLabel'), + chartTitle: document.getElementById('chartTitle'), + chart: document.getElementById('chart'), + chartDescription: document.getElementById('chartDescription'), + chartUnavailable: document.getElementById('chartUnavailable'), + interpretation: document.getElementById('interpretation'), + formRefValue: document.getElementById('formRefValue'), + passesValue: document.getElementById('passesValue'), + attachmentsValue: document.getElementById('attachmentsValue'), + rowsValue: document.getElementById('rowsValue'), + confirmationValue: document.getElementById('confirmationValue'), + buildValue: document.getElementById('buildValue') +}; + +function formatDuration(milliseconds) { + const seconds = Math.max(0, Number(milliseconds) || 0) / 1000; + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)} s`; + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + return `${minutes}m ${remainder}s`; +} + +function formatNumber(value) { + return new Intl.NumberFormat('en-CA').format(Math.max(0, Number(value) || 0)); +} + +function percentile(values, probability) { + const sorted = values.filter(Number.isFinite).sort((left, right) => left - right); + if (!sorted.length) return 0; + const index = (sorted.length - 1) * probability; + const lower = Math.floor(index); + const upper = Math.ceil(index); + return lower === upper + ? sorted[lower] + : sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower); +} + +function comparableHistory() { + if (!selectedRun || !dashboardState) return []; + return (dashboardState.history || []).filter((run) => + run && run.formRef === selectedRun.formRef + ); +} + +function allVisibleRuns() { + const byRef = new Map(); + for (const run of [ + ...(dashboardState && dashboardState.history || []), + ...(dashboardState && dashboardState.runs || []) + ]) { + if (run && run.runRef) byRef.set(run.runRef, run); + } + return Array.from(byRef.values()); +} + +function svgElement(name, attributes, text) { + const node = document.createElementNS(SVG_NS, name); + for (const [key, value] of Object.entries(attributes || {})) { + node.setAttribute(key, String(value)); + } + if (text !== undefined) node.textContent = String(text); + return node; +} + +function clearChart() { + while (elements.chart.firstChild) elements.chart.firstChild.remove(); +} + +function addText(x, y, text, className, anchor) { + elements.chart.appendChild(svgElement('text', { + x, + y, + class: className || 'chart-label', + 'text-anchor': anchor || 'start' + }, text)); +} + +function addLine(x1, y1, x2, y2, className, extra) { + elements.chart.appendChild(svgElement('line', { + x1, y1, x2, y2, class: className || 'axis', ...(extra || {}) + })); +} + +function addRect(x, y, width, height, className, extra) { + elements.chart.appendChild(svgElement('rect', { + x, y, width: Math.max(0, width), height: Math.max(0, height), + rx: 4, class: className || 'series-primary', ...(extra || {}) + })); +} + +function addCircle(cx, cy, r, className, extra) { + elements.chart.appendChild(svgElement('circle', { + cx, cy, r, class: className || 'series-primary', ...(extra || {}) + })); +} + +function linePath(points) { + return points.map((point, index) => + `${index ? 'L' : 'M'} ${point[0].toFixed(1)} ${point[1].toFixed(1)}` + ).join(' '); +} + +function resultClass(result) { + if (result === 'submitted' || result === 'completed') return 'success'; + if (result === 'stopped' || result === 'safety_stop') return 'warning'; + return 'failure'; +} + +function issueTotal(run) { + const issues = run && run.issueCounts || {}; + return Object.values(issues).reduce((sum, value) => sum + (Number(value) || 0), 0); +} + +function plainInterpretation(run) { + if (!run) return 'No completed result is available.'; + const metrics = run.metrics || {}; + if (run.result === 'submitted' || run.result === 'completed') { + const issuePhrase = issueTotal(run) + ? `${formatNumber(issueTotal(run))} diagnostic issue${issueTotal(run) === 1 ? '' : 's'} were recorded.` + : 'No diagnostic issues were recorded.'; + return `The run completed successfully after ${formatNumber(metrics.passes)} pass${metrics.passes === 1 ? '' : 'es'} and filled ${formatNumber(metrics.filled)} fields. ${issuePhrase}`; + } + return `The run ended as ${run.result.replaceAll('_', ' ')}. The failure category is ${run.failureCategory.replaceAll('_', ' ')}, with ${formatNumber(metrics.remaining)} visible fields remaining.`; +} + +const chartRegistry = [ + { + id: 'outcome', + group: 'simple', + title: 'Outcome overview', + description: 'Shows the result mix for the current singleton or batch.', + availability: () => ({ ok: Boolean(selectedRun), reason: 'Select a completed run.' }), + render: renderOutcome + }, + { + id: 'progress', + group: 'simple', + title: 'Field progress', + description: 'Compares discovered, filled, remaining, failed and unsupported field counts.', + availability: () => ({ ok: Boolean(selectedRun), reason: 'Select a completed run.' }), + render: renderProgress + }, + { + id: 'durations', + group: 'simple', + title: 'Duration by result', + description: 'Compares total execution time across the current batch.', + availability: () => ({ + ok: Boolean(dashboardState && dashboardState.runs && dashboardState.runs.length > 1), + reason: 'This chart needs a batch containing at least two runs.' + }), + render: renderDurations + }, + { + id: 'phases', + group: 'simple', + title: 'Where the time went', + description: 'Breaks total execution time into initialization, filling, validation and submission.', + availability: () => ({ ok: Boolean(selectedRun), reason: 'Select a completed run.' }), + render: renderPhases + }, + { + id: 'pass-trend', + group: 'analyst', + title: 'Pass-by-pass trend', + description: 'Tracks filled and remaining fields as the fill loop progresses.', + availability: () => ({ + ok: Boolean(selectedRun && selectedRun.passSeries && selectedRun.passSeries.length >= 2), + reason: 'At least two recorded fill passes are required.' + }), + render: renderPassTrend + }, + { + id: 'strategy-latency', + group: 'analyst', + title: 'Strategy latency distribution', + description: 'Shows minimum, quartiles, median and maximum fill latency by strategy.', + availability: () => ({ + ok: Boolean(selectedRun && selectedRun.strategies && + selectedRun.strategies.some((strategy) => strategy.latencyMs.length >= 2)), + reason: 'At least one fill strategy needs two measured actions.' + }), + render: renderStrategyLatency + }, + { + id: 'component-outcomes', + group: 'analyst', + title: 'Component outcome flow', + description: 'Shows how discovered components ended: filled, protected, failed, unsupported or empty.', + availability: () => ({ + ok: Boolean(selectedRun && Object.keys(selectedRun.componentOutcomes || {}).length), + reason: 'The run has no component outcome snapshot.' + }), + render: renderComponentOutcomes + }, + { + id: 'retry-heatmap', + group: 'analyst', + title: 'Fill strategy heatmap', + description: 'Compares attempts, successes and failures across fill strategies.', + availability: () => ({ + ok: Boolean(selectedRun && selectedRun.strategies && selectedRun.strategies.length), + reason: 'The run has no fill-strategy evidence.' + }), + render: renderStrategyHeatmap + }, + { + id: 'duration-histogram', + group: 'statistical', + title: 'Duration bell curve', + description: 'Shows a duration histogram with a normal-distribution reference curve for comparable runs.', + availability: () => ({ + ok: comparableHistory().length >= 20, + reason: `Requires ${Math.max(0, 20 - comparableHistory().length)} more retained runs for ${selectedRun ? selectedRun.formRef : 'this form'}.` + }), + render: renderHistogram + }, + { + id: 'control-chart', + group: 'statistical', + title: 'Duration control chart', + description: 'Tracks comparable run duration against the mean and three-sigma control limits.', + availability: () => ({ + ok: comparableHistory().length >= 8, + reason: `Requires ${Math.max(0, 8 - comparableHistory().length)} more retained comparable runs.` + }), + render: renderControlChart + }, + { + id: 'complexity-scatter', + group: 'statistical', + title: 'Complexity versus duration', + description: 'Plots discovered component count against total duration.', + availability: () => ({ + ok: allVisibleRuns().length >= 3, + reason: `Requires ${Math.max(0, 3 - allVisibleRuns().length)} more aggregate runs.` + }), + render: renderScatter + }, + { + id: 'percentile-bands', + group: 'statistical', + title: 'Duration percentiles', + description: 'Shows the 10th, 25th, 50th, 75th and 90th duration percentiles.', + availability: () => ({ + ok: comparableHistory().length >= 5, + reason: `Requires ${Math.max(0, 5 - comparableHistory().length)} more retained comparable runs.` + }), + render: renderPercentiles + }, + { + id: 'duration-candles', + group: 'experimental', + title: 'Duration candlestick time series', + description: 'Maps each day to first, high, low and final comparable-run duration.', + availability: () => { + const dates = new Set(comparableHistory().map((run) => String(run.endedAt || '').slice(0, 10))); + return { + ok: comparableHistory().length >= 8 && dates.size >= 2, + reason: 'Requires at least eight comparable retained runs across two or more days.' + }; + }, + render: renderCandlesticks + }, + { + id: 'event-density', + group: 'experimental', + title: 'Pass activity density', + description: 'Shows recorded actions per pass against elapsed time.', + availability: () => ({ + ok: Boolean(selectedRun && selectedRun.passSeries && selectedRun.passSeries.length >= 3), + reason: 'At least three pass checkpoints are required.' + }), + render: renderEventDensity + }, + { + id: 'build-distribution', + group: 'experimental', + title: 'Build duration distributions', + description: 'Compares duration quartiles between retained extension builds.', + availability: () => { + const builds = new Set(allVisibleRuns().map((run) => run.buildNumber).filter(Boolean)); + return { + ok: allVisibleRuns().length >= 6 && builds.size >= 2, + reason: 'Requires at least six aggregate runs spanning two extension builds.' + }; + }, + render: renderBuildDistribution + } +]; + +function renderOutcome() { + const runs = dashboardState.mode === 'batch' ? dashboardState.runs : [selectedRun]; + const counts = {}; + for (const run of runs) counts[run.result] = (counts[run.result] || 0) + 1; + const total = runs.length; + const colors = { + submitted: '#2e8540', completed: '#2e8540', blocked: '#e69f00', + stalled: '#c62828', failed: '#c62828', stopped: '#7c5fb3', safety_stop: '#7c5fb3' + }; + let offset = 0; + const circumference = 2 * Math.PI * 105; + for (const [result, count] of Object.entries(counts)) { + const length = circumference * count / total; + elements.chart.appendChild(svgElement('circle', { + cx: 260, cy: 215, r: 105, fill: 'none', stroke: colors[result] || '#9fb1c1', + 'stroke-width': 46, 'stroke-dasharray': `${length} ${circumference - length}`, + 'stroke-dashoffset': -offset, transform: 'rotate(-90 260 215)' + })); + offset += length; + } + addText( + 260, + 210, + `${(counts.submitted || 0) + (counts.completed || 0)}/${total}`, + 'chart-value', + 'middle' + ); + addText(260, 234, 'successful', 'chart-label', 'middle'); + let y = 145; + for (const [result, count] of Object.entries(counts)) { + addRect(500, y - 13, 18, 18, '', { fill: colors[result] || '#9fb1c1' }); + addText(532, y, result.replaceAll('_', ' '), 'chart-label'); + addText(760, y, count, 'chart-value', 'end'); + y += 42; + } +} + +function renderProgress() { + const metrics = selectedRun.metrics || {}; + const rows = [ + ['Discovered', metrics.discovered, 'series-muted'], + ['Filled', metrics.filled, 'series-success'], + ['Remaining', metrics.remaining, 'series-secondary'], + ['Failed', metrics.failed, 'series-danger'], + ['Unsupported', metrics.unsupported, 'series-primary'] + ]; + const maximum = Math.max(1, ...rows.map((row) => row[1])); + rows.forEach((row, index) => { + const y = 70 + index * 70; + addText(30, y + 18, row[0], 'chart-label'); + addRect(170, y, 640 * row[1] / maximum, 28, row[2]); + addText(830, y + 20, formatNumber(row[1]), 'chart-value', 'end'); + }); +} + +function renderDurations() { + const runs = dashboardState.runs || []; + const maximum = Math.max(1, ...runs.map((run) => run.durationMs)); + const width = Math.max(28, Math.min(82, 700 / runs.length - 12)); + runs.forEach((run, index) => { + const x = 90 + index * (720 / runs.length); + const height = 290 * run.durationMs / maximum; + addRect(x, 350 - height, width, height, `series-${resultClass(run.result)}`); + addText(x + width / 2, 375, run.batch && run.batch.index || String(index + 1), 'chart-label', 'middle'); + addText(x + width / 2, 340 - height, formatDuration(run.durationMs), 'chart-value', 'middle'); + }); + addLine(70, 350, 850, 350, 'axis'); +} + +function renderPhases() { + const phases = selectedRun.phases || {}; + const rows = [ + ['Initialization', phases.initialization, '#5b8ff9'], + ['Filling', phases.filling, '#2e8540'], + ['Validation', phases.validation, '#f0a202'], + ['Submission', phases.submission, '#7c5fb3'] + ]; + const total = Math.max(1, rows.reduce((sum, row) => sum + (row[1] || 0), 0)); + let x = 80; + rows.forEach((row, index) => { + const width = 740 * (row[1] || 0) / total; + addRect(x, 150, width, 72, '', { fill: row[2], rx: 0 }); + if (width > 80) addText(x + width / 2, 192, formatDuration(row[1]), 'chart-value', 'middle'); + addRect(100 + (index % 2) * 350, 290 + Math.floor(index / 2) * 55, 18, 18, '', { fill: row[2] }); + addText(130 + (index % 2) * 350, 304 + Math.floor(index / 2) * 55, row[0], 'chart-label'); + x += width; + }); +} + +function renderPassTrend() { + const series = selectedRun.passSeries; + const maximum = Math.max(1, ...series.flatMap((item) => [item.filled, item.remaining])); + const xFor = (index) => 75 + index * 760 / Math.max(1, series.length - 1); + const yFor = (value) => 360 - value * 290 / maximum; + [0, 0.25, 0.5, 0.75, 1].forEach((fraction) => { + const y = 360 - fraction * 290; + addLine(70, y, 850, y, 'gridline'); + addText(60, y + 4, Math.round(maximum * fraction), 'chart-label', 'end'); + }); + const filled = series.map((item, index) => [xFor(index), yFor(item.filled)]); + const remaining = series.map((item, index) => [xFor(index), yFor(item.remaining)]); + elements.chart.appendChild(svgElement('path', { + d: linePath(filled), fill: 'none', stroke: '#2e8540', 'stroke-width': 4 + })); + elements.chart.appendChild(svgElement('path', { + d: linePath(remaining), fill: 'none', stroke: '#e69f00', 'stroke-width': 4 + })); + filled.forEach((point) => addCircle(point[0], point[1], 4, '', { fill: '#2e8540' })); + remaining.forEach((point) => addCircle(point[0], point[1], 4, '', { fill: '#e69f00' })); + addText(720, 45, 'Filled', 'chart-value'); + addLine(680, 41, 710, 41, '', { stroke: '#2e8540', 'stroke-width': 4 }); + addText(815, 45, 'Remaining', 'chart-value'); + addLine(775, 41, 805, 41, '', { stroke: '#e69f00', 'stroke-width': 4 }); +} + +function renderStrategyLatency() { + const strategies = selectedRun.strategies.filter((item) => item.latencyMs.length >= 2).slice(0, 7); + const maximum = Math.max(1, ...strategies.flatMap((item) => item.latencyMs)); + strategies.forEach((strategy, index) => { + const values = strategy.latencyMs; + const min = Math.min(...values); + const max = Math.max(...values); + const q1 = percentile(values, 0.25); + const median = percentile(values, 0.5); + const q3 = percentile(values, 0.75); + const y = 65 + index * 52; + const x = (value) => 210 + value * 610 / maximum; + addText(190, y + 5, strategy.strategy, 'chart-label', 'end'); + addLine(x(min), y, x(max), y, 'axis'); + addRect(x(q1), y - 13, x(q3) - x(q1), 26, 'series-primary'); + addLine(x(median), y - 13, x(median), y + 13, '', { stroke: '#fff', 'stroke-width': 3 }); + addText(840, y + 5, formatDuration(median), 'chart-value', 'end'); + }); +} + +function renderComponentOutcomes() { + const entries = Object.entries(selectedRun.componentOutcomes || {}) + .sort((left, right) => right[1] - left[1]); + const total = Math.max(1, entries.reduce((sum, entry) => sum + entry[1], 0)); + const colors = { + filled: '#2e8540', protected: '#5b8ff9', failed: '#c62828', + unsupported: '#7c5fb3', empty: '#e69f00', pending: '#9fb1c1', unknown: '#9fb1c1' + }; + let x = 80; + entries.forEach(([status, count], index) => { + const width = 740 * count / total; + addRect(x, 125, width, 90, '', { fill: colors[status] || '#9fb1c1', rx: 0 }); + if (width > 55) addText(x + width / 2, 177, count, 'chart-value', 'middle'); + addRect(100 + (index % 3) * 245, 285 + Math.floor(index / 3) * 48, 17, 17, '', { + fill: colors[status] || '#9fb1c1' + }); + addText(128 + (index % 3) * 245, 299 + Math.floor(index / 3) * 48, status, 'chart-label'); + x += width; + }); +} + +function renderStrategyHeatmap() { + const strategies = selectedRun.strategies.slice(0, 9); + const columns = ['attempts', 'successes', 'failures']; + const maximum = Math.max(1, ...strategies.flatMap((item) => columns.map((column) => item[column]))); + columns.forEach((column, index) => addText(390 + index * 150, 45, column, 'chart-label', 'middle')); + strategies.forEach((strategy, row) => { + const y = 65 + row * 38; + addText(280, y + 23, strategy.strategy, 'chart-label', 'end'); + columns.forEach((column, columnIndex) => { + const value = strategy[column]; + const opacity = 0.12 + 0.88 * value / maximum; + addRect(315 + columnIndex * 150, y, 130, 30, '', { + fill: column === 'failures' ? '#c62828' : '#1d70b8', + opacity + }); + addText(380 + columnIndex * 150, y + 21, value, 'chart-value', 'middle'); + }); + }); +} + +function renderHistogram() { + const values = comparableHistory().map((run) => run.durationMs); + const min = Math.min(...values); + const max = Math.max(...values); + const binCount = Math.min(10, Math.max(5, Math.round(Math.sqrt(values.length)))); + const width = Math.max(1, max - min); + const bins = Array.from({ length: binCount }, () => 0); + values.forEach((value) => { + const index = Math.min(binCount - 1, Math.floor((value - min) / width * binCount)); + bins[index] += 1; + }); + const maximum = Math.max(...bins); + bins.forEach((count, index) => { + const x = 90 + index * 720 / binCount; + const barWidth = 700 / binCount; + const height = count * 250 / maximum; + addRect(x, 350 - height, barWidth, height, 'series-primary', { opacity: 0.65 }); + }); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const deviation = Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length) || 1; + const curve = []; + for (let index = 0; index <= 100; index += 1) { + const value = min + width * index / 100; + const density = Math.exp(-0.5 * ((value - mean) / deviation) ** 2); + curve.push([90 + 720 * index / 100, 350 - density * 230]); + } + elements.chart.appendChild(svgElement('path', { + d: linePath(curve), fill: 'none', stroke: '#c62828', 'stroke-width': 4 + })); + addLine(80, 350, 830, 350, 'axis'); +} + +function renderControlChart() { + const runs = comparableHistory(); + const values = runs.map((run) => run.durationMs); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const deviation = Math.sqrt(values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length); + const upper = mean + 3 * deviation; + const lower = Math.max(0, mean - 3 * deviation); + const maximum = Math.max(1, upper, ...values); + const x = (index) => 80 + index * 750 / Math.max(1, values.length - 1); + const y = (value) => 365 - value * 300 / maximum; + [['Mean', mean, '#1d70b8'], ['UCL', upper, '#c62828'], ['LCL', lower, '#c62828']] + .forEach(([label, value, color]) => { + addLine(70, y(value), 850, y(value), '', { stroke: color, 'stroke-dasharray': '8 6' }); + addText(845, y(value) - 6, label, 'chart-label', 'end'); + }); + const points = values.map((value, index) => [x(index), y(value)]); + elements.chart.appendChild(svgElement('path', { + d: linePath(points), fill: 'none', stroke: '#2e8540', 'stroke-width': 3 + })); + points.forEach((point, index) => addCircle(point[0], point[1], 5, '', { + fill: values[index] > upper || values[index] < lower ? '#c62828' : '#2e8540' + })); +} + +function renderScatter() { + const runs = allVisibleRuns(); + const maxX = Math.max(1, ...runs.map((run) => run.metrics.discovered)); + const maxY = Math.max(1, ...runs.map((run) => run.durationMs)); + addLine(75, 360, 850, 360, 'axis'); + addLine(75, 50, 75, 360, 'axis'); + runs.forEach((run) => { + const x = 75 + run.metrics.discovered * 750 / maxX; + const y = 360 - run.durationMs * 290 / maxY; + addCircle(x, y, 7, `series-${resultClass(run.result)}`, { opacity: 0.75 }); + }); + addText(460, 405, 'Discovered components', 'chart-label', 'middle'); + addText(18, 210, 'Duration', 'chart-label', 'middle'); +} + +function renderPercentiles() { + const values = comparableHistory().map((run) => run.durationMs); + const rows = [ + ['P10', percentile(values, 0.1)], ['P25', percentile(values, 0.25)], + ['Median', percentile(values, 0.5)], ['P75', percentile(values, 0.75)], + ['P90', percentile(values, 0.9)] + ]; + const maximum = Math.max(1, ...rows.map((row) => row[1])); + rows.forEach((row, index) => { + const y = 70 + index * 68; + addText(115, y + 20, row[0], 'chart-label', 'end'); + addRect(145, y, 620 * row[1] / maximum, 30, 'series-primary'); + addText(800, y + 21, formatDuration(row[1]), 'chart-value', 'end'); + }); +} + +function renderCandlesticks() { + const buckets = new Map(); + comparableHistory().forEach((run) => { + const date = String(run.endedAt).slice(0, 10); + if (!buckets.has(date)) buckets.set(date, []); + buckets.get(date).push(run); + }); + const days = Array.from(buckets.entries()).sort((left, right) => left[0].localeCompare(right[0])); + const maximum = Math.max(1, ...days.flatMap(([, runs]) => runs.map((run) => run.durationMs))); + const y = (value) => 360 - value * 290 / maximum; + days.forEach(([date, runs], index) => { + runs.sort((left, right) => Date.parse(left.endedAt) - Date.parse(right.endedAt)); + const open = runs[0].durationMs; + const close = runs.at(-1).durationMs; + const high = Math.max(...runs.map((run) => run.durationMs)); + const low = Math.min(...runs.map((run) => run.durationMs)); + const x = 120 + index * 680 / Math.max(1, days.length - 1); + const color = close <= open ? '#2e8540' : '#c62828'; + addLine(x, y(high), x, y(low), '', { stroke: color, 'stroke-width': 3 }); + addRect(x - 14, Math.min(y(open), y(close)), 28, Math.max(3, Math.abs(y(open) - y(close))), '', { + fill: color + }); + addText(x, 390, date.slice(5), 'chart-label', 'middle'); + }); +} + +function renderEventDensity() { + const series = selectedRun.passSeries; + const maxActions = Math.max(1, ...series.map((item) => item.actions)); + const maxTime = Math.max(1, ...series.map((item) => item.elapsedMs)); + addLine(75, 360, 850, 360, 'axis'); + series.forEach((item, index) => { + const x = 90 + index * 730 / Math.max(1, series.length - 1); + const height = item.actions * 230 / maxActions; + addRect(x - 10, 360 - height, 20, height, 'series-primary', { opacity: 0.7 }); + const timeY = 360 - item.elapsedMs * 280 / maxTime; + addCircle(x, timeY, 4, 'series-secondary'); + }); + addText(720, 45, 'Bars: actions', 'chart-label'); + addText(720, 65, 'Dots: elapsed time', 'chart-label'); +} + +function renderBuildDistribution() { + const byBuild = new Map(); + allVisibleRuns().forEach((run) => { + if (!byBuild.has(run.buildNumber)) byBuild.set(run.buildNumber, []); + byBuild.get(run.buildNumber).push(run.durationMs); + }); + const groups = Array.from(byBuild.entries()); + const maximum = Math.max(1, ...groups.flatMap(([, values]) => values)); + groups.forEach(([build, values], index) => { + const x = 180 + index * 540 / Math.max(1, groups.length - 1); + const y = (value) => 350 - value * 280 / maximum; + const min = Math.min(...values); + const max = Math.max(...values); + const q1 = percentile(values, 0.25); + const median = percentile(values, 0.5); + const q3 = percentile(values, 0.75); + addLine(x, y(max), x, y(min), 'axis'); + addRect(x - 35, y(q3), 70, y(q1) - y(q3), 'series-primary'); + addLine(x - 35, y(median), x + 35, y(median), '', { stroke: '#fff', 'stroke-width': 3 }); + addText(x, 385, build || 'unknown', 'chart-label', 'middle'); + }); +} + +function populateRunSelect() { + elements.runSelect.textContent = ''; + const runs = dashboardState && dashboardState.runs || []; + if (!runs.length) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No completed run'; + elements.runSelect.appendChild(option); + elements.runSelect.disabled = true; + return; + } + elements.runSelect.disabled = false; + runs.forEach((run, index) => { + const option = document.createElement('option'); + option.value = run.runRef; + const prefix = run.batch && run.batch.index ? `#${run.batch.index} · ` : ''; + option.textContent = `${prefix}${run.formRef} · ${run.result}`; + elements.runSelect.appendChild(option); + if (run.runRef === dashboardState.selectedRunRef || (!dashboardState.selectedRunRef && index === runs.length - 1)) { + option.selected = true; + } + }); + selectedRun = runs.find((run) => run.runRef === elements.runSelect.value) || runs.at(-1); +} + +function populateChartSelect() { + const view = elements.viewSelect.value; + const current = elements.chartSelect.value; + elements.chartSelect.textContent = ''; + chartRegistry.filter((chart) => chart.group === view).forEach((chart) => { + const availability = chart.availability(); + const option = document.createElement('option'); + option.value = chart.id; + option.textContent = availability.ok ? chart.title : `${chart.title} — unavailable`; + option.disabled = !availability.ok; + option.title = availability.ok ? '' : availability.reason; + elements.chartSelect.appendChild(option); + }); + if (Array.from(elements.chartSelect.options).some((option) => option.value === current)) { + elements.chartSelect.value = current; + } +} + +function renderSummary() { + if (!selectedRun) { + elements.contextText.textContent = 'No completed run is available yet.'; + return; + } + const metrics = selectedRun.metrics || {}; + const issues = issueTotal(selectedRun); + const batchCount = dashboardState.mode === 'batch' ? dashboardState.runs.length : 1; + elements.contextText.textContent = dashboardState.mode === 'batch' + ? `Completed batch · ${batchCount} result${batchCount === 1 ? '' : 's'} · ${selectedRun.formRef} selected` + : `Latest singleton · ${selectedRun.formRef}`; + elements.resultValue.textContent = selectedRun.result.replaceAll('_', ' '); + elements.resultDetail.textContent = selectedRun.failureCategory === 'none' + ? 'Terminal result captured' + : `Category: ${selectedRun.failureCategory.replaceAll('_', ' ')}`; + elements.resultCard.className = `summary-card result-card ${resultClass(selectedRun.result)}`; + elements.durationValue.textContent = formatDuration(selectedRun.durationMs); + elements.fieldsValue.textContent = `${formatNumber(metrics.filled)} / ${formatNumber(metrics.discovered)}`; + elements.issuesValue.textContent = formatNumber(issues); + elements.issuesDetail.textContent = issues ? 'Review the findings and diagnostic ZIP' : 'No aggregate issues recorded'; + elements.interpretation.textContent = plainInterpretation(selectedRun); + elements.formRefValue.textContent = selectedRun.formRef; + elements.passesValue.textContent = formatNumber(metrics.passes); + elements.attachmentsValue.textContent = formatNumber(metrics.attachmentsCompleted); + elements.rowsValue.textContent = formatNumber(metrics.rowsAdded); + elements.confirmationValue.textContent = selectedRun.confirmationCaptured ? 'Captured' : 'Not captured'; + elements.buildValue.textContent = selectedRun.buildNumber || '—'; +} + +function renderChart() { + clearChart(); + elements.chartUnavailable.classList.add('hidden'); + const chart = chartRegistry.find((item) => item.id === elements.chartSelect.value) || + chartRegistry.find((item) => item.group === elements.viewSelect.value); + if (!chart) return; + elements.chartGroupLabel.textContent = chart.group; + elements.chartTitle.textContent = chart.title; + elements.chartDescription.textContent = chart.description; + const availability = chart.availability(); + if (!availability.ok) { + elements.chartUnavailable.textContent = availability.reason; + elements.chartUnavailable.classList.remove('hidden'); + elements.interpretation.textContent = availability.reason; + return; + } + chart.render(); + elements.interpretation.textContent = `${plainInterpretation(selectedRun)} ${chart.description}`; +} + +function render() { + populateRunSelect(); + elements.viewSelect.value = dashboardState && dashboardState.defaultView || 'simple'; + populateChartSelect(); + renderSummary(); + renderChart(); +} + +async function refresh() { + const response = await chrome.runtime.sendMessage({ type: 'GET_DASHBOARD_STATE' }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'Dashboard state is unavailable.'); + } + dashboardState = response.state; + render(); +} + +async function clearHistory() { + if (!window.confirm('Clear all retained PID-free aggregate dashboard history?')) return; + const response = await chrome.runtime.sendMessage({ type: 'CLEAR_DASHBOARD_HISTORY' }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'Dashboard history could not be cleared.'); + } + dashboardState = response.state; + render(); +} + +elements.runSelect.addEventListener('change', () => { + selectedRun = (dashboardState.runs || []).find((run) => run.runRef === elements.runSelect.value) || null; + populateChartSelect(); + renderSummary(); + renderChart(); +}); +elements.viewSelect.addEventListener('change', () => { + populateChartSelect(); + renderChart(); +}); +elements.chartSelect.addEventListener('change', renderChart); +elements.refreshButton.addEventListener('click', () => refresh().catch((error) => { + elements.interpretation.textContent = error && error.message ? error.message : String(error); +})); +elements.clearHistoryButton.addEventListener('click', () => clearHistory().catch((error) => { + elements.interpretation.textContent = error && error.message ? error.message : String(error); +})); +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') refresh().catch(() => undefined); +}); + +refresh().catch((error) => { + elements.interpretation.textContent = error && error.message ? error.message : String(error); +}); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/examples/custom-format-rules.example.json b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/examples/custom-format-rules.example.json new file mode 100644 index 0000000000..97252d8abd --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/examples/custom-format-rules.example.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "exportedAt": "2026-07-22T16:20:00-07:00", + "extensionVersion": "0.4.0", + "rules": [ + { + "id": "irma-number", + "enabled": true, + "labelMatch": "IRMA Number", + "matchMode": "contains", + "caseSensitive": false, + "mask": "aaa-999999", + "notes": "Example program-area identifier" + }, + { + "id": "vehicle-identification-number", + "enabled": true, + "labelMatch": "Vehicle Identification Number", + "matchMode": "contains", + "caseSensitive": false, + "mask": "*****************", + "notes": "Example 17-character alphanumeric identifier" + } + ] +} diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-folder-picker.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-folder-picker.js new file mode 100644 index 0000000000..b9b086d5f9 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-folder-picker.js @@ -0,0 +1,159 @@ +'use strict'; + +(() => { + const PROBE_TIMEOUT_MS = 45000; + const PROBE_VISIBILITY_ATTEMPTS = 10; + + function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); + } + + async function waitForDownload(downloads, downloadId, timeoutMs) { + return await new Promise((resolve, reject) => { + let settled = false; + const finish = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + downloads.onChanged.removeListener(onChanged); + if (error) { + reject(error); + } else { + resolve(); + } + }; + const inspect = (item) => { + if (!item) { + return; + } + if (item.state === 'complete') { + finish(); + } else if (item.state === 'interrupted') { + finish(new Error(item.error || 'The validation download was interrupted.')); + } + }; + const onChanged = (delta) => { + if (!delta || delta.id !== downloadId) { + return; + } + if (delta.state && delta.state.current === 'complete') { + finish(); + } else if (delta.state && delta.state.current === 'interrupted') { + finish(new Error(delta.error && delta.error.current || 'The validation download was interrupted.')); + } + }; + const timer = setTimeout( + () => finish(new Error('Timed out while validating the selected folder.')), + timeoutMs + ); + downloads.onChanged.addListener(onChanged); + downloads.search({ id: downloadId }) + .then((items) => inspect(items && items[0])) + .catch(finish); + }); + } + + async function probeAppearsInSelectedFolder(directoryHandle, probeName, attempts, wait) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await directoryHandle.getFileHandle(probeName); + return true; + } catch (error) { + if (!error || error.name !== 'NotFoundError') { + throw error; + } + } + if (attempt + 1 < attempts) { + await wait(100); + } + } + return false; + } + + async function cleanupProbe(downloads, downloadId) { + if (downloadId === null || downloadId === undefined) { + return; + } + try { + await downloads.removeFile(downloadId); + } catch (error) { + // The file may already be absent after an interrupted or policy-blocked download. + } + try { + await downloads.erase({ id: downloadId }); + } catch (error) { + // Cleanup failure must not hide the validation result. + } + } + + async function validateSelectedFolder(directoryHandle, dependencies) { + const options = dependencies || {}; + const downloads = options.downloads || chrome.downloads; + const exportPath = options.exportPath || ChefsExportPath; + const randomUUID = options.randomUUID || (() => crypto.randomUUID()); + const wait = options.delay || delay; + const probeAttempts = options.probeAttempts || PROBE_VISIBILITY_ATTEMPTS; + const timeoutMs = options.timeoutMs || PROBE_TIMEOUT_MS; + + if (!directoryHandle || directoryHandle.kind !== 'directory') { + throw new Error('Select a folder directly inside Downloads.'); + } + + const folder = exportPath.normalizeExportFolder(directoryHandle.name); + if (!folder || folder.includes('/')) { + throw new Error('Select a folder directly inside Downloads. Clear Export Folder to use Downloads itself.'); + } + + const probeName = `chefs-export-folder-validation-${randomUUID()}.txt`; + const downloadPath = exportPath.joinExportPath(folder, probeName); + const url = `data:text/plain;charset=utf-8,${encodeURIComponent('CHEFS export folder validation. This temporary file should be removed automatically.')}`; + let downloadId = null; + + try { + downloadId = await downloads.download({ + url, + filename: downloadPath, + conflictAction: 'uniquify', + saveAs: false + }); + await waitForDownload(downloads, downloadId, timeoutMs); + const found = await probeAppearsInSelectedFolder( + directoryHandle, + probeName, + probeAttempts, + wait + ); + if (!found) { + const error = new Error( + 'The selected folder is not a direct child of Downloads. Select a folder directly inside Downloads, or type a validated relative path.' + ); + error.code = 'NOT_DOWNLOADS_CHILD'; + throw error; + } + return folder; + } finally { + await cleanupProbe(downloads, downloadId); + } + } + + async function selectValidatedFolder(dependencies) { + const options = dependencies || {}; + const showPicker = options.showDirectoryPicker || globalThis.showDirectoryPicker; + if (typeof showPicker !== 'function') { + throw new Error('Folder selection is unavailable in this browser. Type a Downloads-relative folder instead.'); + } + const directoryHandle = await showPicker({ + id: 'chefs-export-folder', + mode: 'read', + startIn: 'downloads' + }); + return await validateSelectedFolder(directoryHandle, options); + } + + globalThis.ChefsExportFolderPicker = Object.freeze({ + selectValidatedFolder, + validateSelectedFolder + }); +})(); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-path.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-path.js new file mode 100644 index 0000000000..68931333bc --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/export-path.js @@ -0,0 +1,58 @@ +'use strict'; + +(() => { + const INVALID_COMPONENT_CHARACTERS = /[<>:"|?*\u0000-\u001F]/; + const RESERVED_WINDOWS_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; + + function normalizeExportFolder(value) { + const text = String(value || '').trim(); + if (!text) { + return ''; + } + if (text.length > 240) { + throw new Error('The export folder cannot exceed 240 characters.'); + } + if (/^[A-Za-z]:/.test(text) || /^[\\/]/.test(text)) { + throw new Error('Enter a folder relative to Downloads, not an absolute path or drive letter.'); + } + + const components = text.split(/[\\/]/); + if (components.some((component) => component.length === 0)) { + throw new Error('The export folder cannot contain repeated or trailing separators.'); + } + + for (const component of components) { + if (component === '.' || component === '..') { + throw new Error('The export folder cannot contain . or .. path traversal.'); + } + if (component.length > 255) { + throw new Error('An export folder component cannot exceed 255 characters.'); + } + if (INVALID_COMPONENT_CHARACTERS.test(component)) { + throw new Error(`The export folder component "${component}" contains an invalid Windows filename character.`); + } + if (/[. ]$/.test(component)) { + throw new Error(`The export folder component "${component}" cannot end with a period or space.`); + } + if (RESERVED_WINDOWS_NAME.test(component)) { + throw new Error(`The export folder component "${component}" is a reserved Windows name.`); + } + } + + return components.join('/'); + } + + function joinExportPath(subfolder, filename) { + const normalized = normalizeExportFolder(subfolder); + const safeFilename = String(filename || '').replace(/[\\/]/g, ''); + if (!safeFilename) { + throw new Error('An export filename is required.'); + } + return normalized ? `${normalized}/${safeFilename}` : safeFilename; + } + + globalThis.ChefsExportPath = Object.freeze({ + normalizeExportFolder, + joinExportPath + }); +})(); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-128.png b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..b686495513102a9d02618502d1022653b4768fd9 GIT binary patch literal 1054 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrV1DZ9;uumf=k45!fp;7PTz=;) zDv+AcWIvbf$O8@;=E4u2Tt^;i_{_D}_{n$Q)5>%6<;}0^>P7S-eD}-tip9Td_Y7aUf)9pU-msmuYB=1#I8}Ex z$Ab@#Ged*rSvrOF8p`Xfqpp~haNOB+fNOcu%#f9$e>Ea>e>6VabyL=O4YST(fvXRW zCtU3ZndTi~$|;!8xtFhgj_TIVU8O$^1LlM3Kl@0hMZg zc?}JRsh3S#KQtyWh5WoR#p{QIBICN+oK@~sT=E)92O@6pa8`-t>y|J+0P|%r1=A>QYv*u6oF{g--9h`o)H4Bm% z8Q3KpuQM?`7Lt*hr^dR`M!rO!p+NH4&+FG+3Vx`UDKZprm9A3usg0j%!e|s{WGkMh z|1f>ugzC)WBRk|XXU2sEwd#LtWkDpP+fZ>HUXS|z6Mz}sP1S9oy`1B zzCX}D=d9}r{)um9UA!-ABjVuOq7`efg_)sLFK4|={*(`r4!P>-mlkcS;}Kcj$?%Tp zpQ5wV*x0sYxLoTwE0Du*nDs`^p;YTIW{-H*Lh44$rjF6*2UngBf*JLCWW literal 0 HcmV?d00001 diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-32.png b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..98abab72390eefc56fc45832c68845ac3212195b GIT binary patch literal 284 zcmV+%0ptFOP))4ualqTQeSdN>-XZ+lAGjmUu0sOX2K5v z5s|aE`Fnjhkmt?ZGb_M?qg+Yrg8T)~J_^@zZ7RTompQabm{-7nFW)!J)vXIw___`i zD50JNT|k4S>tmci1Wp$yPmkbK0raso383WBdk+bqc4~n`0n|>da3~N>7JVKHXi2Rr zse$hj@FW;PX~WvnhXgE03p^x{!^*XbfHg+dz`c=dBY-BDXrKv8oe5d+Jgljx>qxdI imsx>PG5ycz5cmUj6=R>{6WE9V0000%!F zxr(2$L<;T2m6%s* zrzHxY*EN|n)}jDfyLM#IEARKBQ%DLCVIENaiAN@)%mWi;v^F!!br17^@pU?K=ZmHq znWr(SraSQn!W+`$D$z3;aYANyp<=fF^p@#mF4{bB0Km*zTt{ zU_22Vo" + ] + } + ] +} diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.css b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.css new file mode 100644 index 0000000000..fafc9a2621 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.css @@ -0,0 +1,38 @@ +:root { font-family: Arial, Helvetica, sans-serif; color: #1f2933; background: #f5f7fa; } +body { margin: 0; } +main { max-width: 980px; margin: 32px auto; padding: 24px; background: white; border: 1px solid #d1d5db; border-radius: 8px; } +h1, h2 { color: #003366; } +h2 { margin-top: 0; font-size: 20px; } +section { margin-top: 26px; padding-top: 22px; border-top: 1px solid #d1d5db; } +label { display: block; margin-top: 18px; font-weight: 700; } +textarea, input[type="number"], input[type="text"], select { box-sizing: border-box; width: 100%; margin-top: 6px; padding: 9px; border: 1px solid #9ca3af; border-radius: 4px; background: white; } +.folder-picker-row { display: flex; align-items: stretch; gap: 10px; } +.folder-picker-row input { flex: 1 1 auto; min-width: 0; } +.folder-picker-row button { flex: 0 0 auto; margin-top: 6px; } +.checkbox-row { display: flex; align-items: center; gap: 8px; font-weight: 600; } +.checkbox-row input { width: auto; } +.hint { margin-top: 5px; color: #5f6b76; font-size: 13px; } +.actions, .rule-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-top: 20px; } +.rule-toolbar .inline-label { margin: 0 0 0 auto; } +.rule-toolbar select { width: auto; min-width: 120px; margin: 0; } +button { border: 1px solid #003366; border-radius: 5px; background: #003366; color: white; padding: 10px 18px; font-weight: 700; cursor: pointer; } +button.secondary { background: white; color: #003366; } +button.danger { border-color: #b91c1c; background: white; color: #b91c1c; padding: 7px 11px; } +button:focus, input:focus, select:focus, textarea:focus { outline: 3px solid rgba(26, 90, 150, 0.25); outline-offset: 1px; } +.table-wrap { overflow-x: auto; margin-top: 14px; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 9px; border: 1px solid #d1d5db; text-align: left; vertical-align: middle; } +th { background: #eef3f8; color: #003366; } +td.enabled-cell, td.remove-cell { width: 84px; text-align: center; } +td input[type="checkbox"] { width: 20px; height: 20px; } +td input[type="text"] { margin: 0; min-width: 210px; } +.empty-row td { color: #5f6b76; text-align: center; font-style: italic; } +.message { min-height: 22px; margin-top: 10px; font-weight: 600; } +.message.error { color: #b91c1c; } +.message.success, #savedMessage { color: #166534; } +code { padding: 1px 4px; background: #eef2f7; border-radius: 3px; } +@media (max-width: 700px) { + main { margin: 0; border-radius: 0; } + .rule-toolbar .inline-label { margin-left: 0; } + .folder-picker-row { align-items: stretch; } +} diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.html b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.html new file mode 100644 index 0000000000..2ad0500132 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.html @@ -0,0 +1,154 @@ + + + + + + CHEFS Tester Settings + + + +
    +

    CHEFS One-Click Form Tester Settings

    +

    TEST, UAT, DEV and localhost CHEFS hosts are allowed automatically. Production-like hosts remain blocked unless explicitly enabled here.

    + +
    +

    Run settings

    + + +

    Enter one exact hostname per line. Do not include protocol or paths.

    + + + + + + + +
    + +
    +

    Custom field formats

    +

    Use these rules when a field needs a program-specific identifier format. Rules match normalized visible label text and take precedence over masks detected from the CHEFS component.

    + +
    + + + + + + +
    + +
    + + + + + + + + + + +
    EnabledLabel keyword or phraseCHEFS input maskRemove
    +
    +

    Standard CHEFS mask tokens: 9 numeric, a alphabetic, * alphanumeric. Literal punctuation and spaces are preserved. Example: aaa-999999.

    +
    +
    + +
    +

    Export settings

    + + +
    + + +
    +

    Leave blank to save exports directly to the browser's Downloads folder. To organize exports, enter an optional Downloads-relative folder such as CHEFS Exports.

    +

    Select validates folders directly inside Downloads with a temporary file. The file is removed automatically. Type a path when using a validated nested relative folder.

    +
    + + +

    After a run ends in success, failure, a stall, a block, a safety stop or a user stop, download one finalized troubleshooting ZIP without an extension-requested Save As window. Browser or workplace download policies can still require confirmation.

    +
    + +
    +

    Batch regression launcher

    +

    Enable the project-level batch file to open marked regression tabs and run them sequentially through this extension.

    + + + + +
    + + +
    +

    The token must match LAUNCHER_TOKEN in the project batch file. It prevents unrelated marked URLs from entering the queue.

    + + + +

    Enter one exact HTTP or HTTPS origin per line, without a path. Every origin must also pass the existing approved non-production environment checks.

    + +
    + +
    +

    Chrome requires one-time host access before the extension can start runs in tabs opened by an external batch file.

    +
    +
    + +
    +

    Results dashboard

    +

    The dashboard is an internal extension page built only from PID-free aggregate run metrics.

    + + +

    A singleton opens the dashboard after finalization. A batch opens it once after the entire queue finishes. An existing dashboard tab is refreshed and reused.

    + + + + + +

    Disabled by default. When enabled, retain at most 200 aggregate records and 90 days. No field values, labels, names, emails, attachments, screenshots, URLs or confirmation IDs are retained.

    + +
    + + +
    +
    +
    + +
    +
    + + +
    +
    + + + + + diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js new file mode 100644 index 0000000000..1505c0e843 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js @@ -0,0 +1,491 @@ +'use strict'; + +const VERSION = '0.4.0'; +const RULE_SCHEMA_VERSION = 1; +const DEFAULT_SETTINGS = { + additionalHosts: [], + allowProduction: false, + rowsPerGrid: 2, + captureScreenshot: true, + customFormatRules: [], + exportFolder: '', + autoExportAfterRun: false, + batchLauncherEnabled: false, + batchLauncherToken: '', + batchOrigins: [], + openDashboardAfterCompletion: false, + retainDashboardHistory: false, + dashboardDefaultView: 'simple' +}; + +let rules = []; + +function newRuleId() { + if (crypto && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `rule-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function normalizePhrase(value) { + return String(value || '') + .replace(/<[^>]*>/g, ' ') + .replace(/[\u00a0\s]+/g, ' ') + .replace(/^[*:\-\s]+|[*:\-\s]+$/g, '') + .trim(); +} + +function normalizeRule(raw) { + const source = raw || {}; + return { + id: String(source.id || newRuleId()), + enabled: source.enabled !== false, + labelMatch: normalizePhrase(source.labelMatch || source.labelPhrase || source.label || ''), + matchMode: source.matchMode === 'exact' ? 'exact' : 'contains', + caseSensitive: Boolean(source.caseSensitive), + mask: String(source.mask || '').trim(), + notes: String(source.notes || '').trim() + }; +} + +function validateMask(mask) { + const text = String(mask || '').trim(); + if (!text) { + return 'A mask is required.'; + } + if (text.length > 200) { + return 'Masks cannot exceed 200 characters.'; + } + if (!/[9a*]/.test(text)) { + return 'The mask must contain at least one 9, a, or * token.'; + } + if (/[^\x20-\x7E]/.test(text)) { + return 'The mask must contain printable characters only.'; + } + return ''; +} + +function validateRules(candidateRules) { + const normalized = candidateRules.map(normalizeRule); + const errors = []; + const ids = new Set(); + const identities = new Set(); + normalized.forEach((rule, index) => { + const row = index + 1; + if (!rule.labelMatch) { + errors.push(`Row ${row}: a label keyword or phrase is required.`); + } + const maskError = validateMask(rule.mask); + if (maskError) { + errors.push(`Row ${row}: ${maskError}`); + } + if (ids.has(rule.id)) { + errors.push(`Row ${row}: duplicate rule ID ${rule.id}.`); + } + ids.add(rule.id); + const identity = `${rule.labelMatch.toLowerCase()}\u0000${rule.mask}\u0000${rule.matchMode}`; + if (identities.has(identity)) { + errors.push(`Row ${row}: duplicate label-and-mask rule.`); + } + identities.add(identity); + }); + return { rules: normalized, errors }; +} + +function setRulesMessage(text, type) { + const element = document.getElementById('rulesMessage'); + element.textContent = text || ''; + element.className = `message${type ? ` ${type}` : ''}`; +} + +function setSettingsMessage(text, type) { + const element = document.getElementById('settingsMessage'); + element.textContent = text || ''; + element.className = `message${type ? ` ${type}` : ''}`; +} + +function setExportFolderMessage(text, type) { + const element = document.getElementById('exportFolderMessage'); + element.textContent = text || ''; + element.className = `message${type ? ` ${type}` : ''}`; +} + +function setBatchSettingsMessage(text, type) { + const element = document.getElementById('batchSettingsMessage'); + element.textContent = text || ''; + element.className = `message${type ? ` ${type}` : ''}`; +} + +function normalizeBatchLauncherToken(value) { + const token = String(value || '').trim(); + if (!token) { + return ''; + } + if (!/^[A-Za-z0-9_-]{16,128}$/.test(token)) { + throw new Error('The launcher token must contain 16 to 128 letters, numbers, underscores or hyphens.'); + } + return token; +} + +function normalizeBatchOrigins(values) { + const origins = Array.isArray(values) + ? values + : String(values || '').split(/\r?\n/); + const normalized = []; + for (const rawValue of origins) { + const value = String(rawValue || '').trim(); + if (!value) { + continue; + } + let url; + try { + url = new URL(value); + } catch (error) { + throw new Error(`Invalid batch origin: ${value}`); + } + if (!['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash) { + throw new Error(`Enter an exact HTTP or HTTPS origin without a path: ${value}`); + } + normalized.push(url.origin); + } + return Array.from(new Set(normalized)); +} + +function permissionPatternForOrigin(origin) { + const url = new URL(origin); + return `${url.protocol}//${url.hostname}/*`; +} + +function renderRules() { + const body = document.getElementById('rulesBody'); + body.textContent = ''; + if (!rules.length) { + const row = document.createElement('tr'); + row.className = 'empty-row'; + const cell = document.createElement('td'); + cell.colSpan = 4; + cell.textContent = 'No custom field format rules are configured.'; + row.appendChild(cell); + body.appendChild(row); + return; + } + + rules.forEach((rule, index) => { + const row = document.createElement('tr'); + row.dataset.ruleId = rule.id; + + const enabledCell = document.createElement('td'); + enabledCell.className = 'enabled-cell'; + const enabled = document.createElement('input'); + enabled.type = 'checkbox'; + enabled.checked = rule.enabled !== false; + enabled.setAttribute('aria-label', `Enable custom format rule ${index + 1}`); + enabled.addEventListener('change', () => { rule.enabled = enabled.checked; }); + enabledCell.appendChild(enabled); + + const phraseCell = document.createElement('td'); + const phrase = document.createElement('input'); + phrase.type = 'text'; + phrase.value = rule.labelMatch; + phrase.placeholder = 'IRMA Number'; + phrase.setAttribute('aria-label', `Label keyword or phrase for rule ${index + 1}`); + phrase.addEventListener('input', () => { rule.labelMatch = phrase.value; }); + phraseCell.appendChild(phrase); + + const maskCell = document.createElement('td'); + const mask = document.createElement('input'); + mask.type = 'text'; + mask.value = rule.mask; + mask.placeholder = 'aaa-999999'; + mask.setAttribute('aria-label', `CHEFS input mask for rule ${index + 1}`); + mask.addEventListener('input', () => { rule.mask = mask.value; }); + maskCell.appendChild(mask); + + const removeCell = document.createElement('td'); + removeCell.className = 'remove-cell'; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'danger'; + remove.textContent = 'Remove'; + remove.setAttribute('aria-label', `Remove custom format rule ${index + 1}`); + remove.addEventListener('click', () => { + rules = rules.filter((item) => item.id !== rule.id); + renderRules(); + setRulesMessage('Rule removed. Save settings to keep this change.', 'success'); + }); + removeCell.appendChild(remove); + + row.append(enabledCell, phraseCell, maskCell, removeCell); + body.appendChild(row); + }); +} + +async function loadSettings() { + const stored = await chrome.storage.local.get('chefsTesterSettings'); + const existing = stored.chefsTesterSettings || {}; + const settings = Object.assign({}, DEFAULT_SETTINGS, existing); + if (!Object.prototype.hasOwnProperty.call(existing, 'autoExportAfterRun')) { + settings.autoExportAfterRun = Boolean(existing.autoExportAfterSubmit); + } + document.getElementById('additionalHosts').value = (settings.additionalHosts || []).join('\n'); + document.getElementById('allowProduction').checked = Boolean(settings.allowProduction); + document.getElementById('rowsPerGrid').value = String(Math.max(2, settings.rowsPerGrid || 2)); + document.getElementById('captureScreenshot').checked = settings.captureScreenshot !== false; + document.getElementById('exportFolder').value = settings.exportFolder || ''; + document.getElementById('autoExportAfterRun').checked = Boolean(settings.autoExportAfterRun); + document.getElementById('batchLauncherEnabled').checked = Boolean(settings.batchLauncherEnabled); + document.getElementById('batchLauncherToken').value = settings.batchLauncherToken || ''; + document.getElementById('batchOrigins').value = (settings.batchOrigins || []).join('\n'); + document.getElementById('openDashboardAfterCompletion').checked = + Boolean(settings.openDashboardAfterCompletion); + document.getElementById('retainDashboardHistory').checked = + Boolean(settings.retainDashboardHistory); + document.getElementById('dashboardDefaultView').value = + ['simple', 'analyst', 'statistical', 'experimental'].includes(settings.dashboardDefaultView) + ? settings.dashboardDefaultView + : 'simple'; + rules = Array.isArray(settings.customFormatRules) ? settings.customFormatRules.map(normalizeRule) : []; + renderRules(); +} + +function collectSettings() { + const additionalHosts = document.getElementById('additionalHosts').value + .split(/\r?\n/) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + const rowsPerGrid = Math.max(2, Math.min(5, Number(document.getElementById('rowsPerGrid').value) || 2)); + const validation = validateRules(rules); + if (validation.errors.length) { + throw new Error(validation.errors.join('\n')); + } + const exportFolder = ChefsExportPath.normalizeExportFolder( + document.getElementById('exportFolder').value + ); + const batchLauncherEnabled = document.getElementById('batchLauncherEnabled').checked; + const batchLauncherToken = normalizeBatchLauncherToken( + document.getElementById('batchLauncherToken').value + ); + const batchOrigins = normalizeBatchOrigins(document.getElementById('batchOrigins').value); + if (batchLauncherEnabled && !batchLauncherToken) { + throw new Error('Generate or enter a launcher token before enabling the batch regression launcher.'); + } + if (batchLauncherEnabled && !batchOrigins.length) { + throw new Error('Add at least one permitted regression origin before enabling the batch regression launcher.'); + } + rules = validation.rules; + return { + additionalHosts: Array.from(new Set(additionalHosts)), + allowProduction: document.getElementById('allowProduction').checked, + rowsPerGrid, + captureScreenshot: document.getElementById('captureScreenshot').checked, + customFormatRules: rules, + exportFolder, + autoExportAfterRun: document.getElementById('autoExportAfterRun').checked, + batchLauncherEnabled, + batchLauncherToken, + batchOrigins, + openDashboardAfterCompletion: + document.getElementById('openDashboardAfterCompletion').checked, + retainDashboardHistory: + document.getElementById('retainDashboardHistory').checked, + dashboardDefaultView: + document.getElementById('dashboardDefaultView').value + }; +} + +async function saveSettings() { + try { + const settings = collectSettings(); + await chrome.storage.local.set({ chefsTesterSettings: settings }); + renderRules(); + setSettingsMessage('', ''); + setRulesMessage(`${rules.length} custom format rule${rules.length === 1 ? '' : 's'} saved.`, 'success'); + const message = document.getElementById('savedMessage'); + message.textContent = 'Saved.'; + setTimeout(() => { message.textContent = ''; }, 1800); + } catch (error) { + setSettingsMessage(error.message, 'error'); + } +} + +async function selectExportFolder() { + const input = document.getElementById('exportFolder'); + const button = document.getElementById('selectExportFolderButton'); + const previousValue = input.value; + button.disabled = true; + setExportFolderMessage('Select a folder directly inside Downloads...', ''); + try { + const folder = await ChefsExportFolderPicker.selectValidatedFolder(); + input.value = folder; + setExportFolderMessage( + `Validated ${folder}. Select Save Settings to keep this Export Folder.`, + 'success' + ); + } catch (error) { + input.value = previousValue; + if (error && error.name === 'AbortError') { + setExportFolderMessage('Folder selection cancelled.', ''); + } else { + setExportFolderMessage(error && error.message ? error.message : String(error), 'error'); + } + } finally { + button.disabled = false; + } +} + +function generateBatchToken() { + const token = crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, ''); + document.getElementById('batchLauncherToken').value = token; + setBatchSettingsMessage('Token generated. Copy it into LAUNCHER_TOKEN in the batch file, then save Settings.', 'success'); +} + +async function grantBatchPermissions() { + const button = document.getElementById('grantBatchPermissionsButton'); + button.disabled = true; + try { + const origins = normalizeBatchOrigins(document.getElementById('batchOrigins').value); + if (!origins.length) { + throw new Error('Add at least one permitted regression origin first.'); + } + const granted = await chrome.permissions.request({ + origins: origins.map(permissionPatternForOrigin) + }); + if (!granted) { + throw new Error('Chrome did not grant access to the listed regression origins.'); + } + setBatchSettingsMessage('Host access granted. Select Save Settings to keep the origin list.', 'success'); + } catch (error) { + setBatchSettingsMessage(error && error.message ? error.message : String(error), 'error'); + } finally { + button.disabled = false; + } +} + +function setDashboardSettingsMessage(text, type) { + const element = document.getElementById('dashboardSettingsMessage'); + element.textContent = text || ''; + element.className = `message${type ? ` ${type}` : ''}`; +} + +async function openDashboard() { + try { + const response = await chrome.runtime.sendMessage({ type: 'OPEN_DASHBOARD' }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'The dashboard could not be opened.'); + } + setDashboardSettingsMessage('Dashboard opened.', 'success'); + } catch (error) { + setDashboardSettingsMessage(error && error.message ? error.message : String(error), 'error'); + } +} + +async function clearDashboardHistory() { + if (!window.confirm('Clear all retained PID-free aggregate dashboard history?')) { + return; + } + try { + const response = await chrome.runtime.sendMessage({ type: 'CLEAR_DASHBOARD_HISTORY' }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'Dashboard history could not be cleared.'); + } + setDashboardSettingsMessage('Dashboard history cleared.', 'success'); + } catch (error) { + setDashboardSettingsMessage(error && error.message ? error.message : String(error), 'error'); + } +} + +function exportRules() { + try { + const validation = validateRules(rules); + if (validation.errors.length) { + throw new Error(validation.errors.join('\n')); + } + const payload = { + schemaVersion: RULE_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + extensionVersion: VERSION, + rules: validation.rules + }; + const data = `data:application/json;charset=utf-8,${encodeURIComponent(JSON.stringify(payload, null, 2))}`; + const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); + chrome.downloads.download({ + url: data, + filename: `chefs-custom-format-rules-v${RULE_SCHEMA_VERSION}-${stamp}.json`, + saveAs: true + }); + setRulesMessage(`Exported ${validation.rules.length} rule${validation.rules.length === 1 ? '' : 's'}.`, 'success'); + } catch (error) { + setRulesMessage(error.message, 'error'); + } +} + +function mergeRules(localRules, importedRules) { + const byId = new Map(localRules.map((rule) => [rule.id, normalizeRule(rule)])); + const identities = new Map(); + for (const rule of byId.values()) { + identities.set(`${rule.labelMatch.toLowerCase()}\u0000${rule.mask}\u0000${rule.matchMode}`, rule.id); + } + for (const imported of importedRules.map(normalizeRule)) { + const identity = `${imported.labelMatch.toLowerCase()}\u0000${imported.mask}\u0000${imported.matchMode}`; + if (byId.has(imported.id)) { + byId.set(imported.id, imported); + identities.set(identity, imported.id); + } else if (!identities.has(identity)) { + byId.set(imported.id, imported); + identities.set(identity, imported.id); + } + } + return Array.from(byId.values()); +} + +async function importRulesFromFile(file) { + try { + const text = await file.text(); + const payload = JSON.parse(text); + if (!payload || payload.schemaVersion !== RULE_SCHEMA_VERSION || !Array.isArray(payload.rules)) { + throw new Error(`The file must use rule schema version ${RULE_SCHEMA_VERSION} and contain a rules array.`); + } + const validation = validateRules(payload.rules); + if (validation.errors.length) { + throw new Error(validation.errors.join('\n')); + } + const mode = document.getElementById('importMode').value; + rules = mode === 'replace' ? validation.rules : mergeRules(rules, validation.rules); + renderRules(); + setRulesMessage(`Imported ${validation.rules.length} rule${validation.rules.length === 1 ? '' : 's'} using ${mode} mode. Save settings to keep the changes.`, 'success'); + } catch (error) { + setRulesMessage(`Import rejected: ${error.message}`, 'error'); + } finally { + document.getElementById('rulesFileInput').value = ''; + } +} + +document.getElementById('addRuleButton').addEventListener('click', () => { + rules.push(normalizeRule({ labelMatch: '', mask: '', enabled: true })); + renderRules(); + const lastRowInput = document.querySelector('#rulesBody tr:last-child input[type="text"]'); + if (lastRowInput) { + lastRowInput.focus(); + } +}); +document.getElementById('importRulesButton').addEventListener('click', () => document.getElementById('rulesFileInput').click()); +document.getElementById('exportRulesButton').addEventListener('click', exportRules); +document.getElementById('rulesFileInput').addEventListener('change', (event) => { + const file = event.target.files && event.target.files[0]; + if (file) { + importRulesFromFile(file); + } +}); +document.getElementById('saveButton').addEventListener('click', saveSettings); +document.getElementById('selectExportFolderButton').addEventListener('click', selectExportFolder); +document.getElementById('exportFolder').addEventListener('input', () => setExportFolderMessage('', '')); +document.getElementById('generateBatchTokenButton').addEventListener('click', generateBatchToken); +document.getElementById('grantBatchPermissionsButton').addEventListener('click', grantBatchPermissions); +document.getElementById('openDashboardButton').addEventListener('click', openDashboard); +document.getElementById('clearDashboardHistoryButton').addEventListener('click', clearDashboardHistory); +loadSettings(); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/page-bridge.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/page-bridge.js new file mode 100644 index 0000000000..82cc4d76ab --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/page-bridge.js @@ -0,0 +1,717 @@ +'use strict'; + +(function installChefsTesterBridge() { + if (window.__CHEFS_TESTER_PAGE_BRIDGE__) { + window.postMessage({ channel: 'CHEFS_TESTER_BRIDGE', type: 'BRIDGE_READY' }, '*'); + return; + } + window.__CHEFS_TESTER_PAGE_BRIDGE__ = true; + + let cachedForm = null; + + function isObject(value) { + return value !== null && (typeof value === 'object' || typeof value === 'function'); + } + + function isFormInstance(value) { + return isObject(value) && + typeof value.everyComponent === 'function' && + typeof value.getComponent === 'function' && + (typeof value.checkValidity === 'function' || typeof value.checkData === 'function'); + } + + function normalizeFormCandidate(value) { + if (isFormInstance(value)) { + return value; + } + if (value && isFormInstance(value.root)) { + return value.root; + } + if (value && isFormInstance(value.formio)) { + return value.formio; + } + if (value && isFormInstance(value.webform)) { + return value.webform; + } + return null; + } + + function safeOwnValues(value) { + const preferredKeys = [ + 'formio', 'form', 'webform', 'instance', 'formInstance', 'formioForm', + 'root', 'currentForm', 'component', 'proxy', 'ctx', 'setupState', + 'exposed', 'subTree', 'provides', 'appContext', '_instance', + '__vueParentComponent', '__vue_app__', '__vue__' + ]; + const values = []; + for (const key of preferredKeys) { + try { + if (key in value) { + values.push(value[key]); + } + } catch (error) { + // Ignore inaccessible properties. + } + } + let keys = []; + try { + keys = Object.keys(value).slice(0, 80); + } catch (error) { + return values; + } + for (const key of keys) { + if (preferredKeys.includes(key)) { + continue; + } + try { + const child = value[key]; + if (isObject(child)) { + values.push(child); + } + } catch (error) { + // Ignore getters that throw. + } + } + return values; + } + + function findFormInstance() { + if (isFormInstance(cachedForm)) { + return cachedForm; + } + + const roots = []; + const knownWindowKeys = [ + 'formio', 'form', 'webform', 'formInstance', 'formioForm', + '__formio', '__FORMIO_FORM__', 'chefsForm' + ]; + for (const key of knownWindowKeys) { + try { + if (window[key]) { + roots.push(window[key]); + } + } catch (error) { + // Ignore inaccessible globals. + } + } + + const formElement = document.querySelector('[ref="webform"], .formio-form'); + const appElement = document.querySelector('#app'); + const candidates = [formElement, appElement, document.body, document.documentElement].filter(Boolean); + for (const element of candidates) { + roots.push(element); + try { + roots.push(element.__vueParentComponent, element.__vue__, element.__vue_app__); + } catch (error) { + // Ignore inaccessible framework internals. + } + } + + const queue = roots.filter(Boolean).map((value) => ({ value, depth: 0 })); + const visited = new WeakSet(); + let inspected = 0; + + while (queue.length && inspected < 5000) { + const item = queue.shift(); + const value = item.value; + const depth = item.depth; + if (!isObject(value)) { + continue; + } + if (visited.has(value)) { + continue; + } + visited.add(value); + inspected += 1; + + const normalized = normalizeFormCandidate(value); + if (normalized) { + cachedForm = normalized; + return cachedForm; + } + if (depth >= 6) { + continue; + } + for (const child of safeOwnValues(value)) { + if (isObject(child) && !visited.has(child)) { + queue.push({ value: child, depth: depth + 1 }); + } + } + } + return null; + } + + function sanitizeValueType(value) { + if (Array.isArray(value)) { + return 'array'; + } + if (value === null) { + return 'null'; + } + return typeof value; + } + + function serializeMaskValue(value) { + if (Array.isArray(value)) { + return value.length ? String(value[0] || '') : ''; + } + if (typeof value === 'string' || typeof value === 'number') { + return String(value); + } + return ''; + } + + function runtimeInputMask(wrapper, instance) { + const controls = []; + if (wrapper) { + controls.push(...Array.from(wrapper.querySelectorAll('input:not([type="hidden"]), textarea'))); + } + if (instance && instance.refs) { + for (const value of Object.values(instance.refs)) { + if (value instanceof HTMLInputElement || value instanceof HTMLTextAreaElement) { + controls.push(value); + } else if (Array.isArray(value)) { + controls.push(...value.filter((item) => item instanceof HTMLInputElement || item instanceof HTMLTextAreaElement)); + } + } + } + for (const control of controls) { + try { + if (control.inputmask && control.inputmask.opts && control.inputmask.opts.mask) { + const mask = serializeMaskValue(control.inputmask.opts.mask); + if (mask) { + return mask; + } + } + } catch (error) { + // Continue through rendered attributes. + } + const direct = control.getAttribute('data-inputmask-mask') || control.getAttribute('data-mask'); + if (direct) { + return direct; + } + const encoded = control.getAttribute('data-inputmask'); + if (encoded) { + const match = encoded.match(/(?:mask\s*[:=]\s*['"])([^'"]+)/i); + if (match) { + return match[1]; + } + } + } + return ''; + } + + function sanitizeComponent(instance) { + const component = instance && instance.component ? instance.component : {}; + const validate = component.validate || {}; + const values = Array.isArray(component.values) + ? component.values.slice(0, 100).map((item) => ({ + label: item && item.label !== undefined ? String(item.label) : '', + valueType: item ? sanitizeValueType(item.value) : 'undefined', + value: item && ['string', 'number', 'boolean'].includes(typeof item.value) ? item.value : undefined + })) + : []; + const element = instance && instance.element ? instance.element : null; + const wrapper = element && element.closest ? element.closest('.formio-component') : null; + return { + key: component.key || instance.key || '', + path: instance.path || '', + domId: (wrapper && wrapper.id) || (element && element.id) || '', + instanceId: instance.id || component.id || '', + type: component.type || instance.type || '', + label: component.label || '', + description: component.description || '', + placeholder: component.placeholder || '', + input: component.input !== false, + hidden: Boolean(component.hidden), + disabled: Boolean(component.disabled || instance.disabled), + readOnly: Boolean(component.readOnly), + calculateValue: Boolean(component.calculateValue), + customDefaultValue: Boolean(component.customDefaultValue), + persistent: component.persistent, + multiple: Boolean(component.multiple), + dataSrc: component.dataSrc || '', + widgetType: component.widget && component.widget.type ? component.widget.type : component.widget || '', + filePattern: component.filePattern || '', + fileMinSize: component.fileMinSize || '', + fileMaxSize: component.fileMaxSize || '', + currency: component.currency || '', + delimiter: component.delimiter, + inputMask: serializeMaskValue(component.inputMask || component.mask || ''), + runtimeInputMask: runtimeInputMask(wrapper, instance), + minLength: validate.minLength, + maxLength: validate.maxLength, + minWords: validate.minWords, + maxWords: validate.maxWords, + minSelectedCount: validate.minSelectedCount !== undefined + ? validate.minSelectedCount + : component.minSelectedCount, + maxSelectedCount: validate.maxSelectedCount !== undefined + ? validate.maxSelectedCount + : component.maxSelectedCount, + min: validate.min, + max: validate.max, + pattern: validate.pattern || '', + required: Boolean(validate.required), + validationMessage: validate.customMessage || '', + values, + hasValue: typeof instance.hasValue === 'function' ? Boolean(instance.hasValue()) : undefined, + valueType: sanitizeValueType(instance.dataValue) + }; + } + + function getComponents() { + const form = findFormInstance(); + if (!form) { + return { formFound: false, components: [] }; + } + const components = []; + form.everyComponent((instance) => { + try { + components.push(sanitizeComponent(instance)); + } catch (error) { + components.push({ + key: instance && instance.key ? instance.key : '', + type: instance && instance.type ? instance.type : '', + metadataError: error && error.message ? error.message : String(error) + }); + } + }); + return { + formFound: true, + formType: form.display || (form.form && form.form.display) || '', + formLoading: Boolean(form.loading), + componentCount: components.length, + components + }; + } + + function findComponentCandidates(key, wrapperId) { + const form = findFormInstance(); + if (!form || (!key && !wrapperId)) { + return []; + } + const candidates = []; + const seen = new Set(); + const add = (instance) => { + if (!instance || seen.has(instance)) { + return; + } + const component = instance.component || {}; + const instanceKey = component.key || instance.key || ''; + const path = instance.path || ''; + const element = instance.element || null; + const wrapper = element && element.closest ? element.closest('.formio-component') : null; + const instanceDomId = (wrapper && wrapper.id) || (element && element.id) || ''; + const instanceId = instance.id || component.id || ''; + const keyMatches = Boolean( + key && + ( + instanceKey === key || + path === key || + path.endsWith(`.${key}`) || + path.endsWith(`[${key}]`) + ) + ); + const idMatches = Boolean(wrapperId && (instanceDomId === wrapperId || instanceId === wrapperId)); + if (keyMatches || idMatches) { + seen.add(instance); + candidates.push(instance); + } + }; + if (key) { + try { + add(form.getComponent(key)); + } catch (error) { + // Continue with full component traversal. + } + } + try { + form.everyComponent((instance) => add(instance)); + } catch (error) { + // Return whatever was discovered. + } + return candidates; + } + + function findComponent(key, predicate) { + const candidates = findComponentCandidates(key); + if (typeof predicate === 'function') { + return candidates.find(predicate) || candidates[0] || null; + } + return candidates[0] || null; + } + + function callableMethodNames(instance) { + const names = new Set(); + let current = instance; + let depth = 0; + while (current && depth < 5) { + let properties = []; + try { + properties = Object.getOwnPropertyNames(current); + } catch (error) { + properties = []; + } + for (const name of properties) { + try { + if (typeof instance[name] === 'function' && /file|upload|drop/i.test(name)) { + names.add(name); + } + } catch (error) { + // Ignore inaccessible functions. + } + } + current = Object.getPrototypeOf(current); + depth += 1; + } + return Array.from(names).sort(); + } + + async function setComponentValue(payload) { + const candidates = findComponentCandidates(payload.key, payload.wrapperId); + const component = candidates[0] || null; + if (!component || typeof component.setValue !== 'function') { + throw new Error(`Form.io component was not found for ${payload.key}.`); + } + const changed = component.setValue(payload.value, { + modified: true, + fromSubmission: false, + noUpdateEvent: false + }); + if (typeof component.triggerChange === 'function') { + component.triggerChange({ modified: true }); + } + if (component.root && typeof component.root.checkData === 'function') { + component.root.checkData(component.root.data, { modified: true }); + } + return { + changed: Boolean(changed), + valueType: sanitizeValueType(component.dataValue), + hasValue: typeof component.hasValue === 'function' ? Boolean(component.hasValue()) : undefined + }; + } + + async function setMaskedValue(payload) { + const wrapper = findRenderedWrapper(payload.key, payload.wrapperId); + const control = wrapper && wrapper.querySelector('input:not([type="hidden"]), textarea'); + let inputmaskUsed = false; + let inputmaskComplete = false; + if (control) { + try { + if (control.inputmask && typeof control.inputmask.setValue === 'function') { + control.inputmask.setValue(payload.value); + inputmaskUsed = true; + inputmaskComplete = typeof control.inputmask.isComplete === 'function' + ? Boolean(control.inputmask.isComplete()) + : false; + } else { + control.value = payload.value; + } + } catch (error) { + control.value = payload.value; + } + control.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + control.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + control.dispatchEvent(new Event('blur', { bubbles: true, composed: true })); + } + + const candidates = findComponentCandidates(payload.key, payload.wrapperId); + const component = candidates[0] || null; + let changed = false; + if (component && typeof component.setValue === 'function') { + changed = Boolean(component.setValue(payload.value, { + modified: true, + fromSubmission: false, + noUpdateEvent: false + })); + if (typeof component.triggerChange === 'function') { + component.triggerChange({ modified: true }); + } + if (component.root && typeof component.root.checkData === 'function') { + component.root.checkData(component.root.data, { modified: true }); + } + } + const liveWrapper = findRenderedWrapper(payload.key, payload.wrapperId) || wrapper; + const liveControl = liveWrapper && liveWrapper.querySelector('input:not([type="hidden"]), textarea'); + if (liveControl && liveControl.inputmask && typeof liveControl.inputmask.isComplete === 'function') { + try { + inputmaskComplete = Boolean(liveControl.inputmask.isComplete()); + } catch (error) { + // Keep the earlier completion state. + } + } + return { + changed, + inputmaskUsed, + inputmaskComplete, + renderedValue: liveControl ? String(liveControl.value || '') : '', + hasValue: component && typeof component.hasValue === 'function' + ? Boolean(component.hasValue()) + : Boolean(liveControl && liveControl.value) + }; + } + + function base64ToBytes(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + + function createDragEvent(type, dataTransfer) { + try { + return new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer }); + } catch (error) { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + return event; + } + } + + function renderedWrapperIsVisible(item) { + if (!item || !item.isConnected || item.closest('.formio-hidden, [hidden], [aria-hidden="true"]')) { + return false; + } + const style = getComputedStyle(item); + return style.display !== 'none' && + style.visibility !== 'hidden' && + Number(style.opacity) !== 0 && + item.getClientRects().length > 0; + } + + function findRenderedWrapper(key, wrapperId) { + if (wrapperId) { + const exact = document.getElementById(wrapperId); + if (renderedWrapperIsVisible(exact)) { + return exact; + } + } + if (!key) { + return null; + } + const escapedKey = window.CSS && typeof window.CSS.escape === 'function' + ? window.CSS.escape(key) + : String(key).replace(/[^A-Za-z0-9_-]/g, '\\$&'); + const wrappers = Array.from(document.querySelectorAll(`.formio-component-${escapedKey}`)); + return wrappers.find((item) => renderedWrapperIsVisible(item)) || + wrappers.find((item) => item.isConnected) || + null; + } + + function renderedFileRows(wrapper, filename) { + if (!wrapper) { + return []; + } + const candidates = Array.from(wrapper.querySelectorAll( + '.list-group > .list-group-item:not(.list-group-header), ' + + '.list-group-item:not(.list-group-header), tbody tr:not(:first-child), ' + + '[ref="fileLink"], [ref="fileName"], .file-name, .file-list a, a[download]' + )); + const matches = candidates.filter((element) => { + const text = String(element.textContent || '').replace(/\s+/g, ' ').trim(); + const hasRemoveControl = Boolean(element.querySelector && element.querySelector( + 'button[ref*="remove"], button[aria-label*="remove" i], .fa-times, .fa-times-circle-o' + )); + if (filename && text.includes(filename)) { + return true; + } + if (hasRemoveControl && text) { + return true; + } + if (!text) { + return false; + } + return !/^file\s*name\s*size$/i.test(text) && !/drop files to attach|browse to attach/i.test(text); + }); + return Array.from(new Set(matches.map((element) => + element.closest('.list-group-item:not(.list-group-header), tbody tr') || element + ))); + } + + async function uploadFileByDomDrop(payload, file) { + let wrapper = findRenderedWrapper(payload.key, payload.wrapperId); + const dropTarget = wrapper && (wrapper.querySelector('[ref="fileDrop"], .fileSelector') || wrapper); + if (!dropTarget) { + throw new Error(`No rendered file drop target was found for ${payload.key}.`); + } + const baselineCount = renderedFileRows(wrapper).length; + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + dropTarget.dispatchEvent(createDragEvent('dragenter', dataTransfer)); + dropTarget.dispatchEvent(createDragEvent('dragover', dataTransfer)); + dropTarget.dispatchEvent(createDragEvent('drop', dataTransfer)); + + const started = Date.now(); + let wrapperReplaced = false; + while (Date.now() - started < 45000) { + const liveWrapper = findRenderedWrapper(payload.key, payload.wrapperId); + if (liveWrapper && wrapper && liveWrapper !== wrapper) { + wrapperReplaced = true; + } + wrapper = liveWrapper || wrapper; + const uploadedRows = renderedFileRows(wrapper, payload.filename); + const allRows = renderedFileRows(wrapper); + if ( + uploadedRows.length || + allRows.length > baselineCount || + (wrapper && String(wrapper.textContent || '').includes(payload.filename)) + ) { + return { + hasValue: true, + valueCount: Math.max(1, uploadedRows.length, allRows.length), + pendingUploads: 0, + syncing: false, + uploadMethod: wrapperReplaced ? 'page-dom-drop-rerendered-wrapper' : 'page-dom-drop', + componentType: 'rendered-file', + candidateCount: 0 + }; + } + const errorElement = wrapper && wrapper.querySelector('.formio-errors, .invalid-feedback'); + const message = String(errorElement ? errorElement.textContent : '').trim(); + if (message) { + throw new Error(message); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`The rendered drop target for ${payload.key} did not produce an uploaded file row before timeout.`); + } + + async function uploadFile(payload) { + const candidates = findComponentCandidates(payload.key, payload.wrapperId); + if (!candidates.length) { + throw new Error(`No Form.io component instance was found for ${payload.key}.`); + } + const bytes = base64ToBytes(payload.base64); + const file = new File([bytes], payload.filename, { + type: payload.mimeType || 'application/octet-stream', + lastModified: Date.now() + }); + + const attempts = []; + for (const component of candidates) { + const componentType = component && component.component ? component.component.type : component.type || ''; + const methods = callableMethodNames(component); + const operations = [ + ['handleFilesToUpload', () => component.handleFilesToUpload([file])], + ['uploadFile', () => component.uploadFile(file)], + ['addFile', () => component.addFile(file)], + ['onDrop', () => component.onDrop({ + dataTransfer: { files: [file], items: [{ kind: 'file', type: file.type, getAsFile: () => file }] }, + preventDefault() {}, + stopPropagation() {} + })] + ]; + for (const [method, invoke] of operations) { + if (typeof component[method] !== 'function') { + continue; + } + try { + await invoke(); + if (typeof component.triggerChange === 'function') { + component.triggerChange({ modified: true }); + } + if (component.root && typeof component.root.checkData === 'function') { + component.root.checkData(component.root.data, { modified: true }); + } + return { + hasValue: typeof component.hasValue === 'function' ? Boolean(component.hasValue()) : undefined, + valueCount: Array.isArray(component.dataValue) ? component.dataValue.length : component.dataValue ? 1 : 0, + pendingUploads: component.filesToSync && Array.isArray(component.filesToSync.filesToUpload) + ? component.filesToSync.filesToUpload.length + : 0, + syncing: Boolean(component.isSyncing), + uploadMethod: method, + componentType, + candidateCount: candidates.length + }; + } catch (error) { + attempts.push({ method, componentType, message: error && error.message ? error.message : String(error) }); + } + } + attempts.push({ method: 'none', componentType, callableMethods: methods }); + } + try { + return await uploadFileByDomDrop(payload, file); + } catch (error) { + attempts.push({ method: 'page-dom-drop', message: error && error.message ? error.message : String(error) }); + } + throw new Error(`File component API was not usable for ${payload.key}. Diagnostics: ${JSON.stringify(attempts).slice(0, 3000)}`); + } + + function checkValidity() { + const form = findFormInstance(); + if (!form) { + return { formFound: false, valid: false, errors: [] }; + } + let valid = false; + if (typeof form.checkValidity === 'function') { + valid = Boolean(form.checkValidity(form.data, true, null, false)); + } else if (typeof form.checkData === 'function') { + valid = Boolean(form.checkData(form.data, { dirty: true })); + } + const errors = Array.isArray(form.errors) + ? form.errors.map((error) => ({ + message: error && error.message ? String(error.message) : String(error), + key: error && error.component && error.component.key ? error.component.key : '', + type: error && error.component && error.component.type ? error.component.type : '' + })) + : []; + return { formFound: true, valid, errors }; + } + + async function executeCommand(command, payload) { + switch (command) { + case 'PING': + return { ready: true, formFound: Boolean(findFormInstance()) }; + case 'GET_COMPONENTS': + return getComponents(); + case 'SET_VALUE': + return setComponentValue(payload || {}); + case 'SET_MASKED_VALUE': + return setMaskedValue(payload || {}); + case 'UPLOAD_FILE': + return uploadFile(payload || {}); + case 'CHECK_VALIDITY': + return checkValidity(); + case 'RESET_CACHE': + cachedForm = null; + return { reset: true }; + default: + throw new Error(`Unknown page bridge command: ${command}`); + } + } + + window.addEventListener('message', (event) => { + if (event.source !== window || !event.data || event.data.channel !== 'CHEFS_TESTER_BRIDGE_REQUEST') { + return; + } + const requestId = event.data.requestId; + Promise.resolve() + .then(() => executeCommand(event.data.command, event.data.payload)) + .then((result) => { + window.postMessage({ + channel: 'CHEFS_TESTER_BRIDGE_RESPONSE', + requestId, + ok: true, + result + }, '*'); + }) + .catch((error) => { + window.postMessage({ + channel: 'CHEFS_TESTER_BRIDGE_RESPONSE', + requestId, + ok: false, + error: { + message: error && error.message ? error.message : String(error), + stack: error && error.stack ? error.stack : '' + } + }, '*'); + }); + }); + + window.postMessage({ channel: 'CHEFS_TESTER_BRIDGE', type: 'BRIDGE_READY' }, '*'); +})(); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.css b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.css new file mode 100644 index 0000000000..a853984daa --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.css @@ -0,0 +1,102 @@ +:root { + font-family: Arial, Helvetica, sans-serif; + color: #1f2933; + background: #f5f7fa; +} +body { + margin: 0; + min-width: 390px; +} +main { + padding: 16px; +} +header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +h1 { + margin: 0; + color: #003366; + font-size: 18px; +} +header p { + margin: 4px 0 0; + color: #5f6b76; + font-size: 12px; +} +.pill { + border-radius: 999px; + padding: 5px 9px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} +.pill.idle { background: #e5e7eb; color: #374151; } +.pill.running { background: #dbeafe; color: #1d4ed8; } +.pill.success { background: #dcfce7; color: #166534; } +.pill.failure { background: #fee2e2; color: #991b1b; } +.pill.warning { background: #fef3c7; color: #92400e; } +.primary-action { + margin: 16px 0 12px; +} +button { + border: 1px solid #9ca3af; + border-radius: 5px; + background: #fff; + color: #1f2933; + cursor: pointer; + font-size: 14px; + min-height: 38px; + padding: 8px 12px; +} +button:hover:not(:disabled) { filter: brightness(0.97); } +button:disabled { cursor: not-allowed; opacity: 0.5; } +button.primary { + width: 100%; + border-color: #003366; + background: #003366; + color: #fff; + font-size: 16px; + font-weight: 700; + min-height: 48px; +} +button.danger { + width: 100%; + border-color: #b91c1c; + background: #b91c1c; + color: #fff; + font-weight: 700; +} +.hidden { display: none !important; } +.status-card { + border: 1px solid #d1d5db; + border-radius: 7px; + background: #fff; + padding: 12px; +} +dl { margin: 0; } +dl div { + display: grid; + grid-template-columns: 110px 1fr; + gap: 8px; + padding: 4px 0; +} +dt { color: #5f6b76; } +dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; } +.message { + min-height: 18px; + margin: 10px 0 0; + color: #7c2d12; + font-size: 12px; +} +.secondary-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} +.secondary-actions .full-width { + grid-column: 1 / -1; +} diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.html b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.html new file mode 100644 index 0000000000..9796d986d9 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.html @@ -0,0 +1,49 @@ + + + + + + CHEFS One-Click Form Tester + + + +
    +
    +
    +

    CHEFS One-Click Form Tester

    +

    v0.4.0 build 2026.07.23.14

    +
    + Ready +
    + +
    + + +
    + +
    +
    +
    Run
    None
    +
    Pass
    0
    +
    Filled
    0
    +
    Remaining
    0
    +
    Current action
    Idle
    +
    Confirmation
    -
    +
    Batch active
    No
    +
    Batch queued
    0
    +
    Batch completed
    0
    +
    +

    +
    + +
    + + + + + +
    +
    + + + diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.js new file mode 100644 index 0000000000..4bc61d243c --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/popup.js @@ -0,0 +1,262 @@ +'use strict'; + +const VERSION = '0.4.0'; +const BUILD = '2026.07.23.14'; +let activeTabId = null; +let pollTimer = null; +let latestRun = null; + +const elements = { + statusPill: document.getElementById('statusPill'), + startButton: document.getElementById('startButton'), + stopButton: document.getElementById('stopButton'), + exportButton: document.getElementById('exportButton'), + copyButton: document.getElementById('copyButton'), + dashboardButton: document.getElementById('dashboardButton'), + settingsButton: document.getElementById('settingsButton'), + stopBatchButton: document.getElementById('stopBatchButton'), + runId: document.getElementById('runId'), + passCount: document.getElementById('passCount'), + filledCount: document.getElementById('filledCount'), + remainingCount: document.getElementById('remainingCount'), + currentAction: document.getElementById('currentAction'), + confirmationId: document.getElementById('confirmationId'), + batchActive: document.getElementById('batchActive'), + batchQueued: document.getElementById('batchQueued'), + batchCompleted: document.getElementById('batchCompleted'), + message: document.getElementById('message') +}; + +document.getElementById('versionText').textContent = `v${VERSION} build ${BUILD}`; + +function setMessage(text) { + elements.message.textContent = text || ''; +} + +function renderRun(run) { + latestRun = run || null; + if (!run) { + elements.statusPill.textContent = 'Ready'; + elements.statusPill.className = 'pill idle'; + elements.runId.textContent = 'None'; + elements.passCount.textContent = '0'; + elements.filledCount.textContent = '0'; + elements.remainingCount.textContent = '0'; + elements.currentAction.textContent = 'Idle'; + elements.confirmationId.textContent = '-'; + elements.startButton.classList.remove('hidden'); + elements.stopButton.classList.add('hidden'); + elements.exportButton.disabled = true; + elements.copyButton.disabled = true; + return; + } + + const status = String(run.status || 'unknown').toLowerCase(); + const isRunning = ['initializing', 'scanning', 'filling', 'settling', 'validating', 'submitting', 'waiting'].includes(status); + const isSuccess = status === 'submitted' || status === 'completed'; + const isFailure = ['failed', 'stalled', 'blocked', 'safety_stop'].includes(status); + + elements.statusPill.textContent = run.statusLabel || status.replaceAll('_', ' '); + elements.statusPill.className = `pill ${isRunning ? 'running' : isSuccess ? 'success' : isFailure ? 'failure' : 'warning'}`; + elements.runId.textContent = run.runId || 'Unknown'; + elements.passCount.textContent = String(run.progress && run.progress.pass || 0); + elements.filledCount.textContent = String(run.progress && run.progress.filled || 0); + elements.remainingCount.textContent = String(run.progress && run.progress.remaining || 0); + elements.currentAction.textContent = run.currentAction || 'Idle'; + elements.confirmationId.textContent = run.confirmationId || '-'; + elements.startButton.classList.toggle('hidden', isRunning); + elements.stopButton.classList.toggle('hidden', !isRunning); + elements.exportButton.disabled = !run.runId; + elements.copyButton.disabled = !run.runId; + + if (run.exportState && run.exportState.automatic) { + const automatic = run.exportState.automatic; + const resultLabel = run.statusLabel || status.replaceAll('_', ' '); + if (automatic.status === 'pending') { + setMessage(`${resultLabel}. Preparing automatic export...`); + } else if (automatic.status === 'succeeded') { + setMessage(`${resultLabel}. Automatic export saved to ${automatic.downloadPath}.`); + } else if (automatic.status === 'failed') { + setMessage(`${resultLabel}. Automatic export failed: ${automatic.error} Use Export Last Run.`); + } else if (run.failure && run.failure.message) { + setMessage(run.failure.message); + } else if (run.message) { + setMessage(run.message); + } else { + setMessage(''); + } + } else if (run.failure && run.failure.message) { + setMessage(run.failure.message); + } else if (run.message) { + setMessage(run.message); + } else { + setMessage(''); + } +} + +async function getActiveTab() { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + return tabs[0] || null; +} + +async function refreshStatus() { + if (!activeTabId) { + return; + } + try { + const [response, batchResponse] = await Promise.all([ + chrome.runtime.sendMessage({ + type: 'GET_TAB_RUN', + tabId: activeTabId + }), + chrome.runtime.sendMessage({ type: 'GET_BATCH_STATE' }) + ]); + renderRun(response && response.run ? response.run : null); + renderBatchState(batchResponse && batchResponse.state); + } catch (error) { + setMessage(error && error.message ? error.message : 'Unable to read run status.'); + } +} + +function renderBatchState(state) { + const batch = state || { queue: [], active: null, completed: [] }; + const queuedCount = (batch.queue || []).length; + if (batch.active) { + const statusLabel = batch.active.status === 'waiting_for_form' + ? ' — waiting for CHEFS form' + : batch.active.status === 'starting' + ? ' — starting' + : ''; + elements.batchActive.textContent = + `${batch.active.suiteId || 'suite'} #${batch.active.index || '?'}${statusLabel}`; + if (batch.active.status === 'waiting_for_form') { + setMessage(`Batch item #${batch.active.index || '?'} is waiting for the CHEFS form to finish loading.`); + } + } else if (queuedCount) { + elements.batchActive.textContent = 'Preparing marked tabs…'; + setMessage(`Preparing ${queuedCount} marked regression tab${queuedCount === 1 ? '' : 's'}…`); + } else { + elements.batchActive.textContent = 'No'; + } + elements.batchQueued.textContent = String(queuedCount); + elements.batchCompleted.textContent = String((batch.completed || []).length); + elements.stopBatchButton.classList.toggle( + 'hidden', + !batch.active && !(batch.queue && batch.queue.length) + ); +} + +async function startRun() { + setMessage('Starting run...'); + elements.startButton.disabled = true; + try { + const response = await chrome.runtime.sendMessage({ + type: 'START_RUN_IN_TAB', + tabId: activeTabId + }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'The run could not be started.'); + } + await refreshStatus(); + } catch (error) { + setMessage(error && error.message ? error.message : String(error)); + } finally { + elements.startButton.disabled = false; + } +} + +async function stopRun() { + try { + await chrome.tabs.sendMessage(activeTabId, { type: 'CHEFS_TESTER_STOP' }); + setMessage('Stop requested.'); + } catch (error) { + setMessage(error && error.message ? error.message : 'Unable to stop the run.'); + } +} + +async function exportRun() { + if (!latestRun || !latestRun.runId) { + return; + } + elements.exportButton.disabled = true; + setMessage('Preparing run bundle...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'EXPORT_RUN', + runId: latestRun.runId + }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'Export failed.'); + } + setMessage(`Save As requested for ${response.downloadPath || response.filename}.`); + } catch (error) { + setMessage(error && error.message ? error.message : String(error)); + } finally { + elements.exportButton.disabled = false; + } +} + +async function copySummary() { + if (!latestRun || !latestRun.summaryText) { + return; + } + try { + await navigator.clipboard.writeText(latestRun.summaryText); + setMessage('Summary copied.'); + } catch (error) { + setMessage('Unable to copy the summary. Export the run bundle instead.'); + } +} + +async function stopBatch() { + elements.stopBatchButton.disabled = true; + try { + const response = await chrome.runtime.sendMessage({ type: 'STOP_BATCH' }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'Unable to stop the batch.'); + } + setMessage('Batch stop requested.'); + await refreshStatus(); + } catch (error) { + setMessage(error && error.message ? error.message : String(error)); + } finally { + elements.stopBatchButton.disabled = false; + } +} + +async function openDashboard() { + try { + const response = await chrome.runtime.sendMessage({ type: 'OPEN_DASHBOARD' }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'Unable to open the dashboard.'); + } + } catch (error) { + setMessage(error && error.message ? error.message : String(error)); + } +} + +elements.startButton.addEventListener('click', startRun); +elements.stopButton.addEventListener('click', stopRun); +elements.exportButton.addEventListener('click', exportRun); +elements.copyButton.addEventListener('click', copySummary); +elements.dashboardButton.addEventListener('click', openDashboard); +elements.settingsButton.addEventListener('click', () => chrome.runtime.openOptionsPage()); +elements.stopBatchButton.addEventListener('click', stopBatch); + +(async function initialize() { + const tab = await getActiveTab(); + activeTabId = tab ? tab.id : null; + if (!activeTabId) { + setMessage('No active tab is available.'); + elements.startButton.disabled = true; + return; + } + await refreshStatus(); + pollTimer = setInterval(refreshStatus, 700); +})(); + +window.addEventListener('unload', () => { + if (pollTimer) { + clearInterval(pollTimer); + } +}); diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/rules/chefs-custom-format-rules-v1-20260722-163940.json b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/rules/chefs-custom-format-rules-v1-20260722-163940.json new file mode 100644 index 0000000000..c25b6df22d --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/rules/chefs-custom-format-rules-v1-20260722-163940.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "exportedAt": "2026-07-22T16:39:40.749Z", + "extensionVersion": "0.2.0", + "rules": [ + { + "id": "6bf799a2-3fa9-4434-8865-8e89b1654441", + "enabled": true, + "labelMatch": "Vehicle Registration Number", + "matchMode": "contains", + "caseSensitive": false, + "mask": "99999999", + "notes": "" + } + ] +} \ No newline at end of file diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/service-worker.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/service-worker.js new file mode 100644 index 0000000000..8bd5c3107b --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/service-worker.js @@ -0,0 +1,1690 @@ +'use strict'; + +importScripts('export-path.js', 'dashboard-model.js'); + +const EXTENSION_VERSION = '0.4.0'; +const BUILD_NUMBER = '2026.07.23.14'; +const RUN_KEY_PREFIX = 'chefsTesterRun:'; +const TAB_KEY_PREFIX = 'chefsTesterTab:'; +const LAST_RUN_KEY = 'chefsTesterLastRunId'; +const SETTINGS_KEY = 'chefsTesterSettings'; +const WATCHDOG_ALARM = 'chefsTesterWatchdog'; +const BATCH_QUEUE_KEY = 'chefsTesterBatchQueue'; +const BATCH_QUEUE_ALARM = 'chefsTesterBatchQueueAlarm'; +const BATCH_MARKER_PARAM = 'chefs-one-click-batch'; +const DASHBOARD_STATE_KEY = 'chefsTesterDashboardState'; +const DASHBOARD_HISTORY_KEY = 'chefsTesterDashboardHistory'; +const DASHBOARD_PAGE = 'dashboard.html'; +const WATCHDOG_STALE_MS = 75000; +const BATCH_ENTRY_TIMEOUT_MS = 90000; +const BATCH_COLLECTION_DELAY_MS = 1500; +const BATCH_FORM_READY_POLL_MS = 750; +const BATCH_FORM_READY_TIMEOUT_MS = 45000; +const BATCH_COMPLETED_LIMIT = 200; +const ACTIVE_RUN_STATUSES = new Set(['initializing', 'scanning', 'filling', 'settling', 'validating', 'submitting']); +const FINAL_RUN_STATUSES = new Set(['submitted', 'completed', 'failed', 'stalled', 'blocked', 'safety_stop', 'stopped']); +const DEFAULT_SETTINGS = { + additionalHosts: [], + allowProduction: false, + rowsPerGrid: 2, + captureScreenshot: true, + customFormatRules: [], + exportFolder: '', + autoExportAfterRun: false, + batchLauncherEnabled: false, + batchLauncherToken: '', + batchOrigins: [], + openDashboardAfterCompletion: false, + retainDashboardHistory: false, + dashboardDefaultView: 'simple' +}; +const runLocks = new Map(); +const automaticExportsInProgress = new Set(); +let batchProcessorRunning = false; +let batchScheduleTimer = null; +let batchStateLock = Promise.resolve(); + +async function ensureWatchdogAlarm() { + const existing = await chrome.alarms.get(WATCHDOG_ALARM); + if (!existing) { + await chrome.alarms.create(WATCHDOG_ALARM, { + delayInMinutes: 0.5, + periodInMinutes: 0.5 + }); + } + const batchAlarm = await chrome.alarms.get(BATCH_QUEUE_ALARM); + if (!batchAlarm) { + await chrome.alarms.create(BATCH_QUEUE_ALARM, { + delayInMinutes: 0.5, + periodInMinutes: 0.5 + }); + } +} + +function normalizeBatchLauncherToken(value) { + const token = String(value || '').trim(); + return /^[A-Za-z0-9_-]{16,128}$/.test(token) ? token : ''; +} + +function normalizeBatchOrigins(values) { + const normalized = []; + for (const rawValue of Array.isArray(values) ? values : []) { + try { + const url = new URL(String(rawValue || '').trim()); + if ( + ['http:', 'https:'].includes(url.protocol) && + !url.username && + !url.password && + url.pathname === '/' && + !url.search && + !url.hash + ) { + normalized.push(url.origin); + } + } catch (error) { + // Invalid stored origins are discarded during migration. + } + } + return Array.from(new Set(normalized)); +} + +function normalizeSettings(rawSettings) { + const existing = rawSettings || {}; + const settings = Object.assign({}, DEFAULT_SETTINGS, existing); + settings.rowsPerGrid = Math.max(2, Math.min(5, Number(settings.rowsPerGrid) || 2)); + settings.customFormatRules = Array.isArray(settings.customFormatRules) ? settings.customFormatRules : []; + try { + settings.exportFolder = ChefsExportPath.normalizeExportFolder(settings.exportFolder); + } catch (error) { + settings.exportFolder = ''; + } + if (Object.prototype.hasOwnProperty.call(existing, 'autoExportAfterRun')) { + settings.autoExportAfterRun = Boolean(existing.autoExportAfterRun); + } else { + settings.autoExportAfterRun = Boolean(existing.autoExportAfterSubmit); + } + delete settings.exportSubfolder; + delete settings.autoExportAfterSubmit; + settings.batchLauncherEnabled = Boolean(settings.batchLauncherEnabled); + settings.batchLauncherToken = normalizeBatchLauncherToken(settings.batchLauncherToken); + settings.batchOrigins = normalizeBatchOrigins(settings.batchOrigins); + settings.openDashboardAfterCompletion = Boolean(settings.openDashboardAfterCompletion); + settings.retainDashboardHistory = Boolean(settings.retainDashboardHistory); + settings.dashboardDefaultView = ['simple', 'analyst', 'statistical', 'experimental'] + .includes(settings.dashboardDefaultView) + ? settings.dashboardDefaultView + : 'simple'; + return settings; +} + +chrome.runtime.onInstalled.addListener(async () => { + const existing = await chrome.storage.local.get(SETTINGS_KEY); + const migrated = normalizeSettings(existing[SETTINGS_KEY]); + await chrome.storage.local.set({ [SETTINGS_KEY]: migrated }); + await ensureWatchdogAlarm(); + scheduleBatchProcessing(); +}); + +chrome.runtime.onStartup.addListener(() => { + ensureWatchdogAlarm().catch(() => undefined); + scheduleBatchProcessing(); +}); + +ensureWatchdogAlarm().catch(() => undefined); +scheduleBatchProcessing(); + +function runKey(runId) { + return `${RUN_KEY_PREFIX}${runId}`; +} + +function tabKey(tabId) { + return `${TAB_KEY_PREFIX}${tabId}`; +} + +async function getSettings() { + const stored = await chrome.storage.local.get(SETTINGS_KEY); + return normalizeSettings(stored[SETTINGS_KEY]); +} + +function isDefaultAllowedHost(hostname) { + const host = String(hostname || '').toLowerCase(); + return host === 'localhost' || + host === '127.0.0.1' || + /^chefs-(test|uat|dev|development|qa)\./.test(host) || + /\.(test|uat|dev)\.[a-z0-9.-]+$/.test(host); +} + +function isProductionLikeHost(hostname) { + const host = String(hostname || '').toLowerCase(); + return host.includes('chefs') && !isDefaultAllowedHost(host); +} + +async function evaluateEnvironment(urlText) { + let url; + try { + url = new URL(urlText); + } catch (error) { + return { allowed: false, reason: 'The active tab does not have a valid web address.' }; + } + if (!['http:', 'https:'].includes(url.protocol)) { + return { allowed: false, reason: 'Open a CHEFS form in a normal web tab first.' }; + } + const settings = await getSettings(); + const host = url.hostname.toLowerCase(); + const additional = (settings.additionalHosts || []).map((value) => String(value).toLowerCase()); + if (isDefaultAllowedHost(host) || additional.includes(host)) { + return { allowed: true, environment: 'non-production', settings }; + } + if (settings.allowProduction && isProductionLikeHost(host)) { + return { allowed: true, environment: 'production-override', settings }; + } + return { + allowed: false, + reason: `Automatic submission is blocked on ${host}. Add the hostname in extension settings only when that environment is approved.`, + settings + }; +} + +async function readRun(runId) { + if (!runId) { + return null; + } + const stored = await chrome.storage.local.get(runKey(runId)); + return stored[runKey(runId)] || null; +} + +function withRunLock(runId, operation) { + const previous = runLocks.get(runId) || Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(operation); + const tracked = next.finally(() => { + if (runLocks.get(runId) === tracked) { + runLocks.delete(runId); + } + }); + runLocks.set(runId, tracked); + return tracked; +} + +async function mutateRun(runId, mutator) { + return withRunLock(runId, async () => { + const run = await readRun(runId); + if (!run) { + throw new Error(`Run ${runId} was not found.`); + } + const result = await mutator(run); + run.updatedAt = new Date().toISOString(); + await chrome.storage.local.set({ [runKey(runId)]: run }); + return result === undefined ? run : result; + }); +} + +function buildSummaryText(run) { + const progress = run.progress || {}; + const failure = run.failure || {}; + const lines = [ + 'CHEFS One-Click Form Tester', + '', + `Version: ${run.extensionVersion || EXTENSION_VERSION}`, + `Build: ${run.buildNumber || BUILD_NUMBER}`, + `Run ID: ${run.runId || 'Unknown'}`, + '', + `Form: ${run.formTitle || 'Unknown'}`, + `URL: ${run.formUrl || 'Unknown'}`, + `Result: ${String(run.status || 'UNKNOWN').toUpperCase()}`, + '', + `Passes completed: ${progress.pass || 0}`, + `Components discovered: ${progress.discovered || 0}`, + `Fields filled: ${progress.filled || 0}`, + `Visible empty fields remaining: ${progress.remaining || 0}`, + `Fields failed: ${progress.failed || 0}`, + `Fields unsupported: ${progress.unsupported || 0}`, + `Rows added: ${progress.rowsAdded || 0}`, + `Attachments completed: ${progress.attachmentsCompleted || 0}`, + `Attachments pending: ${progress.attachmentsPending || 0}`, + `Submission attempts: ${progress.submitAttempts || 0}`, + '', + `Custom format rules loaded: ${progress.customRulesLoaded || (run.customRuleSet && run.customRuleSet.enabledRuleCount) || 0}`, + `Custom rules matched: ${progress.customRuleMatches || 0}`, + `Custom rule values accepted: ${progress.customRuleAccepted || 0}`, + `Custom rule values rejected: ${progress.customRuleRejected || 0}`, + `Detected masks used: ${progress.detectedMasksUsed || 0}`, + `Mask values rejected: ${progress.maskValuesRejected || 0}`, + `Rule-set hash: ${(run.customRuleSet && run.customRuleSet.ruleSetHash) || 'None'}`, + '', + 'Last successful action:', + run.lastSuccessfulAction || 'None recorded', + '', + 'Current action:', + run.currentAction || 'None recorded' + ]; + if (run.confirmationId) { + lines.push('', `Confirmation ID: ${run.confirmationId}`); + } + if (failure.message || failure.reason) { + lines.push('', 'Failure or stall:', failure.message || failure.reason); + } + if (run.message) { + lines.push('', 'Run message:', run.message); + } + return lines.join('\n'); +} + +function publicRun(run) { + if (!run) { + return null; + } + return { + runId: run.runId, + extensionVersion: run.extensionVersion, + buildNumber: run.buildNumber, + status: run.status, + statusLabel: run.statusLabel, + formTitle: run.formTitle, + formUrl: run.formUrl, + progress: run.progress || {}, + currentAction: run.currentAction, + lastSuccessfulAction: run.lastSuccessfulAction, + confirmationId: run.confirmationId, + message: run.message, + finalizedAt: run.finalizedAt || null, + exportState: run.exportState || null, + failure: run.failure ? { + message: run.failure.message, + reason: run.failure.reason, + fieldKey: run.failure.fieldKey + } : null, + updatedAt: run.updatedAt, + summaryText: buildSummaryText(run) + }; +} + +async function createRun(message, sender) { + const tabId = message.tabId || (sender.tab && sender.tab.id); + const run = { + runId: message.runId, + tabId, + extensionVersion: EXTENSION_VERSION, + buildNumber: BUILD_NUMBER, + engineVersion: EXTENSION_VERSION, + chromeVersion: message.chromeVersion || '', + formTitle: message.formTitle || '', + formUrl: message.formUrl || '', + formId: message.formId || '', + startedAt: message.startedAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + endedAt: null, + status: 'initializing', + statusLabel: 'Initializing', + message: '', + currentAction: 'Starting run', + lastSuccessfulAction: '', + confirmationId: null, + customRuleSet: message.customRuleSet || { schemaVersion: 1, enabledRuleCount: 0, ruleSetHash: '', lastModifiedAt: '' }, + customFormatRules: Array.isArray(message.customFormatRules) ? message.customFormatRules : [], + progress: { + pass: 0, + discovered: 0, + filled: 0, + remaining: 0, + failed: 0, + unsupported: 0, + rowsAdded: 0, + attachmentsCompleted: 0, + attachmentsPending: 0, + submitAttempts: 0, + customRulesLoaded: message.customRuleSet && message.customRuleSet.enabledRuleCount || 0, + customRuleMatches: 0, + customRuleAccepted: 0, + customRuleRejected: 0, + detectedMasksUsed: 0, + maskValuesRejected: 0 + }, + events: [], + checkpoints: [], + snapshots: {}, + validationErrors: [], + attachments: [], + failure: null, + failureScreenshotDataUrl: null, + finalizedAt: null, + exportState: { + automatic: { + status: 'not_requested', + requestedAt: null, + completedAt: null, + failedAt: null, + filename: '', + downloadPath: '', + downloadId: null, + error: '' + } + } + }; + await chrome.storage.local.set({ + [runKey(run.runId)]: run, + [tabKey(tabId)]: run.runId, + [LAST_RUN_KEY]: run.runId + }); + return publicRun(run); +} + +async function appendEvent(message) { + return mutateRun(message.runId, (run) => { + const events = Array.isArray(message.events) ? message.events : [message.event]; + for (const event of events.filter(Boolean)) { + run.events.push(event); + } + if (run.events.length > 12000) { + run.events = run.events.slice(-12000); + run.message = 'The oldest events were removed because the run exceeded the event retention limit.'; + } + }); +} + +async function addCheckpoint(message) { + return mutateRun(message.runId, (run) => { + run.checkpoints.push(message.checkpoint); + if (run.checkpoints.length > 3000) { + run.checkpoints = run.checkpoints.slice(-3000); + } + }); +} + +async function updateRun(message) { + return mutateRun(message.runId, (run) => { + const patch = message.patch || {}; + if (patch.progress) { + run.progress = Object.assign({}, run.progress || {}, patch.progress); + delete patch.progress; + } + Object.assign(run, patch); + if (['submitted', 'failed', 'stalled', 'blocked', 'stopped', 'safety_stop', 'completed'].includes(run.status)) { + run.endedAt = run.endedAt || new Date().toISOString(); + } + }); +} + +async function setSnapshot(message) { + return mutateRun(message.runId, (run) => { + run.snapshots[message.name] = message.snapshot; + }); +} + +async function addValidationErrors(message) { + return mutateRun(message.runId, (run) => { + const errors = Array.isArray(message.errors) ? message.errors : []; + run.validationErrors.push(...errors); + }); +} + +async function addAttachmentRecord(message) { + return mutateRun(message.runId, (run) => { + run.attachments.push(message.attachment); + }); +} + +async function setFailure(message, sender) { + const run = await mutateRun(message.runId, (storedRun) => { + storedRun.failure = message.failure || { message: 'Unknown failure' }; + storedRun.status = message.status || storedRun.status || 'failed'; + storedRun.statusLabel = message.statusLabel || String(storedRun.status).replaceAll('_', ' '); + storedRun.endedAt = storedRun.endedAt || new Date().toISOString(); + }); + const settings = await getSettings(); + const tabId = (sender.tab && sender.tab.id) || run.tabId; + if (settings.captureScreenshot && tabId) { + try { + const tab = await chrome.tabs.get(tabId); + const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, { format: 'png' }); + await mutateRun(message.runId, (storedRun) => { + storedRun.failureScreenshotDataUrl = dataUrl; + }); + } catch (error) { + await appendEvent({ + runId: message.runId, + event: { + time: new Date().toISOString(), + event: 'SCREENSHOT_CAPTURE_FAILED', + message: error && error.message ? error.message : String(error) + } + }); + } + } + return true; +} + +async function detectStaleRuns() { + const stored = await chrome.storage.local.get(null); + const now = Date.now(); + const candidates = Object.entries(stored) + .filter(([key, value]) => key.startsWith(RUN_KEY_PREFIX) && value && ACTIVE_RUN_STATUSES.has(value.status)); + + for (const [, candidate] of candidates) { + const updated = Date.parse(candidate.updatedAt || candidate.startedAt || ''); + if (!Number.isFinite(updated) || now - updated < WATCHDOG_STALE_MS) { + continue; + } + if (candidate.tabId) { + try { + const status = await chrome.tabs.sendMessage(candidate.tabId, { + type: 'CHEFS_TESTER_STATUS' + }); + if ( + status && + status.running && + status.runId === candidate.runId + ) { + await mutateRun(candidate.runId, (storedRun) => { + storedRun.currentAction = status.currentAction || storedRun.currentAction; + storedRun.progress = Object.assign({}, storedRun.progress || {}, status.progress || {}); + storedRun.watchdogState = Object.assign({}, storedRun.watchdogState || {}, { + lastResponsiveProbeAt: new Date().toISOString() + }); + }); + continue; + } + } catch (error) { + // An absent or unresponsive content controller falls through to bounded stall finalization. + } + } + const staleForMs = now - updated; + await appendEvent({ + runId: candidate.runId, + event: { + time: new Date().toISOString(), + event: 'WATCHDOG_STALL_DETECTED', + pass: candidate.progress && candidate.progress.pass || 0, + staleForMs, + currentAction: candidate.currentAction || '', + lastSuccessfulAction: candidate.lastSuccessfulAction || '', + message: 'No diagnostic heartbeat was persisted within the watchdog window.' + } + }); + await setFailure({ + runId: candidate.runId, + status: 'stalled', + statusLabel: 'Stalled', + failure: { + time: new Date().toISOString(), + reason: 'Background watchdog detected no diagnostic heartbeat.', + message: `The run stopped persisting progress for ${Math.round(staleForMs / 1000)} seconds.`, + currentAction: candidate.currentAction || '', + lastSuccessfulAction: candidate.lastSuccessfulAction || '', + watchdog: true, + staleForMs + } + }, { tab: { id: candidate.tabId } }); + await setSnapshot({ + runId: candidate.runId, + name: 'final', + snapshot: candidate.snapshots && (candidate.snapshots.lastKnown || candidate.snapshots.initial) || [] + }); + await addCheckpoint({ + runId: candidate.runId, + checkpoint: { + time: new Date().toISOString(), + reason: 'Background watchdog finalized stalled run', + status: 'stalled', + pass: candidate.progress && candidate.progress.pass || 0, + currentAction: candidate.currentAction || '', + lastSuccessfulAction: candidate.lastSuccessfulAction || '', + progress: candidate.progress || {} + } + }); + await finalizeRun(candidate.runId, new Date().toISOString()); + } +} + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm && alarm.name === WATCHDOG_ALARM) { + detectStaleRuns().catch(() => undefined); + } else if (alarm && alarm.name === BATCH_QUEUE_ALARM) { + processBatchQueue().catch(() => undefined); + } +}); + +async function getTabRun(tabId) { + const stored = await chrome.storage.local.get([tabKey(tabId), LAST_RUN_KEY]); + const runId = stored[tabKey(tabId)] || stored[LAST_RUN_KEY]; + return publicRun(await readRun(runId)); +} + +async function startRunInTab(tabId) { + const tab = await chrome.tabs.get(tabId); + const environment = await evaluateEnvironment(tab.url || ''); + if (!environment.allowed) { + throw new Error(environment.reason); + } + await chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + files: ['page-bridge.js'] + }); + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['content-script.js'] + }); + const response = await chrome.tabs.sendMessage(tabId, { + type: 'CHEFS_TESTER_START', + settings: environment.settings, + environment: environment.environment, + extensionVersion: EXTENSION_VERSION, + buildNumber: BUILD_NUMBER + }); + return response || { ok: true }; +} + +async function isChefsFormReady(tabId) { + try { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: async () => { + const sample = () => { + const root = document.querySelector('.formio-form, [ref="webform"]'); + if (!root) { + return { ready: false, componentCount: 0, interactiveCount: 0 }; + } + const componentCount = root.querySelectorAll( + '.formio-component[ref="component"], .formio-component' + ).length; + const interactiveCount = root.querySelectorAll( + 'input, select, textarea, button, [role="tab"]' + ).length; + return { + ready: componentCount > 0 && interactiveCount > 0, + componentCount, + interactiveCount + }; + }; + const first = sample(); + if (!first.ready) { + return first; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + const second = sample(); + return { + ready: second.ready, + componentCount: second.componentCount, + interactiveCount: second.interactiveCount, + stable: second.ready && + second.componentCount >= first.componentCount && + second.interactiveCount >= first.interactiveCount + }; + } + }); + const result = results && results[0] && results[0].result; + return { + ready: Boolean(result && result.ready && result.stable !== false), + componentCount: boundedReadinessCount(result && result.componentCount), + interactiveCount: boundedReadinessCount(result && result.interactiveCount), + error: '' + }; + } catch (error) { + return { + ready: false, + error: error && error.message ? error.message : String(error) + }; + } +} + +function boundedReadinessCount(value) { + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, Math.min(100000, Math.round(number))) : 0; +} + +function emptyBatchState() { + return { + queue: [], + active: null, + completed: [], + cancelledSuites: [], + updatedAt: new Date().toISOString() + }; +} + +function normalizeBatchState(rawState) { + const state = Object.assign(emptyBatchState(), rawState || {}); + state.queue = Array.isArray(state.queue) ? state.queue : []; + state.active = state.active || null; + state.completed = Array.isArray(state.completed) + ? state.completed.slice(-BATCH_COMPLETED_LIMIT) + : []; + state.cancelledSuites = Array.isArray(state.cancelledSuites) + ? state.cancelledSuites.slice(-20) + : []; + return state; +} + +async function getBatchState() { + const stored = await chrome.storage.local.get(BATCH_QUEUE_KEY); + return normalizeBatchState(stored[BATCH_QUEUE_KEY]); +} + +async function saveBatchState(state) { + state.updatedAt = new Date().toISOString(); + state.completed = (state.completed || []).slice(-BATCH_COMPLETED_LIMIT); + state.cancelledSuites = (state.cancelledSuites || []).slice(-20); + await chrome.storage.local.set({ [BATCH_QUEUE_KEY]: state }); + return state; +} + +function withBatchStateLock(operation) { + const next = batchStateLock + .catch(() => undefined) + .then(operation); + batchStateLock = next.catch(() => undefined); + return next; +} + +function batchEntryComparator(left, right) { + if (left.suiteId !== right.suiteId) { + return (left.queuedAt || 0) - (right.queuedAt || 0); + } + const leftNumber = Number(left.index); + const rightNumber = Number(right.index); + if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) { + return leftNumber - rightNumber; + } + return String(left.index || '').localeCompare(String(right.index || '')); +} + +function parseBatchMarker(urlText) { + try { + const url = new URL(urlText); + const parameters = new URLSearchParams(url.hash.replace(/^#/, '')); + const token = parameters.get(BATCH_MARKER_PARAM) || ''; + if (!token) { + return null; + } + const rawSuiteId = parameters.get('suite') || 'regression'; + const rawIndex = parameters.get('index') || '0'; + parameters.delete(BATCH_MARKER_PARAM); + parameters.delete('suite'); + parameters.delete('index'); + url.hash = parameters.toString() ? `#${parameters.toString()}` : ''; + return { + token, + suiteId: rawSuiteId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'regression', + index: rawIndex.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32) || '0', + url: url.href, + origin: url.origin + }; + } catch (error) { + return null; + } +} + +async function scrubBatchMarker(tabId) { + await chrome.scripting.executeScript({ + target: { tabId }, + func: (markerName) => { + const url = new URL(window.location.href); + const parameters = new URLSearchParams(url.hash.replace(/^#/, '')); + parameters.delete(markerName); + parameters.delete('suite'); + parameters.delete('index'); + url.hash = parameters.toString() ? `#${parameters.toString()}` : ''; + window.history.replaceState(window.history.state, document.title, url.href); + }, + args: [BATCH_MARKER_PARAM] + }); +} + +function permissionPatternForUrl(urlText) { + const url = new URL(urlText); + return `${url.protocol}//${url.hostname}/*`; +} + +function batchCompletedRecord(entry, status, details) { + return Object.assign({ + tabId: entry.tabId, + suiteId: entry.suiteId, + index: entry.index, + url: entry.url, + status, + completedAt: new Date().toISOString() + }, details || {}); +} + +async function recordBatchRejection(tabId, marker, reason) { + await withBatchStateLock(async () => { + const state = await getBatchState(); + if ( + state.completed.some((item) => item.tabId === tabId) || + (state.active && state.active.tabId === tabId) + ) { + return; + } + state.queue = state.queue.filter((item) => item.tabId !== tabId); + state.completed.push(batchCompletedRecord({ + tabId, + suiteId: marker.suiteId, + index: marker.index, + url: marker.url + }, 'launcher_rejected', { error: reason })); + await saveBatchState(state); + }); +} + +async function upsertBatchTab(tab, ready) { + const tabId = tab && tab.id; + const marker = parseBatchMarker(tab && (tab.pendingUrl || tab.url) || ''); + if (!tabId || !marker) { + return; + } + const settings = await getSettings(); + if ( + !settings.batchLauncherEnabled || + !settings.batchLauncherToken || + marker.token !== settings.batchLauncherToken + ) { + return; + } + if (!settings.batchOrigins.includes(marker.origin)) { + await recordBatchRejection(tabId, marker, `Origin ${marker.origin} is not configured for batch launching.`); + return; + } + const environment = await evaluateEnvironment(marker.url); + if (!environment.allowed) { + await recordBatchRejection(tabId, marker, environment.reason); + return; + } + const hasPermission = await chrome.permissions.contains({ + origins: [permissionPatternForUrl(marker.url)] + }); + if (!hasPermission) { + await recordBatchRejection(tabId, marker, `Chrome host access has not been granted for ${marker.origin}.`); + return; + } + if (ready) { + try { + await scrubBatchMarker(tabId); + } catch (error) { + await recordBatchRejection( + tabId, + marker, + `The launcher marker could not be removed before automation: ${ + error && error.message ? error.message : String(error) + }` + ); + return; + } + } + + await withBatchStateLock(async () => { + const state = await getBatchState(); + if (state.cancelledSuites.includes(marker.suiteId)) { + return; + } + if ( + state.completed.some((item) => item.tabId === tabId) || + (state.active && state.active.tabId === tabId) + ) { + return; + } + const existing = state.queue.find((item) => item.tabId === tabId); + if (existing) { + existing.ready = existing.ready || Boolean(ready); + existing.url = marker.url; + } else { + state.queue.push({ + tabId, + suiteId: marker.suiteId, + index: marker.index, + url: marker.url, + origin: marker.origin, + ready: Boolean(ready), + queuedAt: Date.now() + }); + } + state.queue.sort(batchEntryComparator); + await saveBatchState(state); + }); + scheduleBatchProcessing(); +} + +function scheduleBatchProcessing(delayMs) { + if (batchScheduleTimer) { + clearTimeout(batchScheduleTimer); + } + batchScheduleTimer = setTimeout(() => { + batchScheduleTimer = null; + processBatchQueue().catch(() => undefined); + }, Number.isFinite(delayMs) ? delayMs : BATCH_COLLECTION_DELAY_MS); +} + +async function completeBatchRun(run) { + const result = { + completed: false, + batchFinished: false, + entry: null + }; + await withBatchStateLock(async () => { + const state = await getBatchState(); + if (!state.active) { + return; + } + if ( + state.active.tabId !== run.tabId || + (state.active.runId && state.active.runId !== run.runId) + ) { + return; + } + const entry = Object.assign({}, state.active); + state.completed.push(batchCompletedRecord(entry, run.status, { + runId: run.runId, + exportStatus: run.exportState && run.exportState.automatic && + run.exportState.automatic.status || 'not_requested' + })); + state.active = null; + result.completed = true; + result.batchFinished = state.queue.length === 0; + result.entry = entry; + await saveBatchState(state); + }); + return result; +} + +async function markActiveBatchFailure(entry, status, error) { + await withBatchStateLock(async () => { + const state = await getBatchState(); + if (!state.active || state.active.tabId !== entry.tabId) { + return; + } + state.completed.push(batchCompletedRecord(state.active, status, { + error: error && error.message ? error.message : String(error || status) + })); + state.active = null; + await saveBatchState(state); + }); +} + +async function claimNextBatchEntry() { + return await withBatchStateLock(async () => { + const state = await getBatchState(); + if (state.active || !state.queue.length) { + return null; + } + const first = state.queue[0]; + if (!first.ready) { + if (Date.now() - first.queuedAt > BATCH_ENTRY_TIMEOUT_MS) { + state.queue.shift(); + state.completed.push(batchCompletedRecord(first, 'load_timeout', { + error: 'The marked tab did not finish loading within the batch timeout.' + })); + await saveBatchState(state); + return { retry: true }; + } + return null; + } + state.queue.shift(); + state.active = Object.assign({}, first, { + status: 'starting', + startedAt: Date.now(), + runId: null + }); + await saveBatchState(state); + return Object.assign({}, state.active); + }); +} + +async function recoverActiveBatchRun(active) { + if (active.runId) { + return await readRun(active.runId); + } + const stored = await chrome.storage.local.get(tabKey(active.tabId)); + const runId = stored[tabKey(active.tabId)]; + if (!runId) { + return null; + } + const run = await readRun(runId); + if (run) { + await withBatchStateLock(async () => { + const state = await getBatchState(); + if (state.active && state.active.tabId === active.tabId && !state.active.runId) { + state.active.runId = runId; + state.active.status = 'running'; + await saveBatchState(state); + } + }); + } + return run; +} + +async function resolveInterruptedAutomaticExport(run) { + const automatic = run && run.exportState && run.exportState.automatic; + if ( + !automatic || + automatic.status !== 'pending' || + automaticExportsInProgress.has(run.runId) + ) { + return run; + } + return await mutateRun(run.runId, (storedRun) => { + const storedAutomatic = ensureAutomaticExportState(storedRun); + if (storedAutomatic.status === 'pending') { + storedAutomatic.status = 'failed'; + storedAutomatic.failedAt = new Date().toISOString(); + storedAutomatic.completedAt = null; + storedAutomatic.error = + 'The automatic export was interrupted when the extension background worker restarted. Use Export Last Run to retry.'; + } + }); +} + +async function updateActiveBatchReadiness(entry, readiness) { + await withBatchStateLock(async () => { + const state = await getBatchState(); + if (!state.active || state.active.tabId !== entry.tabId || state.active.runId) { + return; + } + state.active.status = 'waiting_for_form'; + state.active.readinessStartedAt = + state.active.readinessStartedAt || state.active.startedAt || Date.now(); + state.active.lastReadinessCheckAt = Date.now(); + state.active.readinessError = readiness.error || ''; + state.active.readinessComponentCount = readiness.componentCount || 0; + state.active.readinessInteractiveCount = readiness.interactiveCount || 0; + await saveBatchState(state); + }); +} + +async function startActiveBatchEntry(entry) { + await chrome.tabs.get(entry.tabId); + await chrome.tabs.update(entry.tabId, { active: true }); + const readiness = await isChefsFormReady(entry.tabId); + if (!readiness.ready) { + await updateActiveBatchReadiness(entry, readiness); + scheduleBatchProcessing(BATCH_FORM_READY_POLL_MS); + return false; + } + const response = await startRunInTab(entry.tabId); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : 'The batch run could not be started.'); + } + await withBatchStateLock(async () => { + const latest = await getBatchState(); + if (latest.active && latest.active.tabId === entry.tabId) { + latest.active.runId = response.runId || null; + latest.active.status = 'running'; + latest.active.readinessError = ''; + latest.active.readinessComponentCount = readiness.componentCount || 0; + latest.active.readinessInteractiveCount = readiness.interactiveCount || 0; + await saveBatchState(latest); + } + }); + return true; +} + +async function processBatchQueue() { + if (batchProcessorRunning) { + return; + } + batchProcessorRunning = true; + try { + while (true) { + const state = await getBatchState(); + if (state.active) { + let run = await recoverActiveBatchRun(state.active); + if (run && FINAL_RUN_STATUSES.has(run.status)) { + const automatic = run.exportState && run.exportState.automatic; + if ( + automatic && + automatic.status === 'pending' && + automaticExportsInProgress.has(run.runId) + ) { + return; + } + run = await resolveInterruptedAutomaticExport(run); + await finalizeRun(run.runId, run.finalizedAt); + continue; + } + if (run) { + return; + } + const readinessStartedAt = + state.active.readinessStartedAt || state.active.startedAt || Date.now(); + if (Date.now() - readinessStartedAt > BATCH_FORM_READY_TIMEOUT_MS) { + await markActiveBatchFailure( + state.active, + 'form_not_ready', + new Error('The CHEFS Form.io form did not become ready within the launcher timeout.') + ); + continue; + } + try { + await startActiveBatchEntry(state.active); + } catch (error) { + await markActiveBatchFailure(state.active, 'launcher_failed', error); + continue; + } + return; + } + + const entry = await claimNextBatchEntry(); + if (!entry) { + return; + } + if (entry.retry) { + continue; + } + try { + await startActiveBatchEntry(entry); + return; + } catch (error) { + await markActiveBatchFailure(entry, 'launcher_failed', error); + } + } + } finally { + batchProcessorRunning = false; + } +} + +async function stopBatch() { + let active = null; + await withBatchStateLock(async () => { + const state = await getBatchState(); + active = state.active ? Object.assign({}, state.active) : null; + const suiteIds = new Set(state.cancelledSuites || []); + for (const entry of state.queue) { + suiteIds.add(entry.suiteId); + state.completed.push(batchCompletedRecord(entry, 'stopped_before_start')); + } + if (active) { + suiteIds.add(active.suiteId); + state.active.stopRequestedAt = new Date().toISOString(); + } + state.cancelledSuites = Array.from(suiteIds).slice(-20); + state.queue = []; + await saveBatchState(state); + }); + + if (active) { + try { + await chrome.tabs.sendMessage(active.tabId, { type: 'CHEFS_TESTER_STOP' }); + } catch (error) { + await markActiveBatchFailure(active, 'launcher_stopped', error); + scheduleBatchProcessing(); + } + } + return await getBatchState(); +} + +async function handleBatchTabRemoved(tabId) { + let changed = false; + await withBatchStateLock(async () => { + const state = await getBatchState(); + const queued = state.queue.filter((entry) => entry.tabId === tabId); + if (queued.length) { + state.queue = state.queue.filter((entry) => entry.tabId !== tabId); + for (const entry of queued) { + state.completed.push(batchCompletedRecord(entry, 'tab_closed')); + } + changed = true; + } + if (state.active && state.active.tabId === tabId) { + state.completed.push(batchCompletedRecord(state.active, 'tab_closed')); + state.active = null; + changed = true; + } + if (changed) { + await saveBatchState(state); + } + }); + if (changed) { + scheduleBatchProcessing(); + } +} + +chrome.tabs.onCreated.addListener((tab) => { + upsertBatchTab(tab, tab.status === 'complete').catch(() => undefined); +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + const candidate = Object.assign({}, tab, { + id: tabId, + url: changeInfo.url || tab.url + }); + upsertBatchTab(candidate, changeInfo.status === 'complete' || tab.status === 'complete') + .catch(() => undefined); +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + handleBatchTabRemoved(tabId).catch(() => undefined); +}); + +function encodeUtf8(text) { + return new TextEncoder().encode(String(text)); +} + +function uint16(value) { + return new Uint8Array([value & 0xff, (value >>> 8) & 0xff]); +} + +function uint32(value) { + return new Uint8Array([ + value & 0xff, + (value >>> 8) & 0xff, + (value >>> 16) & 0xff, + (value >>> 24) & 0xff + ]); +} + +function concatArrays(arrays) { + const length = arrays.reduce((sum, array) => sum + array.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const array of arrays) { + output.set(array, offset); + offset += array.length; + } + return output; +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date) { + const year = Math.max(1980, date.getFullYear()); + const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2); + const dosDate = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(); + return { dosTime, dosDate }; +} + +function createZip(files) { + const localParts = []; + const centralParts = []; + let offset = 0; + const now = dosDateTime(new Date()); + + for (const file of files) { + const nameBytes = encodeUtf8(file.name); + const data = file.data instanceof Uint8Array ? file.data : encodeUtf8(file.data); + const crc = crc32(data); + const localHeader = concatArrays([ + uint32(0x04034b50), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(now.dosTime), + uint16(now.dosDate), + uint32(crc), + uint32(data.length), + uint32(data.length), + uint16(nameBytes.length), + uint16(0), + nameBytes + ]); + localParts.push(localHeader, data); + + const centralHeader = concatArrays([ + uint32(0x02014b50), + uint16(20), + uint16(20), + uint16(0x0800), + uint16(0), + uint16(now.dosTime), + uint16(now.dosDate), + uint32(crc), + uint32(data.length), + uint32(data.length), + uint16(nameBytes.length), + uint16(0), + uint16(0), + uint16(0), + uint16(0), + uint32(0), + uint32(offset), + nameBytes + ]); + centralParts.push(centralHeader); + offset += localHeader.length + data.length; + } + + const local = concatArrays(localParts); + const central = concatArrays(centralParts); + const end = concatArrays([ + uint32(0x06054b50), + uint16(0), + uint16(0), + uint16(files.length), + uint16(files.length), + uint32(central.length), + uint32(local.length), + uint16(0) + ]); + return concatArrays([local, central, end]); +} + +function dataUrlToBytes(dataUrl) { + const comma = dataUrl.indexOf(','); + const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function bytesToBase64(bytes) { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); +} + +function prettyJson(value) { + return JSON.stringify(value, null, 2); +} + +function createRunBundle(run) { + const files = [ + { + name: 'manifest.json', + data: prettyJson({ + extensionVersion: run.extensionVersion, + buildNumber: run.buildNumber, + engineVersion: run.engineVersion, + chromeVersion: run.chromeVersion, + runId: run.runId, + tabId: run.tabId, + formTitle: run.formTitle, + formUrl: run.formUrl, + formId: run.formId, + startedAt: run.startedAt, + endedAt: run.endedAt, + finalizedAt: run.finalizedAt || null, + result: run.status, + customRuleSet: run.customRuleSet || null, + exportState: run.exportState || null + }) + }, + { name: 'run-summary.txt', data: buildSummaryText(run) }, + { name: 'events.jsonl', data: (run.events || []).map((event) => JSON.stringify(event)).join('\n') }, + { name: 'checkpoints.jsonl', data: (run.checkpoints || []).map((checkpoint) => JSON.stringify(checkpoint)).join('\n') }, + { name: 'initial-components.json', data: prettyJson(run.snapshots && run.snapshots.initial || []) }, + { name: 'last-known-components.json', data: prettyJson(run.snapshots && run.snapshots.lastKnown || []) }, + { name: 'final-components.json', data: prettyJson(run.snapshots && run.snapshots.final || []) }, + { name: 'validation-errors.json', data: prettyJson(run.validationErrors || []) }, + { name: 'attachments.json', data: prettyJson(run.attachments || []) }, + { + name: 'custom-format-rules.json', + data: prettyJson({ + schemaVersion: run.customRuleSet && run.customRuleSet.schemaVersion || 1, + ruleSetHash: run.customRuleSet && run.customRuleSet.ruleSetHash || '', + enabledRuleCount: run.customRuleSet && run.customRuleSet.enabledRuleCount || 0, + rules: run.customFormatRules || [] + }) + } + ]; + if (run.failure) { + files.push({ name: 'failure.json', data: prettyJson(run.failure) }); + } + if (run.failureScreenshotDataUrl) { + files.push({ name: 'failure-screenshot.png', data: dataUrlToBytes(run.failureScreenshotDataUrl) }); + } + const zip = createZip(files); + const safeRun = String(run.runId).replace(/[^A-Za-z0-9_-]/g, ''); + const stamp = new Date(run.startedAt || Date.now()).toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-'); + const filename = `chefs-one-click-tester-v${run.extensionVersion}-build-${run.buildNumber}-run-${safeRun}-${stamp}.zip`; + const url = `data:application/zip;base64,${bytesToBase64(zip)}`; + return { filename, url }; +} + +async function downloadRun(runId, options) { + const run = await readRun(runId); + if (!run) { + throw new Error(`Run ${runId} was not found.`); + } + const settings = await getSettings(); + const bundle = createRunBundle(run); + const downloadPath = ChefsExportPath.joinExportPath(settings.exportFolder, bundle.filename); + const downloadId = await chrome.downloads.download({ + url: bundle.url, + filename: downloadPath, + conflictAction: 'uniquify', + saveAs: Boolean(options && options.saveAs) + }); + return { + filename: bundle.filename, + downloadPath, + downloadId: downloadId === undefined ? null : downloadId + }; +} + +function ensureAutomaticExportState(run) { + run.exportState = run.exportState || {}; + run.exportState.automatic = Object.assign({ + status: 'not_requested', + requestedAt: null, + completedAt: null, + failedAt: null, + filename: '', + downloadPath: '', + downloadId: null, + error: '' + }, run.exportState.automatic || {}); + return run.exportState.automatic; +} + +function emptyDashboardState(defaultView) { + return { + schemaVersion: ChefsDashboardModel.SCHEMA_VERSION, + mode: 'empty', + defaultView: defaultView || 'simple', + updatedAt: new Date().toISOString(), + selectedRunRef: '', + runs: [], + batch: null, + history: [] + }; +} + +function normalizeDashboardState(rawState, defaultView) { + const state = Object.assign(emptyDashboardState(defaultView), rawState || {}); + state.schemaVersion = ChefsDashboardModel.SCHEMA_VERSION; + state.mode = ['empty', 'run', 'batch'].includes(state.mode) ? state.mode : 'empty'; + state.defaultView = ['simple', 'analyst', 'statistical', 'experimental'] + .includes(state.defaultView) ? state.defaultView : 'simple'; + state.runs = Array.isArray(state.runs) + ? state.runs.filter(ChefsDashboardModel.isDashboardSummary).slice(0, 200) + : []; + state.history = ChefsDashboardModel.trimHistory(state.history || []); + state.batch = state.batch && typeof state.batch === 'object' ? { + suiteRef: /^run-[0-9a-f]{8}$/.test(String(state.batch.suiteRef || '')) + ? state.batch.suiteRef + : 'run-unknown', + completed: Boolean(state.batch.completed), + completedAt: Number.isFinite(Date.parse(String(state.batch.completedAt || ''))) + ? new Date(state.batch.completedAt).toISOString() + : null + } : null; + return state; +} + +async function getDashboardState() { + const settings = await getSettings(); + const stored = await chrome.storage.local.get([DASHBOARD_STATE_KEY, DASHBOARD_HISTORY_KEY]); + const state = normalizeDashboardState(stored[DASHBOARD_STATE_KEY], settings.dashboardDefaultView); + state.defaultView = settings.dashboardDefaultView; + state.history = settings.retainDashboardHistory + ? ChefsDashboardModel.trimHistory(stored[DASHBOARD_HISTORY_KEY] || []) + : []; + return state; +} + +async function saveDashboardState(state, history) { + state.updatedAt = new Date().toISOString(); + const values = { [DASHBOARD_STATE_KEY]: state }; + if (history) { + values[DASHBOARD_HISTORY_KEY] = history; + } + await chrome.storage.local.set(values); + return state; +} + +function compareDashboardRuns(left, right) { + const leftIndex = Number(left && left.batch && left.batch.index); + const rightIndex = Number(right && right.batch && right.batch.index); + if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) { + return leftIndex - rightIndex; + } + return Date.parse(left.startedAt || '') - Date.parse(right.startedAt || ''); +} + +async function recordDashboardRun(run, batchCompletion, settings) { + const entry = batchCompletion && batchCompletion.entry; + const context = entry ? { suiteId: entry.suiteId, index: entry.index } : null; + const summary = ChefsDashboardModel.buildRunSummary(run, context); + const stored = await chrome.storage.local.get([DASHBOARD_STATE_KEY, DASHBOARD_HISTORY_KEY]); + const state = normalizeDashboardState(stored[DASHBOARD_STATE_KEY], settings.dashboardDefaultView); + let history = ChefsDashboardModel.trimHistory(stored[DASHBOARD_HISTORY_KEY] || []); + if (settings.retainDashboardHistory) { + history = ChefsDashboardModel.trimHistory(history.concat(summary)); + } + state.defaultView = settings.dashboardDefaultView; + state.selectedRunRef = summary.runRef; + if (entry) { + const suiteRef = summary.batch.suiteRef; + if (!state.batch || state.batch.suiteRef !== suiteRef) { + state.batch = { + suiteRef, + completed: false, + completedAt: null + }; + state.runs = []; + } + const existingIndex = state.runs.findIndex((item) => item.runRef === summary.runRef); + if (existingIndex >= 0) { + state.runs[existingIndex] = summary; + } else { + state.runs.push(summary); + } + state.runs.sort(compareDashboardRuns); + state.mode = 'batch'; + if (batchCompletion.batchFinished) { + state.batch.completed = true; + state.batch.completedAt = new Date().toISOString(); + } + } else { + state.mode = 'run'; + state.batch = null; + state.runs = [summary]; + } + state.history = settings.retainDashboardHistory ? history : []; + await saveDashboardState(state, state.history); + return state; +} + +async function openDashboardTab() { + const dashboardUrl = chrome.runtime.getURL(DASHBOARD_PAGE); + const existing = await chrome.tabs.query({ url: `${dashboardUrl}*` }); + if (existing && existing.length) { + const tab = existing[0]; + if (chrome.tabs.reload) { + await chrome.tabs.reload(tab.id); + } + await chrome.tabs.update(tab.id, { active: true }); + return { reused: true, tabId: tab.id }; + } + const tab = await chrome.tabs.create({ url: dashboardUrl, active: true }); + return { reused: false, tabId: tab.id }; +} + +async function clearDashboardHistory() { + const state = await getDashboardState(); + state.history = []; + await saveDashboardState(state, []); + return state; +} + +async function handleDashboardCompletion(run, batchCompletion) { + const settings = await getSettings(); + let shouldProcess = false; + const processedRun = await mutateRun(run.runId, (storedRun) => { + storedRun.dashboardState = storedRun.dashboardState || {}; + if (!storedRun.dashboardState.processedAt) { + storedRun.dashboardState.processedAt = new Date().toISOString(); + storedRun.dashboardState.context = batchCompletion && batchCompletion.completed + ? 'batch' + : 'singleton'; + shouldProcess = true; + } + }); + if (!shouldProcess) { + return; + } + await recordDashboardRun(processedRun, batchCompletion, settings); + const shouldOpen = settings.openDashboardAfterCompletion && ( + !batchCompletion || + !batchCompletion.completed || + batchCompletion.batchFinished + ); + if (shouldOpen) { + await openDashboardTab(); + } +} + +async function completeRunPostProcessing(run) { + const batchCompletion = await completeBatchRun(run); + try { + await handleDashboardCompletion(run, batchCompletion); + } catch (error) { + await appendEvent({ + runId: run.runId, + event: { + time: new Date().toISOString(), + event: 'DASHBOARD_UPDATE_FAILED' + } + }).catch(() => undefined); + } finally { + if (batchCompletion.completed) { + scheduleBatchProcessing(0); + } + } +} + +async function finalizeRun(runId, finalizedAt) { + const settings = await getSettings(); + let shouldExport = false; + let run = await mutateRun(runId, (storedRun) => { + storedRun.finalizedAt = storedRun.finalizedAt || finalizedAt || new Date().toISOString(); + const automatic = ensureAutomaticExportState(storedRun); + if ( + FINAL_RUN_STATUSES.has(storedRun.status) && + settings.autoExportAfterRun && + automatic.status === 'not_requested' + ) { + automatic.status = 'pending'; + automatic.requestedAt = new Date().toISOString(); + automatic.completedAt = null; + automatic.failedAt = null; + automatic.error = ''; + shouldExport = true; + } + }); + + if (!shouldExport) { + const automatic = run && run.exportState && run.exportState.automatic; + if (automatic && automatic.status === 'pending') { + if (automaticExportsInProgress.has(runId)) { + return publicRun(run); + } + run = await resolveInterruptedAutomaticExport(run); + } + await completeRunPostProcessing(run); + return publicRun(run); + } + + automaticExportsInProgress.add(runId); + try { + const result = await downloadRun(runId, { saveAs: false }); + run = await mutateRun(runId, (storedRun) => { + const automatic = ensureAutomaticExportState(storedRun); + automatic.status = 'succeeded'; + automatic.completedAt = new Date().toISOString(); + automatic.failedAt = null; + automatic.filename = result.filename; + automatic.downloadPath = result.downloadPath; + automatic.downloadId = result.downloadId; + automatic.error = ''; + }); + } catch (error) { + run = await mutateRun(runId, (storedRun) => { + const automatic = ensureAutomaticExportState(storedRun); + automatic.status = 'failed'; + automatic.failedAt = new Date().toISOString(); + automatic.completedAt = null; + automatic.error = error && error.message ? error.message : String(error); + }); + } finally { + automaticExportsInProgress.delete(runId); + } + await completeRunPostProcessing(run); + return publicRun(run); +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + (async () => { + switch (message.type) { + case 'START_RUN_IN_TAB': + return await startRunInTab(message.tabId); + case 'CREATE_RUN': + return { ok: true, run: await createRun(message, sender) }; + case 'APPEND_EVENTS': + await appendEvent(message); + return { ok: true }; + case 'ADD_CHECKPOINT': + await addCheckpoint(message); + return { ok: true }; + case 'UPDATE_RUN': + await updateRun(message); + return { ok: true }; + case 'SET_SNAPSHOT': + await setSnapshot(message); + return { ok: true }; + case 'ADD_VALIDATION_ERRORS': + await addValidationErrors(message); + return { ok: true }; + case 'ADD_ATTACHMENT_RECORD': + await addAttachmentRecord(message); + return { ok: true }; + case 'SET_FAILURE': + await setFailure(message, sender); + return { ok: true }; + case 'GET_TAB_RUN': + return { ok: true, run: await getTabRun(message.tabId) }; + case 'GET_BATCH_STATE': + return { ok: true, state: await getBatchState() }; + case 'STOP_BATCH': + return { ok: true, state: await stopBatch() }; + case 'GET_DASHBOARD_STATE': + return { ok: true, state: await getDashboardState() }; + case 'OPEN_DASHBOARD': + return { ok: true, ...(await openDashboardTab()) }; + case 'CLEAR_DASHBOARD_HISTORY': + return { ok: true, state: await clearDashboardHistory() }; + case 'EXPORT_RUN': { + const result = await downloadRun(message.runId, { saveAs: true }); + return { ok: true, ...result }; + } + case 'RUN_FINALIZED': + return { + ok: true, + run: await finalizeRun(message.runId, message.finalizedAt) + }; + case 'GET_SETTINGS': + return { ok: true, settings: await getSettings() }; + default: + return { ok: false, error: `Unknown message type: ${message.type}` }; + } + })() + .then(sendResponse) + .catch((error) => sendResponse({ + ok: false, + error: error && error.message ? error.message : String(error) + })); + return true; +}); diff --git a/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd b/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd new file mode 100644 index 0000000000..5891ced205 --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd @@ -0,0 +1,55 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion + +rem CHEFS One-Click Form Tester regression launcher +rem 1. Copy the launcher token from the extension Settings page below. +rem 2. Set CHROME_PROFILE to the profile folder that has the extension loaded. +rem 3. Add, remove, or reorder the OPEN_FORM lines in the embedded test list. + +set "LAUNCHER_TOKEN=a08e7b8bce5a402d9262859aba8998380495eabebe594c33ace293a8f7c697ca" +set "CHROME_PROFILE=Default" +set "SUITE_ID=regression-%RANDOM%-%RANDOM%" + +if "%LAUNCHER_TOKEN%"=="PASTE_TOKEN_FROM_EXTENSION_SETTINGS_HERE" ( + echo ERROR: Set LAUNCHER_TOKEN in this file before running it. + pause + exit /b 2 +) + +set "CHROME_EXE=%ProgramFiles%\Google\Chrome\Application\chrome.exe" +if not exist "%CHROME_EXE%" set "CHROME_EXE=%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" +if not exist "%CHROME_EXE%" set "CHROME_EXE=%LocalAppData%\Google\Chrome\Application\chrome.exe" +if not exist "%CHROME_EXE%" ( + echo ERROR: Google Chrome was not found in a standard installation location. + pause + exit /b 3 +) + +echo Opening CHEFS regression suite %SUITE_ID% in Chrome profile "%CHROME_PROFILE%"... + +rem Evidence-derived regression suite from feedback round 004. Keep indexes unique and sortable. +rem 001 - CGG - Human and Social Services (TEST) +call :OPEN_FORM "001" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=8e1678c7-5f1e-4f9b-b9e4-87a81d0ecd7f" +rem 002 - CGG - DPAC - UAT +call :OPEN_FORM "002" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=13d98806-cf0a-4e96-a396-f98322220ca2" +rem 003 - Template - Simple Functional Chefs Form (TEST) +call :OPEN_FORM "003" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=6f3fe864-8942-4849-9396-0c343e24a72d" +rem 004 - Template - Custom Fields +call :OPEN_FORM "004" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=90d32c33-4932-4de3-adaa-ea3d5998059e" +rem 005 - Template - Core Fields +call :OPEN_FORM "005" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=8a1aae54-f534-4207-b3f7-e8c1f61c337e" +rem 006 - REDIP - Economic Capacity (UAT) +call :OPEN_FORM "006" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=34a28edb-251d-4f94-80ac-89c42e68e17c" +rem 007 - 2026 Community Event Support Fund +call :OPEN_FORM "007" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=55d5a529-3687-4726-8c09-3f8aa6ae2431" +rem 008 - A and C Rebate calculations +call :OPEN_FORM "008" "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=b73a4e19-d607-4c4e-be7c-6cac281b099f" + +echo Opened all marked tabs. The extension will process them sequentially when its batch launcher is enabled. +exit /b 0 + +:OPEN_FORM +set "FORM_INDEX=%~1" +set "FORM_URL=%~2" +start "" "%CHROME_EXE%" --profile-directory="%CHROME_PROFILE%" --new-tab "%FORM_URL%#chefs-one-click-batch=%LAUNCHER_TOKEN%&suite=%SUITE_ID%&index=%FORM_INDEX%" +exit /b 0 From 61ad9416df766253d2f5158d0ee11966c237211a Mon Sep 17 00:00:00 2001 From: Stephan McColm Date: Thu, 23 Jul 2026 15:06:13 -0700 Subject: [PATCH 07/12] feature/AB#33893_CHEFS_One_Click_Form_Tester - Removed stale references --- .../PERMISSIONS.md | 63 +++++++++++++++++++ .../chefs-one-click-form-tester/README.md | 2 +- .../Unity.CHEFS/run-regression-suite.cmd | 2 +- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PERMISSIONS.md diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PERMISSIONS.md b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PERMISSIONS.md new file mode 100644 index 0000000000..f2bc4d399a --- /dev/null +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PERMISSIONS.md @@ -0,0 +1,63 @@ +# Chrome Extension Permissions + +This extension is a developer/testing tool for exercising CHEFS forms in approved non-production environments. It is not intended for general browsing use. + +## Required permissions + +### `activeTab` + +Allows the extension to interact with the currently selected CHEFS form tab after the user invokes the extension. This keeps normal operation scoped to the active tab instead of granting broad default site access. + +### `scripting` + +Allows the extension to inject its Form.io test controller into an approved CHEFS form tab. The injected scripts inspect rendered form controls, populate test values, attach packaged synthetic files and trigger submission. + +### `storage` + +Persists extension settings and run diagnostics, including environment safeguards, batch launcher configuration, run progress, troubleshooting checkpoints and dashboard state. + +### `downloads` + +Exports troubleshooting bundles and run evidence from the browser. This includes summaries, event logs, checkpoints, component snapshots and optional failure screenshots. + +### `unlimitedStorage` + +Prevents Chrome from evicting longer diagnostic runs or larger troubleshooting bundles while a test is in progress. The extension still stores bounded batch/dashboard history and exposes clearing controls. + +### `alarms` + +Runs background watchdog and batch-queue checks while the Manifest V3 service worker is idle or restarted. This lets the extension detect stalled runs and advance queued regression tabs reliably. + +### `tabs` + +Supports batch regression orchestration across marked CHEFS form tabs. The extension needs tab metadata to detect launcher markers, activate the next queued tab, scrub launcher markers from tab history and handle closed tabs. + +## Optional host permissions + +### `http://*/*` and `https://*/*` + +Host access is optional and must be granted by the user for approved CHEFS origins before automation is injected. The extension does not declare default `host_permissions`. + +Batch launching requires all of the following before a marked tab is processed: + +- Batch launcher enabled in settings. +- Matching launcher token. +- Exact approved origin configured in settings. +- Chrome host access granted for that origin. +- Existing environment protection passes. + +Production-like CHEFS hosts remain blocked by default. Adding an origin to the batch list does not bypass the separate production override requirement. + +## Web-accessible resources + +### `page-bridge.js` + +Exposed so the extension can bridge into the page context when Form.io state must be read from the rendered CHEFS page. + +### `attachments/*` + +Exposed so the test runner can upload packaged synthetic attachment files through normal form file inputs. + +## Network access + +The extension does not load remote extension assets. Its dashboard uses a PID-free projection of run results and does not fetch external resources. diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md index 4ad32f5c8b..77289b9b74 100644 --- a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/README.md @@ -65,7 +65,7 @@ Batch launching is disabled by default. Open **Settings** and configure **Batch The token, exact configured origin, Chrome host permission and existing environment protection must all pass. A production-like host is not made safe merely by adding it to the batch list. Before injecting the tester, the extension removes the launcher marker from browser history and retains only the cleaned form URL in queue records. The extension popup reports active, queued and completed items and provides **Stop Batch**. -The launcher contains no extension ID. If the extension is absent, disabled or not loaded in the chosen Chrome profile, the form tabs simply open. See the project-root `BATCH-REGRESSION.md` for setup, form-list editing and troubleshooting. +The launcher contains no extension ID. If the extension is absent, disabled or not loaded in the chosen Chrome profile, the form tabs simply open. ## v0.2.6 Select Export Folder diff --git a/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd b/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd index 5891ced205..be2ba90dac 100644 --- a/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd +++ b/applications/Unity.Tools/Unity.CHEFS/run-regression-suite.cmd @@ -6,7 +6,7 @@ rem 1. Copy the launcher token from the extension Settings page below. rem 2. Set CHROME_PROFILE to the profile folder that has the extension loaded. rem 3. Add, remove, or reorder the OPEN_FORM lines in the embedded test list. -set "LAUNCHER_TOKEN=a08e7b8bce5a402d9262859aba8998380495eabebe594c33ace293a8f7c697ca" +set "LAUNCHER_TOKEN=PASTE_TOKEN_FROM_EXTENSION_SETTINGS_HERE" set "CHROME_PROFILE=Default" set "SUITE_ID=regression-%RANDOM%-%RANDOM%" From fe60888fcb6b4cb60e664e24f2dc897d1488f5f6 Mon Sep 17 00:00:00 2001 From: Stephan McColm <143556068+Stephan-McColm@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:14:15 -0700 Subject: [PATCH 08/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Unity.CHEFS/chefs-one-click-form-tester/options.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js index 1505c0e843..fe4f4d24f2 100644 --- a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/options.js @@ -21,8 +21,9 @@ const DEFAULT_SETTINGS = { let rules = []; function newRuleId() { - if (crypto && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); + const cryptoApi = globalThis.crypto; + if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID(); } return `rule-${Date.now()}-${Math.random().toString(16).slice(2)}`; } From 2a4e1e609cf6fa2684f61e00890a4bd9ff48fba4 Mon Sep 17 00:00:00 2001 From: Stephan McColm <143556068+Stephan-McColm@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:14:37 -0700 Subject: [PATCH 09/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../chefs-one-click-form-tester/PACKAGE-MANIFEST.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json index 65d8f31781..519b639605 100644 --- a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/PACKAGE-MANIFEST.json @@ -1,4 +1,4 @@ -{ +{ "extension": "CHEFS One-Click Form Tester", "version": "0.4.0", "build": "2026.07.23.14", From 22145af736a3ef36dfeffd80eb3cbe1b7a256624 Mon Sep 17 00:00:00 2001 From: Stephan McColm <143556068+Stephan-McColm@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:14:53 -0700 Subject: [PATCH 10/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt index cc4861be61..9254c7e1c5 100644 --- a/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt +++ b/applications/Unity.Tools/Unity.CHEFS/chefs-one-click-form-tester/SHA256SUMS.txt @@ -1,4 +1,4 @@ -b1371c6eb4498332a725369dcad54a3597b7e1e30e4e039aae5b87b0a7908d03 attachments/chefs-attachment.csv +b1371c6eb4498332a725369dcad54a3597b7e1e30e4e039aae5b87b0a7908d03 attachments/chefs-attachment.csv 24e8557c69ce50601b7abe3d45b50d2b8a513349614f94820249029f0b52064d attachments/chefs-attachment.docx 7662f0224080976c58fe50e0bd921d7a8033b5be16965f2fad66cd9e4a54ed34 attachments/chefs-attachment.jpg 3671c6bc4eb9ebb45c5c9cb88490680a3973fcb8da27562dd486350ef8dd11a1 attachments/chefs-attachment.json From 39fd4a9be9ebb39478666e42a55b3b5074e8bb61 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Thu, 23 Jul 2026 15:24:01 -0700 Subject: [PATCH 11/12] bugfix/AB#33848-FixRolesAndAdjustColumnSizeForEmailCancel --- .../Permissions/PermissionGrantsDataSeeder.cs | 3 ++- .../Views/Shared/Components/EmailHistoryWidget/Default.js | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index 03e898b420..bacbdb7800 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -76,7 +76,8 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) public readonly List NotificationsScheduling_CommonPermissions = [ NotificationsPermissions.Email.CancelScheduled, NotificationsPermissions.Email.ScheduleCreate, - NotificationsPermissions.Email.ScheduleCancel + NotificationsPermissions.Email.ScheduleCancel, + NotificationsPermissions.Email.Schedule ]; public readonly List Dashboard_CommonPermissions = [ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js index 993cbcb86f..e0555114ab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js @@ -217,7 +217,9 @@ }); PubSub.subscribe('refresh_application_emails', () => { - emailHistoryDataTable.ajax.reload(); + emailHistoryDataTable.ajax.reload(() => { + emailHistoryDataTable.columns.adjust().draw(); + }, false); }); $('#emails-tab').on('click', function () { From e6698c84a43b373a865450285f3c818fdbe432a8 Mon Sep 17 00:00:00 2001 From: Stephan McColm Date: Thu, 23 Jul 2026 15:57:10 -0700 Subject: [PATCH 12/12] Address Cypress PR review feedback --- .../Unity.AutoUI/cypress/e2e/basicEmail.cy.ts | 7 ++-- .../cypress/regression/ApprovalFlow.cy.ts | 32 +++++++++---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 48077844b7..eb14581527 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -318,10 +318,9 @@ describe("Send an email", () => { .should("be.visible") .click({ force: true }); - // The composer stays open after saving (it doesn't hand control back to - // #btn-new-email — that stays hidden while a composer is active), so - // check for the history list refreshing instead. - cy.contains("Email History", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.contains("#EmailHistoryTable td", TEST_EMAIL_SUBJECT, { + timeout: STANDARD_TIMEOUT, + }).should("exist"); }); it("Select saved email from Email History", () => { diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index b870a49147..afe702025e 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -225,27 +225,13 @@ const APPLICATIONS_PATH = "GrantApplications"; } function confirmStatusActionIfNeeded(): void { - // The confirmation modal (SweetAlert2 or Bootstrap "Confirm Action") renders after - // client-side validation that runs post-click — there's no network call to key a - // wait off of, and it can take longer than a single fixed delay to appear. Some - // actions (Start Review, Complete Review, Start Assessment) never show a modal at - // all, so we can't just wait for one to exist either. Poll for either outcome. - const pollDeadline = Date.now() + 4000; - cy.get("body", { timeout: 4000 }).should(($body) => { - const modalPresent = - $body.find(".swal2-popup .swal2-confirm").length > 0 || - $body.find(".modal.show .modal-content:contains('Confirm Action')") - .length > 0; - expect(modalPresent || Date.now() > pollDeadline).to.be.true; - }); - - cy.get("body").then(($body) => { + const confirmIfPresent = ($body: JQuery): boolean => { if ($body.find(".swal2-popup .swal2-confirm").length > 0) { cy.get(".swal2-popup .swal2-confirm", { timeout: 20000 }) .should("be.visible") .click({ force: true }); cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); - return; + return true; } if ( @@ -263,7 +249,21 @@ const APPLICATIONS_PATH = "GrantApplications"; timeout: 20000, }).should("not.exist"); cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + return true; + } + + return false; + }; + + cy.get("body").then(($body) => { + if (confirmIfPresent($body)) { + return; } + + cy.wait(750); + cy.get("body").then(($bodyAfterGracePeriod) => { + confirmIfPresent($bodyAfterGracePeriod); + }); }); }