zero-query 0.9.6 → 0.9.8
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/README.md +36 -8
- package/cli/commands/build.js +50 -3
- package/cli/commands/create.js +22 -9
- package/cli/help.js +2 -0
- package/cli/scaffold/default/app/app.js +211 -0
- package/cli/scaffold/default/app/components/about.js +201 -0
- package/cli/scaffold/default/app/components/api-demo.js +143 -0
- package/cli/scaffold/default/app/components/contact-card.js +231 -0
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -0
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -0
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -0
- package/cli/scaffold/default/app/components/counter.js +127 -0
- package/cli/scaffold/default/app/components/home.js +249 -0
- package/cli/scaffold/{app → default/app}/components/not-found.js +2 -2
- package/cli/scaffold/default/app/components/playground/playground.css +116 -0
- package/cli/scaffold/default/app/components/playground/playground.html +162 -0
- package/cli/scaffold/default/app/components/playground/playground.js +117 -0
- package/cli/scaffold/default/app/components/todos.js +225 -0
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -0
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -0
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -0
- package/cli/scaffold/default/app/routes.js +15 -0
- package/cli/scaffold/{app → default/app}/store.js +15 -10
- package/cli/scaffold/{global.css → default/global.css} +238 -252
- package/cli/scaffold/{index.html → default/index.html} +35 -0
- package/cli/scaffold/{app → minimal/app}/app.js +37 -39
- package/cli/scaffold/minimal/app/components/about.js +68 -0
- package/cli/scaffold/minimal/app/components/counter.js +122 -0
- package/cli/scaffold/minimal/app/components/home.js +68 -0
- package/cli/scaffold/minimal/app/components/not-found.js +16 -0
- package/cli/scaffold/minimal/app/routes.js +9 -0
- package/cli/scaffold/minimal/app/store.js +36 -0
- package/cli/scaffold/minimal/assets/.gitkeep +0 -0
- package/cli/scaffold/minimal/global.css +291 -0
- package/cli/scaffold/minimal/index.html +44 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +1949 -1894
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +10 -1
- package/index.js +5 -3
- package/package.json +1 -1
- package/src/component.js +6 -3
- package/src/diff.js +15 -2
- package/src/http.js +37 -0
- package/tests/cli.test.js +304 -0
- package/tests/http.test.js +200 -0
- package/types/http.d.ts +15 -4
- package/cli/scaffold/app/components/about.js +0 -131
- package/cli/scaffold/app/components/api-demo.js +0 -103
- package/cli/scaffold/app/components/contacts/contacts.css +0 -246
- package/cli/scaffold/app/components/contacts/contacts.html +0 -140
- package/cli/scaffold/app/components/contacts/contacts.js +0 -153
- package/cli/scaffold/app/components/counter.js +0 -85
- package/cli/scaffold/app/components/home.js +0 -137
- package/cli/scaffold/app/components/todos.js +0 -131
- package/cli/scaffold/app/routes.js +0 -13
- /package/cli/scaffold/{LICENSE → default/LICENSE} +0 -0
- /package/cli/scaffold/{assets → default/assets}/.gitkeep +0 -0
- /package/cli/scaffold/{favicon.ico → default/favicon.ico} +0 -0
package/src/component.js
CHANGED
|
@@ -502,11 +502,14 @@ class Component {
|
|
|
502
502
|
this._bindRefs();
|
|
503
503
|
this._bindModels();
|
|
504
504
|
|
|
505
|
-
// Restore focus if the morph replaced the focused element
|
|
505
|
+
// Restore focus if the morph replaced the focused element.
|
|
506
|
+
// Always restore selectionRange — even when the element is still
|
|
507
|
+
// the activeElement — because _bindModels or morph attribute syncing
|
|
508
|
+
// can alter the value and move the cursor.
|
|
506
509
|
if (_focusInfo) {
|
|
507
510
|
const el = this._el.querySelector(_focusInfo.selector);
|
|
508
|
-
if (el
|
|
509
|
-
el.focus();
|
|
511
|
+
if (el) {
|
|
512
|
+
if (el !== document.activeElement) el.focus();
|
|
510
513
|
try {
|
|
511
514
|
if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
|
|
512
515
|
el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
|
package/src/diff.js
CHANGED
|
@@ -89,6 +89,11 @@ export function morphElement(oldEl, newHTML) {
|
|
|
89
89
|
return clone;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Aliases for the concat build — core.js imports these as _morph / _morphElement,
|
|
93
|
+
// but the build strips `import … as` lines, so the aliases must exist at runtime.
|
|
94
|
+
const _morph = morph;
|
|
95
|
+
const _morphElement = morphElement;
|
|
96
|
+
|
|
92
97
|
/**
|
|
93
98
|
* Reconcile children of `oldParent` to match `newParent`.
|
|
94
99
|
*
|
|
@@ -434,6 +439,13 @@ function _morphAttributes(oldEl, newEl) {
|
|
|
434
439
|
|
|
435
440
|
/**
|
|
436
441
|
* Sync input element value, checked, disabled states.
|
|
442
|
+
*
|
|
443
|
+
* Only updates the value when the new HTML explicitly carries a `value`
|
|
444
|
+
* attribute. Templates that use z-model manage values through reactive
|
|
445
|
+
* state + _bindModels — the morph engine should not interfere by wiping
|
|
446
|
+
* a live input's content to '' just because the template has no `value`
|
|
447
|
+
* attr. This prevents the wipe-then-restore cycle that resets cursor
|
|
448
|
+
* position on every keystroke.
|
|
437
449
|
*/
|
|
438
450
|
function _syncInputValue(oldEl, newEl) {
|
|
439
451
|
const type = (oldEl.type || '').toLowerCase();
|
|
@@ -441,8 +453,9 @@ function _syncInputValue(oldEl, newEl) {
|
|
|
441
453
|
if (type === 'checkbox' || type === 'radio') {
|
|
442
454
|
if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
|
|
443
455
|
} else {
|
|
444
|
-
|
|
445
|
-
|
|
456
|
+
const newVal = newEl.getAttribute('value');
|
|
457
|
+
if (newVal !== null && oldEl.value !== newVal) {
|
|
458
|
+
oldEl.value = newVal;
|
|
446
459
|
}
|
|
447
460
|
}
|
|
448
461
|
|
package/src/http.js
CHANGED
|
@@ -165,6 +165,7 @@ export const http = {
|
|
|
165
165
|
put: (url, data, opts) => request('PUT', url, data, opts),
|
|
166
166
|
patch: (url, data, opts) => request('PATCH', url, data, opts),
|
|
167
167
|
delete: (url, data, opts) => request('DELETE', url, data, opts),
|
|
168
|
+
head: (url, opts) => request('HEAD', url, undefined, opts),
|
|
168
169
|
|
|
169
170
|
/**
|
|
170
171
|
* Configure defaults
|
|
@@ -175,20 +176,56 @@ export const http = {
|
|
|
175
176
|
if (opts.timeout !== undefined) _config.timeout = opts.timeout;
|
|
176
177
|
},
|
|
177
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Read-only snapshot of current configuration
|
|
181
|
+
*/
|
|
182
|
+
getConfig() {
|
|
183
|
+
return {
|
|
184
|
+
baseURL: _config.baseURL,
|
|
185
|
+
headers: { ..._config.headers },
|
|
186
|
+
timeout: _config.timeout,
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
|
|
178
190
|
/**
|
|
179
191
|
* Add request interceptor
|
|
180
192
|
* @param {Function} fn — (fetchOpts, url) → void | false | { url, options }
|
|
193
|
+
* @returns {Function} unsubscribe function
|
|
181
194
|
*/
|
|
182
195
|
onRequest(fn) {
|
|
183
196
|
_interceptors.request.push(fn);
|
|
197
|
+
return () => {
|
|
198
|
+
const idx = _interceptors.request.indexOf(fn);
|
|
199
|
+
if (idx !== -1) _interceptors.request.splice(idx, 1);
|
|
200
|
+
};
|
|
184
201
|
},
|
|
185
202
|
|
|
186
203
|
/**
|
|
187
204
|
* Add response interceptor
|
|
188
205
|
* @param {Function} fn — (result) → void
|
|
206
|
+
* @returns {Function} unsubscribe function
|
|
189
207
|
*/
|
|
190
208
|
onResponse(fn) {
|
|
191
209
|
_interceptors.response.push(fn);
|
|
210
|
+
return () => {
|
|
211
|
+
const idx = _interceptors.response.indexOf(fn);
|
|
212
|
+
if (idx !== -1) _interceptors.response.splice(idx, 1);
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Clear interceptors — all, or just 'request' / 'response'
|
|
218
|
+
*/
|
|
219
|
+
clearInterceptors(type) {
|
|
220
|
+
if (!type || type === 'request') _interceptors.request.length = 0;
|
|
221
|
+
if (!type || type === 'response') _interceptors.response.length = 0;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run multiple requests in parallel
|
|
226
|
+
*/
|
|
227
|
+
all(requests) {
|
|
228
|
+
return Promise.all(requests);
|
|
192
229
|
},
|
|
193
230
|
|
|
194
231
|
/**
|
package/tests/cli.test.js
CHANGED
|
@@ -454,3 +454,307 @@ describe('CLI — flag/option extra', () => {
|
|
|
454
454
|
expect(flag('watch', 'w')).toBe(true);
|
|
455
455
|
});
|
|
456
456
|
});
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
// ===========================================================================
|
|
460
|
+
// createProject — scaffold command
|
|
461
|
+
// ===========================================================================
|
|
462
|
+
|
|
463
|
+
describe('CLI — createProject', () => {
|
|
464
|
+
const fs = require('fs');
|
|
465
|
+
const path = require('path');
|
|
466
|
+
const os = require('os');
|
|
467
|
+
|
|
468
|
+
let createProject;
|
|
469
|
+
|
|
470
|
+
beforeEach(async () => {
|
|
471
|
+
vi.resetModules();
|
|
472
|
+
const mod = await import('../cli/commands/create.js');
|
|
473
|
+
createProject = mod.default || mod;
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
function tmpDir() {
|
|
477
|
+
const dir = path.join(os.tmpdir(), 'zq-create-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8));
|
|
478
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
479
|
+
return dir;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function cleanup(dir) {
|
|
483
|
+
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// -- scaffold variant directories exist --
|
|
487
|
+
|
|
488
|
+
it('default scaffold directory exists', () => {
|
|
489
|
+
const dir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default');
|
|
490
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('minimal scaffold directory exists', () => {
|
|
494
|
+
const dir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
|
|
495
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// -- default scaffold contains expected files --
|
|
499
|
+
|
|
500
|
+
it('default scaffold has index.html', () => {
|
|
501
|
+
const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'index.html');
|
|
502
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('default scaffold has app/app.js', () => {
|
|
506
|
+
const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'app', 'app.js');
|
|
507
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('default scaffold has app/routes.js', () => {
|
|
511
|
+
const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'app', 'routes.js');
|
|
512
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('default scaffold has app/store.js', () => {
|
|
516
|
+
const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'app', 'store.js');
|
|
517
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// -- minimal scaffold contains expected files --
|
|
521
|
+
|
|
522
|
+
it('minimal scaffold has index.html', () => {
|
|
523
|
+
const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'index.html');
|
|
524
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('minimal scaffold has app/app.js', () => {
|
|
528
|
+
const f = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'app', 'app.js');
|
|
529
|
+
expect(fs.existsSync(f)).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('minimal scaffold has 4 components', () => {
|
|
533
|
+
const dir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'app', 'components');
|
|
534
|
+
const files = fs.readdirSync(dir).sort();
|
|
535
|
+
expect(files).toEqual(['about.js', 'counter.js', 'home.js', 'not-found.js']);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// -- minimal scaffold is a subset of default --
|
|
539
|
+
|
|
540
|
+
it('minimal scaffold has fewer files than default', () => {
|
|
541
|
+
function countFiles(dir) {
|
|
542
|
+
let count = 0;
|
|
543
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
544
|
+
if (entry.isDirectory()) count += countFiles(path.join(dir, entry.name));
|
|
545
|
+
else count++;
|
|
546
|
+
}
|
|
547
|
+
return count;
|
|
548
|
+
}
|
|
549
|
+
const defDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default');
|
|
550
|
+
const minDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
|
|
551
|
+
expect(countFiles(minDir)).toBeLessThan(countFiles(defDir));
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// -- {{NAME}} replacement --
|
|
555
|
+
|
|
556
|
+
it('scaffold templates contain {{NAME}} placeholder', () => {
|
|
557
|
+
const html = fs.readFileSync(
|
|
558
|
+
path.resolve(__dirname, '..', 'cli', 'scaffold', 'default', 'index.html'), 'utf-8'
|
|
559
|
+
);
|
|
560
|
+
expect(html).toContain('{{NAME}}');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('minimal scaffold templates contain {{NAME}} placeholder', () => {
|
|
564
|
+
const html = fs.readFileSync(
|
|
565
|
+
path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal', 'index.html'), 'utf-8'
|
|
566
|
+
);
|
|
567
|
+
expect(html).toContain('{{NAME}}');
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// -- walkDir functionality (tested via module internals) --
|
|
571
|
+
|
|
572
|
+
it('scaffolds default project into a target directory', () => {
|
|
573
|
+
const target = tmpDir();
|
|
574
|
+
const projDir = path.join(target, 'test-app');
|
|
575
|
+
|
|
576
|
+
// Simulate: process.argv for default (no --minimal)
|
|
577
|
+
process.argv = ['node', 'zquery', 'create', 'test-app'];
|
|
578
|
+
vi.resetModules();
|
|
579
|
+
|
|
580
|
+
// We can't easily call createProject because it uses process.exit.
|
|
581
|
+
// Instead, test the walkDir + copy logic directly.
|
|
582
|
+
const scaffoldDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'default');
|
|
583
|
+
|
|
584
|
+
function walkDir(dir, prefix = '') {
|
|
585
|
+
const entries = [];
|
|
586
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
587
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
588
|
+
if (entry.isDirectory()) entries.push(...walkDir(path.join(dir, entry.name), rel));
|
|
589
|
+
else entries.push(rel);
|
|
590
|
+
}
|
|
591
|
+
return entries;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const files = walkDir(scaffoldDir);
|
|
595
|
+
expect(files.length).toBeGreaterThan(5);
|
|
596
|
+
expect(files).toContain('index.html');
|
|
597
|
+
expect(files).toContain('global.css');
|
|
598
|
+
expect(files).toContain('app/app.js');
|
|
599
|
+
expect(files).toContain('app/routes.js');
|
|
600
|
+
expect(files).toContain('app/store.js');
|
|
601
|
+
|
|
602
|
+
cleanup(target);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('walkDir lists minimal scaffold files correctly', () => {
|
|
606
|
+
const scaffoldDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
|
|
607
|
+
|
|
608
|
+
function walkDir(dir, prefix = '') {
|
|
609
|
+
const entries = [];
|
|
610
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
611
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
612
|
+
if (entry.isDirectory()) entries.push(...walkDir(path.join(dir, entry.name), rel));
|
|
613
|
+
else entries.push(rel);
|
|
614
|
+
}
|
|
615
|
+
return entries;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const files = walkDir(scaffoldDir);
|
|
619
|
+
expect(files).toContain('index.html');
|
|
620
|
+
expect(files).toContain('global.css');
|
|
621
|
+
expect(files).toContain('app/app.js');
|
|
622
|
+
expect(files).toContain('app/routes.js');
|
|
623
|
+
expect(files).toContain('app/store.js');
|
|
624
|
+
expect(files).toContain('app/components/home.js');
|
|
625
|
+
expect(files).toContain('app/components/counter.js');
|
|
626
|
+
expect(files).toContain('app/components/about.js');
|
|
627
|
+
expect(files).toContain('app/components/not-found.js');
|
|
628
|
+
expect(files).toContain('assets/.gitkeep');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// -- {{NAME}} replacement in copied files --
|
|
632
|
+
|
|
633
|
+
it('replaces {{NAME}} in copied scaffold files', () => {
|
|
634
|
+
const target = tmpDir();
|
|
635
|
+
const scaffoldDir = path.resolve(__dirname, '..', 'cli', 'scaffold', 'minimal');
|
|
636
|
+
const projectName = 'my-cool-app';
|
|
637
|
+
|
|
638
|
+
function walkDir(dir, prefix = '') {
|
|
639
|
+
const entries = [];
|
|
640
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
641
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
642
|
+
if (entry.isDirectory()) entries.push(...walkDir(path.join(dir, entry.name), rel));
|
|
643
|
+
else entries.push(rel);
|
|
644
|
+
}
|
|
645
|
+
return entries;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const files = walkDir(scaffoldDir);
|
|
649
|
+
|
|
650
|
+
for (const rel of files) {
|
|
651
|
+
let content = fs.readFileSync(path.join(scaffoldDir, rel), 'utf-8');
|
|
652
|
+
content = content.replace(/\{\{NAME\}\}/g, projectName);
|
|
653
|
+
const dest = path.join(target, rel);
|
|
654
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
655
|
+
fs.writeFileSync(dest, content, 'utf-8');
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const html = fs.readFileSync(path.join(target, 'index.html'), 'utf-8');
|
|
659
|
+
expect(html).toContain(projectName);
|
|
660
|
+
expect(html).not.toContain('{{NAME}}');
|
|
661
|
+
|
|
662
|
+
const appJs = fs.readFileSync(path.join(target, 'app', 'app.js'), 'utf-8');
|
|
663
|
+
expect(appJs).toContain(projectName);
|
|
664
|
+
expect(appJs).not.toContain('{{NAME}}');
|
|
665
|
+
|
|
666
|
+
cleanup(target);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// -- conflict detection --
|
|
670
|
+
|
|
671
|
+
it('conflicts array detects existing files', () => {
|
|
672
|
+
const target = tmpDir();
|
|
673
|
+
fs.writeFileSync(path.join(target, 'index.html'), 'existing');
|
|
674
|
+
fs.mkdirSync(path.join(target, 'app'), { recursive: true });
|
|
675
|
+
|
|
676
|
+
const conflicts = ['index.html', 'global.css', 'app', 'assets'].filter(f =>
|
|
677
|
+
fs.existsSync(path.join(target, f))
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
expect(conflicts).toContain('index.html');
|
|
681
|
+
expect(conflicts).toContain('app');
|
|
682
|
+
expect(conflicts).not.toContain('global.css');
|
|
683
|
+
expect(conflicts).not.toContain('assets');
|
|
684
|
+
|
|
685
|
+
cleanup(target);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('no conflicts in empty directory', () => {
|
|
689
|
+
const target = tmpDir();
|
|
690
|
+
|
|
691
|
+
const conflicts = ['index.html', 'global.css', 'app', 'assets'].filter(f =>
|
|
692
|
+
fs.existsSync(path.join(target, f))
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
expect(conflicts).toHaveLength(0);
|
|
696
|
+
|
|
697
|
+
cleanup(target);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// -- flag parsing for --minimal --
|
|
701
|
+
|
|
702
|
+
it('--minimal flag resolves to minimal variant', async () => {
|
|
703
|
+
process.argv = ['node', 'zquery', 'create', '--minimal', 'my-app'];
|
|
704
|
+
vi.resetModules();
|
|
705
|
+
const { flag } = await import('../cli/args.js');
|
|
706
|
+
expect(flag('minimal', 'm')).toBe(true);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('-m short flag resolves to minimal variant', async () => {
|
|
710
|
+
process.argv = ['node', 'zquery', 'create', '-m', 'my-app'];
|
|
711
|
+
vi.resetModules();
|
|
712
|
+
const { flag } = await import('../cli/args.js');
|
|
713
|
+
expect(flag('minimal', 'm')).toBe(true);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('no flag defaults to default variant', async () => {
|
|
717
|
+
process.argv = ['node', 'zquery', 'create', 'my-app'];
|
|
718
|
+
vi.resetModules();
|
|
719
|
+
const { flag } = await import('../cli/args.js');
|
|
720
|
+
expect(flag('minimal', 'm')).toBe(false);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// -- dirArg parsing skips flags --
|
|
724
|
+
|
|
725
|
+
it('dirArg parsing skips flag args', () => {
|
|
726
|
+
const args = ['create', '--minimal', 'my-app'];
|
|
727
|
+
const dirArg = args.slice(1).find(a => !a.startsWith('-'));
|
|
728
|
+
expect(dirArg).toBe('my-app');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('dirArg parsing returns first positional', () => {
|
|
732
|
+
const args = ['create', 'my-app'];
|
|
733
|
+
const dirArg = args.slice(1).find(a => !a.startsWith('-'));
|
|
734
|
+
expect(dirArg).toBe('my-app');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('dirArg is undefined when no positional given', () => {
|
|
738
|
+
const args = ['create', '--minimal'];
|
|
739
|
+
const dirArg = args.slice(1).find(a => !a.startsWith('-'));
|
|
740
|
+
expect(dirArg).toBeUndefined();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('dirArg with flag after dir name', () => {
|
|
744
|
+
const args = ['create', 'my-app', '--minimal'];
|
|
745
|
+
const dirArg = args.slice(1).find(a => !a.startsWith('-'));
|
|
746
|
+
expect(dirArg).toBe('my-app');
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// -- help text mentions --minimal --
|
|
750
|
+
|
|
751
|
+
it('help text includes --minimal flag', async () => {
|
|
752
|
+
vi.resetModules();
|
|
753
|
+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
754
|
+
const showHelp = (await import('../cli/help.js')).default;
|
|
755
|
+
showHelp();
|
|
756
|
+
const text = spy.mock.calls.map(c => c.join(' ')).join('\n');
|
|
757
|
+
expect(text).toContain('--minimal');
|
|
758
|
+
spy.mockRestore();
|
|
759
|
+
});
|
|
760
|
+
});
|
package/tests/http.test.js
CHANGED
|
@@ -446,3 +446,203 @@ describe('http — AbortController integration', () => {
|
|
|
446
446
|
expect(fetchSpy).toHaveBeenCalled();
|
|
447
447
|
});
|
|
448
448
|
});
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
// ===========================================================================
|
|
452
|
+
// HEAD requests
|
|
453
|
+
// ===========================================================================
|
|
454
|
+
|
|
455
|
+
describe('http.head', () => {
|
|
456
|
+
it('sends a HEAD request', async () => {
|
|
457
|
+
mockFetch({});
|
|
458
|
+
const result = await http.head('https://api.test.com/resource');
|
|
459
|
+
expect(fetchSpy.mock.calls[0][1].method).toBe('HEAD');
|
|
460
|
+
expect(result.ok).toBe(true);
|
|
461
|
+
expect(result.status).toBe(200);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('does not send a body', async () => {
|
|
465
|
+
mockFetch({});
|
|
466
|
+
await http.head('https://api.test.com/resource');
|
|
467
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
468
|
+
expect(opts.body).toBeUndefined();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('accepts per-request options', async () => {
|
|
472
|
+
mockFetch({});
|
|
473
|
+
await http.head('https://api.test.com/resource', {
|
|
474
|
+
headers: { 'X-Check': 'exists' },
|
|
475
|
+
});
|
|
476
|
+
const opts = fetchSpy.mock.calls[0][1];
|
|
477
|
+
expect(opts.headers['X-Check']).toBe('exists');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('returns response headers', async () => {
|
|
481
|
+
mockFetch({});
|
|
482
|
+
const result = await http.head('https://api.test.com/resource');
|
|
483
|
+
expect(result.headers).toBeDefined();
|
|
484
|
+
expect(typeof result.headers).toBe('object');
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
// ===========================================================================
|
|
490
|
+
// Interceptor unsubscribe
|
|
491
|
+
// ===========================================================================
|
|
492
|
+
|
|
493
|
+
describe('http — interceptor unsubscribe', () => {
|
|
494
|
+
it('onRequest returns an unsubscribe function', async () => {
|
|
495
|
+
http.clearInterceptors();
|
|
496
|
+
const spy = vi.fn();
|
|
497
|
+
const unsub = http.onRequest(spy);
|
|
498
|
+
expect(typeof unsub).toBe('function');
|
|
499
|
+
|
|
500
|
+
mockFetch({});
|
|
501
|
+
await http.get('https://api.test.com/a');
|
|
502
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
503
|
+
|
|
504
|
+
unsub();
|
|
505
|
+
await http.get('https://api.test.com/b');
|
|
506
|
+
expect(spy).toHaveBeenCalledTimes(1); // not called again
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('onResponse returns an unsubscribe function', async () => {
|
|
510
|
+
http.clearInterceptors();
|
|
511
|
+
const spy = vi.fn();
|
|
512
|
+
const unsub = http.onResponse(spy);
|
|
513
|
+
expect(typeof unsub).toBe('function');
|
|
514
|
+
|
|
515
|
+
mockFetch({});
|
|
516
|
+
await http.get('https://api.test.com/a');
|
|
517
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
518
|
+
|
|
519
|
+
unsub();
|
|
520
|
+
await http.get('https://api.test.com/b');
|
|
521
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('double-unsubscribe is safe', async () => {
|
|
525
|
+
http.clearInterceptors();
|
|
526
|
+
const spy = vi.fn();
|
|
527
|
+
const unsub = http.onRequest(spy);
|
|
528
|
+
unsub();
|
|
529
|
+
unsub(); // should not throw
|
|
530
|
+
mockFetch({});
|
|
531
|
+
await http.get('https://api.test.com/data');
|
|
532
|
+
expect(spy).not.toHaveBeenCalled();
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
// ===========================================================================
|
|
538
|
+
// clearInterceptors
|
|
539
|
+
// ===========================================================================
|
|
540
|
+
|
|
541
|
+
describe('http.clearInterceptors', () => {
|
|
542
|
+
it('clears all interceptors when called with no args', async () => {
|
|
543
|
+
http.clearInterceptors();
|
|
544
|
+
const reqSpy = vi.fn();
|
|
545
|
+
const resSpy = vi.fn();
|
|
546
|
+
http.onRequest(reqSpy);
|
|
547
|
+
http.onResponse(resSpy);
|
|
548
|
+
|
|
549
|
+
http.clearInterceptors();
|
|
550
|
+
mockFetch({});
|
|
551
|
+
await http.get('https://api.test.com/data');
|
|
552
|
+
expect(reqSpy).not.toHaveBeenCalled();
|
|
553
|
+
expect(resSpy).not.toHaveBeenCalled();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('clears only request interceptors with "request"', async () => {
|
|
557
|
+
http.clearInterceptors();
|
|
558
|
+
const reqSpy = vi.fn();
|
|
559
|
+
const resSpy = vi.fn();
|
|
560
|
+
http.onRequest(reqSpy);
|
|
561
|
+
http.onResponse(resSpy);
|
|
562
|
+
|
|
563
|
+
http.clearInterceptors('request');
|
|
564
|
+
mockFetch({});
|
|
565
|
+
await http.get('https://api.test.com/data');
|
|
566
|
+
expect(reqSpy).not.toHaveBeenCalled();
|
|
567
|
+
expect(resSpy).toHaveBeenCalledTimes(1);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('clears only response interceptors with "response"', async () => {
|
|
571
|
+
http.clearInterceptors();
|
|
572
|
+
const reqSpy = vi.fn();
|
|
573
|
+
const resSpy = vi.fn();
|
|
574
|
+
http.onRequest(reqSpy);
|
|
575
|
+
http.onResponse(resSpy);
|
|
576
|
+
|
|
577
|
+
http.clearInterceptors('response');
|
|
578
|
+
mockFetch({});
|
|
579
|
+
await http.get('https://api.test.com/data');
|
|
580
|
+
expect(reqSpy).toHaveBeenCalledTimes(1);
|
|
581
|
+
expect(resSpy).not.toHaveBeenCalled();
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
// http.all — parallel requests
|
|
588
|
+
// ===========================================================================
|
|
589
|
+
|
|
590
|
+
describe('http.all', () => {
|
|
591
|
+
it('resolves all parallel requests', async () => {
|
|
592
|
+
mockFetch({ ok: true });
|
|
593
|
+
const results = await http.all([
|
|
594
|
+
http.get('https://api.test.com/a'),
|
|
595
|
+
http.get('https://api.test.com/b'),
|
|
596
|
+
http.get('https://api.test.com/c'),
|
|
597
|
+
]);
|
|
598
|
+
expect(results).toHaveLength(3);
|
|
599
|
+
expect(results.every(r => r.ok)).toBe(true);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('rejects if any request fails', async () => {
|
|
603
|
+
http.clearInterceptors();
|
|
604
|
+
mockFetch({ error: 'fail' }, false, 500);
|
|
605
|
+
await expect(
|
|
606
|
+
http.all([
|
|
607
|
+
http.get('https://api.test.com/a'),
|
|
608
|
+
http.get('https://api.test.com/b'),
|
|
609
|
+
])
|
|
610
|
+
).rejects.toThrow();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('handles empty array', async () => {
|
|
614
|
+
const results = await http.all([]);
|
|
615
|
+
expect(results).toEqual([]);
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
// ===========================================================================
|
|
621
|
+
// http.getConfig
|
|
622
|
+
// ===========================================================================
|
|
623
|
+
|
|
624
|
+
describe('http.getConfig', () => {
|
|
625
|
+
it('returns current config', () => {
|
|
626
|
+
http.configure({ baseURL: 'https://myapi.com', timeout: 5000 });
|
|
627
|
+
const config = http.getConfig();
|
|
628
|
+
expect(config.baseURL).toBe('https://myapi.com');
|
|
629
|
+
expect(config.timeout).toBe(5000);
|
|
630
|
+
expect(config.headers).toBeDefined();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('returns a copy (not the internal reference)', () => {
|
|
634
|
+
const config = http.getConfig();
|
|
635
|
+
config.baseURL = 'https://mutated.com';
|
|
636
|
+
config.headers['X-Evil'] = 'injected';
|
|
637
|
+
const fresh = http.getConfig();
|
|
638
|
+
expect(fresh.baseURL).not.toBe('https://mutated.com');
|
|
639
|
+
expect(fresh.headers['X-Evil']).toBeUndefined();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('reflects updates after configure', () => {
|
|
643
|
+
http.configure({ baseURL: '' });
|
|
644
|
+
expect(http.getConfig().baseURL).toBe('');
|
|
645
|
+
http.configure({ baseURL: 'https://updated.com' });
|
|
646
|
+
expect(http.getConfig().baseURL).toBe('https://updated.com');
|
|
647
|
+
});
|
|
648
|
+
});
|
package/types/http.d.ts
CHANGED
|
@@ -61,15 +61,26 @@ export interface HttpClient {
|
|
|
61
61
|
patch<T = any>(url: string, data?: any, opts?: HttpRequestOptions): Promise<HttpResponse<T>>;
|
|
62
62
|
/** DELETE request. */
|
|
63
63
|
delete<T = any>(url: string, data?: any, opts?: HttpRequestOptions): Promise<HttpResponse<T>>;
|
|
64
|
+
/** HEAD request — no body, useful for checking resource existence or headers. */
|
|
65
|
+
head<T = any>(url: string, opts?: HttpRequestOptions): Promise<HttpResponse<T>>;
|
|
64
66
|
|
|
65
67
|
/** Update default configuration for all subsequent requests. */
|
|
66
68
|
configure(options: HttpConfigureOptions): void;
|
|
67
69
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
+
/** Read-only snapshot of the current configuration. Returns a shallow copy. */
|
|
71
|
+
getConfig(): { baseURL: string; headers: Record<string, string>; timeout: number };
|
|
70
72
|
|
|
71
|
-
/** Add a
|
|
72
|
-
|
|
73
|
+
/** Add a request interceptor (called before every request). Returns an unsubscribe function. */
|
|
74
|
+
onRequest(fn: HttpRequestInterceptor): () => void;
|
|
75
|
+
|
|
76
|
+
/** Add a response interceptor (called after every response, before error check). Returns an unsubscribe function. */
|
|
77
|
+
onResponse(fn: HttpResponseInterceptor): () => void;
|
|
78
|
+
|
|
79
|
+
/** Clear interceptors. No args = all; `'request'` or `'response'` for one type. */
|
|
80
|
+
clearInterceptors(type?: 'request' | 'response'): void;
|
|
81
|
+
|
|
82
|
+
/** Run multiple request promises in parallel via `Promise.all`. */
|
|
83
|
+
all<T extends readonly Promise<HttpResponse<any>>[]>(requests: T): Promise<{ -readonly [K in keyof T]: Awaited<T[K]> }>;
|
|
73
84
|
|
|
74
85
|
/** Create a new `AbortController` for manual request cancellation. */
|
|
75
86
|
createAbort(): AbortController;
|