@antelopejs/dms 0.15.0 → 0.16.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/CHANGELOG.md +47 -0
- package/README.md +36 -26
- package/bin/control/base.mjs +9 -4
- package/bin/control/browser-client.mjs +24 -5
- package/bin/control/browser-wire.mjs +41 -0
- package/bin/control/browser-wire.test.mjs +96 -0
- package/bin/control/browser.mjs +78 -27
- package/bin/control/commands-stack.mjs +58 -8
- package/bin/control/commands-verify.mjs +47 -1
- package/bin/control/doctor.mjs +49 -4
- package/bin/control/failure.mjs +9 -0
- package/bin/control/journey.mjs +163 -30
- package/bin/control/journey.test.mjs +61 -0
- package/bin/control/json.mjs +229 -0
- package/bin/control/json.test.mjs +130 -0
- package/bin/control/mail.mjs +57 -7
- package/bin/control/mail.test.mjs +55 -0
- package/bin/control/mongo.mjs +205 -27
- package/bin/control/mongo.test.mjs +298 -0
- package/bin/control/options.mjs +33 -0
- package/bin/control/processes.mjs +24 -2
- package/bin/control/renderer.mjs +39 -0
- package/bin/control/renderer.test.mjs +324 -0
- package/bin/control/run-state.mjs +119 -6
- package/bin/control/run-state.test.mjs +171 -0
- package/bin/control/session.mjs +31 -14
- package/bin/control/stack.mjs +96 -24
- package/bin/control/tab.mjs +117 -18
- package/bin/control/verify/behaviour.mjs +44 -6
- package/bin/control/verify/behaviour.test.mjs +130 -22
- package/bin/control/verify/decode.mjs +220 -0
- package/bin/control/verify/decode.test.mjs +245 -0
- package/bin/control/verify/doors.mjs +1 -1
- package/bin/control/verify/doors.test.mjs +40 -0
- package/bin/control/verify/emit.mjs +135 -9
- package/bin/control/verify/formal.mjs +61 -10
- package/bin/control/verify/formal.test.mjs +19 -3
- package/bin/control/verify/judgement.mjs +22 -3
- package/bin/control/verify/map.mjs +25 -5
- package/bin/control/verify/publish.mjs +20 -14
- package/bin/control/verify/seeded.mjs +65 -16
- package/bin/control/verify/skill.mjs +27 -6
- package/bin/control/verify/types.mjs +29 -0
- package/bin/control/verify/verdict.mjs +72 -7
- package/bin/dms-control.mjs +8 -28
- package/checks/api-error-presentation.d.mts +61 -0
- package/dist/attachments/access.js +1 -1
- package/dist/attachments/access.js.map +1 -1
- package/dist/attachments/fields.d.ts +2 -2
- package/dist/attachments/fields.js +26 -13
- package/dist/attachments/fields.js.map +1 -1
- package/dist/attachments/index.d.ts +1 -0
- package/dist/attachments/index.js +1 -0
- package/dist/attachments/index.js.map +1 -1
- package/dist/attachments/read.js.map +1 -1
- package/dist/attachments/save.d.ts +1 -1
- package/dist/attachments/save.js +49 -17
- package/dist/attachments/save.js.map +1 -1
- package/dist/attachments/table-view.d.ts +1 -2
- package/dist/attachments/table-view.js +23 -13
- package/dist/attachments/table-view.js.map +1 -1
- package/dist/automation/events.d.ts +10 -10
- package/dist/automation/index.js +3 -4
- package/dist/automation/index.js.map +1 -1
- package/dist/automation/send-notification.d.ts +2 -2
- package/dist/automation/send-notification.js +4 -6
- package/dist/automation/send-notification.js.map +1 -1
- package/dist/config/internal/state.d.ts +7 -0
- package/dist/config/internal/state.js +9 -0
- package/dist/config/internal/state.js.map +1 -1
- package/dist/config/startup-config.d.ts +1 -0
- package/dist/config/startup-config.js +11 -0
- package/dist/config/startup-config.js.map +1 -1
- package/dist/crons/cleanup-user-invites.js.map +1 -1
- package/dist/crons/sweep-expired-sessions.js.map +1 -1
- package/dist/crons/sweep-stale-exports.js.map +1 -1
- package/dist/db/guard-row.d.ts +2 -3
- package/dist/db/guard-row.js +1 -2
- package/dist/db/guard-row.js.map +1 -1
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/index.js.map +1 -1
- package/dist/db/models/attachments.model.d.ts +7 -5
- package/dist/db/models/index.d.ts +1 -0
- package/dist/db/models/index.js +1 -0
- package/dist/db/models/index.js.map +1 -1
- package/dist/db/models/setupToken.model.d.ts +31 -0
- package/dist/db/models/setupToken.model.js +62 -0
- package/dist/db/models/setupToken.model.js.map +1 -0
- package/dist/db/models/signInAttempts.model.d.ts +7 -5
- package/dist/db/models/systemState.model.d.ts +7 -5
- package/dist/db/models/userEmailChanges.model.d.ts +7 -5
- package/dist/db/models/userKnownDevices.model.d.ts +7 -5
- package/dist/db/models/userNotificationPreferences.model.d.ts +7 -5
- package/dist/db/models/userNotifications.model.d.ts +9 -6
- package/dist/db/models/userNotifications.model.js.map +1 -1
- package/dist/db/setup-token.d.ts +2 -0
- package/dist/db/setup-token.js +28 -0
- package/dist/db/setup-token.js.map +1 -0
- package/dist/db/tables/index.d.ts +1 -0
- package/dist/db/tables/index.js +1 -0
- package/dist/db/tables/index.js.map +1 -1
- package/dist/db/tables/setupToken.table.d.ts +7 -0
- package/dist/db/tables/setupToken.table.js +34 -0
- package/dist/db/tables/setupToken.table.js.map +1 -0
- package/dist/dev/module-update-notifications.js.map +1 -1
- package/dist/hooks/internal/sign-in-monitor.js +6 -6
- package/dist/hooks/internal/sign-in-monitor.js.map +1 -1
- package/dist/implementations/dms/page/controller.d.ts +2 -2
- package/dist/implementations/dms/page/controller.js +3 -1
- package/dist/implementations/dms/page/controller.js.map +1 -1
- package/dist/implementations/dms/page/dynamic-menu.js +3 -2
- package/dist/implementations/dms/page/dynamic-menu.js.map +1 -1
- package/dist/implementations/dms/page/notifications.d.ts +6 -2
- package/dist/implementations/dms/page/notifications.js +1 -0
- package/dist/implementations/dms/page/notifications.js.map +1 -1
- package/dist/implementations/dms/page/payloads.js +6 -10
- package/dist/implementations/dms/page/payloads.js.map +1 -1
- package/dist/implementations/dms/page/preview-pages.js +6 -3
- package/dist/implementations/dms/page/preview-pages.js.map +1 -1
- package/dist/implementations/dms/page/public-layout.d.ts +2 -0
- package/dist/implementations/dms/page/public-layout.js +45 -0
- package/dist/implementations/dms/page/public-layout.js.map +1 -0
- package/dist/implementations/dms/page/registry.js +10 -4
- package/dist/implementations/dms/page/registry.js.map +1 -1
- package/dist/implementations/dms/permissions.js +12 -12
- package/dist/implementations/dms/permissions.js.map +1 -1
- package/dist/implementations/dms-base/bulk-selection.d.ts +3 -4
- package/dist/implementations/dms-base/bulk-selection.js +18 -6
- package/dist/implementations/dms-base/bulk-selection.js.map +1 -1
- package/dist/implementations/dms-base/filter-tokens.js.map +1 -1
- package/dist/implementations/dms-base/footer-summary.js +44 -27
- package/dist/implementations/dms-base/footer-summary.js.map +1 -1
- package/dist/implementations/dms-base/row-rule-operand.d.ts +6 -0
- package/dist/implementations/dms-base/row-rule-operand.js +41 -0
- package/dist/implementations/dms-base/row-rule-operand.js.map +1 -0
- package/dist/implementations/dms-base/search-route.d.ts +9 -13
- package/dist/implementations/dms-base/search-route.js +7 -6
- package/dist/implementations/dms-base/search-route.js.map +1 -1
- package/dist/implementations/dms-base/services.d.ts +1 -0
- package/dist/implementations/dms-base/services.js +1 -0
- package/dist/implementations/dms-base/services.js.map +1 -1
- package/dist/implementations/dms-base/table-view.d.ts +5 -6
- package/dist/implementations/dms-base/table-view.js +17 -6
- package/dist/implementations/dms-base/table-view.js.map +1 -1
- package/dist/implementations/dms-html-render/index.js +16 -6
- package/dist/implementations/dms-html-render/index.js.map +1 -1
- package/dist/implementations/dms-notifications/delivery-guard.d.ts +3 -2
- package/dist/implementations/dms-notifications/delivery-guard.js.map +1 -1
- package/dist/implementations/dms-notifications/index.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/pages/public/preview/block-types.d.ts +1 -2
- package/dist/pages/public/preview/block-types.js.map +1 -1
- package/dist/pages/settings/overview/account-summary.js +4 -1
- package/dist/pages/settings/overview/account-summary.js.map +1 -1
- package/dist/pages/settings/users/account-activity-feed.js +3 -2
- package/dist/pages/settings/users/account-activity-feed.js.map +1 -1
- package/dist/pages/settings/users/account-credentials.d.ts +1 -1
- package/dist/pages/settings/users/account-credentials.js +1 -2
- package/dist/pages/settings/users/account-credentials.js.map +1 -1
- package/dist/pages/settings/users/account-data-store.d.ts +1 -1
- package/dist/pages/settings/users/account-data-store.js +1 -3
- package/dist/pages/settings/users/account-data-store.js.map +1 -1
- package/dist/pages/settings/users/current-password.js +1 -1
- package/dist/pages/settings/users/current-password.js.map +1 -1
- package/dist/pages/settings/users/email-change.d.ts +2 -2
- package/dist/pages/settings/users/email-change.js +2 -3
- package/dist/pages/settings/users/email-change.js.map +1 -1
- package/dist/pages/settings/users/invite-extension-routes.d.ts +2 -2
- package/dist/pages/settings/users/invite-extension-routes.js +11 -4
- package/dist/pages/settings/users/invite-extension-routes.js.map +1 -1
- package/dist/pages/settings/users/invites.d.ts +2 -2
- package/dist/pages/settings/users/member-invite-batch.d.ts +2 -2
- package/dist/pages/settings/users/member-invite-batch.js +7 -3
- package/dist/pages/settings/users/member-invite-batch.js.map +1 -1
- package/dist/pages/settings/users/member-roles-field.js +1 -4
- package/dist/pages/settings/users/member-roles-field.js.map +1 -1
- package/dist/pages/settings/users/members.d.ts +2 -3
- package/dist/pages/settings/users/members.js.map +1 -1
- package/dist/pages/settings/users/notifications.d.ts +2 -3
- package/dist/pages/settings/users/notifications.js +2 -1
- package/dist/pages/settings/users/notifications.js.map +1 -1
- package/dist/pages/settings/users/profile-helpers.d.ts +0 -1
- package/dist/pages/settings/users/profile-helpers.js +0 -17
- package/dist/pages/settings/users/profile-helpers.js.map +1 -1
- package/dist/pages/settings/users/profile.d.ts +3 -4
- package/dist/pages/settings/users/profile.js +12 -12
- package/dist/pages/settings/users/profile.js.map +1 -1
- package/dist/pages/settings/users/region.d.ts +3 -4
- package/dist/pages/settings/users/region.js.map +1 -1
- package/dist/pages/settings/users/regional-preferences.d.ts +8 -1
- package/dist/pages/settings/users/regional-preferences.js +10 -6
- package/dist/pages/settings/users/regional-preferences.js.map +1 -1
- package/dist/pages/settings/users/role-preview.js +3 -2
- package/dist/pages/settings/users/role-preview.js.map +1 -1
- package/dist/pages/settings/users/roles.d.ts +4 -5
- package/dist/pages/settings/users/roles.js.map +1 -1
- package/dist/pages/settings/users/security.d.ts +9 -12
- package/dist/pages/settings/users/security.js +11 -9
- package/dist/pages/settings/users/security.js.map +1 -1
- package/dist/pages/settings/users/two-factor-operations.d.ts +7 -7
- package/dist/pages/settings/users/two-factor-operations.js +5 -7
- package/dist/pages/settings/users/two-factor-operations.js.map +1 -1
- package/dist/realtime/broker.js.map +1 -1
- package/dist/realtime/heartbeat.js.map +1 -1
- package/dist/realtime/redis-broker.js.map +1 -1
- package/dist/realtime/sessions.js.map +1 -1
- package/dist/realtime/sse.js +1 -2
- package/dist/realtime/sse.js.map +1 -1
- package/dist/routes/auth/forgot-password.d.ts +1 -1
- package/dist/routes/auth/forgot-password.js +1 -3
- package/dist/routes/auth/forgot-password.js.map +1 -1
- package/dist/routes/auth/login.d.ts +2 -2
- package/dist/routes/auth/login.js +8 -4
- package/dist/routes/auth/login.js.map +1 -1
- package/dist/routes/auth/logout.d.ts +1 -1
- package/dist/routes/auth/logout.js +1 -2
- package/dist/routes/auth/logout.js.map +1 -1
- package/dist/routes/auth/oauth/providers.js +30 -6
- package/dist/routes/auth/oauth/providers.js.map +1 -1
- package/dist/routes/auth/oauth/state.js +8 -5
- package/dist/routes/auth/oauth/state.js.map +1 -1
- package/dist/routes/auth/oauth/token-exchange.js +8 -1
- package/dist/routes/auth/oauth/token-exchange.js.map +1 -1
- package/dist/routes/auth/password-hash-upgrade.d.ts +2 -0
- package/dist/routes/auth/password-hash-upgrade.js +22 -0
- package/dist/routes/auth/password-hash-upgrade.js.map +1 -0
- package/dist/routes/auth/refresh.d.ts +1 -1
- package/dist/routes/auth/refresh.js +1 -2
- package/dist/routes/auth/refresh.js.map +1 -1
- package/dist/routes/auth/request-2fa-email.d.ts +1 -1
- package/dist/routes/auth/request-2fa-email.js +1 -2
- package/dist/routes/auth/request-2fa-email.js.map +1 -1
- package/dist/routes/auth/reset-password.d.ts +1 -1
- package/dist/routes/auth/reset-password.js +1 -3
- package/dist/routes/auth/reset-password.js.map +1 -1
- package/dist/routes/auth/session-handoff.d.ts +1 -1
- package/dist/routes/auth/session-handoff.js +1 -2
- package/dist/routes/auth/session-handoff.js.map +1 -1
- package/dist/routes/auth/signup.d.ts +1 -1
- package/dist/routes/auth/signup.js +1 -2
- package/dist/routes/auth/signup.js.map +1 -1
- package/dist/routes/auth/switch-tenant.d.ts +2 -3
- package/dist/routes/auth/switch-tenant.js +3 -2
- package/dist/routes/auth/switch-tenant.js.map +1 -1
- package/dist/routes/auth/validate-forgot-password-token.d.ts +1 -1
- package/dist/routes/auth/validate-forgot-password-token.js +1 -3
- package/dist/routes/auth/validate-forgot-password-token.js.map +1 -1
- package/dist/routes/auth/validate-invite-token.d.ts +1 -1
- package/dist/routes/auth/validate-invite-token.js +1 -3
- package/dist/routes/auth/validate-invite-token.js.map +1 -1
- package/dist/routes/auth/verify-2fa.d.ts +1 -1
- package/dist/routes/auth/verify-2fa.js +1 -2
- package/dist/routes/auth/verify-2fa.js.map +1 -1
- package/dist/routes/auth/verify-email.d.ts +1 -1
- package/dist/routes/auth/verify-email.js +1 -2
- package/dist/routes/auth/verify-email.js.map +1 -1
- package/dist/routes/auth.d.ts +14 -15
- package/dist/routes/auth.js +15 -13
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/authentication.d.ts +2 -0
- package/dist/routes/authentication.js +2 -0
- package/dist/routes/authentication.js.map +1 -1
- package/dist/routes/files.d.ts +1 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/onboarding.d.ts +4 -3
- package/dist/routes/onboarding.js +20 -3
- package/dist/routes/onboarding.js.map +1 -1
- package/dist/routes/realtime.js +5 -6
- package/dist/routes/realtime.js.map +1 -1
- package/dist/routes/store.d.ts +6 -7
- package/dist/routes/store.js +19 -13
- package/dist/routes/store.js.map +1 -1
- package/dist/store/catalog.d.ts +13 -5
- package/dist/store/catalog.js +19 -9
- package/dist/store/catalog.js.map +1 -1
- package/dist/store/compatibility.d.ts +1 -0
- package/dist/store/compatibility.js +24 -8
- package/dist/store/compatibility.js.map +1 -1
- package/dist/store/frontend-compatibility.js +15 -3
- package/dist/store/frontend-compatibility.js.map +1 -1
- package/dist/store/interfaces.js +35 -13
- package/dist/store/interfaces.js.map +1 -1
- package/dist/store/internal/frontend/frontend-modules.js +7 -3
- package/dist/store/internal/frontend/frontend-modules.js.map +1 -1
- package/dist/store/internal/frontend/frontend-ready.d.ts +2 -1
- package/dist/store/internal/frontend/frontend-ready.js +6 -4
- package/dist/store/internal/frontend/frontend-ready.js.map +1 -1
- package/dist/store/internal/frontend/manifest.js +6 -7
- package/dist/store/internal/frontend/manifest.js.map +1 -1
- package/dist/store/operations.js.map +1 -1
- package/dist/store/registry.js.map +1 -1
- package/dist/store/runtime.d.ts +13 -0
- package/dist/store/runtime.js +9 -1
- package/dist/store/runtime.js.map +1 -1
- package/dist/utils/admin-invite-email.js +3 -1
- package/dist/utils/admin-invite-email.js.map +1 -1
- package/dist/utils/email-subjects.js +3 -1
- package/dist/utils/email-subjects.js.map +1 -1
- package/dist/utils/ensure-default-tenant.d.ts +2 -1
- package/dist/utils/ensure-default-tenant.js +126 -8
- package/dist/utils/ensure-default-tenant.js.map +1 -1
- package/dist/utils/export-jobs-run.js.map +1 -1
- package/dist/utils/export-jobs.js +3 -9
- package/dist/utils/export-jobs.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/own-value.d.ts +1 -0
- package/dist/utils/own-value.js +7 -0
- package/dist/utils/own-value.js.map +1 -0
- package/dist/utils/row-action-rule-evaluator.js +31 -35
- package/dist/utils/row-action-rule-evaluator.js.map +1 -1
- package/dist/utils/sign-in-country.js +4 -2
- package/dist/utils/sign-in-country.js.map +1 -1
- package/dist/utils/tenant-export-archive.js +9 -14
- package/dist/utils/tenant-export-archive.js.map +1 -1
- package/dist/utils/user-agent.js +2 -1
- package/dist/utils/user-agent.js.map +1 -1
- package/dist/validation/auth.schema.d.ts +10 -10
- package/dist/validation/onboarding/register-admin.schema.d.ts +5 -0
- package/dist/validation/onboarding/register-admin.schema.js +3 -0
- package/dist/validation/onboarding/register-admin.schema.js.map +1 -1
- package/dist/validation/regional-preferences.schema.d.ts +2 -2
- package/dist/validation/role-editor.schema.d.ts +1 -1
- package/frontend-vue/build/backend-boundary.ts +49 -18
- package/frontend-vue/dms.frontend.ts +8 -12
- package/frontend-vue/layers/dms-auth/server/accounts.ts +44 -3
- package/frontend-vue/layers/dms-auth/server/actions.ts +17 -4
- package/frontend-vue/layers/dms-auth/server/oauth.ts +42 -4
- package/frontend-vue/layers/dms-core/app/types/watch.ts +1 -2
- package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +1 -0
- package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +1 -0
- package/frontend-vue/layers/dms-core/shared/types/app-config.ts +5 -0
- package/frontend-vue/layers/dms-layout/app/app.config.ts +1 -0
- package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardSidebar.vue +9 -9
- package/frontend-vue/layers/dms-layout/app/components/layout/AppLogo.vue +1 -0
- package/frontend-vue/layers/dms-layout/app/composables/page/useInstalledModules.ts +78 -0
- package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +5 -24
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +2 -1
- package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +2 -1
- package/frontend-vue/layers/dms-onboarding/app/components/onboarding/steps/register.vue +21 -1
- package/frontend-vue/layers/dms-onboarding/app/custom-pages/onboarding.vue +14 -0
- package/frontend-vue/layers/dms-onboarding/i18n/locales/onboarding-en-GB.json +2 -0
- package/frontend-vue/layers/dms-onboarding/i18n/locales/onboarding-fr-FR.json +2 -0
- package/frontend-vue/layers/dms-ui/app/build/composables/form/fieldValueRule.ts +57 -6
- package/frontend-vue/layers/dms-ui/app/build/composables/form/registerFormFunctions.ts +7 -9
- package/frontend-vue/layers/dms-ui/app/entries/composables/form/registerFormFunctions.ts +1 -0
- package/frontend-vue/lazy-plugin.ts +18 -0
- package/frontend-vue/package.json +2 -2
- package/package.json +19 -13
- package/skills/dms-project/SKILL.md +4 -3
package/bin/control/journey.mjs
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
rmSync,
|
|
7
|
-
writeFileSync,
|
|
8
|
-
} from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
3
|
import { join, relative } from "node:path";
|
|
10
4
|
import { pathToFileURL } from "node:url";
|
|
11
5
|
import { withBaseBackend } from "./base.mjs";
|
|
12
6
|
import { launchBrowser, newContext } from "./browser.mjs";
|
|
13
7
|
import { capturedMail } from "./mail.mjs";
|
|
8
|
+
import { isRecord, isString, readRecordList } from "./json.mjs";
|
|
14
9
|
import { saveRun } from "./run-state.mjs";
|
|
15
10
|
import {
|
|
16
11
|
adminCredentials,
|
|
@@ -27,6 +22,14 @@ import {
|
|
|
27
22
|
} from "./stack.mjs";
|
|
28
23
|
import { Tab } from "./tab.mjs";
|
|
29
24
|
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{ name: string, pass: boolean, detail: string }} Check
|
|
27
|
+
* @typedef {ReturnType<typeof capturedMail>[number]} CapturedMail
|
|
28
|
+
* @typedef {import("./verify/types.mjs").FeatureResult} FeatureResult
|
|
29
|
+
* @typedef {{ id: string, title: string, entryPoint: string, database?: string, onboard?: boolean, run: (context: JourneyContext) => Promise<void> }} Journey
|
|
30
|
+
* @typedef {{ name: string, folder: string, checks: Check[] }} Step
|
|
31
|
+
*/
|
|
32
|
+
|
|
30
33
|
const EXPECT_TIMEOUT_MS = 15_000;
|
|
31
34
|
const POLL_MS = 250;
|
|
32
35
|
const DETAIL_LENGTH = 300;
|
|
@@ -34,6 +37,10 @@ const UNIQUE_BYTES = 3;
|
|
|
34
37
|
|
|
35
38
|
class JourneyAbort extends Error {}
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} text The text
|
|
42
|
+
* @returns {string} The text as a folder name
|
|
43
|
+
*/
|
|
37
44
|
function slug(text) {
|
|
38
45
|
return text
|
|
39
46
|
.toLowerCase()
|
|
@@ -42,11 +49,22 @@ function slug(text) {
|
|
|
42
49
|
.slice(0, 60);
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} file The file
|
|
54
|
+
* @param {unknown} value The value
|
|
55
|
+
*/
|
|
45
56
|
function writeJson(file, value) {
|
|
46
57
|
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* An equality check over JSON-serialisable values.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} name What is compared
|
|
64
|
+
* @param {unknown} actual The value found
|
|
65
|
+
* @param {unknown} expected The value wanted
|
|
66
|
+
* @returns {Check} The check
|
|
67
|
+
*/
|
|
50
68
|
function equal(name, actual, expected) {
|
|
51
69
|
const [left, right] = [JSON.stringify(actual), JSON.stringify(expected)];
|
|
52
70
|
return {
|
|
@@ -56,7 +74,13 @@ function equal(name, actual, expected) {
|
|
|
56
74
|
};
|
|
57
75
|
}
|
|
58
76
|
|
|
59
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* A check that an API answer has `expected` as its status.
|
|
79
|
+
*
|
|
80
|
+
* @param {Pick<import("./session.mjs").ApiAnswer, "status" | "body">} answer The answer
|
|
81
|
+
* @param {number} expected The status wanted
|
|
82
|
+
* @returns {Check} The check
|
|
83
|
+
*/
|
|
60
84
|
function status(answer, expected) {
|
|
61
85
|
const body = JSON.stringify(answer.body) ?? "";
|
|
62
86
|
return {
|
|
@@ -66,7 +90,14 @@ function status(answer, expected) {
|
|
|
66
90
|
};
|
|
67
91
|
}
|
|
68
92
|
|
|
69
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* A check that a condition holds, with what was observed.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} name What is checked
|
|
97
|
+
* @param {unknown} pass Whether it holds
|
|
98
|
+
* @param {unknown} detail What was observed
|
|
99
|
+
* @returns {Check} The check
|
|
100
|
+
*/
|
|
70
101
|
function holds(name, pass, detail) {
|
|
71
102
|
return { name, pass: Boolean(pass), detail: String(detail) };
|
|
72
103
|
}
|
|
@@ -77,6 +108,13 @@ function holds(name, pass, detail) {
|
|
|
77
108
|
* folders under `<run>/evidence/<feature>/<journey>/`.
|
|
78
109
|
*/
|
|
79
110
|
export class JourneyContext {
|
|
111
|
+
/**
|
|
112
|
+
* @param {import("./run-state.mjs").RunState} state The run
|
|
113
|
+
* @param {string} feature The feature the journey belongs to
|
|
114
|
+
* @param {string} journey The journey id
|
|
115
|
+
* @param {import("playwright-core").Browser} browser The Playwright browser
|
|
116
|
+
* @param {import("./verify/skill.mjs").Skill} skill The skill the journey belongs to
|
|
117
|
+
*/
|
|
80
118
|
constructor(state, feature, journey, browser, skill) {
|
|
81
119
|
this.state = state;
|
|
82
120
|
this.skill = skill;
|
|
@@ -84,7 +122,9 @@ export class JourneyContext {
|
|
|
84
122
|
this.journey = journey;
|
|
85
123
|
this.browser = browser;
|
|
86
124
|
this.folder = join(state.evidence, feature, journey);
|
|
125
|
+
/** @type {Step[]} */
|
|
87
126
|
this.steps = [];
|
|
127
|
+
/** @type {Map<string, import("playwright-core").BrowserContext>} */
|
|
88
128
|
this.contexts = new Map();
|
|
89
129
|
this.index = 0;
|
|
90
130
|
this.startedAt = new Date().toISOString();
|
|
@@ -95,7 +135,12 @@ export class JourneyContext {
|
|
|
95
135
|
mkdirSync(this.folder, { recursive: true });
|
|
96
136
|
}
|
|
97
137
|
|
|
98
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* A new numbered step folder.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} label What the step does
|
|
142
|
+
* @returns {string} The step folder
|
|
143
|
+
*/
|
|
99
144
|
step(label) {
|
|
100
145
|
this.index += 1;
|
|
101
146
|
const name = `${String(this.index).padStart(2, "0")}-${slug(label)}`;
|
|
@@ -104,7 +149,14 @@ export class JourneyContext {
|
|
|
104
149
|
return folder;
|
|
105
150
|
}
|
|
106
151
|
|
|
107
|
-
/**
|
|
152
|
+
/**
|
|
153
|
+
* Records the checks of a step folder; a failed critical step ends the journey.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} folder The step folder
|
|
156
|
+
* @param {{ name: string, pass: boolean, detail: string }[]} checks The checks
|
|
157
|
+
* @param {{ critical?: boolean }} [options] Whether a failure ends the journey
|
|
158
|
+
* @returns {boolean} Whether every check passed
|
|
159
|
+
*/
|
|
108
160
|
record(folder, checks, options = {}) {
|
|
109
161
|
this.steps.push({
|
|
110
162
|
name: relative(this.folder, folder),
|
|
@@ -124,12 +176,27 @@ export class JourneyContext {
|
|
|
124
176
|
/**
|
|
125
177
|
* Records a step of checks, which may be promises (`tab.visible(...)`).
|
|
126
178
|
* With `{ critical: true }` a failure ends the journey.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} label What the step verifies
|
|
181
|
+
* @param {(Check | Promise<Check>)[]} checks The checks, or promises of them
|
|
182
|
+
* @param {{ critical?: boolean }} [options] Whether a failure ends the journey
|
|
183
|
+
* @returns {Promise<boolean>} Whether every check passed
|
|
127
184
|
*/
|
|
128
185
|
async verify(label, checks, options = {}) {
|
|
129
|
-
return this.record(
|
|
186
|
+
return this.record(
|
|
187
|
+
this.step(label),
|
|
188
|
+
await Promise.all(checks.map((check) => Promise.resolve(check))),
|
|
189
|
+
options,
|
|
190
|
+
);
|
|
130
191
|
}
|
|
131
192
|
|
|
132
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* A new tab as `account` (`anon` for nobody), in that account's own browser context.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} [account] The account, or `anon`
|
|
197
|
+
* @param {string} [label] The tab's name in evidence
|
|
198
|
+
* @returns {Promise<Tab>} The tab
|
|
199
|
+
*/
|
|
133
200
|
async tab(account = "admin", label = account) {
|
|
134
201
|
if (!this.contexts.has(account)) {
|
|
135
202
|
this.contexts.set(account, await newContext(this.browser));
|
|
@@ -140,6 +207,10 @@ export class JourneyContext {
|
|
|
140
207
|
/**
|
|
141
208
|
* Calls the backend as `as` (an account, or `anon`; `admin` by default),
|
|
142
209
|
* or with an explicit `token` and no retry, and records the exchange.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} label What the call does
|
|
212
|
+
* @param {{ method: string, path: string, body?: unknown, as?: string, token?: string }} request What to send and as whom
|
|
213
|
+
* @returns {Promise<import("./session.mjs").ApiAnswer & { folder?: string }>} The answer
|
|
143
214
|
*/
|
|
144
215
|
api(label, request) {
|
|
145
216
|
if (request.token) {
|
|
@@ -155,7 +226,13 @@ export class JourneyContext {
|
|
|
155
226
|
});
|
|
156
227
|
}
|
|
157
228
|
|
|
158
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* Sends anonymous requests at once, for races, each recorded in its own step folder.
|
|
231
|
+
*
|
|
232
|
+
* @param {string} label What the calls do
|
|
233
|
+
* @param {{ method: string, path: string, body?: unknown, token?: string }[]} requests What to send
|
|
234
|
+
* @returns {Promise<import("./session.mjs").ApiAnswer[]>} The answers
|
|
235
|
+
*/
|
|
159
236
|
concurrent(label, requests) {
|
|
160
237
|
return Promise.all(
|
|
161
238
|
requests.map((request, index) =>
|
|
@@ -167,18 +244,33 @@ export class JourneyContext {
|
|
|
167
244
|
);
|
|
168
245
|
}
|
|
169
246
|
|
|
170
|
-
/**
|
|
247
|
+
/**
|
|
248
|
+
* Adds an account the journey created (an invited member) so tabs and the API can act as it.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} name The account name
|
|
251
|
+
* @param {import("./run-state.mjs").RunState["accounts"][string]} credentials Its credentials
|
|
252
|
+
*/
|
|
171
253
|
addAccount(name, credentials) {
|
|
172
254
|
this.state.accounts = { ...this.state.accounts, [name]: credentials };
|
|
173
255
|
saveRun(this.state);
|
|
174
256
|
}
|
|
175
257
|
|
|
176
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* An address unique to this run and journey, which the mail sink receives.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} name The local part
|
|
262
|
+
* @returns {string} The address
|
|
263
|
+
*/
|
|
177
264
|
email(name) {
|
|
178
265
|
return `${name}+${this.state.id}-${slug(this.journey)}@verify.local`.toLowerCase();
|
|
179
266
|
}
|
|
180
267
|
|
|
181
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* A string unique to this journey, for names a journey creates.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} prefix What the string starts with
|
|
272
|
+
* @returns {string} The unique string
|
|
273
|
+
*/
|
|
182
274
|
unique(prefix) {
|
|
183
275
|
return `${prefix} ${randomBytes(UNIQUE_BYTES).toString("hex")}`;
|
|
184
276
|
}
|
|
@@ -193,6 +285,11 @@ export class JourneyContext {
|
|
|
193
285
|
return adminCredentials(this.state);
|
|
194
286
|
}
|
|
195
287
|
|
|
288
|
+
/** The setup token the run's backend accepts for the first registration (`DMS_SETUP_TOKEN`). */
|
|
289
|
+
setupToken() {
|
|
290
|
+
return this.state.secrets.DMS_SETUP_TOKEN;
|
|
291
|
+
}
|
|
292
|
+
|
|
196
293
|
/** Creates the administrator through `POST /api/onboarding/register`, then signs it in. */
|
|
197
294
|
onboard(options = {}) {
|
|
198
295
|
return onboard(this.state, options);
|
|
@@ -202,6 +299,9 @@ export class JourneyContext {
|
|
|
202
299
|
* Runs `action` against the base version's backend on the same database,
|
|
203
300
|
* then the run's own backend again: data written inside `action` is data
|
|
204
301
|
* the previous release wrote. Records the base commit as a step.
|
|
302
|
+
*
|
|
303
|
+
* @param {(sha: string) => Promise<void>} action What to do against the base backend
|
|
304
|
+
* @returns {Promise<string>} The base commit
|
|
205
305
|
*/
|
|
206
306
|
async withBaseBackend(action) {
|
|
207
307
|
const sha = await withBaseBackend(this.state, this.skill, action);
|
|
@@ -220,6 +320,10 @@ export class JourneyContext {
|
|
|
220
320
|
/**
|
|
221
321
|
* The mail the sink captured since the journey started, waiting until
|
|
222
322
|
* `accept` matches one or the timeout passes; `match` is the latest match.
|
|
323
|
+
*
|
|
324
|
+
* @param {(message: CapturedMail) => boolean} accept Whether a message is the awaited one
|
|
325
|
+
* @param {number} [timeoutMs] How long to wait
|
|
326
|
+
* @returns {Promise<{ folder: string, messages: CapturedMail[], match: CapturedMail | undefined }>} What arrived
|
|
223
327
|
*/
|
|
224
328
|
async mail(accept, timeoutMs = EXPECT_TIMEOUT_MS) {
|
|
225
329
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -241,6 +345,12 @@ export class JourneyContext {
|
|
|
241
345
|
}
|
|
242
346
|
}
|
|
243
347
|
|
|
348
|
+
/**
|
|
349
|
+
* @param {import("./run-state.mjs").RunState} state The run
|
|
350
|
+
* @param {string} feature The feature the journey belongs to
|
|
351
|
+
* @param {Journey} journey The journey
|
|
352
|
+
* @param {JourneyContext} context The journey's context
|
|
353
|
+
*/
|
|
244
354
|
async function prepare(state, feature, journey, context) {
|
|
245
355
|
await ensureMail(state);
|
|
246
356
|
if (journey.database !== "shared") {
|
|
@@ -278,10 +388,11 @@ async function prepare(state, feature, journey, context) {
|
|
|
278
388
|
* false` skips the administrator), with a fresh browser, and writes its
|
|
279
389
|
* result.json.
|
|
280
390
|
*
|
|
281
|
-
* @param {
|
|
391
|
+
* @param {import("./run-state.mjs").RunState} state The run
|
|
282
392
|
* @param {string} feature The feature it belongs to
|
|
283
|
-
* @param {
|
|
284
|
-
* @
|
|
393
|
+
* @param {Journey} journey The journey
|
|
394
|
+
* @param {import("./verify/skill.mjs").Skill} skill The skill the journey belongs to
|
|
395
|
+
* @returns {Promise<FeatureResult>} The result
|
|
285
396
|
*/
|
|
286
397
|
export async function runJourney(state, feature, journey, skill) {
|
|
287
398
|
const browser = await launchBrowser(resolutionFolders(state));
|
|
@@ -323,17 +434,40 @@ export async function runJourney(state, feature, journey, skill) {
|
|
|
323
434
|
return result;
|
|
324
435
|
}
|
|
325
436
|
|
|
437
|
+
/**
|
|
438
|
+
* @param {unknown} value A value a journeys module exports
|
|
439
|
+
* @returns {value is Journey} True for an object with the fields a journey run reads
|
|
440
|
+
*/
|
|
441
|
+
function isJourney(value) {
|
|
442
|
+
return (
|
|
443
|
+
isRecord(value) &&
|
|
444
|
+
isString(value.id) &&
|
|
445
|
+
isString(value.title) &&
|
|
446
|
+
isString(value.entryPoint) &&
|
|
447
|
+
(value.database === undefined || isString(value.database)) &&
|
|
448
|
+
(value.onboard === undefined || typeof value.onboard === "boolean") &&
|
|
449
|
+
typeof value.run === "function"
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
326
453
|
/**
|
|
327
454
|
* Loads the journeys a feature's file exports (`export const journeys`).
|
|
328
455
|
*
|
|
329
456
|
* @param {string} file The journeys module
|
|
330
|
-
* @returns {Promise<
|
|
457
|
+
* @returns {Promise<Journey[]>} The journeys
|
|
331
458
|
*/
|
|
332
459
|
export async function loadJourneys(file) {
|
|
333
460
|
if (!existsSync(file)) throw new Error(`${file} does not exist`);
|
|
334
|
-
|
|
335
|
-
|
|
461
|
+
/** @type {unknown} */
|
|
462
|
+
const exported = await import(pathToFileURL(file).href);
|
|
463
|
+
const entries = isRecord(exported) ? exported.journeys : undefined;
|
|
464
|
+
if (!Array.isArray(entries))
|
|
336
465
|
throw new Error(`${file} exports no journeys array`);
|
|
466
|
+
const journeys = entries.filter(isJourney);
|
|
467
|
+
if (journeys.length !== entries.length)
|
|
468
|
+
throw new Error(
|
|
469
|
+
`${file} exports a journey without an id, title, entry point or run function`,
|
|
470
|
+
);
|
|
337
471
|
return journeys;
|
|
338
472
|
}
|
|
339
473
|
|
|
@@ -341,9 +475,9 @@ export async function loadJourneys(file) {
|
|
|
341
475
|
* Runs a feature's journeys, all or those named, and merges their results
|
|
342
476
|
* into `<feature>/feature.json` so a partial run never drops earlier ones.
|
|
343
477
|
*
|
|
344
|
-
* @param {
|
|
345
|
-
* @param {{ file: string, feature: string, ids: string[], skill:
|
|
346
|
-
* @returns {Promise<
|
|
478
|
+
* @param {import("./run-state.mjs").RunState} state The run
|
|
479
|
+
* @param {{ file: string, feature: string, ids: string[], skill: import("./verify/skill.mjs").Skill }} request Which journeys, and the skill they belong to
|
|
480
|
+
* @returns {Promise<FeatureResult[]>} The results of this run
|
|
347
481
|
*/
|
|
348
482
|
export async function runJourneyFile(state, request) {
|
|
349
483
|
const journeys = await loadJourneys(request.file);
|
|
@@ -359,15 +493,14 @@ export async function runJourneyFile(state, request) {
|
|
|
359
493
|
request.ids.length > 0
|
|
360
494
|
? journeys.filter((journey) => request.ids.includes(journey.id))
|
|
361
495
|
: journeys;
|
|
496
|
+
/** @type {FeatureResult[]} */
|
|
362
497
|
const results = [];
|
|
363
498
|
for (const journey of selected)
|
|
364
499
|
results.push(
|
|
365
500
|
await runJourney(state, request.feature, journey, request.skill),
|
|
366
501
|
);
|
|
367
502
|
const file = join(state.evidence, request.feature, "feature.json");
|
|
368
|
-
const earlier = existsSync(file)
|
|
369
|
-
? JSON.parse(readFileSync(file, "utf8"))
|
|
370
|
-
: [];
|
|
503
|
+
const earlier = existsSync(file) ? readRecordList(file) : [];
|
|
371
504
|
const merged = journeys.flatMap((journey) => {
|
|
372
505
|
const fresh = results.filter((result) => result.journey === journey.id);
|
|
373
506
|
return fresh.length > 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { loadJourneys } from "./journey.mjs";
|
|
7
|
+
|
|
8
|
+
const JOURNEY = '{ id: "smoke", title: "t", entryPoint: "e", async run() {} }';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {import("node:test").TestContext} t The test
|
|
12
|
+
* @param {string} source The module source
|
|
13
|
+
* @returns {string} The journeys module
|
|
14
|
+
*/
|
|
15
|
+
function journeysModule(t, source) {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "dms-control-journey-test-"));
|
|
17
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
18
|
+
const file = join(dir, "journeys.mjs");
|
|
19
|
+
writeFileSync(file, source);
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await test("a module exporting well-formed journeys loads them", async (t) => {
|
|
24
|
+
const file = journeysModule(
|
|
25
|
+
t,
|
|
26
|
+
`export const journeys = [${JOURNEY}, { id: "b", title: "t", entryPoint: "e", database: "fresh", onboard: false, async run() {} }];`,
|
|
27
|
+
);
|
|
28
|
+
const journeys = await loadJourneys(file);
|
|
29
|
+
assert.deepEqual(
|
|
30
|
+
journeys.map((journey) => journey.id),
|
|
31
|
+
["smoke", "b"],
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await test("a module without a journeys array is refused", async (t) => {
|
|
36
|
+
await assert.rejects(
|
|
37
|
+
loadJourneys(journeysModule(t, "export const journeys = {};")),
|
|
38
|
+
/exports no journeys array/,
|
|
39
|
+
);
|
|
40
|
+
await assert.rejects(
|
|
41
|
+
loadJourneys(journeysModule(t, "export const other = [];")),
|
|
42
|
+
/exports no journeys array/,
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await test("a journey missing a field a run reads is refused", async (t) => {
|
|
47
|
+
const broken = [
|
|
48
|
+
'{ title: "t", entryPoint: "e", async run() {} }',
|
|
49
|
+
'{ id: "x", title: "t", entryPoint: "e" }',
|
|
50
|
+
'{ id: "x", title: "t", entryPoint: "e", database: 3, async run() {} }',
|
|
51
|
+
'{ id: "x", title: "t", entryPoint: "e", onboard: "yes", async run() {} }',
|
|
52
|
+
"null",
|
|
53
|
+
];
|
|
54
|
+
for (const journey of broken) {
|
|
55
|
+
await assert.rejects(
|
|
56
|
+
loadJourneys(journeysModule(t, `export const journeys = [${journey}];`)),
|
|
57
|
+
/exports a journey without an id/,
|
|
58
|
+
journey,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads of JSON a program received, narrowed through checks instead of typed
|
|
5
|
+
* by assertion. Everything here takes `unknown` because JSON arrives
|
|
6
|
+
* unchecked.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {unknown} value A parsed JSON value
|
|
11
|
+
* @returns {value is Record<string, unknown>} True for an object that is not an array or null
|
|
12
|
+
*/
|
|
13
|
+
export function isRecord(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {unknown} value A parsed JSON value
|
|
19
|
+
* @returns {value is string} True for a string
|
|
20
|
+
*/
|
|
21
|
+
export function isString(value) {
|
|
22
|
+
return typeof value === "string";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The string an object holds under its own key.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} body A parsed JSON value
|
|
29
|
+
* @param {string} key The property
|
|
30
|
+
* @returns {string | undefined} The string, or undefined when `body` is no object or the property is absent or not a string
|
|
31
|
+
*/
|
|
32
|
+
export function stringField(body, key) {
|
|
33
|
+
if (!isRecord(body)) return undefined;
|
|
34
|
+
/** @type {unknown} */
|
|
35
|
+
const value = Object.getOwnPropertyDescriptor(body, key)?.value;
|
|
36
|
+
return isString(value) ? value : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The first element of a JSON array, when it is a string.
|
|
41
|
+
*
|
|
42
|
+
* @param {unknown} body A parsed JSON value
|
|
43
|
+
* @returns {string | undefined} The first string, or undefined when `body` is no array or starts with anything else
|
|
44
|
+
*/
|
|
45
|
+
export function firstString(body) {
|
|
46
|
+
if (!Array.isArray(body)) return undefined;
|
|
47
|
+
/** @type {unknown} */
|
|
48
|
+
const first = body.at(0);
|
|
49
|
+
return isString(first) ? first : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The objects a JSON file lists, kept as written.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} file The JSON file
|
|
56
|
+
* @returns {Record<string, unknown>[]} The listed objects
|
|
57
|
+
* @throws When the file holds anything but a list of objects
|
|
58
|
+
*/
|
|
59
|
+
export function readRecordList(file) {
|
|
60
|
+
/** @type {unknown} */
|
|
61
|
+
const stored = JSON.parse(readFileSync(file, "utf8"));
|
|
62
|
+
if (!Array.isArray(stored))
|
|
63
|
+
throw new Error(`${file} is not a list of objects`);
|
|
64
|
+
const records = stored.filter(isRecord);
|
|
65
|
+
if (records.length !== stored.length)
|
|
66
|
+
throw new Error(`${file} is not a list of objects`);
|
|
67
|
+
return records;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @template T
|
|
72
|
+
* @typedef {(value: unknown) => value is T} Guard A check that narrows a parsed JSON value to `T`
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {unknown} value A parsed JSON value
|
|
77
|
+
* @returns {value is number} True for a number
|
|
78
|
+
*/
|
|
79
|
+
export function isNumber(value) {
|
|
80
|
+
return typeof value === "number";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {unknown} value A parsed JSON value
|
|
85
|
+
* @returns {value is boolean} True for a boolean
|
|
86
|
+
*/
|
|
87
|
+
export function isBoolean(value) {
|
|
88
|
+
return typeof value === "boolean";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {unknown} value A parsed JSON value
|
|
93
|
+
* @returns {value is null} True for null
|
|
94
|
+
*/
|
|
95
|
+
function isNull(value) {
|
|
96
|
+
return value === null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @template T
|
|
101
|
+
* @param {Guard<T>} item What each element must be
|
|
102
|
+
* @returns {Guard<T[]>} A guard for lists
|
|
103
|
+
*/
|
|
104
|
+
export function arrayOf(item) {
|
|
105
|
+
/**
|
|
106
|
+
* @param {unknown} value A parsed JSON value
|
|
107
|
+
* @returns {value is T[]} True for a list whose every element passes `item`
|
|
108
|
+
*/
|
|
109
|
+
return (value) => Array.isArray(value) && value.every(item);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @template T
|
|
114
|
+
* @param {Guard<T>} item What each value must be
|
|
115
|
+
* @returns {Guard<Record<string, T>>} A guard for objects keyed by any string
|
|
116
|
+
*/
|
|
117
|
+
export function recordOf(item) {
|
|
118
|
+
/**
|
|
119
|
+
* @param {unknown} value A parsed JSON value
|
|
120
|
+
* @returns {value is Record<string, T>} True for an object whose every value passes `item`
|
|
121
|
+
*/
|
|
122
|
+
return (value) => isRecord(value) && Object.values(value).every(item);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @template T
|
|
127
|
+
* @param {Guard<T>} guard What the value must be unless it is null
|
|
128
|
+
* @returns {Guard<T | null>} A guard that also accepts null
|
|
129
|
+
*/
|
|
130
|
+
export function nullable(guard) {
|
|
131
|
+
return either(guard, isNull);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @template A
|
|
136
|
+
* @template B
|
|
137
|
+
* @param {Guard<A>} first One accepted form
|
|
138
|
+
* @param {Guard<B>} second The other accepted form
|
|
139
|
+
* @returns {Guard<A | B>} A guard for either form
|
|
140
|
+
*/
|
|
141
|
+
export function either(first, second) {
|
|
142
|
+
/**
|
|
143
|
+
* @param {unknown} value A parsed JSON value
|
|
144
|
+
* @returns {value is A | B} True when one of the forms matches
|
|
145
|
+
*/
|
|
146
|
+
return (value) => first(value) || second(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @template {string} Name
|
|
151
|
+
* @param {Name[]} names The accepted strings
|
|
152
|
+
* @returns {Guard<Name>} A guard for one of the names
|
|
153
|
+
*/
|
|
154
|
+
export function oneOf(names) {
|
|
155
|
+
/**
|
|
156
|
+
* @param {unknown} value A parsed JSON value
|
|
157
|
+
* @returns {value is Name} True for one of the names
|
|
158
|
+
*/
|
|
159
|
+
return (value) => isString(value) && names.some((name) => name === value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @template T
|
|
164
|
+
* @param {new (...args: never[]) => T} constructor The class
|
|
165
|
+
* @returns {Guard<T>} A guard for its instances
|
|
166
|
+
*/
|
|
167
|
+
export function instanceOf(constructor) {
|
|
168
|
+
/**
|
|
169
|
+
* @param {unknown} value A value
|
|
170
|
+
* @returns {value is T} True for an instance
|
|
171
|
+
*/
|
|
172
|
+
return (value) => value instanceof constructor;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {Record<string, unknown>} record An object
|
|
177
|
+
* @param {string} key The property
|
|
178
|
+
* @returns {unknown} Its own value, undefined when it holds none
|
|
179
|
+
*/
|
|
180
|
+
function ownValue(record, key) {
|
|
181
|
+
return Object.getOwnPropertyDescriptor(record, key)?.value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @template {Record<string, Guard<unknown>>} Required
|
|
186
|
+
* @template {Record<string, Guard<unknown>>} Optional
|
|
187
|
+
* @typedef {{ [Key in keyof Required]: Required[Key] extends Guard<infer T> ? T : never } & { [Key in keyof Optional]?: Optional[Key] extends Guard<infer T> ? T : never }} Properties
|
|
188
|
+
*/
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* An object with the required properties, each passing its guard, and the
|
|
192
|
+
* optional ones, which pass theirs when present. Other properties are not
|
|
193
|
+
* looked at. Only own properties count.
|
|
194
|
+
*
|
|
195
|
+
* @template {Record<string, Guard<unknown>>} Required
|
|
196
|
+
* @template {Record<string, Guard<unknown>>} [Optional=Record<never, never>]
|
|
197
|
+
* @param {Required} required The guard of each required property
|
|
198
|
+
* @param {Optional} [optional] The guard of each optional property
|
|
199
|
+
* @returns {Guard<Properties<Required, Optional>>} A guard for the object
|
|
200
|
+
*/
|
|
201
|
+
export function objectOf(required, optional) {
|
|
202
|
+
/**
|
|
203
|
+
* @param {unknown} value A parsed JSON value
|
|
204
|
+
* @returns {value is Properties<Required, Optional>} True for an object with these properties
|
|
205
|
+
*/
|
|
206
|
+
return (value) =>
|
|
207
|
+
isRecord(value) &&
|
|
208
|
+
Object.entries(required).every(([key, guard]) =>
|
|
209
|
+
guard(ownValue(value, key)),
|
|
210
|
+
) &&
|
|
211
|
+
Object.entries(optional ?? {}).every(
|
|
212
|
+
([key, guard]) =>
|
|
213
|
+
ownValue(value, key) === undefined || guard(ownValue(value, key)),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @template T
|
|
219
|
+
* @param {Guard<T>} guard What the value must be
|
|
220
|
+
* @param {unknown} value The value read
|
|
221
|
+
* @param {string} source Where it was read, named in the error
|
|
222
|
+
* @param {string} expected What the value should be, named in the error
|
|
223
|
+
* @returns {T} The value
|
|
224
|
+
* @throws When the value does not pass the guard
|
|
225
|
+
*/
|
|
226
|
+
export function decode(guard, value, source, expected) {
|
|
227
|
+
if (guard(value)) return value;
|
|
228
|
+
throw new Error(`${source} is malformed: expected ${expected}`);
|
|
229
|
+
}
|