@better-auth/test-utils 1.5.0-beta.9

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,898 @@
1
+ import type { BetterAuthOptions } from "@better-auth/core";
2
+ import type {
3
+ Account,
4
+ Session,
5
+ User,
6
+ Verification,
7
+ } from "@better-auth/core/db";
8
+ import { getAuthTables } from "@better-auth/core/db";
9
+ import type { DBAdapter } from "@better-auth/core/db/adapter";
10
+ import {
11
+ createAdapterFactory,
12
+ deepmerge,
13
+ initGetDefaultModelName,
14
+ } from "@better-auth/core/db/adapter";
15
+ import { TTY_COLORS } from "@better-auth/core/env";
16
+ import { generateId } from "@better-auth/core/utils/id";
17
+ import { betterAuth } from "better-auth";
18
+ import { test } from "vitest";
19
+ import type { Logger } from "./test-adapter";
20
+
21
+ /**
22
+ * Test entry type that supports both callback and object formats.
23
+ * Object format allows specifying migration options that will be applied before the test runs.
24
+ */
25
+ export type TestEntry =
26
+ | ((context: {
27
+ readonly skip: {
28
+ (note?: string | undefined): never;
29
+ (condition: boolean, note?: string | undefined): void;
30
+ };
31
+ }) => Promise<void>)
32
+ | {
33
+ migrateBetterAuth?: BetterAuthOptions;
34
+ test: (context: {
35
+ readonly skip: {
36
+ (note?: string | undefined): never;
37
+ (condition: boolean, note?: string | undefined): void;
38
+ };
39
+ }) => Promise<void>;
40
+ };
41
+
42
+ /**
43
+ * Deep equality comparison for BetterAuthOptions.
44
+ * Handles nested objects, arrays, and primitive values.
45
+ */
46
+ function deepEqual(a: any, b: any): boolean {
47
+ if (a === b) return true;
48
+ if (a == null || b == null) return a === b;
49
+ if (typeof a !== typeof b) return false;
50
+
51
+ if (typeof a === "object") {
52
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
53
+
54
+ if (Array.isArray(a)) {
55
+ if (a.length !== b.length) return false;
56
+ for (let i = 0; i < a.length; i++) {
57
+ if (!deepEqual(a[i], b[i])) return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ const keysA = Object.keys(a);
63
+ const keysB = Object.keys(b);
64
+
65
+ if (keysA.length !== keysB.length) return false;
66
+
67
+ for (const key of keysA) {
68
+ if (!keysB.includes(key)) return false;
69
+ if (!deepEqual(a[key], b[key])) return false;
70
+ }
71
+
72
+ return true;
73
+ }
74
+
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * Statistics tracking for test suites.
80
+ */
81
+ export type TestSuiteStats = {
82
+ migrationCount: number;
83
+ totalMigrationTime: number;
84
+ testCount: number;
85
+ suiteStartTime: number;
86
+ suiteDuration: number;
87
+ suiteName: string;
88
+ groupingStats?: {
89
+ totalGroups: number;
90
+ averageTestsPerGroup: number;
91
+ largestGroupSize: number;
92
+ smallestGroupSize: number;
93
+ groupsWithMultipleTests: number;
94
+ totalTestsInGroups: number;
95
+ };
96
+ };
97
+
98
+ type GenerateFn = <M extends "user" | "session" | "verification" | "account">(
99
+ Model: M,
100
+ ) => Promise<
101
+ M extends "user"
102
+ ? User
103
+ : M extends "session"
104
+ ? Session
105
+ : M extends "verification"
106
+ ? Verification
107
+ : M extends "account"
108
+ ? Account
109
+ : undefined
110
+ >;
111
+
112
+ type Success<T> = {
113
+ data: T;
114
+ error: null;
115
+ };
116
+
117
+ type Failure<E> = {
118
+ data: null;
119
+ error: E;
120
+ };
121
+
122
+ type Result<T, E = Error> = Success<T> | Failure<E>;
123
+
124
+ async function tryCatch<T, E = Error>(
125
+ promise: Promise<T>,
126
+ ): Promise<Result<T, E>> {
127
+ try {
128
+ const data = await promise;
129
+ return { data, error: null };
130
+ } catch (error) {
131
+ return { data: null, error: error as E };
132
+ }
133
+ }
134
+
135
+ export type InsertRandomFn = <
136
+ M extends "user" | "session" | "verification" | "account",
137
+ Count extends number = 1,
138
+ >(
139
+ model: M,
140
+ count?: Count | undefined,
141
+ ) => Promise<
142
+ Count extends 1
143
+ ? M extends "user"
144
+ ? [User]
145
+ : M extends "session"
146
+ ? [User, Session]
147
+ : M extends "verification"
148
+ ? [Verification]
149
+ : M extends "account"
150
+ ? [User, Account]
151
+ : [undefined]
152
+ : Array<
153
+ M extends "user"
154
+ ? [User]
155
+ : M extends "session"
156
+ ? [User, Session]
157
+ : M extends "verification"
158
+ ? [Verification]
159
+ : M extends "account"
160
+ ? [User, Account]
161
+ : [undefined]
162
+ >
163
+ >;
164
+
165
+ export const createTestSuite = <
166
+ Tests extends Record<string, TestEntry>,
167
+ AdditionalOptions extends Record<string, any> = {},
168
+ >(
169
+ suiteName: string,
170
+ config: {
171
+ defaultBetterAuthOptions?: BetterAuthOptions | undefined;
172
+ /**
173
+ * Helpful if the default better auth options require migrations to be run.
174
+ */
175
+ alwaysMigrate?: boolean | undefined;
176
+ prefixTests?: string | undefined;
177
+ customIdGenerator?: () => any | Promise<any> | undefined;
178
+ },
179
+ tests: (
180
+ helpers: {
181
+ adapter: DBAdapter<BetterAuthOptions>;
182
+ log: Logger;
183
+ generate: GenerateFn;
184
+ insertRandom: InsertRandomFn;
185
+ /**
186
+ * A light cleanup function that will only delete rows it knows about.
187
+ */
188
+ cleanup: () => Promise<void>;
189
+ /**
190
+ * A hard cleanup function that will delete all rows from the database.
191
+ */
192
+ hardCleanup: () => Promise<void>;
193
+ modifyBetterAuthOptions: (
194
+ options: BetterAuthOptions,
195
+ shouldRunMigrations: boolean,
196
+ ) => Promise<BetterAuthOptions>;
197
+ getBetterAuthOptions: () => BetterAuthOptions;
198
+ sortModels: (
199
+ models: Array<
200
+ Record<string, any> & {
201
+ id: string;
202
+ }
203
+ >,
204
+ by?: ("id" | "createdAt") | undefined,
205
+ ) => (Record<string, any> & {
206
+ id: string;
207
+ })[];
208
+ getAuth: () => Promise<ReturnType<typeof betterAuth>>;
209
+ tryCatch<T, E = Error>(promise: Promise<T>): Promise<Result<T, E>>;
210
+ customIdGenerator?: () => any | Promise<any> | undefined;
211
+ transformIdOutput?: (id: any) => string | undefined;
212
+ /**
213
+ * Some adapters may change the ID type, this function allows you to pass the entire model
214
+ * data and it will return the correct better-auth-expected transformed data.
215
+ *
216
+ * Eg:
217
+ * MongoDB uses ObjectId for IDs, but it's possible the user can disable that option in the adapter config.
218
+ * Because of this, the expected data would be a string.
219
+ * These sorts of conversions will cause issues with the test when you use the `generate` function.
220
+ * This is because the `generate` function will return the raw data expected to be saved in DB, not the excpected BA output.
221
+ */
222
+ transformGeneratedModel: (
223
+ data: Record<string, any>,
224
+ ) => Record<string, any>;
225
+ },
226
+ additionalOptions?: AdditionalOptions | undefined,
227
+ ) => Tests,
228
+ ) => {
229
+ return (
230
+ options?:
231
+ | ({
232
+ disableTests?: Partial<
233
+ Record<keyof Tests, boolean> & { ALL?: boolean }
234
+ >;
235
+ } & AdditionalOptions)
236
+ | undefined,
237
+ ) => {
238
+ return async (helpers: {
239
+ adapter: () => Promise<DBAdapter<BetterAuthOptions>>;
240
+ log: Logger;
241
+ adapterDisplayName: string;
242
+ getBetterAuthOptions: () => BetterAuthOptions;
243
+ modifyBetterAuthOptions: (
244
+ options: BetterAuthOptions,
245
+ ) => Promise<BetterAuthOptions>;
246
+ cleanup: () => Promise<void>;
247
+ runMigrations: () => Promise<void>;
248
+ prefixTests?: string | undefined;
249
+ onTestFinish: (stats: TestSuiteStats) => Promise<void>;
250
+ customIdGenerator?: () => any | Promise<any> | undefined;
251
+ transformIdOutput?: (id: any) => string | undefined;
252
+ }) => {
253
+ const createdRows: Record<string, any[]> = {};
254
+
255
+ let adapter = await helpers.adapter();
256
+ const wrapperAdapter = (
257
+ overrideOptions?: BetterAuthOptions | undefined,
258
+ ) => {
259
+ const options = deepmerge(
260
+ deepmerge(
261
+ helpers.getBetterAuthOptions(),
262
+ config?.defaultBetterAuthOptions || {},
263
+ ),
264
+ overrideOptions || {},
265
+ );
266
+ const adapterConfig = {
267
+ adapterId: helpers.adapterDisplayName,
268
+ ...(adapter.options?.adapterConfig || {}),
269
+ adapterName: `Wrapped ${adapter.options?.adapterConfig.adapterName}`,
270
+ disableTransformOutput: true,
271
+ disableTransformInput: true,
272
+ disableTransformJoin: true,
273
+ };
274
+ const adapterCreator = (
275
+ options: BetterAuthOptions,
276
+ ): DBAdapter<BetterAuthOptions> =>
277
+ createAdapterFactory({
278
+ config: {
279
+ ...adapterConfig,
280
+ transaction: adapter.transaction,
281
+ },
282
+ adapter: ({ getDefaultModelName }) => {
283
+ adapter.transaction = undefined as any;
284
+ return {
285
+ count: async (args: any) => {
286
+ adapter = await helpers.adapter();
287
+ const res = await adapter.count(args);
288
+ return res as any;
289
+ },
290
+ deleteMany: async (args: any) => {
291
+ adapter = await helpers.adapter();
292
+ const res = await adapter.deleteMany(args);
293
+ return res as any;
294
+ },
295
+ delete: async (args: any) => {
296
+ adapter = await helpers.adapter();
297
+ const res = await adapter.delete(args);
298
+ return res as any;
299
+ },
300
+ findOne: async (args) => {
301
+ adapter = await helpers.adapter();
302
+ const res = await adapter.findOne(args);
303
+ return res as any;
304
+ },
305
+ findMany: async (args) => {
306
+ adapter = await helpers.adapter();
307
+ const res = await adapter.findMany(args);
308
+ return res as any;
309
+ },
310
+ update: async (args: any) => {
311
+ adapter = await helpers.adapter();
312
+ const res = await adapter.update(args);
313
+ return res as any;
314
+ },
315
+ updateMany: async (args) => {
316
+ adapter = await helpers.adapter();
317
+ const res = await adapter.updateMany(args);
318
+ return res as any;
319
+ },
320
+ createSchema: adapter.createSchema as any,
321
+ async create({ data, model, select }) {
322
+ const defaultModelName = getDefaultModelName(model);
323
+ adapter = await helpers.adapter();
324
+ const res = await adapter.create({
325
+ data: data,
326
+ model: defaultModelName,
327
+ select,
328
+ forceAllowId: true,
329
+ });
330
+ createdRows[model] = [...(createdRows[model] || []), res];
331
+ return res as any;
332
+ },
333
+ options: adapter.options,
334
+ };
335
+ },
336
+ })(options);
337
+
338
+ return adapterCreator(options);
339
+ };
340
+
341
+ const resetDebugLogs = () => {
342
+ //@ts-expect-error
343
+ wrapperAdapter()?.adapterTestDebugLogs?.resetDebugLogs();
344
+ };
345
+
346
+ const printDebugLogs = () => {
347
+ //@ts-expect-error
348
+ wrapperAdapter()?.adapterTestDebugLogs?.printDebugLogs();
349
+ };
350
+
351
+ const cleanupCreatedRows = async () => {
352
+ adapter = await helpers.adapter();
353
+ for (const model of Object.keys(createdRows)) {
354
+ for (const row of createdRows[model]!) {
355
+ const schema = getAuthTables(helpers.getBetterAuthOptions());
356
+ const getDefaultModelName = initGetDefaultModelName({
357
+ schema,
358
+ usePlural: adapter.options?.adapterConfig.usePlural,
359
+ });
360
+ let defaultModelName: string;
361
+ try {
362
+ defaultModelName = getDefaultModelName(model);
363
+ } catch {
364
+ continue;
365
+ }
366
+ if (!schema[defaultModelName]) continue; // model doesn't exist in the schema anymore, so we skip it
367
+ try {
368
+ await adapter.delete({
369
+ model,
370
+ where: [{ field: "id", value: row.id }],
371
+ });
372
+ } catch {
373
+ // We ignore any failed attempts to delete the created rows.
374
+ }
375
+ if (createdRows[model]!.length === 1) {
376
+ delete createdRows[model];
377
+ }
378
+ }
379
+ }
380
+ };
381
+
382
+ // Track current applied BetterAuth options state
383
+ let currentAppliedOptions: BetterAuthOptions | null = null;
384
+
385
+ // Statistics tracking
386
+ const stats: TestSuiteStats = {
387
+ migrationCount: 0,
388
+ totalMigrationTime: 0,
389
+ testCount: 0,
390
+ suiteStartTime: performance.now(),
391
+ suiteDuration: 0,
392
+ suiteName,
393
+ };
394
+
395
+ /**
396
+ * Apply BetterAuth options and run migrations if needed.
397
+ * Tracks migration statistics.
398
+ */
399
+ const applyOptionsAndMigrate = async (
400
+ options: BetterAuthOptions | Partial<BetterAuthOptions>,
401
+ forceMigrate: boolean = false,
402
+ ): Promise<BetterAuthOptions> => {
403
+ const finalOptions = deepmerge(
404
+ config?.defaultBetterAuthOptions || {},
405
+ options || {},
406
+ );
407
+
408
+ // Check if options have changed
409
+ const optionsChanged = !deepEqual(currentAppliedOptions, finalOptions);
410
+
411
+ if (optionsChanged || forceMigrate) {
412
+ adapter = await helpers.adapter();
413
+ await helpers.modifyBetterAuthOptions(finalOptions);
414
+
415
+ if (config.alwaysMigrate || forceMigrate) {
416
+ const migrationStart = performance.now();
417
+ await helpers.runMigrations();
418
+ const migrationTime = performance.now() - migrationStart;
419
+
420
+ stats.migrationCount++;
421
+ stats.totalMigrationTime += migrationTime;
422
+
423
+ adapter = await helpers.adapter();
424
+ }
425
+
426
+ currentAppliedOptions = finalOptions;
427
+ } else {
428
+ // Options haven't changed, just update the reference
429
+ currentAppliedOptions = finalOptions;
430
+ }
431
+
432
+ return finalOptions;
433
+ };
434
+
435
+ const transformGeneratedModel = (data: Record<string, any>) => {
436
+ const newData = { ...data };
437
+ if (helpers.transformIdOutput) {
438
+ newData.id = helpers.transformIdOutput(newData.id);
439
+ }
440
+ return newData;
441
+ };
442
+
443
+ const idGenerator = async () => {
444
+ if (config.customIdGenerator) {
445
+ return config.customIdGenerator();
446
+ }
447
+ if (helpers.customIdGenerator) {
448
+ return helpers.customIdGenerator();
449
+ }
450
+ return generateId();
451
+ };
452
+
453
+ const generateModel: GenerateFn = async (model: string) => {
454
+ const id = await idGenerator();
455
+ const randomDate = new Date(
456
+ Date.now() - Math.random() * 1000 * 60 * 60 * 24 * 365,
457
+ );
458
+ if (model === "user") {
459
+ const user: User = {
460
+ id,
461
+ createdAt: randomDate,
462
+ updatedAt: new Date(),
463
+ email:
464
+ `user-${helpers.transformIdOutput?.(id) ?? id}@email.com`.toLowerCase(),
465
+ emailVerified: true,
466
+ name: `user-${helpers.transformIdOutput?.(id) ?? id}`,
467
+ image: null,
468
+ };
469
+ return user as any;
470
+ }
471
+ if (model === "session") {
472
+ const session: Session = {
473
+ id,
474
+ createdAt: randomDate,
475
+ updatedAt: new Date(),
476
+ expiresAt: new Date(),
477
+ token: generateId(32),
478
+ userId: generateId(),
479
+ ipAddress: "127.0.0.1",
480
+ userAgent: "Some User Agent",
481
+ };
482
+ return session as any;
483
+ }
484
+ if (model === "verification") {
485
+ const verification: Verification = {
486
+ id,
487
+ createdAt: randomDate,
488
+ updatedAt: new Date(),
489
+ expiresAt: new Date(),
490
+ identifier: `test:${generateId()}`,
491
+ value: generateId(),
492
+ };
493
+ return verification as any;
494
+ }
495
+ if (model === "account") {
496
+ const account: Account = {
497
+ id,
498
+ createdAt: randomDate,
499
+ updatedAt: new Date(),
500
+ accountId: generateId(),
501
+ providerId: "test",
502
+ userId: generateId(),
503
+ accessToken: generateId(),
504
+ refreshToken: generateId(),
505
+ idToken: generateId(),
506
+ accessTokenExpiresAt: new Date(),
507
+ refreshTokenExpiresAt: new Date(),
508
+ scope: "test",
509
+ };
510
+ return account as any;
511
+ }
512
+ // This should never happen given the type constraints, but TypeScript needs an exhaustive check
513
+ throw new Error(`Unknown model type: ${model}`);
514
+ };
515
+
516
+ const insertRandom: InsertRandomFn = async <
517
+ M extends "user" | "session" | "verification" | "account",
518
+ Count extends number = 1,
519
+ >(
520
+ model: M,
521
+ count: Count = 1 as Count,
522
+ ) => {
523
+ const res: any[] = [];
524
+ const a = wrapperAdapter();
525
+
526
+ for (let i = 0; i < count; i++) {
527
+ const modelResults = [];
528
+
529
+ if (model === "user") {
530
+ const user = await generateModel("user");
531
+ modelResults.push(
532
+ await a.create({
533
+ data: user,
534
+ model: "user",
535
+ forceAllowId: true,
536
+ }),
537
+ );
538
+ }
539
+ if (model === "session") {
540
+ const user = await generateModel("user");
541
+ const userRes = await a.create({
542
+ data: user,
543
+ model: "user",
544
+ forceAllowId: true,
545
+ });
546
+ const session = await generateModel("session");
547
+ session.userId = userRes.id;
548
+ const sessionRes = await a.create({
549
+ data: session,
550
+ model: "session",
551
+ forceAllowId: true,
552
+ });
553
+ modelResults.push(userRes, sessionRes);
554
+ }
555
+ if (model === "verification") {
556
+ const verification = await generateModel("verification");
557
+ modelResults.push(
558
+ await a.create({
559
+ data: verification,
560
+ model: "verification",
561
+ forceAllowId: true,
562
+ }),
563
+ );
564
+ }
565
+ if (model === "account") {
566
+ const user = await generateModel("user");
567
+ const account = await generateModel("account");
568
+ const userRes = await a.create({
569
+ data: user,
570
+ model: "user",
571
+ forceAllowId: true,
572
+ });
573
+ account.userId = userRes.id;
574
+ const accRes = await a.create({
575
+ data: account,
576
+ model: "account",
577
+ forceAllowId: true,
578
+ });
579
+ modelResults.push(userRes, accRes);
580
+ }
581
+ res.push(modelResults);
582
+ }
583
+ return res.length === 1 ? res[0] : (res as any);
584
+ };
585
+
586
+ const sortModels = (
587
+ models: Array<Record<string, any> & { id: string }>,
588
+ by: "id" | "createdAt" = "id",
589
+ ) => {
590
+ return models.sort((a, b) => {
591
+ if (by === "createdAt") {
592
+ return (
593
+ new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
594
+ );
595
+ }
596
+ return a.id.localeCompare(b.id);
597
+ });
598
+ };
599
+
600
+ const modifyBetterAuthOptions = async (
601
+ opts: BetterAuthOptions,
602
+ shouldRunMigrations: boolean,
603
+ ) => {
604
+ return await applyOptionsAndMigrate(opts, shouldRunMigrations);
605
+ };
606
+
607
+ const additionalOptions = { ...options };
608
+ additionalOptions.disableTests = undefined;
609
+
610
+ const fullTests = tests(
611
+ {
612
+ adapter: new Proxy({} as any, {
613
+ get(target, prop) {
614
+ const adapter = wrapperAdapter();
615
+ if (prop === "transaction") {
616
+ return adapter.transaction;
617
+ }
618
+ const value = adapter[prop as keyof typeof adapter];
619
+ if (typeof value === "function") {
620
+ return value.bind(adapter);
621
+ }
622
+ return value;
623
+ },
624
+ }),
625
+ getAuth: async () => {
626
+ adapter = await helpers.adapter();
627
+ const auth = betterAuth({
628
+ ...helpers.getBetterAuthOptions(),
629
+ ...(config?.defaultBetterAuthOptions || {}),
630
+ database: (options: BetterAuthOptions) => {
631
+ const adapter = wrapperAdapter(options);
632
+ return adapter;
633
+ },
634
+ });
635
+ return auth;
636
+ },
637
+ log: helpers.log,
638
+ generate: generateModel,
639
+ cleanup: cleanupCreatedRows,
640
+ hardCleanup: helpers.cleanup,
641
+ insertRandom,
642
+ modifyBetterAuthOptions,
643
+ getBetterAuthOptions: helpers.getBetterAuthOptions,
644
+ sortModels,
645
+ tryCatch,
646
+ customIdGenerator: helpers.customIdGenerator,
647
+ transformGeneratedModel,
648
+ transformIdOutput: helpers.transformIdOutput,
649
+ },
650
+ additionalOptions as AdditionalOptions,
651
+ );
652
+
653
+ const dash = `─`;
654
+ const allDisabled: boolean = options?.disableTests?.ALL ?? false;
655
+
656
+ // Here to display a label in the tests showing the suite name
657
+ test(`\n${TTY_COLORS.fg.white}${" ".repeat(3)}${dash.repeat(35)} [${TTY_COLORS.fg.magenta}${suiteName}${TTY_COLORS.fg.white}] ${dash.repeat(35)}`, async () => {
658
+ try {
659
+ await helpers.cleanup();
660
+ } catch {}
661
+ if (config.defaultBetterAuthOptions && !allDisabled) {
662
+ await applyOptionsAndMigrate(
663
+ config.defaultBetterAuthOptions,
664
+ config.alwaysMigrate,
665
+ );
666
+ }
667
+ });
668
+
669
+ /**
670
+ * Extract test function and migration options from a test entry.
671
+ */
672
+ const extractTestEntry = (
673
+ entry: TestEntry,
674
+ ): {
675
+ testFn: (context: {
676
+ readonly skip: {
677
+ (note?: string | undefined): never;
678
+ (condition: boolean, note?: string | undefined): void;
679
+ };
680
+ }) => Promise<void>;
681
+ migrateBetterAuth?: BetterAuthOptions;
682
+ } => {
683
+ if (typeof entry === "function") {
684
+ return { testFn: entry };
685
+ }
686
+ return {
687
+ testFn: entry.test,
688
+ migrateBetterAuth: entry.migrateBetterAuth,
689
+ };
690
+ };
691
+
692
+ // Convert test entries to array with migration info (moved before onFinish for access)
693
+ const testEntries = Object.entries(fullTests).map(([name, entry]) => {
694
+ const { testFn, migrateBetterAuth } = extractTestEntry(
695
+ entry as TestEntry,
696
+ );
697
+ return { name, testFn, migrateBetterAuth };
698
+ });
699
+
700
+ /**
701
+ * Group tests by their migrateBetterAuth options.
702
+ * Tests with equal migration options are grouped together.
703
+ */
704
+ type TestGroup = {
705
+ migrationOptions: BetterAuthOptions | null | undefined;
706
+ testIndices: number[];
707
+ };
708
+
709
+ const groupTestsByMigrationOptions = (): TestGroup[] => {
710
+ const groups: TestGroup[] = [];
711
+ let currentGroup: TestGroup | null = null;
712
+
713
+ for (let i = 0; i < testEntries.length; i++) {
714
+ const { migrateBetterAuth } = testEntries[i]!;
715
+ const isSkipped =
716
+ (allDisabled &&
717
+ options?.disableTests?.[testEntries[i]!.name] !== false) ||
718
+ (options?.disableTests?.[testEntries[i]!.name] ?? false);
719
+
720
+ // Skip grouping for skipped tests - they'll be handled individually
721
+ if (isSkipped) {
722
+ if (currentGroup) {
723
+ groups.push(currentGroup);
724
+ currentGroup = null;
725
+ }
726
+ groups.push({
727
+ migrationOptions: migrateBetterAuth,
728
+ testIndices: [i],
729
+ });
730
+ continue;
731
+ }
732
+
733
+ // Check if this test belongs to the current group
734
+ if (
735
+ currentGroup &&
736
+ deepEqual(currentGroup.migrationOptions, migrateBetterAuth)
737
+ ) {
738
+ currentGroup.testIndices.push(i);
739
+ } else {
740
+ // Start a new group
741
+ if (currentGroup) {
742
+ groups.push(currentGroup);
743
+ }
744
+ currentGroup = {
745
+ migrationOptions: migrateBetterAuth,
746
+ testIndices: [i],
747
+ };
748
+ }
749
+ }
750
+
751
+ // Add the last group if it exists
752
+ if (currentGroup) {
753
+ groups.push(currentGroup);
754
+ }
755
+
756
+ return groups;
757
+ };
758
+
759
+ const testGroups = groupTestsByMigrationOptions();
760
+
761
+ // Calculate grouping statistics
762
+ const calculateGroupingStats = () => {
763
+ const nonSkippedGroups = testGroups.filter(
764
+ (group) => group.testIndices.length > 0,
765
+ );
766
+ const groupSizes = nonSkippedGroups.map(
767
+ (group) => group.testIndices.length,
768
+ );
769
+
770
+ if (groupSizes.length === 0) {
771
+ return {
772
+ totalGroups: 0,
773
+ averageTestsPerGroup: 0,
774
+ largestGroupSize: 0,
775
+ smallestGroupSize: 0,
776
+ groupsWithMultipleTests: 0,
777
+ totalTestsInGroups: 0,
778
+ };
779
+ }
780
+
781
+ const totalTestsInGroups = groupSizes.reduce(
782
+ (sum, size) => sum + size,
783
+ 0,
784
+ );
785
+ const groupsWithMultipleTests = groupSizes.filter(
786
+ (size) => size > 1,
787
+ ).length;
788
+
789
+ return {
790
+ totalGroups: nonSkippedGroups.length,
791
+ averageTestsPerGroup: totalTestsInGroups / nonSkippedGroups.length,
792
+ largestGroupSize: Math.max(...groupSizes),
793
+ smallestGroupSize: Math.min(...groupSizes),
794
+ groupsWithMultipleTests,
795
+ totalTestsInGroups,
796
+ };
797
+ };
798
+
799
+ const onFinish = async (testName: string) => {
800
+ await cleanupCreatedRows();
801
+
802
+ const currentTestIndex = testEntries.findIndex(
803
+ ({ name }) => name === testName,
804
+ );
805
+ const isLastTest = currentTestIndex === testEntries.length - 1;
806
+
807
+ if (isLastTest) {
808
+ stats.suiteDuration = performance.now() - stats.suiteStartTime;
809
+ stats.groupingStats = calculateGroupingStats();
810
+ await helpers.onTestFinish(stats);
811
+ }
812
+ };
813
+
814
+ // Track the current group's migration options
815
+ let currentGroupMigrationOptions: BetterAuthOptions | null | undefined =
816
+ null;
817
+
818
+ for (let i = 0; i < testEntries.length; i++) {
819
+ const { name: testName, testFn, migrateBetterAuth } = testEntries[i]!;
820
+
821
+ // Find which group this test belongs to
822
+ const testGroup = testGroups.find((group) =>
823
+ group.testIndices.includes(i),
824
+ );
825
+ const isFirstInGroup = testGroup && testGroup.testIndices[0] === i;
826
+
827
+ const shouldSkip =
828
+ (allDisabled && options?.disableTests?.[testName] !== false) ||
829
+ (options?.disableTests?.[testName] ?? false);
830
+
831
+ let displayName = testName.replace(
832
+ " - ",
833
+ ` ${TTY_COLORS.dim}${dash}${TTY_COLORS.undim} `,
834
+ );
835
+ if (config.prefixTests) {
836
+ displayName = `${config.prefixTests} ${TTY_COLORS.dim}>${TTY_COLORS.undim} ${displayName}`;
837
+ }
838
+ if (helpers.prefixTests) {
839
+ displayName = `[${TTY_COLORS.dim}${helpers.prefixTests}${TTY_COLORS.undim}] ${displayName}`;
840
+ }
841
+
842
+ test.skipIf(shouldSkip)(
843
+ displayName,
844
+ { timeout: 30000 },
845
+ async ({ onTestFailed, skip }) => {
846
+ resetDebugLogs();
847
+
848
+ // Apply migration options before test runs
849
+ await (async () => {
850
+ if (shouldSkip) return;
851
+
852
+ const thisMigration = deepmerge(
853
+ config.defaultBetterAuthOptions || {},
854
+ migrateBetterAuth || {},
855
+ );
856
+
857
+ // If this is the first test in a group, migrate to the group's options
858
+ if (isFirstInGroup && testGroup) {
859
+ const groupMigrationOptions = testGroup.migrationOptions;
860
+ const groupFinalOptions = deepmerge(
861
+ config.defaultBetterAuthOptions || {},
862
+ groupMigrationOptions || {},
863
+ );
864
+
865
+ // Only migrate if the group's options are different from current state
866
+ if (
867
+ !deepEqual(
868
+ currentGroupMigrationOptions,
869
+ groupMigrationOptions,
870
+ )
871
+ ) {
872
+ await applyOptionsAndMigrate(groupFinalOptions, true);
873
+ currentGroupMigrationOptions = groupMigrationOptions;
874
+ }
875
+ }
876
+ // If this test is not in a group or not first in group, check if migration is needed
877
+ else if (
878
+ !deepEqual(currentGroupMigrationOptions, migrateBetterAuth)
879
+ ) {
880
+ await applyOptionsAndMigrate(thisMigration, true);
881
+ currentGroupMigrationOptions = migrateBetterAuth;
882
+ }
883
+ })();
884
+
885
+ stats.testCount++;
886
+
887
+ onTestFailed(async () => {
888
+ printDebugLogs();
889
+ await onFinish(testName);
890
+ });
891
+ await testFn({ skip });
892
+ await onFinish(testName);
893
+ },
894
+ );
895
+ }
896
+ };
897
+ };
898
+ };