Repository navigation
An npm package that gives Playwright tests a generated set, and an example of what it finds #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
f81a23f
An npm package that gives Playwright tests a generated set, and an ex…
donislawdev 96513b2
The dependency gate lets the repository's own linked npm package thro…
donislawdev 35d94a7
A kept set is checked byte for byte before it is used, and tfg's erro…
donislawdev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| node_modules/ | ||
| results/ | ||
| test-results/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # An upload form, the tests it usually gets, and the tests a generated set gives it | ||
|
|
||
| `app/server.mjs` is an upload form with four mistakes, each one a mistake real upload forms have: | ||
|
|
||
| 1. The server never checks the size. Only the page does. | ||
| 2. The content and the extension are each checked against the allowed types, and never against each other, so a PDF named `.jpg` goes through. | ||
| 3. An allowed extension anywhere in the name counts, so `invoice.jpg.exe` is taken for a picture. | ||
| 4. The page refuses a file of exactly the limit, which is the largest size allowed. | ||
|
|
||
| `tests/typical.spec.ts` is what such a form usually gets: a real picture that should go through, and files made in the test with `Buffer.alloc` that should not. Every one of those tests passes. The file over the limit is zeros rather than a PDF, so the server turns it away for what it holds, and the test meant to prove the size limit passes on a server that has none. | ||
|
|
||
| `tests/upload.spec.ts` sends the `upload-validation` set from [testing-files-generator](../../npm/testing-files-generator) through the page and straight to the server. It finds all four mistakes, and each failure says what it means. | ||
|
|
||
| ## Run it | ||
|
|
||
| From the root of the repository, build tfg first. The example finds it there. | ||
|
|
||
| ```bash | ||
| go build -tags "$(cat .github/build-tags)" -o "tfg$(go env GOEXE)" ./cmd/tfg | ||
| ``` | ||
|
|
||
| ```bash | ||
| cd examples/playwright && npm install && npx playwright install chromium | ||
| ``` | ||
|
|
||
| ```bash | ||
| npx playwright test | ||
| ``` | ||
|
|
||
| Eleven tests fail, five through the page and six through the API, and every failure names its file and the mistake it shows: | ||
|
|
||
| ``` | ||
| 1mb_over_1b.jpg was taken, and it should have been turned away (size_limit). | ||
| A JPEG file 1 B over the limit of 1mb. Your system should turn it away - taking it means | ||
| the limit is not enforced, or enforced only in the browser. | ||
| ``` | ||
|
|
||
| Ten are skipped, because what a form does with an empty file or a name full of emoji is its own policy, and the set says so rather than pass them. | ||
|
|
||
| `FIXED=1 npx playwright test` corrects all four mistakes, and nothing fails. `npm run check` runs both and says whether the example still shows what it is for. The repository's CI runs that. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head><meta charset="utf-8"><title>Upload</title></head> | ||
| <body data-limit="{{limit}}" data-fixed="{{fixed}}"> | ||
| <form id="upload"> | ||
| <label for="file">File</label> | ||
| <input type="file" id="file" name="file"> | ||
| <button type="submit">Upload</button> | ||
| </form> | ||
| <p role="status" id="status"></p> | ||
| <script src="/page.js"></script> | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| // The form's own check, in the browser, before anything is sent. | ||
|
|
||
| const limit = Number(document.body.dataset.limit); | ||
| const fixed = document.body.dataset.fixed === '1'; | ||
|
|
||
| document.getElementById('upload').addEventListener('submit', async (event) => { | ||
| event.preventDefault(); | ||
| const status = document.getElementById('status'); | ||
| const file = document.getElementById('file').files[0]; | ||
| // Mistake 4: a file of exactly the limit is refused. The limit is the | ||
| // largest size allowed, so the comparison should be >. | ||
| const tooLarge = fixed ? file.size > limit : file.size >= limit; | ||
| if (tooLarge) { | ||
| status.textContent = 'Refused (size_limit)'; | ||
| return; | ||
| } | ||
| const body = new FormData(); | ||
| body.append('file', file); | ||
| const response = await fetch('/upload', { method: 'POST', body }); | ||
| const answer = await response.json(); | ||
| status.textContent = response.ok ? 'Uploaded' : `Refused (${answer.reason})`; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| // An upload form with four mistakes made on purpose, each one a mistake real | ||
| // upload forms have. playwright.config.ts starts it. FIXED=1 corrects all four, | ||
| // which is how check.mjs shows that the red tests come from the mistakes and | ||
| // not from the tests. | ||
| // | ||
| // It uses nothing outside Node, so the example has no server dependency. | ||
|
|
||
| import { readFileSync } from 'node:fs'; | ||
| import http from 'node:http'; | ||
| import { Readable } from 'node:stream'; | ||
|
|
||
| const limit = Number(process.env.UPLOAD_LIMIT_BYTES); | ||
| const port = Number(process.env.PORT); | ||
| const fixed = process.env.FIXED === '1'; | ||
| const allowed = ['jpg', 'png', 'pdf']; | ||
|
|
||
| if (!Number.isInteger(limit) || !Number.isInteger(port)) { | ||
| throw new Error('Set UPLOAD_LIMIT_BYTES and PORT. playwright.config.ts sets both.'); | ||
| } | ||
|
|
||
| // What a file holds, read from its first bytes rather than from its name. | ||
| function sniff(bytes) { | ||
| if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { | ||
| return 'jpg'; | ||
| } | ||
| if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { | ||
| return 'png'; | ||
| } | ||
| if (bytes.subarray(0, 5).toString('latin1') === '%PDF-') { | ||
| return 'pdf'; | ||
| } | ||
| return ''; | ||
| } | ||
|
|
||
| function extensionOf(name) { | ||
| const lower = name.toLowerCase(); | ||
| if (fixed) { | ||
| const dot = lower.lastIndexOf('.'); | ||
| return dot > 0 ? lower.slice(dot + 1) : ''; | ||
| } | ||
| // Mistake 3: an allowed extension anywhere in the name counts, so | ||
| // invoice.jpg.exe is taken for a picture. | ||
| return allowed.find((ext) => lower.includes(`.${ext}`)) ?? ''; | ||
| } | ||
|
|
||
| function verdict(name, bytes) { | ||
| // Mistake 1: the server never looks at the size. The page does, so a test | ||
| // through the page sees a limit that a request straight to the server does not. | ||
| if (fixed && bytes.length > limit) { | ||
| return { status: 413, reason: 'size_limit' }; | ||
| } | ||
| const extension = extensionOf(name); | ||
| if (!allowed.includes(extension)) { | ||
| return { status: 422, reason: 'extension_rule' }; | ||
| } | ||
| const content = sniff(bytes); | ||
| if (!allowed.includes(content)) { | ||
| return { status: 415, reason: 'content_malformed' }; | ||
| } | ||
| // Mistake 2: the content and the extension are each checked against the | ||
| // allowed list and never against each other, so a PDF named .jpg goes through. | ||
| if (fixed && content !== extension) { | ||
| return { status: 415, reason: 'mime_mismatch' }; | ||
| } | ||
| return { status: 201 }; | ||
| } | ||
|
|
||
| // The page carries the limit and the mode, and page.js reads them from it. | ||
| const page = readFileSync(new URL('index.html', import.meta.url), 'utf8') | ||
| .replace('{{limit}}', String(limit)) | ||
| .replace('{{fixed}}', fixed ? '1' : ''); | ||
| const script = readFileSync(new URL('page.js', import.meta.url), 'utf8'); | ||
|
|
||
| async function upload(req, res) { | ||
| const request = new Request(`http://localhost${req.url}`, { | ||
| method: 'POST', headers: req.headers, body: Readable.toWeb(req), duplex: 'half', | ||
| }); | ||
| const file = (await request.formData()).get('file'); | ||
| if (!file || typeof file === 'string') { | ||
| return answer(res, 400, { reason: 'none' }); | ||
| } | ||
| const { status, reason } = verdict(file.name, Buffer.from(await file.arrayBuffer())); | ||
| return answer(res, status, reason ? { reason } : { stored: file.name }); | ||
| } | ||
|
|
||
| function answer(res, status, body) { | ||
| res.writeHead(status, { 'content-type': 'application/json' }); | ||
| res.end(JSON.stringify(body)); | ||
| } | ||
|
|
||
| http.createServer((req, res) => { | ||
| if (req.method === 'GET' && req.url === '/') { | ||
| res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); | ||
| res.end(page); | ||
| return; | ||
| } | ||
| if (req.method === 'GET' && req.url === '/page.js') { | ||
| res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); | ||
| res.end(script); | ||
| return; | ||
| } | ||
| if (req.method === 'POST' && req.url === '/upload') { | ||
| upload(req, res).catch((err) => answer(res, 500, { reason: 'none', error: String(err) })); | ||
| return; | ||
| } | ||
| res.writeHead(404); | ||
| res.end(); | ||
| }).listen(port, () => console.log(`upload form on http://localhost:${port}${fixed ? ', mistakes fixed' : ''}`)); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // Runs the example twice and says whether it shows what it is for. | ||
| // | ||
| // With the four mistakes, the tests from the generated set fail on exactly the | ||
| // files each mistake lets through or turns away, and every typical test passes. | ||
| // With FIXED=1, nothing fails. The second run is the control: without it, red | ||
| // tests could come from the tests themselves rather than from the app. | ||
|
|
||
| import { spawnSync } from 'node:child_process'; | ||
| import { existsSync, readFileSync, rmSync } from 'node:fs'; | ||
| import { createRequire } from 'node:module'; | ||
|
|
||
| // Playwright's own command line, run by this Node rather than through npx and | ||
| // a shell, which Windows would need for npx. | ||
| const playwright = createRequire(import.meta.url).resolve('@playwright/test/cli'); | ||
|
|
||
| // Each mistake in app/server.mjs, and the tests it turns red. | ||
| const mistakes = { | ||
| 'through the page': { | ||
| '1mb_at_limit.jpg': 4, | ||
| 'pdf_as_jpg.jpg': 2, | ||
| 'png_as_pdf.pdf': 2, | ||
| 'jpg_as_png.png': 2, | ||
| 'invoice.jpg.exe': 3, | ||
| }, | ||
| 'through the API': { | ||
| '1mb_over_1b.jpg': 1, | ||
| '1mb_far_over_2x.jpg': 1, | ||
| 'pdf_as_jpg.jpg': 2, | ||
| 'png_as_pdf.pdf': 2, | ||
| 'jpg_as_png.png': 2, | ||
| 'invoice.jpg.exe': 3, | ||
| }, | ||
| }; | ||
|
|
||
| const reportFile = 'results/check.json'; | ||
|
|
||
| // run runs the tests once and reads their report. Red tests end the run with | ||
| // a code other than nought, which is what this is here to see, so the code says | ||
| // nothing. A report left by an earlier run is removed first, or a run that | ||
| // never got as far as writing one would be read as that earlier run. | ||
| function run(fixed) { | ||
| rmSync(reportFile, { force: true }); | ||
| const done = spawnSync(process.execPath, [playwright, 'test', '--reporter=json'], { | ||
| stdio: ['ignore', 'ignore', 'inherit'], | ||
| env: { ...process.env, FIXED: fixed ? '1' : '', PLAYWRIGHT_JSON_OUTPUT_NAME: reportFile }, | ||
| }); | ||
| if (done.error || done.signal || !existsSync(reportFile)) { | ||
| const why = done.error?.message ?? (done.signal ? `it was stopped by ${done.signal}` : `it ended with code ${done.status} and wrote no ${reportFile}`); | ||
| throw new Error(`Playwright did not run the tests - ${why}. Run npm install and npx playwright install chromium, then try again.`); | ||
| } | ||
| const report = JSON.parse(readFileSync(reportFile, 'utf8')); | ||
| const tests = []; | ||
| const walk = (suite, titles) => { | ||
| for (const spec of suite.specs ?? []) { | ||
| for (const one of spec.tests) { | ||
| tests.push({ file: spec.file, describe: titles.at(-1) ?? '', title: spec.title, status: one.status }); | ||
| } | ||
| } | ||
| for (const child of suite.suites ?? []) { | ||
| walk(child, [...titles, child.title]); | ||
| } | ||
| }; | ||
| for (const suite of report.suites) { | ||
| walk(suite, []); | ||
| } | ||
| if (report.errors?.length) { | ||
| throw new Error(`The run did not get as far as its tests: ${report.errors.map((e) => e.message).join('\n')}`); | ||
| } | ||
| return tests; | ||
| } | ||
|
|
||
| const problems = []; | ||
| const say = (problem) => problems.push(problem); | ||
|
|
||
| for (const fixed of [false, true]) { | ||
| const mode = fixed ? 'with the mistakes fixed' : 'with the four mistakes'; | ||
| const tests = run(fixed); | ||
| const ran = tests.filter((t) => t.status !== 'skipped'); | ||
| const generated = tests.filter((t) => t.file.endsWith('upload.spec.ts')); | ||
| if (ran.length === 0 || generated.every((t) => t.status === 'skipped')) { | ||
| say(`${mode}: no test from the generated set ran`); | ||
| } | ||
| for (const t of tests) { | ||
| const name = t.title.split(' - expected ')[0]; | ||
| const mistake = mistakes[t.describe]?.[name]; | ||
| const shouldFail = !fixed && mistake !== undefined && t.file.endsWith('upload.spec.ts'); | ||
| const failed = t.status === 'unexpected'; | ||
| if (shouldFail && !failed) { | ||
| say(`${mode}: ${t.describe} - ${name} should be red for mistake ${mistake} and is ${t.status}`); | ||
| } | ||
| if (!shouldFail && failed) { | ||
| say(`${mode}: ${t.describe ? `${t.describe} - ` : ''}${t.title} failed, and nothing in the app should make it`); | ||
| } | ||
| } | ||
| const red = tests.filter((t) => t.status === 'unexpected').length; | ||
| const skipped = tests.filter((t) => t.status === 'skipped').length; | ||
| console.log(`${mode}: ${red} red, ${tests.length - red - skipped} green, ${skipped} skipped as unspecified`); | ||
| } | ||
|
|
||
| const found = new Set(Object.values(mistakes).flatMap((files) => Object.values(files))); | ||
| for (const n of [1, 2, 3, 4]) { | ||
| if (!found.has(n)) { | ||
| say(`mistake ${n} has no test that finds it`); | ||
| } | ||
| } | ||
|
|
||
| if (problems.length > 0) { | ||
| console.error(`The example does not show what it is for:\n- ${problems.join('\n- ')}`); | ||
| process.exit(1); | ||
| } | ||
| console.log('The generated set finds all four mistakes, the typical tests find none, and with the mistakes fixed nothing is red.'); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make the "mistake has no test that finds it" check depend on the test report.
foundis built from the staticmistakestable. Every number 1-4 is in that table, so this loop cannot fail unless someone edits the table. The per-test loop also checks only tests that appear in the report. If the preset stops producing a listed file, for exampleinvoice.jpg.exe, no test exists. Nothing reports it, and the script still prints that the set finds all four mistakes. The control can therefore pass without detecting the mistake.Record which listed tests went red in the default run. Report each listed test that did not.
Add this before the
for (const fixed ...)loop:Add this inside the per-test loop, after
const failed = ...:Proposed fix for lines 100-105
As per path instructions: "A guard that cannot fail is the most valuable finding."
Source: Path instructions