diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..aa787b6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +script/* linguist-vendored +test/env/* linguist-vendored diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1567a0c..0cf5d1b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,23 +4,30 @@ name: build on: push: + branches: + - master pull_request: workflow_dispatch: jobs: build: - runs-on: windows-latest + runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x, 18.x] + node-version: [20.x] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: node-version: ${{matrix.node-version}} - - run: npm install - - run: npm run build + - name: Build + run: | + npm install + npm run build test: needs: build uses: ./.github/workflows/test.yml + secrets: inherit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 601c8c5..d8c33ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,15 +10,19 @@ on: jobs: publish-npm: - runs-on: windows-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 registry-url: https://registry.npmjs.org/ - - run: npm install - - run: npm run build - - run: npm publish + - name: Build and publish + run: | + npm install + npm run build + npm publish env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fba73ff..b26d396 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,15 +7,19 @@ on: jobs: test: - runs-on: windows-latest + runs-on: ubuntu-latest strategy: matrix: - node-version: [16.x, 18.x] + node-version: [20.x] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 with: node-version: ${{matrix.node-version}} - - run: npm install - - run: npm test + - name: Test + run: | + npm install + npm test diff --git a/.gitignore b/.gitignore index 3bf3d1e..0c5d95a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ target/ # Dependencies node_modules/ jspm_packages/ +# Libraries should not commit package-lock package-lock.json # Logs diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 9f34637..0000000 --- a/jest.config.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {JestConfigWithTsJest} from 'ts-jest'; - -/* - * For a detailed explanation regarding each configuration property and type check, visit: - * https://jestjs.io/docs/configuration - */ -const jestConfig: JestConfigWithTsJest = { - // The directory where Jest should store its cached dependency information - cacheDirectory: './dist/test/jest', - // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, - // The directory where Jest should output its coverage files - coverageDirectory: './dist/test/coverage', - // An array of regexp pattern strings used to skip coverage collection - coveragePathIgnorePatterns: [ - '\\.idea\\', - '\\dist\\', - '\\node_modules\\', - '\\test\\', - ], - // Indicates which provider should be used to instrument code for coverage - coverageProvider: 'v8', - // An object that configures minimum threshold enforcement for coverage results - coverageThreshold: { - global: { - branches: 80, - functions: 80 - }, - }, - // The default configuration for fake timers - fakeTimers: { - enableGlobally: true - }, - // A preset that is used as a base for Jest's configuration - preset: 'ts-jest/presets/js-with-ts-esm-legacy', - // A list of paths to modules that run some code to configure or set up the testing framework before each test - setupFilesAfterEnv: ['./test/fake/polyfill-fake.js'], - // The test environment that will be used for testing - testEnvironment: 'jsdom', - // A map from regular expressions to paths to transformers - transform: { - '^.+\\.[tj]sx?$': [ - 'ts-jest', - { - useESM: true, - tsconfig: './test/tsconfig.json' - }, - ], - }, - // A list of additional extensions to treat as native ESM (besides .js) - extensionsToTreatAsEsm: ['.ts'], - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources, like images or styles with a single module. - moduleNameMapper: { - '^(\\.{1,2}/.+)\\.js$': '$1', - }, -}; - -export default jestConfig; diff --git a/package.json b/package.json index 463a846..ca5ea96 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "build": "npm run build:tsc && npm run build:bundle && npm run build:css", "build:tsc": "tsc --project src/tsconfig.json", "build:bundle": "esbuild src/index.ts --bundle --format=esm --platform=browser --target=esnext --outfile=dist/bundle/comments-element-esm.js --legal-comments=none", - "build:css": "ts-node --esm -P tsconfig-template.json -O {\\\"moduleResolution\\\":\\\"node\\\"} compact-css.ts", - "test": "node --experimental-vm-modules node_modules/jest/bin/jest" + "build:css": "tsx --tsconfig tsconfig-template.json script/compact-css.ts", + "test": "node --import tsx script/run-tests.ts | faucet" }, "files": [ "dist/*.js", @@ -57,24 +57,25 @@ "url": "https://github.com/adanski/ax-comments.git" }, "engines": { - "node": ">=16.0.0 <21.0.0" + "node": ">=20.9.0" }, "dependencies": { - "@textcomplete/core": "~0.1.12", - "@textcomplete/textarea": "~0.1.12", - "dompurify": "~2.4.3" + "@textcomplete/core": "^0.1.13", + "@textcomplete/textarea": "~0.1.13", + "eventemitter3": "^5.0.1", + "dompurify": "^3.0.6" }, "devDependencies": { - "@types/node": "~16.18.10", - "@jest/globals": "~29.3.1", - "@types/dompurify": "~2.4.0", - "typescript": "~4.9.4", - "tslib": "~2.4.1", - "jest": "~29.3.1", - "jest-environment-jsdom": "~29.3.1", - "ts-jest": "~29.0.3", - "ts-node": "~10.9.1", - "esbuild": "~0.16.14", - "magic-string": "~0.27.0" + "@types/node": "~20.10.4", + "@types/dompurify": "~3.0.5", + "typescript": "~5.3.3", + "tslib": "~2.6.2", + "glob": "~10.3.10", + "tsx": "~4.6.2", + "jsdom": "~23.0.1", + "faucet": "~0.0.4", + "c8": "~8.0.1", + "esbuild": "~0.19.9", + "magic-string": "~0.30.5" } } diff --git a/compact-css.ts b/script/compact-css.ts similarity index 100% rename from compact-css.ts rename to script/compact-css.ts diff --git a/script/run-tests.ts b/script/run-tests.ts new file mode 100644 index 0000000..0369eff --- /dev/null +++ b/script/run-tests.ts @@ -0,0 +1,12 @@ +import {tap} from 'node:test/reporters'; +import {run} from 'node:test'; +import {stdout} from 'node:process'; +import {globSync} from 'glob'; + +const files: string[] = globSync('test/**/*.spec.ts', {absolute: true}); +files.forEach(console.log); +run({ + files: files +}) + .compose(tap) + .pipe(stdout); diff --git a/src/api.ts b/src/api.ts index 91c8fb6..5ea98d2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -9,7 +9,7 @@ import {Misc} from './options/misc.js'; export interface CommentsOptions extends CurrentUser, Icons, Labels, Functionalities, Callbacks, Formatters, Misc { } -export {CommentModel, UserDisplayNamesById, PingableUser, ReferenceableHashtag} from './options/models.js'; +export type {CommentModel, UserDisplayNamesById, PingableUser, ReferenceableHashtag} from './options/models.js'; export {SortKey} from './options/misc.js'; diff --git a/src/comment-view-model.ts b/src/comment-view-model.ts index e76c979..4d0b991 100644 --- a/src/comment-view-model.ts +++ b/src/comment-view-model.ts @@ -1,6 +1,6 @@ import {CommentsById, CommentModelEnriched, CommentId} from './comments-by-id.js'; -import EventEmitter from 'EventEmitter3'; import {CommentModel} from './options/models.js'; +import {EventEmitter} from 'eventemitter3'; export class CommentViewModel { diff --git a/src/comments-element.ts b/src/comments-element.ts index 4db27ca..52c2679 100644 --- a/src/comments-element.ts +++ b/src/comments-element.ts @@ -11,15 +11,15 @@ import {CommentSorter} from './comment-sorter.js'; import {NavigationElement} from './subcomponent/navigation-element.js'; import {SpinnerFactory} from './subcomponent/spinner-factory.js'; import {CommentViewModel, CommentViewModelEvent} from './comment-view-model.js'; -import {findParentsBySelector, findSiblingsBySelector, hideElement, showElement} from './html-util.js'; +import {findParentsBySelector, hideElement} from './html-util.js'; import {STYLE_SHEET} from './css/stylesheet.js'; -import {RegisterCustomElement} from './register-custom-element.js'; +import {CustomElement, defineCustomElement} from './custom-element.js'; import {createDynamicStylesheet} from './css/dynamic-stylesheet-factory.js'; import {ToggleAllButtonElement} from './subcomponent/toggle-all-button-element.js'; import {CommentingFieldElement} from './subcomponent/commenting-field-element.js'; import {CommentElement} from './subcomponent/comment-element.js'; -@RegisterCustomElement('ax-comments') +//@CustomElement('ax-comments') export class CommentsElement extends HTMLElement implements WebComponent { private container!: HTMLElement; @@ -396,3 +396,5 @@ export class CommentsElement extends HTMLElement implements WebComponent { } } + +defineCustomElement(CommentsElement, 'ax-comments'); diff --git a/src/custom-element.ts b/src/custom-element.ts new file mode 100644 index 0000000..ff27322 --- /dev/null +++ b/src/custom-element.ts @@ -0,0 +1,33 @@ +/** + * Decorator which registers custom HTML element. + */ +export function CustomElement(selector: string, options?: ElementDefinitionOptions): CustomElementDecorator { + return (target, context) => { + context.addInitializer(function () { + customElements.define(selector, this, options); + }); + }; +} + +/*declare global { + interface HTMLElementTagNameMap { + selector: target; + } +}*/ + +/** + * Custom element decorator. Based on {@link ClassDecorator}. + */ +type CustomElementDecorator = >(target: C, context: ClassDecoratorContext) => C | void; + +/** + * Reference to the specific custom element's class. + */ +type CustomElementConstructor = new (...args: any[]) => E; + +/** + * @deprecated Use until https://github.com/evanw/esbuild/issues/3462 is done. See also https://caniuse.com/decorators + */ +export function defineCustomElement(ctor: CustomElementConstructor, selector: string, options?: ElementDefinitionOptions): void { + customElements.define(selector, ctor, options); +} diff --git a/src/register-custom-element.ts b/src/register-custom-element.ts deleted file mode 100644 index 58fa0cb..0000000 --- a/src/register-custom-element.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Decorator which registers custom HTML element. - */ -export function RegisterCustomElement(selector: string, options?: ElementDefinitionOptions): CustomElementDecorator { - return (target) => { - customElements.define(selector, target, options); - }; -} - -/*declare global { - interface HTMLElementTagNameMap { - selector: target; - } -}*/ - -/** - * Custom element decorator. Based on {@link ClassDecorator}. - */ -type CustomElementDecorator = >(target: C) => C | void; - -/** - * Reference to the specific custom element's class. - */ -type CustomElementConstructor = new (...args: any[]) => E; diff --git a/src/subcomponent/button-element.ts b/src/subcomponent/button-element.ts index a0d25b5..772fe5c 100644 --- a/src/subcomponent/button-element.ts +++ b/src/subcomponent/button-element.ts @@ -1,7 +1,7 @@ import {CommentModel, CommentsOptions} from '../api.js'; import {OptionsProvider, ServiceProvider} from '../provider.js'; import {SpinnerFactory} from './spinner-factory.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import {WebComponent} from '../web-component.js'; import {getHostContainer} from '../html-util.js'; import {CommentTransformer} from '../comment-transformer.js'; @@ -9,7 +9,7 @@ import {CommentModelEnriched} from '../comments-by-id.js'; import {ErrorFct, SuccessFct} from '../options/callbacks.js'; import {isNil, noop} from '../util.js'; -@RegisterCustomElement('ax-button', {extends: 'button'}) +//@CustomElement('ax-button', {extends: 'button'}) export class ButtonElement extends HTMLButtonElement implements WebComponent { set inline(value: boolean) { @@ -199,3 +199,5 @@ export class ButtonElement extends HTMLButtonElement implements WebComponent { } } + +defineCustomElement(ButtonElement, 'ax-button', {extends: 'button'}); diff --git a/src/subcomponent/comment-container-element.ts b/src/subcomponent/comment-container-element.ts index 15da1d2..1031193 100644 --- a/src/subcomponent/comment-container-element.ts +++ b/src/subcomponent/comment-container-element.ts @@ -4,7 +4,7 @@ import {TagFactory} from './tag-factory.js'; import {CommentModel, CommentsOptions} from '../api.js'; import {CommentViewModelProvider, OptionsProvider, ServiceProvider} from '../provider.js'; import {WebComponent} from '../web-component.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import {findParentsBySelector, getHostContainer} from '../html-util.js'; import {ButtonElement} from './button-element.js'; import {CommentViewModel, CommentViewModelEvent, CommentViewModelEventSubscription} from '../comment-view-model.js'; @@ -16,7 +16,7 @@ import {SuccessFct} from '../options/callbacks.js'; import {CommentTransformer} from '../comment-transformer.js'; import {AttachmentModel} from '../options/models.js'; -@RegisterCustomElement('ax-comment-container') +//@CustomElement('ax-comment-container') export class CommentContainerElement extends HTMLElement implements WebComponent { commentModel!: CommentModelEnriched; @@ -374,3 +374,5 @@ export class CommentContainerElement extends HTMLElement implements WebComponent this.querySelector('.actions')!.replaceWith(actions); } } + +defineCustomElement(CommentContainerElement, 'ax-comment-container'); diff --git a/src/subcomponent/comment-element.ts b/src/subcomponent/comment-element.ts index 308b3df..1919513 100644 --- a/src/subcomponent/comment-element.ts +++ b/src/subcomponent/comment-element.ts @@ -1,10 +1,10 @@ import {isNil} from '../util.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import {WebComponent} from '../web-component.js'; import {CommentContainerElement} from './comment-container-element.js'; import {CommentModelEnriched} from '../comments-by-id.js'; -@RegisterCustomElement('ax-comment', {extends: 'li'}) +//@CustomElement('ax-comment', {extends: 'li'}) export class CommentElement extends HTMLLIElement implements WebComponent { #commentModel!: CommentModelEnriched; @@ -61,3 +61,5 @@ export class CommentElement extends HTMLLIElement implements WebComponent { commentContainer.reRenderCommentActionBar(); } } + +defineCustomElement(CommentElement, 'ax-comment', {extends: 'li'}); diff --git a/src/subcomponent/commenting-field-element.ts b/src/subcomponent/commenting-field-element.ts index 7c3e917..6613a12 100644 --- a/src/subcomponent/commenting-field-element.ts +++ b/src/subcomponent/commenting-field-element.ts @@ -9,7 +9,7 @@ import {CommentModelEnriched} from '../comments-by-id.js'; import {CommentViewModelProvider, OptionsProvider, ServiceProvider} from '../provider.js'; import {CommentViewModel} from '../comment-view-model.js'; import {WebComponent} from '../web-component.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import { findSiblingsBySelector, getHostContainer, @@ -21,7 +21,7 @@ import {ErrorFct, SuccessFct} from '../options/callbacks.js'; import {CommentTransformer} from '../comment-transformer.js'; import {AttachmentModel} from '../options/models.js'; -@RegisterCustomElement('ax-commenting-field') +//@CustomElement('ax-commenting-field') export class CommentingFieldElement extends HTMLElement implements WebComponent { parentId: string | null = null; @@ -449,3 +449,5 @@ export class CommentingFieldElement extends HTMLElement implements WebComponent uploadButton.querySelector('input')!.value = ''; } } + +defineCustomElement(CommentingFieldElement, 'ax-commenting-field'); diff --git a/src/subcomponent/navigation-element.ts b/src/subcomponent/navigation-element.ts index f6bd602..23b63ea 100644 --- a/src/subcomponent/navigation-element.ts +++ b/src/subcomponent/navigation-element.ts @@ -1,11 +1,11 @@ import {CommentsOptions, SortKey} from '../api.js'; import {OptionsProvider} from '../provider.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import {WebComponent} from '../web-component.js'; import {getHostContainer, hideElement, showElement} from '../html-util.js'; import {noop} from '../util.js'; -@RegisterCustomElement('ax-navigation') +//@CustomElement('ax-navigation') export class NavigationElement extends HTMLElement implements WebComponent { sortKey: SortKey = SortKey.NEWEST; @@ -161,3 +161,5 @@ export class NavigationElement extends HTMLElement implements WebComponent { getHostContainer(this).classList.add('responsive'); } } + +defineCustomElement(NavigationElement, 'ax-navigation'); diff --git a/src/subcomponent/textarea-element.ts b/src/subcomponent/textarea-element.ts index 236e614..e110857 100644 --- a/src/subcomponent/textarea-element.ts +++ b/src/subcomponent/textarea-element.ts @@ -3,11 +3,11 @@ import {CommentsOptions} from '../api.js'; import {CommentViewModelProvider, OptionsProvider} from '../provider.js'; import {WebComponent} from '../web-component.js'; import {findSiblingsBySelector, getHostContainer} from '../html-util.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import {PingableUser, UserDisplayNamesById} from '../options/models.js'; import {CommentViewModel} from '../comment-view-model.js'; -@RegisterCustomElement('ax-textarea', {extends: 'textarea'}) +//@CustomElement('ax-textarea', {extends: 'textarea'}) export class TextareaElement extends HTMLTextAreaElement implements WebComponent { parentId: string | null = null; @@ -79,7 +79,7 @@ export class TextareaElement extends HTMLTextAreaElement implements WebComponent if (el.valueBeforeChange !== el.value) { el.valueBeforeChange = el.value; - el.dispatchEvent(new Event('change', {bubbles: true})) + el.dispatchEvent(new CustomEvent('change', {bubbles: true})) } }; @@ -111,3 +111,5 @@ export class TextareaElement extends HTMLTextAreaElement implements WebComponent } } + +defineCustomElement(TextareaElement, 'ax-textarea', {extends: 'textarea'}); diff --git a/src/subcomponent/toggle-all-button-element.ts b/src/subcomponent/toggle-all-button-element.ts index 6e2c222..2af0bc4 100644 --- a/src/subcomponent/toggle-all-button-element.ts +++ b/src/subcomponent/toggle-all-button-element.ts @@ -1,12 +1,12 @@ import {isNil} from '../util.js'; import {OptionsProvider} from '../provider.js'; import {WebComponent} from '../web-component.js'; -import {RegisterCustomElement} from '../register-custom-element.js'; +import {CustomElement, defineCustomElement} from '../custom-element.js'; import {findSiblingsBySelector, getHostContainer} from '../html-util.js'; import {Labels} from '../options/labels.js'; import {Misc} from '../options/misc.js'; -@RegisterCustomElement('ax-toggle-all-button', {extends: 'li'}) +//@CustomElement('ax-toggle-all-button', {extends: 'li'}) export class ToggleAllButtonElement extends HTMLLIElement implements WebComponent { #options!: Required; @@ -127,3 +127,5 @@ export class ToggleAllButtonElement extends HTMLLIElement implements WebComponen } } } + +defineCustomElement(ToggleAllButtonElement, 'ax-toggle-all-button', {extends: 'li'}); diff --git a/test/comments-element.spec.ts b/test/comments-element.spec.ts index 51ca19c..3132ca5 100644 --- a/test/comments-element.spec.ts +++ b/test/comments-element.spec.ts @@ -1,5 +1,8 @@ +import {describe, beforeEach, afterEach, it, mock, before} from 'node:test'; +import {expect} from './util/expectation.js'; import {CommentsElement} from '../src/comments-element.js'; -import {CommentModel, PingableUser, SortKey} from '../src/api.js'; +import {CommentModel, PingableUser} from '../src/options/models.js'; +import {SortKey} from '../src/options/misc.js'; import {commentsArray, usersArray} from './data/comments-data.js'; import {CommentViewModelProvider} from '../src/provider.js'; import {CommentViewModel} from '../src/comment-view-model.js'; @@ -10,7 +13,7 @@ import {CommentingFieldElement} from '../src/subcomponent/commenting-field-eleme import {TextareaElement} from '../src/subcomponent/textarea-element.js'; import {findParentsBySelector, getElementStyle} from '../src/html-util.js'; import {CommentModelEnriched} from '../src/comments-by-id.js'; -import {jest, describe, beforeEach, afterEach, it, expect} from '@jest/globals'; +import {bootstrap} from './env/bootstrap.js'; describe('CommentsElement', () => { @@ -20,8 +23,19 @@ describe('CommentsElement', () => { let commentContainer: HTMLElement; let commentViewModel: CommentViewModel; + let createCommentsElement: typeof CommentsElement['create']; + + before(async () => { + bootstrap(); + mock.timers.enable(); + + // classes need to be imported and evaluated after the context bootstrapping + const {CommentsElement} = await import('../src/comments-element.js'); + createCommentsElement = CommentsElement.create; + }); + beforeEach(() => { - commentsElement = CommentsElement.create({ + commentsElement = createCommentsElement({ options: { profilePictureURL: 'https://viima-app.s3.amazonaws.com/media/public/defaults/user-icon.png', currentUserId: 'current-user', @@ -223,12 +237,12 @@ describe('CommentsElement', () => { expect(sendButton.classList.contains('enabled')).toBe(false); }); - it('Should be possible to add a new main level comment', async () => { + it('Should be possible to add a new main level comment', () => { mainTextarea.value = multilineText; mainTextarea.dispatchEvent(new InputEvent('input')); mainCommentingField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); // New comment should always be placed first initially const commentEl = queryComments('#comment-list li.comment')!; @@ -247,7 +261,7 @@ describe('CommentsElement', () => { checkOrder(queryCommentsAll('#comment-list > li.comment'), [idOfNewComment, '3', '2', '1']); }); - it('Should be possible to add a new main level comment with attachments', async () => { + it('Should be possible to add a new main level comment with attachments', () => { mainTextarea.value = multilineText; mainTextarea.dispatchEvent(new InputEvent('input')); @@ -262,7 +276,7 @@ describe('CommentsElement', () => { expect(attachmentTags[1].textContent).toBe('test2.png'); mainCommentingField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); const commentEl: CommentElement = queryComments('#comment-list li.comment')!; checkCommentElementData(commentEl); @@ -282,7 +296,7 @@ describe('CommentsElement', () => { expect(replyField).toBe(null); }); - it('Should be possible to reply', async () => { + it('Should be possible to reply', () => { mostPopularComment.querySelector('.reply')!.click(); const replyField: CommentingFieldElement = mostPopularComment.querySelector('.commenting-field')!; expect(isNil(replyField)).toBe(false); @@ -298,7 +312,7 @@ describe('CommentsElement', () => { replyFieldTextarea.dispatchEvent(new InputEvent('input')); replyField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); // New reply should always be placed last const commentEl: CommentElement = mostPopularComment.querySelector('li.comment:last-of-type')!; @@ -316,7 +330,7 @@ describe('CommentsElement', () => { expect(mostPopularComment.querySelectorAll('li.comment.visible').length).toBe(2); }); - it('Should be possible to reply with attachments', async () => { + it('Should be possible to reply with attachments', () => { mostPopularComment.querySelector('.reply')!.click(); const replyField: CommentingFieldElement = mostPopularComment.querySelector('.commenting-field')!; @@ -336,7 +350,7 @@ describe('CommentsElement', () => { expect(attachmentTags[1].textContent).toBe('test2.png'); replyField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); const commentEl: CommentElement = mostPopularComment.querySelector('li.comment:last-of-type')!; checkCommentElementData(commentEl); @@ -352,7 +366,7 @@ describe('CommentsElement', () => { expect(isNil(replyField)).toBe(true); }); - it('Should be possible to re-reply', async () => { + it('Should be possible to re-reply', () => { const childComment: CommentElement = mostPopularComment.querySelector('.child-comments li.comment[data-id="9"]')!; childComment.querySelector('.reply')!.click(); const replyField: CommentingFieldElement = mostPopularComment.querySelector('.commenting-field')!; @@ -369,13 +383,13 @@ describe('CommentsElement', () => { replyFieldTextarea.dispatchEvent(new InputEvent('input')); replyField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); // New reply should always be placed last const commentEl: CommentElement = mostPopularComment.querySelector('li.comment:last-of-type')!; const idOfNewComment = commentEl.commentModel.id; - expect(commentEl.querySelector('.comment-header .reply-to')!.textContent!.indexOf('Bryan Connery')).not.toBe(-1); + expect(commentEl.querySelector('.comment-header .reply-to')!.textContent!.indexOf('Bryan Connery')).notToBe(-1); expect(commentEl.querySelector('.content')!.textContent).toBe(replyText); expect(commentEl.classList.contains('by-current-user')).toBe(true); checkCommentElementData(commentEl); @@ -385,7 +399,7 @@ describe('CommentsElement', () => { expect(mostPopularComment.querySelectorAll('li.comment.visible').length).toBe(2); }); - it('Should be possible to re-reply to a hidden reply', async () => { + it('Should be possible to re-reply to a hidden reply', () => { mostPopularComment.querySelector('.toggle-all')!.click(); const childComment = mostPopularComment.querySelector('.child-comments li.comment')!; childComment.querySelector('.reply')!.click(); @@ -400,13 +414,13 @@ describe('CommentsElement', () => { replyFieldTextarea.dispatchEvent(new InputEvent('input')); replyField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); // New reply should always be placed last const commentEl: CommentElement = mostPopularComment.querySelector('li.comment:last-of-type')!; const idOfNewComment = commentEl.commentModel.id; - expect(commentEl.querySelector('.comment-header .reply-to')!.textContent!.indexOf('Jack Hemsworth')).not.toBe(-1); + expect(commentEl.querySelector('.comment-header .reply-to')!.textContent!.indexOf('Jack Hemsworth')).notToBe(-1); expect(commentEl.querySelector('.content')!.textContent).toBe(replyText); expect(commentEl.classList.contains('by-current-user')).toBe(true); checkCommentElementData(commentEl); @@ -466,11 +480,11 @@ describe('CommentsElement', () => { expect(ownComment.outerHTML).toBe(cloneOfOwnComment.outerHTML); }); - it('Should be possible to edit a main level comment', async () => { - await testEditingComment(ownComment.commentModel.id); + it('Should be possible to edit a main level comment', () => { + testEditingComment(ownComment.commentModel.id); }); - it('Should be possible to edit a reply', async () => { + it('Should be possible to edit a reply', () => { [...ownComment.querySelectorAll('.reply')].at(-1)!.click(); const replyText = 'This is a re-reply'; @@ -481,12 +495,12 @@ describe('CommentsElement', () => { // Create reply replyField.querySelector('.send')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); // Test editing the reply let reply: CommentElement = [...ownComment.querySelector('.child-comments')!.children].at(-1) as CommentElement; let replyId = reply.commentModel.id; - await testEditingComment(replyId); + testEditingComment(replyId); }); it(`Should not let the user save the comment if it hasn't changed`, () => { @@ -525,7 +539,7 @@ describe('CommentsElement', () => { expect(deleteButton.classList.contains('enabled')).toBe(true); }); - it('Should be possible to delete a main level comment', async () => { + it('Should be possible to delete a main level comment', () => { const commentId = '3'; const ownComment: CommentElement = queryComments(`#comment-list li.comment[data-id="${commentId}"]`)!; @@ -534,13 +548,13 @@ describe('CommentsElement', () => { const deleteButton: ButtonElement = ownComment.querySelector('.delete')!; deleteButton.click(); - jest.runAllTimers(); + mock.timers.runAll(); // Except the main comment to be deleted expect(commentViewModel.getComment(commentId)!.content).toBe('Deleted'); }); - it('Should be possible to delete a reply', async () => { + it('Should be possible to delete a reply', () => { const commentId = '10'; const ownComment: CommentElement = queryComments(`#comment-list li.comment[data-id="${commentId}"]`)!; const outermostParent: CommentElement = findParentsBySelector(ownComment, 'li.comment').last()!; @@ -552,12 +566,12 @@ describe('CommentsElement', () => { const deleteButton: ButtonElement = ownComment.querySelector('.delete')!; deleteButton.click(); - jest.runAllTimers(); + mock.timers.runAll(); expect(commentViewModel.getComment(commentId)!.content).toBe('Deleted'); }); - it('Should be possible to delete a reply that has re-replies', async () => { + it('Should be possible to delete a reply that has re-replies', () => { const commentId = '8'; const ownComment: CommentElement = queryComments(`#comment-list li.comment[data-id="${commentId}"]`)!; const outermostParent: CommentElement = findParentsBySelector(ownComment, 'li.comment').last()!; @@ -569,7 +583,7 @@ describe('CommentsElement', () => { const deleteButton: HTMLElement = ownComment.querySelector('.delete')!; deleteButton.click(); - jest.runAllTimers(); + mock.timers.runAll(); // Except the main reply to be deleted expect(commentViewModel.getComment(commentId)!.content).toBe('Deleted'); @@ -579,7 +593,7 @@ describe('CommentsElement', () => { expect(commentViewModel.getChildComments(outermostParent.commentModel.id).length).toBe(5); }); - it('Should be possible to delete attachments', async () => { + it('Should be possible to delete attachments', () => { const ownCommentModel = commentViewModel.getComment('10')!; const ownComment: CommentElement = queryComments('#comment-list li.comment[data-id="10"]')!; @@ -599,7 +613,7 @@ describe('CommentsElement', () => { const saveButton: HTMLElement = ownComment.querySelector('.save')!; expect(saveButton.classList.contains('enabled')).toBe(true); saveButton.click(); - jest.runAllTimers(); + mock.timers.runAll(); expect(ownCommentModel.attachments!.length).toBe(0); expect(ownComment.querySelector('.attachments')!.querySelectorAll('.attachment').length).toBe(0); @@ -658,7 +672,6 @@ describe('CommentsElement', () => { }); afterEach(() => { - jest.clearAllTimers(); commentsElement.remove(); }); @@ -699,8 +712,8 @@ describe('CommentsElement', () => { const commentModel = commentEl.commentModel; // Check basic fields - expect(profilePictureURL).toBe(commentModel.creatorProfilePictureURL); - expect(displayName).toBe(commentModel.creatorDisplayName); + expect(profilePictureURL).toBe(commentModel.creatorProfilePictureURL!!); + expect(displayName).toBe(commentModel.creatorDisplayName!!); // Check content const content = getEscapedTextContentFromCommentElement(commentEl); @@ -726,7 +739,7 @@ describe('CommentsElement', () => { attachmentName = urlParts[urlParts.length - 1]; } - const tagText = commentEl.querySelectorAll('.attachment')[index].textContent; + const tagText = commentEl.querySelectorAll('.attachment')[index].textContent!!; expect(attachmentName).toBe(tagText); }); } @@ -766,7 +779,7 @@ describe('CommentsElement', () => { return [...elements].map((commentEl) => commentEl.getAttribute('data-id')!); } - async function testEditingComment(id: string): Promise { + function testEditingComment(id: string): void { let ownComment: CommentElement = queryComments(`#comment-list li.comment[data-id="${id}"]`)!; const editButton: HTMLElement = ownComment.querySelector('.edit')!; @@ -794,14 +807,14 @@ describe('CommentsElement', () => { // Save the comment editField.querySelector('.save')!.click(); - jest.runAllTimers(); + mock.timers.runAll(); expect(isNil(ownComment.querySelector('.commenting-field'))).toBe(true); // Check the edited comment ownComment = queryComments(`#comment-list li.comment[data-id="${id}"]`)!; checkCommentElementData(ownComment); - expect(ownComment.querySelector('.content .edited')!.textContent!.length).not.toBe(0); + expect(ownComment.querySelector('.content .edited')!.textContent!.length).notToBe(0); // Check that only fields content and modified have changed in comment model const ownCommentModel = commentViewModel.getComment(id)!; @@ -826,7 +839,7 @@ describe('CommentsElement', () => { } if (key === 'content' || key === 'modifiedAt' || key === 'attachments' || key === 'hasAttachments') { - expect(currentComparisonValue).not.toBe(oldComparisonValue); + expect(currentComparisonValue).notToBe(oldComparisonValue); } else { expect(currentComparisonValue).toBe(oldComparisonValue); } diff --git a/test/css/tag-noop.spec.ts b/test/css/tag-noop.spec.ts index 986d525..4d79165 100644 --- a/test/css/tag-noop.spec.ts +++ b/test/css/tag-noop.spec.ts @@ -1,9 +1,10 @@ -import {describe, expect, it} from '@jest/globals'; +import {describe, it} from 'node:test'; import {tagNoop} from '../../src/css/tag-noop.js'; +import {expect} from '../util/expectation.js'; describe('tagNoop', () => { it('Should return unchanged string', () => { const four: string = '4'; - expect(tagNoop`${1}23${four}`).toEqual('1234'); + expect(tagNoop`${1}23${four}`).toBe('1234'); }); }); diff --git a/test/env/bootstrap.js b/test/env/bootstrap.js new file mode 100644 index 0000000..e60c4a3 --- /dev/null +++ b/test/env/bootstrap.js @@ -0,0 +1,129 @@ +import {JSDOM} from 'jsdom'; +import {polyfill} from './polyfill-fake.js'; + +const windowPropPartials = [ + 'resize', + 'move', + 'scroll', + 'CSS', + 'Style', + 'EventListener', + 'Event', + 'customElements', + 'document' +]; + +/** + * https://developer.mozilla.org/en-US/docs/Web/API/Event#interfaces_based_on_event + * https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API#html_element_interfaces_2 + */ +const windowHtmlProps = [ + 'querySelector', + 'querySelectorAll', + + 'CustomEvent', + 'UIEvent', + 'FocusEvent', + 'InputEvent', + 'MouseEvent', + 'KeyboardEvent', + 'TouchEvent', + 'CompositionEvent', + 'WheelEvent', + + 'HTMLElement', + 'HTMLHeadElement', + 'HTMLTitleElement', + 'HTMLBaseElement', + 'HTMLLinkElement', + 'HTMLMetaElement', + 'HTMLStyleElement', + 'HTMLBodyElement', + 'HTMLHeadingElement', + 'HTMLParagraphElement', + 'HTMLHRElement', + 'HTMLPreElement', + 'HTMLUListElement', + 'HTMLOListElement', + 'HTMLLIElement', + 'HTMLMenuElement', + 'HTMLDListElement', + 'HTMLDivElement', + 'HTMLAnchorElement', + 'HTMLAreaElement', + 'HTMLBRElement', + 'HTMLButtonElement', + 'HTMLCanvasElement', + 'HTMLDataElement', + 'HTMLDataListElement', + 'HTMLDetailsElement', + 'HTMLDialogElement', + 'HTMLDirectoryElement', + 'HTMLFieldSetElement', + 'HTMLFormElement', + 'HTMLHtmlElement', + 'HTMLImageElement', + 'HTMLInputElement', + 'HTMLLabelElement', + 'HTMLLegendElement', + 'HTMLMapElement', + 'HTMLMediaElement', + 'HTMLMeterElement', + 'HTMLModElement', + 'HTMLOptGroupElement', + 'HTMLOptionElement', + 'HTMLOutputElement', + 'HTMLPictureElement', + 'HTMLProgressElement', + 'HTMLQuoteElement', + 'HTMLScriptElement', + 'HTMLSelectElement', + 'HTMLSlotElement', + 'HTMLSourceElement', + 'HTMLSpanElement', + 'HTMLTableCaptionElement', + 'HTMLTableCellElement', + 'HTMLTableColElement', + 'HTMLTableElement', + 'HTMLTimeElement', + 'HTMLTableRowElement', + 'HTMLTableSectionElement', + 'HTMLTemplateElement', + 'HTMLTextAreaElement', + 'HTMLUnknownElement', + 'HTMLIFrameElement', + 'HTMLEmbedElement', + 'HTMLObjectElement', + 'HTMLParamElement', + 'HTMLVideoElement', + 'HTMLAudioElement', + 'HTMLTrackElement', + 'HTMLFormControlsCollection' +]; + +export function bootstrap() { + const dom = new JSDOM(bootstrapHtml(), bootstrapOptions()); + Object.keys(dom.window).forEach(key => { + if (windowPropPartials.some(p => key.includes(p) && !key.startsWith('_'))) + global[key] = dom.window[key]; + }); + + windowHtmlProps.forEach(key => global[key] = dom.window[key]); + global.window = dom.window; + return dom; +} + +export function bootstrapHtml() { + return `

Node Tests

`; +} + +export function bootstrapOptions() { + return { + runScripts: 'dangerously', + resources: 'usable', + pretendToBeVisual: true, + beforeParse(window) { + polyfill(window); + } + }; +} diff --git a/test/env/polyfill-fake.js b/test/env/polyfill-fake.js new file mode 100644 index 0000000..8551728 --- /dev/null +++ b/test/env/polyfill-fake.js @@ -0,0 +1,21 @@ +/** + * Fake polyfills for spec JSDom does not implement + */ +export function polyfill(window) { + // https://github.com/ionic-team/stencil/issues/2277#issuecomment-680737430 + if (typeof window.CSSStyleSheet.prototype.replaceSync !== 'function' + || typeof window.CSSStyleSheet.prototype.replace !== 'function') { + window.CSSStyleSheet.prototype.replaceSync = noop; + window.CSSStyleSheet.prototype.replace = () => Promise.resolve(); + } + + // https://github.com/jsdom/jsdom/issues/1695 + if (typeof window.Element.prototype.scrollIntoView !== 'function') { + window.Element.prototype.scrollIntoView = noop; + } + +} + +function noop() { + // +} diff --git a/test/fake/polyfill-fake.js b/test/fake/polyfill-fake.js deleted file mode 100644 index 260f81e..0000000 --- a/test/fake/polyfill-fake.js +++ /dev/null @@ -1,15 +0,0 @@ -import {noop} from "../../src/util.js"; - -// As JSDom does not implement the whole specification we need to provide fake polyfills - -// https://github.com/ionic-team/stencil/issues/2277#issuecomment-680737430 -if (typeof CSSStyleSheet.prototype.replaceSync !== 'function' - || typeof CSSStyleSheet.prototype.replace !== 'function') { - CSSStyleSheet.prototype.replaceSync = noop; - CSSStyleSheet.prototype.replace = () => Promise.resolve(); -} - -// https://github.com/jsdom/jsdom/issues/1695 -if (typeof Element.prototype.scrollIntoView !== 'function') { - Element.prototype.scrollIntoView = noop; -} diff --git a/test/util/expectation.ts b/test/util/expectation.ts new file mode 100644 index 0000000..ca8afbb --- /dev/null +++ b/test/util/expectation.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert'; + +export function expect(actual: T): Expectation { + return new Expectation(actual); +} + +class Expectation { + + readonly #actual: unknown; + + constructor(actual: unknown) { + this.#actual = actual; + } + + toBe(expected: Partial, message?: string | Error): void { + assert.strictEqual(this.#actual, expected, message); + } + + notToBe(expected: Partial, message?: string | Error): void { + assert.notStrictEqual(this.#actual, expected, message); + } + + toEqual(expected: Partial, message?: string | Error): void { + assert.deepStrictEqual(this.#actual, expected, message); + } + + notToEqual(expected: Partial, message?: string | Error): void { + assert.notDeepStrictEqual(this.#actual, expected, message); + } + +} diff --git a/tsconfig-template.json b/tsconfig-template.json index e020102..c08a8d9 100644 --- a/tsconfig-template.json +++ b/tsconfig-template.json @@ -1,15 +1,13 @@ { "compilerOptions": { - "target": "esnext", - "module": "esnext", + "target": "es2022", + "module": "nodenext", "moduleResolution": "nodenext", "noEmitOnError": true, - "lib": ["esnext", "dom", "dom.iterable"], + "lib": ["es2022", "dom", "dom.iterable"], "strict": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, "importHelpers": false, "outDir": "dist", "sourceMap": true,