@digital-ai/dot-illustrations 2.0.45 → 2.0.46

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.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression test for the device-flow login polling in github-upload.js.
4
+ *
5
+ * Guards the fix from #90: ghPollForToken must honor GitHub's `slow_down`
6
+ * backoff and must not fire a burst of sub-interval polls when the tab is
7
+ * refocused after the user authorises — otherwise the connect modal stalls
8
+ * forever on the code screen. Ported from digital-ai/dot-icons, which hit
9
+ * and fixed the same bug in the same demo pattern (icons #85, #86).
10
+ *
11
+ * It runs the ACTUAL ghPollForToken extracted from github-upload.js (not a
12
+ * copy) against a mocked fetch/document/window, replaying the real failure
13
+ * scenario: a focus + visibilitychange refocus burst, a `slow_down` from
14
+ * GitHub, then the token. Zero dependencies — plain Node.
15
+ *
16
+ * Run: node demo/device-flow-poll.test.js (or: npm test)
17
+ */
18
+ 'use strict';
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // ── Extract ghPollForToken from the source by brace-matching ────────────────
24
+ const src = fs.readFileSync(path.join(__dirname, 'github-upload.js'), 'utf8');
25
+ const anchor = src.indexOf('function ghPollForToken');
26
+ if (anchor === -1) fail('ghPollForToken not found in github-upload.js');
27
+ let depth = 0, end = -1, started = false;
28
+ for (let i = anchor; i < src.length; i++) {
29
+ const c = src[i];
30
+ if (c === '{') { depth++; started = true; }
31
+ else if (c === '}') { depth--; if (started && depth === 0) { end = i + 1; break; } }
32
+ }
33
+ if (end === -1) fail('could not brace-match ghPollForToken body');
34
+ const fnText = src.slice(anchor, end);
35
+
36
+ // ── Browser stubs ───────────────────────────────────────────────────────────
37
+ const listeners = { visibilitychange: [], focus: [] };
38
+ const document = {
39
+ visibilityState: 'visible',
40
+ addEventListener: (e, cb) => listeners[e] && listeners[e].push(cb),
41
+ removeEventListener: (e, cb) => { if (listeners[e]) listeners[e] = listeners[e].filter(f => f !== cb); },
42
+ };
43
+ const window = {
44
+ addEventListener: (e, cb) => listeners[e] && listeners[e].push(cb),
45
+ removeEventListener: (e, cb) => { if (listeners[e]) listeners[e] = listeners[e].filter(f => f !== cb); },
46
+ };
47
+
48
+ // Scripted GitHub responses, one per /access_token poll.
49
+ const RESPONSES = [
50
+ { error: 'authorization_pending' },
51
+ { error: 'slow_down', interval: 10 }, // GitHub raises the required interval to 10s
52
+ { access_token: 'gho_REGRESSION_TEST_TOKEN' },
53
+ ];
54
+ const pollTimes = [];
55
+ let step = 0;
56
+ const t0 = Date.now();
57
+ global.fetch = async () => {
58
+ pollTimes.push(Date.now() - t0);
59
+ const body = RESPONSES[Math.min(step, RESPONSES.length - 1)];
60
+ step++;
61
+ return { ok: true, status: 200, json: async () => body };
62
+ };
63
+
64
+ const GITHUB_UPLOAD_CONFIG = { OAUTH_PROXY_URL: 'https://proxy.test', CLIENT_ID: 'test' };
65
+
66
+ let ghPollForToken;
67
+ eval(`ghPollForToken = ${fnText}`);
68
+
69
+ // ── Run the scenario ────────────────────────────────────────────────────────
70
+ function fail(msg) { console.error(`\nFAIL ❌ — ${msg}`); process.exit(1); }
71
+
72
+ (async () => {
73
+ const promise = ghPollForToken('device-code', 5, 899);
74
+
75
+ // Refocus burst shortly after start: both events fire in the same tick.
76
+ // The fixed code must throttle these to the current interval.
77
+ setTimeout(() => {
78
+ listeners.visibilitychange.forEach(cb => cb());
79
+ listeners.focus.forEach(cb => cb());
80
+ }, 1200);
81
+
82
+ let token;
83
+ try {
84
+ token = await promise;
85
+ } catch (e) {
86
+ fail(`polling rejected unexpectedly: ${e.message}`);
87
+ }
88
+
89
+ const gaps = pollTimes.slice(1).map((t, i) => t - pollTimes[i]);
90
+
91
+ if (token !== 'gho_REGRESSION_TEST_TOKEN')
92
+ fail(`expected the token to resolve, got: ${token}`);
93
+ if (pollTimes.length < 3)
94
+ fail(`expected at least 3 polls (pending, slow_down, token), got ${pollTimes.length}`);
95
+ // No self-inflicted burst: no two polls closer than ~the 5s interval, even
96
+ // though a focus+visibilitychange pair fired mid-flight.
97
+ const MIN_GAP = 4800;
98
+ const burst = gaps.find(g => g < MIN_GAP);
99
+ if (burst !== undefined)
100
+ fail(`polls fired ${Math.round(burst)}ms apart (< ${MIN_GAP}ms) — refocus burst not throttled`);
101
+ // Backoff: the interval must widen after slow_down (10s gap > the initial 5s).
102
+ if (!(gaps[1] > gaps[0] + 3000))
103
+ fail(`interval did not widen after slow_down (gaps ${gaps.map(Math.round)})`);
104
+
105
+ console.log('device-flow polling regression test');
106
+ console.log(` poll times (ms): ${pollTimes.map(Math.round).join(', ')}`);
107
+ console.log(` gaps (ms): ${gaps.map(Math.round).join(', ')}`);
108
+ console.log('\nPASS ✅ — token resolved, no sub-interval burst, slow_down widened the interval');
109
+ process.exit(0);
110
+ })();
@@ -161,9 +161,62 @@ async function ghStartDeviceFlow() {
161
161
  return resp.json();
162
162
  }
163
163
 
164
- function ghPollForToken(deviceCode, intervalSec) {
164
+ /**
165
+ * Poll GitHub (via the oauth-proxy Worker) until the user authorises (or times out).
166
+ * Resolves with the access_token string.
167
+ *
168
+ * The intended UX is to approve the code on another tab/device while this tab
169
+ * waits — which backgrounds this tab, and browsers throttle setInterval in
170
+ * background tabs (down to ~once/minute or slower). The token is usually ready
171
+ * within seconds of approval, so also poll immediately on `visibilitychange`
172
+ * and `focus` to catch up as soon as the user switches back, instead of
173
+ * waiting on the throttled timer.
174
+ *
175
+ * A single failed poll (transient network blip, a fetch aborted because the
176
+ * tab was backgrounded, etc.) must not kill the whole flow — it just retries
177
+ * on the next tick. GitHub's own `expired_token` response is the normal stop
178
+ * condition, but if the proxy itself is unreachable we'd never get that
179
+ * response from GitHub at all — so `expiresInSec` also bounds retries
180
+ * client-side to the code's real lifetime, instead of polling forever.
181
+ *
182
+ * GitHub's device-flow spec mandates a client-side backoff: when the token
183
+ * endpoint answers `slow_down`, the client MUST raise its polling interval
184
+ * (GitHub returns the new minimum in the response, otherwise add 5s) and use
185
+ * the larger value from then on. Polling faster than the current interval
186
+ * just gets `slow_down` again — so a client that ignores it never receives
187
+ * the token and the flow stalls on the code screen even after the user has
188
+ * authorised. The `focus`/`visibilitychange` wake-ups (which catch up the
189
+ * throttled background timer as soon as the user switches back) are exactly
190
+ * what would otherwise fire a burst of sub-interval polls and trip
191
+ * `slow_down` in the first place, so they are throttled to the current
192
+ * interval too, and only ever one poll is in flight at a time.
193
+ */
194
+ function ghPollForToken(deviceCode, intervalSec, expiresInSec) {
165
195
  return new Promise((resolve, reject) => {
166
- const timer = setInterval(async () => {
196
+ let settled = false;
197
+ let inFlight = false;
198
+ let intervalMs = Math.max(intervalSec || 5, 5) * 1000;
199
+ let lastPollAt = 0;
200
+ let timer = null;
201
+ const deadline = Date.now() + (expiresInSec || 900) * 1000;
202
+
203
+ // (Re)arm the next poll `delay` ms from now (clamped to ≥0, so an
204
+ // already-due wake-up polls immediately). Always supersedes any pending
205
+ // timer so the interval can grow after a `slow_down`.
206
+ function schedule(delay) {
207
+ if (settled) return;
208
+ clearTimeout(timer);
209
+ timer = setTimeout(poll, Math.max(delay, 0));
210
+ }
211
+
212
+ async function poll() {
213
+ if (settled || inFlight) return;
214
+ if (Date.now() > deadline) {
215
+ settle(() => reject(new Error('Device code expired — please try again.')));
216
+ return;
217
+ }
218
+ inFlight = true;
219
+ lastPollAt = Date.now();
167
220
  try {
168
221
  const resp = await fetch(`${GITHUB_UPLOAD_CONFIG.OAUTH_PROXY_URL}/access_token`, {
169
222
  method: 'POST',
@@ -174,13 +227,52 @@ function ghPollForToken(deviceCode, intervalSec) {
174
227
  grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
175
228
  }),
176
229
  });
177
- const d = await resp.json();
178
- if (d.access_token) { clearInterval(timer); resolve(d.access_token); }
179
- else if (d.error === 'expired_token' || d.error === 'access_denied') {
180
- clearInterval(timer); reject(new Error(d.error_description || d.error));
230
+ const data = await resp.json();
231
+ if (settled) return;
232
+ if (data.access_token) {
233
+ settle(() => resolve(data.access_token));
234
+ return;
235
+ }
236
+ if (data.error === 'expired_token' || data.error === 'access_denied') {
237
+ settle(() => reject(new Error(data.error_description || data.error)));
238
+ return;
181
239
  }
182
- } catch (e) { clearInterval(timer); reject(e); }
183
- }, Math.max(intervalSec, 5) * 1000);
240
+ if (data.error === 'slow_down') {
241
+ // Honor GitHub's backoff: use the interval it hands back, else +5s.
242
+ intervalMs = Math.max(data.interval ? data.interval * 1000 : intervalMs + 5000,
243
+ intervalMs + 5000);
244
+ }
245
+ // 'authorization_pending' or 'slow_down' — keep polling on the interval
246
+ } catch (err) {
247
+ // Transient failure (network blip, backgrounded-tab fetch abort) —
248
+ // keep polling rather than killing the whole flow.
249
+ console.warn('Device-flow poll failed, retrying:', err);
250
+ } finally {
251
+ inFlight = false;
252
+ }
253
+ schedule(intervalMs);
254
+ }
255
+
256
+ function settle(fn) {
257
+ settled = true;
258
+ clearTimeout(timer);
259
+ document.removeEventListener('visibilitychange', onVisible);
260
+ window.removeEventListener('focus', onVisible);
261
+ fn();
262
+ }
263
+
264
+ // Wake up as soon as the user switches back to this tab, but never poll
265
+ // sooner than the current interval since the last poll — otherwise the
266
+ // focus + visibilitychange pair fires two sub-interval polls and trips
267
+ // GitHub's `slow_down`. If a poll is already due, this fires ~immediately.
268
+ function onVisible() {
269
+ if (settled || document.visibilityState !== 'visible') return;
270
+ schedule(intervalMs - (Date.now() - lastPollAt));
271
+ }
272
+
273
+ document.addEventListener('visibilitychange', onVisible);
274
+ window.addEventListener('focus', onVisible);
275
+ poll(); // first poll now; it self-schedules the rest
184
276
  });
185
277
  }
186
278
 
package/demo/script.js CHANGED
@@ -632,7 +632,7 @@ document.addEventListener('DOMContentLoaded', () => {
632
632
  document.getElementById('gh-device-code').textContent = user_code;
633
633
  document.getElementById('gh-copy-code-btn').onclick = () =>
634
634
  navigator.clipboard.writeText(user_code).then(() => showToast('Code copied!'));
635
- const token = await window.GitHubUpload.pollForToken(device_code, interval || 5);
635
+ const token = await window.GitHubUpload.pollForToken(device_code, interval || 5, expires_in);
636
636
  window.GitHubUpload.setToken(token);
637
637
  const user = await window.GitHubUpload.getUser();
638
638
  updateAuthBadge(user);
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@digital-ai/dot-illustrations",
3
- "version": "2.0.45",
3
+ "version": "2.0.46",
4
4
  "description": "A central place for the design team to keep illustrations and for dev teams to find them.",
5
5
  "main": "./index.css",
6
6
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
7
+ "test": "node demo/device-flow-poll.test.js"
8
8
  },
9
9
  "repository": {
10
10
  "type": "git",