@stacksjs/testing 0.74.36 → 0.74.38
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/dist/console.d.ts +55 -0
- package/dist/database.d.ts +143 -0
- package/dist/database.js +1 -1
- package/dist/dynamodb.d.ts +0 -1
- package/dist/dynamodb.js +1 -1
- package/dist/events.d.ts +41 -0
- package/dist/feature.d.ts +81 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2 -1
- package/dist/mail.d.ts +23 -0
- package/dist/time.d.ts +56 -0
- package/package.json +12 -10
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split a command string the way a shell would, honouring quotes.
|
|
3
|
+
*
|
|
4
|
+
* `command('make:model Post --migration')` is the documented form, so the
|
|
5
|
+
* string has to become argv somewhere. Quoted arguments matter as soon as
|
|
6
|
+
* anything takes a message: `command('commit -m "two words"')` is otherwise
|
|
7
|
+
* three arguments and a broken test.
|
|
8
|
+
*/
|
|
9
|
+
export declare function splitCommand(input: string): string[];
|
|
10
|
+
/**
|
|
11
|
+
* Run a buddy command and capture what it did.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const result = await command('greet John --loud')
|
|
16
|
+
* expect(result.exitCode).toBe(0)
|
|
17
|
+
* expect(result.output).toContain('HELLO, JOHN!')
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @example Answering a prompt
|
|
21
|
+
* ```ts
|
|
22
|
+
* const result = await command('db:drop').withInput(['yes'])
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function command(input: string | readonly string[]): CommandBuilder;
|
|
26
|
+
/**
|
|
27
|
+
* Running a buddy command in a test (stacksjs/stacks#2581).
|
|
28
|
+
*
|
|
29
|
+
* Documented in `docs/testing/console-tests.md` and never implemented. The
|
|
30
|
+
* documentation also had its shape wrong: it imported `withInput` and
|
|
31
|
+
* `withEnv` as free functions when the samples used them as chained calls on
|
|
32
|
+
* the result of `command()`. They are methods, and this is the builder they
|
|
33
|
+
* hang off.
|
|
34
|
+
*
|
|
35
|
+
* The command runs in a CHILD PROCESS rather than in-process. A buddy command
|
|
36
|
+
* writes real files on startup - `storage/framework/runtime`, `storage/cloud` -
|
|
37
|
+
* and calls `process.exit`, so running one inside the test runner would leave
|
|
38
|
+
* that state behind and could take the runner down with it. A child process
|
|
39
|
+
* gets its own exit code and its own mess.
|
|
40
|
+
*/
|
|
41
|
+
/** What a finished command produced. */
|
|
42
|
+
export declare interface CommandResult {
|
|
43
|
+
exitCode: number
|
|
44
|
+
output: string
|
|
45
|
+
stdout: string
|
|
46
|
+
stderr: string
|
|
47
|
+
timedOut: boolean
|
|
48
|
+
}
|
|
49
|
+
/** A command that has not run yet. Awaiting it runs it. */
|
|
50
|
+
export declare interface CommandBuilder extends PromiseLike<CommandResult> {
|
|
51
|
+
withInput: (lines: readonly string[]) => CommandBuilder
|
|
52
|
+
withEnv: (env: Record<string, string>) => CommandBuilder
|
|
53
|
+
withCwd: (cwd: string) => CommandBuilder
|
|
54
|
+
withTimeout: (ms: number) => CommandBuilder
|
|
55
|
+
}
|
package/dist/database.d.ts
CHANGED
|
@@ -54,3 +54,146 @@ export declare function useTransactionalTests(): {
|
|
|
54
54
|
begin: () => Promise<void>
|
|
55
55
|
rollback: () => Promise<void>
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Assert at least one row in `table` matches `criteria`.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* await assertDatabaseHas('users', { email: 'john@example.com' })
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function assertDatabaseHas(table: string, criteria: RowCriteria): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Assert no row in `table` matches `criteria`.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* await assertDatabaseMissing('users', { id: user.id })
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function assertDatabaseMissing(table: string, criteria: RowCriteria): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Assert exactly `count` rows in `table` match `criteria`.
|
|
77
|
+
*
|
|
78
|
+
* Omit `criteria` to count the whole table.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* await assertDatabaseCount('orders', 3)
|
|
83
|
+
* await assertDatabaseCount('orders', 2, { product_id: 1 })
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export declare function assertDatabaseCount(table: string, count: number, criteria?: RowCriteria): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Assert a matching row exists and is soft-deleted.
|
|
89
|
+
*
|
|
90
|
+
* Soft deletion is `deleted_at` being set - the column the `useSoftDeletes`
|
|
91
|
+
* trait adds. A row that is not there at all fails differently from one that is
|
|
92
|
+
* there and not deleted, because those are different bugs: the first means the
|
|
93
|
+
* delete removed the row outright, the second that it did nothing.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* await assertSoftDeleted('users', { id: user.id })
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export declare function assertSoftDeleted(table: string, criteria: RowCriteria): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Assert a matching row exists and is NOT soft-deleted.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* await assertNotSoftDeleted('users', { id: user.id })
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export declare function assertNotSoftDeleted(table: string, criteria: RowCriteria): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Wrap every test in this file in a transaction that is rolled back after it
|
|
112
|
+
* (stacksjs/stacks#2581).
|
|
113
|
+
*
|
|
114
|
+
* The self-wiring form of {@link useTransactionalTests}, and the one six
|
|
115
|
+
* documentation pages have always shown. It registers its own `beforeEach` and
|
|
116
|
+
* `afterEach`, so a suite says what it wants once:
|
|
117
|
+
*
|
|
118
|
+
* ```ts
|
|
119
|
+
* import { useTransaction } from '@stacksjs/testing/database'
|
|
120
|
+
*
|
|
121
|
+
* describe('Order', () => {
|
|
122
|
+
* useTransaction()
|
|
123
|
+
*
|
|
124
|
+
* it('creates an order', async () => {
|
|
125
|
+
* // ...rolled back when this returns
|
|
126
|
+
* })
|
|
127
|
+
* })
|
|
128
|
+
* ```
|
|
129
|
+
*
|
|
130
|
+
* Call it at the top of a `describe`, or at the top of the file for every test
|
|
131
|
+
* in it - `beforeEach` is scoped by where it is registered, and so is this.
|
|
132
|
+
*
|
|
133
|
+
* Prefer {@link useTransactionalTests} when the hooks need to interleave with
|
|
134
|
+
* others in a particular order; this is the common case, not the only one.
|
|
135
|
+
*/
|
|
136
|
+
export declare function useTransaction(): void;
|
|
137
|
+
/**
|
|
138
|
+
* A factory for `modelName`, e.g. `factory('User')`.
|
|
139
|
+
*
|
|
140
|
+
* There is deliberately no singular/plural mode switch: `make` and `create`
|
|
141
|
+
* always return one row, `makeMany` and `createMany` always return an array.
|
|
142
|
+
* A helper whose return type depends on whether `.count()` was called earlier
|
|
143
|
+
* in the chain cannot be typed honestly, and the caller always knows which
|
|
144
|
+
* one they want.
|
|
145
|
+
*/
|
|
146
|
+
export declare function factory(modelName: string): ModelFactory;
|
|
147
|
+
/**
|
|
148
|
+
* Rows built from a model's own declared attribute factories.
|
|
149
|
+
*
|
|
150
|
+
* `docs/` documented a `Factory` class in four places, with four mutually
|
|
151
|
+
* incompatible signatures - `new Factory(User, attrs)`, `new Factory({
|
|
152
|
+
* definition() })`, `new Factory<User>({ definition() })` and
|
|
153
|
+
* `Factory.define(fn)` - and none of them ever existed. That is a fair sign
|
|
154
|
+
* nobody had one, and a fifth invented shape would not help.
|
|
155
|
+
*
|
|
156
|
+
* What DOES exist is the factory a model already declares:
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* attributes: {
|
|
160
|
+
* email: { factory: faker => faker.internet.email() },
|
|
161
|
+
* }
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* so this exposes that, rather than asking a test to restate it. The rows come
|
|
165
|
+
* out of the same generator `buddy seed` uses, which is the point: password
|
|
166
|
+
* columns are hashed, unique columns are disambiguated across the batch, and
|
|
167
|
+
* relation columns are filled. A test factory that reimplemented this would
|
|
168
|
+
* differ from the seeder in exactly the ways that are invisible until a test
|
|
169
|
+
* passes for the wrong reason.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* const attrs = await factory('User').make() // not persisted
|
|
174
|
+
* const user = await factory('User').create() // one row
|
|
175
|
+
* const admins = await factory('User').createMany(3, { role: 'admin' })
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export declare interface ModelFactory {
|
|
179
|
+
make: (overrides?: RowCriteria) => Promise<Record<string, unknown>>
|
|
180
|
+
makeMany: (count: number, overrides?: RowCriteria) => Promise<Record<string, unknown>[]>
|
|
181
|
+
create: (overrides?: RowCriteria) => Promise<Record<string, unknown>>
|
|
182
|
+
createMany: (count: number, overrides?: RowCriteria) => Promise<Record<string, unknown>[]>
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Database assertions (stacksjs/stacks#2581).
|
|
186
|
+
*
|
|
187
|
+
* Documented for a long time and never implemented, so every sample teaching
|
|
188
|
+
* them imported from `@stacksjs/testing` and failed. They live HERE rather than
|
|
189
|
+
* at the package root for the reason the file header gives: the root
|
|
190
|
+
* deliberately does not import `@stacksjs/database`, because doing so eagerly
|
|
191
|
+
* deadlocks bun's module loader outside the framework's preloader.
|
|
192
|
+
*
|
|
193
|
+
* Each throws rather than returning a boolean. A test asserting on a database
|
|
194
|
+
* wants the row it was expecting in the failure message - `expect(true).toBe(
|
|
195
|
+
* true)` on a helper that returned false tells nobody anything - so the message
|
|
196
|
+
* carries the table, the criteria, and what was actually there.
|
|
197
|
+
*/
|
|
198
|
+
/** Column/value pairs a row must match. Every pair is ANDed. */
|
|
199
|
+
export type RowCriteria = Record<string, unknown>;
|
package/dist/database.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import{config as
|
|
2
|
+
import{afterEach as P,beforeEach as R}from"bun:test";import{config as h}from"@stacksjs/config";import{copyModelFiles as v,db as s,deleteFrameworkModels as k,dropSqliteTables as T,fetchSqliteFile as f,fetchTables as S,migrateAuthTables as C,migrateNotificationTables as M,migrateRbacTables as A,migrateTraitTables as F,ensureUtcDatetimeColumns as _,ensureUtcTimestampDefaults as D,runDatabaseMigration as p}from"@stacksjs/database";import{path as u}from"@stacksjs/path";import{fs as c,globSync as y}from"@stacksjs/storage";function b(){return h.database?.default||""}var d=null;function N(){if(d)return d;return d=`${f()}.snapshot`,d}async function E(){let t=[["auth",C],["notification",M],["RBAC",A],["trait",F],["utc-datetime",_],["utc-defaults",D]];for(let[e,n]of t)try{let r=await n();if(!r.success)console.error(`[testing] Failed to migrate ${e} tables: ${r.error}`)}catch(r){console.error(`[testing] Failed to migrate ${e} tables:`,r)}}async function q(){let t=`${h.database?.connections?.mysql?.name??"stacks"}_testing`;if(b()==="mysql")await s.unsafe(`CREATE DATABASE IF NOT EXISTS ${t}`).execute(),await p(),await E()}async function V(){await q();let t=b();if(t==="mysql")await O();if(t==="sqlite")await B()}async function O(){let t=await S();await s.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute();try{for(let e of t)await s.unsafe(`TRUNCATE TABLE ${e}`).execute()}finally{await s.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}}async function I(){let t=f();if(!c.existsSync(t))await Bun.$`touch ${t}`;await T(),await k();let e=y([u.userModelsPath("*.ts"),u.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0});for(let n of e)await v(n);await p(),await E()}async function B(){let t=f(),e=N();if((()=>{if(!c.existsSync(e))return!0;let r=c.statSync(e).mtimeMs,a=y([u.userModelsPath("*.ts"),u.storagePath("framework/defaults/app/Models/**/*.ts"),u.userMigrationsPath("*.ts")],{absolute:!0});for(let o of a)try{if(c.statSync(o).mtimeMs>r)return!0}catch{}return!1})()){await I();try{await s.unsafe("PRAGMA wal_checkpoint(TRUNCATE)").execute(),c.copyFileSync(t,e)}catch{}return}c.copyFileSync(e,t)}function K(){let t=null;return{begin:async()=>{let e=`stacks_test_${Date.now()}_${Math.floor(Math.random()*1e6)}`;await s.unsafe(`SAVEPOINT ${e}`).execute(),t={rollback:async()=>{await s.unsafe(`ROLLBACK TO SAVEPOINT ${e}`).execute(),await s.unsafe(`RELEASE SAVEPOINT ${e}`).execute()}}},rollback:async()=>{if(!t)return;try{await t.rollback()}finally{t=null}}}}function i(t){let e=Object.entries(t).map(([n,r])=>`${n}=${JSON.stringify(r)}`);return e.length>0?e.join(", "):"(no criteria)"}async function l(t,e){let n=s.selectFrom(t).selectAll();for(let[r,a]of Object.entries(e))n=n.where(r,"=",a);return await n.execute()}async function J(t,e){if((await l(t,e)).length>0)return;let r=await l(t,{});throw Error(`Expected ${t} to have a row matching ${i(e)}, but none did (${r.length} row(s) in the table).`)}async function Y(t,e){let n=await l(t,e);if(n.length===0)return;throw Error(`Expected ${t} to have no row matching ${i(e)}, but found ${n.length}: ${JSON.stringify(n.slice(0,3))}${n.length>3?" \u2026":""}`)}async function X(t,e,n={}){if(!Number.isInteger(e)||e<0)throw TypeError(`assertDatabaseCount expects a non-negative integer, got ${e}`);let r=await l(t,n);if(r.length===e)return;throw Error(`Expected ${e} row(s) in ${t} matching ${i(n)}, found ${r.length}.`)}async function z(t,e){let n=await l(t,e);if(n.length===0)throw Error(`Expected ${t} to have a soft-deleted row matching ${i(e)}, but no row matched at all - a hard delete removes the row rather than setting deleted_at.`);let r=n.filter((a)=>a.deleted_at===null||a.deleted_at===void 0);if(r.length===0)return;throw Error(`Expected every ${t} row matching ${i(e)} to be soft-deleted, but ${r.length} of ${n.length} still has a null deleted_at.`)}async function Q(t,e){let n=await l(t,e);if(n.length===0)throw Error(`Expected ${t} to have a row matching ${i(e)}, but none did.`);let r=n.filter((a)=>a.deleted_at!==null&&a.deleted_at!==void 0);if(r.length===0)return;throw Error(`Expected no ${t} row matching ${i(e)} to be soft-deleted, but ${r.length} of ${n.length} has a deleted_at set.`)}function W(){let t=K();R(t.begin),P(t.rollback)}function Z(t){async function e(){let{plural:a,snakeCase:o}=await import("@stacksjs/strings");return a(o(t))}async function n(a,o){let{makeModelRecords:w}=await import("@stacksjs/database");return w(t,a,o)}async function r(a){let{db:o}=await import("@stacksjs/database"),w=await e(),m=[];for(let g of a){let x=await o.insertInto(w).values(g).returningAll().executeTakeFirst();m.push(x??g)}return m}return{async make(a={}){return(await n(1,a))[0]},async makeMany(a,o={}){return n(a,o)},async create(a={}){return(await r(await n(1,a)))[0]},async createMany(a,o={}){return r(await n(a,o))}}}export{X as assertDatabaseCount,J as assertDatabaseHas,Y as assertDatabaseMissing,Q as assertNotSoftDeleted,z as assertSoftDeleted,Z as factory,V as refreshDatabase,q as setupDatabase,O as truncateMysql,I as truncateSqlite,B as truncateSqliteFast,W as useTransaction,K as useTransactionalTests};
|
package/dist/dynamodb.d.ts
CHANGED
package/dist/dynamodb.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import
|
|
2
|
+
import{DynamoDBClient as a}from"@stacksjs/ts-cloud";var t=new a("us-east-1");async function i(){try{await t.createTable({TableName:"stacks",KeySchema:[{AttributeName:"key",KeyType:"HASH"}],AttributeDefinitions:[{AttributeName:"key",AttributeType:"S"}],ProvisionedThroughput:{ReadCapacityUnits:5,WriteCapacityUnits:5}})}catch(e){console.error("Error Creating Table",e)}}async function o(){try{await t.deleteTable({TableName:"stacks"})}catch(e){console.error("Error deleting table:",e)}}export{i as createStacksTable,o as deleteStacksTable};
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record every dispatched event and stop the real listeners from running.
|
|
3
|
+
*
|
|
4
|
+
* Suppression is the point, not a side effect. A feature test asserting that
|
|
5
|
+
* an event fired should not also send the welcome email, write the audit row,
|
|
6
|
+
* and reindex the model - those are the listeners' own tests. The handlers are
|
|
7
|
+
* taken off the shared emitter and put back by `restoreEvents()`, which is
|
|
8
|
+
* wired to `afterEach` automatically so a test that forgets cannot leak a
|
|
9
|
+
* silenced event bus into the next one.
|
|
10
|
+
*/
|
|
11
|
+
export declare function eventFake(): void;
|
|
12
|
+
/** Whether a fake is currently installed. */
|
|
13
|
+
export declare function eventsAreFaked(): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Events dispatched since `eventFake()`, optionally filtered by type.
|
|
16
|
+
*
|
|
17
|
+
* Returns a copy, so a test holding the result across further dispatches sees
|
|
18
|
+
* what it asked for rather than a list that keeps growing under it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getDispatchedEvents(type?: string): DispatchedEvent[];
|
|
21
|
+
/** Whether an event of this type was dispatched at least once. */
|
|
22
|
+
export declare function hasDispatchedEvent(type: string): boolean;
|
|
23
|
+
/** Put the real listeners back and forget what was recorded. */
|
|
24
|
+
export declare function restoreEvents(): void;
|
|
25
|
+
/**
|
|
26
|
+
* Event fakes for feature tests (stacksjs/stacks#2581).
|
|
27
|
+
*
|
|
28
|
+
* `docs/testing/feature-tests.md` has documented `eventFake()` and
|
|
29
|
+
* `getDispatchedEvents()` for a long time against a package that exported
|
|
30
|
+
* neither. They are worth having rather than deleting: asserting that
|
|
31
|
+
* registering a user FIRES `UserRegistered` is a different test from asserting
|
|
32
|
+
* what the listener then did, and without a fake the two are stuck together.
|
|
33
|
+
*
|
|
34
|
+
* `@stacksjs/queue` already has the same pair for jobs (`fake()`,
|
|
35
|
+
* `getFakeQueue()`, `restore()`), so this follows it.
|
|
36
|
+
*/
|
|
37
|
+
/** One event as it was dispatched. */
|
|
38
|
+
export declare interface DispatchedEvent {
|
|
39
|
+
type: string
|
|
40
|
+
payload: unknown
|
|
41
|
+
}
|
package/dist/feature.d.ts
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
export declare function setupTestEnvironment(): void;
|
|
2
|
+
/**
|
|
3
|
+
* `?a=1&b=2` for `options.query`, or `''` when there is nothing to append.
|
|
4
|
+
*
|
|
5
|
+
* `undefined` and `null` are dropped rather than sent as the strings
|
|
6
|
+
* `"undefined"` / `"null"`, so an optional filter can be passed through
|
|
7
|
+
* without the caller building the object conditionally. An array repeats the
|
|
8
|
+
* key (`tag=a&tag=b`), which is what every server-side parser in this
|
|
9
|
+
* repository expects.
|
|
10
|
+
*/
|
|
11
|
+
export declare function queryString(query: Record<string, QueryValue> | undefined): string;
|
|
12
|
+
/**
|
|
13
|
+
* Reject an options object that is really a body.
|
|
14
|
+
*
|
|
15
|
+
* `http.post('/api/users', { name: 'Jane' })` is the mistake this package
|
|
16
|
+
* would otherwise make silently: the object looks like a body, is treated as
|
|
17
|
+
* options, matches no known key, and the request goes out empty. Failing
|
|
18
|
+
* loudly costs one comparison and saves a debugging session.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertRequestOptions(options: RequestOptions, method: string, path: string): void;
|
|
2
21
|
export declare function featureTest(baseUrl?: string): FeatureTestClient;
|
|
22
|
+
/**
|
|
23
|
+
* Authenticate as `user`, then make requests.
|
|
24
|
+
*
|
|
25
|
+
* Shorthand for `featureTest().actingAs(user)`, which is how nearly every
|
|
26
|
+
* feature test starts. The returned client is fresh per call, so two tests
|
|
27
|
+
* acting as two users never share a token.
|
|
28
|
+
*/
|
|
29
|
+
export declare function actingAs(user: { id: number | string, [k: string]: unknown }, baseUrl?: string): FeatureTestClient;
|
|
30
|
+
/**
|
|
31
|
+
* A request-at-a-time HTTP client for feature tests.
|
|
32
|
+
*
|
|
33
|
+
* Each call constructs its own `featureTest()` client, so `http` holds no
|
|
34
|
+
* state at all: nothing an earlier test set can reach a later one, which is
|
|
35
|
+
* the failure mode a shared module-level client would have.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* await http.get('/api/users', { query: { page: 1 } })
|
|
39
|
+
* await http.post('/api/users', { body: { name: 'Jane' } })
|
|
40
|
+
* await http.post('/api/upload', { formData: { file: new File(['x'], 'a.txt') } })
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* Note the body is WRAPPED. The fluent client takes a bare body
|
|
44
|
+
* (`featureTest().post('/api/users', { name: 'Jane' })`) because it has no
|
|
45
|
+
* options to disambiguate it from; `http` has, so it cannot. Passing a bare
|
|
46
|
+
* body here throws rather than sending an empty request - see
|
|
47
|
+
* `assertRequestOptions`.
|
|
48
|
+
*/
|
|
49
|
+
export declare const http: HttpClient;
|
|
3
50
|
/**
|
|
4
51
|
* Lightweight feature-test helper. Returns a fluent client that wraps
|
|
5
52
|
* the request flow (`actingAs`, `json`, `assertStatus`, etc.) so test
|
|
@@ -28,6 +75,7 @@ export declare function featureTest(baseUrl?: string): FeatureTestClient;
|
|
|
28
75
|
*/
|
|
29
76
|
export declare interface FeatureTestResponse {
|
|
30
77
|
status: number
|
|
78
|
+
ok: boolean
|
|
31
79
|
headers: Headers
|
|
32
80
|
text: () => Promise<string>
|
|
33
81
|
json: <T = unknown>() => Promise<T>
|
|
@@ -38,9 +86,42 @@ export declare interface FeatureTestResponse {
|
|
|
38
86
|
export declare interface FeatureTestClient {
|
|
39
87
|
actingAs: (user: { id: number | string, [k: string]: unknown }) => FeatureTestClient
|
|
40
88
|
withHeaders: (headers: Record<string, string>) => FeatureTestClient
|
|
89
|
+
request: (method: string, path: string, spec?: Omit<RequestOptions, 'actingAs'>) => Promise<FeatureTestResponse>
|
|
41
90
|
get: (path: string) => Promise<FeatureTestResponse>
|
|
42
91
|
post: (path: string, body?: unknown) => Promise<FeatureTestResponse>
|
|
43
92
|
put: (path: string, body?: unknown) => Promise<FeatureTestResponse>
|
|
44
93
|
patch: (path: string, body?: unknown) => Promise<FeatureTestResponse>
|
|
45
94
|
delete: (path: string, body?: unknown) => Promise<FeatureTestResponse>
|
|
46
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* One request, described rather than built up by chaining.
|
|
98
|
+
*
|
|
99
|
+
* This is the shape `http.*` takes. It exists next to the fluent client
|
|
100
|
+
* because the two answer different questions: `featureTest()` is for a
|
|
101
|
+
* sequence of requests that share an identity, `http` is for a single
|
|
102
|
+
* self-contained one.
|
|
103
|
+
*/
|
|
104
|
+
export declare interface RequestOptions {
|
|
105
|
+
headers?: Record<string, string>
|
|
106
|
+
query?: Record<string, QueryValue>
|
|
107
|
+
body?: unknown
|
|
108
|
+
formData?: Record<string, string | Blob>
|
|
109
|
+
actingAs?: { id: number | string, [k: string]: unknown }
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A stateless HTTP client for feature tests.
|
|
113
|
+
*
|
|
114
|
+
* Every method takes `(path, options?)` and builds a fresh request, so
|
|
115
|
+
* nothing leaks between tests and there is no client to construct.
|
|
116
|
+
*/
|
|
117
|
+
export declare interface HttpClient {
|
|
118
|
+
get: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
119
|
+
post: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
120
|
+
put: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
121
|
+
patch: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
122
|
+
delete: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
123
|
+
head: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
124
|
+
options: (path: string, options?: RequestOptions) => Promise<FeatureTestResponse>
|
|
125
|
+
}
|
|
126
|
+
/** A value a query string can carry, before it is stringified. */
|
|
127
|
+
export type QueryValue = string | number | boolean | null | undefined | Array<string | number | boolean>;
|
package/dist/index.d.ts
CHANGED
|
@@ -18,4 +18,13 @@
|
|
|
18
18
|
// Tests that don't need them get a fast, hang-free import from
|
|
19
19
|
// '@stacksjs/testing'.
|
|
20
20
|
export * from './feature';
|
|
21
|
+
// Clock control (stacksjs/stacks#2581). No database dependency, so unlike the
|
|
22
|
+
// fixtures it is safe at the root.
|
|
23
|
+
export * from './console';
|
|
24
|
+
// Event fakes (stacksjs/stacks#2581). `@stacksjs/events` is a leaf package with
|
|
25
|
+
// no database dependency, so unlike the fixtures it is safe at the root.
|
|
26
|
+
export * from './events';
|
|
27
|
+
// Mail fakes (stacksjs/stacks#2581), on top of the email package's capture driver.
|
|
28
|
+
export * from './mail';
|
|
29
|
+
export * from './time';
|
|
21
30
|
export * from 'bun:test';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import
|
|
2
|
+
import R from"process";function z(){R.env.NODE_ENV="test",R.env.APP_ENV="test"}var b=new Set(["headers","query","body","formData","actingAs"]);function q(e){if(!e)return"";let t=new URLSearchParams;for(let[s,o]of Object.entries(e)){if(o===void 0||o===null)continue;if(Array.isArray(o)){for(let u of o)t.append(s,String(u));continue}t.append(s,String(o))}let n=t.toString();return n?`?${n}`:""}function D(e,t,n){let s=Object.keys(e).filter((o)=>!b.has(o));if(s.length===0)return;throw TypeError(`http.${t.toLowerCase()}('${n}', \u2026) got unknown option(s) ${s.map((o)=>`'${o}'`).join(", ")}. Did you mean to send a body? Wrap it: { body: { ${s[0]}: \u2026 } }. `+`Valid options are ${[...b].join(", ")}.`)}async function S(){let e=await import("@stacksjs/router");return e.serverResponse}function N(e){let t={status:e.status,ok:e.ok,headers:e.headers,text:()=>e.text(),json:async()=>await e.clone().json(),assertStatus(n){if(this.status!==n)throw Error(`Expected status ${n}, got ${this.status}`);return t},async assertJson(n){let s=await e.clone().json();for(let[o,u]of Object.entries(n))if(JSON.stringify(s[o])!==JSON.stringify(u))throw Error(`assertJson: expected ${o}=${JSON.stringify(u)}, got ${JSON.stringify(s[o])}`);return t},assertHeader(n,s){let o=e.headers.get(n);if(o===null)throw Error(`assertHeader: missing header '${n}'`);if(s!==void 0){if(!(typeof s==="string"?o===s:s.test(o)))throw Error(`assertHeader: expected ${n}=${String(s)}, got '${o}'`)}return t}};return t}function k(e="http://localhost"){let t=null,n={},s=null;async function o(){if(!t)return null;if(s)return s;let{Auth:r}=await import("@stacksjs/auth"),i=Number(t.id);if(!Number.isFinite(i))throw TypeError(`actingAs() needs a user with a numeric id; got ${JSON.stringify(t.id)}`);let d=await r.loginUsingId(i);if(!d)throw Error(`actingAs() could not authenticate user ${i}: no such user. Create the row first (e.g. via a factory) so a token can be issued against it.`);return s=String(d.token),s}async function u(r){let i={Accept:"application/json",...n,...r.headers};if(r.body!==void 0&&r.formData===void 0)i["Content-Type"]="application/json";let d=await o();if(d&&!i.Authorization&&!i.authorization)i.Authorization=`Bearer ${d}`;return i}function l(r){if(r.formData!==void 0){if(r.body!==void 0)throw TypeError("A request carries either `body` or `formData`, not both.");let i=new FormData;for(let[d,m]of Object.entries(r.formData))i.append(d,m);return i}if(r.body===void 0)return;return typeof r.body==="string"?r.body:JSON.stringify(r.body)}async function c(r,i,d={}){let m=await S(),w=i.startsWith("http")?i:`${e}${i}`,y=q(d.query),P=y&&w.includes("?")?`${w}&${y.slice(1)}`:`${w}${y}`,C={method:r,headers:await u(d),body:l(d)},A=await m(new Request(P,C));return N(A)}let a={actingAs(r){return t=r,s=null,a},withHeaders(r){return n={...n,...r},a},request:(r,i,d)=>c(r,i,d),get:(r)=>c("GET",r),post:(r,i)=>c("POST",r,{body:i}),put:(r,i)=>c("PUT",r,{body:i}),patch:(r,i)=>c("PATCH",r,{body:i}),delete:(r,i)=>c("DELETE",r,{body:i})};return a}function Q(e,t){return k(t).actingAs(e)}var G=Object.freeze(["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"].reduce((e,t)=>{let n=async(s,o={})=>{D(o,t,s);let{actingAs:u,...l}=o,c=k();if(u)c.actingAs(u);return c.request(t,s,l)};return{...e,[t.toLowerCase()]:n}},{}));import x from"process";var j=60000;function H(e){let t=[],n="",s=null,o=!1,u=!1;for(let l of e){if(o){n+=l,o=!1;continue}if(l==="\\"){o=!0,u=!0;continue}if(s){if(l===s)s=null;else n+=l;continue}if(l==='"'||l==="'"){s=l,u=!0;continue}if(/\s/.test(l)){if(u)t.push(n),n="",u=!1;continue}n+=l,u=!0}if(s)throw Error(`Unterminated ${s} in command: ${e}`);if(u)t.push(n);return t}function Y(e){let t=typeof e==="string"?H(e):[...e],n,s={},o=x.cwd(),u=j;async function l(){if(t.length===0)throw Error("command() needs something to run");let a=Bun.spawn(["./buddy",...t],{cwd:o,env:{...x.env,...s},stdin:n===void 0?"ignore":new TextEncoder().encode(n),stdout:"pipe",stderr:"pipe"}),r=!1,i=setTimeout(()=>{r=!0,a.kill()},u);try{let[d,m,w]=await Promise.all([new Response(a.stdout).text(),new Response(a.stderr).text(),a.exited]);return{exitCode:w,stdout:d,stderr:m,output:d+m,timedOut:r}}finally{clearTimeout(i)}}let c={withInput(a){return n=a.map((r)=>`${r}
|
|
3
|
+
`).join(""),c},withEnv(a){return s={...s,...a},c},withCwd(a){return o=a,c},withTimeout(a){if(!Number.isFinite(a)||a<=0)throw TypeError(`withTimeout expects a positive number of milliseconds, got ${a}`);return u=a,c},then(a,r){return l().then(a,r)}};return c}import{afterEach as B}from"bun:test";import{events as g}from"@stacksjs/events";var p=null,h=[],E=(e,t)=>{h.push({type:String(e),payload:t})};function te(){if(p)return;p=[...g.all.entries()],g.all.clear(),h=[],g.on("*",E)}function ne(){return p!==null}function L(e){if(!p)throw Error('getDispatchedEvents() was called without eventFake(). Nothing is recording, so an empty result would look like "the event did not fire".');return e===void 0?[...h]:h.filter((t)=>t.type===e)}function re(e){return L(e).length>0}function J(){if(!p)return;g.off("*",E),g.all.clear();for(let[e,t]of p)g.all.set(e,t);p=null,h=[]}B(()=>{J()});import{afterEach as I}from"bun:test";import{CaptureEmailDriver as T,mail as v}from"@stacksjs/email";var f=null;function ae(){if(f!==null)return;f=v.fake(),T.clear()}function ue(){return f!==null}function O(){if(f===null)throw Error('sentEmails() was called without mailFake(). Nothing is capturing, so an empty result would look like "no email was sent".');return[...T.all()]}function de(){return O().at(-1)}function le(e){let t=(n)=>{if(n===void 0||n===null)return!1;return(Array.isArray(n)?n:[n]).some((o)=>(typeof o==="string"?o:o?.address)===e)};return O().filter((n)=>t(n.to)||t(n.cc)||t(n.bcc))}function M(){if(f===null)return;v.restoreDriver(f),f=null,T.clear()}I(()=>{M()});import{setSystemTime as F}from"bun:test";function U(e){let t=e instanceof Date?e:new Date(e);if(Number.isNaN(t.getTime()))throw TypeError(`Not a valid time: ${JSON.stringify(e)}`);return t}function _(e=new Date){let t=U(e);return F(t),t}function fe(e){return _(e)}function me(){F()}export*from"bun:test";export{Q as actingAs,D as assertRequestOptions,Y as command,le as emailsTo,te as eventFake,ne as eventsAreFaked,k as featureTest,_ as freezeTime,L as getDispatchedEvents,re as hasDispatchedEvent,G as http,de as lastEmail,ae as mailFake,ue as mailIsFaked,q as queryString,J as restoreEvents,M as restoreMail,O as sentEmails,z as setupTestEnvironment,H as splitCommand,fe as travelTo,me as useRealTime};
|
package/dist/mail.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CapturedMessage } from '@stacksjs/email';
|
|
2
|
+
/**
|
|
3
|
+
* Send every email into memory instead of a transport.
|
|
4
|
+
*
|
|
5
|
+
* Redirects the shared `mail` singleton - the one the code under test uses -
|
|
6
|
+
* and clears anything a previous test captured.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mailFake(): void;
|
|
9
|
+
/** Whether a mail fake is currently installed. */
|
|
10
|
+
export declare function mailIsFaked(): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Every message sent since `mailFake()`, oldest first.
|
|
13
|
+
*
|
|
14
|
+
* A copy, so a test holding the result across further sends sees what it
|
|
15
|
+
* asked for.
|
|
16
|
+
*/
|
|
17
|
+
export declare function sentEmails(): CapturedMessage[];
|
|
18
|
+
/** The most recent message, or `undefined` if none was sent. */
|
|
19
|
+
export declare function lastEmail(): CapturedMessage | undefined;
|
|
20
|
+
/** Messages addressed to `address`, matching any of to/cc/bcc. */
|
|
21
|
+
export declare function emailsTo(address: string): CapturedMessage[];
|
|
22
|
+
/** Put the real transport back and drop what was captured. */
|
|
23
|
+
export declare function restoreMail(): void;
|
package/dist/time.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop the clock, at `at` or at the current moment.
|
|
3
|
+
*
|
|
4
|
+
* **Restore it when the test ends.** The system time is process-wide and bun
|
|
5
|
+
* does not roll it back between test files, so a frozen clock leaks into every
|
|
6
|
+
* later suite in the same run - where it surfaces as a token that is
|
|
7
|
+
* inexplicably expired, or a `created_at` in the wrong year, a long way from
|
|
8
|
+
* the test that caused it.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { afterEach, freezeTime, useRealTime } from '@stacksjs/testing'
|
|
13
|
+
*
|
|
14
|
+
* afterEach(useRealTime)
|
|
15
|
+
*
|
|
16
|
+
* it('expires after an hour', () => {
|
|
17
|
+
* freezeTime('2024-01-15T10:00:00Z')
|
|
18
|
+
* // ...
|
|
19
|
+
* })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function freezeTime(at?: TimeLike): Date;
|
|
23
|
+
/**
|
|
24
|
+
* Move the clock to `at`, leaving it frozen there.
|
|
25
|
+
*
|
|
26
|
+
* The same operation as {@link freezeTime} with an argument; both exist because
|
|
27
|
+
* a test that has already frozen reads better as travelling than as freezing
|
|
28
|
+
* again. Restore with {@link useRealTime}.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* travelTo(new Date('2024-06-01'))
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function travelTo(at: TimeLike): Date;
|
|
36
|
+
/**
|
|
37
|
+
* Hand the clock back to the operating system.
|
|
38
|
+
*
|
|
39
|
+
* Not in the documentation this implements, and that was the documentation's
|
|
40
|
+
* bug: without it the clock stays where the last test put it for the rest of
|
|
41
|
+
* the process.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useRealTime(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Controlling the clock in a test (stacksjs/stacks#2581).
|
|
46
|
+
*
|
|
47
|
+
* Documented in `docs/packages/testing.md` and never implemented, so the sample
|
|
48
|
+
* teaching it imported two names that did not exist.
|
|
49
|
+
*
|
|
50
|
+
* Thin on purpose: `bun:test` already has `setSystemTime`, and these exist
|
|
51
|
+
* because the intent of a test reads better as "freeze time" than as "set the
|
|
52
|
+
* system time to now". The one thing they add over calling it directly is
|
|
53
|
+
* {@link useRealTime} and the insistence on restoring - see below.
|
|
54
|
+
*/
|
|
55
|
+
/** What a caller may name a moment with. */
|
|
56
|
+
export type TimeLike = Date | string | number;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/testing",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.38",
|
|
6
6
|
"description": "The Stacks way of testing.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
"testing",
|
|
28
28
|
"utilities",
|
|
29
29
|
"functions",
|
|
30
|
-
"playwright",
|
|
31
30
|
"stacks"
|
|
32
31
|
],
|
|
33
32
|
"exports": {
|
|
@@ -74,14 +73,17 @@
|
|
|
74
73
|
"prepublishOnly": "bun run build"
|
|
75
74
|
},
|
|
76
75
|
"dependencies": {
|
|
77
|
-
"@stacksjs/auth": "0.74.
|
|
78
|
-
"@stacksjs/cache": "0.74.
|
|
79
|
-
"@stacksjs/config": "0.74.
|
|
80
|
-
"@stacksjs/database": "0.74.
|
|
81
|
-
"@stacksjs/
|
|
82
|
-
"@stacksjs/
|
|
83
|
-
"@stacksjs/
|
|
84
|
-
"@stacksjs/
|
|
76
|
+
"@stacksjs/auth": "0.74.38",
|
|
77
|
+
"@stacksjs/cache": "0.74.38",
|
|
78
|
+
"@stacksjs/config": "0.74.38",
|
|
79
|
+
"@stacksjs/database": "0.74.38",
|
|
80
|
+
"@stacksjs/email": "0.74.38",
|
|
81
|
+
"@stacksjs/events": "0.74.38",
|
|
82
|
+
"@stacksjs/path": "0.74.38",
|
|
83
|
+
"@stacksjs/router": "0.74.38",
|
|
84
|
+
"@stacksjs/storage": "0.74.38",
|
|
85
|
+
"@stacksjs/strings": "0.74.38",
|
|
86
|
+
"@stacksjs/ts-cloud": "^0.15.1"
|
|
85
87
|
},
|
|
86
88
|
"devDependencies": {
|
|
87
89
|
"better-dx": "^0.2.24"
|