@astryxdesign/cli 0.7.2 → 0.7.3-canary.02f5fe4
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/README.md +104 -71
- package/api/build/_adapter.d.mts +58 -0
- package/api/build/_adapter.mjs +127 -3
- package/api/build/blocks/blocks.d.mts +92 -0
- package/api/build/blocks/blocks.mjs +528 -0
- package/api/build/blocks/blocks.test.mjs +238 -0
- package/api/build/blocks/blocks.weights.json +1 -0
- package/api/build/build.mjs +25 -6
- package/api/build/build.type.mjs +1 -1
- package/api/build/kit/answer.d.mts +18 -0
- package/api/build/kit/answer.mjs +195 -0
- package/api/build/kit/answer.test.mjs +108 -0
- package/api/build/kit/kind.d.mts +25 -0
- package/api/build/kit/kind.mjs +170 -0
- package/api/build/kit/kit.d.mts +2 -4
- package/api/build/kit/kit.mjs +8 -398
- package/api/build/kit/rank.d.mts +3 -32
- package/api/build/kit/rank.mjs +25 -342
- package/api/build/kit/rank.test.mjs +2 -1
- package/api/build/kit/signals.d.mts +79 -0
- package/api/build/kit/signals.mjs +295 -0
- package/api/build/kit/start.d.mts +32 -0
- package/api/build/kit/start.mjs +163 -0
- package/api/build/kit/words.d.mts +72 -0
- package/api/build/kit/words.mjs +202 -0
- package/api/build/tree.d.mts +20 -0
- package/api/build/tree.mjs +53 -0
- package/api/build/tree.test.mjs +185 -0
- package/api/build/tree.weights.json +1 -0
- package/api/component/_adapter.mjs +39 -5
- package/api/component/component-replacement.test.mjs +1 -0
- package/api/component/component.mjs +17 -3
- package/api/component/component.test.mjs +15 -0
- package/api/component/doc-links.test.mjs +2 -2
- package/api/discover/_adapter.d.mts +1 -2
- package/api/discover/_adapter.mjs +42 -17
- package/api/discover/_adapter.test.mjs +42 -0
- package/api/discover/discover.mjs +0 -1
- package/api/docs/_adapter.d.mts +2 -2
- package/api/docs/_adapter.mjs +82 -23
- package/api/docs/docs-homes.test.mjs +127 -0
- package/api/docs/docs.doc.mjs +3 -3
- package/api/docs/docs.test.mjs +35 -19
- package/api/docs/integration-tree.test.mjs +20 -20
- package/api/docs/integrationDocs.test.mjs +1 -1
- package/api/docs/namespace-section.test.mjs +6 -6
- package/api/docs/reference-blocks.test.mjs +2 -2
- package/api/doctor/_adapter.d.mts +33 -13
- package/api/doctor/_adapter.mjs +55 -25
- package/api/doctor/doctor.d.mts +23 -5
- package/api/doctor/doctor.mjs +183 -40
- package/api/doctor/doctor.test.mjs +95 -12
- package/api/hook/hook.mjs +1 -1
- package/api/init/init.doc.mjs +20 -6
- package/api/init/init.test.mjs +18 -2
- package/api/init/init.type.d.mts +3 -3
- package/api/init/init.type.mjs +3 -3
- package/api/init/remove/remove.mjs +17 -4
- package/api/init/remove/remove.test.mjs +114 -2
- package/api/init/run/run.mjs +69 -6
- package/api/integration/_adapter.d.mts +8 -0
- package/api/integration/_adapter.mjs +21 -1
- package/api/integration/authoring-checks.mjs +83 -9
- package/api/integration/authoring-checks.test.mjs +220 -0
- package/api/integration/pack-check.mjs +123 -4
- package/api/integration/pack-check.test.mjs +87 -0
- package/api/integration/validate-integration-case-disk.test.mjs +111 -0
- package/api/integration/validate-integration-fixes.test.mjs +6 -5
- package/api/integration/validate-integration-gaps.test.mjs +1975 -0
- package/api/integration/validate-integration.d.mts +15 -2
- package/api/integration/validate-integration.mjs +298 -39
- package/api/integration/validate-integration.type.d.mts +90 -1
- package/api/integration/validate-integration.type.mjs +67 -2
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/search/ORDERING.md +28 -0
- package/api/search/_adapter.d.mts +82 -2
- package/api/search/_adapter.mjs +157 -10
- package/api/search/_adapter.test.mjs +173 -0
- package/api/search/_candidate-score.d.mts +40 -0
- package/api/search/_candidate-score.mjs +238 -0
- package/api/search/_cards.json +6098 -0
- package/api/search/_compound.d.mts +50 -0
- package/api/search/_compound.mjs +170 -0
- package/api/search/_doc-searcher.d.mts +4 -0
- package/api/search/_doc-searcher.mjs +23 -0
- package/api/search/_doc-searcher.test.mjs +36 -0
- package/api/search/_doc-searcher.weights.json +1 -0
- package/api/search/_doc-text.d.mts +30 -0
- package/api/search/_doc-text.mjs +87 -0
- package/api/search/_entries.d.mts +63 -0
- package/api/search/_entries.mjs +232 -0
- package/api/search/_entries.test.mjs +48 -0
- package/api/search/_features.d.mts +35 -0
- package/api/search/_features.mjs +155 -0
- package/api/search/_gather-components.d.mts +26 -0
- package/api/search/_gather-components.mjs +284 -0
- package/api/search/_gather-docs.d.mts +20 -0
- package/api/search/_gather-docs.mjs +254 -0
- package/api/search/_gather-hooks.d.mts +12 -0
- package/api/search/_gather-hooks.mjs +52 -0
- package/api/search/_gather-templates.d.mts +25 -0
- package/api/search/_gather-templates.mjs +107 -0
- package/api/search/_lexical.d.mts +135 -0
- package/api/search/_lexical.mjs +351 -0
- package/api/search/_main-searcher.d.mts +19 -0
- package/api/search/_main-searcher.mjs +115 -0
- package/api/search/_main-searcher.test.mjs +106 -0
- package/api/search/_main-searcher.weights.json +1 -0
- package/api/search/_module-doc.d.mts +32 -0
- package/api/search/_module-doc.mjs +78 -0
- package/api/search/_order.d.mts +32 -0
- package/api/search/_order.mjs +162 -0
- package/api/search/_order.test.mjs +72 -0
- package/api/search/_priority.d.mts +50 -0
- package/api/search/_priority.mjs +139 -0
- package/api/search/_query-words.d.mts +25 -0
- package/api/search/_query-words.mjs +92 -0
- package/api/search/_query.d.mts +65 -0
- package/api/search/_query.mjs +176 -0
- package/api/search/_result.d.mts +97 -0
- package/api/search/_result.mjs +150 -0
- package/api/search/_score.d.mts +60 -0
- package/api/search/_score.mjs +283 -0
- package/api/search/_synonyms.d.mts +21 -0
- package/api/search/_synonyms.mjs +67 -0
- package/api/search/_template-searcher.d.mts +4 -0
- package/api/search/_template-searcher.mjs +23 -0
- package/api/search/_template-searcher.test.mjs +40 -0
- package/api/search/_template-searcher.weights.json +1 -0
- package/api/search/_titles.d.mts +33 -0
- package/api/search/_titles.mjs +65 -0
- package/api/search/_words.d.mts +64 -0
- package/api/search/_words.mjs +157 -0
- package/api/search/search.d.mts +22 -305
- package/api/search/search.doc.mjs +8 -6
- package/api/search/search.mjs +56 -2482
- package/api/search/search.test.mjs +14 -14
- package/api/search/search.type.d.mts +6 -1
- package/api/search/search.type.mjs +6 -1
- package/api/search/tree.d.mts +15 -0
- package/api/search/tree.mjs +40 -0
- package/api/search/tree.test.mjs +66 -0
- package/api/search/tree.weights.json +1 -0
- package/api/template/template.mjs +5 -1
- package/api/theme/build/build.packed-old-core.test.mjs +1 -0
- package/api/upgrade/_adapter.d.mts +57 -9
- package/api/upgrade/_adapter.mjs +198 -28
- package/api/upgrade/registry/registry.d.mts +10 -4
- package/api/upgrade/registry/registry.mjs +33 -16
- package/api/upgrade/registry/registry.test.mjs +85 -0
- package/api/upgrade/run/run.d.mts +4 -2
- package/api/upgrade/run/run.mjs +13 -2
- package/api/upgrade/run/run.test.mjs +417 -3
- package/api/upgrade/upgrade.doc.mjs +10 -5
- package/api/upgrade/upgrade.mjs +13 -2
- package/api/upgrade/upgrade.test.mjs +194 -0
- package/api/upgrade/upgrade.type.d.mts +5 -3
- package/api/upgrade/upgrade.type.mjs +8 -6
- package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
- package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
- package/assets/docs/author-a-theme.doc.mjs +2 -2
- package/assets/docs/is-my-code-good.doc.mjs +287 -0
- package/assets/docs/tree/add-a-component.doc.mjs +1 -1
- package/assets/docs/tree/add-a-theme.doc.mjs +1 -1
- package/assets/docs/tree/add-a-topic.doc.mjs +2 -2
- package/assets/docs/tree/agent-guidance.doc.mjs +1 -1
- package/assets/docs/tree/api.doc.mjs +6 -0
- package/assets/docs/tree/{integrations.doc.mjs → authoring.doc.mjs} +15 -6
- package/assets/docs/tree/block-template.doc.mjs +1 -1
- package/assets/docs/tree/build-the-template.doc.mjs +1 -1
- package/assets/docs/tree/building-blocks.doc.mjs +2 -2
- package/assets/docs/tree/check-the-props.doc.mjs +1 -1
- package/assets/docs/tree/check-your-docs.doc.mjs +2 -2
- package/assets/docs/tree/checks.doc.mjs +3 -3
- package/assets/docs/tree/ci-and-containers.doc.mjs +13 -0
- package/assets/docs/tree/cli-command.doc.mjs +1 -1
- package/assets/docs/tree/cli.doc.mjs +1 -1
- package/assets/docs/tree/codemods.doc.mjs +1 -1
- package/assets/docs/tree/component-family.doc.mjs +1 -1
- package/assets/docs/tree/component-imports.doc.mjs +1 -1
- package/assets/docs/tree/components.doc.mjs +1 -1
- package/assets/docs/tree/configuration.doc.mjs +1 -1
- package/assets/docs/tree/debug-and-gap-reports.doc.mjs +1 -1
- package/assets/docs/tree/define-the-theme.doc.mjs +1 -1
- package/assets/docs/tree/describe-the-component.doc.mjs +1 -1
- package/assets/docs/tree/docs.doc.mjs +1 -1
- package/assets/docs/tree/document-the-template.doc.mjs +1 -1
- package/assets/docs/tree/document-the-theme.doc.mjs +1 -1
- package/assets/docs/tree/export-template-assets.doc.mjs +1 -1
- package/assets/docs/tree/extend-or-replace.doc.mjs +1 -1
- package/assets/docs/{authoring.doc.mjs → tree/field-reference.doc.mjs} +4 -3
- package/assets/docs/tree/fonts-and-assets.doc.mjs +1 -1
- package/assets/docs/tree/generate-a-palette.doc.mjs +1 -1
- package/assets/docs/tree/global-integrations.doc.mjs +2 -2
- package/assets/docs/tree/grade-template-with-agent.doc.mjs +4 -4
- package/assets/docs/tree/help.doc.mjs +2 -2
- package/assets/docs/tree/host-entry.doc.mjs +128 -0
- package/assets/docs/tree/links.doc.mjs +1 -1
- package/assets/docs/tree/package-and-test.doc.mjs +1 -1
- package/assets/docs/tree/page-template.doc.mjs +1 -1
- package/assets/docs/tree/publishing.doc.mjs +1 -1
- package/assets/docs/tree/quick-start.doc.mjs +1 -1
- package/assets/docs/tree/replace-a-core-component.doc.mjs +1 -1
- package/assets/docs/tree/replace-a-core-template.doc.mjs +1 -1
- package/assets/docs/tree/sections-and-placement.doc.mjs +1 -1
- package/assets/docs/tree/see-it-in-an-app.doc.mjs +1 -1
- package/assets/docs/tree/ship.doc.mjs +2 -2
- package/assets/docs/tree/short-and-findable.doc.mjs +1 -1
- package/assets/docs/tree/single-component.doc.mjs +1 -1
- package/assets/docs/tree/start-a-template.doc.mjs +1 -1
- package/assets/docs/tree/subcomponent.doc.mjs +1 -1
- package/assets/docs/tree/template-assets.doc.mjs +1 -1
- package/assets/docs/tree/template-doc-overview.doc.mjs +2 -2
- package/assets/docs/tree/template-fonts.doc.mjs +1 -1
- package/assets/docs/tree/template-grading-rubric.doc.mjs +17 -38
- package/assets/docs/tree/template-icons.doc.mjs +1 -1
- package/assets/docs/tree/template-images-media.doc.mjs +1 -1
- package/assets/docs/tree/template-styles.doc.mjs +1 -1
- package/assets/docs/tree/templates.doc.mjs +1 -1
- package/assets/docs/tree/test-in-an-app.doc.mjs +1 -1
- package/assets/docs/tree/test-template-in-app.doc.mjs +1 -1
- package/assets/docs/tree/themes.doc.mjs +1 -1
- package/assets/docs/tree/tokens-data.doc.mjs +2 -2
- package/assets/docs/tree/troubleshooting.doc.mjs +1 -1
- package/assets/docs/tree/upgrading.doc.mjs +1 -1
- package/assets/docs/tree/use-a-theme-in-an-app.doc.mjs +1 -1
- package/assets/docs/tree/verify-packed-template.doc.mjs +1 -1
- package/assets/docs/tree/versioning.doc.mjs +5 -1
- package/assets/docs/tree/write-good-templates.doc.mjs +1 -1
- package/assets/docs/tree/write-the-template-file.doc.mjs +1 -1
- package/assets/docs/use-integrations.doc.mjs +120 -0
- package/assets/docs/working-with-ai.doc.mjs +6 -2
- package/assets/templates/themes/butter/butterTheme.ts +4 -2
- package/assets/templates/themes/stone/stoneTheme.ts +4 -2
- package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
- package/authoring/debug/parse.d.mts +1 -1
- package/authoring/doctypes/schema/schema.doc.mjs +1 -1
- package/authoring/integration/integration.doc.mjs +1 -1
- package/authoring/integration/type.ts +1 -1
- package/clients/cli/bin/astryx.mjs +7 -78
- package/clients/cli/command-result-coverage.test.mjs +6 -5
- package/clients/cli/commands/build-theme.color-scheme.test.mjs +1 -1
- package/clients/cli/commands/{component/index.mjs → component.mjs} +37 -37
- package/clients/cli/commands/component.test.mjs +6 -6
- package/clients/cli/commands/docs.doc.mjs +2 -2
- package/clients/cli/commands/docs.mjs +1 -1
- package/clients/cli/commands/docs.test.mjs +14 -14
- package/clients/cli/commands/doctor-integration.test.mjs +49 -1
- package/clients/cli/commands/doctor.doc.mjs +1 -1
- package/clients/cli/commands/doctor.mjs +40 -0
- package/clients/cli/commands/doctor.test.mjs +65 -0
- package/clients/cli/commands/{hook/index.mjs → hook.mjs} +22 -22
- package/clients/cli/commands/import-hint-correctness.test.mjs +1 -1
- package/clients/cli/commands/init.behavior.test.mjs +230 -1
- package/clients/cli/commands/init.doc.mjs +28 -9
- package/clients/cli/commands/init.flag-help.test.mjs +1 -0
- package/clients/cli/commands/init.mjs +2 -2
- package/clients/cli/commands/integration-check.test.mjs +48 -0
- package/clients/cli/commands/integration.doc.mjs +1 -1
- package/clients/cli/commands/manifest.doc.mjs +1 -1
- package/clients/cli/commands/manifest.mjs +67 -0
- package/clients/cli/commands/outside-project.test.mjs +101 -1
- package/clients/cli/commands/postinstall.doc.mjs +23 -0
- package/clients/cli/commands/postinstall.mjs +31 -0
- package/clients/cli/commands/setup-nudge.test.mjs +78 -0
- package/clients/cli/commands/{build-theme.mjs → theme.mjs} +1 -1
- package/clients/cli/commands/upgrade.doc.mjs +4 -2
- package/clients/cli/commands/upgrade.mjs +9 -2
- package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
- package/clients/cli/index.mjs +54 -94
- package/clients/cli/lib/component-format.mjs +1 -1
- package/clients/cli/lib/define-command.mjs +12 -7
- package/clients/cli/lib/exit-codes.test.mjs +5 -0
- package/clients/cli/lib/manifest.test.mjs +12 -0
- package/clients/cli/lib/welcome-box.mjs +50 -0
- package/clients/cli/run.mjs +108 -0
- package/foundation/agent-docs/agent-doc-state.d.mts +32 -7
- package/foundation/agent-docs/agent-doc-state.mjs +58 -9
- package/foundation/agent-docs/agent-docs.d.mts +100 -12
- package/foundation/agent-docs/agent-docs.mjs +1017 -105
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
- package/foundation/agent-docs/agent-docs.test.mjs +683 -39
- package/foundation/agent-docs/computer-agent-text.d.mts +39 -2
- package/foundation/agent-docs/computer-agent-text.mjs +135 -8
- package/foundation/agent-docs/computer-agent-text.test.mjs +99 -2
- package/foundation/config/project.mjs +20 -22
- package/foundation/config/project.test.mjs +56 -0
- package/foundation/discovery/authoring-self-docs.d.mts +7 -0
- package/foundation/discovery/authoring-self-docs.mjs +16 -4
- package/foundation/discovery/authoring-self-docs.test.mjs +15 -3
- package/foundation/discovery/authoring-surface.d.mts +1 -1
- package/foundation/discovery/authoring-surface.mjs +2 -2
- package/foundation/discovery/authoring-surface.test.mjs +1 -1
- package/foundation/discovery/cli-self-docs.d.mts +2 -1
- package/foundation/discovery/cli-self-docs.mjs +4 -3
- package/foundation/discovery/cli-self-docs.test.mjs +2 -2
- package/foundation/discovery/component-discovery.d.mts +37 -0
- package/foundation/discovery/component-discovery.mjs +80 -5
- package/foundation/discovery/component-loader.d.mts +1 -1
- package/foundation/discovery/docs-discovery.mjs +14 -1
- package/foundation/discovery/module-exports.d.mts +41 -0
- package/foundation/discovery/module-exports.mjs +253 -0
- package/foundation/discovery/runner-allowlist.d.mts +2 -4
- package/foundation/discovery/runner-allowlist.mjs +2 -4
- package/foundation/discovery/runner-commands.d.mts +77 -0
- package/foundation/discovery/runner-commands.mjs +294 -0
- package/foundation/discovery/runner-commands.test.mjs +128 -0
- package/foundation/discovery/stray-pointers.test.mjs +1 -1
- package/foundation/discovery/template-adapter.mjs +18 -1
- package/foundation/discovery/template-needs.mjs +1 -44
- package/foundation/discovery/theme-discovery.mjs +147 -3
- package/foundation/doc-compiler/doc-loads.test.mjs +33 -9
- package/foundation/doc-compiler/read.mjs +59 -1
- package/foundation/doc-compiler/read.test.mjs +11 -2
- package/foundation/doc-compiler/split-topic-keywords.test.mjs +3 -2
- package/foundation/doc-compiler/tree.d.mts +32 -6
- package/foundation/doc-compiler/tree.mjs +77 -43
- package/foundation/doc-compiler/tree.test.mjs +7 -7
- package/foundation/fs/paths.d.mts +70 -8
- package/foundation/fs/paths.mjs +556 -38
- package/foundation/fs/paths.test.mjs +430 -54
- package/foundation/fs/read-record.d.mts +18 -0
- package/foundation/fs/read-record.mjs +131 -0
- package/foundation/fs/read-record.test.mjs +77 -0
- package/foundation/integrations/autolink.d.mts +2 -1
- package/foundation/integrations/autolink.mjs +4 -13
- package/foundation/integrations/cli-peer-version.d.mts +36 -0
- package/foundation/integrations/cli-peer-version.mjs +132 -0
- package/foundation/integrations/cli-peer-version.test.mjs +354 -0
- package/foundation/integrations/computer-integrations.d.mts +6 -3
- package/foundation/integrations/computer-integrations.mjs +38 -9
- package/foundation/integrations/contribution-inventory.d.mts +15 -20
- package/foundation/integrations/contribution-inventory.mjs +41 -5
- package/foundation/integrations/exports-subpaths.d.mts +57 -0
- package/foundation/integrations/exports-subpaths.mjs +113 -0
- package/foundation/integrations/integrations.d.mts +27 -1
- package/foundation/integrations/integrations.mjs +61 -2
- package/foundation/integrations/public-imports.d.mts +118 -0
- package/foundation/integrations/public-imports.mjs +1769 -0
- package/foundation/integrations/public-imports.test.mjs +1793 -0
- package/foundation/integrations/validate-contributions.d.mts +115 -0
- package/foundation/integrations/validate-contributions.mjs +1168 -13
- package/foundation/release/cache.test.mjs +24 -21
- package/foundation/release/global-state.d.mts +8 -4
- package/foundation/release/global-state.mjs +4 -2
- package/foundation/release/handoff.d.mts +23 -4
- package/foundation/release/handoff.mjs +175 -8
- package/foundation/release/notices.d.mts +62 -8
- package/foundation/release/notices.mjs +136 -25
- package/foundation/release/notices.test.mjs +21 -0
- package/foundation/release/run-context.mjs +14 -0
- package/foundation/response/response-types.doc.mjs +1 -1
- package/foundation/route/route.d.mts +285 -0
- package/foundation/route/route.mjs +324 -0
- package/foundation/route/route.test.mjs +153 -0
- package/foundation/runtime/hosted-runtime.test.mjs +403 -0
- package/foundation/runtime/locked-versions.d.mts +32 -0
- package/foundation/runtime/locked-versions.mjs +65 -0
- package/foundation/runtime/package-root.d.mts +91 -0
- package/foundation/runtime/package-root.mjs +174 -0
- package/foundation/runtime/package-set.d.mts +62 -0
- package/foundation/runtime/package-set.mjs +130 -0
- package/foundation/runtime/package-set.test.mjs +432 -0
- package/foundation/text/string-utils.d.mts +2 -1
- package/foundation/text/string-utils.mjs +16 -5
- package/foundation/text/suggest-order.d.mts +72 -0
- package/foundation/text/suggest-order.mjs +363 -0
- package/foundation/text/suggest-order.test.mjs +259 -0
- package/host/door-conformance.test.mjs +274 -0
- package/host/index.d.ts +116 -0
- package/host/index.mjs +295 -0
- package/host/index.test.mjs +302 -0
- package/package.json +14 -9
|
@@ -0,0 +1,1975 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Package mistakes `astryx doctor integration validate` and
|
|
5
|
+
* `astryx integration check` used to pass, crash on, or describe badly: names
|
|
6
|
+
* that differ from file names, ids readers cannot take, duplicate and
|
|
7
|
+
* case-mismatched docs, symlinked or absolute roots, a root that is a file, a
|
|
8
|
+
* package.json missing what npm needs, manifest key typos, docs and manifests
|
|
9
|
+
* with no default export, and theme descriptors named for built output. Each
|
|
10
|
+
* finding carries a `Fix:` that holds when followed.
|
|
11
|
+
*
|
|
12
|
+
* Temp dirs live under the repo root, as in validate-integration.test.mjs.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import {parse} from '@babel/parser';
|
|
20
|
+
import {spawnSync} from 'node:child_process';
|
|
21
|
+
import {
|
|
22
|
+
validateIntegration,
|
|
23
|
+
validateLocalIntegration,
|
|
24
|
+
} from './validate-integration.mjs';
|
|
25
|
+
import {
|
|
26
|
+
integrationCheck,
|
|
27
|
+
strictIssues,
|
|
28
|
+
summarizeCheck,
|
|
29
|
+
} from './check/check.mjs';
|
|
30
|
+
import {componentDocNamesCheck} from '../../foundation/integrations/validate-contributions.mjs';
|
|
31
|
+
import {formatCliCommand} from '../../foundation/env/package-manager.mjs';
|
|
32
|
+
|
|
33
|
+
let tmpDir;
|
|
34
|
+
let copies = 0;
|
|
35
|
+
|
|
36
|
+
const CLI = path.join(
|
|
37
|
+
process.cwd(),
|
|
38
|
+
'packages',
|
|
39
|
+
'cli',
|
|
40
|
+
'clients',
|
|
41
|
+
'cli',
|
|
42
|
+
'bin',
|
|
43
|
+
'astryx.mjs',
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-validate-gaps-'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const MANIFEST = 'astryx.integration.mjs';
|
|
55
|
+
const THEME_TYPE =
|
|
56
|
+
"/** @type {import('@astryxdesign/cli/authoring').ThemeDoc} */\n";
|
|
57
|
+
|
|
58
|
+
/** @param {Record<string, unknown>} roots */
|
|
59
|
+
const manifest = roots => `export default ${JSON.stringify(roots)};\n`;
|
|
60
|
+
/** @param {string} name */
|
|
61
|
+
const componentDoc = name =>
|
|
62
|
+
`export default {type: 'component', name: '${name}', displayName: '${name}', usage: {description: '${name}.'}, props: []};\n`;
|
|
63
|
+
/** @param {string} name */
|
|
64
|
+
const componentSource = name => `export function ${name}() { return null; }\n`;
|
|
65
|
+
/** @param {string} name */
|
|
66
|
+
const topic = name =>
|
|
67
|
+
`export default ${JSON.stringify({
|
|
68
|
+
type: 'generic',
|
|
69
|
+
name,
|
|
70
|
+
title: name,
|
|
71
|
+
description: `The ${name} topic.`,
|
|
72
|
+
sections: [{title: 'Overview', content: [{type: 'prose', text: name}]}],
|
|
73
|
+
})};\n`;
|
|
74
|
+
/** @param {string} name */
|
|
75
|
+
const templateDoc = name =>
|
|
76
|
+
`export default {type: 'block', name: '${name}', displayName: '${name}', aspectRatio: 1};\n`;
|
|
77
|
+
const templateSource = 'export default function Block() { return null; }\n';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {Record<string, string>} files package-relative path -> contents
|
|
81
|
+
* @param {Record<string, unknown>} [pkg] package.json
|
|
82
|
+
* @returns {string} the package directory
|
|
83
|
+
*/
|
|
84
|
+
function writePackage(files, pkg = {name: '@acme/kit', version: '1.0.0'}) {
|
|
85
|
+
const dir = path.join(tmpDir, 'pkg');
|
|
86
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
87
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg));
|
|
88
|
+
for (const [file, contents] of Object.entries(files)) {
|
|
89
|
+
fs.mkdirSync(path.dirname(path.join(dir, file)), {recursive: true});
|
|
90
|
+
fs.writeFileSync(path.join(dir, file), contents);
|
|
91
|
+
}
|
|
92
|
+
return dir;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Carry out a fix on a fresh copy of the package.
|
|
97
|
+
* @param {string} dir
|
|
98
|
+
* @param {{move?: Array<[string, string]>, write?: Record<string, string>, remove?: string[]}} steps
|
|
99
|
+
*/
|
|
100
|
+
function follow(dir, {move = [], write = {}, remove = []}) {
|
|
101
|
+
copies += 1;
|
|
102
|
+
const copy = path.join(tmpDir, `followed-${copies}`);
|
|
103
|
+
fs.cpSync(dir, copy, {recursive: true, verbatimSymlinks: true});
|
|
104
|
+
for (const file of remove) {
|
|
105
|
+
fs.rmSync(path.join(copy, file), {recursive: true, force: true});
|
|
106
|
+
}
|
|
107
|
+
for (const [from, to] of move) {
|
|
108
|
+
fs.mkdirSync(path.dirname(path.join(copy, to)), {recursive: true});
|
|
109
|
+
fs.renameSync(path.join(copy, from), path.join(copy, to));
|
|
110
|
+
}
|
|
111
|
+
for (const [file, contents] of Object.entries(write)) {
|
|
112
|
+
fs.mkdirSync(path.dirname(path.join(copy, file)), {recursive: true});
|
|
113
|
+
fs.writeFileSync(path.join(copy, file), contents);
|
|
114
|
+
}
|
|
115
|
+
return copy;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {string} dir */
|
|
119
|
+
async function issuesOf(dir) {
|
|
120
|
+
return (await validateLocalIntegration(dir)).issues;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The notes validate lists beside its issues, which count nowhere: the layout
|
|
125
|
+
* findings added after the last minor release, each with its fix.
|
|
126
|
+
* @param {string} dir
|
|
127
|
+
* @returns {Promise<Array<{code: string, message: string}>>}
|
|
128
|
+
*/
|
|
129
|
+
async function listedOf(dir) {
|
|
130
|
+
return (await validateIntegration(undefined, {cwd: dir})).data.notes ?? [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Issues and notes together: what a followed fix must clear.
|
|
135
|
+
* @param {string} dir
|
|
136
|
+
*/
|
|
137
|
+
async function allOf(dir) {
|
|
138
|
+
return [...(await issuesOf(dir)), ...(await listedOf(dir))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one issue with this code, which the test is about.
|
|
143
|
+
* @param {Array<{code: string, message: string}>} issues
|
|
144
|
+
* @param {string} code
|
|
145
|
+
*/
|
|
146
|
+
function only(issues, code) {
|
|
147
|
+
const found = issues.filter(issue => issue.code === code);
|
|
148
|
+
expect(found).toHaveLength(1);
|
|
149
|
+
return found[0].message;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A component doc whose import names its published subpath.
|
|
154
|
+
* @param {string} stem the doc's file name
|
|
155
|
+
* @param {string} name
|
|
156
|
+
*/
|
|
157
|
+
const namedDoc = (stem, name) =>
|
|
158
|
+
componentDoc(name).replace(
|
|
159
|
+
"type: 'component',",
|
|
160
|
+
`type: 'component', import: '@acme/kit/components/${stem}',`,
|
|
161
|
+
);
|
|
162
|
+
/**
|
|
163
|
+
* A package that publishes each file under its components folder.
|
|
164
|
+
* @param {Record<string, string>} files
|
|
165
|
+
*/
|
|
166
|
+
const writeNamed = files =>
|
|
167
|
+
writePackage(files, {
|
|
168
|
+
name: '@acme/kit',
|
|
169
|
+
version: '1.0.0',
|
|
170
|
+
exports: {
|
|
171
|
+
'./components/*': './components/*.tsx',
|
|
172
|
+
'./templates/*': './templates/*.tsx',
|
|
173
|
+
'.': './index.ts',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('a name that differs from its file name', () => {
|
|
178
|
+
it('lists a component doc whose name is not the name it is listed under, without an issue', async () => {
|
|
179
|
+
const dir = writeNamed({
|
|
180
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
181
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
182
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(await issuesOf(dir)).toEqual([]);
|
|
186
|
+
const issues = await allOf(dir);
|
|
187
|
+
expect(issues.map(issue => issue.code)).toEqual(['doc_name_mismatch']);
|
|
188
|
+
expect(Object.keys(issues[0]).sort()).toEqual(['code', 'message']);
|
|
189
|
+
expect(issues[0].message).toBe(
|
|
190
|
+
'Component doc "AcmeWidget.doc.mjs" sets name: \'AcmeWidgets\', but components/AcmeWidget.tsx, the module its import names, does not export AcmeWidgets, so Astryx lists the component as "AcmeWidget" (its file name), and `astryx component AcmeWidget` prints `import {AcmeWidgets}`. Fix: set name: \'AcmeWidget\' in AcmeWidget.doc.mjs.',
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const fixed = follow(dir, {
|
|
194
|
+
write: {
|
|
195
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidget'),
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
expect(await allOf(fixed)).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it.each([
|
|
202
|
+
[
|
|
203
|
+
'a re-export from another package',
|
|
204
|
+
"export {AcmeWidgets} from '@acme/ui';\n",
|
|
205
|
+
'AcmeWidget.tsx re-exports AcmeWidgets from "@acme/ui", another package this check does not read.',
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
'a .js specifier this read cannot follow',
|
|
209
|
+
"export {AcmeWidgets} from './impl/widgets.js';\n",
|
|
210
|
+
'AcmeWidget.tsx re-exports AcmeWidgets from "./impl/widgets.js", which resolves to no file this check can read.',
|
|
211
|
+
],
|
|
212
|
+
[
|
|
213
|
+
'an export * from another package',
|
|
214
|
+
"export * from '@acme/ui';\n",
|
|
215
|
+
'AcmeWidget.tsx re-exports everything from "@acme/ui", another package this check does not read.',
|
|
216
|
+
],
|
|
217
|
+
[
|
|
218
|
+
'a destructured export',
|
|
219
|
+
'const parts = {AcmeWidgets: () => null};\nexport const {AcmeWidgets} = parts;\n',
|
|
220
|
+
'AcmeWidget.tsx exports a destructured binding, whose names this check does not read.',
|
|
221
|
+
],
|
|
222
|
+
[
|
|
223
|
+
'a TypeScript namespace export',
|
|
224
|
+
'export namespace AcmeWidgets {\n export const Root = () => null;\n}\n',
|
|
225
|
+
'AcmeWidget.tsx exports AcmeWidgets as a TypeScript namespace, which has a runtime value only when it holds one.',
|
|
226
|
+
],
|
|
227
|
+
[
|
|
228
|
+
'an export assignment',
|
|
229
|
+
'const AcmeWidgets = () => null;\nexport = {AcmeWidgets};\n',
|
|
230
|
+
'AcmeWidget.tsx uses `export =`, whose names this check does not read.',
|
|
231
|
+
],
|
|
232
|
+
])(
|
|
233
|
+
'lists a doc name the code cannot judge as not checked, with no finding: %s',
|
|
234
|
+
async (_label, source, reason) => {
|
|
235
|
+
const dir = writeNamed({
|
|
236
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
237
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
238
|
+
'components/AcmeWidget.tsx': source,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
242
|
+
expect(data.notes ?? []).toEqual([]);
|
|
243
|
+
expect(data.docNames).toEqual({
|
|
244
|
+
status: 'checked',
|
|
245
|
+
findings: [],
|
|
246
|
+
unchecked: [
|
|
247
|
+
{file: 'components/AcmeWidget.doc.mjs', name: 'AcmeWidgets', reason},
|
|
248
|
+
],
|
|
249
|
+
});
|
|
250
|
+
// Counted as not compared, never as a failure, even under --strict.
|
|
251
|
+
const checked = await integrationCheck({cwd: dir, strict: true});
|
|
252
|
+
const propsNotCompared = checked.data.checks
|
|
253
|
+
.map(check => check.response.data.propDrift?.unchecked?.length ?? 0)
|
|
254
|
+
.reduce((a, b) => a + b, 0);
|
|
255
|
+
expect(checked.data.unchecked).toBe(1 + propsNotCompared);
|
|
256
|
+
expect(
|
|
257
|
+
checked.data.failures.filter(
|
|
258
|
+
failure => failure.code === 'doc_names_skipped',
|
|
259
|
+
),
|
|
260
|
+
).toEqual([]);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
it.each([
|
|
265
|
+
[
|
|
266
|
+
'a source that exports both names',
|
|
267
|
+
'export function AcmeWidget() { return null; }\nexport const AcmeWidgets = AcmeWidget;\n',
|
|
268
|
+
],
|
|
269
|
+
[
|
|
270
|
+
'an export import alias, a runtime value',
|
|
271
|
+
'namespace Impl {\n export const Widgets = () => null;\n}\nexport function AcmeWidget() { return null; }\nexport import AcmeWidgets = Impl.Widgets;\n',
|
|
272
|
+
],
|
|
273
|
+
[
|
|
274
|
+
'an enum, a runtime value',
|
|
275
|
+
'export function AcmeWidget() { return null; }\nexport enum AcmeWidgets {\n Small,\n}\n',
|
|
276
|
+
],
|
|
277
|
+
])(
|
|
278
|
+
'checks a doc name the source exports, with nothing listed: %s',
|
|
279
|
+
async (_label, source) => {
|
|
280
|
+
const dir = writeNamed({
|
|
281
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
282
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
283
|
+
'components/AcmeWidget.tsx': source,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
287
|
+
expect(data.issues).toEqual([]);
|
|
288
|
+
expect(data.docNames).toEqual({
|
|
289
|
+
status: 'checked',
|
|
290
|
+
findings: [],
|
|
291
|
+
unchecked: [],
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
it('prints the doc names it could not check', () => {
|
|
297
|
+
const dir = writeNamed({
|
|
298
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
299
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
300
|
+
'components/AcmeWidget.tsx': "export {AcmeWidgets} from '@acme/ui';\n",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const run = spawnSync(
|
|
304
|
+
process.execPath,
|
|
305
|
+
[CLI, 'doctor', 'integration', 'validate'],
|
|
306
|
+
{cwd: dir, encoding: 'utf-8', timeout: 60_000},
|
|
307
|
+
);
|
|
308
|
+
expect(run.status, run.stderr).toBe(0);
|
|
309
|
+
expect(run.stdout).toContain(
|
|
310
|
+
'[info] Component names not checked: 1 doc(s) whose source exports this check cannot read:',
|
|
311
|
+
);
|
|
312
|
+
expect(run.stdout).toContain('components/AcmeWidget.doc.mjs');
|
|
313
|
+
expect(run.stdout).toContain(
|
|
314
|
+
'AcmeWidget.tsx re-exports AcmeWidgets from "@acme/ui", another package this check does not read.',
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('does not count a member a namespace exports as an export of the module', async () => {
|
|
319
|
+
const dir = writeNamed({
|
|
320
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
321
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
322
|
+
'components/AcmeWidget.tsx':
|
|
323
|
+
'namespace Impl {\n export const AcmeWidgets = () => null;\n}\nexport function AcmeWidget() { return null; }\n',
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(only(await allOf(dir), 'doc_name_mismatch')).toContain(
|
|
327
|
+
'components/AcmeWidget.tsx, the module its import names, does not export AcmeWidgets',
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('follows a local re-export that does not export the documented name', async () => {
|
|
332
|
+
const dir = writeNamed({
|
|
333
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
334
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
335
|
+
'components/AcmeWidget.tsx': "export * from './impl';\n",
|
|
336
|
+
'components/impl.tsx': componentSource('AcmeWidget'),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(only(await allOf(dir), 'doc_name_mismatch')).toContain(
|
|
340
|
+
"sets name: 'AcmeWidgets'",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('proposes the export a kebab-case source holds, never its file name', async () => {
|
|
345
|
+
const dir = writeNamed({
|
|
346
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
347
|
+
'components/acme-widget.doc.mjs': namedDoc('acme-widget', 'AcmeWidgets'),
|
|
348
|
+
'components/acme-widget.tsx': componentSource('AcmeWidget'),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const message = only(await allOf(dir), 'doc_name_mismatch');
|
|
352
|
+
expect(message).toBe(
|
|
353
|
+
'Component doc "acme-widget.doc.mjs" sets name: \'AcmeWidgets\', but components/acme-widget.tsx, the module its import names, does not export AcmeWidgets, so Astryx lists the component as "acme-widget" (its file name), and `astryx component acme-widget` prints `import {AcmeWidgets}`. Fix: set name: \'AcmeWidget\' in acme-widget.doc.mjs, the component components/acme-widget.tsx exports.',
|
|
354
|
+
);
|
|
355
|
+
const fixed = follow(dir, {
|
|
356
|
+
write: {
|
|
357
|
+
'components/acme-widget.doc.mjs': namedDoc('acme-widget', 'AcmeWidget'),
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
expect(await allOf(fixed)).toEqual([]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('names no single export when a source exports several components', async () => {
|
|
364
|
+
const dir = writeNamed({
|
|
365
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
366
|
+
'components/acme-widget.doc.mjs': namedDoc('acme-widget', 'AcmeWidgets'),
|
|
367
|
+
'components/acme-widget.tsx':
|
|
368
|
+
'export function AcmeWidget() { return null; }\nexport function AcmeGizmo() { return null; }\n',
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
expect(only(await allOf(dir), 'doc_name_mismatch')).toContain(
|
|
372
|
+
'Fix: set name in acme-widget.doc.mjs to the component components/acme-widget.tsx exports (AcmeGizmo, AcmeWidget).',
|
|
373
|
+
);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('judges the module the printed import names: an entry that publishes a default under the doc name passes', async () => {
|
|
377
|
+
const dir = writePackage(
|
|
378
|
+
{
|
|
379
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
380
|
+
'components/acme-widget.doc.mjs': componentDoc('AcmeWidget'),
|
|
381
|
+
'components/acme-widget.tsx':
|
|
382
|
+
'export default function AcmeWidget() { return null; }\n',
|
|
383
|
+
'index.ts':
|
|
384
|
+
"export {default as AcmeWidget} from './components/acme-widget';\n",
|
|
385
|
+
},
|
|
386
|
+
{name: '@acme/kit', version: '1.0.0', exports: {'.': './index.ts'}},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
390
|
+
expect(data.issues).toEqual([]);
|
|
391
|
+
expect(data.docNames?.unchecked).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('flags a name the printed entry provably does not publish', async () => {
|
|
395
|
+
const dir = writePackage(
|
|
396
|
+
{
|
|
397
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
398
|
+
'components/acme-widget.doc.mjs': componentDoc('AcmeWidget'),
|
|
399
|
+
'components/acme-widget.tsx':
|
|
400
|
+
'export default function AcmeWidget() { return null; }\n',
|
|
401
|
+
'index.ts':
|
|
402
|
+
"export {default as AcmeGadget} from './components/acme-widget';\n",
|
|
403
|
+
},
|
|
404
|
+
{name: '@acme/kit', version: '1.0.0', exports: {'.': './index.ts'}},
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
expect(only(await allOf(dir), 'doc_name_mismatch')).toContain(
|
|
408
|
+
'but index.ts, the module its import names, does not export AcmeWidget',
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('flags a name a default-only printed module cannot give, with a fix that parses and clears it', async () => {
|
|
413
|
+
const dir = writeNamed({
|
|
414
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
415
|
+
'components/acme-widget.doc.mjs': namedDoc('acme-widget', 'AcmeWidget'),
|
|
416
|
+
'components/acme-widget.tsx':
|
|
417
|
+
'export default function AcmeWidget() { return null; }\n',
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const message = only(await allOf(dir), 'doc_name_mismatch');
|
|
421
|
+
expect(message).toContain(
|
|
422
|
+
'but components/acme-widget.tsx, the module its import names, does not export AcmeWidget',
|
|
423
|
+
);
|
|
424
|
+
expect(message).toContain(
|
|
425
|
+
"Fix: add `export {AcmeWidget};` to components/acme-widget.tsx, so it exports AcmeWidget by name as well, or set the doc's import to the module that publishes it as AcmeWidget.",
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
const source =
|
|
429
|
+
'export default function AcmeWidget() { return null; }\nexport {AcmeWidget};\n';
|
|
430
|
+
expect(() =>
|
|
431
|
+
parse(source, {sourceType: 'module', plugins: ['typescript', 'jsx']}),
|
|
432
|
+
).not.toThrow();
|
|
433
|
+
const fixed = follow(dir, {write: {'components/acme-widget.tsx': source}});
|
|
434
|
+
expect(await allOf(fixed)).toEqual([]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('proposes a named binding for an anonymous default, which parses and clears it', async () => {
|
|
438
|
+
const dir = writeNamed({
|
|
439
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
440
|
+
'components/acme-widget.doc.mjs': namedDoc('acme-widget', 'AcmeWidget'),
|
|
441
|
+
'components/acme-widget.tsx': 'export default () => null;\n',
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(only(await allOf(dir), 'doc_name_mismatch')).toContain(
|
|
445
|
+
'Fix: bind the default to AcmeWidget in components/acme-widget.tsx and export it by name too (`const AcmeWidget = …; export default AcmeWidget; export {AcmeWidget};`)',
|
|
446
|
+
);
|
|
447
|
+
const source =
|
|
448
|
+
'const AcmeWidget = () => null;\nexport default AcmeWidget;\nexport {AcmeWidget};\n';
|
|
449
|
+
expect(() =>
|
|
450
|
+
parse(source, {sourceType: 'module', plugins: ['typescript', 'jsx']}),
|
|
451
|
+
).not.toThrow();
|
|
452
|
+
const fixed = follow(dir, {write: {'components/acme-widget.tsx': source}});
|
|
453
|
+
expect(await allOf(fixed)).toEqual([]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('lists a default-only source as not checked when no printed import can be resolved', async () => {
|
|
457
|
+
const dir = writePackage({
|
|
458
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
459
|
+
'components/acme-widget.doc.mjs': componentDoc('AcmeWidget'),
|
|
460
|
+
'components/acme-widget.tsx':
|
|
461
|
+
'export default function AcmeWidget() { return null; }\n',
|
|
462
|
+
});
|
|
463
|
+
// No package to resolve an import in: the same-stem source is read.
|
|
464
|
+
const {report} = await componentDocNamesCheck({
|
|
465
|
+
name: '@acme/kit',
|
|
466
|
+
components: path.join(dir, 'components'),
|
|
467
|
+
__spec: '@acme/kit',
|
|
468
|
+
__manifestFile: path.join(dir, MANIFEST),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
expect(report.unchecked).toEqual([
|
|
472
|
+
{
|
|
473
|
+
file: 'acme-widget.doc.mjs',
|
|
474
|
+
name: 'AcmeWidget',
|
|
475
|
+
reason:
|
|
476
|
+
'acme-widget.tsx exports a default, which the package entry may publish under any name.',
|
|
477
|
+
},
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('lists a name whose printed import resolves to no file as not checked', async () => {
|
|
482
|
+
const dir = writePackage({
|
|
483
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
484
|
+
'components/acme-widget.doc.mjs': componentDoc('AcmeGadget'),
|
|
485
|
+
'components/acme-widget.tsx': componentSource('AcmeWidget'),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
489
|
+
expect(data.issues).toEqual([]);
|
|
490
|
+
expect(data.docNames?.unchecked.map(entry => entry.file)).toEqual([
|
|
491
|
+
'components/acme-widget.doc.mjs',
|
|
492
|
+
]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('accepts a kebab-case file whose doc names the export it holds', async () => {
|
|
496
|
+
const dir = writeNamed({
|
|
497
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
498
|
+
'components/acme-widget.doc.mjs': namedDoc('acme-widget', 'AcmeWidget'),
|
|
499
|
+
'components/acme-widget.tsx': componentSource('AcmeWidget'),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(await allOf(dir)).toEqual([]);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('leaves a doc topic named differently from its file alone: the code does not contradict it', async () => {
|
|
506
|
+
const dir = writeNamed({
|
|
507
|
+
[MANIFEST]: manifest({docs: './docs'}),
|
|
508
|
+
'docs/deploying.doc.mjs': topic('deploy'),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(await allOf(dir)).toEqual([]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('leaves a template name alone: it is the display title', async () => {
|
|
515
|
+
const dir = writeNamed({
|
|
516
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
517
|
+
'templates/account-card.doc.mjs': templateDoc('Account card'),
|
|
518
|
+
'templates/account-card.tsx': templateSource,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
expect(await allOf(dir)).toEqual([]);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
describe('block component names', () => {
|
|
526
|
+
/**
|
|
527
|
+
* A package whose own name resolves, through its exports, to an entry that
|
|
528
|
+
* publishes the names the templates import from it.
|
|
529
|
+
* @param {Record<string, string>} files
|
|
530
|
+
*/
|
|
531
|
+
const writeKit = files =>
|
|
532
|
+
writePackage(
|
|
533
|
+
{
|
|
534
|
+
'index.tsx':
|
|
535
|
+
'export function Panel() { return null; }\nexport const UI = {};\nexport const Lib = {};\nexport function pick() { return null; }\n',
|
|
536
|
+
...files,
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
name: '@acme/kit',
|
|
540
|
+
version: '1.0.0',
|
|
541
|
+
exports: {
|
|
542
|
+
'.': './index.tsx',
|
|
543
|
+
'./templates/*': './templates/*.tsx',
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
);
|
|
547
|
+
/** @param {string} extra */
|
|
548
|
+
const block = extra =>
|
|
549
|
+
`export default {type: 'block', name: 'Account card', displayName: 'Account card', aspectRatio: 1,${extra}};\n`;
|
|
550
|
+
|
|
551
|
+
it('flags exampleFor and componentsUsed naming no component', async () => {
|
|
552
|
+
const dir = writeKit({
|
|
553
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
554
|
+
'templates/account-card.doc.mjs': block(
|
|
555
|
+
" exampleFor: 'AcmeGizmo', componentsUsed: ['AcmeGizmo']",
|
|
556
|
+
),
|
|
557
|
+
'templates/account-card.tsx': templateSource,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const issues = await allOf(dir);
|
|
561
|
+
expect(issues.map(issue => issue.code)).toEqual([
|
|
562
|
+
'unknown_block_component',
|
|
563
|
+
'unknown_block_component',
|
|
564
|
+
]);
|
|
565
|
+
expect(issues[0].message).toBe(
|
|
566
|
+
'Template "account-card" names "AcmeGizmo" in exampleFor, but neither this package nor Core has a component by that name and the template does not import it, so `astryx template --list` and the block\'s search keywords give a component name no one can look up. Fix: use the component\'s exact name, or remove "AcmeGizmo" from exampleFor.',
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("accepts every doc astryx component resolves in Core, such as Chat and Layout, and the package's and imported names", async () => {
|
|
571
|
+
const dir = writeKit({
|
|
572
|
+
[MANIFEST]: manifest({
|
|
573
|
+
components: './components',
|
|
574
|
+
templates: './templates',
|
|
575
|
+
}),
|
|
576
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
577
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
578
|
+
'templates/account-card.doc.mjs': block(
|
|
579
|
+
" exampleFor: 'AcmeWidget', componentsUsed: ['Chat', 'Layout', 'Button', 'AcmeWidget', 'Helper']",
|
|
580
|
+
),
|
|
581
|
+
'templates/account-card.tsx':
|
|
582
|
+
"import {Helper} from 'some-lib';\nexport default function A() {\n return <Helper />;\n}\n",
|
|
583
|
+
// The package root the doc's derived import names exports the component.
|
|
584
|
+
'index.tsx':
|
|
585
|
+
"export function Panel() { return null; }\nexport {AcmeWidget} from './components/AcmeWidget';\n",
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
expect(await allOf(dir)).toEqual([]);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('looks a name up in Core before giving up on a namespace import', async () => {
|
|
592
|
+
const dir = writeKit({
|
|
593
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
594
|
+
'templates/account-card.doc.mjs': block(
|
|
595
|
+
" componentsUsed: ['Layout', 'Panel']",
|
|
596
|
+
),
|
|
597
|
+
'templates/account-card.tsx':
|
|
598
|
+
"import * as stylex from '@stylexjs/stylex';\nimport * as UI from '@acme/kit';\nexport default function A() {\n return <UI.Panel />;\n}\n",
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
602
|
+
expect(data.issues).toEqual([]);
|
|
603
|
+
expect(data.blockComponents?.unchecked.map(entry => entry.name)).toEqual([
|
|
604
|
+
'Panel',
|
|
605
|
+
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('lists a name the template may render through a namespace import as not checked', async () => {
|
|
609
|
+
const dir = writeKit({
|
|
610
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
611
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['Panel']"),
|
|
612
|
+
'templates/account-card.tsx':
|
|
613
|
+
"import * as UI from '@acme/kit';\nexport default function A() {\n return <UI.Panel />;\n}\n",
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
617
|
+
expect(data.issues).toEqual([]);
|
|
618
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
619
|
+
{
|
|
620
|
+
file: 'templates/account-card.doc.mjs',
|
|
621
|
+
name: 'Panel',
|
|
622
|
+
reason:
|
|
623
|
+
'account-card.tsx imports "@acme/kit" as a namespace (UI), whose component names this check does not read.',
|
|
624
|
+
},
|
|
625
|
+
]);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('prints the block names it could not judge', () => {
|
|
629
|
+
const dir = writeKit({
|
|
630
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
631
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['Panel']"),
|
|
632
|
+
'templates/account-card.tsx':
|
|
633
|
+
"import * as UI from '@acme/kit';\nexport default function A() {\n return <UI.Panel />;\n}\n",
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const run = spawnSync(
|
|
637
|
+
process.execPath,
|
|
638
|
+
[CLI, 'doctor', 'integration', 'validate'],
|
|
639
|
+
{cwd: dir, encoding: 'utf-8', timeout: 60_000},
|
|
640
|
+
);
|
|
641
|
+
expect(run.status, run.stderr).toBe(0);
|
|
642
|
+
expect(run.stdout).toContain(
|
|
643
|
+
'[info] Block component names not checked: 1 name(s) this check could not judge:',
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('follows a local helper the template renders through', async () => {
|
|
648
|
+
const dir = writeKit({
|
|
649
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
650
|
+
'templates/account-card/account-card.doc.mjs': block(
|
|
651
|
+
" componentsUsed: ['Panel', 'AcmeGizmo']",
|
|
652
|
+
),
|
|
653
|
+
'templates/account-card/account-card.tsx':
|
|
654
|
+
"import {Body} from './parts/Body';\nexport default function A() {\n return <Body />;\n}\n",
|
|
655
|
+
'templates/account-card/parts/Body.tsx':
|
|
656
|
+
"import {Panel} from '@acme/kit';\nexport function Body() {\n return <Panel />;\n}\n",
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
660
|
+
expect((data.notes ?? []).map(note => note.message)).toEqual([
|
|
661
|
+
expect.stringContaining('names "AcmeGizmo" in componentsUsed'),
|
|
662
|
+
]);
|
|
663
|
+
expect(data.blockComponents?.unchecked).toEqual([]);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it.each([
|
|
667
|
+
['a named re-export', "export {Body} from './Body';\n"],
|
|
668
|
+
['an export *', "export * from './Body';\n"],
|
|
669
|
+
])(
|
|
670
|
+
'follows a helper barrel with %s like an import',
|
|
671
|
+
async (_label, barrel) => {
|
|
672
|
+
const dir = writeKit({
|
|
673
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
674
|
+
'templates/account-card/account-card.doc.mjs': block(
|
|
675
|
+
" componentsUsed: ['Panel', 'AcmeGizmo']",
|
|
676
|
+
),
|
|
677
|
+
'templates/account-card/account-card.tsx':
|
|
678
|
+
"import {Body} from './parts';\nexport default function A() {\n return <Body />;\n}\n",
|
|
679
|
+
'templates/account-card/parts/index.ts': barrel,
|
|
680
|
+
'templates/account-card/parts/Body.tsx':
|
|
681
|
+
"import {Panel} from '@acme/kit';\nexport function Body() {\n return <Panel />;\n}\n",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
685
|
+
expect((data.notes ?? []).map(note => note.message)).toEqual([
|
|
686
|
+
expect.stringContaining('names "AcmeGizmo" in componentsUsed'),
|
|
687
|
+
]);
|
|
688
|
+
expect(data.blockComponents?.unchecked).toEqual([]);
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
it.each([
|
|
693
|
+
[
|
|
694
|
+
'a default import used as a namespace',
|
|
695
|
+
"import UI from '@acme/kit';\nexport default function A() {\n return <UI.Panel />;\n}\n",
|
|
696
|
+
'account-card.tsx imports the default of "@acme/kit" as UI, which may be any component.',
|
|
697
|
+
],
|
|
698
|
+
[
|
|
699
|
+
'members destructured off a named import',
|
|
700
|
+
"import {UI} from '@acme/kit';\nconst {Panel} = UI;\nexport default function A() {\n return <Panel />;\n}\n",
|
|
701
|
+
'account-card.tsx reads UI (imported from "@acme/kit") into another name, which this check does not follow.',
|
|
702
|
+
],
|
|
703
|
+
[
|
|
704
|
+
'a member read off a named import',
|
|
705
|
+
"import {UI} from '@acme/kit';\nconst Panel = UI.Panel;\nexport default function A() {\n return <Panel />;\n}\n",
|
|
706
|
+
'account-card.tsx reads members of UI (imported from "@acme/kit"), whose names this check does not read.',
|
|
707
|
+
],
|
|
708
|
+
[
|
|
709
|
+
'createElement with a member',
|
|
710
|
+
"import {createElement} from 'react';\nimport {UI} from '@acme/kit';\nexport default function A() {\n return createElement(UI.Panel);\n}\n",
|
|
711
|
+
'account-card.tsx reads members of UI (imported from "@acme/kit"), whose names this check does not read.',
|
|
712
|
+
],
|
|
713
|
+
[
|
|
714
|
+
'a default import under another name',
|
|
715
|
+
"import Panel from '@acme/kit/AcmePanel';\nexport default function A() {\n return <Panel />;\n}\n",
|
|
716
|
+
'account-card.tsx imports the default of "@acme/kit/AcmePanel" as Panel, which may be any component.',
|
|
717
|
+
],
|
|
718
|
+
[
|
|
719
|
+
'an alias of an imported binding',
|
|
720
|
+
"import {Lib} from '@acme/kit';\nconst UI = Lib;\nexport default function A() {\n return <UI.AcmePanel />;\n}\n",
|
|
721
|
+
'account-card.tsx reads Lib (imported from "@acme/kit") into another name, which this check does not follow.',
|
|
722
|
+
],
|
|
723
|
+
[
|
|
724
|
+
'an optional member read',
|
|
725
|
+
"import {UI} from '@acme/kit';\nconst Panel = UI?.AcmePanel;\nexport default function A() {\n return <Panel />;\n}\n",
|
|
726
|
+
'account-card.tsx reads members of UI (imported from "@acme/kit"), whose names this check does not read.',
|
|
727
|
+
],
|
|
728
|
+
[
|
|
729
|
+
'a component held in a variable',
|
|
730
|
+
"import {pick} from '@acme/kit';\nconst Panel = pick('panel');\nexport default function A() {\n return <Panel />;\n}\n",
|
|
731
|
+
'account-card.tsx renders Panel, a value this check does not trace to an import.',
|
|
732
|
+
],
|
|
733
|
+
[
|
|
734
|
+
'require()',
|
|
735
|
+
"const {Panel} = require('@acme/kit');\nexport default function A() {\n return <Panel />;\n}\n",
|
|
736
|
+
'account-card.tsx loads "@acme/kit" with require(), whose component names this check does not read.',
|
|
737
|
+
],
|
|
738
|
+
[
|
|
739
|
+
'a lazy dynamic import',
|
|
740
|
+
"import {lazy} from 'react';\nconst Panel = lazy(() => import('@acme/kit/Panel'));\nexport default function A() {\n return <Panel />;\n}\n",
|
|
741
|
+
'account-card.tsx loads "@acme/kit/Panel" with a dynamic import(), whose component names this check does not read.',
|
|
742
|
+
],
|
|
743
|
+
])(
|
|
744
|
+
'lists a name the template may render through %s as not checked',
|
|
745
|
+
async (_label, source, reason) => {
|
|
746
|
+
const dir = writeKit({
|
|
747
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
748
|
+
'templates/account-card.doc.mjs': block(
|
|
749
|
+
" componentsUsed: ['AcmePanel']",
|
|
750
|
+
),
|
|
751
|
+
'templates/account-card.tsx': source,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
755
|
+
expect(data.issues).toEqual([]);
|
|
756
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
757
|
+
{file: 'templates/account-card.doc.mjs', name: 'AcmePanel', reason},
|
|
758
|
+
]);
|
|
759
|
+
},
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
it('leaves names to unchecked when the template imports another package, which may render them', async () => {
|
|
763
|
+
const dir = writeKit({
|
|
764
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
765
|
+
'templates/account-card.doc.mjs': block(
|
|
766
|
+
" componentsUsed: ['AcmeCardGrid', 'AcmeCard']",
|
|
767
|
+
),
|
|
768
|
+
'templates/account-card.tsx':
|
|
769
|
+
"import {AcmeCardGrid} from '@acme/cards';\nexport default function A() {\n return <AcmeCardGrid />;\n}\n",
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
773
|
+
expect(data.issues).toEqual([]);
|
|
774
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
775
|
+
{
|
|
776
|
+
file: 'templates/account-card.doc.mjs',
|
|
777
|
+
name: 'AcmeCard',
|
|
778
|
+
reason:
|
|
779
|
+
'account-card.tsx imports from "@acme/cards", another package whose components may render ones this check does not see.',
|
|
780
|
+
},
|
|
781
|
+
]);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('leaves names to unchecked when the template imports a default by name', async () => {
|
|
785
|
+
const dir = writeKit({
|
|
786
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
787
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['AcmePanel']"),
|
|
788
|
+
'templates/account-card.tsx':
|
|
789
|
+
"import {default as Panel} from '@acme/kit/AcmePanel';\nexport default function A() {\n return <Panel />;\n}\n",
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
793
|
+
expect(data.issues).toEqual([]);
|
|
794
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
795
|
+
{
|
|
796
|
+
file: 'templates/account-card.doc.mjs',
|
|
797
|
+
name: 'AcmePanel',
|
|
798
|
+
reason:
|
|
799
|
+
'account-card.tsx imports the default of "@acme/kit/AcmePanel" as Panel, which may be any component.',
|
|
800
|
+
},
|
|
801
|
+
]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('leaves names to unchecked when a helper re-exports a default by name', async () => {
|
|
805
|
+
const dir = writeKit({
|
|
806
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
807
|
+
'templates/account-card/account-card.doc.mjs': block(
|
|
808
|
+
" componentsUsed: ['AcmePanel']",
|
|
809
|
+
),
|
|
810
|
+
'templates/account-card/account-card.tsx':
|
|
811
|
+
"import {Body} from './parts';\nexport default function A() {\n return <Body />;\n}\n",
|
|
812
|
+
'templates/account-card/parts/index.ts':
|
|
813
|
+
"export {default as Body} from './Body';\n",
|
|
814
|
+
'templates/account-card/parts/Body.tsx':
|
|
815
|
+
'export default function Body() {\n return null;\n}\n',
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
819
|
+
expect(data.issues).toEqual([]);
|
|
820
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
821
|
+
{
|
|
822
|
+
file: 'templates/account-card/account-card.doc.mjs',
|
|
823
|
+
name: 'AcmePanel',
|
|
824
|
+
reason:
|
|
825
|
+
'parts/index.ts re-exports the default of "./Body" as Body, which may be any component.',
|
|
826
|
+
},
|
|
827
|
+
]);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('still flags a name when the template imports only from Core, React, StyleX and itself', async () => {
|
|
831
|
+
const dir = writeKit({
|
|
832
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
833
|
+
'templates/account-card.doc.mjs': block(
|
|
834
|
+
" componentsUsed: ['Layout', 'AcmeGizmo']",
|
|
835
|
+
),
|
|
836
|
+
'templates/account-card.tsx':
|
|
837
|
+
"import {useState} from 'react';\nimport * as stylex from '@stylexjs/stylex';\nimport {Layout} from '@astryxdesign/core';\nimport type {ReactNode} from 'react';\nimport type {Gizmo} from '@acme/types';\nexport default function A() {\n useState();\n return <Layout />;\n}\n",
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
841
|
+
expect((data.notes ?? []).map(note => note.message)).toEqual([
|
|
842
|
+
expect.stringContaining('names "AcmeGizmo" in componentsUsed'),
|
|
843
|
+
]);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("follows an import of the package's own name to its source, and lists what that source may render", async () => {
|
|
847
|
+
const dir = writeKit({
|
|
848
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
849
|
+
'index.tsx': "export {AcmeCardGrid} from './src/AcmeCardGrid';\n",
|
|
850
|
+
'src/AcmeCardGrid.tsx':
|
|
851
|
+
"import {AcmeCard} from '@acme/cards';\nexport function AcmeCardGrid() {\n return <AcmeCard />;\n}\n",
|
|
852
|
+
'templates/account-card.doc.mjs': block(
|
|
853
|
+
" componentsUsed: ['AcmeCardGrid', 'AcmeCardRow']",
|
|
854
|
+
),
|
|
855
|
+
'templates/account-card.tsx':
|
|
856
|
+
"import {AcmeCardGrid} from '@acme/kit';\nexport default function A() {\n return <AcmeCardGrid />;\n}\n",
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
860
|
+
expect(data.notes ?? []).toEqual([]);
|
|
861
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
862
|
+
{
|
|
863
|
+
file: 'templates/account-card.doc.mjs',
|
|
864
|
+
name: 'AcmeCardRow',
|
|
865
|
+
reason:
|
|
866
|
+
'../src/AcmeCardGrid.tsx imports from "@acme/cards", another package whose components may render ones this check does not see.',
|
|
867
|
+
},
|
|
868
|
+
]);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it("still flags a name when the package's own source it follows imports only what this check sees", async () => {
|
|
872
|
+
const dir = writeKit({
|
|
873
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
874
|
+
'index.tsx': "export {AcmeCardGrid} from './src/AcmeCardGrid';\n",
|
|
875
|
+
'src/AcmeCardGrid.tsx':
|
|
876
|
+
"import {Layout} from '@astryxdesign/core';\nexport function AcmeCardGrid() {\n return <Layout />;\n}\n",
|
|
877
|
+
'templates/account-card.doc.mjs': block(
|
|
878
|
+
" componentsUsed: ['AcmeCardGrid', 'AcmeGizmo']",
|
|
879
|
+
),
|
|
880
|
+
'templates/account-card.tsx':
|
|
881
|
+
"import {AcmeCardGrid} from '@acme/kit';\nexport default function A() {\n return <AcmeCardGrid />;\n}\n",
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
885
|
+
expect((data.notes ?? []).map(note => note.message)).toEqual([
|
|
886
|
+
expect.stringContaining('names "AcmeGizmo" in componentsUsed'),
|
|
887
|
+
]);
|
|
888
|
+
expect(data.blockComponents?.unchecked).toEqual([]);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("lists names behind an import of the package's own name that resolves to no source", async () => {
|
|
892
|
+
const dir = writePackage({
|
|
893
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
894
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['AcmeGizmo']"),
|
|
895
|
+
'templates/account-card.tsx':
|
|
896
|
+
"import {AcmeCardGrid} from '@acme/kit/grid';\nexport default function A() {\n return <AcmeCardGrid />;\n}\n",
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
900
|
+
expect(data.notes ?? []).toEqual([]);
|
|
901
|
+
expect(data.blockComponents?.unchecked).toEqual([
|
|
902
|
+
{
|
|
903
|
+
file: 'templates/account-card.doc.mjs',
|
|
904
|
+
name: 'AcmeGizmo',
|
|
905
|
+
reason:
|
|
906
|
+
'account-card.tsx imports "@acme/kit/grid", this package\'s own name, which resolves to no source this check can read.',
|
|
907
|
+
},
|
|
908
|
+
]);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("skips a renderless library's default import, as its namespace import", async () => {
|
|
912
|
+
const dir = writeKit({
|
|
913
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
914
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['AcmeGizmo']"),
|
|
915
|
+
'templates/account-card.tsx':
|
|
916
|
+
"import React from 'react';\nexport default function A() {\n return <div />;\n}\n",
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
920
|
+
expect((data.notes ?? []).map(note => note.message)).toEqual([
|
|
921
|
+
expect.stringContaining('names "AcmeGizmo" in componentsUsed'),
|
|
922
|
+
]);
|
|
923
|
+
expect(data.blockComponents?.unchecked).toEqual([]);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('lists a name behind a local helper it cannot read as not checked', async () => {
|
|
927
|
+
const dir = writeKit({
|
|
928
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
929
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['Panel']"),
|
|
930
|
+
'templates/account-card.tsx':
|
|
931
|
+
"import {Body} from './parts/missing';\nexport default function A() {\n return <Body />;\n}\n",
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
935
|
+
expect(data.issues).toEqual([]);
|
|
936
|
+
expect(data.blockComponents?.unchecked.map(entry => entry.name)).toEqual([
|
|
937
|
+
'Panel',
|
|
938
|
+
]);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('lists names only Core could resolve as not checked when Core is not installed', async () => {
|
|
942
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-no-core-'));
|
|
943
|
+
try {
|
|
944
|
+
fs.writeFileSync(
|
|
945
|
+
path.join(outside, 'package.json'),
|
|
946
|
+
JSON.stringify({name: '@acme/kit', version: '1.0.0'}),
|
|
947
|
+
);
|
|
948
|
+
fs.writeFileSync(
|
|
949
|
+
path.join(outside, MANIFEST),
|
|
950
|
+
manifest({templates: './templates'}),
|
|
951
|
+
);
|
|
952
|
+
fs.mkdirSync(path.join(outside, 'templates'));
|
|
953
|
+
fs.writeFileSync(
|
|
954
|
+
path.join(outside, 'templates', 'account-card.doc.mjs'),
|
|
955
|
+
block(" componentsUsed: ['Chat', 'Layout']"),
|
|
956
|
+
);
|
|
957
|
+
fs.writeFileSync(
|
|
958
|
+
path.join(outside, 'templates', 'account-card.tsx'),
|
|
959
|
+
templateSource,
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
const {data} = await validateIntegration(undefined, {cwd: outside});
|
|
963
|
+
expect(data.notes ?? []).toEqual([]);
|
|
964
|
+
const reason =
|
|
965
|
+
'Core is not installed, so its component names cannot be checked.';
|
|
966
|
+
expect(data.blockComponents).toEqual({
|
|
967
|
+
status: 'checked',
|
|
968
|
+
findings: [],
|
|
969
|
+
unchecked: [
|
|
970
|
+
{file: 'templates/account-card.doc.mjs', name: 'Chat', reason},
|
|
971
|
+
{file: 'templates/account-card.doc.mjs', name: 'Layout', reason},
|
|
972
|
+
],
|
|
973
|
+
});
|
|
974
|
+
} finally {
|
|
975
|
+
fs.rmSync(outside, {recursive: true, force: true});
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
describe('template ids', () => {
|
|
981
|
+
it('leaves an id with spaces alone: it is naming policy, not a contradiction', async () => {
|
|
982
|
+
const dir = writePackage({
|
|
983
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
984
|
+
'templates/AI Chat Conversation.doc.mjs': templateDoc(
|
|
985
|
+
'AI Chat Conversation',
|
|
986
|
+
),
|
|
987
|
+
'templates/AI Chat Conversation.tsx': templateSource,
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
expect(await issuesOf(dir)).toEqual([]);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
describe('component doc files', () => {
|
|
995
|
+
it('lists two docs for one component, naming the one read, without an issue', async () => {
|
|
996
|
+
const dir = writePackage({
|
|
997
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
998
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
999
|
+
'components/AcmeWidget.doc.js': componentDoc('AcmeWidget'),
|
|
1000
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
expect(await issuesOf(dir)).toEqual([]);
|
|
1004
|
+
const message = only(await listedOf(dir), 'duplicate_component_doc');
|
|
1005
|
+
expect(message).toBe(
|
|
1006
|
+
'Component "AcmeWidget" has 2 docs: AcmeWidget.doc.mjs, AcmeWidget.doc.js. Astryx reads AcmeWidget.doc.mjs and ignores AcmeWidget.doc.js. Fix: delete AcmeWidget.doc.js, moving anything only it says into AcmeWidget.doc.mjs.',
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
const fixed = follow(dir, {remove: ['components/AcmeWidget.doc.js']});
|
|
1010
|
+
expect(await allOf(fixed)).toEqual([]);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it('gives one finding for a doc whose name differs from its source only in case', async () => {
|
|
1014
|
+
const dir = writePackage({
|
|
1015
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1016
|
+
'components/Acmewidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1017
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const issues = await issuesOf(dir);
|
|
1021
|
+
expect(issues).toHaveLength(1);
|
|
1022
|
+
expect(issues[0]).toEqual({
|
|
1023
|
+
code: 'invalid_component',
|
|
1024
|
+
severity: 'error',
|
|
1025
|
+
message:
|
|
1026
|
+
'Component doc "Acmewidget.doc.mjs" and its source "AcmeWidget.tsx" differ only in letter case. They pair on a case-insensitive disk (macOS, Windows) but not on Linux, where CI and most installs run, so here the component is missing. Fix: rename Acmewidget.doc.mjs to AcmeWidget.doc.mjs.',
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// Through a temporary name, so a case-insensitive disk renames it too.
|
|
1030
|
+
const fixed = follow(dir, {
|
|
1031
|
+
move: [
|
|
1032
|
+
['components/Acmewidget.doc.mjs', 'components/tmp.doc.mjs'],
|
|
1033
|
+
['components/tmp.doc.mjs', 'components/AcmeWidget.doc.mjs'],
|
|
1034
|
+
],
|
|
1035
|
+
});
|
|
1036
|
+
expect(await issuesOf(fixed)).toEqual([]);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it('names a .jsx source beside a component doc and says to rename it', async () => {
|
|
1040
|
+
const dir = writePackage({
|
|
1041
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1042
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1043
|
+
'components/AcmeWidget.jsx': componentSource('AcmeWidget'),
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const message = only(await issuesOf(dir), 'invalid_component');
|
|
1047
|
+
expect(message).toBe(
|
|
1048
|
+
'Component "AcmeWidget" is missing its same-stem source file AcmeWidget.tsx: found AcmeWidget.jsx, but a component source must be a .tsx file. Fix: rename AcmeWidget.jsx to AcmeWidget.tsx (and point its package export at the new file).',
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
const fixed = follow(dir, {
|
|
1052
|
+
move: [['components/AcmeWidget.jsx', 'components/AcmeWidget.tsx']],
|
|
1053
|
+
});
|
|
1054
|
+
expect(await issuesOf(fixed)).toEqual([]);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('says what an empty doc and a doc with no default export are missing', async () => {
|
|
1058
|
+
const dir = writePackage({
|
|
1059
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1060
|
+
'components/Empty.doc.mjs': '',
|
|
1061
|
+
'components/Empty.tsx': componentSource('Empty'),
|
|
1062
|
+
'components/Named.doc.mjs': componentDoc('Named').replace(
|
|
1063
|
+
'export default',
|
|
1064
|
+
'export const doc =',
|
|
1065
|
+
),
|
|
1066
|
+
'components/Named.tsx': componentSource('Named'),
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
const messages = (await issuesOf(dir)).map(issue => issue.message);
|
|
1070
|
+
const fix =
|
|
1071
|
+
'Fix: export the doc object as the default export: `export default {type: ..., name: ..., ...}`.';
|
|
1072
|
+
expect(messages).toEqual([
|
|
1073
|
+
`Component "Empty" has invalid metadata: ${path.join(dir, 'components', 'Empty.doc.mjs')} is empty, so it documents nothing. ${fix}`,
|
|
1074
|
+
`Component "Named" has invalid metadata: ${path.join(dir, 'components', 'Named.doc.mjs')} has no default export or \`docs\` export (it exports \`doc\`), so it documents nothing. ${fix}`,
|
|
1075
|
+
]);
|
|
1076
|
+
expect(messages.join('\n')).not.toContain('Invalid input');
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
describe('template and theme pairing', () => {
|
|
1081
|
+
it('gives a template doc with no source a fix', async () => {
|
|
1082
|
+
const dir = writePackage({
|
|
1083
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
1084
|
+
'templates/account-card.doc.mjs': templateDoc('Account card'),
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
const message = only(await issuesOf(dir), 'invalid_template');
|
|
1088
|
+
expect(message).toBe(
|
|
1089
|
+
'Template "account-card" is missing its same-stem source file account-card.tsx. Fix: add account-card.tsx beside account-card.doc.mjs, with the template as its default export.',
|
|
1090
|
+
);
|
|
1091
|
+
const fixed = follow(dir, {
|
|
1092
|
+
write: {'templates/account-card.tsx': templateSource},
|
|
1093
|
+
});
|
|
1094
|
+
expect(await issuesOf(fixed)).toEqual([]);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const themeDoc = `${THEME_TYPE}export default {type: 'theme', name: 'ocean', displayName: 'Ocean', description: 'A theme.', maintained: true};\n`;
|
|
1098
|
+
const themeSource =
|
|
1099
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nexport const oceanTheme = defineTheme({name: 'ocean'});\n";
|
|
1100
|
+
|
|
1101
|
+
it('gives a theme descriptor with no source a fix', async () => {
|
|
1102
|
+
const dir = writePackage({
|
|
1103
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1104
|
+
'themes/ocean/oceanTheme.doc.mjs': themeDoc,
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
expect(only(await issuesOf(dir), 'invalid_theme')).toBe(
|
|
1108
|
+
'Theme "ocean" for @acme/kit must contain exactly one same-stem source for oceanTheme.doc.mjs; found 0. Fix: add oceanTheme.ts beside oceanTheme.doc.mjs, exporting `oceanTheme` from defineTheme.',
|
|
1109
|
+
);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('gives a theme source with no descriptor a fix', async () => {
|
|
1113
|
+
const dir = writePackage({
|
|
1114
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1115
|
+
'themes/ocean/oceanTheme.ts': themeSource,
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
const message = only(await issuesOf(dir), 'invalid_theme');
|
|
1119
|
+
expect(message).toBe(
|
|
1120
|
+
`Theme "ocean" for @acme/kit must contain exactly one same-stem .doc.mjs descriptor; found 0 beside "oceanTheme.ts". Fix: add oceanTheme.doc.mjs beside it with type: 'theme' and name: 'ocean'; \`${formatCliCommand('docs authoring theme-doc', path.join(dir, 'themes', 'ocean'))}\` lists its fields.`,
|
|
1121
|
+
);
|
|
1122
|
+
const fixed = follow(dir, {
|
|
1123
|
+
write: {'themes/ocean/oceanTheme.doc.mjs': themeDoc},
|
|
1124
|
+
});
|
|
1125
|
+
expect(
|
|
1126
|
+
(await issuesOf(fixed)).filter(issue => issue.code === 'invalid_theme'),
|
|
1127
|
+
).toEqual([]);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it('says to rename a source named for its theme when the build writes beside it, and the rename clears it', async () => {
|
|
1131
|
+
const brandDoc = `${THEME_TYPE}export default {type: 'theme', name: 'brand', displayName: 'Brand', description: 'A theme.', maintained: true};\n`;
|
|
1132
|
+
const dir = writePackage({
|
|
1133
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1134
|
+
'themes/brand/brand.doc.mjs': brandDoc,
|
|
1135
|
+
'themes/brand/brand.ts':
|
|
1136
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nexport const brand = defineTheme({name: 'brand'});\n",
|
|
1137
|
+
'themes/brand/brand.js':
|
|
1138
|
+
"/**\n * @generated by `astryx theme build` — do not edit manually.\n */\nimport './brand.css';\nexport const brandTheme = {};\n",
|
|
1139
|
+
'themes/brand/brand.css': '.astryx-theme-brand {}\n',
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
expect(only(await issuesOf(dir), 'invalid_theme')).toBe(
|
|
1143
|
+
`Theme "brand" for @acme/kit must contain exactly one same-stem source for brand.doc.mjs; found 2: "brand.js", "brand.ts". Fix: "brand.js" is the module \`${formatCliCommand('theme build', path.join(dir, 'themes', 'brand'))}\` writes, which takes the theme's name; rename brand.ts to brandTheme.ts and brand.doc.mjs to brandTheme.doc.mjs, export \`brandTheme\` from it, and build again.`,
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
const fixed = follow(dir, {
|
|
1147
|
+
remove: ['themes/brand/brand.ts', 'themes/brand/brand.doc.mjs'],
|
|
1148
|
+
write: {
|
|
1149
|
+
'themes/brand/brandTheme.ts':
|
|
1150
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nexport const brandTheme = defineTheme({name: 'brand'});\n",
|
|
1151
|
+
'themes/brand/brandTheme.doc.mjs': brandDoc,
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
expect(
|
|
1155
|
+
(await issuesOf(fixed)).filter(issue => issue.code === 'invalid_theme'),
|
|
1156
|
+
).toEqual([]);
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const builtOcean = (/** @type {string} */ extra) =>
|
|
1160
|
+
`/**\n * @generated by \`astryx theme build\` — do not edit manually.\n${extra} */\nimport './ocean.css';\nexport const oceanTheme = {};\n`;
|
|
1161
|
+
|
|
1162
|
+
it("names the theme's source when its defineTheme gives the theme's name, for a descriptor named for the built module", async () => {
|
|
1163
|
+
const dir = writePackage({
|
|
1164
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1165
|
+
'themes/ocean/ocean.doc.mjs': themeDoc,
|
|
1166
|
+
'themes/ocean/oceanTheme.ts': themeSource,
|
|
1167
|
+
'themes/ocean/ocean.js': builtOcean(''),
|
|
1168
|
+
'themes/ocean/ocean.css': '.astryx-theme-ocean {}\n',
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
const message = only(await issuesOf(dir), 'invalid_theme');
|
|
1172
|
+
expect(message).toContain(
|
|
1173
|
+
'shares its stem with the built module "ocean.js", which does not export "ocean": a descriptor must share its stem with the source that calls defineTheme. The theme\'s source is "oceanTheme.ts". Fix: rename ocean.doc.mjs to oceanTheme.doc.mjs.',
|
|
1174
|
+
);
|
|
1175
|
+
const fixed = follow(dir, {
|
|
1176
|
+
move: [['themes/ocean/ocean.doc.mjs', 'themes/ocean/oceanTheme.doc.mjs']],
|
|
1177
|
+
});
|
|
1178
|
+
expect(
|
|
1179
|
+
(await issuesOf(fixed)).filter(issue => issue.code === 'invalid_theme'),
|
|
1180
|
+
).toEqual([]);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
it("names the source the built module's Source: line gives, over a <name>Theme helper", async () => {
|
|
1184
|
+
const dir = writePackage({
|
|
1185
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1186
|
+
'themes/ocean/ocean.doc.mjs': themeDoc,
|
|
1187
|
+
'themes/ocean/seaTheme.ts': themeSource.replace('oceanTheme', 'seaTheme'),
|
|
1188
|
+
'themes/ocean/baseTheme.ts':
|
|
1189
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nexport const baseTheme = defineTheme({name: 'base'});\n",
|
|
1190
|
+
'themes/ocean/ocean.js': builtOcean(
|
|
1191
|
+
' * Source: themes/ocean/seaTheme.ts\n',
|
|
1192
|
+
),
|
|
1193
|
+
'themes/ocean/ocean.css': '.astryx-theme-ocean {}\n',
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
expect(only(await issuesOf(dir), 'invalid_theme')).toContain(
|
|
1197
|
+
'The theme\'s source is "seaTheme.ts". Fix: rename ocean.doc.mjs to seaTheme.doc.mjs.',
|
|
1198
|
+
);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
it('never names a lone <name>Theme helper as the source: the message stays generic', async () => {
|
|
1202
|
+
const dir = writePackage({
|
|
1203
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1204
|
+
'themes/ocean/ocean.doc.mjs': themeDoc,
|
|
1205
|
+
'themes/ocean/oceanTheme.ts':
|
|
1206
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nexport const oceanTheme = defineTheme({name: 'base'});\n",
|
|
1207
|
+
'themes/ocean/ocean.js': builtOcean(''),
|
|
1208
|
+
'themes/ocean/ocean.css': '.astryx-theme-ocean {}\n',
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
const message = only(await issuesOf(dir), 'invalid_theme');
|
|
1212
|
+
expect(message).toContain(
|
|
1213
|
+
'shares its stem with the built module "ocean.js", which does not export "ocean": a descriptor must share its stem with the source that calls defineTheme. Fix: rename ocean.doc.mjs to share its stem with the theme\'s source (the module that calls defineTheme, such as oceanTheme.ts).',
|
|
1214
|
+
);
|
|
1215
|
+
expect(message).not.toContain("The theme's source is");
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('accepts a descriptor named for a built module that exports its stem, as 0.7.2 does', async () => {
|
|
1219
|
+
const dir = writePackage({
|
|
1220
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1221
|
+
'themes/ocean/ocean.doc.mjs': themeDoc,
|
|
1222
|
+
'themes/ocean/oceanTheme.ts': themeSource,
|
|
1223
|
+
'themes/ocean/ocean.js': builtOcean('').replace(
|
|
1224
|
+
'export const oceanTheme = {};',
|
|
1225
|
+
'export const oceanTheme = {};\nexport const ocean = oceanTheme;',
|
|
1226
|
+
),
|
|
1227
|
+
'themes/ocean/ocean.css': '.astryx-theme-ocean {}\n',
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
expect(
|
|
1231
|
+
(await issuesOf(dir)).filter(issue => issue.code === 'invalid_theme'),
|
|
1232
|
+
).toEqual([]);
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
describe('a theme folder with a helper', () => {
|
|
1237
|
+
it('reads a descriptor beside its own defineTheme source, with a <name>Theme helper beside them', async () => {
|
|
1238
|
+
const dir = writePackage({
|
|
1239
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1240
|
+
'themes/brand/brand.doc.mjs': `${THEME_TYPE}export default {type: 'theme', name: 'brand', displayName: 'Brand', description: 'A theme.', maintained: true};\n`,
|
|
1241
|
+
'themes/brand/brand.ts':
|
|
1242
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nimport {baseTheme} from './baseTheme';\nexport const brand = defineTheme({name: 'brand', extends: baseTheme});\n",
|
|
1243
|
+
'themes/brand/baseTheme.ts':
|
|
1244
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nexport const baseTheme = defineTheme({name: 'base'});\n",
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
expect(
|
|
1248
|
+
(await issuesOf(dir)).filter(issue => issue.code === 'invalid_theme'),
|
|
1249
|
+
).toEqual([]);
|
|
1250
|
+
});
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
describe('a hidden doc beside a source whose name differs only in case', () => {
|
|
1254
|
+
it('keeps the source warning 0.7.2 gives where the hidden doc does not pair, as on Linux', async () => {
|
|
1255
|
+
const dir = writePackage({
|
|
1256
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1257
|
+
'components/Acmewidget.doc.mjs': componentDoc('AcmeWidget').replace(
|
|
1258
|
+
"type: 'component',",
|
|
1259
|
+
"type: 'component',\n hidden: true,",
|
|
1260
|
+
),
|
|
1261
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
const issues = await issuesOf(dir);
|
|
1265
|
+
expect(issues.map(issue => [issue.code, issue.severity])).toEqual([
|
|
1266
|
+
['source_without_component_doc', 'warning'],
|
|
1267
|
+
]);
|
|
1268
|
+
expect(issues[0].message).toMatch(
|
|
1269
|
+
/^Component source "AcmeWidget\.tsx" has no same-stem metadata file AcmeWidget\.doc\.mjs, so Astryx ignores it\. Fix: /u,
|
|
1270
|
+
);
|
|
1271
|
+
expect(await listedOf(dir)).toEqual([]);
|
|
1272
|
+
});
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
describe('roots', () => {
|
|
1276
|
+
it('lists that npm pack does not follow a symlinked root, and reads its folder through it', async () => {
|
|
1277
|
+
const dir = writePackage({
|
|
1278
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1279
|
+
'real-components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1280
|
+
'real-components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1281
|
+
});
|
|
1282
|
+
fs.symlinkSync('real-components', path.join(dir, 'components'));
|
|
1283
|
+
|
|
1284
|
+
expect(await issuesOf(dir)).toEqual([]);
|
|
1285
|
+
const issues = await listedOf(dir);
|
|
1286
|
+
expect(issues.map(issue => issue.code)).toEqual(['symlinked_root']);
|
|
1287
|
+
expect(issues[0].message).toBe(
|
|
1288
|
+
'Declared components root "components" is reached through the symlink "components" (to "real-components"). Astryx follows it locally, but npm pack does not follow a symlinked folder, so the published package ships none of these files. Fix: replace the link with the folder itself (move real-components/ to components/), or set `components: \'./real-components\'` in astryx.integration.mjs.',
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
const fixed = follow(dir, {
|
|
1292
|
+
remove: ['components'],
|
|
1293
|
+
move: [['real-components', 'components']],
|
|
1294
|
+
});
|
|
1295
|
+
expect(await allOf(fixed)).toEqual([]);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it('says an absolute root inside the package must be relative', async () => {
|
|
1299
|
+
const dir = writePackage({
|
|
1300
|
+
'docs/deploying.doc.mjs': topic('deploying'),
|
|
1301
|
+
});
|
|
1302
|
+
fs.writeFileSync(
|
|
1303
|
+
path.join(dir, MANIFEST),
|
|
1304
|
+
manifest({docs: path.join(dir, 'docs')}),
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
const issues = await issuesOf(dir);
|
|
1308
|
+
expect(issues).toEqual([
|
|
1309
|
+
{
|
|
1310
|
+
code: 'root_outside_package',
|
|
1311
|
+
severity: 'error',
|
|
1312
|
+
message: `The docs root "${path.join(dir, 'docs')}" is an absolute path, so Astryx reads nothing from it: a root is relative to the package, so it resolves the same wherever the package is installed. Fix: set \`docs: './docs'\` in astryx.integration.mjs.`,
|
|
1313
|
+
},
|
|
1314
|
+
]);
|
|
1315
|
+
expect(issues[0].message).not.toContain('outside');
|
|
1316
|
+
|
|
1317
|
+
const fixed = follow(dir, {
|
|
1318
|
+
write: {[MANIFEST]: manifest({docs: './docs'})},
|
|
1319
|
+
});
|
|
1320
|
+
expect(await issuesOf(fixed)).toEqual([]);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
it('reports a root that is a file from every check, without crashing', async () => {
|
|
1324
|
+
const dir = writePackage({
|
|
1325
|
+
[MANIFEST]: manifest({
|
|
1326
|
+
components: './components/AcmeWidget.tsx',
|
|
1327
|
+
templates: './templates/account-card.tsx',
|
|
1328
|
+
docs: './docs/deploying.doc.mjs',
|
|
1329
|
+
}),
|
|
1330
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1331
|
+
'templates/account-card.tsx': templateSource,
|
|
1332
|
+
'docs/deploying.doc.mjs': topic('deploying'),
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
const result = await integrationCheck({cwd: dir});
|
|
1336
|
+
expect(result.data.checks.map(check => check.check)).toEqual([
|
|
1337
|
+
'validate',
|
|
1338
|
+
'components',
|
|
1339
|
+
'templates',
|
|
1340
|
+
'docs',
|
|
1341
|
+
]);
|
|
1342
|
+
for (const check of result.data.checks) {
|
|
1343
|
+
const codes = check.response.data.issues
|
|
1344
|
+
.filter(issue => issue.severity === 'error')
|
|
1345
|
+
.map(issue => issue.code);
|
|
1346
|
+
expect(codes).toEqual(['invalid_root', 'invalid_root', 'invalid_root']);
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
describe('package.json', () => {
|
|
1352
|
+
it('lists a missing name and keeps checking, without crashing any check', async () => {
|
|
1353
|
+
const dir = writePackage(
|
|
1354
|
+
{
|
|
1355
|
+
[MANIFEST]: manifest({components: './components', docs: './docs'}),
|
|
1356
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1357
|
+
'docs/deploying.doc.mjs': topic('deploying'),
|
|
1358
|
+
},
|
|
1359
|
+
{version: '1.0.0'},
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
const result = await integrationCheck({cwd: dir});
|
|
1363
|
+
for (const check of result.data.checks) {
|
|
1364
|
+
expect(
|
|
1365
|
+
check.response.data.issues.map(issue => [issue.code, issue.severity]),
|
|
1366
|
+
).toEqual([['invalid_component', 'error']]);
|
|
1367
|
+
}
|
|
1368
|
+
const validate = result.data.checks[0].response.data;
|
|
1369
|
+
expect('notes' in validate ? validate.notes : undefined).toEqual([
|
|
1370
|
+
{
|
|
1371
|
+
code: 'invalid_package_json',
|
|
1372
|
+
message:
|
|
1373
|
+
'package.json has no "name", so npm cannot pack or publish the package, and a project that installs it has no name to select it by. Fix: add "name": "@your-scope/your-package" to package.json.',
|
|
1374
|
+
},
|
|
1375
|
+
]);
|
|
1376
|
+
expect(result.data.failed).toBe(true);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
it('still compares documented props for a package without a name', async () => {
|
|
1380
|
+
const dir = writePackage(
|
|
1381
|
+
{
|
|
1382
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1383
|
+
'components/AcmeWidget.doc.mjs':
|
|
1384
|
+
"export default {type: 'component', name: 'AcmeWidget', displayName: 'AcmeWidget', usage: {description: 'A widget.'}, props: [{name: 'label', type: 'string', description: 'Text.'}]};\n",
|
|
1385
|
+
'components/AcmeWidget.tsx':
|
|
1386
|
+
'export function AcmeWidget(props: {title: string}) { return null; }\n',
|
|
1387
|
+
},
|
|
1388
|
+
{version: '1.0.0'},
|
|
1389
|
+
);
|
|
1390
|
+
|
|
1391
|
+
const result = await integrationCheck({cwd: dir, strict: true});
|
|
1392
|
+
const components = result.data.checks.find(
|
|
1393
|
+
check => check.check === 'components',
|
|
1394
|
+
);
|
|
1395
|
+
const propDrift = /** @type {any} */ (components?.response.data)?.propDrift;
|
|
1396
|
+
expect(propDrift).toBeDefined();
|
|
1397
|
+
const failures = result.data.failures.map(failure => failure.code);
|
|
1398
|
+
expect(failures).not.toContain('invalid_package_json');
|
|
1399
|
+
if (propDrift.status === 'skipped') {
|
|
1400
|
+
// No TypeScript here: --strict names the skipped comparison.
|
|
1401
|
+
expect(failures).toContain('prop_drift_skipped');
|
|
1402
|
+
} else {
|
|
1403
|
+
expect(propDrift.findings.map((/** @type {any} */ f) => f.kind)).toEqual(
|
|
1404
|
+
expect.arrayContaining(['not_in_code', 'not_in_doc']),
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it('completes --strict for a package without a name whose component docs hold links, reporting none', async () => {
|
|
1410
|
+
const dir = writePackage(
|
|
1411
|
+
{
|
|
1412
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1413
|
+
'components/AcmeWidget.doc.mjs':
|
|
1414
|
+
"export default {type: 'component', name: 'AcmeWidget', displayName: 'AcmeWidget', usage: {description: 'Read {@link generic:x} first.'}, props: []};\n",
|
|
1415
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1416
|
+
},
|
|
1417
|
+
{version: '1.0.0'},
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
const result = await integrationCheck({cwd: dir, strict: true});
|
|
1421
|
+
const components = result.data.checks.find(
|
|
1422
|
+
check => check.check === 'components',
|
|
1423
|
+
);
|
|
1424
|
+
expect(components?.response.data.issues.map(issue => issue.code)).toEqual(
|
|
1425
|
+
[],
|
|
1426
|
+
);
|
|
1427
|
+
expect(
|
|
1428
|
+
result.data.failures.filter(
|
|
1429
|
+
failure => failure.code === 'invalid_doc_graph',
|
|
1430
|
+
),
|
|
1431
|
+
).toEqual([]);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
it('checks the docs of a package without a name, naming them by path', async () => {
|
|
1435
|
+
const dir = writePackage(
|
|
1436
|
+
{
|
|
1437
|
+
[MANIFEST]: manifest({docs: './docs'}),
|
|
1438
|
+
'docs/deploying.doc.mjs': topic('deploying').replace(
|
|
1439
|
+
'"text":"deploying"',
|
|
1440
|
+
'"text":"See {@link generic:no-such-topic}."',
|
|
1441
|
+
),
|
|
1442
|
+
},
|
|
1443
|
+
{version: '1.0.0'},
|
|
1444
|
+
);
|
|
1445
|
+
|
|
1446
|
+
const result = await integrationCheck({cwd: dir});
|
|
1447
|
+
const docs = result.data.checks.find(check => check.check === 'docs');
|
|
1448
|
+
const messages =
|
|
1449
|
+
docs?.response.data.issues.map(issue => issue.message) ?? [];
|
|
1450
|
+
expect(messages.join('\n')).not.toContain('astryx-unnamed-local-package');
|
|
1451
|
+
expect(docs?.response.data.issues.map(issue => issue.code)).toEqual([
|
|
1452
|
+
'invalid_doc_graph',
|
|
1453
|
+
]);
|
|
1454
|
+
expect(messages[0]).toMatch(
|
|
1455
|
+
/^deploying § overview: "generic:no-such-topic" names no doc\./u,
|
|
1456
|
+
);
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
it.each([
|
|
1460
|
+
['acme kit'],
|
|
1461
|
+
['_acme'],
|
|
1462
|
+
['acme/kit'],
|
|
1463
|
+
['-acme'],
|
|
1464
|
+
['@acme/-kit'],
|
|
1465
|
+
['~acme'],
|
|
1466
|
+
])(
|
|
1467
|
+
'lists that no provider ID can be derived from the name %s, and keeps checking',
|
|
1468
|
+
async name => {
|
|
1469
|
+
const dir = writePackage(
|
|
1470
|
+
{
|
|
1471
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1472
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1473
|
+
},
|
|
1474
|
+
{name, version: '1.0.0'},
|
|
1475
|
+
);
|
|
1476
|
+
|
|
1477
|
+
expect(
|
|
1478
|
+
(await issuesOf(dir)).map(issue => [issue.code, issue.severity]),
|
|
1479
|
+
).toEqual([['invalid_component', 'error']]);
|
|
1480
|
+
const issues = await listedOf(dir);
|
|
1481
|
+
expect(issues.map(issue => issue.code)).toEqual(['no_provider_id']);
|
|
1482
|
+
expect(issues[0].message).toBe(
|
|
1483
|
+
`No provider ID can be derived for this package: package.json "name" ${JSON.stringify(name)} is not a bare package name (an optional @scope/ and a name, each starting with a lowercase letter or digit and holding only lowercase letters, digits, "-", ".", "_" and "~"), and astryx.integration.mjs declares no providerId that is one. Astryx files the package's components, templates and docs under that ID, so links to them and selecting the package cannot work. Fix: rename the package, such as "@your-scope/your-package", or set \`providerId: '@your-scope/your-package'\` in astryx.integration.mjs.`,
|
|
1484
|
+
);
|
|
1485
|
+
expect(issues[0].message).not.toContain('npm cannot pack');
|
|
1486
|
+
const result = await integrationCheck({cwd: dir});
|
|
1487
|
+
expect(result.data.checks.every(check => check.response.type)).toBe(true);
|
|
1488
|
+
},
|
|
1489
|
+
);
|
|
1490
|
+
|
|
1491
|
+
it('leaves the provider ID to invalid_manifest when the manifest does not load', async () => {
|
|
1492
|
+
const dir = writePackage(
|
|
1493
|
+
{
|
|
1494
|
+
[MANIFEST]: manifest({providerId: '@acme/kit', issuesUrl: 42}),
|
|
1495
|
+
},
|
|
1496
|
+
{name: '-acme', version: '1.0.0'},
|
|
1497
|
+
);
|
|
1498
|
+
|
|
1499
|
+
const issues = await issuesOf(dir);
|
|
1500
|
+
expect(issues.map(issue => issue.code)).toEqual(['invalid_manifest']);
|
|
1501
|
+
expect(await listedOf(dir)).toEqual([]);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
it('derives the provider ID from the manifest when the name cannot give one', async () => {
|
|
1505
|
+
const dir = writePackage(
|
|
1506
|
+
{
|
|
1507
|
+
[MANIFEST]: manifest({
|
|
1508
|
+
providerId: '@acme/kit',
|
|
1509
|
+
components: './components',
|
|
1510
|
+
}),
|
|
1511
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1512
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1513
|
+
},
|
|
1514
|
+
{name: '@acme/_kit', version: '1.0.0'},
|
|
1515
|
+
);
|
|
1516
|
+
|
|
1517
|
+
expect(await issuesOf(dir)).toEqual([]);
|
|
1518
|
+
expect((await integrationCheck({cwd: dir, strict: true})).data.failed).toBe(
|
|
1519
|
+
false,
|
|
1520
|
+
);
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it('checks a package without a name whose root is a file, without crashing', async () => {
|
|
1524
|
+
const dir = writePackage(
|
|
1525
|
+
{
|
|
1526
|
+
[MANIFEST]: manifest({
|
|
1527
|
+
templates: './templates/account-card.tsx',
|
|
1528
|
+
docs: './docs/deploying.doc.mjs',
|
|
1529
|
+
}),
|
|
1530
|
+
'templates/account-card.tsx': templateSource,
|
|
1531
|
+
'docs/deploying.doc.mjs': topic('deploying'),
|
|
1532
|
+
},
|
|
1533
|
+
{version: '1.0.0'},
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1536
|
+
const result = await integrationCheck({cwd: dir});
|
|
1537
|
+
for (const check of result.data.checks) {
|
|
1538
|
+
expect(
|
|
1539
|
+
check.response.data.issues
|
|
1540
|
+
.filter(issue => issue.severity === 'error')
|
|
1541
|
+
.map(issue => issue.code),
|
|
1542
|
+
).toEqual(['invalid_root', 'invalid_root']);
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it('checks links under the manifest providerId when the name gives none', async () => {
|
|
1547
|
+
const linked = (/** @type {string} */ name, /** @type {string} */ text) =>
|
|
1548
|
+
`export default ${JSON.stringify({
|
|
1549
|
+
type: 'generic',
|
|
1550
|
+
name,
|
|
1551
|
+
title: name,
|
|
1552
|
+
description: `The ${name} topic.`,
|
|
1553
|
+
sections: [{title: 'Overview', content: [{type: 'prose', text}]}],
|
|
1554
|
+
})};\n`;
|
|
1555
|
+
const dir = writePackage(
|
|
1556
|
+
{
|
|
1557
|
+
[MANIFEST]: manifest({
|
|
1558
|
+
providerId: '@acme/kit',
|
|
1559
|
+
components: './components',
|
|
1560
|
+
docs: './docs',
|
|
1561
|
+
}),
|
|
1562
|
+
'components/AcmeWidget.doc.mjs':
|
|
1563
|
+
"export default {type: 'component', name: 'AcmeWidget', displayName: 'AcmeWidget', usage: {description: 'Read {@link generic:no-such-topic}.'}, props: []};\n",
|
|
1564
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1565
|
+
'docs/deploying.doc.mjs': linked(
|
|
1566
|
+
'deploying',
|
|
1567
|
+
'See {@link @acme/kit:generic:other}.',
|
|
1568
|
+
),
|
|
1569
|
+
'docs/other.doc.mjs': linked('other', 'The other topic.'),
|
|
1570
|
+
},
|
|
1571
|
+
{name: '@acme/_kit', version: '1.0.0'},
|
|
1572
|
+
);
|
|
1573
|
+
|
|
1574
|
+
const result = await integrationCheck({cwd: dir});
|
|
1575
|
+
const issuesOf = (/** @type {string} */ name) =>
|
|
1576
|
+
result.data.checks.find(check => check.check === name)?.response.data
|
|
1577
|
+
.issues ?? [];
|
|
1578
|
+
const componentLinks = issuesOf('components').filter(
|
|
1579
|
+
issue => issue.code === 'invalid_doc_graph',
|
|
1580
|
+
);
|
|
1581
|
+
expect(componentLinks).toHaveLength(1);
|
|
1582
|
+
expect(componentLinks[0].message).toContain('no-such-topic');
|
|
1583
|
+
expect(
|
|
1584
|
+
issuesOf('docs').filter(issue => issue.code === 'invalid_doc_graph'),
|
|
1585
|
+
).toEqual([]);
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
it('fails neither run on a missing name when nothing else is wrong: it is listed until the next minor', async () => {
|
|
1589
|
+
const dir = writePackage(
|
|
1590
|
+
{
|
|
1591
|
+
[MANIFEST]: manifest({docs: './docs'}),
|
|
1592
|
+
'docs/deploying.doc.mjs': topic('deploying'),
|
|
1593
|
+
},
|
|
1594
|
+
{version: '1.0.0'},
|
|
1595
|
+
);
|
|
1596
|
+
|
|
1597
|
+
expect((await integrationCheck({cwd: dir})).data.failed).toBe(false);
|
|
1598
|
+
expect((await integrationCheck({cwd: dir, strict: true})).data.failed).toBe(
|
|
1599
|
+
false,
|
|
1600
|
+
);
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
it('lists a missing version and still checks the rest', async () => {
|
|
1604
|
+
const dir = writePackage(
|
|
1605
|
+
{
|
|
1606
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1607
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1608
|
+
},
|
|
1609
|
+
{name: '@acme/kit'},
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
expect(
|
|
1613
|
+
(await issuesOf(dir)).map(issue => [issue.code, issue.severity]),
|
|
1614
|
+
).toEqual([['invalid_component', 'error']]);
|
|
1615
|
+
const issues = await listedOf(dir);
|
|
1616
|
+
expect(issues.map(issue => issue.code)).toEqual(['invalid_package_json']);
|
|
1617
|
+
expect(issues[0].message).toBe(
|
|
1618
|
+
'package.json has no "version", so npm cannot pack or publish the package. Fix: add "version": "0.1.0" to package.json.',
|
|
1619
|
+
);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
it('asks no version of a private package', async () => {
|
|
1623
|
+
const dir = writePackage(
|
|
1624
|
+
{[MANIFEST]: manifest({docs: './docs'}), 'docs/a.doc.mjs': topic('a')},
|
|
1625
|
+
{name: '@acme/kit', private: true},
|
|
1626
|
+
);
|
|
1627
|
+
|
|
1628
|
+
expect(await allOf(dir)).toEqual([]);
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
describe('the manifest', () => {
|
|
1633
|
+
it('asks about a key one letter from a known one before blaming the CLI version', async () => {
|
|
1634
|
+
const dir = writePackage({
|
|
1635
|
+
[MANIFEST]: manifest({component: './components'}),
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
const message = only(await issuesOf(dir), 'unknown_manifest_key');
|
|
1639
|
+
expect(message).toBe(
|
|
1640
|
+
'This CLI does not know the manifest key "component", so it is ignored. Did you mean `components`? Fix: rename "component" to "components" in astryx.integration.mjs. If it is a key from a newer Astryx CLI instead, upgrading @astryxdesign/cli picks it up.',
|
|
1641
|
+
);
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it('says to remove a near-miss key when the manifest already sets the key it suggests', async () => {
|
|
1645
|
+
const dir = writePackage({
|
|
1646
|
+
[MANIFEST]: manifest({components: './components', component: './x'}),
|
|
1647
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1648
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
expect(only(await issuesOf(dir), 'unknown_manifest_key')).toBe(
|
|
1652
|
+
'This CLI does not know the manifest key "component", so it is ignored. Did you mean `components`? Fix: remove "component" from astryx.integration.mjs; it already sets "components". If it is a key from a newer Astryx CLI instead, upgrading @astryxdesign/cli picks it up.',
|
|
1653
|
+
);
|
|
1654
|
+
const fixed = follow(dir, {
|
|
1655
|
+
write: {[MANIFEST]: manifest({components: './components'})},
|
|
1656
|
+
});
|
|
1657
|
+
expect(await issuesOf(fixed)).toEqual([]);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it.each([
|
|
1661
|
+
[
|
|
1662
|
+
'alone, though the package name gives a provider ID',
|
|
1663
|
+
{providerID: '@acme/kit', docs: './docs'},
|
|
1664
|
+
'Fix: rename "providerID" to "providerId" in astryx.integration.mjs.',
|
|
1665
|
+
],
|
|
1666
|
+
[
|
|
1667
|
+
'beside the key it suggests',
|
|
1668
|
+
{providerID: '@acme/kit', providerId: '@acme/kit', docs: './docs'},
|
|
1669
|
+
'Fix: remove "providerID" from astryx.integration.mjs; it already sets "providerId".',
|
|
1670
|
+
],
|
|
1671
|
+
])(
|
|
1672
|
+
'reads the keys the manifest declares: providerID %s',
|
|
1673
|
+
async (_label, keys, fix) => {
|
|
1674
|
+
const dir = writePackage({
|
|
1675
|
+
[MANIFEST]: manifest(keys),
|
|
1676
|
+
'docs/a.doc.mjs': topic('a'),
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
expect(only(await issuesOf(dir), 'unknown_manifest_key')).toContain(fix);
|
|
1680
|
+
},
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
it('keeps the newer-CLI explanation for a key that is no near miss', async () => {
|
|
1684
|
+
const dir = writePackage({[MANIFEST]: manifest({playgrounds: './x'})});
|
|
1685
|
+
|
|
1686
|
+
expect(only(await issuesOf(dir), 'unknown_manifest_key')).toContain(
|
|
1687
|
+
'published against a newer Astryx CLI',
|
|
1688
|
+
);
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it('says a manifest has no default export, and what it exports', async () => {
|
|
1692
|
+
const dir = writePackage({
|
|
1693
|
+
[MANIFEST]: "export const integration = {docs: './docs'};\n",
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
expect(only(await issuesOf(dir), 'invalid_manifest')).toBe(
|
|
1697
|
+
"Integration manifest (astryx.integration.mjs) has no default export (it exports `integration`). Fix: export the manifest object as the default export: `export default {components: './components'}`, naming the roots the package has.",
|
|
1698
|
+
);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
it('says a manifest is empty', async () => {
|
|
1702
|
+
const dir = writePackage({[MANIFEST]: ''});
|
|
1703
|
+
|
|
1704
|
+
expect(only(await issuesOf(dir), 'invalid_manifest')).toMatch(
|
|
1705
|
+
/^Integration manifest \(astryx\.integration\.mjs\) is empty\. Fix: /u,
|
|
1706
|
+
);
|
|
1707
|
+
});
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
describe('unreadable files and theme names', () => {
|
|
1711
|
+
const asRoot = process.getuid?.() === 0;
|
|
1712
|
+
|
|
1713
|
+
it.skipIf(asRoot)(
|
|
1714
|
+
'reports a contribution file it cannot read, which npm pack would fail on',
|
|
1715
|
+
async () => {
|
|
1716
|
+
const dir = writePackage({
|
|
1717
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1718
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1719
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1720
|
+
});
|
|
1721
|
+
const file = path.join(dir, 'components', 'AcmeWidget.tsx');
|
|
1722
|
+
fs.chmodSync(file, 0);
|
|
1723
|
+
try {
|
|
1724
|
+
expect(await issuesOf(dir)).toEqual([]);
|
|
1725
|
+
const message = only(await listedOf(dir), 'unreadable_file');
|
|
1726
|
+
expect(message).toBe(
|
|
1727
|
+
'Could not read "components/AcmeWidget.tsx" (EACCES), so Astryx cannot check it and npm pack fails on it. Fix: make it readable (chmod a+r components/AcmeWidget.tsx), or delete it if it does not belong in the package.',
|
|
1728
|
+
);
|
|
1729
|
+
} finally {
|
|
1730
|
+
fs.chmodSync(file, 0o644);
|
|
1731
|
+
}
|
|
1732
|
+
expect(await allOf(dir)).toEqual([]);
|
|
1733
|
+
},
|
|
1734
|
+
);
|
|
1735
|
+
|
|
1736
|
+
it.skipIf(asRoot)(
|
|
1737
|
+
'ignores an unreadable file no reader and no pack reads, such as .DS_Store',
|
|
1738
|
+
async () => {
|
|
1739
|
+
const dir = writePackage({
|
|
1740
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1741
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1742
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1743
|
+
'components/.DS_Store': 'x',
|
|
1744
|
+
'components/notes.txt': 'x',
|
|
1745
|
+
});
|
|
1746
|
+
const files = ['.DS_Store', 'notes.txt'].map(name =>
|
|
1747
|
+
path.join(dir, 'components', name),
|
|
1748
|
+
);
|
|
1749
|
+
for (const file of files) fs.chmodSync(file, 0);
|
|
1750
|
+
try {
|
|
1751
|
+
expect(await allOf(dir)).toEqual([]);
|
|
1752
|
+
} finally {
|
|
1753
|
+
for (const file of files) fs.chmodSync(file, 0o644);
|
|
1754
|
+
}
|
|
1755
|
+
},
|
|
1756
|
+
);
|
|
1757
|
+
|
|
1758
|
+
const themeDoc = `${THEME_TYPE}export default {type: 'theme', name: 'ocean', displayName: 'Ocean', description: 'A theme.', maintained: true};\n`;
|
|
1759
|
+
/** @param {string} name */
|
|
1760
|
+
const themeSource = name =>
|
|
1761
|
+
`import {defineTheme} from '@astryxdesign/core/theme';\nexport const oceanTheme = defineTheme({\n name: '${name}',\n});\n`;
|
|
1762
|
+
|
|
1763
|
+
it('accepts a theme that names itself differently from its folder', async () => {
|
|
1764
|
+
const dir = writePackage({
|
|
1765
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1766
|
+
'themes/ocean/oceanTheme.doc.mjs': themeDoc,
|
|
1767
|
+
'themes/ocean/oceanTheme.ts': themeSource('deep-sea'),
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
expect(
|
|
1771
|
+
(await issuesOf(dir)).filter(issue => issue.code === 'invalid_theme'),
|
|
1772
|
+
).toEqual([]);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
it('leaves a theme name it can only know by building alone', async () => {
|
|
1776
|
+
const dir = writePackage({
|
|
1777
|
+
[MANIFEST]: manifest({themes: './themes'}),
|
|
1778
|
+
'themes/ocean/oceanTheme.doc.mjs': themeDoc,
|
|
1779
|
+
'themes/ocean/oceanTheme.ts':
|
|
1780
|
+
"import {defineTheme} from '@astryxdesign/core/theme';\nconst name = 'sea';\nexport const oceanTheme = defineTheme({name});\n",
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
expect(
|
|
1784
|
+
(await issuesOf(dir)).filter(issue => issue.code === 'invalid_theme'),
|
|
1785
|
+
).toEqual([]);
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
describe('layout findings 0.7.2 did not fail on', () => {
|
|
1790
|
+
it('never fails --strict on a note: notes count nowhere', () => {
|
|
1791
|
+
/** @type {any} */
|
|
1792
|
+
const response = {
|
|
1793
|
+
type: 'integration.validate',
|
|
1794
|
+
data: {
|
|
1795
|
+
validated: true,
|
|
1796
|
+
name: '@acme/kit',
|
|
1797
|
+
version: '1.0.0',
|
|
1798
|
+
issues: [],
|
|
1799
|
+
notes: [
|
|
1800
|
+
{code: 'duplicate_component_doc', message: 'Two docs. Fix: one.'},
|
|
1801
|
+
],
|
|
1802
|
+
},
|
|
1803
|
+
};
|
|
1804
|
+
expect(strictIssues('validate', response)).toEqual({
|
|
1805
|
+
warnings: [],
|
|
1806
|
+
skipped: [],
|
|
1807
|
+
});
|
|
1808
|
+
expect(summarizeCheck(response)).toEqual({
|
|
1809
|
+
errors: 0,
|
|
1810
|
+
warnings: 0,
|
|
1811
|
+
skipped: [],
|
|
1812
|
+
failed: false,
|
|
1813
|
+
});
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const asRoot = process.getuid?.() === 0;
|
|
1817
|
+
|
|
1818
|
+
/** @type {Array<[string, () => string]>} */
|
|
1819
|
+
const layouts = [
|
|
1820
|
+
[
|
|
1821
|
+
'two docs for one component',
|
|
1822
|
+
() =>
|
|
1823
|
+
writePackage({
|
|
1824
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1825
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1826
|
+
'components/AcmeWidget.doc.js': componentDoc('AcmeWidget'),
|
|
1827
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1828
|
+
}),
|
|
1829
|
+
],
|
|
1830
|
+
[
|
|
1831
|
+
'a root reached through a symlink',
|
|
1832
|
+
() => {
|
|
1833
|
+
const dir = writePackage({
|
|
1834
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1835
|
+
'real-components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1836
|
+
'real-components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1837
|
+
});
|
|
1838
|
+
fs.symlinkSync('real-components', path.join(dir, 'components'));
|
|
1839
|
+
return dir;
|
|
1840
|
+
},
|
|
1841
|
+
],
|
|
1842
|
+
[
|
|
1843
|
+
'a package.json without a version',
|
|
1844
|
+
() =>
|
|
1845
|
+
writePackage(
|
|
1846
|
+
{
|
|
1847
|
+
[MANIFEST]: manifest({docs: './docs'}),
|
|
1848
|
+
'docs/a.doc.mjs': topic('a'),
|
|
1849
|
+
},
|
|
1850
|
+
{name: '@acme/kit'},
|
|
1851
|
+
),
|
|
1852
|
+
],
|
|
1853
|
+
[
|
|
1854
|
+
'a package.json without a name',
|
|
1855
|
+
() =>
|
|
1856
|
+
writePackage(
|
|
1857
|
+
{
|
|
1858
|
+
[MANIFEST]: manifest({docs: './docs'}),
|
|
1859
|
+
'docs/a.doc.mjs': topic('a'),
|
|
1860
|
+
},
|
|
1861
|
+
{version: '1.0.0'},
|
|
1862
|
+
),
|
|
1863
|
+
],
|
|
1864
|
+
...(asRoot
|
|
1865
|
+
? []
|
|
1866
|
+
: /** @type {Array<[string, () => string]>} */ ([
|
|
1867
|
+
[
|
|
1868
|
+
'a contribution file it cannot read',
|
|
1869
|
+
() => {
|
|
1870
|
+
const dir = writePackage({
|
|
1871
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1872
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1873
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1874
|
+
});
|
|
1875
|
+
fs.chmodSync(path.join(dir, 'components', 'AcmeWidget.tsx'), 0);
|
|
1876
|
+
return dir;
|
|
1877
|
+
},
|
|
1878
|
+
],
|
|
1879
|
+
])),
|
|
1880
|
+
];
|
|
1881
|
+
|
|
1882
|
+
it.each(layouts)(
|
|
1883
|
+
'lists %s as a note and passes integration check --strict',
|
|
1884
|
+
async (_label, make) => {
|
|
1885
|
+
const dir = make();
|
|
1886
|
+
try {
|
|
1887
|
+
const result = await integrationCheck({cwd: dir, strict: true});
|
|
1888
|
+
expect(result.data.failures).toEqual([]);
|
|
1889
|
+
expect(result.data.failed).toBe(false);
|
|
1890
|
+
const validate = /** @type {any} */ (
|
|
1891
|
+
result.data.checks[0].response.data
|
|
1892
|
+
);
|
|
1893
|
+
expect(validate.notes.length).toBeGreaterThan(0);
|
|
1894
|
+
for (const note of validate.notes) {
|
|
1895
|
+
expect(Object.keys(note).sort()).toEqual(['code', 'message']);
|
|
1896
|
+
expect(note.message).toContain('Fix: ');
|
|
1897
|
+
}
|
|
1898
|
+
} finally {
|
|
1899
|
+
const file = path.join(dir, 'components', 'AcmeWidget.tsx');
|
|
1900
|
+
if (fs.existsSync(file)) fs.chmodSync(file, 0o644);
|
|
1901
|
+
}
|
|
1902
|
+
},
|
|
1903
|
+
);
|
|
1904
|
+
|
|
1905
|
+
it('prints the notes and counts none of them', () => {
|
|
1906
|
+
const dir = writePackage({
|
|
1907
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1908
|
+
'components/AcmeWidget.doc.mjs': componentDoc('AcmeWidget'),
|
|
1909
|
+
'components/AcmeWidget.doc.js': componentDoc('AcmeWidget'),
|
|
1910
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
const run = spawnSync(
|
|
1914
|
+
process.execPath,
|
|
1915
|
+
[CLI, 'doctor', 'integration', 'validate'],
|
|
1916
|
+
{cwd: dir, encoding: 'utf-8', timeout: 60_000},
|
|
1917
|
+
);
|
|
1918
|
+
expect(run.status, run.stderr).toBe(0);
|
|
1919
|
+
expect(run.stdout).toContain(
|
|
1920
|
+
'note (duplicate_component_doc): Component "AcmeWidget" has 2 docs:',
|
|
1921
|
+
);
|
|
1922
|
+
expect(run.stdout).toContain('Summary: 0 errors, 0 warnings');
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
describe('name findings listed until the next minor release', () => {
|
|
1927
|
+
/** @param {string} extra */
|
|
1928
|
+
const block = extra =>
|
|
1929
|
+
`export default {type: 'block', name: 'Account card', displayName: 'Account card', aspectRatio: 1,${extra}};\n`;
|
|
1930
|
+
const docLayout = () =>
|
|
1931
|
+
writeNamed({
|
|
1932
|
+
[MANIFEST]: manifest({components: './components'}),
|
|
1933
|
+
'components/AcmeWidget.doc.mjs': namedDoc('AcmeWidget', 'AcmeWidgets'),
|
|
1934
|
+
'components/AcmeWidget.tsx': componentSource('AcmeWidget'),
|
|
1935
|
+
});
|
|
1936
|
+
const blockLayout = () =>
|
|
1937
|
+
writePackage({
|
|
1938
|
+
[MANIFEST]: manifest({templates: './templates'}),
|
|
1939
|
+
'templates/account-card.doc.mjs': block(" componentsUsed: ['AcmeGizmo']"),
|
|
1940
|
+
'templates/account-card.tsx': templateSource,
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
it.each([
|
|
1944
|
+
['doc_name_mismatch', docLayout, 'docNames'],
|
|
1945
|
+
['unknown_block_component', blockLayout, 'blockComponents'],
|
|
1946
|
+
])(
|
|
1947
|
+
'lists %s as a note beside the issues, and integration check --strict passes',
|
|
1948
|
+
async (code, make, report) => {
|
|
1949
|
+
const dir = make();
|
|
1950
|
+
const {data} = await validateIntegration(undefined, {cwd: dir});
|
|
1951
|
+
expect(data.issues).toEqual([]);
|
|
1952
|
+
expect(/** @type {any} */ (data)[report].findings).toEqual([]);
|
|
1953
|
+
expect(data.notes?.map(note => note.code)).toEqual([code]);
|
|
1954
|
+
expect(data.notes?.[0].message).toContain('Fix: ');
|
|
1955
|
+
|
|
1956
|
+
const result = await integrationCheck({cwd: dir, strict: true});
|
|
1957
|
+
expect(result.data.failures).toEqual([]);
|
|
1958
|
+
expect(result.data.failed).toBe(false);
|
|
1959
|
+
},
|
|
1960
|
+
);
|
|
1961
|
+
|
|
1962
|
+
it('prints the name notes and counts none', () => {
|
|
1963
|
+
const dir = blockLayout();
|
|
1964
|
+
const run = spawnSync(
|
|
1965
|
+
process.execPath,
|
|
1966
|
+
[CLI, 'doctor', 'integration', 'validate'],
|
|
1967
|
+
{cwd: dir, encoding: 'utf-8', timeout: 60_000},
|
|
1968
|
+
);
|
|
1969
|
+
expect(run.status, run.stderr).toBe(0);
|
|
1970
|
+
expect(run.stdout).toContain(
|
|
1971
|
+
'note (unknown_block_component): Template "account-card" names "AcmeGizmo" in componentsUsed',
|
|
1972
|
+
);
|
|
1973
|
+
expect(run.stdout).toContain('Summary: 0 errors, 0 warnings');
|
|
1974
|
+
});
|
|
1975
|
+
});
|