Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/__tests__/commands/snapshot-readiness.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import {describe, it, expect} from 'vitest';
import {snapshot_running_status} from '../../commands/dataset';
import type {Response_envelope} from '../../utils/client';

// The snapshot endpoint answers in two dimensions that vary independently:
// the HTTP status (200 data vs 202 still-building) and the body shape (a
// parsed object under --format json, raw text under csv/ndjson/jsonl). Every
// combination has to resolve correctly, because the failure that matters is
// silent: a not-ready response mistaken for data prints a status stub and
// exits 0, which downstream ETL then consumes as if it were the dataset.
const envelope = (status: number, body: unknown): Response_envelope=>({
status,
headers: new Headers(),
body,
});

describe('commands/dataset.snapshot_running_status', ()=>{
it('200 + object data is ready', ()=>{
expect(snapshot_running_status(envelope(200, [{a: 1}])))
.toBeUndefined();
});

it('200 + text data is ready (csv/jsonl formats)', ()=>{
expect(snapshot_running_status(envelope(200, 'a,b\n1,2\n')))
.toBeUndefined();
});

it('200 + object status body is still running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'running'})))
.toBe('running');
});

it('200 + TEXT status body is still running', ()=>{
// The regression this predicate exists for: under a non-json format
// the client hands back a string, so an object-only check misses it
// and the status stub gets printed as data.
expect(snapshot_running_status(
envelope(200, '{"status":"running"}'))).toBe('running');
});

it('202 is still running even when the body looks like data', ()=>{
// The protocol is the most authoritative signal available.
expect(snapshot_running_status(envelope(202, [{a: 1}])))
.toBe('building');
});

it('202 + text status body reports the real status', ()=>{
expect(snapshot_running_status(
envelope(202, '{"status":"starting"}'))).toBe('starting');
});

it('keeps the real status string rather than a flattened literal', ()=>{
// Progress output prints this value, so collapsing every running
// state to one token would lose starting -> building -> running.
for (const s of ['starting', 'building', 'running'])
{
expect(snapshot_running_status(envelope(200, {status: s})))
.toBe(s);
}
});

it('treats terminal statuses as ready, not running', ()=>{
expect(snapshot_running_status(envelope(200, {status: 'ready'})))
.toBeUndefined();
expect(snapshot_running_status(envelope(200, {status: 'failed'})))
.toBeUndefined();
});

it('treats unparseable text as data', ()=>{
expect(snapshot_running_status(envelope(200, 'not json at all')))
.toBeUndefined();
});
});
185 changes: 185 additions & 0 deletions src/__tests__/utils/client.request.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';

// load_config() reads a real file from the user's config dir and can override
// api_url, which would make URL assertions depend on the machine running the
// suite. Pin it.
vi.mock('../../utils/config', ()=>({
load: ()=>({api_url: 'https://api.brightdata.com'}),
}));

vi.mock('../../utils/output', ()=>({
dim: (s: string)=>s,
}));

import {
request,
get_with_status,
Client_api_error,
} from '../../utils/client';

const json_response = (status: number, body: unknown)=>new Response(
JSON.stringify(body),
{status, headers: {'content-type': 'application/json'}}
);

const text_response = (status: number, body: string)=>new Response(
body,
{status, headers: {'content-type': 'text/plain'}}
);

// Fast retries — real backoff starts at 500ms and doubles, which would add
// seconds of wall-clock to the suite.
const fast_retry = {retry: {base_ms: 1, max_ms: 2}};

describe('utils/client.request', ()=>{
beforeEach(()=>{
vi.spyOn(console, 'error').mockImplementation(()=>{});
});

afterEach(()=>{
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('returns the parsed body only (unchanged contract)', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>json_response(200, {ok: 1})));
await expect(request('key', '/x')).resolves.toEqual({ok: 1});
});

it('returns text when the response is not json', async()=>{
vi.stubGlobal('fetch', vi.fn(async()=>text_response(200, 'a,b\n')));
await expect(request('key', '/x')).resolves.toBe('a,b\n');
});

it('sends bearer auth to the resolved url', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('secret', '/datasets/v3/snapshot/s1');
const [url, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(url).toBe(
'https://api.brightdata.com/datasets/v3/snapshot/s1');
expect((init.headers as Record<string, string>)['Authorization'])
.toBe('Bearer secret');
});

describe('get_with_status', ()=>{
it('exposes the status code alongside the body', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {rows: 1})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(200);
expect(env.body).toEqual({rows: 1});
});

it('surfaces 202 rather than hiding it behind the body', async()=>{
// 202 is res.ok, so before this the caller could not tell an
// accepted-but-unfinished job from finished data.
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(202, {status: 'running'})));
const env = await get_with_status('key', '/x');
expect(env.status).toBe(202);
});

it('exposes response headers', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>json_response(200, {})));
const env = await get_with_status('key', '/x');
expect(env.headers.get('content-type'))
.toContain('application/json');
});
});

