diff --git a/commit-editor-app/jest.config.js b/commit-editor-app/jest.config.js index 07f6f84..f613ff8 100644 --- a/commit-editor-app/jest.config.js +++ b/commit-editor-app/jest.config.js @@ -2,7 +2,7 @@ module.exports = { transform: { '^.+\\.tsx?$': 'ts-jest', - '^.+\\.vue$': 'vue-jest' + '^.+\\.vue$': 'vue-jest', }, collectCoverageFrom: ['src/**/*.ts'], } diff --git a/commit-editor-app/src/components/validity-indicator.vue b/commit-editor-app/src/components/validity-indicator.vue index a2f6c2b..1b76a48 100644 --- a/commit-editor-app/src/components/validity-indicator.vue +++ b/commit-editor-app/src/components/validity-indicator.vue @@ -139,9 +139,6 @@ export default defineComponent({ margin-left: 0.5rem; } -.warning-label { -} - .problem-button > span, .problem-label > span, .warning-label > span { margin-left: 0.5rem; } diff --git a/commit-editor-app/src/lib/__snapshots__/config-json-schema.test.ts.snap b/commit-editor-app/src/lib/__snapshots__/config-json-schema.test.ts.snap new file mode 100644 index 0000000..42a0fea --- /dev/null +++ b/commit-editor-app/src/lib/__snapshots__/config-json-schema.test.ts.snap @@ -0,0 +1,855 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`config-json-schema.ts should snap config schema 1`] = ` +Object { + "$id": "internal://server/config.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": Object { + "defaultIgnores": Object { + "type": "boolean", + }, + "extends": Object { + "items": Object { + "enum": Array [ + "@commitlint/config-conventional", + "@commitlint/config-angular", + "@commitlint/config-angular-type-enum", + ], + }, + "type": "array", + }, + "rules": Object { + "properties": Object { + "body-case": Object { + "description": "The body must be written in the configured case: +'lower-case': default +'upper-case': UPPERCASE +'camel-case': camelCase +'kebab-case': kebab-case +'pascal-case': PascalCase +'sentence-case': Sentence case +'snake-case': snake_case +'start-case': Start Case", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "enum": Array [ + "lower-case", + "upper-case", + "camel-case", + "kebab-case", + "pascal-case", + "sentence-case", + "snake-case", + "start-case", + ], + }, + ], + "title": "body is in case value", + "type": "array", + }, + "body-empty": Object { + "description": "The body must kept empty", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "body is empty", + "type": "array", + }, + "body-full-stop": Object { + "description": "The body must end with the configured value, e. g. '.'", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "string", + }, + ], + "title": "body ends with value", + "type": "array", + }, + "body-leading-blank": Object { + "description": "The line before the body must be a blank line", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "body begins with blank line", + "type": "array", + }, + "body-max-length": Object { + "description": "The body can consist of only up to the number of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "body has value or less characters", + "type": "array", + }, + "body-max-line-length": Object { + "description": "Each line in the body can have a maximum length of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "body lines has value or less characters", + "type": "array", + }, + "body-min-length": Object { + "description": "The body must consist of at least as many characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "body has value or more characters", + "type": "array", + }, + "footer-empty": Object { + "description": "A footer is not allowed, it must be empty", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "footer is empty", + "type": "array", + }, + "footer-leading-blank": Object { + "description": "The line before the footer must be a blank line", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "footer begins with blank line", + "type": "array", + }, + "footer-max-length": Object { + "description": "The footer can consist of only up to the number of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "footer has value or less characters", + "type": "array", + }, + "footer-max-line-length": Object { + "description": "Each line in the footer can have a maximum length of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "footer lines has value or less characters", + "type": "array", + }, + "footer-min-length": Object { + "description": "The footer must consist of at least as many characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "footer has value or more characters", + "type": "array", + }, + "header-case": Object { + "description": "The header must be written in the configured case: +'lower-case': default +'upper-case': UPPERCASE +'camel-case': camelCase +'kebab-case': kebab-case +'pascal-case': PascalCase +'sentence-case': Sentence case +'snake-case': snake_case +'start-case': Start Case", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "enum": Array [ + "lower-case", + "upper-case", + "camel-case", + "kebab-case", + "pascal-case", + "sentence-case", + "snake-case", + "start-case", + ], + }, + ], + "title": "header is in case value", + "type": "array", + }, + "header-full-stop": Object { + "description": "The header must end with the configured value, e. g. '.'", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "string", + }, + ], + "title": "header ends with value", + "type": "array", + }, + "header-max-length": Object { + "description": "The header can consist of only up to the number of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "header has value or less characters", + "type": "array", + }, + "header-min-length": Object { + "description": "The header must consist of at least as many characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "header has value or more characters", + "type": "array", + }, + "references-empty": Object { + "description": "When configured with 'never', the references must have at least one entry", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "references has at least one entry", + "type": "array", + }, + "scope-case": Object { + "description": "The scope must be written in the configured case: +'lower-case': default +'upper-case': UPPERCASE +'camel-case': camelCase +'kebab-case': kebab-case +'pascal-case': PascalCase +'sentence-case': Sentence case +'snake-case': snake_case +'start-case': Start Case", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "enum": Array [ + "lower-case", + "upper-case", + "camel-case", + "kebab-case", + "pascal-case", + "sentence-case", + "snake-case", + "start-case", + ], + }, + ], + "title": "scope is in case value", + "type": "array", + }, + "scope-empty": Object { + "description": "A scope value is not allowed, it must be empty", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "scope is empty", + "type": "array", + }, + "scope-enum": Object { + "description": "The scope value in brackets after the type must be one of the specified values", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "items": Object { + "type": "string", + }, + "type": "array", + }, + ], + "title": "scope is found in value", + "type": "array", + }, + "scope-max-length": Object { + "description": "The scope can consist of only up to the number of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "scope has value or less characters", + "type": "array", + }, + "scope-min-length": Object { + "description": "The scope must consist of at least as many characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "scope has value or more characters", + "type": "array", + }, + "signed-off-by": Object { + "description": "message must contain the configured value, e. g. 'Signed-off-by:'", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "string", + }, + ], + "title": "message has value", + "type": "array", + }, + "subject-case": Object { + "description": "The subject must be written in the configured case: +'lower-case': default +'upper-case': UPPERCASE +'camel-case': camelCase +'kebab-case': kebab-case +'pascal-case': PascalCase +'sentence-case': Sentence case +'snake-case': snake_case +'start-case': Start Case", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "enum": Array [ + "lower-case", + "upper-case", + "camel-case", + "kebab-case", + "pascal-case", + "sentence-case", + "snake-case", + "start-case", + ], + }, + ], + "title": "subject is in case value", + "type": "array", + }, + "subject-empty": Object { + "description": "A subject is not allowed, it must be empty", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "subject is empty", + "type": "array", + }, + "subject-full-stop": Object { + "description": "The subject must end with the configured value, e. g. '.'", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "string", + }, + ], + "title": "subject ends with value", + "type": "array", + }, + "subject-max-length": Object { + "description": "The subject can consist of only up to the number of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "subject has value or less characters", + "type": "array", + }, + "subject-min-length": Object { + "description": "The subject must consist of at least as many characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "subject has value or more characters", + "type": "array", + }, + "type-case": Object { + "description": "The type must be written in the configured case: +'lower-case': default +'upper-case': UPPERCASE +'camel-case': camelCase +'kebab-case': kebab-case +'pascal-case': PascalCase +'sentence-case': Sentence case +'snake-case': snake_case +'start-case': Start Case", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "enum": Array [ + "lower-case", + "upper-case", + "camel-case", + "kebab-case", + "pascal-case", + "sentence-case", + "snake-case", + "start-case", + ], + }, + ], + "title": "type is in case value", + "type": "array", + }, + "type-empty": Object { + "description": "Type is not allowed, it must be empty", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + ], + "title": "type is empty", + "type": "array", + }, + "type-enum": Object { + "description": "The type value must be one of the specified values", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "items": Object { + "type": "string", + }, + "type": "array", + }, + ], + "title": "type is found in value", + "type": "array", + }, + "type-max-length": Object { + "description": "The type can consist of only up to the number of characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "type has value or less characters", + "type": "array", + }, + "type-min-length": Object { + "description": "The type must consist of at least as many characters as configured", + "items": Array [ + Object { + "enum": Array [ + 0, + 1, + 2, + ], + }, + Object { + "enum": Array [ + "always", + "never", + ], + }, + Object { + "type": "integer", + }, + ], + "title": "type has value or more characters", + "type": "array", + }, + }, + }, + }, + "type": "object", +} +`; diff --git a/commit-editor-app/src/lib/commitlint.test.ts b/commit-editor-app/src/lib/commitlint.test.ts new file mode 100644 index 0000000..2d573c9 --- /dev/null +++ b/commit-editor-app/src/lib/commitlint.test.ts @@ -0,0 +1,437 @@ +import type { + RuleConfigQuality, + RulesConfig, + Commit, + Parser, + LintOptions, + LintOutcome, +} from '@commitlint/types' +import { validate } from './commitlint' +import parseMock from '@commitlint/parse' +import lintMock from '@commitlint/lint' +import type { ParseOptions } from 'jsonc-parser' + +const parse = parseMock as any as jest.Mock< + Promise, + [string, Parser | undefined, ParseOptions | undefined] +> +const lint = lintMock as any as jest.Mock< + Promise, + [ + string, + Partial> | undefined, + LintOptions | undefined + ] +> + +jest.mock('@commitlint/lint', () => jest.fn(), { virtual: true }) +jest.mock('@commitlint/parse', () => jest.fn(), { virtual: true }) + +const generateMockCommit = (raw = '', footer: string | null = null): Commit => { + const bodyContent = raw.split('\n').slice(2).join('\n') + const header = raw.split('\n')[0] + return { + raw, + body: bodyContent ?? null, + header, + footer: footer ?? null, + scope: header.includes('(') + ? header.split('(')[1]?.split(')')?.[0] ?? null + : null, + type: header.includes(':') ? header.split(':')[0] ?? null : null, + subject: header.split(':')[1] ?? null, + merge: null, + revert: null, + references: [], + notes: [], + mentions: [], + } +} + +const defaultRules: Partial> = { + 'body-leading-blank': [1, 'always'], + 'body-max-line-length': [2, 'always', 100], + 'footer-leading-blank': [1, 'always'], + 'footer-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'subject-case': [ + 2, + 'never', + ['sentence-case', 'start-case', 'pascal-case', 'upper-case'], + ], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'type-enum': [ + 2, + 'always', + [ + 'build', + 'chore', + 'ci', + 'docs', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + ], + ], +} + +describe('commitlint.ts', () => { + test('validates an empty message to no errors/updates', async () => { + lint.mockResolvedValue({ warnings: [], errors: [], valid: true, input: '' }) + parse.mockResolvedValue(generateMockCommit('')) + + const validated = await validate({ commitMessage: '' }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: false, + minor: false, + patch: false, + }, + }) + }) + + test('validates an only-whitespace message to no errors/updates', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [], + valid: true, + input: ' ', + }) + parse.mockResolvedValue(generateMockCommit(' ')) + + const validated = await validate({ commitMessage: ' ' }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: false, + minor: false, + patch: false, + }, + }) + }) + + test('validates a message without rules to no errors/updates', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [], + valid: true, + input: 'hey!', + }) + parse.mockResolvedValue(generateMockCommit('hey!')) + + const validated = await validate({ commitMessage: 'hey!' }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: false, + minor: false, + patch: false, + }, + }) + }) + + test('validates a valid message with rules to no errors/updates', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [], + valid: true, + input: 'docs: update docs', + }) + parse.mockResolvedValue(generateMockCommit('docs: update docs')) + + const validated = await validate({ + commitMessage: 'docs: update docs', + rules: defaultRules, + }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: false, + minor: false, + patch: false, + }, + }) + }) + + test('validates a valid message to patch update', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [], + valid: true, + input: 'fix: fix bug', + }) + parse.mockResolvedValue(generateMockCommit('fix: fix bug')) + + const validated = await validate({ + commitMessage: 'fix: fix bug', + rules: defaultRules, + }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: false, + minor: false, + patch: true, + }, + }) + }) + + test('validates a valid message to minor update', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [], + valid: true, + input: 'feat: new feature', + }) + parse.mockResolvedValue(generateMockCommit('feat: new feature')) + + const validated = await validate({ + commitMessage: 'feat: new feature', + rules: defaultRules, + }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: false, + minor: true, + patch: false, + }, + }) + }) + + test('validates a valid message to major update', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [], + valid: true, + input: + 'feat: new feature\n\nBREAKING-CHANGE: dropping support for something', + }) + parse.mockResolvedValue( + generateMockCommit( + 'feat: new feature\n\nBREAKING-CHANGE: dropping support for something' + ) + ) + + const validated = await validate({ + commitMessage: + 'feat: new feature\n\nBREAKING-CHANGE: dropping support for something', + rules: defaultRules, + }) + + expect(validated).toEqual({ + configErrors: [], + markers: [], + semVerUpdate: { + major: true, + minor: false, + patch: false, + }, + }) + }) + + test('handles when commitlint throws', async () => { + lint.mockRejectedValue(new Error('something happened')) + parse.mockResolvedValue( + generateMockCommit( + 'feat: new feature\n\nBREAKING-CHANGE: dropping support for something' + ) + ) + + const validated = await validate({ + commitMessage: 'feat: some feature', + rules: defaultRules, + }) + + expect(validated).toEqual({ + configErrors: ['something happened'], + markers: [], + semVerUpdate: { + major: false, + minor: false, + patch: false, + }, + }) + }) + + test('validates an invalid message with errors', async () => { + lint.mockResolvedValue({ + warnings: [], + errors: [ + { + level: 1, + message: 'header may not be longer than 10 characters', + name: 'header-max-length', + valid: false, + }, + { + level: 2, + message: 'header may not be empty', + name: 'header-empty', + valid: false, + }, + { + level: 2, + message: 'type may not be empty', + name: 'type-empty', + valid: false, + }, + { + level: 2, + message: 'scope may not be empty', + name: 'scope-empty', + valid: false, + }, + { + level: 2, + message: 'subject may not be empty', + name: 'subject-empty', + valid: false, + }, + { + level: 2, + message: 'body requires a leading blank line', + name: 'body-leading-blank', + valid: false, + }, + { + level: 1, + message: 'body may not be empty', + name: 'body-empty', + valid: false, + }, + { + level: 2, + message: 'footer requires a leading blank line', + name: 'footer-leading-blank', + valid: false, + }, + { + level: 1, + message: 'footer may not be empty', + name: 'footer-empty', + valid: false, + }, + { + level: 1, + message: 'illegal-token may not be empty', + name: 'illegal-token-empty', + valid: false, + }, + ], + valid: false, + input: 'feat some feature', + }) + parse.mockResolvedValue(generateMockCommit('feat some feature')) + + const validated = await validate({ + commitMessage: 'feat some feature', + rules: defaultRules, + }) + + expect(validated).toEqual({ + configErrors: [], + markers: [ + { + endColumn: 18, + endLineNumber: 0, + message: + 'header may not be longer than 10 characters (header-max-length)', + severity: 4, + startColumn: 100, + startLineNumber: 0, + }, + { + endColumn: 18, + endLineNumber: 0, + message: 'header may not be empty (header-empty)', + severity: 8, + startColumn: 0, + startLineNumber: 0, + }, + { + endColumn: 5, + endLineNumber: 0, + message: 'type may not be empty (type-empty)', + severity: 8, + startColumn: 0, + startLineNumber: 0, + }, + { + endColumn: 17, + endLineNumber: 0, + message: 'scope may not be empty (scope-empty)', + severity: 8, + startColumn: 0, + startLineNumber: 0, + }, + { + endColumn: 18, + endLineNumber: 0, + message: 'subject may not be empty (subject-empty)', + severity: 8, + startColumn: 0, + startLineNumber: 0, + }, + { + endColumn: 18, + endLineNumber: 1, + message: 'body requires a leading blank line (body-leading-blank)', + severity: 8, + startColumn: 17, + startLineNumber: 1, + }, + { + endColumn: 2, + endLineNumber: 3, + message: 'body may not be empty (body-empty)', + severity: 4, + startColumn: 1, + startLineNumber: 3, + }, + { + endColumn: 1, + endLineNumber: 1, + message: + 'footer requires a leading blank line (footer-leading-blank)', + severity: 8, + startColumn: 17, + startLineNumber: 1, + }, + { + endColumn: 1, + endLineNumber: 18, + message: 'footer may not be empty (footer-empty)', + severity: 4, + startColumn: 17, + startLineNumber: 1, + }, + ], + semVerUpdate: { + major: false, + minor: false, + patch: false, + }, + }) + }) +}) diff --git a/commit-editor-app/src/lib/commitlint.ts b/commit-editor-app/src/lib/commitlint.ts index d71e185..77afb3a 100644 --- a/commit-editor-app/src/lib/commitlint.ts +++ b/commit-editor-app/src/lib/commitlint.ts @@ -129,8 +129,8 @@ export const validate = async ({ const markerPositions: { [ruleStartsWith: string]: ( - positionData: PositionData, - rules?: Partial> + _positionData: PositionData, + _rules?: Partial> ) => monaco.editor.IMarkerData } = { 'header-max-length': ({ headerPosition, severity, message }, rules) => ({ @@ -161,7 +161,6 @@ const markerPositions: { message, }), 'body-leading-blank': ({ - bodyPosition, severity, message, commitMessage, @@ -402,12 +401,12 @@ const getBodyPosition = ( : undefined const bodyStartLine = bodyStartLineAccordingToParsed ?? bodyStartLineAccordingToLeadingBlankRule - const bodyLineCount = !!parsed.body + const bodyLineCount = parsed.body ? parsed.body?.split('\n').length - 1 : Math.max(commitMessage?.split('\n').length, bodyStartLine + 1) - bodyStartLine - 1 - const endColumn = !!parsed.body + const endColumn = parsed.body ? parsed.body?.split('\n').slice(-1)[0].length + 1 : commitMessage?.split('\n').slice(-1)[0].length + 1 return { diff --git a/commit-editor-app/src/lib/config-json-schema.test.ts b/commit-editor-app/src/lib/config-json-schema.test.ts new file mode 100755 index 0000000..04a0c92 --- /dev/null +++ b/commit-editor-app/src/lib/config-json-schema.test.ts @@ -0,0 +1,39 @@ +import { initConfigSchema } from './config-json-schema' + +describe('config-json-schema.ts', () => { + test('should init config schema', () => { + + const setDiagnosticsOptions = jest.fn() + const monacoMock = { + languages: { + json: { + jsonDefaults: { + setDiagnosticsOptions + } + } + } + } + + initConfigSchema(monacoMock as any) + + expect(setDiagnosticsOptions).toHaveBeenCalled() + }) + + test('should snap config schema', () => { + const setDiagnosticsOptions = jest.fn() + const monacoMock = { + languages: { + json: { + jsonDefaults: { + setDiagnosticsOptions + } + } + } + } + + initConfigSchema(monacoMock as any) + + const schema = setDiagnosticsOptions.mock.calls[0][0].schemas[0].schema + expect(schema).toMatchSnapshot() + }) +}) \ No newline at end of file diff --git a/commit-editor-app/src/lib/config-json-schema.ts b/commit-editor-app/src/lib/config-json-schema.ts index 49e87cd..9c9ebdb 100755 --- a/commit-editor-app/src/lib/config-json-schema.ts +++ b/commit-editor-app/src/lib/config-json-schema.ts @@ -10,7 +10,7 @@ const ruleType = ({ description, title, ...valueType -}: ValueType & { description?: string; title?: string } = {}) => { +}: ValueType & { description?: string; title?: string }) => { const defaultRuleTuple = [{ enum: [0, 1, 2] }, { enum: ['always', 'never'] }] const hasKeys = Object.keys(valueType).length > 0 return { diff --git a/commit-editor-app/src/lib/debounce.test.ts b/commit-editor-app/src/lib/debounce.test.ts new file mode 100755 index 0000000..ba036dc --- /dev/null +++ b/commit-editor-app/src/lib/debounce.test.ts @@ -0,0 +1,51 @@ +import { debounce } from "./debounce" + + +describe('debounce.ts', () => { + beforeAll(() => { + jest.useFakeTimers() + }) + test('should call debounced after 1s if no second call happened', () => { + const fn = jest.fn() + + const debounced = debounce(fn, 1000) + debounced() + jest.advanceTimersByTime(1000) + + expect(fn).toHaveBeenCalledTimes(1) + }) + + test('should not call debounced after 1s if no second call happened', () => { + const fn = jest.fn() + + const debounced = debounce(fn, 1000) + debounced() + jest.advanceTimersByTime(999) + + expect(fn).toHaveBeenCalledTimes(0) + }) + + test('should not call debounced after 1.5s if second call happened after .8s', () => { + const fn = jest.fn() + + const debounced = debounce(fn, 1000) + debounced() + jest.advanceTimersByTime(800) + debounced() + jest.advanceTimersByTime(800) + + expect(fn).toHaveBeenCalledTimes(0) + }) + + test('should call debounced after 1.5s if second call happened after .3s', () => { + const fn = jest.fn() + + const debounced = debounce(fn, 1000) + debounced() + jest.advanceTimersByTime(300) + debounced() + jest.advanceTimersByTime(1200) + + expect(fn).toHaveBeenCalledTimes(1) + }) +}) \ No newline at end of file diff --git a/commit-editor-app/src/lib/debounce.ts b/commit-editor-app/src/lib/debounce.ts index 55034b9..e9ba344 100755 --- a/commit-editor-app/src/lib/debounce.ts +++ b/commit-editor-app/src/lib/debounce.ts @@ -1,20 +1,15 @@ export const debounce = ( - func: (..._funcArgs: unknown[]) => unknown, - wait: number, - immediate?: boolean + debouncee: (..._debounceeArgs: unknown[]) => unknown, + waitForMsToTrigger: number, ) => { - let timeout: number | undefined | NodeJS.Timeout + let timeoutHandle: number | undefined | NodeJS.Timeout return function (this: unknown, ...args: unknown[]) { - const context = this - const later = () => { - timeout = undefined - if (!immediate) func.apply(context, args) - } - const callNow = immediate && timeout === undefined - clearTimeout(timeout as number) - timeout = setTimeout(later, wait) - if (callNow) { - func.apply(context, args) + const thisContext = this + const delayedFunctionCall = () => { + timeoutHandle = undefined + debouncee.apply(thisContext, args) } + clearTimeout(timeoutHandle as number | undefined) + timeoutHandle = setTimeout(delayedFunctionCall, waitForMsToTrigger) } } diff --git a/commit-editor-app/src/lib/extendable-commitlint-configs.test.ts b/commit-editor-app/src/lib/extendable-commitlint-configs.test.ts new file mode 100755 index 0000000..11bb58f --- /dev/null +++ b/commit-editor-app/src/lib/extendable-commitlint-configs.test.ts @@ -0,0 +1,25 @@ +import { extendableCommitlintConfigs } from './extendable-commitlint-configs' +import { default as configConventional } from '@commitlint/config-conventional' +import { default as configAngular } from '@commitlint/config-angular' +import { default as configAngularTypeEnum } from '@commitlint/config-angular-type-enum' + +describe('extendable-commitlint-configs.ts', () => { + test('should contain config-conventional', () => { + const conf = extendableCommitlintConfigs['@commitlint/config-conventional'] + + expect(conf).toBe(configConventional) + }) + + test('should contain config-angular', () => { + const conf = extendableCommitlintConfigs['@commitlint/config-angular'] + + expect(conf).toBe(configAngular) + }) + + test('should contain config-angular-type-enum', () => { + const conf = extendableCommitlintConfigs['@commitlint/config-angular-type-enum'] + + expect(conf).toBe(configAngularTypeEnum) + }) +}) + diff --git a/commit-editor-app/src/lib/message-empty-check.test.ts b/commit-editor-app/src/lib/message-empty-check.test.ts new file mode 100644 index 0000000..e6df34a --- /dev/null +++ b/commit-editor-app/src/lib/message-empty-check.test.ts @@ -0,0 +1,46 @@ +import { isCommitMessageEmpty } from "./message-empty-check" + + +describe('message-empty-check.ts', () => { + test('empty string should be classified as empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty('') + + expect(classifiedAsEmpty).toBe(true) + }) + + test('only new-line message should be classified as empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty('\n\n\n') + + expect(classifiedAsEmpty).toBe(true) + }) + + test('only spaces message should be classified as empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty(' ') + + expect(classifiedAsEmpty).toBe(true) + }) + + test('only spaces/tabs/newlines message should be classified as empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty(' \n \t \n ') + + expect(classifiedAsEmpty).toBe(true) + }) + + test('message with comments should be classified as empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty(' \n# hey \t \n# how ar eyou \n ') + + expect(classifiedAsEmpty).toBe(true) + }) + + test('message with custom comment char comments should be classified as empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty(' \nX hey \t \nX how ar eyou \n ', 'X') + + expect(classifiedAsEmpty).toBe(true) + }) + + test('message letters outside of comments should be classified as not empty', () => { + const classifiedAsEmpty = isCommitMessageEmpty(' x\n# hey \t \n# how ar eyou \n ') + + expect(classifiedAsEmpty).toBe(false) + }) +}) \ No newline at end of file diff --git a/commit-editor-app/src/lib/monaco.test.ts b/commit-editor-app/src/lib/monaco.test.ts new file mode 100644 index 0000000..4fa625a --- /dev/null +++ b/commit-editor-app/src/lib/monaco.test.ts @@ -0,0 +1,27 @@ +import { monaco } from './monaco' + +jest.mock('monaco-editor', () => ({ editor: 'editor-mock' }), {virtual: true}) +jest.mock('monaco-editor/esm/vs/editor/editor.worker?worker', () => (class EditorWorker { type = 'editor' }), {virtual: true}) +jest.mock('monaco-editor/esm/vs/language/json/json.worker?worker', () => (class JsonWorker { type = 'json' }), {virtual: true}) + +describe('monaco.ts', () => { + test('monaco should exist', () => { + expect(monaco.editor).toEqual('editor-mock') + }) + + test('getWorker returns jsonWorker for label "json"', () => { + // @ts-ignore + const worker = self.MonacoEnvironment.getWorker('', 'json') + + expect(worker).toBeTruthy() + expect(worker.type).toEqual('json') + }) + + test('getWorker returns editorWorker for any other label than "json"', () => { + // @ts-ignore + const worker = self.MonacoEnvironment.getWorker('', 'whatever') + + expect(worker).toBeTruthy() + expect(worker.type).toEqual('editor') + }) +}) diff --git a/commit-editor-app/src/lib/parse-commit.test.ts b/commit-editor-app/src/lib/parse-commit.test.ts new file mode 100644 index 0000000..b424dbb --- /dev/null +++ b/commit-editor-app/src/lib/parse-commit.test.ts @@ -0,0 +1,150 @@ +import type { Commit } from '@commitlint/types' +import { parseCommit } from './parse-commit' + +jest.mock('@commitlint/parse', () => jest.fn()) + +describe('parse-commit.ts', () => { + class Range { + constructor(public _startLineNumber: number, public _startColumn: number, public _endLineNumber: number, public _endColumn: number) {} + } + const monacoMock: any = { Range } + + test('should parse commit', async () => { + const parseMock: jest.Mock, [string]> = require('@commitlint/parse') + parseMock.mockResolvedValue({ + type: 'feat', + scope: 'frontend', + subject: 'add blue border', + header: 'feat(frontend): add blue border', + body: 'add the blue border to all visual components', + footer: `BREAKING-CHANGE: the old 'border' option is removed\nFixes #123`, + notes: [], + references: [], + mentions: [], + raw: `feat(frontend): add blue border + +add the blue border to all visual components + +BREAKING-CHANGE: the old 'border' option is removed +Fixes #123`, + revert: null, + merge: null, + }) + const commitMessage = `feat(frontend): add blue border + +add the blue border to all visual components + +BREAKING-CHANGE: the old 'border' option is removed +Fixes #123` + + const parsed = await parseCommit(monacoMock, { commitMessage } ) + + expect(parsed).toEqual({ + parsed: { + body: 'add the blue border to all visual components', + footer: `BREAKING-CHANGE: the old 'border' option is removed\nFixes #123`, + header: 'feat(frontend): add blue border', + mentions: [], + merge: null, + notes: [], + raw: `feat(frontend): add blue border + +add the blue border to all visual components + +BREAKING-CHANGE: the old 'border' option is removed +Fixes #123`, + references: [], + revert: null, + scope: 'frontend', + subject: 'add blue border', + type: 'feat', + }, + ranges: { + body: new Range(3, 1, 4, 46), + footer: new Range(5, 1, 7, 11), + header: new Range(1, 1, 1, 31), + mentions: null, + merge: null, + notes: null, + raw: null, + references: null, + revert: null, + scope: new Range(1, 5, 1, 13), + subject: new Range(1, 15, 1, 30), + type: new Range(1, 1, 1, 4), + } + }) + + }) + + test('should parse different', async () => { + const parseMock: jest.Mock, [string]> = require('@commitlint/parse') + parseMock.mockResolvedValue({ + type: 'fix', + scope: null, + subject: 'bug', + header: 'fix: bug', + body: 'just\na\nbody', + footer: `Fixes #123`, + notes: [], + references: [], + mentions: [], + raw: `fix: bug + +just +a +body + +Fixes #123`, + revert: null, + merge: null, + }) + const commitMessage = `fix: bug + +just +a +body + +Fixes #123` + + const parsed = await parseCommit(monacoMock, { commitMessage } ) + + expect(parsed).toEqual({ + parsed: { + body: 'just\na\nbody', + footer: `Fixes #123`, + header: 'fix: bug', + mentions: [], + merge: null, + notes: [], + raw: `fix: bug + +just +a +body + +Fixes #123`, + references: [], + revert: null, + scope: null, + subject: 'bug', + type: 'fix', + }, + ranges: { + body: new Range(3, 1, 6, 5), + footer: new Range(7, 1, 8, 12), + header: new Range(1, 1, 1, 8), + mentions: null, + merge: null, + notes: null, + raw: null, + references: null, + revert: null, + scope: null, + subject: new Range(1, 4, 1, 7), + type: new Range(1, 1, 1, 3), + } + }) + + }) +}) diff --git a/commit-editor-app/src/lib/parse-commit.ts b/commit-editor-app/src/lib/parse-commit.ts index dfb6c28..c1ab1d4 100644 --- a/commit-editor-app/src/lib/parse-commit.ts +++ b/commit-editor-app/src/lib/parse-commit.ts @@ -3,7 +3,8 @@ import parse from '@commitlint/parse' import type { Monaco, monaco } from './monaco' export type CommitRanges = { - [key in keyof Commit]: monaco.Range | null + // eslint-disable-next-line no-unused-vars + [_key in keyof Commit]: monaco.Range | null } export const parseCommit = async ( diff --git a/commit-editor-app/src/lib/parse-options-from-location.test.ts b/commit-editor-app/src/lib/parse-options-from-location.test.ts new file mode 100644 index 0000000..9bd5734 --- /dev/null +++ b/commit-editor-app/src/lib/parse-options-from-location.test.ts @@ -0,0 +1,77 @@ +import { parseUrlParams } from './parse-options-from-location' + +describe('parse-options-from-locations.ts', () => { + const oldWindowLocation = window.location + const locationSearchGetter = jest.fn() + + beforeAll(() => { + // @ts-ignore ts2790 + delete globalThis.location + + globalThis.location = Object.defineProperties( + {}, + { + ...Object.getOwnPropertyDescriptors(oldWindowLocation), + search: { + configurable: true, + get: locationSearchGetter, + }, + } + ) as any + }) + beforeEach(() => { + locationSearchGetter.mockReset() + }) + afterAll(() => { + window.location = oldWindowLocation + }) + + test('parses nothing', () => { + const parsed = parseUrlParams() + + expect(parsed).toBeNull() + }) + + test('parses nothing from query without message and config', () => { + locationSearchGetter.mockReturnValue('?notmessage=blabla¬config=blub') + + const parsed = parseUrlParams() + + expect(parsed).toBeNull() + }) + + test('parses only message from query without config', () => { + locationSearchGetter.mockReturnValue('?message=some%20message') + + const { config, message, errors } = parseUrlParams()! + + expect(config).toBeUndefined() + expect(message).toEqual('some message') + expect(errors).toEqual([]) + }) + + test('parses only config from query without message', () => { + locationSearchGetter.mockReturnValue( + '?config=%7B%22extends%22%3A%20%5B%22some-fancy-config%22%5D%7D' + ) + + const { config, message, errors } = parseUrlParams()! + + expect(config).toEqual({ extends: ['some-fancy-config'] }) + expect(message).toBeUndefined() + expect(errors).toEqual([]) + }) + + test('collects errors on parsing', () => { + locationSearchGetter.mockReturnValue('?config=no-config&message=%E0%A4%A') + + const { config, message, errors } = parseUrlParams()! + + expect(config).toBeUndefined() + expect(message).toBeUndefined() + expect(errors).toEqual([ + 'could not parse config from params', + 'could not parse message from params', + ]) + }) +}) diff --git a/commit-editor-app/src/lib/parse-options-from-location.ts b/commit-editor-app/src/lib/parse-options-from-location.ts index 79c0f68..0476200 100644 --- a/commit-editor-app/src/lib/parse-options-from-location.ts +++ b/commit-editor-app/src/lib/parse-options-from-location.ts @@ -8,7 +8,7 @@ export function parseUrlParams(): { if (!location.search) { return null } - const params = new URLSearchParams(window.location.search) + const params = new URLSearchParams(location.search) const paramMessage: string | undefined = params.get('message') ?? undefined const paramConfig: string | undefined = params.get('config') ?? undefined if (!paramMessage && !paramConfig) { diff --git a/commit-editor-app/src/lib/web-socket.ts b/commit-editor-app/src/lib/web-socket.ts index 7154a7b..b5adc4c 100644 --- a/commit-editor-app/src/lib/web-socket.ts +++ b/commit-editor-app/src/lib/web-socket.ts @@ -1,9 +1,11 @@ -import { ref } from "vue" +import { ref } from 'vue' export class WebSocketHandler { private webSocket: WebSocket private webSocketInitialize: (() => void) | undefined - private webSocketInitialized = new Promise(rs => this.webSocketInitialize = rs) + private webSocketInitialized = new Promise( + (rs) => (this.webSocketInitialize = rs) + ) open = ref(false) constructor(httpProtocol: string, host: string) { diff --git a/commit-editor-app/src/stubs/through2/through2.js b/commit-editor-app/src/stubs/through2/through2.js index 45340b8..e585dd2 100644 --- a/commit-editor-app/src/stubs/through2/through2.js +++ b/commit-editor-app/src/stubs/through2/through2.js @@ -1,91 +1,3 @@ -// function inherits (fn, sup) { -// fn.super_ = sup -// fn.prototype = Object.create(sup.prototype, { -// constructor: { value: fn, enumerable: false, writable: true, configurable: true } -// }) -// } - -// // create a new export function, used by both the main export and -// // the .ctor export, contains common logic for dealing with arguments -// function through2 (construct) { -// return (options, transform, flush) => { -// if (typeof options === 'function') { -// flush = transform -// transform = options -// options = {} -// } - -// if (typeof transform !== 'function') { -// // noop -// transform = (chunk, enc, cb) => cb(null, chunk) -// } - -// if (typeof flush !== 'function') { -// flush = null -// } - -// return construct(options, transform, flush) -// } -// } - -// // main export, just make me a transform stream! -// const make = through2((options, transform, flush) => { -// const t2 = new Transform(options) - -// t2._transform = transform - -// if (flush) { -// t2._flush = flush -// } - -// return t2 -// }) - -// // make me a reusable prototype that I can `new`, or implicitly `new` -// // with a constructor call -// const ctor = through2((options, transform, flush) => { -// function Through2 (override) { -// if (!(this instanceof Through2)) { -// return new Through2(override) -// } - -// this.options = Object.assign({}, options, override) - -// Transform.call(this, this.options) - -// this._transform = transform -// if (flush) { -// this._flush = flush -// } -// } - -// inherits(Through2, Transform) - -// return Through2 -// }) - -// const obj = through2(function (options, transform, flush) { -// const t2 = new Transform(Object.assign({ objectMode: true, highWaterMark: 16 }, options)) - -// t2._transform = transform - -// if (flush) { -// t2._flush = flush -// } - -// return t2 -// }) - -// module.exports = make -// module.exports.ctor = ctor -// module.exports.obj = obj - -// module.exports = { -// obj: () => { -// throw new Error('through2 is not implemented') -// }, -// } - export const obj = () => { throw new Error('through2 is not implemented') }