@excom/kit-utils 0.1.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/.rush/temp/chunked-rush-logs/kit-utils.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +4 -0
- package/batch-manager.ts +109 -0
- package/common.ts +234 -0
- package/config/rig.json +6 -0
- package/dom.ts +511 -0
- package/fetching.ts +120 -0
- package/form.ts +99 -0
- package/index.ts +9 -0
- package/load-dependency.ts +40 -0
- package/loop-guard.ts +187 -0
- package/package.json +39 -0
- package/property.ts +225 -0
- package/queue-manager.ts +214 -0
- package/rush-logs/kit-utils.apply-exports.cache.log +1 -0
- package/rush-logs/kit-utils.apply-exports.log +1 -0
- package/support/tests/batch-manager.test.ts +381 -0
- package/support/tests/common.test.ts +376 -0
- package/support/tests/dom.test.ts +852 -0
- package/support/tests/fetching.test.ts +230 -0
- package/support/tests/form.test.ts +297 -0
- package/support/tests/index.test.ts +20 -0
- package/support/tests/load-dependency.test.ts +98 -0
- package/support/tests/loop-guard.test.ts +225 -0
- package/support/tests/property.test.ts +355 -0
- package/support/tests/queue-manager.test.ts +471 -0
- package/support/tests/url.test.ts +111 -0
- package/tsconfig.json +5 -0
- package/url.ts +39 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearFetchCaches,
|
|
3
|
+
fetchPlainText,
|
|
4
|
+
fetchTemplate,
|
|
5
|
+
resolveModuleReference,
|
|
6
|
+
resolveTemplateContent,
|
|
7
|
+
} from "../../fetching";
|
|
8
|
+
import {
|
|
9
|
+
afterEach,
|
|
10
|
+
describe,
|
|
11
|
+
expect,
|
|
12
|
+
fixture,
|
|
13
|
+
it,
|
|
14
|
+
spyFetch,
|
|
15
|
+
vi,
|
|
16
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
17
|
+
|
|
18
|
+
// The template cache is module-wide, so every URL test uses its own path.
|
|
19
|
+
let counter = 0;
|
|
20
|
+
const uniqueUrl = (prefix = "/") => `${prefix}tpl-${++counter}.html`;
|
|
21
|
+
|
|
22
|
+
describe("fetchTemplate", () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("fetches the URL and parses the body into a fragment", async () => {
|
|
28
|
+
const fetchSpy = spyFetch({ body: `<p class="fetched">hi</p>` });
|
|
29
|
+
const url = uniqueUrl();
|
|
30
|
+
const content = await fetchTemplate(url);
|
|
31
|
+
expect(content).toBeInstanceOf(DocumentFragment);
|
|
32
|
+
expect(content.firstElementChild?.className).toBe("fetched");
|
|
33
|
+
expect(fetchSpy).toHaveBeenCalledWith(url, {});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("forwards reqInit to fetch", async () => {
|
|
37
|
+
const fetchSpy = spyFetch({ body: "<p></p>" });
|
|
38
|
+
const url = uniqueUrl();
|
|
39
|
+
const reqInit = { headers: { "x-test": "1" } };
|
|
40
|
+
await fetchTemplate(url, { reqInit });
|
|
41
|
+
expect(fetchSpy).toHaveBeenCalledWith(url, reqInit);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("resolveTemplateContent: URL templates", () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns a promise on the first fetch and a clone synchronously after", async () => {
|
|
51
|
+
const fetchSpy = spyFetch({ body: `<p class="one">one</p>` });
|
|
52
|
+
const url = uniqueUrl();
|
|
53
|
+
const first = resolveTemplateContent(url);
|
|
54
|
+
expect(first).toBeInstanceOf(Promise);
|
|
55
|
+
const firstEl = (await first) as Element;
|
|
56
|
+
expect(firstEl.className).toBe("one");
|
|
57
|
+
const second = resolveTemplateContent(url) as Element;
|
|
58
|
+
expect(second).not.toBeInstanceOf(Promise);
|
|
59
|
+
expect(second.className).toBe("one");
|
|
60
|
+
expect(second).not.toBe(firstEl);
|
|
61
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("shares one in-flight fetch between concurrent resolves", async () => {
|
|
65
|
+
const fetchSpy = spyFetch({ body: `<p>shared</p>` }, 2);
|
|
66
|
+
const url = uniqueUrl("./");
|
|
67
|
+
const a = resolveTemplateContent(url);
|
|
68
|
+
const b = resolveTemplateContent(url);
|
|
69
|
+
expect(a).toBeInstanceOf(Promise);
|
|
70
|
+
expect(b).toBeInstanceOf(Promise);
|
|
71
|
+
const [elA, elB] = (await Promise.all([a, b])) as Element[];
|
|
72
|
+
expect(elA.textContent).toBe("shared");
|
|
73
|
+
expect(elB.textContent).toBe("shared");
|
|
74
|
+
expect(elA).not.toBe(elB);
|
|
75
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("bypassCache refetches even when a fragment is cached and replaces it", async () => {
|
|
79
|
+
let body = `<p>first</p>`;
|
|
80
|
+
const fetchSpy = spyFetch(() => ({ body }));
|
|
81
|
+
const url = uniqueUrl("../");
|
|
82
|
+
await resolveTemplateContent(url);
|
|
83
|
+
expect((resolveTemplateContent(url) as Element).textContent).toBe("first");
|
|
84
|
+
body = `<p>second</p>`;
|
|
85
|
+
const reload = resolveTemplateContent(url, { bypassCache: true });
|
|
86
|
+
expect(reload).toBeInstanceOf(Promise);
|
|
87
|
+
expect(((await reload) as Element).textContent).toBe("second");
|
|
88
|
+
// later reads see the refreshed entry, synchronously
|
|
89
|
+
const after = resolveTemplateContent(url) as Element;
|
|
90
|
+
expect(after).not.toBeInstanceOf(Promise);
|
|
91
|
+
expect(after.textContent).toBe("second");
|
|
92
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("passes reqInit through on fetch and accepts absolute http URLs", async () => {
|
|
96
|
+
const fetchSpy = spyFetch({ body: `<p>abs</p>` });
|
|
97
|
+
const url = `http://example.test${uniqueUrl()}`;
|
|
98
|
+
const reqInit = { cache: "no-store" as const };
|
|
99
|
+
await resolveTemplateContent(url, { reqInit });
|
|
100
|
+
expect(fetchSpy).toHaveBeenCalledWith(url, { reqInit }.reqInit);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("wraps multi-root templates and honours skipCloning", async () => {
|
|
104
|
+
spyFetch({ body: `<p>a</p><p>b</p>` });
|
|
105
|
+
const url = uniqueUrl();
|
|
106
|
+
const wrapped = (await resolveTemplateContent(url)) as Element;
|
|
107
|
+
expect(wrapped.tagName).toBe("DIV");
|
|
108
|
+
expect(wrapped.children.length).toBe(2);
|
|
109
|
+
const single = uniqueUrl();
|
|
110
|
+
vi.restoreAllMocks();
|
|
111
|
+
spyFetch({ body: `<p>only</p>` });
|
|
112
|
+
await resolveTemplateContent(single);
|
|
113
|
+
const own1 = resolveTemplateContent(single, { skipCloning: true });
|
|
114
|
+
const own2 = resolveTemplateContent(single, { skipCloning: true });
|
|
115
|
+
expect(own1).toBe(own2);
|
|
116
|
+
expect(resolveTemplateContent(single)).not.toBe(own1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("resolveTemplateContent: selector templates", () => {
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
document.body.innerHTML = "";
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("clones a <template> found in the document", () => {
|
|
126
|
+
fixture(`<template id="tpl-sel"><span class="sel">x</span></template>`);
|
|
127
|
+
const el = resolveTemplateContent("#tpl-sel") as Element;
|
|
128
|
+
expect(el).not.toBeInstanceOf(Promise);
|
|
129
|
+
expect(el.className).toBe("sel");
|
|
130
|
+
const tpl = document.querySelector("#tpl-sel") as HTMLTemplateElement;
|
|
131
|
+
expect(tpl.content.children.length).toBe(1);
|
|
132
|
+
expect(el).not.toBe(tpl.content.firstElementChild);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resolves :scope relative to the given scope element", () => {
|
|
136
|
+
const host = fixture<HTMLElement>(
|
|
137
|
+
`<div><template class="inner-tpl"><i>scoped</i></template></div>`
|
|
138
|
+
);
|
|
139
|
+
fixture(`<template class="inner-tpl"><b>other</b></template>`);
|
|
140
|
+
const el = resolveTemplateContent(":scope > .inner-tpl", {
|
|
141
|
+
scope: host,
|
|
142
|
+
}) as Element;
|
|
143
|
+
expect(el.tagName).toBe("I");
|
|
144
|
+
const own = resolveTemplateContent(":scope > .inner-tpl", {
|
|
145
|
+
scope: host,
|
|
146
|
+
skipCloning: true,
|
|
147
|
+
}) as Element;
|
|
148
|
+
expect(own).toBe(host.querySelector("template")!.content.firstElementChild);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("throws when the scope cannot query or the template is missing", () => {
|
|
152
|
+
expect(() =>
|
|
153
|
+
resolveTemplateContent("#anything", { scope: {} as Element })
|
|
154
|
+
).toThrow("resolveTemplateContent requires a scope with querySelector");
|
|
155
|
+
expect(() => resolveTemplateContent("#does-not-exist")).toThrow(
|
|
156
|
+
"Template not found: #does-not-exist"
|
|
157
|
+
);
|
|
158
|
+
fixture(`<div id="not-a-template"></div>`);
|
|
159
|
+
expect(() => resolveTemplateContent("#not-a-template")).toThrow(
|
|
160
|
+
"Template not found: #not-a-template"
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("resolveModuleReference", () => {
|
|
166
|
+
it("imports relative to the page origin (unsupported under node)", async () => {
|
|
167
|
+
await expect(resolveModuleReference("/mods/x.js")).rejects.toThrow();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("fetchPlainText", () => {
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
vi.restoreAllMocks();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("fetches once per URL and caches the text", async () => {
|
|
177
|
+
const fetchSpy = spyFetch({ body: "plain body" });
|
|
178
|
+
const url = uniqueUrl("/text/");
|
|
179
|
+
const reqInit = { method: "GET" };
|
|
180
|
+
await expect(fetchPlainText(url, { reqInit })).resolves.toBe("plain body");
|
|
181
|
+
await expect(fetchPlainText(url)).resolves.toBe("plain body");
|
|
182
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
183
|
+
expect(fetchSpy).toHaveBeenCalledWith(url, reqInit);
|
|
184
|
+
const other = uniqueUrl("/text/");
|
|
185
|
+
await expect(fetchPlainText(other)).resolves.toBe("plain body");
|
|
186
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
187
|
+
expect(fetchSpy).toHaveBeenLastCalledWith(other, {});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("clearFetchCaches", () => {
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
vi.restoreAllMocks();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("forgets one URL so the next resolve fetches again", async () => {
|
|
197
|
+
const fetchSpy = spyFetch({ body: `<p>v1</p>` }, 2);
|
|
198
|
+
const url = uniqueUrl();
|
|
199
|
+
await resolveTemplateContent(url);
|
|
200
|
+
expect(clearFetchCaches(url)).toBe(1);
|
|
201
|
+
const again = resolveTemplateContent(url);
|
|
202
|
+
expect(again).toBeInstanceOf(Promise);
|
|
203
|
+
await again;
|
|
204
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("forgets plain-text entries too, and everything when no URL is given", async () => {
|
|
208
|
+
spyFetch({ body: "text" }, 4);
|
|
209
|
+
const tpl = uniqueUrl();
|
|
210
|
+
const txt = uniqueUrl("/text-");
|
|
211
|
+
await resolveTemplateContent(tpl);
|
|
212
|
+
await fetchPlainText(txt);
|
|
213
|
+
expect(clearFetchCaches()).toBeGreaterThanOrEqual(2);
|
|
214
|
+
expect(clearFetchCaches(tpl)).toBe(0);
|
|
215
|
+
await fetchPlainText(txt);
|
|
216
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does not resurrect a purged entry when the in-flight fetch settles", async () => {
|
|
220
|
+
const fetchSpy = spyFetch({ body: `<p>late</p>` }, 2);
|
|
221
|
+
const url = uniqueUrl();
|
|
222
|
+
const pending = resolveTemplateContent(url);
|
|
223
|
+
expect(clearFetchCaches(url)).toBe(1);
|
|
224
|
+
await pending;
|
|
225
|
+
const next = resolveTemplateContent(url);
|
|
226
|
+
expect(next).toBeInstanceOf(Promise);
|
|
227
|
+
await next;
|
|
228
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { formToJson, parseFormInputValue } from "../../form";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
fixture,
|
|
7
|
+
it,
|
|
8
|
+
vi,
|
|
9
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
10
|
+
|
|
11
|
+
const formHtml = `
|
|
12
|
+
<form action="/api/submit" method="put">
|
|
13
|
+
<!-- Simple primitives -->
|
|
14
|
+
<input type="text" name="name" value="John Doe">
|
|
15
|
+
<input type="email" name="email" value="j@doe.com">
|
|
16
|
+
<input type="number" name="age" value="50">
|
|
17
|
+
<input type="range" name="volume" min="0" max="100" value="20">
|
|
18
|
+
<input type="tel" name="phone" value="123-456-7890">
|
|
19
|
+
<input type="url" name="website" value="https://example.com">
|
|
20
|
+
<input type="color" name="favoriteColor" value="#ff0000">
|
|
21
|
+
<input type="password" name="password" value="secret">
|
|
22
|
+
<!-- Checkbox -->
|
|
23
|
+
<input type="checkbox" name="isAdmin" checked>
|
|
24
|
+
<input type="checkbox" name="isModerator">
|
|
25
|
+
<!-- Checkbox array -->
|
|
26
|
+
<input type="checkbox" name="hobbies" value="reading" checked>
|
|
27
|
+
<input type="checkbox" name="hobbies" value="cooking">
|
|
28
|
+
<input type="checkbox" name="hobbies" value="gaming" checked>
|
|
29
|
+
<!-- Radio -->
|
|
30
|
+
<input type="radio" name="activity" value="running">
|
|
31
|
+
<input type="radio" name="activity" value="swimming" checked>
|
|
32
|
+
<!-- Datetimes -->
|
|
33
|
+
<input type="date" name="birthday" value="1999-10-01">
|
|
34
|
+
<input type="time" name="wakeupTime" value="08:00">
|
|
35
|
+
<input type="datetime-local" name="meetingTime" value="2023-10-01T10:00">
|
|
36
|
+
<input type="month" name="birthMonth" value="1999-10">
|
|
37
|
+
<input type="week" name="birthWeek" value="1999-W40">
|
|
38
|
+
<!-- Select -->
|
|
39
|
+
<select name="country">
|
|
40
|
+
<option value="USA">United States</option>
|
|
41
|
+
<option value="CAN" selected>Canada</option>
|
|
42
|
+
<option value="MEX">Mexico</option>
|
|
43
|
+
</select>
|
|
44
|
+
<!-- Select multiple -->
|
|
45
|
+
<select name="languages" multiple>
|
|
46
|
+
<option value="english" selected>English</option>
|
|
47
|
+
<option value="french">French</option>
|
|
48
|
+
<option value="spanish" selected>Spanish</option>
|
|
49
|
+
<option value="german">German</option>
|
|
50
|
+
<option value="chinese">Chinese</option>
|
|
51
|
+
</select>
|
|
52
|
+
<!-- Arrays -->
|
|
53
|
+
<input type="text" name="tags[]" value="smart">
|
|
54
|
+
<input type="text" name="tags[]" value="athletic">
|
|
55
|
+
<input type="text" name="tags[]" value="kind">
|
|
56
|
+
<!-- Nested -->
|
|
57
|
+
<input type="text" name="address.city" value="Anytown">
|
|
58
|
+
<input type="text" name="address.state" value="CA">
|
|
59
|
+
<input type="number" name="address.zip" value="12345">
|
|
60
|
+
<input type="number" name="address.coordinates[]" value="37.7749">
|
|
61
|
+
<input type="number" name="address.coordinates[]" value="-122.4194">
|
|
62
|
+
<!-- Disabled -->
|
|
63
|
+
<input type="text" name="disabledValue" value="test" disabled>
|
|
64
|
+
<button type="submit">Submit</button>
|
|
65
|
+
</form>
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const formJson = {
|
|
69
|
+
name: "John Doe",
|
|
70
|
+
email: "j@doe.com",
|
|
71
|
+
age: 50,
|
|
72
|
+
volume: 20,
|
|
73
|
+
phone: "123-456-7890",
|
|
74
|
+
website: "https://example.com",
|
|
75
|
+
favoriteColor: "#ff0000",
|
|
76
|
+
password: "secret",
|
|
77
|
+
isAdmin: true,
|
|
78
|
+
isModerator: false,
|
|
79
|
+
activity: "swimming",
|
|
80
|
+
hobbies: ["reading", "gaming"],
|
|
81
|
+
birthday: "1999-10-01",
|
|
82
|
+
wakeupTime: "08:00",
|
|
83
|
+
meetingTime: "2023-10-01T10:00",
|
|
84
|
+
birthMonth: "1999-10",
|
|
85
|
+
birthWeek: "1999-W40",
|
|
86
|
+
country: "CAN",
|
|
87
|
+
languages: ["english", "spanish"],
|
|
88
|
+
tags: ["smart", "athletic", "kind"],
|
|
89
|
+
address: {
|
|
90
|
+
city: "Anytown",
|
|
91
|
+
state: "CA",
|
|
92
|
+
zip: 12345,
|
|
93
|
+
coordinates: [37.7749, -122.4194],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("formToJson", () => {
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
document.body.innerHTML = "";
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("converts form to json correctly", async () => {
|
|
103
|
+
// nested paths + typed values
|
|
104
|
+
document.body.innerHTML = formHtml;
|
|
105
|
+
const form = document.querySelector("form");
|
|
106
|
+
const json = formToJson(form);
|
|
107
|
+
expect(json).toEqual(formJson);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("converts forms created via fixture() (connected wrapper)", () => {
|
|
111
|
+
const form = fixture<HTMLFormElement>(formHtml);
|
|
112
|
+
expect(formToJson(form)).toEqual(formJson);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns an empty object for a form with no named controls", () => {
|
|
116
|
+
const form = fixture<HTMLFormElement>(
|
|
117
|
+
`<form><input type="text" value="unnamed"><button>Go</button></form>`,
|
|
118
|
+
);
|
|
119
|
+
expect(formToJson(form)).toEqual({});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("serializes textareas and empty text values", () => {
|
|
123
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
124
|
+
<textarea name="bio">Hello
|
|
125
|
+
world</textarea>
|
|
126
|
+
<input type="text" name="empty" value="">
|
|
127
|
+
</form>`);
|
|
128
|
+
expect(formToJson(form)).toEqual({ bio: "Hello\nworld", empty: "" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("types number and range controls, including empty numbers", () => {
|
|
132
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
133
|
+
<input type="number" name="n" value="3.5">
|
|
134
|
+
<input type="range" name="r" min="0" max="10" value="7">
|
|
135
|
+
<input type="number" name="blank" value="">
|
|
136
|
+
</form>`);
|
|
137
|
+
expect(formToJson(form)).toEqual({ n: 3.5, r: 7, blank: 0 });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("maps value-less checkboxes to booleans", () => {
|
|
141
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
142
|
+
<input type="checkbox" name="on" checked>
|
|
143
|
+
<input type="checkbox" name="off">
|
|
144
|
+
</form>`);
|
|
145
|
+
expect(formToJson(form)).toEqual({ on: true, off: false });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("omits a valued checkbox group with nothing checked", () => {
|
|
149
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
150
|
+
<input type="checkbox" name="hobbies" value="reading">
|
|
151
|
+
<input type="checkbox" name="hobbies" value="cooking">
|
|
152
|
+
<input type="text" name="name" value="x">
|
|
153
|
+
</form>`);
|
|
154
|
+
expect(formToJson(form)).toEqual({ name: "x" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("collects a single checked valued checkbox into an array", () => {
|
|
158
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
159
|
+
<input type="checkbox" name="hobbies" value="reading" checked>
|
|
160
|
+
<input type="checkbox" name="hobbies" value="cooking">
|
|
161
|
+
</form>`);
|
|
162
|
+
expect(formToJson(form)).toEqual({ hobbies: ["reading"] });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("omits a radio group with nothing checked", () => {
|
|
166
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
167
|
+
<input type="radio" name="activity" value="running">
|
|
168
|
+
<input type="radio" name="activity" value="swimming">
|
|
169
|
+
</form>`);
|
|
170
|
+
expect(formToJson(form)).toEqual({});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("serializes a single select by its selected option", () => {
|
|
174
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
175
|
+
<select name="single">
|
|
176
|
+
<option value="a">A</option>
|
|
177
|
+
<option value="b" selected>B</option>
|
|
178
|
+
</select>
|
|
179
|
+
</form>`);
|
|
180
|
+
expect(formToJson(form)).toEqual({ single: "b" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("lets the last of several same-named text inputs win", () => {
|
|
184
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
185
|
+
<input type="text" name="dup" value="first">
|
|
186
|
+
<input type="text" name="dup" value="second">
|
|
187
|
+
</form>`);
|
|
188
|
+
expect(formToJson(form)).toEqual({ dup: "second" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("keeps [] arrays in document order with repeated values", () => {
|
|
192
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
193
|
+
<input type="text" name="tags[]" value="a">
|
|
194
|
+
<input type="text" name="tags[]" value="a">
|
|
195
|
+
<input type="number" name="tags[]" value="3">
|
|
196
|
+
</form>`);
|
|
197
|
+
expect(formToJson(form)).toEqual({ tags: ["a", "a", 3] });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("builds nested objects and arrays from dotted and [] names", () => {
|
|
201
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
202
|
+
<input type="text" name="a.b.c" value="deep">
|
|
203
|
+
<input type="number" name="a.list[]" value="1">
|
|
204
|
+
<input type="number" name="a.list[]" value="2">
|
|
205
|
+
<input type="checkbox" name="a.flag" checked>
|
|
206
|
+
</form>`);
|
|
207
|
+
expect(formToJson(form)).toEqual({
|
|
208
|
+
a: { b: { c: "deep" }, list: [1, 2], flag: true },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("looks controls up by name, not by id", () => {
|
|
213
|
+
// `form.elements.namedItem()` would match the id first
|
|
214
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
215
|
+
<input type="text" id="age" name="label" value="not a number">
|
|
216
|
+
<input type="number" name="age" value="42">
|
|
217
|
+
</form>`);
|
|
218
|
+
expect(formToJson(form)).toEqual({ label: "not a number", age: 42 });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("types controls associated through the form attribute", () => {
|
|
222
|
+
const wrap = fixture<HTMLElement>(`<section>
|
|
223
|
+
<form id="f"><input type="text" name="inside" value="in"></form>
|
|
224
|
+
<input type="number" name="outside" value="8" form="f">
|
|
225
|
+
<input type="checkbox" name="flag" form="f" checked>
|
|
226
|
+
</section>`);
|
|
227
|
+
const form = wrap.querySelector("form") as HTMLFormElement;
|
|
228
|
+
expect(formToJson(form)).toEqual({ inside: "in", outside: 8, flag: true });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("ignores disabled controls and unnamed controls", () => {
|
|
232
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
233
|
+
<input type="text" name="keep" value="1">
|
|
234
|
+
<input type="text" name="skip" value="2" disabled>
|
|
235
|
+
<input type="text" value="3">
|
|
236
|
+
</form>`);
|
|
237
|
+
expect(formToJson(form)).toEqual({ keep: "1" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("serializes thousands of fields with one pass over the controls", () => {
|
|
241
|
+
const rows = 100;
|
|
242
|
+
const cols = 26;
|
|
243
|
+
const inputs: string[] = [];
|
|
244
|
+
for (let r = 0; r < rows; r++) {
|
|
245
|
+
for (let c = 0; c < cols; c++) {
|
|
246
|
+
const ref = String.fromCharCode(65 + c) + r;
|
|
247
|
+
inputs.push(
|
|
248
|
+
`<input type="text" name="detail.${ref}" value="${r === 0 ? "=A1" : ref}">`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const form = fixture<HTMLFormElement>(`<form>${inputs.join("")}</form>`);
|
|
253
|
+
const querySelector = vi.spyOn(form, "querySelector");
|
|
254
|
+
const querySelectorAll = vi.spyOn(form, "querySelectorAll");
|
|
255
|
+
const json = formToJson(form) as { detail: Record<string, string> };
|
|
256
|
+
expect(Object.keys(json.detail)).toHaveLength(rows * cols);
|
|
257
|
+
expect(json.detail.A0).toBe("=A1");
|
|
258
|
+
expect(json.detail.Z99).toBe("Z99");
|
|
259
|
+
// no per-field selector queries: one query for unchecked checkboxes
|
|
260
|
+
expect(querySelector).not.toHaveBeenCalled();
|
|
261
|
+
expect(querySelectorAll).toHaveBeenCalledTimes(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("skips entries whose name is empty", () => {
|
|
265
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
266
|
+
<input type="checkbox" name="">
|
|
267
|
+
<input type="text" name="keep" value="k">
|
|
268
|
+
</form>`);
|
|
269
|
+
expect(formToJson(form)).toEqual({ keep: "k" });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("parseFormInputValue: passes the value through without a control", () => {
|
|
273
|
+
const form = fixture<HTMLFormElement>(`<form></form>`);
|
|
274
|
+
expect(parseFormInputValue(form, undefined, "k", "raw")).toBe("raw");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("parseFormInputValue: radio with nothing checked yields null", () => {
|
|
278
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
279
|
+
<input type="radio" name="pick" value="a">
|
|
280
|
+
<input type="radio" name="pick" value="b">
|
|
281
|
+
</form>`);
|
|
282
|
+
const radio = form.querySelector("input") as HTMLInputElement;
|
|
283
|
+
expect(parseFormInputValue(form, radio, "pick", "a")).toBe(null);
|
|
284
|
+
radio.checked = true;
|
|
285
|
+
expect(parseFormInputValue(form, radio, "pick", "a")).toBe("a");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("parseFormInputValue: checkbox on/off strings map to the checked state", () => {
|
|
289
|
+
const form = fixture<HTMLFormElement>(`<form>
|
|
290
|
+
<input type="checkbox" name="flag" checked>
|
|
291
|
+
</form>`);
|
|
292
|
+
const box = form.querySelector("input") as HTMLInputElement;
|
|
293
|
+
expect(parseFormInputValue(form, box, "flag", "on")).toBe(true);
|
|
294
|
+
box.checked = false;
|
|
295
|
+
expect(parseFormInputValue(form, box, "flag", "off")).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as index from "../../index";
|
|
2
|
+
import {
|
|
3
|
+
describe,
|
|
4
|
+
expect,
|
|
5
|
+
it,
|
|
6
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
7
|
+
|
|
8
|
+
describe("index", () => {
|
|
9
|
+
it("re-exports the helper modules", () => {
|
|
10
|
+
expect(index.BatchManager).toBeTypeOf("function");
|
|
11
|
+
expect(index.deepMerge).toBeTypeOf("function");
|
|
12
|
+
expect(index.Converter).toBeTypeOf("object");
|
|
13
|
+
expect(index.TokenList).toBeTypeOf("function");
|
|
14
|
+
expect(index.resolveTemplateContent).toBeTypeOf("function");
|
|
15
|
+
expect(index.formToJson).toBeTypeOf("function");
|
|
16
|
+
expect(index.observeProperty).toBeTypeOf("function");
|
|
17
|
+
expect(index.QueueManager).toBeTypeOf("function");
|
|
18
|
+
expect(index.mergeSearchParamsIntoUrl).toBeTypeOf("function");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
9
|
+
|
|
10
|
+
type LoadDependency = typeof import("../../load-dependency").loadDependency;
|
|
11
|
+
|
|
12
|
+
const win = window as unknown as Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
describe("loadDependency", () => {
|
|
15
|
+
let loadDependency: LoadDependency;
|
|
16
|
+
let appendChild: ReturnType<typeof vi.spyOn>;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
vi.resetModules();
|
|
20
|
+
delete win.__DEPENDENCY_PROMISES__;
|
|
21
|
+
({ loadDependency } = await import("../../load-dependency"));
|
|
22
|
+
/* Keep the `<script>` out of the document so happy-dom never
|
|
23
|
+
* fetches `src`; tests drive `onload` / `onerror`. */
|
|
24
|
+
appendChild = vi
|
|
25
|
+
.spyOn(document.body, "appendChild")
|
|
26
|
+
.mockImplementation((node) => node);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
delete win.TestDep;
|
|
32
|
+
delete win.__DEPENDENCY_PROMISES__;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const pendingScript = () =>
|
|
36
|
+
appendChild.mock.calls[0][0] as unknown as HTMLScriptElement;
|
|
37
|
+
|
|
38
|
+
it("initialises the shared promise registry once", async () => {
|
|
39
|
+
expect(window.__DEPENDENCY_PROMISES__).toEqual({});
|
|
40
|
+
const existing = { Preset: Promise.resolve("preset") };
|
|
41
|
+
win.__DEPENDENCY_PROMISES__ = existing;
|
|
42
|
+
vi.resetModules();
|
|
43
|
+
await import("../../load-dependency");
|
|
44
|
+
expect(window.__DEPENDENCY_PROMISES__).toBe(existing);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("esm: returns the imported module", async () => {
|
|
48
|
+
const mod = await loadDependency<{ default: number }>(
|
|
49
|
+
"esm",
|
|
50
|
+
"data:text/javascript,export default 42"
|
|
51
|
+
);
|
|
52
|
+
expect(mod.default).toBe(42);
|
|
53
|
+
expect(appendChild).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("umd: returns an already-present global without a script", async () => {
|
|
57
|
+
win.TestDep = { ready: true };
|
|
58
|
+
await expect(loadDependency("umd", "/dep.js", "TestDep")).resolves.toEqual({
|
|
59
|
+
ready: true,
|
|
60
|
+
});
|
|
61
|
+
expect(appendChild).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("umd: injects a script once and resolves with the global on load", async () => {
|
|
65
|
+
const first = loadDependency("umd", "/vendor/dep.js", "TestDep");
|
|
66
|
+
const second = loadDependency("umd", "/vendor/dep.js", "TestDep");
|
|
67
|
+
expect(appendChild).toHaveBeenCalledTimes(1);
|
|
68
|
+
const script = pendingScript();
|
|
69
|
+
expect(script.tagName).toBe("SCRIPT");
|
|
70
|
+
expect(script.src).toBe(`${window.location.origin}/vendor/dep.js`);
|
|
71
|
+
expect(window.__DEPENDENCY_PROMISES__.TestDep).toBeInstanceOf(Promise);
|
|
72
|
+
win.TestDep = { loaded: true };
|
|
73
|
+
script.onload!(new Event("load"));
|
|
74
|
+
await expect(first).resolves.toEqual({ loaded: true });
|
|
75
|
+
await expect(second).resolves.toEqual({ loaded: true });
|
|
76
|
+
// now that the global exists, later calls short-circuit
|
|
77
|
+
await expect(
|
|
78
|
+
loadDependency("umd", "/vendor/dep.js", "TestDep")
|
|
79
|
+
).resolves.toEqual({ loaded: true });
|
|
80
|
+
expect(appendChild).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("umd: rejects when the script fails to load", async () => {
|
|
84
|
+
const pending = loadDependency("umd", "/vendor/missing.js", "TestDep");
|
|
85
|
+
pendingScript().onerror!(new Event("error"));
|
|
86
|
+
await expect(pending).rejects.toThrow("Failed to load dependency: TestDep");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects invalid requests", async () => {
|
|
90
|
+
await expect(loadDependency("umd", "/dep.js")).rejects.toThrow(
|
|
91
|
+
"Invalid dependency load request: umd | /dep.js | undefined"
|
|
92
|
+
);
|
|
93
|
+
await expect(
|
|
94
|
+
loadDependency("cjs" as unknown as "umd", "/dep.js", "X")
|
|
95
|
+
).rejects.toThrow("Invalid dependency load request: cjs | /dep.js | X");
|
|
96
|
+
expect(appendChild).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
});
|