@multiplatform.one/core 7.1.0 → 7.2.1
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/package.json +14 -13
- package/src/app/CreateApp.tsx +25 -0
- package/src/app/TanstackDevtools.tsx +52 -5
- package/src/app/devtoolsTrigger.spec.ts +560 -0
- package/src/app/devtoolsTrigger.ts +429 -0
- package/types/app/CreateApp.d.ts +25 -0
- package/types/app/CreateApp.d.ts.map +1 -1
- package/types/app/TanstackDevtools.d.ts.map +1 -1
- package/types/app/devtoolsTrigger.d.ts +179 -0
- package/types/app/devtoolsTrigger.d.ts.map +1 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
__resetMotionArming,
|
|
11
|
+
armDevtoolsMotion,
|
|
12
|
+
createMotionPermissionFlow,
|
|
13
|
+
createShakeDetector,
|
|
14
|
+
devtoolsArmMotionEvent,
|
|
15
|
+
devtoolsMotionArmedEvent,
|
|
16
|
+
devtoolsOpenHotkey,
|
|
17
|
+
devtoolsShellPluginId,
|
|
18
|
+
devtoolsTriggerToggledEvent,
|
|
19
|
+
isDevtoolsOpenHotkeyEvent,
|
|
20
|
+
isMotionPermissionRequired,
|
|
21
|
+
motionMagnitude,
|
|
22
|
+
openDevtoolsShell,
|
|
23
|
+
requestMotionPermissionFromGesture,
|
|
24
|
+
shakeDefaults,
|
|
25
|
+
} from "./devtoolsTrigger";
|
|
26
|
+
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/** A single devicemotion-shaped sample with the given magnitude on x. */
|
|
30
|
+
function sample(magnitude: number) {
|
|
31
|
+
return { acceleration: { x: magnitude, y: 0, z: 0 } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function keyEvent(init: {
|
|
35
|
+
metaKey?: boolean;
|
|
36
|
+
ctrlKey?: boolean;
|
|
37
|
+
shiftKey?: boolean;
|
|
38
|
+
altKey?: boolean;
|
|
39
|
+
key?: string;
|
|
40
|
+
code?: string;
|
|
41
|
+
}): KeyboardEvent {
|
|
42
|
+
return {
|
|
43
|
+
metaKey: false,
|
|
44
|
+
ctrlKey: false,
|
|
45
|
+
shiftKey: false,
|
|
46
|
+
altKey: false,
|
|
47
|
+
key: "",
|
|
48
|
+
code: "",
|
|
49
|
+
...init,
|
|
50
|
+
} as KeyboardEvent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("isDevtoolsOpenHotkeyEvent", () => {
|
|
54
|
+
it("matches Cmd+Shift+Backquote and Ctrl+Shift+Backquote", () => {
|
|
55
|
+
expect(
|
|
56
|
+
isDevtoolsOpenHotkeyEvent(
|
|
57
|
+
keyEvent({ metaKey: true, shiftKey: true, code: "Backquote", key: "~" }),
|
|
58
|
+
),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
expect(
|
|
61
|
+
isDevtoolsOpenHotkeyEvent(
|
|
62
|
+
keyEvent({ ctrlKey: true, shiftKey: true, code: "Backquote", key: "~" }),
|
|
63
|
+
),
|
|
64
|
+
).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("matches by physical Backquote even when e.key is not tilde", () => {
|
|
68
|
+
// UK / ISO layouts produce ¬ or ` for Shift+Backquote; the physical key
|
|
69
|
+
// is still the one left of 1.
|
|
70
|
+
expect(
|
|
71
|
+
isDevtoolsOpenHotkeyEvent(
|
|
72
|
+
keyEvent({ metaKey: true, shiftKey: true, code: "Backquote", key: "¬" }),
|
|
73
|
+
),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("rejects the unshifted Cmd/Ctrl+` combo macOS steals for window cycle", () => {
|
|
78
|
+
expect(
|
|
79
|
+
isDevtoolsOpenHotkeyEvent(keyEvent({ metaKey: true, code: "Backquote", key: "`" })),
|
|
80
|
+
).toBe(false);
|
|
81
|
+
expect(
|
|
82
|
+
isDevtoolsOpenHotkeyEvent(keyEvent({ ctrlKey: true, code: "Backquote", key: "`" })),
|
|
83
|
+
).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rejects Shift+Backquote without Ctrl/Cmd and other keys with the modifiers", () => {
|
|
87
|
+
expect(
|
|
88
|
+
isDevtoolsOpenHotkeyEvent(keyEvent({ shiftKey: true, code: "Backquote", key: "~" })),
|
|
89
|
+
).toBe(false);
|
|
90
|
+
expect(
|
|
91
|
+
isDevtoolsOpenHotkeyEvent(
|
|
92
|
+
keyEvent({ metaKey: true, shiftKey: true, code: "KeyK", key: "K" }),
|
|
93
|
+
),
|
|
94
|
+
).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("motionMagnitude", () => {
|
|
99
|
+
it("prefers the gravity-excluded acceleration reading", () => {
|
|
100
|
+
expect(
|
|
101
|
+
motionMagnitude({
|
|
102
|
+
acceleration: { x: 3, y: 4, z: 0 },
|
|
103
|
+
accelerationIncludingGravity: { x: 100, y: 0, z: 0 },
|
|
104
|
+
}),
|
|
105
|
+
).toBe(5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("falls back to accelerationIncludingGravity minus standard gravity", () => {
|
|
109
|
+
expect(
|
|
110
|
+
motionMagnitude({
|
|
111
|
+
acceleration: null,
|
|
112
|
+
accelerationIncludingGravity: { x: 0, y: 0, z: 9.81 },
|
|
113
|
+
}),
|
|
114
|
+
).toBeCloseTo(0);
|
|
115
|
+
expect(
|
|
116
|
+
motionMagnitude({
|
|
117
|
+
accelerationIncludingGravity: { x: 0, y: 0, z: 29.81 },
|
|
118
|
+
}),
|
|
119
|
+
).toBeCloseTo(20);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("treats missing axes as zero", () => {
|
|
123
|
+
expect(motionMagnitude({ acceleration: { x: 13, y: null, z: null } })).toBe(13);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns null when the event carries no data (all-null desktop events)", () => {
|
|
127
|
+
expect(motionMagnitude({})).toBeNull();
|
|
128
|
+
expect(motionMagnitude({ acceleration: null, accelerationIncludingGravity: null })).toBeNull();
|
|
129
|
+
expect(
|
|
130
|
+
motionMagnitude({
|
|
131
|
+
acceleration: { x: null, y: null, z: null },
|
|
132
|
+
accelerationIncludingGravity: { x: null, y: null, z: null },
|
|
133
|
+
}),
|
|
134
|
+
).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("createShakeDetector", () => {
|
|
139
|
+
it("fires after three fast peaks inside the window", () => {
|
|
140
|
+
const onShake = vi.fn();
|
|
141
|
+
const detector = createShakeDetector({ onShake });
|
|
142
|
+
detector.handleMotion(sample(20), 0);
|
|
143
|
+
detector.handleMotion(sample(20), 150);
|
|
144
|
+
expect(onShake).not.toHaveBeenCalled();
|
|
145
|
+
detector.handleMotion(sample(20), 300);
|
|
146
|
+
expect(onShake).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("ignores samples at or below the threshold", () => {
|
|
150
|
+
const onShake = vi.fn();
|
|
151
|
+
const detector = createShakeDetector({ onShake });
|
|
152
|
+
for (let t = 0; t < 2000; t += 100) {
|
|
153
|
+
detector.handleMotion(sample(shakeDefaults.threshold), t);
|
|
154
|
+
}
|
|
155
|
+
expect(onShake).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("counts one swing as one peak (min gap between peaks)", () => {
|
|
159
|
+
const onShake = vi.fn();
|
|
160
|
+
const detector = createShakeDetector({ onShake });
|
|
161
|
+
// Six samples of the same 100ms swing, 20ms apart — only two peaks
|
|
162
|
+
// (t=0 and t=100) survive the 80ms gap, so no fire.
|
|
163
|
+
for (let t = 0; t <= 100; t += 20) {
|
|
164
|
+
detector.handleMotion(sample(25), t);
|
|
165
|
+
}
|
|
166
|
+
expect(onShake).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does not fire when peaks fall outside the rolling window", () => {
|
|
170
|
+
const onShake = vi.fn();
|
|
171
|
+
const detector = createShakeDetector({ onShake });
|
|
172
|
+
detector.handleMotion(sample(20), 0);
|
|
173
|
+
detector.handleMotion(sample(20), 600);
|
|
174
|
+
detector.handleMotion(sample(20), 1200); // t=0 peak has left the 1000ms window
|
|
175
|
+
expect(onShake).not.toHaveBeenCalled();
|
|
176
|
+
detector.handleMotion(sample(20), 1700); // t=600 is also gone; 1200+1700 = 2 peaks
|
|
177
|
+
expect(onShake).not.toHaveBeenCalled();
|
|
178
|
+
detector.handleMotion(sample(20), 1800); // 1200, 1700, 1800 all inside
|
|
179
|
+
expect(onShake).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("goes dead for the cooldown after firing, then re-arms", () => {
|
|
183
|
+
const onShake = vi.fn();
|
|
184
|
+
const detector = createShakeDetector({ onShake });
|
|
185
|
+
detector.handleMotion(sample(20), 0);
|
|
186
|
+
detector.handleMotion(sample(20), 100);
|
|
187
|
+
detector.handleMotion(sample(20), 200);
|
|
188
|
+
expect(onShake).toHaveBeenCalledTimes(1);
|
|
189
|
+
// A continued violent shake inside the 2000ms cooldown is dropped.
|
|
190
|
+
detector.handleMotion(sample(25), 500);
|
|
191
|
+
detector.handleMotion(sample(25), 700);
|
|
192
|
+
detector.handleMotion(sample(25), 900);
|
|
193
|
+
detector.handleMotion(sample(25), 2100);
|
|
194
|
+
expect(onShake).toHaveBeenCalledTimes(1);
|
|
195
|
+
// After the cooldown (200 + 2000 = 2200) a fresh three-peak shake
|
|
196
|
+
// fires again; nothing from the cooldown window carries over.
|
|
197
|
+
detector.handleMotion(sample(25), 2300);
|
|
198
|
+
detector.handleMotion(sample(25), 2450);
|
|
199
|
+
detector.handleMotion(sample(25), 2600);
|
|
200
|
+
expect(onShake).toHaveBeenCalledTimes(2);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("reset clears peaks and cooldown", () => {
|
|
204
|
+
const onShake = vi.fn();
|
|
205
|
+
const detector = createShakeDetector({ onShake });
|
|
206
|
+
detector.handleMotion(sample(20), 0);
|
|
207
|
+
detector.handleMotion(sample(20), 100);
|
|
208
|
+
detector.reset();
|
|
209
|
+
detector.handleMotion(sample(20), 200);
|
|
210
|
+
expect(onShake).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("honors custom tuning", () => {
|
|
214
|
+
const onShake = vi.fn();
|
|
215
|
+
const detector = createShakeDetector({ onShake, threshold: 5, peakCount: 2, windowMs: 300 });
|
|
216
|
+
detector.handleMotion(sample(6), 0);
|
|
217
|
+
detector.handleMotion(sample(6), 200);
|
|
218
|
+
expect(onShake).toHaveBeenCalledTimes(1);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("createMotionPermissionFlow", () => {
|
|
223
|
+
it("grants immediately when no requestPermission exists (Android/desktop)", () => {
|
|
224
|
+
const onGranted = vi.fn();
|
|
225
|
+
const flow = createMotionPermissionFlow({ onGranted });
|
|
226
|
+
expect(flow.state).toBe("granted");
|
|
227
|
+
expect(onGranted).toHaveBeenCalledTimes(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("waits for a gesture on iOS and grants on 'granted'", async () => {
|
|
231
|
+
const onGranted = vi.fn();
|
|
232
|
+
const requestPermission = vi.fn().mockResolvedValue("granted");
|
|
233
|
+
const flow = createMotionPermissionFlow({ requestPermission, onGranted });
|
|
234
|
+
expect(flow.state).toBe("awaiting-gesture");
|
|
235
|
+
expect(onGranted).not.toHaveBeenCalled();
|
|
236
|
+
await flow.handleGesture();
|
|
237
|
+
expect(flow.state).toBe("granted");
|
|
238
|
+
expect(onGranted).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(requestPermission).toHaveBeenCalledTimes(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("lands on denied (terminal for this load) on 'denied'", async () => {
|
|
243
|
+
const onGranted = vi.fn();
|
|
244
|
+
const onDenied = vi.fn();
|
|
245
|
+
const flow = createMotionPermissionFlow({
|
|
246
|
+
requestPermission: vi.fn().mockResolvedValue("denied"),
|
|
247
|
+
onGranted,
|
|
248
|
+
onDenied,
|
|
249
|
+
});
|
|
250
|
+
await flow.handleGesture();
|
|
251
|
+
expect(flow.state).toBe("denied");
|
|
252
|
+
expect(onGranted).not.toHaveBeenCalled();
|
|
253
|
+
expect(onDenied).toHaveBeenCalledTimes(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("treats a rejection (called outside a gesture / insecure context) as denied", async () => {
|
|
257
|
+
const flow = createMotionPermissionFlow({
|
|
258
|
+
requestPermission: vi.fn().mockRejectedValue(new Error("NotAllowedError")),
|
|
259
|
+
onGranted: vi.fn(),
|
|
260
|
+
});
|
|
261
|
+
await flow.handleGesture();
|
|
262
|
+
expect(flow.state).toBe("denied");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("no-ops repeat gestures after settling and while a request is in flight", async () => {
|
|
266
|
+
const onGranted = vi.fn();
|
|
267
|
+
let resolve!: (v: string) => void;
|
|
268
|
+
const requestPermission = vi.fn(
|
|
269
|
+
() =>
|
|
270
|
+
new Promise<string>((r) => {
|
|
271
|
+
resolve = r;
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
const flow = createMotionPermissionFlow({ requestPermission, onGranted });
|
|
275
|
+
const first = flow.handleGesture();
|
|
276
|
+
expect(flow.state).toBe("requesting");
|
|
277
|
+
await flow.handleGesture(); // in flight — must not re-ask
|
|
278
|
+
expect(requestPermission).toHaveBeenCalledTimes(1);
|
|
279
|
+
resolve("granted");
|
|
280
|
+
await first;
|
|
281
|
+
await flow.handleGesture(); // settled — must not re-ask
|
|
282
|
+
expect(requestPermission).toHaveBeenCalledTimes(1);
|
|
283
|
+
expect(onGranted).toHaveBeenCalledTimes(1);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("openDevtoolsShell", () => {
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
(globalThis as { __TANSTACK_EVENT_TARGET__?: EventTarget | null }).__TANSTACK_EVENT_TARGET__ =
|
|
290
|
+
null;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("emits the shell's trigger-toggled open event on the devtools bus", () => {
|
|
294
|
+
const bus = new EventTarget();
|
|
295
|
+
(globalThis as { __TANSTACK_EVENT_TARGET__?: EventTarget | null }).__TANSTACK_EVENT_TARGET__ =
|
|
296
|
+
bus;
|
|
297
|
+
// Play the shell's side of the connect handshake.
|
|
298
|
+
bus.addEventListener("tanstack-connect", () => {
|
|
299
|
+
bus.dispatchEvent(new CustomEvent("tanstack-connect-success"));
|
|
300
|
+
});
|
|
301
|
+
const received: Array<{ type: string; payload: { isOpen: boolean } }> = [];
|
|
302
|
+
bus.addEventListener("tanstack-dispatch-event", (event) => {
|
|
303
|
+
received.push((event as CustomEvent).detail);
|
|
304
|
+
});
|
|
305
|
+
openDevtoolsShell();
|
|
306
|
+
expect(received).toHaveLength(1);
|
|
307
|
+
expect(received[0]).toMatchObject({
|
|
308
|
+
type: `${devtoolsShellPluginId}:${devtoolsTriggerToggledEvent}`,
|
|
309
|
+
payload: { isOpen: true },
|
|
310
|
+
});
|
|
311
|
+
// Once connected, later shakes emit directly (open-only, never close).
|
|
312
|
+
openDevtoolsShell();
|
|
313
|
+
expect(received).toHaveLength(2);
|
|
314
|
+
expect(received[1]?.payload).toEqual({ isOpen: true });
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe("upstream contract (installed packages)", () => {
|
|
319
|
+
// The trigger-toggled bus event is an internal implementation detail of
|
|
320
|
+
// @tanstack/devtools, not documented public API. Pin it against the
|
|
321
|
+
// installed packages so an upstream rename fails tests instead of
|
|
322
|
+
// silently breaking shake-to-open.
|
|
323
|
+
/**
|
|
324
|
+
* Locate an installed package dir regardless of linker layout: walk
|
|
325
|
+
* ancestor node_modules (hoisted puts everything at the repo root),
|
|
326
|
+
* then fall back to a sibling of a package that depends on it
|
|
327
|
+
* (pnpm's isolated virtual store keeps dependencies as siblings).
|
|
328
|
+
*/
|
|
329
|
+
function findInstalled(pkg: string, siblingOf?: string): string | null {
|
|
330
|
+
let dir = here;
|
|
331
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
332
|
+
const candidate = join(dir, "node_modules", pkg);
|
|
333
|
+
if (existsSync(candidate)) return realpathSync(candidate);
|
|
334
|
+
const parent = dirname(dir);
|
|
335
|
+
if (parent === dir) break;
|
|
336
|
+
dir = parent;
|
|
337
|
+
}
|
|
338
|
+
if (siblingOf) {
|
|
339
|
+
const sibling = join(siblingOf, "..", pkg.split("/").pop() as string);
|
|
340
|
+
if (existsSync(sibling)) return realpathSync(sibling);
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
it("@tanstack/devtools still listens for the trigger-toggled event", () => {
|
|
346
|
+
const reactDevtoolsDir = findInstalled("@tanstack/react-devtools");
|
|
347
|
+
expect(reactDevtoolsDir).toBeTruthy();
|
|
348
|
+
const devtoolsDir = findInstalled("@tanstack/devtools", reactDevtoolsDir as string);
|
|
349
|
+
expect(devtoolsDir).toBeTruthy();
|
|
350
|
+
const chunkDir = join(devtoolsDir as string, "dist", "devtools");
|
|
351
|
+
const chunks = readdirSync(chunkDir).filter((f) => f.endsWith(".js"));
|
|
352
|
+
const hit = chunks.some((f) =>
|
|
353
|
+
readFileSync(join(chunkDir, f), "utf8").includes(`"${devtoolsTriggerToggledEvent}"`),
|
|
354
|
+
);
|
|
355
|
+
expect(hit).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("@tanstack/devtools-client still uses the tanstack-devtools-core plugin id", () => {
|
|
359
|
+
const reactDevtoolsDir = findInstalled("@tanstack/react-devtools");
|
|
360
|
+
const devtoolsDir = findInstalled("@tanstack/devtools", reactDevtoolsDir as string);
|
|
361
|
+
const clientDir = findInstalled("@tanstack/devtools-client", devtoolsDir as string);
|
|
362
|
+
expect(clientDir).toBeTruthy();
|
|
363
|
+
const source = readFileSync(join(clientDir as string, "dist", "esm", "index.js"), "utf8");
|
|
364
|
+
expect(source).toContain(`pluginId: "${devtoolsShellPluginId}"`);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("@tanstack/pacer-devtools/production resolves the REAL core, not the NoOp", async () => {
|
|
368
|
+
// A NoOp core (constructCoreClass's second class) has silent
|
|
369
|
+
// mount/unmount; the real core throws "Devtools is not mounted" when
|
|
370
|
+
// unmounted before mount. That behavioral difference — not an import
|
|
371
|
+
// string — is what proves a deployed bundle gets a working Pacer panel.
|
|
372
|
+
const pacerDir = findInstalled("@tanstack/pacer-devtools");
|
|
373
|
+
expect(pacerDir).toBeTruthy();
|
|
374
|
+
|
|
375
|
+
// Sanity-check the trap first: vitest runs with NODE_ENV !== "development",
|
|
376
|
+
// the same condition a deployed bundle sees, so the MAIN entry's gate
|
|
377
|
+
// must hand back the NoOp here. If this half fails, upstream removed the
|
|
378
|
+
// gate and the /production workaround can be dropped.
|
|
379
|
+
const mainEntry = await import(
|
|
380
|
+
pathToFileURL(join(pacerDir as string, "dist", "index.js")).href
|
|
381
|
+
);
|
|
382
|
+
expect(() => new mainEntry.PacerDevtoolsCore().unmount()).not.toThrow();
|
|
383
|
+
|
|
384
|
+
// The entry the shell actually imports must be the real core.
|
|
385
|
+
const prodEntry = await import(
|
|
386
|
+
pathToFileURL(join(pacerDir as string, "dist", "production", "index.js")).href
|
|
387
|
+
);
|
|
388
|
+
expect(() => new prodEntry.PacerDevtoolsCore().unmount()).toThrow(/not mounted/i);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
describe("motion arming (deliberate, app-driven grant)", () => {
|
|
393
|
+
const realWindow = (globalThis as { window?: unknown }).window;
|
|
394
|
+
const realMotion = (globalThis as { DeviceMotionEvent?: unknown }).DeviceMotionEvent;
|
|
395
|
+
|
|
396
|
+
afterEach(() => {
|
|
397
|
+
__resetMotionArming();
|
|
398
|
+
(globalThis as { window?: unknown }).window = realWindow;
|
|
399
|
+
(globalThis as { DeviceMotionEvent?: unknown }).DeviceMotionEvent = realMotion;
|
|
400
|
+
vi.restoreAllMocks();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
/** Stand up a window (an EventTarget) and, optionally, an iOS motion ctor. */
|
|
404
|
+
function stubEnv(requestPermission?: () => Promise<string>) {
|
|
405
|
+
const win = new EventTarget();
|
|
406
|
+
(globalThis as { window?: unknown }).window = win;
|
|
407
|
+
(globalThis as { DeviceMotionEvent?: unknown }).DeviceMotionEvent = requestPermission
|
|
408
|
+
? { requestPermission }
|
|
409
|
+
: {};
|
|
410
|
+
return win as EventTarget & { addEventListener: EventTarget["addEventListener"] };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
it("isMotionPermissionRequired is true only when requestPermission exists (iOS)", () => {
|
|
414
|
+
stubEnv(async () => "granted");
|
|
415
|
+
expect(isMotionPermissionRequired()).toBe(true);
|
|
416
|
+
stubEnv(undefined);
|
|
417
|
+
expect(isMotionPermissionRequired()).toBe(false);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("grants immediately with no prompt on Android/desktop and broadcasts granted", async () => {
|
|
421
|
+
const win = stubEnv(undefined);
|
|
422
|
+
const armed: Array<boolean> = [];
|
|
423
|
+
win.addEventListener(devtoolsMotionArmedEvent, (e) =>
|
|
424
|
+
armed.push((e as CustomEvent).detail.granted),
|
|
425
|
+
);
|
|
426
|
+
const state = await requestMotionPermissionFromGesture();
|
|
427
|
+
expect(state).toBe("granted");
|
|
428
|
+
expect(armed).toEqual([true]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("asks once on iOS and broadcasts the granted outcome", async () => {
|
|
432
|
+
const requestPermission = vi.fn().mockResolvedValue("granted");
|
|
433
|
+
const win = stubEnv(requestPermission);
|
|
434
|
+
const armed: Array<boolean> = [];
|
|
435
|
+
win.addEventListener(devtoolsMotionArmedEvent, (e) =>
|
|
436
|
+
armed.push((e as CustomEvent).detail.granted),
|
|
437
|
+
);
|
|
438
|
+
const state = await requestMotionPermissionFromGesture();
|
|
439
|
+
expect(requestPermission).toHaveBeenCalledTimes(1);
|
|
440
|
+
expect(state).toBe("granted");
|
|
441
|
+
expect(armed).toEqual([true]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("broadcasts a denial and does not re-prompt once granted", async () => {
|
|
445
|
+
const denied = vi.fn().mockResolvedValue("denied");
|
|
446
|
+
const win = stubEnv(denied);
|
|
447
|
+
const armed: Array<boolean> = [];
|
|
448
|
+
win.addEventListener(devtoolsMotionArmedEvent, (e) =>
|
|
449
|
+
armed.push((e as CustomEvent).detail.granted),
|
|
450
|
+
);
|
|
451
|
+
expect(await requestMotionPermissionFromGesture()).toBe("denied");
|
|
452
|
+
expect(armed).toEqual([false]);
|
|
453
|
+
|
|
454
|
+
// A later grant flips it; after that the flow short-circuits without
|
|
455
|
+
// touching requestPermission again (Safari remembers the answer).
|
|
456
|
+
__resetMotionArming();
|
|
457
|
+
const granted = vi.fn().mockResolvedValue("granted");
|
|
458
|
+
stubEnv(granted);
|
|
459
|
+
expect(await requestMotionPermissionFromGesture()).toBe("granted");
|
|
460
|
+
expect(await requestMotionPermissionFromGesture()).toBe("granted");
|
|
461
|
+
expect(granted).toHaveBeenCalledTimes(1);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("armDevtoolsMotion dispatches the arm event on window", () => {
|
|
465
|
+
const win = stubEnv(async () => "granted");
|
|
466
|
+
const seen = vi.fn();
|
|
467
|
+
win.addEventListener(devtoolsArmMotionEvent, seen);
|
|
468
|
+
armDevtoolsMotion();
|
|
469
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
describe("devtools shell wiring", () => {
|
|
474
|
+
const shellSource = readFileSync(join(here, "TanstackDevtools.tsx"), "utf8");
|
|
475
|
+
const triggerSource = readFileSync(join(here, "devtoolsTrigger.ts"), "utf8");
|
|
476
|
+
|
|
477
|
+
it("opens focused on the Theme tab (defaultOpen on the theme plugin)", () => {
|
|
478
|
+
// The theme plugin object carries `defaultOpen: true` so a shake lands
|
|
479
|
+
// on the theme flipper on first open.
|
|
480
|
+
expect(shellSource).toMatch(/id:\s*"theme"[\s\S]*?defaultOpen:\s*true/);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("arms iOS motion deliberately, never by grabbing an arbitrary tap", () => {
|
|
484
|
+
// The old build grabbed the first `pointerup` anywhere to self-grant,
|
|
485
|
+
// which popped the iOS prompt on a random tap. The trigger is now a
|
|
486
|
+
// deliberate arm event and the pointerup grab is gone.
|
|
487
|
+
expect(triggerSource).not.toContain('addEventListener("pointerup"');
|
|
488
|
+
expect(triggerSource).toContain("window.addEventListener(devtoolsArmMotionEvent");
|
|
489
|
+
expect(shellSource).toContain("useShakeToOpen(tanstackConfig.shakeToOpen !== false)");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("binds Cmd/Ctrl+Shift+` (TanStack e.key for Shift+Backquote is ~)", () => {
|
|
493
|
+
// Unshifted Ctrl/Cmd+` is the macOS "cycle windows" shortcut — the
|
|
494
|
+
// page never sees it. Shift+Backquote produces e.key "~", and
|
|
495
|
+
// @solid-primitives/keyboard matches an exact e.key sequence, so the
|
|
496
|
+
// TanStack combo must include both Shift and "~". The capture-phase
|
|
497
|
+
// physical-key listener (e.code === "Backquote") is the source of
|
|
498
|
+
// truth across layouts; this constant is what TanStack's own matcher
|
|
499
|
+
// gets so it agrees on US keyboards.
|
|
500
|
+
expect(devtoolsOpenHotkey).toEqual(["CtrlOrMeta", "Shift", "~"]);
|
|
501
|
+
expect(shellSource).toContain("openHotkey: [...devtoolsOpenHotkey]");
|
|
502
|
+
expect(shellSource).toContain("useDevtoolsKeyboardTrigger(");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("mounts shake-to-open with the config opt-out", () => {
|
|
506
|
+
expect(shellSource).toContain("useShakeToOpen(tanstackConfig.shakeToOpen !== false)");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("forwards the accent channel to the theme panel", () => {
|
|
510
|
+
// `ThemeDevtoolsPanel` takes themeColors / currentThemeColor /
|
|
511
|
+
// onThemeColorChange and renders its accent dropdown only when the list
|
|
512
|
+
// and the setter are both present. The shell mounts that panel and used
|
|
513
|
+
// to pass none of the three, so the control existed and was unreachable
|
|
514
|
+
// through the only wrapper that mounts it.
|
|
515
|
+
for (const prop of ["themeColors", "currentThemeColor", "onThemeColorChange"]) {
|
|
516
|
+
expect(shellSource).toContain(`${prop}={tanstackConfig.${prop}}`);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("keeps the accent channel app-owned — the shell invents no default", () => {
|
|
521
|
+
// A theme colour is app state. The shell forwards what the app gives it
|
|
522
|
+
// and nothing else, so an app that wires nothing sees the panel it saw
|
|
523
|
+
// before this change rather than a dropdown that changes nothing.
|
|
524
|
+
expect(shellSource).not.toMatch(/themeColors=\{(?!tanstackConfig\.themeColors\})/);
|
|
525
|
+
expect(shellSource).not.toMatch(
|
|
526
|
+
/onThemeColorChange=\{(?!tanstackConfig\.onThemeColorChange\})/,
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("loads the plugin panels through their production entries", () => {
|
|
531
|
+
// The main entries dead-code themselves into no-ops when
|
|
532
|
+
// NODE_ENV !== "development"; a deployed build needs /production.
|
|
533
|
+
expect(shellSource).toContain('"@tanstack/react-query-devtools/production"');
|
|
534
|
+
expect(shellSource).toContain('"@tanstack/react-form-devtools/production"');
|
|
535
|
+
// Pacer cannot go through @tanstack/react-pacer-devtools/production:
|
|
536
|
+
// that entry transitively imports @tanstack/pacer-devtools' NODE_ENV-gated
|
|
537
|
+
// MAIN entry, so its "production" plugin mounts the NoOp core. The shell
|
|
538
|
+
// rebuilds the plugin from the ungated core instead.
|
|
539
|
+
expect(shellSource).toContain('"@tanstack/pacer-devtools/production"');
|
|
540
|
+
expect(shellSource).toContain("createReactPanel(PacerDevtoolsCore)");
|
|
541
|
+
expect(shellSource).not.toContain('from "@tanstack/react-pacer-devtools');
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// Type-level lock: the three accent fields on TanstackConfig ARE the panel's,
|
|
547
|
+
// so widening or renaming one upstream breaks the typecheck here instead of
|
|
548
|
+
// silently dropping the channel again.
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
type PanelProps = import("@multiplatform.one/theme").ThemeDevtoolsPanelProps;
|
|
551
|
+
type Cfg = import("./CreateApp").TanstackConfig;
|
|
552
|
+
type _AccentChannelMatches =
|
|
553
|
+
Pick<Cfg, "themeColors" | "currentThemeColor" | "onThemeColorChange"> extends Pick<
|
|
554
|
+
PanelProps,
|
|
555
|
+
"themeColors" | "currentThemeColor" | "onThemeColorChange"
|
|
556
|
+
>
|
|
557
|
+
? true
|
|
558
|
+
: never;
|
|
559
|
+
const _accentChannelLock: _AccentChannelMatches = true;
|
|
560
|
+
void _accentChannelLock;
|