@gjsify/unit 0.0.4 → 0.1.0

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/src/index.ts CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  import "@girs/gjs";
4
4
 
5
- import type GLib from 'gi://GLib?version=2.0';
5
+ import type GLib from '@girs/glib-2.0';
6
6
  export * from './spy.js';
7
- import nodeAssert from 'assert';
7
+ import nodeAssert from 'node:assert';
8
+ import { quitMainLoop } from '@gjsify/utils/main-loop';
8
9
 
9
10
  const mainloop: GLib.MainLoop | undefined = (globalThis as any)?.imports?.mainloop;
10
11
 
@@ -12,6 +13,70 @@ let countTestsOverall = 0;
12
13
  let countTestsFailed = 0;
13
14
  let countTestsIgnored = 0;
14
15
  let runtime = '';
16
+ let runStartTime = 0;
17
+
18
+ export interface TimeoutConfig {
19
+ /** Per-it() timeout in ms. Default: 5000. 0 = disabled. */
20
+ testTimeout: number;
21
+ /** Per-describe() timeout in ms. Default: 30000. 0 = disabled. */
22
+ suiteTimeout: number;
23
+ /** Global run timeout in ms. Default: 120000. 0 = disabled. */
24
+ runTimeout: number;
25
+ }
26
+
27
+ const DEFAULT_TIMEOUT_CONFIG: TimeoutConfig = {
28
+ testTimeout: 5000,
29
+ suiteTimeout: 30000,
30
+ runTimeout: 120000,
31
+ };
32
+
33
+ let timeoutConfig: TimeoutConfig = { ...DEFAULT_TIMEOUT_CONFIG };
34
+
35
+ class TimeoutError extends Error {
36
+ constructor(label: string, timeoutMs: number) {
37
+ super(`Timeout: "${label}" exceeded ${timeoutMs}ms`);
38
+ this.name = 'TimeoutError';
39
+ }
40
+ }
41
+
42
+ async function withTimeout<T>(
43
+ fn: () => T | Promise<T>,
44
+ timeoutMs: number,
45
+ label: string
46
+ ): Promise<T> {
47
+ if (timeoutMs <= 0) return fn();
48
+
49
+ let timeoutId: ReturnType<typeof setTimeout>;
50
+ const timeoutPromise = new Promise<never>((_, reject) => {
51
+ timeoutId = setTimeout(() => reject(new TimeoutError(label, timeoutMs)), timeoutMs);
52
+ });
53
+
54
+ const fnPromise = Promise.resolve(fn());
55
+ fnPromise.catch(() => {}); // Prevent unhandled rejection if it fails after timeout
56
+
57
+ try {
58
+ return await Promise.race([fnPromise, timeoutPromise]);
59
+ } finally {
60
+ clearTimeout(timeoutId!);
61
+ }
62
+ }
63
+
64
+ export const configure = (overrides: Partial<TimeoutConfig>) => {
65
+ timeoutConfig = { ...timeoutConfig, ...overrides };
66
+ };
67
+
68
+ function applyEnvOverrides() {
69
+ try {
70
+ const env = (globalThis as any).process?.env;
71
+ if (!env) return;
72
+ const t = parseInt(env.GJSIFY_TEST_TIMEOUT, 10);
73
+ if (!isNaN(t) && t >= 0) timeoutConfig.testTimeout = t;
74
+ const s = parseInt(env.GJSIFY_SUITE_TIMEOUT, 10);
75
+ if (!isNaN(s) && s >= 0) timeoutConfig.suiteTimeout = s;
76
+ const r = parseInt(env.GJSIFY_RUN_TIMEOUT, 10);
77
+ if (!isNaN(r) && r >= 0) timeoutConfig.runTimeout = r;
78
+ } catch (_e) { /* process.env may not be available */ }
79
+ }
15
80
 
16
81
  const RED = '\x1B[31m';
17
82
  const GREEN = '\x1B[32m';
@@ -19,13 +84,22 @@ const BLUE = '\x1b[34m';
19
84
  const GRAY = '\x1B[90m';
20
85
  const RESET = '\x1B[39m';
21
86
 
