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.
- package/CLAUDE.md +13 -4
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +698 -8
- package/demo/examples.test.ts +6 -2
- package/demo/src/autocomplete.ts +40 -2
- package/demo/src/introspection-bridge.ts +140 -0
- package/demo/src/introspection-doc.test.ts +63 -0
- package/demo/src/playground-shared.ts +101 -16
- package/demo/src/playground-test-results.test.ts +112 -0
- package/demo/src/tjs-playground.ts +32 -5
- package/dist/examples/modules/dist/main.d.ts +34 -0
- package/dist/examples/modules/dist/math.d.ts +120 -0
- package/dist/index.js +115 -112
- package/dist/index.js.map +4 -4
- package/dist/src/lang/core.d.ts +1 -1
- package/dist/src/lang/dialect.d.ts +35 -0
- package/dist/src/lang/emitters/ast.d.ts +1 -1
- package/dist/src/lang/emitters/js-tests.d.ts +7 -0
- package/dist/src/lang/emitters/js.d.ts +12 -0
- package/dist/src/lang/index.d.ts +2 -1
- package/dist/src/lang/parser-types.d.ts +17 -0
- package/dist/src/lang/parser.d.ts +18 -0
- package/dist/src/lang/transpiler.d.ts +1 -0
- package/dist/src/lang/types.d.ts +18 -0
- package/dist/src/vm/runtime.d.ts +18 -0
- package/dist/src/vm/vm.d.ts +15 -1
- package/dist/tjs-batteries.js +2 -2
- package/dist/tjs-batteries.js.map +2 -2
- package/dist/tjs-eval.js +43 -43
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +76 -73
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +49 -49
- package/dist/tjs-vm.js.map +3 -3
- package/editors/codemirror/ajs-language.ts +130 -44
- package/editors/codemirror/completion-source.test.ts +114 -0
- package/editors/introspect-value.test.ts +61 -0
- package/editors/introspect-value.ts +86 -0
- package/editors/scope-symbols.test.ts +113 -0
- package/editors/scope-symbols.ts +173 -0
- package/llms.txt +1 -0
- package/package.json +21 -1
- package/src/batteries/audit.ts +3 -2
- package/src/cli/commands/check.ts +3 -2
- package/src/cli/commands/emit.ts +4 -2
- package/src/cli/commands/run.ts +6 -2
- package/src/cli/commands/types.ts +2 -2
- package/src/lang/codegen.test.ts +4 -1
- package/src/lang/core.ts +6 -4
- package/src/lang/dialect.test.ts +63 -0
- package/src/lang/dialect.ts +50 -0
- package/src/lang/emitters/ast.ts +145 -2
- package/src/lang/emitters/js-tests.ts +46 -37
- package/src/lang/emitters/js.ts +19 -2
- package/src/lang/features.test.ts +6 -5
- package/src/lang/index.ts +40 -5
- package/src/lang/parser-types.ts +17 -0
- package/src/lang/parser.test.ts +12 -6
- package/src/lang/parser.ts +113 -3
- package/src/lang/predicate-schema.test.ts +97 -0
- package/src/lang/predicate-schema.ts +168 -0
- package/src/lang/predicate.test.ts +184 -0
- package/src/lang/predicate.ts +550 -0
- package/src/lang/subset-invariant.test.ts +90 -0
- package/src/lang/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +34 -0
- package/src/lang/types.ts +18 -0
- package/src/lang/wasm.test.ts +8 -2
- package/src/linalg/linalg.test.ts +8 -2
- package/src/use-cases/batteries.test.ts +4 -0
- package/src/use-cases/local-helpers.test.ts +219 -0
- package/src/use-cases/timeout-overrides.test.ts +169 -0
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +29 -3
- package/src/vm/runtime.ts +140 -4
- package/src/vm/vm.ts +47 -9
package/demo/examples.test.ts
CHANGED
|
@@ -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
|
-
},
|
|
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 () => {
|
package/demo/src/autocomplete.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
301
|
-
|
|
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="
|
|
308
|
-
if (
|
|
309
|
-
|
|
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
|
+
})
|