@strav/testing 0.4.31 → 1.0.0-alpha.24
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/README.md +34 -71
- package/package.json +22 -29
- package/src/boot_test_app.ts +169 -0
- package/src/brain/index.ts +1 -0
- package/src/brain/stub_brain_provider.ts +72 -0
- package/src/compose_test_config.ts +47 -0
- package/src/index.ts +27 -15
- package/src/mem_stream.ts +50 -0
- package/src/postgres/connected_role_bypasses_rls.ts +22 -0
- package/src/postgres/create_test_database.ts +20 -0
- package/src/postgres/index.ts +5 -0
- package/src/postgres/is_postgres_available.ts +33 -0
- package/src/postgres/reset_schema.ts +14 -0
- package/src/postgres/test_database_url.ts +33 -0
- package/src/stub_fetch.ts +56 -0
- package/src/tenant_manager_provider.ts +40 -0
- package/CHANGELOG.md +0 -7
- package/src/browser/db_fresh.ts +0 -38
- package/src/browser/demo_flow.ts +0 -89
- package/src/browser/index.ts +0 -11
- package/src/browser/server_lifecycle.ts +0 -42
- package/src/browser/test_case.ts +0 -572
- package/src/database_manager.ts +0 -131
- package/src/factory.ts +0 -68
- package/src/test_case.ts +0 -312
- package/tsconfig.json +0 -5
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the standard env-var contract (`DB_HOST` / `DB_PORT` / `DB_USER` /
|
|
3
|
+
* `DB_PASSWORD` / `DB_DATABASE`) shared with CI and `.env.test` and
|
|
4
|
+
* assembles a Postgres URL. Returns `null` when any required variable
|
|
5
|
+
* is missing — callers self-skip rather than throw, so `bun test` is a
|
|
6
|
+
* no-op for integration tests in environments without local Postgres.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
interface PgEnv {
|
|
10
|
+
host: string
|
|
11
|
+
port: string
|
|
12
|
+
user: string
|
|
13
|
+
password: string
|
|
14
|
+
database: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readPgEnv(): PgEnv | null {
|
|
18
|
+
const host = process.env.DB_HOST
|
|
19
|
+
const port = process.env.DB_PORT
|
|
20
|
+
const user = process.env.DB_USER
|
|
21
|
+
const password = process.env.DB_PASSWORD
|
|
22
|
+
const database = process.env.DB_DATABASE
|
|
23
|
+
if (!host || !port || !user || !password || !database) return null
|
|
24
|
+
return { host, port, user, password, database }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function testDatabaseUrl(): string | null {
|
|
28
|
+
const env = readPgEnv()
|
|
29
|
+
if (env === null) return null
|
|
30
|
+
// `encodeURIComponent` so a password containing `@` or `:` doesn't
|
|
31
|
+
// corrupt the URL.
|
|
32
|
+
return `postgres://${env.user}:${encodeURIComponent(env.password)}@${env.host}:${env.port}/${env.database}`
|
|
33
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed `fetch` stub for driver tests that need to assert on outgoing
|
|
3
|
+
* HTTP without a network round-trip.
|
|
4
|
+
*
|
|
5
|
+
* Adapters that take a `fetch` injection point (e.g. `LineSocialDriver`,
|
|
6
|
+
* pure-fetch brain drivers) typically end up with `as unknown as typeof
|
|
7
|
+
* fetch` boilerplate in tests because the standard `fetch` signature
|
|
8
|
+
* (with `preconnect`, etc.) doesn't match a plain async function.
|
|
9
|
+
* `stubFetch` confines that cast to one place.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { stubFetch } from '@strav/testing'
|
|
13
|
+
*
|
|
14
|
+
* const captured: Request[] = []
|
|
15
|
+
* const driver = new LineSocialDriver({
|
|
16
|
+
* config: { ... },
|
|
17
|
+
* fetch: stubFetch(async (req) => {
|
|
18
|
+
* captured.push(req)
|
|
19
|
+
* if (req.url.includes('/token')) {
|
|
20
|
+
* return Response.json({ access_token: 'AT_1', expires_in: 3600 })
|
|
21
|
+
* }
|
|
22
|
+
* return new Response('not found', { status: 404 })
|
|
23
|
+
* }),
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The handler receives a `Request` regardless of how the caller invoked
|
|
28
|
+
* `fetch` (URL + init, URL string, existing Request). Normalization
|
|
29
|
+
* happens here so the handler doesn't have to branch on input shape.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type FetchHandler = (request: Request) => Response | Promise<Response>
|
|
33
|
+
|
|
34
|
+
export function stubFetch(handler: FetchHandler): typeof fetch {
|
|
35
|
+
const fn = async (
|
|
36
|
+
input: Parameters<typeof fetch>[0],
|
|
37
|
+
init?: Parameters<typeof fetch>[1],
|
|
38
|
+
): Promise<Response> => {
|
|
39
|
+
const request = normalizeToRequest(input, init)
|
|
40
|
+
return handler(request)
|
|
41
|
+
}
|
|
42
|
+
return fn as unknown as typeof fetch
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeToRequest(
|
|
46
|
+
input: Parameters<typeof fetch>[0],
|
|
47
|
+
init: Parameters<typeof fetch>[1],
|
|
48
|
+
): Request {
|
|
49
|
+
if (input instanceof Request) {
|
|
50
|
+
// If `init` is provided, the spec says we layer it on; new Request
|
|
51
|
+
// accepts (Request, RequestInit) and merges accordingly.
|
|
52
|
+
return init === undefined ? input : new Request(input, init)
|
|
53
|
+
}
|
|
54
|
+
// string | URL → construct a Request. URL converts via its toString.
|
|
55
|
+
return new Request(typeof input === 'string' ? input : input.toString(), init)
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard `TenantManager` wiring for tests.
|
|
3
|
+
*
|
|
4
|
+
* `DatabaseProvider` doesn't auto-register `TenantManager` — apps wire
|
|
5
|
+
* it themselves so the binding is explicit (and so apps that don't use
|
|
6
|
+
* tenancy don't pay for it). Every integration / e2e suite that uses
|
|
7
|
+
* `TenantManager` defined an identical 3-line `ServiceProvider` to do
|
|
8
|
+
* the wiring; this is the extracted version.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { TenantManagerProvider } from '@strav/testing'
|
|
12
|
+
*
|
|
13
|
+
* app.useProviders([
|
|
14
|
+
* new ConfigProvider({ ... }),
|
|
15
|
+
* new LoggerProvider(),
|
|
16
|
+
* new DatabaseProvider(),
|
|
17
|
+
* new TenantManagerProvider(),
|
|
18
|
+
* // your provider here
|
|
19
|
+
* ])
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The class implements the same shape every e2e was rolling by hand:
|
|
23
|
+
* declares `database` as a dependency, binds `TenantManager` as a
|
|
24
|
+
* singleton built from the resolved `PostgresDatabase` + `EventBus`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { PostgresDatabase, TenantManager } from '@strav/database'
|
|
28
|
+
import { type Application, EventBus, ServiceProvider } from '@strav/kernel'
|
|
29
|
+
|
|
30
|
+
export class TenantManagerProvider extends ServiceProvider {
|
|
31
|
+
override readonly name = 'tenant'
|
|
32
|
+
override readonly dependencies = ['database']
|
|
33
|
+
|
|
34
|
+
override register(app: Application): void {
|
|
35
|
+
app.singleton(
|
|
36
|
+
TenantManager,
|
|
37
|
+
(c) => new TenantManager(c.resolve(PostgresDatabase), c.resolve(EventBus)),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
package/CHANGELOG.md
DELETED
package/src/browser/db_fresh.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Programmatic equivalent of `bun strav fresh`. Refuses to run unless the
|
|
3
|
-
* caller has set APP_ENV to 'test' or 'local' — the CLI command requires
|
|
4
|
-
* 'local'; we additionally accept 'test' so test runners don't need a flag
|
|
5
|
-
* dance. Lazy-imports `@strav/cli` so packages that don't use BrowserTestCase
|
|
6
|
-
* don't pay the dependency cost.
|
|
7
|
-
*/
|
|
8
|
-
export async function runFresh(): Promise<void> {
|
|
9
|
-
const env = process.env.APP_ENV
|
|
10
|
-
if (env !== 'test' && env !== 'local') {
|
|
11
|
-
throw new Error(
|
|
12
|
-
`runFresh refused: APP_ENV must be 'test' or 'local' (got '${env ?? '<unset>'}'). ` +
|
|
13
|
-
`This is a safety guard — fresh wipes all tables.`
|
|
14
|
-
)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let freshDatabase: typeof import('@strav/cli/commands/migration_fresh').freshDatabase
|
|
18
|
-
let bootstrap: typeof import('@strav/cli/cli/bootstrap').bootstrap
|
|
19
|
-
let shutdown: typeof import('@strav/cli/cli/bootstrap').shutdown
|
|
20
|
-
let getDatabasePaths: typeof import('@strav/cli/config/loader').getDatabasePaths
|
|
21
|
-
try {
|
|
22
|
-
;({ freshDatabase } = await import('@strav/cli/commands/migration_fresh'))
|
|
23
|
-
;({ bootstrap, shutdown } = await import('@strav/cli/cli/bootstrap'))
|
|
24
|
-
;({ getDatabasePaths } = await import('@strav/cli/config/loader'))
|
|
25
|
-
} catch (err) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
`runFresh requires @strav/cli to be installed in this workspace. Add it as a peer dependency. (${(err as Error).message})`
|
|
28
|
-
)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const paths = await getDatabasePaths()
|
|
32
|
-
const { db, registry, introspector } = await bootstrap()
|
|
33
|
-
try {
|
|
34
|
-
await freshDatabase(db, registry, introspector, paths.migrations)
|
|
35
|
-
} finally {
|
|
36
|
-
await shutdown(db)
|
|
37
|
-
}
|
|
38
|
-
}
|
package/src/browser/demo_flow.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { Page } from 'playwright-core'
|
|
2
|
-
import { BrowserTestCase } from './test_case.ts'
|
|
3
|
-
import type { BrowserTestCaseOptions } from './test_case.ts'
|
|
4
|
-
|
|
5
|
-
export interface DemoFlowOptions extends BrowserTestCaseOptions {
|
|
6
|
-
/** Default: '/auth/magic' — the conventional magic-link endpoint. */
|
|
7
|
-
signInEndpoint?: string
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Opinionated wrapper around {@link BrowserTestCase} that ships the AGON
|
|
12
|
-
* "demo flow" surface: magic-link sign-in via captured mail, fixture
|
|
13
|
-
* composition for slice-by-slice extension, and stricter defaults
|
|
14
|
-
* (`fresh: true`, `mail: 'capture'`).
|
|
15
|
-
*
|
|
16
|
-
* Apps doing general browser testing should reach for `BrowserTestCase`
|
|
17
|
-
* directly; `DemoFlow` exists for the AGON slice-DoD use case.
|
|
18
|
-
*/
|
|
19
|
-
export class DemoFlow {
|
|
20
|
-
readonly tc: BrowserTestCase
|
|
21
|
-
private readonly signInEndpoint: string
|
|
22
|
-
|
|
23
|
-
constructor(tc: BrowserTestCase, options: DemoFlowOptions = {}) {
|
|
24
|
-
this.tc = tc
|
|
25
|
-
this.signInEndpoint = options.signInEndpoint ?? '/auth/magic'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Boot a DemoFlow — defaults `fresh: true`, `mail: 'capture'`. */
|
|
29
|
-
static async boot(options: DemoFlowOptions = {}): Promise<DemoFlow> {
|
|
30
|
-
const tc = await BrowserTestCase.boot({
|
|
31
|
-
fresh: options.fresh ?? true,
|
|
32
|
-
mail: options.mail ?? 'capture',
|
|
33
|
-
...options,
|
|
34
|
-
})
|
|
35
|
-
return new DemoFlow(tc, options)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Build a fixture function that returns a fresh DemoFlow with `setup`
|
|
40
|
-
* applied. Fixture flows compose: `withWorkspace` can call
|
|
41
|
-
* `signedInUser()` and extend it.
|
|
42
|
-
*/
|
|
43
|
-
static fixture<T extends DemoFlow>(setup: (flow: DemoFlow) => Promise<T>): () => Promise<T> {
|
|
44
|
-
return async () => {
|
|
45
|
-
const flow = await DemoFlow.boot()
|
|
46
|
-
return await setup(flow)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Direct passthrough to the Playwright page for escape hatches.
|
|
51
|
-
get page(): Page { return this.tc.page }
|
|
52
|
-
|
|
53
|
-
// ---------------------------------------------------------------------------
|
|
54
|
-
// Delegated DSL
|
|
55
|
-
// ---------------------------------------------------------------------------
|
|
56
|
-
|
|
57
|
-
goto(path: string): Promise<void> { return this.tc.goto(path) }
|
|
58
|
-
click(selector: string): Promise<void> { return this.tc.click(selector) }
|
|
59
|
-
fill(selector: string, value: string): Promise<void> { return this.tc.fill(selector, value) }
|
|
60
|
-
expectUrl(url: string | RegExp): Promise<void> { return this.tc.expectUrl(url) }
|
|
61
|
-
expectVisible(selector: string, text?: string | RegExp): Promise<void> { return this.tc.expectVisible(selector, text) }
|
|
62
|
-
expectComputedStyle(
|
|
63
|
-
selector: string,
|
|
64
|
-
property: string,
|
|
65
|
-
matcher: Parameters<BrowserTestCase['expectComputedStyle']>[2],
|
|
66
|
-
): Promise<void> {
|
|
67
|
-
return this.tc.expectComputedStyle(selector, property, matcher)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Auth
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Sign in via the captured magic-link flow. Posts to the configured
|
|
76
|
-
* endpoint, waits for the captured email, follows the link.
|
|
77
|
-
*/
|
|
78
|
-
signIn(opts: { email: string; endpoint?: string; subject?: string | RegExp; tokenParam?: string }): Promise<void> {
|
|
79
|
-
return this.tc.signInWithMagicLink({
|
|
80
|
-
email: opts.email,
|
|
81
|
-
endpoint: opts.endpoint ?? this.signInEndpoint,
|
|
82
|
-
subject: opts.subject,
|
|
83
|
-
tokenParam: opts.tokenParam,
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Skip the email loop — mint a session directly for `user`. */
|
|
88
|
-
signInAs(user: unknown): Promise<void> { return this.tc.signInAs(user) }
|
|
89
|
-
}
|
package/src/browser/index.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export { BrowserTestCase } from './test_case.ts'
|
|
2
|
-
export type {
|
|
3
|
-
BrowserTestCaseOptions,
|
|
4
|
-
BrowserName,
|
|
5
|
-
MailMode,
|
|
6
|
-
} from './test_case.ts'
|
|
7
|
-
export { DemoFlow } from './demo_flow.ts'
|
|
8
|
-
export type { DemoFlowOptions } from './demo_flow.ts'
|
|
9
|
-
export { runFresh } from './db_fresh.ts'
|
|
10
|
-
export { startListener, stopListener } from './server_lifecycle.ts'
|
|
11
|
-
export type { ServerHandle } from './server_lifecycle.ts'
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { app, Configuration } from '@strav/kernel'
|
|
2
|
-
import { Router, Server } from '@strav/http'
|
|
3
|
-
|
|
4
|
-
export interface ServerHandle {
|
|
5
|
-
server: Server
|
|
6
|
-
router: Router
|
|
7
|
-
baseUrl: string
|
|
8
|
-
port: number
|
|
9
|
-
hostname: string
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Bind a Bun.serve listener for a router that has already had its routes
|
|
14
|
-
* registered. Forces an ephemeral port (port=0) unless the caller specifies
|
|
15
|
-
* one. The caller owns the lifecycle: call {@link stopListener} to tear down.
|
|
16
|
-
*/
|
|
17
|
-
export function startListener(router: Router, options: { port?: number; hostname?: string } = {}): ServerHandle {
|
|
18
|
-
const config = app.resolve(Configuration)
|
|
19
|
-
const requestedPort = options.port ?? 0
|
|
20
|
-
const requestedHost = options.hostname ?? '127.0.0.1'
|
|
21
|
-
|
|
22
|
-
// Override config so Server.start() picks up our port/host without the
|
|
23
|
-
// caller having to mutate their config files.
|
|
24
|
-
config.set('http.port', requestedPort)
|
|
25
|
-
config.set('http.host', requestedHost)
|
|
26
|
-
|
|
27
|
-
if (!app.has(Server)) app.singleton(Server)
|
|
28
|
-
const server = app.resolve(Server)
|
|
29
|
-
server.start(router)
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
server,
|
|
33
|
-
router,
|
|
34
|
-
port: server.port,
|
|
35
|
-
hostname: server.hostname,
|
|
36
|
-
baseUrl: `http://${server.hostname}:${server.port}`,
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function stopListener(handle: ServerHandle): void {
|
|
41
|
-
handle.server.stop()
|
|
42
|
-
}
|