@specific.dev/spectest 0.16.0 → 0.17.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/package.json +1 -1
- package/src/browser.ts +45 -7
- package/src/components/expo.ts +24 -2
- package/src/daemon.ts +39 -4
- package/src/mobile.ts +27 -11
- package/src/recorder.ts +5 -1
- package/src/sql.ts +21 -1
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -159,8 +159,10 @@ export interface Browser {
|
|
|
159
159
|
/** Navigate to a URL; resolves when the main frame's load completes. */
|
|
160
160
|
navigate(url: string): Promise<void>;
|
|
161
161
|
/**
|
|
162
|
-
* Evaluate
|
|
163
|
-
*
|
|
162
|
+
* Evaluate JS in the page and return the JSON-deserialised result.
|
|
163
|
+
* Accepts a single expression or a statement body (`const x = …;
|
|
164
|
+
* return x;`) — statement bodies are auto-wrapped in an async IIFE, so
|
|
165
|
+
* `return` and `await` work without manual wrapping.
|
|
164
166
|
*
|
|
165
167
|
* `description` is a short human-readable label for what the
|
|
166
168
|
* snippet is doing ("read rendered todo list"); it surfaces in the
|
|
@@ -255,8 +257,9 @@ export interface MobileBackend extends Browser {
|
|
|
255
257
|
* when the CDP override is unavailable). The daemon stamps these onto
|
|
256
258
|
* the session record for the dashboard's replay. */
|
|
257
259
|
readonly safeAreaInsets: SafeAreaInsets | null;
|
|
258
|
-
/** Touch-tap at viewport CSS coordinates (touchStart→
|
|
259
|
-
|
|
260
|
+
/** Touch-tap at viewport CSS coordinates (touchStart → short dwell →
|
|
261
|
+
* touchEnd; `durationMs` overrides the dwell). */
|
|
262
|
+
tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
|
|
260
263
|
/** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
|
|
261
264
|
swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
|
|
262
265
|
/**
|
|
@@ -289,6 +292,32 @@ const CHROME_ARGV = [
|
|
|
289
292
|
"--dns-over-https-mode=off",
|
|
290
293
|
];
|
|
291
294
|
|
|
295
|
+
/** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
|
|
296
|
+
const TAP_DWELL_MS = 60;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
|
|
300
|
+
* single EXPRESSION (it wraps the source in `await (...)`), so a statement
|
|
301
|
+
* body (`const x = …; return x;`) is a syntax error. Rather than forcing
|
|
302
|
+
* authors to IIFE-wrap by hand, wrap it for them when it isn't an expression.
|
|
303
|
+
*
|
|
304
|
+
* The check is parse-only (`new Function` compiles without executing) and
|
|
305
|
+
* happens BEFORE the script runs — deciding by catching the page-side error
|
|
306
|
+
* and retrying would re-execute side-effecting expressions whose *runtime*
|
|
307
|
+
* error happens to look syntactic (`JSON.parse` throws SyntaxError too).
|
|
308
|
+
*/
|
|
309
|
+
function toEvaluable(script: string): string {
|
|
310
|
+
try {
|
|
311
|
+
new Function(`return (${script}\n);`);
|
|
312
|
+
return script;
|
|
313
|
+
} catch {
|
|
314
|
+
// Statement body — an async IIFE makes `return`, declarations, and
|
|
315
|
+
// multi-statement snippets valid, with `await` still available. The
|
|
316
|
+
// newlines keep a trailing line comment from eating the wrapper.
|
|
317
|
+
return `(async () => {\n${script}\n})()`;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
292
321
|
// ────────────────────────────────────────────────────────────────────────
|
|
293
322
|
// Device emulation (mobile frame)
|
|
294
323
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -1144,7 +1173,7 @@ function buildBackend(
|
|
|
1144
1173
|
scriptTruncated: truncatedScript.truncated,
|
|
1145
1174
|
},
|
|
1146
1175
|
async () => {
|
|
1147
|
-
const v = await holder.view.evaluate<T>(script);
|
|
1176
|
+
const v = await holder.view.evaluate<T>(toEvaluable(script));
|
|
1148
1177
|
return v;
|
|
1149
1178
|
},
|
|
1150
1179
|
) as Promise<Wrapped<T>>;
|
|
@@ -1183,6 +1212,8 @@ function buildBackend(
|
|
|
1183
1212
|
scriptTruncated: truncatedScript.truncated,
|
|
1184
1213
|
attempts: 0,
|
|
1185
1214
|
};
|
|
1215
|
+
// Normalised once up front — see `toEvaluable` (statement bodies work).
|
|
1216
|
+
const evaluable = toEvaluable(expression);
|
|
1186
1217
|
return instrumented<T>("waitFor", fields, async () => {
|
|
1187
1218
|
const deadline = Date.now() + timeoutMs;
|
|
1188
1219
|
// (return value wrapped by `instrumented`; cast below matches.)
|
|
@@ -1195,7 +1226,7 @@ function buildBackend(
|
|
|
1195
1226
|
fields.attempts = (fields.attempts ?? 0) + 1;
|
|
1196
1227
|
let v: unknown;
|
|
1197
1228
|
try {
|
|
1198
|
-
v = await holder.view.evaluate<unknown>(
|
|
1229
|
+
v = await holder.view.evaluate<unknown>(evaluable);
|
|
1199
1230
|
} catch (err) {
|
|
1200
1231
|
if (Date.now() >= deadline) throw err;
|
|
1201
1232
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
@@ -1272,7 +1303,7 @@ function buildBackend(
|
|
|
1272
1303
|
}
|
|
1273
1304
|
},
|
|
1274
1305
|
// ── Mobile-only primitives ──────────────────────────────────────────
|
|
1275
|
-
tapAt(x, y) {
|
|
1306
|
+
tapAt(x, y, opts) {
|
|
1276
1307
|
// Recorded as a "click" (the event schema stays desktop-shaped; the
|
|
1277
1308
|
// mobile facade owns the author-facing "tap" vocabulary). Dispatches
|
|
1278
1309
|
// a real touch so RN-Web's responder system fires.
|
|
@@ -1281,6 +1312,13 @@ function buildBackend(
|
|
|
1281
1312
|
type: "touchStart",
|
|
1282
1313
|
touchPoints: [{ x, y, id: 0 }],
|
|
1283
1314
|
});
|
|
1315
|
+
// Dwell between start and end, like a real finger. An instant
|
|
1316
|
+
// touchStart→touchEnd starves RN Pressables whose `onPressIn`
|
|
1317
|
+
// mutates state (optimistic label flips, scale animations): React
|
|
1318
|
+
// re-renders mid-gesture and the press never completes. The dwell
|
|
1319
|
+
// lets that commit land before release; well under any long-press
|
|
1320
|
+
// threshold (RN default 500ms).
|
|
1321
|
+
await new Promise((r) => setTimeout(r, opts?.durationMs ?? TAP_DWELL_MS));
|
|
1284
1322
|
await holder.view.cdp("Input.dispatchTouchEvent", {
|
|
1285
1323
|
type: "touchEnd",
|
|
1286
1324
|
touchPoints: [],
|
package/src/components/expo.ts
CHANGED
|
@@ -28,7 +28,12 @@ export interface ExpoOptions {
|
|
|
28
28
|
nodeVersion?: string;
|
|
29
29
|
/** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
|
|
30
30
|
outputDir?: string;
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
|
|
33
|
+
* into the image build so `expo export` inlines them into the bundle —
|
|
34
|
+
* Expo resolves them at export time, so runtime-only container env would be
|
|
35
|
+
* too late. Everything else applies to the static-server container only.
|
|
36
|
+
*/
|
|
32
37
|
env?: Record<string, string>;
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -106,6 +111,23 @@ export function expo(opts: ExpoOptions = {}) {
|
|
|
106
111
|
const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
|
|
107
112
|
const outputDir = opts.outputDir ?? "dist";
|
|
108
113
|
|
|
114
|
+
// Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
|
|
115
|
+
// time, so they must exist during the image build — runtime container env
|
|
116
|
+
// is too late. Bake them as ENV lines placed after `npm install` (an env
|
|
117
|
+
// edit re-exports without re-installing) and before the export RUN. They
|
|
118
|
+
// remain in `env` too, so server-side reads in the container agree.
|
|
119
|
+
const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) =>
|
|
120
|
+
k.startsWith("EXPO_PUBLIC_"),
|
|
121
|
+
);
|
|
122
|
+
for (const [k, v] of publicEnv) {
|
|
123
|
+
if (/[\r\n]/.test(v)) {
|
|
124
|
+
throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const publicEnvLines = publicEnv
|
|
128
|
+
.map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
|
|
129
|
+
.join("\n");
|
|
130
|
+
|
|
109
131
|
// CI=1 keeps `expo export` non-interactive. Static export resolves the web
|
|
110
132
|
// bundle deterministically into `outputDir`.
|
|
111
133
|
const dockerfile = `FROM node:${nodeVersion}
|
|
@@ -113,7 +135,7 @@ WORKDIR /app
|
|
|
113
135
|
ENV CI=1
|
|
114
136
|
COPY ${appDir}/ ./
|
|
115
137
|
RUN npm install
|
|
116
|
-
RUN npx expo export --platform web --output-dir ${outputDir}
|
|
138
|
+
${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
|
|
117
139
|
`;
|
|
118
140
|
|
|
119
141
|
return {
|
package/src/daemon.ts
CHANGED
|
@@ -845,6 +845,19 @@ async function buildServiceImage(
|
|
|
845
845
|
await fs.mkdir(dfDir, { recursive: true });
|
|
846
846
|
const dfPath = path.join(dfDir, "Dockerfile");
|
|
847
847
|
await fs.writeFile(dfPath, image.content);
|
|
848
|
+
// Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
|
|
849
|
+
// (next to the Dockerfile) in preference to the context root's
|
|
850
|
+
// `.dockerignore`, so this build sees the defaults plus ITS OWN
|
|
851
|
+
// `exclude` only — one service excluding `handhelds/**` no longer
|
|
852
|
+
// empties a sibling's build context. Verified on both the remote-buildx
|
|
853
|
+
// and DOCKER_BUILDKIT paths (client-side context filtering). The root
|
|
854
|
+
// union `.dockerignore` written at bootstrap stays as the fallback for
|
|
855
|
+
// the legacy non-BuildKit builder, which predates per-Dockerfile
|
|
856
|
+
// ignores.
|
|
857
|
+
await fs.writeFile(
|
|
858
|
+
`${dfPath}.dockerignore`,
|
|
859
|
+
[...DEFAULT_DOCKERIGNORE, ...(image.exclude ?? [])].join("\n") + "\n",
|
|
860
|
+
);
|
|
848
861
|
const useRemote = await ensureRemoteBuilder();
|
|
849
862
|
// Both the remote builder and a local buildx are BuildKit, so both emit
|
|
850
863
|
// per-step timing on stderr under `--progress=plain` (parsed below). Only
|
|
@@ -2045,6 +2058,16 @@ async function proxyToService(
|
|
|
2045
2058
|
// the public hostname. Lets origin servers that vhost by Host header
|
|
2046
2059
|
// continue to find the right virtual host.
|
|
2047
2060
|
fwdHeaders.set("host", `${service}:${port}`);
|
|
2061
|
+
// Fresh connection per upstream request — never reuse a pooled
|
|
2062
|
+
// keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
|
|
2063
|
+
// to 5s) close pooled connections under Bun's fetch, and the next
|
|
2064
|
+
// request on the dead socket fails with "socket closed unexpectedly"
|
|
2065
|
+
// even though the service is healthy. In-VM connects to a peer
|
|
2066
|
+
// container are sub-ms, so per-request connects cost nothing at test
|
|
2067
|
+
// scale. `connection: close` makes the upstream tear down immediately;
|
|
2068
|
+
// `keepalive: false` on the fetch below keeps Bun from pooling its end.
|
|
2069
|
+
// (Both verified effective on Bun 1.3.14.)
|
|
2070
|
+
fwdHeaders.set("connection", "close");
|
|
2048
2071
|
|
|
2049
2072
|
// Buffer bounded request bodies so a transient upstream connect failure
|
|
2050
2073
|
// can be retried (a ReadableStream body is consumed by the first
|
|
@@ -2085,8 +2108,13 @@ async function proxyToService(
|
|
|
2085
2108
|
redirect: "manual",
|
|
2086
2109
|
});
|
|
2087
2110
|
// decompress:false → forward the encoded body untouched (see fn doc).
|
|
2111
|
+
// keepalive:false → fresh connection per request (see fwdHeaders above).
|
|
2088
2112
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2089
|
-
const upstreamRes = await NATIVE_FETCH(upstreamReq, {
|
|
2113
|
+
const upstreamRes = await NATIVE_FETCH(upstreamReq, {
|
|
2114
|
+
decompress: false,
|
|
2115
|
+
keepalive: false,
|
|
2116
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2117
|
+
} as any);
|
|
2090
2118
|
// Strip hop-by-hop response headers; let Bun set content-length / TE.
|
|
2091
2119
|
const respHeaders = new Headers();
|
|
2092
2120
|
for (const [k, v] of upstreamRes.headers) {
|
|
@@ -2100,11 +2128,18 @@ async function proxyToService(
|
|
|
2100
2128
|
});
|
|
2101
2129
|
} catch (err) {
|
|
2102
2130
|
lastErr = err;
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2131
|
+
// Retry connection-level failures: connect errors, plus a socket
|
|
2132
|
+
// that died before any response bytes ("socket closed unexpectedly",
|
|
2133
|
+
// ECONNRESET, hang-up) — under boot-time bursts an upstream accepts
|
|
2134
|
+
// and drops connections while still warming up. Pre-response
|
|
2135
|
+
// failures are the standard retry class for reverse proxies
|
|
2136
|
+
// (nginx's proxy_next_upstream error). Anything that produced a
|
|
2137
|
+
// response is never replayed.
|
|
2105
2138
|
const msg = (err as Error)?.message ?? String(err);
|
|
2106
2139
|
const connectFailure =
|
|
2107
|
-
/unable to connect|connection refused|connect|typo in the url/i.test(
|
|
2140
|
+
/unable to connect|connection refused|connect|typo in the url|socket closed|connection closed|econnreset|socket hang ?up|epipe/i.test(
|
|
2141
|
+
msg,
|
|
2142
|
+
);
|
|
2108
2143
|
if (!connectFailure || attempt === attempts) break;
|
|
2109
2144
|
// The cached IP may be stale (container recreated) — re-resolve.
|
|
2110
2145
|
PROXY_IP_CACHE.delete(service);
|
package/src/mobile.ts
CHANGED
|
@@ -69,12 +69,13 @@ interface LocatorDesc {
|
|
|
69
69
|
* moment an action runs. Mirrors the select-then-act idiom shared by
|
|
70
70
|
* Playwright, Detox, and RN Testing Library. */
|
|
71
71
|
export interface MobileLocator {
|
|
72
|
-
/** Wait for the element to be visible
|
|
73
|
-
tap
|
|
72
|
+
/** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
|
|
73
|
+
* then touch-tap its center. `durationMs` overrides the touch dwell. */
|
|
74
|
+
tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void>;
|
|
74
75
|
/** Tap to focus, then type `text` via real key events. */
|
|
75
|
-
typeText(text: string): Promise<void>;
|
|
76
|
+
typeText(text: string, opts?: { timeoutMs?: number }): Promise<void>;
|
|
76
77
|
/** Clear a text input's current value (RN-Web controlled input safe). */
|
|
77
|
-
clearText(): Promise<void>;
|
|
78
|
+
clearText(opts?: { timeoutMs?: number }): Promise<void>;
|
|
78
79
|
/** Scroll the element to the center of the viewport. */
|
|
79
80
|
scrollIntoView(): Promise<void>;
|
|
80
81
|
/** Wait until the element is attached and visible (throws on timeout). */
|
|
@@ -168,6 +169,11 @@ function descLabel(desc: LocatorDesc): string {
|
|
|
168
169
|
return `${desc.kind} ${JSON.stringify(desc.value)}`;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
/** Default wait for a locator action's target to become visible. Override
|
|
173
|
+
* per action with `{ timeoutMs }` (animations that move elements mid-flight
|
|
174
|
+
* often need more than the default). */
|
|
175
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
176
|
+
|
|
171
177
|
function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
|
|
172
178
|
const label = descLabel(desc);
|
|
173
179
|
|
|
@@ -188,17 +194,17 @@ function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
|
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
return {
|
|
191
|
-
async tap() {
|
|
192
|
-
const { x, y } = await waitCoords(
|
|
193
|
-
await backend.tapAt(x, y);
|
|
197
|
+
async tap(opts) {
|
|
198
|
+
const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
199
|
+
await backend.tapAt(x, y, { durationMs: opts?.durationMs });
|
|
194
200
|
},
|
|
195
|
-
async typeText(text) {
|
|
196
|
-
const { x, y } = await waitCoords(
|
|
201
|
+
async typeText(text, opts) {
|
|
202
|
+
const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
197
203
|
await backend.tapAt(x, y);
|
|
198
204
|
await backend.type(text);
|
|
199
205
|
},
|
|
200
|
-
async clearText() {
|
|
201
|
-
await waitCoords(
|
|
206
|
+
async clearText(opts) {
|
|
207
|
+
await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
|
|
202
208
|
// Native value-setter + input event so RN-Web's controlled TextInput
|
|
203
209
|
// sees the change (a plain `.value = ""` is swallowed by React).
|
|
204
210
|
await backend.evaluate(
|
|
@@ -269,6 +275,13 @@ export interface Mobile {
|
|
|
269
275
|
getByLabel(text: string): MobileLocator;
|
|
270
276
|
/** Escape hatch: select by a raw CSS selector. */
|
|
271
277
|
locator(css: string): MobileLocator;
|
|
278
|
+
/**
|
|
279
|
+
* Touch-tap at viewport CSS coordinates — the escape hatch for targets no
|
|
280
|
+
* locator can select (unlabeled icon-only buttons, canvas hit areas). Same
|
|
281
|
+
* real-touch dispatch and dwell as a locator `tap()`. Prefer locators when
|
|
282
|
+
* the element has a testID/label; coordinates break on layout changes.
|
|
283
|
+
*/
|
|
284
|
+
tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
|
|
272
285
|
/** Swipe the screen in a direction (a touch drag from the center). */
|
|
273
286
|
swipe(direction: "up" | "down" | "left" | "right", opts?: { distance?: number }): Promise<void>;
|
|
274
287
|
/** Wheel-scroll the viewport by a pixel delta. */
|
|
@@ -334,6 +347,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
|
|
|
334
347
|
locator(css) {
|
|
335
348
|
return makeLocator(backend, { kind: "css", value: css });
|
|
336
349
|
},
|
|
350
|
+
tapAt(x, y, opts) {
|
|
351
|
+
return backend.tapAt(x, y, opts);
|
|
352
|
+
},
|
|
337
353
|
async swipe(direction, opts) {
|
|
338
354
|
const vp = await backend.probe<{ w: number; h: number }>(
|
|
339
355
|
"({ w: window.innerWidth, h: window.innerHeight })",
|
package/src/recorder.ts
CHANGED
|
@@ -115,8 +115,12 @@ export interface DbEvent extends BaseEvent {
|
|
|
115
115
|
query: string;
|
|
116
116
|
/** Parameter values. Best-effort JSON-safe; large/binary values stringified. */
|
|
117
117
|
params?: unknown[];
|
|
118
|
-
/** Rows returned
|
|
118
|
+
/** Rows returned — or rows AFFECTED when `rowsAffected` is set. */
|
|
119
119
|
rowCount?: number;
|
|
120
|
+
/** Set when `rowCount` counts affected rows (a non-RETURNING
|
|
121
|
+
* INSERT/UPDATE/DELETE) rather than a result set — rendered as
|
|
122
|
+
* "N affected" so an `UPDATE → 0 rows` can't read as "nothing updated". */
|
|
123
|
+
rowsAffected?: boolean;
|
|
120
124
|
/** Captured rows for table rendering in the web UI. Capped at
|
|
121
125
|
* MAX_DB_ROWS; `rowsTruncated` is set when there were more. */
|
|
122
126
|
rows?: unknown[];
|
package/src/sql.ts
CHANGED
|
@@ -124,6 +124,7 @@ export function instrumentSql(raw: RawSqlClient, label: string): SqlClient {
|
|
|
124
124
|
query,
|
|
125
125
|
params: params.length > 0 ? params.map(safeSerialize) : undefined,
|
|
126
126
|
rowCount: rowCountOf(value),
|
|
127
|
+
rowsAffected: isAffectedCount(query, value) || undefined,
|
|
127
128
|
rows,
|
|
128
129
|
rowsTruncated,
|
|
129
130
|
columns,
|
|
@@ -192,15 +193,34 @@ function reconstructSqlTemplate(
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
function rowCountOf(value: unknown): number | undefined {
|
|
195
|
-
if (Array.isArray(value)) return value.length;
|
|
196
196
|
if (value && typeof value === "object") {
|
|
197
197
|
const obj = value as { count?: unknown; rowCount?: unknown };
|
|
198
|
+
// Bun.SQL (postgres.js semantics): `.count` is rows RETURNED for
|
|
199
|
+
// SELECT/RETURNING queries and rows AFFECTED for other writes — where
|
|
200
|
+
// the result array itself is empty. Check it before `.length`, or a
|
|
201
|
+
// non-RETURNING `UPDATE` that touched 3 rows records a misleading 0.
|
|
198
202
|
if (typeof obj.count === "number") return obj.count;
|
|
199
203
|
if (typeof obj.rowCount === "number") return obj.rowCount;
|
|
200
204
|
}
|
|
205
|
+
if (Array.isArray(value)) return value.length;
|
|
201
206
|
return undefined;
|
|
202
207
|
}
|
|
203
208
|
|
|
209
|
+
/** True when the recorded count means "rows affected" rather than "rows
|
|
210
|
+
* returned" — a write with no result set. Lets the dashboard render
|
|
211
|
+
* `3 affected` instead of `3 rows` (and keeps `UPDATE … (0 rows)` from
|
|
212
|
+
* reading as "nothing updated"). */
|
|
213
|
+
function isAffectedCount(query: string, value: unknown): boolean {
|
|
214
|
+
if (Array.isArray(value) && value.length > 0) return false; // rows came back
|
|
215
|
+
const q = query.trimStart().slice(0, 8).toUpperCase();
|
|
216
|
+
return (
|
|
217
|
+
q.startsWith("INSERT") ||
|
|
218
|
+
q.startsWith("UPDATE") ||
|
|
219
|
+
q.startsWith("DELETE") ||
|
|
220
|
+
q.startsWith("MERGE")
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
204
224
|
const MAX_DB_ROWS = 50;
|
|
205
225
|
|
|
206
226
|
function captureRows(value: unknown): {
|