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
393 changes: 393 additions & 0 deletions .github/workflows/npm.yml

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ because it turns other people's test suites red.

### Added

- **tfg is on npm, for tests in Node.**
`npm install --save-dev testing-files-generator` gives Playwright, Vitest
and `node:test` a generated set of files, and says what the system under
test should do with each one. A test written for a file fails with that
file's own sentence on what the difference means. The package installs the
`tfg` of the same release for Windows and Linux on x64 and Arm and for macOS
on Apple silicon, and `npx tfg` runs it. [The example](examples/playwright)
is an upload form with four mistakes, and the generated set finds all four.

- **How much memory a run may use is yours to set.** `--memory` on
`generate`, `validate` and `preset show`, and Memory on the window's
Preferences screen: `auto` (half of this machine or container), a size
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ The files land in the current directory. On Linux add
belong to you. With no directory mounted the run stops with exit code 5
instead of writing files that disappear with the container.

**With npm, for tests in Node.** The
[testing-files-generator](npm/testing-files-generator/README.md) package
gives Playwright, Vitest and `node:test` a generated set and what the system
under test should do with each file, and installs the `tfg` of the same
release for your system:

```
npm install --save-dev testing-files-generator
npx tfg version
```

It comes for Windows and Linux on x64 and Arm, and for macOS on Apple silicon.

**With Go installed:**

```
Expand Down
21 changes: 21 additions & 0 deletions internal/guard/mutation/entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -16888,6 +16888,27 @@
"; unset it to use",
"TestTheTextOfThePackageIsAsciiWithPlainPunctuation",
),
(
"a system's npm package goes to npm without being installed on that system",
".github/workflows/npm.yml",
" - package: linux-arm64\n runner: ubuntu-24.04-arm\n",
"",
"TestEverySystemThePackageKnowsIsTriedBeforeAnythingIsStaged",
),
(
"npm.yml tries a package the npm package installs nothing from",
".github/workflows/npm.yml",
" - package: win32-arm64\n",
" - package: windows-arm64\n",
"TestEverySystemThePackageKnowsIsTriedBeforeAnythingIsStaged",
),
(
"the npm packages are staged before they were tried",
".github/workflows/npm.yml",
" needs: [which, try]\n if: needs.which.outputs.stage == 'true'",
" needs: [which, assemble]\n if: needs.which.outputs.stage == 'true'",
"TestEverySystemThePackageKnowsIsTriedBeforeAnythingIsStaged",
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
(
"the linked npm package changes its licence while the dependency gate still lets it through as MPL-2.0",
"npm/testing-files-generator/package.json",
Expand Down
53 changes: 45 additions & 8 deletions internal/guard/npmpackage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,42 @@ func TestThePackageKnowsEveryPlatformTheReleaseBuilds(t *testing.T) {
}
}

// Every package of a system is installed and run on that system before
// anything goes to npm (.github/workflows/npm.yml), and nothing is staged until
// every one of them has. The systems tried are written out in the workflow,
// one runner each, so they are held to lib/binary.js here: a system missing
// there goes to npm untried, and a system the package does not know is a run
// that tries nothing. docs/NPM-PUBLISH-2026-11-09.md.
func TestEverySystemThePackageKnowsIsTriedBeforeAnythingIsStaged(t *testing.T) {
workflow := npmFile(t, ".github/workflows/npm.yml")
var tried []string
for _, m := range regexp.MustCompile(`(?m)^\s+- package: ([a-z0-9-]+)\s*$`).FindAllStringSubmatch(workflow, -1) {
tried = append(tried, m[1])
}
var known []string
binary := npmFile(t, npmPackage+"/lib/binary.js")
for _, m := range regexp.MustCompile(`'[a-z0-9]+ [a-z0-9]+': '@testing-files-generator/([a-z0-9-]+)'`).FindAllStringSubmatch(binary, -1) {
known = append(known, m[1])
}
if len(tried) == 0 || len(known) == 0 {
t.Fatalf("read %d systems tried in npm.yml and %d known to lib/binary.js - this guard no longer reads them", len(tried), len(known))
}
sort.Strings(tried)
sort.Strings(known)
if !reflect.DeepEqual(tried, known) {
t.Errorf("npm.yml tries the packages of %v, and the package installs a binary for %v", tried, known)
}

stage := regexp.MustCompile(`(?ms)^ stage:\n(.*?)(?:^ [a-z][a-z-]*:\n|\z)`).FindStringSubmatch(workflow)
if stage == nil {
t.Fatal("npm.yml has no stage job - this guard no longer reads it")
}
needs := regexp.MustCompile(`(?m)^ needs: \[([^\]]*)\]`).FindStringSubmatch(stage[1])
if needs == nil || !strings.Contains(", "+needs[1]+",", " try,") {
t.Errorf("the stage job of npm.yml does not wait for the try job, so a package can reach npm untried: needs %v", needs)
}
}

// The dependency gate lets the package through by name, because the graph has
// no licence for a directory linked with file: (#220). That is only true while
// the package is what the gate's exception says it is.
Expand Down Expand Up @@ -218,14 +254,15 @@ var npmTextRoots = []string{npmPackage + "/", "examples/playwright/"}
// header value, and judging it as prose would be crying wolf - the same line
// goFileFaults draws for Go.
var npmLiterals = map[string]bool{
npmPackage + "/index.js": true,
npmPackage + "/bin/tfg.js": true,
npmPackage + "/lib/binary.js": true,
npmPackage + "/lib/fileset.js": true,
npmPackage + "/lib/run.js": true,
npmPackage + "/lib/testfile.js": true,
"examples/playwright/app/page.js": true,
"examples/playwright/check.mjs": true,
npmPackage + "/index.js": true,
npmPackage + "/bin/tfg.js": true,
npmPackage + "/lib/binary.js": true,
npmPackage + "/lib/fileset.js": true,
npmPackage + "/lib/run.js": true,
npmPackage + "/lib/testfile.js": true,
npmPackage + "/release/assemble.js": true,
"examples/playwright/app/page.js": true,
"examples/playwright/check.mjs": true,
}

func npmTextFiles(t *testing.T) []string {
Expand Down
7 changes: 3 additions & 4 deletions npm/testing-files-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ Real files of exact sizes and formats for your tests, and what the system under

A PDF of exactly 1 MiB and one byte is a real PDF, so a server that looks inside it can only turn it away for its size. A file made with `Buffer.alloc` is zeros, so the same server turns it away for what it holds, and a test meant to prove a size limit passes on a server that has none. [The example](https://github.com/donislawdev/TestingFilesGenerator/tree/main/examples/playwright) shows exactly that.

> **Not on npm yet.** It will be published with a release of tfg. Until then, [the example](https://github.com/donislawdev/TestingFilesGenerator/tree/main/examples/playwright) runs it from the repository.

## Contents

- [What it is](#what-it-is)
Expand Down Expand Up @@ -43,7 +41,7 @@ npm install --save-dev testing-files-generator
```

