@lowdefy/server-dev 6.1.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/App.jsx +52 -24
- package/client/Inspector.jsx +73 -30
- package/client/JourneyObserver.jsx +47 -0
- package/client/Page.jsx +54 -22
- package/client/Recorder.jsx +73 -0
- package/client/Routing.jsx +37 -13
- package/client/clientBuildImports.test.mjs +2 -0
- package/client/devStreamConnections.test.mjs +40 -0
- package/client/feedback/FeedbackMount.jsx +2 -2
- package/client/feedback/FeedbackOverlay.jsx +7 -4
- package/client/feedback/feedbackStyles.js +3 -0
- package/client/journeyObserver/startJourneyObserver.js +101 -0
- package/client/journeyObserver/startJourneyObserver.test.mjs +149 -0
- package/client/openInEditor/OpenInEditorListener.jsx +2 -0
- package/client/recorder/buildEngineRecord.js +37 -0
- package/client/recorder/buildInteractionRecord.js +64 -0
- package/client/recorder/buildTraceEvent.js +57 -0
- package/client/recorder/buildTraceEvent.test.mjs +81 -0
- package/client/recorder/chunkRecords.js +69 -0
- package/client/recorder/chunkRecords.test.mjs +59 -0
- package/client/recorder/collectEventActions.js +30 -0
- package/client/recorder/collectEventEndpoints.js +40 -0
- package/client/recorder/collectEventRequests.js +39 -0
- package/client/recorder/createPairingBuffer.js +187 -0
- package/client/recorder/createPairingBuffer.test.mjs +405 -0
- package/client/recorder/createPasswordRedactor.js +84 -0
- package/client/recorder/createRecordBatcher.js +54 -0
- package/client/recorder/createRecordBatcher.test.mjs +76 -0
- package/client/recorder/createRecorder.js +216 -0
- package/client/recorder/createRecorder.test.mjs +318 -0
- package/client/recorder/createRecordingSession.js +61 -0
- package/client/recorder/createRecordingSession.test.mjs +72 -0
- package/client/recorder/diffStateWrites.js +77 -0
- package/client/recorder/diffStateWrites.test.mjs +66 -0
- package/client/recorder/isEventCall.js +30 -0
- package/client/recorder/jsonType.js +39 -0
- package/client/recorder/keyChord.js +47 -0
- package/client/recorder/keyChord.test.mjs +50 -0
- package/client/recorder/sendRecords.js +40 -0
- package/client/recorder/toJsonSafe.js +29 -0
- package/lib/client/BuildingPage.jsx +3 -1
- package/lib/client/ErrorBar.jsx +17 -13
- package/lib/client/RedirectingPage.jsx +56 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +45 -4
- package/lib/client/auth/authNotConfiguredParity.test.mjs +61 -0
- package/lib/client/auth/authTemplateSync.test.mjs +40 -0
- package/lib/client/auth/betterAuthSessionContract.test.mjs +181 -0
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/bundleBuildId.js +25 -0
- package/lib/client/collectVisibleBlockIds.js +51 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/client/getPagePath.js +36 -0
- package/lib/client/getPagePath.test.mjs +51 -0
- package/lib/client/utils/formatErrorsForCopy.js +30 -0
- package/lib/client/utils/formatErrorsForCopy.test.mjs +39 -0
- package/lib/client/utils/getErrorBarColor.js +25 -0
- package/lib/client/utils/getErrorBarColor.test.mjs +34 -0
- package/lib/client/utils/request.js +8 -1
- package/lib/client/utils/usePageConfig.js +89 -25
- package/lib/client/utils/usePageConfig.test.mjs +198 -13
- package/lib/configTree/buildConfigTree.mjs +58 -0
- package/lib/configTree/buildConfigTree.test.mjs +77 -0
- package/lib/configTree/collectConfigText.mjs +101 -0
- package/lib/configTree/collectConfigText.test.mjs +116 -0
- package/lib/configTree/prepareScratchServer.mjs +46 -0
- package/lib/configTree/runConfigTreeBuild.mjs +88 -0
- package/lib/configTree/runConfigTreeBuild.skipPluginChecks.test.mjs +74 -0
- package/lib/configTree/runConfigTreeBuild.test.mjs +202 -0
- package/lib/docs/JourneyStepError.js +30 -0
- package/lib/docs/acquireBrowserSlot.js +30 -0
- package/lib/docs/addNestedEndpoints.js +54 -0
- package/lib/docs/addNestedEndpoints.test.mjs +185 -0
- package/lib/docs/addWriteFlags.js +77 -0
- package/lib/docs/buildEditedPages.js +50 -0
- package/lib/docs/buildWaitHeader.js +21 -0
- package/lib/docs/captureAnnotatedScreenshot.js +23 -12
- package/lib/docs/checkAgentFiles.js +148 -0
- package/lib/docs/checkAgentFiles.test.mjs +225 -0
- package/lib/docs/checkStepReferences.js +239 -0
- package/lib/docs/checkStepReferences.test.mjs +173 -0
- package/lib/docs/checkpointStore.js +3 -0
- package/lib/docs/clientErrorStore.js +18 -1
- package/lib/docs/clientErrorStore.test.mjs +46 -0
- package/lib/docs/collectExercised.js +52 -0
- package/lib/docs/createBrowserLifecycle.js +102 -0
- package/lib/docs/createBrowserLifecycle.test.mjs +212 -0
- package/lib/docs/createDocsMcpServer.js +345 -388
- package/lib/docs/createDocsMcpServer.test.mjs +644 -4
- package/lib/docs/createDocsMcpServerRunJourney.test.mjs +148 -0
- package/lib/docs/createJourneyActors.chromium.test.mjs +227 -0
- package/lib/docs/createJourneyActors.js +213 -0
- package/lib/docs/createJourneyActors.test.mjs +79 -0
- package/lib/docs/createJourneyObservations.js +80 -0
- package/lib/docs/createLeftOriginError.js +26 -0
- package/lib/docs/createNetworkCounter.js +101 -0
- package/lib/docs/dataSets/checkSharedCollectionIds.js +67 -0
- package/lib/docs/dataSets/dataSessionRegistry.js +25 -0
- package/lib/docs/dataSets/dataSetJourneys.chromium.mongodb.test.mjs +679 -0
- package/lib/docs/dataSets/describeDataSetResult.js +25 -0
- package/lib/docs/dataSets/describeDatabaseTarget.js +46 -0
- package/lib/docs/dataSets/deserializeFixture.js +34 -0
- package/lib/docs/dataSets/drainSessionWork.js +37 -0
- package/lib/docs/dataSets/drainSessionWork.test.mjs +64 -0
- package/lib/docs/dataSets/duplicateIndexName.js +24 -0
- package/lib/docs/dataSets/findCollectionCollisions.js +52 -0
- package/lib/docs/dataSets/getDataStore.js +106 -0
- package/lib/docs/dataSets/getDataStore.mongodb.test.mjs +63 -0
- package/lib/docs/dataSets/getDataStore.test.mjs +195 -0
- package/lib/docs/dataSets/groupDataSetByCollection.js +44 -0
- package/lib/docs/dataSets/insertFixture.js +57 -0
- package/lib/docs/dataSets/insertGeneratedDocuments.js +41 -0
- package/lib/docs/dataSets/isDataStoreGone.js +29 -0
- package/lib/docs/dataSets/loadDataSetCollection.js +42 -0
- package/lib/docs/dataSets/mergeCollectionIndexes.js +52 -0
- package/lib/docs/dataSets/mergeCollectionIndexes.test.mjs +59 -0
- package/lib/docs/dataSets/openDataSession.js +119 -0
- package/lib/docs/dataSets/openDataSession.mongodb.test.mjs +343 -0
- package/lib/docs/dataSets/pickDataStorePort.js +58 -0
- package/lib/docs/dataSets/pickDataStorePort.test.mjs +63 -0
- package/lib/docs/dataSets/readConnectionArtifacts.js +46 -0
- package/lib/docs/dataSets/readDataSession.js +41 -0
- package/lib/docs/dataSets/readDataSession.test.mjs +57 -0
- package/lib/docs/dataSets/readDataSet.js +68 -0
- package/lib/docs/dataSets/readDataSet.test.mjs +220 -0
- package/lib/docs/dataSets/registerDataStoreShutdown.js +47 -0
- package/lib/docs/dataSets/removeDataStoreFiles.js +32 -0
- package/lib/docs/dataSets/resolveDataSetCollection.js +46 -0
- package/lib/docs/dataSets/resolveJourneyDataSet.js +106 -0
- package/lib/docs/dataSets/resolveJourneyDataSet.test.mjs +130 -0
- package/lib/docs/dataSets/sweepOrphanDatabases.js +33 -0
- package/lib/docs/dataSets/test/app/lowdefy.yaml +203 -0
- package/lib/docs/dataSets/test/app/modules/archive/module.lowdefy.yaml +30 -0
- package/lib/docs/dataSets/test/app/tests/data/shop.yaml +8 -0
- package/lib/docs/dataSets/test/buildTestApp.mjs +43 -0
- package/lib/docs/devEventBus.js +136 -0
- package/lib/docs/devEventBus.test.mjs +269 -0
- package/lib/docs/devToolDefinitions.js +529 -0
- package/lib/docs/docSources.js +21 -0
- package/lib/docs/docs.test.mjs +171 -15
- package/lib/docs/docsIndex.test.mjs +377 -0
- package/lib/docs/evalOperator.js +5 -3
- package/lib/docs/evalOperatorHeadless.js +84 -12
- package/lib/docs/evalOperatorHeadless.test.mjs +7 -1
- package/lib/docs/evalOperatorInTab.js +2 -1
- package/lib/docs/findConfig.js +95 -49
- package/lib/docs/fitResponse.js +81 -0
- package/lib/docs/fitResponse.test.mjs +128 -0
- package/lib/docs/formatFeedback.js +26 -5
- package/lib/docs/formatFeedback.test.mjs +48 -0
- package/lib/docs/getAppMap.js +7 -1
- package/lib/docs/getBrowser.js +150 -80
- package/lib/docs/getBrowser.test.mjs +407 -16
- package/lib/docs/getBuildId.js +61 -0
- package/lib/docs/getBuildId.test.mjs +84 -0
- package/lib/docs/getBuildStatus.js +54 -6
- package/lib/docs/getBuildStatus.test.mjs +133 -0
- package/lib/docs/getBuildStatusAfterEdits.js +35 -0
- package/lib/docs/getBuildStatusAfterEdits.test.mjs +76 -0
- package/lib/docs/getCoreDoc.js +9 -1
- package/lib/docs/getDoc.js +100 -0
- package/lib/docs/getDocsIndex.js +152 -0
- package/lib/docs/getHazards.js +115 -0
- package/lib/docs/getHazards.test.mjs +175 -0
- package/lib/docs/getMailOutboxDirectory.js +26 -0
- package/lib/docs/getOverview.js +79 -13
- package/lib/docs/getOverviewBasePath.test.mjs +37 -0
- package/lib/docs/getPageBuildStatus.js +46 -0
- package/lib/docs/getPluginDoc.js +21 -23
- package/lib/docs/getSchema.js +33 -1
- package/lib/docs/getStaleStatus.js +36 -0
- package/lib/docs/getStaleStatus.test.mjs +74 -0
- package/lib/docs/guardJourneyOrigin.js +39 -0
- package/lib/docs/inspectState.js +5 -3
- package/lib/docs/inspectStateFromTab.js +2 -2
- package/lib/docs/inspectStateHeadless.js +89 -27
- package/lib/docs/inspectStateHeadless.test.mjs +20 -1
- package/lib/docs/installHeadlessShell.failure.test.mjs +45 -0
- package/lib/docs/installHeadlessShell.js +123 -0
- package/lib/docs/installHeadlessShell.test.mjs +70 -0
- package/lib/docs/installHeadlessShell.timeout.test.mjs +72 -0
- package/lib/docs/isHeadlessShellIncomplete.js +34 -0
- package/lib/docs/isHeadlessShellIncomplete.test.mjs +63 -0
- package/lib/docs/isPageReady.js +77 -0
- package/lib/docs/isPageReady.test.mjs +235 -0
- package/lib/docs/isRunErrorEntry.js +25 -0
- package/lib/docs/journeyMutantCookie.chromium.test.mjs +150 -0
- package/lib/docs/journeyRunRecording.js +35 -0
- package/lib/docs/journeyRunRecording.test.mjs +39 -0
- package/lib/docs/keepEventStreamAlive.js +68 -0
- package/lib/docs/keepEventStreamAlive.test.mjs +77 -0
- package/lib/docs/launchBrowser.js +83 -0
- package/lib/docs/launchBrowser.test.mjs +175 -0
- package/lib/docs/listModuleDocEntries.js +69 -0
- package/lib/docs/listPackageDocFiles.js +67 -0
- package/lib/docs/listPluginDocEntries.js +111 -0
- package/lib/docs/listPlugins.js +9 -58
- package/lib/docs/listTypes.js +14 -6
- package/lib/docs/loadFrameworkHazards.js +40 -0
- package/lib/docs/loadFrameworkHazards.test.mjs +43 -0
- package/lib/docs/loadState.js +103 -35
- package/lib/docs/makeModuleManifestDoc.js +174 -0
- package/lib/docs/matchExercisedPages.js +35 -0
- package/lib/docs/matchJourneyRoute.js +69 -0
- package/lib/docs/matchJourneyRoute.test.mjs +178 -0
- package/lib/docs/mergeNetworkSnapshots.js +49 -0
- package/lib/docs/nextJourneySequence.js +30 -0
- package/lib/docs/noBrowserError.js +22 -0
- package/lib/docs/observe/TRANSIENT_SELECTOR.js +21 -0
- package/lib/docs/observe/claimExpectedErrors.js +67 -0
- package/lib/docs/observe/createJourneyAppErrors.js +236 -0
- package/lib/docs/observe/createJourneyAppErrors.test.mjs +385 -0
- package/lib/docs/observe/evaluateInvariants.js +183 -0
- package/lib/docs/observe/evaluateInvariants.test.mjs +299 -0
- package/lib/docs/observe/findingKey.js +29 -0
- package/lib/docs/observe/hasNoEffect.js +31 -0
- package/lib/docs/observe/hasNoEffect.test.mjs +37 -0
- package/lib/docs/observe/installStepObserver.js +72 -0
- package/lib/docs/observe/isAppApiUrl.js +33 -0
- package/lib/docs/observe/parseAppApiUrl.js +37 -0
- package/lib/docs/observe/readStepObserver.js +42 -0
- package/lib/docs/observe/relativeSource.js +29 -0
- package/lib/docs/observe/resolveConfigKeySource.js +33 -0
- package/lib/docs/observe/takeJourneyEvents.js +36 -0
- package/lib/docs/observe/toAppErrorFailure.js +61 -0
- package/lib/docs/observe/usesSearchStage.js +45 -0
- package/lib/docs/observe/waitForClientErrorReports.js +31 -0
- package/lib/docs/observe/watchJourneyContext.js +75 -0
- package/lib/docs/observe/withAppErrors.js +31 -0
- package/lib/docs/observe/withAppErrors.test.mjs +101 -0
- package/lib/docs/openJourney.js +94 -0
- package/lib/docs/openJourneyEmail.js +41 -0
- package/lib/docs/openJourneyEmail.test.mjs +187 -0
- package/lib/docs/pageBlocks.js +47 -0
- package/lib/docs/pageBuildStatus.test.mjs +103 -0
- package/lib/{client/setPageId.js → docs/parseModuleSource.js} +11 -12
- package/lib/docs/rails/authEngineActions.js +53 -0
- package/lib/docs/rails/authEngineActions.test.mjs +69 -0
- package/lib/docs/rails/authEngineMethods.js +52 -0
- package/lib/docs/rails/authEngineMethods.test.mjs +36 -0
- package/lib/docs/rails/checkAuthActionsRan.js +66 -0
- package/lib/docs/rails/checkAuthActionsRan.test.mjs +149 -0
- package/lib/docs/rails/checkClickRails.js +56 -0
- package/lib/docs/rails/createDataSetRails.js +82 -0
- package/lib/docs/rails/createDataSetRails.test.mjs +172 -0
- package/lib/docs/rails/findExternalBlocks.js +147 -0
- package/lib/docs/rails/findExternalBlocks.test.mjs +254 -0
- package/lib/docs/rails/installAuthActionRecorder.js +56 -0
- package/lib/docs/rails/readAuthActionRecorder.js +32 -0
- package/lib/docs/rails/readBuiltArtifact.js +30 -0
- package/lib/docs/readConnectionPlugins.js +26 -0
- package/lib/docs/readDevAuthMode.js +32 -0
- package/lib/docs/readDocEntry.js +40 -0
- package/lib/docs/readDocTitle.js +26 -0
- package/lib/docs/readJourneyEmailMatch.js +57 -0
- package/lib/docs/readJourneyEmailMatch.test.mjs +110 -0
- package/lib/docs/readJourneySession.js +56 -0
- package/lib/docs/readMailOutbox.js +37 -0
- package/lib/docs/readPagePath.js +27 -0
- package/lib/docs/readPluginPackages.js +99 -0
- package/lib/docs/readProxyBuildWait.js +38 -0
- package/lib/docs/readRoutineSteps.js +45 -0
- package/lib/docs/registerShownTextEngine.js +118 -0
- package/lib/docs/requestRestart.js +30 -0
- package/lib/docs/requestRestart.test.mjs +49 -0
- package/lib/docs/resolvePageInstance.js +46 -0
- package/lib/docs/resolvePageInstance.test.mjs +81 -0
- package/lib/docs/resolveSource.js +11 -7
- package/lib/docs/resolveSource.test.mjs +1 -1
- package/lib/docs/resolveToolCaller.js +62 -0
- package/lib/docs/reviewPage.js +51 -0
- package/lib/docs/reviewPageBuilds.js +56 -0
- package/lib/docs/reviewPageBuilds.test.mjs +207 -0
- package/lib/docs/runCheck.js +75 -0
- package/lib/docs/runCheck.test.mjs +91 -0
- package/lib/docs/runCheckChild.js +49 -0
- package/lib/docs/runEndpoint.js +185 -0
- package/lib/docs/runEndpoint.test.mjs +509 -0
- package/lib/docs/runErrorBuffers.js +99 -0
- package/lib/docs/runErrorBuffers.test.mjs +95 -0
- package/lib/docs/runJourney.js +347 -0
- package/lib/docs/runJourney.test.mjs +2383 -0
- package/lib/docs/runJourneyAsUsers.js +44 -0
- package/lib/docs/runJourneyDataSet.test.mjs +274 -0
- package/lib/docs/runJourneySteps.js +1320 -0
- package/lib/docs/runRequest.js +100 -59
- package/lib/docs/runRequest.test.mjs +190 -0
- package/lib/docs/saveAnnotatedScreenshot.js +1 -1
- package/lib/docs/scaffoldPage.js +3 -1
- package/lib/docs/screenshotPage.js +163 -11
- package/lib/docs/screenshotPage.test.mjs +263 -1
- package/lib/docs/screenshotPageClip.test.mjs +4 -0
- package/lib/docs/searchDocs.js +82 -30
- package/lib/docs/searchDocs.test.mjs +100 -0
- package/lib/docs/searchIcons.js +90 -0
- package/lib/docs/searchIcons.test.mjs +127 -0
- package/lib/docs/selectExercised.js +39 -0
- package/lib/docs/selectFinalState.js +66 -0
- package/lib/docs/selectFinalState.test.mjs +51 -0
- package/lib/docs/serverErrorStore.js +54 -0
- package/lib/docs/serverErrorStore.test.mjs +50 -0
- package/lib/docs/setupTestFixtures.mjs +133 -39
- package/lib/docs/shownTextSelector.js +28 -0
- package/lib/docs/snapshotState.js +13 -5
- package/lib/docs/splitSearchTerms.js +62 -0
- package/lib/docs/splitSearchTerms.test.mjs +50 -0
- package/lib/docs/stateCheckpoints.test.mjs +77 -0
- package/lib/docs/tabAvailable.js +6 -5
- package/lib/docs/tabChannel.js +127 -21
- package/lib/docs/tabChannel.test.mjs +187 -29
- package/lib/docs/unsettledPageNote.js +24 -0
- package/lib/docs/validateJourneyMail.js +36 -0
- package/lib/docs/validateJourneyTimeout.js +38 -0
- package/lib/docs/validateJourneyTimeout.test.mjs +29 -0
- package/lib/docs/validateRecordingParam.js +42 -0
- package/lib/docs/validateStateSelection.js +33 -0
- package/lib/docs/validateStateSelection.test.mjs +33 -0
- package/lib/docs/validateViewport.js +51 -0
- package/lib/docs/validateViewport.test.mjs +54 -0
- package/lib/docs/waitForBuild.js +50 -0
- package/lib/docs/waitForBuild.test.mjs +75 -0
- package/lib/docs/waitForJourneyEmail.js +77 -0
- package/lib/docs/withBrowserSlot.js +45 -0
- package/lib/docs/withBrowserSlot.test.mjs +75 -0
- package/lib/docs/withDataSession.js +53 -0
- package/lib/server/applyDataSetRedirect.js +83 -0
- package/lib/server/applyDataSetRedirect.test.mjs +340 -0
- package/lib/server/applyDataSetRedirectTenantReach.test.mjs +204 -0
- package/lib/server/applyDataSetRedirectWalled.test.mjs +131 -0
- package/lib/server/auth/createSystemContext.js +73 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getHeadlessUser.js +9 -8
- package/lib/server/auth/getHeadlessUser.test.mjs +41 -0
- package/lib/server/auth/getMockUser.js +18 -14
- package/lib/server/auth/{session.js → getStrategies.js} +17 -16
- package/lib/server/auth/headlessUser.js +3 -2
- package/lib/server/auth/journeyActor.js +35 -0
- package/lib/server/auth/resolveHeadlessUser.test.mjs +0 -4
- package/lib/server/checkPageRecord.js +44 -0
- package/lib/server/contextMapBudget.js +24 -0
- package/lib/server/contextMapBudget.test.mjs +98 -0
- package/lib/server/continueJitKeys.js +28 -0
- package/lib/server/continueJitKeys.test.mjs +43 -0
- package/lib/server/createDataSessionEndedError.js +33 -0
- package/lib/server/createDataSetRefusal.js +26 -0
- package/lib/server/createLowdefyContext.js +168 -41
- package/lib/server/createLowdefyContext.test.mjs +328 -8
- package/lib/server/getClientAddress.js +51 -0
- package/lib/server/getClientAddress.test.mjs +56 -0
- package/lib/server/getPageJitEnrichment.test.mjs +29 -11
- package/lib/server/hashConfigContent.js +26 -0
- package/lib/server/importAppCode.test.mjs +57 -0
- package/lib/server/isRoutineStep.js +27 -0
- package/lib/server/jitPageBuilder.js +309 -126
- package/lib/server/jitPageBuilder.skipPluginChecks.test.mjs +78 -0
- package/lib/server/jitPageBuilder.test.mjs +218 -0
- package/lib/server/journeyCookies/JOURNEY_COOKIES.js +37 -0
- package/lib/server/journeyCookies/forwardJourneyCookies.js +46 -0
- package/lib/server/journeyCookies/readJourneyCookie.js +52 -0
- package/lib/server/journeyCookies/writeJourneyCookie.js +46 -0
- package/lib/server/journeyCookies.js +22 -0
- package/lib/server/journeyCookies.test.mjs +145 -0
- package/lib/server/loadDynamicJsMap.js +47 -0
- package/lib/server/log/createHandleError.js +38 -2
- package/lib/server/log/createHandleError.test.mjs +125 -2
- package/lib/server/log/createLogger.js +3 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +2 -3
- package/lib/server/mutantDataSetWrappers.test.mjs +112 -0
- package/lib/server/mutants/applyMutant.js +39 -0
- package/lib/server/mutants/artifactPatterns.js +25 -0
- package/lib/server/mutants/buildAnchor.js +45 -0
- package/lib/server/mutants/collectSharedIds.js +41 -0
- package/lib/server/mutants/configPathSegments.js +38 -0
- package/lib/server/mutants/createMutantReadConfigFile.js +42 -0
- package/lib/server/mutants/describeEvent.js +22 -0
- package/lib/server/mutants/enumerateArtifact.js +56 -0
- package/lib/server/mutants/findNodeByKey.js +45 -0
- package/lib/server/mutants/findNodeByPointer.js +49 -0
- package/lib/server/mutants/getArtifactKind.js +32 -0
- package/lib/server/mutants/groupMutantCopies.js +56 -0
- package/lib/server/mutants/hashArtifact.js +25 -0
- package/lib/server/mutants/hashMutantTarget.js +34 -0
- package/lib/server/mutants/listMutants.js +122 -0
- package/lib/server/mutants/listMutants.test.mjs +502 -0
- package/lib/server/mutants/mutantId.js +30 -0
- package/lib/server/mutants/mutantRuns.js +57 -0
- package/lib/server/mutants/mutantRuns.test.mjs +136 -0
- package/lib/server/mutants/operators/dropAction.js +47 -0
- package/lib/server/mutants/operators/dropBlock.js +44 -0
- package/lib/server/mutants/operators/dropPayload.js +52 -0
- package/lib/server/mutants/operators/dropStep.js +44 -0
- package/lib/server/mutants/operators/flipVisible.js +53 -0
- package/lib/server/mutants/operators/index.js +37 -0
- package/lib/server/mutants/operators/retargetLink.js +72 -0
- package/lib/server/mutants/operators/skipValidate.js +77 -0
- package/lib/server/mutants/operators/swapIf.js +112 -0
- package/lib/server/mutants/operators.test.mjs +396 -0
- package/lib/server/mutants/positionFreePath.js +38 -0
- package/lib/server/mutants/removeFromParent.js +28 -0
- package/lib/server/mutants/test/fixtures.mjs +173 -0
- package/lib/server/mutants/test/keyArtifact.mjs +63 -0
- package/lib/server/mutants/validateMutantParam.js +69 -0
- package/lib/server/mutants/validateMutantsBody.js +88 -0
- package/lib/server/mutants/walkArtifact.js +179 -0
- package/lib/server/pageBuildRecords.js +131 -0
- package/lib/server/pageBuildRecords.test.mjs +258 -0
- package/lib/server/pageCache.mjs +22 -7
- package/lib/server/pageCache.test.mjs +22 -7
- package/lib/server/pageRebuilds.test.mjs +543 -0
- package/lib/server/pruneJitMaps.js +36 -0
- package/lib/server/pruneJitMaps.test.mjs +55 -0
- package/lib/server/readInstanceRecord.js +33 -0
- package/lib/server/readInstanceRecord.test.mjs +99 -0
- package/lib/server/readMergedMaps.js +86 -0
- package/lib/server/readMergedMaps.test.mjs +79 -0
- package/lib/server/recording/appendRecords.js +33 -0
- package/lib/server/recording/errorRecordingStamp.js +34 -0
- package/lib/server/recording/errorRecordingStamp.test.mjs +31 -0
- package/lib/server/recording/isRecordingEnabled.js +31 -0
- package/lib/server/recording/readRecordingCookie.js +71 -0
- package/lib/server/recording/readRecordingCookie.test.mjs +73 -0
- package/lib/server/recording/recordingCookiePayload.js +33 -0
- package/lib/server/recording/servedBuilds.js +46 -0
- package/lib/server/recording/servedBuilds.test.mjs +42 -0
- package/lib/server/sinkExternalConnections.js +95 -0
- package/lib/server/sinkExternalConnections.test.mjs +135 -0
- package/lib/server/skipStaleMapWrites.js +62 -0
- package/lib/server/skipStaleMapWrites.test.mjs +113 -0
- package/lib/server/startIdleGc.js +67 -0
- package/lib/server/startIdleGc.test.mjs +138 -0
- package/lib/server/testFixtures/importAppCodeTwice.mjs +70 -0
- package/lib/server/trackSessionWork.js +30 -0
- package/lib/vite/createDevServerExclude.js +40 -0
- package/lib/vite/createDevServerExclude.test.mjs +79 -0
- package/manager/getContext.mjs +24 -2
- package/manager/processes/initialBuild.mjs +14 -0
- package/manager/processes/initialBuild.test.mjs +101 -0
- package/manager/processes/lowdefyBuild.mjs +30 -7
- package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +62 -0
- package/manager/processes/lowdefyBuild.test.mjs +136 -1
- package/manager/processes/optimizeDependencies.mjs +59 -0
- package/manager/processes/restartServer.mjs +34 -6
- package/manager/processes/restartServer.test.mjs +162 -0
- package/manager/processes/shutdownServer.mjs +11 -0
- package/manager/processes/shutdownServer.test.mjs +45 -0
- package/manager/processes/startFirstServer.mjs +46 -0
- package/manager/processes/startFirstServer.test.mjs +326 -0
- package/manager/processes/startMailSink.mjs +135 -0
- package/manager/processes/startMailSink.test.mjs +146 -0
- package/manager/processes/startProxy.mjs +129 -12
- package/manager/processes/startProxy.test.mjs +366 -0
- package/manager/processes/startServer.mjs +47 -6
- package/manager/processes/startServer.test.mjs +155 -0
- package/manager/processes/startWatchers.mjs +6 -4
- package/manager/processes/syncServer.mjs +69 -0
- package/manager/processes/syncServer.test.mjs +141 -0
- package/manager/processes/waitForDevServer.mjs +48 -0
- package/manager/processes/waitForDevServer.test.mjs +112 -0
- package/manager/run.mjs +128 -43
- package/manager/utils/BatchChanges.mjs +21 -5
- package/manager/utils/BatchChanges.test.mjs +77 -0
- package/manager/utils/acquireDevInstance.mjs +99 -0
- package/manager/utils/acquireDevInstance.test.mjs +160 -0
- package/manager/utils/createBuildActivity.mjs +67 -0
- package/manager/utils/createBuildActivity.test.mjs +58 -0
- package/manager/utils/createChangeSignal.mjs +39 -0
- package/manager/utils/createChangeSignal.test.mjs +62 -0
- package/manager/utils/createCustomPluginMessagesMap.mjs +23 -33
- package/manager/utils/createCustomPluginTypesMap.mjs +24 -30
- package/manager/utils/createCustomPluginTypesMap.test.mjs +94 -0
- package/manager/utils/createDotPathIgnore.mjs +44 -0
- package/manager/utils/createDotPathIgnore.test.mjs +65 -0
- package/manager/utils/createIdleGc.mjs +51 -0
- package/manager/utils/createIdleGc.test.mjs +77 -0
- package/manager/utils/createRequestActivity.mjs +100 -0
- package/manager/utils/createRequestActivity.test.mjs +121 -0
- package/manager/utils/createServerArtifactTracker.mjs +83 -0
- package/manager/utils/createServerArtifactTracker.test.mjs +78 -0
- package/manager/utils/createServerEnv.mjs +39 -0
- package/manager/utils/filesHaveSameBytes.mjs +41 -0
- package/manager/utils/findBuildFilesOutsideWatch.mjs +59 -0
- package/manager/utils/findBuildFilesOutsideWatch.test.mjs +111 -0
- package/manager/utils/importFresh.mjs +46 -0
- package/manager/utils/importFresh.test.mjs +72 -0
- package/manager/utils/importFreshWorker.mjs +20 -0
- package/manager/utils/killTaggedBrowser.mjs +35 -0
- package/manager/utils/pagePathChanged.mjs +56 -0
- package/manager/utils/pruneRecordings.mjs +85 -0
- package/manager/utils/pruneRecordings.test.mjs +117 -0
- package/manager/utils/publishBuildDirectory.mjs +121 -0
- package/manager/utils/publishBuildDirectory.order.test.mjs +74 -0
- package/manager/utils/publishBuildDirectory.test.mjs +262 -0
- package/manager/utils/readBasePath.mjs +27 -0
- package/manager/utils/readBasePath.test.mjs +43 -0
- package/manager/utils/readBuildStatusWait.mjs +67 -0
- package/manager/utils/readManagerVersion.mjs +30 -0
- package/manager/utils/readManagerVersion.test.mjs +35 -0
- package/manager/utils/readPluginDefinitions.mjs +36 -0
- package/manager/utils/resolveDevAuthUrl.mjs +58 -0
- package/manager/utils/resolveDevAuthUrl.test.mjs +107 -0
- package/manager/utils/resolvePorts.mjs +49 -0
- package/manager/utils/setupWatcher.mjs +19 -11
- package/manager/utils/startRecordingPruner.mjs +45 -0
- package/manager/utils/startRecordingPruner.test.mjs +66 -0
- package/manager/utils/waitForChildExit.mjs +53 -0
- package/manager/utils/waitForChildExit.test.mjs +70 -0
- package/manager/utils/waitForServer.mjs +44 -0
- package/manager/utils/writeBuildStatus.mjs +1 -1
- package/manager/watchers/createPluginSourceIgnore.mjs +77 -0
- package/manager/watchers/envWatcher.mjs +9 -2
- package/manager/watchers/envWatcher.test.mjs +87 -0
- package/manager/watchers/listServerSidePackages.mjs +47 -0
- package/manager/watchers/listServerSidePackages.test.mjs +38 -0
- package/manager/watchers/lowdefyBuildWatcher.mjs +106 -22
- package/manager/watchers/lowdefyBuildWatcher.test.mjs +309 -0
- package/manager/watchers/pluginSourceWatcher.mjs +79 -0
- package/manager/watchers/pluginSourceWatcher.test.mjs +222 -0
- package/manager/watchers/restartRequestWatcher.mjs +71 -0
- package/manager/watchers/restartRequestWatcher.test.mjs +111 -0
- package/manager/watchers/selectLocalPluginPackages.mjs +48 -0
- package/manager/watchers/selectLocalPluginPackages.test.mjs +73 -0
- package/manager/watchers/serverPackageWatcher.mjs +38 -0
- package/package.json +60 -47
- package/package.original.json +60 -47
- package/src/app.js +131 -20
- package/src/html/renderDevPage.js +63 -11
- package/src/html/renderDevPage.test.mjs +138 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/createSameOriginGuard.js +37 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +51 -1
- package/src/middleware/errorHandler.test.mjs +173 -3
- package/src/middleware/isRebindingSafeHost.js +44 -0
- package/src/middleware/isRebindingSafeHost.test.mjs +32 -0
- package/src/middleware/localDevToolsOnly.js +38 -0
- package/src/middleware/localDevToolsOnly.test.mjs +61 -0
- package/src/middleware/noStore.js +27 -0
- package/src/middleware/noStore.test.mjs +48 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/staleFlag.js +76 -0
- package/src/middleware/staleFlag.test.mjs +127 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/auth.js +29 -20
- package/src/routes/auth.test.mjs +30 -56
- package/src/routes/clientError.js +13 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/devInspect.js +17 -8
- package/src/routes/devInspect.test.mjs +44 -7
- package/src/routes/devRecording.js +113 -0
- package/src/routes/devRecording.test.mjs +253 -0
- package/src/routes/docs/buildStatus.js +10 -2
- package/src/routes/docs/buildStatus.test.mjs +46 -0
- package/src/routes/docs/content.js +5 -3
- package/src/routes/docs/evalOperator.js +15 -1
- package/src/routes/docs/events.js +50 -0
- package/src/routes/docs/events.test.mjs +117 -0
- package/src/routes/docs/find.js +9 -1
- package/src/routes/docs/icons.js +30 -0
- package/src/routes/docs/inspectState.js +16 -1
- package/src/routes/docs/journey.js +194 -0
- package/src/routes/docs/journey.test.mjs +401 -0
- package/src/routes/docs/journeyRefusals.test.mjs +71 -0
- package/src/routes/docs/loadState.js +4 -2
- package/src/routes/docs/mcp.js +34 -3
- package/src/routes/docs/mcp.test.mjs +130 -0
- package/src/routes/docs/mutants.js +78 -0
- package/src/routes/docs/mutants.test.mjs +180 -0
- package/src/routes/docs/parsePathParamsParam.js +41 -0
- package/src/routes/docs/parsePathParamsParam.test.mjs +42 -0
- package/src/routes/docs/parseUserParam.js +27 -21
- package/src/routes/docs/parseUserParam.test.mjs +17 -5
- package/src/routes/docs/restart.js +35 -0
- package/src/routes/docs/restart.test.mjs +64 -0
- package/src/routes/docs/runEndpoint.js +54 -0
- package/src/routes/docs/runEndpoint.test.mjs +165 -0
- package/src/routes/docs/runRequest.js +25 -8
- package/src/routes/docs/runRequest.test.mjs +92 -0
- package/src/routes/docs/screenshot.js +50 -2
- package/src/routes/docs/screenshot.test.mjs +117 -0
- package/src/routes/docs/search.js +15 -2
- package/src/routes/docs/snapshotState.js +17 -3
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/feedback.js +10 -13
- package/src/routes/jitPage.js +99 -35
- package/src/routes/jitPage.test.mjs +248 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountDevPageRoutes.js +25 -0
- package/src/routes/mountDevPageRoutes.test.mjs +30 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/reload.js +16 -0
- package/src/routes/request.js +6 -4
- package/src/routes/request.test.mjs +9 -5
- package/src/routes/requestBody.test.mjs +159 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +29 -8
- package/src/routes/websocket.test.mjs +65 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/src/routes/wellKnownFallback.test.mjs +32 -0
- package/src/websocket/devWebSocket.js +41 -26
- package/src/websocket/devWebSocket.test.mjs +157 -0
- package/vite.config.js +53 -23
- package/lib/server/auth/getDevSession.js +0 -70
- package/lib/server/auth/getDevSession.test.mjs +0 -138
- package/manager/processes/warnAuthUrlPortMismatch.mjs +0 -47
- package/manager/processes/warnAuthUrlPortMismatch.test.mjs +0 -93
- package/manager/watchers/moduleBuildWatcher.mjs +0 -79
- package/manager/watchers/serverArtifactWatcher.mjs +0 -120
- package/src/routes/apiPage.js +0 -37
|
@@ -0,0 +1,1320 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { get, journeyTargetSelectors, type } from '@lowdefy/helpers';
|
|
18
|
+
import {
|
|
19
|
+
getBlock,
|
|
20
|
+
getRequestState,
|
|
21
|
+
getShortcutModifier,
|
|
22
|
+
getState,
|
|
23
|
+
} from '@lowdefy/e2e-utils/runtime';
|
|
24
|
+
import { getStepKey } from '@lowdefy/node-utils';
|
|
25
|
+
|
|
26
|
+
import checkAuthActionsRan from './rails/checkAuthActionsRan.js';
|
|
27
|
+
import checkClickRails from './rails/checkClickRails.js';
|
|
28
|
+
import checkStepReferences from './checkStepReferences.js';
|
|
29
|
+
import createLeftOriginError from './createLeftOriginError.js';
|
|
30
|
+
import { buildPageUrl } from './getBrowser.js';
|
|
31
|
+
import hasNoEffect from './observe/hasNoEffect.js';
|
|
32
|
+
import isPageReady from './isPageReady.js';
|
|
33
|
+
import JourneyStepError from './JourneyStepError.js';
|
|
34
|
+
import nextJourneySequence from './nextJourneySequence.js';
|
|
35
|
+
import openJourneyEmail from './openJourneyEmail.js';
|
|
36
|
+
import readJourneyEmailMatch from './readJourneyEmailMatch.js';
|
|
37
|
+
import readPagePath from './readPagePath.js';
|
|
38
|
+
import shownTextSelector from './shownTextSelector.js';
|
|
39
|
+
|
|
40
|
+
// Structural equality over values that have already been through the JSON
|
|
41
|
+
// round-trip getState performs in the page (no undefined, no Dates, no
|
|
42
|
+
// functions) — so key order is the only thing that must not matter.
|
|
43
|
+
function isDeepEqual(a, b) {
|
|
44
|
+
if (a === b) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (type.isArray(a) && type.isArray(b)) {
|
|
48
|
+
return a.length === b.length && a.every((item, index) => isDeepEqual(item, b[index]));
|
|
49
|
+
}
|
|
50
|
+
if (type.isObject(a) && type.isObject(b)) {
|
|
51
|
+
const keysA = Object.keys(a);
|
|
52
|
+
const keysB = Object.keys(b);
|
|
53
|
+
return keysA.length === keysB.length && keysA.every((key) => isDeepEqual(a[key], b[key]));
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Playwright colours its call log with ANSI escapes; the result is read by an
|
|
59
|
+
// agent as JSON, where they are noise.
|
|
60
|
+
function cleanMessage(error) {
|
|
61
|
+
// eslint-disable-next-line no-control-regex
|
|
62
|
+
return error.message.replace(/\u001b\[[0-9;]*m/g, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Wraps a Playwright interaction so a locator that never became actionable
|
|
66
|
+
// (missing block, hidden, disabled, detached) reads as "expected the block
|
|
67
|
+
// to be actionable, actual: <Playwright's message>" instead of a bare error.
|
|
68
|
+
async function actOnTarget({ target, action }) {
|
|
69
|
+
try {
|
|
70
|
+
await action();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof JourneyStepError) {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
const actual = cleanMessage(error);
|
|
76
|
+
const description = describeTarget(target);
|
|
77
|
+
throw new JourneyStepError(`${capitalise(description)} was not actionable: ${actual}`, {
|
|
78
|
+
expected: `${description} to be actionable`,
|
|
79
|
+
actual,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function capitalise(text) {
|
|
85
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The #bl-<id> wrapper spans the full row while the control inside it (an
|
|
89
|
+
// antd button, a link, a checkbox) is usually narrower, so a click at the
|
|
90
|
+
// wrapper's centre can land beside the control. The block's own e2e helpers
|
|
91
|
+
// target the inner control for the same reason; a block with no interactive
|
|
92
|
+
// descendant (a Box with its own onClick) is clicked directly.
|
|
93
|
+
//
|
|
94
|
+
// A radio or checkbox inside a <label> is reached through the label: the label
|
|
95
|
+
// carries the option's text and is what a person clicks, while the input may
|
|
96
|
+
// have no size at all (antd Segmented hides it at zero width and height), so
|
|
97
|
+
// Playwright would never find it visible. The selectors live in
|
|
98
|
+
// @lowdefy/helpers, where the engine's describe functions read them too.
|
|
99
|
+
const {
|
|
100
|
+
cellAttribute: CELL_ATTRIBUTE,
|
|
101
|
+
checkboxOption: CHECKBOX_OPTION,
|
|
102
|
+
dropdownOption: DROPDOWN_OPTION,
|
|
103
|
+
interactiveControl: INTERACTIVE_CONTROL,
|
|
104
|
+
layers: LAYERS,
|
|
105
|
+
radioOption: RADIO_OPTION,
|
|
106
|
+
rowAttribute: ROW_ATTRIBUTE,
|
|
107
|
+
} = journeyTargetSelectors;
|
|
108
|
+
|
|
109
|
+
// A lazy block (createLazyBlock in @lowdefy/block-utils) shows its wrapper and
|
|
110
|
+
// a fallback before its implementation, and counts itself in
|
|
111
|
+
// window.__lowdefyLazyLoads from mount until the implementation has rendered.
|
|
112
|
+
// A table that has loaded may still hold its loading skeleton for a minimum
|
|
113
|
+
// time (useSkeletonTiming in @lowdefy/blocks-antd), counted in
|
|
114
|
+
// window.__lowdefySkeletonHolds; a pointer press ends the hold, so a click
|
|
115
|
+
// started on the skeleton lands on rows that are no longer there. Waits for
|
|
116
|
+
// both counts to reach 0, as isPageReady does, so a block's controls are
|
|
117
|
+
// counted once they exist; with neither on screen it passes at once.
|
|
118
|
+
async function waitForLoadingBlocks({ page, timeout }) {
|
|
119
|
+
try {
|
|
120
|
+
await page.waitForFunction(
|
|
121
|
+
() => (window.__lowdefyLazyLoads ?? 0) === 0 && (window.__lowdefySkeletonHolds ?? 0) === 0,
|
|
122
|
+
undefined,
|
|
123
|
+
{ timeout }
|
|
124
|
+
);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new JourneyStepError(`A block was still loading after ${timeout} ms.`, {
|
|
127
|
+
expected:
|
|
128
|
+
'every lazy block on the page to have loaded and every loading skeleton to have cleared',
|
|
129
|
+
actual: cleanMessage(error),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Waits for the block (or its control) to show, and for a lazy block's
|
|
135
|
+
// implementation, before counting, so a block that renders a moment after the
|
|
136
|
+
// step starts is clicked through its control, not at its wrapper's centre or
|
|
137
|
+
// on its fallback. A block that never shows fails the wait.
|
|
138
|
+
async function resolveClickTarget({ scope, timeout }) {
|
|
139
|
+
const control = scope.locator(INTERACTIVE_CONTROL).first();
|
|
140
|
+
await control.or(scope).first().waitFor({ state: 'visible', timeout });
|
|
141
|
+
await waitForLoadingBlocks({ page: scope.page(), timeout });
|
|
142
|
+
if ((await control.count()) > 0) {
|
|
143
|
+
return control;
|
|
144
|
+
}
|
|
145
|
+
return scope;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Exact match on a control's or option's text: a regex anchored at both ends,
|
|
149
|
+
// so "Cat" does not pick "Category".
|
|
150
|
+
function exactText(value) {
|
|
151
|
+
return new RegExp(`^\\s*${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// A step's target is a blockId string or an object narrowing the search (see
|
|
155
|
+
// validateJourneySteps). The string form is the object form with only blockId.
|
|
156
|
+
function normaliseTarget(target) {
|
|
157
|
+
if (type.isString(target)) {
|
|
158
|
+
return { blockId: target };
|
|
159
|
+
}
|
|
160
|
+
return target;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The block, row and column a target is narrowed to, as describeTarget reads them.
|
|
164
|
+
function describeScopeParts(target) {
|
|
165
|
+
const parts = [];
|
|
166
|
+
if (!type.isUndefined(target.blockId)) {
|
|
167
|
+
parts.push(`block "${target.blockId}"`);
|
|
168
|
+
}
|
|
169
|
+
if (!type.isUndefined(target.row)) {
|
|
170
|
+
parts.push(`row ${target.row}`);
|
|
171
|
+
}
|
|
172
|
+
if (!type.isUndefined(target.column)) {
|
|
173
|
+
parts.push(`column "${target.column}"`);
|
|
174
|
+
}
|
|
175
|
+
return parts;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reads back the way an author thinks of it — `block "grid" row 1 control
|
|
179
|
+
// "Edit"` — for the expected/actual pair of a failed step.
|
|
180
|
+
function describeTarget(target) {
|
|
181
|
+
const parts = describeScopeParts(target);
|
|
182
|
+
if (!type.isUndefined(target.text)) {
|
|
183
|
+
parts.push(`control "${target.text}"`);
|
|
184
|
+
}
|
|
185
|
+
if (!type.isUndefined(target.containing)) {
|
|
186
|
+
parts.push(`text containing "${target.containing}"`);
|
|
187
|
+
}
|
|
188
|
+
if (!type.isUndefined(target.nth)) {
|
|
189
|
+
parts.push(`nth ${target.nth}`);
|
|
190
|
+
}
|
|
191
|
+
return parts.join(' ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function attributeValue(value) {
|
|
195
|
+
return `"${String(value).replace(/["\\]/g, '\\$&')}"`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The element the target's scope keys name: the block wrapper, narrowed to a
|
|
199
|
+
// grid row (`.ag-row[row-index]`, the row as displayed, which ag-grid renders
|
|
200
|
+
// once per pinned/centre column container) and to a cell (`.ag-cell[col-id]`,
|
|
201
|
+
// the column's field or colId). Undefined for a page-wide `text` target,
|
|
202
|
+
// which has no scope narrower than the page.
|
|
203
|
+
function resolveScope({ page, target }) {
|
|
204
|
+
if (type.isUndefined(target.blockId)) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
let scope = getBlock(page, target.blockId);
|
|
208
|
+
if (!type.isUndefined(target.row)) {
|
|
209
|
+
scope = scope.locator(`.ag-row[${ROW_ATTRIBUTE}=${attributeValue(target.row)}]`);
|
|
210
|
+
}
|
|
211
|
+
if (!type.isUndefined(target.column)) {
|
|
212
|
+
scope = scope.locator(`.ag-cell[${CELL_ATTRIBUTE}=${attributeValue(target.column)}]`);
|
|
213
|
+
}
|
|
214
|
+
return scope;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The scope a `text` or `containing` target searches. A Modal or Drawer renders its content and
|
|
218
|
+
// footer in a portal outside its #bl- wrapper, marked with a class naming the block, so a target
|
|
219
|
+
// scoped to the block alone (no row or column) searches the wrapper and that portal's root.
|
|
220
|
+
function resolveMatchScope({ page, target }) {
|
|
221
|
+
const scope = resolveScope({ page, target });
|
|
222
|
+
if (
|
|
223
|
+
type.isUndefined(scope) ||
|
|
224
|
+
!type.isUndefined(target.row) ||
|
|
225
|
+
!type.isUndefined(target.column)
|
|
226
|
+
) {
|
|
227
|
+
return scope;
|
|
228
|
+
}
|
|
229
|
+
return scope.or(page.locator(`.${journeyTargetSelectors.portalClass(target.blockId)}`));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function controlsWithText({ root, text }) {
|
|
233
|
+
return root
|
|
234
|
+
.locator(INTERACTIVE_CONTROL)
|
|
235
|
+
.filter({ hasText: exactText(text) })
|
|
236
|
+
.filter({ visible: true });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// LAYERS are the portal layers, front-most first. A control found by text alone is looked
|
|
240
|
+
// for in the front-most open layer before the page, the way a person reads a
|
|
241
|
+
// screen: an open dropdown menu covers a dialog, a dialog covers the page. A
|
|
242
|
+
// confirm dialog's "Delete" is then found over the grid's "Delete" cell
|
|
243
|
+
// buttons behind its mask, without the author counting buttons.
|
|
244
|
+
async function locatePageWideText({ page, target }) {
|
|
245
|
+
for (const layer of LAYERS) {
|
|
246
|
+
const open = page.locator(layer).filter({ visible: true });
|
|
247
|
+
if ((await open.count()) > 0) {
|
|
248
|
+
const inLayer = controlsWithText({ root: open.last(), text: target.text });
|
|
249
|
+
if ((await inLayer.count()) > 0) {
|
|
250
|
+
return { matches: inLayer, where: 'the front-most open layer' };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { matches: controlsWithText({ root: page, text: target.text }), where: 'the page' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function namesMatches(target) {
|
|
258
|
+
return !type.isUndefined(target.text) || !type.isUndefined(target.containing);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Every visible element a `text` or `containing` target matches, before `nth`
|
|
262
|
+
// picks one, and where they were looked for. With `containing` they are the
|
|
263
|
+
// innermost elements whose shown text contains the string, however the markup
|
|
264
|
+
// splits it (registerShownTextEngine.js), inside the scope or on the page: a
|
|
265
|
+
// list row a person picks by the name or address it shows, which is neither a
|
|
266
|
+
// block nor an interactive control. With `text` they are the interactive
|
|
267
|
+
// controls with exactly that text (a cell button, a confirm dialog's OK, a
|
|
268
|
+
// menu item) inside the scope, or in the front-most open layer of the page
|
|
269
|
+
// when there is no blockId — portal-rendered controls live outside every
|
|
270
|
+
// block.
|
|
271
|
+
async function locateMatches({ page, target }) {
|
|
272
|
+
const scope = resolveMatchScope({ page, target });
|
|
273
|
+
const where = type.isUndefined(scope) ? 'the page' : describeScopeParts(target).join(' ');
|
|
274
|
+
if (!type.isUndefined(target.containing)) {
|
|
275
|
+
return {
|
|
276
|
+
matches: (scope ?? page)
|
|
277
|
+
.locator(shownTextSelector(target.containing))
|
|
278
|
+
.filter({ visible: true }),
|
|
279
|
+
where,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (type.isUndefined(scope)) {
|
|
283
|
+
return locatePageWideText({ page, target });
|
|
284
|
+
}
|
|
285
|
+
return { matches: controlsWithText({ root: scope, text: target.text }), where };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// The element an expectation asserts about, or an action acts on when its
|
|
289
|
+
// target needs no single-match check (it gives `nth`, or names no `text` or
|
|
290
|
+
// `containing`). A `text` or `containing` target is its nth match, the first
|
|
291
|
+
// when no `nth` is given: an expectation holds for any match.
|
|
292
|
+
// With `nth` alone it is the nth interactive control in the scope. Otherwise
|
|
293
|
+
// it is the scope itself, and a click resolves its inner control the way a
|
|
294
|
+
// plain blockId click does.
|
|
295
|
+
async function resolveTarget({ page, target }) {
|
|
296
|
+
if (namesMatches(target)) {
|
|
297
|
+
const { matches } = await locateMatches({ page, target });
|
|
298
|
+
return matches.nth(target.nth ?? 0);
|
|
299
|
+
}
|
|
300
|
+
const scope = resolveScope({ page, target });
|
|
301
|
+
if (!type.isUndefined(target.nth)) {
|
|
302
|
+
return scope.locator(INTERACTIVE_CONTROL).nth(target.nth);
|
|
303
|
+
}
|
|
304
|
+
return scope;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function plural({ count, word }) {
|
|
308
|
+
return `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The element an action step (click, open, fill, select) acts on. A `text` or
|
|
312
|
+
// `containing` target with no `nth` must name exactly one visible element:
|
|
313
|
+
// acting on the first of several is a guess, and a guess that happens to pass
|
|
314
|
+
// hides a journey that clicks the wrong row. Waits for a first match to show,
|
|
315
|
+
// then looks again (a dialog may have opened over the page in the meantime) and
|
|
316
|
+
// counts. When none shows, the wait's own error reports it.
|
|
317
|
+
//
|
|
318
|
+
// A target with `nth` also waits for a first match before it is resolved, so
|
|
319
|
+
// the front-most open layer is chosen (locatePageWideText) once the control is
|
|
320
|
+
// showing, not before a dialog the previous step opened has rendered.
|
|
321
|
+
async function resolveActionTarget({ page, target, timeout }) {
|
|
322
|
+
if (!namesMatches(target)) {
|
|
323
|
+
return resolveTarget({ page, target });
|
|
324
|
+
}
|
|
325
|
+
const first = (await locateMatches({ page, target })).matches.first();
|
|
326
|
+
await first.waitFor({ state: 'visible', timeout });
|
|
327
|
+
if (!type.isUndefined(target.nth)) {
|
|
328
|
+
return resolveTarget({ page, target });
|
|
329
|
+
}
|
|
330
|
+
const { matches, where } = await locateMatches({ page, target });
|
|
331
|
+
const count = await matches.count();
|
|
332
|
+
if (count > 1) {
|
|
333
|
+
const noun = type.isUndefined(target.text) ? 'element' : 'control';
|
|
334
|
+
const named = type.isUndefined(target.text)
|
|
335
|
+
? `containing "${target.containing}"`
|
|
336
|
+
: `with text "${target.text}"`;
|
|
337
|
+
throw new JourneyStepError(
|
|
338
|
+
`Matched ${plural({ count, word: noun })} ${named} in ${where}; add nth: 0..${
|
|
339
|
+
count - 1
|
|
340
|
+
}, or a blockId/row to narrow it.`,
|
|
341
|
+
{
|
|
342
|
+
expected: `exactly one ${noun} ${named} in ${where}`,
|
|
343
|
+
actual: plural({ count, word: noun }),
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return matches.first();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// A target that names a control (`text`, `nth`) or the words shown
|
|
351
|
+
// (`containing`) is clicked as is - a click on a list row's text reaches the
|
|
352
|
+
// row's own click handler, the way a person clicks the row; a target that
|
|
353
|
+
// names a container (block, row, cell) is clicked on the first control inside
|
|
354
|
+
// it, or on itself when it has none.
|
|
355
|
+
async function resolveClickLocator({ located, target, timeout }) {
|
|
356
|
+
if (namesMatches(target) || !type.isUndefined(target.nth)) {
|
|
357
|
+
return located;
|
|
358
|
+
}
|
|
359
|
+
return resolveClickTarget({ scope: located, timeout });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The element a person clicks to open an input's popup: the trigger antd draws
|
|
363
|
+
// inside the block. Every popup input - Select, MultipleSelector, AutoComplete,
|
|
364
|
+
// TreeSelector, Cascader (all `.ant-select`, whose clickable box antd 6 renders
|
|
365
|
+
// as `.ant-select-content`), the date and time pickers (`.ant-picker`), a
|
|
366
|
+
// colour picker, a dropdown button - has one.
|
|
367
|
+
const POPUP_TRIGGER = [
|
|
368
|
+
'.ant-select-content',
|
|
369
|
+
'.ant-picker',
|
|
370
|
+
'.ant-color-picker-trigger',
|
|
371
|
+
'.ant-dropdown-trigger',
|
|
372
|
+
'.ant-mentions',
|
|
373
|
+
].join(', ');
|
|
374
|
+
|
|
375
|
+
// The popups those triggers open. antd mounts them in a portal at the end of
|
|
376
|
+
// <body>, so they are looked for page-wide; the hidden class marks a popup
|
|
377
|
+
// that has closed but is still in the DOM.
|
|
378
|
+
const POPUP = [
|
|
379
|
+
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
|
380
|
+
'.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)',
|
|
381
|
+
'.ant-cascader-dropdown:not(.ant-select-dropdown-hidden)',
|
|
382
|
+
'.ant-color-picker-inner',
|
|
383
|
+
'.ant-dropdown:not(.ant-dropdown-hidden)',
|
|
384
|
+
'.ant-mentions-dropdown:not(.ant-mentions-dropdown-hidden)',
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
// Marks the popups already showing before an `open` step clicks, so the step
|
|
388
|
+
// waits for a popup its own click opened: the dropdown a previous step opened
|
|
389
|
+
// stays visible while it fades out, and would otherwise pass for the new one.
|
|
390
|
+
const SHOWING_BEFORE = 'data-lowdefy-journey-showing-before';
|
|
391
|
+
|
|
392
|
+
const NEW_POPUP = POPUP.map((selector) => `${selector}:not([${SHOWING_BEFORE}])`).join(', ');
|
|
393
|
+
|
|
394
|
+
async function markShowingPopups({ page }) {
|
|
395
|
+
await page
|
|
396
|
+
.locator(POPUP.join(', '))
|
|
397
|
+
.evaluateAll(
|
|
398
|
+
(popups, attribute) =>
|
|
399
|
+
popups
|
|
400
|
+
.filter((popup) => popup.checkVisibility())
|
|
401
|
+
.forEach((popup) => popup.setAttribute(attribute, '')),
|
|
402
|
+
SHOWING_BEFORE
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The marks come off once the step is done, so a later `open` of the same
|
|
407
|
+
// input finds its popup again.
|
|
408
|
+
async function unmarkPopups({ page }) {
|
|
409
|
+
await page
|
|
410
|
+
.locator(`[${SHOWING_BEFORE}]`)
|
|
411
|
+
.evaluateAll(
|
|
412
|
+
(popups, attribute) => popups.forEach((popup) => popup.removeAttribute(attribute)),
|
|
413
|
+
SHOWING_BEFORE
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Opens the popup of the input the target names and waits until it shows, so
|
|
418
|
+
// a screenshot or a `select`-less inspection sees the options. A block with no
|
|
419
|
+
// popup trigger (a button) is clicked as `click` does; a block whose click
|
|
420
|
+
// opens nothing fails the step, since `open` promises a popup.
|
|
421
|
+
async function runOpen({ page, step, timeout }) {
|
|
422
|
+
const target = normaliseTarget(step.open);
|
|
423
|
+
await markShowingPopups({ page });
|
|
424
|
+
try {
|
|
425
|
+
await actOnTarget({
|
|
426
|
+
target,
|
|
427
|
+
action: async () => {
|
|
428
|
+
const located = await resolveActionTarget({ page, target, timeout });
|
|
429
|
+
const trigger = located.locator(POPUP_TRIGGER).first();
|
|
430
|
+
// The input renders its trigger with the block (a lazy block once its
|
|
431
|
+
// implementation has rendered); counted once either shows.
|
|
432
|
+
await trigger.or(located).first().waitFor({ state: 'visible', timeout });
|
|
433
|
+
await waitForLoadingBlocks({ page, timeout });
|
|
434
|
+
if ((await trigger.count()) > 0) {
|
|
435
|
+
await trigger.click({ timeout });
|
|
436
|
+
} else {
|
|
437
|
+
await (await resolveClickLocator({ located, target, timeout })).click({ timeout });
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
const popup = page.locator(NEW_POPUP).filter({ visible: true }).first();
|
|
442
|
+
try {
|
|
443
|
+
await popup.waitFor({ state: 'visible', timeout });
|
|
444
|
+
} catch (error) {
|
|
445
|
+
const description = describeTarget(target);
|
|
446
|
+
throw new JourneyStepError(`Opening ${description} showed no dropdown or popup.`, {
|
|
447
|
+
expected: `a popup to open from ${description}`,
|
|
448
|
+
actual: cleanMessage(error),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
} finally {
|
|
452
|
+
await unmarkPopups({ page });
|
|
453
|
+
}
|
|
454
|
+
// Popups animate in; a capture mid-animation is faded or offset.
|
|
455
|
+
await page.waitForTimeout(250);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// `count` clicks in quick succession through one Playwright click, as a
|
|
459
|
+
// person's double click: the runner's settle comes after all of them, so a
|
|
460
|
+
// double click is never turned into a second submit after the first settled.
|
|
461
|
+
async function runClick({ journey, page, step, timeout }) {
|
|
462
|
+
const { count = 1, ...target } = normaliseTarget(step.click);
|
|
463
|
+
let blockId = null;
|
|
464
|
+
await actOnTarget({
|
|
465
|
+
target,
|
|
466
|
+
action: async () => {
|
|
467
|
+
const located = await resolveActionTarget({ page, target, timeout });
|
|
468
|
+
const locator = await resolveClickLocator({ located, target, timeout });
|
|
469
|
+
blockId = await checkClickRails({ journey, page, locator });
|
|
470
|
+
await locator.click({ timeout, clickCount: count });
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
return { blockId };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Where a fill looks for the input. A block that renders its input in a portal (the Search
|
|
477
|
+
// modal) marks the portal root with the block's portal class, so a block-scoped fill also
|
|
478
|
+
// searches that portal. The block's own input comes first in document order, and a closed
|
|
479
|
+
// portal is not in the DOM, so a block with an input of its own is unaffected.
|
|
480
|
+
function fillScope({ page, target, located }) {
|
|
481
|
+
const isBlockScope =
|
|
482
|
+
!type.isUndefined(target.blockId) &&
|
|
483
|
+
type.isUndefined(target.row) &&
|
|
484
|
+
type.isUndefined(target.column) &&
|
|
485
|
+
!namesMatches(target) &&
|
|
486
|
+
type.isUndefined(target.nth);
|
|
487
|
+
if (!isBlockScope) return located;
|
|
488
|
+
return located.or(page.locator(`.${journeyTargetSelectors.portalClass(target.blockId)}`));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A fill types `value`, or text read from an email (`fromEmail`) - the way a
|
|
492
|
+
// person types a one-time code from their inbox into the tab they started in.
|
|
493
|
+
async function runFill({ journey, page, step, timeout }) {
|
|
494
|
+
const { value: literal, fromEmail, ...target } = step.fill;
|
|
495
|
+
let value = literal;
|
|
496
|
+
if (!type.isUndefined(fromEmail)) {
|
|
497
|
+
value = await readJourneyEmailMatch({
|
|
498
|
+
page,
|
|
499
|
+
params: fromEmail,
|
|
500
|
+
since: journey.startedAt,
|
|
501
|
+
configDirectory: journey.configDirectory,
|
|
502
|
+
timeout,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
await actOnTarget({
|
|
506
|
+
target,
|
|
507
|
+
action: async () => {
|
|
508
|
+
const located = await resolveActionTarget({ page, target, timeout });
|
|
509
|
+
const input = fillScope({ page, target, located }).locator('input, textarea').first();
|
|
510
|
+
const colorTrigger = located.locator(COLOR_PICKER_TRIGGER).first();
|
|
511
|
+
// A colour picker shows no input until its popup opens, so its trigger counts too.
|
|
512
|
+
try {
|
|
513
|
+
await input.or(colorTrigger).first().waitFor({ state: 'visible', timeout });
|
|
514
|
+
} catch (error) {
|
|
515
|
+
// A block that never showed fails as not actionable, as any other step on it does.
|
|
516
|
+
if (!(await located.first().isVisible())) throw error;
|
|
517
|
+
throw await createNoInputError({ page, target, error });
|
|
518
|
+
}
|
|
519
|
+
if ((await input.count()) === 0 && (await colorTrigger.count()) > 0) {
|
|
520
|
+
await fillColorPicker({ page, target, trigger: colorTrigger, value, timeout });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
await input.fill(String(value), { timeout });
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// The engine's type for the block a fill names, or null when the shown page does not
|
|
529
|
+
// hold it or the tab is between pages.
|
|
530
|
+
async function readBlockType({ page, blockId }) {
|
|
531
|
+
try {
|
|
532
|
+
return await page.evaluate((id) => {
|
|
533
|
+
const lowdefy = window.lowdefy;
|
|
534
|
+
if (!lowdefy?.pageId || lowdefy._devNavigating === true) return null;
|
|
535
|
+
const shownKey = lowdefy.pageInstances?.[lowdefy.pageId]?.at(-1);
|
|
536
|
+
return lowdefy.contexts?.[shownKey]?._internal?.RootSlots?.map?.[id]?.type ?? null;
|
|
537
|
+
}, blockId);
|
|
538
|
+
} catch {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function createNoInputError({ page, target, error }) {
|
|
544
|
+
const description = describeTarget(target);
|
|
545
|
+
const blockType = await readBlockType({ page, blockId: target.blockId });
|
|
546
|
+
const named = blockType === null ? description : `${description} (${blockType})`;
|
|
547
|
+
return new JourneyStepError(
|
|
548
|
+
`Fill found no input in ${named} to type into: fill types into a text input, textarea or colour picker.`,
|
|
549
|
+
{
|
|
550
|
+
expected: `a text input or colour picker in ${description}`,
|
|
551
|
+
actual: cleanMessage(error),
|
|
552
|
+
}
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The antd colour picker (the ColorSelector block): its trigger opens a popup whose hex
|
|
557
|
+
// input sets the colour once it holds a full 6 or 8 digit hex value.
|
|
558
|
+
const COLOR_PICKER_TRIGGER = '.ant-color-picker-trigger';
|
|
559
|
+
const COLOR_PICKER_TRIGGER_ACTIVE = 'ant-color-picker-trigger-active';
|
|
560
|
+
const COLOR_PICKER_TRIGGER_DISABLED = 'ant-color-picker-trigger-disabled';
|
|
561
|
+
const COLOR_PICKER_PANEL = '.ant-popover:not(.ant-popover-hidden) .ant-color-picker-inner';
|
|
562
|
+
const COLOR_PICKER_HEX_INPUT =
|
|
563
|
+
'.ant-color-picker-hex-input input, input.ant-color-picker-hex-input';
|
|
564
|
+
const HEX_COLOR = /^#?([\da-f]{6}|[\da-f]{8})$/i;
|
|
565
|
+
|
|
566
|
+
// The hex input shows its "#" as a prefix, so only the digits are typed. Escape closes the
|
|
567
|
+
// picker, leaving the page as a person who picked a colour leaves it. The close is waited
|
|
568
|
+
// for briefly and never fails the fill: a picker whose `open` the config holds stays open.
|
|
569
|
+
async function fillColorPicker({ page, target, trigger, value, timeout }) {
|
|
570
|
+
const description = describeTarget(target);
|
|
571
|
+
const hex = String(value);
|
|
572
|
+
if (!HEX_COLOR.test(hex)) {
|
|
573
|
+
throw new JourneyStepError(
|
|
574
|
+
`Fill of the colour picker in ${description} takes a 6 or 8 digit hex colour, such as #1677ff; got "${hex}".`,
|
|
575
|
+
{ expected: 'a 6 or 8 digit hex colour', actual: hex }
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const triggerClass = (await trigger.getAttribute('class', { timeout })) ?? '';
|
|
579
|
+
if (triggerClass.split(' ').includes(COLOR_PICKER_TRIGGER_DISABLED)) {
|
|
580
|
+
throw new JourneyStepError(`The colour picker in ${description} is disabled.`, {
|
|
581
|
+
expected: `an enabled colour picker in ${description}`,
|
|
582
|
+
actual: 'disabled',
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
// The picker's panel is not tied to its trigger in the DOM, so another picker's open
|
|
586
|
+
// panel is closed first: then the one panel showing is this picker's.
|
|
587
|
+
const panel = page.locator(COLOR_PICKER_PANEL).filter({ visible: true });
|
|
588
|
+
if (!triggerClass.split(' ').includes(COLOR_PICKER_TRIGGER_ACTIVE)) {
|
|
589
|
+
if ((await panel.count()) > 0) {
|
|
590
|
+
await page.keyboard.press('Escape');
|
|
591
|
+
await panel.first().waitFor({ state: 'hidden', timeout });
|
|
592
|
+
}
|
|
593
|
+
await trigger.click({ timeout });
|
|
594
|
+
}
|
|
595
|
+
const hexInput = panel.locator(COLOR_PICKER_HEX_INPUT).first();
|
|
596
|
+
try {
|
|
597
|
+
await hexInput.waitFor({ state: 'visible', timeout });
|
|
598
|
+
} catch (error) {
|
|
599
|
+
throw new JourneyStepError(
|
|
600
|
+
`The colour picker in ${description} showed no hex input: fill types a hex colour, and the picker shows another format.`,
|
|
601
|
+
{
|
|
602
|
+
expected: `a hex input in the colour picker of ${description}`,
|
|
603
|
+
actual: cleanMessage(error),
|
|
604
|
+
}
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
await hexInput.fill(hex.replace(/^#/, ''), { timeout });
|
|
608
|
+
await page.keyboard.press('Escape');
|
|
609
|
+
await panel
|
|
610
|
+
.first()
|
|
611
|
+
.waitFor({ state: 'hidden', timeout: Math.min(timeout, 1000) })
|
|
612
|
+
.catch(() => {});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// The options of a radio group, a button selector or a segmented control are
|
|
616
|
+
// all on the page already: the one labelled `text` is clicked, no dropdown.
|
|
617
|
+
async function selectRadioOption({ options, target, text, timeout }) {
|
|
618
|
+
const option = options
|
|
619
|
+
.filter({ hasText: exactText(text) })
|
|
620
|
+
.filter({ visible: true })
|
|
621
|
+
.first();
|
|
622
|
+
try {
|
|
623
|
+
await option.click({ timeout });
|
|
624
|
+
} catch (error) {
|
|
625
|
+
const description = describeTarget(target);
|
|
626
|
+
throw new JourneyStepError(`No option with text "${text}" in ${description}.`, {
|
|
627
|
+
expected: `option "${text}" in ${description}`,
|
|
628
|
+
actual: cleanMessage(error),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// A checkbox option is ticked, never toggled: clicking one already ticked would
|
|
634
|
+
// untick it, so a step that selects it reads the same on a rerun.
|
|
635
|
+
async function tickCheckboxOption({ options, target, text, timeout }) {
|
|
636
|
+
const option = options
|
|
637
|
+
.filter({ hasText: exactText(text) })
|
|
638
|
+
.filter({ visible: true })
|
|
639
|
+
.first();
|
|
640
|
+
try {
|
|
641
|
+
await option.waitFor({ state: 'visible', timeout });
|
|
642
|
+
} catch (error) {
|
|
643
|
+
const description = describeTarget(target);
|
|
644
|
+
throw new JourneyStepError(`No option with text "${text}" in ${description}.`, {
|
|
645
|
+
expected: `option "${text}" in ${description}`,
|
|
646
|
+
actual: cleanMessage(error),
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (await option.locator('input[type="checkbox"]').isChecked()) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await actOnTarget({ target, action: () => option.click({ timeout }) });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// A native <select> inside the block is preferred when present (Playwright's
|
|
656
|
+
// selectOption is exact and needs no open dropdown), then radio and checkbox
|
|
657
|
+
// options labelled in the block. Otherwise the block is clicked to open its dropdown
|
|
658
|
+
// and the option with exactly `value` as text is clicked — Ant Design renders
|
|
659
|
+
// options into a portal, so they are searched page-wide, restricted to visible
|
|
660
|
+
// ones so the hidden accessibility list is never matched.
|
|
661
|
+
async function runSelect({ page, step, timeout }) {
|
|
662
|
+
const { value, ...target } = step.select;
|
|
663
|
+
const text = String(value);
|
|
664
|
+
let scope;
|
|
665
|
+
await actOnTarget({
|
|
666
|
+
target,
|
|
667
|
+
action: async () => {
|
|
668
|
+
scope = await resolveActionTarget({ page, target, timeout });
|
|
669
|
+
// A native select or the radio and checkbox options render with the
|
|
670
|
+
// block; counted once it shows, so a late block does not fall through to
|
|
671
|
+
// the dropdown.
|
|
672
|
+
await scope.first().waitFor({ state: 'visible', timeout });
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
const native = scope.locator('select');
|
|
676
|
+
if ((await native.count()) > 0) {
|
|
677
|
+
await actOnTarget({
|
|
678
|
+
target,
|
|
679
|
+
action: () => native.first().selectOption({ label: text }, { timeout }),
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const radioOptions = scope.locator(RADIO_OPTION);
|
|
684
|
+
if ((await radioOptions.count()) > 0) {
|
|
685
|
+
await selectRadioOption({ options: radioOptions, target, text, timeout });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const checkboxOptions = scope.locator(CHECKBOX_OPTION);
|
|
689
|
+
if ((await checkboxOptions.count()) > 0) {
|
|
690
|
+
await tickCheckboxOption({ options: checkboxOptions, target, text, timeout });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
await actOnTarget({
|
|
694
|
+
target,
|
|
695
|
+
action: async () => {
|
|
696
|
+
const locator = await resolveClickLocator({ located: scope, target, timeout });
|
|
697
|
+
await locator.click({ timeout });
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
const option = page
|
|
701
|
+
.locator(DROPDOWN_OPTION)
|
|
702
|
+
.filter({ hasText: exactText(text) })
|
|
703
|
+
.filter({ visible: true })
|
|
704
|
+
.first();
|
|
705
|
+
try {
|
|
706
|
+
await option.click({ timeout });
|
|
707
|
+
} catch (error) {
|
|
708
|
+
const description = describeTarget(target);
|
|
709
|
+
throw new JourneyStepError(
|
|
710
|
+
`No option with text "${text}" appeared in the dropdown of ${description}.`,
|
|
711
|
+
{
|
|
712
|
+
expected: `option "${text}" in the dropdown of ${description}`,
|
|
713
|
+
actual: cleanMessage(error),
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
// The dropdown fades out for a few hundred ms after a pick; a screenshot or
|
|
718
|
+
// click taken during the fade would still see it covering the rows below.
|
|
719
|
+
// Tolerant and short: a multi-select dropdown stays open by design.
|
|
720
|
+
await page
|
|
721
|
+
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
|
722
|
+
.first()
|
|
723
|
+
.waitFor({ state: 'hidden', timeout: Math.min(timeout, 1000) })
|
|
724
|
+
.catch(() => {});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// `Mod` in a chord resolves to Meta or Control from the platform the page
|
|
728
|
+
// reports, the way the app's own shortcut handling (tinykeys) does.
|
|
729
|
+
async function runPress({ page, step }) {
|
|
730
|
+
const modifier = await getShortcutModifier(page);
|
|
731
|
+
const key = step.press
|
|
732
|
+
.split('+')
|
|
733
|
+
.map((part) => (part === 'Mod' ? modifier : part))
|
|
734
|
+
.join('+');
|
|
735
|
+
try {
|
|
736
|
+
await page.keyboard.press(key);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
throw new JourneyStepError(`Could not press "${key}": ${cleanMessage(error)}`, {
|
|
739
|
+
expected: `key "${key}" to be pressed`,
|
|
740
|
+
actual: cleanMessage(error),
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Goes back one entry in the browser history, the way the Back button does.
|
|
746
|
+
// The headless tab opens on about:blank before the journey's page, which the
|
|
747
|
+
// Navigation API leaves out of the app's entries, so an entry index of 0 means
|
|
748
|
+
// the journey has not navigated anywhere it could go back from.
|
|
749
|
+
async function runBack({ page, timeout }) {
|
|
750
|
+
const index = await page.evaluate(() => window.navigation.currentEntry.index);
|
|
751
|
+
if (index === 0) {
|
|
752
|
+
throw new JourneyStepError(
|
|
753
|
+
'There is no earlier page in this journey to go back to: back needs a page the journey navigated from.',
|
|
754
|
+
{ expected: 'the browser to go back one page', actual: `no earlier page than ${page.url()}` }
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
await page.goBack({ timeout });
|
|
759
|
+
} catch (error) {
|
|
760
|
+
throw new JourneyStepError(`Could not go back: ${cleanMessage(error)}`, {
|
|
761
|
+
expected: 'the browser to go back one page',
|
|
762
|
+
actual: cleanMessage(error),
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Loads an app page the way a typed URL does. The page shown may not be the
|
|
768
|
+
// one asked for - a protected page redirects a signed-out actor to sign in -
|
|
769
|
+
// so the runner settles whichever page the app mounts (isPageReady reads the
|
|
770
|
+
// instance on screen, as in openPage) and lets the next step assert where it
|
|
771
|
+
// landed.
|
|
772
|
+
async function runGoto({ page, step, origin, timeout }) {
|
|
773
|
+
const { pageId, pathParams, urlQuery } = type.isString(step.goto)
|
|
774
|
+
? { pageId: step.goto }
|
|
775
|
+
: step.goto;
|
|
776
|
+
let url;
|
|
777
|
+
try {
|
|
778
|
+
url = buildPageUrl({
|
|
779
|
+
origin,
|
|
780
|
+
pageId,
|
|
781
|
+
path: readPagePath({ pageId }),
|
|
782
|
+
pathParams,
|
|
783
|
+
urlQuery,
|
|
784
|
+
});
|
|
785
|
+
} catch (error) {
|
|
786
|
+
throw new JourneyStepError(`Could not open page "${pageId}": ${error.message}`, {
|
|
787
|
+
expected: `page "${pageId}" to load`,
|
|
788
|
+
actual: error.message,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
await page.goto(url, { waitUntil: 'load', timeout });
|
|
793
|
+
} catch (error) {
|
|
794
|
+
throw new JourneyStepError(`Could not open page "${pageId}": ${cleanMessage(error)}`, {
|
|
795
|
+
expected: `page "${pageId}" to load`,
|
|
796
|
+
actual: cleanMessage(error),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
await page.waitForFunction(isPageReady, undefined, { timeout }).catch(() => {});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Polls a page read until it satisfies `check`, or fails once `timeout` has
|
|
803
|
+
// elapsed. Playwright's own waitForFunction cannot be used here because the
|
|
804
|
+
// reads go through the e2e-utils helpers, which run in Node.
|
|
805
|
+
async function pollUntil({ page, read, check, timeout, expected }) {
|
|
806
|
+
const deadline = Date.now() + timeout;
|
|
807
|
+
let value = await read();
|
|
808
|
+
while (!check(value)) {
|
|
809
|
+
if (Date.now() >= deadline) {
|
|
810
|
+
throw new JourneyStepError(`Timed out after ${timeout}ms waiting for ${expected}.`, {
|
|
811
|
+
expected,
|
|
812
|
+
actual: value,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
await page.waitForTimeout(50);
|
|
816
|
+
value = await read();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// A request the page made before the step's window (the call on page load,
|
|
821
|
+
// when a click was meant to call it again) does not satisfy wait.request: the
|
|
822
|
+
// actor's network counter must show a call started inside the window, and the
|
|
823
|
+
// page must have that request no longer loading.
|
|
824
|
+
async function readRequestWait({ journey, page, requestId, since }) {
|
|
825
|
+
const state = await getRequestState(page, requestId);
|
|
826
|
+
const pageId = await page.evaluate(() => window.lowdefy?.pageId);
|
|
827
|
+
return {
|
|
828
|
+
calledSinceLastInteraction: journey.actors.calledSince({ request: requestId, pageId, since }),
|
|
829
|
+
state: state ?? null,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function runWait({ journey, page, step, timeout, requestWindowStart }) {
|
|
834
|
+
const wait = step.wait;
|
|
835
|
+
switch (getStepKey(wait)) {
|
|
836
|
+
case 'ms':
|
|
837
|
+
await page.waitForTimeout(wait.ms);
|
|
838
|
+
return;
|
|
839
|
+
case 'request':
|
|
840
|
+
await pollUntil({
|
|
841
|
+
page,
|
|
842
|
+
read: () =>
|
|
843
|
+
readRequestWait({ journey, page, requestId: wait.request, since: requestWindowStart }),
|
|
844
|
+
check: ({ calledSinceLastInteraction, state }) =>
|
|
845
|
+
calledSinceLastInteraction && !type.isNone(state) && state.loading !== true,
|
|
846
|
+
timeout,
|
|
847
|
+
expected: `request "${wait.request}" to have been called since the last interaction and finished loading`,
|
|
848
|
+
});
|
|
849
|
+
return;
|
|
850
|
+
case 'state':
|
|
851
|
+
await pollUntil({
|
|
852
|
+
page,
|
|
853
|
+
read: async () => get((await getState(page)) ?? {}, wait.state),
|
|
854
|
+
check: (value) => !type.isUndefined(value),
|
|
855
|
+
timeout,
|
|
856
|
+
expected: `state "${wait.state}" to be defined`,
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
default:
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function runScreenshot({ page, step, index, screenshots }) {
|
|
865
|
+
const name = type.isString(step.screenshot) ? step.screenshot : `step-${index}`;
|
|
866
|
+
const buffer = await page.screenshot({ type: 'png' });
|
|
867
|
+
screenshots.push({ name, data: buffer.toString('base64'), mimeType: 'image/png' });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// A path that does not exist reads as null: a journey is JSON, where null is
|
|
871
|
+
// the only way to say "absent", and the failure report already shows a
|
|
872
|
+
// missing value as null - so `equals: null` asserts the value is not there.
|
|
873
|
+
// A read while the page navigates fails and is retried.
|
|
874
|
+
async function readStateValue({ page, path }) {
|
|
875
|
+
try {
|
|
876
|
+
return { value: get((await getState(page)) ?? {}, path) ?? null };
|
|
877
|
+
} catch (error) {
|
|
878
|
+
return { error };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Polled like every other expectation: the value a click leads to often
|
|
883
|
+
// lands once the request or endpoint it called has answered, which can be
|
|
884
|
+
// after the page has settled on a busy machine.
|
|
885
|
+
async function expectState({ page, params, timeout }) {
|
|
886
|
+
const { path, equals } = params;
|
|
887
|
+
const deadline = Date.now() + timeout;
|
|
888
|
+
let read = await readStateValue({ page, path });
|
|
889
|
+
while (
|
|
890
|
+
(!type.isUndefined(read.error) || !isDeepEqual(read.value, equals)) &&
|
|
891
|
+
Date.now() < deadline
|
|
892
|
+
) {
|
|
893
|
+
await page.waitForTimeout(50);
|
|
894
|
+
read = await readStateValue({ page, path });
|
|
895
|
+
}
|
|
896
|
+
if (!type.isUndefined(read.error)) {
|
|
897
|
+
throw read.error;
|
|
898
|
+
}
|
|
899
|
+
if (!isDeepEqual(read.value, equals)) {
|
|
900
|
+
throw new JourneyStepError(
|
|
901
|
+
`Expected state "${path}" to equal ${JSON.stringify(equals)} but found ${JSON.stringify(
|
|
902
|
+
read.value
|
|
903
|
+
)}.`,
|
|
904
|
+
{ expected: equals, actual: read.value }
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function expectVisible({ page, params, timeout }) {
|
|
910
|
+
const target = normaliseTarget(params);
|
|
911
|
+
const description = describeTarget(target);
|
|
912
|
+
try {
|
|
913
|
+
const located = await resolveTarget({ page, target });
|
|
914
|
+
await located.waitFor({ state: 'visible', timeout });
|
|
915
|
+
} catch (error) {
|
|
916
|
+
throw new JourneyStepError(`Expected ${description} to be visible.`, {
|
|
917
|
+
expected: `${description} to be visible`,
|
|
918
|
+
actual: cleanMessage(error),
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Passes once no element the target names is visible: nothing matches, or
|
|
924
|
+
// every match is hidden. Resolves at once when nothing matches yet, so a
|
|
925
|
+
// journey pairs it with something that must be present first.
|
|
926
|
+
async function expectHidden({ page, params, timeout }) {
|
|
927
|
+
const target = normaliseTarget(params);
|
|
928
|
+
const description = describeTarget(target);
|
|
929
|
+
try {
|
|
930
|
+
const located = await resolveTarget({ page, target });
|
|
931
|
+
await located.filter({ visible: true }).first().waitFor({ state: 'hidden', timeout });
|
|
932
|
+
} catch (error) {
|
|
933
|
+
throw new JourneyStepError(`Expected ${description} to be hidden.`, {
|
|
934
|
+
expected: `${description} to be hidden`,
|
|
935
|
+
actual: cleanMessage(error),
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Counts the calls the current actor's browser made to one request or
|
|
941
|
+
// endpoint since the journey started, from the network counter, which keeps
|
|
942
|
+
// its counts across full page loads. Compared once, after the page settles,
|
|
943
|
+
// without polling: "not called" can only be judged once the moment has passed.
|
|
944
|
+
// A request with no pageId is counted on the page the actor is on; when no
|
|
945
|
+
// Lowdefy page is showing (it crashed or left the app) the step fails, since a
|
|
946
|
+
// count of 0 against no page would pass without proving anything.
|
|
947
|
+
async function expectCalls({ journey, page, params }) {
|
|
948
|
+
await settlePage({ page, timeout: Math.min(journey.stepTimeout, SETTLE_TIMEOUT_MS) });
|
|
949
|
+
let query;
|
|
950
|
+
let description;
|
|
951
|
+
if (type.isUndefined(params.endpoint)) {
|
|
952
|
+
const pageId = params.pageId ?? (await page.evaluate(() => window.lowdefy?.pageId));
|
|
953
|
+
if (type.isNone(pageId)) {
|
|
954
|
+
throw new JourneyStepError(
|
|
955
|
+
`Expected request "${params.request}" to be counted on the current page, but no Lowdefy page is showing.`,
|
|
956
|
+
{ expected: `a Lowdefy page to count request "${params.request}" on`, actual: null }
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
query = { request: params.request, pageId };
|
|
960
|
+
description = `request "${params.request}" on page "${pageId}"`;
|
|
961
|
+
} else {
|
|
962
|
+
query = { endpoint: params.endpoint };
|
|
963
|
+
description = `endpoint "${params.endpoint}"`;
|
|
964
|
+
}
|
|
965
|
+
const actual = journey.actors.countCalls(query);
|
|
966
|
+
if (actual !== params.count) {
|
|
967
|
+
throw new JourneyStepError(
|
|
968
|
+
`Expected ${description} to have been called ${plural({
|
|
969
|
+
count: params.count,
|
|
970
|
+
word: 'time',
|
|
971
|
+
})} but it was called ${plural({ count: actual, word: 'time' })}.`,
|
|
972
|
+
{ expected: params.count, actual }
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Waits for the target to be in the page, then reads the text of every element
|
|
978
|
+
// it matches. A grid row is rendered once per column container (pinned left,
|
|
979
|
+
// centre, pinned right), so a row target legitimately matches more than one
|
|
980
|
+
// element; joining them reads the whole row.
|
|
981
|
+
async function readTargetText({ page, target, timeout }) {
|
|
982
|
+
// A block's text includes its Modal or Drawer portal's (see resolveMatchScope).
|
|
983
|
+
const located =
|
|
984
|
+
!namesMatches(target) && type.isUndefined(target.nth) && !type.isUndefined(target.blockId)
|
|
985
|
+
? resolveMatchScope({ page, target })
|
|
986
|
+
: await resolveTarget({ page, target });
|
|
987
|
+
await located.first().waitFor({ state: 'attached', timeout });
|
|
988
|
+
const texts = await located.allInnerTexts();
|
|
989
|
+
return texts.join('\n');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// One read of the target's text: { text } or, when the target is not in the
|
|
993
|
+
// page yet or the page navigated mid-read, { error }.
|
|
994
|
+
async function tryReadTargetText({ page, target, timeout }) {
|
|
995
|
+
try {
|
|
996
|
+
return { text: await readTargetText({ page, target, timeout }) };
|
|
997
|
+
} catch (error) {
|
|
998
|
+
return { error };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Polled rather than read once, like the url and the title: a block often
|
|
1003
|
+
// renders its text only once the request it shows has answered (a list hidden
|
|
1004
|
+
// until its data arrives reads as ""), and a sign-in or sign-out may still be
|
|
1005
|
+
// reloading the page when the step starts.
|
|
1006
|
+
async function expectText({ page, params, timeout }) {
|
|
1007
|
+
const { contains, ...target } = params;
|
|
1008
|
+
const description = describeTarget(target);
|
|
1009
|
+
const expected = `${description} text to contain "${contains}"`;
|
|
1010
|
+
const deadline = Date.now() + timeout;
|
|
1011
|
+
let read = await tryReadTargetText({ page, target, timeout });
|
|
1012
|
+
// The last text actually read. The final poll runs with whatever is left of
|
|
1013
|
+
// the timeout (1ms at the end), which can fail on its own latency: that
|
|
1014
|
+
// failure must not hide the text the earlier reads found.
|
|
1015
|
+
let lastText = read.text;
|
|
1016
|
+
while (read.text?.includes(contains) !== true && Date.now() < deadline) {
|
|
1017
|
+
await page.waitForTimeout(50);
|
|
1018
|
+
read = await tryReadTargetText({
|
|
1019
|
+
page,
|
|
1020
|
+
target,
|
|
1021
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
1022
|
+
});
|
|
1023
|
+
lastText = read.text ?? lastText;
|
|
1024
|
+
}
|
|
1025
|
+
if (!type.isUndefined(read.error) && type.isUndefined(lastText)) {
|
|
1026
|
+
throw new JourneyStepError(`Expected ${description} to contain text "${contains}".`, {
|
|
1027
|
+
expected,
|
|
1028
|
+
actual: cleanMessage(read.error),
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
if (!lastText.includes(contains)) {
|
|
1032
|
+
// innerText is the text as rendered, so CSS text-transform applies to it.
|
|
1033
|
+
const hint = lastText.toLowerCase().includes(String(contains).toLowerCase())
|
|
1034
|
+
? ' It matches ignoring case: the block may render its text transformed (text-transform).'
|
|
1035
|
+
: '';
|
|
1036
|
+
throw new JourneyStepError(
|
|
1037
|
+
`Expected ${description} text to contain "${contains}" but found ${JSON.stringify(
|
|
1038
|
+
lastText
|
|
1039
|
+
)}.${hint}`,
|
|
1040
|
+
{ expected, actual: lastText }
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// The document title is set by the page after it renders, so it is polled
|
|
1046
|
+
// rather than read once.
|
|
1047
|
+
async function expectTitle({ page, params, timeout }) {
|
|
1048
|
+
if (!type.isUndefined(params.equals)) {
|
|
1049
|
+
await pollUntil({
|
|
1050
|
+
page,
|
|
1051
|
+
read: () => page.title(),
|
|
1052
|
+
check: (title) => title === params.equals,
|
|
1053
|
+
timeout,
|
|
1054
|
+
expected: `title to equal "${params.equals}"`,
|
|
1055
|
+
});
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
await pollUntil({
|
|
1059
|
+
page,
|
|
1060
|
+
read: () => page.title(),
|
|
1061
|
+
check: (title) => title.includes(params.contains),
|
|
1062
|
+
timeout,
|
|
1063
|
+
expected: `title to contain "${params.contains}"`,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Waits for the URL rather than reading it once, because a click that
|
|
1068
|
+
// navigates resolves before the new route is committed — page.waitForURL is
|
|
1069
|
+
// Playwright's own wait for exactly this.
|
|
1070
|
+
async function expectUrl({ page, params, timeout }) {
|
|
1071
|
+
const { contains } = params;
|
|
1072
|
+
try {
|
|
1073
|
+
await page.waitForURL((url) => url.href.includes(contains), { timeout });
|
|
1074
|
+
} catch {
|
|
1075
|
+
throw new JourneyStepError(
|
|
1076
|
+
`Expected url to contain "${contains}" but found ${JSON.stringify(page.url())}.`,
|
|
1077
|
+
{ expected: `url to contain "${contains}"`, actual: page.url() }
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Fails when the interaction just before it did nothing, the dead-click
|
|
1083
|
+
// invariant inverted (see observe/hasNoEffect). It reads that
|
|
1084
|
+
// interaction's window, which the runner closed once the page settled, so it
|
|
1085
|
+
// does not wait. Only a run that watches its steps' windows (a journey run,
|
|
1086
|
+
// not a screenshot's steps) has one to read.
|
|
1087
|
+
function expectEffect({ journey }) {
|
|
1088
|
+
const window = journey.appErrors?.previousWindow();
|
|
1089
|
+
if (type.isNone(window)) {
|
|
1090
|
+
throw new JourneyStepError(
|
|
1091
|
+
'expect.effect can only be checked in a journey run, which watches what each step does.',
|
|
1092
|
+
{ expected: 'the previous step to have an effect', actual: null }
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
if (hasNoEffect({ window })) {
|
|
1096
|
+
throw new JourneyStepError(
|
|
1097
|
+
'Expected the previous step to have an effect, but it ran no event, changed nothing on the page, sent no app request and left the URL unchanged.',
|
|
1098
|
+
{
|
|
1099
|
+
expected: 'the previous step to have an effect',
|
|
1100
|
+
actual: 'no event, no DOM change, no app request, URL unchanged',
|
|
1101
|
+
}
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async function runExpect({ journey, page, step, timeout }) {
|
|
1107
|
+
const expectation = step.expect;
|
|
1108
|
+
const key = getStepKey(expectation);
|
|
1109
|
+
const params = expectation[key];
|
|
1110
|
+
switch (key) {
|
|
1111
|
+
case 'state':
|
|
1112
|
+
await expectState({ page, params, timeout });
|
|
1113
|
+
return;
|
|
1114
|
+
case 'visible':
|
|
1115
|
+
await expectVisible({ page, params, timeout });
|
|
1116
|
+
return;
|
|
1117
|
+
case 'hidden':
|
|
1118
|
+
await expectHidden({ page, params, timeout });
|
|
1119
|
+
return;
|
|
1120
|
+
case 'calls':
|
|
1121
|
+
await expectCalls({ journey, page, params });
|
|
1122
|
+
return;
|
|
1123
|
+
case 'text':
|
|
1124
|
+
await expectText({ page, params, timeout });
|
|
1125
|
+
return;
|
|
1126
|
+
case 'url':
|
|
1127
|
+
await expectUrl({ page, params, timeout });
|
|
1128
|
+
return;
|
|
1129
|
+
case 'title':
|
|
1130
|
+
await expectTitle({ page, params, timeout });
|
|
1131
|
+
return;
|
|
1132
|
+
case 'error':
|
|
1133
|
+
// Claimed when the step's window closes (see observe/claimExpectedErrors).
|
|
1134
|
+
return;
|
|
1135
|
+
case 'effect':
|
|
1136
|
+
expectEffect({ journey });
|
|
1137
|
+
return;
|
|
1138
|
+
default:
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// After an interaction, waits for the page's own load chain — the event the
|
|
1144
|
+
// interaction fired, the requests it called — to settle, using the same
|
|
1145
|
+
// readiness check openPage uses, so the next step asserts against the
|
|
1146
|
+
// outcome rather than racing it. Tolerant: a page that never settles (a
|
|
1147
|
+
// hung request) simply moves on and lets the next expect report what it
|
|
1148
|
+
// finds. Waits for the instance on screen, since a click may have navigated
|
|
1149
|
+
// to another page or instance.
|
|
1150
|
+
//
|
|
1151
|
+
// Never longer than SETTLE_TIMEOUT_MS, however long the steps may wait: an
|
|
1152
|
+
// event that ends in a Wait (a sign-in link's resend cooldown) keeps the page
|
|
1153
|
+
// unsettled for as long as it waits, and every step after it waits for what it
|
|
1154
|
+
// needs on its own.
|
|
1155
|
+
async function settlePage({ page, timeout }) {
|
|
1156
|
+
const pageId = await page.evaluate(() => window.lowdefy?.pageId);
|
|
1157
|
+
if (type.isNone(pageId)) {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
await page.waitForFunction(isPageReady, undefined, { timeout }).catch(() => {});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const INTERACTION_STEPS = ['click', 'open', 'fill', 'select', 'press', 'back'];
|
|
1164
|
+
|
|
1165
|
+
// The steps that open a new window for wait.request: an interaction, or a step
|
|
1166
|
+
// that loads or switches to a page. Before the first of them the window is the
|
|
1167
|
+
// page open.
|
|
1168
|
+
const REQUEST_WINDOW_STEPS = [...INTERACTION_STEPS, 'goto', 'as'];
|
|
1169
|
+
|
|
1170
|
+
const SETTLE_TIMEOUT_MS = 5000;
|
|
1171
|
+
|
|
1172
|
+
async function runStep({ journey, step, index, screenshots, requestWindowStart }) {
|
|
1173
|
+
const page = journey.actors.current().page;
|
|
1174
|
+
const timeout = journey.stepTimeout;
|
|
1175
|
+
await checkStepReferences({ journey, page, step });
|
|
1176
|
+
switch (getStepKey(step)) {
|
|
1177
|
+
case 'click':
|
|
1178
|
+
return runClick({ journey, page, step, timeout });
|
|
1179
|
+
case 'open':
|
|
1180
|
+
await runOpen({ page, step, timeout });
|
|
1181
|
+
return;
|
|
1182
|
+
case 'fill':
|
|
1183
|
+
await runFill({ journey, page, step, timeout });
|
|
1184
|
+
return;
|
|
1185
|
+
case 'select':
|
|
1186
|
+
await runSelect({ page, step, timeout });
|
|
1187
|
+
return;
|
|
1188
|
+
case 'press':
|
|
1189
|
+
await runPress({ page, step, timeout });
|
|
1190
|
+
return;
|
|
1191
|
+
case 'back':
|
|
1192
|
+
await runBack({ page, timeout });
|
|
1193
|
+
return;
|
|
1194
|
+
case 'goto':
|
|
1195
|
+
await runGoto({ page, step, origin: journey.origin, timeout: journey.openTimeout });
|
|
1196
|
+
return;
|
|
1197
|
+
case 'email':
|
|
1198
|
+
await openJourneyEmail({
|
|
1199
|
+
page,
|
|
1200
|
+
params: step.email,
|
|
1201
|
+
since: journey.startedAt,
|
|
1202
|
+
configDirectory: journey.configDirectory,
|
|
1203
|
+
timeout,
|
|
1204
|
+
});
|
|
1205
|
+
return;
|
|
1206
|
+
case 'as':
|
|
1207
|
+
await journey.actors.switchTo(step.as);
|
|
1208
|
+
return;
|
|
1209
|
+
case 'wait':
|
|
1210
|
+
await runWait({ journey, page, step, timeout, requestWindowStart });
|
|
1211
|
+
return;
|
|
1212
|
+
case 'screenshot':
|
|
1213
|
+
await runScreenshot({ page, step, index, screenshots });
|
|
1214
|
+
return;
|
|
1215
|
+
case 'expect':
|
|
1216
|
+
await runExpect({ journey, page, step, timeout });
|
|
1217
|
+
return;
|
|
1218
|
+
default:
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function toFailure({ error, index, step }) {
|
|
1224
|
+
if (error instanceof JourneyStepError) {
|
|
1225
|
+
// `actual` is null rather than undefined so the key survives JSON — an
|
|
1226
|
+
// agent reading the failure sees "found nothing", not a missing field.
|
|
1227
|
+
return {
|
|
1228
|
+
index,
|
|
1229
|
+
step,
|
|
1230
|
+
expected: error.expected,
|
|
1231
|
+
actual: type.isUndefined(error.actual) ? null : error.actual,
|
|
1232
|
+
message: error.message,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return {
|
|
1236
|
+
index,
|
|
1237
|
+
step,
|
|
1238
|
+
expected: `step ${index} (${getStepKey(step)}) to complete`,
|
|
1239
|
+
actual: cleanMessage(error),
|
|
1240
|
+
message: cleanMessage(error),
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Runs the steps in order, stopping at the first failure. Returns the step
|
|
1245
|
+
// log, the failure (if any) and the screenshots taken — never throws for a
|
|
1246
|
+
// step that fails, because a failed journey is a result an agent reads, not
|
|
1247
|
+
// an error it recovers from. A journey run that watches for app errors
|
|
1248
|
+
// (journey.appErrors, see createJourneyAppErrors) also fails at the step
|
|
1249
|
+
// whose window held one, or on open when opening the page caused one.
|
|
1250
|
+
async function runJourneySteps({ journey, steps }) {
|
|
1251
|
+
const results = [];
|
|
1252
|
+
const screenshots = [];
|
|
1253
|
+
const { appErrors } = journey;
|
|
1254
|
+
let failure;
|
|
1255
|
+
// In nextJourneySequence's order; 0 takes in every call since the page opened.
|
|
1256
|
+
let requestWindowStart = 0;
|
|
1257
|
+
if (!type.isUndefined(appErrors)) {
|
|
1258
|
+
failure = await appErrors.judgeOpen({ page: journey.actors.current().page });
|
|
1259
|
+
}
|
|
1260
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
1261
|
+
const step = steps[index];
|
|
1262
|
+
if (!type.isUndefined(failure)) {
|
|
1263
|
+
results.push({ index, step, status: 'skipped', durationMs: 0 });
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
const started = Date.now();
|
|
1267
|
+
if (REQUEST_WINDOW_STEPS.includes(getStepKey(step))) {
|
|
1268
|
+
requestWindowStart = nextJourneySequence();
|
|
1269
|
+
}
|
|
1270
|
+
await appErrors?.openWindow({ page: journey.actors.current().page });
|
|
1271
|
+
try {
|
|
1272
|
+
const ran = await runStep({ journey, step, index, screenshots, requestWindowStart });
|
|
1273
|
+
if (INTERACTION_STEPS.includes(getStepKey(step))) {
|
|
1274
|
+
await settlePage({
|
|
1275
|
+
page: journey.actors.current().page,
|
|
1276
|
+
timeout: Math.min(journey.stepTimeout, SETTLE_TIMEOUT_MS),
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
if (getStepKey(step) === 'click') {
|
|
1280
|
+
await checkAuthActionsRan({
|
|
1281
|
+
journey,
|
|
1282
|
+
page: journey.actors.current().page,
|
|
1283
|
+
blockId: ran?.blockId,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
await journey.actors.sampleRendered();
|
|
1287
|
+
results.push({ index, step, status: 'ok', durationMs: Date.now() - started });
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
failure = toFailure({ error, index, step });
|
|
1290
|
+
results.push({ index, step, status: 'failed', durationMs: Date.now() - started });
|
|
1291
|
+
}
|
|
1292
|
+
// A request this step caused to another host of the dev server was aborted, so whatever the
|
|
1293
|
+
// step saw afterwards is not what the app does: that request is the failure.
|
|
1294
|
+
const departure = journey.actors.leftOrigin();
|
|
1295
|
+
if (!type.isUndefined(departure)) {
|
|
1296
|
+
failure = toFailure({
|
|
1297
|
+
error: createLeftOriginError({ origin: journey.origin, departure }),
|
|
1298
|
+
index,
|
|
1299
|
+
step,
|
|
1300
|
+
});
|
|
1301
|
+
results[results.length - 1].status = 'failed';
|
|
1302
|
+
}
|
|
1303
|
+
if (!type.isUndefined(appErrors)) {
|
|
1304
|
+
failure = await appErrors.judgeStep({
|
|
1305
|
+
journey,
|
|
1306
|
+
steps,
|
|
1307
|
+
index,
|
|
1308
|
+
results,
|
|
1309
|
+
failure,
|
|
1310
|
+
leftOrigin: !type.isUndefined(departure),
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (!type.isUndefined(appErrors) && type.isUndefined(failure) && steps.length > 0) {
|
|
1315
|
+
failure = await appErrors.judgeDrain({ journey, steps, results });
|
|
1316
|
+
}
|
|
1317
|
+
return { results, screenshots, failure };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
export default runJourneySteps;
|