blob: 456b0c385b7b0c408ae2196cf479a4b3e8c9fec1 [file] [log] [blame]
/* eslint-disable dot-notation */
import { assert } from 'chai';
import fetchMock from 'fetch-mock';
import {
ColumnHeader,
CommitNumber,
FrameResponse,
QueryConfig,
TimestampSeconds,
Trace,
TraceSet,
DataFrame,
ReadOnlyParamSet,
} from '../json';
import { deepCopy } from '../../../infra-sk/modules/object';
import {
calculateRangeChange,
defaultPointSelected,
ExploreSimpleSk,
isValidSelection,
PointSelected,
selectionToEvent,
CommitRange,
GraphConfig,
updateShortcut,
State,
} from './explore-simple-sk';
import { MdDialog } from '@material/web/dialog/dialog';
import { MdSwitch } from '@material/web/switch/switch';
import { PlotSummarySk } from '../plot-summary-sk/plot-summary-sk';
import { setUpElementUnderTest, waitForRender } from '../../../infra-sk/modules/test_util';
import { generateFullDataFrame } from '../dataframe/test_utils';
import sinon from 'sinon';
// Import for side effects. Make `plotSummary`(has no direct interaction with the module)
// work when run in isolation.
import './explore-simple-sk';
fetchMock.config.overwriteRoutes = true;
const now = 1726081856; // an arbitrary UNIX time;
const timeSpan = 89; // an arbitrary prime number for time span between commits .
window.perf = {
instance_url: '',
radius: 2,
key_order: null,
num_shift: 50,
interesting: 2,
step_up_only: false,
commit_range_url: '',
demo: true,
display_group_by: false,
hide_list_of_commits_on_explore: false,
notifications: 'none',
fetch_chrome_perf_anomalies: false,
fetch_anomalies_from_sql: false,
feedback_url: '',
chat_url: '',
help_url_override: '',
trace_format: '',
need_alert_action: false,
bug_host_url: '',
git_repo_url: '',
keys_for_commit_range: [],
keys_for_useful_links: [],
skip_commit_detail_display: false,
image_tag: 'fake-tag',
remove_default_stat_value: false,
enable_skia_bridge_aggregation: false,
show_json_file_display: false,
always_show_commit_info: false,
show_triage_link: true,
show_bisect_btn: true,
app_version: 'test-version',
instance_name: 'chrome-perf-test',
header_image_url: '',
enable_v2_ui: false,
dev_mode: false,
extra_links: null,
};
describe('calculateRangeChange', () => {
const offsets: CommitRange = [100, 120] as CommitRange;
it('finds a left range increase', () => {
const zoom: CommitRange = [-1, 10] as CommitRange;
const clampedZoom: CommitRange = [0, 10] as CommitRange;
const ret = calculateRangeChange(zoom, clampedZoom, offsets);
assert.isTrue(ret.rangeChange);
// We shift left by RANGE_CHANGE_ON_ZOOM_PERCENT of the total range.
assert.deepEqual(ret.newOffsets, [90, 110]);
});
it('finds a right range increase', () => {
const zoom: CommitRange = [0, 12] as CommitRange;
const clampedZoom: CommitRange = [0, 10] as CommitRange;
const ret = calculateRangeChange(zoom, clampedZoom, offsets);
assert.isTrue(ret.rangeChange);
// We shift right by RANGE_CHANGE_ON_ZOOM_PERCENT of the total range.
assert.deepEqual(ret.newOffsets, [110, 130]);
});
it('find an increase in the range in both directions', () => {
const zoom: CommitRange = [-1, 11] as CommitRange;
const clampedZoom: CommitRange = [0, 10] as CommitRange;
const ret = calculateRangeChange(zoom, clampedZoom, offsets);
assert.isTrue(ret.rangeChange);
// We shift both the begin and end of the range by
// RANGE_CHANGE_ON_ZOOM_PERCENT of the total range.
assert.deepEqual(ret.newOffsets, [90, 130]);
});
it('find an increase in the range in both directions and clamps the offset', () => {
const zoom: CommitRange = [-1, 11] as CommitRange;
const clampedZoom: CommitRange = [0, 10] as CommitRange;
const widerOffsets: CommitRange = [0, 100] as CommitRange;
const ret = calculateRangeChange(zoom, clampedZoom, widerOffsets);
assert.isTrue(ret.rangeChange);
// We shift both the begin and end of the range by
// RANGE_CHANGE_ON_ZOOM_PERCENT of the total range.
assert.deepEqual(ret.newOffsets, [0, 150]);
});
it('does not find a range change', () => {
const zoom: CommitRange = [0, 10] as CommitRange;
const clampedZoom: CommitRange = [0, 10] as CommitRange;
const ret = calculateRangeChange(zoom, clampedZoom, offsets);
assert.isFalse(ret.rangeChange);
});
describe('Even X-Axis Spacing Cache', () => {
const CACHE_KEY = 'explore-simple-sk-even-x-axis-spacing';
let explore: ExploreSimpleSk;
beforeEach(async () => {
// Clear cache before each test
localStorage.removeItem(CACHE_KEY);
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await waitForRender(explore);
});
afterEach(() => {
localStorage.removeItem(CACHE_KEY);
fetchMock.reset();
});
it('should load the state from localStorage on connectedCallback', async () => {
localStorage.setItem(CACHE_KEY, 'true');
const newExplore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await waitForRender(newExplore);
assert.isTrue(newExplore.state.evenXAxisSpacing);
const switchEl = newExplore.querySelector(
'#settings-dialog #even-x-axis-spacing-switch'
) as MdSwitch;
assert.isTrue(switchEl.selected);
});
it('should save the state to localStorage when the switch is toggled', async () => {
const settingsDialog = explore.querySelector('#settings-dialog') as MdDialog;
const switchEl = settingsDialog.querySelector('#even-x-axis-spacing-switch') as MdSwitch;
switchEl.selected = true;
switchEl.dispatchEvent(new Event('change'));
await waitForRender(explore);
assert.equal(localStorage.getItem(CACHE_KEY), 'true');
switchEl.selected = false;
switchEl.dispatchEvent(new Event('change'));
await waitForRender(explore);
assert.equal(localStorage.getItem(CACHE_KEY), 'false');
});
it('should override incoming state with localStorage value in setState', async () => {
localStorage.setItem(CACHE_KEY, 'true');
const newState = new State();
explore.setUseDiscreteAxis(false); // Parent tries to set it to false
explore.state = newState;
await waitForRender(explore);
assert.isTrue(explore.state.evenXAxisSpacing); // Should still be true due to cache
const switchEl = explore.querySelector(
'#settings-dialog #even-x-axis-spacing-switch'
) as MdSwitch;
assert.isTrue(switchEl.selected);
});
it('should default to false if no value in localStorage', async () => {
assert.isFalse(explore.state.evenXAxisSpacing);
const switchEl = explore.querySelector(
'#settings-dialog #even-x-axis-spacing-switch'
) as MdSwitch;
assert.isFalse(switchEl.selected);
});
});
});
describe('zoomKey', () => {
let element: ExploreSimpleSk;
beforeEach(async () => {
// We need to flush the fetch mock to ensure the element is initialized
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/frame/start', {
status: 'Finished',
results: { dataframe: { traceset: {} } },
});
element = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
(element as any).plotSummary = {
value: {
selectedValueRange: { begin: 100, end: 200 },
},
} as any;
(element as any).dfRepo = {
value: {
dataframe: {
header: [
{ offset: 0, timestamp: 1000 },
{ offset: 300, timestamp: 2000 },
],
},
} as any,
} as any;
// Mock summarySelected to verify it's called
sinon.stub(element, 'summarySelected');
element.state.domain = 'commit';
});
afterEach(() => {
sinon.restore();
});
it('zooms in', () => {
// Zoom in by 10% of 100 (range 100-200) = 10.
// New range should be [110, 190].
element.onZoomIn();
assert.isTrue((element.summarySelected as sinon.SinonStub).calledOnce);
const event = (element.summarySelected as sinon.SinonStub).firstCall.args[0] as CustomEvent;
assert.deepEqual(event.detail.value, { begin: 110, end: 190 });
assert.deepEqual((element as any).plotSummary.value.selectedValueRange, {
begin: 110,
end: 190,
});
});
it('zooms out', () => {
// Zoom out by 10% of 100 (range 100-200) = 10.
// New range should be [90, 210].
element.onZoomOut();
assert.isTrue((element.summarySelected as sinon.SinonStub).calledOnce);
const event = (element.summarySelected as sinon.SinonStub).firstCall.args[0] as CustomEvent;
assert.deepEqual(event.detail.value, { begin: 90, end: 210 });
assert.deepEqual((element as any).plotSummary.value.selectedValueRange, {
begin: 90,
end: 210,
});
});
it('pans left', () => {
// Pan left by 10% of 100 = 10.
// New range should be [90, 190].
element.onPanLeft();
assert.isTrue((element.summarySelected as sinon.SinonStub).calledOnce);
const event = (element.summarySelected as sinon.SinonStub).firstCall.args[0] as CustomEvent;
assert.deepEqual(event.detail.value, { begin: 90, end: 190 });
assert.deepEqual((element as any).plotSummary.value.selectedValueRange, {
begin: 90,
end: 190,
});
});
it('pans right', () => {
// Pan right by 10% of 100 = 10.
// New range should be [110, 210].
element.onPanRight();
assert.isTrue((element.summarySelected as sinon.SinonStub).calledOnce);
const event = (element.summarySelected as sinon.SinonStub).firstCall.args[0] as CustomEvent;
assert.deepEqual(event.detail.value, { begin: 110, end: 210 });
assert.deepEqual((element as any).plotSummary.value.selectedValueRange, {
begin: 110,
end: 210,
});
});
it('triggers fetch when zooming out of bounds', () => {
// Mock zoomOrRangeChange to verify it's called fallback
const zoomOrRangeChangeStub = sinon.stub(element as any, 'zoomOrRangeChange');
fetchMock.post('/_/shift/', {
begin: 0,
end: 300,
});
// Set range close to edge [0, 100] with data [0, 300]
(element as any).plotSummary.value.selectedValueRange = { begin: 0, end: 100 };
// Zoom out will try to go to [-5, 105]. -5 is out of bounds [0, 300].
// Should trigger zoomOrRangeChange (fallback path).
element.onZoomOut();
assert.isTrue(zoomOrRangeChangeStub.calledOnce);
assert.isFalse((element.summarySelected as sinon.SinonStub).called);
});
});
describe('PointSelected', () => {
it('defaults to not having a name', () => {
const p = defaultPointSelected();
assert.isEmpty(p.name);
});
it('defaults to being invalid', () => {
const p = defaultPointSelected();
assert.isFalse(isValidSelection(p));
});
it('becomes a valid event if the commit appears in the header', () => {
const header: ColumnHeader[] = [
{
offset: CommitNumber(99),
timestamp: TimestampSeconds(0),
author: '',
hash: '',
message: '',
url: '',
},
{
offset: CommitNumber(100),
timestamp: TimestampSeconds(0),
author: '',
hash: '',
message: '',
url: '',
},
{
offset: CommitNumber(101),
timestamp: TimestampSeconds(0),
author: '',
hash: '',
message: '',
url: '',
},
];
const p: PointSelected = {
commit: CommitNumber(100),
name: 'foo',
};
// selectionToEvent will look up the commit (aka offset) in header and
// should return an event where the 'x' value is the index of the matching
// ColumnHeader in 'header', i.e. 1.
const e = selectionToEvent(p, header);
assert.equal(e.detail.x, 1);
});
it('becomes an invalid event if the commit does not appear in the header', () => {
const header: ColumnHeader[] = [
{
offset: CommitNumber(99),
timestamp: TimestampSeconds(0),
author: '',
hash: '',
message: '',
url: '',
},
{
offset: CommitNumber(100),
timestamp: TimestampSeconds(0),
author: '',
hash: '',
message: '',
url: '',
},
{
offset: CommitNumber(101),
timestamp: TimestampSeconds(0),
author: '',
hash: '',
message: '',
url: '',
},
];
const p: PointSelected = {
commit: CommitNumber(102),
name: 'foo',
};
// selectionToEvent will look up the commit (aka offset) in header and
// should return an event where the 'x' value is -1 since the matching
// ColumnHeader in 'header' doesn't exist.
const e = selectionToEvent(p, header);
assert.equal(e.detail.x, -1);
});
});
describe('updateShortcut', () => {
it('should return empty shortcut if graph configs are absent', async () => {
const shortcut = await updateShortcut([]);
assert.equal(shortcut, '');
});
it('should return shortcut for non empty graph list', async () => {
const defaultConfig: QueryConfig = {
default_param_selections: null,
default_url_values: null,
include_params: null,
};
const defaultBody = JSON.stringify(defaultConfig);
fetchMock.get('path:/_/defaults/', {
status: 200,
body: defaultBody,
});
fetchMock.post('/_/count/', {
count: 117,
paramset: {},
});
fetchMock.get(/_\/initpage\/.*/, () => ({
dataframe: {
traceset: null,
header: null,
paramset: {},
skip: 0,
},
ticks: [],
skps: [],
msg: '',
}));
fetchMock.postOnce('/_/shortcut/update', { id: '12345' });
fetchMock.flush(true);
const shortcut = await updateShortcut([
{ keys: '', queries: [], formulas: [] },
] as GraphConfig[]);
assert.deepEqual(shortcut, '12345');
});
});
describe('createGraphConfigs', () => {
it('traceset without formulas', () => {
const traceset = TraceSet({
',config=8888,arch=x86,': Trace([0.1, 0.2, 0.0, 0.4]),
',config=8888,arch=arm,': Trace([1.1, 1.2, 0.0, 1.4]),
',config=565,arch=x86,': Trace([0.0, 0.0, 3.3, 3.4]),
',config=565,arch=arm,': Trace([0.0, 0.0, 4.3, 4.4]),
});
const explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
const result = explore.createGraphConfigs(traceset);
const expected: GraphConfig[] = [
{
keys: '',
formulas: [],
queries: ['config=8888&arch=x86'],
},
{
keys: '',
formulas: [],
queries: ['config=8888&arch=arm'],
},
{
keys: '',
formulas: [],
queries: ['config=565&arch=x86'],
},
{
keys: '',
formulas: [],
queries: ['config=565&arch=arm'],
},
];
assert.deepEqual(result, expected);
});
it('traceset with formulas', () => {
const traceset = TraceSet({
'func1(,config=8888,arch=x86,)': Trace([0.1, 0.2, 0.0, 0.4]),
'func2(,config=8888,arch=arm,)': Trace([1.1, 1.2, 0.0, 1.4]),
});
const explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
const result = explore.createGraphConfigs(traceset);
const expected: GraphConfig[] = [
{
keys: '',
formulas: ['func1(,config=8888,arch=x86,)'],
queries: [],
},
{
keys: '',
formulas: ['func2(,config=8888,arch=arm,)'],
queries: [],
},
];
assert.deepEqual(result, expected);
});
});
describe('Default values', () => {
beforeEach(() => {
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/count/', {
count: 117,
paramset: {},
});
fetchMock.get(/_\/initpage\/.*/, () => ({
dataframe: {
traceset: null,
header: null,
paramset: {},
skip: 0,
},
ticks: [],
skps: [],
msg: '',
}));
});
it('Checks no default values', async () => {
const defaultConfig: QueryConfig = {
default_param_selections: null,
default_url_values: null,
include_params: null,
};
const defaultBody = JSON.stringify(defaultConfig);
fetchMock.get('path:/_/defaults/', {
status: 200,
body: defaultBody,
});
const explore = await setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
const originalState = deepCopy(explore!.state);
await explore['applyQueryDefaultsIfMissing']();
const newState = explore.state;
assert.deepEqual(newState, originalState);
});
it('Checks for default summary value', async () => {
const defaultConfig: QueryConfig = {
default_param_selections: null,
default_url_values: {
summary: 'true',
},
include_params: null,
};
const explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
explore['_defaults'] = defaultConfig;
const originalState = deepCopy(explore.state);
await explore['applyQueryDefaultsIfMissing']();
const newState = explore.state;
assert.notDeepEqual(newState, originalState, 'new state should not equal original state');
assert.isTrue(newState.summary);
});
});
describe('plotSummary', () => {
it('Populate Plot Summary bar', async () => {
const explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
explore.state.plotSummary = true;
explore['tracesRendered'] = true;
explore.render();
const plotSummaryElement = explore['plotSummary'].value;
assert.notEqual(plotSummaryElement, undefined);
});
it('Plot Summary bar not enabled', async () => {
const explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
explore.render();
const plotSummaryElement = explore['plotSummary'].value;
assert.equal(plotSummaryElement, undefined);
});
});
describe('updateBrowserURL', () => {
let explore: ExploreSimpleSk;
beforeEach(() => {
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
});
afterEach(() => {
fetchMock.reset(); // Reset fetch mocks
});
it('should add begin, end, and request_type=0 params when none exist', () => {
explore.state.begin = 100;
explore.state.end = 200;
explore['updateBrowserURL']();
const pushedUrl = new URL(window.location.href as string);
assert.equal(pushedUrl.searchParams.get('begin'), '100');
assert.equal(pushedUrl.searchParams.get('end'), '200');
assert.equal(pushedUrl.searchParams.get('request_type'), '0');
});
});
describe('rationalizeTimeRange', () => {
let explore: ExploreSimpleSk;
let clock: sinon.SinonFakeTimers;
const now = 1672531200; // Jan 1, 2023 in seconds
beforeEach(() => {
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
clock = sinon.useFakeTimers(now * 1000);
});
afterEach(() => {
clock.restore();
});
it('handles uninitialized begin and end', () => {
const state = new State();
const rationalizedState = explore['rationalizeTimeRange'](state);
assert.equal(rationalizedState.end, now);
assert.closeTo(rationalizedState.begin, now - 24 * 60 * 60, 1);
});
it('corrects inverted time ranges', () => {
const state = new State();
state.begin = now - 100;
state.end = now - 500;
const rationalizedState = explore['rationalizeTimeRange'](state);
assert.isTrue(rationalizedState.end > rationalizedState.begin);
});
it('handles zero-length time ranges', () => {
const state = new State();
state.begin = now - 100;
state.end = now - 100;
const rationalizedState = explore['rationalizeTimeRange'](state);
assert.isTrue(rationalizedState.end > rationalizedState.begin);
});
it('ensures end is not in the future', () => {
const state = new State();
state.begin = now - 100;
state.end = now + 500;
const rationalizedState = explore['rationalizeTimeRange'](state);
assert.equal(rationalizedState.end, now);
});
});
describe('updateTestPickerUrl', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
// Mock all fetches that can be triggered by element creation.
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
});
afterEach(() => {
fetchMock.reset();
});
it('should set the URL to "#" when there are no queries, formulas, or keys', (done) => {
explore.state.queries = [];
explore.state.formulas = [];
explore.state.keys = '';
explore['updateTestPickerUrl']();
setTimeout(() => {
assert.equal(explore['testPickerUrl'], '#');
done();
});
});
it('should construct the correct URL when there are queries', (done) => {
fetchMock.post('/_/shortcut/update', { id: 'shortcut123' });
explore.state.queries = ['config=test'];
explore.state.formulas = [];
explore.state.keys = '';
explore.state.begin = 123;
explore.state.end = 456;
explore.state.requestType = 0;
explore['updateTestPickerUrl']();
setTimeout(() => {
assert.equal(
explore['testPickerUrl'],
'/m/?begin=123&end=456&request_type=0&shortcut=shortcut123&totalGraphs=1'
);
done();
});
});
describe('JSON Input', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/frame/start', {
status: 'Finished',
results: { dataframe: { traceset: {} } },
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
});
afterEach(() => {
fetchMock.reset();
});
it('parses valid JSON and updates state', async () => {
const json = JSON.stringify({
graphs: [
{
queries: ['config=test'],
formulas: ['formula1'],
keys: 'key1',
},
],
});
await explore.addFromQueryOrFormula(true, 'json', '', '', json);
assert.deepEqual(explore.state.queries, ['config=test']);
assert.deepEqual(explore.state.formulas, ['formula1']);
assert.equal(explore.state.keys, 'key1');
});
it('handles empty JSON gracefully', async () => {
await explore.addFromQueryOrFormula(true, 'json', '', '', '');
// Should not update state if JSON is empty (errorMessage is called, but state remains)
});
it('handles invalid JSON gracefully', async () => {
await explore.addFromQueryOrFormula(true, 'json', '', '', '{invalid');
// Should catch error and return.
});
it('reads JSON from URL param', async () => {
const json = JSON.stringify({
graphs: [
{
queries: ['config=url'],
},
],
});
const url = new URL(window.location.href);
url.searchParams.set('json', json);
window.history.pushState({}, 'Test', url.toString());
const spy = sinon.spy(explore, 'addFromQueryOrFormula');
explore.useBrowserURL();
assert.isTrue(spy.calledWith(true, 'json'));
});
});
describe('Incremental Trace Loading', () => {
let explore: ExploreSimpleSk;
// Define clean queries and comma-wrapped keys separately.
const initialQuery = 'arch=x86&config=original';
const initialTraceKey = `,${initialQuery},`; // Comma-wrapped for traceset
const newQuery = 'arch=arm&config=new';
const newTraceKey = `,${newQuery},`; // Comma-wrapped for traceset
// Generate a dataframe and ensure it has traceMetadata.
const initialDataFrame = generateFullDataFrame(
{ begin: 1, end: 100 },
now,
1,
[timeSpan],
[[10, 20]],
[initialTraceKey]
);
initialDataFrame.traceMetadata = []; // Ensure traceMetadata exists.
const initialFrameResponse: FrameResponse = {
dataframe: initialDataFrame,
anomalymap: {},
skps: [],
msg: '',
display_mode: 'display_plot',
};
// Generate the second dataframe and ensure it has traceMetadata.
const newDataFrame = generateFullDataFrame(
{ begin: 1, end: 100 },
now,
1,
[timeSpan],
[[30, 40]],
[newTraceKey]
);
newDataFrame.traceMetadata = []; // Ensure traceMetadata exists.
const newFrameResponse: FrameResponse = {
dataframe: newDataFrame,
anomalymap: {},
skps: [],
msg: '',
display_mode: 'display_plot',
};
beforeEach(() => {
fetchMock.reset();
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.get('path:/_/defaults/', {
status: 200,
body: JSON.stringify({}),
});
fetchMock.post('/_/frame/start', {});
fetchMock.post('/_/user_issues/', { UserIssues: [] });
fetchMock.flush(true);
});
it('only fetches new data when adding a trace', async () => {
// Original data.
fetchMock.postOnce('/_/frame/start', (_url, opts) => {
const body = JSON.parse(opts.body as string);
assert.deepEqual(body.queries, [initialQuery]);
return {
status: 'Finished',
results: initialFrameResponse,
messages: [],
};
});
await explore.addFromQueryOrFormula(true, 'query', initialQuery, '');
await fetchMock.flush(true);
// Verify the initial data is present.
assert.containsAllKeys(explore['_dataframe'].traceset, [initialTraceKey]);
assert.lengthOf(Object.keys(explore['_dataframe'].traceset), 1);
// New data, expect incremental fetch.
fetchMock.postOnce('/_/frame/start', (_url, opts) => {
const body = JSON.parse(opts.body as string);
assert.deepEqual(body.queries, [newQuery]);
return {
status: 'Finished',
results: newFrameResponse,
messages: [],
};
});
await explore.addFromQueryOrFormula(false, 'query', newQuery, '');
await fetchMock.flush(true);
const finalTraceset = explore['_dataframe'].traceset;
assert.containsAllKeys(finalTraceset, [initialTraceKey, newTraceKey]);
assert.lengthOf(Object.keys(finalTraceset), 2);
});
});
describe('State Management', () => {
let explore: ExploreSimpleSk;
let updateTestPickerUrlStub: sinon.SinonStub;
beforeEach(async () => {
// Mock all fetches that can be triggered by element creation.
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/frame/start', {
status: 'Finished',
results: { dataframe: { traceset: {} } },
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
// Stub the method that gets called when a state change is detected.
updateTestPickerUrlStub = sinon.stub(explore, 'updateTestPickerUrl' as any);
});
afterEach(() => {
fetchMock.reset();
sinon.restore();
});
it('detects query changes with special characters due to encoding', () => {
const state1 = new State();
state1.queries = ['config=a b'];
explore.state = state1;
// Reset the stub after the initial state setting.
updateTestPickerUrlStub.resetHistory();
const state2 = new State();
state2.queries = ['config=a+b'];
explore.state = state2;
// 'a b' and 'a+b' are different strings, so a change should be detected.
assert.isTrue(
updateTestPickerUrlStub.called,
"URL update should be called for different queries ('a b' vs 'a+b')"
);
updateTestPickerUrlStub.resetHistory();
const state3 = new State();
state3.queries = ['config=a+b'];
explore.state = state3;
// The queries are identical, so no change should be detected.
assert.isFalse(
updateTestPickerUrlStub.called,
'URL update should not be called for identical queries'
);
});
it('correctly syncs state to the domain picker on connectedCallback', async () => {
// Create a new element to test connectedCallback in isolation.
const testElement = document.createElement('explore-simple-sk') as ExploreSimpleSk;
// Set an initial state before the element is connected.
const initialState = new State();
initialState.begin = 1000;
initialState.end = 2000;
initialState.numCommits = 150;
initialState.requestType = 1;
testElement.state = initialState;
// The range picker should not exist yet.
assert.isNull(testElement.querySelector('domain-picker-sk'));
// Append to the DOM to trigger connectedCallback.
document.body.appendChild(testElement);
await waitForRender(testElement);
// Now, the range picker should exist and its state should be synced.
const rangePicker = testElement.querySelector('domain-picker-sk') as any;
assert.isNotNull(rangePicker);
assert.deepEqual(rangePicker.state, {
begin: 1000,
end: 2000,
num_commits: 150,
request_type: 1,
});
// Cleanup
document.body.removeChild(testElement);
});
});
describe('x-axis domain switching', () => {
const INITIAL_TIMESTAMP_BEGIN = 1672531200;
const INITIAL_TIMESTAMP_END = 1672542000;
const COMMIT_101 = 101;
const COMMIT_102 = 102;
const TIMESTAMP_101 = 1672534800;
const TIMESTAMP_102 = 1672538400;
const ROUNDING_TOLERANCE_SECONDS = 120;
// A simple header for converting between commit offsets and timestamps.
const testHeader: ColumnHeader[] = [
{
offset: CommitNumber(100),
timestamp: TimestampSeconds(1672531200),
hash: 'h1',
author: '',
message: '',
url: '',
},
{
offset: CommitNumber(101),
timestamp: TimestampSeconds(1672534800),
hash: 'h2',
author: '',
message: '',
url: '',
},
{
offset: CommitNumber(102),
timestamp: TimestampSeconds(1672538400),
hash: 'h3',
author: '',
message: '',
url: '',
},
{
offset: CommitNumber(103),
timestamp: TimestampSeconds(1672542000),
hash: 'h4',
author: '',
message: '',
url: '',
},
];
const testDataFrame: DataFrame = {
traceset: TraceSet({
',config=test,': Trace([1, 2, 3, 4]),
}),
header: testHeader,
paramset: {} as ReadOnlyParamSet,
skip: 0,
traceMetadata: [],
};
const testFrameResponse: FrameResponse = {
dataframe: testDataFrame,
anomalymap: null,
display_mode: 'display_plot',
skps: [],
msg: '',
};
let explore: ExploreSimpleSk;
beforeEach(async () => {
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/count/', {
count: 117,
paramset: {},
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await window.customElements.whenDefined('explore-simple-sk');
await window.customElements.whenDefined('dataframe-repository-sk');
await window.customElements.whenDefined('plot-summary-sk');
});
// Helper function to set up the component for domain switching tests.
async function setupDomainSwitchTest(domain: 'date' | 'commit'): Promise<PlotSummarySk> {
explore.state = {
...explore.state,
queries: ['config=test'],
domain: domain,
plotSummary: true,
begin: INITIAL_TIMESTAMP_BEGIN,
end: INITIAL_TIMESTAMP_END,
requestType: 0,
};
await waitForRender(explore);
// Provide data to the component.
await explore.UpdateWithFrameResponse(
testFrameResponse,
{
begin: explore.state.begin,
end: explore.state.end,
num_commits: 250,
request_type: 0,
formulas: [],
queries: ['config=test'],
keys: '',
tz: 'UTC',
pivot: null,
disable_filter_parent_traces: false,
},
false,
null,
false
);
await waitForRender(explore);
await new Promise((resolve) => setTimeout(resolve, 0));
const plotSummary = explore.querySelector('plot-summary-sk') as PlotSummarySk;
assert.exists(plotSummary, 'The plot-summary-sk element should be in the DOM.');
return plotSummary;
}
it('preserves selection when switching from date to commit', async () => {
const plotSummary = await setupDomainSwitchTest('date');
// Set an initial time-based selection on plot-summary-sk
const initialSelection = { begin: TIMESTAMP_101, end: TIMESTAMP_102 };
plotSummary.selectedValueRange = initialSelection;
await plotSummary.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 0));
const settingsDialog = explore.querySelector('#settings-dialog') as MdDialog;
const switchEl = settingsDialog.querySelector('#commit-switch') as MdSwitch;
assert.exists(switchEl, '#commit-switch element not found.');
// Simulate switching to 'commit' domain (selected = false)
switchEl!.selected = false;
switchEl!.dispatchEvent(new Event('change'));
// Wait for ExploreSimpleSk to handle the change and update its children
// Wait for ExploreSimpleSk to handle the change and update its children
await waitForRender(explore);
await plotSummary.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 500));
// rare, but can be flaky here. Since it wait for async event.
// Increase of timeout above can help.
assert.exists(
plotSummary.selectedValueRange,
'selectedValueRange should not be null after switch'
);
// Although the actual 'begin' and 'end' values are integers, they can be converted to
// floating-point numbers for UI rendering to prevent the graph from "jumping" when the x-axis
// domain is switched. The approximation in this test is used solely to prevent failures caused
// by floating-point arithmetic inaccuracies, e.g., 101 !== 101.000000001.
assert.approximately(
plotSummary.selectedValueRange.begin as number,
COMMIT_101,
1e-3,
'Selected range.begin did not convert correctly'
);
assert.approximately(
plotSummary.selectedValueRange.end as number,
COMMIT_102,
1e-3,
'Selected range.end did not convert correctly'
);
assert.equal(explore.state.domain, 'commit', 'Explore state domain should be commit');
assert.equal(plotSummary.domain, 'commit', 'PlotSummary domain property should be commit');
});
it('preserves selection when switching from commit to date', async () => {
const roundingToleranceSeconds = ROUNDING_TOLERANCE_SECONDS;
const plotSummary = await setupDomainSwitchTest('commit');
// Set an initial commit-based selection on plot-summary-sk
const initialSelection = { begin: COMMIT_101, end: COMMIT_102 };
plotSummary.selectedValueRange = initialSelection;
await plotSummary.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 0));
const settingsDialog = explore.querySelector('#settings-dialog') as MdDialog;
const switchEl = settingsDialog.querySelector('#commit-switch') as MdSwitch;
assert.exists(switchEl, '#commit-switch element not found.');
// Simulate switching to 'date' domain (selected = true)
switchEl!.selected = true;
switchEl!.dispatchEvent(new Event('change'));
// Wait for ExploreSimpleSk to handle the change and update its children
await waitForRender(explore);
await plotSummary.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 500));
assert.exists(
plotSummary.selectedValueRange,
'selectedValueRange should not be null after switch'
);
assert.approximately(
plotSummary.selectedValueRange.begin as number,
TIMESTAMP_101,
roundingToleranceSeconds,
'Selected range.begin did not convert correctly'
);
assert.approximately(
plotSummary.selectedValueRange.end as number,
TIMESTAMP_102,
roundingToleranceSeconds,
'Selected range.end did not convert correctly'
);
assert.equal(explore.state.domain, 'date', 'Explore state domain should be date');
assert.equal(plotSummary.domain, 'date', 'PlotSummary domain property should be date');
});
});
describe('reset', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
explore.state.queries = ['a=b'];
explore.state.formulas = ['norm()'];
explore.state.keys = 'somekeys';
});
afterEach(() => {
sinon.restore();
});
it('should call removeAll and queryDialog.show when use_test_picker_query is false', async () => {
explore.openQueryByDefault = true;
explore.reset();
await waitForRender(explore);
assert.isTrue(explore['_dialogOn']);
assert.isEmpty(explore.state.queries);
assert.isEmpty(explore.state.formulas);
assert.isEmpty(explore.state.keys);
});
it('should only call removeAll when use_test_picker_query is true', async () => {
explore.openQueryByDefault = false;
explore.reset();
await waitForRender(explore);
assert.isFalse(explore['_dialogOn']);
assert.isEmpty(explore.state.queries);
assert.isEmpty(explore.state.formulas);
assert.isEmpty(explore.state.keys);
});
});
describe('JSON Input', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/frame/start', {
status: 'Finished',
results: {
dataframe: {
traceset: {},
header: [
{ offset: 100, timestamp: 1000 },
{ offset: 101, timestamp: 1001 },
],
paramset: {},
},
skps: [],
msg: '',
},
messages: [],
});
fetchMock.post('/_/shortcut/update', { id: '123' });
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
});
afterEach(() => {
fetchMock.reset();
});
it('parses valid JSON and updates state', async () => {
const json = JSON.stringify({
graphs: [
{
queries: ['config=test'],
formulas: ['formula1'],
keys: 'key1',
},
],
});
await explore.addFromQueryOrFormula(true, 'json', '', '', json);
assert.deepEqual(explore.state.queries, ['config=test']);
assert.deepEqual(explore.state.formulas, ['formula1']);
assert.equal(explore.state.keys, 'key1');
});
it('handles empty JSON gracefully', async () => {
await explore.addFromQueryOrFormula(true, 'json', '', '', '');
});
it('handles invalid JSON gracefully', async () => {
await explore.addFromQueryOrFormula(true, 'json', '', '', '{invalid');
});
describe('JSON Input', () => {
let pushStateStub: sinon.SinonStub;
beforeEach(() => {
const desc = Object.getOwnPropertyDescriptor(window.history, 'pushState');
console.log('DEBUG: pushState descriptor', desc);
pushStateStub = sinon.stub(window.history, 'pushState');
});
afterEach(() => {
pushStateStub.restore();
});
it('reads JSON from URL param', async () => {
const json = JSON.stringify({
graphs: [
{
queries: ['config=url'],
formulas: [],
keys: '',
},
],
});
const url = new URL(window.location.href);
url.searchParams.set('json', json);
const stub = sinon.stub(explore, 'addFromQueryOrFormula').callsFake(() => {
return Promise.resolve();
});
explore.useBrowserURL(true, url);
assert.isTrue(stub.calledWith(true, 'json'));
});
});
});
});
describe('Keyboard Shortcuts', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
fetchMock.post('/_/fe_telemetry', 200);
await fetchMock.flush(true);
});
it('triggers triage actions on key press', async () => {
// Mock tooltip and its methods
const tooltip = explore.querySelector('chart-tooltip-sk') as any;
tooltip.openNewBug = sinon.spy();
tooltip.openExistingBug = sinon.spy();
tooltip.ignoreAnomaly = sinon.spy();
// Mock anomaly presence
const anomaly = {
id: '123',
bug_id: 0,
test_path: 'master/bot/benchmark/test',
start_revision: 123,
end_revision: 125,
is_improvement: false,
recovered: false,
state: 'regression',
statistic: 'avg',
units: 'ms',
degrees_of_freedom: 1,
median_before_anomaly: 10,
median_after_anomaly: 20,
p_value: 0.01,
segment_size_after: 10,
segment_size_before: 10,
std_dev_before_anomaly: 1,
t_statistic: 5,
subscription_name: 'sub',
bug_component: '',
bug_labels: [],
bug_cc_emails: [],
bisect_ids: [],
};
tooltip.anomaly = anomaly;
(explore as any).selectedAnomaly = anomaly;
// Trigger 'p' key for New Bug
explore.keyDown(new KeyboardEvent('keydown', { key: 'p' }));
assert.isTrue(tooltip.openNewBug.calledOnce, 'p key should trigger openNewBug');
// Trigger 'n' key for Ignore
explore.keyDown(new KeyboardEvent('keydown', { key: 'n' }));
assert.isTrue(tooltip.ignoreAnomaly.calledOnce, 'n key should trigger ignoreAnomaly');
// Trigger 'e' key for Existing Bug
explore.keyDown(new KeyboardEvent('keydown', { key: 'e' }));
assert.isTrue(tooltip.openExistingBug.calledOnce, 'e key should trigger openExistingBug');
});
});
describe('Even X-Axis Spacing toggle', () => {
let explore: ExploreSimpleSk;
let switchEl: MdSwitch;
let eventSpy: sinon.SinonSpy;
beforeEach(async () => {
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await waitForRender(explore);
const settingsDialog = explore.querySelector('#settings-dialog') as MdDialog;
switchEl = settingsDialog.querySelector('#even-x-axis-spacing-switch') as MdSwitch;
eventSpy = sinon.spy();
explore.addEventListener('even-x-axis-spacing-changed', eventSpy);
});
afterEach(() => {});
it('should have the switch element', () => {
assert.exists(switchEl);
});
it('should be unchecked by default', () => {
assert.isFalse(switchEl.selected);
assert.isFalse(explore.state.evenXAxisSpacing);
});
it('should update state and fire event when toggled on', async () => {
switchEl.selected = true;
switchEl.dispatchEvent(new Event('change'));
await waitForRender(explore);
await new Promise((resolve) => setTimeout(resolve, 0)); // Add delay
assert.isTrue(explore.state.evenXAxisSpacing);
assert.isTrue(eventSpy.calledOnce);
const event = eventSpy.firstCall.args[0];
assert.equal(event.type, 'even-x-axis-spacing-changed');
assert.deepEqual(event.detail, { value: true, graph_index: 0 });
});
it('should update state and fire event when toggled off', async () => {
// Turn it on first
switchEl.selected = true;
switchEl.dispatchEvent(new Event('change'));
await waitForRender(explore);
eventSpy.resetHistory();
// Turn it off
switchEl.selected = false;
switchEl.dispatchEvent(new Event('change'));
await waitForRender(explore);
assert.isFalse(explore.state.evenXAxisSpacing);
assert.isTrue(eventSpy.calledOnce);
const event = eventSpy.firstCall.args[0];
assert.equal(event.type, 'even-x-axis-spacing-changed');
assert.deepEqual(event.detail, { value: false, graph_index: 0 });
});
});
describe('clearTooltipDataFromURL', () => {
let explore: ExploreSimpleSk;
let pushStateStub: sinon.SinonStub;
beforeEach(async () => {
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
});
afterEach(() => {
if (pushStateStub) {
pushStateStub.restore();
}
});
it('removes graph, commit, and trace params from URL', () => {
const url = new URL(window.location.href);
url.searchParams.set('graph', '1');
url.searchParams.set('commit', '123');
url.searchParams.set('trace', '456');
url.searchParams.set('sid', '12345');
window.history.pushState(null, '', url.toString());
pushStateStub = sinon.stub(window.history, 'pushState');
(explore as any).clearTooltipDataFromURL();
assert.isTrue(pushStateStub.calledOnce);
// history.pushState(state, title, url)
const urlPositionIndex = 2;
const newUrl = new URL(pushStateStub.firstCall.args[urlPositionIndex] as string);
assert.isNull(newUrl.searchParams.get('graph'));
assert.isNull(newUrl.searchParams.get('commit'));
assert.isNull(newUrl.searchParams.get('trace'));
assert.equal(newUrl.searchParams.get('sid'), '12345');
});
});
describe('after initial data load', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
});
describe('Pivot Table Sort', () => {
it('updates state on sort change', () => {
const pivotTable = explore.querySelector('pivot-table-sk');
assert.isNotNull(pivotTable);
pivotTable!.dispatchEvent(new CustomEvent('change', { detail: 'sort_order' }));
assert.equal(explore.state.sort, 'sort_order');
});
});
describe('Details Toggle', () => {
it('toggles navOpen', () => {
// Force hide_paramset to false so the collapse button is rendered
explore.state.hide_paramset = false;
explore.render();
const collapseButton = explore.querySelector('#collapseButton') as HTMLElement;
assert.isNotNull(collapseButton);
assert.isFalse(explore.navOpen);
collapseButton.click();
assert.isTrue(explore.navOpen);
collapseButton.click();
assert.isFalse(explore.navOpen);
});
});
});
describe('Domain Picker Interaction', () => {
let explore: ExploreSimpleSk;
beforeEach(async () => {
fetchMock.get(/.*\/_\/initpage\/.*/, {
dataframe: { paramset: {} },
});
fetchMock.get('/_/login/status', {
email: 'someone@example.org',
roles: ['editor'],
});
fetchMock.post('/_/frame/start', {
status: 'Finished',
results: { dataframe: { traceset: {} } },
});
fetchMock.post('/_/count/', {
count: 0,
paramset: {},
});
explore = setUpElementUnderTest<ExploreSimpleSk>('explore-simple-sk')();
await fetchMock.flush(true);
});
afterEach(() => {
fetchMock.reset();
sinon.restore();
});
it('should NOT sync range from domain-picker when useTestPicker is true', async () => {
(explore as any).useTestPicker = true;
const initialBegin = 1000;
const initialEnd = 2000;
explore.state = {
...explore.state,
begin: initialBegin,
end: initialEnd,
queries: ['config=test'],
};
// Mock the domain-picker (this.range) to have a LARGER range.
// The current logic only lengthens the range if the picker's range is wider.
const pickerBegin = 900;
const pickerEnd = 2100;
const mockRange = {
state: {
begin: pickerBegin,
end: pickerEnd,
num_commits: 50,
request_type: 0,
},
};
(explore as any).range = mockRange;
await explore.addFromQueryOrFormula(true, 'query', 'config=test', '');
assert.equal(explore.state.begin, initialBegin, 'Begin time should not sync with picker');
assert.equal(explore.state.end, initialEnd, 'End time should not sync with picker');
});
it('should sync range from domain-picker when useTestPicker is false', async () => {
(explore as any).useTestPicker = false;
const initialBegin = 1000;
const initialEnd = 2000;
explore.state = {
...explore.state,
begin: initialBegin,
end: initialEnd,
queries: ['config=test'],
};
// Mock the domain-picker to have a LARGER range.
const pickerBegin = 900;
const pickerEnd = 2100;
const mockRange = {
state: {
begin: pickerBegin,
end: pickerEnd,
num_commits: 50,
request_type: 0,
},
};
(explore as any).range = mockRange;
await explore.addFromQueryOrFormula(true, 'query', 'config=test', '');
assert.equal(explore.state.begin, pickerBegin, 'Begin time should sync with picker');
assert.equal(explore.state.end, pickerEnd, 'End time should sync with picker');
});
});