tjs-lang 0.8.1 → 0.8.3

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 (78) hide show
  1. package/CLAUDE.md +13 -4
  2. package/demo/autocomplete.test.ts +37 -0
  3. package/demo/docs.json +698 -8
  4. package/demo/examples.test.ts +6 -2
  5. package/demo/src/autocomplete.ts +40 -2
  6. package/demo/src/introspection-bridge.ts +140 -0
  7. package/demo/src/introspection-doc.test.ts +63 -0
  8. package/demo/src/playground-shared.ts +101 -16
  9. package/demo/src/playground-test-results.test.ts +112 -0
  10. package/demo/src/tjs-playground.ts +32 -5
  11. package/dist/examples/modules/dist/main.d.ts +34 -0
  12. package/dist/examples/modules/dist/math.d.ts +120 -0
  13. package/dist/index.js +115 -112
  14. package/dist/index.js.map +4 -4
  15. package/dist/src/lang/core.d.ts +1 -1
  16. package/dist/src/lang/dialect.d.ts +35 -0
  17. package/dist/src/lang/emitters/ast.d.ts +1 -1
  18. package/dist/src/lang/emitters/js-tests.d.ts +7 -0
  19. package/dist/src/lang/emitters/js.d.ts +12 -0
  20. package/dist/src/lang/index.d.ts +2 -1
  21. package/dist/src/lang/parser-types.d.ts +17 -0
  22. package/dist/src/lang/parser.d.ts +18 -0
  23. package/dist/src/lang/transpiler.d.ts +1 -0
  24. package/dist/src/lang/types.d.ts +18 -0
  25. package/dist/src/vm/runtime.d.ts +18 -0
  26. package/dist/src/vm/vm.d.ts +15 -1
  27. package/dist/tjs-batteries.js +2 -2
  28. package/dist/tjs-batteries.js.map +2 -2
  29. package/dist/tjs-eval.js +43 -43
  30. package/dist/tjs-eval.js.map +3 -3
  31. package/dist/tjs-from-ts.js +1 -1
  32. package/dist/tjs-from-ts.js.map +2 -2
  33. package/dist/tjs-lang.js +76 -73
  34. package/dist/tjs-lang.js.map +4 -4
  35. package/dist/tjs-vm.js +49 -49
  36. package/dist/tjs-vm.js.map +3 -3
  37. package/editors/codemirror/ajs-language.ts +130 -44
  38. package/editors/codemirror/completion-source.test.ts +114 -0
  39. package/editors/introspect-value.test.ts +61 -0
  40. package/editors/introspect-value.ts +86 -0
  41. package/editors/scope-symbols.test.ts +113 -0
  42. package/editors/scope-symbols.ts +173 -0
  43. package/llms.txt +1 -0
  44. package/package.json +21 -1
  45. package/src/batteries/audit.ts +3 -2
  46. package/src/cli/commands/check.ts +3 -2
  47. package/src/cli/commands/emit.ts +4 -2
  48. package/src/cli/commands/run.ts +6 -2
  49. package/src/cli/commands/types.ts +2 -2
  50. package/src/lang/codegen.test.ts +4 -1
  51. package/src/lang/core.ts +6 -4
  52. package/src/lang/dialect.test.ts +63 -0
  53. package/src/lang/dialect.ts +50 -0
  54. package/src/lang/emitters/ast.ts +145 -2
  55. package/src/lang/emitters/js-tests.ts +46 -37
  56. package/src/lang/emitters/js.ts +19 -2
  57. package/src/lang/features.test.ts +6 -5
  58. package/src/lang/index.ts +40 -5
  59. package/src/lang/parser-types.ts +17 -0
  60. package/src/lang/parser.test.ts +12 -6
  61. package/src/lang/parser.ts +113 -3
  62. package/src/lang/predicate-schema.test.ts +97 -0
  63. package/src/lang/predicate-schema.ts +168 -0
  64. package/src/lang/predicate.test.ts +184 -0
  65. package/src/lang/predicate.ts +550 -0
  66. package/src/lang/subset-invariant.test.ts +90 -0
  67. package/src/lang/suggest.test.ts +84 -0
  68. package/src/lang/transpiler.ts +34 -0
  69. package/src/lang/types.ts +18 -0
  70. package/src/lang/wasm.test.ts +8 -2
  71. package/src/linalg/linalg.test.ts +8 -2
  72. package/src/use-cases/batteries.test.ts +4 -0
  73. package/src/use-cases/local-helpers.test.ts +219 -0
  74. package/src/use-cases/timeout-overrides.test.ts +169 -0
  75. package/src/vm/atom-effects.test.ts +72 -0
  76. package/src/vm/atoms/batteries.ts +29 -3
  77. package/src/vm/runtime.ts +140 -4
  78. package/src/vm/vm.ts +47 -9
