penultimate 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Woodell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,162 @@
1
- # Temporary Holding Version
1
+ # penultimate
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![Vitest](https://img.shields.io/badge/Vitest-4.1+-6E9F18?logo=vitest&logoColor=white)](package.json)
4
+ [![CI](https://github.com/woodie/penultimate/actions/workflows/ci.yml/badge.svg)](https://github.com/woodie/penultimate/actions/workflows/ci.yml)
5
+ [![npm](https://img.shields.io/npm/v/penultimate.svg)](https://www.npmjs.com/package/penultimate)
6
+ [![Release](https://img.shields.io/github/v/release/woodie/penultimate.svg)](https://github.com/woodie/penultimate/releases/latest)
7
+ [![License](https://img.shields.io/github/license/woodie/penultimate.svg)](LICENSE)
8
+
9
+ A `justBeforeEach` hook for Vitest -- real nested `context`s with less
10
+ duplication between them, inspired by [Quick](https://github.com/Quick/Quick).
11
+ The name says when it runs: penultimate, second to last, right before the
12
+ test itself. Works with Vitest 4.1 and later 4.x releases; Vitest 5 isn't
13
+ supported yet (see [Requirements](#requirements)).
14
+
15
+ ## Installation
16
+
17
+ Since `penultimate` is only ever imported from test files, it belongs in
18
+ `devDependencies`:
19
+
20
+ ```
21
+ npm install --save-dev penultimate
22
+ ```
23
+
24
+ There's nothing to register or configure -- import it and use it.
25
+
26
+ ## `justBeforeEach`
27
+
28
+ Vitest's own `beforeEach` runs fresh before every `it`, from the outermost
29
+ `describe` inward -- but there's no hook that runs *after* every
30
+ `beforeEach` at every nesting depth, immediately before the `it` itself.
31
+ That's `justBeforeEach`'s job: separate what varies (set by an ordinary
32
+ `beforeEach` in each `context`) from the action under test (declared once,
33
+ in the parent):
34
+
35
+ ```js
36
+ import { beforeEach, describe, expect, it } from 'vitest'
37
+ import { justBeforeEach } from 'penultimate'
38
+
39
+ const context = describe
40
+
41
+ describe('Calculator', () => {
42
+ let calculator
43
+
44
+ beforeEach(() => { calculator = new Calculator() })
45
+
46
+ describe('#divide', () => {
47
+ let numerator, denominator, result
48
+
49
+ beforeEach(() => { numerator = 10; denominator = 2 })
50
+
51
+ justBeforeEach(() => { result = calculator.divide(numerator, denominator) })
52
+
53
+ it('returns the quotient', () => {
54
+ expect(result).toBe(5)
55
+ })
56
+
57
+ context('when the numerator leaves a remainder', () => {
58
+ beforeEach(() => { numerator = 7 })
59
+
60
+ it('drops the remainder', () => {
61
+ expect(result).toBe(3)
62
+ })
63
+ })
64
+
65
+ context('when the denominator is 1', () => {
66
+ beforeEach(() => { denominator = 1 })
67
+
68
+ it('returns the numerator', () => {
69
+ expect(result).toBe(10)
70
+ })
71
+ })
72
+ })
73
+ })
74
+ ```
75
+
76
+ Here, `#divide` sets default inputs in an ordinary `beforeEach`, and each
77
+ `context` overrides only the one it's about. The call itself can't go in
78
+ that `beforeEach`, because Vitest runs a parent's `beforeEach` before its
79
+ children's: it would divide 10 by 2 every time, before any `context` had
80
+ changed an input. Because `justBeforeEach` runs after every `beforeEach` at
81
+ every depth, it always sees the final inputs, and each `it` just checks
82
+ `result`. In RSpec terms, the defaults play the part of `let`, and
83
+ `justBeforeEach` plays `subject`, which RSpec can leave lazy instead.
84
+
85
+ When several `justBeforeEach` blocks apply to one test, they run outermost
86
+ first, the same order as `beforeEach`. Each one gets the same test context
87
+ a `beforeEach` gets (`({ task }) => ...`), and an `async` one is awaited
88
+ before the next.
89
+
90
+ ## Works with Vitest's own `it`
91
+
92
+ You keep importing `it` and `test` from `vitest`, since `penultimate`
93
+ doesn't replace them, so `it.each`, `it.skip`, `it.only`, `it.fails`, `it.concurrent`
94
+ and `retry` all behave as usual. Each test gets its own queue of
95
+ `justBeforeEach` steps, so concurrent tests don't share them, and a retried
96
+ test runs them again.
97
+
98
+ ## A step that might throw
99
+
100
+ The usual reason to hoist an action into `justBeforeEach` is to share it
101
+ across sibling `context`s -- including ones where the action is expected to
102
+ fail. Left alone, a throw inside the step fails the test before any
103
+ expectation runs, so the error-path `context` can't assert on it. Capture
104
+ the outcome instead, and let each `it` check whichever half it cares about:
105
+
106
+ ```js
107
+ describe('#divide', () => {
108
+ let numerator, denominator, result, error
109
+
110
+ beforeEach(() => { numerator = 10; denominator = 2 })
111
+
112
+ justBeforeEach(() => {
113
+ result = error = undefined
114
+ try { result = calculator.divide(numerator, denominator) } catch (e) { error = e }
115
+ })
116
+
117
+ it('returns the quotient', () => {
118
+ expect(result).toBe(5)
119
+ })
120
+
121
+ context('when the denominator is 0', () => {
122
+ beforeEach(() => { denominator = 0 })
123
+
124
+ it('reports it', () => {
125
+ expect(error.message).toBe('division by zero')
126
+ })
127
+ })
128
+ })
129
+ ```
130
+
131
+ Clearing `result` and `error` at the top of the step means no test can see
132
+ an outcome left over from the one before it.
133
+
134
+ ## Scoping variables
135
+
136
+ The usual way to share a value between hooks and tests is a `let` declared
137
+ in the `describe` body. Vitest runs that body only once, to collect the
138
+ tests, so anything assigned there is shared by every test below it -- and a
139
+ test that changes it leaves the change behind for the next one. Assign
140
+ starting values in a `beforeEach` instead, so every test begins from the
141
+ same place, and let each `context`'s own `beforeEach` change only what it's
142
+ about.
143
+
144
+ ## Requirements
145
+
146
+ Vitest 4.1 or a later 4.x release, and Node 20 or later.
147
+
148
+ Under the hood, `penultimate` works by replacing each test's body with one that runs the queued
149
+ steps first, using `getFn` and `setFn` from `@vitest/runner`, which Vitest 4
150
+ installs alongside itself. Vitest 5 bundles that package inside `vitest`
151
+ instead, and the replacement it offers, `TestRunner.setTestFn`, currently
152
+ reads a test's body rather than setting it -- so there's no working way to
153
+ do this on Vitest 5 yet.
154
+
155
+ ## Development
156
+
157
+ ```
158
+ make build # tsc -p tsconfig.build.json
159
+ make test # vitest run --reporter=tree
160
+ make lint # eslint . && tsc --noEmit
161
+ make check # terse: lint, typecheck, and tests
162
+ ```
@@ -0,0 +1,3 @@
1
+ import { type TestContext } from 'vitest';
2
+ export type Step = (context: TestContext) => unknown;
3
+ export declare function justBeforeEach(step: Step): void;
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ import { beforeEach } from 'vitest';
2
+ import { getFn, setFn } from '@vitest/runner';
3
+ const queues = new WeakMap();
4
+ function queueFor(task) {
5
+ const queued = queues.get(task);
6
+ if (queued !== undefined)
7
+ return queued;
8
+ const queue = [];
9
+ queues.set(task, queue);
10
+ const test = getFn(task);
11
+ // Vitest reads the test body only after every beforeEach has run, so the steps land in between.
12
+ setFn(task, async () => {
13
+ for (const step of queue)
14
+ await step();
15
+ return test();
16
+ });
17
+ return queue;
18
+ }
19
+ export function justBeforeEach(step) {
20
+ beforeEach((context) => {
21
+ const queue = queueFor(context.task);
22
+ queue.push(() => step(context));
23
+ return () => { queue.length = 0; };
24
+ });
25
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "penultimate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A justBeforeEach hook for Vitest, inspired by Quick",
5
+ "keywords": [
6
+ "vitest",
7
+ "justBeforeEach",
8
+ "beforeEach",
9
+ "bdd",
10
+ "rspec",
11
+ "quick",
12
+ "describe",
13
+ "context"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "John Woodell <woodie@netpress.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/woodie/penultimate.git"
20
+ },
21
+ "homepage": "https://github.com/woodie/penultimate#readme",
22
+ "bugs": "https://github.com/woodie/penultimate/issues",
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "sideEffects": false,
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "prepare": "npm run build",
42
+ "lint": "eslint . && tsc --noEmit",
43
+ "test": "vitest run --reporter=tree",
44
+ "check": "eslint . && tsc --noEmit && vitest run --reporter=dot",
45
+ "prepublishOnly": "npm run check && npm run build"
46
+ },
47
+ "peerDependencies": {
48
+ "@vitest/runner": ">=4.1.0 <5",
49
+ "vitest": ">=4.1.0 <5"
50
+ },
51
+ "devDependencies": {
52
+ "@vitest/eslint-plugin": "^1.6.27",
53
+ "@vitest/runner": "^4.1.11",
54
+ "eslint": "^10.12.0",
55
+ "neostandard": "^0.14.0",
56
+ "typescript": "^6.0.3",
57
+ "vitest": "^4.1.11"
58
+ }
59
+ }