@specific.dev/spectest 0.88.3 → 0.89.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/README.md +42 -2
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +47 -0
- package/dist/components/electron.d.ts +11 -1
- package/dist/components/electron.js +42 -10
- package/dist/daemon.js +40 -85
- package/dist/harness/fetch.js +7 -3
- package/dist/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -0
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/daemon.ts +48 -86
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -0
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
package/src/inspect.ts
CHANGED
|
@@ -240,6 +240,20 @@ export function wrap<T>(
|
|
|
240
240
|
if (raw === null || raw === undefined) return raw;
|
|
241
241
|
const t = typeof raw;
|
|
242
242
|
if (t === "object") {
|
|
243
|
+
const native = readRaw(raw);
|
|
244
|
+
if (native instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && native instanceof SharedArrayBuffer)) {
|
|
245
|
+
return wrapNative(native, sourceSeq, path, new Set(["byteLength", "maxByteLength", "resizable", "growable", "detached"]),
|
|
246
|
+
new Set(["slice", "transfer", "transferToFixedLength"])) as T;
|
|
247
|
+
}
|
|
248
|
+
if (ArrayBuffer.isView(native)) {
|
|
249
|
+
return wrapNative(native, sourceSeq, path, new Set(["buffer", "byteLength", "byteOffset"]), BINARY_METHODS, true) as T;
|
|
250
|
+
}
|
|
251
|
+
if (native instanceof Blob) {
|
|
252
|
+
return wrapNative(native, sourceSeq, path, new Set(["size", "type", "name", "lastModified", "webkitRelativePath"]),
|
|
253
|
+
new Set(["slice", "text", "bytes", "arrayBuffer", "stream"])) as T;
|
|
254
|
+
}
|
|
255
|
+
if (native instanceof FormData) return wrapFormData(native, sourceSeq, path) as T;
|
|
256
|
+
if (native instanceof ReadableStream) return wrapStream(native, sourceSeq, path) as T;
|
|
243
257
|
return wrapObject(raw as object, sourceSeq, path) as unknown as T;
|
|
244
258
|
}
|
|
245
259
|
if (t === "function") return raw;
|
|
@@ -578,12 +592,23 @@ export type Wrapped<T> = T extends null | undefined
|
|
|
578
592
|
? T
|
|
579
593
|
: T extends readonly (infer U)[]
|
|
580
594
|
? WrappedArray<U>
|
|
581
|
-
:
|
|
582
|
-
T
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
595
|
+
: T extends ArrayBuffer | SharedArrayBuffer
|
|
596
|
+
? WrappedBuffer<T>
|
|
597
|
+
: T extends ArrayBufferView
|
|
598
|
+
? WrappedBinaryView<T>
|
|
599
|
+
: T extends File
|
|
600
|
+
? WrappedFile
|
|
601
|
+
: T extends Blob
|
|
602
|
+
? WrappedBlob
|
|
603
|
+
: T extends FormData
|
|
604
|
+
? WrappedFormData
|
|
605
|
+
: T extends ReadableStream<infer C>
|
|
606
|
+
? WrappedStream<C>
|
|
607
|
+
: T extends (...args: never[]) => unknown
|
|
608
|
+
? T
|
|
609
|
+
: T extends object
|
|
610
|
+
? WrappedObject<T>
|
|
611
|
+
: Carrier<T>;
|
|
587
612
|
|
|
588
613
|
/** A wrapped object: every own property is itself {@link Wrapped}, plus an
|
|
589
614
|
* `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
|
|
@@ -645,6 +670,188 @@ export interface WrappedArray<U> {
|
|
|
645
670
|
[Symbol.iterator](): IterableIterator<U>;
|
|
646
671
|
}
|
|
647
672
|
|
|
673
|
+
/** Native objects need their own views: getters and methods must receive the
|
|
674
|
+
* real native receiver, and method results must be wrapped after they resolve.
|
|
675
|
+
* Iteration, callbacks, and control-flow flags on stream readers remain raw.
|
|
676
|
+
*/
|
|
677
|
+
type NativeResult<T> = T extends Promise<infer R> ? Promise<Wrapped<R>> : Wrapped<T>;
|
|
678
|
+
type NativeView<T, K extends keyof T> = Omit<T, K> & {
|
|
679
|
+
readonly [P in K]: T[P] extends (...args: infer A) => infer R
|
|
680
|
+
? (...args: A) => NativeResult<R> : Wrapped<T[P]>;
|
|
681
|
+
} & {
|
|
682
|
+
unwrap(): T;
|
|
683
|
+
transform<R>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
export type WrappedBuffer<T extends ArrayBuffer | SharedArrayBuffer = ArrayBuffer> = NativeView<T,
|
|
687
|
+
Extract<keyof T, "byteLength" | "maxByteLength" | "resizable" | "growable" | "detached" |
|
|
688
|
+
"slice" | "transfer" | "transferToFixedLength">>;
|
|
689
|
+
|
|
690
|
+
const BINARY_METHOD_NAMES = [
|
|
691
|
+
"at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast",
|
|
692
|
+
"findIndex", "findLastIndex", "some", "every", "filter", "map", "join",
|
|
693
|
+
"fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64",
|
|
694
|
+
"getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32",
|
|
695
|
+
"getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64",
|
|
696
|
+
] as const;
|
|
697
|
+
const BINARY_METHODS = new Set<string>(BINARY_METHOD_NAMES);
|
|
698
|
+
/** Numeric indexes carry provenance. Like WrappedArray, length and iteration
|
|
699
|
+
* stay raw; callbacks receive native values. Unwrap for native API inputs. */
|
|
700
|
+
export type WrappedBinaryView<T extends ArrayBufferView> = NativeView<T,
|
|
701
|
+
Extract<keyof T, number | "buffer" | "byteLength" | "byteOffset" | typeof BINARY_METHOD_NAMES[number]>>;
|
|
702
|
+
export type WrappedBytes = WrappedBinaryView<Uint8Array>;
|
|
703
|
+
export type WrappedBlob = NativeView<Blob,
|
|
704
|
+
"size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
|
|
705
|
+
export type WrappedFile = NativeView<File,
|
|
706
|
+
"name" | "lastModified" | "webkitRelativePath" | "size" | "type" |
|
|
707
|
+
"slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
|
|
708
|
+
export type WrappedFormData = NativeView<FormData, "get" | "getAll" | "has">;
|
|
709
|
+
|
|
710
|
+
export type WrappedReadResult<T> =
|
|
711
|
+
| { done: false; value: Wrapped<T> }
|
|
712
|
+
| { done: true; value: Wrapped<T> | undefined };
|
|
713
|
+
export interface WrappedReader<T> extends Omit<ReadableStreamDefaultReader<T>, "read"> {
|
|
714
|
+
read(): Promise<WrappedReadResult<T>>;
|
|
715
|
+
unwrap(): ReadableStreamDefaultReader<T>;
|
|
716
|
+
}
|
|
717
|
+
export interface WrappedBYOBReader extends Omit<ReadableStreamBYOBReader, "read"> {
|
|
718
|
+
read<T extends ArrayBufferView>(view: T, options?: { min?: number }): Promise<WrappedReadResult<T>>;
|
|
719
|
+
unwrap(): ReadableStreamBYOBReader;
|
|
720
|
+
}
|
|
721
|
+
export interface WrappedStream<T> extends Omit<ReadableStream<T>, "getReader" | "tee" | "pipeThrough"> {
|
|
722
|
+
getReader(): WrappedReader<T>;
|
|
723
|
+
getReader(options: { mode: "byob" }): WrappedBYOBReader;
|
|
724
|
+
getReader(options: ReadableStreamGetReaderOptions): WrappedReader<T> | WrappedBYOBReader;
|
|
725
|
+
tee(): [WrappedStream<T>, WrappedStream<T>];
|
|
726
|
+
pipeThrough<R>(transform: ReadableWritablePair<R, any>, options?: StreamPipeOptions): WrappedStream<R>;
|
|
727
|
+
unwrap(): ReadableStream<T>;
|
|
728
|
+
transform<R>(label: string, fn: (raw: ReadableStream<T>) => R): Wrapped<R>;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function wrapNative<T extends object>(
|
|
732
|
+
raw: T, sourceSeq: number | undefined, path: readonly string[],
|
|
733
|
+
properties: ReadonlySet<string>, methods: ReadonlySet<string>, indexes = false,
|
|
734
|
+
): T {
|
|
735
|
+
const tag: OpTag = { sourceSeq, path };
|
|
736
|
+
return new Proxy(raw, {
|
|
737
|
+
get(target, prop) {
|
|
738
|
+
if (prop === OP_TAG) return tag;
|
|
739
|
+
if (prop === UNWRAP) return target;
|
|
740
|
+
if (prop === "unwrap") return () => target;
|
|
741
|
+
if (prop === "transform") return makeTransform(target, sourceSeq, path);
|
|
742
|
+
const value = Reflect.get(target, prop, target);
|
|
743
|
+
if (typeof prop === "string") {
|
|
744
|
+
if (properties.has(prop) || (indexes && /^(0|[1-9]\d*)$/.test(prop))) {
|
|
745
|
+
return wrapChild(value, sourceSeq, [...path, prop]);
|
|
746
|
+
}
|
|
747
|
+
if (methods.has(prop) && typeof value === "function") {
|
|
748
|
+
return (...args: unknown[]) => {
|
|
749
|
+
const result = value.apply(target, args);
|
|
750
|
+
const resultPath = [...path, `<${prop}>`];
|
|
751
|
+
return result instanceof Promise
|
|
752
|
+
? result.then((v) => wrapChild(v, sourceSeq, resultPath))
|
|
753
|
+
: wrapChild(result, sourceSeq, resultPath);
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
758
|
+
},
|
|
759
|
+
has(target, prop) {
|
|
760
|
+
return prop === OP_TAG || prop === UNWRAP || Reflect.has(target, prop);
|
|
761
|
+
},
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function wrapFormData(raw: FormData, sourceSeq: number | undefined, path: readonly string[]): FormData {
|
|
766
|
+
const base = wrapNative(raw, sourceSeq, path, new Set(), new Set());
|
|
767
|
+
return new Proxy(base, {
|
|
768
|
+
get(target, prop) {
|
|
769
|
+
if (prop === "get" || prop === "getAll" || prop === "has") {
|
|
770
|
+
return (name: string) => wrapChild(raw[prop](name), sourceSeq,
|
|
771
|
+
prop === "get" ? [...path, name] : [...path, name, `<${prop}>`]);
|
|
772
|
+
}
|
|
773
|
+
return Reflect.get(target, prop);
|
|
774
|
+
},
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function wrapStream<T>(raw: ReadableStream<T>, sourceSeq: number | undefined, path: readonly string[]): ReadableStream<T> {
|
|
779
|
+
const base = wrapNative(raw, sourceSeq, path, new Set(), new Set(["pipeThrough"]));
|
|
780
|
+
return new Proxy(base, {
|
|
781
|
+
get(target, prop) {
|
|
782
|
+
if (prop === "tee") return () => raw.tee().map((s, i) => wrapStream(s, sourceSeq, [...path, `<tee>`, String(i)]));
|
|
783
|
+
if (prop === "getReader") return (options?: ReadableStreamGetReaderOptions) => {
|
|
784
|
+
const reader = raw.getReader(options!);
|
|
785
|
+
let index = 0;
|
|
786
|
+
return new Proxy(reader, {
|
|
787
|
+
get(r, key) {
|
|
788
|
+
if (key === "unwrap" || key === UNWRAP) return key === "unwrap" ? () => r : r;
|
|
789
|
+
if (key === OP_TAG) return { sourceSeq, path };
|
|
790
|
+
if (key === "read") return async (...args: unknown[]) => {
|
|
791
|
+
const read = r.read as (...args: unknown[]) => Promise<ReadableStreamReadResult<T>>;
|
|
792
|
+
const result = await read.apply(r, args);
|
|
793
|
+
const valuePath = [...path, "<read>", String(index++)];
|
|
794
|
+
// done stays a real boolean so normal reader loops terminate.
|
|
795
|
+
return { done: result.done, get value() { return wrapChild(result.value, sourceSeq, valuePath); } };
|
|
796
|
+
};
|
|
797
|
+
const value = Reflect.get(r, key, r);
|
|
798
|
+
return typeof value === "function" ? value.bind(r) : value;
|
|
799
|
+
},
|
|
800
|
+
has(r, key) { return key === UNWRAP || key === OP_TAG || Reflect.has(r, key); },
|
|
801
|
+
});
|
|
802
|
+
};
|
|
803
|
+
return Reflect.get(target, prop);
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Response headers whose lookups retain the originating HTTP call's provenance.
|
|
809
|
+
* Missing headers remain `null`; `expect(headers.get(name)).toBe(null)` still
|
|
810
|
+
* links to the call. Iterators and other native methods return raw values.
|
|
811
|
+
*/
|
|
812
|
+
export interface WrappedHeaders extends Omit<Headers, "get" | "has" | "getSetCookie" | "getAll" | "toJSON" | "count"> {
|
|
813
|
+
get(name: string): Carrier<string> | null;
|
|
814
|
+
/** Unwrap before using the result as a condition. */
|
|
815
|
+
has(name: string): Carrier<boolean>;
|
|
816
|
+
getSetCookie(): WrappedArray<string>;
|
|
817
|
+
getAll(name: "set-cookie" | "Set-Cookie"): WrappedArray<string>;
|
|
818
|
+
toJSON(): Wrapped<Record<string, string | string[]>>;
|
|
819
|
+
readonly count: Carrier<number>;
|
|
820
|
+
unwrap(): Headers;
|
|
821
|
+
transform<R = unknown>(label: string, fn: (raw: Headers) => R): Wrapped<R>;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function wrapHeaders(headers: Headers, sourceSeq: number | undefined): WrappedHeaders {
|
|
825
|
+
const path = ["headers"];
|
|
826
|
+
const tag: OpTag = { sourceSeq, path };
|
|
827
|
+
return new Proxy(headers, {
|
|
828
|
+
get(target, prop) {
|
|
829
|
+
if (prop === OP_TAG) return tag;
|
|
830
|
+
if (prop === UNWRAP) return target;
|
|
831
|
+
if (prop === "unwrap") return () => target;
|
|
832
|
+
if (prop === "transform") return makeTransform(target, sourceSeq, path);
|
|
833
|
+
if (prop === "get" || prop === "has") {
|
|
834
|
+
return (name: string) => wrapChild(
|
|
835
|
+
target[prop](name), sourceSeq,
|
|
836
|
+
prop === "get" ? [...path, name.toLowerCase()] : [...path, name.toLowerCase(), "<has>"],
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
if (prop === "count") return wrap(target.count, sourceSeq, [...path, "count"]);
|
|
840
|
+
if (prop === "getAll") return (name: "set-cookie" | "Set-Cookie") => wrap(target.getAll(name), sourceSeq, [...path, name.toLowerCase()]);
|
|
841
|
+
if (prop === "toJSON") return () => wrap(target.toJSON(), sourceSeq, path);
|
|
842
|
+
if (prop === "getSetCookie") {
|
|
843
|
+
return () => wrap(target.getSetCookie(), sourceSeq, [...path, "set-cookie"]);
|
|
844
|
+
}
|
|
845
|
+
const value = Reflect.get(target, prop, target);
|
|
846
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
847
|
+
},
|
|
848
|
+
has(target, prop) {
|
|
849
|
+
if (prop === OP_TAG || prop === UNWRAP) return true;
|
|
850
|
+
return Reflect.has(target, prop);
|
|
851
|
+
},
|
|
852
|
+
}) as unknown as WrappedHeaders;
|
|
853
|
+
}
|
|
854
|
+
|
|
648
855
|
/**
|
|
649
856
|
* The view `ctx.fetch` resolves to in every context (a spectest op wraps
|
|
650
857
|
* unconditionally): a {@link Response} whose status-line accessors are
|
|
@@ -653,8 +860,9 @@ export interface WrappedArray<U> {
|
|
|
653
860
|
* used to silently always be false. Compare `res.status.unwrap() === 200`
|
|
654
861
|
* or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
|
|
655
862
|
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
863
|
+
* All body readers return provenance-carrying values, including binary bodies,
|
|
864
|
+
* blobs and form data. `.unwrap()` recovers the native response or body value.
|
|
865
|
+
* Stream reader `done` and stream `locked` remain raw control-flow booleans.
|
|
658
866
|
*/
|
|
659
867
|
export interface WrappedResponse {
|
|
660
868
|
readonly status: Carrier<number>;
|
|
@@ -663,13 +871,15 @@ export interface WrappedResponse {
|
|
|
663
871
|
readonly url: Carrier<string>;
|
|
664
872
|
readonly redirected: Carrier<boolean>;
|
|
665
873
|
readonly type: Carrier<string>;
|
|
666
|
-
readonly headers:
|
|
667
|
-
readonly bodyUsed: boolean
|
|
874
|
+
readonly headers: WrappedHeaders;
|
|
875
|
+
readonly bodyUsed: Carrier<boolean>;
|
|
876
|
+
readonly body: WrappedStream<Uint8Array> | null;
|
|
668
877
|
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
669
878
|
text(): Promise<Carrier<string>>;
|
|
670
|
-
arrayBuffer(): Promise<
|
|
671
|
-
|
|
672
|
-
|
|
879
|
+
arrayBuffer(): Promise<WrappedBuffer>;
|
|
880
|
+
bytes(): Promise<WrappedBytes>;
|
|
881
|
+
blob(): Promise<WrappedBlob>;
|
|
882
|
+
formData(): Promise<WrappedFormData>;
|
|
673
883
|
clone(): WrappedResponse;
|
|
674
884
|
/** Recover the underlying raw {@link Response} (a real `number` status,
|
|
675
885
|
* an unwrapped body, etc.). */
|
|
@@ -680,18 +890,27 @@ export interface WrappedResponse {
|
|
|
680
890
|
* {@link WrappedResponse} so reads carry provenance into assertions. */
|
|
681
891
|
export type SpectestFetch = (
|
|
682
892
|
input: RequestInfo | URL,
|
|
683
|
-
init?:
|
|
893
|
+
init?: SpectestRequestInit,
|
|
684
894
|
) => Promise<WrappedResponse>;
|
|
685
895
|
|
|
896
|
+
export interface SpectestRequestInit extends RequestInit {
|
|
897
|
+
/** Skip the recorder's body capture for open-ended streams. SSE skips it
|
|
898
|
+
* automatically. The response and assertions remain recorded. */
|
|
899
|
+
captureBody?: boolean;
|
|
900
|
+
}
|
|
901
|
+
|
|
686
902
|
/**
|
|
687
903
|
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
688
904
|
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
689
|
-
* to `sourceSeq`.
|
|
690
|
-
*
|
|
691
|
-
*
|
|
905
|
+
* to `sourceSeq`. Every body-reading method returns its resolved value
|
|
906
|
+
* wrapped under `path: ["body"]`. Header lookups carry paths
|
|
907
|
+
* under `headers`, and clones retain the same provenance. Everything else
|
|
908
|
+
* passes through bound to the real Response.
|
|
692
909
|
*/
|
|
693
910
|
export function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse {
|
|
694
911
|
const tag: OpTag = { sourceSeq, path: [] };
|
|
912
|
+
let headers: WrappedHeaders | undefined;
|
|
913
|
+
let body: ReadableStream<Uint8Array> | undefined;
|
|
695
914
|
const carrierProps = new Set([
|
|
696
915
|
"status",
|
|
697
916
|
"ok",
|
|
@@ -699,8 +918,9 @@ export function wrapResponse(res: Response, sourceSeq: number | undefined): Wrap
|
|
|
699
918
|
"url",
|
|
700
919
|
"redirected",
|
|
701
920
|
"type",
|
|
921
|
+
"bodyUsed",
|
|
702
922
|
]);
|
|
703
|
-
const bodyMethods = new Set(["json", "text"]);
|
|
923
|
+
const bodyMethods = new Set(["json", "text", "bytes", "arrayBuffer", "blob", "formData"]);
|
|
704
924
|
const handler: ProxyHandler<Response> = {
|
|
705
925
|
get(target, prop) {
|
|
706
926
|
if (prop === OP_TAG) return tag;
|
|
@@ -708,6 +928,12 @@ export function wrapResponse(res: Response, sourceSeq: number | undefined): Wrap
|
|
|
708
928
|
// Full recursive unwrap (see the note in `wrapObject`).
|
|
709
929
|
if (prop === "unwrap") return () => readRaw(target);
|
|
710
930
|
if (prop === "then") return undefined;
|
|
931
|
+
if (prop === "headers") return headers ??= wrapHeaders(target.headers, sourceSeq);
|
|
932
|
+
if (prop === "body") {
|
|
933
|
+
if (target.body === null) return wrapChild(null, sourceSeq, ["body"]);
|
|
934
|
+
return body ??= wrapStream(target.body, sourceSeq, ["body"]);
|
|
935
|
+
}
|
|
936
|
+
if (prop === "clone") return () => wrapResponse(target.clone(), sourceSeq);
|
|
711
937
|
|
|
712
938
|
if (typeof prop === "string" && carrierProps.has(prop)) {
|
|
713
939
|
const value = (target as unknown as Record<string, unknown>)[prop];
|
|
@@ -720,7 +946,7 @@ export function wrapResponse(res: Response, sourceSeq: number | undefined): Wrap
|
|
|
720
946
|
if (typeof fn !== "function") return fn;
|
|
721
947
|
return async (...args: unknown[]) => {
|
|
722
948
|
const value = await fn.apply(target, args);
|
|
723
|
-
return
|
|
949
|
+
return wrapChild(value, sourceSeq, ["body"]);
|
|
724
950
|
};
|
|
725
951
|
}
|
|
726
952
|
const value = (target as unknown as Record<string | symbol, unknown>)[
|