diff --git a/src/streamdeck/plugins/manifest/__tests__/all.test.ts b/src/streamdeck/plugins/manifest/__tests__/all.test.ts new file mode 100644 index 0000000..8571db3 --- /dev/null +++ b/src/streamdeck/plugins/manifest/__tests__/all.test.ts @@ -0,0 +1,256 @@ +import { validateStreamDeckPluginManifest } from "@tests"; + +describe.each(["v6.4", "v6.5", "v6.6"])("%s", (version) => { + const filePath = `${version}.json`; + + /** + * Asserts the patterns of `Actions[]`. + */ + describe("Actions[]", () => { + /** + * Asserts the pattern of `Icon`. + */ + test("Icon", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].Icon = "test.png")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/Icon", + keyword: "pattern", + pattern: patterns.IMAGE_PATH + }); + }); + + /** + * Asserts the pattern of `PropertyInspectorPath`. + */ + test("PropertyInspectorPath", () => { + // Arrange, act. + const errors = validateStreamDeckPluginManifest(filePath, (m) => { + // @ts-expect-error Test non-HTML file path. + m.Actions[0].PropertyInspectorPath = "file.txt"; + }); + + // Assert. + expect(errors).toHaveError({ + instancePath: "/Actions/0/PropertyInspectorPath", + keyword: "pattern", + pattern: patterns.HTML_PATH + }); + }); + + /** + * Asserts the pattern of `UUID`. + */ + test("UUID", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].UUID = "com.$.test")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/UUID", + keyword: "pattern", + pattern: patterns.UUID + }); + }); + + /** + * Asserts the patterns of `Encoder`. + */ + describe("Encoder", () => { + /** + * Asserts the pattern of `Icon`. + */ + test("Icon", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].Encoder!.Icon = "test.png")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/Encoder/Icon", + keyword: "pattern", + pattern: patterns.IMAGE_PATH + }); + }); + + /** + * Asserts the pattern of `background`. + */ + test("background", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].Encoder!.background = "test.png")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/Encoder/background", + keyword: "pattern", + pattern: patterns.ENCODER_BACKGROUND_PATH + }); + }); + + /** + * Asserts the pattern of `layout`. + */ + test("layout", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].Encoder!.layout = "./test.json")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/Encoder/layout", + keyword: "pattern", + pattern: patterns.LAYOUT + }); + }); + }); + + /** + * Asserts the patterns of `States[]`. + */ + describe("States[]", () => { + /** + * Asserts the pattern of `Image`. + */ + test("Image", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].States[0].Image = "test.png")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/States/0/Image", + keyword: "pattern", + pattern: patterns.IMAGE_PATH_WITH_GIF_SUPPORT + }); + }); + + /** + * Asserts the pattern of `MultiActionImage`. + */ + test("MultiActionImage", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Actions[0].States[0].MultiActionImage = "test.png")); + expect(errors).toHaveError({ + instancePath: "/Actions/0/States/0/MultiActionImage", + keyword: "pattern", + pattern: patterns.IMAGE_PATH + }); + }); + }); + }); + + /** + * Asserts the pattern of `CategoryIcon`. + */ + test("CategoryIcon", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.CategoryIcon = "test.png")); + expect(errors).toHaveError({ + instancePath: "/CategoryIcon", + keyword: "pattern", + pattern: patterns.IMAGE_PATH + }); + }); + + /** + * Asserts the pattern of `CodePath`. + */ + test("CodePath", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.CodePath = "./file.exe")); + expect(errors).toHaveError({ + instancePath: "/CodePath", + keyword: "pattern", + pattern: patterns.FILE_PATH + }); + }); + + /** + * Asserts the pattern of `CodePathMac`. + */ + test("CodePathMac", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.CodePathMac = "./file.exe")); + expect(errors).toHaveError({ + instancePath: "/CodePathMac", + keyword: "pattern", + pattern: patterns.FILE_PATH + }); + }); + + /** + * Asserts the pattern of `CodePathWin`. + */ + test("CodePathWin", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.CodePathWin = "./file.exe")); + expect(errors).toHaveError({ + instancePath: "/CodePathWin", + keyword: "pattern", + pattern: patterns.FILE_PATH + }); + }); + + /** + * Asserts the pattern of `Icon`. + */ + test("Icon", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.Icon = "test.png")); + expect(errors).toHaveError({ + instancePath: "/Icon", + keyword: "pattern", + pattern: patterns.IMAGE_PATH + }); + }); + + /** + * Asserts the patterns of `Profiles[]`. + */ + describe("Profiles[]", () => { + test("Name", () => { + // Arrange, act. + const errors = validateStreamDeckPluginManifest(filePath, (m) => { + m.Profiles![0].Name = "other.streamDeckProfile"; + }); + + // Assert. + expect(errors).toHaveError({ + instancePath: "/Profiles/0/Name", + keyword: "pattern", + pattern: patterns.PROFILE_PATH + }); + }); + }); + + /** + * Asserts the pattern of `PropertyInspectorPath`. + */ + test("PropertyInspectorPath", () => { + // Arrange, act. + const errors = validateStreamDeckPluginManifest(filePath, (m) => { + // @ts-expect-error Test non-HTML file path string. + m.PropertyInspectorPath = "test.txt"; + }); + + // Assert. + expect(errors).toHaveError({ + instancePath: "/PropertyInspectorPath", + keyword: "pattern", + pattern: patterns.HTML_PATH + }); + }); + + /** + * Asserts the pattern of `UUID`. + */ + test("UUID", () => { + // Arrange, act, assert. + const errors = validateStreamDeckPluginManifest(filePath, (m) => (m.UUID = "com.$.test")); + expect(errors).toHaveError({ + instancePath: "/UUID", + keyword: "pattern", + pattern: patterns.UUID + }); + }); +}); + +const patterns = { + COLOR: "^#(?:[0-9a-fA-F]{3}){1,2}$", + ENCODER_BACKGROUND_PATH: "^(?![~\\.]*[\\\\\\/]+)(?!.*\\.(([Pp][Nn][Gg])|([Ss][Vv][Gg]))$).*$", + FILE_PATH: "^(?![~\\.]*[\\\\\\/]+).*$", + HTML_PATH: "^(?![~\\.]*[\\\\\\/]+).*\\.(([Hh][Tt][Mm])|([Hh][Tt][Mm][Ll]))$", + LAYOUT: "^(^(?![\\.]*[\\\\\\/]+).+\\.([Jj][Ss][Oo][Nn])$)|(\\$(X1|A0|A1|B1|B2|C1))$", + IMAGE_PATH_WITH_GIF_SUPPORT: "^(?![~\\.]*[\\\\\\/]+)(?!.*\\.(([Gg][Ii][Ff])|([Ss][Vv][Gg])|([Pp][Nn][Gg]))$).*$", + IMAGE_PATH: "^(?![~\\.]*[\\\\\\/]+)(?!.*\\.(([Ss][Vv][Gg])|([Pp][Nn][Gg]))$).*$", + PROFILE_PATH: "^(?![~\\.]*[\\\\\\/]+)(?!.*\\.(([Ss][Tt][Rr][Ee][Aa][Mm][Dd][Ee][Cc][Kk][Pp][Rr][Oo][Ff][Ii][Ll][Ee]))$).*$", + UUID: "^([a-z0-9-]+)(\\.[a-z0-9-]+)+$" +}; diff --git a/src/streamdeck/plugins/manifest/__tests__/v6.6.test.ts b/src/streamdeck/plugins/manifest/__tests__/v6.6.test.ts index 6375f8f..d518f92 100644 --- a/src/streamdeck/plugins/manifest/__tests__/v6.6.test.ts +++ b/src/streamdeck/plugins/manifest/__tests__/v6.6.test.ts @@ -15,7 +15,7 @@ describe("v6.6", () => { /** * Asserts more than 2 states are allowed. */ - test("allow more than 2 states", () => { + test("Actions[].States[] allow more than 2 items", () => { // Arrange, act, assert. const errors = validateStreamDeckPluginManifest("v6.6.json", (m) => { m.Actions[0].States.push({ Image: "imgs/two" }); diff --git a/src/streamdeck/plugins/manifest/latest.ts b/src/streamdeck/plugins/manifest/latest.ts index bc61498..4e0a4f0 100644 --- a/src/streamdeck/plugins/manifest/latest.ts +++ b/src/streamdeck/plugins/manifest/latest.ts @@ -52,10 +52,12 @@ export type Manifest = { * **Examples**: * - assets/category-icon * - imgs/category + * @filePath + * { extensions: [".svg", ".png"], includeExtension: false } * @imageDimensions * [28, 28] */ - CategoryIcon?: ImageFilePath; + CategoryIcon?: string; /** * Path to the plugin's main entry point; this is executed when the Stream Deck application starts the plugin. @@ -109,10 +111,12 @@ export type Manifest = { * **Examples**: * assets/plugin-icon * imgs/plugin + * @filePath + * { extensions: [".svg", ".png"], includeExtension: false } * @imageDimensions * [288, 288] */ - Icon: ImageFilePath; + Icon: string; /** * Name of the plugin, e.g. "Wave Link", "Camera Hub", "Control Center", etc. @@ -157,8 +161,10 @@ export type Manifest = { * * **Also see:** * - `streamDeck.ui.onSendToPlugin(...)` + * @filePath + * { extensions: [".htm", ".html"], includeExtension: true } */ - PropertyInspectorPath?: HtmlFilePath; + PropertyInspectorPath?: FilePath<"htm" | "html">; /** * Preferred SDK version; this should _currently_ always be 2. @@ -191,8 +197,12 @@ export type Manifest = { * - com.elgato.wavelink * - com.elgato.discord * - tv.twitch + * @pattern + * ^([a-z0-9-]+)(\.[a-z0-9-]+)+$ + * @errorMessage + * String must be in reverse DNS format, and must only contain lowercase alphanumeric characters (a-z, 0-9), hyphens (-), and periods (.) */ - UUID: Identifier; + UUID: string; /** * Version of the plugin represented as a semantic version (https://semver.org) with a build number; pre-release identifiers are not permitted. @@ -258,10 +268,12 @@ export type Action = { * **Examples:** * - assets/counter * - imgs/actions/mute + * @filePath + * { extensions: [".svg", ".png"], includeExtension: false } * @imageDimensions * [20, 20] */ - Icon: ImageFilePath; + Icon: string; /** * Name of the action; this is displayed to the user in the actions list, and is used throughout the Stream Deck application to visually identify the action. @@ -285,8 +297,10 @@ export type Action = { * **Examples:** * - mute.html * - actions/join-voice-chat/settings.html + * @filePath + * { extensions: [".htm", ".html"], includeExtension: true } */ - PropertyInspectorPath?: HtmlFilePath; + PropertyInspectorPath?: FilePath<"htm" | "html">; /** * States the action can be in. When two states are defined the action will act as a toggle, with users being able to select their preferred iconography for each state. @@ -322,8 +336,12 @@ export type Action = { * - com.elgato.wavelink.toggle-mute * - com.elgato.discord.join-voice * - tv.twitch.go-live + * @pattern + * ^([a-z0-9-]+)(\.[a-z0-9-]+)+$ + * @errorMessage + * String must be in reverse DNS format, and must only contain lowercase alphanumeric characters (a-z, 0-9), hyphens (-), and periods (.) */ - UUID: Identifier; + UUID: string; /** * Determines whether the title field is available to the user when viewing the action's property inspector. Setting this to `false` will disable the user from specifying a @@ -374,10 +392,12 @@ export type Encoder = { * **Examples:** * - assets/actions/mute/encoder-icon * - imgs/join-voice-chat-encoder + * @filePath + * { extensions: [".svg", ".png"], includeExtension: false } * @imageDimensions * [72, 72] */ - Icon?: ImageFilePath; + Icon?: string; /** * Background color to display in the Stream Deck application when the action is part of a dial stack, and is the current action. Represented as a hexadecimal value. @@ -387,7 +407,7 @@ export type Encoder = { * - #1f1f1 * - #0038a8 */ - StackColor?: HexColorString; + StackColor?: string; /** * Descriptions that define the interaction of the action when it is associated with a dial / touchscreen on the Stream Deck +. This information is shown to the user. @@ -544,10 +564,12 @@ export type State = { * **Examples:** * - assets/counter-key * - assets/icons/mute + * @filePath + * { extensions: [".svg", ".png"], includeExtension: false } * @imageDimensions * [72, 72] */ - MultiActionImage?: ImageFilePath; + MultiActionImage?: string; /** * Name of the state; when multiple states are defined this value is shown to the user when the action is being added to a multi-action. The user is then able to specify which @@ -584,7 +606,7 @@ export type State = { * - #f5a9b8 * - #FFFFFF */ - TitleColor?: HexColorString; + TitleColor?: string; }; /** @@ -688,35 +710,3 @@ export type OS = { * File path, relative to the manifest's location. */ type FilePath = `${string}.${Lowercase}`; - -/** - * Color represents as a hexadecimal string. - * @pattern - * ^#(?:[0-9a-fA-F]{3}){1,2}$ - * @errorMessage - * String must be hexadecimal color. - */ -type HexColorString = string; - -/** - * File path that represents an HTML file relative to the plugin's manifest. - * @filePath - * { extensions: [".htm", ".html"], includeExtension: true } - */ -type HtmlFilePath = FilePath<"htm" | "html">; - -/** - * Unique identifier, in reverse DNS format. - * @pattern - * ^([a-z0-9-]+)(\.[a-z0-9-]+)+$ - * @errorMessage - * String must be in reverse DNS format, and must only contain lowercase alphanumeric characters (a-z, 0-9), hyphens (-), and periods (.) - */ -type Identifier = string; - -/** - * File path that represents a file relative to the plugin's manifest, with the extension omitted. When multiple images with the same name are found, they are resolved in order. - * @filePath - * { extensions: [".svg", ".png"], includeExtension: false } - */ -type ImageFilePath = string; diff --git a/tests/matchers/to-have-error.ts b/tests/matchers/to-have-error.ts index 321e010..b15ccc4 100644 --- a/tests/matchers/to-have-error.ts +++ b/tests/matchers/to-have-error.ts @@ -6,7 +6,7 @@ import type { MatcherFunction } from "expect"; * @param error Expected error. * @returns The matcher result. */ -export const toHaveError: MatcherFunction<[error: AdditionalPropertyError]> = function (actual: unknown, error: AdditionalPropertyError) { +export const toHaveError: MatcherFunction<[error: AdditionalPropertyError]> = function (actual: unknown, error: JsonSchemaError) { if (!Array.isArray(actual)) { return { message: () => `expected ${this.utils.printReceived(actual)} to be an array`, @@ -22,18 +22,26 @@ export const toHaveError: MatcherFunction<[error: AdditionalPropertyError]> = fu }; } - if ( - item.instancePath === error.instancePath && - item.keyword === error.keyword && - "params" in item && - "additionalProperty" in item.params && - item.params.additionalProperty === error.property - ) { + // keyword or instancePath differ. + if (item.keyword !== error.keyword || item.instancePath !== error.instancePath) { + continue; + } + + // When keyword is params do not match the error, continue. + if (error.keyword === "additionalProperties" && item.params.additionalProperty !== error.property) { + continue; + } else if (error.keyword === "pattern" && item.params.pattern !== error.pattern) { return { - message: () => `expected ${this.utils.printReceived(item)} to be a JSON schema of keyword "additionalProperty" for ${error.instancePath}`, - pass: true + message: () => `expected ${this.utils.printReceived(item.params.pattern)} to be ${this.utils.printExpected(error.pattern)}`, + pass: false }; } + + // Otherwise the error was found. + return { + message: () => `expected ${this.utils.printReceived(item)} to be a JSON schema of keyword "additionalProperty" for ${error.instancePath}`, + pass: true + }; } return { @@ -42,6 +50,11 @@ export const toHaveError: MatcherFunction<[error: AdditionalPropertyError]> = fu }; }; +/** + * Represents a JSON error. + */ +type JsonSchemaError = AdditionalPropertyError | PatternError; + /** * Represents a JSON error for the keyword `additionalProperties`. */ @@ -62,14 +75,34 @@ type AdditionalPropertyError = { property: string; }; +/** + * Represents a JSON error for the keyword `pattern`. + */ +type PatternError = { + /** + * Path to the instance of the error. + */ + instancePath: string; + + /** + * Keyword of the error. + */ + keyword: "pattern"; + + /** + * Expected pattern. + */ + pattern: string; +}; + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface AsymmetricMatchers { - toHaveError(error: AdditionalPropertyError): void; + toHaveError(error: JsonSchemaError): void; } interface Matchers { - toHaveError(error: AdditionalPropertyError): R; + toHaveError(error: JsonSchemaError): R; } } } diff --git a/tests/validate.ts b/tests/validate.ts index f9a999d..fe391b4 100644 --- a/tests/validate.ts +++ b/tests/validate.ts @@ -12,7 +12,7 @@ import type { Manifest } from "../src/streamdeck/plugins"; */ export function validateStreamDeckPluginManifest(filename: string, modify?: (manifest: Manifest) => void): ErrorObject, unknown>[] { const schema = JSON.parse(getFileContents("../streamdeck/plugins/manifest.json")); - const validate = new Ajv() + const validate = new Ajv({ allErrors: true }) .addKeyword(keywordDefinitions.errorMessage) .addKeyword(keywordDefinitions.filePath) .addKeyword(keywordDefinitions.imageDimensions)