@@ -448,9 +448,13 @@ describe('Playground Examples', () => {
448
448
  args.imageUrl = '/test-shapes.jpg'
449
449
  }
450
450
 
451
- // Use the SAME capabilities as the playground
451
+ // Use the SAME capabilities as the playground.
452
+ // Vision inference on a local model can take a while; give it an
453
+ // explicit budget well above the slow-atom timeout so the test is
454
+ // deterministic regardless of the run-level default.
452
455
  const runResult = await vm.run(result.ast, args, {
453
456
  fuel: 100000,
457
+ timeoutMs: 180000,
454
458
  capabilities: {
455
459
  fetch: httpFetch,
456
460
  llm: llmCapability || mockLLM,
@@ -466,7 +470,7 @@ describe('Playground Examples', () => {
466
470
  } finally {
467
471
  trackTestEnd()
468
472
  }
469
- }, 120000)
473
+ }, 240000) // bun-test timeout above the vm timeoutMs (180s) + overhead
470
474
  } else if (needsRetry) {
471
475
  // Examples that use runCode need retry due to LLM variability
472
476
  it(`${example.name} - runs successfully`, async () => {
@@ -5,6 +5,11 @@
5
5
  * Uses __tjs metadata for rich type information.
6
6
  */
7
7
 
8
+ import {
9
+ collectScopeSymbols,
10
+ type ScopeSymbol,
11
+ } from '../../editors/scope-symbols'
12
+
8
13
  export interface Completion {
9
14
  /** The text to insert */
10
15
  label: string
@@ -277,6 +282,40 @@ function isInTestBlock(source: string, position: number): boolean {
277
282
  return testMatches.length > 0
278
283
  }
279
284
 
285
+ /**
286
+ * Completions from the scope-aware symbol model (acorn-based, destructuring-aware).
287
+ * Subsumes the old regex `extractFunctions`/`extractVariables`, which missed all
288
+ * destructuring. Falls back to the regex extractors only if parsing yields
289
+ * nothing (so the list never goes blank on pathological input).
290
+ */
291
+ function completionsFromScope(source: string, position: number): Completion[] {
292
+ const symbols = collectScopeSymbols(source, position)
293
+ if (symbols.length === 0) {
294
+ return [...extractFunctions(source), ...extractVariables(source, position)]
295
+ }
296
+ return symbols.map((s) => scopeSymbolToCompletion(s))
297
+ }
298
+
299
+ function scopeSymbolToCompletion(s: ScopeSymbol): Completion {
300
+ const kind: Completion['kind'] =
301
+ s.kind === 'function' ? 'function' : 'variable'
302
+ // For a destructured binding, show where it came from — `h1 ∈ elements`.
303
+ const detail =
304
+ s.origin?.member && s.origin.expr
305
+ ? `∈ ${s.origin.expr}`
306
+ : s.kind === 'import' && s.origin?.module
307
+ ? `import '${s.origin.module}'`
308
+ : s.kind === 'parameter'
309
+ ? 'parameter'
310
+ : undefined
311
+ return {
312
+ label: s.name,
313
+ kind,
314
+ detail,
315
+ snippet: kind === 'function' ? `${s.name}($1)` : undefined,
316
+ }
317
+ }
318
+
280
319
  /**
281
320
  * Extract function names from source
282
321
  */
@@ -418,8 +457,7 @@ export function getCompletions(ctx: CompletionContext): Completion[] {
418
457
  completions = [
419
458
  ...TJS_KEYWORDS,
420
459
  ...RUNTIME_COMPLETIONS,
421
- ...extractFunctions(source),
422
- ...extractVariables(source, position),
460
+ ...completionsFromScope(source, position),
423
461
  ]
424
462
 
425
463
  // Add metadata-based completions
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Introspection bridge — the runtime-truth half of the autocomplete plan.
3
+ *
4
+ * Chrome-console style: run the user's code in a hidden, disposable sandbox
5
+ * iframe and keep a live `eval` handle into its module scope. Member completion
6
+ * (`todoApp.items.` → the real array's members; `elements.` → tosijs's
7
+ * proxy-generated creators) then comes from the ACTUAL values, not a static
8
+ * model. The sandbox reuses the playground's run pipeline (transpile →
9
+ * rewriteImports → buildIframeDoc → SW-served URL) so imports resolve the same
10
+ * way the preview does.
11
+ *
12
+ * Lifecycle: `refresh(source)` rebuilds the sandbox (debounced by the caller, on
13
+ * statement boundaries) and caches the last good one — if the source doesn't
14
+ * transpile, the previous sandbox stays queryable. `members(path)` asks the
15
+ * sandbox to introspect a value, with a short timeout so the editor never hangs.
16
+ */
17
+ import { tjs } from '../../src/lang'
18
+ import { rewriteImports, registerIframeContent } from './imports'
19
+ import { buildIframeDoc } from './playground-shared'
20
+ import type { IntrospectMember } from '../../editors/introspect-value'
21
+
22
+ export class IntrospectionBridge {
23
+ private iframe: HTMLIFrameElement
24
+ private ready = false
25
+ private lastSource: string | null = null
26
+ private pending = new Map<number, (members: IntrospectMember[]) => void>()
27
+ private nextId = 1
28
+ private readonly onMessage: (e: MessageEvent) => void
29
+
30
+ constructor() {
31
+ const iframe = document.createElement('iframe')
32
+ iframe.setAttribute('aria-hidden', 'true')
33
+ iframe.setAttribute('tabindex', '-1')
34
+ // Off-screen and inert, but still a real DOM so tosijs `elements` work.
35
+ iframe.style.cssText =
36
+ 'position:absolute;width:1px;height:1px;left:-9999px;top:-9999px;border:0;visibility:hidden;pointer-events:none;'
37
+ document.body.appendChild(iframe)
38
+ this.iframe = iframe
39
+
40
+ this.onMessage = (e: MessageEvent) => {
41
+ const d = e.data
42
+ if (!d || e.source !== iframe.contentWindow) return
43
+ if (d.type === 'tjs-bridge-ready') {
44
+ this.ready = true
45
+ } else if (d.type === 'tjs-introspect-result') {
46
+ const resolve = this.pending.get(d.id)
47
+ if (resolve) {
48
+ this.pending.delete(d.id)
49
+ resolve(Array.isArray(d.members) ? d.members : [])
50
+ }
51
+ }
52
+ }
53
+ window.addEventListener('message', this.onMessage)
54
+ }
55
+
56
+ /**
57
+ * Rebuild the sandbox from the current source. No-op if unchanged or if the
58
+ * source doesn't transpile (keeps the last good sandbox). Caller should
59
+ * debounce this to statement boundaries.
60
+ */
61
+ async refresh(source: string): Promise<void> {
62
+ if (source === this.lastSource) return
63
+
64
+ let code: string
65
+ try {
66
+ const result = tjs(source)
67
+ code = result.code
68
+ } catch {
69
+ return // not transpilable yet — keep the last good sandbox
70
+ }
71
+ this.lastSource = source
72
+
73
+ const rewritten = rewriteImports(code)
74
+ const importStatements: string[] = []
75
+ const body = rewritten.replace(
76
+ /^import\s+(?:.*?from\s+)?['"][^'"]+['"];?\s*$/gm,
77
+ (match) => {
78
+ importStatements.push(match)
79
+ return ''
80
+ }
81
+ )
82
+
83
+ const doc = buildIframeDoc({
84
+ cssContent: '',
85
+ htmlContent: '',
86
+ importMapScript: '',
87
+ jsCode: body,
88
+ importStatements,
89
+ introspectionBridge: true,
90
+ })
91
+
92
+ this.ready = false
93
+ // Reject in-flight queries against the old sandbox.
94
+ for (const resolve of this.pending.values()) resolve([])
95
+ this.pending.clear()
96
+
97
+ const sessionId = `tjs-introspect-${Date.now()}-${Math.random()
98
+ .toString(36)
99
+ .slice(2)}`
100
+ const registered = await registerIframeContent(sessionId, doc)
101
+ if (registered) {
102
+ this.iframe.src = `/iframe/${sessionId}`
103
+ } else {
104
+ const blob = new Blob([doc], { type: 'text/html' })
105
+ this.iframe.src = URL.createObjectURL(blob)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Introspect a dotted path in the live sandbox scope. Resolves to the value's
111
+ * real members, or `undefined` if the sandbox isn't ready / times out (so the
112
+ * caller can fall back to static completions).
113
+ */
114
+ members(
115
+ path: string,
116
+ timeoutMs = 500
117
+ ): Promise<IntrospectMember[] | undefined> {
118
+ const win = this.iframe.contentWindow
119
+ if (!this.ready || !win) return Promise.resolve(undefined)
120
+
121
+ const id = this.nextId++
122
+ return new Promise((resolve) => {
123
+ const timer = setTimeout(() => {
124
+ this.pending.delete(id)
125
+ resolve(undefined)
126
+ }, timeoutMs)
127
+ this.pending.set(id, (members) => {
128
+ clearTimeout(timer)
129
+ resolve(members)
130
+ })
131
+ win.postMessage({ type: 'tjs-introspect', id, path }, '*')
132
+ })
133
+ }
134
+
135
+ dispose(): void {
136
+ window.removeEventListener('message', this.onMessage)
137
+ this.iframe.remove()
138
+ this.pending.clear()
139
+ }
140
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'bun:test'
2
+
3
+ // buildIframeDoc reads location.origin (browser global) — stub it for headless.
4
+ ;(globalThis as any).location ??= { origin: 'http://test' }
5
+
6
+ import { buildIframeDoc } from './playground-shared'
7
+
8
+ /**
9
+ * Structural guard for the introspection sandbox document. The critical
10
+ * invariant: user code must run at MODULE TOP LEVEL (not inside a try block) so
11
+ * its top-level `const`/`let` are module-scoped and the bridge's direct `eval`
12
+ * can see them. A regression here silently breaks `todoApp.` completion.
13
+ */
14
+ describe('buildIframeDoc — introspection bridge document', () => {
15
+ const doc = buildIframeDoc({
16
+ cssContent: '',
17
+ htmlContent: '',
18
+ importMapScript: '',
19
+ jsCode: 'const todoApp = { items: [] }',
20
+ importStatements: ["import { tosi } from 'https://cdn/tosijs'"],
21
+ introspectionBridge: true,
22
+ })
23
+
24
+ it('runs user code at module top level — NOT inside a try block', () => {
25
+ expect(doc).toContain('const todoApp = { items: [] }')
26
+ // The real invariant: after the bridge is installed, the user code runs with
27
+ // no try-wrap between (a block would re-scope its declarations away from
28
+ // eval). (The injected introspector has its own internal try/catch — that's
29
+ // fine and lives before the ready post.)
30
+ const readyIdx = doc.indexOf('tjs-bridge-ready')
31
+ const codeIdx = doc.indexOf('const todoApp')
32
+ expect(codeIdx).toBeGreaterThan(readyIdx)
33
+ expect(doc.slice(readyIdx, codeIdx)).not.toContain('try {')
34
+ })
35
+
36
+ it('installs the message listener and the injected introspector', () => {
37
+ expect(doc).toContain("d.type !== 'tjs-introspect'")
38
+ expect(doc).toContain('eval(d.path)')
39
+ expect(doc).toContain('function introspectValue') // injected source
40
+ })
41
+
42
+ it('posts bridge-ready and puts imports first', () => {
43
+ expect(doc).toContain('tjs-bridge-ready')
44
+ const importIdx = doc.indexOf('import { tosi }')
45
+ const listenerIdx = doc.indexOf('tjs-introspect')
46
+ const codeIdx = doc.indexOf('const todoApp')
47
+ // imports → listener → user code
48
+ expect(importIdx).toBeGreaterThan(-1)
49
+ expect(importIdx).toBeLessThan(listenerIdx)
50
+ expect(listenerIdx).toBeLessThan(codeIdx)
51
+ })
52
+
53
+ it('does not install the bridge for a normal preview doc', () => {
54
+ const preview = buildIframeDoc({
55
+ cssContent: '',
56
+ htmlContent: '',
57
+ importMapScript: '',
58
+ jsCode: 'console.log(1)',
59
+ })
60
+ expect(preview).not.toContain('tjs-introspect')
61
+ expect(preview).toContain('try {') // normal preview DOES wrap in try
62
+ })
63
+ })
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { CodeMirror } from '../../editors/codemirror/component'
9
+ import { INTROSPECT_VALUE_SOURCE } from '../../editors/introspect-value'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // TJS Runtime for iframe — loaded from a built file, not a hand-maintained stub.
@@ -48,6 +49,32 @@ export interface IframeDocOptions {
48
49
  autoCallTjsFunction?: boolean
49
50
  /** Whether parent is in dark mode — sets color-scheme on iframe */
50
51
  darkMode?: boolean
52
+ /**
53
+ * Install the introspection bridge: after the user's code runs, a message
54
+ * listener answers `{type:'tjs-introspect', id, path}` by evaluating `path` in
55
+ * module scope and returning the value's real members. Used by the hidden
56
+ * autocomplete sandbox (IntrospectionBridge), not the visible preview.
57
+ */
58
+ introspectionBridge?: boolean
59
+ }
60
+
61
+ /**
62
+ * Script (injected at module top-level, after the user's code) that answers
63
+ * member-introspection queries from the parent. `eval(path)` is a DIRECT eval in
64
+ * the module's lexical scope, so it sees the user's top-level `const`/`let`
65
+ * bindings (e.g. `todoApp.items`). Runs only in the hidden sandbox.
66
+ */
67
+ function introspectionBridgeScript(): string {
68
+ return `
69
+ const __introspectValue = ${INTROSPECT_VALUE_SOURCE};
70
+ window.addEventListener('message', (e) => {
71
+ const d = e.data;
72
+ if (!d || d.type !== 'tjs-introspect') return;
73
+ let members = [];
74
+ try { members = __introspectValue(eval(d.path)); } catch (_e) {}
75
+ parent.postMessage({ type: 'tjs-introspect-result', id: d.id, members }, '*');
76
+ });
77
+ parent.postMessage({ type: 'tjs-bridge-ready' }, '*');`
51
78
  }
52
79
 
53
80
  /**
@@ -68,8 +95,34 @@ export function buildIframeDoc(options: IframeDocOptions): string {
68
95
  parentBindings = false,
69
96
  autoCallTjsFunction = false,
70
97
  darkMode = false,
98
+ introspectionBridge = false,
71
99
  } = options
72
100
 
101
+ // Introspection sandbox: run user code at MODULE TOP LEVEL (no try-wrap) so
102
+ // top-level `const`/`let` are module-scoped and the bridge's direct `eval` can
103
+ // see them. The listener is installed first; it's invoked later (at query
104
+ // time), by when the module — and `todoApp` — has fully initialised. Ready is
105
+ // posted up front so globals/imports stay introspectable even if user code
106
+ // throws partway. No try/catch precisely because a block would re-scope the
107
+ // declarations away from `eval`.
108
+ if (introspectionBridge) {
109
+ return `<!DOCTYPE html>
110
+ <html>
111
+ <head>
112
+ <style>:root { color-scheme: ${darkMode ? 'dark' : 'light dark'} }</style>
113
+ ${importMapScript}
114
+ </head>
115
+ <body>
116
+ ${runtimeScriptTag}
117
+ <script type="module">
118
+ ${importStatements.join('\n ')}
119
+ ${introspectionBridgeScript()}
120
+ ${jsCode}
121
+ </script>
122
+ </body>
123
+ </html>`
124
+ }
125
+
73
126
  const colorScheme = darkMode ? 'dark' : 'light dark'
74
127
 
75
128
  const parentBindingsScript = parentBindings
@@ -255,28 +308,36 @@ export function renderConsoleMessages(
255
308
 
256
309
  /**
257
310
  * Render test results HTML with clickable error links and editor gutter markers.
258
- * Returns pass/fail counts so callers can update tab indicators.
311
+ * Returns pass/fail/inconclusive counts so callers can update tab indicators.
259
312
  */
260
313
  export function renderTestResults(
261
314
  tests: any[],
262
315
  outputEl: HTMLElement,
263
316
  editor: CodeMirror,
264
317
  goToLine: (line: number) => void
265
- ): { passed: number; failed: number } {
318
+ ): { passed: number; failed: number; inconclusive: number } {
266
319
  if (!tests || tests.length === 0) {
267
320
  outputEl.textContent = 'No tests defined'
268
321
  editor.clearMarkers()
269
- return { passed: 0, failed: 0 }
322
+ return { passed: 0, failed: 0, inconclusive: 0 }
270
323
  }
271
324
 
272
325
  const passed = tests.filter((t: any) => t.passed).length
273
- const failed = tests.filter((t: any) => !t.passed).length
274
-
275
- // Mark only FAILED tests in the editor. Passing tests appear in the
276
- // results panel and don't need an underline `severity: 'info'` was
277
- // rendering as a grey squiggle on every passing test's line, which is
278
- // visual noise.
279
- const failedWithLines = tests.filter((t: any) => t.line && !t.passed)
326
+ // Inconclusive = the test couldn't *run* (e.g. it calls an AJS atom that
327
+ // doesn't exist at build time). It is neither pass nor fail — surface it as
328
+ // a distinct warning state and keep it out of the failure count, so legal
329
+ // AJS doesn't read as broken in the playground. See PRINCIPLES.md.
330
+ const inconclusive = tests.filter(
331
+ (t: any) => !t.passed && t.inconclusive
332
+ ).length
333
+ const failed = tests.filter((t: any) => !t.passed && !t.inconclusive).length
334
+
335
+ // Mark only genuine FAILURES in the editor. Passing tests don't need an
336
+ // underline (it reads as visual noise), and an inconclusive test isn't a
337
+ // failure — a red squiggle would wrongly imply the code is broken.
338
+ const failedWithLines = tests.filter(
339
+ (t: any) => t.line && !t.passed && !t.inconclusive
340
+ )
280
341
  if (failedWithLines.length > 0) {
281
342
  editor.setMarkers(
282
343
  failedWithLines.map((t: any) => ({
@@ -294,19 +355,29 @@ export function renderTestResults(
294
355
  if (failed > 0) {
295
356
  html += `, <strong class="test-failed">${failed} failed</strong>`
296
357
  }
358
+ if (inconclusive > 0) {
359
+ html += `, <strong class="test-inconclusive">${inconclusive} inconclusive</strong>`
360
+ }
297
361
  html += `</div><ul class="test-list">`
298
362
 
299
363
  for (const test of tests) {
300
- const icon = test.passed ? '✓' : '✗'
301
- const cls = test.passed ? 'test-pass' : 'test-fail'
364
+ const state = test.passed
365
+ ? 'pass'
366
+ : test.inconclusive
367
+ ? 'inconclusive'
368
+ : 'fail'
369
+ const icon = test.passed ? '✓' : test.inconclusive ? '—' : '✗'
302
370
  const sigBadge = test.isSignatureTest
303
371
  ? ' <span class="sig-badge">signature</span>'
304
372
  : ''
305
373
  const dataLine = test.line ? ` data-line="${test.line}"` : ''
306
374
  const clickable = test.line ? ' clickable-test' : ''
307
- html += `<li class="${cls}${clickable}"${dataLine}>${icon} ${test.description}${sigBadge}`
308
- if (!test.passed && test.error) {
309
- html += `<div class="test-error">${test.error}</div>`
375
+ html += `<li class="test-${state}${clickable}"${dataLine}>${icon} ${test.description}${sigBadge}`
376
+ if (test.error) {
377
+ // Inconclusive notes are warnings (couldn't run), not failures.
378
+ const noteCls = test.inconclusive ? 'test-note' : 'test-error'
379
+ const prefix = test.inconclusive ? 'could not run — ' : ''
380
+ html += `<div class="${noteCls}">${prefix}${test.error}</div>`
310
381
  }
311
382
  html += `</li>`
312
383
  }
@@ -327,7 +398,7 @@ export function renderTestResults(
327
398
  })
328
399
  })
329
400
 
330
- return { passed, failed }
401
+ return { passed, failed, inconclusive }
331
402
  }
332
403
 
333
404
  // ---------------------------------------------------------------------------
@@ -589,6 +660,20 @@ export const sharedPlaygroundStyles: Record<string, Record<string, string>> = {
589
660
  fontFamily: 'var(--font-mono, monospace)',
590
661
  },
591
662
 
663
+ ':host .test-inconclusive': {
664
+ color: '#d97706',
665
+ },
666
+
667
+ ':host .test-note': {
668
+ marginLeft: '20px',
669
+ marginTop: '4px',
670
+ padding: '8px',
671
+ background: 'rgba(217, 119, 6, 0.1)',
672
+ borderRadius: '4px',
673
+ fontSize: '13px',
674
+ fontFamily: 'var(--font-mono, monospace)',
675
+ },
676
+
592
677
  ':host .clickable-test': {
593
678
  cursor: 'pointer',
594
679
  },
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
2
+ import { GlobalRegistrator } from '@happy-dom/global-registrator'
3
+
4
+ beforeAll(() => GlobalRegistrator.register())
5
+ afterAll(() => GlobalRegistrator.unregister())
6
+
7
+ // Imported after DOM globals exist.
8
+ const { renderTestResults } = await import('./playground-shared')
9
+
10
+ function fakeEditor() {
11
+ const calls: any[] = []
12
+ return {
13
+ setMarkers: (m: any) => calls.push({ setMarkers: m }),
14
+ clearMarkers: () => calls.push({ clearMarkers: true }),
15
+ _calls: calls,
16
+ } as any
17
+ }
18
+
19
+ describe('renderTestResults — inconclusive affordance', () => {
20
+ it('separates inconclusive from passed and failed', () => {
21
+ const out = document.createElement('div')
22
+ const editor = fakeEditor()
23
+ const counts = renderTestResults(
24
+ [
25
+ { description: 'a', passed: true },
26
+ { description: 'b', passed: false, error: 'Expected 5 got 6' },
27
+ {
28
+ description: 'c signature example',
29
+ passed: false,
30
+ inconclusive: true,
31
+ isSignatureTest: true,
32
+ error: 'httpFetch is not defined',
33
+ },
34
+ ],
35
+ out,
36
+ editor,
37
+ () => {}
38
+ )
39
+
40
+ expect(counts).toEqual({ passed: 1, failed: 1, inconclusive: 1 })
41
+
42
+ // Summary shows all three states
43
+ expect(out.querySelector('.test-summary')?.textContent).toContain(
44
+ '1 passed'
45
+ )
46
+ expect(out.querySelector('.test-summary')?.textContent).toContain(
47
+ '1 failed'
48
+ )
49
+ expect(out.querySelector('.test-summary')?.textContent).toContain(
50
+ '1 inconclusive'
51
+ )
52
+
53
+ // The inconclusive test renders with its own class + a warning note (not
54
+ // a red error), with the "could not run" framing.
55
+ const incLi = out.querySelector('li.test-inconclusive')
56
+ expect(incLi).not.toBeNull()
57
+ expect(incLi?.textContent).toContain('—') // not the ✗ failure icon
58
+ expect(incLi?.querySelector('.test-note')).not.toBeNull()
59
+ expect(incLi?.querySelector('.test-error')).toBeNull()
60
+ expect(incLi?.querySelector('.test-note')?.textContent).toContain(
61
+ 'could not run'
62
+ )
63
+
64
+ // The genuine failure keeps the red error box + ✗ icon.
65
+ const failLi = out.querySelector('li.test-fail')
66
+ expect(failLi?.textContent).toContain('✗')
67
+ expect(failLi?.querySelector('.test-error')).not.toBeNull()
68
+ })
69
+
70
+ it('renders REAL transpiler output: atom-calling agent → inconclusive', async () => {
71
+ // End-to-end: the transpiler emits inconclusive for an un-runnable
72
+ // signature test; the playground must render it as such (not a failure).
73
+ const { tjs } = await import('../../src/lang')
74
+ const { testResults } = tjs(
75
+ `function main(url: ''): { x: '' } {\n const x = httpFetch({ url })\n return { x }\n}`
76
+ )
77
+ const out = document.createElement('div')
78
+ const counts = renderTestResults(
79
+ testResults as any,
80
+ out,
81
+ fakeEditor(),
82
+ () => {}
83
+ )
84
+ expect(counts.failed).toBe(0)
85
+ expect(counts.inconclusive).toBe(1)
86
+ expect(out.querySelector('li.test-inconclusive')).not.toBeNull()
87
+ expect(out.querySelector('li.test-fail')).toBeNull()
88
+ })
89
+
90
+ it('does NOT put an error marker on an inconclusive test line', () => {
91
+ const out = document.createElement('div')
92
+ const editor = fakeEditor()
93
+ renderTestResults(
94
+ [
95
+ {
96
+ description: 'sig',
97
+ passed: false,
98
+ inconclusive: true,
99
+ line: 3,
100
+ error: 'atom not defined',
101
+ },
102
+ ],
103
+ out,
104
+ editor,
105
+ () => {}
106
+ )
107
+ // No setMarkers with an error for the inconclusive line; clearMarkers instead.
108
+ const markerCalls = editor._calls.filter((c: any) => c.setMarkers)
109
+ expect(markerCalls).toHaveLength(0)
110
+ expect(editor._calls.some((c: any) => c.clearMarkers)).toBe(true)
111
+ })
112
+ })