describe('error typing', ()=>{
it('throws a Client_api_error carrying the status', async()=>{
vi.stubGlobal('fetch',
vi.fn(async()=>text_response(404, 'no such dataset')));
await expect(request('key', '/x', fast_retry))
.rejects.toBeInstanceOf(Client_api_error);
try {
await request('key', '/x', fast_retry);
} catch(e) {
const err = e as Client_api_error;
expect(err.status).toBe(404);
// message bytes are part of the contract: scraper-studio
// matches on error prose (e.g. 'realtime job limit')
expect(err.message).toBe(
'Error: no such dataset\n'
+' Status: 404\n'
+' Hint: Resource not found. Check the URL or dataset '
+'type.'
);
}
});

it('does not retry an API error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(400, 'bad input'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(1);
});

it('retries a network error whose message starts with "Error:"',
async()=>{
// Retry used to be decided by message.startsWith('Error:'),
// so a network failure worded this way was misclassified as a
// final API error and never retried.
const fetch_mock = vi.fn(async()=>{
throw new Error('Error: socket hang up');
});
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry))
.rejects.toThrow('Network request failed');
expect(fetch_mock).toHaveBeenCalledTimes(4);
});

it('retries transient statuses then surfaces the error', async()=>{
const fetch_mock = vi.fn(async()=>text_response(503, 'busy'));
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', fast_retry)).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(4);
});
});

describe('request timeout', ()=>{
// A hung connection produces no error at all, so without an abort the
// retry loop never engages and the CLI waits forever.
const hang_until_aborted = (_url: string, init: RequestInit)=>
new Promise<Response>((_resolve, reject)=>{
init.signal?.addEventListener('abort', ()=>{
const err = new Error('aborted');
err.name = 'TimeoutError';
reject(err);
});
});

it('aborts a hung request instead of hanging forever', async()=>{
vi.stubGlobal('fetch', vi.fn(hang_until_aborted));
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow('Request timed out after 0s');
});

it('retries a timeout once, not the full retry budget', async()=>{
// timeout x attempts multiplies the user-visible stall, so the
// generic budget (3 retries) is deliberately not reused here.
const fetch_mock = vi.fn(hang_until_aborted);
vi.stubGlobal('fetch', fetch_mock);
await expect(request('key', '/x', {
timeout_ms: 20,
...fast_retry,
})).rejects.toThrow();
expect(fetch_mock).toHaveBeenCalledTimes(2);
});

it('passes an abort signal on every attempt', async()=>{
const fetch_mock = vi.fn(async()=>json_response(200, {}));
vi.stubGlobal('fetch', fetch_mock);
await request('key', '/x');
const [, init] = fetch_mock.mock.calls[0] as unknown as
[string, RequestInit];
expect(init.signal).toBeInstanceOf(AbortSignal);
});
});
});
44 changes: 38 additions & 6 deletions src/commands/dataset.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import {Command} from 'commander';
import {ensure_authenticated} from '../utils/auth';
import {get, post} from '../utils/client';
import {get_with_status, post} from '../utils/client';
import type {Response_envelope} from '../utils/client';
import {print, dim, fail} from '../utils/output';
import {start as start_spinner} from '../utils/spinner';
import {parse_timeout, poll_until} from '../utils/polling';
Expand DownExpand Up@@ -214,6 +215,36 @@ const extract_status = (result: unknown): string|undefined=>{
return undefined;
};

// A snapshot body only parses to an object when the requested format is json.
// Under csv/ndjson/jsonl the client hands back text, so a status payload would
// arrive as a string and extract_status would miss it.
const parse_if_json_text = (body: unknown): unknown=>{
if (typeof body != 'string')
return body;
try {
return JSON.parse(body);
} catch(_e) {
return body;
}
};

// Is this snapshot still building? Three signals, most authoritative first:
// 1. HTTP 202 — the server says "accepted, not done". Trusted outright.
// 2. a parsed body carrying a running status.
// 3. a *text* body that parses to one (the csv/ndjson/jsonl case above).
// Returns the running status string (so progress output keeps showing
// starting/building/running rather than a flattened literal), or undefined
// when the response is the data.
const snapshot_running_status = (
env: Response_envelope
): string|undefined=>{
const status = extract_status(parse_if_json_text(env.body));
const is_running = !!status && RUNNING_STATUSES.includes(status);
if (env.status == 202)
return is_running ? status : 'building';
return is_running ? status : undefined;
};

const handle_pipelines = async(
dataset_type_raw: string,
params: string[],
Expand DownExpand Up@@ -266,14 +297,15 @@ const handle_pipelines = async(
}
console.error(dim(`Triggered collection with snapshot ID:` +
`${snapshot_id}`));
const poll_result = await poll_until<unknown>({
const poll_result = await poll_until<Response_envelope>({
timeout_seconds: timeout,
fetch_once: ()=>{
const endpoint = `${SNAPSHOT_ENDPOINT}/${snapshot_id}`
+`?format=${format}`;
return get<unknown>(api_key, endpoint, {timing: opts.timing});
return get_with_status<unknown>(
api_key, endpoint, {timing: opts.timing});
},
get_status: extract_status,
get_status: snapshot_running_status,
running_statuses: RUNNING_STATUSES,
timeout_label: 'data',
on_running: ({attempt, timeout_seconds, status})=>{
Expand All@@ -286,7 +318,7 @@ const handle_pipelines = async(
console.error(dim(
`Data received after ${poll_result.attempts} attempts`
));
const result = poll_result.result;
const result = poll_result.result.body;
const cleaned_result = format == 'json' ? strip_nulls(result) : result;
print(cleaned_result, {
json: opts.json,
Expand DownExpand Up@@ -333,4 +365,4 @@ add_examples(pipelines_command, [
},
]);

export {pipelines_command, handle_pipelines};
export {pipelines_command, handle_pipelines, snapshot_running_status};
Loading