From be79b3883b425152958a6cb215b9f63b8ac5a7d5 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 1 Apr 2021 16:35:26 -0700 Subject: [PATCH] test: bring new folio and migrate small amount of tests to it (#5994) --- .eslintignore | 2 +- .github/workflows/tests.yml | 53 +- package.json | 4 +- test/android/browser.spec.ts | 59 - test/android/device.spec.ts | 63 - test/android/webview.spec.ts | 61 - test/fixtures.spec.ts | 12 - tests/android/browser.spec.ts | 57 + tests/android/device.spec.ts | 59 + tests/android/webview.spec.ts | 58 + {test/fixtures => tests/assets}/callback.js | 0 {test => tests}/browser.spec.ts | 8 +- {test => tests}/browsertype-basic.spec.ts | 27 +- tests/config/android.config.ts | 38 + tests/config/androidEnv.ts | 81 + tests/config/androidTest.ts | 27 + tests/config/browserEnv.ts | 261 ++ tests/config/browserTest.ts | 28 + tests/config/default.config.ts | 62 + tests/config/pageTest.ts | 42 + tests/config/playwrightTest.ts | 33 + tests/config/serverEnv.ts | 77 + tests/config/serverTest.ts | 24 + tests/folio/.gitignore | 1 + tests/folio/cli.js | 19 + tests/folio/src/cli.ts | 278 +++ tests/folio/src/dispatcher.ts | 387 +++ tests/folio/src/expect.ts | 53 + tests/folio/src/expectType.ts | 150 ++ tests/folio/src/globals.ts | 25 + tests/folio/src/golden.ts | 171 ++ tests/folio/src/index.ts | 27 + tests/folio/src/ipc.ts | 66 + tests/folio/src/loader.ts | 94 + tests/folio/src/reporters/base.ts | 246 ++ tests/folio/src/reporters/dot.ts | 57 + tests/folio/src/reporters/empty.ts | 30 + tests/folio/src/reporters/json.ts | 136 + tests/folio/src/reporters/junit.ts | 185 ++ tests/folio/src/reporters/line.ts | 81 + tests/folio/src/reporters/list.ts | 79 + tests/folio/src/reporters/multiplexer.ts | 65 + tests/folio/src/runner.ts | 132 + tests/folio/src/spec.ts | 218 ++ tests/folio/src/test.ts | 237 ++ tests/folio/src/transform.ts | 80 + tests/folio/src/types.ts | 194 ++ tests/folio/src/util.ts | 139 ++ tests/folio/src/worker.ts | 119 + tests/folio/src/workerRunner.ts | 440 ++++ tests/folio/third_party/diff_match_patch.js | 2222 +++++++++++++++++ tests/folio/tsconfig.json | 22 + .../jshandle-as-element.spec.ts | 10 +- tests/stack-trace.spec.ts | 30 + 54 files changed, 6903 insertions(+), 226 deletions(-) delete mode 100644 test/android/browser.spec.ts delete mode 100644 test/android/device.spec.ts delete mode 100644 test/android/webview.spec.ts create mode 100644 tests/android/browser.spec.ts create mode 100644 tests/android/device.spec.ts create mode 100644 tests/android/webview.spec.ts rename {test/fixtures => tests/assets}/callback.js (100%) rename {test => tests}/browser.spec.ts (83%) rename {test => tests}/browsertype-basic.spec.ts (54%) create mode 100644 tests/config/android.config.ts create mode 100644 tests/config/androidEnv.ts create mode 100644 tests/config/androidTest.ts create mode 100644 tests/config/browserEnv.ts create mode 100644 tests/config/browserTest.ts create mode 100644 tests/config/default.config.ts create mode 100644 tests/config/pageTest.ts create mode 100644 tests/config/playwrightTest.ts create mode 100644 tests/config/serverEnv.ts create mode 100644 tests/config/serverTest.ts create mode 100644 tests/folio/.gitignore create mode 100755 tests/folio/cli.js create mode 100644 tests/folio/src/cli.ts create mode 100644 tests/folio/src/dispatcher.ts create mode 100644 tests/folio/src/expect.ts create mode 100644 tests/folio/src/expectType.ts create mode 100644 tests/folio/src/globals.ts create mode 100644 tests/folio/src/golden.ts create mode 100644 tests/folio/src/index.ts create mode 100644 tests/folio/src/ipc.ts create mode 100644 tests/folio/src/loader.ts create mode 100644 tests/folio/src/reporters/base.ts create mode 100644 tests/folio/src/reporters/dot.ts create mode 100644 tests/folio/src/reporters/empty.ts create mode 100644 tests/folio/src/reporters/json.ts create mode 100644 tests/folio/src/reporters/junit.ts create mode 100644 tests/folio/src/reporters/line.ts create mode 100644 tests/folio/src/reporters/list.ts create mode 100644 tests/folio/src/reporters/multiplexer.ts create mode 100644 tests/folio/src/runner.ts create mode 100644 tests/folio/src/spec.ts create mode 100644 tests/folio/src/test.ts create mode 100644 tests/folio/src/transform.ts create mode 100644 tests/folio/src/types.ts create mode 100644 tests/folio/src/util.ts create mode 100644 tests/folio/src/worker.ts create mode 100644 tests/folio/src/workerRunner.ts create mode 100644 tests/folio/third_party/diff_match_patch.js create mode 100644 tests/folio/tsconfig.json rename {test/page => tests}/jshandle-as-element.spec.ts (84%) create mode 100644 tests/stack-trace.spec.ts diff --git a/.eslintignore b/.eslintignore index 14b2242beb..9c8e091091 100644 --- a/.eslintignore +++ b/.eslintignore @@ -13,5 +13,5 @@ utils/generate_types/test/test.ts node_modules/ browser_patches/*/checkout/ browser_patches/chromium/output/ -packages/**/*.d.ts +**/*.d.ts output/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 251a0863e2..8866870a07 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,10 +38,17 @@ jobs: # XVFB-RUN merges both STDOUT and STDERR, whereas we need only STDERR # Wrap `npm run` in a subshell to redirect STDERR to file. # Enable core dumps in the subshell. - - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json && node test/checkCoverage.js" + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npm run folio -- ${{ matrix.browser }} --reporter=dot,json" + env: + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json" env: BROWSER: ${{ matrix.browser }} FOLIO_JSON_OUTPUT_NAME: "test-results/report.json" + # Checking coverage across two test suites is hard. Temporary disabled. + # - run: node test/checkCoverage.js + # env: + # BROWSER: ${{ matrix.browser }} - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json if: always() && github.repository == 'microsoft/playwright' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release-')) - uses: actions/upload-artifact@v1 @@ -66,6 +73,9 @@ jobs: - run: npm ci - run: npm run build - run: node lib/cli/cli install-deps ${{ matrix.browser }} chromium + - run: npm run folio -- ${{ matrix.browser }} --reporter=dot,json + env: + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json env: BROWSER: ${{ matrix.browser }} @@ -96,6 +106,10 @@ jobs: - run: npm ci - run: npm run build - run: node lib/cli/cli install-deps + - run: npm run folio -- ${{ matrix.browser }} --reporter=dot,json + shell: bash + env: + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json shell: bash env: @@ -150,6 +164,11 @@ jobs: # XVFB-RUN merges both STDOUT and STDERR, whereas we need only STDERR # Wrap `npm run` in a subshell to redirect STDERR to file. # Enable core dumps in the subshell. + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npm run folio -- ${{ matrix.browser }} --reporter=dot,json" + if: ${{ always() }} + env: + HEADFUL: 1 + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json" if: ${{ always() }} env: @@ -185,6 +204,10 @@ jobs: # XVFB-RUN merges both STDOUT and STDERR, whereas we need only STDERR # Wrap `npm run` in a subshell to redirect STDERR to file. # Enable core dumps in the subshell. + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npm run folio -- chromium --reporter=dot,json" + env: + PWMODE: "${{ matrix.mode }}" + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json" env: BROWSER: "chromium" @@ -219,6 +242,10 @@ jobs: # XVFB-RUN merges both STDOUT and STDERR, whereas we need only STDERR # Wrap `npm run` in a subshell to redirect STDERR to file. # Enable core dumps in the subshell. + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npm run folio -- ${{ matrix.browser }} --reporter=dot,json" + env: + PWVIDEO: 1 + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npx folio test/ --workers=1 --forbid-only --timeout=60000 --global-timeout=5400000 --retries=3 --reporter=dot,json -p video" env: BROWSER: ${{ matrix.browser }} @@ -249,8 +276,10 @@ jobs: run: utils/avd_recreate.sh - name: Start Android Emulator run: utils/avd_start.sh - - name: Run device tests - run: npx folio test/android -p browserName=chromium --workers=1 --forbid-only --timeout=120000 --global-timeout=5400000 --retries=3 --reporter=dot,json + - name: Run tests + run: npm run build-folio && node tests/folio/cli.js --config=tests/config/android.config.ts + env: + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - name: Run page tests run: npx folio test/page -p browserName=chromium --workers=1 --forbid-only --timeout=120000 --global-timeout=5400000 --retries=3 --reporter=dot,json - run: ./utils/upload_flakiness_dashboard.sh ./test-results/report.json @@ -286,6 +315,10 @@ jobs: # XVFB-RUN merges both STDOUT and STDERR, whereas we need only STDERR # Wrap `npm run` in a subshell to redirect STDERR to file. # Enable core dumps in the subshell. + - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npm run folio -- chromium --reporter=dot,json" + env: + PW_CHROMIUM_CHANNEL: "chrome" + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- bash -c "ulimit -c unlimited && npx folio test/ --workers=1 --forbid-only --timeout=60000 --global-timeout=5400000 --retries=3 --reporter=dot,json" env: BROWSER: "chromium" @@ -314,6 +347,11 @@ jobs: - run: npm run build # This only created problems, should we move ffmpeg back into npm? - run: node lib/cli/cli install ffmpeg + - run: npm run folio -- chromium --reporter=dot,json + shell: bash + env: + PW_CHROMIUM_CHANNEL: "chrome" + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json shell: bash env: @@ -340,6 +378,10 @@ jobs: - run: npm run build # This only created problems, should we move ffmpeg back into npm? - run: node lib/cli/cli install ffmpeg + - run: npm run folio -- chromium --reporter=dot,json + env: + PW_CHROMIUM_CHANNEL: "chrome" + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json env: BROWSER: "chromium" @@ -368,6 +410,11 @@ jobs: - run: npm run build # This only created problems, should we move ffmpeg back into npm? - run: node lib/cli/cli install ffmpeg + - run: npm run folio -- chromium --reporter=dot,json + shell: bash + env: + PW_CHROMIUM_CHANNEL: "msedge" + FOLIO_JSON_OUTPUT_NAME: "test-results/report-new.json" - run: npx folio test/ --workers=1 --forbid-only --global-timeout=5400000 --retries=3 --reporter=dot,json shell: bash env: diff --git a/package.json b/package.json index f724a33af8..fccda6a5e7 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "check-deps": "node utils/check_deps.js", "build-android-driver": "./utils/build_android_driver.sh", "storybook": "start-storybook -p 6006 -s public", - "build-storybook": "build-storybook -s public" + "build-storybook": "build-storybook -s public", + "build-folio": "tsc -p ./tests/folio", + "folio": "npm run build-folio && node tests/folio/cli.js --config=tests/config/default.config.ts" }, "author": { "name": "Microsoft Corporation" diff --git a/test/android/browser.spec.ts b/test/android/browser.spec.ts deleted file mode 100644 index a4208448ae..0000000000 --- a/test/android/browser.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright 2020 Microsoft Corporation. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { folio } from '../fixtures'; -const { it, expect } = folio; - -if (process.env.PW_ANDROID_TESTS) { - it('androidDevice.model', async function({ androidDevice }) { - expect(androidDevice.model()).toBe('sdk_gphone_x86_arm'); - }); - - it('androidDevice.launchBrowser', async function({ androidDevice }) { - const context = await androidDevice.launchBrowser(); - const [page] = context.pages(); - await page.goto('data:text/html,Hello world!'); - expect(await page.title()).toBe('Hello world!'); - await context.close(); - }); - - it('should create new page', async function({ androidDevice }) { - const context = await androidDevice.launchBrowser(); - const page = await context.newPage(); - await page.goto('data:text/html,Hello world!'); - expect(await page.title()).toBe('Hello world!'); - await page.close(); - await context.close(); - }); - - it('should check', async function({ androidDevice }) { - const context = await androidDevice.launchBrowser(); - const [page] = context.pages(); - await page.setContent(``); - await page.check('input'); - expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true); - await page.close(); - await context.close(); - }); - it('should be able to send CDP messages', async ({ androidDevice }) => { - const context = await androidDevice.launchBrowser(); - const [page] = context.pages(); - const client = await context.newCDPSession(page); - await client.send('Runtime.enable'); - const evalResponse = await client.send('Runtime.evaluate', {expression: '1 + 2', returnByValue: true}); - expect(evalResponse.result.value).toBe(3); - }); -} diff --git a/test/android/device.spec.ts b/test/android/device.spec.ts deleted file mode 100644 index 864c9c43d4..0000000000 --- a/test/android/device.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright 2020 Microsoft Corporation. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs'; -import { PNG } from 'pngjs'; - -import { folio } from '../fixtures'; -const { it, expect } = folio; - -if (process.env.PW_ANDROID_TESTS) { - it('androidDevice.shell', async function({ androidDevice }) { - const output = await androidDevice.shell('echo 123'); - expect(output.toString()).toBe('123\n'); - }); - - it('androidDevice.open', async function({ androidDevice }) { - const socket = await androidDevice.open('shell:/bin/cat'); - await socket.write(Buffer.from('321\n')); - const output = await new Promise(resolve => socket.on('data', resolve)); - expect(output.toString()).toBe('321\n'); - const closedPromise = new Promise(resolve => socket.on('close', resolve)); - await socket.close(); - await closedPromise; - }); - - it('androidDevice.screenshot', async function({ androidDevice, testInfo }) { - const path = testInfo.outputPath('screenshot.png'); - const result = await androidDevice.screenshot({ path }); - const buffer = fs.readFileSync(path); - expect(result.length).toBe(buffer.length); - const { width, height} = PNG.sync.read(result); - expect(width).toBe(1080); - expect(height).toBe(1920); - }); - - it('androidDevice.push', async function({ androidDevice }) { - await androidDevice.shell('rm /data/local/tmp/hello-world'); - await androidDevice.push(Buffer.from('hello world'), '/data/local/tmp/hello-world'); - const data = await androidDevice.shell('cat /data/local/tmp/hello-world'); - expect(data).toEqual(Buffer.from('hello world')); - }); - - it('androidDevice.fill', test => { - test.fixme(!!process.env.CI, 'Hangs on the bots'); - }, async function({ androidDevice }) { - await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); - await androidDevice.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'Hello'); - expect((await androidDevice.info({ res: 'org.chromium.webview_shell:id/url_field' })).text).toBe('Hello'); - }); -} diff --git a/test/android/webview.spec.ts b/test/android/webview.spec.ts deleted file mode 100644 index 7d7d29fe99..0000000000 --- a/test/android/webview.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright 2020 Microsoft Corporation. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { folio } from '../fixtures'; -const { it, expect } = folio; - -if (process.env.PW_ANDROID_TESTS) { - it('androidDevice.webView', async function({ androidDevice }) { - expect(androidDevice.webViews().length).toBe(0); - await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); - const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); - expect(webview.pkg()).toBe('org.chromium.webview_shell'); - expect(androidDevice.webViews().length).toBe(1); - }); - - it('webView.page', async function({ androidDevice }) { - expect(androidDevice.webViews().length).toBe(0); - await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); - const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); - const page = await webview.page(); - expect(page.url()).toBe('about:blank'); - }); - - it('should navigate page internally', async function({ androidDevice }) { - expect(androidDevice.webViews().length).toBe(0); - await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); - const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); - const page = await webview.page(); - await page.goto('data:text/html,Hello world!'); - expect(await page.title()).toBe('Hello world!'); - }); - - it('should navigate page externally', test => { - test.fixme(!!process.env.CI, 'Hangs on the bots'); - }, async function({ androidDevice }) { - expect(androidDevice.webViews().length).toBe(0); - await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); - const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); - const page = await webview.page(); - - await androidDevice.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'data:text/html,Hello world!'); - await Promise.all([ - page.waitForNavigation(), - androidDevice.press({ res: 'org.chromium.webview_shell:id/url_field' }, 'Enter') - ]); - expect(await page.title()).toBe('Hello world!'); - }); -} diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index ac4a2fb109..cf99813696 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -17,9 +17,6 @@ import { folio } from './remoteServer.fixture'; import { execSync } from 'child_process'; -import path from 'path'; -import * as stackTrace from '../src/utils/stackTrace'; -import { setUnderTest } from '../src/utils/utils'; import type { Browser } from '../index'; const { it, describe, expect, beforeEach, afterEach } = folio; @@ -146,12 +143,3 @@ describe('stalling signals', (suite, { platform, headful }) => { expect(await stallingRemoteServer.childExitCode()).toBe(130); }); }); - -it('caller file path', async ({}) => { - setUnderTest(); - const callme = require('./fixtures/callback'); - const filePath = callme(() => { - return stackTrace.getCallerFilePath(path.join(__dirname, 'fixtures') + path.sep); - }); - expect(filePath).toBe(__filename); -}); diff --git a/tests/android/browser.spec.ts b/tests/android/browser.spec.ts new file mode 100644 index 0000000000..855dc0bec1 --- /dev/null +++ b/tests/android/browser.spec.ts @@ -0,0 +1,57 @@ +/** + * Copyright 2020 Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '../config/androidTest'; + +test('androidDevice.model', async function({ androidDevice }) { + expect(androidDevice.model()).toBe('sdk_gphone_x86_arm'); +}); + +test('androidDevice.launchBrowser', async function({ androidDevice }) { + const context = await androidDevice.launchBrowser(); + const [page] = context.pages(); + await page.goto('data:text/html,Hello world!'); + expect(await page.title()).toBe('Hello world!'); + await context.close(); +}); + +test('should create new page', async function({ androidDevice }) { + const context = await androidDevice.launchBrowser(); + const page = await context.newPage(); + await page.goto('data:text/html,Hello world!'); + expect(await page.title()).toBe('Hello world!'); + await page.close(); + await context.close(); +}); + +test('should check', async function({ androidDevice }) { + const context = await androidDevice.launchBrowser(); + const [page] = context.pages(); + await page.setContent(``); + await page.check('input'); + expect(await page.evaluate(() => window['checkbox'].checked)).toBe(true); + await page.close(); + await context.close(); +}); + +test('should be able to send CDP messages', async ({ androidDevice }) => { + const context = await androidDevice.launchBrowser(); + const [page] = context.pages(); + const client = await context.newCDPSession(page); + await client.send('Runtime.enable'); + const evalResponse = await client.send('Runtime.evaluate', {expression: '1 + 2', returnByValue: true}); + expect(evalResponse.result.value).toBe(3); +}); diff --git a/tests/android/device.spec.ts b/tests/android/device.spec.ts new file mode 100644 index 0000000000..7e9752df03 --- /dev/null +++ b/tests/android/device.spec.ts @@ -0,0 +1,59 @@ +/** + * Copyright 2020 Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import { PNG } from 'pngjs'; +import { test, expect } from '../config/androidTest'; + +test('androidDevice.shell', async function({ androidDevice }) { + const output = await androidDevice.shell('echo 123'); + expect(output.toString()).toBe('123\n'); +}); + +test('androidDevice.open', async function({ androidDevice }) { + const socket = await androidDevice.open('shell:/bin/cat'); + await socket.write(Buffer.from('321\n')); + const output = await new Promise(resolve => socket.on('data', resolve)); + expect(output.toString()).toBe('321\n'); + const closedPromise = new Promise(resolve => socket.on('close', resolve)); + await socket.close(); + await closedPromise; +}); + +test('androidDevice.screenshot', async function({ androidDevice }, testInfo) { + const path = testInfo.outputPath('screenshot.png'); + const result = await androidDevice.screenshot({ path }); + const buffer = fs.readFileSync(path); + expect(result.length).toBe(buffer.length); + const { width, height} = PNG.sync.read(result); + expect(width).toBe(1080); + expect(height).toBe(1920); +}); + +test('androidDevice.push', async function({ androidDevice }) { + await androidDevice.shell('rm /data/local/tmp/hello-world'); + await androidDevice.push(Buffer.from('hello world'), '/data/local/tmp/hello-world'); + const data = await androidDevice.shell('cat /data/local/tmp/hello-world'); + expect(data).toEqual(Buffer.from('hello world')); +}); + +test('androidDevice.fill', async function({ androidDevice }) { + test.fixme(!!process.env.CI, 'Hangs on the bots'); + + await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); + await androidDevice.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'Hello'); + expect((await androidDevice.info({ res: 'org.chromium.webview_shell:id/url_field' })).text).toBe('Hello'); +}); diff --git a/tests/android/webview.spec.ts b/tests/android/webview.spec.ts new file mode 100644 index 0000000000..0c651bef89 --- /dev/null +++ b/tests/android/webview.spec.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2020 Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from '../config/androidTest'; + +test('androidDevice.webView', async function({ androidDevice }) { + expect(androidDevice.webViews().length).toBe(0); + await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); + const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); + expect(webview.pkg()).toBe('org.chromium.webview_shell'); + expect(androidDevice.webViews().length).toBe(1); +}); + +test('webView.page', async function({ androidDevice }) { + expect(androidDevice.webViews().length).toBe(0); + await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); + const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); + const page = await webview.page(); + expect(page.url()).toBe('about:blank'); +}); + +test('should navigate page internally', async function({ androidDevice }) { + expect(androidDevice.webViews().length).toBe(0); + await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); + const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); + const page = await webview.page(); + await page.goto('data:text/html,Hello world!'); + expect(await page.title()).toBe('Hello world!'); +}); + +test('should navigate page externally', async function({ androidDevice }) { + test.fixme(!!process.env.CI, 'Hangs on the bots'); + + expect(androidDevice.webViews().length).toBe(0); + await androidDevice.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity'); + const webview = await androidDevice.webView({ pkg: 'org.chromium.webview_shell' }); + const page = await webview.page(); + + await androidDevice.fill({ res: 'org.chromium.webview_shell:id/url_field' }, 'data:text/html,Hello world!'); + await Promise.all([ + page.waitForNavigation(), + androidDevice.press({ res: 'org.chromium.webview_shell:id/url_field' }, 'Enter') + ]); + expect(await page.title()).toBe('Hello world!'); +}); diff --git a/test/fixtures/callback.js b/tests/assets/callback.js similarity index 100% rename from test/fixtures/callback.js rename to tests/assets/callback.js diff --git a/test/browser.spec.ts b/tests/browser.spec.ts similarity index 83% rename from test/browser.spec.ts rename to tests/browser.spec.ts index 0985a1e16a..7a4ae2e2e6 100644 --- a/test/browser.spec.ts +++ b/tests/browser.spec.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { it, expect } from './fixtures'; +import { test, expect } from './config/browserTest'; -it('should create new page', async function({browser}) { +test('should create new page', async function({browser}) { const page1 = await browser.newPage(); expect(browser.contexts().length).toBe(1); @@ -30,7 +30,7 @@ it('should create new page', async function({browser}) { expect(browser.contexts().length).toBe(0); }); -it('should throw upon second create new page', async function({browser}) { +test('should throw upon second create new page', async function({browser}) { const page = await browser.newPage(); let error; await page.context().newPage().catch(e => error = e); @@ -38,7 +38,7 @@ it('should throw upon second create new page', async function({browser}) { expect(error.message).toContain('Please use browser.newContext()'); }); -it('version should work', async function({browser, isChromium}) { +test('version should work', async function({browser, isChromium}) { const version = browser.version(); if (isChromium) expect(version.match(/^\d+\.\d+\.\d+\.\d+$/)).toBeTruthy(); diff --git a/test/browsertype-basic.spec.ts b/tests/browsertype-basic.spec.ts similarity index 54% rename from test/browsertype-basic.spec.ts rename to tests/browsertype-basic.spec.ts index 09daa7e25b..a24242ee6d 100644 --- a/test/browsertype-basic.spec.ts +++ b/tests/browsertype-basic.spec.ts @@ -16,30 +16,23 @@ */ import fs from 'fs'; -import { it, expect } from './fixtures'; +import { test, expect } from './config/playwrightTest'; + +test('browserType.executablePath should work', async ({ browserType, browserChannel, browserOptions }) => { + test.skip(!!browserChannel, 'We skip browser download when testing a channel'); + test.skip(!!browserOptions.executablePath, 'Skip with custom executable path'); -it('browserType.executablePath should work', (test, { browserChannel }) => { - test.fixme(!!browserChannel, 'Uncomment on roll'); - test.skip(Boolean(process.env.CRPATH || process.env.FFPATH || process.env.WKPATH)); -}, async ({ browserType, browserChannel }) => { - // Interesting, unless I use browserChannel in test, filter above does not work! const executablePath = browserType.executablePath(); expect(fs.existsSync(executablePath)).toBe(true); }); -it('browserType.name should work', async ({browserType, isChromium, isFirefox, isWebKit}) => { - if (isWebKit) - expect(browserType.name()).toBe('webkit'); - else if (isFirefox) - expect(browserType.name()).toBe('firefox'); - else if (isChromium) - expect(browserType.name()).toBe('chromium'); - else - throw new Error('Unknown browser'); +test('browserType.name should work', async ({browserType, browserName}) => { + expect(browserType.name()).toBe(browserName); }); -it('should throw when trying to connect with not-chromium', (test, {browserName}) => { + +test('should throw when trying to connect with not-chromium', async ({ browserType, browserName }) => { test.skip(browserName === 'chromium'); -}, async ({browserType }) => { + const error = await browserType.connectOverCDP({wsEndpoint: 'foo'}).catch(e => e); expect(error.message).toBe('Connecting over CDP is only supported in Chromium.'); }); diff --git a/tests/config/android.config.ts b/tests/config/android.config.ts new file mode 100644 index 0000000000..1755a35e36 --- /dev/null +++ b/tests/config/android.config.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setConfig, Config } from '../folio/out'; +import * as path from 'path'; +import { test as pageTest } from './pageTest'; +import { test as androidTest } from './androidTest'; +import { ServerEnv } from './serverEnv'; +import { AndroidEnv, AndroidPageEnv } from './androidEnv'; + +const config: Config = { + testDir: path.join(__dirname, '..'), + timeout: 120000, + globalTimeout: 5400000, +}; +if (process.env.CI) { + config.workers = 1; + config.forbidOnly = true; + config.retries = 3; +} +setConfig(config); + +const serverEnv = new ServerEnv(); +pageTest.runWith('android', serverEnv, new AndroidPageEnv(), {}); +androidTest.runWith('android', serverEnv, new AndroidEnv(), {}); diff --git a/tests/config/androidEnv.ts b/tests/config/androidEnv.ts new file mode 100644 index 0000000000..be24da10ac --- /dev/null +++ b/tests/config/androidEnv.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Env, WorkerInfo, TestInfo } from '../folio/out'; +import type { AndroidDevice, BrowserContext } from '../../index'; +import * as os from 'os'; +import { AndroidTestArgs } from './androidTest'; +import { PageTestArgs } from './pageTest'; + +require('../../lib/utils/utils').setUnderTest(); +const playwright: typeof import('../../index') = require('../../index'); + +export class AndroidEnv implements Env { + protected _device?: AndroidDevice; + + async beforeAll(workerInfo: WorkerInfo) { + this._device = (await playwright._android.devices())[0]; + await this._device.shell('am force-stop org.chromium.webview_shell'); + await this._device.shell('am force-stop com.android.chrome'); + this._device.setDefaultTimeout(120000); + } + + async beforeEach(testInfo: TestInfo) { + // Use chromium screenshots. + testInfo.snapshotPathSegment = 'chromium'; + return { + mode: 'default' as const, + isChromium: true, + isFirefox: false, + isWebKit: false, + isWindows: os.platform() === 'win32', + isMac: os.platform() === 'darwin', + isLinux: os.platform() === 'linux', + platform: os.platform() as ('win32' | 'darwin' | 'linux'), + video: false, + toImpl: (playwright as any)._toImpl, + playwright, + androidDevice: this._device!, + }; + } + + async afterAll(workerInfo: WorkerInfo) { + if (this._device) + await this._device.close(); + this._device = undefined; + } +} + +export class AndroidPageEnv extends AndroidEnv implements Env { + private _context?: BrowserContext; + + async beforeAll(workerInfo: WorkerInfo) { + await super.beforeAll(workerInfo); + this._context = await this._device!.launchBrowser(); + } + + async beforeEach(testInfo: TestInfo) { + const result = await super.beforeEach(testInfo); + for (const page of this._context!.pages()) + await page.close(); + const page = await this._context!.newPage(); + return { + ...result, + androidDevice: undefined, + page, + }; + } +} diff --git a/tests/config/androidTest.ts b/tests/config/androidTest.ts new file mode 100644 index 0000000000..a199291edb --- /dev/null +++ b/tests/config/androidTest.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { newTestType } from '../folio/out'; +import type { AndroidDevice } from '../../index'; +import type { CommonTestArgs } from './pageTest'; +import type { ServerTestArgs } from './serverTest'; +export { expect } from 'folio'; + +export type AndroidTestArgs = CommonTestArgs & { + androidDevice: AndroidDevice; +}; + +export const test = newTestType(); diff --git a/tests/config/browserEnv.ts b/tests/config/browserEnv.ts new file mode 100644 index 0000000000..c02f583af1 --- /dev/null +++ b/tests/config/browserEnv.ts @@ -0,0 +1,261 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Env, WorkerInfo, TestInfo } from '../folio/out'; +import type { Browser, BrowserContext, BrowserContextOptions, BrowserType, LaunchOptions } from '../../index'; +import { installCoverageHooks } from '../../test/coverage'; +import { start } from '../../lib/outofprocess'; +import { PlaywrightClient } from '../../lib/remote/playwrightClient'; +import { removeFolders } from '../../lib/utils/utils'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as util from 'util'; +import * as childProcess from 'child_process'; +import { PlaywrightTestArgs } from './playwrightTest'; +import { BrowserTestArgs } from './browserTest'; + +const mkdtempAsync = util.promisify(fs.mkdtemp); + +export type BrowserName = 'chromium' | 'firefox' | 'webkit'; + +type TestOptions = { + mode: 'default' | 'driver' | 'service'; + video?: boolean; + trace?: boolean; +}; + +class DriverMode { + private _playwrightObject: any; + + async setup(workerInfo: WorkerInfo) { + this._playwrightObject = await start(); + return this._playwrightObject; + } + + async teardown() { + await this._playwrightObject.stop(); + } +} + +class ServiceMode { + private _playwrightObejct: any; + private _client: any; + private _serviceProcess: childProcess.ChildProcess; + + async setup(workerInfo: WorkerInfo) { + const port = 9407 + workerInfo.workerIndex * 2; + this._serviceProcess = childProcess.fork(path.join(__dirname, '..', '..', 'lib', 'service.js'), [String(port)], { + stdio: 'pipe' + }); + this._serviceProcess.stderr.pipe(process.stderr); + await new Promise(f => { + this._serviceProcess.stdout.on('data', data => { + if (data.toString().includes('Listening on')) + f(); + }); + }); + this._serviceProcess.unref(); + this._serviceProcess.on('exit', this._onExit); + this._client = await PlaywrightClient.connect(`ws://localhost:${port}/ws`); + this._playwrightObejct = this._client.playwright(); + return this._playwrightObejct; + } + + async teardown() { + await this._client.close(); + this._serviceProcess.removeListener('exit', this._onExit); + const processExited = new Promise(f => this._serviceProcess.on('exit', f)); + this._serviceProcess.kill(); + await processExited; + } + + private _onExit(exitCode, signal) { + throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`); + } +} + +class DefaultMode { + async setup(workerInfo: WorkerInfo) { + return require('../../index'); + } + + async teardown() { + } +} + +export class PlaywrightEnv implements Env { + private _mode: DriverMode | ServiceMode | DefaultMode; + private _browserName: BrowserName; + protected _options: LaunchOptions & TestOptions; + protected _browserOptions: LaunchOptions; + private _playwright: typeof import('../../index'); + protected _browserType: BrowserType; + private _coverage: ReturnType | undefined; + private _userDataDirs: string[] = []; + private _persistentContext: BrowserContext | undefined; + + constructor(browserName: BrowserName, options: LaunchOptions & TestOptions) { + this._browserName = browserName; + this._options = options; + this._mode = { + default: new DefaultMode(), + service: new ServiceMode(), + driver: new DriverMode(), + }[this._options.mode]; + } + + async beforeAll(workerInfo: WorkerInfo) { + this._coverage = installCoverageHooks(this._browserName); + require('../../lib/utils/utils').setUnderTest(); + this._playwright = await this._mode.setup(workerInfo); + this._browserType = this._playwright[this._browserName]; + this._browserOptions = { + ...this._options, + handleSIGINT: false, + }; + } + + private async _createUserDataDir() { + // We do not put user data dir in testOutputPath, + // because we do not want to upload them as test result artifacts. + // + // Additionally, it is impossible to upload user data dir after test run: + // - Firefox removes lock file later, presumably from another watchdog process? + // - WebKit has circular symlinks that makes CI go crazy. + const dir = await mkdtempAsync(path.join(os.tmpdir(), 'playwright-test-')); + this._userDataDirs.push(dir); + return dir; + } + + private async _launchPersistent(options?: Parameters['launchPersistentContext']>[1]) { + if (this._persistentContext) + throw new Error('can only launch one persitent context'); + const userDataDir = await this._createUserDataDir(); + this._persistentContext = await this._browserType.launchPersistentContext(userDataDir, { ...this._browserOptions, ...options }); + const page = this._persistentContext.pages()[0]; + return { context: this._persistentContext, page }; + } + + async beforeEach(testInfo: TestInfo) { + // Different screenshots per browser. + testInfo.snapshotPathSegment = this._browserName; + return { + playwright: this._playwright, + browserName: this._browserName, + browserType: this._browserType, + browserChannel: this._options.channel, + browserOptions: this._browserOptions, + isChromium: this._browserName === 'chromium', + isFirefox: this._browserName === 'firefox', + isWebKit: this._browserName === 'webkit', + isWindows: os.platform() === 'win32', + isMac: os.platform() === 'darwin', + isLinux: os.platform() === 'linux', + headful: !this._browserOptions.headless, + video: !!this._options.video, + mode: this._options.mode, + platform: os.platform() as ('win32' | 'darwin' | 'linux'), + createUserDataDir: this._createUserDataDir.bind(this), + launchPersistent: this._launchPersistent.bind(this), + toImpl: (this._playwright as any)._toImpl, + }; + } + + async afterEach(testInfo: TestInfo) { + await removeFolders(this._userDataDirs); + this._userDataDirs = []; + if (this._persistentContext) { + await this._persistentContext.close(); + this._persistentContext = undefined; + } + } + + async afterAll(workerInfo: WorkerInfo) { + const { coverage, uninstall } = this._coverage!; + uninstall(); + const coveragePath = path.join(__dirname, '..', '..', 'test', 'coverage-report', workerInfo.workerIndex + '.json'); + const coverageJSON = [...coverage.keys()].filter(key => coverage.get(key)); + await fs.promises.mkdir(path.dirname(coveragePath), { recursive: true }); + await fs.promises.writeFile(coveragePath, JSON.stringify(coverageJSON, undefined, 2), 'utf8'); + } +} + +export class BrowserEnv extends PlaywrightEnv implements Env { + private _browser: Browser | undefined; + private _contextOptions: BrowserContextOptions; + private _contexts: BrowserContext[] = []; + + constructor(browserName: BrowserName, options: LaunchOptions & BrowserContextOptions & TestOptions) { + super(browserName, options); + this._contextOptions = options; + } + + async beforeAll(workerInfo: WorkerInfo) { + await super.beforeAll(workerInfo); + this._browser = await this._browserType.launch(this._browserOptions); + } + + async beforeEach(testInfo: TestInfo) { + const result = await super.beforeEach(testInfo); + + const contextOptions = { + recordVideo: this._options.video ? { dir: testInfo.outputPath('') } : undefined, + _traceDir: this._options.trace ? testInfo.outputPath('') : undefined, + ...this._contextOptions, + } as BrowserContextOptions; + + const contextFactory = async (options: BrowserContextOptions = {}) => { + const context = await this._browser.newContext({ ...contextOptions, ...options }); + this._contexts.push(context); + return context; + }; + + return { + ...result, + browser: this._browser, + contextOptions: this._contextOptions as BrowserContextOptions, + contextFactory, + }; + } + + async afterEach(testInfo: TestInfo) { + for (const context of this._contexts) + await context.close(); + this._contexts = []; + await super.afterEach(testInfo); + } + + async afterAll(workerInfo: WorkerInfo) { + if (this._browser) + await this._browser.close(); + this._browser = undefined; + await super.afterAll(workerInfo); + } +} + +export class PageEnv extends BrowserEnv { + async beforeEach(testInfo: TestInfo) { + const result = await super.beforeEach(testInfo); + const context = await result.contextFactory(); + const page = await context.newPage(); + return { + ...result, + context, + page, + }; + } +} diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts new file mode 100644 index 0000000000..4ab99e028c --- /dev/null +++ b/tests/config/browserTest.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { newTestType } from '../folio/out'; +import type { Browser, BrowserContextOptions, BrowserContext } from '../../index'; +import type { PlaywrightTestArgs } from './playwrightTest'; +export { expect } from 'folio'; + +export type BrowserTestArgs = PlaywrightTestArgs & { + browser: Browser; + contextOptions: BrowserContextOptions; + contextFactory: (options?: BrowserContextOptions) => Promise; +}; + +export const test = newTestType(); diff --git a/tests/config/default.config.ts b/tests/config/default.config.ts new file mode 100644 index 0000000000..9982d23cbf --- /dev/null +++ b/tests/config/default.config.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { setConfig, Config } from '../folio/out'; +import * as path from 'path'; +import { test as playwrightTest } from './playwrightTest'; +import { test as browserTest } from './browserTest'; +import { test as pageTest } from './pageTest'; +import { PlaywrightEnv, BrowserEnv, PageEnv, BrowserName } from './browserEnv'; +import { ServerEnv } from './serverEnv'; + +const config: Config = { + testDir: path.join(__dirname, '..'), + timeout: process.env.PWVIDEO ? 60000 : 30000, + globalTimeout: 5400000, +}; +if (process.env.CI) { + config.workers = 1; + config.forbidOnly = true; + config.retries = 3; +} +setConfig(config); + +const getExecutablePath = (browserName: BrowserName) => { + if (browserName === 'chromium' && process.env.CRPATH) + return process.env.CRPATH; + if (browserName === 'firefox' && process.env.FFPATH) + return process.env.FFPATH; + if (browserName === 'webkit' && process.env.WKPATH) + return process.env.WKPATH; +}; + +const serverEnv = new ServerEnv(); + +const browsers = ['chromium', 'webkit', 'firefox'] as BrowserName[]; +for (const browserName of browsers) { + const executablePath = getExecutablePath(browserName); + const options = { + mode: (process.env.PWMODE || 'default') as ('default' | 'driver' | 'service'), + executablePath, + trace: !!process.env.PWTRACE, + headless: !process.env.HEADFUL, + channel: process.env.PW_CHROMIUM_CHANNEL as any, + video: !!process.env.PWVIDEO, + }; + playwrightTest.runWith(browserName, serverEnv, new PlaywrightEnv(browserName, options), {}); + browserTest.runWith(browserName, serverEnv, new BrowserEnv(browserName, options), {}); + pageTest.runWith(browserName, serverEnv, new PageEnv(browserName, options), {}); +} diff --git a/tests/config/pageTest.ts b/tests/config/pageTest.ts new file mode 100644 index 0000000000..e146352dcd --- /dev/null +++ b/tests/config/pageTest.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { newTestType } from '../folio/out'; +import type { Page } from '../../index'; +import type { ServerTestArgs } from './serverTest'; +export { expect } from 'folio'; + +export type CommonTestArgs = { + mode: 'default' | 'driver' | 'service'; + platform: 'win32' | 'darwin' | 'linux'; + video: boolean; + + playwright: typeof import('../../index'); + toImpl: (rpcObject: any) => any; + + isChromium: boolean; + isFirefox: boolean; + isWebKit: boolean; + isWindows: boolean; + isMac: boolean; + isLinux: boolean; +}; + +export type PageTestArgs = CommonTestArgs & { + page: Page; +}; + +export const test = newTestType(); diff --git a/tests/config/playwrightTest.ts b/tests/config/playwrightTest.ts new file mode 100644 index 0000000000..5d17103ae9 --- /dev/null +++ b/tests/config/playwrightTest.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { newTestType } from '../folio/out'; +import type { Browser, BrowserType, LaunchOptions, BrowserContext, Page } from '../../index'; +import { CommonTestArgs } from './pageTest'; +import type { ServerTestArgs } from './serverTest'; +export { expect } from 'folio'; + +export type PlaywrightTestArgs = CommonTestArgs & { + browserName: 'chromium' | 'firefox' | 'webkit'; + browserType: BrowserType; + browserChannel: string | undefined; + browserOptions: LaunchOptions; + headful: boolean; + createUserDataDir: () => Promise; + launchPersistent: (options?: Parameters['launchPersistentContext']>[1]) => Promise<{ context: BrowserContext, page: Page }>; +}; + +export const test = newTestType(); diff --git a/tests/config/serverEnv.ts b/tests/config/serverEnv.ts new file mode 100644 index 0000000000..1f6a1b5f3d --- /dev/null +++ b/tests/config/serverEnv.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { WorkerInfo, TestInfo } from '../folio/out'; +import { TestServer } from '../../utils/testserver'; +import * as path from 'path'; +import socks from 'socksv5'; + +export class ServerEnv { + private _server: TestServer; + private _httpsServer: TestServer; + private _socksServer: any; + + async beforeAll(workerInfo: WorkerInfo) { + const assetsPath = path.join(__dirname, '..', 'assets'); + const cachedPath = path.join(__dirname, '..', 'assets', 'cached'); + + const port = 8907 + workerInfo.workerIndex * 2; + this._server = await TestServer.create(assetsPath, port); + this._server.enableHTTPCache(cachedPath); + + const httpsPort = port + 1; + this._httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort); + this._httpsServer.enableHTTPCache(cachedPath); + + this._socksServer = socks.createServer((info, accept, deny) => { + let socket; + if ((socket = accept(true))) { + // Catch and ignore ECONNRESET errors. + socket.on('error', () => {}); + const body = 'Served by the SOCKS proxy'; + socket.end([ + 'HTTP/1.1 200 OK', + 'Connection: close', + 'Content-Type: text/html', + 'Content-Length: ' + Buffer.byteLength(body), + '', + body + ].join('\r\n')); + } + }); + const socksPort = 9107 + workerInfo.workerIndex * 2; + this._socksServer.listen(socksPort, 'localhost'); + this._socksServer.useAuth(socks.auth.None()); + } + + async beforeEach(testInfo: TestInfo) { + this._server.reset(); + this._httpsServer.reset(); + return { + asset: (p: string) => path.join(__dirname, '..', 'assets', p), + server: this._server, + httpsServer: this._httpsServer, + }; + } + + async afterAll(workerInfo: WorkerInfo) { + await Promise.all([ + this._server.stop(), + this._httpsServer.stop(), + this._socksServer.close(), + ]); + } +} diff --git a/tests/config/serverTest.ts b/tests/config/serverTest.ts new file mode 100644 index 0000000000..f8e69b9876 --- /dev/null +++ b/tests/config/serverTest.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestServer } from '../../utils/testserver'; + +export type ServerTestArgs = { + asset: (path: string) => string; + socksPort: number, + server: TestServer; + httpsServer: TestServer; +}; diff --git a/tests/folio/.gitignore b/tests/folio/.gitignore new file mode 100644 index 0000000000..89f9ac04aa --- /dev/null +++ b/tests/folio/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/tests/folio/cli.js b/tests/folio/cli.js new file mode 100755 index 0000000000..a46c799d04 --- /dev/null +++ b/tests/folio/cli.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +require('./out/cli'); \ No newline at end of file diff --git a/tests/folio/src/cli.ts b/tests/folio/src/cli.ts new file mode 100644 index 0000000000..988c73ce2a --- /dev/null +++ b/tests/folio/src/cli.ts @@ -0,0 +1,278 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { default as ignore } from 'fstream-ignore'; +import * as commander from 'commander'; +import * as fs from 'fs'; +import * as path from 'path'; +import EmptyReporter from './reporters/empty'; +import DotReporter from './reporters/dot'; +import JSONReporter from './reporters/json'; +import JUnitReporter from './reporters/junit'; +import LineReporter from './reporters/line'; +import ListReporter from './reporters/list'; +import { Multiplexer } from './reporters/multiplexer'; +import { Runner } from './runner'; +import { Config, FullConfig, Reporter } from './types'; +import { Loader } from './loader'; +import { createMatcher } from './util'; + +export const reporters: { [name: string]: new () => Reporter } = { + 'dot': DotReporter, + 'json': JSONReporter, + 'junit': JUnitReporter, + 'line': LineReporter, + 'list': ListReporter, + 'null': EmptyReporter, +}; + +const availableReporters = Object.keys(reporters).map(r => `"${r}"`).join(); + +const defaultConfig: FullConfig = { + forbidOnly: false, + globalTimeout: 0, + grep: /.*/, + maxFailures: 0, + outputDir: path.resolve(process.cwd(), 'test-results'), + quiet: false, + repeatEach: 1, + retries: 0, + shard: null, + snapshotDir: '__snapshots__', + testDir: path.resolve(process.cwd()), + testIgnore: 'node_modules/**', + testMatch: '**/?(*.)+(spec|test).[jt]s', + timeout: 10000, + updateSnapshots: false, + workers: Math.ceil(require('os').cpus().length / 2), +}; + +const loadProgram = new commander.Command(); +loadProgram.helpOption(false); +addRunnerOptions(loadProgram); +loadProgram.action(async command => { + try { + await runTests(command); + } catch (e) { + console.log(e); + process.exit(1); + } +}); +loadProgram.parse(process.argv); + +async function runTests(command: any) { + if (command.help === undefined) { + console.log(loadProgram.helpInformation()); + process.exit(0); + } + + const reporterList: string[] = command.reporter.split(','); + const reporterObjects: Reporter[] = reporterList.map(c => { + if (reporters[c]) + return new reporters[c](); + try { + const p = path.resolve(process.cwd(), c); + return new (require(p).default)(); + } catch (e) { + console.error('Invalid reporter ' + c, e); + process.exit(1); + } + }); + + const loader = new Loader(); + loader.addConfig(defaultConfig); + + function loadConfig(configName: string) { + const configFile = path.resolve(process.cwd(), configName); + if (fs.existsSync(configFile)) { + loader.loadConfigFile(configFile); + return true; + } + return false; + } + + if (command.config) { + if (!loadConfig(command.config)) + throw new Error(`${command.config} does not exist`); + } else if (!loadConfig('folio.config.ts') && !loadConfig('folio.config.js')) { + throw new Error(`Configuration file not found. Either pass --config, or create folio.config.(js|ts) file`); + } + + loader.addConfig(configFromCommand(command)); + loader.addConfig({ testMatch: normalizeFilePatterns(loader.config().testMatch) }); + loader.addConfig({ testIgnore: normalizeFilePatterns(loader.config().testIgnore) }); + + const testDir = loader.config().testDir; + if (!fs.existsSync(testDir)) + throw new Error(`${testDir} does not exist`); + if (!fs.statSync(testDir).isDirectory()) + throw new Error(`${testDir} is not a directory`); + + const allAliases = new Set(loader.runLists().map(s => s.alias)); + const runListFilter: string[] = []; + const testFileFilter: string[] = []; + for (const arg of command.args) { + if (allAliases.has(arg)) + runListFilter.push(arg); + else + testFileFilter.push(arg); + } + + const allFiles = await collectFiles(testDir); + const testFiles = filterFiles(testDir, allFiles, testFileFilter, createMatcher(loader.config().testMatch), createMatcher(loader.config().testIgnore)); + for (const file of testFiles) + loader.loadTestFile(file); + + const reporter = new Multiplexer(reporterObjects); + const runner = new Runner(loader, reporter, runListFilter.length ? runListFilter : undefined); + + if (command.list) { + runner.list(); + return; + } + + const result = await runner.run(); + if (result === 'sigint') + process.exit(130); + + if (result === 'forbid-only') { + console.error('====================================='); + console.error(' --forbid-only found a focused test.'); + console.error('====================================='); + process.exit(1); + } + if (result === 'no-tests') { + console.error('================='); + console.error(' no tests found.'); + console.error('================='); + process.exit(1); + } + process.exit(result === 'failed' ? 1 : 0); +} + +async function collectFiles(testDir: string): Promise { + const entries: any[] = []; + let callback = () => {}; + const promise = new Promise(f => callback = f); + ignore({ path: testDir, ignoreFiles: ['.gitignore'] }) + .on('child', (entry: any) => entries.push(entry)) + .on('end', callback); + await promise; + return entries.filter(e => e.type === 'File').sort((a, b) => { + if (a.depth !== b.depth && (a.dirname.startsWith(b.dirname) || b.dirname.startsWith(a.dirname))) + return a.depth - b.depth; + return a.path > b.path ? 1 : (a.path < b.path ? -1 : 0); + }).map(e => e.path); +} + +function filterFiles(base: string, files: string[], filters: string[], filesMatch: (value: string) => boolean, filesIgnore: (value: string) => boolean): string[] { + return files.filter(file => { + file = path.relative(base, file); + if (filesIgnore(file)) + return false; + if (!filesMatch(file)) + return false; + if (filters.length && !filters.find(filter => file.includes(filter))) + return false; + return true; + }); +} + +function addRunnerOptions(program: commander.Command) { + program = program + .version('Version alpha') + .option('-c, --config ', `Configuration file (default: "folio.config.ts" or "folio.config.js")`) + .option('--forbid-only', `Fail if exclusive test(s) encountered (default: ${defaultConfig.forbidOnly})`) + .option('-g, --grep ', `Only run tests matching this string or regexp (default: "${defaultConfig.grep}")`) + .option('--global-timeout ', `Specify maximum time this test suite can run in milliseconds (default: 0 for unlimited)`) + .option('-h, --help', `Display help`) + .option('-j, --workers ', `Number of concurrent workers, use 1 to run in single worker (default: number of CPU cores / 2)`) + .option('--list', `Only collect all the test and report them`) + .option('--max-failures ', `Stop after the first N failures (default: ${defaultConfig.maxFailures})`) + .option('--output ', `Folder for output artifacts (default: "test-results")`) + .option('--quiet', `Suppress stdio`) + .option('--repeat-each ', `Specify how many times to run the tests (default: ${defaultConfig.repeatEach})`) + .option('--reporter ', `Specify reporter to use, comma-separated, can be ${availableReporters}`, process.env.CI ? 'dot' : 'line') + .option('--retries ', `Specify retry count (default: ${defaultConfig.retries})`) + .option('--shard ', `Shard tests and execute only selected shard, specify in the form "current/all", 1-based, for example "3/5"`) + .option('--snapshot-dir ', `Snapshot directory, relative to tests directory (default: "${defaultConfig.snapshotDir}"`) + .option('--test-dir ', `Directory containing test files (default: current directory)`) + .option('--test-ignore ', `Pattern used to ignore test files (default: "${defaultConfig.testIgnore}")`) + .option('--test-match ', `Pattern used to find test files (default: "${defaultConfig.testMatch}")`) + .option('--timeout ', `Specify test timeout threshold in milliseconds (default: ${defaultConfig.timeout})`) + .option('-u, --update-snapshots', `Whether to update snapshots with actual results (default: ${defaultConfig.updateSnapshots})`) + .option('-x', `Stop after the first failure`); +} + +function configFromCommand(command: any): Config { + const config: Config = {}; + if (command.forbidOnly) + config.forbidOnly = true; + if (command.globalTimeout) + config.globalTimeout = parseInt(command.globalTimeout, 10); + if (command.grep) + config.grep = maybeRegExp(command.grep); + if (command.maxFailures || command.x) + config.maxFailures = command.x ? 1 : parseInt(command.maxFailures, 10); + if (command.output) + config.outputDir = path.resolve(process.cwd(), command.output); + if (command.quiet) + config.quiet = command.quiet; + if (command.repeatEach) + config.repeatEach = parseInt(command.repeatEach, 10); + if (command.retries) + config.retries = parseInt(command.retries, 10); + if (command.shard) { + const pair = command.shard.split('/').map((t: string) => parseInt(t, 10)); + config.shard = { current: pair[0] - 1, total: pair[1] }; + } + if (command.snapshotDir) + config.snapshotDir = command.snapshotDir; + if (command.testDir) + config.testDir = path.resolve(process.cwd(), command.testDir); + if (command.testMatch) + config.testMatch = maybeRegExp(command.testMatch); + if (command.testIgnore) + config.testIgnore = maybeRegExp(command.testIgnore); + if (command.timeout) + config.timeout = parseInt(command.timeout, 10); + if (command.updateSnapshots) + config.updateSnapshots = !!command.updateSnapshots; + if (command.workers) + config.workers = parseInt(command.workers, 10); + return config; +} + +function normalizeFilePattern(pattern: string): string { + if (!pattern.includes('/') && !pattern.includes('\\')) + pattern = '**/' + pattern; + return pattern; +} + +function normalizeFilePatterns(patterns: string | RegExp | (string | RegExp)[]) { + if (typeof patterns === 'string') + patterns = normalizeFilePattern(patterns); + else if (Array.isArray(patterns)) + patterns = patterns.map(item => typeof item === 'string' ? normalizeFilePattern(item) : item); + return patterns; +} + +function maybeRegExp(pattern: string): string | RegExp { + const match = pattern.match(/^\/(.*)\/([gi]*)$/); + if (match) + return new RegExp(match[1], match[2]); + return pattern; +} diff --git a/tests/folio/src/dispatcher.ts b/tests/folio/src/dispatcher.ts new file mode 100644 index 0000000000..e94129bd79 --- /dev/null +++ b/tests/folio/src/dispatcher.ts @@ -0,0 +1,387 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import child_process from 'child_process'; +import path from 'path'; +import { EventEmitter } from 'events'; +import { RunPayload, TestBeginPayload, TestEndPayload, DonePayload, TestOutputPayload, TestStatus, WorkerInitParams } from './ipc'; +import { TestResult, Reporter } from './types'; +import { Suite, Test } from './test'; +import { Loader } from './loader'; + +type DispatcherEntry = { + runPayload: RunPayload; + hash: string; + repeatEachIndex: number; + runListIndex: number; +}; + +export class Dispatcher { + private _workers = new Set(); + private _freeWorkers: Worker[] = []; + private _workerClaimers: (() => void)[] = []; + + private _testById = new Map(); + private _queue: DispatcherEntry[] = []; + private _stopCallback: () => void; + readonly _loader: Loader; + private _suite: Suite; + private _reporter: Reporter; + private _hasWorkerErrors = false; + private _isStopped = false; + private _failureCount = 0; + private _didRunGlobalSetup = false; + _globalSetupResult: any = undefined; + + constructor(loader: Loader, suite: Suite, reporter: Reporter) { + this._loader = loader; + this._reporter = reporter; + + this._suite = suite; + for (const suite of this._suite.suites) { + for (const spec of suite._allSpecs()) { + for (const test of spec.tests) + this._testById.set(test._id, { test, result: test._appendTestResult() }); + } + } + + this._queue = this._filesSortedByWorkerHash(); + + // Shard tests. + let total = this._suite.totalTestCount(); + let shardDetails = ''; + + const shard = this._loader.config().shard; + if (shard) { + total = 0; + const shardSize = Math.ceil(total / shard.total); + const from = shardSize * shard.current; + const to = shardSize * (shard.current + 1); + shardDetails = `, shard ${shard.current + 1} of ${shard.total}`; + let current = 0; + const filteredQueue: DispatcherEntry[] = []; + for (const entry of this._queue) { + if (current >= from && current < to) { + filteredQueue.push(entry); + total += entry.runPayload.entries.length; + } + current += entry.runPayload.entries.length; + } + this._queue = filteredQueue; + } + + if (process.stdout.isTTY) { + const workers = new Set(); + suite.findSpec(test => { + for (const variant of test.tests) + workers.add(test.file + variant._workerHash); + }); + console.log(); + const jobs = Math.min(this._loader.config().workers, workers.size); + console.log(`Running ${total} test${total > 1 ? 's' : ''} using ${jobs} worker${jobs > 1 ? 's' : ''}${shardDetails}`); + } + } + + _filesSortedByWorkerHash(): DispatcherEntry[] { + const entriesByWorkerHashAndFile = new Map>(); + for (const suite of this._suite.suites) { + for (const spec of suite._allSpecs()) { + for (const test of spec.tests) { + let entriesByFile = entriesByWorkerHashAndFile.get(test._workerHash); + if (!entriesByFile) { + entriesByFile = new Map(); + entriesByWorkerHashAndFile.set(test._workerHash, entriesByFile); + } + let entry = entriesByFile.get(spec.file); + if (!entry) { + entry = { + runPayload: { + entries: [], + file: spec.file, + }, + repeatEachIndex: test._repeatEachIndex, + runListIndex: test._runListIndex, + hash: test._workerHash, + }; + entriesByFile.set(spec.file, entry); + } + entry.runPayload.entries.push({ + retry: this._testById.get(test._id).result.retry, + testId: test._id, + }); + } + } + } + + const result: DispatcherEntry[] = []; + for (const entriesByFile of entriesByWorkerHashAndFile.values()) { + for (const entry of entriesByFile.values()) + result.push(entry); + } + result.sort((a, b) => a.hash < b.hash ? -1 : (a.hash === b.hash ? 0 : 1)); + return result; + } + + async run() { + if (this._loader.globalSetup) { + this._didRunGlobalSetup = true; + this._globalSetupResult = await this._loader.globalSetup(); + } + // Loop in case job schedules more jobs + while (this._queue.length && !this._isStopped) + await this._dispatchQueue(); + } + + async _dispatchQueue() { + const jobs = []; + while (this._queue.length) { + if (this._isStopped) + break; + const entry = this._queue.shift(); + const requiredHash = entry.hash; + let worker = await this._obtainWorker(entry); + while (!this._isStopped && worker.hash && worker.hash !== requiredHash) { + worker.stop(); + worker = await this._obtainWorker(entry); + } + if (this._isStopped) + break; + jobs.push(this._runJob(worker, entry)); + } + await Promise.all(jobs); + } + + async _runJob(worker: Worker, entry: DispatcherEntry) { + worker.run(entry.runPayload); + let doneCallback; + const result = new Promise(f => doneCallback = f); + worker.once('done', (params: DonePayload) => { + // We won't file remaining if: + // - there are no remaining + // - we are here not because something failed + // - no unrecoverable worker error + if (!params.remaining.length && !params.failedTestId && !params.fatalError) { + this._freeWorkers.push(worker); + this._notifyWorkerClaimer(); + doneCallback(); + return; + } + + // When worker encounters error, we will stop it and create a new one. + worker.stop(); + + // In case of fatal error, we are done with the entry. + if (params.fatalError) { + // Report all the tests are failing with this error. + for (const { testId } of entry.runPayload.entries) { + const { test, result } = this._testById.get(testId); + this._reporter.onTestBegin(test); + result.error = params.fatalError; + this._reportTestEnd(test, result, 'failed'); + } + doneCallback(); + return; + } + + const remaining = params.remaining; + + // Only retry expected failures, not passes and only if the test failed. + if (this._loader.config().retries && params.failedTestId) { + const pair = this._testById.get(params.failedTestId); + if (pair.test.expectedStatus === 'passed' && pair.test.results.length < this._loader.config().retries + 1) { + pair.result = pair.test._appendTestResult(); + remaining.unshift({ + retry: pair.result.retry, + testId: pair.test._id, + }); + } + } + + if (remaining.length) + this._queue.unshift({ ...entry, runPayload: { ...entry.runPayload, entries: remaining } }); + + // This job is over, we just scheduled another one. + doneCallback(); + }); + return result; + } + + async _obtainWorker(entry: DispatcherEntry) { + const claimWorker = (): Promise | null => { + // Use available worker. + if (this._freeWorkers.length) + return Promise.resolve(this._freeWorkers.pop()); + // Create a new worker. + if (this._workers.size < this._loader.config().workers) + return this._createWorker(entry); + return null; + }; + + // Note: it is important to claim the worker synchronously, + // so that we won't miss a _notifyWorkerClaimer call while awaiting. + let worker = claimWorker(); + if (!worker) { + // Wait for available or stopped worker. + await new Promise(f => this._workerClaimers.push(f)); + worker = claimWorker(); + } + return worker; + } + + async _notifyWorkerClaimer() { + if (this._isStopped || !this._workerClaimers.length) + return; + const callback = this._workerClaimers.shift(); + callback(); + } + + _createWorker(entry: DispatcherEntry) { + const worker = new Worker(this); + worker.on('testBegin', (params: TestBeginPayload) => { + const { test, result: testRun } = this._testById.get(params.testId); + testRun.workerIndex = params.workerIndex; + this._reporter.onTestBegin(test); + }); + worker.on('testEnd', (params: TestEndPayload) => { + const { test, result } = this._testById.get(params.testId); + result.data = params.data; + result.duration = params.duration; + result.error = params.error; + test.expectedStatus = params.expectedStatus; + test.annotations = params.annotations; + test.timeout = params.timeout; + if (params.expectedStatus === 'skipped') + test.skipped = true; + this._reportTestEnd(test, result, params.status); + }); + worker.on('stdOut', (params: TestOutputPayload) => { + const chunk = chunkFromParams(params); + const pair = this._testById.get(params.testId); + if (pair) + pair.result.stdout.push(chunk); + this._reporter.onStdOut(chunk, pair ? pair.test : undefined); + }); + worker.on('stdErr', (params: TestOutputPayload) => { + const chunk = chunkFromParams(params); + const pair = this._testById.get(params.testId); + if (pair) + pair.result.stderr.push(chunk); + this._reporter.onStdErr(chunk, pair ? pair.test : undefined); + }); + worker.on('teardownError', ({error}) => { + this._hasWorkerErrors = true; + this._reporter.onError(error); + }); + worker.on('exit', () => { + this._workers.delete(worker); + this._notifyWorkerClaimer(); + if (this._stopCallback && !this._workers.size) + this._stopCallback(); + }); + this._workers.add(worker); + return worker.init(entry).then(() => worker); + } + + async stop() { + this._isStopped = true; + if (this._workers.size) { + const result = new Promise(f => this._stopCallback = f); + for (const worker of this._workers) + worker.stop(); + await result; + } + if (this._didRunGlobalSetup && this._loader.globalTeardown) + await this._loader.globalTeardown(this._globalSetupResult); + } + + private _reportTestEnd(test: Test, result: TestResult, status: TestStatus) { + if (this._isStopped) + return; + result.status = status; + if (result.status !== 'skipped' && result.status !== test.expectedStatus) + ++this._failureCount; + const maxFailures = this._loader.config().maxFailures; + if (!maxFailures || this._failureCount <= maxFailures) + this._reporter.onTestEnd(test, result); + if (maxFailures && this._failureCount === maxFailures) + this._isStopped = true; + } + + hasWorkerErrors(): boolean { + return this._hasWorkerErrors; + } +} + +let lastWorkerIndex = 0; + +class Worker extends EventEmitter { + process: child_process.ChildProcess; + runner: Dispatcher; + hash: string; + index: number; + stdout: any[]; + stderr: any[]; + + constructor(runner: Dispatcher) { + super(); + this.runner = runner; + this.index = lastWorkerIndex++; + + this.process = child_process.fork(path.join(__dirname, 'worker.js'), { + detached: false, + env: { + FORCE_COLOR: process.stdout.isTTY ? '1' : '0', + DEBUG_COLORS: process.stdout.isTTY ? '1' : '0', + ...process.env + }, + // Can't pipe since piping slows down termination for some reason. + stdio: ['ignore', 'ignore', process.env.PW_RUNNER_DEBUG ? 'inherit' : 'ignore', 'ipc'] + }); + this.process.on('exit', () => this.emit('exit')); + this.process.on('error', e => {}); // do not yell at a send to dead process. + this.process.on('message', (message: any) => { + const { method, params } = message; + this.emit(method, params); + }); + } + + async init(entry: DispatcherEntry) { + this.hash = entry.hash; + const params: WorkerInitParams = { + workerIndex: this.index, + repeatEachIndex: entry.repeatEachIndex, + runListIndex: entry.runListIndex, + globalSetupResult: this.runner._globalSetupResult, + loader: this.runner._loader.serialize(), + }; + this.process.send({ method: 'init', params }); + await new Promise(f => this.process.once('message', f)); // Ready ack + } + + run(runPayload: RunPayload) { + this.process.send({ method: 'run', params: runPayload }); + } + + stop() { + this.process.send({ method: 'stop' }); + } +} + +function chunkFromParams(params: TestOutputPayload): string | Buffer { + if (typeof params.text === 'string') + return params.text; + return Buffer.from(params.buffer, 'base64'); +} diff --git a/tests/folio/src/expect.ts b/tests/folio/src/expect.ts new file mode 100644 index 0000000000..c79161f89d --- /dev/null +++ b/tests/folio/src/expect.ts @@ -0,0 +1,53 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Expect } from './expectType'; +import expectLibrary from 'expect'; +import { currentTestInfo } from './globals'; +import { compare } from './golden'; + +export const expect: Expect = expectLibrary; + +const snapshotOrdinalSymbol = Symbol('snapshotOrdinalSymbol'); + +function toMatchSnapshot(received: Buffer | string, nameOrOptions?: string | { name?: string, threshold?: number }, optOptions: { threshold?: number } = {}) { + let options: { name?: string, threshold?: number }; + const testInfo = currentTestInfo(); + if (typeof nameOrOptions === 'string') + options = { name: nameOrOptions, ...optOptions }; + else + options = { ...nameOrOptions }; + + let name = options.name; + if (!name) { + const ordinal = (testInfo as any)[snapshotOrdinalSymbol] || 0; + (testInfo as any)[snapshotOrdinalSymbol] = ordinal + 1; + let extension: string; + if (typeof received === 'string') + extension = '.txt'; + else if (received[0] === 0x89 && received[1] === 0x50 && received[2] === 0x4E && received[3] === 0x47) + extension = '.png'; + else if (received[0] === 0xFF && received[1] === 0xD8 && received[2] === 0xFF) + extension = '.jpeg'; + else + extension = '.dat'; + name = 'snapshot' + (ordinal ? '_' + ordinal : '') + extension; + } + const { pass, message } = compare(received, name, testInfo.snapshotPath, testInfo.outputPath, testInfo.config.updateSnapshots, options); + return { pass, message: () => message }; +} + +expectLibrary.extend({ toMatchSnapshot }); diff --git a/tests/folio/src/expectType.ts b/tests/folio/src/expectType.ts new file mode 100644 index 0000000000..bf69140485 --- /dev/null +++ b/tests/folio/src/expectType.ts @@ -0,0 +1,150 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export declare type AsymmetricMatcher = Record; +export declare type Expect = { + (actual: T): Matchers; + [id: string]: AsymmetricMatcher; + not: { + [id: string]: AsymmetricMatcher; + }; +}; + +export interface Matchers { + /** + * If you know how to test something, `.not` lets you test its opposite. + */ + not: Matchers; + /** + * Use resolves to unwrap the value of a fulfilled promise so any other + * matcher can be chained. If the promise is rejected the assertion fails. + */ + resolves: Matchers>; + /** + * Unwraps the reason of a rejected promise so any other matcher can be chained. + * If the promise is fulfilled the assertion fails. + */ + rejects: Matchers>; + /** + * Checks that a value is what you expect. It uses `===` to check strict equality. + * Don't use `toBe` with floating-point numbers. + */ + toBe(expected: unknown): R; + /** + * Using exact equality with floating point numbers is a bad idea. + * Rounding means that intuitive things fail. + * The default for numDigits is 2. + */ + toBeCloseTo(expected: number, numDigits?: number): R; + /** + * Ensure that a variable is not undefined. + */ + toBeDefined(): R; + /** + * When you don't care what a value is, you just want to + * ensure a value is false in a boolean context. + */ + toBeFalsy(): R; + /** + * For comparing floating point numbers. + */ + toBeGreaterThan(expected: number | bigint): R; + /** + * For comparing floating point numbers. + */ + toBeGreaterThanOrEqual(expected: number | bigint): R; + /** + * Ensure that an object is an instance of a class. + * This matcher uses `instanceof` underneath. + */ + toBeInstanceOf(expected: Function): R; + /** + * For comparing floating point numbers. + */ + toBeLessThan(expected: number | bigint): R; + /** + * For comparing floating point numbers. + */ + toBeLessThanOrEqual(expected: number | bigint): R; + /** + * This is the same as `.toBe(null)` but the error messages are a bit nicer. + * So use `.toBeNull()` when you want to check that something is null. + */ + toBeNull(): R; + /** + * Use when you don't care what a value is, you just want to ensure a value + * is true in a boolean context. In JavaScript, there are six falsy values: + * `false`, `0`, `''`, `null`, `undefined`, and `NaN`. Everything else is truthy. + */ + toBeTruthy(): R; + /** + * Used to check that a variable is undefined. + */ + toBeUndefined(): R; + /** + * Used to check that a variable is NaN. + */ + toBeNaN(): R; + /** + * Used when you want to check that an item is in a list. + * For testing the items in the list, this uses `===`, a strict equality check. + */ + toContain(expected: unknown): R; + /** + * Used when you want to check that an item is in a list. + * For testing the items in the list, this matcher recursively checks the + * equality of all fields, rather than checking for object identity. + */ + toContainEqual(expected: unknown): R; + /** + * Used when you want to check that two objects have the same value. + * This matcher recursively checks the equality of all fields, rather than checking for object identity. + */ + toEqual(expected: unknown): R; + /** + * Use to check if property at provided reference keyPath exists for an object. + * For checking deeply nested properties in an object you may use dot notation or an array containing + * the keyPath for deep references. + * + * Optionally, you can provide a value to check if it's equal to the value present at keyPath + * on the target object. This matcher uses 'deep equality' (like `toEqual()`) and recursively checks + * the equality of all fields. + * + * @example + * + * expect(houseForSale).toHaveProperty('kitchen.area', 20); + */ + toHaveProperty(keyPath: string | Array, value?: unknown): R; + /** + * Check that a string matches a regular expression. + */ + toMatch(expected: string | RegExp): R; + /** + * Used to check that a JavaScript object matches a subset of the properties of an object + */ + toMatchObject(expected: Record | Array): R; + /** + * Use to test that objects have the same types as well as structure. + */ + toStrictEqual(expected: unknown): R; + /** + * Match snapshot + */ + toMatchSnapshot(options?: { + name?: string, + threshold?: number + }): R; + /** + * Match snapshot + */ + toMatchSnapshot(name: string, options?: { + threshold?: number + }): R; +} +export {}; diff --git a/tests/folio/src/globals.ts b/tests/folio/src/globals.ts new file mode 100644 index 0000000000..43733abd0c --- /dev/null +++ b/tests/folio/src/globals.ts @@ -0,0 +1,25 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestInfo } from './types'; + +let currentTestInfoValue: TestInfo | null = null; +export function setCurrentTestInfo(testInfo: TestInfo | null) { + currentTestInfoValue = testInfo; +} +export function currentTestInfo(): TestInfo | null { + return currentTestInfoValue; +} diff --git a/tests/folio/src/golden.ts b/tests/folio/src/golden.ts new file mode 100644 index 0000000000..a20f9786a3 --- /dev/null +++ b/tests/folio/src/golden.ts @@ -0,0 +1,171 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import colors from 'colors/safe'; +import fs from 'fs'; +import path from 'path'; +import jpeg from 'jpeg-js'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; +import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../third_party/diff_match_patch'; + +const extensionToMimeType: { [key: string]: string } = { + 'dat': 'application/octet-string', + 'jpeg': 'image/jpeg', + 'jpg': 'image/jpeg', + 'png': 'image/png', + 'txt': 'text/plain', +}; + +const GoldenComparators: { [key: string]: any } = { + 'application/octet-string': compareBuffers, + 'image/png': compareImages, + 'image/jpeg': compareImages, + 'text/plain': compareText, +}; + +function compareBuffers(actualBuffer: Buffer | string, expectedBuffer: Buffer, mimeType: string): { diff?: object; errorMessage?: string; } | null { + if (!actualBuffer || !(actualBuffer instanceof Buffer)) + return { errorMessage: 'Actual result should be Buffer.' }; + if (Buffer.compare(actualBuffer, expectedBuffer)) + return { errorMessage: 'Buffers differ' }; + return null; +} + +function compareImages(actualBuffer: Buffer | string, expectedBuffer: Buffer, mimeType: string, options = {}): { diff?: object; errorMessage?: string; } | null { + if (!actualBuffer || !(actualBuffer instanceof Buffer)) + return { errorMessage: 'Actual result should be Buffer.' }; + + const actual = mimeType === 'image/png' ? PNG.sync.read(actualBuffer) : jpeg.decode(actualBuffer); + const expected = mimeType === 'image/png' ? PNG.sync.read(expectedBuffer) : jpeg.decode(expectedBuffer); + if (expected.width !== actual.width || expected.height !== actual.height) { + return { + errorMessage: `Sizes differ; expected image ${expected.width}px X ${expected.height}px, but got ${actual.width}px X ${actual.height}px. ` + }; + } + const diff = new PNG({width: expected.width, height: expected.height}); + const count = pixelmatch(expected.data, actual.data, diff.data, expected.width, expected.height, { threshold: 0.2, ...options }); + return count > 0 ? { diff: PNG.sync.write(diff) } : null; +} + +function compareText(actual: Buffer | string, expectedBuffer: Buffer): { diff?: object; errorMessage?: string; diffExtension?: string; } | null { + if (typeof actual !== 'string') + return { errorMessage: 'Actual result should be string' }; + const expected = expectedBuffer.toString('utf-8'); + if (expected === actual) + return null; + const dmp = new diff_match_patch(); + const d = dmp.diff_main(expected, actual); + dmp.diff_cleanupSemantic(d); + return { + errorMessage: diff_prettyTerminal(d) + }; +} + +export function compare(actual: Buffer | string, name: string, snapshotPath: (name: string) => string, outputPath: (name: string) => string, updateSnapshots: boolean, options?: { threshold?: number }): { pass: boolean; message?: string; } { + const snapshotFile = snapshotPath(name); + if (!fs.existsSync(snapshotFile)) { + fs.mkdirSync(path.dirname(snapshotFile), { recursive: true }); + fs.writeFileSync(snapshotFile, actual); + return { + pass: false, + message: snapshotFile + ' is missing in golden results, writing actual.' + }; + } + const expected = fs.readFileSync(snapshotFile); + const extension = path.extname(snapshotFile).substring(1); + const mimeType = extensionToMimeType[extension] || 'application/octet-string'; + const comparator = GoldenComparators[mimeType]; + if (!comparator) { + return { + pass: false, + message: 'Failed to find comparator with type ' + mimeType + ': ' + snapshotFile, + }; + } + + const result = comparator(actual, expected, mimeType, options); + if (!result) + return { pass: true }; + + if (updateSnapshots) { + fs.mkdirSync(path.dirname(snapshotFile), { recursive: true }); + fs.writeFileSync(snapshotFile, actual); + console.log('Updating snapshot at ' + snapshotFile); + return { + pass: true, + message: snapshotFile + ' running with --p-update-snapshots, writing actual.' + }; + } + const outputFile = outputPath(name); + const expectedPath = addSuffix(outputFile, '-expected'); + const actualPath = addSuffix(outputFile, '-actual'); + const diffPath = addSuffix(outputFile, '-diff'); + fs.writeFileSync(expectedPath, expected); + fs.writeFileSync(actualPath, actual); + if (result.diff) + fs.writeFileSync(diffPath, result.diff); + + const output = [ + colors.red(`Snapshot comparison failed:`), + ]; + if (result.errorMessage) { + output.push(''); + output.push(indent(result.errorMessage, ' ')); + } + output.push(''); + output.push(`Expected: ${colors.yellow(expectedPath)}`); + output.push(`Received: ${colors.yellow(actualPath)}`); + if (result.diff) + output.push(` Diff: ${colors.yellow(diffPath)}`); + + return { + pass: false, + message: output.join('\n'), + }; +} + +function indent(lines: string, tab: string) { + return lines.replace(/^(?=.+$)/gm, tab); +} + +function addSuffix(filePath: string, suffix: string, customExtension?: string): string { + const dirname = path.dirname(filePath); + const ext = path.extname(filePath); + const name = path.basename(filePath, ext); + return path.join(dirname, name + suffix + (customExtension || ext)); +} + +function diff_prettyTerminal(diffs) { + const html = []; + for (let x = 0; x < diffs.length; x++) { + const op = diffs[x][0]; // Operation (insert, delete, equal) + const data = diffs[x][1]; // Text of change. + const text = data; + switch (op) { + case DIFF_INSERT: + html[x] = colors.green(text); + break; + case DIFF_DELETE: + html[x] = colors.strikethrough(colors.red(text)); + break; + case DIFF_EQUAL: + html[x] = text; + break; + } + } + return html.join(''); +} diff --git a/tests/folio/src/index.ts b/tests/folio/src/index.ts new file mode 100644 index 0000000000..cbe9235305 --- /dev/null +++ b/tests/folio/src/index.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { TestType } from './types'; +import { newTestTypeImpl } from './spec'; + +export * from './types'; +export { expect } from './expect'; +export { setConfig, globalSetup, globalTeardown } from './spec'; + +export function newTestType(): TestType { + return newTestTypeImpl(); +} diff --git a/tests/folio/src/ipc.ts b/tests/folio/src/ipc.ts new file mode 100644 index 0000000000..6d95a98bd3 --- /dev/null +++ b/tests/folio/src/ipc.ts @@ -0,0 +1,66 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Config, TestStatus, TestError } from './types'; +export type { TestStatus } from './types'; + +export type WorkerInitParams = { + workerIndex: number; + repeatEachIndex: number; + runListIndex: number; + globalSetupResult: any; + loader: { + configs: (string | Config)[]; + }; +}; + +export type TestBeginPayload = { + testId: string; + workerIndex: number, +}; + +export type TestEndPayload = { + testId: string; + duration: number; + status: TestStatus; + error?: TestError; + data: any; + expectedStatus: TestStatus; + annotations: any[]; + timeout: number; +}; + +export type TestEntry = { + testId: string; + retry: number; +}; + +export type RunPayload = { + file: string; + entries: TestEntry[]; +}; + +export type DonePayload = { + failedTestId?: string; + fatalError?: any; + remaining: TestEntry[]; +}; + +export type TestOutputPayload = { + testId?: string; + text?: string; + buffer?: string; +}; diff --git a/tests/folio/src/loader.ts b/tests/folio/src/loader.ts new file mode 100644 index 0000000000..b53a687202 --- /dev/null +++ b/tests/folio/src/loader.ts @@ -0,0 +1,94 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { installTransform } from './transform'; +import { Config, FullConfig } from './types'; +import { prependErrorMessage } from './util'; +import { configFile, setCurrentFile, RunListDescription } from './spec'; + +type SerializedLoaderData = { + configs: (string | Config)[]; +}; + +export class Loader { + globalSetup?: () => any; + globalTeardown?: (globalSetupResult: any) => any; + + private _mergedConfig: FullConfig; + private _layeredConfigs: { config: Config, source?: string }[] = []; + + constructor() { + this._mergedConfig = {} as any; + } + + deserialize(data: SerializedLoaderData) { + for (const config of data.configs) { + if (typeof config === 'string') + this.loadConfigFile(config); + else + this.addConfig(config); + } + } + + loadConfigFile(file: string) { + const revertBabelRequire = installTransform(); + try { + require(file); + this.addConfig(configFile.config || {}); + this._layeredConfigs[this._layeredConfigs.length - 1].source = file; + this.globalSetup = configFile.globalSetup; + this.globalTeardown = configFile.globalTeardown; + } catch (e) { + // Drop the stack. + throw new Error(e.message); + } finally { + revertBabelRequire(); + } + } + + addConfig(config: Config) { + this._layeredConfigs.push({ config }); + this._mergedConfig = { ...this._mergedConfig, ...config }; + } + + loadTestFile(file: string) { + const revertBabelRequire = installTransform(); + setCurrentFile(file); + try { + require(file); + } catch (e) { + prependErrorMessage(e, `Error while reading ${file}:\n`); + throw e; + } finally { + setCurrentFile(); + revertBabelRequire(); + } + } + + config(): FullConfig { + return this._mergedConfig; + } + + runLists(): RunListDescription[] { + return configFile.runLists; + } + + serialize(): SerializedLoaderData { + return { + configs: this._layeredConfigs.map(c => c.source || c.config), + }; + } +} diff --git a/tests/folio/src/reporters/base.ts b/tests/folio/src/reporters/base.ts new file mode 100644 index 0000000000..677774aa15 --- /dev/null +++ b/tests/folio/src/reporters/base.ts @@ -0,0 +1,246 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { codeFrameColumns } from '@babel/code-frame'; +import colors from 'colors/safe'; +import fs from 'fs'; +import milliseconds from 'ms'; +import path from 'path'; +import StackUtils from 'stack-utils'; +import { TestStatus, Test, Suite, TestResult, TestError, Reporter } from '../types'; +import { FullConfig } from '../types'; + +const stackUtils = new StackUtils(); + +export class BaseReporter implements Reporter { + duration = 0; + config: FullConfig; + suite: Suite; + timeout: number; + fileDurations = new Map(); + monotonicStartTime: number; + + constructor() { + } + + onBegin(config: FullConfig, suite: Suite) { + this.monotonicStartTime = monotonicTime(); + this.config = config; + this.suite = suite; + } + + onTestBegin(test: Test) { + } + + onStdOut(chunk: string | Buffer) { + if (!this.config.quiet) + process.stdout.write(chunk); + } + + onStdErr(chunk: string | Buffer) { + if (!this.config.quiet) + process.stderr.write(chunk); + } + + onTestEnd(test: Test, result: TestResult) { + const spec = test.spec; + let duration = this.fileDurations.get(spec.file) || 0; + duration += result.duration; + this.fileDurations.set(spec.file, duration); + } + + onError(error: TestError) { + console.log(formatError(error)); + } + + onTimeout(timeout: number) { + this.timeout = timeout; + } + + onEnd() { + this.duration = monotonicTime() - this.monotonicStartTime; + } + + private _printSlowTests() { + const fileDurations = [...this.fileDurations.entries()]; + fileDurations.sort((a, b) => b[1] - a[1]); + let insertedGap = false; + for (let i = 0; i < 10 && i < fileDurations.length; ++i) { + const baseName = path.basename(fileDurations[i][0]); + const duration = fileDurations[i][1]; + if (duration < 15000) + break; + if (!insertedGap) { + insertedGap = true; + console.log(); + } + console.log(colors.yellow(' Slow test: ') + baseName + colors.yellow(` (${milliseconds(duration)})`)); + } + console.log(); + } + + epilogue(full: boolean) { + let skipped = 0; + let expected = 0; + const unexpected: Test[] = []; + const flaky: Test[] = []; + + this.suite.findTest(test => { + switch (test.status()) { + case 'skipped': ++skipped; break; + case 'expected': ++expected; break; + case 'unexpected': unexpected.push(test); break; + case 'flaky': flaky.push(test); break; + } + }); + + if (expected) + console.log(colors.green(` ${expected} passed`) + colors.dim(` (${milliseconds(this.duration)})`)); + if (skipped) + console.log(colors.yellow(` ${skipped} skipped`)); + if (unexpected.length) { + console.log(colors.red(` ${unexpected.length} failed`)); + this._printTestHeaders(unexpected); + } + if (flaky.length) { + console.log(colors.red(` ${flaky.length} flaky`)); + this._printTestHeaders(flaky); + } + if (this.timeout) + console.log(colors.red(` Timed out waiting ${this.timeout / 1000}s for the entire test run`)); + + if (full && unexpected.length) { + console.log(''); + this._printFailures(unexpected); + } + this._printSlowTests(); + } + + private _printTestHeaders(tests: Test[]) { + tests.forEach(test => { + console.log(formatTestHeader(this.config, test, ' ')); + }); + } + + private _printFailures(failures: Test[]) { + failures.forEach((test, index) => { + console.log(formatFailure(this.config, test, index + 1)); + }); + } + + hasResultWithStatus(test: Test, status: TestStatus): boolean { + return !!test.results.find(r => r.status === status); + } + + willRetry(test: Test, result: TestResult): boolean { + return result.status !== 'passed' && result.status !== test.expectedStatus && test.results.length <= this.config.retries; + } +} + +export function formatFailure(config: FullConfig, test: Test, index?: number): string { + const tokens: string[] = []; + tokens.push(formatTestHeader(config, test, ' ', index)); + for (const result of test.results) { + if (result.status === 'passed') + continue; + tokens.push(formatResult(test, result)); + } + tokens.push(''); + return tokens.join('\n'); +} + +function formatTestHeader(config: FullConfig, test: Test, indent: string, index?: number): string { + const tokens: string[] = []; + const spec = test.spec; + let relativePath = path.relative(config.testDir, spec.file) || path.basename(spec.file); + relativePath += ':' + spec.line + ':' + spec.column; + const passedUnexpectedlySuffix = test.results[0].status === 'passed' ? ' -- passed unexpectedly' : ''; + const runListName = test.alias ? `[${test.alias}] ` : ''; + const header = `${indent}${index ? index + ') ' : ''}${relativePath} › ${runListName}${spec.fullTitle()}${passedUnexpectedlySuffix}`; + tokens.push(colors.red(pad(header, '='))); + return tokens.join('\n'); +} + +function formatResult(test: Test, result: TestResult): string { + const tokens: string[] = []; + if (result.retry) + tokens.push(colors.gray(pad(`\n Retry #${result.retry}`, '-'))); + if (result.status === 'timedOut') { + tokens.push(''); + tokens.push(indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), ' ')); + } else { + tokens.push(indent(formatError(result.error, test.spec.file), ' ')); + } + return tokens.join('\n'); +} + +function formatError(error: TestError, file?: string) { + const stack = error.stack; + const tokens = []; + if (stack) { + tokens.push(''); + const messageLocation = error.stack.indexOf(error.message); + const preamble = error.stack.substring(0, messageLocation + error.message.length); + tokens.push(preamble); + const position = file ? positionInFile(stack, file) : null; + if (position) { + const source = fs.readFileSync(file, 'utf8'); + tokens.push(''); + tokens.push(codeFrameColumns(source, { + start: position, + }, + { highlightCode: true} + )); + } + tokens.push(''); + tokens.push(colors.dim(stack.substring(preamble.length + 1))); + } else { + tokens.push(''); + tokens.push(error.value); + } + return tokens.join('\n'); +} + +function pad(line: string, char: string): string { + return line + ' ' + colors.gray(char.repeat(Math.max(0, 100 - line.length - 1))); +} + +function indent(lines: string, tab: string) { + return lines.replace(/^(?=.+$)/gm, tab); +} + +function positionInFile(stack: string, file: string): { column: number; line: number; } { + // Stack will have /private/var/folders instead of /var/folders on Mac. + file = fs.realpathSync(file); + for (const line of stack.split('\n')) { + const parsed = stackUtils.parseLine(line); + if (!parsed) + continue; + if (path.resolve(process.cwd(), parsed.file) === file) + return {column: parsed.column, line: parsed.line}; + } + return null; +} + +function monotonicTime(): number { + const [seconds, nanoseconds] = process.hrtime(); + return seconds * 1000 + (nanoseconds / 1000000 | 0); +} + +const asciiRegex = new RegExp('[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 'g'); +export function stripAscii(str: string): string { + return str.replace(asciiRegex, ''); +} diff --git a/tests/folio/src/reporters/dot.ts b/tests/folio/src/reporters/dot.ts new file mode 100644 index 0000000000..fa85b57873 --- /dev/null +++ b/tests/folio/src/reporters/dot.ts @@ -0,0 +1,57 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import colors from 'colors/safe'; +import { BaseReporter } from './base'; +import { Test, TestResult } from '../types'; + +class DotReporter extends BaseReporter { + private _counter = 0; + + onTestEnd(test: Test, result: TestResult) { + super.onTestEnd(test, result); + if (++this._counter === 81) { + process.stdout.write('\n'); + return; + } + if (result.status === 'skipped') { + process.stdout.write(colors.yellow('°')); + return; + } + if (this.willRetry(test, result)) { + process.stdout.write(colors.gray('×')); + return; + } + switch (test.status()) { + case 'expected': process.stdout.write(colors.green('·')); break; + case 'unexpected': process.stdout.write(colors.red(test.results[test.results.length - 1].status === 'timedOut' ? 'T' : 'F')); break; + case 'flaky': process.stdout.write(colors.yellow('±')); break; + } + } + + onTimeout(timeout: number) { + super.onTimeout(timeout); + this.onEnd(); + } + + onEnd() { + super.onEnd(); + process.stdout.write('\n'); + this.epilogue(true); + } +} + +export default DotReporter; diff --git a/tests/folio/src/reporters/empty.ts b/tests/folio/src/reporters/empty.ts new file mode 100644 index 0000000000..93d1560a3b --- /dev/null +++ b/tests/folio/src/reporters/empty.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FullConfig, TestResult, Test, Suite, TestError, Reporter } from '../types'; + +class EmptyReporter implements Reporter { + onBegin(config: FullConfig, suite: Suite) {} + onTestBegin(test: Test) {} + onStdOut(chunk: string | Buffer, test?: Test) {} + onStdErr(chunk: string | Buffer, test?: Test) {} + onTestEnd(test: Test, result: TestResult) {} + onTimeout(timeout: number) {} + onError(error: TestError) {} + onEnd() {} +} + +export default EmptyReporter; diff --git a/tests/folio/src/reporters/json.ts b/tests/folio/src/reporters/json.ts new file mode 100644 index 0000000000..c4d69e0636 --- /dev/null +++ b/tests/folio/src/reporters/json.ts @@ -0,0 +1,136 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import EmptyReporter from './empty'; +import { FullConfig, Test, Suite, Spec, TestResult, TestError } from '../types'; + +export interface SerializedSuite { + title: string; + file: string; + column: number; + line: number; + specs: ReturnType[]; + suites?: SerializedSuite[]; +} + +export type ReportFormat = { + config: FullConfig; + errors?: TestError[]; + suites?: SerializedSuite[]; +}; + +function toPosixPath(aPath: string): string { + return aPath.split(path.sep).join(path.posix.sep); +} + +class JSONReporter extends EmptyReporter { + config: FullConfig; + suite: Suite; + private _errors: TestError[] = []; + + onBegin(config: FullConfig, suite: Suite) { + this.config = config; + this.suite = suite; + } + + onTimeout() { + this.onEnd(); + } + + onError(error: TestError): void { + this._errors.push(error); + } + + onEnd() { + outputReport({ + config: { + ...this.config, + outputDir: toPosixPath(this.config.outputDir), + testDir: toPosixPath(this.config.testDir), + }, + suites: this.suite.suites.map(suite => this._serializeSuite(suite)).filter(s => s), + errors: this._errors + }); + } + + private _serializeSuite(suite: Suite): null | SerializedSuite { + if (!suite.findSpec(test => true)) + return null; + const suites = suite.suites.map(suite => this._serializeSuite(suite)).filter(s => s); + return { + title: suite.title, + file: toPosixPath(path.relative(this.config.testDir, suite.file)), + line: suite.line, + column: suite.column, + specs: suite.specs.map(test => this._serializeTestSpec(test)), + suites: suites.length ? suites : undefined, + }; + } + + private _serializeTestSpec(spec: Spec) { + return { + title: spec.title, + ok: spec.ok(), + tests: spec.tests.map(r => this._serializeTest(r)), + file: toPosixPath(path.relative(this.config.testDir, spec.file)), + line: spec.line, + column: spec.column, + }; + } + + private _serializeTest(test: Test) { + return { + timeout: test.timeout, + annotations: test.annotations, + expectedStatus: test.expectedStatus, + results: test.results.map(r => this._serializeTestResult(r)) + }; + } + + private _serializeTestResult(result: TestResult) { + return { + workerIndex: result.workerIndex, + status: result.status, + duration: result.duration, + error: result.error, + stdout: result.stdout.map(s => stdioEntry(s)), + stderr: result.stderr.map(s => stdioEntry(s)), + data: result.data, + retry: result.retry, + }; + } +} + +function outputReport(report: ReportFormat) { + const reportString = JSON.stringify(report, undefined, 2); + const outputName = process.env[`FOLIO_JSON_OUTPUT_NAME`]; + if (outputName) { + fs.mkdirSync(path.dirname(outputName), { recursive: true }); + fs.writeFileSync(outputName, reportString); + } else { + console.log(reportString); + } +} + +function stdioEntry(s: string | Buffer): any { + if (typeof s === 'string') + return { text: s }; + return { buffer: s.toString('base64') }; +} + +export default JSONReporter; diff --git a/tests/folio/src/reporters/junit.ts b/tests/folio/src/reporters/junit.ts new file mode 100644 index 0000000000..daac185c97 --- /dev/null +++ b/tests/folio/src/reporters/junit.ts @@ -0,0 +1,185 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import { FullConfig } from '../types'; +import EmptyReporter from './empty'; +import { Suite, Test } from '../test'; +import { monotonicTime } from '../util'; +import { formatFailure, stripAscii } from './base'; + +class JUnitReporter extends EmptyReporter { + private config: FullConfig; + private suite: Suite; + private timestamp: number; + private startTime: number; + private totalTests = 0; + private totalFailures = 0; + private totalSkipped = 0; + + onBegin(config: FullConfig, suite: Suite) { + this.config = config; + this.suite = suite; + this.timestamp = Date.now(); + this.startTime = monotonicTime(); + } + + onEnd() { + const duration = monotonicTime() - this.startTime; + const children: XMLEntry[] = []; + for (const suite of this.suite.suites) + children.push(this._buildTestSuite(suite)); + const tokens: string[] = []; + + const self = this; + const root: XMLEntry = { + name: 'testsuites', + attributes: { + id: process.env[`FOLIO_JUNIT_SUITE_ID`] || '', + name: process.env[`FOLIO_JUNIT_SUITE_NAME`] || '', + tests: self.totalTests, + failures: self.totalFailures, + skipped: self.totalSkipped, + errors: 0, + time: duration / 1000 + }, + children + }; + + serializeXML(root, tokens); + const reportString = tokens.join('\n'); + const outputName = process.env[`FOLIO_JUNIT_OUTPUT_NAME`]; + if (outputName) { + fs.mkdirSync(path.dirname(outputName), { recursive: true }); + fs.writeFileSync(outputName, reportString); + } else { + console.log(reportString); + } + } + + private _buildTestSuite(suite: Suite): XMLEntry { + let tests = 0; + let skipped = 0; + let failures = 0; + let duration = 0; + const children: XMLEntry[] = []; + + suite.findTest(test => { + ++tests; + if (test.skipped) + ++skipped; + if (!test.ok()) + ++failures; + for (const result of test.results) + duration += result.duration; + this._addTestCase(test, children); + }); + this.totalTests += tests; + this.totalSkipped += skipped; + this.totalFailures += failures; + + const entry: XMLEntry = { + name: 'testsuite', + attributes: { + name: path.relative(this.config.testDir, suite.file), + timestamp: this.timestamp, + hostname: '', + tests, + failures, + skipped, + time: duration / 1000, + errors: 0, + }, + children + }; + + return entry; + } + + private _addTestCase(test: Test, entries: XMLEntry[]) { + const entry = { + name: 'testcase', + attributes: { + name: test.spec.fullTitle(), + classname: path.relative(this.config.testDir, test.spec.file) + ' ' + test.spec.parent.fullTitle(), + time: (test.results.reduce((acc, value) => acc + value.duration, 0)) / 1000 + }, + children: [] + }; + entries.push(entry); + + if (test.skipped) { + entry.children.push({ name: 'skipped'}); + return; + } + + if (!test.ok()) { + entry.children.push({ + name: 'failure', + attributes: { + message: `${path.basename(test.spec.file)}:${test.spec.line}:${test.spec.column} ${test.spec.title}`, + type: 'FAILURE', + }, + text: stripAscii(formatFailure(this.config, test)) + }); + } + for (const result of test.results) { + for (const stdout of result.stdout) { + entries.push({ + name: 'system-out', + text: stdout.toString() + }); + } + + for (const stderr of result.stderr) { + entries.push({ + name: 'system-err', + text: stderr.toString() + }); + } + } + } +} + +type XMLEntry = { + name: string; + attributes?: { [name: string]: string | number | boolean }; + children?: XMLEntry[]; + text?: string; +}; + +function serializeXML(entry: XMLEntry, tokens: string[]) { + const attrs: string[] = []; + for (const name of Object.keys(entry.attributes || {})) + attrs.push(`${name}="${escape(String(entry.attributes[name]))}"`); + tokens.push(`<${entry.name}${attrs.length ? ' ' : ''}${attrs.join(' ')}>`); + for (const child of entry.children || []) + serializeXML(child, tokens); + if (entry.text) + tokens.push(escape(entry.text)); + tokens.push(``); +} + +function escape(text: string): string { + text = text.replace(/"/g, '"'); + text = text.replace(/&/g, '&'); + text = text.replace(//g, '>'); + return text; +} + +export default JUnitReporter; diff --git a/tests/folio/src/reporters/line.ts b/tests/folio/src/reporters/line.ts new file mode 100644 index 0000000000..3e9f400b18 --- /dev/null +++ b/tests/folio/src/reporters/line.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import colors from 'colors/safe'; +import * as path from 'path'; +import { BaseReporter, formatFailure } from './base'; +import { FullConfig, Test, Suite, TestResult } from '../types'; + +class LineReporter extends BaseReporter { + private _total: number; + private _current = 0; + private _failures = 0; + private _lastTest: Test; + + onBegin(config: FullConfig, suite: Suite) { + super.onBegin(config, suite); + this._total = suite.totalTestCount(); + console.log(); + } + + onStdOut(chunk: string | Buffer, test?: Test) { + this._dumpToStdio(test, chunk, process.stdout); + } + + onStdErr(chunk: string | Buffer, test?: Test) { + this._dumpToStdio(test, chunk, process.stderr); + } + + private _fullTitle(test: Test) { + const baseName = path.basename(test.spec.file); + const runListName = test.alias ? `[${test.alias}] ` : ''; + return `${baseName} - ${runListName}${test.spec.fullTitle()}`; + } + + private _dumpToStdio(test: Test | undefined, chunk: string | Buffer, stream: NodeJS.WriteStream) { + if (this.config.quiet) + return; + stream.write(`\u001B[1A\u001B[2K`); + if (test && this._lastTest !== test) { + // Write new header for the output. + stream.write(colors.gray(this._fullTitle(test) + `\n`)); + this._lastTest = test; + } + + stream.write(chunk); + console.log(); + } + + onTestEnd(test: Test, result: TestResult) { + super.onTestEnd(test, result); + const width = process.stdout.columns - 1; + const title = `[${++this._current}/${this._total}] ${this._fullTitle(test)}`.substring(0, width); + process.stdout.write(`\u001B[1A\u001B[2K${title}\n`); + if (!this.willRetry(test, result) && !test.ok()) { + process.stdout.write(`\u001B[1A\u001B[2K`); + console.log(formatFailure(this.config, test, ++this._failures)); + console.log(); + } + } + + onEnd() { + process.stdout.write(`\u001B[1A\u001B[2K`); + super.onEnd(); + this.epilogue(false); + } +} + +export default LineReporter; diff --git a/tests/folio/src/reporters/list.ts b/tests/folio/src/reporters/list.ts new file mode 100644 index 0000000000..15e7340c3f --- /dev/null +++ b/tests/folio/src/reporters/list.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import colors from 'colors/safe'; +import milliseconds from 'ms'; +import { BaseReporter } from './base'; +import { FullConfig, Suite, Test, TestResult } from '../types'; + +class ListReporter extends BaseReporter { + private _failure = 0; + private _lastRow = 0; + private _testRows = new Map(); + + onBegin(config: FullConfig, suite: Suite) { + super.onBegin(config, suite); + console.log(); + } + + onTestBegin(test: Test) { + super.onTestBegin(test); + if (process.stdout.isTTY) + process.stdout.write(' ' + colors.gray(test.spec.fullTitle() + ': ') + '\n'); + this._testRows.set(test, this._lastRow++); + } + + onTestEnd(test: Test, result: TestResult) { + super.onTestEnd(test, result); + const spec = test.spec; + + const duration = colors.dim(` (${milliseconds(result.duration)})`); + let text = ''; + if (result.status === 'skipped') { + text = colors.green(' - ') + colors.cyan(spec.fullTitle()); + } else { + const statusMark = result.status === 'passed' ? ' ✓ ' : ' x '; + if (result.status === test.expectedStatus) + text = '\u001b[2K\u001b[0G' + colors.green(statusMark) + colors.gray(spec.fullTitle()) + duration; + else + text = '\u001b[2K\u001b[0G' + colors.red(`${statusMark}${++this._failure}) ` + spec.fullTitle()) + duration; + } + + const testRow = this._testRows.get(test); + // Go up if needed + if (process.stdout.isTTY && testRow !== this._lastRow) + process.stdout.write(`\u001B[${this._lastRow - testRow}A`); + // Erase line + if (process.stdout.isTTY) + process.stdout.write('\u001B[2K'); + process.stdout.write(text); + // Go down if needed. + if (testRow !== this._lastRow) { + if (process.stdout.isTTY) + process.stdout.write(`\u001B[${this._lastRow - testRow}E`); + else + process.stdout.write('\n'); + } + } + + onEnd() { + super.onEnd(); + process.stdout.write('\n'); + this.epilogue(true); + } +} + +export default ListReporter; diff --git a/tests/folio/src/reporters/multiplexer.ts b/tests/folio/src/reporters/multiplexer.ts new file mode 100644 index 0000000000..a7b0d50ac0 --- /dev/null +++ b/tests/folio/src/reporters/multiplexer.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FullConfig, Suite, Test, TestError, TestResult, Reporter } from '../types'; + +export class Multiplexer implements Reporter { + private _reporters: Reporter[]; + + constructor(reporters: Reporter[]) { + this._reporters = reporters; + } + + onBegin(config: FullConfig, suite: Suite) { + for (const reporter of this._reporters) + reporter.onBegin(config, suite); + } + + onTestBegin(test: Test) { + for (const reporter of this._reporters) + reporter.onTestBegin(test); + } + + onStdOut(chunk: string | Buffer, test?: Test) { + for (const reporter of this._reporters) + reporter.onStdOut(chunk, test); + } + + onStdErr(chunk: string | Buffer, test?: Test) { + for (const reporter of this._reporters) + reporter.onStdErr(chunk, test); + } + + onTestEnd(test: Test, result: TestResult) { + for (const reporter of this._reporters) + reporter.onTestEnd(test, result); + } + + onTimeout(timeout: number) { + for (const reporter of this._reporters) + reporter.onTimeout(timeout); + } + + onEnd() { + for (const reporter of this._reporters) + reporter.onEnd(); + } + + onError(error: TestError) { + for (const reporter of this._reporters) + reporter.onError(error); + } +} diff --git a/tests/folio/src/runner.ts b/tests/folio/src/runner.ts new file mode 100644 index 0000000000..9a5d041b28 --- /dev/null +++ b/tests/folio/src/runner.ts @@ -0,0 +1,132 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import rimraf from 'rimraf'; +import { promisify } from 'util'; +import { Dispatcher } from './dispatcher'; +import { Reporter } from './types'; +import { createMatcher, monotonicTime, raceAgainstDeadline } from './util'; +import { Suite } from './test'; +import { Loader } from './loader'; + +const removeFolderAsync = promisify(rimraf); + +type RunResult = 'passed' | 'failed' | 'sigint' | 'forbid-only' | 'no-tests'; + +export class Runner { + private _reporter: Reporter; + private _loader: Loader; + private _rootSuite: Suite; + + constructor(loader: Loader, reporter: Reporter, runListFilter?: string[]) { + this._reporter = reporter; + this._loader = loader; + + // This makes sure we don't generate 1000000 tests if only one spec is focused. + const filtered = new Set(); + for (const { fileSuites } of loader.runLists()) { + for (const fileSuite of fileSuites.values()) { + if (fileSuite._hasOnly()) + filtered.add(fileSuite); + } + } + + this._rootSuite = new Suite(''); + const grepMatcher = createMatcher(loader.config().grep); + + const nonEmptySuites = new Set(); + for (let runListIndex = 0; runListIndex < loader.runLists().length; runListIndex++) { + const runList = loader.runLists()[runListIndex]; + if (runListFilter && !runListFilter.includes(runList.alias)) + continue; + for (const fileSuite of runList.fileSuites.values()) { + if (filtered.size && !filtered.has(fileSuite)) + continue; + const specs = fileSuite._allSpecs().filter(spec => grepMatcher(spec.fullTitle())); + if (!specs.length) + continue; + fileSuite._renumber(); + for (const spec of specs) { + for (let i = 0; i < loader.config().repeatEach; ++i) + spec._appendTest(runListIndex, runList.alias, i); + } + nonEmptySuites.add(fileSuite); + } + } + for (const fileSuite of nonEmptySuites) + this._rootSuite._addSuite(fileSuite); + + filterOnly(this._rootSuite); + } + + list() { + this._reporter.onBegin(this._loader.config(), this._rootSuite); + this._reporter.onEnd(); + } + + async run(): Promise { + await removeFolderAsync(this._loader.config().outputDir).catch(e => {}); + + if (this._loader.config().forbidOnly) { + const hasOnly = this._rootSuite.findSpec(t => t._only) || this._rootSuite.findSuite(s => s._only); + if (hasOnly) + return 'forbid-only'; + } + + const total = this._rootSuite.totalTestCount(); + if (!total) + return 'no-tests'; + const globalDeadline = this._loader.config().globalTimeout ? this._loader.config().globalTimeout + monotonicTime() : 0; + const { result, timedOut } = await raceAgainstDeadline(this._runTests(this._rootSuite), globalDeadline); + if (timedOut) { + this._reporter.onTimeout(this._loader.config().globalTimeout); + process.exit(1); + } + return result; + } + + private async _runTests(suite: Suite): Promise { + const dispatcher = new Dispatcher(this._loader, suite, this._reporter); + let sigint = false; + let sigintCallback: () => void; + const sigIntPromise = new Promise(f => sigintCallback = f); + const sigintHandler = () => { + process.off('SIGINT', sigintHandler); + sigint = true; + sigintCallback(); + }; + process.on('SIGINT', sigintHandler); + this._reporter.onBegin(this._loader.config(), suite); + await Promise.race([dispatcher.run(), sigIntPromise]); + await dispatcher.stop(); + this._reporter.onEnd(); + if (sigint) + return 'sigint'; + return dispatcher.hasWorkerErrors() || suite.findSpec(spec => !spec.ok()) ? 'failed' : 'passed'; + } +} + +function filterOnly(suite: Suite) { + const onlySuites = suite.suites.filter(child => filterOnly(child) || child._only); + const onlyTests = suite.specs.filter(spec => spec._only); + if (onlySuites.length || onlyTests.length) { + suite.suites = onlySuites; + suite.specs = onlyTests; + return true; + } + return false; +} diff --git a/tests/folio/src/spec.ts b/tests/folio/src/spec.ts new file mode 100644 index 0000000000..98b2721e35 --- /dev/null +++ b/tests/folio/src/spec.ts @@ -0,0 +1,218 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from './expect'; +import { currentTestInfo } from './globals'; +import { Spec, Suite } from './test'; +import { callLocation, errorWithCallLocation, interpretCondition } from './util'; +import { Config, Env, RunWithConfig, TestInfo, TestType, WorkerInfo } from './types'; + +Error.stackTraceLimit = 15; + +let currentFile: string | undefined; +export function setCurrentFile(file?: string) { + currentFile = file; +} + +export type RunListDescription = { + alias: string; + fileSuites: Map; + env: Env; + config: RunWithConfig; + testType: TestType; +}; + +export const configFile: { + config?: Config, + globalSetup?: () => any, + globalTeardown?: (globalSetupResult: any) => any, + runLists: RunListDescription[] +} = { runLists: [] }; + +function mergeEnvs(envs: any[]): any { + if (envs.length === 1) + return envs[0]; + const forward = [...envs]; + const backward = [...forward].reverse(); + return { + beforeAll: async (workerInfo: WorkerInfo) => { + for (const env of forward) { + if (env.beforeAll) + await env.beforeAll(workerInfo); + } + }, + afterAll: async (workerInfo: WorkerInfo) => { + for (const env of backward) { + if (env.afterAll) + await env.afterAll(workerInfo); + } + }, + beforeEach: async (testInfo: TestInfo) => { + let result = undefined; + for (const env of forward) { + if (env.beforeEach) { + const r = await env.beforeEach(testInfo); + result = result === undefined ? r : { ...result, ...r }; + } + } + return result; + }, + afterEach: async (testInfo: TestInfo) => { + for (const env of backward) { + if (env.afterEach) + await env.afterEach(testInfo); + } + }, + }; +} + +export function newTestTypeImpl(): any { + const fileSuites = new Map(); + let suites: Suite[] = []; + + function ensureSuiteForCurrentLocation() { + const location = callLocation(currentFile); + let fileSuite = fileSuites.get(location.file); + if (!fileSuite) { + fileSuite = new Suite(''); + fileSuite.file = location.file; + fileSuites.set(location.file, fileSuite); + } + if (suites[suites.length - 1] !== fileSuite) + suites = [fileSuite]; + return location; + } + + function spec(type: 'default' | 'only', title: string, options: Function | any, fn?: Function) { + if (!currentFile) + throw errorWithCallLocation(`Test can only be defined in a test file.`); + const location = ensureSuiteForCurrentLocation(); + + if (typeof fn !== 'function') { + fn = options; + options = {}; + } + const spec = new Spec(title, fn, suites[0]); + spec.file = location.file; + spec.line = location.line; + spec.column = location.column; + spec.testOptions = options; + + if (type === 'only') + spec._only = true; + } + + function describe(type: 'default' | 'only', title: string, fn: Function) { + if (!currentFile) + throw errorWithCallLocation(`Suite can only be defined in a test file.`); + const location = ensureSuiteForCurrentLocation(); + + const child = new Suite(title, suites[0]); + child.file = location.file; + child.line = location.line; + child.column = location.column; + + if (type === 'only') + child._only = true; + + suites.unshift(child); + fn(); + suites.shift(); + } + + function hook(name: string, fn: Function) { + if (!currentFile) + throw errorWithCallLocation(`Hook can only be defined in a test file.`); + suites[0]._addHook(name, fn); + } + + const modifier = (type: 'skip' | 'fail' | 'fixme', arg?: boolean | string, description?: string) => { + const processed = interpretCondition(arg, description); + if (!processed.condition) + return; + + if (currentFile) { + suites[0]._annotations.push({ type, description: processed.description }); + return; + } + + const testInfo = currentTestInfo(); + if (!testInfo) + throw new Error(`test.${type} can only be called inside the test`); + + testInfo.annotations.push({ type, description: processed.description }); + if (type === 'skip' || type === 'fixme') { + testInfo.expectedStatus = 'skipped'; + throw new SkipError(processed.description); + } else if (type === 'fail') { + if (testInfo.expectedStatus !== 'skipped') + testInfo.expectedStatus = 'failed'; + } + }; + + const test: any = spec.bind(null, 'default'); + test.expect = expect; + test.only = spec.bind(null, 'only'); + test.describe = describe.bind(null, 'default'); + test.describe.only = describe.bind(null, 'only'); + test.beforeEach = hook.bind(null, 'beforeEach'); + test.afterEach = hook.bind(null, 'afterEach'); + test.beforeAll = hook.bind(null, 'beforeAll'); + test.afterAll = hook.bind(null, 'afterAll'); + test.skip = modifier.bind(null, 'skip'); + test.fixme = modifier.bind(null, 'fixme'); + test.fail = modifier.bind(null, 'fail'); + test.runWith = (...envs: any[]) => { + let alias = ''; + if (typeof envs[0] === 'string') { + alias = envs[0]; + envs = envs.slice(1); + } + let options = envs[envs.length - 1]; + if (!envs.length || options.beforeAll || options.beforeEach || options.afterAll || options.afterEach) + options = {}; + else + envs = envs.slice(0, envs.length - 1); + configFile.runLists.push({ + fileSuites, + env: mergeEnvs(envs), + alias, + config: { timeout: options.timeout }, + testType: test, + }); + }; + return test; +} + +export class SkipError extends Error { +} + +export function setConfig(config: Config) { + // TODO: add config validation. + configFile.config = config; +} + +export function globalSetup(globalSetupFunction: () => any) { + if (typeof globalSetupFunction !== 'function') + throw errorWithCallLocation(`globalSetup takes a single function argument.`); + configFile.globalSetup = globalSetupFunction; +} + +export function globalTeardown(globalTeardownFunction: (globalSetupResult: any) => any) { + if (typeof globalTeardownFunction !== 'function') + throw errorWithCallLocation(`globalTeardown takes a single function argument.`); + configFile.globalTeardown = globalTeardownFunction; +} diff --git a/tests/folio/src/test.ts b/tests/folio/src/test.ts new file mode 100644 index 0000000000..f208ec251b --- /dev/null +++ b/tests/folio/src/test.ts @@ -0,0 +1,237 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as types from './types'; + +class Base { + title: string; + file: string; + line: number; + column: number; + parent?: Suite; + + _only = false; + _ordinal: number; + + constructor(title: string, parent?: Suite) { + this.title = title; + this.parent = parent; + } + + titlePath(): string[] { + if (!this.parent) + return []; + if (!this.title) + return this.parent.titlePath(); + return [...this.parent.titlePath(), this.title]; + } + + fullTitle(): string { + return this.titlePath().join(' '); + } +} + +export class Spec extends Base implements types.Spec { + fn: Function; + tests: Test[] = []; + testOptions: any = {}; + + constructor(title: string, fn: Function, suite: Suite) { + super(title, suite); + this.fn = fn; + suite._addSpec(this); + } + + ok(): boolean { + return !this.tests.find(r => !r.ok()); + } + + _appendTest(runListIndex: number, runListAlias: string, repeatEachIndex: number) { + const test = new Test(this); + test.alias = runListAlias; + test._runListIndex = runListIndex; + test._workerHash = `${runListIndex}#repeat-${repeatEachIndex}`; + test._id = `${this._ordinal}@${this.file}::[${test._workerHash}]`; + test._repeatEachIndex = repeatEachIndex; + this.tests.push(test); + return test; + } +} + +export class Suite extends Base implements types.Suite { + suites: Suite[] = []; + specs: Spec[] = []; + _entries: (Suite | Spec)[] = []; + _hooks: { type: string, fn: Function } [] = []; + _annotations: { type: 'skip' | 'fixme' | 'fail', description?: string }[] = []; + + constructor(title: string, parent?: Suite) { + super(title, parent); + if (parent) + parent._addSuite(this); + } + + _clear() { + this.suites = []; + this.specs = []; + this._entries = []; + this._hooks = []; + this._annotations = []; + } + + _addSpec(spec: Spec) { + spec.parent = this; + this.specs.push(spec); + this._entries.push(spec); + } + + _addSuite(suite: Suite) { + suite.parent = this; + this.suites.push(suite); + this._entries.push(suite); + } + + findTest(fn: (test: Test) => boolean | void): boolean { + for (const suite of this.suites) { + if (suite.findTest(fn)) + return true; + } + for (const spec of this.specs) { + for (const test of spec.tests) { + if (fn(test)) + return true; + } + } + return false; + } + + findSpec(fn: (spec: Spec) => boolean | void): boolean { + for (const suite of this.suites) { + if (suite.findSpec(fn)) + return true; + } + for (const spec of this.specs) { + if (fn(spec)) + return true; + } + return false; + } + + findSuite(fn: (suite: Suite) => boolean | void): boolean { + if (fn(this)) + return true; + for (const suite of this.suites) { + if (suite.findSuite(fn)) + return true; + } + return false; + } + + totalTestCount(): number { + let total = 0; + for (const suite of this.suites) + total += suite.totalTestCount(); + for (const spec of this.specs) + total += spec.tests.length; + return total; + } + + _allSpecs(): Spec[] { + const result: Spec[] = []; + this.findSpec(test => { result.push(test); }); + return result; + } + + _renumber() { + // All tests are identified with their ordinals. + let ordinal = 0; + this.findSpec((test: Spec) => { + test._ordinal = ordinal++; + }); + } + + _hasOnly(): boolean { + if (this._only) + return true; + if (this.suites.find(suite => suite._hasOnly())) + return true; + if (this.specs.find(spec => spec._only)) + return true; + } + + _addHook(type: string, fn: any) { + this._hooks.push({ type, fn }); + } +} + +export class Test implements types.Test { + spec: Spec; + results: types.TestResult[] = []; + + skipped = false; + expectedStatus: types.TestStatus = 'passed'; + timeout = 0; + annotations: any[] = []; + alias = ''; + + _id: string; + _workerHash: string; + _repeatEachIndex: number; + _runListIndex: number; + + constructor(spec: Spec) { + this.spec = spec; + } + + status(): 'skipped' | 'expected' | 'unexpected' | 'flaky' { + if (this.skipped) + return 'skipped'; + // List mode bail out. + if (!this.results.length) + return 'skipped'; + if (this.results.length === 1 && this.expectedStatus === this.results[0].status) + return 'expected'; + let hasPassedResults = false; + for (const result of this.results) { + // Missing status is Ok when running in shards mode. + if (!result.status) + return 'skipped'; + if (result.status === this.expectedStatus) + hasPassedResults = true; + } + if (hasPassedResults) + return 'flaky'; + return 'unexpected'; + } + + ok(): boolean { + const status = this.status(); + return status === 'expected' || status === 'flaky' || status === 'skipped'; + } + + _appendTestResult(): types.TestResult { + const result: types.TestResult = { + retry: this.results.length, + workerIndex: 0, + duration: 0, + stdout: [], + stderr: [], + data: {} + }; + this.results.push(result); + return result; + } +} diff --git a/tests/folio/src/transform.ts b/tests/folio/src/transform.ts new file mode 100644 index 0000000000..acdac1e571 --- /dev/null +++ b/tests/folio/src/transform.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as crypto from 'crypto'; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as pirates from 'pirates'; +import * as babel from '@babel/core'; +import * as sourceMapSupport from 'source-map-support'; + +const version = 3; +const cacheDir = path.join(os.tmpdir(), 'playwright-transform-cache'); +const sourceMaps: Map = new Map(); + +sourceMapSupport.install({ + environment: 'node', + handleUncaughtExceptions: false, + retrieveSourceMap(source) { + if (!sourceMaps.has(source)) + return null; + const sourceMapPath = sourceMaps.get(source); + if (!fs.existsSync(sourceMapPath)) + return null; + return { + map: JSON.parse(fs.readFileSync(sourceMapPath, 'utf-8')), + url: source + }; + } +}); + +function calculateCachePath(content: string, filePath: string): string { + const hash = crypto.createHash('sha1').update(content).update(filePath).update(String(version)).digest('hex'); + const fileName = path.basename(filePath, path.extname(filePath)).replace(/\W/g, '') + '_' + hash; + return path.join(cacheDir, hash[0] + hash[1], fileName); +} + +export function installTransform(): () => void { + return pirates.addHook((code, filename) => { + const cachePath = calculateCachePath(code, filename); + const codePath = cachePath + '.js'; + const sourceMapPath = cachePath + '.map'; + sourceMaps.set(filename, sourceMapPath); + if (fs.existsSync(codePath)) + return fs.readFileSync(codePath, 'utf8'); + + const result = babel.transformFileSync(filename, { + babelrc: false, + configFile: false, + presets: [ + ['@babel/preset-env', { targets: {node: '10.17.0'} }], + ['@babel/preset-typescript', { onlyRemoveTypeImports: true }], + ], + plugins: [['@babel/plugin-proposal-class-properties', {loose: true}]], + sourceMaps: 'both', + }); + if (result.code) { + fs.mkdirSync(path.dirname(cachePath), {recursive: true}); + if (result.map) + fs.writeFileSync(sourceMapPath, JSON.stringify(result.map), 'utf8'); + fs.writeFileSync(codePath, result.code, 'utf8'); + } + return result.code; + }, { + exts: ['.ts'] + }); +} diff --git a/tests/folio/src/types.ts b/tests/folio/src/types.ts new file mode 100644 index 0000000000..140fa0c832 --- /dev/null +++ b/tests/folio/src/types.ts @@ -0,0 +1,194 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Expect } from './expectType'; + +export interface RunWithConfig { + timeout?: number; + // TODO: move retries, outputDir, repeatEach, snapshotDir, testPathSegment here from Config. +} + +export interface Config extends RunWithConfig { + forbidOnly?: boolean; + globalTimeout?: number; + grep?: string | RegExp | (string | RegExp)[]; + maxFailures?: number; + outputDir?: string; + quiet?: boolean; + repeatEach?: number; + retries?: number; + shard?: { total: number, current: number } | null; + snapshotDir?: string; + testDir?: string; + testIgnore?: string | RegExp | (string | RegExp)[]; + testMatch?: string | RegExp | (string | RegExp)[]; + updateSnapshots?: boolean; + workers?: number; +} +export type FullConfig = Required; + +export type TestStatus = 'passed' | 'failed' | 'timedOut' | 'skipped'; + +export interface WorkerInfo { + config: FullConfig; + workerIndex: number; + globalSetupResult: any; +} + +export interface TestInfo extends WorkerInfo { + // Declaration + title: string; + file: string; + line: number; + column: number; + fn: Function; + + // Modifiers + expectedStatus: TestStatus; + timeout: number; + annotations: any[]; + testOptions: any; // TODO: make testOptions typed. + repeatEachIndex: number; + retry: number; + + // Results + duration: number; + status?: TestStatus; + error?: any; + stdout: (string | Buffer)[]; + stderr: (string | Buffer)[]; + data: any; + + // Paths + snapshotPathSegment: string; + snapshotPath: (...pathSegments: string[]) => string; + outputPath: (...pathSegments: string[]) => string; +} + +interface SuiteFunction { + (name: string, inner: () => void): void; +} + +interface TestFunction { + (name: string, inner: (args: TestArgs, testInfo: TestInfo) => Promise | void): void; + (name: string, options: TestOptions, fn: (args: TestArgs, testInfo: TestInfo) => any): void; +} + +export interface TestType extends TestFunction { + only: TestFunction; + describe: SuiteFunction & { + only: SuiteFunction; + }; + + beforeEach: (inner: (args: TestArgs, testInfo: TestInfo) => Promise | void) => void; + afterEach: (inner: (args: TestArgs, testInfo: TestInfo) => Promise | void) => void; + beforeAll: (inner: (workerInfo: WorkerInfo) => Promise | void) => void; + afterAll: (inner: (workerInfo: WorkerInfo) => Promise | void) => void; + + expect: Expect; + + skip(): void; + skip(condition: boolean): void; + skip(description: string): void; + skip(condition: boolean, description: string): void; + + fixme(): void; + fixme(condition: boolean): void; + fixme(description: string): void; + fixme(condition: boolean, description: string): void; + + fail(): void; + fail(condition: boolean): void; + fail(description: string): void; + fail(condition: boolean, description: string): void; + + runWith(config?: RunWithConfig): void; + runWith(alias: string, config?: RunWithConfig): void; + runWith(env: Env, config?: RunWithConfig): void; + runWith(alias: string, env: Env, config?: RunWithConfig): void; + runWith(env1: Env, env2: Env, config?: RunWithConfig): RunWithOrNever; + runWith(alias: string, env1: Env, env2: Env, config?: RunWithConfig): RunWithOrNever; + runWith(env1: Env, env2: Env, env3: Env, config?: RunWithConfig): RunWithOrNever; + runWith(alias: string, env1: Env, env2: Env, env3: Env, config?: RunWithConfig): RunWithOrNever; +} + +export interface Env { + beforeAll?(workerInfo: WorkerInfo): Promise; + beforeEach?(testInfo: TestInfo): Promise; + afterEach?(testInfo: TestInfo): Promise; + afterAll?(workerInfo: WorkerInfo): Promise; +} + +type RunWithOrNever = CombinedTestArgs extends ExpectedTestArgs ? void : never; + +// ---------- Reporters API ----------- + +export interface Suite { + title: string; + file: string; + line: number; + column: number; + suites: Suite[]; + specs: Spec[]; + findTest(fn: (test: Test) => boolean | void): boolean; + findSpec(fn: (spec: Spec) => boolean | void): boolean; + totalTestCount(): number; +} +export interface Spec { + title: string; + file: string; + line: number; + column: number; + tests: Test[]; + fullTitle(): string; + ok(): boolean; +} +export interface Test { + spec: Spec; + results: TestResult[]; + skipped: boolean; + expectedStatus: TestStatus; + timeout: number; + annotations: any[]; + alias: string; + status(): 'skipped' | 'expected' | 'unexpected' | 'flaky'; + ok(): boolean; +} +export interface TestResult { + retry: number; + workerIndex: number, + duration: number; + status?: TestStatus; + error?: TestError; + stdout: (string | Buffer)[]; + stderr: (string | Buffer)[]; + data: any; +} +export interface TestError { + message?: string; + stack?: string; + value?: string; +} +export interface Reporter { + onBegin(config: FullConfig, suite: Suite): void; + onTestBegin(test: Test): void; + onStdOut(chunk: string | Buffer, test?: Test): void; + onStdErr(chunk: string | Buffer, test?: Test): void; + onTestEnd(test: Test, result: TestResult): void; + onTimeout(timeout: number): void; + onError(error: TestError): void; + onEnd(): void; +} diff --git a/tests/folio/src/util.ts b/tests/folio/src/util.ts new file mode 100644 index 0000000000..7e172e611c --- /dev/null +++ b/tests/folio/src/util.ts @@ -0,0 +1,139 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import path from 'path'; +import util from 'util'; +import StackUtils from 'stack-utils'; +import { TestError } from './types'; +import { default as minimatch } from 'minimatch'; + +const FOLIO_DIRS = [__dirname, path.join(__dirname, '..', 'src')]; +const cwd = process.cwd(); +const stackUtils = new StackUtils({ cwd }); + +export async function raceAgainstDeadline(promise: Promise, deadline: number): Promise<{ result?: T, timedOut?: boolean }> { + if (!deadline) + return { result: await promise }; + + const timeout = deadline - monotonicTime(); + if (timeout <= 0) + return { timedOut: true }; + + let timer: NodeJS.Timer; + let done = false; + let fulfill: (t: { result?: T, timedOut?: boolean }) => void; + let reject: (e: Error) => void; + const result = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + setTimeout(() => { + done = true; + fulfill({ timedOut: true }); + }, timeout); + promise.then(result => { + clearTimeout(timer); + if (!done) { + done = true; + fulfill({ result }); + } + }).catch(e => { + clearTimeout(timer); + if (!done) + reject(e); + }); + return result; +} + +export function serializeError(error: Error | any): TestError { + if (error instanceof Error) { + return { + message: error.message, + stack: error.stack + }; + } + return { + value: util.inspect(error) + }; +} + +function callFrames(): string[] { + const obj = { stack: '' }; + Error.captureStackTrace(obj); + const frames = obj.stack.split('\n').slice(1); + while (frames.length && FOLIO_DIRS.some(dir => frames[0].includes(dir))) + frames.shift(); + return frames; +} + +export function callLocation(fallbackFile: string): {file: string, line: number, column: number} { + const frames = callFrames(); + if (!frames.length) + return {file: fallbackFile, line: 1, column: 1}; + const location = stackUtils.parseLine(frames[0]); + return { + file: path.resolve(cwd, location.file), + line: location.line, + column: location.column, + }; +} + +export function errorWithCallLocation(message: string): Error { + const frames = callFrames(); + const error = new Error(message); + error.stack = 'Error: ' + message + '\n' + frames.join('\n'); + return error; +} + +export function monotonicTime(): number { + const [seconds, nanoseconds] = process.hrtime(); + return seconds * 1000 + (nanoseconds / 1000000 | 0); +} + +export function prependErrorMessage(e: Error, message: string) { + let stack = e.stack || ''; + if (stack.includes(e.message)) + stack = stack.substring(stack.indexOf(e.message) + e.message.length); + let m = e.message; + if (m.startsWith('Error:')) + m = m.substring('Error:'.length); + e.message = message + m; + e.stack = e.message + stack; +} + +export function interpretCondition(arg?: boolean | string, description?: string): { condition: boolean, description?: string } { + if (arg === undefined && description === undefined) + return { condition: true }; + if (typeof arg === 'string') + return { condition: true, description: arg }; + return { condition: !!arg, description }; +} + +export function createMatcher(patterns: string | RegExp | (string | RegExp)[]): (value: string) => boolean { + const list = Array.isArray(patterns) ? patterns : [patterns]; + return (value: string) => { + for (const pattern of list) { + if (pattern instanceof RegExp || Object.prototype.toString.call(pattern) === '[object RegExp]') { + if ((pattern as RegExp).test(value)) + return true; + } else { + if (minimatch(value, pattern)) + return true; + } + } + return false; + }; +} diff --git a/tests/folio/src/worker.ts b/tests/folio/src/worker.ts new file mode 100644 index 0000000000..9a8a1efbe6 --- /dev/null +++ b/tests/folio/src/worker.ts @@ -0,0 +1,119 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Console } from 'console'; +import * as util from 'util'; +import { RunPayload, TestOutputPayload, WorkerInitParams } from './ipc'; +import { serializeError } from './util'; +import { WorkerRunner } from './workerRunner'; + +let closed = false; + +sendMessageToParent('ready'); + +global.console = new Console({ + stdout: process.stdout, + stderr: process.stderr, + colorMode: process.env.FORCE_COLOR === '1', +}); + +process.stdout.write = chunk => { + const outPayload: TestOutputPayload = { + testId: workerRunner ? workerRunner._testId : undefined, + ...chunkToParams(chunk) + }; + sendMessageToParent('stdOut', outPayload); + return true; +}; + +if (!process.env.PW_RUNNER_DEBUG) { + process.stderr.write = chunk => { + const outPayload: TestOutputPayload = { + testId: workerRunner ? workerRunner._testId : undefined, + ...chunkToParams(chunk) + }; + sendMessageToParent('stdErr', outPayload); + return true; + }; +} + +process.on('disconnect', gracefullyCloseAndExit); +process.on('SIGINT',() => {}); +process.on('SIGTERM',() => {}); + +let workerRunner: WorkerRunner; + +process.on('unhandledRejection', (reason, promise) => { + if (workerRunner) + workerRunner.unhandledError(reason); +}); + +process.on('uncaughtException', error => { + if (workerRunner) + workerRunner.unhandledError(error); +}); + +process.on('message', async message => { + if (message.method === 'init') { + const initParams = message.params as WorkerInitParams; + workerRunner = new WorkerRunner(initParams); + for (const event of ['testBegin', 'testEnd', 'done']) + workerRunner.on(event, sendMessageToParent.bind(null, event)); + return; + } + if (message.method === 'stop') { + await gracefullyCloseAndExit(); + return; + } + if (message.method === 'run') { + const runPayload = message.params as RunPayload; + await workerRunner!.run(runPayload); + } +}); + +async function gracefullyCloseAndExit() { + if (closed) + return; + closed = true; + // Force exit after 30 seconds. + setTimeout(() => process.exit(0), 30000); + // Meanwhile, try to gracefully shutdown. + try { + if (workerRunner) { + workerRunner.stop(); + await workerRunner.cleanup(); + } + } catch (e) { + process.send({ method: 'teardownError', params: { error: serializeError(e) } }); + } + process.exit(0); +} + +function sendMessageToParent(method, params = {}) { + try { + process.send({ method, params }); + } catch (e) { + // Can throw when closing. + } +} + +function chunkToParams(chunk: Buffer | string): { text?: string, buffer?: string } { + if (chunk instanceof Buffer) + return { buffer: chunk.toString('base64') }; + if (typeof chunk !== 'string') + return { text: util.inspect(chunk) }; + return { text: chunk }; +} diff --git a/tests/folio/src/workerRunner.ts b/tests/folio/src/workerRunner.ts new file mode 100644 index 0000000000..3c9b8a6e53 --- /dev/null +++ b/tests/folio/src/workerRunner.ts @@ -0,0 +1,440 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import { EventEmitter } from 'events'; +import { monotonicTime, raceAgainstDeadline, serializeError } from './util'; +import { TestBeginPayload, TestEndPayload, RunPayload, TestEntry, DonePayload, WorkerInitParams } from './ipc'; +import { setCurrentTestInfo } from './globals'; +import { Loader } from './loader'; +import { Spec, Suite, Test } from './test'; +import { TestInfo, WorkerInfo } from './types'; +import { SkipError, RunListDescription } from './spec'; + +export class WorkerRunner extends EventEmitter { + private _params: WorkerInitParams; + private _loader: Loader; + private _runList: RunListDescription; + private _outputPathSegment: string; + private _workerInfo: WorkerInfo; + private _envInitialized = false; + + private _failedTestId: string | undefined; + private _fatalError: any | undefined; + private _entries: Map; + private _remaining: Map; + private _isStopped: any; + _testId: string | null; + private _testInfo: TestInfo | null = null; + private _file: string; + private _timeout: number; + + constructor(params: WorkerInitParams) { + super(); + this._params = params; + } + + stop() { + this._isStopped = true; + this._testId = null; + this._setCurrentTestInfo(null); + } + + async cleanup() { + if (!this._envInitialized) + return; + this._envInitialized = false; + if (this._runList.env.afterAll) { + // TODO: separate timeout for afterAll? + const result = await raceAgainstDeadline(this._runList.env.afterAll(this._workerInfo), this._deadline()); + if (result.timedOut) + throw new Error(`Timeout of ${this._timeout}ms exceeded while shutting down environment`); + } + } + + unhandledError(error: Error | any) { + if (this._isStopped) + return; + if (this._testInfo) { + this._testInfo.status = 'failed'; + this._testInfo.error = serializeError(error); + this._failedTestId = this._testId; + this.emit('testEnd', buildTestEndPayload(this._testId, this._testInfo)); + } else { + // No current test - fatal error. + this._fatalError = serializeError(error); + } + this._reportDoneAndStop(); + } + + private _deadline() { + return this._timeout ? monotonicTime() + this._timeout : 0; + } + + private async _loadIfNeeded() { + if (this._loader) + return; + + this._loader = new Loader(); + this._loader.deserialize(this._params.loader); + this._runList = this._loader.runLists()[this._params.runListIndex]; + const sameAliasAndTestType = this._loader.runLists().filter(runList => runList.alias === this._runList.alias && runList.testType === this._runList.testType); + if (sameAliasAndTestType.length > 1) + this._outputPathSegment = this._runList.alias + (sameAliasAndTestType.indexOf(this._runList) + 1); + else + this._outputPathSegment = this._runList.alias; + this._timeout = this._runList.config.timeout === undefined ? this._loader.config().timeout : this._runList.config.timeout; + this._workerInfo = { + workerIndex: this._params.workerIndex, + config: this._loader.config(), + globalSetupResult: this._params.globalSetupResult, + }; + + if (this._isStopped) + return; + + if (this._runList.env.beforeAll) { + // TODO: separate timeout for beforeAll? + const result = await raceAgainstDeadline(this._runList.env.beforeAll(this._workerInfo), this._deadline()); + if (result.timedOut) { + this._fatalError = serializeError(new Error(`Timeout of ${this._timeout}ms exceeded while initializing environment`)); + this._reportDoneAndStop(); + } + } + this._envInitialized = true; + } + + async run(runPayload: RunPayload) { + this._file = runPayload.file; + this._entries = new Map(runPayload.entries.map(e => [ e.testId, e ])); + this._remaining = new Map(runPayload.entries.map(e => [ e.testId, e ])); + + await this._loadIfNeeded(); + if (this._isStopped) + return; + + this._loader.loadTestFile(this._file); + const fileSuite = this._runList.fileSuites.get(this._file); + if (fileSuite) { + fileSuite._renumber(); + fileSuite.findSpec(spec => { + spec._appendTest(this._params.runListIndex, this._runList.alias, this._params.repeatEachIndex); + }); + await this._runSuite(fileSuite); + } + if (this._isStopped) + return; + this._reportDone(); + } + + private async _runSuite(suite: Suite) { + if (this._isStopped) + return; + const skipHooks = !this._hasTestsToRun(suite); + for (const hook of suite._hooks) { + if (hook.type !== 'beforeAll' || skipHooks) + continue; + if (this._isStopped) + return; + // TODO: separate timeout for beforeAll? + const result = await raceAgainstDeadline(hook.fn(this._workerInfo), this._deadline()); + if (result.timedOut) { + this._fatalError = serializeError(new Error(`Timeout of ${this._timeout}ms exceeded while running beforeAll hook`)); + this._reportDoneAndStop(); + } + } + for (const entry of suite._entries) { + if (entry instanceof Suite) + await this._runSuite(entry); + else + await this._runSpec(entry); + } + for (const hook of suite._hooks) { + if (hook.type !== 'afterAll' || skipHooks) + continue; + if (this._isStopped) + return; + // TODO: separate timeout for afterAll? + const result = await raceAgainstDeadline(hook.fn(this._workerInfo), this._deadline()); + if (result.timedOut) { + this._fatalError = serializeError(new Error(`Timeout of ${this._timeout}ms exceeded while running afterAll hook`)); + this._reportDoneAndStop(); + } + } + } + + private async _runSpec(spec: Spec) { + if (this._isStopped) + return; + const test = spec.tests[0]; + if (!this._entries.has(test._id)) + return; + const { retry } = this._entries.get(test._id); + // TODO: support some of test.slow(), test.setTimeout(), describe.slow() and describe.setTimeout() + const deadline = this._deadline(); + this._remaining.delete(test._id); + + const testId = test._id; + this._testId = testId; + + const config = this._workerInfo.config; + + const relativePath = path.relative(config.testDir, spec.file.replace(/\.(spec|test)\.(js|ts)/, '')); + const sanitizedTitle = spec.title.replace(/[^\w\d]+/g, '-'); + const relativeTestPath = path.join(relativePath, sanitizedTitle); + + const testInfo: TestInfo = { + ...this._workerInfo, + title: spec.title, + file: spec.file, + line: spec.line, + column: spec.column, + fn: spec.fn, + repeatEachIndex: this._params.repeatEachIndex, + retry, + expectedStatus: 'passed', + annotations: [], + duration: 0, + status: 'passed', + stdout: [], + stderr: [], + timeout: this._timeout, + data: {}, + snapshotPathSegment: '', + outputPath: (...pathSegments: string[]): string => { + let suffix = this._outputPathSegment; + if (testInfo.retry) + suffix += (suffix ? '-' : '') + 'retry' + testInfo.retry; + if (testInfo.repeatEachIndex) + suffix += (suffix ? '-' : '') + 'repeat' + testInfo.repeatEachIndex; + const basePath = path.join(config.outputDir, relativeTestPath, suffix); + fs.mkdirSync(basePath, { recursive: true }); + return path.join(basePath, ...pathSegments); + }, + snapshotPath: (...pathSegments: string[]): string => { + const basePath = path.join(config.testDir, config.snapshotDir, relativeTestPath, testInfo.snapshotPathSegment); + return path.join(basePath, ...pathSegments); + }, + testOptions: spec.testOptions, + }; + this._setCurrentTestInfo(testInfo); + + // Preprocess suite annotations. + for (let parent = spec.parent; parent; parent = parent.parent) + testInfo.annotations.push(...parent._annotations); + if (testInfo.annotations.some(a => a.type === 'skip' || a.type === 'fixme')) + testInfo.expectedStatus = 'skipped'; + else if (testInfo.annotations.some(a => a.type === 'fail')) + testInfo.expectedStatus = 'failed'; + + this.emit('testBegin', buildTestBeginPayload(testId, testInfo)); + + if (testInfo.expectedStatus === 'skipped') { + testInfo.status = 'skipped'; + this.emit('testEnd', buildTestEndPayload(testId, testInfo)); + return; + } + + const startTime = monotonicTime(); + + const testArgsResult = await raceAgainstDeadline(this._runEnvBeforeEach(testInfo), deadline); + if (testArgsResult.timedOut && testInfo.status === 'passed') + testInfo.status = 'timedOut'; + if (this._isStopped) + return; + + const testArgs = testArgsResult.result; + // Do not run test/teardown if we failed to initialize. + if (testArgs !== undefined) { + const result = await raceAgainstDeadline(this._runTestWithBeforeHooks(test, testInfo, testArgs), deadline); + // Do not overwrite test failure upon hook timeout. + if (result.timedOut && testInfo.status === 'passed') + testInfo.status = 'timedOut'; + if (this._isStopped) + return; + + if (!result.timedOut) { + const hooksResult = await raceAgainstDeadline(this._runAfterHooks(test, testInfo, testArgs), deadline); + // Do not overwrite test failure upon hook timeout. + if (hooksResult.timedOut && testInfo.status === 'passed') + testInfo.status = 'timedOut'; + } else { + // A timed-out test gets a full additional timeout to run after hooks. + const newDeadline = this._deadline(); + await raceAgainstDeadline(this._runAfterHooks(test, testInfo, testArgs), newDeadline); + } + } + if (this._isStopped) + return; + + testInfo.duration = monotonicTime() - startTime; + this.emit('testEnd', buildTestEndPayload(testId, testInfo)); + if (testInfo.status !== 'passed') { + this._failedTestId = this._testId; + this._reportDoneAndStop(); + } + this._setCurrentTestInfo(null); + this._testId = null; + } + + private _setCurrentTestInfo(testInfo: TestInfo | null) { + this._testInfo = testInfo; + setCurrentTestInfo(testInfo); + } + + // Returns TestArgs or undefined when env.beforeEach has failed. + private async _runEnvBeforeEach(testInfo: TestInfo): Promise { + try { + let testArgs: any = {}; + if (this._runList.env.beforeEach) + testArgs = await this._runList.env.beforeEach(testInfo); + if (testArgs === undefined) + testArgs = {}; + return testArgs; + } catch (error) { + testInfo.status = 'failed'; + testInfo.error = serializeError(error); + // Failed to initialize environment - no need to run any hooks now. + return undefined; + } + } + + private async _runTestWithBeforeHooks(test: Test, testInfo: TestInfo, testArgs: any) { + try { + await this._runHooks(test.spec.parent, 'beforeEach', testArgs, testInfo); + } catch (error) { + if (error instanceof SkipError) { + testInfo.status = 'skipped'; + } else { + testInfo.status = 'failed'; + testInfo.error = serializeError(error); + } + // Continue running afterEach hooks even after the failure. + } + + // Do not run the test when beforeEach hook fails. + if (this._isStopped || testInfo.status === 'failed' || testInfo.status === 'skipped') + return; + + try { + await test.spec.fn(testArgs, testInfo); + testInfo.status = 'passed'; + } catch (error) { + if (error instanceof SkipError) { + testInfo.status = 'skipped'; + } else { + testInfo.status = 'failed'; + testInfo.error = serializeError(error); + } + } + } + + private async _runAfterHooks(test: Test, testInfo: TestInfo, testArgs: any) { + try { + await this._runHooks(test.spec.parent, 'afterEach', testArgs, testInfo); + } catch (error) { + // Do not overwrite test failure error. + if (!(error instanceof SkipError) && testInfo.status === 'passed') { + testInfo.status = 'failed'; + testInfo.error = serializeError(error); + // Continue running even after the failure. + } + } + try { + if (this._runList.env.afterEach) + await this._runList.env.afterEach(testInfo); + } catch (error) { + // Do not overwrite test failure error. + if (testInfo.status === 'passed') { + testInfo.status = 'failed'; + testInfo.error = serializeError(error); + } + } + } + + private async _runHooks(suite: Suite, type: 'beforeEach' | 'afterEach', testArgs: any, testInfo: TestInfo) { + if (this._isStopped) + return; + const all = []; + for (let s = suite; s; s = s.parent) { + const funcs = s._hooks.filter(e => e.type === type).map(e => e.fn); + all.push(...funcs.reverse()); + } + if (type === 'beforeEach') + all.reverse(); + let error: Error | undefined; + for (const hook of all) { + try { + await hook(testArgs, testInfo); + } catch (e) { + // Always run all the hooks, and capture the first error. + error = error || e; + } + } + if (error) + throw error; + } + + private _reportDone() { + const donePayload: DonePayload = { + failedTestId: this._failedTestId, + fatalError: this._fatalError, + remaining: [...this._remaining.values()], + }; + this.emit('done', donePayload); + } + + private _reportDoneAndStop() { + if (this._isStopped) + return; + this._reportDone(); + this.stop(); + } + + private _hasTestsToRun(suite: Suite): boolean { + return suite.findSpec(spec => { + const entry = this._entries.get(spec.tests[0]._id); + if (!entry) + return; + for (let parent = spec.parent; parent; parent = parent.parent) { + if (parent._annotations.some(a => a.type === 'skip' || a.type === 'fixme')) + return; + } + return true; + }); + } +} + +function buildTestBeginPayload(testId: string, testInfo: TestInfo): TestBeginPayload { + return { + testId, + workerIndex: testInfo.workerIndex + }; +} + +function buildTestEndPayload(testId: string, testInfo: TestInfo): TestEndPayload { + return { + testId, + duration: testInfo.duration, + status: testInfo.status!, + error: testInfo.error, + data: testInfo.data, + expectedStatus: testInfo.expectedStatus, + annotations: testInfo.annotations, + timeout: testInfo.timeout, + }; +} diff --git a/tests/folio/third_party/diff_match_patch.js b/tests/folio/third_party/diff_match_patch.js new file mode 100644 index 0000000000..ba0df0f6ab --- /dev/null +++ b/tests/folio/third_party/diff_match_patch.js @@ -0,0 +1,2222 @@ +/** + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * @author fraser@google.com (Neil Fraser) + */ + +/** + * Class containing the diff, match and patch methods. + * @constructor + */ +var diff_match_patch = function() { + + // Defaults. + // Redefine these in your program to override the defaults. + + // Number of seconds to map a diff before giving up (0 for infinity). + this.Diff_Timeout = 1.0; + // Cost of an empty edit operation in terms of edit characters. + this.Diff_EditCost = 4; + // At what point is no match declared (0.0 = perfection, 1.0 = very loose). + this.Match_Threshold = 0.5; + // How far to search for a match (0 = exact location, 1000+ = broad match). + // A match this many characters away from the expected location will add + // 1.0 to the score (0.0 is a perfect match). + this.Match_Distance = 1000; + // When deleting a large block of text (over ~64 characters), how close do + // the contents have to be to match the expected contents. (0.0 = perfection, + // 1.0 = very loose). Note that Match_Threshold controls how closely the + // end points of a delete need to match. + this.Patch_DeleteThreshold = 0.5; + // Chunk size for context length. + this.Patch_Margin = 4; + + // The number of bits in an int. + this.Match_MaxBits = 32; +}; + + +// DIFF FUNCTIONS + + +/** + * The data structure representing a diff is an array of tuples: + * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] + * which means: delete 'Hello', add 'Goodbye' and keep ' world.' + */ +var DIFF_DELETE = -1; +var DIFF_INSERT = 1; +var DIFF_EQUAL = 0; + +/** + * Class representing one diff tuple. + * Attempts to look like a two-element array (which is what this used to be). + * @param {number} op Operation, one of: DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL. + * @param {string} text Text to be deleted, inserted, or retained. + * @constructor + */ +diff_match_patch.Diff = function(op, text) { + this[0] = op; + this[1] = text; +}; + +diff_match_patch.Diff.prototype.length = 2; + +/** + * Emulate the output of a two-element array. + * @return {string} Diff operation as a string. + */ +diff_match_patch.Diff.prototype.toString = function() { + return this[0] + ',' + this[1]; +}; + + +/** + * Find the differences between two texts. Simplifies the problem by stripping + * any common prefix or suffix off the texts before diffing. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {boolean=} opt_checklines Optional speedup flag. If present and false, + * then don't run a line-level diff first to identify the changed areas. + * Defaults to true, which does a faster, slightly less optimal diff. + * @param {number=} opt_deadline Optional time when the diff should be complete + * by. Used internally for recursive calls. Users should set DiffTimeout + * instead. + * @return {!Array.} Array of diff tuples. + */ +diff_match_patch.prototype.diff_main = function(text1, text2, opt_checklines, + opt_deadline) { + // Set a deadline by which time the diff must be complete. + if (typeof opt_deadline == 'undefined') { + if (this.Diff_Timeout <= 0) { + opt_deadline = Number.MAX_VALUE; + } else { + opt_deadline = (new Date).getTime() + this.Diff_Timeout * 1000; + } + } + var deadline = opt_deadline; + + // Check for null inputs. + if (text1 == null || text2 == null) { + throw new Error('Null input. (diff_main)'); + } + + // Check for equality (speedup). + if (text1 == text2) { + if (text1) { + return [new diff_match_patch.Diff(DIFF_EQUAL, text1)]; + } + return []; + } + + if (typeof opt_checklines == 'undefined') { + opt_checklines = true; + } + var checklines = opt_checklines; + + // Trim off common prefix (speedup). + var commonlength = this.diff_commonPrefix(text1, text2); + var commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = this.diff_commonSuffix(text1, text2); + var commonsuffix = text1.substring(text1.length - commonlength); + text1 = text1.substring(0, text1.length - commonlength); + text2 = text2.substring(0, text2.length - commonlength); + + // Compute the diff on the middle block. + var diffs = this.diff_compute_(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix) { + diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, commonprefix)); + } + if (commonsuffix) { + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, commonsuffix)); + } + this.diff_cleanupMerge(diffs); + return diffs; +}; + + +/** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {boolean} checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster, slightly less optimal diff. + * @param {number} deadline Time when the diff should be complete by. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_compute_ = function(text1, text2, checklines, + deadline) { + var diffs; + + if (!text1) { + // Just add some text (speedup). + return [new diff_match_patch.Diff(DIFF_INSERT, text2)]; + } + + if (!text2) { + // Just delete some text (speedup). + return [new diff_match_patch.Diff(DIFF_DELETE, text1)]; + } + + var longtext = text1.length > text2.length ? text1 : text2; + var shorttext = text1.length > text2.length ? text2 : text1; + var i = longtext.indexOf(shorttext); + if (i != -1) { + // Shorter text is inside the longer text (speedup). + diffs = [new diff_match_patch.Diff(DIFF_INSERT, longtext.substring(0, i)), + new diff_match_patch.Diff(DIFF_EQUAL, shorttext), + new diff_match_patch.Diff(DIFF_INSERT, + longtext.substring(i + shorttext.length))]; + // Swap insertions for deletions if diff is reversed. + if (text1.length > text2.length) { + diffs[0][0] = diffs[2][0] = DIFF_DELETE; + } + return diffs; + } + + if (shorttext.length == 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + return [new diff_match_patch.Diff(DIFF_DELETE, text1), + new diff_match_patch.Diff(DIFF_INSERT, text2)]; + } + + // Check to see if the problem can be split in two. + var hm = this.diff_halfMatch_(text1, text2); + if (hm) { + // A half-match was found, sort out the return data. + var text1_a = hm[0]; + var text1_b = hm[1]; + var text2_a = hm[2]; + var text2_b = hm[3]; + var mid_common = hm[4]; + // Send both pairs off for separate processing. + var diffs_a = this.diff_main(text1_a, text2_a, checklines, deadline); + var diffs_b = this.diff_main(text1_b, text2_b, checklines, deadline); + // Merge the results. + return diffs_a.concat([new diff_match_patch.Diff(DIFF_EQUAL, mid_common)], + diffs_b); + } + + if (checklines && text1.length > 100 && text2.length > 100) { + return this.diff_lineMode_(text1, text2, deadline); + } + + return this.diff_bisect_(text1, text2, deadline); +}; + + +/** + * Do a quick line-level diff on both strings, then rediff the parts for + * greater accuracy. + * This speedup can produce non-minimal diffs. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} deadline Time when the diff should be complete by. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_lineMode_ = function(text1, text2, deadline) { + // Scan the text on a line-by-line basis first. + var a = this.diff_linesToChars_(text1, text2); + text1 = a.chars1; + text2 = a.chars2; + var linearray = a.lineArray; + + var diffs = this.diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + this.diff_charsToLines_(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + this.diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); + var pointer = 0; + var count_delete = 0; + var count_insert = 0; + var text_delete = ''; + var text_insert = ''; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + diffs.splice(pointer - count_delete - count_insert, + count_delete + count_insert); + pointer = pointer - count_delete - count_insert; + var subDiff = + this.diff_main(text_delete, text_insert, false, deadline); + for (var j = subDiff.length - 1; j >= 0; j--) { + diffs.splice(pointer, 0, subDiff[j]); + } + pointer = pointer + subDiff.length; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + pointer++; + } + diffs.pop(); // Remove the dummy entry at the end. + + return diffs; +}; + + +/** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_bisect_ = function(text1, text2, deadline) { + // Cache the text lengths to prevent multiple calls. + var text1_length = text1.length; + var text2_length = text2.length; + var max_d = Math.ceil((text1_length + text2_length) / 2); + var v_offset = max_d; + var v_length = 2 * max_d; + var v1 = new Array(v_length); + var v2 = new Array(v_length); + // Setting all elements to -1 is faster in Chrome & Firefox than mixing + // integers and undefined. + for (var x = 0; x < v_length; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + var delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will collide + // with the reverse path. + var front = (delta % 2 != 0); + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + var k1start = 0; + var k1end = 0; + var k2start = 0; + var k2end = 0; + for (var d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if ((new Date()).getTime() > deadline) { + break; + } + + // Walk the front path one step. + for (var k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + var k1_offset = v_offset + k1; + var x1; + if (k1 == -d || (k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1])) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + var y1 = x1 - k1; + while (x1 < text1_length && y1 < text2_length && + text1.charAt(x1) == text2.charAt(y1)) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + var k2_offset = v_offset + delta - k1; + if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { + // Mirror x2 onto top-left coordinate system. + var x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); + } + } + } + } + + // Walk the reverse path one step. + for (var k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + var k2_offset = v_offset + k2; + var x2; + if (k2 == -d || (k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1])) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + var y2 = x2 - k2; + while (x2 < text1_length && y2 < text2_length && + text1.charAt(text1_length - x2 - 1) == + text2.charAt(text2_length - y2 - 1)) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + var k1_offset = v_offset + delta - k2; + if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { + var x1 = v1[k1_offset]; + var y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + return [new diff_match_patch.Diff(DIFF_DELETE, text1), + new diff_match_patch.Diff(DIFF_INSERT, text2)]; +}; + + +/** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} x Index of split point in text1. + * @param {number} y Index of split point in text2. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_bisectSplit_ = function(text1, text2, x, y, + deadline) { + var text1a = text1.substring(0, x); + var text2a = text2.substring(0, y); + var text1b = text1.substring(x); + var text2b = text2.substring(y); + + // Compute both diffs serially. + var diffs = this.diff_main(text1a, text2a, false, deadline); + var diffsb = this.diff_main(text1b, text2b, false, deadline); + + return diffs.concat(diffsb); +}; + + +/** + * Split two texts into an array of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {{chars1: string, chars2: string, lineArray: !Array.}} + * An object containing the encoded text1, the encoded text2 and + * the array of unique strings. + * The zeroth element of the array of unique strings is intentionally blank. + * @private + */ +diff_match_patch.prototype.diff_linesToChars_ = function(text1, text2) { + var lineArray = []; // e.g. lineArray[4] == 'Hello\n' + var lineHash = {}; // e.g. lineHash['Hello\n'] == 4 + + // '\x00' is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray[0] = ''; + + /** + * Split a text into an array of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * Modifies linearray and linehash through being a closure. + * @param {string} text String to encode. + * @return {string} Encoded string. + * @private + */ + function diff_linesToCharsMunge_(text) { + var chars = ''; + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + var lineStart = 0; + var lineEnd = -1; + // Keeping our own length variable is faster than looking it up. + var lineArrayLength = lineArray.length; + while (lineEnd < text.length - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd == -1) { + lineEnd = text.length - 1; + } + var line = text.substring(lineStart, lineEnd + 1); + + if (lineHash.hasOwnProperty ? lineHash.hasOwnProperty(line) : + (lineHash[line] !== undefined)) { + chars += String.fromCharCode(lineHash[line]); + } else { + if (lineArrayLength == maxLines) { + // Bail out at 65535 because + // String.fromCharCode(65536) == String.fromCharCode(0) + line = text.substring(lineStart); + lineEnd = text.length; + } + chars += String.fromCharCode(lineArrayLength); + lineHash[line] = lineArrayLength; + lineArray[lineArrayLength++] = line; + } + lineStart = lineEnd + 1; + } + return chars; + } + // Allocate 2/3rds of the space for text1, the rest for text2. + var maxLines = 40000; + var chars1 = diff_linesToCharsMunge_(text1); + maxLines = 65535; + var chars2 = diff_linesToCharsMunge_(text2); + return {chars1: chars1, chars2: chars2, lineArray: lineArray}; +}; + + +/** + * Rehydrate the text in a diff from a string of line hashes to real lines of + * text. + * @param {!Array.} diffs Array of diff tuples. + * @param {!Array.} lineArray Array of unique strings. + * @private + */ +diff_match_patch.prototype.diff_charsToLines_ = function(diffs, lineArray) { + for (var i = 0; i < diffs.length; i++) { + var chars = diffs[i][1]; + var text = []; + for (var j = 0; j < chars.length; j++) { + text[j] = lineArray[chars.charCodeAt(j)]; + } + diffs[i][1] = text.join(''); + } +}; + + +/** + * Determine the common prefix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the start of each + * string. + */ +diff_match_patch.prototype.diff_commonPrefix = function(text1, text2) { + // Quick check for common null cases. + if (!text1 || !text2 || text1.charAt(0) != text2.charAt(0)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + var pointermin = 0; + var pointermax = Math.min(text1.length, text2.length); + var pointermid = pointermax; + var pointerstart = 0; + while (pointermin < pointermid) { + if (text1.substring(pointerstart, pointermid) == + text2.substring(pointerstart, pointermid)) { + pointermin = pointermid; + pointerstart = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + + +/** + * Determine the common suffix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of each string. + */ +diff_match_patch.prototype.diff_commonSuffix = function(text1, text2) { + // Quick check for common null cases. + if (!text1 || !text2 || + text1.charAt(text1.length - 1) != text2.charAt(text2.length - 1)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + var pointermin = 0; + var pointermax = Math.min(text1.length, text2.length); + var pointermid = pointermax; + var pointerend = 0; + while (pointermin < pointermid) { + if (text1.substring(text1.length - pointermid, text1.length - pointerend) == + text2.substring(text2.length - pointermid, text2.length - pointerend)) { + pointermin = pointermid; + pointerend = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + + +/** + * Determine if the suffix of one string is the prefix of another. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of the first + * string and the start of the second string. + * @private + */ +diff_match_patch.prototype.diff_commonOverlap_ = function(text1, text2) { + // Cache the text lengths to prevent multiple calls. + var text1_length = text1.length; + var text2_length = text2.length; + // Eliminate the null case. + if (text1_length == 0 || text2_length == 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + var text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1 == text2) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + var best = 0; + var length = 1; + while (true) { + var pattern = text1.substring(text_length - length); + var found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if (found == 0 || text1.substring(text_length - length) == + text2.substring(0, length)) { + best = length; + length++; + } + } +}; + + +/** + * Do the two texts share a substring which is at least half the length of the + * longer text? + * This speedup can produce non-minimal diffs. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {Array.} Five element Array, containing the prefix of + * text1, the suffix of text1, the prefix of text2, the suffix of + * text2 and the common middle. Or null if there was no match. + * @private + */ +diff_match_patch.prototype.diff_halfMatch_ = function(text1, text2) { + if (this.Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + var longtext = text1.length > text2.length ? text1 : text2; + var shorttext = text1.length > text2.length ? text2 : text1; + if (longtext.length < 4 || shorttext.length * 2 < longtext.length) { + return null; // Pointless. + } + var dmp = this; // 'this' becomes 'window' in a closure. + + /** + * Does a substring of shorttext exist within longtext such that the substring + * is at least half the length of longtext? + * Closure, but does not reference any external variables. + * @param {string} longtext Longer string. + * @param {string} shorttext Shorter string. + * @param {number} i Start index of quarter length substring within longtext. + * @return {Array.} Five element Array, containing the prefix of + * longtext, the suffix of longtext, the prefix of shorttext, the suffix + * of shorttext and the common middle. Or null if there was no match. + * @private + */ + function diff_halfMatchI_(longtext, shorttext, i) { + // Start with a 1/4 length substring at position i as a seed. + var seed = longtext.substring(i, i + Math.floor(longtext.length / 4)); + var j = -1; + var best_common = ''; + var best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b; + while ((j = shorttext.indexOf(seed, j + 1)) != -1) { + var prefixLength = dmp.diff_commonPrefix(longtext.substring(i), + shorttext.substring(j)); + var suffixLength = dmp.diff_commonSuffix(longtext.substring(0, i), + shorttext.substring(0, j)); + if (best_common.length < suffixLength + prefixLength) { + best_common = shorttext.substring(j - suffixLength, j) + + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length * 2 >= longtext.length) { + return [best_longtext_a, best_longtext_b, + best_shorttext_a, best_shorttext_b, best_common]; + } else { + return null; + } + } + + // First check if the second quarter is the seed for a half-match. + var hm1 = diff_halfMatchI_(longtext, shorttext, + Math.ceil(longtext.length / 4)); + // Check again based on the third quarter. + var hm2 = diff_halfMatchI_(longtext, shorttext, + Math.ceil(longtext.length / 2)); + var hm; + if (!hm1 && !hm2) { + return null; + } else if (!hm2) { + hm = hm1; + } else if (!hm1) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length > hm2[4].length ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + var text1_a, text1_b, text2_a, text2_b; + if (text1.length > text2.length) { + text1_a = hm[0]; + text1_b = hm[1]; + text2_a = hm[2]; + text2_b = hm[3]; + } else { + text2_a = hm[0]; + text2_b = hm[1]; + text1_a = hm[2]; + text1_b = hm[3]; + } + var mid_common = hm[4]; + return [text1_a, text1_b, text2_a, text2_b, mid_common]; +}; + + +/** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupSemantic = function(diffs) { + var changes = false; + var equalities = []; // Stack of indices where equalities are found. + var equalitiesLength = 0; // Keeping our own length var is faster in JS. + /** @type {?string} */ + var lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + var pointer = 0; // Index of current position. + // Number of characters that changed prior to the equality. + var length_insertions1 = 0; + var length_deletions1 = 0; + // Number of characters that changed after the equality. + var length_insertions2 = 0; + var length_deletions2 = 0; + while (pointer < diffs.length) { + if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. + equalities[equalitiesLength++] = pointer; + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = diffs[pointer][1]; + } else { // An insertion or deletion. + if (diffs[pointer][0] == DIFF_INSERT) { + length_insertions2 += diffs[pointer][1].length; + } else { + length_deletions2 += diffs[pointer][1].length; + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality && (lastEquality.length <= + Math.max(length_insertions1, length_deletions1)) && + (lastEquality.length <= Math.max(length_insertions2, + length_deletions2))) { + // Duplicate record. + diffs.splice(equalities[equalitiesLength - 1], 0, + new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + // Throw away the equality we just deleted. + equalitiesLength--; + // Throw away the previous equality (it needs to be reevaluated). + equalitiesLength--; + pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; + length_insertions1 = 0; // Reset the counters. + length_deletions1 = 0; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + pointer++; + } + + // Normalize the diff. + if (changes) { + this.diff_cleanupMerge(diffs); + } + this.diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 1; + while (pointer < diffs.length) { + if (diffs[pointer - 1][0] == DIFF_DELETE && + diffs[pointer][0] == DIFF_INSERT) { + var deletion = diffs[pointer - 1][1]; + var insertion = diffs[pointer][1]; + var overlap_length1 = this.diff_commonOverlap_(deletion, insertion); + var overlap_length2 = this.diff_commonOverlap_(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length / 2 || + overlap_length1 >= insertion.length / 2) { + // Overlap found. Insert an equality and trim the surrounding edits. + diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, + insertion.substring(0, overlap_length1))); + diffs[pointer - 1][1] = + deletion.substring(0, deletion.length - overlap_length1); + diffs[pointer + 1][1] = insertion.substring(overlap_length1); + pointer++; + } + } else { + if (overlap_length2 >= deletion.length / 2 || + overlap_length2 >= insertion.length / 2) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, + deletion.substring(0, overlap_length2))); + diffs[pointer - 1][0] = DIFF_INSERT; + diffs[pointer - 1][1] = + insertion.substring(0, insertion.length - overlap_length2); + diffs[pointer + 1][0] = DIFF_DELETE; + diffs[pointer + 1][1] = + deletion.substring(overlap_length2); + pointer++; + } + } + pointer++; + } + pointer++; + } +}; + + +/** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupSemanticLossless = function(diffs) { + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * Closure, but does not reference any external variables. + * @param {string} one First string. + * @param {string} two Second string. + * @return {number} The score. + * @private + */ + function diff_cleanupSemanticScore_(one, two) { + if (!one || !two) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + var char1 = one.charAt(one.length - 1); + var char2 = two.charAt(0); + var nonAlphaNumeric1 = char1.match(diff_match_patch.nonAlphaNumericRegex_); + var nonAlphaNumeric2 = char2.match(diff_match_patch.nonAlphaNumericRegex_); + var whitespace1 = nonAlphaNumeric1 && + char1.match(diff_match_patch.whitespaceRegex_); + var whitespace2 = nonAlphaNumeric2 && + char2.match(diff_match_patch.whitespaceRegex_); + var lineBreak1 = whitespace1 && + char1.match(diff_match_patch.linebreakRegex_); + var lineBreak2 = whitespace2 && + char2.match(diff_match_patch.linebreakRegex_); + var blankLine1 = lineBreak1 && + one.match(diff_match_patch.blanklineEndRegex_); + var blankLine2 = lineBreak2 && + two.match(diff_match_patch.blanklineStartRegex_); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + var pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1][0] == DIFF_EQUAL && + diffs[pointer + 1][0] == DIFF_EQUAL) { + // This is a single edit surrounded by equalities. + var equality1 = diffs[pointer - 1][1]; + var edit = diffs[pointer][1]; + var equality2 = diffs[pointer + 1][1]; + + // First, shift the edit as far left as possible. + var commonOffset = this.diff_commonSuffix(equality1, edit); + if (commonOffset) { + var commonString = edit.substring(edit.length - commonOffset); + equality1 = equality1.substring(0, equality1.length - commonOffset); + edit = commonString + edit.substring(0, edit.length - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + var bestEquality1 = equality1; + var bestEdit = edit; + var bestEquality2 = equality2; + var bestScore = diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + while (edit.charAt(0) === equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + var score = diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (diffs[pointer - 1][1] != bestEquality1) { + // We have an improvement, save it back to the diff. + if (bestEquality1) { + diffs[pointer - 1][1] = bestEquality1; + } else { + diffs.splice(pointer - 1, 1); + pointer--; + } + diffs[pointer][1] = bestEdit; + if (bestEquality2) { + diffs[pointer + 1][1] = bestEquality2; + } else { + diffs.splice(pointer + 1, 1); + pointer--; + } + } + } + pointer++; + } +}; + +// Define some regex patterns for matching boundaries. +diff_match_patch.nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/; +diff_match_patch.whitespaceRegex_ = /\s/; +diff_match_patch.linebreakRegex_ = /[\r\n]/; +diff_match_patch.blanklineEndRegex_ = /\n\r?\n$/; +diff_match_patch.blanklineStartRegex_ = /^\r?\n\r?\n/; + +/** + * Reduce the number of edits by eliminating operationally trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupEfficiency = function(diffs) { + var changes = false; + var equalities = []; // Stack of indices where equalities are found. + var equalitiesLength = 0; // Keeping our own length var is faster in JS. + /** @type {?string} */ + var lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + var pointer = 0; // Index of current position. + // Is there an insertion operation before the last equality. + var pre_ins = false; + // Is there a deletion operation before the last equality. + var pre_del = false; + // Is there an insertion operation after the last equality. + var post_ins = false; + // Is there a deletion operation after the last equality. + var post_del = false; + while (pointer < diffs.length) { + if (diffs[pointer][0] == DIFF_EQUAL) { // Equality found. + if (diffs[pointer][1].length < this.Diff_EditCost && + (post_ins || post_del)) { + // Candidate found. + equalities[equalitiesLength++] = pointer; + pre_ins = post_ins; + pre_del = post_del; + lastEquality = diffs[pointer][1]; + } else { + // Not a candidate, and can never become one. + equalitiesLength = 0; + lastEquality = null; + } + post_ins = post_del = false; + } else { // An insertion or deletion. + if (diffs[pointer][0] == DIFF_DELETE) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if (lastEquality && ((pre_ins && pre_del && post_ins && post_del) || + ((lastEquality.length < this.Diff_EditCost / 2) && + (pre_ins + pre_del + post_ins + post_del) == 3))) { + // Duplicate record. + diffs.splice(equalities[equalitiesLength - 1], 0, + new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + equalitiesLength--; // Throw away the equality we just deleted; + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalitiesLength = 0; + } else { + equalitiesLength--; // Throw away the previous equality. + pointer = equalitiesLength > 0 ? + equalities[equalitiesLength - 1] : -1; + post_ins = post_del = false; + } + changes = true; + } + } + pointer++; + } + + if (changes) { + this.diff_cleanupMerge(diffs); + } +}; + + +/** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupMerge = function(diffs) { + // Add a dummy entry at the end. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); + var pointer = 0; + var count_delete = 0; + var count_insert = 0; + var text_delete = ''; + var text_insert = ''; + var commonlength; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + pointer++; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + pointer++; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete + count_insert > 1) { + if (count_delete !== 0 && count_insert !== 0) { + // Factor out any common prefixies. + commonlength = this.diff_commonPrefix(text_insert, text_delete); + if (commonlength !== 0) { + if ((pointer - count_delete - count_insert) > 0 && + diffs[pointer - count_delete - count_insert - 1][0] == + DIFF_EQUAL) { + diffs[pointer - count_delete - count_insert - 1][1] += + text_insert.substring(0, commonlength); + } else { + diffs.splice(0, 0, new diff_match_patch.Diff(DIFF_EQUAL, + text_insert.substring(0, commonlength))); + pointer++; + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = this.diff_commonSuffix(text_insert, text_delete); + if (commonlength !== 0) { + diffs[pointer][1] = text_insert.substring(text_insert.length - + commonlength) + diffs[pointer][1]; + text_insert = text_insert.substring(0, text_insert.length - + commonlength); + text_delete = text_delete.substring(0, text_delete.length - + commonlength); + } + } + // Delete the offending records and add the merged ones. + pointer -= count_delete + count_insert; + diffs.splice(pointer, count_delete + count_insert); + if (text_delete.length) { + diffs.splice(pointer, 0, + new diff_match_patch.Diff(DIFF_DELETE, text_delete)); + pointer++; + } + if (text_insert.length) { + diffs.splice(pointer, 0, + new diff_match_patch.Diff(DIFF_INSERT, text_insert)); + pointer++; + } + pointer++; + } else if (pointer !== 0 && diffs[pointer - 1][0] == DIFF_EQUAL) { + // Merge this equality with the previous one. + diffs[pointer - 1][1] += diffs[pointer][1]; + diffs.splice(pointer, 1); + } else { + pointer++; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + } + if (diffs[diffs.length - 1][1] === '') { + diffs.pop(); // Remove the dummy entry at the end. + } + + // Second pass: look for single edits surrounded on both sides by equalities + // which can be shifted sideways to eliminate an equality. + // e.g: ABAC -> ABAC + var changes = false; + pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1][0] == DIFF_EQUAL && + diffs[pointer + 1][0] == DIFF_EQUAL) { + // This is a single edit surrounded by equalities. + if (diffs[pointer][1].substring(diffs[pointer][1].length - + diffs[pointer - 1][1].length) == diffs[pointer - 1][1]) { + // Shift the edit over the previous equality. + diffs[pointer][1] = diffs[pointer - 1][1] + + diffs[pointer][1].substring(0, diffs[pointer][1].length - + diffs[pointer - 1][1].length); + diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1]; + diffs.splice(pointer - 1, 1); + changes = true; + } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) == + diffs[pointer + 1][1]) { + // Shift the edit over the next equality. + diffs[pointer - 1][1] += diffs[pointer + 1][1]; + diffs[pointer][1] = + diffs[pointer][1].substring(diffs[pointer + 1][1].length) + + diffs[pointer + 1][1]; + diffs.splice(pointer + 1, 1); + changes = true; + } + } + pointer++; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + this.diff_cleanupMerge(diffs); + } +}; + + +/** + * loc is a location in text1, compute and return the equivalent location in + * text2. + * e.g. 'The cat' vs 'The big cat', 1->1, 5->8 + * @param {!Array.} diffs Array of diff tuples. + * @param {number} loc Location within text1. + * @return {number} Location within text2. + */ +diff_match_patch.prototype.diff_xIndex = function(diffs, loc) { + var chars1 = 0; + var chars2 = 0; + var last_chars1 = 0; + var last_chars2 = 0; + var x; + for (x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_INSERT) { // Equality or deletion. + chars1 += diffs[x][1].length; + } + if (diffs[x][0] !== DIFF_DELETE) { // Equality or insertion. + chars2 += diffs[x][1].length; + } + if (chars1 > loc) { // Overshot the location. + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + // Was the location was deleted? + if (diffs.length != x && diffs[x][0] === DIFF_DELETE) { + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); +}; + + +/** + * Convert a diff array into a pretty HTML report. + * @param {!Array.} diffs Array of diff tuples. + * @return {string} HTML representation. + */ +diff_match_patch.prototype.diff_prettyHtml = function(diffs) { + var html = []; + var pattern_amp = /&/g; + var pattern_lt = //g; + var pattern_para = /\n/g; + for (var x = 0; x < diffs.length; x++) { + var op = diffs[x][0]; // Operation (insert, delete, equal) + var data = diffs[x][1]; // Text of change. + var text = data.replace(pattern_amp, '&').replace(pattern_lt, '<') + .replace(pattern_gt, '>').replace(pattern_para, '¶
'); + switch (op) { + case DIFF_INSERT: + html[x] = '' + text + ''; + break; + case DIFF_DELETE: + html[x] = '' + text + ''; + break; + case DIFF_EQUAL: + html[x] = '' + text + ''; + break; + } + } + return html.join(''); +}; + + +/** + * Compute and return the source text (all equalities and deletions). + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Source text. + */ +diff_match_patch.prototype.diff_text1 = function(diffs) { + var text = []; + for (var x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_INSERT) { + text[x] = diffs[x][1]; + } + } + return text.join(''); +}; + + +/** + * Compute and return the destination text (all equalities and insertions). + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Destination text. + */ +diff_match_patch.prototype.diff_text2 = function(diffs) { + var text = []; + for (var x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_DELETE) { + text[x] = diffs[x][1]; + } + } + return text.join(''); +}; + + +/** + * Compute the Levenshtein distance; the number of inserted, deleted or + * substituted characters. + * @param {!Array.} diffs Array of diff tuples. + * @return {number} Number of changes. + */ +diff_match_patch.prototype.diff_levenshtein = function(diffs) { + var levenshtein = 0; + var insertions = 0; + var deletions = 0; + for (var x = 0; x < diffs.length; x++) { + var op = diffs[x][0]; + var data = diffs[x][1]; + switch (op) { + case DIFF_INSERT: + insertions += data.length; + break; + case DIFF_DELETE: + deletions += data.length; + break; + case DIFF_EQUAL: + // A deletion and an insertion is one substitution. + levenshtein += Math.max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += Math.max(insertions, deletions); + return levenshtein; +}; + + +/** + * Crush the diff into an encoded string which describes the operations + * required to transform text1 into text2. + * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + * Operations are tab-separated. Inserted text is escaped using %xx notation. + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Delta text. + */ +diff_match_patch.prototype.diff_toDelta = function(diffs) { + var text = []; + for (var x = 0; x < diffs.length; x++) { + switch (diffs[x][0]) { + case DIFF_INSERT: + text[x] = '+' + encodeURI(diffs[x][1]); + break; + case DIFF_DELETE: + text[x] = '-' + diffs[x][1].length; + break; + case DIFF_EQUAL: + text[x] = '=' + diffs[x][1].length; + break; + } + } + return text.join('\t').replace(/%20/g, ' '); +}; + + +/** + * Given the original text1, and an encoded string which describes the + * operations required to transform text1 into text2, compute the full diff. + * @param {string} text1 Source string for the diff. + * @param {string} delta Delta text. + * @return {!Array.} Array of diff tuples. + * @throws {!Error} If invalid input. + */ +diff_match_patch.prototype.diff_fromDelta = function(text1, delta) { + var diffs = []; + var diffsLength = 0; // Keeping our own length var is faster in JS. + var pointer = 0; // Cursor in text1 + var tokens = delta.split(/\t/g); + for (var x = 0; x < tokens.length; x++) { + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + var param = tokens[x].substring(1); + switch (tokens[x].charAt(0)) { + case '+': + try { + diffs[diffsLength++] = + new diff_match_patch.Diff(DIFF_INSERT, decodeURI(param)); + } catch (ex) { + // Malformed URI sequence. + throw new Error('Illegal escape in diff_fromDelta: ' + param); + } + break; + case '-': + // Fall through. + case '=': + var n = parseInt(param, 10); + if (isNaN(n) || n < 0) { + throw new Error('Invalid number in diff_fromDelta: ' + param); + } + var text = text1.substring(pointer, pointer += n); + if (tokens[x].charAt(0) == '=') { + diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_EQUAL, text); + } else { + diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_DELETE, text); + } + break; + default: + // Blank tokens are ok (from a trailing \t). + // Anything else is an error. + if (tokens[x]) { + throw new Error('Invalid diff operation in diff_fromDelta: ' + + tokens[x]); + } + } + } + if (pointer != text1.length) { + throw new Error('Delta length (' + pointer + + ') does not equal source text length (' + text1.length + ').'); + } + return diffs; +}; + + +// MATCH FUNCTIONS + + +/** + * Locate the best instance of 'pattern' in 'text' near 'loc'. + * @param {string} text The text to search. + * @param {string} pattern The pattern to search for. + * @param {number} loc The location to search around. + * @return {number} Best match index or -1. + */ +diff_match_patch.prototype.match_main = function(text, pattern, loc) { + // Check for null inputs. + if (text == null || pattern == null || loc == null) { + throw new Error('Null input. (match_main)'); + } + + loc = Math.max(0, Math.min(loc, text.length)); + if (text == pattern) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (!text.length) { + // Nothing to match. + return -1; + } else if (text.substring(loc, loc + pattern.length) == pattern) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return this.match_bitap_(text, pattern, loc); + } +}; + + +/** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. + * @param {string} text The text to search. + * @param {string} pattern The pattern to search for. + * @param {number} loc The location to search around. + * @return {number} Best match index or -1. + * @private + */ +diff_match_patch.prototype.match_bitap_ = function(text, pattern, loc) { + if (pattern.length > this.Match_MaxBits) { + throw new Error('Pattern too long for this browser.'); + } + + // Initialise the alphabet. + var s = this.match_alphabet_(pattern); + + var dmp = this; // 'this' becomes 'window' in a closure. + + /** + * Compute and return the score for a match with e errors and x location. + * Accesses loc and pattern through being a closure. + * @param {number} e Number of errors in match. + * @param {number} x Location of match. + * @return {number} Overall score for match (0.0 = good, 1.0 = bad). + * @private + */ + function match_bitapScore_(e, x) { + var accuracy = e / pattern.length; + var proximity = Math.abs(loc - x); + if (!dmp.Match_Distance) { + // Dodge divide by zero error. + return proximity ? 1.0 : accuracy; + } + return accuracy + (proximity / dmp.Match_Distance); + } + + // Highest score beyond which we give up. + var score_threshold = this.Match_Threshold; + // Is there a nearby exact match? (speedup) + var best_loc = text.indexOf(pattern, loc); + if (best_loc != -1) { + score_threshold = Math.min(match_bitapScore_(0, best_loc), score_threshold); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length); + if (best_loc != -1) { + score_threshold = + Math.min(match_bitapScore_(0, best_loc), score_threshold); + } + } + + // Initialise the bit arrays. + var matchmask = 1 << (pattern.length - 1); + best_loc = -1; + + var bin_min, bin_mid; + var bin_max = pattern.length + text.length; + var last_rd; + for (var d = 0; d < pattern.length; d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at this + // error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (match_bitapScore_(d, loc + bin_mid) <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = Math.floor((bin_max - bin_min) / 2 + bin_min); + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + var start = Math.max(1, loc - bin_mid + 1); + var finish = Math.min(loc + bin_mid, text.length) + pattern.length; + + var rd = Array(finish + 2); + rd[finish + 1] = (1 << d) - 1; + for (var j = finish; j >= start; j--) { + // The alphabet (s) is a sparse hash, so the following line generates + // warnings. + var charMatch = s[text.charAt(j - 1)]; + if (d === 0) { // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { // Subsequent passes: fuzzy match. + rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) | + (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | + last_rd[j + 1]; + } + if (rd[j] & matchmask) { + var score = match_bitapScore_(d, j - 1); + // This match will almost certainly be better than any existing match. + // But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = Math.max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + // No hope for a (better) match at greater error levels. + if (match_bitapScore_(d + 1, loc) > score_threshold) { + break; + } + last_rd = rd; + } + return best_loc; +}; + + +/** + * Initialise the alphabet for the Bitap algorithm. + * @param {string} pattern The text to encode. + * @return {!Object} Hash of character locations. + * @private + */ +diff_match_patch.prototype.match_alphabet_ = function(pattern) { + var s = {}; + for (var i = 0; i < pattern.length; i++) { + s[pattern.charAt(i)] = 0; + } + for (var i = 0; i < pattern.length; i++) { + s[pattern.charAt(i)] |= 1 << (pattern.length - i - 1); + } + return s; +}; + + +// PATCH FUNCTIONS + + +/** + * Increase the context until it is unique, + * but don't let the pattern expand beyond Match_MaxBits. + * @param {!diff_match_patch.patch_obj} patch The patch to grow. + * @param {string} text Source text. + * @private + */ +diff_match_patch.prototype.patch_addContext_ = function(patch, text) { + if (text.length == 0) { + return; + } + if (patch.start2 === null) { + throw Error('patch not initialized'); + } + var pattern = text.substring(patch.start2, patch.start2 + patch.length1); + var padding = 0; + + // Look for the first and last matches of pattern in text. If two different + // matches are found, increase the pattern length. + while (text.indexOf(pattern) != text.lastIndexOf(pattern) && + pattern.length < this.Match_MaxBits - this.Patch_Margin - + this.Patch_Margin) { + padding += this.Patch_Margin; + pattern = text.substring(patch.start2 - padding, + patch.start2 + patch.length1 + padding); + } + // Add one chunk for good luck. + padding += this.Patch_Margin; + + // Add the prefix. + var prefix = text.substring(patch.start2 - padding, patch.start2); + if (prefix) { + patch.diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, prefix)); + } + // Add the suffix. + var suffix = text.substring(patch.start2 + patch.length1, + patch.start2 + patch.length1 + padding); + if (suffix) { + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, suffix)); + } + + // Roll back the start points. + patch.start1 -= prefix.length; + patch.start2 -= prefix.length; + // Extend the lengths. + patch.length1 += prefix.length + suffix.length; + patch.length2 += prefix.length + suffix.length; +}; + + +/** + * Compute a list of patches to turn text1 into text2. + * Use diffs if provided, otherwise compute it ourselves. + * There are four ways to call this function, depending on what data is + * available to the caller: + * Method 1: + * a = text1, b = text2 + * Method 2: + * a = diffs + * Method 3 (optimal): + * a = text1, b = diffs + * Method 4 (deprecated, use method 3): + * a = text1, b = text2, c = diffs + * + * @param {string|!Array.} a text1 (methods 1,3,4) or + * Array of diff tuples for text1 to text2 (method 2). + * @param {string|!Array.=} opt_b text2 (methods 1,4) or + * Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). + * @param {string|!Array.=} opt_c Array of diff tuples + * for text1 to text2 (method 4) or undefined (methods 1,2,3). + * @return {!Array.} Array of Patch objects. + */ +diff_match_patch.prototype.patch_make = function(a, opt_b, opt_c) { + var text1, diffs; + if (typeof a == 'string' && typeof opt_b == 'string' && + typeof opt_c == 'undefined') { + // Method 1: text1, text2 + // Compute diffs from text1 and text2. + text1 = /** @type {string} */(a); + diffs = this.diff_main(text1, /** @type {string} */(opt_b), true); + if (diffs.length > 2) { + this.diff_cleanupSemantic(diffs); + this.diff_cleanupEfficiency(diffs); + } + } else if (a && typeof a == 'object' && typeof opt_b == 'undefined' && + typeof opt_c == 'undefined') { + // Method 2: diffs + // Compute text1 from diffs. + diffs = /** @type {!Array.} */(a); + text1 = this.diff_text1(diffs); + } else if (typeof a == 'string' && opt_b && typeof opt_b == 'object' && + typeof opt_c == 'undefined') { + // Method 3: text1, diffs + text1 = /** @type {string} */(a); + diffs = /** @type {!Array.} */(opt_b); + } else if (typeof a == 'string' && typeof opt_b == 'string' && + opt_c && typeof opt_c == 'object') { + // Method 4: text1, text2, diffs + // text2 is not used. + text1 = /** @type {string} */(a); + diffs = /** @type {!Array.} */(opt_c); + } else { + throw new Error('Unknown call format to patch_make.'); + } + + if (diffs.length === 0) { + return []; // Get rid of the null case. + } + var patches = []; + var patch = new diff_match_patch.patch_obj(); + var patchDiffLength = 0; // Keeping our own length var is faster in JS. + var char_count1 = 0; // Number of characters into the text1 string. + var char_count2 = 0; // Number of characters into the text2 string. + // Start with text1 (prepatch_text) and apply the diffs until we arrive at + // text2 (postpatch_text). We recreate the patches one by one to determine + // context info. + var prepatch_text = text1; + var postpatch_text = text1; + for (var x = 0; x < diffs.length; x++) { + var diff_type = diffs[x][0]; + var diff_text = diffs[x][1]; + + if (!patchDiffLength && diff_type !== DIFF_EQUAL) { + // A new patch starts here. + patch.start1 = char_count1; + patch.start2 = char_count2; + } + + switch (diff_type) { + case DIFF_INSERT: + patch.diffs[patchDiffLength++] = diffs[x]; + patch.length2 += diff_text.length; + postpatch_text = postpatch_text.substring(0, char_count2) + diff_text + + postpatch_text.substring(char_count2); + break; + case DIFF_DELETE: + patch.length1 += diff_text.length; + patch.diffs[patchDiffLength++] = diffs[x]; + postpatch_text = postpatch_text.substring(0, char_count2) + + postpatch_text.substring(char_count2 + + diff_text.length); + break; + case DIFF_EQUAL: + if (diff_text.length <= 2 * this.Patch_Margin && + patchDiffLength && diffs.length != x + 1) { + // Small equality inside a patch. + patch.diffs[patchDiffLength++] = diffs[x]; + patch.length1 += diff_text.length; + patch.length2 += diff_text.length; + } else if (diff_text.length >= 2 * this.Patch_Margin) { + // Time for a new patch. + if (patchDiffLength) { + this.patch_addContext_(patch, prepatch_text); + patches.push(patch); + patch = new diff_match_patch.patch_obj(); + patchDiffLength = 0; + // Unlike Unidiff, our patch lists have a rolling context. + // https://github.com/google/diff-match-patch/wiki/Unidiff + // Update prepatch text & pos to reflect the application of the + // just completed patch. + prepatch_text = postpatch_text; + char_count1 = char_count2; + } + } + break; + } + + // Update the current character count. + if (diff_type !== DIFF_INSERT) { + char_count1 += diff_text.length; + } + if (diff_type !== DIFF_DELETE) { + char_count2 += diff_text.length; + } + } + // Pick up the leftover patch if not empty. + if (patchDiffLength) { + this.patch_addContext_(patch, prepatch_text); + patches.push(patch); + } + + return patches; +}; + + +/** + * Given an array of patches, return another array that is identical. + * @param {!Array.} patches Array of Patch objects. + * @return {!Array.} Array of Patch objects. + */ +diff_match_patch.prototype.patch_deepCopy = function(patches) { + // Making deep copies is hard in JavaScript. + var patchesCopy = []; + for (var x = 0; x < patches.length; x++) { + var patch = patches[x]; + var patchCopy = new diff_match_patch.patch_obj(); + patchCopy.diffs = []; + for (var y = 0; y < patch.diffs.length; y++) { + patchCopy.diffs[y] = + new diff_match_patch.Diff(patch.diffs[y][0], patch.diffs[y][1]); + } + patchCopy.start1 = patch.start1; + patchCopy.start2 = patch.start2; + patchCopy.length1 = patch.length1; + patchCopy.length2 = patch.length2; + patchesCopy[x] = patchCopy; + } + return patchesCopy; +}; + + +/** + * Merge a set of patches onto the text. Return a patched text, as well + * as a list of true/false values indicating which patches were applied. + * @param {!Array.} patches Array of Patch objects. + * @param {string} text Old text. + * @return {!Array.>} Two element Array, containing the + * new text and an array of boolean values. + */ +diff_match_patch.prototype.patch_apply = function(patches, text) { + if (patches.length == 0) { + return [text, []]; + } + + // Deep copy the patches so that no changes are made to originals. + patches = this.patch_deepCopy(patches); + + var nullPadding = this.patch_addPadding(patches); + text = nullPadding + text + nullPadding; + + this.patch_splitMax(patches); + // delta keeps track of the offset between the expected and actual location + // of the previous patch. If there are patches expected at positions 10 and + // 20, but the first patch was found at 12, delta is 2 and the second patch + // has an effective expected position of 22. + var delta = 0; + var results = []; + for (var x = 0; x < patches.length; x++) { + var expected_loc = patches[x].start2 + delta; + var text1 = this.diff_text1(patches[x].diffs); + var start_loc; + var end_loc = -1; + if (text1.length > this.Match_MaxBits) { + // patch_splitMax will only provide an oversized pattern in the case of + // a monster delete. + start_loc = this.match_main(text, text1.substring(0, this.Match_MaxBits), + expected_loc); + if (start_loc != -1) { + end_loc = this.match_main(text, + text1.substring(text1.length - this.Match_MaxBits), + expected_loc + text1.length - this.Match_MaxBits); + if (end_loc == -1 || start_loc >= end_loc) { + // Can't find valid trailing context. Drop this patch. + start_loc = -1; + } + } + } else { + start_loc = this.match_main(text, text1, expected_loc); + } + if (start_loc == -1) { + // No match found. :( + results[x] = false; + // Subtract the delta for this failed patch from subsequent patches. + delta -= patches[x].length2 - patches[x].length1; + } else { + // Found a match. :) + results[x] = true; + delta = start_loc - expected_loc; + var text2; + if (end_loc == -1) { + text2 = text.substring(start_loc, start_loc + text1.length); + } else { + text2 = text.substring(start_loc, end_loc + this.Match_MaxBits); + } + if (text1 == text2) { + // Perfect match, just shove the replacement text in. + text = text.substring(0, start_loc) + + this.diff_text2(patches[x].diffs) + + text.substring(start_loc + text1.length); + } else { + // Imperfect match. Run a diff to get a framework of equivalent + // indices. + var diffs = this.diff_main(text1, text2, false); + if (text1.length > this.Match_MaxBits && + this.diff_levenshtein(diffs) / text1.length > + this.Patch_DeleteThreshold) { + // The end points match, but the content is unacceptably bad. + results[x] = false; + } else { + this.diff_cleanupSemanticLossless(diffs); + var index1 = 0; + var index2; + for (var y = 0; y < patches[x].diffs.length; y++) { + var mod = patches[x].diffs[y]; + if (mod[0] !== DIFF_EQUAL) { + index2 = this.diff_xIndex(diffs, index1); + } + if (mod[0] === DIFF_INSERT) { // Insertion + text = text.substring(0, start_loc + index2) + mod[1] + + text.substring(start_loc + index2); + } else if (mod[0] === DIFF_DELETE) { // Deletion + text = text.substring(0, start_loc + index2) + + text.substring(start_loc + this.diff_xIndex(diffs, + index1 + mod[1].length)); + } + if (mod[0] !== DIFF_DELETE) { + index1 += mod[1].length; + } + } + } + } + } + } + // Strip the padding off. + text = text.substring(nullPadding.length, text.length - nullPadding.length); + return [text, results]; +}; + + +/** + * Add some padding on text start and end so that edges can match something. + * Intended to be called only from within patch_apply. + * @param {!Array.} patches Array of Patch objects. + * @return {string} The padding string added to each side. + */ +diff_match_patch.prototype.patch_addPadding = function(patches) { + var paddingLength = this.Patch_Margin; + var nullPadding = ''; + for (var x = 1; x <= paddingLength; x++) { + nullPadding += String.fromCharCode(x); + } + + // Bump all the patches forward. + for (var x = 0; x < patches.length; x++) { + patches[x].start1 += paddingLength; + patches[x].start2 += paddingLength; + } + + // Add some padding on start of first diff. + var patch = patches[0]; + var diffs = patch.diffs; + if (diffs.length == 0 || diffs[0][0] != DIFF_EQUAL) { + // Add nullPadding equality. + diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); + patch.start1 -= paddingLength; // Should be 0. + patch.start2 -= paddingLength; // Should be 0. + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs[0][1].length) { + // Grow first equality. + var extraLength = paddingLength - diffs[0][1].length; + diffs[0][1] = nullPadding.substring(diffs[0][1].length) + diffs[0][1]; + patch.start1 -= extraLength; + patch.start2 -= extraLength; + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + // Add some padding on end of last diff. + patch = patches[patches.length - 1]; + diffs = patch.diffs; + if (diffs.length == 0 || diffs[diffs.length - 1][0] != DIFF_EQUAL) { + // Add nullPadding equality. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs[diffs.length - 1][1].length) { + // Grow last equality. + var extraLength = paddingLength - diffs[diffs.length - 1][1].length; + diffs[diffs.length - 1][1] += nullPadding.substring(0, extraLength); + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + return nullPadding; +}; + + +/** + * Look through the patches and break up any which are longer than the maximum + * limit of the match algorithm. + * Intended to be called only from within patch_apply. + * @param {!Array.} patches Array of Patch objects. + */ +diff_match_patch.prototype.patch_splitMax = function(patches) { + var patch_size = this.Match_MaxBits; + for (var x = 0; x < patches.length; x++) { + if (patches[x].length1 <= patch_size) { + continue; + } + var bigpatch = patches[x]; + // Remove the big old patch. + patches.splice(x--, 1); + var start1 = bigpatch.start1; + var start2 = bigpatch.start2; + var precontext = ''; + while (bigpatch.diffs.length !== 0) { + // Create one of several smaller patches. + var patch = new diff_match_patch.patch_obj(); + var empty = true; + patch.start1 = start1 - precontext.length; + patch.start2 = start2 - precontext.length; + if (precontext !== '') { + patch.length1 = patch.length2 = precontext.length; + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, precontext)); + } + while (bigpatch.diffs.length !== 0 && + patch.length1 < patch_size - this.Patch_Margin) { + var diff_type = bigpatch.diffs[0][0]; + var diff_text = bigpatch.diffs[0][1]; + if (diff_type === DIFF_INSERT) { + // Insertions are harmless. + patch.length2 += diff_text.length; + start2 += diff_text.length; + patch.diffs.push(bigpatch.diffs.shift()); + empty = false; + } else if (diff_type === DIFF_DELETE && patch.diffs.length == 1 && + patch.diffs[0][0] == DIFF_EQUAL && + diff_text.length > 2 * patch_size) { + // This is a large deletion. Let it pass in one chunk. + patch.length1 += diff_text.length; + start1 += diff_text.length; + empty = false; + patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); + bigpatch.diffs.shift(); + } else { + // Deletion or equality. Only take as much as we can stomach. + diff_text = diff_text.substring(0, + patch_size - patch.length1 - this.Patch_Margin); + patch.length1 += diff_text.length; + start1 += diff_text.length; + if (diff_type === DIFF_EQUAL) { + patch.length2 += diff_text.length; + start2 += diff_text.length; + } else { + empty = false; + } + patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); + if (diff_text == bigpatch.diffs[0][1]) { + bigpatch.diffs.shift(); + } else { + bigpatch.diffs[0][1] = + bigpatch.diffs[0][1].substring(diff_text.length); + } + } + } + // Compute the head context for the next patch. + precontext = this.diff_text2(patch.diffs); + precontext = + precontext.substring(precontext.length - this.Patch_Margin); + // Append the end context for this patch. + var postcontext = this.diff_text1(bigpatch.diffs) + .substring(0, this.Patch_Margin); + if (postcontext !== '') { + patch.length1 += postcontext.length; + patch.length2 += postcontext.length; + if (patch.diffs.length !== 0 && + patch.diffs[patch.diffs.length - 1][0] === DIFF_EQUAL) { + patch.diffs[patch.diffs.length - 1][1] += postcontext; + } else { + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, postcontext)); + } + } + if (!empty) { + patches.splice(++x, 0, patch); + } + } + } +}; + + +/** + * Take a list of patches and return a textual representation. + * @param {!Array.} patches Array of Patch objects. + * @return {string} Text representation of patches. + */ +diff_match_patch.prototype.patch_toText = function(patches) { + var text = []; + for (var x = 0; x < patches.length; x++) { + text[x] = patches[x]; + } + return text.join(''); +}; + + +/** + * Parse a textual representation of patches and return a list of Patch objects. + * @param {string} textline Text representation of patches. + * @return {!Array.} Array of Patch objects. + * @throws {!Error} If invalid input. + */ +diff_match_patch.prototype.patch_fromText = function(textline) { + var patches = []; + if (!textline) { + return patches; + } + var text = textline.split('\n'); + var textPointer = 0; + var patchHeader = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/; + while (textPointer < text.length) { + var m = text[textPointer].match(patchHeader); + if (!m) { + throw new Error('Invalid patch string: ' + text[textPointer]); + } + var patch = new diff_match_patch.patch_obj(); + patches.push(patch); + patch.start1 = parseInt(m[1], 10); + if (m[2] === '') { + patch.start1--; + patch.length1 = 1; + } else if (m[2] == '0') { + patch.length1 = 0; + } else { + patch.start1--; + patch.length1 = parseInt(m[2], 10); + } + + patch.start2 = parseInt(m[3], 10); + if (m[4] === '') { + patch.start2--; + patch.length2 = 1; + } else if (m[4] == '0') { + patch.length2 = 0; + } else { + patch.start2--; + patch.length2 = parseInt(m[4], 10); + } + textPointer++; + + while (textPointer < text.length) { + var sign = text[textPointer].charAt(0); + try { + var line = decodeURI(text[textPointer].substring(1)); + } catch (ex) { + // Malformed URI sequence. + throw new Error('Illegal escape in patch_fromText: ' + line); + } + if (sign == '-') { + // Deletion. + patch.diffs.push(new diff_match_patch.Diff(DIFF_DELETE, line)); + } else if (sign == '+') { + // Insertion. + patch.diffs.push(new diff_match_patch.Diff(DIFF_INSERT, line)); + } else if (sign == ' ') { + // Minor equality. + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, line)); + } else if (sign == '@') { + // Start of next patch. + break; + } else if (sign === '') { + // Blank line? Whatever. + } else { + // WTF? + throw new Error('Invalid patch mode "' + sign + '" in: ' + line); + } + textPointer++; + } + } + return patches; +}; + + +/** + * Class representing one patch operation. + * @constructor + */ +diff_match_patch.patch_obj = function() { + /** @type {!Array.} */ + this.diffs = []; + /** @type {?number} */ + this.start1 = null; + /** @type {?number} */ + this.start2 = null; + /** @type {number} */ + this.length1 = 0; + /** @type {number} */ + this.length2 = 0; +}; + + +/** + * Emulate GNU diff's format. + * Header: @@ -382,8 +481,9 @@ + * Indices are printed as 1-based, not 0-based. + * @return {string} The GNU diff string. + */ +diff_match_patch.patch_obj.prototype.toString = function() { + var coords1, coords2; + if (this.length1 === 0) { + coords1 = this.start1 + ',0'; + } else if (this.length1 == 1) { + coords1 = this.start1 + 1; + } else { + coords1 = (this.start1 + 1) + ',' + this.length1; + } + if (this.length2 === 0) { + coords2 = this.start2 + ',0'; + } else if (this.length2 == 1) { + coords2 = this.start2 + 1; + } else { + coords2 = (this.start2 + 1) + ',' + this.length2; + } + var text = ['@@ -' + coords1 + ' +' + coords2 + ' @@\n']; + var op; + // Escape the body of the patch with %xx notation. + for (var x = 0; x < this.diffs.length; x++) { + switch (this.diffs[x][0]) { + case DIFF_INSERT: + op = '+'; + break; + case DIFF_DELETE: + op = '-'; + break; + case DIFF_EQUAL: + op = ' '; + break; + } + text[x + 1] = op + encodeURI(this.diffs[x][1]) + '\n'; + } + return text.join('').replace(/%20/g, ' '); +}; + +module.exports = { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL }; diff --git a/tests/folio/tsconfig.json b/tests/folio/tsconfig.json new file mode 100644 index 0000000000..922a962b50 --- /dev/null +++ b/tests/folio/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "moduleResolution": "node", + "module": "commonjs", + "strict": false, + "sourceMap": true, + "declarationMap": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "outDir": "./out", + "rootDir": "./src", + "lib": ["esnext", "dom", "DOM.Iterable"], + }, + "exclude": [ + "node_modules" + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/test/page/jshandle-as-element.spec.ts b/tests/jshandle-as-element.spec.ts similarity index 84% rename from test/page/jshandle-as-element.spec.ts rename to tests/jshandle-as-element.spec.ts index e6f1f2c7b3..0d11bc9969 100644 --- a/test/page/jshandle-as-element.spec.ts +++ b/tests/jshandle-as-element.spec.ts @@ -15,21 +15,21 @@ * limitations under the License. */ -import { it, expect } from '../fixtures'; +import { test, expect } from './config/pageTest'; -it('should work', async ({page}) => { +test('should work', async ({page}) => { const aHandle = await page.evaluateHandle(() => document.body); const element = aHandle.asElement(); expect(element).toBeTruthy(); }); -it('should return null for non-elements', async ({page}) => { +test('should return null for non-elements', async ({page}) => { const aHandle = await page.evaluateHandle(() => 2); const element = aHandle.asElement(); expect(element).toBeFalsy(); }); -it('should return ElementHandle for TextNodes', async ({page}) => { +test('should return ElementHandle for TextNodes', async ({page}) => { await page.setContent('
ee!
'); const aHandle = await page.evaluateHandle(() => document.querySelector('div').firstChild); const element = aHandle.asElement(); @@ -37,7 +37,7 @@ it('should return ElementHandle for TextNodes', async ({page}) => { expect(await page.evaluate(e => e.nodeType === Node.TEXT_NODE, element)).toBeTruthy(); }); -it('should work with nullified Node', async ({page}) => { +test('should work with nullified Node', async ({page}) => { await page.setContent('
test
'); await page.evaluate('delete Node'); const handle = await page.evaluateHandle(() => document.querySelector('section')); diff --git a/tests/stack-trace.spec.ts b/tests/stack-trace.spec.ts new file mode 100644 index 0000000000..e490b1da73 --- /dev/null +++ b/tests/stack-trace.spec.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './config/playwrightTest'; +import path from 'path'; +import * as stackTrace from '../src/utils/stackTrace'; +import { setUnderTest } from '../src/utils/utils'; + +test('caller file path', async ({}) => { + setUnderTest(); + const callme = require('./assets/callback'); + const filePath = callme(() => { + return stackTrace.getCallerFilePath(path.join(__dirname, 'assets') + path.sep); + }); + expect(filePath).toBe(__filename); +});