blob: e5d5628bdd9ba2bbe99821f19503ab6195b70023 [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {afterEach, describe, expect, test, vi} from 'vitest';
import {
BigtraceHttpError,
BigtraceQueryClient,
parseQueryResponse,
QueryNotFoundError,
} from './bigtrace_query_client';
import {coerceFiltersForWire, encodeFilters} from './filter_encoding';
import type {Filter} from '../../components/widgets/datagrid/model';
import type {SettingFilter} from '../settings/settings_types';
// Both the native-array body and the change-detection-key string route through
// `coerceFiltersForWire`; lock the always-strings coercion in once here.
describe('coerceFiltersForWire', () => {
test('passes strings and JSON null through unchanged', () => {
expect(
coerceFiltersForWire([
{field: 'name', op: 'glob', value: 'ui::*'},
{field: 'kind', op: 'in', value: ['a', 'b']},
{field: 'parent_id', op: 'is null'},
]),
).toEqual([
{field: 'name', op: 'glob', value: 'ui::*'},
{field: 'kind', op: 'in', value: ['a', 'b']},
{field: 'parent_id', op: 'is null'},
]);
});
test('coerces numbers / bigints / booleans to strings (always-strings wire)', () => {
// int64 past Number.MAX_SAFE_INTEGER survives as a string (no precision
// loss).
expect(
coerceFiltersForWire([
{field: 'count', op: '>=', value: 10},
{field: 'dur', op: '>', value: 9223372036854775807n},
JSON.parse('{"field":"flag","op":"=","value":true}'),
]),
).toEqual([
{field: 'count', op: '>=', value: '10'},
{field: 'dur', op: '>', value: '9223372036854775807'},
{field: 'flag', op: '=', value: 'true'},
]);
});
test('coerces every element of an in-list', () => {
expect(
coerceFiltersForWire([{field: 'tid', op: 'in', value: [1n, 2, 3n]}]),
).toEqual([{field: 'tid', op: 'in', value: ['1', '2', '3']}]);
});
test('preserves JSON null distinct from the literal "null" string', () => {
// null must NOT collapse to the literal "null" text value.
expect(coerceFiltersForWire([{field: 'a', op: '=', value: null}])).toEqual([
{field: 'a', op: '=', value: null},
]);
});
test('returns a fresh array (does not mutate the input)', () => {
const input: Filter[] = [{field: 'n', op: '=', value: 1}];
const out = coerceFiltersForWire(input);
expect(out).not.toBe(input);
expect(input[0]).toEqual({field: 'n', op: '=', value: 1});
});
});
// The change-detection key must be canonical (key-sorted) so equivalent
// filters compare equal.
describe('encodeFilters', () => {
test('shares value coercion with coerceFiltersForWire', () => {
expect(
JSON.parse(
encodeFilters([{field: 'dur', op: '>', value: 9007199254740993n}]),
),
).toEqual([{field: 'dur', op: '>', value: '9007199254740993'}]);
});
test('produces canonical (key-sorted) output regardless of construction order', () => {
const a = encodeFilters([{field: 'x', op: '=', value: '1'}]);
const b = encodeFilters([JSON.parse('{"value":"1","op":"=","field":"x"}')]);
expect(a).toBe(b);
expect(a).toBe('[{"field":"x","op":"=","value":"1"}]');
});
test('empty array → "[]"', () => {
expect(encodeFilters([])).toBe('[]');
});
});
describe('BigtraceQueryClient trace-metadata wire', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
function captureFetch(payload: unknown = {}): ReturnType<typeof vi.fn> {
const body = JSON.stringify(payload);
const fakeResp = {
ok: true,
status: 200,
text: () => Promise.resolve(body),
json: () => Promise.resolve(payload),
};
const fn = vi.fn().mockResolvedValue(fakeResp);
global.fetch = fn as unknown as typeof fetch;
return fn;
}
function urlFrom(fetchMock: ReturnType<typeof vi.fn>): string {
return (fetchMock.mock.calls[0] as unknown[])[0] as string;
}
function initFrom(fetchMock: ReturnType<typeof vi.fn>): RequestInit {
return (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
}
function bodyFrom(
fetchMock: ReturnType<typeof vi.fn>,
): Record<string, unknown> {
return JSON.parse(initFrom(fetchMock).body as string);
}
const traceDir: SettingFilter = {
settingId: 'trace_directory',
values: ['/var/traces'],
category: 'TRACE_ADDRESS',
};
test('listTraceMetadata POSTs /trace_metadata with mapped settings + paging', async () => {
const fetchMock = captureFetch({columnNames: ['file_name'], rows: []});
const client = new BigtraceQueryClient('http://example/');
await client.listTraceMetadata([traceDir], 50, 10);
expect(urlFrom(fetchMock)).toBe('http://example//trace_metadata');
expect(initFrom(fetchMock).method).toBe('POST');
const body = bodyFrom(fetchMock);
// Settings map from camelCase to the snake_case wire fields.
expect(body.settings).toEqual([
{
setting_id: 'trace_directory',
values: ['/var/traces'],
category: 'TRACE_ADDRESS',
},
]);
expect(body.limit).toBe(50);
expect(body.offset).toBe(10);
// Optional fields stay off the wire when not provided.
expect(body.order_by).toBeUndefined();
expect(body.filters).toBeUndefined();
expect(body.columns).toBeUndefined();
});
test('listTraceMetadata parses the always-strings response into rows', async () => {
const fetchMock = captureFetch({
columnNames: ['file_name', 'size_bytes'],
rows: [{values: ['a.pftrace', '9007199254740993']}],
});
const client = new BigtraceQueryClient('http://example/');
const page = await client.listTraceMetadata([traceDir], 50, 0);
expect(page.columns).toEqual(['file_name', 'size_bytes']);
// Big values survive as strings (no Number coercion, no precision loss).
expect(page.rows).toEqual([
{file_name: 'a.pftrace', size_bytes: '9007199254740993'},
]);
expect(urlFrom(fetchMock)).toContain('/trace_metadata');
});
test('listTraceMetadata ships order_by, native-array filters, and columns when set', async () => {
const fetchMock = captureFetch({columnNames: [], rows: []});
const client = new BigtraceQueryClient('http://example/');
await client.listTraceMetadata(
[],
50,
0,
undefined,
'size_bytes desc',
[{field: 'size_bytes', op: '>', value: 100}],
['file_name', 'size_bytes'],
);
const body = bodyFrom(fetchMock);
expect(body.order_by).toBe('size_bytes desc');
// Native JSON array, not a stringified blob.
expect(Array.isArray(body.filters)).toBe(true);
expect(body.filters).toEqual([
{field: 'size_bytes', op: '>', value: '100'},
]);
expect(body.columns).toEqual(['file_name', 'size_bytes']);
});
test('listTraceMetadata omits empty order_by / filter / columns', async () => {
const fetchMock = captureFetch({columnNames: [], rows: []});
const client = new BigtraceQueryClient('http://example/');
await client.listTraceMetadata([], 50, 0, undefined, '', [], []);
const body = bodyFrom(fetchMock);
expect(body.order_by).toBeUndefined();
expect(body.filters).toBeUndefined();
expect(body.columns).toBeUndefined();
});
test('listTraceMetadataSchema POSTs /trace_metadata_schema with settings', async () => {
const fetchMock = captureFetch({
columns: [
{name: 'file_name', type: 'TEXT', defaultVisible: true},
{name: 'size_bytes', type: 'BIGINT', defaultVisible: false},
],
});
const client = new BigtraceQueryClient('http://example/');
const schema = await client.listTraceMetadataSchema([traceDir]);
expect(urlFrom(fetchMock)).toBe('http://example//trace_metadata_schema');
expect(initFrom(fetchMock).method).toBe('POST');
expect(bodyFrom(fetchMock).settings).toEqual([
{
setting_id: 'trace_directory',
values: ['/var/traces'],
category: 'TRACE_ADDRESS',
},
]);
expect(schema.columns.map((c) => c.name)).toEqual([
'file_name',
'size_bytes',
]);
expect(schema.columns[0].defaultVisible).toBe(true);
});
});
describe('BigtraceQueryClient execute trace-selection snapshot', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
function captureFetch(): ReturnType<typeof vi.fn> {
const payload = {queryUuid: 'uid', columnNames: [], rows: []};
const fakeResp = {
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(payload)),
json: () => Promise.resolve(payload),
};
const fn = vi.fn().mockResolvedValue(fakeResp);
global.fetch = fn as unknown as typeof fetch;
return fn;
}
function bodyFrom(
fetchMock: ReturnType<typeof vi.fn>,
): Record<string, unknown> {
const init = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
return JSON.parse(init.body as string);
}
test('omits every trace_* field when no options are passed', async () => {
const fetchMock = captureFetch();
const client = new BigtraceQueryClient('http://example/');
await client.executeAsync('select 1', 100, []);
const body = bodyFrom(fetchMock);
expect(body.perfetto_sql).toBe('select 1');
expect(body.limit).toBe(100);
expect(body.trace_filters).toBeUndefined();
expect(body.trace_metadata_columns).toBeUndefined();
expect(body.trace_order_by).toBeUndefined();
});
test('ships trace_filters as a native, coerced array', async () => {
const fetchMock = captureFetch();
const client = new BigtraceQueryClient('http://example/');
await client.executeAsync('select 1', 100, [], undefined, {
traceFilters: [
{field: 'file_name', op: 'glob', value: '*.pftrace'},
{field: 'size_bytes', op: '>', value: 1000},
],
});
const body = bodyFrom(fetchMock);
expect(Array.isArray(body.trace_filters)).toBe(true);
expect(body.trace_filters).toEqual([
{field: 'file_name', op: 'glob', value: '*.pftrace'},
{field: 'size_bytes', op: '>', value: '1000'},
]);
});
test('ships trace_metadata_columns / trace_order_by when set', async () => {
const fetchMock = captureFetch();
const client = new BigtraceQueryClient('http://example/');
await client.executeSync('select 1', 100, [], undefined, {
traceMetadataColumns: ['device_name', 'android_id'],
traceOrderBy: 'size_bytes desc',
});
const body = bodyFrom(fetchMock);
expect(body.trace_metadata_columns).toEqual(['device_name', 'android_id']);
expect(body.trace_order_by).toBe('size_bytes desc');
});
test('omits empty / default trace_* fields', async () => {
const fetchMock = captureFetch();
const client = new BigtraceQueryClient('http://example/');
await client.executeAsync('select 1', 100, [], undefined, {
traceFilters: [],
traceMetadataColumns: [],
traceOrderBy: '',
});
const body = bodyFrom(fetchMock);
expect(body.trace_filters).toBeUndefined();
expect(body.trace_metadata_columns).toBeUndefined();
expect(body.trace_order_by).toBeUndefined();
});
});
describe('BigtraceQueryClient.fetchResults', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
function captureFetch(
payload: unknown = {columnNames: [], rows: []},
): ReturnType<typeof vi.fn> {
const body = JSON.stringify(payload);
const fakeResp = {
ok: true,
status: 200,
text: () => Promise.resolve(body),
json: () => Promise.resolve(payload),
};
const fn = vi.fn().mockResolvedValue(fakeResp);
global.fetch = fn as unknown as typeof fetch;
return fn;
}
function urlFrom(fetchMock: ReturnType<typeof vi.fn>): string {
return (fetchMock.mock.calls[0] as unknown[])[0] as string;
}
function initFrom(fetchMock: ReturnType<typeof vi.fn>): RequestInit {
return (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
}
function bodyFrom(
fetchMock: ReturnType<typeof vi.fn>,
): Record<string, unknown> {
return JSON.parse(initFrom(fetchMock).body as string);
}
test('POSTs to a plain :fetch_results URL with limit/offset in the body', async () => {
const fetchMock = captureFetch();
const client = new BigtraceQueryClient('http://example/');
await client.fetchResults('uid', 50, 10);
expect(urlFrom(fetchMock)).toBe(
'http://example//query_executions/uid:fetch_results',
);
expect(initFrom(fetchMock).method).toBe('POST');
expect(urlFrom(fetchMock)).not.toContain('?');
const body = bodyFrom(fetchMock);
expect(body.limit).toBe(50);
expect(body.offset).toBe(10);
expect(body.order_by).toBeUndefined();
expect(body.filters).toBeUndefined();
expect(body.columns).toBeUndefined();
});
test('ships order_by, native-array filters, and the columns field-mask', async () => {
const fetchMock = captureFetch();
const client = new BigtraceQueryClient('http://example/');
await client.fetchResults(
'uid',
50,
0,
undefined,
'dur desc',
[{field: 'dur', op: '>', value: 9223372036854775807n}],
['name', 'dur', 'device_name'],
);
const body = bodyFrom(fetchMock);
expect(body.order_by).toBe('dur desc');
expect(Array.isArray(body.filters)).toBe(true);
// bigint coerced to string for lossless int64 round-trip.
expect(body.filters).toEqual([
{field: 'dur', op: '>', value: '9223372036854775807'},
]);
expect(body.columns).toEqual(['name', 'dur', 'device_name']);
});
test('exposes availableColumnNames from the response (the column-picker union)', async () => {
const fetchMock = captureFetch({
columnNames: ['name', 'dur'],
rows: [{values: ['slice', '10']}],
availableColumnNames: ['name', 'dur', 'device_name', 'android_id'],
});
const client = new BigtraceQueryClient('http://example/');
const page = await client.fetchResults('uid', 50, 0);
expect(page.columns).toEqual(['name', 'dur']);
expect(page.availableColumnNames).toEqual([
'name',
'dur',
'device_name',
'android_id',
]);
expect(urlFrom(fetchMock)).toContain(':fetch_results');
});
});
describe('BigtraceQueryClient error responses', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
});
function failWith(status: number, body: string): void {
const fakeResp = {
ok: false,
status,
text: () => Promise.resolve(body),
json: () => Promise.reject(new Error('not called on error path')),
};
global.fetch = vi
.fn()
.mockResolvedValue(fakeResp) as unknown as typeof fetch;
}
test('a 400 throws BigtraceHttpError carrying the backend detail and status', async () => {
failWith(400, JSON.stringify({detail: "unknown column 'foo'"}));
const client = new BigtraceQueryClient('http://example/');
const err = await client.fetchResults('uid', 10, 0).catch((e) => e);
expect(err).toBeInstanceOf(BigtraceHttpError);
expect(err.status).toBe(400);
expect(err.detail).toBe("unknown column 'foo'");
});
test('a non-JSON error body becomes the detail verbatim', async () => {
failWith(500, 'upstream exploded');
const client = new BigtraceQueryClient('http://example/');
const err = await client.fetchResults('uid', 10, 0).catch((e) => e);
expect(err).toBeInstanceOf(BigtraceHttpError);
expect(err.status).toBe(500);
expect(err.detail).toBe('upstream exploded');
});
test('a 404 throws QueryNotFoundError, not BigtraceHttpError', async () => {
failWith(404, JSON.stringify({detail: 'gone'}));
const client = new BigtraceQueryClient('http://example/');
const err = await client.fetchResults('uid', 10, 0).catch((e) => e);
expect(err).toBeInstanceOf(QueryNotFoundError);
});
});
describe('parseQueryResponse', () => {
test('passes values through: JSON null is SQL NULL, the string "NULL" stays text', () => {
const page = parseQueryResponse({
columnNames: ['name', 'note'],
rows: [{values: ['NULL', null]}, {values: ['x', 'y']}],
});
expect(page.columns).toEqual(['name', 'note']);
expect(page.rows[0]).toEqual({name: 'NULL', note: null});
expect(page.rows[1]).toEqual({name: 'x', note: 'y'});
});
});