@crab-dev/wake 0.1.21 → 0.1.23

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.23
4
+
5
+ - Made Yarn 4.16 PnP authoritative for package visibility, alias dependencies, peer/virtual packages,
6
+ ignore patterns, nested roots, compressed zip archives, watch invalidation, and structured resolver
7
+ diagnostics; PnP rejection can no longer be overridden by Wake aliases, `node_modules`, or the
8
+ retired Components fallback.
9
+ - Migrated the source repository and VS Code editor to one strict Yarn PnP workspace/lock, with
10
+ PnP-aware embedded dependency and maintenance tooling plus differential `pnpapi` conformance gates.
11
+ - Kept npm `node_modules` projects as a first-class resolver contract, including workspace links and
12
+ package-lock watch invalidation, and added blocking Windows/Linux Node 22.14/26 `npm ci` consumer
13
+ gates built exclusively from the current local tarballs.
14
+ - Moved private Wake Docs generated imports to a non-package alias namespace so Yarn PnP remains
15
+ authoritative for valid bare package names without intercepting internal runtime modules.
16
+ - Kept absolute issuers outside a PnP root on classic npm resolution, and made Wake Test resolve
17
+ React package metadata through the same issuer-owned PnP/npm environment as executable modules.
18
+ - Made the pinned Corepack bootstrap overwrite preinstalled Windows shims consistently across CI,
19
+ release, and VS Code workflows.
20
+ - Made npm 11/12 consumer locks include every optional platform locator from local artifacts before
21
+ `npm ci --omit=optional`, while still loading only the matching native package at runtime, and
22
+ invoke the installed CLI without the removed `npx --no-install` behavior.
23
+
24
+ ## 0.1.22
25
+
26
+ - Added the experimental Wake-native, React-first `wake test` system, `runTests()` and `TestContext`
27
+ Node APIs, explicit `@crab-dev/wake/test` ESM/CommonJS imports, isolated V8 suite realms, Wake
28
+ snapshots, function/network mocks, an async clock, projects, sharding, and a token-authenticated
29
+ crash-isolated test host. ADR 0020 defines the private fast-DOM and system-Chromium boundary;
30
+ external runner/config/plugin/result compatibility is intentionally outside the contract.
31
+ - Added typed Chromium input and browser screenshot snapshots. `toMatchScreenshot()` stores
32
+ rendering-profiled PNG baselines and emits received PNG plus self-contained visual-diff artifacts
33
+ without exposing raw CDP or adding a second browser event consumer.
34
+
3
35
  ## 0.1.21
4
36
 
5
37
  - Added aggregate Wake Docs workspaces with isolated production bundles, deterministic manifests,
package/bin/terminal.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import clipboard from 'clipboardy'
2
- import open from 'open'
3
1
  import stringWidth from 'string-width'
4
2
 
