diff --git a/.github/workflows/playwright.yml.DISABLED b/.github/workflows/playwright.yml.DISABLED new file mode 100644 index 0000000..adf086b --- /dev/null +++ b/.github/workflows/playwright.yml.DISABLED @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v6 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 033c6b8..f31527d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,11 @@ out/ .DS_Store /src/.DS_Store .vscode -playwright-report -test-results \ No newline at end of file +local.code-workspace + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/README.md b/README.md index 89b6f5e..3561191 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,8 @@ p5.sound.js extends the musical and sonic capabilities of [p5.js](https://p5js.o ## Examples -- p5.sound example on p5.js editor [here](https://editor.p5js.org/thomasjohnmartinez/collections/Dp0zGclVL) +- A set of p5.sound examples are in this repo at [examples/](examples/) +- The original examples can be found on the p5.js web editor [here](https://editor.p5js.org/thomasjohnmartinez/collections/Dp0zGclVL). Note that these may differ from the above set. - Legacy p5.js Sound Tutorial by Dan Shiffman on [YouTube](https://www.youtube.com/playlist?list=PLRqwX-V7Uu6aFcVjlDAkkGIixw70s7jpW) ## Documentation @@ -76,3 +77,32 @@ building reference pages (optional) ``` npx yuidoc . ``` + +## Testing the examples + +The library is configured to use [Playwright](https://playwright.dev/) to automatically test the [local](./examples) and [web-editor-hosted](https://editor.p5js.org/thomasjohnmartinez/collections/Dp0zGclVL) sets of p5.sound.js examples by automatically controlling a browser (firefox or chromium). + +If you haven't used Playwright on your system before, you'll have to run the following command _once_ to allow it to download the browsers it uses: + +### Setting up playwright +```bash +npx playwright install +``` + +### Starting the tests +1. Launch playwright's test-runner UI: +```bash +npm run test:integration:ui +``` + +2. Choose example set(s) and browser(s) +From the GUI, click "projects" and choose which examples ("web-" and/or "local-") and which browsers ("chromium" and/or "firefox") you wish to test. + +3. Run the tests! +click the green play button at the top of the list of tests. + +If a test fails, you can inspect its console log, the test actions, and even screenshots of what it looked like while it was running. + +There are also various other ways to run the tests automatically without any interaction. + +For more information, read [tests/integration/about-these-tests.md](tests/integration/about-these-tests.md) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index be94487..84491ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,14 @@ "devDependencies": { "@babel/core": "^7.24.5", "@babel/preset-env": "^7.24.5", + "@playwright/test": "^1.60.0", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", + "@types/node": "^25.9.1", "babel-loader": "^9.1.3", + "http-server": "^14.1.1", "rollup": "^2.79.1", "rollup-plugin-ignore": "^1.0.10", "rollup-plugin-terser": "^7.0.2", @@ -1807,6 +1810,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-babel": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.0.4.tgz", @@ -1958,12 +1977,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.11.tgz", - "integrity": "sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/resolve": { @@ -2467,6 +2487,26 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -2891,6 +2931,16 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2928,12 +2978,13 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -3179,6 +3230,13 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -3416,6 +3474,27 @@ "flat": "cli.js" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", @@ -3659,6 +3738,16 @@ "node": ">=0.8.0" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hoek": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/hoek/-/hoek-0.9.1.tgz", @@ -3669,6 +3758,19 @@ "node": ">=0.8.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -3684,6 +3786,115 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/http-server/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/http-server/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/http-server/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-server/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/http-signature": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-0.10.1.tgz", @@ -4108,6 +4319,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -4118,10 +4339,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/negotiator": { "version": "0.6.3", @@ -4208,6 +4429,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -4347,6 +4578,74 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/portfinder/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4552,6 +4851,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -4791,6 +5097,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4844,11 +5157,6 @@ "node": ">= 0.8" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -5288,10 +5596,11 @@ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", @@ -5333,6 +5642,18 @@ "node": ">=4" } }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5380,6 +5701,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -5533,6 +5861,33 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ed9bb58..01d90c3 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,13 @@ "version": "0.3.0", "description": "p5.sound is a minimal wrapper for Tone.js designed to extend the musical and audio capabilities of the p5.js core library.", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "build": "rollup -c" + "build": "rollup -c", + "test:integration": "npm run build && npx playwright test", + "test:integration:web": "npx playwright test test-examples-on-web-editor", + "test:integration:web:chromium": "npx playwright test test-examples-on-web-editor --project=web-chromium", + "test:integration:local": "npm run build && npx playwright test test-examples-local", + "test:integration:local:chromium": "npm run build && npx playwright test test-examples-local --project=local-chromium", + "test:integration:ui": "npm run build && npx playwright test --ui" }, "keywords": [ "p5.js", @@ -26,11 +31,14 @@ "devDependencies": { "@babel/core": "^7.24.5", "@babel/preset-env": "^7.24.5", + "@playwright/test": "^1.60.0", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", + "@types/node": "^25.9.1", "babel-loader": "^9.1.3", + "http-server": "^14.1.1", "rollup": "^2.79.1", "rollup-plugin-ignore": "^1.0.10", "rollup-plugin-terser": "^7.0.2", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..a7f7b99 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,88 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* + * Two example smoke suites, each run on the browsers we have headless + * audio/mic setup for (chromium & firefox; webkit is not investigated): + * + * web-* -> test-examples-on-web-editor.spec.js + * tests the published examples collection on editor.p5js.org (what users see) + * local-* -> test-examples-local.spec.js + * tests the repo's examples/ against the freshly built dist/, + * served by the webServer below (deterministic, offline) + * + * `testMatch` binds each spec to its projects so the two never cross over. + * The local-* projects set baseURL to the local http-server. + */ + projects: [ + { + name: 'web-chromium', + testMatch: 'test-examples-on-web-editor.spec.js', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'web-firefox', + testMatch: 'test-examples-on-web-editor.spec.js', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'local-chromium', + testMatch: 'test-examples-local.spec.js', + use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5050/' }, + }, + { + name: 'local-firefox', + testMatch: 'test-examples-local.spec.js', + use: { ...devices['Desktop Firefox'], baseURL: 'http://localhost:5050/' }, + }, + + //We haven't investigated setting up mic+camera permissions and auto-start of audio context on safari + // webkit (Desktop Safari) is intentionally omitted. + ], + + /* + * Static server for the local-examples suite. Serves the repo root so that + * each example's index.html and its ../../dist/p5.sound.js resolve. -c-1 + * disables caching so a fresh `npm run build` is always picked up. + * Harmless (just idles) when only the web-* projects run. + */ + webServer: { + command: 'npx http-server . -p 5050 -c-1 --silent', + url: 'http://localhost:5050/examples/', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); + diff --git a/tests/integration/about-these-tests.md b/tests/integration/about-these-tests.md new file mode 100644 index 0000000..0b7579e --- /dev/null +++ b/tests/integration/about-these-tests.md @@ -0,0 +1,112 @@ +# About these tests + +There are **two** Playwright smoke-test suites. Both run each p5.sound example sketch, do a trivial interaction, and assert **zero** console errors / uncaught exceptions. They differ only in *where the sketches come from*: + +| Suite | Spec | Runs the sketches… | Good for | +| --- | --- | --- | --- | +| **web editor** | `test-examples-on-web-editor.spec.js` | in place on editor.p5js.org | testing what users actually see; catches platform/CDN drift | +| **local** | `test-examples-local.spec.js` | from this repo's `examples/`, against the freshly built `dist/` | deterministic, offline, tests *your* library changes | + +Neither is "the real one" - they answer different questions. The web suite can drift if the published collection changes; the local suite can drift if `examples/` falls behind the collection. Keeping the two in sync is currently a manual job. + +The two specs are intentionally kept **separate and simple** rather than sharing a procedure: run locally there are no iframes, no Play button, no settle race, no cookie banner and no Stop button, so the local spec is much shorter. The only shared code is the per-browser audio/mic setup in [`lib/browser-setup.js`](./lib/browser-setup.js) - identical, non-obvious, and easy to get subtly wrong, so it lives in one place. + +## Setup (first time) + +```bash +npm install # installs packages, including @playwright/test and http-server +npx playwright install # downloads the browser binaries Playwright drives +``` + +Or if you really need to save disk space, you might be able to change that second command to +```bash +npx playwright install chromium firefox +``` + +These are two separate steps because they install different things: `npm install` populates `node_modules` whereas `npx playwright install` downloads the browser binaries separately into a machine-global cache (`~/Library/Caches/ms-playwright` on macOS). If you've previously run `npx playwright install` for any other Playwright project it may not need to do anything. + +Note `npx playwright install` installs the various browsers Playwright ships (Chromium, Firefox, WebKit, FFmpeg) even though we don't currently make use of them all. (On macOS 14 you may see a "frozen WebKit" warning - that's harmless here, since the suite currently skips WebKit.) + +The local examples also need a built `dist/`. The relevant `test:integration:*` npm scripts first run `npm run build` for you so that you're always testing the latest local code on your branch. + +## Where are the sketches? + +- **web editor suite:** the list of sketch URLs is a literal array (`SKETCHES`) in the spec, extracted once from the [collection](https://editor.p5js.org/thomasjohnmartinez/collections/Dp0zGclVL). The test does not re-scrape at runtime, so it is deterministic. Re-extract and update the list when the collection changes. +- **local suite:** the list is discovered at load time by scanning `examples/*/` for directories containing an `index.html`. Each example's `index.html` loads `../../dist/p5.sound.js`, so the local suite tests **the library you just built** - run `npm run build` first (all relevant `test:integration:*` npm scripts do this for you). + +## What does each test do? + +Same essential procedure, different mechanics: + +- **web editor:** load the sketch → dismiss the cookie banner → press Play → wait for the canvas to attach → click inside canvas → let it run → stop it → assert zero errors. +- **local:** load the served `index.html` (the sketch auto-runs) → wait for the canvas to attach → click inside canvas → let it run → assert zero errors. + +## Browsers + +Both suites are cross-browser. Each browser needs a *different* mechanism to (a) allow `getUserMedia` (camera/mic access) without a prompt and (b) let audio start without a user gesture, so these are kept as separate, self-contained configs in `BROWSER_SETUP` (in `lib/browser-setup.js`): + +- **Chromium** - grants `microphone`/`camera` permissions and passes `--autoplay-policy=no-user-gesture-required`. +- **Firefox** - those permission names aren't accepted, so instead it uses `firefoxUserPrefs` (fake media device + disabled prompt, plus autoplay prefs). It also mutes output (`media.volume_scale: "0.0"`): headless Firefox routes audio to the real device, so the run would otherwise be audible. +- **WebKit** - not in `BROWSER_SETUP`, so it is skipped (no equivalent headless audio/mic mechanism - needs research). + +(Native permission prompts are browser chrome and can't be clicked by Playwright, which is why the prompt is bypassed at the config level rather than clicked.) + +## Test "projects" and how to run them + +`playwright.config.js` defines four "projects" - a matrix of the two suites × {chromium, firefox} - bound to their spec via `testMatch`: `web-chromium`, `web-firefox`, `local-chromium`, `local-firefox`. The local projects point `baseURL` at a local `http-server` (a `webServer` in the config) that serves the repo root so `examples//` and its `../../dist/p5.sound.js` resolve. + +| Command | Runs | +| --- | --- | +| `npm run test:integration` | builds `dist/`, then everything (both suites, both browsers) | +| `npm run test:integration:web` | web-editor suite (both browsers) | +| `npm run test:integration:web:chromium` | web-editor suite, Chromium only | +| `npm run test:integration:local` | builds `dist/`, then the local suite (both browsers) | +| `npm run test:integration:local:chromium` | builds `dist/`, then local suite, Chromium only | +| `npm run test:integration:ui` | opens the Playwright UI runner | + +## What counts as a failure? + +Any console `error` or uncaught exception while the sketch loads, is clicked, and runs. Nothing is filtered - a 404 for a missing asset or a failed AudioContext start is a real failure, not noise. The interaction is also asserted: if the canvas attaches but never becomes clickable, or never attaches at all, the test fails with an explicit message rather than silently skipping the interaction: + +- `Expected to click the canvas but it never became visible` +- `Sketch never rendered a canvas (...)` + +## Difficulties in testing on the web editor + +These apply to the **web editor** suite only - the local suite sidesteps all of them: + +- The preview is **two iframes deep** (`iframe[title="sketch preview"]` → a `blob:` child iframe → `#defaultCanvas0`). +- There's a **race**: the editor ships the sketch code to the preview sandbox over `postMessage`; pressing Play before that channel is established means the code never arrives and no canvas renders. A short fixed settle wait before Play (`SETTLE_BEFORE_PLAY_MS`) is a brittle-but-readable workaround. (A better fix would be for the editor to disable Play until it's ready.) +- p5 marks the canvas `data-hidden="true"` during setup/preload, so the test waits for the canvas to be **`attached`** (not `visible`) before deciding the sketch started. + +## Other testing "gotchas" +p5 v2 numbers the default canvas inconsistently (`defaultCanvas1` for 2D sketches, `defaultCanvas0` for WEBGL ones), so the local spec matches the canvas by its `p5Canvas` class rather than by id. + +As the tests click the canvas very soon after it is marked attached, there's a chance that the following pattern in examples will hit on an undefined mySound because loadSound hasn't completed before the mouse click: +```js +let mySound; +async function setup(){ + createCanvas(400,400); + mySound = await loadSound(someURL); +} + +function mousePressed(){ + //mySound could still be undefined + mySound.play(); +} +``` +Either check if mySound is defined in mousePressed, or register the mousePressed event handler synchronously in line with awaiting loadSound: + +```js +let mySound; +async function setup(){ + const cnv = createCanvas(400,400); + mySound = await loadSound(someURL); + //only register the mouse handler afer the sound has finished loading! + cnv.mousePressed(playSound); +} + +function playSound(){ + mySound.play(); +} +``` diff --git a/tests/integration/example.spec.js b/tests/integration/example.spec.js new file mode 100644 index 0000000..26ed206 --- /dev/null +++ b/tests/integration/example.spec.js @@ -0,0 +1,19 @@ +// @ts-check +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); diff --git a/tests/integration/lib/browser-setup.js b/tests/integration/lib/browser-setup.js new file mode 100644 index 0000000..39d954b --- /dev/null +++ b/tests/integration/lib/browser-setup.js @@ -0,0 +1,79 @@ +//@ts-check +import { test as testOriginal, expect } from "@playwright/test"; + +/** + * Per-browser launch/context setup shared by BOTH example smoke suites + * (the web-editor suite and the local-examples suite). + * + * This is the ONLY code shared between those two suites. It lives here on + * purpose: the two test bodies are otherwise deliberately separate and simple, + * but this block is identical, non-obvious, and hard to get right + * (a wrong Firefox pref makes audio fail or the run audible without any error), + * so we don't duplicate it. + */ + +/** + * The launch/context setup one browser needs to run audio + getUserMedia sketches + * headlessly without prompts or gesture gating. + * @typedef {Object} BrowserSetup + * @property {string[]} permissions Context permissions to grant up front. + * @property {import("@playwright/test").LaunchOptions} launchOptions + */ + +/** + * Configs for per-browser setup. The keys are the browsers the suites run on; a + * browser not listed here (e.g. webkit) is skipped. + * Each browser needs a different mechanism to: + * (a) satisfy granting getUserMedia (camera & microphone access) without a prompt and + * (b) let audio start without a user gesture. + * @type {Record} + */ +export const BROWSER_SETUP = { + chromium: { + // Grant mic/camera so getUserMedia() resolves without a native prompt. + permissions: ["microphone", "camera"], + // --autoplay-policy lets audio start without a user gesture. + launchOptions: { args: ["--autoplay-policy=no-user-gesture-required"] }, + }, + firefox: { + // Firefox rejects the "microphone"/"camera" permission names; instead we + // feed a fake device and disable the prompt (see firefoxUserPrefs below). + permissions: [], + launchOptions: { + firefoxUserPrefs: { + // Fake media device + no prompt, in place of granting permissions. + "media.navigator.streams.fake": true, + "media.navigator.permission.disabled": true, + // Allow autoplay (incl. WebAudio), in place of the autoplay launch flag. + "media.autoplay.default": 0, + "media.autoplay.blocking_policy": 0, + "media.autoplay.block-webaudio": false, + // Mute output: headless Firefox routes audio to the real device (unlike + // headless Chromium's null sink), so the test run is otherwise audible. + // Web Audio still runs, so errors still surface — we just silence it. + "media.volume_scale": "0.0", + }, + }, + }, +}; + +/** + * An overridden `test` function that automatically applies the current browser's setup: + * - launchOptions is worker-scoped (the browser launches once per worker); + * - permissions is a per-test context option. + * Use this in place of the plain @playwright/test `test`. Browsers absent from + * BROWSER_SETUP get empty defaults (callers should test.skip them). + */ +export const test = testOriginal.extend({ + launchOptions: [ + async ({ browserName, launchOptions }, use) => { + await use({ ...launchOptions, ...(BROWSER_SETUP[browserName]?.launchOptions ?? {}) }); + }, + { scope: "worker" }, + ], + permissions: async ({ browserName }, use) => { + await use(BROWSER_SETUP[browserName]?.permissions ?? []); + }, +}); + +export { expect }; diff --git a/tests/integration/test-examples-local.spec.js b/tests/integration/test-examples-local.spec.js new file mode 100644 index 0000000..5324285 --- /dev/null +++ b/tests/integration/test-examples-local.spec.js @@ -0,0 +1,173 @@ +//@ts-check +import { readdirSync, existsSync } from "node:fs"; +import path from "node:path"; +import { test, expect, BROWSER_SETUP } from "./lib/browser-setup.js"; + +/** + * Smoke-tests every example under ../../examples/ by serving it locally and + * checking that running it produces no console errors or uncaught exceptions. + * + * This is the local-source counterpart to test-examples-on-web-editor.spec.js. + * The web-editor suite tests what users see on editor.p5js.org; this one tests + * the examples as they live in the repo, running against the freshly built + * dist/p5.sound.js (each example's index.html loads ../../dist/p5.sound.js). + * + * The two suites are intentionally kept separate and simple rather than sharing + * a procedure: running locally there are NO iframes, no Play button, no + * editor/sandbox settle race, no cookie banner and no Stop button, so this file + * is much shorter than its web-editor sibling. The only shared code is the + * per-browser audio/mic setup in ./lib/browser-setup.js. + * + * Serving: playwright.config.js starts an http-server at the repo root and the + * local-* projects set baseURL to it, so we navigate to "examples//". + * + */ + +/** + * @typedef {import("@playwright/test").Page} Page + * @typedef {import("@playwright/test").Locator} Locator + * @typedef {import("@playwright/test").ConsoleMessage} ConsoleMessage + */ + +/** + * A single captured problem (console error or uncaught page exception). + * @typedef {Object} CapturedError + * @property {"console" | "pageerror"} kind + * @property {string} text + * @property {string} url The page URL at the time the problem was captured. + */ + +/** How long to let the sketch run (and potentially throw) after the canvas appears. */ +const SKETCH_RUN_MS = 3000; +/** Max time (ms) to wait for the canvas to be in attached state. */ +const MAX_WAIT_FOR_ATTACHED_CANVAS_MS = 15_000; +/** Max time (ms) to wait for the canvas to become visible before clicking it. */ +const MAX_WAIT_FOR_VISIBLE_CANVAS_MS = 5_000; +/** Max time (ms) for a single canvas click attempt. */ +const CANVAS_CLICK_TIMEOUT_MS = 5_000; + +/** + * Absolute path to the examples directory. Playwright runs with the repo root + * (the directory holding playwright.config.js) as the working directory. + */ +const EXAMPLES_DIR = path.resolve("examples"); + +/** + * Every example directory that has an index.html, discovered at load time. + * @type {string[]} + */ +const EXAMPLE_NAMES = readdirSync(EXAMPLES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => existsSync(path.join(EXAMPLES_DIR, name, "index.html"))) + .sort(); + +// A wide viewport, to match the web-editor suite. +test.use({ viewport: { width: 1400, height: 900 } }); + +for (const name of EXAMPLE_NAMES) { + test(`${name} runs with no console errors`, async ({ page, browserName }) => { + // Skip browsers we have no headless audio/mic setup for (see BROWSER_SETUP). + test.skip(!BROWSER_SETUP[browserName], `No headless audio/mic setup for ${browserName}`); + + const errors = trackErrors(page); + + // Load the example. baseURL (set by the local-* project) points at the http-server serving the repo root. + await page.goto(`examples/${name}/`, { waitUntil: "load" }); + + // The canvas is right on the page here (no iframes). We match it by p5's + // "p5Canvas" class rather than #defaultCanvas0: p5 v2 numbers the default + // canvas inconsistently when run locally (defaultCanvas1 for 2D sketches, + // defaultCanvas0 for WEBGL ones?), but every example sketch seems to produce exactly one + // p5Canvas. Wait for "attached" rather than "visible": p5 marks the canvas + // data-hidden="true" while setup/preload runs. If it never attaches, the + // sketch failed to start. + const canvas = page.locator("canvas.p5Canvas"); + const canvasAttached = await canvas + .waitFor({ state: "attached", timeout: MAX_WAIT_FOR_ATTACHED_CANVAS_MS }) + .then(() => true) + .catch(() => false); + + if (!canvasAttached) { + errors.push({ kind: "pageerror", text: "Sketch never rendered a canvas (page stayed empty)", url: page.url() }); + } else { + await clickCanvasOnceVisible(page, canvas, errors); + // Let the sketch run for a moment so runtime errors have a chance to surface. + await page.waitForTimeout(SKETCH_RUN_MS); + } + + expect(errors.length, formatErrors(name, errors)).toBe(0); + }); +} + +/** + * Attaches console + pageerror listeners and returns the live array they push + * into. Records every console error and uncaught exception; healthy sketches + * produce none. + * @param {Page} page + * @returns {CapturedError[]} + */ +function trackErrors(page) { + /** @type {CapturedError[]} */ + const errors = []; + + page.on("console", (/** @type {ConsoleMessage} */ msg) => { + if (msg.type() !== "error") return; + errors.push({ kind: "console", text: msg.text(), url: page.url() }); + }); + + page.on("pageerror", (/** @type {Error} */ err) => { + errors.push({ kind: "pageerror", text: err.message, url: page.url() }); + }); + + return errors; +} + +/** + * Clicks inside the sketch canvas once it is visible, recording any problem into + * `errors`. Many examples generate or modulate sound on click, so this exercises + * that wiring. We do NOT force the click: if the canvas never becomes visible the + * test is suspect, so we record an error rather than mask it. + * @param {Page} page + * @param {Locator} canvas Locator for the sketch's #defaultCanvas0. + * @param {CapturedError[]} errors Sink for any failure encountered here. + * @returns {Promise} + */ +async function clickCanvasOnceVisible(page, canvas, errors) { + const canvasVisible = await canvas + .waitFor({ state: "visible", timeout: MAX_WAIT_FOR_VISIBLE_CANVAS_MS }) + .then(() => true) + .catch(() => false); + + if (!canvasVisible) { + errors.push({ kind: "pageerror", text: "Expected to click the canvas but it never became visible", url: page.url() }); + return; + } + + try { + await canvas.click({ timeout: CANVAS_CLICK_TIMEOUT_MS }); + } catch (e) { + errors.push({ kind: "pageerror", text: `Canvas click failed: ${firstLine(e)}`, url: page.url() }); + } +} + +/** + * The first line of an Error's message (drops the stack), for compact reporting. + * @param {unknown} err + * @returns {string} + */ +function firstLine(err) { + return String(err instanceof Error ? err.message : err).split("\n")[0]; +} + +/** + * Builds a readable assertion message listing every captured error. + * @param {string} name + * @param {CapturedError[]} errors + * @returns {string} + */ +function formatErrors(name, errors) { + if (errors.length === 0) return `No console errors for ${name}`; + const lines = errors.map((e) => ` [${e.kind}] ${e.text} (at ${e.url})`); + return `Console errors while running ${name}:\n${lines.join("\n")}`; +} diff --git a/tests/integration/test-examples-on-web-editor.spec.js b/tests/integration/test-examples-on-web-editor.spec.js new file mode 100644 index 0000000..f445dff --- /dev/null +++ b/tests/integration/test-examples-on-web-editor.spec.js @@ -0,0 +1,276 @@ +//@ts-check +import { test, expect, BROWSER_SETUP } from "./lib/browser-setup.js"; + +/** + * Smoke-tests every sketch in the p5.sound examples collection on the p5 web + * editor, asserting that running each one produces no console errors or uncaught + * exceptions. + * + * The sketch list below was extracted once (on 2026-05-30) from the collection: + * https://editor.p5js.org/thomasjohnmartinez/collections/Dp0zGclVL + * The test runs against this literal list rather than re-scraping the collection + * at runtime, so it is deterministic and doesn't depend on the collection page's + * markup. Re-extract and update SKETCHES when the collection changes. + * + * Assumptions: + * * all examples will render a canvas (it will be clicked) + * * no example needs browser permissions beyond camera and microphone + * + * Notes on driving the editor reliably: + * - The preview runs in a cross-origin sandbox (preview.p5js.org). The editor + * ships the sketch code to that sandbox over postMessage; if we press Play + * before that channel is established the code never arrives and no canvas ever + * renders. We therefore wait for the editor to settle before pressing Play + * (see SETTLE_BEFORE_PLAY_MS). + * - The canvas ends up two iframes deep: iframe[title="sketch preview"] (the + * preview.p5js.org frame) → a blob: child iframe → #defaultCanvas0. + */ + +/** + * @typedef {import("@playwright/test").Page} Page + * @typedef {import("@playwright/test").Locator} Locator + * @typedef {import("@playwright/test").ConsoleMessage} ConsoleMessage + */ + +/** + * A single captured problem (console error or uncaught page exception). + * @typedef {Object} CapturedError + * @property {"console" | "pageerror"} kind + * @property {string} text + * @property {string} url The page URL at the time the problem was captured. + */ + +/** + * One sketch in the collection. + * @typedef {Object} Sketch + * @property {string} name Human-readable name, used as the test title. + * @property {string} url Direct URL to the sketch on the p5 web editor. + */ + +/** @type {Sketch[]} */ +const SKETCHES = [ + { name: "001-Oscillator-FrequencyAmplitude", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/z-KkeTrcu" }, + { name: "002-Amplitude-VisualizingLoudness", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/Wlcnc6WCD" }, + { name: "003-Microphone-Effects", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/5NV6gUkWM" }, + { name: "004-OscillatorAmplitudeLFOmodulation", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/9bsyBm86Q" }, + { name: "005-Oscillator-Reverb", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/eMQrmFczQ" }, + { name: "006-DelayTime-Envelope", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/Dk95S298f" }, + { name: "006-DelayTime-Envelope_b", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/2ay47nReh" }, + { name: "006-EnvelopeAndfilter", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/buaI5fkJC" }, + { name: "007-Envelope-Attack-Release", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/jx8TmJAST" }, + { name: "008-FFT-WaveForm-Visualize", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/GKLghF22G" }, + { name: "008_b-FFT-WaveForm-VisualizeSoundFile", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/dQFLbAwch" }, + { name: "009-NoiseTypes", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/KSE9hEBCu" }, + { name: "010-PitchShifterOnSampleEnded", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/kq0zqgdmL" }, + { name: "011-ReverbDecayTime", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/6tyyCCbEg" }, + { name: "012-SoundFileSetPath", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/eQQsm5apX" }, + { name: "013-MultiSamplePlaybackWithAmplitudeAnalysis", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/lYJ5w-tbL" }, + { name: "014-3DSoundSource", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/vEvHsr3c-" }, + { name: "015-SoundFile3DScale", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/hvhRcqrqi" }, + { name: "016-String-Synthesis", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/1erR4NUQd" }, + { name: "016-String-Synthesis_b", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/n_owBAPTN" }, + { name: "018-Oscillator-Delay", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/aGXHwoPVm" }, + { name: "p5-to-tone", url: "https://editor.p5js.org/thomasjohnmartinez/sketches/W0_fe403s" }, +]; + +/** + * How long to let the editor settle after load before pressing Play, so the + * editor↔preview-sandbox postMessage channel is established first. + */ +const SETTLE_BEFORE_PLAY_MS = 3000; + +/** How long to let the sketch run (and potentially throw) after the canvas appears. */ +const SKETCH_RUN_MS = 3000; + +/** Max time (ms) to wait for the play button to become visible. */ +const MAX_WAIT_FOR_PLAY_BUTTON_VISIBLE_MS = 15_000; +/** Max time (ms) to wait for the canvas to be in attached state. */ +const MAX_WAIT_FOR_ATTACHED_CANVAS = 20_000; +/** Max time (ms) to wait for the canvas to become visible before clicking it. */ +const MAX_WAIT_FOR_VISIBLE_CANVAS_MS = 5_000; +/** Max time (ms) for a single canvas click attempt. */ +const CANVAS_CLICK_TIMEOUT_MS = 5_000; + +// The important overridden `test` function (with per-browser audio/mic setup), `expect`, and +// BROWSER_SETUP come from ./lib/browser-setup.js — the only code shared with the local-examples +// suite. BROWSER_SETUP is also used below to skip browsers we have no setup for. + +// Test setup for ALL browser types. +// A wide viewport keeps the editor's preview pane from collapsing +test.use({ viewport: { width: 1400, height: 900 } }); + +//Note: This for loop doesn't RUN the tests, rather it DECLARES them. +// In this way, for example, the developer can later decide in the UI test runner which tests to run and against which browser. +for (const sketch of SKETCHES) { + + // Declare one test for the sketch in question. + // The test has a title and a function which will be called if the test is run. + // Here we use our MODIFIED test() function which will automatically set up the + // browser launchOptions and permissions. + test(`${sketch.name} runs with no console errors`, async ({ page, browserName }) => { + // Skip browsers we have no headless audio/mic setup for (see BROWSER_SETUP). + test.skip(!BROWSER_SETUP[browserName], `No headless audio/mic setup for ${browserName}`); + + //the collection which will store any console errors encountered + const errors = trackErrors(page); + + //Visit the sketch in the editor + await page.goto(sketch.url, { waitUntil: "domcontentloaded" }); + + await dismissCookieBannerIfPresent(page); + + // Pressing play... + // The web editor's play button appears before the code has been sent to the iframe, and + // pressing it too early will do nothing (no canvas and no helpful "too early" error) + // So for now we wait giving time for the iframe setup to be done. This is brittle (and slows the tests). + // Better would be if the web editor disabled the button until it was ready for use. + // (see SETTLE_BEFORE_PLAY_MS). + await page.locator("#play-sketch").waitFor({ state: "visible", timeout: MAX_WAIT_FOR_PLAY_BUTTON_VISIBLE_MS }); + await page.waitForTimeout(SETTLE_BEFORE_PLAY_MS); + await page.locator("#play-sketch").click(); + + // Wait for the sketch's canvas to be created. We wait for "attached" rather + // than "visible": p5 marks the canvas data-hidden="true" while setup/preload + // runs, so requiring strict visibility here would time out before we get to + // the real check (console errors). If the canvas never attaches the sketch + // failed to start — fail now, but include any console error already captured + // (often the cause, e.g. a throw in setup() that aborted before createCanvas). + const canvas = getSketchCanvas(page); + const canvasAttached = await canvas + .waitFor({ state: "attached", timeout: MAX_WAIT_FOR_ATTACHED_CANVAS }) + .then(() => true) + .catch(() => false); + if (!canvasAttached) { + errors.push({ kind: "pageerror", text: "Sketch never rendered a canvas (preview stayed empty)", url: page.url() }); + } else { + await clickCanvasOnceVisible(page, canvas, errors); + + // Let the sketch run for a moment so runtime errors have a chance to surface. + await page.waitForTimeout(SKETCH_RUN_MS); + + // Should silence a noisy sketch that's being individually tested (and expose errors during resource release) + await stopSketch(page); + } + // In all cases, if we encountered errors in the console (and/or the canvas didn't attach) then + // the test should fail and report them. + expect(errors.length, formatErrors(sketch, errors)).toBe(0); + }); +} + +/** + * Attaches console + pageerror listeners and returns the live array they push + * into. We record *every* console error and uncaught exceptionObserved healthy + * sketches produce no console errors at all. + * @param {Page} page + * @returns {CapturedError[]} + */ +function trackErrors(page) { + /** @type {CapturedError[]} */ + const errors = []; + + page.on("console", (/** @type {ConsoleMessage} */ msg) => { + if (msg.type() !== "error") return; + errors.push({ kind: "console", text: msg.text(), url: page.url() }); + }); + + page.on("pageerror", (/** @type {Error} */ err) => { + errors.push({ kind: "pageerror", text: err.message, url: page.url() }); + }); + + return errors; +} + +/** + * Dismisses the editor's cookie-consent banner if it is showing. The banner only + * appears on the first visit per browser context, so its absence is fine. + * @param {Page} page + * @returns {Promise} + */ +async function dismissCookieBannerIfPresent(page) { + const allow = page.getByRole("button", { name: "Allow Essential" }); + if (await allow.isVisible().catch(() => false)) { + await allow.click(); + } +} + + +/** + * Clicks inside the sketch canvas once it is visible, recording any problem into + * `errors`. Various examples generate or modulate sound on mouse clicks, so this + * exercises that wiring and reveals basic bugs. + * + * We deliberately do NOT force the click: the canvas must actually become visible + * for a real click to land. If it never does, the test is suspect (we may be + * silently not exercising the sketch at all), so we record an error rather than + * mask it. + * @param {Page} page + * @param {Locator} canvas Locator for the sketch's #defaultCanvas0. + * @param {CapturedError[]} errors Sink for any failure encountered here. + * @returns {Promise} + */ +async function clickCanvasOnceVisible(page, canvas, errors) { + const canvasVisible = await canvas + .waitFor({ state: "visible", timeout: MAX_WAIT_FOR_VISIBLE_CANVAS_MS }) + .then(() => true) + .catch(() => false); + + if (!canvasVisible) { + errors.push({ kind: "pageerror", text: "Expected to click the canvas but it never became visible", url: page.url() }); + return; + } + + try { + await canvas.click({ timeout: CANVAS_CLICK_TIMEOUT_MS }); + } catch (e) { + errors.push({ kind: "pageerror", text: `Canvas click failed: ${firstLine(e)}`, url: page.url() }); + } +} + +/** + * Presses the editor's stop button. Best-effort: stopping is just cleanup, so a + * missing/disabled Stop button shouldn't fail the test (the error assertion is + * the real check). + * TODO: a failed stop button click should probably error. + * @param {Page} page + * @returns {Promise} + */ +async function stopSketch(page) { + await page.getByRole("button", { name: "Stop sketch" }).click({ timeout: 5_000 }).catch(() => {}); +} + +/** + * Locates the running sketch's default p5 canvas. The preview is nested two + * iframes deep: the outer iframe[title="sketch preview"] is the preview.p5js.org + * frame, which embeds a blob: child iframe that finally holds our #defaultCanvas0. + * @param {Page} page + * @returns {Locator} + */ +function getSketchCanvas(page) { + return page + .locator('iframe[title="sketch preview"]') + .contentFrame() + .locator("iframe") + .contentFrame() + .locator("#defaultCanvas0"); +} + +/** + * The first line of an Error's message (drops the stack), for compact reporting. + * @param {unknown} err + * @returns {string} + */ +function firstLine(err) { + return String(err instanceof Error ? err.message : err).split("\n")[0]; +} + +/** + * Builds a readable assertion message listing every captured error. + * @param {Sketch} sketch + * @param {CapturedError[]} errors + * @returns {string} + */ +function formatErrors(sketch, errors) { + if (errors.length === 0) return `No console errors for ${sketch.name}`; + const lines = errors.map((e) => ` [${e.kind}] ${e.text} (at ${e.url})`); + return `Console errors while running ${sketch.name}:\n${lines.join("\n")}`; +}