@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.
@@ -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
+ }
@@ -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 l}from"@stacksjs/config";import{copyModelFiles as w,db as a,deleteFrameworkModels as y,dropSqliteTables as h,fetchSqliteFile as c,fetchTables as T,migrateAuthTables as S,migrateNotificationTables as g,migrateRbacTables as P,migrateTraitTables as E,ensureUtcDatetimeColumns as x,ensureUtcTimestampDefaults as A,runDatabaseMigration as u}from"@stacksjs/database";import{path as o}from"@stacksjs/path";import{fs as i,globSync as f}from"@stacksjs/storage";function m(){return l.database?.default||""}var r=null;function M(){if(r)return r;return r=`${c()}.snapshot`,r}async function d(){let t=[["auth",S],["notification",g],["RBAC",P],["trait",E],["utc-datetime",x],["utc-defaults",A]];for(let[e,n]of t)try{let s=await n();if(!s.success)console.error(`[testing] Failed to migrate ${e} tables: ${s.error}`)}catch(s){console.error(`[testing] Failed to migrate ${e} tables:`,s)}}async function v(){let t=`${l.database?.connections?.mysql?.name??"stacks"}_testing`;if(m()==="mysql")await a.unsafe(`CREATE DATABASE IF NOT EXISTS ${t}`).execute(),await u(),await d()}async function _(){await v();let t=m();if(t==="mysql")await F();if(t==="sqlite")await q()}async function F(){let t=await T();await a.unsafe("SET FOREIGN_KEY_CHECKS = 0").execute();try{for(let e of t)await a.unsafe(`TRUNCATE TABLE ${e}`).execute()}finally{await a.unsafe("SET FOREIGN_KEY_CHECKS = 1").execute()}}async function k(){let t=c();if(!i.existsSync(t))await Bun.$`touch ${t}`;await h(),await y();let e=f([o.userModelsPath("*.ts"),o.storagePath("framework/defaults/app/Models/**/*.ts")],{absolute:!0});for(let n of e)await w(n);await u(),await d()}async function q(){let t=c(),e=M();if((()=>{if(!i.existsSync(e))return!0;let s=i.statSync(e).mtimeMs,p=f([o.userModelsPath("*.ts"),o.storagePath("framework/defaults/app/Models/**/*.ts"),o.userMigrationsPath("*.ts")],{absolute:!0});for(let b of p)try{if(i.statSync(b).mtimeMs>s)return!0}catch{}return!1})()){await k();try{await a.unsafe("PRAGMA wal_checkpoint(TRUNCATE)").execute(),i.copyFileSync(t,e)}catch{}return}i.copyFileSync(e,t)}function D(){let t=null;return{begin:async()=>{let e=`stacks_test_${Date.now()}_${Math.floor(Math.random()*1e6)}`;await a.unsafe(`SAVEPOINT ${e}`).execute(),t={rollback:async()=>{await a.unsafe(`ROLLBACK TO SAVEPOINT ${e}`).execute(),await a.unsafe(`RELEASE SAVEPOINT ${e}`).execute()}}},rollback:async()=>{if(!t)return;try{await t.rollback()}finally{t=null}}}}export{_ as refreshDatabase,v as setupDatabase,F as truncateMysql,k as truncateSqlite,q as truncateSqliteFast,D as useTransactionalTests};
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};
@@ -1,3 +1,2 @@
1
- export declare function launchServer(): Promise<void>;
2
1
  export declare function createStacksTable(): Promise<void>;
3
2
  export declare function deleteStacksTable(): Promise<void>;
package/dist/dynamodb.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- import r from"process";import{DynamoDBClient as o}from"@stacksjs/ts-cloud";var i=await import("@stacksjs/cache").then((e)=>e.dynamoDbTool),t=new o("us-east-1");async function y(){if(!r.env.GITHUB_ACTIONS)await i.dynamoDb.launch();await n(5000),await c()}async function n(e){return new Promise((a)=>setTimeout(a,e))}async function c(){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 l(){try{await t.deleteTable({TableName:"stacks"})}catch(e){console.error("Error deleting table:",e)}}export{c as createStacksTable,l as deleteStacksTable,y as launchServer};
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};
@@ -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 p from"process";function R(){p.env.NODE_ENV="test",p.env.APP_ENV="test"}async function h(){let o=await import("@stacksjs/router");return o.serverResponse}function T(o){let a={status:o.status,headers:o.headers,text:()=>o.text(),json:async()=>await o.clone().json(),assertStatus(s){if(this.status!==s)throw Error(`Expected status ${s}, got ${this.status}`);return a},async assertJson(s){let n=await o.clone().json();for(let[i,u]of Object.entries(s))if(JSON.stringify(n[i])!==JSON.stringify(u))throw Error(`assertJson: expected ${i}=${JSON.stringify(u)}, got ${JSON.stringify(n[i])}`);return a},assertHeader(s,n){let i=o.headers.get(s);if(i===null)throw Error(`assertHeader: missing header '${s}'`);if(n!==void 0){if(!(typeof n==="string"?i===n:n.test(i)))throw Error(`assertHeader: expected ${s}=${String(n)}, got '${i}'`)}return a}};return a}function k(o="http://localhost"){let a=null,s={},n=null;async function i(){if(!a)return null;if(n)return n;let{Auth:t}=await import("@stacksjs/auth"),e=Number(a.id);if(!Number.isFinite(e))throw TypeError(`actingAs() needs a user with a numeric id; got ${JSON.stringify(a.id)}`);let r=await t.loginUsingId(e);if(!r)throw Error(`actingAs() could not authenticate user ${e}: no such user. Create the row first (e.g. via a factory) so a token can be issued against it.`);return n=String(r.token),n}async function u(t){let e={Accept:"application/json",...s};if(t!==void 0)e["Content-Type"]="application/json";let r=await i();if(r&&!e.Authorization&&!e.authorization)e.Authorization=`Bearer ${r}`;return e}async function c(t,e,r){let d=await h(),l=e.startsWith("http")?e:`${o}${e}`,f={method:t,headers:await u(r),body:r===void 0?void 0:typeof r==="string"?r:JSON.stringify(r)},w=await d(new Request(l,f));return T(w)}let g={actingAs(t){return a=t,n=null,g},withHeaders(t){return s={...s,...t},g},get:(t)=>c("GET",t),post:(t,e)=>c("POST",t,e),put:(t,e)=>c("PUT",t,e),patch:(t,e)=>c("PATCH",t,e),delete:(t,e)=>c("DELETE",t,e)};return g}export*from"bun:test";export{k as featureTest,R as setupTestEnvironment};
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.36",
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.36",
78
- "@stacksjs/cache": "0.74.36",
79
- "@stacksjs/config": "0.74.36",
80
- "@stacksjs/database": "0.74.36",
81
- "@stacksjs/path": "0.74.36",
82
- "@stacksjs/router": "0.74.36",
83
- "@stacksjs/storage": "0.74.36",
84
- "@stacksjs/ts-cloud": "^0.14.0"
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"