@octane-xplat/workshop 0.0.0-nightly.g2e22171

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/MOBILE.md +143 -0
  3. package/README.md +307 -0
  4. package/dist/node/index.js +50 -0
  5. package/package.json +127 -0
  6. package/src/catalog.ts +58 -0
  7. package/src/contract.ts +257 -0
  8. package/src/controls.ts +159 -0
  9. package/src/discover.mjs +584 -0
  10. package/src/errors.ts +47 -0
  11. package/src/fixture.ts +158 -0
  12. package/src/index.ts +6 -0
  13. package/src/macos/controller.ts +170 -0
  14. package/src/macos/index.macos.ts +2 -0
  15. package/src/macos/shell.macos.mjs +673 -0
  16. package/src/macos/stage.macos.ts +182 -0
  17. package/src/macos-node.mjs +95 -0
  18. package/src/macos-vite.mjs +47 -0
  19. package/src/mobile/controller.ts +134 -0
  20. package/src/mobile/entry.mobile.ts +607 -0
  21. package/src/mobile/index.mobile.ts +3 -0
  22. package/src/mobile/mount.mobile.ts +60 -0
  23. package/src/mobile/tree.mobile.tsrx +17 -0
  24. package/src/mobile-node.mjs +126 -0
  25. package/src/mobile-regex.mjs +60 -0
  26. package/src/node.d.ts +65 -0
  27. package/src/node.mjs +536 -0
  28. package/src/session.ts +520 -0
  29. package/src/vite-mobile.d.ts +21 -0
  30. package/src/vite-mobile.mjs +101 -0
  31. package/src/vite.d.ts +11 -0
  32. package/src/vite.mjs +315 -0
  33. package/src/web/entry.web.ts +270 -0
  34. package/src/web/index.web.ts +7 -0
  35. package/src/web/mount.web.ts +86 -0
  36. package/src/web/shell.css +449 -0
  37. package/src/web/shell.web.tsrx +759 -0
  38. package/src/windows/preflight.mjs +113 -0
  39. package/types/generated/.tsrx-typegen-manifest.json +24 -0
  40. package/types/generated/catalog.d.ts +30 -0
  41. package/types/generated/contract.d.ts +145 -0
  42. package/types/generated/controls.d.ts +28 -0
  43. package/types/generated/errors.d.ts +13 -0
  44. package/types/generated/fixture.d.ts +53 -0
  45. package/types/generated/index.d.ts +6 -0
  46. package/types/generated/macos/controller.d.ts +52 -0
  47. package/types/generated/macos/index.macos.d.ts +2 -0
  48. package/types/generated/macos/stage.macos.d.ts +10 -0
  49. package/types/generated/mobile/controller.d.ts +31 -0
  50. package/types/generated/mobile/entry.mobile.d.ts +22 -0
  51. package/types/generated/mobile/index.mobile.d.ts +3 -0
  52. package/types/generated/mobile/mount.mobile.d.ts +4 -0
  53. package/types/generated/mobile/tree.mobile.d.ts +4 -0
  54. package/types/generated/session.d.ts +84 -0
  55. package/types/generated/web/entry.web.d.ts +53 -0
  56. package/types/generated/web/index.web.d.ts +7 -0
  57. package/types/generated/web/mount.web.d.ts +2 -0
  58. package/types/generated/web/shell.web.d.ts +4 -0