5
3
  import {
@@ -15,6 +13,24 @@ const BOLD = '\x1b[1m'
15
13
  const DIM = '\x1b[2m'
16
14
  const MAX_ACTIVITY = 200
17
15
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧']
16
+ const OPTIONAL_CLIPBOARD_MODULE = 'clip' + 'boardy'
17
+ const OPTIONAL_OPEN_MODULE = 'op' + 'en'
18
+
19
+ const defaultClipboard = {
20
+ async read() {
21
+ const { default: clipboard } = await import(OPTIONAL_CLIPBOARD_MODULE)
22
+ return clipboard.read()
23
+ },
24
+ async write(value) {
25
+ const { default: clipboard } = await import(OPTIONAL_CLIPBOARD_MODULE)
26
+ return clipboard.write(value)
27
+ },
28
+ }
29
+
30
+ async function defaultOpenUrl(value) {
31
+ const { default: open } = await import(OPTIONAL_OPEN_MODULE)
32
+ return open(value)
33
+ }
18
34
 
19
35
  export function supportsColor(stream = process.stderr, env = process.env) {
20
36
  return stream.isTTY === true && !Object.hasOwn(env, 'NO_COLOR')
@@ -550,8 +566,8 @@ export function createDashboardSession(
550
566
  input = process.stdin,
551
567
  output = process.stderr,
552
568
  ui = createUi(),
553
- clipboardAdapter = clipboard,
554
- openUrl = open,
569
+ clipboardAdapter = defaultClipboard,
570
+ openUrl = defaultOpenUrl,
555
571
  env = process.env,
556
572
  } = {},
557
573
  ) {
package/bin/wake.mjs CHANGED
@@ -1,19 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { readFileSync, writeFileSync } from 'node:fs'
3
4
  import { readFile } from 'node:fs/promises'
4
5
  import {
5
6
  build,
6
7
  buildLibrary,
7
8
  buildDocs,
8
9
  bundle,
10
+ createTestContext,
9
11
  generateCssToken,
10
12
  generateDocgen,
13
+ runTests,
11
14
  startDevServer,
12
15
  startDocsDevServer,
13
16
  version,
14
17
  WakeError,
15
18
  } from '../index.mjs'
16
19
  import { parse, tokenize } from '../experimental.mjs'
20
+ import testContextInternal from '../test-context-internal.cjs'
17
21
  import {
18
22
  applyDashboardEvent,
19
23
  createDashboardSession,
@@ -47,6 +51,12 @@ Usage:
47
51
  wake docs dev [root] [--mode site|components] [--host HOST] [--port PORT] [--open]
48
52
  wake parse <file> [--format auto|human|json]
49
53
  wake tokenize <file> [--format auto|human|json]
54
+ wake test [patterns...] [--root DIR] [--name-pattern TEXT] [--project NAME]
55
+ [--environment auto|dom|browser] [--watch] [--changed] [--related PATH...]
56
+ [--coverage] [--update-snapshots] [--serial] [--workers COUNT]
57
+ [--bail [COUNT]] [--shard INDEX/TOTAL] [--seed SEED] [--shuffle]
58
+ [--reporter pretty|json|junit] [--output FILE] [--allow-no-tests]
59
+ [--browser-path FILE] [--headful]
50
60
  wake --version
51
61
 
52
62
  Options:
@@ -89,6 +99,68 @@ function takeOptions(args, name, usage = false) {
89
99
  }
90
100
  }
91
101
 
102
+ function takeVariadicOptions(args, name) {
103
+ const values = []
104
+ for (;;) {
105
+ const index = args.indexOf(name)
106
+ if (index === -1) return values
107
+ let end = index + 1
108
+ while (end < args.length && !args[end].startsWith('-')) end += 1
109
+ if (end === index + 1) throw testUsageError(`${name} requires at least one value`)
110
+ values.push(...args.slice(index + 1, end))
111
+ args.splice(index, end - index)
112
+ }
113
+ }
114
+
115
+ function testUsageError(message) {
116
+ const error = new WakeError('WAKE_TEST_CONFIG', message)
117
+ error.exitCode = 2
118
+ return error
119
+ }
120
+
121
+ function validateTestChoice(value, name, choices) {
122
+ if (value !== undefined && !choices.includes(value)) {
123
+ throw testUsageError(`${name} must be one of: ${choices.join(', ')}`)
124
+ }
125
+ return value
126
+ }
127
+
128
+ function parseTestWorkers(value) {
129
+ if (value === undefined || value === 'auto') return value
130
+ if (/^[1-9][0-9]*$/.test(value)) {
131
+ const count = Number(value)
132
+ if (Number.isSafeInteger(count)) return count
133
+ }
134
+ const match = /^([1-9][0-9]?)%$|^(100)%$/.exec(value)
135
+ if (match) return `${Number(match[1] || match[2])}%`
136
+ throw testUsageError('--workers requires auto, a positive integer, or 1%-100%')
137
+ }
138
+
139
+ function takeTestBail(args) {
140
+ const index = args.indexOf('--bail')
141
+ if (index === -1) return undefined
142
+ const candidate = args[index + 1]
143
+ const hasValue = candidate !== undefined && !candidate.startsWith('-')
144
+ const value = hasValue ? Number(candidate) : 1
145
+ args.splice(index, hasValue ? 2 : 1)
146
+ if (!Number.isSafeInteger(value) || value < 0) {
147
+ throw testUsageError('--bail requires a non-negative integer')
148
+ }
149
+ return value
150
+ }
151
+
152
+ function parseTestShard(value) {
153
+ if (value === undefined) return undefined
154
+ const match = /^([1-9][0-9]*)\/([1-9][0-9]*)$/.exec(value)
155
+ if (!match) throw testUsageError('--shard requires the 1-based INDEX/TOTAL form')
156
+ const index = Number(match[1])
157
+ const total = Number(match[2])
158
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index > total) {
159
+ throw testUsageError('--shard requires 1 <= INDEX <= TOTAL')
160
+ }
161
+ return `${index}/${total}`
162
+ }
163
+
92
164
  function commonOptions(args) {
93
165
  return {
94
166
  configPath: takeOption(args, '--config'),
@@ -137,6 +209,114 @@ function printResult(ui, result, label, extra = '') {
137
209
  printLines(formatBuildResult(ui, result, label, extra))
138
210
  }
139
211
 
212
+ function formatTestFailure(failure) {
213
+ let rendered = failure.code ? `${failure.code}: ` : ''
214
+ rendered += failure.message
215
+ if (failure.location) {
216
+ rendered += `\n at ${failure.location.path}:${failure.location.line}:${failure.location.column}`
217
+ }
218
+ if (failure.diff?.unified) rendered += `\n${failure.diff.unified}`
219
+ if (failure.stack && !rendered.includes(failure.stack)) rendered += `\n${failure.stack}`
220
+ return rendered
221
+ }
222
+
223
+ function xmlEscape(value) {
224
+ return String(value)
225
+ .replaceAll('&', '&amp;')
226
+ .replaceAll('<', '&lt;')
227
+ .replaceAll('>', '&gt;')
228
+ .replaceAll('"', '&quot;')
229
+ .replaceAll("'", '&apos;')
230
+ }
231
+
232
+ function junitTestReport(result) {
233
+ const suiteErrors = result.suites.filter((suite) => suite.failures.length > 0).length
234
+ const pending = result.counts.tests.skipped + result.counts.tests.todo
235
+ const lines = [
236
+ '<?xml version="1.0" encoding="UTF-8"?>',
237
+ `<testsuites tests="${result.counts.tests.total + suiteErrors}" failures="${result.counts.tests.failed}" errors="${suiteErrors}" skipped="${pending}" time="${(result.durationMs / 1_000).toFixed(3)}">`,
238
+ ]
239
+ for (const suite of result.suites) {
240
+ const failures = suite.tests.filter((testCase) => testCase.status === 'failed').length
241
+ const skipped = suite.tests.filter((testCase) => testCase.status === 'skipped' || testCase.status === 'todo').length
242
+ const suiteError = suite.failures.length > 0 ? 1 : 0
243
+ lines.push(` <testsuite name="${xmlEscape(suite.path)}" tests="${suite.tests.length + suiteError}" failures="${failures}" errors="${suiteError}" skipped="${skipped}" time="${(suite.durationMs / 1_000).toFixed(3)}">`)
244
+ for (const testCase of suite.tests) {
245
+ const prefix = ` <testcase name="${xmlEscape(testCase.name)}" classname="${xmlEscape(suite.path)}" time="${(testCase.durationMs / 1_000).toFixed(3)}">`
246
+ if (testCase.status === 'failed') {
247
+ lines.push(`${prefix}<failure>${xmlEscape(testCase.failures.map(formatTestFailure).join('\n'))}</failure></testcase>`)
248
+ } else if (testCase.status === 'skipped' || testCase.status === 'todo') {
249
+ lines.push(`${prefix}<skipped /></testcase>`)
250
+ } else {
251
+ lines.push(`${prefix}</testcase>`)
252
+ }
253
+ }
254
+ if (suite.failures.length > 0) {
255
+ lines.push(` <testcase name="[suite setup]"><error>${xmlEscape(suite.failures.map(formatTestFailure).join('\n'))}</error></testcase>`)
256
+ }
257
+ lines.push(' </testsuite>')
258
+ }
259
+ lines.push('</testsuites>')
260
+ return lines.join('\n')
261
+ }
262
+
263
+ function printTestRun(result, reporter = 'pretty', output, includeDiagnostics = true) {
264
+ if (reporter !== 'pretty') {
265
+ const report = reporter === 'json' ? JSON.stringify(result) : junitTestReport(result)
266
+ if (output) writeFileSync(output, report)
267
+ else console.log(report)
268
+ return
269
+ }
270
+ for (const suite of result.suites) {
271
+ const status = suite.status === 'failed'
272
+ ? 'FAIL'
273
+ : suite.status === 'skipped'
274
+ ? 'SKIP'
275
+ : 'PASS'
276
+ console.error(`${status} ${suite.path}`)
277
+ for (const testCase of suite.tests) {
278
+ const marker = testCase.status === 'passed'
279
+ ? '✓'
280
+ : testCase.status === 'failed'
281
+ ? '✕'
282
+ : testCase.status === 'todo'
283
+ ? '✎'
284
+ : '○'
285
+ console.error(` ${marker} ${testCase.name}`)
286
+ for (const failure of testCase.failures) {
287
+ console.error(` ${formatTestFailure(failure).replaceAll('\n', '\n ')}`)
288
+ }
289
+ }
290
+ for (const failure of suite.failures) {
291
+ console.error(` ${formatTestFailure(failure).replaceAll('\n', '\n ')}`)
292
+ }
293
+ }
294
+ console.error(`Test Suites: ${result.counts.suites.passed} passed, ${result.counts.suites.failed} failed, ${result.counts.suites.total} total`)
295
+ console.error(`Tests: ${result.counts.tests.passed} passed, ${result.counts.tests.failed} failed, ${result.counts.tests.skipped + result.counts.tests.todo} pending, ${result.counts.tests.total} total`)
296
+ const coverageText = result.artifacts.find((artifact) => artifact.kind === 'coverage-text')
297
+ if (coverageText) {
298
+ console.error(readFileSync(coverageText.path, 'utf8').trimEnd())
299
+ }
300
+ console.error(`Seed: ${result.seed}`)
301
+ console.error(`Time: ${result.durationMs} ms`)
302
+ if (result.terminationReason !== 'completed') {
303
+ console.error(`Termination: ${result.terminationReason}`)
304
+ }
305
+ if (includeDiagnostics) {
306
+ for (const diagnostic of result.diagnostics) {
307
+ console.error(`${diagnostic.code}: ${diagnostic.message}`)
308
+ }
309
+ }
310
+ }
311
+
312
+ function testResultExitCode(result) {
313
+ if (result.terminationReason === 'cancelled' || result.terminationReason === 'watch-restart') {
314
+ return 130
315
+ }
316
+ if (['host-crash', 'oom', 'internal-error'].includes(result.terminationReason)) return 2
317
+ return result.success ? 0 : 1
318
+ }
319
+
140
320
  function metricsFromEvent(event) {
141
321
  return {
142
322
  modules: event.modules,
@@ -257,6 +437,236 @@ async function runServer(factory, options, command, root, ui, uiMode) {
257
437
  }
258
438
  }
259
439
 
440
+ async function runTestCommand(args) {
441
+ try {
442
+ const root = takeOption(args, '--root', true)
443
+ const namePattern = takeOption(args, '--name-pattern', true)
444
+ const projects = takeOptions(args, '--project', true)
445
+ const environment = validateTestChoice(
446
+ takeOption(args, '--environment', true),
447
+ '--environment',
448
+ ['auto', 'dom', 'browser'],
449
+ )
450
+ const watch = takeFlag(args, '--watch')
451
+ const changed = takeFlag(args, '--changed')
452
+ const related = takeVariadicOptions(args, '--related')
453
+ const coverage = takeFlag(args, '--coverage')
454
+ const updateSnapshots = takeFlag(args, '--update-snapshots') ? 'all' : undefined
455
+ const serial = takeFlag(args, '--serial')
456
+ const workers = parseTestWorkers(takeOption(args, '--workers', true))
457
+ const bail = takeTestBail(args)
458
+ const shard = parseTestShard(takeOption(args, '--shard', true))
459
+ const seed = takeOption(args, '--seed', true)
460
+ const shuffle = takeFlag(args, '--shuffle')
461
+ const reporter = validateTestChoice(
462
+ takeOption(args, '--reporter', true),
463
+ '--reporter',
464
+ ['pretty', 'json', 'junit'],
465
+ )
466
+ const output = takeOption(args, '--output', true)
467
+ const allowNoTests = takeFlag(args, '--allow-no-tests')
468
+ const browserPath = takeOption(args, '--browser-path', true)
469
+ const headful = takeFlag(args, '--headful')
470
+
471
+ if (changed && related.length > 0) {
472
+ throw testUsageError('--changed cannot be combined with --related')
473
+ }
474
+ if (serial && workers !== undefined) {
475
+ throw testUsageError('--serial cannot be combined with --workers')
476
+ }
477
+ if (output && reporter === undefined) {
478
+ throw testUsageError('--output requires --reporter json or --reporter junit')
479
+ }
480
+ if (output && reporter === 'pretty') {
481
+ throw testUsageError('--output requires --reporter json or --reporter junit')
482
+ }
483
+ if (args.some((argument) => argument.startsWith('-'))) {
484
+ throw testUsageError(`unknown test arguments: ${args.filter((argument) => argument.startsWith('-')).join(' ')}`)
485
+ }
486
+
487
+ const options = {
488
+ root,
489
+ patterns: args.splice(0),
490
+ namePattern,
491
+ projects,
492
+ environment,
493
+ watch,
494
+ changed,
495
+ related,
496
+ coverage,
497
+ updateSnapshots,
498
+ serial,
499
+ workers,
500
+ bail,
501
+ shard,
502
+ seed,
503
+ shuffle,
504
+ reporter,
505
+ output,
506
+ allowNoTests,
507
+ browserPath,
508
+ headful,
509
+ }
510
+ const selectedReporter = reporter || 'pretty'
511
+
512
+ if (!watch) {
513
+ const result = await runTests(options)
514
+ printTestRun(result, selectedReporter, output)
515
+ return testResultExitCode(result)
516
+ }
517
+
518
+ const context = await createTestContext(options)
519
+ let lastExitCode = 0
520
+ let outputError
521
+ let finish
522
+ const finished = new Promise((resolve) => { finish = resolve })
523
+ let closing
524
+ const requestClose = () => {
525
+ closing ||= context.close().catch((error) => {
526
+ outputError ||= error
527
+ })
528
+ return closing
529
+ }
530
+ const finishAndClose = (reason) => {
531
+ finish(reason)
532
+ void requestClose()
533
+ }
534
+ const onSigint = () => finishAndClose('signal')
535
+ const onSigterm = () => finishAndClose('signal')
536
+ const onClosed = () => {
537
+ const fatalError = testContextInternal.getTestContextFatalError(context)
538
+ if (fatalError) {
539
+ outputError = fatalError
540
+ finish('fatal')
541
+ } else {
542
+ finish('closed')
543
+ }
544
+ }
545
+ process.once('SIGINT', onSigint)
546
+ process.once('SIGTERM', onSigterm)
547
+ context.once('closed', onClosed)
548
+ context.on('runComplete', (result) => {
549
+ if (result.terminationReason === 'watch-restart' || result.terminationReason === 'cancelled') {
550
+ return
551
+ }
552
+ lastExitCode = testResultExitCode(result)
553
+ try {
554
+ printTestRun(result, selectedReporter, output, false)
555
+ } catch (error) {
556
+ outputError = error
557
+ finish('output-error')
558
+ }
559
+ })
560
+ context.on('diagnostic', (diagnostic) => {
561
+ console.error(`${diagnostic.code}: ${diagnostic.message}`)
562
+ })
563
+ const stdin = process.stdin
564
+ const interactive = Boolean(stdin.isTTY && stdin.setRawMode)
565
+ const previousRaw = interactive ? Boolean(stdin.isRaw) : false
566
+ let prompt
567
+ const sendControl = (control) => {
568
+ try {
569
+ testContextInternal.sendTestWatchControl(context, control)
570
+ } catch (error) {
571
+ outputError = error
572
+ finish('control-error')
573
+ }
574
+ }
575
+ const onWatchKey = (chunk) => {
576
+ for (const character of chunk.toString('utf8')) {
577
+ if (character === '\u0003') {
578
+ finishAndClose('signal')
579
+ continue
580
+ }
581
+ if (prompt) {
582
+ if (character === '\r' || character === '\n') {
583
+ process.stdout.write('\n')
584
+ const value = prompt.value.trim()
585
+ if (value) sendControl({ type: prompt.type, pattern: value })
586
+ prompt = undefined
587
+ } else if (character === '\u001b') {
588
+ process.stdout.write('\n')
589
+ prompt = undefined
590
+ } else if (character === '\b' || character === '\u007f') {
591
+ if (prompt.value) {
592
+ prompt.value = prompt.value.slice(0, -1)
593
+ process.stdout.write('\b \b')
594
+ }
595
+ } else if (character >= ' ') {
596
+ prompt.value += character
597
+ process.stdout.write(character)
598
+ }
599
+ continue
600
+ }
601
+ switch (character) {
602
+ case 'a':
603
+ sendControl({ type: 'all' })
604
+ break
605
+ case 'f':
606
+ sendControl({ type: 'failed' })
607
+ break
608
+ case 'p':
609
+ prompt = { type: 'path', value: '' }
610
+ process.stdout.write('\nPath pattern: ')
611
+ break
612
+ case 't':
613
+ prompt = { type: 'name', value: '' }
614
+ process.stdout.write('\nTest name pattern: ')
615
+ break
616
+ case 'u':
617
+ sendControl({ type: 'updateSnapshots' })
618
+ break
619
+ case 'r':
620
+ sendControl({ type: 'rerun' })
621
+ break
622
+ case 'q':
623
+ finishAndClose('quit')
624
+ break
625
+ default:
626
+ }
627
+ }
628
+ }
629
+ try {
630
+ context.startWatch()
631
+ if (interactive) {
632
+ stdin.setRawMode(true)
633
+ stdin.resume()
634
+ stdin.on('data', onWatchKey)
635
+ console.error('Watch keys: a all · f failed · p path · t name · u snapshots · r rerun · q quit')
636
+ }
637
+ // StartWatch schedules the first host-owned run. The JS thread remains free for watch
638
+ // controls and cancellation even when the suite itself is an infinite loop.
639
+ const reason = await finished
640
+ if (closing) await closing
641
+ if (outputError) throw outputError
642
+ return reason === 'closed' || reason === 'quit' ? lastExitCode : 130
643
+ } finally {
644
+ process.off('SIGINT', onSigint)
645
+ process.off('SIGTERM', onSigterm)
646
+ context.off('closed', onClosed)
647
+ if (interactive) {
648
+ stdin.off('data', onWatchKey)
649
+ stdin.setRawMode(previousRaw)
650
+ if (!previousRaw) stdin.pause()
651
+ }
652
+ await (closing || context.close())
653
+ }
654
+ } catch (error) {
655
+ if (error && typeof error === 'object') {
656
+ if (error instanceof WakeError && error.code === 'WAKE_CONFIG') {
657
+ const mapped = new WakeError('WAKE_TEST_CONFIG', error.message, { cause: error })
658
+ mapped.exitCode = 2
659
+ throw mapped
660
+ }
661
+ error.exitCode = error.code === 'WAKE_CANCELLED' ? 130 : 2
662
+ throw error
663
+ }
664
+ const wrapped = new WakeError('WAKE_TEST_RUNTIME', String(error))
665
+ wrapped.exitCode = 2
666
+ throw wrapped
667
+ }
668
+ }
669
+
260
670
  export async function runCli(argv = process.argv.slice(2)) {
261
671
  const args = [...argv]
262
672
  const noColor = takeFlag(args, '--no-color')
@@ -273,6 +683,14 @@ export async function runCli(argv = process.argv.slice(2)) {
273
683
  }
274
684
 
275
685
  const command = args.shift()
686
+ if (command === 'test') {
687
+ if (takeFlag(args, '--help') || takeFlag(args, '-h')) {
688
+ console.log(HELP)
689
+ return 0
690
+ }
691
+ ensureStaticMode(uiMode)
692
+ return runTestCommand(args)
693
+ }
276
694
  if (command === 'build') {
277
695
  ensureStaticMode(uiMode)
278
696
  const options = commonOptions(args)