@pyreon/compiler 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +225 -11
- package/lib/types/index.d.ts +13 -1
- package/package.json +10 -2
- package/src/event-names.ts +65 -0
- package/src/jsx.ts +140 -7
- package/src/pyreon-intercept.ts +226 -2
- package/src/tests/detector-tag-consistency.test.ts +3 -0
- package/src/tests/jsx.test.ts +236 -4
- package/src/tests/native-equivalence.test.ts +77 -0
- package/src/tests/pyreon-intercept.test.ts +155 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
import { For, h, Show } from '@pyreon/core'
|
|
4
|
+
import { signal } from '@pyreon/reactivity'
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiler-runtime tests — control-flow primitives.
|
|
10
|
+
*
|
|
11
|
+
* These tests verify `<For>` and `<Show>` integrate correctly with the
|
|
12
|
+
* Pyreon mount path. They use direct `h()` calls instead of JSX because
|
|
13
|
+
* the harness's `compileAndMount` runs only the template-optimization
|
|
14
|
+
* pass of `@pyreon/compiler` — the bundler-level JSX → `h()` transform
|
|
15
|
+
* (normally done by Vite's esbuild) does NOT run in the harness, so JSX
|
|
16
|
+
* containing components like `<For>` would be left raw and unparseable.
|
|
17
|
+
*
|
|
18
|
+
* `<Match>`, `<Suspense>`, `<ErrorBoundary>` are deferred to Phase C1
|
|
19
|
+
* because they need real Chromium for the async / boundary shapes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
describe('compiler-runtime — control flow (h() form)', () => {
|
|
23
|
+
it('<For> renders each item and reacts to signal updates', async () => {
|
|
24
|
+
const items = signal([
|
|
25
|
+
{ id: 1, name: 'a' },
|
|
26
|
+
{ id: 2, name: 'b' },
|
|
27
|
+
])
|
|
28
|
+
const { container, unmount } = mountInBrowser(
|
|
29
|
+
h(
|
|
30
|
+
'div',
|
|
31
|
+
{ id: 'root' },
|
|
32
|
+
h(For, {
|
|
33
|
+
each: items,
|
|
34
|
+
by: (i: { id: number; name: string }) => i.id,
|
|
35
|
+
children: (i: { name: string }) => h('span', null, i.name),
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
const root = container.querySelector('#root')!
|
|
40
|
+
expect(root.querySelectorAll('span').length).toBe(2)
|
|
41
|
+
expect(root.textContent).toBe('ab')
|
|
42
|
+
items.set([
|
|
43
|
+
{ id: 1, name: 'a' },
|
|
44
|
+
{ id: 2, name: 'b' },
|
|
45
|
+
{ id: 3, name: 'c' },
|
|
46
|
+
])
|
|
47
|
+
await flush()
|
|
48
|
+
expect(root.querySelectorAll('span').length).toBe(3)
|
|
49
|
+
expect(root.textContent).toBe('abc')
|
|
50
|
+
unmount()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('<For> handles removal correctly', async () => {
|
|
54
|
+
const items = signal([
|
|
55
|
+
{ id: 1, name: 'a' },
|
|
56
|
+
{ id: 2, name: 'b' },
|
|
57
|
+
{ id: 3, name: 'c' },
|
|
58
|
+
])
|
|
59
|
+
const { container, unmount } = mountInBrowser(
|
|
60
|
+
h(
|
|
61
|
+
'div',
|
|
62
|
+
{ id: 'root' },
|
|
63
|
+
h(For, {
|
|
64
|
+
each: items,
|
|
65
|
+
by: (i: { id: number; name: string }) => i.id,
|
|
66
|
+
children: (i: { name: string }) => h('span', null, i.name),
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
const root = container.querySelector('#root')!
|
|
71
|
+
expect(root.querySelectorAll('span').length).toBe(3)
|
|
72
|
+
items.set([{ id: 2, name: 'b' }])
|
|
73
|
+
await flush()
|
|
74
|
+
expect(root.querySelectorAll('span').length).toBe(1)
|
|
75
|
+
expect(root.textContent).toBe('b')
|
|
76
|
+
unmount()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('<Show> conditionally renders based on signal', async () => {
|
|
80
|
+
const visible = signal(true)
|
|
81
|
+
const { container, unmount } = mountInBrowser(
|
|
82
|
+
h(
|
|
83
|
+
'div',
|
|
84
|
+
{ id: 'root' },
|
|
85
|
+
h(Show, { when: () => visible(), children: h('span', { id: 'x' }, 'visible') }),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
const root = container.querySelector('#root')!
|
|
89
|
+
expect(root.querySelector('#x')).not.toBeNull()
|
|
90
|
+
visible.set(false)
|
|
91
|
+
await flush()
|
|
92
|
+
expect(root.querySelector('#x')).toBeNull()
|
|
93
|
+
visible.set(true)
|
|
94
|
+
await flush()
|
|
95
|
+
expect(root.querySelector('#x')).not.toBeNull()
|
|
96
|
+
unmount()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('<Show> with fallback renders fallback when condition is false', async () => {
|
|
100
|
+
const flag = signal(false)
|
|
101
|
+
const { container, unmount } = mountInBrowser(
|
|
102
|
+
h(
|
|
103
|
+
'div',
|
|
104
|
+
{ id: 'root' },
|
|
105
|
+
h(Show, {
|
|
106
|
+
when: () => flag(),
|
|
107
|
+
fallback: h('span', { id: 'fb' }, 'fallback'),
|
|
108
|
+
children: h('span', { id: 'x' }, 'visible'),
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
const root = container.querySelector('#root')!
|
|
113
|
+
expect(root.querySelector('#fb')).not.toBeNull()
|
|
114
|
+
expect(root.querySelector('#x')).toBeNull()
|
|
115
|
+
flag.set(true)
|
|
116
|
+
await flush()
|
|
117
|
+
expect(root.querySelector('#fb')).toBeNull()
|
|
118
|
+
expect(root.querySelector('#x')).not.toBeNull()
|
|
119
|
+
unmount()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('<Show> with value prop (not accessor) accepts boolean', () => {
|
|
123
|
+
// Per #352's `<Show>` defensive normalization fix — `when` accepts
|
|
124
|
+
// both `() => boolean` accessor AND raw boolean (for static cases +
|
|
125
|
+
// signal auto-call edge case).
|
|
126
|
+
const { container, unmount } = mountInBrowser(
|
|
127
|
+
h(
|
|
128
|
+
'div',
|
|
129
|
+
{ id: 'root' },
|
|
130
|
+
h(Show, { when: true, children: h('span', { id: 'x' }, 'on') }),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
expect(container.querySelector('#x')).not.toBeNull()
|
|
134
|
+
unmount()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('nested control flow: <Show> inside <For>', async () => {
|
|
138
|
+
const items = signal([
|
|
139
|
+
{ id: 1, name: 'a', visible: true },
|
|
140
|
+
{ id: 2, name: 'b', visible: false },
|
|
141
|
+
{ id: 3, name: 'c', visible: true },
|
|
142
|
+
])
|
|
143
|
+
const { container, unmount } = mountInBrowser(
|
|
144
|
+
h(
|
|
145
|
+
'div',
|
|
146
|
+
{ id: 'root' },
|
|
147
|
+
h(For, {
|
|
148
|
+
each: items,
|
|
149
|
+
by: (i: { id: number }) => i.id,
|
|
150
|
+
children: (i: { name: string; visible: boolean }) =>
|
|
151
|
+
h(Show, { when: () => i.visible, children: h('span', null, i.name) }),
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
const root = container.querySelector('#root')!
|
|
156
|
+
expect(root.textContent).toBe('ac')
|
|
157
|
+
unmount()
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
import { signal } from '@pyreon/reactivity'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { flush } from '@pyreon/test-utils/browser'
|
|
6
|
+
import { compileAndMount } from './harness'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiler-runtime tests — DOM-property assignment.
|
|
10
|
+
*
|
|
11
|
+
* The #352 DOM-property bug used `setAttribute("value", v)` instead of
|
|
12
|
+
* `el.value = v` for IDL properties whose live value diverges from the
|
|
13
|
+
* content attribute. The fix added a `DOM_PROPS` set covering: value,
|
|
14
|
+
* checked, selected, disabled, multiple, readOnly, indeterminate. This
|
|
15
|
+
* file pins down each property + asserts the compiled output uses
|
|
16
|
+
* property assignment so the live state reflects updates correctly.
|
|
17
|
+
*
|
|
18
|
+
* Note: happy-dom's `.value` getter follows the attribute even in
|
|
19
|
+
* static cases, so for `value` specifically the assertion verifies
|
|
20
|
+
* the post-update read works (which would also work via setAttribute
|
|
21
|
+
* in happy-dom — the real differentiator is in real Chromium after a
|
|
22
|
+
* user types). For `checked` / `disabled` / etc., happy-dom DOES
|
|
23
|
+
* differentiate property vs attribute, so those assertions are robust.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
describe('compiler-runtime — DOM properties', () => {
|
|
27
|
+
it('value property reflects signal updates via .value', async () => {
|
|
28
|
+
const text = signal('initial')
|
|
29
|
+
const { container, unmount } = compileAndMount(
|
|
30
|
+
`<div><input id="i" value={() => text()} /></div>`,
|
|
31
|
+
{ text },
|
|
32
|
+
)
|
|
33
|
+
const input = container.querySelector<HTMLInputElement>('#i')!
|
|
34
|
+
expect(input.value).toBe('initial')
|
|
35
|
+
text.set('updated')
|
|
36
|
+
await flush()
|
|
37
|
+
expect(input.value).toBe('updated')
|
|
38
|
+
text.set('')
|
|
39
|
+
await flush()
|
|
40
|
+
expect(input.value).toBe('')
|
|
41
|
+
unmount()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('checked property reflects via .checked (not boolean attribute)', async () => {
|
|
45
|
+
const isOn = signal(true)
|
|
46
|
+
const { container, unmount } = compileAndMount(
|
|
47
|
+
`<div><input id="c" type="checkbox" checked={() => isOn()} /></div>`,
|
|
48
|
+
{ isOn },
|
|
49
|
+
)
|
|
50
|
+
const cb = container.querySelector<HTMLInputElement>('#c')!
|
|
51
|
+
expect(cb.checked).toBe(true)
|
|
52
|
+
isOn.set(false)
|
|
53
|
+
await flush()
|
|
54
|
+
expect(cb.checked).toBe(false)
|
|
55
|
+
isOn.set(true)
|
|
56
|
+
await flush()
|
|
57
|
+
expect(cb.checked).toBe(true)
|
|
58
|
+
unmount()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('disabled property reflects via .disabled', async () => {
|
|
62
|
+
const off = signal(false)
|
|
63
|
+
const { container, unmount } = compileAndMount(
|
|
64
|
+
`<div><button id="b" disabled={() => off()}>x</button></div>`,
|
|
65
|
+
{ off },
|
|
66
|
+
)
|
|
67
|
+
const btn = container.querySelector<HTMLButtonElement>('#b')!
|
|
68
|
+
expect(btn.disabled).toBe(false)
|
|
69
|
+
off.set(true)
|
|
70
|
+
await flush()
|
|
71
|
+
expect(btn.disabled).toBe(true)
|
|
72
|
+
off.set(false)
|
|
73
|
+
await flush()
|
|
74
|
+
expect(btn.disabled).toBe(false)
|
|
75
|
+
unmount()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('selected on <option> reflects via .selected', async () => {
|
|
79
|
+
// Need a sibling option so the browser's "at least one option must
|
|
80
|
+
// be selected" auto-selection doesn't pick our option after we
|
|
81
|
+
// unselect it.
|
|
82
|
+
const sel = signal(false)
|
|
83
|
+
const { container, unmount } = compileAndMount(
|
|
84
|
+
`<div><select><option>first</option><option id="o" selected={() => sel()}>a</option></select></div>`,
|
|
85
|
+
{ sel },
|
|
86
|
+
)
|
|
87
|
+
const opt = container.querySelector<HTMLOptionElement>('#o')!
|
|
88
|
+
expect(opt.selected).toBe(false)
|
|
89
|
+
sel.set(true)
|
|
90
|
+
await flush()
|
|
91
|
+
expect(opt.selected).toBe(true)
|
|
92
|
+
unmount()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('multiple on <select> reflects via .multiple', async () => {
|
|
96
|
+
const multi = signal(true)
|
|
97
|
+
const { container, unmount } = compileAndMount(
|
|
98
|
+
`<div><select id="s" multiple={() => multi()}><option>a</option></select></div>`,
|
|
99
|
+
{ multi },
|
|
100
|
+
)
|
|
101
|
+
const sel = container.querySelector<HTMLSelectElement>('#s')!
|
|
102
|
+
expect(sel.multiple).toBe(true)
|
|
103
|
+
multi.set(false)
|
|
104
|
+
await flush()
|
|
105
|
+
expect(sel.multiple).toBe(false)
|
|
106
|
+
unmount()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('readOnly on <input> reflects via .readOnly', async () => {
|
|
110
|
+
const ro = signal(false)
|
|
111
|
+
const { container, unmount } = compileAndMount(
|
|
112
|
+
`<div><input id="i" readOnly={() => ro()} /></div>`,
|
|
113
|
+
{ ro },
|
|
114
|
+
)
|
|
115
|
+
const input = container.querySelector<HTMLInputElement>('#i')!
|
|
116
|
+
expect(input.readOnly).toBe(false)
|
|
117
|
+
ro.set(true)
|
|
118
|
+
await flush()
|
|
119
|
+
expect(input.readOnly).toBe(true)
|
|
120
|
+
unmount()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('non-DOM-prop attributes still go through setAttribute', async () => {
|
|
124
|
+
// `placeholder` is a real HTML attribute, not a DOM IDL property
|
|
125
|
+
// that diverges. Should still flow through setAttribute (not break).
|
|
126
|
+
const placeholder = signal('type here')
|
|
127
|
+
const { container, unmount } = compileAndMount(
|
|
128
|
+
`<div><input id="i" placeholder={() => placeholder()} /></div>`,
|
|
129
|
+
{ placeholder },
|
|
130
|
+
)
|
|
131
|
+
const input = container.querySelector<HTMLInputElement>('#i')!
|
|
132
|
+
expect(input.getAttribute('placeholder')).toBe('type here')
|
|
133
|
+
placeholder.set('changed')
|
|
134
|
+
await flush()
|
|
135
|
+
expect(input.getAttribute('placeholder')).toBe('changed')
|
|
136
|
+
unmount()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
import { signal } from '@pyreon/reactivity'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { compileAndMount } from './harness'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compiler-runtime tests — event handler emission.
|
|
9
|
+
*
|
|
10
|
+
* Coverage matrix: delegated (single-word common events) vs non-delegated
|
|
11
|
+
* (multi-word + uncommon) × static handler ref vs inline arrow × event
|
|
12
|
+
* dispatch hits the handler. The #352 event-name casing bug surfaced
|
|
13
|
+
* because non-delegated events used the wrong casing; this file locks in
|
|
14
|
+
* each shape independently.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe('compiler-runtime — events', () => {
|
|
18
|
+
// ── Delegated events (single-word, common) ──────────────────────────
|
|
19
|
+
it('onClick fires on bubbled click', () => {
|
|
20
|
+
let fired = 0
|
|
21
|
+
const handler = () => {
|
|
22
|
+
fired++
|
|
23
|
+
}
|
|
24
|
+
const { container, unmount } = compileAndMount(
|
|
25
|
+
`<div><button id="b" onClick={handler}>x</button></div>`,
|
|
26
|
+
{ handler },
|
|
27
|
+
)
|
|
28
|
+
container.querySelector<HTMLButtonElement>('#b')!.click()
|
|
29
|
+
expect(fired).toBe(1)
|
|
30
|
+
unmount()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('onClick with inline arrow handler fires', () => {
|
|
34
|
+
const count = signal(0)
|
|
35
|
+
const { container, unmount } = compileAndMount(
|
|
36
|
+
`<div><button id="b" onClick={() => count.set(count() + 1)}>x</button></div>`,
|
|
37
|
+
{ count },
|
|
38
|
+
)
|
|
39
|
+
const btn = container.querySelector<HTMLButtonElement>('#b')!
|
|
40
|
+
btn.click()
|
|
41
|
+
btn.click()
|
|
42
|
+
expect(count()).toBe(2)
|
|
43
|
+
unmount()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('onInput fires on real input event', () => {
|
|
47
|
+
let value = ''
|
|
48
|
+
const handler = (e: Event) => {
|
|
49
|
+
value = (e.target as HTMLInputElement).value
|
|
50
|
+
}
|
|
51
|
+
const { container, unmount } = compileAndMount(
|
|
52
|
+
`<div><input id="i" onInput={handler} /></div>`,
|
|
53
|
+
{ handler },
|
|
54
|
+
)
|
|
55
|
+
const input = container.querySelector<HTMLInputElement>('#i')!
|
|
56
|
+
input.value = 'hello'
|
|
57
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
58
|
+
expect(value).toBe('hello')
|
|
59
|
+
unmount()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('onChange fires on real change event', () => {
|
|
63
|
+
let changed = 0
|
|
64
|
+
const handler = () => {
|
|
65
|
+
changed++
|
|
66
|
+
}
|
|
67
|
+
const { container, unmount } = compileAndMount(
|
|
68
|
+
`<div><input id="i" onChange={handler} /></div>`,
|
|
69
|
+
{ handler },
|
|
70
|
+
)
|
|
71
|
+
container
|
|
72
|
+
.querySelector<HTMLInputElement>('#i')!
|
|
73
|
+
.dispatchEvent(new Event('change', { bubbles: true }))
|
|
74
|
+
expect(changed).toBe(1)
|
|
75
|
+
unmount()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('onSubmit fires on form submit', () => {
|
|
79
|
+
let submitted = 0
|
|
80
|
+
const handler = (e: Event) => {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
submitted++
|
|
83
|
+
}
|
|
84
|
+
const { container, unmount } = compileAndMount(
|
|
85
|
+
`<div><form id="f" onSubmit={handler}><button type="submit">go</button></form></div>`,
|
|
86
|
+
{ handler },
|
|
87
|
+
)
|
|
88
|
+
const form = container.querySelector<HTMLFormElement>('#f')!
|
|
89
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
90
|
+
expect(submitted).toBe(1)
|
|
91
|
+
unmount()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('onFocus fires on focus event', () => {
|
|
95
|
+
let focused = 0
|
|
96
|
+
const handler = () => {
|
|
97
|
+
focused++
|
|
98
|
+
}
|
|
99
|
+
const { container, unmount } = compileAndMount(
|
|
100
|
+
`<div><input id="i" onFocus={handler} /></div>`,
|
|
101
|
+
{ handler },
|
|
102
|
+
)
|
|
103
|
+
container
|
|
104
|
+
.querySelector<HTMLInputElement>('#i')!
|
|
105
|
+
.dispatchEvent(new Event('focus', { bubbles: true }))
|
|
106
|
+
expect(focused).toBe(1)
|
|
107
|
+
unmount()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('onBlur fires on blur event', () => {
|
|
111
|
+
let blurred = 0
|
|
112
|
+
const handler = () => {
|
|
113
|
+
blurred++
|
|
114
|
+
}
|
|
115
|
+
const { container, unmount } = compileAndMount(
|
|
116
|
+
`<div><input id="i" onBlur={handler} /></div>`,
|
|
117
|
+
{ handler },
|
|
118
|
+
)
|
|
119
|
+
container
|
|
120
|
+
.querySelector<HTMLInputElement>('#i')!
|
|
121
|
+
.dispatchEvent(new Event('blur', { bubbles: true }))
|
|
122
|
+
expect(blurred).toBe(1)
|
|
123
|
+
unmount()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ── Non-delegated events (multi-word — were broken pre-#352) ────────
|
|
127
|
+
it('onKeyDown fires (multi-word event-name lowercase regression)', () => {
|
|
128
|
+
let key = ''
|
|
129
|
+
const handler = (e: KeyboardEvent) => {
|
|
130
|
+
key = e.key
|
|
131
|
+
}
|
|
132
|
+
const { container, unmount } = compileAndMount(
|
|
133
|
+
`<div><input id="i" onKeyDown={handler} /></div>`,
|
|
134
|
+
{ handler },
|
|
135
|
+
)
|
|
136
|
+
container
|
|
137
|
+
.querySelector<HTMLInputElement>('#i')!
|
|
138
|
+
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
|
|
139
|
+
expect(key).toBe('Tab')
|
|
140
|
+
unmount()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('onKeyUp fires (multi-word event-name lowercase regression)', () => {
|
|
144
|
+
let key = ''
|
|
145
|
+
const handler = (e: KeyboardEvent) => {
|
|
146
|
+
key = e.key
|
|
147
|
+
}
|
|
148
|
+
const { container, unmount } = compileAndMount(
|
|
149
|
+
`<div><input id="i" onKeyUp={handler} /></div>`,
|
|
150
|
+
{ handler },
|
|
151
|
+
)
|
|
152
|
+
container
|
|
153
|
+
.querySelector<HTMLInputElement>('#i')!
|
|
154
|
+
.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }))
|
|
155
|
+
expect(key).toBe('Enter')
|
|
156
|
+
unmount()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('onMouseEnter fires (non-bubbling event)', () => {
|
|
160
|
+
let entered = 0
|
|
161
|
+
const handler = () => {
|
|
162
|
+
entered++
|
|
163
|
+
}
|
|
164
|
+
const { container, unmount } = compileAndMount(
|
|
165
|
+
`<div><span id="s" onMouseEnter={handler}>hover</span></div>`,
|
|
166
|
+
{ handler },
|
|
167
|
+
)
|
|
168
|
+
container
|
|
169
|
+
.querySelector<HTMLSpanElement>('#s')!
|
|
170
|
+
.dispatchEvent(new MouseEvent('mouseenter'))
|
|
171
|
+
expect(entered).toBe(1)
|
|
172
|
+
unmount()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('onPointerLeave fires (non-bubbling event)', () => {
|
|
176
|
+
let left = 0
|
|
177
|
+
const handler = () => {
|
|
178
|
+
left++
|
|
179
|
+
}
|
|
180
|
+
const { container, unmount } = compileAndMount(
|
|
181
|
+
`<div><span id="s" onPointerLeave={handler}>hover</span></div>`,
|
|
182
|
+
{ handler },
|
|
183
|
+
)
|
|
184
|
+
container
|
|
185
|
+
.querySelector<HTMLSpanElement>('#s')!
|
|
186
|
+
.dispatchEvent(new PointerEvent('pointerleave'))
|
|
187
|
+
expect(left).toBe(1)
|
|
188
|
+
unmount()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// Locks in the React→DOM event-name mapping for `onDoubleClick` →
|
|
192
|
+
// `dblclick`. The mapping lives in BOTH compiler backends:
|
|
193
|
+
// - JS fallback: packages/core/compiler/src/jsx.ts (doubleclick → dblclick)
|
|
194
|
+
// - Rust native: packages/core/compiler/native/src/lib.rs (same shape)
|
|
195
|
+
// `onContextMenu` lowercases correctly (contextmenu) — no remap needed.
|
|
196
|
+
it('onDoubleClick fires (multi-word + delegated)', () => {
|
|
197
|
+
let dbl = 0
|
|
198
|
+
const handler = () => {
|
|
199
|
+
dbl++
|
|
200
|
+
}
|
|
201
|
+
const { container, unmount } = compileAndMount(
|
|
202
|
+
`<div><button id="b" onDoubleClick={handler}>x</button></div>`,
|
|
203
|
+
{ handler },
|
|
204
|
+
)
|
|
205
|
+
container
|
|
206
|
+
.querySelector<HTMLButtonElement>('#b')!
|
|
207
|
+
.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))
|
|
208
|
+
expect(dbl).toBe(1)
|
|
209
|
+
unmount()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('onContextMenu fires (multi-word, lowercases to contextmenu)', () => {
|
|
213
|
+
let menu = 0
|
|
214
|
+
const handler = () => {
|
|
215
|
+
menu++
|
|
216
|
+
}
|
|
217
|
+
const { container, unmount } = compileAndMount(
|
|
218
|
+
`<div><button id="b" onContextMenu={handler}>x</button></div>`,
|
|
219
|
+
{ handler },
|
|
220
|
+
)
|
|
221
|
+
container
|
|
222
|
+
.querySelector<HTMLButtonElement>('#b')!
|
|
223
|
+
.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))
|
|
224
|
+
expect(menu).toBe(1)
|
|
225
|
+
unmount()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Audit: every multi-word React event-prop in the official component-prop
|
|
229
|
+
// list either (a) has an entry in REACT_EVENT_REMAP, OR (b) lowercases
|
|
230
|
+
// correctly to the DOM event name. This sweep exercises the most common
|
|
231
|
+
// multi-word events from across the family categories (mouse, pointer,
|
|
232
|
+
// keyboard, drag, touch, animation, transition, media, composition, form)
|
|
233
|
+
// to lock in that lowercasing alone is the right rule for all of them.
|
|
234
|
+
// If a new React event-prop is added in a future release with a non-trivial
|
|
235
|
+
// mismatch, this sweep won't catch it — but the runtime test for the
|
|
236
|
+
// specific event will. Keep this sweep as the structural audit.
|
|
237
|
+
const lowercaseSweep: ReadonlyArray<{ prop: string; event: string }> = [
|
|
238
|
+
{ prop: 'onKeyDown', event: 'keydown' },
|
|
239
|
+
{ prop: 'onKeyUp', event: 'keyup' },
|
|
240
|
+
{ prop: 'onMouseDown', event: 'mousedown' },
|
|
241
|
+
{ prop: 'onMouseUp', event: 'mouseup' },
|
|
242
|
+
{ prop: 'onMouseEnter', event: 'mouseenter' },
|
|
243
|
+
{ prop: 'onMouseLeave', event: 'mouseleave' },
|
|
244
|
+
{ prop: 'onMouseMove', event: 'mousemove' },
|
|
245
|
+
{ prop: 'onMouseOut', event: 'mouseout' },
|
|
246
|
+
{ prop: 'onMouseOver', event: 'mouseover' },
|
|
247
|
+
{ prop: 'onPointerDown', event: 'pointerdown' },
|
|
248
|
+
{ prop: 'onPointerMove', event: 'pointermove' },
|
|
249
|
+
{ prop: 'onPointerUp', event: 'pointerup' },
|
|
250
|
+
{ prop: 'onPointerCancel', event: 'pointercancel' },
|
|
251
|
+
{ prop: 'onPointerEnter', event: 'pointerenter' },
|
|
252
|
+
{ prop: 'onPointerLeave', event: 'pointerleave' },
|
|
253
|
+
{ prop: 'onPointerOver', event: 'pointerover' },
|
|
254
|
+
{ prop: 'onPointerOut', event: 'pointerout' },
|
|
255
|
+
{ prop: 'onGotPointerCapture', event: 'gotpointercapture' },
|
|
256
|
+
{ prop: 'onLostPointerCapture', event: 'lostpointercapture' },
|
|
257
|
+
{ prop: 'onDragStart', event: 'dragstart' },
|
|
258
|
+
{ prop: 'onDragEnd', event: 'dragend' },
|
|
259
|
+
{ prop: 'onDragEnter', event: 'dragenter' },
|
|
260
|
+
{ prop: 'onDragLeave', event: 'dragleave' },
|
|
261
|
+
{ prop: 'onDragOver', event: 'dragover' },
|
|
262
|
+
{ prop: 'onTouchStart', event: 'touchstart' },
|
|
263
|
+
{ prop: 'onTouchEnd', event: 'touchend' },
|
|
264
|
+
{ prop: 'onTouchMove', event: 'touchmove' },
|
|
265
|
+
{ prop: 'onTouchCancel', event: 'touchcancel' },
|
|
266
|
+
{ prop: 'onAnimationStart', event: 'animationstart' },
|
|
267
|
+
{ prop: 'onAnimationEnd', event: 'animationend' },
|
|
268
|
+
{ prop: 'onAnimationIteration', event: 'animationiteration' },
|
|
269
|
+
{ prop: 'onTransitionEnd', event: 'transitionend' },
|
|
270
|
+
{ prop: 'onCompositionStart', event: 'compositionstart' },
|
|
271
|
+
{ prop: 'onCompositionEnd', event: 'compositionend' },
|
|
272
|
+
{ prop: 'onCompositionUpdate', event: 'compositionupdate' },
|
|
273
|
+
{ prop: 'onContextMenu', event: 'contextmenu' },
|
|
274
|
+
{ prop: 'onBeforeInput', event: 'beforeinput' },
|
|
275
|
+
{ prop: 'onTimeUpdate', event: 'timeupdate' },
|
|
276
|
+
{ prop: 'onVolumeChange', event: 'volumechange' },
|
|
277
|
+
{ prop: 'onCanPlayThrough', event: 'canplaythrough' },
|
|
278
|
+
{ prop: 'onLoadedData', event: 'loadeddata' },
|
|
279
|
+
{ prop: 'onLoadedMetadata', event: 'loadedmetadata' },
|
|
280
|
+
{ prop: 'onLoadStart', event: 'loadstart' },
|
|
281
|
+
{ prop: 'onRateChange', event: 'ratechange' },
|
|
282
|
+
{ prop: 'onDurationChange', event: 'durationchange' },
|
|
283
|
+
]
|
|
284
|
+
for (const { prop, event } of lowercaseSweep) {
|
|
285
|
+
it(`${prop} → ${event} (lowercase, no remap)`, () => {
|
|
286
|
+
let fired = 0
|
|
287
|
+
const handler = () => {
|
|
288
|
+
fired++
|
|
289
|
+
}
|
|
290
|
+
const { container, unmount } = compileAndMount(
|
|
291
|
+
`<div><button id="b" ${prop}={handler}>x</button></div>`,
|
|
292
|
+
{ handler },
|
|
293
|
+
)
|
|
294
|
+
container
|
|
295
|
+
.querySelector<HTMLButtonElement>('#b')!
|
|
296
|
+
.dispatchEvent(new Event(event, { bubbles: true }))
|
|
297
|
+
expect(fired).toBe(1)
|
|
298
|
+
unmount()
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import * as reactivity from '@pyreon/reactivity'
|
|
3
|
+
import * as runtimeDom from '@pyreon/runtime-dom'
|
|
4
|
+
import { mountInBrowser } from '@pyreon/test-utils/browser'
|
|
5
|
+
import type { MountInBrowserResult } from '@pyreon/test-utils/browser'
|
|
6
|
+
import { transformJSX } from '../../jsx'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiler-runtime test harness.
|
|
10
|
+
*
|
|
11
|
+
* Bridges the gap between the compiler's unit tests (which assert on the
|
|
12
|
+
* generated source string) and the e2e tests (which exercise full apps).
|
|
13
|
+
* The unit tests in `jsx.test.ts` proved each of PR #352's 4 silent
|
|
14
|
+
* compiler bugs produced syntactically valid output; nothing in the unit
|
|
15
|
+
* suite caught that the output was *behaviorally* wrong. This layer
|
|
16
|
+
* compiles a JSX snippet through the real compiler, drops it into a real
|
|
17
|
+
* Chromium DOM, and asserts observable behavior — events fire, signals
|
|
18
|
+
* propagate, props reflect on the right DOM channel.
|
|
19
|
+
*
|
|
20
|
+
* ## How it works
|
|
21
|
+
*
|
|
22
|
+
* 1. Wrap the snippet as `function App(props) { return ${jsxExpr} }`
|
|
23
|
+
* so the compiler emits a `function` declaration with a return.
|
|
24
|
+
* 2. `transformJSX(source)` produces JS that imports specific helpers
|
|
25
|
+
* (`_tpl`, `_bind`, `_bindDirect`, `_bindText`, etc.) from
|
|
26
|
+
* `@pyreon/runtime-dom`.
|
|
27
|
+
* 3. Strip the import statement and the `export` keyword.
|
|
28
|
+
* 4. Build a `new Function(...args, code)` whose parameter list is the
|
|
29
|
+
* union of every runtime-dom export plus the keys of the test's
|
|
30
|
+
* `context` object (signals, event handlers). The compiled code uses
|
|
31
|
+
* those names directly and `new Function` resolves them via the
|
|
32
|
+
* closure-like parameter binding.
|
|
33
|
+
* 5. Invoke the factory to get the `App` component back, then render
|
|
34
|
+
* via `h(App)` + `mountInBrowser`.
|
|
35
|
+
*
|
|
36
|
+
* ## Why not Vite / esbuild bundling
|
|
37
|
+
*
|
|
38
|
+
* The previous bundle-level treeshake tests (in `flow`, `runtime-dom`,
|
|
39
|
+
* `styler`) use `vite.build()` — that's the right tool for asserting
|
|
40
|
+
* tree-shaking but each invocation costs 100-500ms. This harness needs
|
|
41
|
+
* to scale to ~50 tests in Phase B2; the `new Function` path runs in
|
|
42
|
+
* single-digit ms per test.
|
|
43
|
+
*
|
|
44
|
+
* ## Caveats
|
|
45
|
+
*
|
|
46
|
+
* - Snippets must be self-contained JSX expressions (no external imports).
|
|
47
|
+
* - All non-runtime-dom symbols (signals, handlers, components) must be
|
|
48
|
+
* passed in via the `context` parameter.
|
|
49
|
+
* - The compiler is invoked in JS-fallback mode if the native binary isn't
|
|
50
|
+
* available — same as the rest of the test suite.
|
|
51
|
+
*/
|
|
52
|
+
export function compileAndMount(
|
|
53
|
+
jsxExpr: string,
|
|
54
|
+
context: Record<string, unknown> = {},
|
|
55
|
+
): MountInBrowserResult {
|
|
56
|
+
// Wrap as a function so the compiler emits a return statement.
|
|
57
|
+
const source = `export function App(props) { return ${jsxExpr} }`
|
|
58
|
+
const compiled = transformJSX(source, 'compile-runtime-test.tsx').code
|
|
59
|
+
|
|
60
|
+
// Strip ALL `@pyreon/*` imports — we inject the symbols by name
|
|
61
|
+
// through `new Function` parameters instead. Both runtime-dom and
|
|
62
|
+
// reactivity exports are unioned in below, so any compiler-emitted
|
|
63
|
+
// import (`_tpl`, `_bind`, `_bindDirect`, `_bindText`, `_applyProps`,
|
|
64
|
+
// etc.) resolves through the parameter binding. Strip the `export`
|
|
65
|
+
// keyword so `App` is in factory scope.
|
|
66
|
+
const code = compiled
|
|
67
|
+
.replace(/^\s*import\s*\{[^}]+\}\s*from\s*["']@pyreon\/[^"']+["'];?\s*$/gm, '')
|
|
68
|
+
.replace(/export\s+function\s+App/, 'function App')
|
|
69
|
+
|
|
70
|
+
// Union of runtime-dom + reactivity exports + test-supplied context,
|
|
71
|
+
// fed into the factory's parameter list. The compiled code uses these
|
|
72
|
+
// names directly (e.g. `_tpl(...)`, `_bind(...)`, `sig.set(...)`) —
|
|
73
|
+
// `new Function` binds them via closure-equivalent parameter resolution.
|
|
74
|
+
// Same-name overrides resolve in declaration order: later wins. We put
|
|
75
|
+
// user-supplied context LAST so a test can shadow a runtime export if
|
|
76
|
+
// it ever needs to (rare).
|
|
77
|
+
const runtimeKeys = Object.keys(runtimeDom)
|
|
78
|
+
const reactivityKeys = Object.keys(reactivity).filter((k) => !runtimeKeys.includes(k))
|
|
79
|
+
const contextKeys = Object.keys(context).filter(
|
|
80
|
+
(k) => !runtimeKeys.includes(k) && !reactivityKeys.includes(k),
|
|
81
|
+
)
|
|
82
|
+
const allKeys = [...runtimeKeys, ...reactivityKeys, ...contextKeys]
|
|
83
|
+
const allValues = [
|
|
84
|
+
...runtimeKeys.map((k) => (runtimeDom as Record<string, unknown>)[k]),
|
|
85
|
+
...reactivityKeys.map((k) => (reactivity as Record<string, unknown>)[k]),
|
|
86
|
+
...contextKeys.map((k) => context[k]),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
// eslint-disable-next-line no-new-func
|
|
90
|
+
const factory = new Function(...allKeys, `${code}\nreturn App`)
|
|
91
|
+
const App = factory(...allValues) as (props: object) => unknown
|
|
92
|
+
|
|
93
|
+
return mountInBrowser(h(App as never, {}) as never)
|
|
94
|
+
}
|