@@ -0,0 +1,584 @@
1
+ /**
2
+ * Node-side story discovery — static, metadata-only.
3
+ *
4
+ * Story modules are never evaluated: this scanner reads `defineStory` option
5
+ * literals out of the source text. `id`, `title`, and `targets` must be
6
+ * literal values; anything computed fails with an actionable diagnostic.
7
+ */
8
+ import { readFileSync } from 'node:fs'
9
+ import { globSync } from 'tinyglobby'
10
+
11
+ export const ALL_TARGETS = ['web', 'ios', 'android', 'macos', 'windows', 'linux']
12
+
13
+ const DEFAULT_INCLUDE = [
14
+ '**/*.stories.{ts,tsx,tsrx,mts,mjs,js,jsx}',
15
+ '**/*.stories.{web,mobile,ios,android,macos,windows,linux}.{ts,tsx,tsrx,mts,mjs,js,jsx}',
16
+ ]
17
+
18
+ const DEFAULT_EXCLUDE = ['**/node_modules/**', '**/dist/**', '**/types/**']
19
+
20
+ const SUFFIX_TARGETS = {
21
+ web: ['web'],
22
+ mobile: ['ios', 'android'],
23
+ ios: ['ios'],
24
+ android: ['android'],
25
+ macos: ['macos'],
26
+ windows: ['windows'],
27
+ linux: ['linux'],
28
+ }
29
+
30
+ const SUFFIX_RE = /\.stories\.(web|mobile|ios|android|macos|windows|linux)\.[cm]?[jt]srx?$/
31
+
32
+ export function storyFileTag(file) {
33
+ return SUFFIX_RE.exec(file)?.[1] ?? null
34
+ }
35
+
36
+ export function applicableTargets(declared, file) {
37
+ const tag = storyFileTag(file)
38
+ if (!tag) {
39
+ return declared
40
+ }
41
+
42
+ const implied = SUFFIX_TARGETS[tag] ?? []
43
+ return declared.filter((t) => implied.includes(t))
44
+ }
45
+
46
+ export function normalizeInclude(include) {
47
+ if (include === undefined) {
48
+ return DEFAULT_INCLUDE
49
+ }
50
+
51
+ return Array.isArray(include) ? [...include] : [include]
52
+ }
53
+
54
+ export function normalizeExclude(exclude) {
55
+ const extra = exclude === undefined ? [] : Array.isArray(exclude) ? [...exclude] : [exclude]
56
+ return [...DEFAULT_EXCLUDE, ...extra]
57
+ }
58
+
59
+ // ---------- literal source scanning ----------
60
+
61
+ /** Strip strings, template literals, and comments while preserving length. */
62
+ export function maskSource(source) {
63
+ const out = source.split('')
64
+ let i = 0
65
+ const n = source.length
66
+ const blank = (from, to) => {
67
+ for (let k = from; k < to; k++) {
68
+ out[k] = out[k] === '\n' ? '\n' : ' '
69
+ }
70
+ }
71
+
72
+ while (i < n) {
73
+ const ch = source[i]
74
+ if (ch === '/' && source[i + 1] === '/') {
75
+ let j = i
76
+ while (j < n && source[j] !== '\n') {
77
+ j++
78
+ }
79
+
80
+ blank(i, j)
81
+ i = j
82
+ } else if (ch === '/' && source[i + 1] === '*') {
83
+ let j = source.indexOf('*/', i + 2)
84
+ j = j === -1 ? n : j + 2
85
+ blank(i, j)
86
+ i = j
87
+ } else if (ch === "'" || ch === '"') {
88
+ let j = i + 1
89
+ while (j < n) {
90
+ if (source[j] === '\\') {
91
+ j += 2
92
+ } else if (source[j] === ch) {
93
+ j++
94
+ break
95
+ } else {
96
+ j++
97
+ }
98
+ }
99
+
100
+ j = Math.min(j, n)
101
+ // Keep the delimiters so the slice still reads as a string shape;
102
+ // blank the interior only.
103
+ blank(i + 1, Math.max(i + 1, j - 1))
104
+ i = j
105
+ } else if (ch === '`') {
106
+ // Keep ${ } interpolation visible (expressions are not literals);
107
+ // blank only the literal text spans.
108
+ let j = i + 1
109
+ while (j < n) {
110
+ if (source[j] === '\\') {
111
+ blank(j, j + 2)
112
+ j += 2
113
+ } else if (source[j] === '`') {
114
+ j++
115
+ break
116
+ } else if (source[j] === '$' && source[j + 1] === '{') {
117
+ let depth = 1
118
+ let k = j + 2
119
+ while (k < n && depth > 0) {
120
+ if (source[k] === '{') {
121
+ depth++
122
+ } else if (source[k] === '}') {
123
+ depth--
124
+ } else if (source[k] === "'" || source[k] === '"') {
125
+ let q = k + 1
126
+ while (q < n && source[q] !== source[k]) {
127
+ q += source[q] === '\\' ? 2 : 1
128
+ }
129
+
130
+ k = Math.min(q + 1, n)
131
+ continue
132
+ }
133
+
134
+ k++
135
+ }
136
+
137
+ j = k
138
+ } else {
139
+ blank(j, j + 1)
140
+ j++
141
+ }
142
+ }
143
+
144
+ i = j
145
+ } else {
146
+ i++
147
+ }
148
+ }
149
+
150
+ return out.join('')
151
+ }
152
+
153
+ /** Find matching close for an opening bracket at index `open` in masked text. */
154
+ function matchBracket(masked, open, openCh, closeCh) {
155
+ let depth = 0
156
+ for (let i = open; i < masked.length; i++) {
157
+ const ch = masked[i]
158
+ if (ch === openCh) {
159
+ depth++
160
+ } else if (ch === closeCh) {
161
+ depth--
162
+ if (depth === 0) {
163
+ return i
164
+ }
165
+ }
166
+ }
167
+
168
+ return -1
169
+ }
170
+
171
+ /** Split a masked object-literal body into top-level key → raw value text. */
172
+ function objectFields(masked, source, openBrace, closeBrace) {
173
+ const fields = new Map()
174
+ let i = openBrace + 1
175
+ while (i < closeBrace) {
176
+ while (i < closeBrace && /[\s,]/.test(masked[i])) {
177
+ i++
178
+ }
179
+
180
+ if (i >= closeBrace) {
181
+ break
182
+ }
183
+
184
+ const m = /^([A-Za-z_$][\w$]*|'[\s\S]*'|"[\s\S]*"|\d+)\s*:/.exec(masked.slice(i, closeBrace))
185
+ if (!m) {
186
+ return { fields, error: `unsupported object syntax near "${masked.slice(i, i + 24).trim()}"` }
187
+ }
188
+
189
+ const rawKey = m[1]
190
+ const key =
191
+ rawKey.startsWith("'") || rawKey.startsWith('"')
192
+ ? source
193
+ .slice(i, i + m[0].indexOf(':'))
194
+ .trim()
195
+ .slice(1, -1)
196
+ : rawKey
197
+
198
+ let v = i + m[0].length
199
+ const start = v
200
+ let depth = 0
201
+ while (v < closeBrace) {
202
+ const ch = masked[v]
203
+ if (ch === '{' || ch === '[' || ch === '(') {
204
+ depth++
205
+ } else if (ch === '}' || ch === ']' || ch === ')') {
206
+ if (depth === 0) {
207
+ break
208
+ }
209
+
210
+ depth--
211
+ } else if (ch === ',' && depth === 0) {
212
+ break
213
+ }
214
+
215
+ v++
216
+ }
217
+
218
+ fields.set(key, { start, end: v })
219
+ i = v + 1
220
+ }
221
+
222
+ return { fields }
223
+ }
224
+
225
+ /** Evaluate a literal-only expression slice; throws on non-literal content. */
226
+ function literalValue(masked, source, start, end, label, file) {
227
+ const text = masked.slice(start, end).trim()
228
+ const fail = () => {
229
+ throw new Error(
230
+ `${file}: story metadata "${label}" must be a literal value — got "${source.slice(start, end).trim().slice(0, 60)}"`,
231
+ )
232
+ }
233
+
234
+ if (text === '') {
235
+ fail()
236
+ }
237
+
238
+ if (text === 'true') {
239
+ return true
240
+ }
241
+
242
+ if (text === 'false') {
243
+ return false
244
+ }
245
+
246
+ if (text === 'null') {
247
+ return null
248
+ }
249
+
250
+ if (/^-?\d+(\.\d+)?$/.test(text)) {
251
+ return Number(text)
252
+ }
253
+
254
+ if (/^'[^']*'$/.test(text) || /^"[^"]*"$/.test(text)) {
255
+ const value = source.slice(start, end).trim().slice(1, -1)
256
+ if (value.includes('\\')) {
257
+ fail()
258
+ }
259
+
260
+ return value
261
+ }
262
+
263
+ // Template literal without interpolation → masked to spaces; reject.
264
+ if (text.startsWith('`') || text === '') {
265
+ fail()
266
+ }
267
+
268
+ if (text.startsWith('[')) {
269
+ const openIdx = masked.indexOf('[', start)
270
+ const close = matchBracket(masked, openIdx, '[', ']')
271
+ if (close < 0 || masked.slice(close + 1, end).trim()) {
272
+ fail()
273
+ }
274
+
275
+ const items = []
276
+ let seg = openIdx + 1
277
+ let depth = 0
278
+ const push = (end2) => {
279
+ const t = masked.slice(seg, end2).trim()
280
+ if (t) {
281
+ items.push(literalValue(masked, source, seg, end2, label, file))
282
+ }
283
+ }
284
+
285
+ for (let i = openIdx + 1; i < close; i++) {
286
+ const ch = masked[i]
287
+ if (ch === '{' || ch === '[' || ch === '(') {
288
+ depth++
289
+ } else if (ch === '}' || ch === ']' || ch === ')') {
290
+ depth--
291
+ } else if (ch === ',' && depth === 0) {
292
+ push(i)
293
+ seg = i + 1
294
+ }
295
+ }
296
+
297
+ push(close)
298
+ return items
299
+ }
300
+
301
+ fail()
302
+ }
303
+
304
+ const DECL_RE = /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*$/
305
+
306
+ /**
307
+ * Extract story metadata declarations from source text. Returns
308
+ * { stories: [...], diagnostics: [...] } — never throws on malformed input.
309
+ */
310
+ export function scanStoryFile(source, file) {
311
+ const masked = maskSource(source)
312
+ const stories = []
313
+ const diagnostics = []
314
+ const exported = new Set()
315
+ for (const m of masked.matchAll(/export\s*\{([^}]*)\}/g)) {
316
+ for (const part of m[1].split(',')) {
317
+ const name = part
318
+ .trim()
319
+ .split(/\s+as\s+/)
320
+ .pop()
321
+ ?.trim()
322
+
323
+ if (name) {
324
+ exported.add(name)
325
+ }
326
+ }
327
+ }
328
+
329
+ let search = 0
330
+ for (;;) {
331
+ const at = masked.indexOf('defineStory', search)
332
+ if (at === -1) {
333
+ break
334
+ }
335
+
336
+ search = at + 'defineStory'.length
337
+ const before = masked.slice(0, at)
338
+ const after = masked.slice(at + 'defineStory'.length)
339
+ // Word-boundary check on both sides.
340
+ if (/[\w$.]$/.test(before) || /^[\w$]/.test(after)) {
341
+ continue
342
+ }
343
+
344
+ // Binding detection: the statement immediately preceding defineStory
345
+ // must be `<export>? const|let|var X =` or `export default`; the binding
346
+ // may also be re-exported later via `export { X }`.
347
+ let exportName = 'default'
348
+ const stmtStart = Math.max(
349
+ before.lastIndexOf(';'),
350
+ before.lastIndexOf('}'),
351
+ before.lastIndexOf('{'),
352
+ )
353
+
354
+ const stmt = before.slice(stmtStart + 1)
355
+ const decl = DECL_RE.exec(stmt)
356
+ let exportedStory = false
357
+ if (decl && decl.index + decl[0].length === stmt.length) {
358
+ exportName = decl[1]
359
+ exportedStory = /export\s+(const|let|var)\s/.test(stmt) || exported.has(exportName)
360
+ } else if (/export\s+default\s*$/.test(stmt)) {
361
+ exportedStory = true
362
+ }
363
+
364
+ let i = at + 'defineStory'.length
365
+ while (i < masked.length && /\s/.test(masked[i])) {
366
+ i++
367
+ }
368
+
369
+ // Generic instantiation: defineStory<Props, State, Fixture>({ … })
370
+ if (masked[i] === '<') {
371
+ const closeAngle = matchBracket(masked, i, '<', '>')
372
+ if (closeAngle !== -1) {
373
+ i = closeAngle + 1
374
+ while (i < masked.length && /\s/.test(masked[i])) {
375
+ i++
376
+ }
377
+ }
378
+ }
379
+
380
+ if (masked[i] !== '(') {
381
+ // Not a call — an import specifier, re-export or identifier mention.
382
+ continue
383
+ }
384
+
385
+ const closeParen = matchBracket(masked, i, '(', ')')
386
+ if (closeParen === -1) {
387
+ diagnostics.push({ file, exportName, message: 'defineStory call has unbalanced parentheses' })
388
+ continue
389
+ }
390
+
391
+ let j = i + 1
392
+ while (j < closeParen && /\s/.test(masked[j])) {
393
+ j++
394
+ }
395
+
396
+ if (masked[j] !== '{') {
397
+ diagnostics.push({
398
+ file,
399
+ exportName,
400
+ message: 'defineStory options must be an object literal',
401
+ })
402
+
403
+ continue
404
+ }
405
+
406
+ const closeBrace = matchBracket(masked, j, '{', '}')
407
+ const { fields, error } = objectFields(masked, source, j, closeBrace)
408
+ if (error) {
409
+ diagnostics.push({ file, exportName, message: error })
410
+ continue
411
+ }
412
+
413
+ const story = { file, exportName, exported: exportedStory }
414
+ let failed = false
415
+ for (const key of ['id', 'title', 'targets', 'capabilities']) {
416
+ const field = fields.get(key)
417
+ if (!field) {
418
+ if (key === 'capabilities') {
419
+ continue
420
+ }
421
+
422
+ diagnostics.push({ file, exportName, message: `defineStory requires a literal "${key}"` })
423
+ failed = true
424
+ continue
425
+ }
426
+
427
+ try {
428
+ story[key] = literalValue(masked, source, field.start, field.end, key, file)
429
+ } catch (e) {
430
+ diagnostics.push({ file, exportName, message: e.message })
431
+ failed = true
432
+ }
433
+ }
434
+
435
+ if (
436
+ !failed &&
437
+ (typeof story.id !== 'string' ||
438
+ !story.id ||
439
+ typeof story.title !== 'string' ||
440
+ !story.title ||
441
+ !(
442
+ story.targets === 'all' ||
443
+ (Array.isArray(story.targets) &&
444
+ story.targets.length > 0 &&
445
+ story.targets.every((t) => ALL_TARGETS.includes(t)))
446
+ ) ||
447
+ !(
448
+ story.capabilities === undefined ||
449
+ (Array.isArray(story.capabilities) &&
450
+ story.capabilities.every((c) => typeof c === 'string' && c.length > 0))
451
+ ))
452
+ ) {
453
+ diagnostics.push({
454
+ file,
455
+ exportName,
456
+ message:
457
+ 'story requires non-empty literal id/title, targets: all or a non-empty target array, and a string capability array',
458
+ })
459
+
460
+ failed = true
461
+ }
462
+
463
+ if (!failed) {
464
+ stories.push(story)
465
+ }
466
+ }
467
+
468
+ return { stories, diagnostics }
469
+ }
470
+
471
+ /**
472
+ * Discover story metadata under `root`.
473
+ * → { entries, diagnostics }
474
+ * entries: StoryMeta[] (applicable/reason computed for `target`)
475
+ */
476
+ export function discoverStories(root, { include, exclude, target = 'web' } = {}) {
477
+ const files = globSync(normalizeInclude(include), {
478
+ cwd: root,
479
+ ignore: normalizeExclude(exclude),
480
+ absolute: true,
481
+ })
482
+
483
+ const entries = []
484
+ const diagnostics = []
485
+ const byId = new Map()
486
+
487
+ for (const file of files) {
488
+ let source
489
+ try {
490
+ source = readFileSync(file, 'utf8')
491
+ } catch (e) {
492
+ diagnostics.push({ file, message: `could not read story file: ${e.message}` })
493
+ continue
494
+ }
495
+
496
+ const { stories, diagnostics: fileDiagnostics } = scanStoryFile(source, file)
497
+ diagnostics.push(...fileDiagnostics)
498
+ for (const story of stories) {
499
+ if (!story.exported) {
500
+ diagnostics.push({
501
+ file,
502
+ exportName: story.exportName,
503
+ message: `defineStory("${story.id}") must be exported to be discovered`,
504
+ })
505
+
506
+ continue
507
+ }
508
+
509
+ const declared =
510
+ story.targets === 'all'
511
+ ? ALL_TARGETS
512
+ : Array.isArray(story.targets)
513
+ ? story.targets
514
+ : [story.targets]
515
+
516
+ for (const t of declared) {
517
+ if (!ALL_TARGETS.includes(t)) {
518
+ diagnostics.push({
519
+ file,
520
+ exportName: story.exportName,
521
+ message: `story "${story.id}" declares unknown target "${t}"`,
522
+ })
523
+ }
524
+ }
525
+
526
+ const applicable = applicableTargets(
527
+ declared.filter((t) => ALL_TARGETS.includes(t)),
528
+ file,
529
+ )
530
+
531
+ const required = story.capabilities ?? []
532
+ const canRun = applicable.includes(target) && required.length === 0
533
+ const reason = canRun
534
+ ? undefined
535
+ : applicable.includes(target) && required.length > 0
536
+ ? `required capabilities are unknown/unqualified on ${target}: ${required.join(', ')}`
537
+ : applicable.length === 0
538
+ ? `declared targets and the ${storyFileTag(file) ?? 'story'} leaf do not overlap`
539
+ : `story targets ${applicable.join(', ')}, not ${target}`
540
+
541
+ const meta = {
542
+ id: story.id,
543
+ title: story.title,
544
+ targets: applicable,
545
+ capabilities: story.capabilities ?? [],
546
+ file,
547
+ exportName: story.exportName,
548
+ applicable: canRun,
549
+ reason,
550
+ }
551
+
552
+ // One story id may have platform leaves per target; two declarations
553
+ // claiming the same id for the same target is a real conflict.
554
+ const prior = byId.get(story.id) ?? []
555
+ const conflict = prior.find((prev) => prev.targets.some((t) => meta.targets.includes(t)))
556
+ if (conflict) {
557
+ diagnostics.push({
558
+ file,
559
+ exportName: story.exportName,
560
+ message: `duplicate story id "${story.id}" — also declared in ${conflict.file} (export "${conflict.exportName}")`,
561
+ })
562
+
563
+ continue
564
+ }
565
+
566
+ byId.set(story.id, [...prior, meta])
567
+ entries.push(meta)
568
+ }
569
+ }
570
+
571
+ entries.sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id))
572
+
573
+ // One catalog row per story id — platform leaves are variants of one
574
+ // story, so the row carries the leaf applicable to this target.
575
+ const byRow = new Map()
576
+ for (const entry of entries) {
577
+ const list = byRow.get(entry.id) ?? []
578
+ list.push(entry)
579
+ byRow.set(entry.id, list)
580
+ }
581
+
582
+ const merged = [...byRow.values()].map((group) => group.find((e) => e.applicable) ?? group[0])
583
+ return { entries: merged, diagnostics }
584
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,47 @@
1
+ /** Failure phases surfaced by the workshop shell and CLI. */
2
+ export type WorkshopErrorPhase =
3
+ | 'discovery'
4
+ | 'compile'
5
+ | 'fixture'
6
+ | 'mount'
7
+ | 'interaction'
8
+ | 'cleanup'
9
+ | 'launch'
10
+ | 'connection'
11
+
12
+ export class WorkshopError extends Error {
13
+ readonly phase: WorkshopErrorPhase
14
+ readonly storyId?: string
15
+ readonly sourceLocation?: string
16
+
17
+ constructor(
18
+ phase: WorkshopErrorPhase,
19
+ message: string,
20
+ options?: { storyId?: string; cause?: unknown; sourceLocation?: string },
21
+ ) {
22
+ super(message)
23
+ this.name = 'WorkshopError'
24
+ this.phase = phase
25
+ this.storyId = options?.storyId
26
+ this.sourceLocation = options?.sourceLocation
27
+ if (options?.cause !== undefined) {
28
+ ;(this as { cause?: unknown }).cause = options.cause
29
+ }
30
+ }
31
+ }
32
+
33
+ export function describeCause(cause: unknown): string {
34
+ if (cause instanceof Error) {
35
+ // JSC stacks often start directly at a `fn@file:line` frame without the
36
+ // `Name: message` head — keep the message visible either way.
37
+ const head = `${cause.name}: ${cause.message}`
38
+ const tail = cause.stack?.split('\n').slice(0, 3).join('\n')
39
+ if (!tail) {
40
+ return head
41
+ }
42
+
43
+ return tail.startsWith(head) ? tail : `${head}\n${tail}`
44
+ }
45
+
46
+ return String(cause)
47
+ }