Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions examples/playwright/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,25 @@

## Run it

From the root of the repository, build tfg first. The example finds it there.
You need Node 22 or later and the tfg program. Inside the repository the package is linked rather than installed from npm, so tfg does not come with it here. Get it one of two ways:

```bash
go build -tags "$(cat .github/build-tags)" -o "tfg$(go env GOEXE)" ./cmd/tfg
```
- **Download it.** Take the archive for your system from [the latest release](https://github.com/donislawdev/TestingFilesGenerator/releases/latest), such as `tfg_<version>_windows_amd64.zip`, unpack it, and set `TFG_BINARY` to the `tfg` inside, `tfg.exe` on Windows:

```bash
export TFG_BINARY=/path/to/tfg
```

```powershell
$env:TFG_BINARY = 'C:/path/to/tfg.exe'
```

- **Build it with Go**, from the root of the repository. The example finds it there:

```bash
go build -tags "$(cat .github/build-tags)" -o "tfg$(go env GOEXE)" ./cmd/tfg
```

Then install the example and the browser it drives, and run it:

```bash
cd examples/playwright && npm install && npx playwright install chromium
Expand All @@ -37,4 +51,12 @@ 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.

`npx playwright show-report results/html` opens the report, where every test of the set carries what its file expects and what the file is for.

`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.

## What to read, in this order

1. `app/server.mjs` and `app/page.js` - the form, with each mistake marked where it is made.
2. `tests/typical.spec.ts` - the tests such a form usually gets, which all pass.
3. `tests/upload.spec.ts` - the generated set, through the page and through the API. How the package works is in [its README](../../npm/testing-files-generator#readme).
3 changes: 2 additions & 1 deletion examples/playwright/tests/upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test.describe('through the page', () => {
await page.getByLabel('File').setInputFiles(await file.path());
await page.getByRole('button', { name: 'Upload' }).click();
const status = page.getByRole('status');
await expect(status).not.toBeEmpty();
await expect(status).toHaveText(/^(Uploaded|Refused \(\w+\))$/);
file.check(shown(await status.textContent()));
});
}
Expand All @@ -33,6 +33,7 @@ test.describe('through the API', () => {
test(file.title, file.details, async ({ request }) => {
test.skip(file.unspecified, file.whyUnspecified);
const response = await request.post('/upload', { multipart: { file: createReadStream(await file.path()) } });
expect(response.status(), 'a server error is not a refusal').toBeLessThan(500);
const answer = await response.json();
file.check(response.ok() ? 'accept' : { outcome: 'reject', reason: answer.reason });
});
Expand Down
437 changes: 394 additions & 43 deletions npm/testing-files-generator/README.md

Large diffs are not rendered by default.

90 changes: 79 additions & 11 deletions npm/testing-files-generator/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

/** What a system under test is expected to do with a file. */
/**
* What a system under test is expected to do with a file. accept is to take it, reject to turn it away,
* sanitize to take it and change it, and unspecified means there is no right answer until you decide one.
*/
export type Outcome = 'accept' | 'reject' | 'sanitize' | 'unspecified';

/** The rule a file is about. It names the rule and not what the system did, so a file a byte under a limit is expected to be accepted with size_limit. */
/** The rule a file is about, from a closed list. It names the rule and not what a system did with the file. */
export type Reason =
| 'content_malformed'
| 'count_limit'
Expand All @@ -23,107 +26,172 @@ export type Reason =
| 'size_limit'
| 'size_zero';

/** What a file expects of the system under test, as the manifest has it. */
export interface Expected {
/** What the system should do with the file. */
outcome: Outcome;
/** The rule the file is about. A file to be turned away usually names one. */
reason?: Reason;
/** A sentence about the expectation, such as why a file expects nothing. */
detail?: string;
/** certain when the expectation was declared, policy_dependent when nobody declared one. */
confidence?: string;
}

/** One entry of a manifest, as tfg writes it. */
/** One entry of a manifest, as tfg writes it. It carries more fields than are named here. */
export interface ManifestFile {
/** The file's identity within the run. */
id: string;
/** The file's path within the set, with forward slashes. */
path: string;
/** The file's name. */
name: string;
/** Whether the file is on the disk. Every file of a plan has false. */
materialized: boolean;
/** The recipe target that made the file. */
target_id: string;
/** The file's exact size in bytes. */
bytes: number;
/** The file's format, such as jpg. */
format: string;
/** The part of the set the file belongs to, such as size-limit. */
group?: string;
/** One sentence on what the file is for and what it means when a system gets it wrong. */
purpose?: string;
/** What the system under test should do with the file. */
expected: Expected;
[field: string]: unknown;
}

/** A manifest, as tfg writes it. For a plan nothing is written yet and every file has materialized false. */
export interface Manifest {
/** The version of the manifest's format. This package reads major version 1. */
manifest_version: string;
/** The program that wrote the manifest and its version. */
tool: { name: string; version: string; generators: Record<string, string>; [field: string]: unknown };
/** The run that made the set, and the seed every file's bytes come from. */
run: { id: string; seed: number; complete: boolean; recipe_hash?: string; [field: string]: unknown };
/** How many files the set has and how many of them were written. */
summary: { file_count: number; total_bytes: number; materialized: number; [field: string]: unknown };
/** Every file of the set, in order. */
files: ManifestFile[];
[field: string]: unknown;
}

/** The value of one option. true stands for a flag with no value, and an array repeats the flag. */
export type OptionValue = string | number | boolean | ReadonlyArray<string | number>;

/** What a set is made of, and where it is kept. Name a preset, a recipe or the options of a set. */
export interface FileSetSpec {
/** A preset, as tfg preset list names it. */
/** A preset, as npx tfg preset list names them, such as upload-validation. */
preset?: string;
/** A recipe file. A relative path is taken from the directory the tests were started in. */
/**
* A recipe file. A relative path is taken from the directory the tests were started in,
* so path.join(__dirname, 'uploads.yaml') names one beside the spec file. In an ES module,
* import.meta.dirname takes the place of __dirname.
*/
recipe?: string;
/** Flags of tfg generate and of the preset, without the dashes: { limit: '2mb', seed: 7, clean: true }. */
/**
* The parameters of the preset and the flags of tfg generate, without the dashes, such as
* { limit: '2mb', bulk: 0, seed: 7 }. npx tfg preset show lists what a preset takes.
*/
options?: Record<string, OptionValue>;
/** Where sets are written. TFG_CACHE_DIR, or node_modules/.cache/testing-files-generator, or the system's temporary directory. */
/** Where sets are kept. TFG_CACHE_DIR, or node_modules/.cache/testing-files-generator, or the system's temporary directory. */
cacheDir?: string;
/** The tfg binary to run. TFG_BINARY, or the one this package installed. */
binary?: string;
}

/** Which files to take from a set. Each field takes one name or a list, and a file has to match every field given. */
export interface Filter {
/** The group, such as bulk-upload. */
group?: string | readonly string[];
/** The recipe target. */
target?: string | readonly string[];
/** The expected outcome. */
outcome?: Outcome | readonly Outcome[];
}

/** What the system under test did with a file: one word, or the outcome with the reason the system gave. */
export type Observed =
| Exclude<Outcome, 'unspecified'>
| { outcome: Exclude<Outcome, 'unspecified'>; reason?: string };

/** The shape test(title, details, body) takes in Playwright. */
export interface TestDetails {
/** The expected outcome and the group, such as @reject and @size-limit, for --grep. */
tag: string[];
/** What the file expects and its purpose, which the report shows beside the test. */
annotation: Array<{ type: string; description: string }>;
}

/** One file of a set, with what the system under test should do with it. */
export interface TestFile {
/** The file's whole entry in the manifest. */
readonly entry: ManifestFile;
/** The file's path within the set. */
readonly relativePath: string;
/** The file's name. */
readonly name: string;
/** The file's format, such as jpg. */
readonly format: string;
/** The file's exact size in bytes. */
readonly bytes: number;
/** The part of the set the file belongs to, or an empty string. */
readonly group: string;
/** The recipe target that made the file. */
readonly target: string;
/** One sentence on what the file is for and what it means when a system gets it wrong. A failure shows it. */
readonly purpose: string;
/** What the system under test should do with the file. */
readonly expected: Expected;
/** True for a file with no right answer. Skip it with test.skip(file.unspecified, file.whyUnspecified). */
readonly unspecified: boolean;
/** The sentence a skip shows for a file with no right answer. */
readonly whyUnspecified: string;
/** The file's path and what it is expected to meet. Unique within a set. */
/** The file's path and what it expects, such as 2mb_over_1b.jpg - expected reject (size_limit). Unique within a set. */
readonly title: string;
/** Tags and annotations, as the second argument of test(title, details, body). */
readonly details: TestDetails;
/** Writes the set the first time any of its files is asked for, and gives this file's absolute path. */
path(): Promise<string>;
/** Throws when what the system did differs from what is expected, with the sentence that says what the difference means. */
/**
* Compares what the system did with what the file expects, and throws when they differ, with the sentence
* that says what the difference means. A reason is compared only for a file to be turned away, and only when
* one is given. On a file with no right answer it always throws.
*/
check(observed: Observed): void;
}

/** A set of files, or a part of one. Every part shares the set's one write. */
export interface FileSet {
/** Every file of this set or part, in order. */
readonly files: readonly TestFile[];
/** The manifest tfg gave for the plan of the whole set. Nothing in it is written yet. */
readonly plan: Manifest;
/** The files that match the filter. */
only(filter: Filter): FileSet;
/** The files that do not match the filter. */
except(filter: Filter): FileSet;
/** Writes the whole set once per process and gives its directory. */
/** Writes the whole set once per process, or takes a set kept from an earlier run, and gives its directory. */
write(): Promise<string>;
/** Writes the set if it is not written yet, and gives the absolute paths of this part's files. */
paths(): Promise<string[]>;
}

/** Plans a set of files, writing nothing. The files are written the first time one of them is asked for. */
/**
* Plans a set of files, writing nothing, so tests can be declared from it while the spec file loads.
* The files are written the first time one of them is asked for.
*/
export function fileSet(spec: FileSetSpec): FileSet;

/** The tfg binary this package runs. */
export function binaryPath(): string;

/** tfg ending with a code other than nought, or stopped by a signal. */
export class TfgError extends Error {
/** The code tfg ended with, or the name of the signal that stopped it. */
readonly status: number | string;
/** The end of what tfg wrote to its error stream. */
readonly stderr: string;
}
4 changes: 2 additions & 2 deletions npm/testing-files-generator/lib/binary.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ function binaryPath({ env = process.env, platform = process.platform, arch = pro
if (!pkg) {
throw new Error(`testing-files-generator has no binary for ${platform} ${arch}. ` +
`It comes for ${known}. ` +
'On another system, run tfg from its container image ghcr.io/donislawdev/tfg, ' +
'or install it another way and set TFG_BINARY to its path.');
'On another system, run the tests in a Linux container, where npm installs the package for Linux, ' +
'or install tfg another way and set TFG_BINARY to its path.');
}
const exe = platform === 'win32' ? 'tfg.exe' : 'tfg';
try {
Expand Down
58 changes: 52 additions & 6 deletions npm/testing-files-generator/lib/fileset.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { inspect } = require('node:util');

const { binaryPath } = require('./binary.js');
const { TfgError, run, runToFile } = require('./run.js');
const { TfgError, ended, run, runToFile, said } = require('./run.js');
const { TestFile, outcomes } = require('./testfile.js');

// The manifest major this package reads. Another major is refused rather than
Expand Down Expand Up @@ -55,20 +56,26 @@ function fileSet(spec = {}) {
if (typeof spec !== 'object' || spec === null) {
throw new TypeError('fileSet takes an object, for example { preset: \'upload-validation\', options: { limit: \'2mb\' } }.');
}
// Told from the flags and not from the spec: an option set to false or to an
// empty list adds no flag, and tfg asked for nothing answers in flags.
const args = argsOf(spec);
if (args.length === 1) {
throw new Error('fileSet was not told which files to write. Name a preset, a recipe or the options of a set, ' +
'for example { preset: \'upload-validation\' }. npx tfg preset list lists the presets.');
}
const binary = spec.binary === undefined ? binaryPath() : named(spec.binary, 'binary',
'Point it at a tfg binary, or leave it out to use the one this package installs.');
if (spec.recipe) {
named(path.resolve(spec.recipe), 'recipe',
`A relative path is taken from the directory the tests started in, here ${process.cwd()}, ` +
'so path.join(__dirname, ...) names a recipe beside the spec file.');
'so path.join(__dirname, ...) names a recipe beside the spec file, or path.join(import.meta.dirname, ...) in an ES module.');
}
const args = argsOf(spec);
const cacheDir = path.resolve(spec.cacheDir ?? defaultCacheDir());
const recipe = spec.recipe ? fs.readFileSync(path.resolve(spec.recipe), 'utf8') : '';
const key = digest([identity(binary), args, recipe, cacheDir]);
let source = sources.get(key);
if (!source) {
source = new Source(binary, args, cacheDir, labelOf(spec));
source = new Source(binary, args, cacheDir, spec);
sources.set(key, source);
}
return new FileSet(source, source.files);
Expand Down Expand Up @@ -127,6 +134,18 @@ function labelOf(spec) {
return name.replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 40);
}

// described is the call as the test wrote it, for a message - what makes the
// set, without where it is kept or which binary writes it.
function described(spec) {
const shown = {};
for (const key of ['preset', 'recipe', 'options']) {
if (spec[key] !== undefined) {
shown[key] = spec[key];
}
}
return `fileSet(${inspect(shown, { breakLength: Infinity, depth: Infinity })})`;
}

// identity is what tells two binaries apart without running them, so a new
// build or another version plans again rather than reusing an old plan.
function identity(binary) {
Expand All @@ -145,17 +164,19 @@ function scratch(cacheDir) {
// Source is one set: its plan, where it is written, and the one write a process
// makes of it however many files ask.
class Source {
constructor(binary, args, cacheDir, label) {
constructor(binary, args, cacheDir, spec) {
this.binary = binary;
this.args = args;
this.cacheDir = cacheDir;
this.preset = spec.preset;
this.shown = described(spec);
fs.mkdirSync(cacheDir, { recursive: true });
noteUnfinished(cacheDir);
this.plan = this._planned();
// The recipe hash leaves out the seed and --clean, measured 2026-11-04, so
// the arguments are part of the name and two seeds never share a set.
const run = this.plan.run;
this.dir = path.join(cacheDir, `${label}-${digest([args, run.recipe_hash, run.seed, this.plan.tool]).slice(0, 20)}`);
this.dir = path.join(cacheDir, `${labelOf(spec)}-${digest([args, run.recipe_hash, run.seed, this.plan.tool]).slice(0, 20)}`);
this.files = this.plan.files.map((entry) => new TestFile(this, entry));
this._written = undefined;
}
Expand All @@ -169,6 +190,8 @@ class Source {
try {
runToFile(this.binary, [...this.args, `--out=${scratch(this.cacheDir)}`, '--dry-run', '--json'], out);
plan = JSON.parse(fs.readFileSync(out, 'utf8'));
} catch (err) {
throw err instanceof TfgError ? refusedPlan(this.shown, this.preset, err) : err;
} finally {
fs.rmSync(out, { force: true });
}
Expand Down Expand Up @@ -252,6 +275,29 @@ class Source {
}
}

// refusedPlan tells a plan tfg refused in the words the test was written in.
// The command this package ran names a scratch directory and flags nobody
// wrote, and an option tfg does not know brings the whole help of tfg
// generate, every flag behind a dash, where one sentence names the mistake.
// Everything tfg said stays on the error, in stderr. The help lists every
// flag of generate, and a preset or a recipe refuses some of them, so the
// list is not offered as what this call takes.
function refusedPlan(shown, preset, err) {
const unknown = /^flag provided but not defined: -+(\S+)/m.exec(err.stderr);
const known = [...err.stderr.matchAll(/^\s+-([a-z][a-z0-9-]*)\b/gm)]
.map((m) => m[1])
.filter((name) => !Object.hasOwn(reserved, name));
if (unknown && known.length > 0) {
const help = preset
? `npx tfg preset show ${preset} says what the preset's own options do, and npx tfg generate --help the rest.`
: 'npx tfg generate --help says what each one does.';
return new TfgError([], err.status, err.stderr,
`${shown} gives the option ${unknown[1]}, and tfg generate has no flag of that name. ` +
`Its flags are ${known.join(', ')}. Not every one goes with every set, and tfg says why when one does not. ${help}`);
}
return new TfgError([], err.status, err.stderr, `${shown} could not be planned - tfg ${ended(err.status)}.\n${said(err.stderr)}`);
}

// readable refuses a manifest this package cannot read for what it says.
function readable(plan) {
const version = String(plan?.manifest_version ?? '');
Expand Down
Loading
Loading