diff --git a/README.md b/README.md index e358a541..a235af95 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ const payload = message({ - `python/` — the established Python package. - `typescript/` — the ESM TypeScript package. -- `spec/` — 79 valid fixtures, shared invalid cases, and limits. +- `spec/` — 82 valid fixtures, shared invalid cases, capability coverage, and limits. - `docs/` — the Docusaurus documentation site. See the [documentation site](https://nicklambourne.github.io/slackblocks/) for installation and usage. diff --git a/docs/docs/contributing.mdx b/docs/docs/contributing.mdx index 896cff0c..5426427c 100644 --- a/docs/docs/contributing.mdx +++ b/docs/docs/contributing.mdx @@ -53,11 +53,12 @@ The Python and TypeScript conformance suites both exercise every ID in `spec/man ## Add or change a Block Kit feature -1. Add or update canonical JSON in `spec/fixtures/valid/` and register it in `spec/manifest.json`. -2. Add invalid behavior to `spec/fixtures/invalid/manifest.json` when the feature introduces a validation rule. -3. Update `spec/limits.json` for shared scalar constraints. -4. Implement the feature idiomatically in both packages, or record a reason in the affected skip list. -5. Add language-native tests and update the relevant guide or executable example. +1. Add or update canonical JSON in `spec/fixtures/valid/`, register it in `spec/manifest.json`, and link the exact official Slack reference used to validate it. +2. Map every new JSON-producing capability to at least one valid fixture in `spec/coverage.json`. +3. Add invalid behavior to `spec/fixtures/invalid/manifest.json` when the feature introduces a validation rule. Every scalar leaf in `spec/limits.json` must have a matching invalid case. +4. Update `spec/limits.json` for shared scalar constraints. +5. Implement the feature idiomatically in both packages, or record a reason in the affected skip list. +6. Add language-native tests and update the relevant guide or executable example. When introducing a cross-language API, serialized JSON and validation outcomes are shared; public naming and construction style are language-native. diff --git a/python/slackblocks/blocks.py b/python/slackblocks/blocks.py index 3356ee01..90ecf444 100644 --- a/python/slackblocks/blocks.py +++ b/python/slackblocks/blocks.py @@ -26,6 +26,7 @@ EmailInput, ExternalMultiSelectMenu, ExternalSelectMenu, + FileInput, NumberInput, PlainTextInput, RadioButtonGroup, @@ -85,6 +86,7 @@ RichTextInput, EmailInput, URLInput, + FileInput, ) diff --git a/python/slackblocks/elements.py b/python/slackblocks/elements.py index de00a4a4..fe23a141 100644 --- a/python/slackblocks/elements.py +++ b/python/slackblocks/elements.py @@ -404,11 +404,9 @@ def __init__( self.max_files = validate_int(max_files, min_value=1, max_value=10, allow_none=True) def _resolve(self) -> dict[str, Any]: - # FileInput currently does not emit the "type" attribute; this is - # preserved from prior behaviour. The pre-existing #154 export work - # surfaced this class but did not change its rendering contract. return resolve( { + **self._attributes(), "action_id": self.action_id, "filetypes": self.filetypes, "max_files": self.max_files, diff --git a/python/test/conformance/test_conformance.py b/python/test/conformance/test_conformance.py index f761fb2d..ea5e943b 100644 --- a/python/test/conformance/test_conformance.py +++ b/python/test/conformance/test_conformance.py @@ -7,27 +7,35 @@ import pytest from slackblocks import ( + ActionsBlock, Button, + ConfirmationDialogue, ContextBlock, DividerBlock, FileInput, HeaderBlock, HomeTabView, Image, + ImageBlock, InputBlock, LengthError, + MarkdownBlock, MissingRequiredError, + ModalView, MutualExclusivityError, NumberInput, Option, + OptionGroup, OverflowMenu, PlainText, + PlainTextInput, RangeError, SectionBlock, SlackFile, StaticSelectMenu, Text, TypeMismatchError, + VideoBlock, ) from slackblocks.errors import InvalidUsageError @@ -50,10 +58,23 @@ def test_valid_manifest_covers_the_entire_fixture_corpus() -> None: for path in (SPEC_ROOT / "fixtures" / "valid").rglob("*.json") } assert manifest["spec_version"] == "1.0.0" - assert len(fixture_ids) == 79 + assert fixture_ids + assert len(fixture_ids) == len(manifest["fixtures"]) assert fixture_ids == files +def test_every_shared_json_capability_has_an_official_fixture() -> None: + manifest = load_json(SPEC_ROOT / "manifest.json") + coverage = load_json(SPEC_ROOT / "coverage.json") + fixtures = {fixture["id"]: fixture for fixture in manifest["fixtures"]} + assert coverage["spec_version"] == manifest["spec_version"] + for capability, fixture_ids in coverage["capabilities"].items(): + assert fixture_ids, capability + for fixture_id in fixture_ids: + assert fixture_id in fixtures, f"{capability} -> {fixture_id}" + assert fixtures[fixture_id]["slack_docs"].startswith("https://docs.slack.dev/") + + def test_every_valid_fixture_is_exercised_by_a_python_construction_test() -> None: manifest = load_json(SPEC_ROOT / "manifest.json") test_source = "\n".join( @@ -78,18 +99,197 @@ def option(value: str = "a") -> Option: return Option(text=PlainText("A"), value=value) +LIMITS = load_json(SPEC_ROOT / "limits.json") + + +def video(**overrides: object) -> VideoBlock: + inputs: dict[str, object] = { + "alt_text": "Video", + "thumbnail_url": "https://example.com/thumbnail.png", + "title": "Title", + "video_url": "https://example.com/video.mp4", + **overrides, + } + return VideoBlock(**inputs) # type: ignore[arg-type] + + INVALID_CASES: dict[str, Callable[[], object]] = { "text-empty": lambda: Text(""), "text-too-long": lambda: Text("x" * 3001), + "button-action-id-too-long": lambda: Button( + text="A", action_id="x" * (LIMITS["action_id"]["max_length"] + 1) + ), + "button-text-too-long": lambda: Button( + text="x" * (LIMITS["button"]["text"]["max_length"] + 1), action_id="a" + ), + "button-url-too-long": lambda: Button( + text="A", + action_id="a", + url="x" * (LIMITS["button"]["url"]["max_length"] + 1), + ), + "button-value-too-long": lambda: Button( + text="A", + action_id="a", + value="x" * (LIMITS["button"]["value"]["max_length"] + 1), + ), + "confirmation-title-too-long": lambda: ConfirmationDialogue( + title="x" * (LIMITS["confirmation"]["title"]["max_length"] + 1), + text="Text", + confirm="Yes", + deny="No", + ), + "confirmation-text-too-long": lambda: ConfirmationDialogue( + title="Title", + text="x" * (LIMITS["confirmation"]["text"]["max_length"] + 1), + confirm="Yes", + deny="No", + ), + "confirmation-confirm-too-long": lambda: ConfirmationDialogue( + title="Title", + text="Text", + confirm="x" * (LIMITS["confirmation"]["confirm"]["max_length"] + 1), + deny="No", + ), + "confirmation-deny-too-long": lambda: ConfirmationDialogue( + title="Title", + text="Text", + confirm="Yes", + deny="x" * (LIMITS["confirmation"]["deny"]["max_length"] + 1), + ), + "option-text-too-long": lambda: Option( + text="x" * (LIMITS["option"]["text"]["max_length"] + 1), value="a" + ), + "option-value-too-long": lambda: option("x" * (LIMITS["option"]["value"]["max_length"] + 1)), + "option-description-too-long": lambda: Option( + text="A", + value="a", + description="x" * (LIMITS["option"]["description"]["max_length"] + 1), + ), + "option-group-label-too-long": lambda: OptionGroup( + label="x" * (LIMITS["option_group"]["label"]["max_length"] + 1), + options=[option()], + ), + "option-group-empty": lambda: OptionGroup(label="Group", options=[]), + "option-group-too-many-options": lambda: OptionGroup( + label="Group", + options=[ + option(str(index)) + for index in range(LIMITS["option_group"]["options"]["max_items"] + 1) + ], + ), + "select-placeholder-too-long": lambda: StaticSelectMenu( + action_id="a", + options=[option()], + placeholder="x" * (LIMITS["select"]["placeholder"]["max_length"] + 1), + ), + "select-too-many-options": lambda: StaticSelectMenu( + action_id="a", + options=[ + option(str(index)) for index in range(LIMITS["select"]["options"]["max_items"] + 1) + ], + ), + "select-too-many-option-groups": lambda: StaticSelectMenu( + action_id="a", + option_groups=[ + OptionGroup(label=f"Group {index}", options=[option()]) + for index in range(LIMITS["select"]["option_groups"]["max_items"] + 1) + ], + ), + "overflow-empty": lambda: OverflowMenu("a", []), + "overflow-too-many-options": lambda: OverflowMenu( + "a", + [option(str(index)) for index in range(LIMITS["overflow"]["options"]["max_items"] + 1)], + ), + "file-input-max-files-too-small": lambda: FileInput( + action_id="a", max_files=LIMITS["file_input"]["max_files"]["min"] - 1 + ), + "file-input-max-files-too-large": lambda: FileInput( + action_id="a", max_files=LIMITS["file_input"]["max_files"]["max"] + 1 + ), + "plain-text-input-max-length-too-large": lambda: PlainTextInput( + action_id="a", max_length=LIMITS["plain_text_input"]["max_length"]["max"] + 1 + ), + "actions-too-many-elements": lambda: ActionsBlock( + elements=[ + Button(text="A", action_id=f"a-{index}") + for index in range(LIMITS["actions"]["elements"]["max_items"] + 1) + ] + ), + "context-too-many-elements": lambda: ContextBlock( + elements=[Text("A") for _ in range(LIMITS["context"]["elements"]["max_items"] + 1)] + ), + "header-text-too-long": lambda: HeaderBlock("x" * (LIMITS["header"]["text"]["max_length"] + 1)), + "image-url-too-long": lambda: ImageBlock( + image_url="x" * (LIMITS["image"]["image_url"]["max_length"] + 1), + alt_text="Alt", + ), + "image-alt-text-too-long": lambda: ImageBlock( + image_url="https://example.com/image.png", + alt_text="x" * (LIMITS["image"]["alt_text"]["max_length"] + 1), + ), + "input-label-too-long": lambda: InputBlock( + label="x" * (LIMITS["input"]["label"]["max_length"] + 1), + element=PlainTextInput(action_id="a"), + ), + "input-hint-too-long": lambda: InputBlock( + label="Label", + hint="x" * (LIMITS["input"]["hint"]["max_length"] + 1), + element=PlainTextInput(action_id="a"), + ), + "markdown-empty": lambda: MarkdownBlock(""), + "markdown-too-long": lambda: MarkdownBlock( + "x" * (LIMITS["markdown"]["text"]["max_length"] + 1) + ), + "section-text-too-long": lambda: SectionBlock( + "x" * (LIMITS["section"]["text"]["max_length"] + 1) + ), + "section-too-many-fields": lambda: SectionBlock( + fields=["x"] * (LIMITS["section"]["fields"]["max_items"] + 1) + ), + "section-field-too-long": lambda: SectionBlock( + fields=["x" * (LIMITS["section"]["fields"]["item_max_length"] + 1)] + ), + "video-alt-text-empty": lambda: video(alt_text=""), + "video-alt-text-too-long": lambda: video( + alt_text="x" * (LIMITS["video"]["alt_text"]["max_length"] + 1) + ), + "video-title-too-long": lambda: video(title="x" * (LIMITS["video"]["title"]["max_length"] + 1)), + "video-author-name-too-long": lambda: video( + author_name="x" * (LIMITS["video"]["author_name"]["max_length"] + 1) + ), + "video-description-too-long": lambda: video( + description="x" * (LIMITS["video"]["description"]["max_length"] + 1) + ), + "video-provider-name-too-long": lambda: video( + provider_name="x" * (LIMITS["video"]["provider_name"]["max_length"] + 1) + ), + "view-missing-blocks": lambda: HomeTabView(blocks=[]), + "view-too-many-blocks": lambda: HomeTabView( + blocks=[DividerBlock() for _ in range(LIMITS["view"]["blocks"]["max_items"] + 1)] + ), + "view-private-metadata-too-long": lambda: HomeTabView( + blocks=[DividerBlock()], + private_metadata="x" * (LIMITS["view"]["private_metadata"]["max_length"] + 1), + ), + "view-callback-id-too-long": lambda: HomeTabView( + blocks=[DividerBlock()], + callback_id="x" * (LIMITS["view"]["callback_id"]["max_length"] + 1), + ), + "view-title-too-long": lambda: ModalView( + title="x" * (LIMITS["view"]["title"]["max_length"] + 1), + blocks=[DividerBlock()], + ), + "view-close-too-long": lambda: ModalView( + title="Title", + close="x" * (LIMITS["view"]["close"]["max_length"] + 1), + blocks=[DividerBlock()], + ), + "view-submit-too-long": lambda: ModalView( + title="Title", + submit="x" * (LIMITS["view"]["submit"]["max_length"] + 1), + blocks=[DividerBlock()], + ), "section-missing-content": lambda: SectionBlock(), - "section-text-too-long": lambda: SectionBlock("x" * 3001), - "section-too-many-fields": lambda: SectionBlock(fields=["x"] * 11), - "section-field-too-long": lambda: SectionBlock(fields=["x" * 2001]), - "header-text-too-long": lambda: HeaderBlock("x" * 151), - "button-text-too-long": lambda: Button(text="x" * 76, action_id="a"), - "button-action-id-too-long": lambda: Button(text="A", action_id="x" * 256), - "option-value-too-long": lambda: option("x" * 76), - "overflow-too-many-options": lambda: OverflowMenu("a", [option(str(i)) for i in range(6)]), "static-select-options-and-groups": lambda: StaticSelectMenu( "a", options=[option()], @@ -101,12 +301,17 @@ def option(value: str = "a") -> Option: "number-input-inverted-range": lambda: NumberInput( is_decimal_allowed=True, min_value=2, max_value=1 ), - "file-input-max-files-out-of-range": lambda: FileInput(action_id="a", max_files=11), "context-invalid-element": lambda: ContextBlock(elements=[DividerBlock()]), "input-invalid-element": lambda: InputBlock(label="Label", element=Button("A", "a")), - "view-missing-blocks": lambda: HomeTabView(blocks=[]), } + +def scalar_paths(value: object, prefix: tuple[str, ...] = ()) -> set[str]: + if not isinstance(value, dict): + return {".".join(prefix)} + return {path for key, nested in value.items() for path in scalar_paths(nested, (*prefix, key))} + + ERROR_CATEGORIES = { LengthError: "length-exceeded", RangeError: "out-of-range", @@ -134,6 +339,23 @@ def test_invalid_case_category(case_id: str, expected_category: str) -> None: assert category == expected_category +def test_invalid_manifest_covers_every_scalar_limit() -> None: + cases = load_json(SPEC_ROOT / "fixtures" / "invalid" / "manifest.json")["cases"] + covered = {case["constraint"] for case in cases} + assert scalar_paths(LIMITS) <= covered + + +def test_invalid_manifest_has_unique_case_ids_and_constraints() -> None: + cases = load_json(SPEC_ROOT / "fixtures" / "invalid" / "manifest.json")["cases"] + assert len({case["id"] for case in cases}) == len(cases) + assert len({case["constraint"] for case in cases}) == len(cases) + + +def test_every_invalid_case_has_a_python_construction() -> None: + cases = load_json(SPEC_ROOT / "fixtures" / "invalid" / "manifest.json")["cases"] + assert set(INVALID_CASES) == {case["id"] for case in cases} + + def test_python_skiplist_is_empty() -> None: entries = [ line diff --git a/python/test/unit/test_elements.py b/python/test/unit/test_elements.py index 11ea0cd5..103d6d00 100644 --- a/python/test/unit/test_elements.py +++ b/python/test/unit/test_elements.py @@ -15,6 +15,7 @@ EmailInput, ExternalMultiSelectMenu, ExternalSelectMenu, + FileInput, Image, NumberInput, OverflowMenu, @@ -152,11 +153,45 @@ def test_email_input_basic() -> None: assert fetch_sample(path="elements/email_input_basic.json") == repr(email_input) +def test_file_input_basic() -> None: + file_input = FileInput( + action_id="file_input_action_id_1", + filetypes=["jpg", "png"], + max_files=5, + ) + assert fetch_sample(path="elements/file_input_basic.json") == repr(file_input) + + +def test_file_input_is_valid_inside_input_block() -> None: + from slackblocks import InputBlock + + block = InputBlock(label="Upload files", element=FileInput(action_id="file_input")) + assert block._resolve()["element"]["type"] == "file_input" + + def test_image_basic() -> None: image = Image(image_url="https://ndl.im/img/logo.png", alt_text="Logo for ndl.im") assert fetch_sample(path="elements/image_basic.json") == repr(image) +@pytest.mark.parametrize( + ("fixture_id", "slack_file"), + [ + ("elements/image_slack_file_id.json", SlackFile(url=None, id="F0123456")), + ( + "elements/image_slack_file_url.json", + SlackFile( + url="https://files.slack.com/files-pri/T0123456-F0123456/xyz.png", + id=None, + ), + ), + ], +) +def test_image_with_slack_file_fixture(fixture_id: str, slack_file: SlackFile) -> None: + image = Image(alt_text="An incredibly cute kitten.", slack_file=slack_file) + assert fetch_sample(path=fixture_id) == repr(image) + + def test_image_with_slack_file_resolves() -> None: """Regression test for #130: Image element must call ``_resolve()`` on its nested ``slack_file`` so the result is JSON-serializable.""" diff --git a/python/test/unit/test_serializability.py b/python/test/unit/test_serializability.py index 22018455..8c65f241 100644 --- a/python/test/unit/test_serializability.py +++ b/python/test/unit/test_serializability.py @@ -294,8 +294,7 @@ def _serializability_check( "external_select", True, ), - # FileInput historically does not include "type" in its rendered JSON. - ("FileInput", lambda: FileInput(action_id="fi"), "", False), + ("FileInput", lambda: FileInput(action_id="fi"), "file_input", True), ( "Image_with_url", lambda: Image(image_url="https://x.png", alt_text="alt"), diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 17848507..dad144ee 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.0.0 - 2026-08-12 -- Promote the 79 existing Python golden fixtures into the shared valid corpus. +- Promote the existing Python golden fixtures into the shared valid corpus and expand it to 82 Slack-validated cases. - Define the cross-language validation categories and seed invalid cases. - Publish a shared scalar limits registry. +- Require fixture coverage for every supported JSON capability and invalid-case coverage for every scalar limit. diff --git a/spec/SPEC.md b/spec/SPEC.md index 343020a3..2a7492d4 100644 --- a/spec/SPEC.md +++ b/spec/SPEC.md @@ -16,6 +16,10 @@ The normative categories are `length-exceeded`, `out-of-range`, `mutually-exclus Each implementation must either exercise every valid fixture and invalid case or list its ID in `conformance/skiplist.txt` with a reason. Conformance fails for unknown entries, unlisted failures, and stale skips that now pass. +`coverage.json` lists every JSON-producing capability in the shared public API and the valid fixtures that exercise it. Every referenced fixture must be registered in `manifest.json` and link to the official Slack documentation used to validate its shape. Adding a shared capability requires adding it to this registry. + ## Limits `limits.json` is the shared registry for stable scalar constraints. Changes to limits and fixtures must land atomically with implementation updates or explicit skip-list entries. + +Every scalar leaf in `limits.json` must have a corresponding invalid case whose `constraint` is the leaf's dotted path. Structural rules that are not scalar limits may have additional invalid cases. diff --git a/spec/coverage.json b/spec/coverage.json new file mode 100644 index 00000000..2f92de7a --- /dev/null +++ b/spec/coverage.json @@ -0,0 +1,80 @@ +{ + "spec_version": "1.0.0", + "capabilities": { + "blocks.actions": ["blocks/actions_block_checkboxes"], + "blocks.context": ["blocks/context_block_text_only"], + "blocks.divider": ["blocks/divider_block_only"], + "blocks.file": ["blocks/file_block_only"], + "blocks.header": ["blocks/header_block_only"], + "blocks.image": ["blocks/image_block_only"], + "blocks.input": ["blocks/input_block_only"], + "blocks.markdown": ["blocks/markdown_block_basic"], + "blocks.rich_text": ["blocks/rich_text_block_basic"], + "blocks.section": ["blocks/section_block_text_only"], + "blocks.table": ["blocks/table_block"], + "blocks.video": ["blocks/video_block_basic"], + "elements.button": ["elements/button_basic"], + "elements.checkboxes": ["elements/checkbox_basic"], + "elements.date_picker": ["elements/date_picker_basic"], + "elements.datetime_picker": ["elements/datetime_picker_basic"], + "elements.email_input": ["elements/email_input_basic"], + "elements.file_input": ["elements/file_input_basic"], + "elements.image": [ + "elements/image_basic", + "elements/image_slack_file_id", + "elements/image_slack_file_url" + ], + "elements.multi_select_channels": ["elements/multi_select_channel"], + "elements.multi_select_conversations": ["elements/multi_select_conversation"], + "elements.multi_select_external": ["elements/multi_select_external"], + "elements.multi_select_static": ["elements/multi_select_static"], + "elements.multi_select_users": ["elements/multi_select_user"], + "elements.number_input": ["elements/number_input_basic"], + "elements.overflow": ["elements/overflow_menu_basic"], + "elements.plain_text_input": ["elements/plaintext_input_basic"], + "elements.radio_buttons": ["elements/radio_button_group_basic"], + "elements.rich_text_input": ["elements/rich_text_input_basic"], + "elements.select_channels": ["elements/select_menu_channel"], + "elements.select_conversations": ["elements/select_menu_conversation"], + "elements.select_external": ["elements/select_menu_external"], + "elements.select_static": ["elements/select_menu_static"], + "elements.select_users": ["elements/select_menu_user"], + "elements.time_picker": ["elements/timepicker_basic"], + "elements.url_input": ["elements/url_input_basic"], + "elements.workflow_button": ["elements/workflow_button_basic"], + "messages.attachment": ["attachments/attachment_simple"], + "messages.message": ["messages/message_basic"], + "messages.message_response": ["messages/message_response"], + "messages.webhook_message": ["messages/webhook_message_basic"], + "objects.column_settings": ["blocks/table_block"], + "objects.confirmation": ["objects/confirmation_dialogue_basic"], + "objects.conversation_filter": ["objects/conversation_filter_basic"], + "objects.dispatch_action_configuration": [ + "objects/dispatch_action_configuration_basic" + ], + "objects.input_parameter": ["objects/input_parameter_basic"], + "objects.markdown_text": ["objects/text_markdown_basic"], + "objects.option": ["objects/option_basic"], + "objects.option_group": ["objects/option_group_basic"], + "objects.plain_text": ["objects/text_plaintext_basic"], + "objects.raw_text": ["blocks/table_block"], + "objects.slack_file": [ + "elements/image_slack_file_id", + "elements/image_slack_file_url" + ], + "objects.trigger": ["objects/trigger_basic"], + "objects.workflow": ["objects/workflow_basic"], + "rich_text.channel": ["rich_text/rich_text_channel_basic"], + "rich_text.code_block": ["rich_text/rich_text_code_block_basic"], + "rich_text.emoji": ["rich_text/rich_text_emoji_basic"], + "rich_text.link": ["rich_text/rich_text_link_basic"], + "rich_text.list": ["rich_text/rich_text_list_basic"], + "rich_text.quote": ["rich_text/rich_text_quote_basic"], + "rich_text.section": ["rich_text/rich_text_section_basic"], + "rich_text.text": ["rich_text/rich_text_basic"], + "rich_text.user": ["rich_text/rich_text_user_basic"], + "rich_text.user_group": ["rich_text/rich_text_user_group_basic"], + "views.home_tab": ["views/hometab_view"], + "views.modal": ["views/modal_with_blocks"] + } +} diff --git a/spec/fixtures/invalid/manifest.json b/spec/fixtures/invalid/manifest.json index 89831de8..e22fb3e5 100644 --- a/spec/fixtures/invalid/manifest.json +++ b/spec/fixtures/invalid/manifest.json @@ -14,10 +14,190 @@ "description": "A text object longer than 3000 characters must be rejected" }, { - "id": "section-missing-content", - "category": "missing-required", - "constraint": "section.content", - "description": "A section with neither text nor fields must be rejected" + "id": "button-action-id-too-long", + "category": "length-exceeded", + "constraint": "action_id.max_length", + "description": "An action ID longer than 255 characters must be rejected" + }, + { + "id": "button-text-too-long", + "category": "length-exceeded", + "constraint": "button.text.max_length", + "description": "Button text longer than 75 characters must be rejected" + }, + { + "id": "button-url-too-long", + "category": "length-exceeded", + "constraint": "button.url.max_length", + "description": "A button URL longer than 3000 characters must be rejected" + }, + { + "id": "button-value-too-long", + "category": "length-exceeded", + "constraint": "button.value.max_length", + "description": "A button value longer than 2000 characters must be rejected" + }, + { + "id": "confirmation-title-too-long", + "category": "length-exceeded", + "constraint": "confirmation.title.max_length", + "description": "A confirmation title longer than 100 characters must be rejected" + }, + { + "id": "confirmation-text-too-long", + "category": "length-exceeded", + "constraint": "confirmation.text.max_length", + "description": "Confirmation text longer than 300 characters must be rejected" + }, + { + "id": "confirmation-confirm-too-long", + "category": "length-exceeded", + "constraint": "confirmation.confirm.max_length", + "description": "Confirmation button text longer than 30 characters must be rejected" + }, + { + "id": "confirmation-deny-too-long", + "category": "length-exceeded", + "constraint": "confirmation.deny.max_length", + "description": "Denial button text longer than 30 characters must be rejected" + }, + { + "id": "option-text-too-long", + "category": "length-exceeded", + "constraint": "option.text.max_length", + "description": "Option text longer than 75 characters must be rejected" + }, + { + "id": "option-value-too-long", + "category": "length-exceeded", + "constraint": "option.value.max_length", + "description": "An option value longer than 75 characters must be rejected" + }, + { + "id": "option-description-too-long", + "category": "length-exceeded", + "constraint": "option.description.max_length", + "description": "An option description longer than 75 characters must be rejected" + }, + { + "id": "option-group-label-too-long", + "category": "length-exceeded", + "constraint": "option_group.label.max_length", + "description": "An option-group label longer than 75 characters must be rejected" + }, + { + "id": "option-group-empty", + "category": "length-exceeded", + "constraint": "option_group.options.min_items", + "description": "An option group with no options must be rejected" + }, + { + "id": "option-group-too-many-options", + "category": "length-exceeded", + "constraint": "option_group.options.max_items", + "description": "An option group with more than 100 options must be rejected" + }, + { + "id": "select-placeholder-too-long", + "category": "length-exceeded", + "constraint": "select.placeholder.max_length", + "description": "A select placeholder longer than 150 characters must be rejected" + }, + { + "id": "select-too-many-options", + "category": "length-exceeded", + "constraint": "select.options.max_items", + "description": "A static select with more than 100 options must be rejected" + }, + { + "id": "select-too-many-option-groups", + "category": "length-exceeded", + "constraint": "select.option_groups.max_items", + "description": "A static select with more than 100 option groups must be rejected" + }, + { + "id": "overflow-empty", + "category": "length-exceeded", + "constraint": "overflow.options.min_items", + "description": "An overflow menu with no options must be rejected" + }, + { + "id": "overflow-too-many-options", + "category": "length-exceeded", + "constraint": "overflow.options.max_items", + "description": "An overflow menu with more than five options must be rejected" + }, + { + "id": "file-input-max-files-too-small", + "category": "out-of-range", + "constraint": "file_input.max_files.min", + "description": "File input max files below one must be rejected" + }, + { + "id": "file-input-max-files-too-large", + "category": "out-of-range", + "constraint": "file_input.max_files.max", + "description": "File input max files above 10 must be rejected" + }, + { + "id": "plain-text-input-max-length-too-large", + "category": "out-of-range", + "constraint": "plain_text_input.max_length.max", + "description": "Plain-text input max length above 3000 must be rejected" + }, + { + "id": "actions-too-many-elements", + "category": "length-exceeded", + "constraint": "actions.elements.max_items", + "description": "An actions block with more than 25 elements must be rejected" + }, + { + "id": "context-too-many-elements", + "category": "length-exceeded", + "constraint": "context.elements.max_items", + "description": "A context block with more than 10 elements must be rejected" + }, + { + "id": "header-text-too-long", + "category": "length-exceeded", + "constraint": "header.text.max_length", + "description": "Header text longer than 150 characters must be rejected" + }, + { + "id": "image-url-too-long", + "category": "length-exceeded", + "constraint": "image.image_url.max_length", + "description": "An image URL longer than 3000 characters must be rejected" + }, + { + "id": "image-alt-text-too-long", + "category": "length-exceeded", + "constraint": "image.alt_text.max_length", + "description": "Image alternative text longer than 2000 characters must be rejected" + }, + { + "id": "input-label-too-long", + "category": "length-exceeded", + "constraint": "input.label.max_length", + "description": "An input-block label longer than 2000 characters must be rejected" + }, + { + "id": "input-hint-too-long", + "category": "length-exceeded", + "constraint": "input.hint.max_length", + "description": "Input-block hint text longer than 2000 characters must be rejected" + }, + { + "id": "markdown-empty", + "category": "length-exceeded", + "constraint": "markdown.text.min_length", + "description": "An empty markdown block must be rejected" + }, + { + "id": "markdown-too-long", + "category": "length-exceeded", + "constraint": "markdown.text.max_length", + "description": "Markdown block text longer than 12000 characters must be rejected" }, { "id": "section-text-too-long", @@ -38,34 +218,88 @@ "description": "A section field longer than 2000 characters must be rejected" }, { - "id": "header-text-too-long", + "id": "video-alt-text-empty", "category": "length-exceeded", - "constraint": "header.text.max_length", - "description": "Header text longer than 150 characters must be rejected" + "constraint": "video.alt_text.min_length", + "description": "A video block with empty alternative text must be rejected" }, { - "id": "button-text-too-long", + "id": "video-alt-text-too-long", "category": "length-exceeded", - "constraint": "button.text.max_length", - "description": "Button text longer than 75 characters must be rejected" + "constraint": "video.alt_text.max_length", + "description": "Video alternative text longer than 200 characters must be rejected" }, { - "id": "button-action-id-too-long", + "id": "video-title-too-long", "category": "length-exceeded", - "constraint": "action_id.max_length", - "description": "An action ID longer than 255 characters must be rejected" + "constraint": "video.title.max_length", + "description": "A video title longer than 200 characters must be rejected" }, { - "id": "option-value-too-long", + "id": "video-author-name-too-long", "category": "length-exceeded", - "constraint": "option.value.max_length", - "description": "An option value longer than 75 characters must be rejected" + "constraint": "video.author_name.max_length", + "description": "A video author name longer than 50 characters must be rejected" }, { - "id": "overflow-too-many-options", + "id": "video-description-too-long", "category": "length-exceeded", - "constraint": "overflow.options.max_items", - "description": "An overflow menu with more than five options must be rejected" + "constraint": "video.description.max_length", + "description": "A video description longer than 200 characters must be rejected" + }, + { + "id": "video-provider-name-too-long", + "category": "length-exceeded", + "constraint": "video.provider_name.max_length", + "description": "A video provider name longer than 50 characters must be rejected" + }, + { + "id": "view-missing-blocks", + "category": "length-exceeded", + "constraint": "view.blocks.min_items", + "description": "A view must contain at least one block" + }, + { + "id": "view-too-many-blocks", + "category": "length-exceeded", + "constraint": "view.blocks.max_items", + "description": "A view with more than 100 blocks must be rejected" + }, + { + "id": "view-private-metadata-too-long", + "category": "length-exceeded", + "constraint": "view.private_metadata.max_length", + "description": "View private metadata longer than 3000 characters must be rejected" + }, + { + "id": "view-callback-id-too-long", + "category": "length-exceeded", + "constraint": "view.callback_id.max_length", + "description": "A view callback ID longer than 255 characters must be rejected" + }, + { + "id": "view-title-too-long", + "category": "length-exceeded", + "constraint": "view.title.max_length", + "description": "A modal title longer than 24 characters must be rejected" + }, + { + "id": "view-close-too-long", + "category": "length-exceeded", + "constraint": "view.close.max_length", + "description": "Modal close text longer than 24 characters must be rejected" + }, + { + "id": "view-submit-too-long", + "category": "length-exceeded", + "constraint": "view.submit.max_length", + "description": "Modal submit text longer than 24 characters must be rejected" + }, + { + "id": "section-missing-content", + "category": "missing-required", + "constraint": "section.content", + "description": "A section with neither text nor fields must be rejected" }, { "id": "static-select-options-and-groups", @@ -85,12 +319,6 @@ "constraint": "number_input.range", "description": "A number input minimum cannot exceed its maximum" }, - { - "id": "file-input-max-files-out-of-range", - "category": "out-of-range", - "constraint": "file_input.max_files", - "description": "File input max files must be between 1 and 10" - }, { "id": "context-invalid-element", "category": "type-mismatch", @@ -102,12 +330,6 @@ "category": "type-mismatch", "constraint": "input.element", "description": "An input block must contain an input-compatible element" - }, - { - "id": "view-missing-blocks", - "category": "length-exceeded", - "constraint": "view.blocks.min_items", - "description": "A view must contain at least one block" } ] } diff --git a/spec/fixtures/valid/elements/file_input_basic.json b/spec/fixtures/valid/elements/file_input_basic.json new file mode 100644 index 00000000..4529828f --- /dev/null +++ b/spec/fixtures/valid/elements/file_input_basic.json @@ -0,0 +1,9 @@ +{ + "type": "file_input", + "action_id": "file_input_action_id_1", + "filetypes": [ + "jpg", + "png" + ], + "max_files": 5 +} diff --git a/spec/fixtures/valid/elements/image_slack_file_id.json b/spec/fixtures/valid/elements/image_slack_file_id.json new file mode 100644 index 00000000..d07b60ca --- /dev/null +++ b/spec/fixtures/valid/elements/image_slack_file_id.json @@ -0,0 +1,7 @@ +{ + "type": "image", + "slack_file": { + "id": "F0123456" + }, + "alt_text": "An incredibly cute kitten." +} diff --git a/spec/fixtures/valid/elements/image_slack_file_url.json b/spec/fixtures/valid/elements/image_slack_file_url.json new file mode 100644 index 00000000..41021217 --- /dev/null +++ b/spec/fixtures/valid/elements/image_slack_file_url.json @@ -0,0 +1,7 @@ +{ + "type": "image", + "slack_file": { + "url": "https://files.slack.com/files-pri/T0123456-F0123456/xyz.png" + }, + "alt_text": "An incredibly cute kitten." +} diff --git a/spec/manifest.json b/spec/manifest.json index 6696bccc..ecb63133 100644 --- a/spec/manifest.json +++ b/spec/manifest.json @@ -157,12 +157,30 @@ "slack_docs": "https://docs.slack.dev/reference/block-kit/block-elements/", "since": "1.0.0" }, + { + "id": "elements/file_input_basic", + "description": "File Input Basic", + "slack_docs": "https://docs.slack.dev/reference/block-kit/block-elements/file-input-element/", + "since": "1.0.0" + }, { "id": "elements/image_basic", "description": "Image Basic", "slack_docs": "https://docs.slack.dev/reference/block-kit/block-elements/", "since": "1.0.0" }, + { + "id": "elements/image_slack_file_id", + "description": "Image With Slack File ID", + "slack_docs": "https://docs.slack.dev/reference/block-kit/composition-objects/slack-file-object/", + "since": "1.0.0" + }, + { + "id": "elements/image_slack_file_url", + "description": "Image With Slack File URL", + "slack_docs": "https://docs.slack.dev/reference/block-kit/composition-objects/slack-file-object/", + "since": "1.0.0" + }, { "id": "elements/multi_select_channel", "description": "Multi Select Channel", diff --git a/typescript/src/elements.ts b/typescript/src/elements.ts index 7ffee149..651bdf22 100644 --- a/typescript/src/elements.ts +++ b/typescript/src/elements.ts @@ -1,5 +1,5 @@ import { RangeError } from "./errors.js"; -import { create, createObject } from "./internal.js"; +import { create } from "./internal.js"; import { asText, type TextLike } from "./objects.js"; import type { FactorySettings, JsonObject, SlackObject } from "./types.js"; @@ -82,12 +82,11 @@ export function emailInput( export function fileInput( input: { actionId: string; filetypes?: string[]; maxFiles?: number }, settings: FactorySettings = {}, -): JsonObject { +): SlackObject<"file_input"> { if (input.maxFiles !== undefined && (input.maxFiles < 1 || input.maxFiles > 10)) { throw new RangeError("fileInput.maxFiles", "expected a value between 1 and 10"); } - // Python 2.0 fixtures preserve this historical no-type wire shape. - return createObject(input, settings); + return create("file_input", input, settings); } export function imageElement( diff --git a/typescript/src/objects.ts b/typescript/src/objects.ts index ed3d5b1c..60fed3cf 100644 --- a/typescript/src/objects.ts +++ b/typescript/src/objects.ts @@ -111,6 +111,17 @@ export function optionGroup( input: OptionGroupInput, settings: FactorySettings = {}, ): JsonObject { + ensureLength( + textValue(asText(input.label, "plain_text", settings)) ?? "", + "optionGroup.label", + 75, + ); + if (input.options.length < 1 || input.options.length > 100) { + throw new LengthError( + "optionGroup.options", + `expected between 1 and 100 options, received ${input.options.length}`, + ); + } return createObject( { ...input, label: asText(input.label, "plain_text", settings) }, settings, diff --git a/typescript/src/validation.ts b/typescript/src/validation.ts index c882e7cd..67a82b05 100644 --- a/typescript/src/validation.ts +++ b/typescript/src/validation.ts @@ -49,6 +49,21 @@ function length( } } +function range( + value: number | undefined, + path: string, + minimum?: number, + maximum?: number, +): void { + if (value === undefined) return; + if (minimum !== undefined && value < minimum) { + throw new RangeError(path, `${value} is less than minimum ${minimum}`); + } + if (maximum !== undefined && value > maximum) { + throw new RangeError(path, `${value} exceeds maximum ${maximum}`); + } +} + function validateTextObject(object: JsonObject, path: string): void { const value = textValue(object); if (value === undefined) { @@ -141,6 +156,30 @@ function validateKnownObject(object: JsonObject, path: string): void { length(object.value, child(path, "value"), undefined, limits.button.value.max_length); } break; + case "file_input": + range( + typeof object.max_files === "number" ? object.max_files : undefined, + child(path, "max_files"), + limits.file_input.max_files.min, + limits.file_input.max_files.max, + ); + break; + case "plain_text_input": + range( + typeof object.max_length === "number" ? object.max_length : undefined, + child(path, "max_length"), + undefined, + limits.plain_text_input.max_length.max, + ); + if (object.placeholder !== undefined) { + length( + textValue(object.placeholder), + child(path, "placeholder.text"), + undefined, + limits.select.placeholder.max_length, + ); + } + break; case "overflow": if (Array.isArray(object.options)) { length( @@ -159,6 +198,30 @@ function validateKnownObject(object: JsonObject, path: string): void { "options and option_groups cannot be provided together", ); } + if (Array.isArray(object.options)) { + length( + object.options, + child(path, "options"), + undefined, + limits.select.options.max_items, + ); + } + if (Array.isArray(object.option_groups)) { + length( + object.option_groups, + child(path, "option_groups"), + undefined, + limits.select.option_groups.max_items, + ); + } + if (object.placeholder !== undefined) { + length( + textValue(object.placeholder), + child(path, "placeholder.text"), + undefined, + limits.select.placeholder.max_length, + ); + } break; case "number_input": if ( @@ -176,6 +239,22 @@ function validateKnownObject(object: JsonObject, path: string): void { "image_url and slack_file cannot be provided together", ); } + if (typeof object.image_url === "string") { + length( + object.image_url, + child(path, "image_url"), + undefined, + limits.image.image_url.max_length, + ); + } + if (typeof object.alt_text === "string") { + length( + object.alt_text, + child(path, "alt_text"), + undefined, + limits.image.alt_text.max_length, + ); + } break; case "context": if (Array.isArray(object.elements)) { @@ -198,6 +277,20 @@ function validateKnownObject(object: JsonObject, path: string): void { } break; case "input": { + length( + textValue(object.label), + child(path, "label.text"), + undefined, + limits.input.label.max_length, + ); + if (object.hint !== undefined) { + length( + textValue(object.hint), + child(path, "hint.text"), + undefined, + limits.input.hint.max_length, + ); + } const elementPath = child(path, "element"); const element = objectAt(object.element, elementPath); if (!INPUT_ELEMENT_TYPES.has(String(element.type))) { @@ -205,6 +298,48 @@ function validateKnownObject(object: JsonObject, path: string): void { } break; } + case "markdown": + length( + typeof object.text === "string" ? object.text : undefined, + child(path, "text"), + limits.markdown.text.min_length, + limits.markdown.text.max_length, + ); + break; + case "video": + length( + typeof object.alt_text === "string" ? object.alt_text : undefined, + child(path, "alt_text"), + limits.video.alt_text.min_length, + limits.video.alt_text.max_length, + ); + length( + textValue(object.title), + child(path, "title.text"), + undefined, + limits.video.title.max_length, + ); + length( + typeof object.author_name === "string" ? object.author_name : undefined, + child(path, "author_name"), + undefined, + limits.video.author_name.max_length, + ); + if (object.description !== undefined) { + length( + textValue(object.description), + child(path, "description.text"), + undefined, + limits.video.description.max_length, + ); + } + length( + typeof object.provider_name === "string" ? object.provider_name : undefined, + child(path, "provider_name"), + undefined, + limits.video.provider_name.max_length, + ); + break; case "modal": case "home": if (!Array.isArray(object.blocks)) { @@ -216,6 +351,42 @@ function validateKnownObject(object: JsonObject, path: string): void { limits.view.blocks.min_items, limits.view.blocks.max_items, ); + length( + typeof object.private_metadata === "string" ? object.private_metadata : undefined, + child(path, "private_metadata"), + undefined, + limits.view.private_metadata.max_length, + ); + length( + typeof object.callback_id === "string" ? object.callback_id : undefined, + child(path, "callback_id"), + undefined, + limits.view.callback_id.max_length, + ); + if (type === "modal") { + length( + textValue(object.title), + child(path, "title.text"), + undefined, + limits.view.title.max_length, + ); + if (object.close !== undefined) { + length( + textValue(object.close), + child(path, "close.text"), + undefined, + limits.view.close.max_length, + ); + } + if (object.submit !== undefined) { + length( + textValue(object.submit), + child(path, "submit.text"), + undefined, + limits.view.submit.max_length, + ); + } + } break; default: break; diff --git a/typescript/test/conformance.test.ts b/typescript/test/conformance.test.ts index bd726a35..ca8ea9f3 100644 --- a/typescript/test/conformance.test.ts +++ b/typescript/test/conformance.test.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import limits from "../../spec/limits.json" with { type: "json" }; + import { actionsBlock, attachment, @@ -75,14 +77,24 @@ import { const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const SPEC_ROOT = resolve(PACKAGE_ROOT, "../spec"); +interface ManifestFixture { + id: string; + slack_docs: string; +} + interface Manifest { spec_version: string; - fixtures: Array<{ id: string }>; + fixtures: ManifestFixture[]; +} + +interface Coverage { + spec_version: string; + capabilities: Record; } interface InvalidManifest { spec_version: string; - cases: Array<{ id: string; category: ErrorCategory }>; + cases: Array<{ id: string; category: ErrorCategory; constraint: string }>; } function readJson(path: string): T { @@ -213,11 +225,37 @@ function constructFixture(id: string, expected: FixtureInput): unknown { } const manifest = readJson(resolve(SPEC_ROOT, "manifest.json")); +const coverage = readJson(resolve(SPEC_ROOT, "coverage.json")); + +function scalarPaths(value: unknown, prefix: string[] = []): string[] { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return [prefix.join(".")]; + } + return Object.entries(value).flatMap(([key, nested]) => + scalarPaths(nested, [...prefix, key]), + ); +} describe("valid conformance corpus", () => { - it("declares spec 1.0.0 and all 79 fixtures", () => { + it("declares the current spec and a non-empty fixture corpus", () => { expect(manifest.spec_version).toBe("1.0.0"); - expect(manifest.fixtures).toHaveLength(79); + expect(manifest.fixtures.length).toBeGreaterThan(0); + expect(new Set(manifest.fixtures.map(({ id }) => id)).size).toBe( + manifest.fixtures.length, + ); + }); + + it("covers every declared shared JSON capability with an official fixture", () => { + expect(coverage.spec_version).toBe(manifest.spec_version); + const fixtures = new Map(manifest.fixtures.map((fixture) => [fixture.id, fixture])); + for (const [capability, fixtureIds] of Object.entries(coverage.capabilities)) { + expect(fixtureIds.length, capability).toBeGreaterThan(0); + for (const fixtureId of fixtureIds) { + const fixture = fixtures.get(fixtureId); + expect(fixture, `${capability} -> ${fixtureId}`).toBeDefined(); + expect(fixture?.slack_docs).toMatch(/^https:\/\/docs\.slack\.dev\//); + } + } }); for (const fixture of manifest.fixtures) { @@ -249,20 +287,199 @@ describe("valid conformance corpus", () => { }); const choice = () => option({ text: "A", value: "a" }); +const video = (overrides: Record = {}) => + videoBlock({ + altText: "Video", + thumbnailUrl: "https://example.com/thumbnail.png", + title: "Title", + videoUrl: "https://example.com/video.mp4", + ...overrides, + }); const invalidCases: Record unknown> = { "text-empty": () => plainText(""), "text-too-long": () => plainText("x".repeat(3001)), - "section-missing-content": () => sectionBlock({}), - "section-text-too-long": () => sectionBlock({ text: "x".repeat(3001) }), - "section-too-many-fields": () => sectionBlock({ fields: Array(11).fill("x") }), - "section-field-too-long": () => sectionBlock({ fields: ["x".repeat(2001)] }), - "header-text-too-long": () => headerBlock({ text: "x".repeat(151) }), - "button-text-too-long": () => button({ text: "x".repeat(76), actionId: "a" }), - "button-action-id-too-long": () => button({ text: "A", actionId: "x".repeat(256) }), - "option-value-too-long": () => option({ text: "A", value: "x".repeat(76) }), + "button-action-id-too-long": () => + button({ text: "A", actionId: "x".repeat(limits.action_id.max_length + 1) }), + "button-text-too-long": () => + button({ text: "x".repeat(limits.button.text.max_length + 1), actionId: "a" }), + "button-url-too-long": () => + button({ text: "A", actionId: "a", url: "x".repeat(limits.button.url.max_length + 1) }), + "button-value-too-long": () => + button({ + text: "A", + actionId: "a", + value: "x".repeat(limits.button.value.max_length + 1), + }), + "confirmation-title-too-long": () => + confirmation({ + title: "x".repeat(limits.confirmation.title.max_length + 1), + text: "Text", + confirm: "Yes", + deny: "No", + }), + "confirmation-text-too-long": () => + confirmation({ + title: "Title", + text: "x".repeat(limits.confirmation.text.max_length + 1), + confirm: "Yes", + deny: "No", + }), + "confirmation-confirm-too-long": () => + confirmation({ + title: "Title", + text: "Text", + confirm: "x".repeat(limits.confirmation.confirm.max_length + 1), + deny: "No", + }), + "confirmation-deny-too-long": () => + confirmation({ + title: "Title", + text: "Text", + confirm: "Yes", + deny: "x".repeat(limits.confirmation.deny.max_length + 1), + }), + "option-text-too-long": () => + option({ text: "x".repeat(limits.option.text.max_length + 1), value: "a" }), + "option-value-too-long": () => + option({ text: "A", value: "x".repeat(limits.option.value.max_length + 1) }), + "option-description-too-long": () => + option({ + text: "A", + value: "a", + description: "x".repeat(limits.option.description.max_length + 1), + }), + "option-group-label-too-long": () => + optionGroup({ + label: "x".repeat(limits.option_group.label.max_length + 1), + options: [choice()], + }), + "option-group-empty": () => optionGroup({ label: "Group", options: [] }), + "option-group-too-many-options": () => + optionGroup({ + label: "Group", + options: Array.from({ length: limits.option_group.options.max_items + 1 }, choice), + }), + "select-placeholder-too-long": () => + staticSelect({ + actionId: "a", + options: [choice()], + placeholder: "x".repeat(limits.select.placeholder.max_length + 1), + }), + "select-too-many-options": () => + staticSelect({ + actionId: "a", + options: Array.from({ length: limits.select.options.max_items + 1 }, choice), + }), + "select-too-many-option-groups": () => + staticSelect({ + actionId: "a", + optionGroups: Array.from( + { length: limits.select.option_groups.max_items + 1 }, + () => optionGroup({ label: "Group", options: [choice()] }), + ), + }), + "overflow-empty": () => overflow({ actionId: "a", options: [] }), "overflow-too-many-options": () => - overflow({ actionId: "a", options: Array.from({ length: 6 }, choice) }), + overflow({ + actionId: "a", + options: Array.from({ length: limits.overflow.options.max_items + 1 }, choice), + }), + "file-input-max-files-too-small": () => + fileInput({ actionId: "a", maxFiles: limits.file_input.max_files.min - 1 }), + "file-input-max-files-too-large": () => + fileInput({ actionId: "a", maxFiles: limits.file_input.max_files.max + 1 }), + "plain-text-input-max-length-too-large": () => + plainTextInput({ + actionId: "a", + maxLength: limits.plain_text_input.max_length.max + 1, + }), + "actions-too-many-elements": () => + actionsBlock({ + elements: Array.from({ length: limits.actions.elements.max_items + 1 }, () => + button({ text: "A", actionId: "a" }), + ), + }), + "context-too-many-elements": () => + contextBlock({ + elements: Array.from( + { length: limits.context.elements.max_items + 1 }, + () => mrkdwn("A"), + ), + }), + "header-text-too-long": () => + headerBlock({ text: "x".repeat(limits.header.text.max_length + 1) }), + "image-url-too-long": () => + imageBlock({ imageUrl: "x".repeat(limits.image.image_url.max_length + 1), altText: "Alt" }), + "image-alt-text-too-long": () => + imageBlock({ + imageUrl: "https://example.com/image.png", + altText: "x".repeat(limits.image.alt_text.max_length + 1), + }), + "input-label-too-long": () => + inputBlock({ + label: "x".repeat(limits.input.label.max_length + 1), + element: plainTextInput({ actionId: "a" }), + }), + "input-hint-too-long": () => + inputBlock({ + label: "Label", + hint: "x".repeat(limits.input.hint.max_length + 1), + element: plainTextInput({ actionId: "a" }), + }), + "markdown-empty": () => markdownBlock({ text: "" }), + "markdown-too-long": () => + markdownBlock({ text: "x".repeat(limits.markdown.text.max_length + 1) }), + "section-text-too-long": () => + sectionBlock({ text: "x".repeat(limits.section.text.max_length + 1) }), + "section-too-many-fields": () => + sectionBlock({ fields: Array(limits.section.fields.max_items + 1).fill("x") }), + "section-field-too-long": () => + sectionBlock({ fields: ["x".repeat(limits.section.fields.item_max_length + 1)] }), + "video-alt-text-empty": () => video({ altText: "" }), + "video-alt-text-too-long": () => + video({ altText: "x".repeat(limits.video.alt_text.max_length + 1) }), + "video-title-too-long": () => + video({ title: "x".repeat(limits.video.title.max_length + 1) }), + "video-author-name-too-long": () => + video({ authorName: "x".repeat(limits.video.author_name.max_length + 1) }), + "video-description-too-long": () => + video({ description: "x".repeat(limits.video.description.max_length + 1) }), + "video-provider-name-too-long": () => + video({ providerName: "x".repeat(limits.video.provider_name.max_length + 1) }), + "view-missing-blocks": () => homeTab({ blocks: [] }), + "view-too-many-blocks": () => + homeTab({ + blocks: Array.from({ length: limits.view.blocks.max_items + 1 }, () => dividerBlock()), + }), + "view-private-metadata-too-long": () => + homeTab({ + blocks: [dividerBlock()], + privateMetadata: "x".repeat(limits.view.private_metadata.max_length + 1), + }), + "view-callback-id-too-long": () => + homeTab({ + blocks: [dividerBlock()], + callbackId: "x".repeat(limits.view.callback_id.max_length + 1), + }), + "view-title-too-long": () => + modal({ + title: "x".repeat(limits.view.title.max_length + 1), + blocks: [dividerBlock()], + }), + "view-close-too-long": () => + modal({ + title: "Title", + close: "x".repeat(limits.view.close.max_length + 1), + blocks: [dividerBlock()], + }), + "view-submit-too-long": () => + modal({ + title: "Title", + submit: "x".repeat(limits.view.submit.max_length + 1), + blocks: [dividerBlock()], + }), + "section-missing-content": () => sectionBlock({}), "static-select-options-and-groups": () => staticSelect({ actionId: "a", options: [choice()], optionGroups: [{ label: plainText("A") }] }), "image-url-and-slack-file": () => @@ -273,11 +490,9 @@ const invalidCases: Record unknown> = { } as any), "number-input-inverted-range": () => numberInput({ actionId: "a", isDecimalAllowed: true, minValue: 2, maxValue: 1 }), - "file-input-max-files-out-of-range": () => fileInput({ actionId: "a", maxFiles: 11 }), "context-invalid-element": () => contextBlock({ elements: [dividerBlock()] }), "input-invalid-element": () => inputBlock({ label: "Label", element: button({ text: "A", actionId: "a" }) }), - "view-missing-blocks": () => homeTab({ blocks: [] }), }; const invalidManifest = readJson( @@ -285,6 +500,19 @@ const invalidManifest = readJson( ); describe("invalid conformance corpus", () => { + it("exercises every scalar limit in the shared registry", () => { + const covered = new Set(invalidManifest.cases.map(({ constraint }) => constraint)); + expect(scalarPaths(limits).filter((path) => !covered.has(path))).toEqual([]); + }); + + it("contains unique case IDs and constraints", () => { + expect(new Set(invalidManifest.cases.map(({ id }) => id)).size).toBe( + invalidManifest.cases.length, + ); + expect(new Set(invalidManifest.cases.map(({ constraint }) => constraint)).size).toBe( + invalidManifest.cases.length, + ); + }); it("has a construction for every case", () => { expect(Object.keys(invalidCases).sort()).toEqual( invalidManifest.cases.map(({ id }) => id).sort(),