gatefit 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/README.md +30 -7
- package/package.json +4 -2
- package/src/access-adapter.ts +2 -0
- package/src/access-rules.ts +22 -3
- package/src/adapters/hardened.ts +2 -0
- package/src/adapters/sqlite.ts +26 -1
- package/src/chaos.ts +169 -0
- package/src/cli.ts +15 -3
- package/src/packs.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
All notable Gatefit changes are listed here. The heading
|
|
3
|
+
All notable Gatefit changes are listed here. The version heading matches `package.json`. Tag `v0.5.1` is not cut in this change.
|
|
4
|
+
|
|
5
|
+
## [0.5.1] - 2026-10-16
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `better-sqlite3` is optional and loads only when the SQLite adapter opens a database. `--version`, `--help`, and `adapter-check` run when that module is missing. A SQLite command that cannot load it prints what is missing, that the package ships prebuilds and does not compile on install, and how to reinstall the prebuild or run `npm run build-release`, then exits non-zero.
|
|
10
|
+
|
|
11
|
+
## [0.5.0] - 2026-10-16
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Chaos mode, a Pro pack. `gatefit run --suite chaos` replays every golden under seeded perturbations and compares the access row to the in-order replay. It requires `GATEFIT_LICENSE_KEY`.
|
|
16
|
+
- License gate (`GATEFIT_LICENSE_KEY`), first shipped in 0.4.0.
|
|
17
|
+
- Token-free npm trusted publishing. An annotated tag `vX.Y.Z` runs `.github/workflows/publish.yml` with GitHub OIDC. The publish job uses the GitHub environment `npm`. Provenance is automatic.
|
|
18
|
+
|
|
19
|
+
### Not in this version
|
|
20
|
+
|
|
21
|
+
- Provider packs.
|
|
22
|
+
- A monthly plan.
|
|
4
23
|
|
|
5
24
|
## [0.4.0] - 2026-10-16
|
|
6
25
|
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Fixture-first S1–S6 goldens, an `npx gatefit` CLI, and a GitHub Action. Node 2
|
|
|
9
9
|
The CLI and GitHub Action in this repository stay free. The docs site is live. npm is live: `npx gatefit` ([gatefit](https://www.npmjs.com/package/gatefit)). Polar checkout is.
|
|
10
10
|
|
|
11
11
|
- **Docs site:** live at https://gatefit.io. Cloudflare Pages project `gatefit`, hostname `gatefit-bdu.pages.dev`. From `site/`: `npm run build`, then `npx wrangler@4 pages deploy dist --project-name gatefit`. `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` come from the environment. GitHub Pages needs a public repository or a paid plan and is not the live host. `scripts/deploy-site.sh` can copy `site/dist` locally or deploy that Cloudflare target.
|
|
12
|
-
- **npm:** live. `npx gatefit` is `gatefit@0.
|
|
12
|
+
- **npm:** live. `npx gatefit` is the last published tarball (`gatefit@0.5.0` until tag `v0.5.1`) at https://www.npmjs.com/package/gatefit. This repo is `0.5.1`. The named gate runs `npm publish --dry-run` only. A `v*` tag publishes with OIDC. See [Releasing](#releasing).
|
|
13
13
|
- **Polar checkout:** live. Gatefit Indie License is **$49** one-time at `https://buy.polar.sh/polar_cl_biyvx7HYtN2u19tUJCCkIuGOc98uVv7IG3gcB04eIJ0`. A plain `site` build and `scripts/deploy-site.sh` use that committed link. `PUBLIC_POLAR_CHECKOUT_URL` may override it. Any other value fails closed.
|
|
14
14
|
|
|
15
15
|
## Install / run
|
|
@@ -20,7 +20,9 @@ npx gatefit adapter-check
|
|
|
20
20
|
npx gatefit run --suite paid_grants_access --adapter ./src/your-access-adapter.ts
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
`
|
|
23
|
+
This repo is `0.5.1`. Registry: https://www.npmjs.com/package/gatefit
|
|
24
|
+
|
|
25
|
+
`better-sqlite3` is optional and ships prebuilds for common platforms. It does not compile during `npm install`. `--version`, `--help`, and `adapter-check` run when the native module is missing. The reference SQLite adapter loads it only when that adapter runs. If the module is missing, that command tells you to reinstall the prebuild, or to install build tools and run `npm run build-release` in the `better-sqlite3` directory, then exits non-zero.
|
|
24
26
|
|
|
25
27
|
From a clone:
|
|
26
28
|
|
|
@@ -41,13 +43,13 @@ Bring your own AccessAdapter in ≤30 minutes: [docs/bring-your-adapter.md](docs
|
|
|
41
43
|
|
|
42
44
|
## GitHub Action
|
|
43
45
|
|
|
44
|
-
Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.
|
|
46
|
+
Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.5.0` is released. Tag `v0.5.1` is not cut yet. After a human pushes the annotated tag:
|
|
45
47
|
|
|
46
48
|
```yaml
|
|
47
49
|
- uses: actions/setup-node@v4
|
|
48
50
|
with:
|
|
49
51
|
node-version: "22"
|
|
50
|
-
- uses: gatefit/gatefit@v0.
|
|
52
|
+
- uses: gatefit/gatefit@v0.5.1
|
|
51
53
|
with:
|
|
52
54
|
adapter: src/your-access-adapter.ts
|
|
53
55
|
```
|
|
@@ -55,22 +57,43 @@ Marketplace-ready Action lives at the repo root (`action.yml`). Tag `v0.4.0` is
|
|
|
55
57
|
Nested path still works:
|
|
56
58
|
|
|
57
59
|
```yaml
|
|
58
|
-
- uses: gatefit/gatefit/.github/actions/gatefit@v0.
|
|
60
|
+
- uses: gatefit/gatefit/.github/actions/gatefit@v0.5.1
|
|
59
61
|
with:
|
|
60
62
|
adapter: src/your-access-adapter.ts
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
The Action wraps `gatefit run`. Adapter path is in *your* repo; goldens default to Gatefit’s S1–S6 pack. See [docs/publish.md](docs/publish.md) and the release
|
|
65
|
+
The Action wraps `gatefit run`. Adapter path is in *your* repo; goldens default to Gatefit’s S1–S6 pack. See [docs/publish.md](docs/publish.md) and the release notes [docs/release-v0.5.1.md](docs/release-v0.5.1.md).
|
|
64
66
|
|
|
65
67
|
Do not set `GATEFIT_LIVE_CLOCK` for the named gate. Do not use `sk_live`. Never treat Stripe HTTP 200 as proof of access.
|
|
66
68
|
|
|
67
69
|
Opt-in live Test Clocks (not default CI): [docs/live-clock.md](docs/live-clock.md).
|
|
68
70
|
|
|
71
|
+
## Releasing
|
|
72
|
+
|
|
73
|
+
Bump `version` in `package.json` and the lockfile `version` field, and add the matching `CHANGELOG.md` heading, in a pull request. Merge to `main`. Then push an annotated tag whose name is `v` plus that version:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git tag -a vX.Y.Z -m "vX.Y.Z"
|
|
77
|
+
git push origin vX.Y.Z
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The tag must match `v*`. The workflow fails when the tag without the leading `v` is not `package.json` `version`. That push runs `.github/workflows/publish.yml`: unit tests (`npm test`), the tarball checks (bin present, no `site/`, no secrets), then `npm publish --access public`. Provenance is automatic. The publish job requires the GitHub environment `npm`, so a required reviewer can be added there. `workflow_dispatch` runs the checks and does not publish. Do not set a registry token.
|
|
81
|
+
|
|
82
|
+
One-time setup on npmjs.com for package `gatefit`: open Package settings, Trusted Publisher, GitHub Actions. Organization or user `gatefit`, repository `gatefit`, workflow filename `publish.yml`, environment name `npm`, and allow `npm publish`. Then set Publishing access to **Require two-factor authentication and disallow tokens**.
|
|
83
|
+
|
|
84
|
+
`./ci-local.sh` stays the merge gate. It runs `npm publish --dry-run` and does not push tags.
|
|
85
|
+
|
|
69
86
|
## License
|
|
70
87
|
|
|
71
88
|
S1–S6, seats/races, dispute/unpaid, and live Test Clocks stay free. Those packs do not read `GATEFIT_LICENSE_KEY` and do not call Polar.
|
|
72
89
|
|
|
73
|
-
The $49 Gatefit Indie License unlocks Pro packs. Chaos mode is
|
|
90
|
+
The $49 Gatefit Indie License unlocks Pro packs. Chaos mode is live. Provider packs are not in this release. A missing, invalid, or revoked key exits non-zero and points at https://gatefit.io/#pricing.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
gatefit run --suite chaos --adapter src/adapters/hardened.ts --seed 1
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Chaos mode replays every golden under a seeded set of delivery perturbations and compares the access row to the in-order replay. The perturbations are duplicated deliveries, reordered events, stale events, late events, dropped-then-replayed events, and the checkout-return page loading before the webhook. HTTP 200 is not access. The seed defaults to `1`. `--seed` wins over `GATEFIT_CHAOS_SEED`. The same seed prints the same bytes. A failure prints that seed and the perturbed events. Details: [docs/chaos.md](docs/chaos.md).
|
|
74
97
|
|
|
75
98
|
```bash
|
|
76
99
|
export GATEFIT_LICENSE_KEY='GATEFIT-…'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gatefit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Stripe Entitlement Edge Pack — CI that asserts the access DB survives ugly billing transitions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,10 +46,12 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"better-sqlite3": "^13.0.3",
|
|
50
49
|
"tsx": "^4.20.5",
|
|
51
50
|
"yaml": "^2.8.1"
|
|
52
51
|
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"better-sqlite3": "^13.0.3"
|
|
54
|
+
},
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"@types/better-sqlite3": "^9.6.0",
|
|
55
57
|
"@types/node": "^22.18.0",
|
package/src/access-adapter.ts
CHANGED
package/src/access-rules.ts
CHANGED
|
@@ -3,10 +3,12 @@ import type { AccessSnapshot, BillingEvent } from "./access-adapter.ts";
|
|
|
3
3
|
export type AccessStore = {
|
|
4
4
|
rows: Map<string, AccessSnapshot>;
|
|
5
5
|
seen: Set<string>;
|
|
6
|
+
events: BillingEvent[];
|
|
7
|
+
arrival: number;
|
|
6
8
|
};
|
|
7
9
|
|
|
8
10
|
export function emptyStore(): AccessStore {
|
|
9
|
-
return { rows: new Map(), seen: new Set() };
|
|
11
|
+
return { rows: new Map(), seen: new Set(), events: [], arrival: 0 };
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function isPaid(event: BillingEvent): boolean {
|
|
@@ -96,9 +98,26 @@ export function nextSnapshot(current: AccessSnapshot, event: BillingEvent): Acce
|
|
|
96
98
|
return current;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
function foldCustomer(store: AccessStore, customerId: string): void {
|
|
102
|
+
const ordered = store.events.filter((event) => event.customerId === customerId);
|
|
103
|
+
ordered.sort((a, b) => {
|
|
104
|
+
const byCreated = (a.created ?? 0) - (b.created ?? 0);
|
|
105
|
+
if (byCreated !== 0) return byCreated;
|
|
106
|
+
if (a.id < b.id) return -1;
|
|
107
|
+
if (a.id > b.id) return 1;
|
|
108
|
+
return 0;
|
|
109
|
+
});
|
|
110
|
+
let snapshot: AccessSnapshot = { locked: true };
|
|
111
|
+
for (const event of ordered) snapshot = nextSnapshot(snapshot, event);
|
|
112
|
+
store.rows.set(customerId, snapshot);
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
export function applyBillingEvent(store: AccessStore, event: BillingEvent): void {
|
|
100
116
|
if (store.seen.has(event.id)) return;
|
|
101
117
|
store.seen.add(event.id);
|
|
102
|
-
const
|
|
103
|
-
|
|
118
|
+
const stamped: BillingEvent = { ...event };
|
|
119
|
+
if (stamped.created === undefined) stamped.created = store.arrival;
|
|
120
|
+
store.arrival += 1;
|
|
121
|
+
store.events.push(stamped);
|
|
122
|
+
foldCustomer(store, event.customerId);
|
|
104
123
|
}
|
package/src/adapters/hardened.ts
CHANGED
package/src/adapters/sqlite.ts
CHANGED
|
@@ -3,7 +3,31 @@ import type { AccessAdapter, AccessSnapshot, BillingEvent } from "../access-adap
|
|
|
3
3
|
import { nextSnapshot } from "../access-rules.ts";
|
|
4
4
|
|
|
5
5
|
const require = createRequire(import.meta.url);
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
function sqliteUnavailable(err: unknown): Error {
|
|
8
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
9
|
+
return new Error(
|
|
10
|
+
[
|
|
11
|
+
"gatefit: SQLite adapter cannot load better-sqlite3.",
|
|
12
|
+
"What is missing: the better-sqlite3 native module.",
|
|
13
|
+
"Why: better-sqlite3 is optional. Its package ships prebuilds for linux, darwin, and win32 (x64 and arm64) and does not compile during npm install. This process has no usable prebuild.",
|
|
14
|
+
"How to fix: reinstall so the prebuild is unpacked (npm install better-sqlite3, or reinstall gatefit). npm rebuild better-sqlite3 does not compile it. On a platform without a prebuild, install make and a C++ compiler (Xcode Command Line Tools on macOS, or build-essential on Linux), then in the better-sqlite3 directory run npm run build-release.",
|
|
15
|
+
"--version, --help, and adapter-check do not need this module.",
|
|
16
|
+
`Detail: ${detail}`,
|
|
17
|
+
].join("\n"),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadDatabase(): typeof import("better-sqlite3") {
|
|
22
|
+
try {
|
|
23
|
+
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
|
24
|
+
const probe = new Database(":memory:");
|
|
25
|
+
probe.close();
|
|
26
|
+
return Database;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw sqliteUnavailable(err);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
7
31
|
|
|
8
32
|
type AccessRow = {
|
|
9
33
|
customer_id: string;
|
|
@@ -24,6 +48,7 @@ function rowToSnapshot(row: AccessRow | undefined): AccessSnapshot {
|
|
|
24
48
|
|
|
25
49
|
export function createAdapter(): AccessAdapter {
|
|
26
50
|
const filename = process.env.GATEFIT_SQLITE_PATH ?? ":memory:";
|
|
51
|
+
const Database = loadDatabase();
|
|
27
52
|
const db = new Database(filename);
|
|
28
53
|
db.exec(`
|
|
29
54
|
CREATE TABLE IF NOT EXISTS access (
|
package/src/chaos.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { AccessSnapshot, BillingEvent } from "./access-adapter.ts";
|
|
3
|
+
import { loadAdapter } from "./access-adapter.ts";
|
|
4
|
+
import { listGoldens, loadGolden } from "./fixture-driver.ts";
|
|
5
|
+
import type { GoldenSuite } from "./fixture-driver.ts";
|
|
6
|
+
import { registerProPack } from "./packs.ts";
|
|
7
|
+
import { goldenDir, resolveUserOrPackage } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
export const CHAOS_DEFAULT_SEED = "1";
|
|
10
|
+
|
|
11
|
+
const PERTURBATIONS = [
|
|
12
|
+
"duplicate",
|
|
13
|
+
"reorder",
|
|
14
|
+
"stale",
|
|
15
|
+
"late",
|
|
16
|
+
"dropped_replay",
|
|
17
|
+
"checkout_return",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
type Perturbation = (typeof PERTURBATIONS)[number];
|
|
21
|
+
|
|
22
|
+
export function resolveChaosSeed(flag: string | undefined, envValue: string | undefined): string {
|
|
23
|
+
const raw = flag !== undefined ? flag : envValue;
|
|
24
|
+
const trimmed = (raw ?? "").trim();
|
|
25
|
+
return trimmed.length > 0 ? trimmed : CHAOS_DEFAULT_SEED;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pickIndex(seed: string, goldenId: string, kind: string, salt: string, n: number): number {
|
|
29
|
+
const digest = createHash("sha256").update(`${seed}\0${goldenId}\0${kind}\0${salt}`).digest();
|
|
30
|
+
return digest.readUInt32BE(0) % n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cloneEvents(events: BillingEvent[]): BillingEvent[] {
|
|
34
|
+
return events.map((event) => ({ ...event }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stamp(suite: GoldenSuite): BillingEvent[] {
|
|
38
|
+
return suite.steps.map((step, index) => ({ ...step.event, created: index }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function duplicate(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
|
|
42
|
+
const next = cloneEvents(events);
|
|
43
|
+
const index = pickIndex(seed, goldenId, "duplicate", "i", next.length);
|
|
44
|
+
next.push({ ...next[index]! });
|
|
45
|
+
return next;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function reorder(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
|
|
49
|
+
const next = cloneEvents(events);
|
|
50
|
+
if (next.length < 2) return next;
|
|
51
|
+
const left = pickIndex(seed, goldenId, "reorder", "a", next.length);
|
|
52
|
+
let right = pickIndex(seed, goldenId, "reorder", "b", next.length);
|
|
53
|
+
if (left === right) right = (right + 1) % next.length;
|
|
54
|
+
const swap = next[left]!;
|
|
55
|
+
next[left] = next[right]!;
|
|
56
|
+
next[right] = swap;
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function moveToEnd(events: BillingEvent[], index: number): BillingEvent[] {
|
|
61
|
+
const next = cloneEvents(events);
|
|
62
|
+
const [picked] = next.splice(index, 1);
|
|
63
|
+
next.push(picked!);
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stale(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
|
|
68
|
+
if (events.length < 2) return cloneEvents(events);
|
|
69
|
+
const index = pickIndex(seed, goldenId, "stale", "i", events.length - 1);
|
|
70
|
+
return moveToEnd(events, index);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function late(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
|
|
74
|
+
if (events.length < 2) return cloneEvents(events);
|
|
75
|
+
const index = pickIndex(seed, goldenId, "late", "i", events.length);
|
|
76
|
+
return moveToEnd(events, index);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function droppedReplay(seed: string, goldenId: string, events: BillingEvent[]): BillingEvent[] {
|
|
80
|
+
const next = cloneEvents(events);
|
|
81
|
+
const index = pickIndex(seed, goldenId, "dropped_replay", "i", next.length);
|
|
82
|
+
next.splice(index, 1);
|
|
83
|
+
return [...next, ...cloneEvents(events)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkoutReturn(suite: GoldenSuite, events: BillingEvent[]): BillingEvent[] {
|
|
87
|
+
const page: BillingEvent = {
|
|
88
|
+
id: `evt_chaos_checkout_${suite.id}`,
|
|
89
|
+
type: "checkout.session.completed",
|
|
90
|
+
customerId: suite.subject,
|
|
91
|
+
httpStatus: 200,
|
|
92
|
+
stripeHttpStatus: 200,
|
|
93
|
+
created: -1,
|
|
94
|
+
};
|
|
95
|
+
return [page, ...cloneEvents(events)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function perturb(kind: Perturbation, seed: string, suite: GoldenSuite, events: BillingEvent[]): BillingEvent[] {
|
|
99
|
+
if (kind === "duplicate") return duplicate(seed, suite.id, events);
|
|
100
|
+
if (kind === "reorder") return reorder(seed, suite.id, events);
|
|
101
|
+
if (kind === "stale") return stale(seed, suite.id, events);
|
|
102
|
+
if (kind === "late") return late(seed, suite.id, events);
|
|
103
|
+
if (kind === "dropped_replay") return droppedReplay(seed, suite.id, events);
|
|
104
|
+
return checkoutReturn(suite, events);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stableSnapshot(snapshot: AccessSnapshot): string {
|
|
108
|
+
const keys = (Object.keys(snapshot) as (keyof AccessSnapshot)[])
|
|
109
|
+
.filter((key) => snapshot[key] !== undefined)
|
|
110
|
+
.sort();
|
|
111
|
+
const ordered: Record<string, unknown> = {};
|
|
112
|
+
for (const key of keys) ordered[key] = snapshot[key];
|
|
113
|
+
return JSON.stringify(ordered);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function replay(
|
|
117
|
+
adapter: { apply: (event: BillingEvent) => void | Promise<void>; snapshot: (id: string) => AccessSnapshot | Promise<AccessSnapshot>; reset?: () => void | Promise<void> },
|
|
118
|
+
subject: string,
|
|
119
|
+
events: BillingEvent[],
|
|
120
|
+
): Promise<AccessSnapshot> {
|
|
121
|
+
if (adapter.reset) await adapter.reset();
|
|
122
|
+
for (const event of events) await adapter.apply(event);
|
|
123
|
+
return adapter.snapshot(subject);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function fail(seed: string, goldenId: string, kind: Perturbation, events: BillingEvent[], expected: string, actual: string): void {
|
|
127
|
+
console.error(`gatefit: FAIL chaos ${goldenId} perturbation ${kind}`);
|
|
128
|
+
console.error(`seed: ${seed}`);
|
|
129
|
+
console.error("repro:");
|
|
130
|
+
for (const event of events) console.error(JSON.stringify(event));
|
|
131
|
+
console.error(`access: expected ${expected} got ${actual}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runChaos(adapterPath: string, seed: string): Promise<number> {
|
|
135
|
+
console.error(`gatefit chaos: seed ${seed}`);
|
|
136
|
+
if (process.env.GATEFIT_LIVE_CLOCK) {
|
|
137
|
+
console.error("gatefit chaos: refuses GATEFIT_LIVE_CLOCK; fixture order only");
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
const adapter = await loadAdapter(resolveUserOrPackage(adapterPath));
|
|
141
|
+
if (typeof adapter.reset !== "function") {
|
|
142
|
+
console.error("gatefit chaos: adapter.reset is required");
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
const files = listGoldens(goldenDir());
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const suite = loadGolden(file);
|
|
148
|
+
const ordered = stamp(suite);
|
|
149
|
+
const expected = stableSnapshot(await replay(adapter, suite.subject, ordered));
|
|
150
|
+
for (const kind of PERTURBATIONS) {
|
|
151
|
+
const events = perturb(kind, seed, suite, ordered);
|
|
152
|
+
const actual = stableSnapshot(await replay(adapter, suite.subject, events));
|
|
153
|
+
if (actual !== expected) {
|
|
154
|
+
fail(seed, suite.id, kind, events, expected, actual);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
console.error(`gatefit: PASS chaos ${suite.id}`);
|
|
159
|
+
}
|
|
160
|
+
console.error("gatefit: PASS chaos");
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
registerProPack({
|
|
165
|
+
id: "chaos",
|
|
166
|
+
run({ adapterPath, seed }) {
|
|
167
|
+
return runChaos(adapterPath, seed);
|
|
168
|
+
},
|
|
169
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { loadAdapter } from "./access-adapter.ts";
|
|
5
5
|
import { listGoldens, loadGolden, requireReset, runGolden } from "./fixture-driver.ts";
|
|
6
6
|
import { LIVE_SCENARIO_IDS, runLiveLane, type LiveScenarioId } from "./live-scenarios.ts";
|
|
7
|
+
import { resolveChaosSeed } from "./chaos.ts";
|
|
7
8
|
import { ensureProLicense } from "./license.ts";
|
|
8
9
|
import { getProPack, isProPack } from "./packs.ts";
|
|
9
10
|
import { goldenDir, packageRoot, readPackageVersion, resolveUserOrPackage, userCwd } from "./paths.ts";
|
|
@@ -21,8 +22,10 @@ function usage(): string {
|
|
|
21
22
|
return [
|
|
22
23
|
"Usage:",
|
|
23
24
|
" gatefit run --suite <dir|file|id> --adapter <path>",
|
|
25
|
+
" gatefit run --suite chaos --adapter <path> [--seed <value>]",
|
|
24
26
|
" gatefit adapter-check",
|
|
25
27
|
" gatefit live --adapter <path> [--scenario <id>]",
|
|
28
|
+
" gatefit --help",
|
|
26
29
|
" gatefit --version",
|
|
27
30
|
].join("\n");
|
|
28
31
|
}
|
|
@@ -41,7 +44,7 @@ function resolveSuiteFiles(suite: string): string[] {
|
|
|
41
44
|
throw new Error(`suite not found: ${suite}`);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
async function cmdRun(suite: string, adapterPath: string): Promise<number> {
|
|
47
|
+
async function cmdRun(suite: string, adapterPath: string, seed: string): Promise<number> {
|
|
45
48
|
if (isProPack(suite)) {
|
|
46
49
|
const gate = await ensureProLicense();
|
|
47
50
|
if (!gate.ok) {
|
|
@@ -50,7 +53,7 @@ async function cmdRun(suite: string, adapterPath: string): Promise<number> {
|
|
|
50
53
|
}
|
|
51
54
|
if (gate.warning) console.error(gate.warning);
|
|
52
55
|
const pack = getProPack(suite);
|
|
53
|
-
if (pack?.run) return pack.run({ adapterPath });
|
|
56
|
+
if (pack?.run) return pack.run({ adapterPath, seed });
|
|
54
57
|
console.error(`gatefit: PASS ${suite}`);
|
|
55
58
|
return 0;
|
|
56
59
|
}
|
|
@@ -94,11 +97,19 @@ function isVersionFlag(arg: string | undefined): boolean {
|
|
|
94
97
|
return arg === "--version" || arg === "-v";
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
function isHelpFlag(arg: string | undefined): boolean {
|
|
101
|
+
return arg === "--help" || arg === "-h";
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
async function main(argv: string[]): Promise<number> {
|
|
98
105
|
if (isVersionFlag(argv[0])) {
|
|
99
106
|
console.log(`gatefit ${readPackageVersion()}`);
|
|
100
107
|
return 0;
|
|
101
108
|
}
|
|
109
|
+
if (isHelpFlag(argv[0])) {
|
|
110
|
+
console.log(usage());
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
102
113
|
const command = argv[0];
|
|
103
114
|
if (command === "adapter-check") {
|
|
104
115
|
return cmdAdapterCheck();
|
|
@@ -109,13 +120,14 @@ async function main(argv: string[]): Promise<number> {
|
|
|
109
120
|
options: {
|
|
110
121
|
suite: { type: "string" },
|
|
111
122
|
adapter: { type: "string" },
|
|
123
|
+
seed: { type: "string" },
|
|
112
124
|
},
|
|
113
125
|
});
|
|
114
126
|
if (!values.suite || !values.adapter) {
|
|
115
127
|
console.error(usage());
|
|
116
128
|
return 1;
|
|
117
129
|
}
|
|
118
|
-
return cmdRun(values.suite, values.adapter);
|
|
130
|
+
return cmdRun(values.suite, values.adapter, resolveChaosSeed(values.seed, process.env.GATEFIT_CHAOS_SEED));
|
|
119
131
|
}
|
|
120
132
|
if (command === "live") {
|
|
121
133
|
const { values } = parseArgs({
|
package/src/packs.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pro pack registry.
|
|
3
|
-
*
|
|
2
|
+
* Pro pack registry. Chaos mode registers here and runs only after the license gate.
|
|
3
|
+
* The stub has no runner and is not a user-facing feature.
|
|
4
4
|
*/
|
|
5
5
|
export type ProPack = {
|
|
6
6
|
id: string;
|
|
7
|
-
run?: (ctx: { adapterPath: string }) => Promise<number>;
|
|
7
|
+
run?: (ctx: { adapterPath: string; seed: string }) => Promise<number>;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
const proPacks = new Map<string, ProPack>();
|