87
+ const now = (): number =>
88
+ (globalThis as any).performance?.now?.() ?? Date.now();
89
+
90
+ const formatDuration = (ms: number): string => {
91
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
92
+ if (ms >= 100) return `${Math.round(ms)}ms`;
93
+ return `${ms.toFixed(1)}ms`;
94
+ };
95
+
22
96
  export interface Namespaces {
23
97
  [key: string]: () => (Promise<void>) | Namespaces;
24
98
  }
25
99
 
26
100
  export type Callback = () => Promise<void>;
27
101
 
28
- export type Runtime = 'Gjs' | 'Deno' | 'Node.js' | 'Unknown' | 'Browser';
102
+ export type Runtime = 'Gjs' | 'Deno' | 'Node.js' | 'Unknown' | 'Browser' | 'Display';
29
103
 
30
104
  // Makes this work on Gjs and Node.js
31
105
  export const print = globalThis.print || console.log;
@@ -45,8 +119,10 @@ class MatcherFactory {
45
119
  triggerResult(success: boolean, msg: string) {
46
120
  if( (success && !this.positive) ||
47
121
  (!success && this.positive) ) {
122
+ const error = new Error(msg);
123
+ (error as any).__testFailureCounted = true;
48
124
  ++countTestsFailed;
49
- throw new Error(msg);
125
+ throw error;
50
126
  }
51
127
  }
52
128
 
@@ -72,6 +148,23 @@ class MatcherFactory {
72
148
  );
73
149
  }
74
150
 
151
+ toStrictEqual(expectedValue: any) {
152
+ let success = true;
153
+ let errorMessage = '';
154
+ try {
155
+ nodeAssert.deepStrictEqual(this.actualValue, expectedValue);
156
+ } catch (e) {
157
+ success = false;
158
+ errorMessage = e.message || '';
159
+ }
160
+ this.triggerResult(success,
161
+ ` Expected values to be deeply strictly equal\n` +
162
+ ` Expected: ${JSON.stringify(expectedValue)}\n` +
163
+ ` Actual: ${JSON.stringify(this.actualValue)}` +
164
+ (errorMessage ? `\n ${errorMessage}` : '')
165
+ );
166
+ }
167
+
75
168
  toEqualArray(expectedValue: Array<any> | Uint8Array) {
76
169
 
77
170
  let success = Array.isArray(this.actualValue) && Array.isArray(expectedValue) && this.actualValue.length === expectedValue.length;
@@ -90,6 +183,21 @@ class MatcherFactory {
90
183
  );
91
184
  }
92
185
 