- **Node 22 or later.** Types for TypeScript come with the package, and both `import` and `require` work.
- **The tfg program comes in a second package** that npm picks for your system: Windows on x64 and Arm, Linux on x64 and Arm, macOS on Apple silicon. Nothing runs when the package is installed, and nothing is downloaded when the tests run.
- **The tfg program comes in a second package** that npm picks for your system: Windows on x64 and Arm, Linux on x64 and Arm, macOS on Apple silicon. Nothing runs when the package is installed, and nothing is downloaded when the tests run. The program is the one from the tfg release of the same version: signed on Windows, and on macOS signed and notarised inside the app it was signed in.
- **Do not install with `--omit=optional`.** npm leaves that second package out with it, and the tests then stop with a message saying so. On a system with no package of its own, such as an Intel Mac, see [The tfg program](#the-tfg-program).

## Your first test
Expand Down Expand Up @@ -382,7 +380,7 @@ Plans a set and gives it back. Nothing is written until a test asks for a file.
| `preset` | a preset by name, as `npx tfg preset list` lists them |
| `recipe` | the path of a recipe file. A relative path is taken from the directory the tests were started in |
| `options` | the parameters of the preset and the flags of `tfg generate`, without the dashes |
| `cacheDir` | where sets are kept. By default `TFG_CACHE_DIR`, then `node_modules/.cache/testing-files-generator`, then the system's temporary directory |
| `cacheDir` | where sets are kept. By default `TFG_CACHE_DIR`, then `node_modules/.cache/testing-files-generator`, then a directory of your own in the system's temporary directory, which nobody else can write to |
| `binary` | the tfg to run. By default `TFG_BINARY`, then the one this package installed |

`fileSet` throws at once, while the spec file loads, when the binary or the recipe is not there or tfg refuses the plan. Two calls with the same spec in one process share one plan and one write.
Expand Down Expand Up @@ -440,6 +438,7 @@ Every message names what it is about and what to do. The ones you are most likel
| `The binary for ... comes in the package ..., and it is not installed` | npm installed with `--omit=optional`. Install again without it |
| `testing-files-generator has no binary for ...` | This system has no package of tfg. See [The tfg program](#the-tfg-program) |
| `tfg wrote a manifest of version ...` | `TFG_BINARY` points at a tfg whose manifest this package cannot read. Point it at a tfg of the same version as this package, or leave it unset |
| `... where testing-files-generator keeps sets when it is given no other place, ...` | The directory in the system's temporary directory belongs to someone else, or others can write to it, so they could change your files. Set `TFG_CACHE_DIR` or `cacheDir` to a directory of your own |
| `testing-files-generator: 1 unfinished set was left in ...` | A warning. A run stopped part way, and what it left can be deleted |

## The tfg program
Expand Down
2 changes: 1 addition & 1 deletion npm/testing-files-generator/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export interface FileSetSpec {
* { limit: '2mb', bulk: 0, seed: 7 }. npx tfg preset show lists what a preset takes.
*/
options?: Record<string, OptionValue>;
/** Where sets are kept. 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 a directory of your own in the system's temporary directory. */
cacheDir?: string;
/** The tfg binary to run. TFG_BINARY, or the one this package installed. */
binary?: string;
Expand Down
21 changes: 18 additions & 3 deletions npm/testing-files-generator/lib/binary.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ const platforms = {
'darwin arm64': '@testing-files-generator/darwin-arm64',
};

// binaryIn is where the binary sits inside the package of a system, as the
// release archive for that system lays it out. On macOS that is inside the app
// the release signed and notarised: its signature covers the app's Info.plist,
// and macOS kills the binary taken out on its own (exit 137, measured
// 2026-11-09 on macOS 27.2 - docs/NPM-PUBLISH-2026-11-09.md).
function binaryIn(platform) {
switch (platform) {
case 'win32':
return 'tfg.exe';
case 'darwin':
return 'tfg.app/Contents/MacOS/tfg';
default:
return 'tfg';
}
}

// binaryPath finds the tfg binary this package runs.
//
// TFG_BINARY comes first and is taken as given - a tfg installed another way,
Expand All @@ -43,9 +59,8 @@ function binaryPath({ env = process.env, platform = process.platform, arch = pro
'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 {
return resolve(`${pkg}/${exe}`);
return resolve(`${pkg}/${binaryIn(platform)}`);
} catch (err) {
// Only a package that is not there is a matter of --omit. Anything else,
// such as a package that does not export the binary, is told as it is,
Expand All @@ -60,4 +75,4 @@ function binaryPath({ env = process.env, platform = process.platform, arch = pro
}
}

module.exports = { binaryPath, platforms };
module.exports = { binaryIn, binaryPath, platforms };
47 changes: 45 additions & 2 deletions npm/testing-files-generator/lib/fileset.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,50 @@ function defaultCacheDir() {
if (fs.existsSync(modules)) {
return path.join(modules, '.cache', 'testing-files-generator');
}
return path.join(os.tmpdir(), 'testing-files-generator');
return ownTempDir();
}

// ownTempDir is the last place sets go: a directory of this user's own in the
// system's temporary directory. On Linux that directory is shared by every
// user of the machine, and a directory of a fixed name there could be made
// first by someone else, who could then change the files the tests run on. So
// the name carries the user's id, a new one is open to its owner alone, and
// one already there is taken only when it is a directory of this user that
// nobody else can write to. Windows and macOS give each user a temporary
// directory of their own, and Windows has no id to ask for - a uid of null.
// docs/NPM-PUBLISH-2026-11-09.md, code scanning alerts 23 and 24.
function ownTempDir({ tmp = os.tmpdir(), uid = process.getuid ? process.getuid() : null } = {}) {
if (uid === null) {
return path.join(tmp, 'testing-files-generator');
}
const dir = path.join(tmp, `testing-files-generator-${uid}`);
let stat;
try {
stat = fs.lstatSync(dir);
} catch (err) {
if (err.code !== 'ENOENT') {
throw err;
}
// Asked again after it is made, because someone else can make it first.
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
stat = fs.lstatSync(dir);
}
let fault = '';
if (stat.isSymbolicLink()) {
fault = 'is a link to another place';
} else if (!stat.isDirectory()) {
fault = 'is not a directory';
} else if (stat.uid !== uid) {
fault = 'belongs to another user';
} else if ((stat.mode & 0o022) !== 0) {
fault = 'can be written to by other users';
}
if (fault) {
throw new Error(`${dir}, where testing-files-generator keeps sets when it is given no other place, ${fault}, ` +
'so someone else could change the files your tests run on. ' +
'Set TFG_CACHE_DIR or the cacheDir option to a directory of your own, or remove that one.');
}
return dir;
}

function labelOf(spec) {
Expand Down Expand Up @@ -457,4 +500,4 @@ function matches(file, filter = {}) {
return has(filter.group, file.group) && has(filter.target, file.target) && has(filter.outcome, file.expected.outcome);
}

module.exports = { events, fileSet, readable };
module.exports = { events, fileSet, ownTempDir, readable };
9 changes: 7 additions & 2 deletions npm/testing-files-generator/lib/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,17 @@ function notStarted(binary, err) {
// gives a child process by default. The error stream goes to a file of its own
// for the same reason - a buffer that fills stops tfg part way - and only its
// end is kept.
//
// Both files are made new and readable by their owner alone, so neither a file
// nor a link someone else left under the same name is written through. The
// names are drawn at random by the caller, and the directory may be the
// system's temporary one.
function runToFile(binary, args, outFile) {
const errFile = `${outFile}.err`;
const out = fs.openSync(outFile, 'w');
const out = fs.openSync(outFile, 'wx', 0o600);
let err;
try {
err = fs.openSync(errFile, 'w+');
err = fs.openSync(errFile, 'wx+', 0o600);
const done = spawnSync(binary, args, { stdio: ['ignore', out, err], windowsHide: true });
if (done.error) {
throw notStarted(binary, done.error);
Expand Down
Loading
Loading