@lowdefy/server-dev 6.1.0 → 7.1.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 +78 -0
- package/lib/docs/dataSets/dataSessionRegistry.js +25 -0
- package/lib/docs/dataSets/dataSetJourneys.chromium.mongodb.test.mjs +727 -0
- package/lib/docs/dataSets/describeDataSetResult.js +25 -0
- package/lib/docs/dataSets/describeDatabaseTarget.js +46 -0
- package/lib/docs/dataSets/describeTargetKey.js +29 -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/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 +52 -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 +133 -0
- package/lib/docs/dataSets/openDataSession.mongodb.test.mjs +493 -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 +83 -0
- package/lib/docs/dataSets/readDataSet.test.mjs +251 -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/sessionDatabaseName.js +32 -0
- package/lib/docs/dataSets/sweepOrphanDatabases.js +36 -0
- package/lib/docs/dataSets/test/app/lowdefy.yaml +225 -0
- package/lib/docs/dataSets/test/app/modules/archive/module.lowdefy.yaml +30 -0
- package/lib/docs/dataSets/test/app/tests/data/shop.yaml +12 -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 +81 -7
- package/lib/docs/getBuildStatus.test.mjs +229 -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 +92 -0
- package/lib/server/applyDataSetRedirect.test.mjs +446 -0
- package/lib/server/applyDataSetRedirectTenantReach.test.mjs +207 -0
- package/lib/server/applyDataSetRedirectWalled.test.mjs +134 -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 +331 -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 +115 -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 +25 -3
- package/manager/processes/initialBuild.mjs +17 -1
- package/manager/processes/initialBuild.test.mjs +104 -0
- package/manager/processes/lowdefyBuild.mjs +31 -7
- package/manager/processes/lowdefyBuild.skipPluginChecks.test.mjs +72 -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,2383 @@
|
|
|
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 fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { jest } from '@jest/globals';
|
|
22
|
+
import { buildPagePath, isTraceId } from '@lowdefy/helpers';
|
|
23
|
+
import { acquireMachineSlot } from '@lowdefy/node-utils';
|
|
24
|
+
|
|
25
|
+
// getBrowser.js is mocked so no Chromium is needed; the fake page below stands
|
|
26
|
+
// in for Playwright's Page. Its `evaluate` runs the callback in this process
|
|
27
|
+
// against a `window` global, so the real @lowdefy/e2e-utils helpers (getState,
|
|
28
|
+
// getBlock, getShortcutModifier) and isPageReady are exercised, not mocked.
|
|
29
|
+
const mockOpenPage = jest.fn();
|
|
30
|
+
const mockGetBrowser = jest.fn();
|
|
31
|
+
jest.unstable_mockModule('./getBrowser.js', () => ({
|
|
32
|
+
getBrowser: mockGetBrowser,
|
|
33
|
+
openPage: mockOpenPage,
|
|
34
|
+
buildPageUrl: ({ origin, pageId, path, pathParams }) =>
|
|
35
|
+
`${origin}/${buildPagePath({ pageId, path, pathParams })}`,
|
|
36
|
+
}));
|
|
37
|
+
// The route table the skeleton build writes; a page it does not hold has no pattern.
|
|
38
|
+
const mockReadPagePath = jest.fn(() => undefined);
|
|
39
|
+
jest.unstable_mockModule('./readPagePath.js', () => ({ default: mockReadPagePath }));
|
|
40
|
+
|
|
41
|
+
const mockOpenJourneyEmail = jest.fn();
|
|
42
|
+
jest.unstable_mockModule('./openJourneyEmail.js', () => ({ default: mockOpenJourneyEmail }));
|
|
43
|
+
|
|
44
|
+
const mockReadJourneyEmailMatch = jest.fn();
|
|
45
|
+
jest.unstable_mockModule('./readJourneyEmailMatch.js', () => ({
|
|
46
|
+
default: mockReadJourneyEmailMatch,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// The fake pages have no build to check step references against; the checks
|
|
50
|
+
// have tests of their own (checkStepReferences.test.mjs and the fixture suite).
|
|
51
|
+
jest.unstable_mockModule('./checkStepReferences.js', () => ({ default: async () => {} }));
|
|
52
|
+
|
|
53
|
+
// No client error report is ever in flight here; the real wait's grace would
|
|
54
|
+
// only slow every test down.
|
|
55
|
+
jest.unstable_mockModule('./observe/waitForClientErrorReports.js', () => ({
|
|
56
|
+
default: async () => {},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const { default: runJourney } = await import('./runJourney.js');
|
|
60
|
+
const { recordRunError } = await import('./runErrorBuffers.js');
|
|
61
|
+
|
|
62
|
+
// Node ships a read-only navigator; the page's platform decides Mod, so it is
|
|
63
|
+
// replaced with a Mac one for the Mod+k test.
|
|
64
|
+
Object.defineProperty(globalThis, 'navigator', {
|
|
65
|
+
value: { platform: 'MacIntel' },
|
|
66
|
+
configurable: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function createLowdefyWindow({
|
|
70
|
+
pageId = 'form',
|
|
71
|
+
state = {},
|
|
72
|
+
requests = {},
|
|
73
|
+
historyIndex = 0,
|
|
74
|
+
} = {}) {
|
|
75
|
+
return {
|
|
76
|
+
navigation: { currentEntry: { index: historyIndex } },
|
|
77
|
+
location: { pathname: `/${pageId}` },
|
|
78
|
+
lowdefy: {
|
|
79
|
+
pageId,
|
|
80
|
+
pageInstances: { [pageId]: [`page:${pageId}`] },
|
|
81
|
+
// The e2e-utils helpers read the context of the instance on screen through the path memory.
|
|
82
|
+
pathMemory: new Map([[pageId, { pageId, pathParams: {}, instanceKey: `page:${pageId}` }]]),
|
|
83
|
+
contexts: {
|
|
84
|
+
[`page:${pageId}`]: {
|
|
85
|
+
state,
|
|
86
|
+
requests,
|
|
87
|
+
websockets: {},
|
|
88
|
+
_internal: { onInitDone: true, onInitAsyncDone: true, RootSlots: { map: {} } },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createLocator({ selector, page }) {
|
|
96
|
+
const locator = {
|
|
97
|
+
selector,
|
|
98
|
+
click: jest.fn(async () => {
|
|
99
|
+
if (page.missingBlocks.some((id) => selector === `#bl-${id}`)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`locator.click: Timeout 5000ms exceeded.\nCall log:\n\u001b[2m - waiting for locator('${selector}')\u001b[22m`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
page.clicks.push(selector);
|
|
105
|
+
}),
|
|
106
|
+
fill: jest.fn(async (value) => {
|
|
107
|
+
page.fills.push({ selector, value });
|
|
108
|
+
}),
|
|
109
|
+
locator: jest.fn((child) => createLocator({ selector: `${selector} ${child}`, page })),
|
|
110
|
+
getByText: jest.fn((text) => createLocator({ selector: `${selector} >> text=${text}`, page })),
|
|
111
|
+
first: jest.fn(() => locator),
|
|
112
|
+
// A union reads as this locator: the fake page shows a block and its
|
|
113
|
+
// control together.
|
|
114
|
+
or: jest.fn((other) => {
|
|
115
|
+
page.unions.push({ selector, other: other?.selector });
|
|
116
|
+
return locator;
|
|
117
|
+
}),
|
|
118
|
+
last: jest.fn(() => locator),
|
|
119
|
+
nth: jest.fn(() => locator),
|
|
120
|
+
filter: jest.fn(() => locator),
|
|
121
|
+
count: jest.fn(async () => 0),
|
|
122
|
+
isChecked: jest.fn(async () => false),
|
|
123
|
+
selectOption: jest.fn(),
|
|
124
|
+
waitFor: jest.fn(async () => {
|
|
125
|
+
page.waits.push(selector);
|
|
126
|
+
if (page.hiddenBlocks.some((id) => selector === `#bl-${id}`)) {
|
|
127
|
+
throw new Error(`locator.waitFor: Timeout 5000ms exceeded.`);
|
|
128
|
+
}
|
|
129
|
+
}),
|
|
130
|
+
evaluateAll: jest.fn(async (fn, arg) => {
|
|
131
|
+
page.evaluatedAll.push(selector);
|
|
132
|
+
return fn(page.elementsFor(selector), arg);
|
|
133
|
+
}),
|
|
134
|
+
allInnerTexts: jest.fn(async () => [page.texts[selector] ?? '']),
|
|
135
|
+
page: jest.fn(() => page),
|
|
136
|
+
};
|
|
137
|
+
return locator;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createPage({ window = createLowdefyWindow(), url = 'http://localhost:3227/form' } = {}) {
|
|
141
|
+
globalThis.window = window;
|
|
142
|
+
const page = {
|
|
143
|
+
clicks: [],
|
|
144
|
+
fills: [],
|
|
145
|
+
unions: [],
|
|
146
|
+
presses: [],
|
|
147
|
+
missingBlocks: [],
|
|
148
|
+
hiddenBlocks: [],
|
|
149
|
+
waits: [],
|
|
150
|
+
evaluatedAll: [],
|
|
151
|
+
elementsFor: () => [],
|
|
152
|
+
texts: {},
|
|
153
|
+
screenshotCount: 0,
|
|
154
|
+
// Playwright evaluates a string as an expression, as the e2e-utils state helpers pass one.
|
|
155
|
+
evaluate: jest.fn(async (fn, arg) =>
|
|
156
|
+
typeof fn === 'string' ? new Function(`return ${fn}`)() : fn(arg)
|
|
157
|
+
),
|
|
158
|
+
waitForFunction: jest.fn(async (fn, arg) => {
|
|
159
|
+
if (!fn(arg)) {
|
|
160
|
+
throw new Error('waitForFunction: Timeout exceeded.');
|
|
161
|
+
}
|
|
162
|
+
}),
|
|
163
|
+
waitForTimeout: jest.fn(async () => {}),
|
|
164
|
+
waitForURL: jest.fn(async (predicate) => {
|
|
165
|
+
if (!predicate(new URL(url))) {
|
|
166
|
+
throw new Error('page.waitForURL: Timeout exceeded.');
|
|
167
|
+
}
|
|
168
|
+
}),
|
|
169
|
+
url: jest.fn(() => url),
|
|
170
|
+
isClosed: jest.fn(() => false),
|
|
171
|
+
documentTitle: '',
|
|
172
|
+
title: jest.fn(async () => page.documentTitle),
|
|
173
|
+
goBack: jest.fn(async () => null),
|
|
174
|
+
goto: jest.fn(async () => null),
|
|
175
|
+
keyboard: { press: jest.fn(async (key) => page.presses.push(key)) },
|
|
176
|
+
screenshot: jest.fn(async () => {
|
|
177
|
+
page.screenshotCount += 1;
|
|
178
|
+
return Buffer.from(`png-${page.screenshotCount}`);
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
page.locator = jest.fn((selector) => createLocator({ selector, page }));
|
|
182
|
+
page.getByText = jest.fn((text) => createLocator({ selector: `text=${text}`, page }));
|
|
183
|
+
return page;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Each read of the clock moves it on a second, so a polled step's wait runs
|
|
187
|
+
// out after a few reads instead of in real time.
|
|
188
|
+
function advanceClockOnEveryRead() {
|
|
189
|
+
let clock = 0;
|
|
190
|
+
return jest.spyOn(Date, 'now').mockImplementation(() => {
|
|
191
|
+
clock += 1000;
|
|
192
|
+
return clock;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function openWith(page, { ready = true } = {}) {
|
|
197
|
+
const context = { close: jest.fn(async () => {}) };
|
|
198
|
+
mockOpenPage.mockResolvedValue({ context, page, ready, url: page.url(), leftOrigin: [] });
|
|
199
|
+
return context;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
jest.clearAllMocks();
|
|
204
|
+
mockGetBrowser.mockResolvedValue({});
|
|
205
|
+
mockReadPagePath.mockImplementation(() => undefined);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const origin = 'http://localhost:3227';
|
|
209
|
+
|
|
210
|
+
test('runJourney returns an error when origin is missing', async () => {
|
|
211
|
+
const result = await runJourney({ pageId: 'form', steps: [] });
|
|
212
|
+
expect(result.error).toMatch(/requires an "origin" string/);
|
|
213
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('runJourney returns an error when pageId is not a string', async () => {
|
|
217
|
+
const result = await runJourney({ origin, pageId: 7, steps: [] });
|
|
218
|
+
expect(result.error).toMatch(/requires a "pageId" string. Received 7/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('runJourney returns an error when steps is not an array', async () => {
|
|
222
|
+
const result = await runJourney({ origin, pageId: 'form', steps: { click: 'a' } });
|
|
223
|
+
expect(result.error).toMatch(/requires "steps" to be an array/);
|
|
224
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('runJourney returns an error naming an unknown step key before opening a browser', async () => {
|
|
228
|
+
const result = await runJourney({
|
|
229
|
+
origin,
|
|
230
|
+
pageId: 'form',
|
|
231
|
+
steps: [{ click: 'ok' }, { tap: 'ok' }],
|
|
232
|
+
});
|
|
233
|
+
expect(result.error).toEqual(
|
|
234
|
+
'Step 1: Unknown journey step "tap". Steps are: click, open, fill, select, press, back, goto, email, as, wait, screenshot, expect.'
|
|
235
|
+
);
|
|
236
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
237
|
+
expect(mockOpenPage).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('runJourney refuses a placeholder value (from: shape) before opening a browser', async () => {
|
|
241
|
+
const result = await runJourney({
|
|
242
|
+
origin,
|
|
243
|
+
pageId: 'form',
|
|
244
|
+
steps: [{ click: 'new' }, { fill: { blockId: 'title', value: null, from: 'shape' } }],
|
|
245
|
+
});
|
|
246
|
+
expect(result).toEqual({
|
|
247
|
+
error:
|
|
248
|
+
'Step 1: fill on "title" has a placeholder value (from: shape). Fill it from the data set or the journey\'s user, then remove from.',
|
|
249
|
+
refused: true,
|
|
250
|
+
});
|
|
251
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
252
|
+
expect(mockOpenPage).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('runJourney returns an actionable error when no browser is available', async () => {
|
|
256
|
+
mockGetBrowser.mockRejectedValue(new Error("Executable doesn't exist"));
|
|
257
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [] });
|
|
258
|
+
expect(result.error).toMatch(/No Chromium available/);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('runJourney passes user, urlQuery and viewport through to openPage', async () => {
|
|
262
|
+
const page = createPage();
|
|
263
|
+
openWith(page);
|
|
264
|
+
await runJourney({
|
|
265
|
+
origin,
|
|
266
|
+
pageId: 'form',
|
|
267
|
+
steps: [],
|
|
268
|
+
user: { roles: ['admin'] },
|
|
269
|
+
urlQuery: { id: '1' },
|
|
270
|
+
width: 800,
|
|
271
|
+
height: 600,
|
|
272
|
+
});
|
|
273
|
+
expect(mockOpenPage).toHaveBeenCalledWith({
|
|
274
|
+
browser: {},
|
|
275
|
+
origin,
|
|
276
|
+
pageId: 'form',
|
|
277
|
+
user: { roles: ['admin'] },
|
|
278
|
+
urlQuery: { id: '1' },
|
|
279
|
+
width: 800,
|
|
280
|
+
height: 600,
|
|
281
|
+
clientAddress: expect.stringMatching(/^203\.0\.113\.\d+$/),
|
|
282
|
+
recording: {
|
|
283
|
+
source: 'journey',
|
|
284
|
+
run: { id: expect.any(String), by: null, journey: null, actor: 'main' },
|
|
285
|
+
record: false,
|
|
286
|
+
},
|
|
287
|
+
onContext: expect.any(Function),
|
|
288
|
+
timeout: 15000,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('runJourney gives an unrecorded run an identity of its own that records nothing', async () => {
|
|
293
|
+
const page = createPage();
|
|
294
|
+
page.evaluate.mockClear();
|
|
295
|
+
openWith(page);
|
|
296
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [], by: 'test' });
|
|
297
|
+
expect(result.passed).toBe(true);
|
|
298
|
+
const { recording } = mockOpenPage.mock.calls[0][0];
|
|
299
|
+
expect(recording).toEqual({
|
|
300
|
+
source: 'journey',
|
|
301
|
+
run: { id: expect.any(String), by: 'test', journey: null, actor: 'main' },
|
|
302
|
+
record: false,
|
|
303
|
+
});
|
|
304
|
+
expect(isTraceId(recording.run.id)).toBe(true);
|
|
305
|
+
// No recorder flush: the run records nothing.
|
|
306
|
+
expect(page.evaluate.mock.calls.map(([fn]) => String(fn))).not.toContainEqual(
|
|
307
|
+
expect.stringContaining('__lowdefyRecorder')
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('runJourney keeps the identity of a run its caller records', async () => {
|
|
312
|
+
const page = createPage();
|
|
313
|
+
openWith(page);
|
|
314
|
+
const recording = {
|
|
315
|
+
source: 'journey',
|
|
316
|
+
run: { id: '20261003T151200Z-p0d4rm', by: 'test', journey: 'tests/journeys/a.yaml#A' },
|
|
317
|
+
};
|
|
318
|
+
await runJourney({ origin, pageId: 'form', steps: [], recording, by: 'test' });
|
|
319
|
+
expect(mockOpenPage.mock.calls[0][0].recording).toEqual({
|
|
320
|
+
...recording,
|
|
321
|
+
run: { ...recording.run, actor: 'main' },
|
|
322
|
+
});
|
|
323
|
+
expect(page.evaluate.mock.calls.map(([fn]) => String(fn))).toContainEqual(
|
|
324
|
+
expect.stringContaining('__lowdefyRecorder')
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('runJourney claims the errors its run causes while it runs and releases its buffer after', async () => {
|
|
329
|
+
const page = createPage();
|
|
330
|
+
const context = { close: jest.fn(async () => {}) };
|
|
331
|
+
let recording;
|
|
332
|
+
let claimedDuringRun;
|
|
333
|
+
mockOpenPage.mockImplementationOnce(async (options) => {
|
|
334
|
+
recording = options.recording;
|
|
335
|
+
claimedDuringRun = recordRunError({
|
|
336
|
+
timestamp: new Date().toISOString(),
|
|
337
|
+
message: 'during',
|
|
338
|
+
recording: { source: 'journey', run: recording.run.id, journey: recording.run.journey },
|
|
339
|
+
});
|
|
340
|
+
return { context, page, ready: true, url: page.url(), leftOrigin: [] };
|
|
341
|
+
});
|
|
342
|
+
await runJourney({ origin, pageId: 'form', steps: [] });
|
|
343
|
+
expect(claimedDuringRun).toBe(true);
|
|
344
|
+
expect(
|
|
345
|
+
recordRunError({
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
message: 'late',
|
|
348
|
+
recording: { source: 'journey', run: recording.run.id, journey: recording.run.journey },
|
|
349
|
+
})
|
|
350
|
+
).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('runJourney fills, clicks and asserts state, returning passed with the final state', async () => {
|
|
354
|
+
const window = createLowdefyWindow({ state: { name: '', saved: false } });
|
|
355
|
+
const page = createPage({ window });
|
|
356
|
+
const context = openWith(page);
|
|
357
|
+
page.locator.mockImplementation((selector) => {
|
|
358
|
+
const locator = createLocator({ selector, page });
|
|
359
|
+
if (selector === '#bl-submit') {
|
|
360
|
+
locator.click.mockImplementation(async () => {
|
|
361
|
+
window.lowdefy.contexts['page:form'].state.saved = true;
|
|
362
|
+
page.clicks.push(selector);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return locator;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const result = await runJourney({
|
|
369
|
+
origin,
|
|
370
|
+
pageId: 'form',
|
|
371
|
+
steps: [
|
|
372
|
+
{ fill: { blockId: 'name', value: 'Ada', from: 'recorded' } },
|
|
373
|
+
{ click: 'submit' },
|
|
374
|
+
{ expect: { state: { path: 'saved', equals: true, from: 'recorded' } } },
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(result.error).toBeUndefined();
|
|
379
|
+
expect(result.passed).toBe(true);
|
|
380
|
+
expect(result.failure).toBeUndefined();
|
|
381
|
+
expect(page.fills).toEqual([{ selector: '#bl-name input, textarea', value: 'Ada' }]);
|
|
382
|
+
expect(page.clicks).toEqual(['#bl-submit']);
|
|
383
|
+
expect(result.steps.map((step) => step.status)).toEqual(['ok', 'ok', 'ok']);
|
|
384
|
+
expect(result.steps[1]).toMatchObject({ index: 1, step: { click: 'submit' } });
|
|
385
|
+
expect(typeof result.steps[0].durationMs).toBe('number');
|
|
386
|
+
expect(result.state).toEqual({ name: '', saved: true });
|
|
387
|
+
expect(result.screenshots).toEqual([]);
|
|
388
|
+
expect(context.close).toHaveBeenCalledTimes(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('runJourney clicks the interactive control inside a block when there is one', async () => {
|
|
392
|
+
const page = createPage();
|
|
393
|
+
openWith(page);
|
|
394
|
+
page.locator.mockImplementation((selector) => {
|
|
395
|
+
const locator = createLocator({ selector, page });
|
|
396
|
+
locator.locator.mockImplementation((child) => {
|
|
397
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
398
|
+
if (selector === '#bl-submit' && child.startsWith('button')) {
|
|
399
|
+
inner.count.mockResolvedValue(1);
|
|
400
|
+
}
|
|
401
|
+
return inner;
|
|
402
|
+
});
|
|
403
|
+
return locator;
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const result = await runJourney({
|
|
407
|
+
origin,
|
|
408
|
+
pageId: 'form',
|
|
409
|
+
steps: [{ click: 'submit' }, { click: 'card' }],
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
expect(result.passed).toBe(true);
|
|
413
|
+
expect(page.clicks).toEqual([
|
|
414
|
+
`#bl-submit button, [role="button"], a[href], label:has(input[type="radio"]), label:has(input[type="checkbox"]), input:not([type="hidden"]):not(label input[type="radio"]):not(label input[type="checkbox"]), textarea, select, [role="switch"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"]`,
|
|
415
|
+
'#bl-card',
|
|
416
|
+
]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const CONTROLS = `button, [role="button"], a[href], label:has(input[type="radio"]), label:has(input[type="checkbox"]), input:not([type="hidden"]):not(label input[type="radio"]):not(label input[type="checkbox"]), textarea, select, [role="switch"], [role="checkbox"], [role="radio"], [role="tab"], [role="menuitem"]`;
|
|
420
|
+
|
|
421
|
+
// Records every filter and nth applied to any locator, however deeply chained,
|
|
422
|
+
// so a test can assert on the text and visibility filters a target resolves to.
|
|
423
|
+
function trackFilters(page) {
|
|
424
|
+
const filters = [];
|
|
425
|
+
function instrument(locator) {
|
|
426
|
+
locator.filter.mockImplementation((filter) => {
|
|
427
|
+
filters.push({ selector: locator.selector, filter });
|
|
428
|
+
return locator;
|
|
429
|
+
});
|
|
430
|
+
locator.nth.mockImplementation((index) => {
|
|
431
|
+
page.nths.push({ selector: locator.selector, index });
|
|
432
|
+
return locator;
|
|
433
|
+
});
|
|
434
|
+
locator.locator.mockImplementation((child) =>
|
|
435
|
+
instrument(createLocator({ selector: `${locator.selector} ${child}`, page }))
|
|
436
|
+
);
|
|
437
|
+
return locator;
|
|
438
|
+
}
|
|
439
|
+
page.locator.mockImplementation((selector) => instrument(createLocator({ selector, page })));
|
|
440
|
+
return filters;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
test('runJourney clicks an option of a segmented control by its label, not its hidden input', async () => {
|
|
444
|
+
const page = createPage();
|
|
445
|
+
page.nths = [];
|
|
446
|
+
openWith(page);
|
|
447
|
+
const filters = trackFilters(page);
|
|
448
|
+
|
|
449
|
+
const result = await runJourney({
|
|
450
|
+
origin,
|
|
451
|
+
pageId: 'controls',
|
|
452
|
+
steps: [{ click: { blockId: 'period', text: 'Month' } }],
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
expect(result.passed).toBe(true);
|
|
456
|
+
// The labels of radio and checkbox options are controls; an input inside
|
|
457
|
+
// such a label is not, since it can have no size (antd Segmented).
|
|
458
|
+
expect(CONTROLS).toContain('label:has(input[type="radio"])');
|
|
459
|
+
expect(CONTROLS).toContain(':not(label input[type="radio"])');
|
|
460
|
+
expect(page.clicks).toEqual([`#bl-period ${CONTROLS}`]);
|
|
461
|
+
expect(filters[0].filter.hasText.test('Month')).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('runJourney clicks a cell button by row and exact text inside a grid block', async () => {
|
|
465
|
+
const page = createPage();
|
|
466
|
+
page.nths = [];
|
|
467
|
+
openWith(page);
|
|
468
|
+
const filters = trackFilters(page);
|
|
469
|
+
|
|
470
|
+
const result = await runJourney({
|
|
471
|
+
origin,
|
|
472
|
+
pageId: 'controls',
|
|
473
|
+
steps: [{ click: { blockId: 'grid', row: 1, text: 'Edit' } }],
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(result.passed).toBe(true);
|
|
477
|
+
expect(page.clicks).toEqual([`#bl-grid .ag-row[row-index="1"] ${CONTROLS}`]);
|
|
478
|
+
// An action target with text and no nth is located twice, before and after
|
|
479
|
+
// waiting for a first match, and acted on once it is the only one.
|
|
480
|
+
expect(filters).toHaveLength(4);
|
|
481
|
+
expect(filters[0].filter.hasText.test('Edit')).toBe(true);
|
|
482
|
+
expect(filters[0].filter.hasText.test(' Edit ')).toBe(true);
|
|
483
|
+
expect(filters[0].filter.hasText.test('Edit row')).toBe(false);
|
|
484
|
+
expect(filters[1].filter).toEqual({ visible: true });
|
|
485
|
+
expect(filters.slice(2)).toEqual(filters.slice(0, 2));
|
|
486
|
+
expect(page.nths).toEqual([]);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test('runJourney clicks the control inside a grid cell addressed by row and column', async () => {
|
|
490
|
+
const page = createPage();
|
|
491
|
+
openWith(page);
|
|
492
|
+
page.locator.mockImplementation((selector) => {
|
|
493
|
+
const locator = createLocator({ selector, page });
|
|
494
|
+
locator.locator.mockImplementation((child) => {
|
|
495
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
496
|
+
inner.locator.mockImplementation((grandchild) => {
|
|
497
|
+
const cell = createLocator({ selector: `${inner.selector} ${grandchild}`, page });
|
|
498
|
+
cell.locator.mockImplementation((control) => {
|
|
499
|
+
const found = createLocator({ selector: `${cell.selector} ${control}`, page });
|
|
500
|
+
found.count.mockResolvedValue(1);
|
|
501
|
+
return found;
|
|
502
|
+
});
|
|
503
|
+
return cell;
|
|
504
|
+
});
|
|
505
|
+
return inner;
|
|
506
|
+
});
|
|
507
|
+
return locator;
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const result = await runJourney({
|
|
511
|
+
origin,
|
|
512
|
+
pageId: 'controls',
|
|
513
|
+
steps: [{ click: { blockId: 'grid', row: 0, column: 'actions' } }],
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
expect(result.passed).toBe(true);
|
|
517
|
+
expect(page.clicks).toEqual([
|
|
518
|
+
`#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="actions"] ${CONTROLS}`,
|
|
519
|
+
]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('runJourney clicks the nth control in a grid cell when several have no text', async () => {
|
|
523
|
+
const page = createPage();
|
|
524
|
+
page.nths = [];
|
|
525
|
+
openWith(page);
|
|
526
|
+
trackFilters(page);
|
|
527
|
+
|
|
528
|
+
const result = await runJourney({
|
|
529
|
+
origin,
|
|
530
|
+
pageId: 'controls',
|
|
531
|
+
steps: [{ click: { blockId: 'grid', row: 0, column: 'actions', nth: 1 } }],
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(result.passed).toBe(true);
|
|
535
|
+
const selector = `#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="actions"] ${CONTROLS}`;
|
|
536
|
+
expect(page.clicks).toEqual([selector]);
|
|
537
|
+
expect(page.nths).toEqual([{ selector, index: 1 }]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('runJourney clicks a page-wide control by text when the target has no blockId', async () => {
|
|
541
|
+
const page = createPage();
|
|
542
|
+
page.nths = [];
|
|
543
|
+
openWith(page);
|
|
544
|
+
const filters = trackFilters(page);
|
|
545
|
+
|
|
546
|
+
const result = await runJourney({
|
|
547
|
+
origin,
|
|
548
|
+
pageId: 'controls',
|
|
549
|
+
steps: [{ click: { text: 'OK' } }, { click: { text: 'Delete', nth: 1 } }],
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(result.passed).toBe(true);
|
|
553
|
+
expect(page.clicks).toEqual([CONTROLS, CONTROLS]);
|
|
554
|
+
// No menu or dialog is open (every count is 0), so only the page-wide
|
|
555
|
+
// search filters apply: twice for each text, before and after waiting for a
|
|
556
|
+
// match.
|
|
557
|
+
const pageWide = filters.filter((f) => f.selector === CONTROLS);
|
|
558
|
+
expect(pageWide.map((f) => f.filter)).toEqual([
|
|
559
|
+
{ hasText: expect.any(RegExp) },
|
|
560
|
+
{ visible: true },
|
|
561
|
+
{ hasText: expect.any(RegExp) },
|
|
562
|
+
{ visible: true },
|
|
563
|
+
{ hasText: expect.any(RegExp) },
|
|
564
|
+
{ visible: true },
|
|
565
|
+
{ hasText: expect.any(RegExp) },
|
|
566
|
+
{ visible: true },
|
|
567
|
+
]);
|
|
568
|
+
expect(pageWide[0].filter.hasText.test('OK')).toBe(true);
|
|
569
|
+
expect(pageWide[0].filter.hasText.test('OKAY')).toBe(false);
|
|
570
|
+
expect(pageWide[4].filter.hasText.test('Delete')).toBe(true);
|
|
571
|
+
expect(page.nths).toEqual([{ selector: CONTROLS, index: 1 }]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test('runJourney fails an action whose text target matches several controls', async () => {
|
|
575
|
+
const page = createPage();
|
|
576
|
+
page.nths = [];
|
|
577
|
+
openWith(page);
|
|
578
|
+
trackFilters(page);
|
|
579
|
+
const instrumented = page.locator.getMockImplementation();
|
|
580
|
+
page.locator.mockImplementation((selector) => {
|
|
581
|
+
const locator = instrumented(selector);
|
|
582
|
+
if (selector === CONTROLS) {
|
|
583
|
+
locator.count.mockResolvedValue(3);
|
|
584
|
+
}
|
|
585
|
+
return locator;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const result = await runJourney({
|
|
589
|
+
origin,
|
|
590
|
+
pageId: 'controls',
|
|
591
|
+
steps: [{ click: { text: 'Delete' } }],
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
expect(result.passed).toBe(false);
|
|
595
|
+
expect(page.clicks).toEqual([]);
|
|
596
|
+
expect(result.failure).toEqual({
|
|
597
|
+
index: 0,
|
|
598
|
+
step: { click: { text: 'Delete' } },
|
|
599
|
+
expected: 'exactly one control with text "Delete" in the page',
|
|
600
|
+
actual: '3 controls',
|
|
601
|
+
message:
|
|
602
|
+
'Matched 3 controls with text "Delete" in the page; add nth: 0..2, or a blockId/row to narrow it.',
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test('runJourney fails an action whose containing target matches several elements in a block', async () => {
|
|
607
|
+
const page = createPage();
|
|
608
|
+
openWith(page);
|
|
609
|
+
page.locator.mockImplementation((selector) => {
|
|
610
|
+
const locator = createLocator({ selector, page });
|
|
611
|
+
locator.locator.mockImplementation((child) => {
|
|
612
|
+
const found = createLocator({ selector: `${selector} ${child}`, page });
|
|
613
|
+
found.count.mockResolvedValue(2);
|
|
614
|
+
return found;
|
|
615
|
+
});
|
|
616
|
+
return locator;
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const result = await runJourney({
|
|
620
|
+
origin,
|
|
621
|
+
pageId: 'controls',
|
|
622
|
+
steps: [{ fill: { blockId: 'members', containing: 'ada', value: 'x' } }],
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
expect(result.passed).toBe(false);
|
|
626
|
+
expect(page.fills).toEqual([]);
|
|
627
|
+
expect(result.failure.message).toBe(
|
|
628
|
+
'Matched 2 elements containing "ada" in block "members"; add nth: 0..1, or a blockId/row to narrow it.'
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('runJourney accepts any of several matches in an expectation', async () => {
|
|
633
|
+
const page = createPage();
|
|
634
|
+
openWith(page);
|
|
635
|
+
page.locator.mockImplementation((selector) => {
|
|
636
|
+
const locator = createLocator({ selector, page });
|
|
637
|
+
locator.count.mockResolvedValue(3);
|
|
638
|
+
return locator;
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const result = await runJourney({
|
|
642
|
+
origin,
|
|
643
|
+
pageId: 'controls',
|
|
644
|
+
steps: [{ expect: { visible: { text: 'Delete' } } }],
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
expect(result.failure).toBeUndefined();
|
|
648
|
+
expect(result.passed).toBe(true);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test('runJourney finds a page-wide text target in the open dialog before the page', async () => {
|
|
652
|
+
const page = createPage();
|
|
653
|
+
openWith(page);
|
|
654
|
+
// A dialog is open and it holds one control with the text; the grid behind
|
|
655
|
+
// it holds three, but only the dialog's is counted, so the click is not
|
|
656
|
+
// ambiguous.
|
|
657
|
+
page.locator.mockImplementation((selector) => {
|
|
658
|
+
const locator = createLocator({ selector, page });
|
|
659
|
+
if (selector === CONTROLS) {
|
|
660
|
+
locator.count.mockResolvedValue(3);
|
|
661
|
+
}
|
|
662
|
+
if (selector === '[role="dialog"]') {
|
|
663
|
+
locator.count.mockResolvedValue(1);
|
|
664
|
+
locator.last.mockImplementation(() => locator);
|
|
665
|
+
locator.locator.mockImplementation((child) => {
|
|
666
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
667
|
+
inner.count.mockResolvedValue(1);
|
|
668
|
+
return inner;
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return locator;
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const result = await runJourney({
|
|
675
|
+
origin,
|
|
676
|
+
pageId: 'controls',
|
|
677
|
+
steps: [{ click: { text: 'Delete' } }],
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
expect(result.passed).toBe(true);
|
|
681
|
+
expect(page.clicks).toEqual([`[role="dialog"] ${CONTROLS}`]);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('runJourney prefers an open menu over an open dialog for a page-wide text target', async () => {
|
|
685
|
+
const page = createPage();
|
|
686
|
+
openWith(page);
|
|
687
|
+
page.locator.mockImplementation((selector) => {
|
|
688
|
+
const locator = createLocator({ selector, page });
|
|
689
|
+
if (selector === '[role="menu"]' || selector === '[role="dialog"]') {
|
|
690
|
+
locator.count.mockResolvedValue(1);
|
|
691
|
+
locator.last.mockImplementation(() => locator);
|
|
692
|
+
locator.locator.mockImplementation((child) => {
|
|
693
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
694
|
+
inner.count.mockResolvedValue(1);
|
|
695
|
+
return inner;
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return locator;
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const result = await runJourney({
|
|
702
|
+
origin,
|
|
703
|
+
pageId: 'controls',
|
|
704
|
+
steps: [{ click: { text: 'Archive' } }],
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
expect(result.passed).toBe(true);
|
|
708
|
+
expect(page.clicks).toEqual([`[role="menu"] ${CONTROLS}`]);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test('runJourney falls through to the page when the open dialog has no control with the text', async () => {
|
|
712
|
+
const page = createPage();
|
|
713
|
+
openWith(page);
|
|
714
|
+
page.locator.mockImplementation((selector) => {
|
|
715
|
+
const locator = createLocator({ selector, page });
|
|
716
|
+
if (selector === '[role="dialog"]') {
|
|
717
|
+
locator.count.mockResolvedValue(1);
|
|
718
|
+
locator.last.mockImplementation(() => locator);
|
|
719
|
+
}
|
|
720
|
+
return locator;
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const result = await runJourney({
|
|
724
|
+
origin,
|
|
725
|
+
pageId: 'controls',
|
|
726
|
+
steps: [{ click: { text: 'Refresh' } }],
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
expect(result.passed).toBe(true);
|
|
730
|
+
expect(page.clicks).toEqual([CONTROLS]);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test('runJourney escapes quotes in a column id', async () => {
|
|
734
|
+
const page = createPage();
|
|
735
|
+
openWith(page);
|
|
736
|
+
|
|
737
|
+
await runJourney({
|
|
738
|
+
origin,
|
|
739
|
+
pageId: 'controls',
|
|
740
|
+
steps: [{ click: { blockId: 'grid', row: 0, column: 'a"b' } }],
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
expect(page.clicks).toEqual(['#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="a\\"b"]']);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('runJourney describes a failed target by block, row, column and text', async () => {
|
|
747
|
+
const page = createPage();
|
|
748
|
+
openWith(page);
|
|
749
|
+
page.locator.mockImplementation((selector) => {
|
|
750
|
+
const locator = createLocator({ selector, page });
|
|
751
|
+
const fail = async () => {
|
|
752
|
+
throw new Error('locator.click: Timeout 5000ms exceeded.');
|
|
753
|
+
};
|
|
754
|
+
locator.click.mockImplementation(fail);
|
|
755
|
+
locator.locator.mockImplementation((child) => {
|
|
756
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
757
|
+
inner.click.mockImplementation(fail);
|
|
758
|
+
inner.locator.mockImplementation((grandchild) => {
|
|
759
|
+
const deep = createLocator({ selector: `${inner.selector} ${grandchild}`, page });
|
|
760
|
+
deep.click.mockImplementation(fail);
|
|
761
|
+
deep.locator.mockImplementation(() => deep);
|
|
762
|
+
return deep;
|
|
763
|
+
});
|
|
764
|
+
return inner;
|
|
765
|
+
});
|
|
766
|
+
return locator;
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const result = await runJourney({
|
|
770
|
+
origin,
|
|
771
|
+
pageId: 'controls',
|
|
772
|
+
steps: [{ click: { blockId: 'grid', row: 2, column: 'actions', text: 'Edit', nth: 1 } }],
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
expect(result.passed).toBe(false);
|
|
776
|
+
expect(result.failure.expected).toEqual(
|
|
777
|
+
'block "grid" row 2 column "actions" control "Edit" nth 1 to be actionable'
|
|
778
|
+
);
|
|
779
|
+
expect(result.failure.message).toMatch(
|
|
780
|
+
/^Block "grid" row 2 column "actions" control "Edit" nth 1 was not actionable: locator.click/
|
|
781
|
+
);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('runJourney reports a page-wide control that never appears', async () => {
|
|
785
|
+
const page = createPage();
|
|
786
|
+
openWith(page);
|
|
787
|
+
page.locator.mockImplementation((selector) => {
|
|
788
|
+
const locator = createLocator({ selector, page });
|
|
789
|
+
locator.click.mockImplementation(async () => {
|
|
790
|
+
throw new Error('locator.click: Timeout 5000ms exceeded.');
|
|
791
|
+
});
|
|
792
|
+
return locator;
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const result = await runJourney({
|
|
796
|
+
origin,
|
|
797
|
+
pageId: 'controls',
|
|
798
|
+
steps: [{ click: { text: 'Confirm' } }],
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
expect(result.passed).toBe(false);
|
|
802
|
+
expect(result.failure.expected).toEqual('control "Confirm" to be actionable');
|
|
803
|
+
expect(result.failure.message).toMatch(/^Control "Confirm" was not actionable/);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test('runJourney fills and selects inside a grid cell addressed by row and column', async () => {
|
|
807
|
+
const page = createPage();
|
|
808
|
+
openWith(page);
|
|
809
|
+
|
|
810
|
+
const result = await runJourney({
|
|
811
|
+
origin,
|
|
812
|
+
pageId: 'controls',
|
|
813
|
+
steps: [
|
|
814
|
+
{ fill: { blockId: 'grid', row: 1, column: 'name', value: 'Ada' } },
|
|
815
|
+
{ select: { blockId: 'grid', row: 1, column: 'status', value: 'Open' } },
|
|
816
|
+
],
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
expect(result.passed).toBe(true);
|
|
820
|
+
expect(page.fills).toEqual([
|
|
821
|
+
{
|
|
822
|
+
selector: '#bl-grid .ag-row[row-index="1"] .ag-cell[col-id="name"] input, textarea',
|
|
823
|
+
value: 'Ada',
|
|
824
|
+
},
|
|
825
|
+
]);
|
|
826
|
+
expect(page.clicks).toEqual([
|
|
827
|
+
'#bl-grid .ag-row[row-index="1"] .ag-cell[col-id="status"]',
|
|
828
|
+
'.ant-select-item-option, [role="option"]',
|
|
829
|
+
]);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test('runJourney checks visibility and text of a grid row and cell', async () => {
|
|
833
|
+
const page = createPage();
|
|
834
|
+
page.texts['#bl-grid .ag-row[row-index="0"] .ag-cell[col-id="title"]'] = 'Access reviews';
|
|
835
|
+
page.texts['#bl-grid .ag-row[row-index="0"]'] = 'Access reviews open Edit';
|
|
836
|
+
openWith(page);
|
|
837
|
+
const spy = advanceClockOnEveryRead();
|
|
838
|
+
|
|
839
|
+
const result = await runJourney({
|
|
840
|
+
origin,
|
|
841
|
+
pageId: 'controls',
|
|
842
|
+
steps: [
|
|
843
|
+
{ expect: { visible: { blockId: 'grid', row: 0 } } },
|
|
844
|
+
{ expect: { text: { blockId: 'grid', row: 0, column: 'title', contains: 'Access' } } },
|
|
845
|
+
{ expect: { text: { blockId: 'grid', row: 0, contains: 'open' } } },
|
|
846
|
+
{ expect: { text: { blockId: 'grid', row: 0, column: 'title', contains: 'Closed' } } },
|
|
847
|
+
],
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
spy.mockRestore();
|
|
851
|
+
|
|
852
|
+
expect(result.steps.map((step) => step.status)).toEqual(['ok', 'ok', 'ok', 'failed']);
|
|
853
|
+
expect(result.failure.expected).toEqual(
|
|
854
|
+
'block "grid" row 0 column "title" text to contain "Closed"'
|
|
855
|
+
);
|
|
856
|
+
expect(result.failure.actual).toEqual('Access reviews');
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test('runJourney joins the text of every element a row target matches', async () => {
|
|
860
|
+
const page = createPage();
|
|
861
|
+
openWith(page);
|
|
862
|
+
page.locator.mockImplementation((selector) => {
|
|
863
|
+
const locator = createLocator({ selector, page });
|
|
864
|
+
locator.locator.mockImplementation((child) => {
|
|
865
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
866
|
+
// ag-grid renders the row once per column container.
|
|
867
|
+
inner.allInnerTexts.mockResolvedValue(['Access reviews', 'Edit Delete']);
|
|
868
|
+
return inner;
|
|
869
|
+
});
|
|
870
|
+
return locator;
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
const result = await runJourney({
|
|
874
|
+
origin,
|
|
875
|
+
pageId: 'controls',
|
|
876
|
+
steps: [{ expect: { text: { blockId: 'grid', row: 0, contains: 'Delete' } } }],
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
expect(result.passed).toBe(true);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test('runJourney reports a missing expect.state value as actual null', async () => {
|
|
883
|
+
const page = createPage({ window: createLowdefyWindow({ state: {} }) });
|
|
884
|
+
openWith(page);
|
|
885
|
+
const spy = advanceClockOnEveryRead();
|
|
886
|
+
const result = await runJourney({
|
|
887
|
+
origin,
|
|
888
|
+
pageId: 'form',
|
|
889
|
+
steps: [{ expect: { state: { path: 'saved', equals: true } } }],
|
|
890
|
+
});
|
|
891
|
+
spy.mockRestore();
|
|
892
|
+
expect(result.failure.actual).toBeNull();
|
|
893
|
+
expect(Object.keys(result.failure)).toContain('actual');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test('runJourney matches a missing state path with equals null, as its failure report shows it', async () => {
|
|
897
|
+
const page = createPage({ window: createLowdefyWindow({ state: { rows: [{ id: 1 }] } }) });
|
|
898
|
+
openWith(page);
|
|
899
|
+
const spy = advanceClockOnEveryRead();
|
|
900
|
+
const result = await runJourney({
|
|
901
|
+
origin,
|
|
902
|
+
pageId: 'form',
|
|
903
|
+
steps: [
|
|
904
|
+
{ expect: { state: { path: 'rows.1', equals: null } } },
|
|
905
|
+
{ expect: { state: { path: 'rows.0', equals: null } } },
|
|
906
|
+
],
|
|
907
|
+
});
|
|
908
|
+
spy.mockRestore();
|
|
909
|
+
expect(result.failure).toMatchObject({ index: 1, expected: null, actual: { id: 1 } });
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
test('runJourney waits for expect.state until the value the click led to lands', async () => {
|
|
913
|
+
const window = createLowdefyWindow({ state: { invitations: [{ email: 'ada@example.test' }] } });
|
|
914
|
+
const page = createPage({ window });
|
|
915
|
+
page.waitForTimeout = jest.fn(async () => {
|
|
916
|
+
window.lowdefy.contexts['page:form'].state.invitations = [];
|
|
917
|
+
});
|
|
918
|
+
openWith(page);
|
|
919
|
+
|
|
920
|
+
const result = await runJourney({
|
|
921
|
+
origin,
|
|
922
|
+
pageId: 'form',
|
|
923
|
+
steps: [{ expect: { state: { path: 'invitations', equals: [] } } }],
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
expect(result.failure).toBeUndefined();
|
|
927
|
+
expect(page.waitForTimeout).toHaveBeenCalledTimes(1);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test('runJourney settles a page for at most 5 seconds after an interaction, whatever the step timeout', async () => {
|
|
931
|
+
const page = createPage();
|
|
932
|
+
openWith(page);
|
|
933
|
+
|
|
934
|
+
await runJourney({ origin, pageId: 'form', stepTimeout: 30000, steps: [{ click: 'send' }] });
|
|
935
|
+
|
|
936
|
+
expect(page.waitForFunction.mock.calls.at(-1)[2]).toEqual({ timeout: 5000 });
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test('runJourney fails expect.calls with no pageId when no Lowdefy page is showing', async () => {
|
|
940
|
+
const page = createPage({ window: { navigation: { currentEntry: { index: 0 } } } });
|
|
941
|
+
openWith(page);
|
|
942
|
+
|
|
943
|
+
const result = await runJourney({
|
|
944
|
+
origin,
|
|
945
|
+
pageId: 'form',
|
|
946
|
+
steps: [{ expect: { calls: { request: 'save', count: 0 } } }],
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
expect(result.passed).toBe(false);
|
|
950
|
+
expect(result.failure).toMatchObject({
|
|
951
|
+
index: 0,
|
|
952
|
+
expected: 'a Lowdefy page to count request "save" on',
|
|
953
|
+
actual: null,
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test('runJourney fill looks in the block and in its portal for the input', async () => {
|
|
958
|
+
const page = createPage();
|
|
959
|
+
openWith(page);
|
|
960
|
+
await runJourney({
|
|
961
|
+
origin,
|
|
962
|
+
pageId: 'form',
|
|
963
|
+
steps: [{ fill: { blockId: 'header_search', value: 'PAIA' } }],
|
|
964
|
+
});
|
|
965
|
+
expect(page.fills).toEqual([{ selector: '#bl-header_search input, textarea', value: 'PAIA' }]);
|
|
966
|
+
expect(page.unions).toContainEqual({
|
|
967
|
+
selector: '#bl-header_search',
|
|
968
|
+
other: '.ldf-portal-header_search',
|
|
969
|
+
});
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
test('runJourney fill of a grid cell does not look in a portal', async () => {
|
|
973
|
+
const page = createPage();
|
|
974
|
+
openWith(page);
|
|
975
|
+
await runJourney({
|
|
976
|
+
origin,
|
|
977
|
+
pageId: 'form',
|
|
978
|
+
steps: [{ fill: { blockId: 'grid', row: 1, column: 'name', value: 'Ada' } }],
|
|
979
|
+
});
|
|
980
|
+
expect(page.unions.filter((union) => union.other?.startsWith('.ldf-portal'))).toEqual([]);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test('runJourney fills a numeric value as a string', async () => {
|
|
984
|
+
const page = createPage();
|
|
985
|
+
openWith(page);
|
|
986
|
+
await runJourney({ origin, pageId: 'form', steps: [{ fill: { blockId: 'age', value: 42 } }] });
|
|
987
|
+
expect(page.fills).toEqual([{ selector: '#bl-age input, textarea', value: '42' }]);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test('runJourney reports a failing expect.state with expected and actual and skips the rest', async () => {
|
|
991
|
+
const page = createPage({ window: createLowdefyWindow({ state: { count: 1 } }) });
|
|
992
|
+
openWith(page);
|
|
993
|
+
const spy = advanceClockOnEveryRead();
|
|
994
|
+
|
|
995
|
+
const result = await runJourney({
|
|
996
|
+
origin,
|
|
997
|
+
pageId: 'form',
|
|
998
|
+
steps: [
|
|
999
|
+
{ expect: { state: { path: 'count', equals: 2 } } },
|
|
1000
|
+
{ click: 'next' },
|
|
1001
|
+
{ screenshot: 'after' },
|
|
1002
|
+
],
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
spy.mockRestore();
|
|
1006
|
+
expect(result.passed).toBe(false);
|
|
1007
|
+
expect(result.failure).toEqual({
|
|
1008
|
+
index: 0,
|
|
1009
|
+
step: { expect: { state: { path: 'count', equals: 2 } } },
|
|
1010
|
+
expected: 2,
|
|
1011
|
+
actual: 1,
|
|
1012
|
+
message: 'Expected state "count" to equal 2 but found 1.',
|
|
1013
|
+
});
|
|
1014
|
+
expect(result.steps.map((step) => step.status)).toEqual(['failed', 'skipped', 'skipped']);
|
|
1015
|
+
expect(page.clicks).toEqual([]);
|
|
1016
|
+
expect(result.screenshots).toEqual([]);
|
|
1017
|
+
expect(result.state).toEqual({ count: 1 });
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test('runJourney compares expect.state structurally, ignoring key order', async () => {
|
|
1021
|
+
const page = createPage({
|
|
1022
|
+
window: createLowdefyWindow({ state: { row: { b: [1, { c: 2 }], a: 'x' } } }),
|
|
1023
|
+
});
|
|
1024
|
+
openWith(page);
|
|
1025
|
+
const result = await runJourney({
|
|
1026
|
+
origin,
|
|
1027
|
+
pageId: 'form',
|
|
1028
|
+
steps: [{ expect: { state: { path: 'row', equals: { a: 'x', b: [1, { c: 2 }] } } } }],
|
|
1029
|
+
});
|
|
1030
|
+
expect(result.passed).toBe(true);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('runJourney reports a click on a missing block as a failure instead of throwing', async () => {
|
|
1034
|
+
const page = createPage();
|
|
1035
|
+
page.missingBlocks = ['nope'];
|
|
1036
|
+
const context = openWith(page);
|
|
1037
|
+
|
|
1038
|
+
const result = await runJourney({
|
|
1039
|
+
origin,
|
|
1040
|
+
pageId: 'form',
|
|
1041
|
+
steps: [{ click: 'nope' }, { expect: { visible: 'modal' } }],
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
expect(result.error).toBeUndefined();
|
|
1045
|
+
expect(result.passed).toBe(false);
|
|
1046
|
+
expect(result.failure.index).toBe(0);
|
|
1047
|
+
expect(result.failure.step).toEqual({ click: 'nope' });
|
|
1048
|
+
expect(result.failure.expected).toEqual('block "nope" to be actionable');
|
|
1049
|
+
expect(result.failure.actual).toEqual(
|
|
1050
|
+
"locator.click: Timeout 5000ms exceeded.\nCall log:\n - waiting for locator('#bl-nope')"
|
|
1051
|
+
);
|
|
1052
|
+
expect(result.failure.message).toMatch(/Block "nope" was not actionable/);
|
|
1053
|
+
expect(result.steps.map((step) => step.status)).toEqual(['failed', 'skipped']);
|
|
1054
|
+
expect(context.close).toHaveBeenCalledTimes(1);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test('runJourney escapes block ids in the #bl- selector', async () => {
|
|
1058
|
+
const page = createPage();
|
|
1059
|
+
openWith(page);
|
|
1060
|
+
await runJourney({ origin, pageId: 'form', steps: [{ click: 'rows.0.edit' }] });
|
|
1061
|
+
expect(page.clicks).toEqual(['#bl-rows\\.0\\.edit']);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test('runJourney collects screenshots in step order with default and given names', async () => {
|
|
1065
|
+
const page = createPage();
|
|
1066
|
+
openWith(page);
|
|
1067
|
+
|
|
1068
|
+
const result = await runJourney({
|
|
1069
|
+
origin,
|
|
1070
|
+
pageId: 'form',
|
|
1071
|
+
steps: [{ screenshot: 'start' }, { click: 'open' }, { screenshot: true }, { screenshot: null }],
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
expect(result.passed).toBe(true);
|
|
1075
|
+
expect(result.screenshots).toEqual([
|
|
1076
|
+
{ name: 'start', data: Buffer.from('png-1').toString('base64'), mimeType: 'image/png' },
|
|
1077
|
+
{ name: 'step-2', data: Buffer.from('png-2').toString('base64'), mimeType: 'image/png' },
|
|
1078
|
+
{ name: 'step-3', data: Buffer.from('png-3').toString('base64'), mimeType: 'image/png' },
|
|
1079
|
+
]);
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
test('runJourney keeps screenshots taken before a failing step', async () => {
|
|
1083
|
+
const page = createPage();
|
|
1084
|
+
page.hiddenBlocks = ['modal'];
|
|
1085
|
+
openWith(page);
|
|
1086
|
+
|
|
1087
|
+
const result = await runJourney({
|
|
1088
|
+
origin,
|
|
1089
|
+
pageId: 'form',
|
|
1090
|
+
steps: [{ screenshot: 'before' }, { expect: { visible: 'modal' } }, { screenshot: 'after' }],
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
expect(result.passed).toBe(false);
|
|
1094
|
+
expect(result.failure.expected).toEqual('block "modal" to be visible');
|
|
1095
|
+
expect(result.screenshots.map((shot) => shot.name)).toEqual(['before']);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test('runJourney resolves Mod in a press chord through getShortcutModifier', async () => {
|
|
1099
|
+
const page = createPage();
|
|
1100
|
+
openWith(page);
|
|
1101
|
+
|
|
1102
|
+
const result = await runJourney({
|
|
1103
|
+
origin,
|
|
1104
|
+
pageId: 'form',
|
|
1105
|
+
steps: [{ press: 'Mod+k' }, { press: 'Enter' }],
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
expect(result.passed).toBe(true);
|
|
1109
|
+
expect(page.presses).toEqual(['Meta+k', 'Enter']);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test('runJourney selects a native option by label when the block contains a select', async () => {
|
|
1113
|
+
const page = createPage();
|
|
1114
|
+
openWith(page);
|
|
1115
|
+
let native;
|
|
1116
|
+
page.locator.mockImplementation((selector) => {
|
|
1117
|
+
const locator = createLocator({ selector, page });
|
|
1118
|
+
locator.locator.mockImplementation((child) => {
|
|
1119
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
1120
|
+
if (child === 'select') {
|
|
1121
|
+
inner.count.mockResolvedValue(1);
|
|
1122
|
+
native = inner;
|
|
1123
|
+
}
|
|
1124
|
+
return inner;
|
|
1125
|
+
});
|
|
1126
|
+
return locator;
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const result = await runJourney({
|
|
1130
|
+
origin,
|
|
1131
|
+
pageId: 'form',
|
|
1132
|
+
steps: [{ select: { blockId: 'country', value: 'Chile' } }],
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
expect(result.passed).toBe(true);
|
|
1136
|
+
expect(native.selectOption).toHaveBeenCalledWith({ label: 'Chile' }, { timeout: 5000 });
|
|
1137
|
+
expect(page.clicks).toEqual([]);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
test('runJourney selects a dropdown option by exact visible text', async () => {
|
|
1141
|
+
const page = createPage();
|
|
1142
|
+
openWith(page);
|
|
1143
|
+
const filters = [];
|
|
1144
|
+
page.locator.mockImplementation((selector) => {
|
|
1145
|
+
const locator = createLocator({ selector, page });
|
|
1146
|
+
locator.filter.mockImplementation((filter) => {
|
|
1147
|
+
filters.push(filter);
|
|
1148
|
+
return locator;
|
|
1149
|
+
});
|
|
1150
|
+
return locator;
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const result = await runJourney({
|
|
1154
|
+
origin,
|
|
1155
|
+
pageId: 'form',
|
|
1156
|
+
steps: [{ select: { blockId: 'country', value: 'Chile' } }],
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
expect(result.passed).toBe(true);
|
|
1160
|
+
expect(page.clicks).toEqual(['#bl-country', '.ant-select-item-option, [role="option"]']);
|
|
1161
|
+
expect(filters[0].hasText.test('Chile')).toBe(true);
|
|
1162
|
+
expect(filters[0].hasText.test('Chile (CL)')).toBe(false);
|
|
1163
|
+
expect(filters[1]).toEqual({ visible: true });
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const POPUPS = [
|
|
1167
|
+
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
|
|
1168
|
+
'.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)',
|
|
1169
|
+
'.ant-cascader-dropdown:not(.ant-select-dropdown-hidden)',
|
|
1170
|
+
'.ant-color-picker-inner',
|
|
1171
|
+
'.ant-dropdown:not(.ant-dropdown-hidden)',
|
|
1172
|
+
'.ant-mentions-dropdown:not(.ant-mentions-dropdown-hidden)',
|
|
1173
|
+
];
|
|
1174
|
+
|
|
1175
|
+
const SHOWING_BEFORE = 'data-lowdefy-journey-showing-before';
|
|
1176
|
+
|
|
1177
|
+
// A stand-in for a popup element: enough DOM for the open step's marking.
|
|
1178
|
+
function createPopup({ visible }) {
|
|
1179
|
+
const attributes = new Set();
|
|
1180
|
+
return {
|
|
1181
|
+
attributes,
|
|
1182
|
+
checkVisibility: () => visible,
|
|
1183
|
+
setAttribute: (name) => attributes.add(name),
|
|
1184
|
+
removeAttribute: (name) => attributes.delete(name),
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
test('runJourney opens a select by clicking its antd 6 content box', async () => {
|
|
1189
|
+
const page = createPage();
|
|
1190
|
+
openWith(page);
|
|
1191
|
+
page.locator.mockImplementation((selector) => {
|
|
1192
|
+
const locator = createLocator({ selector, page });
|
|
1193
|
+
locator.locator.mockImplementation((child) => {
|
|
1194
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
1195
|
+
if (selector === '#bl-status' && child.startsWith('.ant-select-content')) {
|
|
1196
|
+
inner.count.mockResolvedValue(1);
|
|
1197
|
+
}
|
|
1198
|
+
return inner;
|
|
1199
|
+
});
|
|
1200
|
+
return locator;
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ open: 'status' }] });
|
|
1204
|
+
|
|
1205
|
+
expect(result.passed).toBe(true);
|
|
1206
|
+
expect(page.clicks).toEqual([
|
|
1207
|
+
'#bl-status .ant-select-content, .ant-picker, .ant-color-picker-trigger, .ant-dropdown-trigger, .ant-mentions',
|
|
1208
|
+
]);
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
test('runJourney open waits for a popup its own click opened, not one already showing', async () => {
|
|
1212
|
+
const page = createPage();
|
|
1213
|
+
openWith(page);
|
|
1214
|
+
const fading = createPopup({ visible: true });
|
|
1215
|
+
const closed = createPopup({ visible: false });
|
|
1216
|
+
page.elementsFor = (selector) => {
|
|
1217
|
+
if (selector === POPUPS.join(', ')) {
|
|
1218
|
+
return [fading, closed];
|
|
1219
|
+
}
|
|
1220
|
+
if (selector === `[${SHOWING_BEFORE}]`) {
|
|
1221
|
+
return [fading, closed].filter((popup) => popup.attributes.has(SHOWING_BEFORE));
|
|
1222
|
+
}
|
|
1223
|
+
return [];
|
|
1224
|
+
};
|
|
1225
|
+
const markedWhenClicked = [];
|
|
1226
|
+
page.locator.mockImplementation((selector) => {
|
|
1227
|
+
const locator = createLocator({ selector, page });
|
|
1228
|
+
locator.click.mockImplementation(async () => {
|
|
1229
|
+
markedWhenClicked.push(fading.attributes.has(SHOWING_BEFORE));
|
|
1230
|
+
page.clicks.push(selector);
|
|
1231
|
+
});
|
|
1232
|
+
return locator;
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ open: 'notes' }] });
|
|
1236
|
+
|
|
1237
|
+
expect(result.passed).toBe(true);
|
|
1238
|
+
expect(markedWhenClicked).toEqual([true]);
|
|
1239
|
+
expect(closed.attributes.has(SHOWING_BEFORE)).toBe(false);
|
|
1240
|
+
expect(page.waits).toContain(
|
|
1241
|
+
POPUPS.map((selector) => `${selector}:not([${SHOWING_BEFORE}])`).join(', ')
|
|
1242
|
+
);
|
|
1243
|
+
// Unmarked once the step is done, so a later open of that input finds it.
|
|
1244
|
+
expect(fading.attributes.has(SHOWING_BEFORE)).toBe(false);
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test('runJourney fails an open step whose click opens no popup and still unmarks the popups', async () => {
|
|
1248
|
+
const page = createPage();
|
|
1249
|
+
openWith(page);
|
|
1250
|
+
const showing = createPopup({ visible: true });
|
|
1251
|
+
page.elementsFor = () => [showing];
|
|
1252
|
+
page.locator.mockImplementation((selector) => {
|
|
1253
|
+
const locator = createLocator({ selector, page });
|
|
1254
|
+
if (selector.includes(`:not([${SHOWING_BEFORE}])`)) {
|
|
1255
|
+
locator.waitFor.mockRejectedValue(new Error('locator.waitFor: Timeout 5000ms exceeded.'));
|
|
1256
|
+
}
|
|
1257
|
+
return locator;
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ open: 'notes' }] });
|
|
1261
|
+
|
|
1262
|
+
expect(result.passed).toBe(false);
|
|
1263
|
+
expect(result.failure.message).toEqual('Opening block "notes" showed no dropdown or popup.');
|
|
1264
|
+
expect(showing.attributes.has(SHOWING_BEFORE)).toBe(false);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test('runJourney selects a radio, button or segmented option in the block by its exact label', async () => {
|
|
1268
|
+
const page = createPage();
|
|
1269
|
+
openWith(page);
|
|
1270
|
+
const filters = [];
|
|
1271
|
+
page.locator.mockImplementation((selector) => {
|
|
1272
|
+
const locator = createLocator({ selector, page });
|
|
1273
|
+
locator.locator.mockImplementation((child) => {
|
|
1274
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
1275
|
+
if (child === 'label:has(input[type="radio"])') {
|
|
1276
|
+
inner.count.mockResolvedValue(3);
|
|
1277
|
+
inner.filter.mockImplementation((filter) => {
|
|
1278
|
+
filters.push(filter);
|
|
1279
|
+
return inner;
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
return inner;
|
|
1283
|
+
});
|
|
1284
|
+
return locator;
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
const result = await runJourney({
|
|
1288
|
+
origin,
|
|
1289
|
+
pageId: 'reports',
|
|
1290
|
+
steps: [{ select: { blockId: 'period', value: 'Month' } }],
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
expect(result.passed).toBe(true);
|
|
1294
|
+
expect(page.clicks).toEqual(['#bl-period label:has(input[type="radio"])']);
|
|
1295
|
+
expect(filters[0].hasText.test('Month')).toBe(true);
|
|
1296
|
+
expect(filters[0].hasText.test('Months')).toBe(false);
|
|
1297
|
+
expect(filters[1]).toEqual({ visible: true });
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
test('runJourney reports a radio option that is not in the block by its text', async () => {
|
|
1301
|
+
const page = createPage();
|
|
1302
|
+
openWith(page);
|
|
1303
|
+
page.locator.mockImplementation((selector) => {
|
|
1304
|
+
const locator = createLocator({ selector, page });
|
|
1305
|
+
locator.locator.mockImplementation((child) => {
|
|
1306
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
1307
|
+
if (child === 'label:has(input[type="radio"])') {
|
|
1308
|
+
inner.count.mockResolvedValue(3);
|
|
1309
|
+
inner.click.mockRejectedValue(new Error('locator.click: Timeout 5000ms exceeded.'));
|
|
1310
|
+
}
|
|
1311
|
+
return inner;
|
|
1312
|
+
});
|
|
1313
|
+
return locator;
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
const result = await runJourney({
|
|
1317
|
+
origin,
|
|
1318
|
+
pageId: 'reports',
|
|
1319
|
+
steps: [{ select: { blockId: 'period', value: 'Decade' } }],
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
expect(result.passed).toBe(false);
|
|
1323
|
+
expect(result.failure).toMatchObject({
|
|
1324
|
+
index: 0,
|
|
1325
|
+
expected: 'option "Decade" in block "period"',
|
|
1326
|
+
actual: 'locator.click: Timeout 5000ms exceeded.',
|
|
1327
|
+
message: 'No option with text "Decade" in block "period".',
|
|
1328
|
+
});
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
function withCheckboxOptions({ page, checked = false, waitError } = {}) {
|
|
1332
|
+
const filters = [];
|
|
1333
|
+
page.locator.mockImplementation((selector) => {
|
|
1334
|
+
const locator = createLocator({ selector, page });
|
|
1335
|
+
locator.locator.mockImplementation((child) => {
|
|
1336
|
+
const inner = createLocator({ selector: `${selector} ${child}`, page });
|
|
1337
|
+
if (child === 'label:has(input[type="checkbox"])') {
|
|
1338
|
+
inner.count.mockResolvedValue(3);
|
|
1339
|
+
inner.filter.mockImplementation((filter) => {
|
|
1340
|
+
filters.push(filter);
|
|
1341
|
+
return inner;
|
|
1342
|
+
});
|
|
1343
|
+
if (waitError) {
|
|
1344
|
+
inner.waitFor.mockRejectedValue(waitError);
|
|
1345
|
+
}
|
|
1346
|
+
inner.locator.mockImplementation((input) => {
|
|
1347
|
+
const box = createLocator({ selector: `${inner.selector} ${input}`, page });
|
|
1348
|
+
box.isChecked.mockResolvedValue(checked);
|
|
1349
|
+
return box;
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
return inner;
|
|
1353
|
+
});
|
|
1354
|
+
return locator;
|
|
1355
|
+
});
|
|
1356
|
+
return filters;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
test('runJourney ticks a checkbox option in the block by its exact label', async () => {
|
|
1360
|
+
const page = createPage();
|
|
1361
|
+
openWith(page);
|
|
1362
|
+
const filters = withCheckboxOptions({ page });
|
|
1363
|
+
|
|
1364
|
+
const result = await runJourney({
|
|
1365
|
+
origin,
|
|
1366
|
+
pageId: 'reports',
|
|
1367
|
+
steps: [{ select: { blockId: 'regions', value: 'North' } }],
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
expect(result.passed).toBe(true);
|
|
1371
|
+
expect(page.clicks).toEqual(['#bl-regions label:has(input[type="checkbox"])']);
|
|
1372
|
+
expect(filters[0].hasText.test('North')).toBe(true);
|
|
1373
|
+
expect(filters[0].hasText.test('North East')).toBe(false);
|
|
1374
|
+
expect(filters[1]).toEqual({ visible: true });
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test('runJourney leaves a checkbox option that is already ticked', async () => {
|
|
1378
|
+
const page = createPage();
|
|
1379
|
+
openWith(page);
|
|
1380
|
+
withCheckboxOptions({ page, checked: true });
|
|
1381
|
+
|
|
1382
|
+
const result = await runJourney({
|
|
1383
|
+
origin,
|
|
1384
|
+
pageId: 'reports',
|
|
1385
|
+
steps: [{ select: { blockId: 'regions', value: 'North' } }],
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
expect(result.passed).toBe(true);
|
|
1389
|
+
expect(page.clicks).toEqual([]);
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test('runJourney reports a checkbox option that is not in the block by its text', async () => {
|
|
1393
|
+
const page = createPage();
|
|
1394
|
+
openWith(page);
|
|
1395
|
+
withCheckboxOptions({
|
|
1396
|
+
page,
|
|
1397
|
+
waitError: new Error('locator.waitFor: Timeout 5000ms exceeded.'),
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
const result = await runJourney({
|
|
1401
|
+
origin,
|
|
1402
|
+
pageId: 'reports',
|
|
1403
|
+
steps: [{ select: { blockId: 'regions', value: 'West' } }],
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
expect(result.passed).toBe(false);
|
|
1407
|
+
expect(result.failure).toMatchObject({
|
|
1408
|
+
index: 0,
|
|
1409
|
+
expected: 'option "West" in block "regions"',
|
|
1410
|
+
actual: 'locator.waitFor: Timeout 5000ms exceeded.',
|
|
1411
|
+
message: 'No option with text "West" in block "regions".',
|
|
1412
|
+
});
|
|
1413
|
+
expect(page.clicks).toEqual([]);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
test('runJourney reports a dropdown option that never appears by its text', async () => {
|
|
1417
|
+
const page = createPage();
|
|
1418
|
+
openWith(page);
|
|
1419
|
+
page.locator.mockImplementation((selector) => {
|
|
1420
|
+
const locator = createLocator({ selector, page });
|
|
1421
|
+
if (selector.startsWith('.ant-select-item-option')) {
|
|
1422
|
+
locator.click.mockRejectedValue(new Error('locator.click: Timeout 5000ms exceeded.'));
|
|
1423
|
+
}
|
|
1424
|
+
return locator;
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
const result = await runJourney({
|
|
1428
|
+
origin,
|
|
1429
|
+
pageId: 'form',
|
|
1430
|
+
steps: [{ select: { blockId: 'country', value: 'Chi' } }],
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
expect(result.passed).toBe(false);
|
|
1434
|
+
expect(result.failure.expected).toEqual('option "Chi" in the dropdown of block "country"');
|
|
1435
|
+
expect(result.failure.actual).toEqual('locator.click: Timeout 5000ms exceeded.');
|
|
1436
|
+
expect(result.failure.message).toEqual(
|
|
1437
|
+
'No option with text "Chi" appeared in the dropdown of block "country".'
|
|
1438
|
+
);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
test('runJourney fails expect.text with the actual text when it does not contain the string', async () => {
|
|
1442
|
+
const page = createPage();
|
|
1443
|
+
page.texts['#bl-title'] = 'Hello world';
|
|
1444
|
+
openWith(page);
|
|
1445
|
+
const spy = advanceClockOnEveryRead();
|
|
1446
|
+
|
|
1447
|
+
const result = await runJourney({
|
|
1448
|
+
origin,
|
|
1449
|
+
pageId: 'form',
|
|
1450
|
+
steps: [
|
|
1451
|
+
{ expect: { text: { blockId: 'title', contains: 'Hello' } } },
|
|
1452
|
+
{ expect: { text: { blockId: 'title', contains: 'Goodbye' } } },
|
|
1453
|
+
],
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
spy.mockRestore();
|
|
1457
|
+
|
|
1458
|
+
expect(result.steps.map((step) => step.status)).toEqual(['ok', 'failed']);
|
|
1459
|
+
expect(result.failure.expected).toEqual('block "title" text to contain "Goodbye"');
|
|
1460
|
+
expect(result.failure.actual).toEqual('Hello world');
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
test('runJourney waits for expect.text until the block renders the text', async () => {
|
|
1464
|
+
const page = createPage();
|
|
1465
|
+
page.texts['#bl-members'] = '';
|
|
1466
|
+
let reads = 0;
|
|
1467
|
+
page.waitForTimeout = jest.fn(async () => {
|
|
1468
|
+
reads += 1;
|
|
1469
|
+
if (reads === 2) {
|
|
1470
|
+
page.texts['#bl-members'] = 'ada@example.test Owner';
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
openWith(page);
|
|
1474
|
+
|
|
1475
|
+
const result = await runJourney({
|
|
1476
|
+
origin,
|
|
1477
|
+
pageId: 'members',
|
|
1478
|
+
steps: [{ expect: { text: { blockId: 'members', contains: 'Owner' } } }],
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
expect(result.failure).toBeUndefined();
|
|
1482
|
+
expect(page.waitForTimeout).toHaveBeenCalledTimes(2);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
test('runJourney retries expect.text when the page navigates during a read', async () => {
|
|
1486
|
+
const page = createPage();
|
|
1487
|
+
page.texts['#bl-notice'] = "You're signed out";
|
|
1488
|
+
const locator = page.locator('#bl-notice');
|
|
1489
|
+
locator.allInnerTexts
|
|
1490
|
+
.mockRejectedValueOnce(
|
|
1491
|
+
new Error(
|
|
1492
|
+
'locator.allInnerTexts: Execution context was destroyed, most likely because of a navigation'
|
|
1493
|
+
)
|
|
1494
|
+
)
|
|
1495
|
+
.mockResolvedValue(["You're signed out"]);
|
|
1496
|
+
page.locator = jest.fn(() => locator);
|
|
1497
|
+
openWith(page);
|
|
1498
|
+
|
|
1499
|
+
const result = await runJourney({
|
|
1500
|
+
origin,
|
|
1501
|
+
pageId: 'logout',
|
|
1502
|
+
steps: [{ expect: { text: { blockId: 'notice', contains: 'signed out' } } }],
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
expect(result.failure).toBeUndefined();
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
test('runJourney checks expect.url against the page url', async () => {
|
|
1509
|
+
const page = createPage({ url: 'http://localhost:3227/detail?id=1' });
|
|
1510
|
+
openWith(page);
|
|
1511
|
+
|
|
1512
|
+
const result = await runJourney({
|
|
1513
|
+
origin,
|
|
1514
|
+
pageId: 'form',
|
|
1515
|
+
steps: [
|
|
1516
|
+
{ expect: { url: { contains: '/detail' } } },
|
|
1517
|
+
{ expect: { url: { contains: 'list' } } },
|
|
1518
|
+
],
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
expect(result.steps.map((step) => step.status)).toEqual(['ok', 'failed']);
|
|
1522
|
+
expect(result.failure.actual).toEqual('http://localhost:3227/detail?id=1');
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
test('runJourney goes back one page and settles it', async () => {
|
|
1526
|
+
const window = createLowdefyWindow({ pageId: 'detail', historyIndex: 1 });
|
|
1527
|
+
const page = createPage({ window });
|
|
1528
|
+
openWith(page);
|
|
1529
|
+
page.goBack.mockImplementation(async () => {
|
|
1530
|
+
window.lowdefy.pageId = 'form';
|
|
1531
|
+
window.lowdefy.contexts['page:form'] = window.lowdefy.contexts['page:detail'];
|
|
1532
|
+
return null;
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
const result = await runJourney({ origin, pageId: 'detail', steps: [{ back: true }] });
|
|
1536
|
+
|
|
1537
|
+
expect(result.passed).toBe(true);
|
|
1538
|
+
expect(page.goBack).toHaveBeenCalledWith({ timeout: 5000 });
|
|
1539
|
+
expect(page.waitForFunction).toHaveBeenLastCalledWith(expect.any(Function), undefined, {
|
|
1540
|
+
timeout: 5000,
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
test('runJourney fails back on the first page instead of leaving the app', async () => {
|
|
1545
|
+
const page = createPage({ window: createLowdefyWindow({ historyIndex: 0 }) });
|
|
1546
|
+
openWith(page);
|
|
1547
|
+
|
|
1548
|
+
const result = await runJourney({
|
|
1549
|
+
origin,
|
|
1550
|
+
pageId: 'form',
|
|
1551
|
+
steps: [{ back: null }, { expect: { url: { contains: '/form' } } }],
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
expect(result.passed).toBe(false);
|
|
1555
|
+
expect(page.goBack).not.toHaveBeenCalled();
|
|
1556
|
+
expect(result.failure).toMatchObject({
|
|
1557
|
+
index: 0,
|
|
1558
|
+
expected: 'the browser to go back one page',
|
|
1559
|
+
actual: 'no earlier page than http://localhost:3227/form',
|
|
1560
|
+
});
|
|
1561
|
+
expect(result.failure.message).toMatch(/There is no earlier page in this journey to go back to/);
|
|
1562
|
+
expect(result.steps.map((step) => step.status)).toEqual(['failed', 'skipped']);
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
test('runJourney checks expect.title by equals and contains', async () => {
|
|
1566
|
+
const page = createPage();
|
|
1567
|
+
page.documentTitle = 'Tasks | Acme';
|
|
1568
|
+
openWith(page);
|
|
1569
|
+
|
|
1570
|
+
const result = await runJourney({
|
|
1571
|
+
origin,
|
|
1572
|
+
pageId: 'form',
|
|
1573
|
+
steps: [
|
|
1574
|
+
{ expect: { title: { equals: 'Tasks | Acme' } } },
|
|
1575
|
+
{ expect: { title: { contains: 'Tasks' } } },
|
|
1576
|
+
],
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
expect(result.passed).toBe(true);
|
|
1580
|
+
});
|
|
1581
|
+
|
|
1582
|
+
test('runJourney fails expect.title with the last title seen', async () => {
|
|
1583
|
+
const page = createPage();
|
|
1584
|
+
page.documentTitle = 'Lowdefy';
|
|
1585
|
+
openWith(page);
|
|
1586
|
+
// Each read of the clock moves it on a second, so the 5s wait runs out.
|
|
1587
|
+
let clock = 0;
|
|
1588
|
+
const spy = jest.spyOn(Date, 'now').mockImplementation(() => {
|
|
1589
|
+
clock += 1000;
|
|
1590
|
+
return clock;
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
const result = await runJourney({
|
|
1594
|
+
origin,
|
|
1595
|
+
pageId: 'form',
|
|
1596
|
+
steps: [{ expect: { title: { equals: 'Tasks' } } }],
|
|
1597
|
+
});
|
|
1598
|
+
spy.mockRestore();
|
|
1599
|
+
|
|
1600
|
+
expect(result.passed).toBe(false);
|
|
1601
|
+
expect(result.failure).toMatchObject({
|
|
1602
|
+
expected: 'title to equal "Tasks"',
|
|
1603
|
+
actual: 'Lowdefy',
|
|
1604
|
+
message: 'Timed out after 5000ms waiting for title to equal "Tasks".',
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
test('runJourney returns only the state paths asked for, null where undefined', async () => {
|
|
1609
|
+
const page = createPage({
|
|
1610
|
+
window: createLowdefyWindow({ state: { form: { name: 'Ada' }, rows: [1, 2], big: 'x' } }),
|
|
1611
|
+
});
|
|
1612
|
+
openWith(page);
|
|
1613
|
+
|
|
1614
|
+
const result = await runJourney({
|
|
1615
|
+
origin,
|
|
1616
|
+
pageId: 'form',
|
|
1617
|
+
steps: [],
|
|
1618
|
+
state: ['form.name', 'rows', 'missing'],
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
expect(result.state).toEqual({ 'form.name': 'Ada', rows: [1, 2], missing: null });
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test('runJourney leaves the state out when state is false', async () => {
|
|
1625
|
+
const page = createPage({ window: createLowdefyWindow({ state: { a: 1 } }) });
|
|
1626
|
+
openWith(page);
|
|
1627
|
+
|
|
1628
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [], state: false });
|
|
1629
|
+
|
|
1630
|
+
expect(result.passed).toBe(true);
|
|
1631
|
+
expect('state' in result).toBe(false);
|
|
1632
|
+
expect('stateOmitted' in result).toBe(false);
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
test('runJourney summarises a large final state by default and returns it whole with state true', async () => {
|
|
1636
|
+
const state = { rows: 'x'.repeat(20000), period: 'Month' };
|
|
1637
|
+
const page = createPage({ window: createLowdefyWindow({ state }) });
|
|
1638
|
+
openWith(page);
|
|
1639
|
+
|
|
1640
|
+
const summarised = await runJourney({ origin, pageId: 'form', steps: [] });
|
|
1641
|
+
expect(summarised.state).toBeUndefined();
|
|
1642
|
+
expect(summarised.stateOmitted).toMatchObject({
|
|
1643
|
+
characters: JSON.stringify(state).length,
|
|
1644
|
+
keys: { rows: 20002, period: 7 },
|
|
1645
|
+
});
|
|
1646
|
+
expect(summarised.stateOmitted.note).toMatch(/Pass state: \["path", \.\.\.\]/);
|
|
1647
|
+
|
|
1648
|
+
const whole = await runJourney({ origin, pageId: 'form', steps: [], state: true });
|
|
1649
|
+
expect(whole.state).toEqual(state);
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
test('runJourney returns an error for a malformed state option before opening a browser', async () => {
|
|
1653
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [], state: 'all' });
|
|
1654
|
+
expect(result.error).toMatch(/The "state" option must be true, false or an array of state paths/);
|
|
1655
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
test('runJourney waits for a request to finish loading and for a state path to be defined', async () => {
|
|
1659
|
+
const window = createLowdefyWindow({
|
|
1660
|
+
state: {},
|
|
1661
|
+
requests: { get_rows: [{ loading: true }] },
|
|
1662
|
+
});
|
|
1663
|
+
const page = createPage({ window });
|
|
1664
|
+
// The page calls get_rows as it opens, inside the first step's window.
|
|
1665
|
+
openActorsWithNetwork([
|
|
1666
|
+
{ page, opening: [{ url: `${origin}/api/request/form/get_rows`, method: 'POST' }] },
|
|
1667
|
+
]);
|
|
1668
|
+
page.waitForTimeout.mockImplementation(async () => {
|
|
1669
|
+
window.lowdefy.contexts['page:form'].requests.get_rows[0] = { loading: false, response: [] };
|
|
1670
|
+
window.lowdefy.contexts['page:form'].state.rows = [];
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
const result = await runJourney({
|
|
1674
|
+
origin,
|
|
1675
|
+
pageId: 'form',
|
|
1676
|
+
steps: [{ wait: { request: 'get_rows' } }, { wait: { state: 'rows' } }, { wait: { ms: 10 } }],
|
|
1677
|
+
readConfigFile: async (name) => (name === 'plugins/requestSchemas.json' ? {} : null),
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
expect(result.passed).toBe(true);
|
|
1681
|
+
expect(page.waitForTimeout).toHaveBeenCalledWith(10);
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
test('runJourney fails a wait that never settles with the last value seen', async () => {
|
|
1685
|
+
const page = createPage({
|
|
1686
|
+
window: createLowdefyWindow({ requests: { get_rows: [{ loading: true }] } }),
|
|
1687
|
+
});
|
|
1688
|
+
openActorsWithNetwork([
|
|
1689
|
+
{ page, opening: [{ url: `${origin}/api/request/form/get_rows`, method: 'POST' }] },
|
|
1690
|
+
]);
|
|
1691
|
+
|
|
1692
|
+
const result = await runJourney({
|
|
1693
|
+
origin,
|
|
1694
|
+
pageId: 'form',
|
|
1695
|
+
steps: [{ wait: { request: 'get_rows' } }],
|
|
1696
|
+
stepTimeout: 20,
|
|
1697
|
+
readConfigFile: async (name) => (name === 'plugins/requestSchemas.json' ? {} : null),
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
expect(result.passed).toBe(false);
|
|
1701
|
+
expect(result.failure.expected).toEqual(
|
|
1702
|
+
'request "get_rows" to have been called since the last interaction and finished loading'
|
|
1703
|
+
);
|
|
1704
|
+
expect(result.failure.actual).toEqual({
|
|
1705
|
+
calledSinceLastInteraction: true,
|
|
1706
|
+
state: { loading: true },
|
|
1707
|
+
});
|
|
1708
|
+
expect(result.failure.message).toMatch(/Timed out after 20ms/);
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
test('runJourney settles the page after an interaction before the next step', async () => {
|
|
1712
|
+
const page = createPage();
|
|
1713
|
+
openWith(page);
|
|
1714
|
+
|
|
1715
|
+
await runJourney({
|
|
1716
|
+
origin,
|
|
1717
|
+
pageId: 'form',
|
|
1718
|
+
steps: [{ click: 'open' }, { expect: { visible: 'modal' } }],
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
// openPage owns the readiness wait; the runner waits for lazy blocks before
|
|
1722
|
+
// the click picks a control, and settles once after the click.
|
|
1723
|
+
expect(page.waitForFunction).toHaveBeenCalledTimes(2);
|
|
1724
|
+
expect(page.waitForFunction.mock.calls[1][1]).toBeUndefined();
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test('runJourney waits for lazy blocks to load before it picks the control a click lands on', async () => {
|
|
1728
|
+
const window = createLowdefyWindow();
|
|
1729
|
+
window.__lowdefyLazyLoads = 1;
|
|
1730
|
+
const page = createPage({ window });
|
|
1731
|
+
openWith(page);
|
|
1732
|
+
|
|
1733
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ click: 'table' }] });
|
|
1734
|
+
|
|
1735
|
+
expect(result.passed).toBe(false);
|
|
1736
|
+
expect(result.failure).toMatchObject({
|
|
1737
|
+
index: 0,
|
|
1738
|
+
message: 'A block was still loading after 5000 ms.',
|
|
1739
|
+
expected:
|
|
1740
|
+
'every lazy block on the page to have loaded and every loading skeleton to have cleared',
|
|
1741
|
+
});
|
|
1742
|
+
expect(page.clicks).toEqual([]);
|
|
1743
|
+
|
|
1744
|
+
window.__lowdefyLazyLoads = 0;
|
|
1745
|
+
const loaded = await runJourney({ origin, pageId: 'form', steps: [{ click: 'table' }] });
|
|
1746
|
+
expect(loaded.passed).toBe(true);
|
|
1747
|
+
expect(page.clicks).toEqual(['#bl-table']);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
test('runJourney waits for a table holding its loading skeleton before it picks the control a click lands on', async () => {
|
|
1751
|
+
const window = createLowdefyWindow();
|
|
1752
|
+
window.__lowdefyLazyLoads = 0;
|
|
1753
|
+
window.__lowdefySkeletonHolds = 1;
|
|
1754
|
+
const page = createPage({ window });
|
|
1755
|
+
openWith(page);
|
|
1756
|
+
|
|
1757
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ click: 'table' }] });
|
|
1758
|
+
|
|
1759
|
+
expect(result.passed).toBe(false);
|
|
1760
|
+
expect(result.failure).toMatchObject({
|
|
1761
|
+
index: 0,
|
|
1762
|
+
message: 'A block was still loading after 5000 ms.',
|
|
1763
|
+
expected:
|
|
1764
|
+
'every lazy block on the page to have loaded and every loading skeleton to have cleared',
|
|
1765
|
+
});
|
|
1766
|
+
expect(page.clicks).toEqual([]);
|
|
1767
|
+
|
|
1768
|
+
window.__lowdefySkeletonHolds = 0;
|
|
1769
|
+
const cleared = await runJourney({ origin, pageId: 'form', steps: [{ click: 'table' }] });
|
|
1770
|
+
expect(cleared.passed).toBe(true);
|
|
1771
|
+
expect(page.clicks).toEqual(['#bl-table']);
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
test('runJourney opens pages with the journey timeout when it is longer than 15000 ms', async () => {
|
|
1775
|
+
const page = createPage();
|
|
1776
|
+
openWith(page);
|
|
1777
|
+
|
|
1778
|
+
await runJourney({ origin, pageId: 'form', steps: [{ goto: 'form' }], stepTimeout: 30000 });
|
|
1779
|
+
await runJourney({ origin, pageId: 'form', steps: [], stepTimeout: 20 });
|
|
1780
|
+
|
|
1781
|
+
expect(mockOpenPage.mock.calls.map(([args]) => args.timeout)).toEqual([30000, 15000]);
|
|
1782
|
+
expect(page.goto).toHaveBeenCalledWith('http://localhost:3227/form', {
|
|
1783
|
+
waitUntil: 'load',
|
|
1784
|
+
timeout: 30000,
|
|
1785
|
+
});
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
test('runJourney returns an error for a timeout outside 1 to 60000 ms before opening a browser', async () => {
|
|
1789
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [], stepTimeout: 0 });
|
|
1790
|
+
|
|
1791
|
+
expect(result.error).toMatch(/"timeout" must be a whole number of milliseconds from 1 to 60000/);
|
|
1792
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
test('runJourney reports an unsettled page open with ready: false and a note', async () => {
|
|
1796
|
+
const page = createPage();
|
|
1797
|
+
openWith(page, { ready: false });
|
|
1798
|
+
|
|
1799
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [] });
|
|
1800
|
+
|
|
1801
|
+
expect(result.passed).toBe(true);
|
|
1802
|
+
expect(result.ready).toBe(false);
|
|
1803
|
+
expect(result.note).toMatch(/15000ms/);
|
|
1804
|
+
});
|
|
1805
|
+
|
|
1806
|
+
test('runJourney returns an error and closes the context when the page fails to open', async () => {
|
|
1807
|
+
mockOpenPage.mockRejectedValue(new Error('net::ERR_CONNECTION_REFUSED'));
|
|
1808
|
+
|
|
1809
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ click: 'x' }] });
|
|
1810
|
+
|
|
1811
|
+
expect(result.error).toEqual(
|
|
1812
|
+
'Failed to run journey at "http://localhost:3227/form": net::ERR_CONNECTION_REFUSED'
|
|
1813
|
+
);
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
test('runJourney opens each actor the first time an as step names it and returns to earlier actors', async () => {
|
|
1817
|
+
const mainPage = createPage();
|
|
1818
|
+
const inviteePage = createPage();
|
|
1819
|
+
const mainContext = { close: jest.fn(async () => {}) };
|
|
1820
|
+
const inviteeContext = { close: jest.fn(async () => {}) };
|
|
1821
|
+
mockOpenPage
|
|
1822
|
+
.mockResolvedValueOnce({ context: mainContext, page: mainPage, ready: true, leftOrigin: [] })
|
|
1823
|
+
.mockResolvedValueOnce({
|
|
1824
|
+
context: inviteeContext,
|
|
1825
|
+
page: inviteePage,
|
|
1826
|
+
ready: true,
|
|
1827
|
+
leftOrigin: [],
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
const result = await runJourney({
|
|
1831
|
+
origin,
|
|
1832
|
+
pageId: 'signup',
|
|
1833
|
+
user: 'none',
|
|
1834
|
+
steps: [
|
|
1835
|
+
{ click: 'invite' },
|
|
1836
|
+
{ as: 'invitee' },
|
|
1837
|
+
{ click: 'accept' },
|
|
1838
|
+
{ as: 'main' },
|
|
1839
|
+
{ click: 'members' },
|
|
1840
|
+
{ as: 'invitee' },
|
|
1841
|
+
{ click: 'dashboard' },
|
|
1842
|
+
],
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
expect(result.passed).toBe(true);
|
|
1846
|
+
expect(mainPage.clicks).toEqual(['#bl-invite', '#bl-members']);
|
|
1847
|
+
expect(inviteePage.clicks).toEqual(['#bl-accept', '#bl-dashboard']);
|
|
1848
|
+
expect(mockOpenPage).toHaveBeenCalledTimes(2);
|
|
1849
|
+
const [mainOpen, inviteeOpen] = mockOpenPage.mock.calls.map(([args]) => args);
|
|
1850
|
+
expect(inviteeOpen).toEqual({
|
|
1851
|
+
...mainOpen,
|
|
1852
|
+
clientAddress: inviteeOpen.clientAddress,
|
|
1853
|
+
onContext: inviteeOpen.onContext,
|
|
1854
|
+
recording: { ...mainOpen.recording, run: { ...mainOpen.recording.run, actor: 'invitee' } },
|
|
1855
|
+
});
|
|
1856
|
+
expect(inviteeOpen).toMatchObject({ pageId: 'signup', user: 'none' });
|
|
1857
|
+
// Two people, two clients: rate limits count each actor's attempts apart.
|
|
1858
|
+
expect(inviteeOpen.clientAddress).not.toEqual(mainOpen.clientAddress);
|
|
1859
|
+
expect(mainContext.close).toHaveBeenCalledTimes(1);
|
|
1860
|
+
expect(inviteeContext.close).toHaveBeenCalledTimes(1);
|
|
1861
|
+
});
|
|
1862
|
+
|
|
1863
|
+
test('runJourney with four actors takes one machine browser slot for the whole journey', async () => {
|
|
1864
|
+
const sharedHome = process.env.LOWDEFY_HOME;
|
|
1865
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'lowdefy-journey-slot-'));
|
|
1866
|
+
process.env.LOWDEFY_HOME = home;
|
|
1867
|
+
const slots = path.join(home, 'slots', 'browser');
|
|
1868
|
+
// Two of the three slots are held elsewhere on the machine: one is left.
|
|
1869
|
+
const others = [await acquireMachineSlot({ name: 'browser', limit: 3 })];
|
|
1870
|
+
others.push(await acquireMachineSlot({ name: 'browser', limit: 3 }));
|
|
1871
|
+
const heldDuringJourney = [];
|
|
1872
|
+
const pages = [createPage(), createPage(), createPage(), createPage()];
|
|
1873
|
+
pages.forEach((page) => {
|
|
1874
|
+
mockOpenPage.mockImplementationOnce(async () => {
|
|
1875
|
+
heldDuringJourney.push(fs.readdirSync(slots).length);
|
|
1876
|
+
return { context: { close: jest.fn(async () => {}) }, page, ready: true, leftOrigin: [] };
|
|
1877
|
+
});
|
|
1878
|
+
});
|
|
1879
|
+
try {
|
|
1880
|
+
const result = await runJourney({
|
|
1881
|
+
origin,
|
|
1882
|
+
pageId: 'signup',
|
|
1883
|
+
user: 'none',
|
|
1884
|
+
steps: [{ as: 'a' }, { as: 'b' }, { as: 'c' }, { as: 'main' }],
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
expect(result.passed).toBe(true);
|
|
1888
|
+
expect(mockOpenPage).toHaveBeenCalledTimes(4);
|
|
1889
|
+
expect(heldDuringJourney).toEqual([3, 3, 3, 3]);
|
|
1890
|
+
expect(fs.readdirSync(slots)).toHaveLength(2);
|
|
1891
|
+
} finally {
|
|
1892
|
+
others.forEach((slot) => slot.release());
|
|
1893
|
+
process.env.LOWDEFY_HOME = sharedHome;
|
|
1894
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
test('runJourney fails an as step whose actor cannot open and still closes every actor', async () => {
|
|
1899
|
+
const mainContext = { close: jest.fn(async () => {}) };
|
|
1900
|
+
mockOpenPage
|
|
1901
|
+
.mockResolvedValueOnce({
|
|
1902
|
+
context: mainContext,
|
|
1903
|
+
page: createPage(),
|
|
1904
|
+
ready: true,
|
|
1905
|
+
leftOrigin: [],
|
|
1906
|
+
})
|
|
1907
|
+
.mockRejectedValueOnce(new Error('net::ERR_CONNECTION_REFUSED'));
|
|
1908
|
+
|
|
1909
|
+
const result = await runJourney({
|
|
1910
|
+
origin,
|
|
1911
|
+
pageId: 'login',
|
|
1912
|
+
steps: [{ as: 'member' }, { click: 'logout' }],
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
expect(result.passed).toBe(false);
|
|
1916
|
+
expect(result.failure).toMatchObject({
|
|
1917
|
+
index: 0,
|
|
1918
|
+
step: { as: 'member' },
|
|
1919
|
+
actual: 'net::ERR_CONNECTION_REFUSED',
|
|
1920
|
+
});
|
|
1921
|
+
expect(result.steps.map((step) => step.status)).toEqual(['failed', 'skipped']);
|
|
1922
|
+
expect(mainContext.close).toHaveBeenCalledTimes(1);
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
test('runJourney goto loads the page and settles on the page the app shows', async () => {
|
|
1926
|
+
const window = createLowdefyWindow({ pageId: 'login' });
|
|
1927
|
+
const page = createPage({ window });
|
|
1928
|
+
openWith(page);
|
|
1929
|
+
|
|
1930
|
+
const result = await runJourney({
|
|
1931
|
+
origin,
|
|
1932
|
+
pageId: 'login',
|
|
1933
|
+
steps: [{ goto: 'dashboard' }, { goto: { pageId: 'invoice', urlQuery: { id: 'i1' } } }],
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
expect(result.passed).toBe(true);
|
|
1937
|
+
expect(page.goto.mock.calls).toEqual([
|
|
1938
|
+
['http://localhost:3227/dashboard', { waitUntil: 'load', timeout: 15000 }],
|
|
1939
|
+
['http://localhost:3227/invoice', { waitUntil: 'load', timeout: 15000 }],
|
|
1940
|
+
]);
|
|
1941
|
+
// No argument: isPageReady settles the page the app shows, which after a
|
|
1942
|
+
// redirect is the sign-in page rather than the one asked for.
|
|
1943
|
+
expect(page.waitForFunction.mock.calls.map((call) => call[1])).toEqual([undefined, undefined]);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
const ROUTES = { ticket: 'tickets/{space}/{ticket_id}' };
|
|
1947
|
+
|
|
1948
|
+
test('runJourney opens a patterned page at the path its pathParams build', async () => {
|
|
1949
|
+
mockReadPagePath.mockImplementation(({ pageId }) => ROUTES[pageId]);
|
|
1950
|
+
const page = createPage();
|
|
1951
|
+
openWith(page);
|
|
1952
|
+
|
|
1953
|
+
const result = await runJourney({
|
|
1954
|
+
origin,
|
|
1955
|
+
pageId: 'ticket',
|
|
1956
|
+
pathParams: { space: 's', ticket_id: '1' },
|
|
1957
|
+
steps: [],
|
|
1958
|
+
});
|
|
1959
|
+
|
|
1960
|
+
expect(result.passed).toBe(true);
|
|
1961
|
+
expect(mockOpenPage).toHaveBeenCalledWith(
|
|
1962
|
+
expect.objectContaining({
|
|
1963
|
+
pageId: 'ticket',
|
|
1964
|
+
path: 'tickets/{space}/{ticket_id}',
|
|
1965
|
+
pathParams: { space: 's', ticket_id: '1' },
|
|
1966
|
+
})
|
|
1967
|
+
);
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
test('runJourney refuses a patterned page missing a path value before any browser opens', async () => {
|
|
1971
|
+
mockReadPagePath.mockImplementation(({ pageId }) => ROUTES[pageId]);
|
|
1972
|
+
|
|
1973
|
+
const result = await runJourney({
|
|
1974
|
+
origin,
|
|
1975
|
+
pageId: 'ticket',
|
|
1976
|
+
pathParams: { space: 's' },
|
|
1977
|
+
steps: [],
|
|
1978
|
+
});
|
|
1979
|
+
|
|
1980
|
+
expect(result).toEqual({
|
|
1981
|
+
error: 'Link to page "ticket" is missing a value for path placeholder "ticket_id".',
|
|
1982
|
+
refused: true,
|
|
1983
|
+
});
|
|
1984
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
1985
|
+
});
|
|
1986
|
+
|
|
1987
|
+
test('runJourney refuses pathParams that are not an object of strings', async () => {
|
|
1988
|
+
const result = await runJourney({
|
|
1989
|
+
origin,
|
|
1990
|
+
pageId: 'ticket',
|
|
1991
|
+
pathParams: { ticket_id: 1 },
|
|
1992
|
+
steps: [],
|
|
1993
|
+
});
|
|
1994
|
+
expect(result.error).toBe(
|
|
1995
|
+
'runJourney requires "pathParams" to be an object of strings, one per path placeholder. Received {"ticket_id":1}.'
|
|
1996
|
+
);
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
test('runJourney goto builds a patterned page path from pathParams, and fails the step without one', async () => {
|
|
2000
|
+
mockReadPagePath.mockImplementation(({ pageId }) => ROUTES[pageId]);
|
|
2001
|
+
const page = createPage();
|
|
2002
|
+
openWith(page);
|
|
2003
|
+
|
|
2004
|
+
const result = await runJourney({
|
|
2005
|
+
origin,
|
|
2006
|
+
pageId: 'form',
|
|
2007
|
+
steps: [
|
|
2008
|
+
{ goto: { pageId: 'ticket', pathParams: { space: 's', ticket_id: '1' } } },
|
|
2009
|
+
{ goto: { pageId: 'ticket', pathParams: { space: 's' } } },
|
|
2010
|
+
],
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
expect(page.goto.mock.calls.map(([url]) => url)).toEqual(['http://localhost:3227/tickets/s/1']);
|
|
2014
|
+
expect(result.passed).toBe(false);
|
|
2015
|
+
expect(result.failure).toMatchObject({
|
|
2016
|
+
message:
|
|
2017
|
+
'Could not open page "ticket": Link to page "ticket" is missing a value for path placeholder "ticket_id".',
|
|
2018
|
+
});
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
test('runJourney reports a goto that fails to load', async () => {
|
|
2022
|
+
const page = createPage();
|
|
2023
|
+
openWith(page);
|
|
2024
|
+
page.goto.mockRejectedValue(new Error('page.goto: net::ERR_ABORTED'));
|
|
2025
|
+
|
|
2026
|
+
const result = await runJourney({ origin, pageId: 'form', steps: [{ goto: 'report' }] });
|
|
2027
|
+
|
|
2028
|
+
expect(result.passed).toBe(false);
|
|
2029
|
+
expect(result.failure).toMatchObject({
|
|
2030
|
+
expected: 'page "report" to load',
|
|
2031
|
+
actual: 'page.goto: net::ERR_ABORTED',
|
|
2032
|
+
message: 'Could not open page "report": page.goto: net::ERR_ABORTED',
|
|
2033
|
+
});
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
test.each([
|
|
2037
|
+
['an email step', { email: { to: 'ada@example.test' } }],
|
|
2038
|
+
[
|
|
2039
|
+
'a fill from an email',
|
|
2040
|
+
{ fill: { blockId: 'otp', fromEmail: { to: 'ada@example.test', match: '\\d{6}' } } },
|
|
2041
|
+
],
|
|
2042
|
+
])(
|
|
2043
|
+
'runJourney refuses %s before opening a browser when no mail sink listens, even with the port set',
|
|
2044
|
+
async (_, step) => {
|
|
2045
|
+
// A port added to .env after start: the child sees it, but no sink started.
|
|
2046
|
+
process.env.LOWDEFY_DEV_SMTP_PORT = '2525';
|
|
2047
|
+
try {
|
|
2048
|
+
const result = await runJourney({
|
|
2049
|
+
origin,
|
|
2050
|
+
pageId: 'signup',
|
|
2051
|
+
user: 'none',
|
|
2052
|
+
steps: [step],
|
|
2053
|
+
});
|
|
2054
|
+
|
|
2055
|
+
expect(result.error).toMatch(
|
|
2056
|
+
/captures no mail. Start \(or restart\) it with LOWDEFY_DEV_SMTP_PORT/
|
|
2057
|
+
);
|
|
2058
|
+
expect(mockGetBrowser).not.toHaveBeenCalled();
|
|
2059
|
+
} finally {
|
|
2060
|
+
delete process.env.LOWDEFY_DEV_SMTP_PORT;
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
);
|
|
2064
|
+
|
|
2065
|
+
test('runJourney fills the text read from an email into the block, staying on the page', async () => {
|
|
2066
|
+
process.env.LOWDEFY_SERVER_DEV_MAIL_SINK = 'true';
|
|
2067
|
+
process.env.LOWDEFY_DIRECTORY_CONFIG = '/apps/tenant';
|
|
2068
|
+
const page = createPage();
|
|
2069
|
+
openWith(page);
|
|
2070
|
+
mockReadJourneyEmailMatch.mockResolvedValue('482913');
|
|
2071
|
+
const fromEmail = { to: 'ada@example.test', subject: 'Your sign-in link', match: '\\d{6}' };
|
|
2072
|
+
try {
|
|
2073
|
+
const result = await runJourney({
|
|
2074
|
+
origin,
|
|
2075
|
+
pageId: 'login',
|
|
2076
|
+
user: 'none',
|
|
2077
|
+
steps: [{ fill: { blockId: 'otp', fromEmail } }],
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
expect(result.failure).toBeUndefined();
|
|
2081
|
+
expect(mockReadJourneyEmailMatch).toHaveBeenCalledWith({
|
|
2082
|
+
page,
|
|
2083
|
+
params: fromEmail,
|
|
2084
|
+
since: expect.any(Number),
|
|
2085
|
+
configDirectory: '/apps/tenant',
|
|
2086
|
+
timeout: 5000,
|
|
2087
|
+
});
|
|
2088
|
+
expect(page.fills).toEqual([{ selector: '#bl-otp input, textarea', value: '482913' }]);
|
|
2089
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
2090
|
+
} finally {
|
|
2091
|
+
delete process.env.LOWDEFY_SERVER_DEV_MAIL_SINK;
|
|
2092
|
+
delete process.env.LOWDEFY_DIRECTORY_CONFIG;
|
|
2093
|
+
}
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
test('runJourney hands an email step the current tab, the journey start and the config directory', async () => {
|
|
2097
|
+
process.env.LOWDEFY_SERVER_DEV_MAIL_SINK = 'true';
|
|
2098
|
+
process.env.LOWDEFY_DIRECTORY_CONFIG = '/apps/tenant';
|
|
2099
|
+
const page = createPage();
|
|
2100
|
+
openWith(page);
|
|
2101
|
+
const before = Date.now();
|
|
2102
|
+
try {
|
|
2103
|
+
const result = await runJourney({
|
|
2104
|
+
origin,
|
|
2105
|
+
pageId: 'signup',
|
|
2106
|
+
user: 'none',
|
|
2107
|
+
steps: [{ email: { to: 'ada@example.test', subject: 'Verify' } }],
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
expect(result.failure).toBeUndefined();
|
|
2111
|
+
expect(mockOpenJourneyEmail).toHaveBeenCalledWith({
|
|
2112
|
+
page,
|
|
2113
|
+
params: { to: 'ada@example.test', subject: 'Verify' },
|
|
2114
|
+
since: expect.any(Number),
|
|
2115
|
+
configDirectory: '/apps/tenant',
|
|
2116
|
+
timeout: 5000,
|
|
2117
|
+
});
|
|
2118
|
+
expect(mockOpenJourneyEmail.mock.calls[0][0].since).toBeGreaterThanOrEqual(before);
|
|
2119
|
+
} finally {
|
|
2120
|
+
delete process.env.LOWDEFY_SERVER_DEV_MAIL_SINK;
|
|
2121
|
+
delete process.env.LOWDEFY_DIRECTORY_CONFIG;
|
|
2122
|
+
}
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
test.each([
|
|
2126
|
+
[
|
|
2127
|
+
'inside a block',
|
|
2128
|
+
{ blockId: 'members_list', containing: 'ada@example.test' },
|
|
2129
|
+
'#bl-members_list lowdefy-shown-text="ada@example.test"',
|
|
2130
|
+
],
|
|
2131
|
+
['on the whole page', { containing: 'Invitation sent' }, 'lowdefy-shown-text="Invitation sent"'],
|
|
2132
|
+
])(
|
|
2133
|
+
'runJourney clicks the element showing the text %s, not the first control in it',
|
|
2134
|
+
async (_, target, selector) => {
|
|
2135
|
+
const page = createPage();
|
|
2136
|
+
openWith(page);
|
|
2137
|
+
|
|
2138
|
+
const result = await runJourney({ origin, pageId: 'members', steps: [{ click: target }] });
|
|
2139
|
+
|
|
2140
|
+
expect(result.passed).toBe(true);
|
|
2141
|
+
expect(page.clicks).toEqual([selector]);
|
|
2142
|
+
}
|
|
2143
|
+
);
|
|
2144
|
+
|
|
2145
|
+
function createFakeRequest({ url, method = 'GET' }) {
|
|
2146
|
+
return { url: () => url, method: () => method };
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// openPage hands the journey its browser context through onContext before the
|
|
2150
|
+
// first navigation; the fake context keeps the request listener so the test
|
|
2151
|
+
// can play the requests a real context would emit.
|
|
2152
|
+
function openActorsWithNetwork(actors) {
|
|
2153
|
+
const listeners = [];
|
|
2154
|
+
actors.forEach(({ page, opening }) => {
|
|
2155
|
+
mockOpenPage.mockImplementationOnce(async ({ onContext }) => {
|
|
2156
|
+
// The network counter and the app error watch both listen for requests.
|
|
2157
|
+
const requestCallbacks = [];
|
|
2158
|
+
const listener = (request) => requestCallbacks.forEach((callback) => callback(request));
|
|
2159
|
+
const context = {
|
|
2160
|
+
close: jest.fn(async () => {}),
|
|
2161
|
+
exposeBinding: jest.fn(async () => {}),
|
|
2162
|
+
on: jest.fn((event, callback) => {
|
|
2163
|
+
if (event === 'request') requestCallbacks.push(callback);
|
|
2164
|
+
}),
|
|
2165
|
+
};
|
|
2166
|
+
await onContext(context);
|
|
2167
|
+
listeners.push(listener);
|
|
2168
|
+
opening.forEach((request) => listener(createFakeRequest(request)));
|
|
2169
|
+
return { context, page, ready: true, url: page.url(), leftOrigin: [] };
|
|
2170
|
+
});
|
|
2171
|
+
});
|
|
2172
|
+
return listeners;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
test('runJourney reports the exercised network path merged across actors, with nested endpoints and writes', async () => {
|
|
2176
|
+
const mainPage = createPage();
|
|
2177
|
+
const memberPage = createPage();
|
|
2178
|
+
const listeners = openActorsWithNetwork([
|
|
2179
|
+
{
|
|
2180
|
+
page: mainPage,
|
|
2181
|
+
opening: [{ url: `${origin}/api/root` }, { url: `${origin}/api/page/tickets` }],
|
|
2182
|
+
},
|
|
2183
|
+
{
|
|
2184
|
+
page: memberPage,
|
|
2185
|
+
opening: [
|
|
2186
|
+
{ url: `${origin}/api/page/tickets/s/2` },
|
|
2187
|
+
{ url: `${origin}/api/page/tickets/s/5` },
|
|
2188
|
+
{ url: `${origin}/api/request/tickets/assign`, method: 'POST' },
|
|
2189
|
+
// Another origin, and a route the counter does not track.
|
|
2190
|
+
{ url: 'http://elsewhere.test/api/page/other' },
|
|
2191
|
+
{ url: `${origin}/api/reload` },
|
|
2192
|
+
],
|
|
2193
|
+
},
|
|
2194
|
+
]);
|
|
2195
|
+
const artifacts = {
|
|
2196
|
+
'routes.json': [
|
|
2197
|
+
{ pageId: 'tickets', path: 'tickets' },
|
|
2198
|
+
{ pageId: 'ticket', path: 'tickets/{space}/{ticket_id}' },
|
|
2199
|
+
],
|
|
2200
|
+
'plugins/requestSchemas.json': {
|
|
2201
|
+
MongoDBFind: { meta: { checkWrite: false } },
|
|
2202
|
+
MongoDBInsertOne: { meta: { checkWrite: true } },
|
|
2203
|
+
},
|
|
2204
|
+
'pages/tickets/requests/get_tickets.json': { type: 'MongoDBFind' },
|
|
2205
|
+
'pages/tickets/requests/assign.json': { type: 'MongoDBInsertOne' },
|
|
2206
|
+
'api/notify.json': {
|
|
2207
|
+
routine: [
|
|
2208
|
+
{
|
|
2209
|
+
id: 'endpoint:notify:log',
|
|
2210
|
+
stepId: 'log',
|
|
2211
|
+
type: 'CallApi',
|
|
2212
|
+
properties: { endpointId: 'log' },
|
|
2213
|
+
},
|
|
2214
|
+
],
|
|
2215
|
+
},
|
|
2216
|
+
'api/log.json': {
|
|
2217
|
+
routine: [{ id: 'endpoint:log:insert', stepId: 'insert', type: 'MongoDBInsertOne' }],
|
|
2218
|
+
},
|
|
2219
|
+
};
|
|
2220
|
+
mainPage.locator.mockImplementation((selector) => {
|
|
2221
|
+
const locator = createLocator({ selector, page: mainPage });
|
|
2222
|
+
locator.click = jest.fn(async () => {
|
|
2223
|
+
listeners[0](
|
|
2224
|
+
createFakeRequest({ url: `${origin}/api/request/tickets/assign`, method: 'POST' })
|
|
2225
|
+
);
|
|
2226
|
+
listeners[0](createFakeRequest({ url: `${origin}/api/endpoints/notify`, method: 'POST' }));
|
|
2227
|
+
});
|
|
2228
|
+
return locator;
|
|
2229
|
+
});
|
|
2230
|
+
const result = await runJourney({
|
|
2231
|
+
origin,
|
|
2232
|
+
pageId: 'tickets',
|
|
2233
|
+
steps: [{ click: 'assign' }, { as: 'member' }],
|
|
2234
|
+
readConfigFile: async (name) => artifacts[name] ?? null,
|
|
2235
|
+
});
|
|
2236
|
+
expect(result.passed).toBe(true);
|
|
2237
|
+
expect(result.exercised).toEqual({
|
|
2238
|
+
pages: ['ticket', 'tickets'],
|
|
2239
|
+
appEvents: true,
|
|
2240
|
+
requests: [{ pageId: 'tickets', requestId: 'assign', calls: 2, write: true }],
|
|
2241
|
+
endpoints: [
|
|
2242
|
+
{ endpointId: 'notify', calls: 1, write: true },
|
|
2243
|
+
{ endpointId: 'log', via: 'notify', calls: null, write: true },
|
|
2244
|
+
],
|
|
2245
|
+
unfollowed: 0,
|
|
2246
|
+
events: [],
|
|
2247
|
+
rendered: {},
|
|
2248
|
+
});
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
test('runJourney reports the events and blocks its pages observed, deduplicated with list indices as $', async () => {
|
|
2252
|
+
const page = createPage();
|
|
2253
|
+
let observe;
|
|
2254
|
+
mockOpenPage.mockImplementationOnce(async ({ onContext }) => {
|
|
2255
|
+
const context = {
|
|
2256
|
+
close: jest.fn(async () => {}),
|
|
2257
|
+
exposeBinding: jest.fn(async (name, callback) => {
|
|
2258
|
+
expect(name).toBe('__lowdefyJourneyObserve');
|
|
2259
|
+
observe = callback;
|
|
2260
|
+
}),
|
|
2261
|
+
on: jest.fn(),
|
|
2262
|
+
};
|
|
2263
|
+
await onContext(context);
|
|
2264
|
+
return { context, page, ready: true, url: page.url(), leftOrigin: [] };
|
|
2265
|
+
});
|
|
2266
|
+
page.locator.mockImplementation((selector) => {
|
|
2267
|
+
const locator = createLocator({ selector, page });
|
|
2268
|
+
locator.click = jest.fn(async () => {
|
|
2269
|
+
const event = {
|
|
2270
|
+
kind: 'event',
|
|
2271
|
+
scope: 'page',
|
|
2272
|
+
pageId: 'form',
|
|
2273
|
+
blockId: 'rows.1.label',
|
|
2274
|
+
eventName: 'onChange',
|
|
2275
|
+
actionIds: ['set_label'],
|
|
2276
|
+
};
|
|
2277
|
+
observe({}, event);
|
|
2278
|
+
observe({}, { ...event, blockId: 'rows.0.label' });
|
|
2279
|
+
observe({}, { kind: 'rendered', pageId: 'form', blockIds: ['rows.0.label', 'save'] });
|
|
2280
|
+
// Page JavaScript can send anything: malformed messages are dropped.
|
|
2281
|
+
observe({}, { kind: 'event', scope: 'page', pageId: 'form' });
|
|
2282
|
+
observe({}, 'nonsense');
|
|
2283
|
+
});
|
|
2284
|
+
return locator;
|
|
2285
|
+
});
|
|
2286
|
+
const result = await runJourney({
|
|
2287
|
+
origin,
|
|
2288
|
+
pageId: 'form',
|
|
2289
|
+
steps: [{ click: 'save' }],
|
|
2290
|
+
readConfigFile: async (name) => (name === 'plugins/requestSchemas.json' ? {} : null),
|
|
2291
|
+
});
|
|
2292
|
+
expect(result.passed).toBe(true);
|
|
2293
|
+
expect(result.exercised.events).toEqual([
|
|
2294
|
+
{
|
|
2295
|
+
scope: 'page',
|
|
2296
|
+
pageId: 'form',
|
|
2297
|
+
blockId: 'rows.$.label',
|
|
2298
|
+
eventName: 'onChange',
|
|
2299
|
+
actionIds: ['set_label'],
|
|
2300
|
+
},
|
|
2301
|
+
]);
|
|
2302
|
+
expect(result.exercised.rendered).toEqual({ form: ['rows.$.label', 'save'] });
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
test('runJourney waits on a request only for a call started since the last interaction', async () => {
|
|
2306
|
+
const window = createLowdefyWindow({ requests: { get_rows: [{ loading: false }] } });
|
|
2307
|
+
const page = createPage({ window });
|
|
2308
|
+
// The page called get_rows as it opened; the click calls nothing.
|
|
2309
|
+
openActorsWithNetwork([
|
|
2310
|
+
{ page, opening: [{ url: `${origin}/api/request/form/get_rows`, method: 'POST' }] },
|
|
2311
|
+
]);
|
|
2312
|
+
|
|
2313
|
+
const result = await runJourney({
|
|
2314
|
+
origin,
|
|
2315
|
+
pageId: 'form',
|
|
2316
|
+
steps: [
|
|
2317
|
+
{ wait: { request: 'get_rows' } },
|
|
2318
|
+
{ click: 'noop' },
|
|
2319
|
+
{ wait: { request: 'get_rows' } },
|
|
2320
|
+
],
|
|
2321
|
+
stepTimeout: 20,
|
|
2322
|
+
readConfigFile: async (name) => (name === 'plugins/requestSchemas.json' ? {} : null),
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
expect(result.passed).toBe(false);
|
|
2326
|
+
expect(result.failure.index).toBe(2);
|
|
2327
|
+
expect(result.failure.actual).toEqual({
|
|
2328
|
+
calledSinceLastInteraction: false,
|
|
2329
|
+
state: { loading: false },
|
|
2330
|
+
});
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
test('runJourney passes a wait on a request the interaction before it called', async () => {
|
|
2334
|
+
const window = createLowdefyWindow({ requests: { save: [{ loading: false }] } });
|
|
2335
|
+
const page = createPage({ window });
|
|
2336
|
+
const listeners = openActorsWithNetwork([{ page, opening: [] }]);
|
|
2337
|
+
const instrumented = page.locator.getMockImplementation();
|
|
2338
|
+
page.locator.mockImplementation((selector) => {
|
|
2339
|
+
const locator = instrumented(selector);
|
|
2340
|
+
const click = locator.click;
|
|
2341
|
+
locator.click = jest.fn(async (...args) => {
|
|
2342
|
+
await click(...args);
|
|
2343
|
+
listeners[0]({ url: () => `${origin}/api/request/form/save`, method: () => 'POST' });
|
|
2344
|
+
});
|
|
2345
|
+
return locator;
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
const result = await runJourney({
|
|
2349
|
+
origin,
|
|
2350
|
+
pageId: 'form',
|
|
2351
|
+
steps: [{ click: 'save' }, { wait: { request: 'save' } }],
|
|
2352
|
+
stepTimeout: 20,
|
|
2353
|
+
readConfigFile: async (name) => (name === 'plugins/requestSchemas.json' ? {} : null),
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
expect(result.failure).toBeUndefined();
|
|
2357
|
+
expect(result.passed).toBe(true);
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
test('runJourney reports the text it read when the last poll of expect.text fails', async () => {
|
|
2361
|
+
const page = createPage();
|
|
2362
|
+
const locator = page.locator('#bl-label');
|
|
2363
|
+
locator.allInnerTexts.mockResolvedValue(['1 LAPSED']);
|
|
2364
|
+
let attached = 0;
|
|
2365
|
+
locator.waitFor.mockImplementation(async () => {
|
|
2366
|
+
attached += 1;
|
|
2367
|
+
if (attached > 1) throw new Error('locator.waitFor: Timeout 1ms exceeded.');
|
|
2368
|
+
});
|
|
2369
|
+
page.locator = jest.fn(() => locator);
|
|
2370
|
+
openWith(page);
|
|
2371
|
+
const spy = advanceClockOnEveryRead();
|
|
2372
|
+
|
|
2373
|
+
const result = await runJourney({
|
|
2374
|
+
origin,
|
|
2375
|
+
pageId: 'form',
|
|
2376
|
+
steps: [{ expect: { text: { blockId: 'label', contains: 'lapsed' } } }],
|
|
2377
|
+
});
|
|
2378
|
+
|
|
2379
|
+
spy.mockRestore();
|
|
2380
|
+
|
|
2381
|
+
expect(result.failure.actual).toEqual('1 LAPSED');
|
|
2382
|
+
expect(result.failure.message).toContain('matches ignoring case');
|
|
2383
|
+
});
|