diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b43639c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,64 @@ +## GITATTRIBUTES FOR WEB PROJECTS +# +# These settings are for any web project. +# +# Details per file setting: +# text These files should be normalized (i.e. convert CRLF to LF). +# binary These files are binary and should be left untouched. +# +# Note that binary is a macro for -text -diff. +###################################################################### + +# Auto detect +## Handle line endings automatically for files detected as +## text and leave all files detected as binary untouched. +## This will handle all files NOT defined below. +* text eol=lf + +# Source code +*.css text eol=lf +*.html text diff=html eol=lf +*.js text eol=lf +*.json text eol=lf +*.scss text diff=css eol=lf +*.ts text eol=lf + +# Documentation +*.md text eol=lf +*.txt text eol=lf +AUTHORS text eol=lf +CHANGELOG text eol=lf +CHANGES text eol=lf +CONTRIBUTING text eol=lf +COPYING text eol=lf +copyright text eol=lf +*COPYRIGHT* text eol=lf +INSTALL text eol=lf +license text eol=lf +LICENSE text eol=lf +NEWS text eol=lf +readme text eol=lf +*README* text eol=lf +TODO text eol=lf + +# Linters +.eslintrc text eol=lf +.stylelintrc text eol=lf + +# Configs +.babelrc text eol=lf +.browserslistrc text eol=lf +.editorconfig text eol=lf +.env text eol=lf +.gitattributes text eol=lf +.gitconfig text eol=lf +package-lock.json text -diff eol=lf +*.npmignore text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +browserslist text eol=lf + +# Graphics +# SVG treated as an asset (binary) by default. +*.svg text eol=lf +*.png binary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b8b3e3a..70aaaa9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,12 +4,6 @@ -## How Has This Been Tested? - - - - - ## Screenshots (if appropriate) ## Types of changes @@ -17,6 +11,7 @@ - [ ] Chore (tooling change or documentation change) +- [ ] Refactor (non-breaking change which maintains existing functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) diff --git a/.github/actions-scripts/__snapshots__/chrome-win32.png b/.github/actions-scripts/__snapshots__/chrome-win32.png new file mode 100644 index 0000000..b2ed9f1 Binary files /dev/null and b/.github/actions-scripts/__snapshots__/chrome-win32.png differ diff --git a/.github/actions-scripts/__snapshots__/firefox-darwin.png b/.github/actions-scripts/__snapshots__/firefox-darwin.png new file mode 100644 index 0000000..ca328d1 Binary files /dev/null and b/.github/actions-scripts/__snapshots__/firefox-darwin.png differ diff --git a/.github/actions-scripts/__snapshots__/firefox-win32.png b/.github/actions-scripts/__snapshots__/firefox-win32.png new file mode 100644 index 0000000..8bba2e3 Binary files /dev/null and b/.github/actions-scripts/__snapshots__/firefox-win32.png differ diff --git a/.github/actions-scripts/__snapshots__/ie-win32.png b/.github/actions-scripts/__snapshots__/ie-win32.png new file mode 100644 index 0000000..7b74d98 Binary files /dev/null and b/.github/actions-scripts/__snapshots__/ie-win32.png differ diff --git a/.github/actions-scripts/__snapshots__/puppeteer-darwin.png b/.github/actions-scripts/__snapshots__/puppeteer-darwin.png new file mode 100644 index 0000000..b9ab3bc Binary files /dev/null and b/.github/actions-scripts/__snapshots__/puppeteer-darwin.png differ diff --git a/.github/actions-scripts/__snapshots__/safari-darwin.png b/.github/actions-scripts/__snapshots__/safari-darwin.png new file mode 100644 index 0000000..722b8d4 Binary files /dev/null and b/.github/actions-scripts/__snapshots__/safari-darwin.png differ diff --git a/.github/actions-scripts/puppeteer.js b/.github/actions-scripts/puppeteer.js new file mode 100644 index 0000000..636dc3a --- /dev/null +++ b/.github/actions-scripts/puppeteer.js @@ -0,0 +1,88 @@ +const { readFileSync, writeFileSync, mkdirSync } = require('fs'); +const path = require('path'); +const { once } = require('events'); + +const puppeteer = require('puppeteer'); +const pixelmatch = require('pixelmatch'); +const { PNG } = require('pngjs'); + +const server = require('../../server'); + +async function test() { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + let error; + let pixelDifference; + + if (!server.listening) await once(server, 'listening'); + + try { + page.on('console', msg => { + if (msg.type() === 'error') throw new Error(msg.text()); + }); + page.on('pageerror', err => { + throw err; + }); + + await page.goto(`http://127.0.0.1:${server.address().port}`, { + waitUntil: 'networkidle2', + }); + await page.setViewport({ width: 640, height: 1000 }); + await page.click('label[for="choices-single-custom-templates"]'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + + const snapshotName = `puppeteer-${process.platform}.png`; + const artifactsPath = 'screenshot'; + mkdirSync(artifactsPath, { recursive: true }); + const imageBuffer = await page.screenshot({ + path: path.join(artifactsPath, snapshotName), + fullPage: true, + }); + + // compare with snapshot + const screenshot = PNG.sync.read(imageBuffer); + const snapshot = PNG.sync.read( + readFileSync(path.resolve(__dirname, `./__snapshots__/${snapshotName}`)), + ); + const { width, height } = screenshot; + const diff = new PNG({ width, height }); + pixelDifference = pixelmatch( + screenshot.data, + snapshot.data, + diff.data, + width, + height, + { + threshold: 0.6, + }, + ); + writeFileSync(path.join(artifactsPath, 'diff.png'), PNG.sync.write(diff)); + } catch (err) { + console.error(err); + error = err; + } finally { + await Promise.all([ + browser.close(), + new Promise(resolve => server.close(resolve)), + ]); + } + + if (pixelDifference > 200) { + console.error( + `Snapshot is different from screenshot by ${pixelDifference} pixels`, + ); + process.exit(1); + } + if (error) process.exit(1); +} + +process.on('unhandledRejection', err => { + console.error(err); + process.exit(1); +}); +process.once('uncaughtException', err => { + console.error(err); + process.exit(1); +}); +setImmediate(test); diff --git a/.github/actions-scripts/selenium.js b/.github/actions-scripts/selenium.js new file mode 100644 index 0000000..1b69bac --- /dev/null +++ b/.github/actions-scripts/selenium.js @@ -0,0 +1,155 @@ +const path = require('path'); +const { readFileSync, writeFileSync, mkdirSync } = require('fs'); +const { once } = require('events'); + +const pixelmatch = require('pixelmatch'); +const { PNG } = require('pngjs'); +const { + Builder, + By, + Key, + until, + Capabilities, + logging, +} = require('selenium-webdriver'); + +const server = require('../../server'); + +async function test() { + let pixelDifference; + let error; + + let capabilities; + switch (process.env.BROWSER) { + case 'ie': + capabilities = Capabilities.ie(); + capabilities.set('ignoreProtectedModeSettings', true); + capabilities.set('ignoreZoomSetting', true); + capabilities.set('ie.options', { + enableFullPageScreenshot: true, + ensureCleanSession: true, + }); + break; + + case 'edge': + capabilities = Capabilities.edge(); + break; + + case 'safari': + capabilities = Capabilities.safari(); + capabilities.set('safari.options', { technologyPreview: false }); + break; + + case 'firefox': { + capabilities = Capabilities.firefox().setLoggingPrefs({ browser: 'ALL' }); + break; + } + case 'chrome': { + capabilities = Capabilities.chrome().setLoggingPrefs({ browser: 'ALL' }); + capabilities.set('chromeOptions', { + args: ['--headless', '--no-sandbox', '--disable-gpu'], + }); + break; + } + } + + let driver = await new Builder().withCapabilities(capabilities).build(); + + if (!server.listening) await once(server, 'listening'); + + try { + await driver.get(`http://127.0.0.1:${server.address().port}`); + + // wait for last choice to init + await driver.wait( + until.elementLocated(By.css('#reset-multiple ~ .choices__list')), + 10000, + 'waiting for all Choices instances to init', + ); + + // Resize window + await driver + .manage() + .window() + .maximize(); + await driver + .manage() + .window() + // magic numbers here to make sure all demo page are fit inside + .setRect({ x: 0, y: 0, width: 630, height: 4000 }); + + // and click on press space on it, so it should open choices + await driver + .findElement(By.css('#reset-multiple ~ .choices__list button')) + .sendKeys(Key.SPACE); + await driver.sleep(1000); + + // take screenshot + const image = await driver.takeScreenshot(); + const imageBuffer = Buffer.from(image, 'base64'); + + const snapshotName = `${process.env.BROWSER}-${process.platform}.png`; + const artifactsPath = 'screenshot'; + mkdirSync(artifactsPath, { recursive: true }); + + writeFileSync(path.join(artifactsPath, snapshotName), imageBuffer); + + // compare with snapshot + const screenshot = PNG.sync.read(imageBuffer); + const snapshot = PNG.sync.read( + readFileSync(path.resolve(__dirname, `./__snapshots__/${snapshotName}`)), + ); + const { width, height } = screenshot; + const diff = new PNG({ width, height }); + pixelDifference = pixelmatch( + screenshot.data, + snapshot.data, + diff.data, + width, + height, + { + threshold: 1, + }, + ); + writeFileSync(path.join(artifactsPath, 'diff.png'), PNG.sync.write(diff)); + + // getting console logs + // ensure no errors in console (only supported in Chrome currently) + if (process.env.BROWSER === 'chrome') { + const entries = await driver + .manage() + .logs() + .get(logging.Type.BROWSER); + if ( + Array.isArray(entries) && + entries.some(entry => entry.level.name_ === 'SEVERE') + ) + throw new Error(JSON.stringify(entries)); + } + } catch (err) { + console.error(err); + error = err; + } finally { + await Promise.all([ + driver.quit(), + new Promise(resolve => server.close(resolve)), + ]); + } + if (pixelDifference > 200) { + console.error( + `Snapshot is different from screenshot by ${pixelDifference} pixels`, + ); + process.exit(1); + } + if (error) process.exit(1); +} + +process.on('unhandledRejection', err => { + console.error(err); + process.exit(1); +}); +process.once('uncaughtException', err => { + console.error(err); + process.exit(1); +}); +setImmediate(test); diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 5b74d19..0d888cb 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -1,6 +1,9 @@ name-template: 'Draft (next release)' tag-template: 'v$NEXT_PATCH_VERSION' sort-direction: descending +exclude-labels: + - 'skip-changelog' + - 'release' categories: - title: '🚨 Breaking changes' labels: diff --git a/.github/workflows/browsers.yml b/.github/workflows/browsers.yml new file mode 100644 index 0000000..dc58f31 --- /dev/null +++ b/.github/workflows/browsers.yml @@ -0,0 +1,133 @@ +name: Browsers + +on: + pull_request: + paths: + - 'src/**' + - 'package-lock.json' + - '.browserslistrc' + - '.babelrc' + - 'webpack.config.*' + - 'public/index.html' + - '.github/actions-scripts/__snapshots__/**' + - '.github/workflows/browsers.yml' + +jobs: + selenium: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macOS-latest] + browser: [ie, firefox, safari] + exclude: + # On Windows, run tests with only IE and Edge + - os: windows-latest + browser: safari + # On macOS, run tests with only on safari + - os: macOS-latest + browser: ie + - os: macOS-latest + browser: chrome + # Safari workaround is not working in Catalina + - browser: safari + + runs-on: ${{ matrix.os }} + steps: + - name: Dump GitHub context + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + run: echo "$GITHUB_CONTEXT" + + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + - uses: actions/setup-node@v1 + with: + node-version: '12.x' + + - name: Cache node modules + uses: actions/cache@v1 + with: + path: ~/.npm + key: ${{ runner.OS }}-build-${{ matrix.browser }} + restore-keys: | + ${{ runner.OS }}-build-${{ env.cache-name }}- + ${{ runner.OS }}-build- + ${{ runner.OS }}- + + - run: | + npm ci + npm run build + env: + CYPRESS_INSTALL_BINARY: 0 + HUSKY_SKIP_INSTALL: true + + # install drivers + - name: Enable Safari Driver + run: | + # Workaround for `sudo safardriver --enable` not working: + # https://github.com/web-platform-tests/wpt/issues/19845 + # https://github.com/web-platform-tests/wpt/blob/master/tools/ci/azure/install_safari.yml + mkdir -p ~/Library/WebDriver/ + curl https://raw.githubusercontent.com/web-platform-tests/wpt/master/tools/ci/azure/com.apple.Safari.plist -o ~/Library/WebDriver/com.apple.Safari.plist + defaults write com.apple.Safari WebKitJavaScriptCanOpenWindowsAutomatically 1 + # sudo safaridriver --enable + if: matrix.browser == 'safari' + + - run: | + brew cask install firefox + brew install geckodriver + if: matrix.browser == 'firefox' && matrix.os == 'macOS-latest' + + - run: echo "::add-path::$env:GeckoWebDriver" + if: matrix.browser == 'firefox' && matrix.os == 'windows-latest' + + - run: echo "::add-path::$env:IEWebDriver" + if: matrix.browser == 'ie' && matrix.os == 'windows-latest' + + - run: echo "::add-path::$env:ChromeWebDriver" + if: matrix.browser == 'chrome' && matrix.os == 'windows-latest' + + - run: npm i --no-optional --no-audit selenium-webdriver pixelmatch pngjs + - run: node .github/actions-scripts/selenium.js + env: + BROWSER: ${{ matrix.browser }} + PORT: 0 + NODE_ENV: production # prevent watching + + - uses: actions/upload-artifact@master + if: failure() + with: + name: screenshot-${{ matrix.browser }}-${{ matrix.os }} + path: screenshot + + puppeteer: + runs-on: macos-latest + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 1 + - name: Cache node modules + uses: actions/cache@v1 + with: + path: ~/.npm + key: ${{ runner.OS }}-build-puppeteer + restore-keys: | + ${{ runner.OS }}-build-puppeteer + - run: | + npm ci + npm run build + env: + CYPRESS_INSTALL_BINARY: 0 + HUSKY_SKIP_INSTALL: true + - run: npm i --no-optional --no-audit puppeteer pixelmatch pngjs + - run: node .github/actions-scripts/puppeteer.js + env: + PORT: 0 + NODE_ENV: production # prevent watching + + - uses: actions/upload-artifact@master + if: failure() + with: + name: screenshot-puppeteer-darwin + path: screenshot diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 0791084..3b89840 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -15,8 +15,8 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 10 - # run all tests - - run: | + - name: Build and run all tests + run: | npm ci npm run build npx bundlesize @@ -32,6 +32,15 @@ jobs: BUNDLESIZE_GITHUB_TOKEN: ${{secrets.BUNDLESIZE_GITHUB_TOKEN}} FORCE_COLOR: 2 HUSKY_SKIP_INSTALL: true + - name: Commit built files + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git commit -m "Update build files 🏗" -a || echo "No changes to commit" && exit 0 + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} - name: Upload coverage to Codecov run: bash <(curl -s https://codecov.io/bash) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 0d45e76..bc6b1e5 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -14,7 +14,6 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 10 - # run all tests - run: | npm ci npm run build @@ -45,7 +44,6 @@ jobs: CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} publish-npm: - needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 @@ -64,7 +62,6 @@ jobs: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} deploy-gh-pages: - needs: publish-npm runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 16132f3..0d7d28d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -10,11 +10,15 @@ on: - 'webpack.config.*' - 'public/test/**' - 'cypress/**' - - '.github/workflows/cypress.yml' + - '.github/workflows/e2e-tests.yml' jobs: test-e2e: runs-on: ubuntu-latest + env: + CI: true + TERM: xterm-256color + steps: - uses: actions/checkout@v1 with: @@ -22,18 +26,42 @@ jobs: - uses: actions/setup-node@v1 with: - node-version: 10 + node-version: 12.x + + - name: Cache node modules + uses: actions/cache@v1 + with: + path: ~/.npm + key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.OS }}-build-${{ env.cache-name }}- + ${{ runner.OS }}-build- + ${{ runner.OS }}- + + - name: Get Cypress info + id: cypress-info + run: | + echo ::set-output name=version::$(jq -r .devDependencies.cypress ./package.json) + echo ::set-output name=cache::$(npx cypress cache path) + env: + CYPRESS_INSTALL_BINARY: 0 + - name: Cache Cypress cache + uses: actions/cache@v1 + with: + path: ${{ steps.cypress-info.outputs.cache }} + key: ${{ runner.OS }}-cypress-${{ steps.cypress-info.outputs.version }} + restore-keys: | + ${{ runner.OS }}-cypress-${{ steps.cypress-info.outputs.version }} - name: Install dependencies run: npm ci env: HUSKY_SKIP_INSTALL: true - - name: run Cypress CI - run: npx run-p --race start cypress:ci + - name: run Cypress (with or without recording) + # if we have ran out of free Cypress recordings, run Cypress with recording switched off + run: npx run-p --race start cypress:ci || npx run-p --race start cypress:run env: - CI: true - TERM: xterm-256color NODE_ENV: production # prevent watching CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} DEBUG: commit-info,cypress:server:record diff --git a/.github/workflows/release-management.yml b/.github/workflows/release-drafter.yml similarity index 89% rename from .github/workflows/release-management.yml rename to .github/workflows/release-drafter.yml index f264373..e20d2ac 100644 --- a/.github/workflows/release-management.yml +++ b/.github/workflows/release-drafter.yml @@ -1,4 +1,4 @@ -name: Release management +name: Release drafter on: push: diff --git a/.prettierrc.json b/.prettierrc.json index 7a32250..ab9f978 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,6 +1,7 @@ { "singleQuote": true, "trailingComma": "all", + "endOfLine": "lf", "overrides": [ { "files": ["*.svg"], @@ -8,6 +9,12 @@ "parser": "html", "htmlWhitespaceSensitivity": "ignore" } + }, + { + "files": ["public/*.html"], + "options": { + "trailingComma": "es5" + } } ] } diff --git a/README.md b/README.md index 3bce930..f573282 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Choices.js [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Unit%20Tests/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![npm](https://img.shields.io/npm/v/choices.js.svg)](https://www.npmjs.com/package/choices.js) [![codebeat badge](https://codebeat.co/badges/55120150-5866-42d8-8010-6aaaff5d3fa1)](https://codebeat.co/projects/github-com-jshjohnson-choices-master) +# Choices.js [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Build%20and%20test/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![Actions Status](https://github.com/jshjohnson/Choices/workflows/Bundle%20size%20checks/badge.svg)](https://github.com/jshjohnson/Choices/actions) [![npm](https://img.shields.io/npm/v/choices.js.svg)](https://www.npmjs.com/package/choices.js) A vanilla, lightweight (~19kb gzipped 🎉), configurable select box/text input plugin. Similar to Select2 and Selectize but without the jQuery dependency. @@ -105,7 +105,7 @@ Or include Choices directly: resetScrollPosition: true, shouldSort: true, shouldSortItems: false, - sortFn: () => {...}, + sorter: () => {...}, placeholder: true, placeholderValue: null, searchPlaceholderValue: null, @@ -122,8 +122,8 @@ Or include Choices directly: maxItemText: (maxItemCount) => { return `Only ${maxItemCount} values can be added`; }, - itemComparer: (choice, item) => { - return choice === item; + valueComparer: (value1, value2) => { + return value1 === value2; }, classNames: { containerOuter: 'choices', @@ -147,6 +147,7 @@ Or include Choices directly: openState: 'is-open', disabledState: 'is-disabled', highlightedState: 'is-highlighted', + selectedState: 'is-selected', flippedState: 'is-flipped', loadingState: 'is-loading', noResults: 'has-no-results', @@ -408,7 +409,7 @@ new Choices(element, { **Usage:** Whether items should be sorted. If false, items will appear in the order they were selected. -### sortFn +### sorter **Type:** `Function` **Default:** sortByAlpha @@ -421,7 +422,7 @@ new Choices(element, { ```js // Sorting via length of label from largest to smallest const example = new Choices(element, { - sortFn: function(a, b) { + sorter: function(a, b) { return b.label.length - a.label.length; }, }; @@ -431,11 +432,11 @@ const example = new Choices(element, { **Type:** `Boolean` **Default:** `true` -**Input types affected:** `text`, `select-multiple` +**Input types affected:** `text` **Usage:** Whether the input should show a placeholder. Used in conjunction with `placeholderValue`. If `placeholder` is set to true and no value is passed to `placeholderValue`, the passed input's placeholder attribute will be used as the placeholder value. -**Note:** For single select boxes, the recommended way of adding a placeholder is as follows: +**Note:** For select boxes, the recommended way of adding a placeholder is as follows: ```html