186
+ toBeInstanceOf(expectedType: Function) {
187
+ this.triggerResult(this.actualValue instanceof expectedType,
188
+ ` Expected value to be instance of ${expectedType.name || expectedType}\n` +
189
+ ` Actual: ${this.actualValue?.constructor?.name || typeof this.actualValue}`
190
+ );
191
+ }
192
+
193
+ toHaveLength(expectedLength: number) {
194
+ const actualLength = this.actualValue?.length;
195
+ this.triggerResult(actualLength === expectedLength,
196
+ ` Expected length: ${expectedLength}\n` +
197
+ ` Actual length: ${actualLength}`
198
+ );
199
+ }
200
+
93
201
  toMatch(expectedValue: any) {
94
202
  if(typeof this.actualValue.match !== 'function') {
95
203
  throw new Error(`You can not use toMatch on type ${typeof this.actualValue}`);
@@ -132,8 +240,17 @@ class MatcherFactory {
132
240
  }
133
241
 
134
242
  toContain(needle: any) {
135
- this.triggerResult(this.actualValue instanceof Array && this.actualValue.indexOf(needle) !== -1,
136
- ` Expected ` + this.actualValue + ` to contain ` + needle
243
+ const value = this.actualValue;
244
+ let contains: boolean;
245
+ if (typeof value === 'string') {
246
+ contains = value.includes(String(needle));
247
+ } else if (value instanceof Array) {
248
+ contains = value.indexOf(needle) !== -1;
249
+ } else {
250
+ contains = false;
251
+ }
252
+ this.triggerResult(contains,
253
+ ` Expected ` + value + ` to contain ` + needle
137
254
  );
138
255
  }
139
256
  toBeLessThan(greaterValue: number) {
@@ -146,16 +263,27 @@ class MatcherFactory {
146
263
  ` Expected ` + this.actualValue + ` to be greater than ` + smallerValue
147
264
  );
148
265
  }
266
+ toBeGreaterThanOrEqual(value: number) {
267
+ this.triggerResult(this.actualValue >= value,
268
+ ` Expected ${this.actualValue} to be greater than or equal to ${value}`
269
+ );
270
+ }
271
+ toBeLessThanOrEqual(value: number) {
272
+ this.triggerResult(this.actualValue <= value,
273
+ ` Expected ${this.actualValue} to be less than or equal to ${value}`
274
+ );
275
+ }
149
276
  toBeCloseTo(expectedValue: number, precision: number) {
150
277
  const shiftHelper = Math.pow(10, precision);
151
278
  this.triggerResult(Math.round((this.actualValue as unknown as number) * shiftHelper) / shiftHelper === Math.round(expectedValue * shiftHelper) / shiftHelper,
152
279
  ` Expected ` + this.actualValue + ` with precision ` + precision + ` to be close to ` + expectedValue
153
280
  );
154
281
  }
155
- toThrow(ErrorType?: typeof Error) {
156
- let errorMessage = '';
282
+ toThrow(expected?: typeof Error | string | RegExp) {
283
+ let errorMessage = '';
157
284
  let didThrow = false;
158
285
  let typeMatch = true;
286
+ let messageMatch = true;
159
287
  try {
160
288
  this.actualValue();
161
289
  didThrow = false;
@@ -163,8 +291,12 @@ class MatcherFactory {
163
291
  catch(e) {
164
292
  errorMessage = e.message || '';
165
293
  didThrow = true;
166
- if(ErrorType) {
167
- typeMatch = (e instanceof ErrorType)
294
+ if (typeof expected === 'function') {
295
+ typeMatch = (e instanceof expected);
296
+ } else if (typeof expected === 'string') {
297
+ messageMatch = errorMessage.includes(expected);
298
+ } else if (expected instanceof RegExp) {
299
+ messageMatch = expected.test(errorMessage);
168
300
  }
169
301
  }
170
302
  const functionName = this.actualValue.name || typeof this.actualValue === 'function' ? "[anonymous function]" : this.actualValue.toString();
@@ -172,26 +304,122 @@ class MatcherFactory {
172
304
  ` Expected ${functionName} to ${this.positive ? 'throw' : 'not throw'} an exception ${!this.positive && errorMessage ? `, but an error with the message "${errorMessage}" was thrown` : ''}`
173
305
  );
174
306
 
175
- if(ErrorType) {
307
+ if (typeof expected === 'function') {
176
308
  this.triggerResult(typeMatch,
177
- ` Expected Error type '${ErrorType.name}', but the error is not an instance of it`
309
+ ` Expected Error type '${expected.name}', but the error is not an instance of it`
310
+ );
311
+ } else if (expected !== undefined) {
312
+ this.triggerResult(messageMatch,
313
+ ` Expected error message to match ${expected}\n` +
314
+ ` Actual message: "${errorMessage}"`
178
315
  );
179
316
  }
180
317
  }
318
+
319
+ async toReject(expected?: typeof Error | string | RegExp) {
320
+ let didReject = false;
321
+ let errorMessage = '';
322
+ let typeMatch = true;
323
+ let messageMatch = true;
324
+ try {
325
+ await this.actualValue;
326
+ didReject = false;
327
+ } catch (e) {
328
+ didReject = true;
329
+ errorMessage = e?.message || String(e);
330
+ if (typeof expected === 'function') {
331
+ typeMatch = (e instanceof expected);
332
+ } else if (typeof expected === 'string') {
333
+ messageMatch = errorMessage.includes(expected);
334
+ } else if (expected instanceof RegExp) {
335
+ messageMatch = expected.test(errorMessage);
336
+ }
337
+ }
338
+ this.triggerResult(didReject,
339
+ ` Expected promise to ${this.positive ? 'reject' : 'resolve'}${!this.positive && errorMessage ? `, but it rejected with "${errorMessage}"` : ''}`
340
+ );
341
+ if (didReject && typeof expected === 'function') {
342
+ this.triggerResult(typeMatch,
343
+ ` Expected rejection type '${expected.name}', but the error is not an instance of it`
344
+ );
345
+ } else if (didReject && expected !== undefined) {
346
+ this.triggerResult(messageMatch,
347
+ ` Expected rejection message to match ${expected}\n` +
348
+ ` Actual message: "${errorMessage}"`
349
+ );
350
+ }
351
+ }
352
+
353
+ async toResolve() {
354
+ let didResolve = false;
355
+ let errorMessage = '';
356
+ try {
357
+ await this.actualValue;
358
+ didResolve = true;
359
+ } catch (e) {
360
+ didResolve = false;
361
+ errorMessage = e?.message || String(e);
362
+ }
363
+ this.triggerResult(didResolve,
364
+ ` Expected promise to ${this.positive ? 'resolve' : 'reject'}${!didResolve ? `, but it rejected with "${errorMessage}"` : ''}`
365
+ );
366
+ }
181
367
  }
182
368
 
183
- export const describe = async function(moduleName: string, callback: Callback) {
369
+ export const describe = async function(moduleName: string, callback: Callback, options?: { timeout?: number } | number) {
370
+ const suiteTimeoutMs = typeof options === 'number'
371
+ ? options
372
+ : (options?.timeout ?? timeoutConfig.suiteTimeout);
373
+
184
374
  print('\n' + moduleName);
185
375
 
186
- await callback();
376
+ const t0 = now();
377
+ try {
378
+ await withTimeout(callback, suiteTimeoutMs, `describe: ${moduleName}`);
379
+ } catch (e) {
380
+ if (e instanceof TimeoutError) {
381
+ ++countTestsFailed;
382
+ print(` ${RED}⏱ Suite timed out: ${e.message}${RESET}`);
383
+ } else {
384
+ throw e;
385
+ }
386
+ }
387
+ const duration = now() - t0;
388
+ print(` ${GRAY}↳ ${formatDuration(duration)}${RESET}`);
187
389
 
188
390
  // Reset after and before callbacks
189
391
  beforeEachCb = null;
190
392
  afterEachCb = null;
191
393
  };
192
394
 
395
+ describe.skip = async function(moduleName: string, _callback?: Callback) {
396
+ ++countTestsIgnored;
397
+ print(`\n${BLUE}- ${moduleName} (skipped)${RESET}`);
398
+ };
399
+
400
+ const hasDisplay = (): boolean => {
401
+ // Check process.env (Node.js and GJS with @gjsify/globals)
402
+ const env = (globalThis as any).process?.env;
403
+ if (env) {
404
+ return !!(env.DISPLAY || env.WAYLAND_DISPLAY);
405
+ }
406
+ // GJS fallback via imports.gi.GLib (before process polyfill is available)
407
+ try {
408
+ const GLib = (globalThis as any)?.imports?.gi?.GLib;
409
+ if (GLib) {
410
+ return !!(GLib.getenv('DISPLAY') || GLib.getenv('WAYLAND_DISPLAY'));
411
+ }
412
+ } catch (_) {}
413
+ return false;
414
+ };
415
+
193
416
  const runtimeMatch = async function(onRuntime: Runtime[], version?: string) {
194
417
 
418
+ // Special case: 'Display' checks for a graphical display, not runtime identity
419
+ if (onRuntime.includes('Display')) {
420
+ return { matched: hasDisplay() };
421
+ }
422
+
195
423
  const currRuntime = (await getRuntime());
196
424
 
197
425
  const foundRuntime = onRuntime.find((r) => currRuntime.includes(r));
@@ -255,27 +483,43 @@ export const afterEach = function (callback?: Callback) {
255
483
  }
256
484
 
257
485
 
258
- export const it = async function(expectation: string, callback: () => void | Promise<void>) {
486
+ export const it = async function(expectation: string, callback: () => void | Promise<void>, options?: { timeout?: number } | number) {
487
+ const timeoutMs = typeof options === 'number'
488
+ ? options
489
+ : (options?.timeout ?? timeoutConfig.testTimeout);
490
+
491
+ const t0 = now();
259
492
  try {
260
493
  if(typeof beforeEachCb === 'function') {
261
494
  await beforeEachCb();
262
495
  }
263
-
264
- await callback();
496
+
497
+ await withTimeout(callback, timeoutMs, expectation);
265
498
 
266
499
  if(typeof afterEachCb === 'function') {
267
500
  await afterEachCb();
268
501
  }
269
502
 
270
- print(` ${GREEN}✔${RESET} ${GRAY}${expectation}${RESET}`);
503
+ const duration = now() - t0;
504
+ print(` ${GREEN}✔${RESET} ${GRAY}${expectation} (${formatDuration(duration)})${RESET}`);
271
505
  }
272
506
  catch(e) {
273
- print(` ${RED}❌${RESET} ${GRAY}${expectation}${RESET}`);
507
+ const duration = now() - t0;
508
+ if (!e.__testFailureCounted) {
509
+ ++countTestsFailed;
510
+ }
511
+ const icon = e instanceof TimeoutError ? '⏱' : '❌';
512
+ print(` ${RED}${icon}${RESET} ${GRAY}${expectation} (${formatDuration(duration)})${RESET}`);
274
513
  print(`${RED}${e.message}${RESET}`);
275
514
  if (e.stack) print(e.stack);
276
515
  }
277
516
  }
278
517
 
518
+ it.skip = async function(expectation: string, _callback?: () => void | Promise<void>) {
519
+ ++countTestsIgnored;
520
+ print(` ${BLUE}-${RESET} ${GRAY}${expectation} (skipped)${RESET}`);
521
+ }
522
+
279
523
  export const expect = function(actualValue: any) {
280
524
  ++countTestsOverall;
281
525
 
@@ -291,7 +535,12 @@ export const assert = function(success: any, message?: string | Error) {
291
535
  ++countTestsFailed;
292
536
  }
293
537
 
294
- nodeAssert(success, message);
538
+ try {
539
+ nodeAssert(success, message);
540
+ } catch (error) {
541
+ (error as any).__testFailureCounted = true;
542
+ throw error;
543
+ }
295
544
  }
296
545
 
297
546
  assert.strictEqual = function<T>(actual: unknown, expected: T, message?: string | Error): asserts actual is T {
@@ -300,7 +549,8 @@ assert.strictEqual = function<T>(actual: unknown, expected: T, message?: string
300
549
  nodeAssert.strictEqual(actual, expected, message);
301
550
  } catch (error) {
302
551
  ++countTestsFailed;
303
- throw error
552
+ (error as any).__testFailureCounted = true;
553
+ throw error;
304
554
  }
305
555
  }
306
556
 
@@ -324,7 +574,8 @@ assert.deepStrictEqual = function<T>(actual: unknown, expected: T, message?: str
324
574
  nodeAssert.deepStrictEqual(actual, expected, message);
325
575
  } catch (error) {
326
576
  ++countTestsFailed;
327
- throw error
577
+ (error as any).__testFailureCounted = true;
578
+ throw error;
328
579
  }
329
580
  }
330
581
 
@@ -346,6 +597,8 @@ const runTests = async function(namespaces: Namespaces) {
346
597
  }
347
598
 
348
599
  const printResult = () => {
600
+ const totalMs = runStartTime > 0 ? now() - runStartTime : 0;
601
+ const durationStr = totalMs > 0 ? ` ${GRAY}(${formatDuration(totalMs)})` : '';
349
602
 
350
603
  if( countTestsIgnored ) {
351
604
  // some tests ignored
@@ -354,11 +607,11 @@ const printResult = () => {
354
607
 
355
608
  if( countTestsFailed ) {
356
609
  // some tests failed
357
- print(`\n${RED}❌ ${countTestsFailed} of ${countTestsOverall} tests failed${RESET}`);
610
+ print(`\n${RED}❌ ${countTestsFailed} of ${countTestsOverall} tests failed${durationStr}${RESET}`);
358
611
  }
359
612
  else {
360
613
  // all tests okay
361
- print(`\n${GREEN}✔ ${countTestsOverall} completed${RESET}`);
614
+ print(`\n${GREEN}✔ ${countTestsOverall} completed${durationStr}${RESET}`);
362
615
  }
363
616
  }
364
617
 
@@ -396,20 +649,62 @@ const printRuntime = async () => {
396
649
  print(`\nRunning on ${runtime}`);
397
650
  }
398
651
 
399
- export const run = async (namespaces: Namespaces) => {
652
+ export const run = async (namespaces: Namespaces, options?: { timeout?: number; testTimeout?: number; suiteTimeout?: number } | number) => {
653
+
654
+ applyEnvOverrides();
655
+ runStartTime = now();
656
+
657
+ if (options) {
658
+ if (typeof options === 'number') {
659
+ timeoutConfig.runTimeout = options;
660
+ } else {
661
+ if (options.timeout !== undefined) timeoutConfig.runTimeout = options.timeout;
662
+ if (options.testTimeout !== undefined) timeoutConfig.testTimeout = options.testTimeout;
663
+ if (options.suiteTimeout !== undefined) timeoutConfig.suiteTimeout = options.suiteTimeout;
664
+ }
665
+ }
400
666
 
401
667
  printRuntime()
402
668
  .then(async () => {
403
- return runTests(namespaces)
404
- .then(() => {
405
- printResult();
406
- print();
407
- mainloop?.quit();
408
- })
669
+ try {
670
+ await withTimeout(() => runTests(namespaces), timeoutConfig.runTimeout, 'entire test run');
671
+ } catch (e) {
672
+ if (e instanceof TimeoutError) {
673
+ print(`\n${RED}⏱ ${e.message}${RESET}`);
674
+ ++countTestsFailed;
675
+ } else {
676
+ throw e;
677
+ }
678
+ }
679
+ })
680
+ .then(async () => {
681
+ printResult();
682
+ print();
683
+
684
+ quitMainLoop(); // Pre-quit ensureMainLoop's loop so it exits immediately when the hook fires
685
+ mainloop?.quit();
686
+
687
+ // Node.js: exit here (code after mainloop?.run() executes before tests on Node.js)
688
+ if (!mainloop) {
689
+ const exitCode = countTestsFailed > 0 ? 1 : 0;
690
+ try {
691
+ const process = globalThis.process || await import('process');
692
+ process.exit(exitCode);
693
+ } catch (_e) { /* process unavailable */ }
694
+ }
409
695
  });
410
696
 
411
- // Run the GJS mainloop for async operations
697
+ // Run the GJS mainloop for async operations (blocks until mainloop.quit() is called)
412
698
  mainloop?.run();
699
+
700
+ // GJS: exit after mainloop returns (system.exit() inside a mainloop
701
+ // callback does not terminate immediately)
702
+ if (mainloop) {
703
+ const exitCode = countTestsFailed > 0 ? 1 : 0;
704
+ try {
705
+ (globalThis as any).imports.system.exit(exitCode);
706
+ } catch (_e) { /* system.exit unavailable */ }
707
+ }
413
708
  }
414
709
 
415
710
  export default {
@@ -421,5 +716,6 @@ export default {
421
716
  beforeEach,
422
717
  on,
423
718
  describe,
424
- print,
719
+ configure,
720
+ print,
425
721
  }
package/tsconfig.json CHANGED
@@ -1,15 +1,33 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "rootDir": "src",
3
4
  "outDir": "lib",
4
- "types": ["node"],
5
- "lib": ["ESNext"],
5
+ "types": [
6
+ "node"
7
+ ],
8
+ "lib": [
9
+ "ESNext"
10
+ ],
6
11
  "declarationDir": "lib/types",
7
12
  "declaration": true,
13
+ "emitDeclarationOnly": true,
8
14
  "esModuleInterop": true,
9
15
  "target": "ESNext",
10
- "module": "ESNext",
11
- "moduleResolution": "node"
16
+ "module": "NodeNext",
17
+ "moduleResolution": "NodeNext",
18
+ "strict": false,
19
+ "skipLibCheck": true
12
20
  },
13
- "files": ["src/index.ts"],
14
- "include": ["@girs/gjs"]
15
- }
21
+ "files": [
22
+ "src/index.ts"
23
+ ],
24
+ "include": [
25
+ "@girs/gjs"
26
+ ],
27
+ "exclude": [
28
+ "src/test.ts",
29
+ "src/test.mts",
30
+ "src/**/*.spec.ts",
31
+ "src/**/*.spec.mts"
32
+ ]
33
+ }