Compare commits

...

9 Commits

Author SHA1 Message Date
Vinícius Lourenço
30ae04d14c test(storybook): cover ingestion create key validation, expiry and submit states 2026-09-28 18:00:47 -03:00
Vinícius Lourenço
8ed58b51c8 test(storybook): cover ingestion key tag adding and added states 2026-09-28 18:00:47 -03:00
Vinícius Lourenço
9ef7aeb5e3 test(storybook): click exceptions filter suggestions as they are found 2026-09-28 16:57:53 -03:00
Vinícius Lourenço
618e99bf4e test(storybook): hold the pod filter stories at their opening height 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
748c5141a5 test(storybook): wait for the filter's scrollbars before focusing it 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
d7bacbe0b1 test(storybook): keep filter focused while scrollbars initialise 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
c4164aa3c6 test(storybook): add query builder filter interaction stories 2026-09-28 15:29:44 -03:00
Vinícius Lourenço
b1901f6804 test(storybook): fix interaction stories against current main 2026-09-28 15:29:43 -03:00
Vinícius Lourenço
d90196daba test(storybook): add interaction stories for menus, drawers and selects 2026-09-28 15:29:43 -03:00
26 changed files with 1335 additions and 24 deletions

View File

@@ -23,6 +23,10 @@ const IGNORED_MESSAGES = [
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
// The filter editor's ANTLR parser reports every syntax error through
// `console.error` (`line 1:14 missing ...`), so each partial expression
// typed into it logs one; the editor shows the same errors on screen.
/^line \d+:\d+ /,
];
interface CapturedMessage {

View File

@@ -0,0 +1,241 @@
import { EditorView } from '@codemirror/view';
import { userEvent, waitFor, within } from 'storybook/test';
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
/**
* The editor is controlled: each change round-trips through React state before
* the next one is applied on top of it. People type slower than this.
*/
const KEYSTROKE_MS = 50;
/** Throws until `found` holds something, which is what `waitFor` retries on. */
const present = <TValue>(
found: TValue | null | undefined,
what: string,
): TValue => {
if (found === null || found === undefined) {
throw new Error(`${what} not found`);
}
return found;
};
const pause = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
const suggestionRow = (
canvasElement: HTMLElement,
text: string,
): HTMLElement | undefined => {
const list = suggestionList(canvasElement);
return list
? within(list)
.queryAllByRole('option')
.find((option) => option.textContent?.includes(text))
: undefined;
};
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
const requestSuggestions = (editor: HTMLElement): void => {
editor.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
code: 'Space',
ctrlKey: true,
bubbles: true,
}),
);
};
const currentView = (
canvasElement: HTMLElement,
): { editor: HTMLElement; view: EditorView } => {
// An explorer renders one editor per query; the first is the one on screen.
const editor = present(
canvasElement.querySelector<HTMLElement>(
'.code-mirror-where-clause .cm-content',
),
'filter editor',
);
return {
editor,
view: present(EditorView.findFromDOM(editor), 'editor view'),
};
};
/**
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
* the browser is idle. Initialising moves the content, the editor with it, and
* focuses the editor again through the DOM, which puts the caret back at the
* start and swaps the suggestions for the key list. Throws until every wrapper
* around the filter has initialised.
*/
const assertScrollbarsReady = (editor: HTMLElement): void => {
for (
let wrapper = editor.closest('.overlay-scrollbar');
wrapper;
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
) {
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
throw new Error('scrollbars around the filter still initialising');
}
}
};
/**
* Waits until `text` shows in the suggestion list, asking for suggestions
* whenever the list is shut. Focus and typing only open it once the keys have
* loaded, and moving the caret never does.
*/
const waitForSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> =>
waitFor(
() => {
const { editor } = currentView(canvasElement);
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
},
{ ...untilLoaded, interval: 250 },
);
/**
* Focuses the filter once the scrollbars around it have initialised, and waits
* for its suggestion list.
*/
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
await waitFor(
() => {
const { editor, view } = currentView(canvasElement);
assertScrollbarsReady(editor);
if (!view.hasFocus) {
view.focus();
}
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionList(canvasElement), 'suggestion list');
},
{ ...untilLoaded, interval: 250 },
);
return currentView(canvasElement).view;
};
/** Waits for a row of the suggestion list. */
export const findSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
/** Focuses the empty filter: every key, with any recent filters above them. */
export const openKeySuggestions = async (
canvasElement: HTMLElement,
row: string,
): Promise<void> => {
await focusFilter(canvasElement);
await waitForSuggestion(canvasElement, row);
};
/**
* Focuses the filter and types onto the end of it one character at a time,
* each as the transaction a keystroke makes, leaving the caret at the end so
* the suggestion list follows what was typed. Quotes and brackets are not
* closed for it: type both.
*
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
* highlighted, which strands the caret `userEvent` tracks.
*/
export const typeFilter = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const view = await focusFilter(canvasElement);
for (const character of text) {
const at = view.state.doc.length;
view.dispatch({
changes: { from: at, insert: character },
selection: { anchor: at + character.length },
userEvent: 'input.type',
});
await pause(KEYSTROKE_MS);
}
};
/**
* Types an expression, then steps the caret back inside it, before a closing
* bracket or parenthesis, where the suggestions are about what goes in there.
*/
export const typeFilterWithCaretBack = async (
canvasElement: HTMLElement,
text: string,
stepsBack: number,
row: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
const { view } = currentView(canvasElement);
view.dispatch({
selection: { anchor: view.state.doc.length - stepsBack },
userEvent: 'select',
});
await waitForSuggestion(canvasElement, row);
};
/**
* Moves focus off the filter, which is when the expression is validated and
* the error marker can show.
*/
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.keyboard('{Escape}');
await userEvent.click(canvasElement.ownerDocument.body);
};
/** Types an expression, leaves the filter and opens its validation errors. */
export const showFilterErrors = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
await blurFilter(canvasElement);
const marker = await waitFor(
() =>
present(
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
'error marker',
),
untilLoaded,
);
await userEvent.hover(marker);
await waitFor(
() =>
present(
canvasElement.ownerDocument.querySelector('.query-validation-error'),
'validation error',
),
untilLoaded,
);
};

View File

@@ -8,6 +8,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { encode } from 'js-base64';
import type { Tags } from 'hooks/useResourceAttribute/types';
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -31,6 +32,9 @@ import {
type ServiceHealth,
} from './__story_mockdata__/serviceMap';
/** The keys are only fetched once the select opens, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
const GRAPH = 'Service map · graph';
const FILTERS = 'Service map · filters';
@@ -129,3 +133,80 @@ export const serviceMapMocks = defineStoryMocks({
],
config: (values) => ({ route: serviceMapRoute(values.filters) }),
});
/** Opens the select under a test id; it closes again after every pick. */
export const openSelect = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const select = await within(canvasElement).findByTestId(
testId,
undefined,
untilLoaded,
);
await userEvent.click(within(select).getByRole('combobox'));
};
export const OPEN_DROPDOWN =
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
const visibleOption = (title: string): HTMLElement | null =>
document.querySelector<HTMLElement>(
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
);
/**
* Picks the option titled `title` in the open dropdown. `userEvent.click`
* moves focus off the select on the way, which closes it before the option
* takes the click.
*/
export const pickOption = async (title: string): Promise<void> => {
const option = await waitFor(() => {
const match = visibleOption(title);
if (!match) {
throw new Error(`option "${title}" not found`);
}
return match;
}, untilLoaded);
await fireEvent.click(option);
};
/**
* Opens the attribute filter on its next step. Each single-choice pick closes
* the dropdown and swaps the select for the next step's, and a click that lands
* before the swap opens nothing, so it opens again until `title` shows.
*/
export const openAttributeFilterOn = (
canvasElement: HTMLElement,
title: string,
): Promise<void> =>
waitFor(
async () => {
if (visibleOption(title)) {
return;
}
if (!document.querySelector(OPEN_DROPDOWN)) {
await openSelect(canvasElement, 'resource-attributes-filter');
}
throw new Error(`option "${title}" not shown`);
},
{ ...untilLoaded, interval: 500 },
);
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
export const stageClusterIn = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
await pickOption('k8s.cluster.name');
await openAttributeFilterOn(canvasElement, 'IN');
await pickOption('IN');
await openAttributeFilterOn(canvasElement, 'staging-eu');
};

View File

@@ -1,10 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { serviceMapMocks } from './ServiceMap.stories.mocks';
import {
OPEN_DROPDOWN,
openAttributeFilterOn,
openSelect,
pickOption,
serviceMapMocks,
stageClusterIn,
} from './ServiceMap.stories.mocks';
import ServiceMapContainer from '../index';
@@ -97,3 +104,49 @@ export const FilterAttributes: Story = {
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
},
};
/** A key staged as a chip, the filter open again on how to match it. */
export const FilterOperators: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
await pickOption('k8s.cluster.name');
await openAttributeFilterOn(canvasElement, 'Not IN');
},
};
/** A key and `IN` staged, the filter open on the values the key holds. */
export const FilterValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await stageClusterIn(canvasElement);
},
};
/** Two values ticked before the filter is left, which is what applies it. */
export const FilterValuesSelected: Story = {
play: async ({ canvasElement }): Promise<void> => {
await stageClusterIn(canvasElement);
await pickOption('prod-us-east');
await pickOption('prod-eu-west');
await waitFor(
() =>
expect(
document.querySelectorAll('.ant-select-item-option-selected'),
).toHaveLength(2),
untilLoaded,
);
},
};
/** The environment selector open on the environments the calls came from. */
export const EnvironmentOptions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openSelect(canvasElement, 'resource-environment-filter');
await waitFor(
() =>
expect(
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
).not.toBeNull(),
untilLoaded,
);
},
};

View File

@@ -103,6 +103,18 @@ export const DeleteDowntimeConfirm: Story = {
},
};
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
export const NewDowntimeRecurring: Story = {
play: async ({ canvasElement }): Promise<void> => {
await NewDowntime.play?.({ canvasElement } as never);
await userEvent.click(
await screen.findByRole('combobox', { name: 'Repeats every' }),
);
await userEvent.click(await screen.findByText('Weekly'));
await screen.findByText('Duration');
},
};
/** A client-side search with no matching downtime schedule. */
export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -6,9 +6,19 @@
import { rest } from 'msw';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { countControl, toggleControl } from '@/storybook/controls/controls';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
RESPONSE_STATES,
type ResponseState,
respondWith,
} from '@/storybook/runtime/responseState';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
@@ -23,6 +33,9 @@ import {
type ListErrorsBody,
} from './__story_mockdata__/exceptions';
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LIST = 'Exceptions · list';
const FILTERS = 'Exceptions · filters';
@@ -42,6 +55,13 @@ export const exceptionsMocks = defineStoryMocks({
value: 6,
max: EXCEPTION_QUICK_FILTER_CAP,
}),
filterKeys: choiceControl<ResponseState>('Filter keys', {
group: FILTERS,
description:
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
options: RESPONSE_STATES,
value: 'loaded',
}),
filterPanel: toggleControl('Quick filters panel', {
group: FILTERS,
description:
@@ -92,7 +112,7 @@ export const exceptionsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) =>
respondWith(values.filterKeys, (req) =>
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
),
),
@@ -111,3 +131,61 @@ export const exceptionsMocks = defineStoryMocks({
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
},
});
export const openResourceFilter = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const filter = await within(canvasElement).findByTestId(
'qb-search-select',
undefined,
untilLoaded,
);
await userEvent.click(within(filter).getByRole('combobox'));
return filter;
};
/**
* Clicks the visible row whose label is `text`. The dropdown renders in the
* body, a key row carries its type beside the label, and antd keeps a hidden
* copy of each label for screen readers that takes no clicks.
*
* The key list renders twice after the filter opens, since a second key
* request empties it until it answers. The click happens in the same task as
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
* can land on a row already removed and never reach React.
*/
export const pickSuggestion = async (text: string): Promise<void> => {
await waitFor(() => {
const match = Array.from(
document.querySelectorAll<HTMLElement>(
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
),
).find((option) =>
Array.from(option.querySelectorAll('*')).some(
(node) => node.children.length === 0 && node.textContent === text,
),
);
if (!match) {
throw new Error(`suggestion "${text}" not found`);
}
// `userEvent.click` moves focus off the search input on the way, which closes
// the dropdown before the row takes the click.
match.click();
}, untilLoaded);
};
export const commitFilter = async (
key: string,
operator: string,
value: string,
): Promise<void> => {
await pickSuggestion(key);
await screen.findByText('Operator for', { exact: false }, untilLoaded);
await pickSuggestion(operator);
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
await pickSuggestion(value);
};

View File

@@ -5,7 +5,12 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { exceptionsMocks } from './AllErrors.stories.mocks';
import {
commitFilter,
exceptionsMocks,
openResourceFilter,
pickSuggestion,
} from './AllErrors.stories.mocks';
import AllErrors from '../index';
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
@@ -125,3 +130,108 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/** The resource filter opened: every key the exceptions can be narrowed by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await screen.findByText('Suggested Filters', undefined, untilLoaded);
},
};
/** The key list grown past its first rows with the Show all shortcut. */
export const FilterAllKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await screen.findByText('Show all filter items', undefined, untilLoaded);
await userEvent.keyboard('{Control>}/{/Control}');
await screen.findByText('cloud.region', undefined, untilLoaded);
},
};
/** A partial key: the typed text as a free search, then the keys that match. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
const filter = await openResourceFilter(canvasElement);
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
await screen.findByText('service.namespace', undefined, untilLoaded);
},
};
/** A key picked: the operators the exceptions page allows for it. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await pickSuggestion('service.name');
await screen.findByText('Operator for', { exact: false }, untilLoaded);
},
};
/** A key and an operator picked: the values the key holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await pickSuggestion('service.name');
await pickSuggestion('=');
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
},
};
/** Two conditions committed as chips, with the dropdown closed again. */
export const FilterChips: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await commitFilter('service.name', '=', 'checkout');
await commitFilter('deployment.environment', '!=', 'staging');
await userEvent.click(canvasElement.ownerDocument.body);
await within(canvasElement).findByText(
'deployment.environment != staging',
undefined,
untilLoaded,
);
},
};
/**
* A committed chip clicked to change it: its text goes back into the input,
* with the dropdown shut until the input is typed into.
*/
export const FilterEditChip: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await commitFilter('service.name', '=', 'checkout');
await userEvent.click(
await within(canvasElement).findByText(
'service.name = checkout',
undefined,
untilLoaded,
),
);
// The select remounts whenever its chips change, so it is looked up again.
await waitFor(
() =>
expect(
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
'combobox',
),
).toHaveValue('service.name = checkout'),
untilLoaded,
);
},
};
/** The key list while its request is still in flight. */
export const FilterKeysLoading: Story = {
args: { filterKeys: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await waitFor(
() =>
expect(
document.querySelector('.query-builder-search .ant-spin'),
).not.toBeNull(),
untilLoaded,
);
},
};

View File

@@ -15,7 +15,10 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
fieldKeysResponse,
fieldValuesResponse,
} from '@/storybook/msw/__story_mockdata__/fields';
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
import {
@@ -31,6 +34,7 @@ import {
import {
emptyPanelResponse,
NAMESPACE_VALUES,
VARIABLE_ATTRIBUTES,
panelResponse,
serviceVariableValues,
} from './__story_mockdata__/panelData';
@@ -238,6 +242,12 @@ export const dashboardMocks = defineStoryMocks({
response.json(() => fieldValuesResponse(NAMESPACE_VALUES)),
),
// The dynamic variable editor lists the attributes a variable can read.
rest.get(
'http://localhost/api/v1/fields/keys',
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
),
// The header reads the public link on every load, so it answers even while
// the panels are held in the loading or failed state.
rest.get('http://localhost/api/v1/dashboards/:id/public', (_req, res, ctx) =>

View File

@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -285,6 +285,67 @@ export const SectionActionsMenu: Story = {
},
};
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
export const PanelMoveToSectionSubmenu: Story = {
play: async (context) => {
await PanelActionsMenu.play?.(context);
await userEvent.hover(await screen.findByText('Move to section'));
// The submenu lists the sections the panel is not already in.
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
timeout: 10000,
});
},
};
/** Dashboard settings on the Publish tab, where the public link is managed. */
export const SettingsPublicDashboard: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Configure' },
{ timeout: 10000 },
),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Publish' }));
await screen.findByText('Default time range');
},
};
/** The Publish tab with its default time range select open. */
export const SettingsPublicDashboardSelectOpen: Story = {
play: async (context) => {
await SettingsPublicDashboard.play?.(context);
await userEvent.click(
within(screen.getByRole('tabpanel')).getByRole('combobox'),
);
await screen.findByRole('listbox');
},
};
/** The Variables tab of dashboard settings with a new variable's form open. */
export const SettingsVariablesNew: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Configure' },
{ timeout: 10000 },
),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
const add = await within(await screen.findByRole('tabpanel')).findByRole(
'button',
{ name: 'Add variable' },
);
// The button stays disabled until its permission check resolves.
await waitFor(() => expect(add).toBeEnabled(), { timeout: 10000 });
await userEvent.click(add);
await screen.findByText('Variable Type');
},
};
/**
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
*

View File

@@ -168,6 +168,15 @@ export const serviceVariableValues = (count: number): string[] =>
);
/** Values the dynamic `namespace` variable resolves from the fields endpoint. */
/** Attributes the dynamic variable editor offers a variable to read. */
export const VARIABLE_ATTRIBUTES = [
'k8s.namespace.name',
'k8s.cluster.name',
'service.name',
'deployment.environment',
'host.name',
];
export const NAMESPACE_VALUES = [
'checkout-prod',
'payments-prod',

View File

@@ -6,6 +6,7 @@
import { rest } from 'msw';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -342,3 +343,30 @@ export const dashboardsListMocks = defineStoryMocks({
});
},
});
/** Opens the actions menu of the row at `index`. */
export const openRowActions = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
// The icon-only trigger carries no accessible name.
const triggers = await within(canvasElement).findAllByTestId(
'dashboard-action-icon',
{},
{ timeout: 10000 },
);
await userEvent.click(triggers[index]);
await screen.findByText('Rename');
};
/** Picks a row action, retrying while its permission check still disables it. */
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
await waitFor(
async () => {
await userEvent.click(screen.getByText(label));
await screen.findByRole('dialog', {}, { timeout: 500 });
},
{ timeout: 10000 },
);
};

View File

@@ -6,7 +6,9 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardsListMocks,
openRowActions,
overflowingRows,
pickRowAction,
} from './DashboardsListPage.stories.mocks';
import { BuiltinViewId } from '../types';
@@ -114,6 +116,45 @@ export const Tooltips: Story = {
parameters: { msw: { handlers: [overflowingRows] } },
};
/** The first row's actions menu, open over the list. */
export const RowActionsMenu: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 0);
},
};
/** The rename dialog, opened from the menu of the second row (the first is locked). */
export const RenameDashboardDialog: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
},
};
/** The tags dialog, opened from the menu of the second row (the first is locked). */
export const EditTagsDialog: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
},
};
/** The popover that names the current filters as a new saved view. */
export const SaveViewPopover: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: 10000 },
),
);
await screen.findByText('Save as view');
},
};
/**
* The query the backend refused: the parse error it returned replaces the
* generic failure copy, and there is nothing to retry.

View File

@@ -1,4 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
@@ -41,6 +46,21 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS },
};
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(
await drawer.findByText('Logs', {}, { timeout: 10000 }),
);
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
},
};
/**
* Every tooltip the pod list carries, held open: Collapse Filters beside the
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
@@ -86,3 +106,27 @@ export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
await screen.findByText('Columns');
},
};
/**
* The pod list re-renders the filter when the viewport grows, and each render
* reconfigures the editor, which closes its suggestions. Shot at the height
* the page opened at.
*/
const heldViewport = { sbshot: { viewport: { height: 1200 } } };
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.node.name');
},
};
/** The pod filter on a namespace: the namespaces the pods run in. */
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
},
};

View File

@@ -69,3 +69,12 @@ export const GroupActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The first mapping group's edit drawer, opened from its menu. */
export const GroupFormDrawer: Story = {
play: async (context): Promise<void> => {
await GroupActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Edit'));
await screen.findByText('Edit group');
},
};

View File

@@ -54,3 +54,24 @@ export const ModelCostActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The first pricing rule's drawer, opened from its row menu. */
export const ModelCostDrawer: Story = {
play: async (context): Promise<void> => {
await ModelCostActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Edit'));
await screen.findByText('Edit model cost');
},
};
/** The drawer with its cache mode select open. */
export const ModelCostDrawerCacheModeOpen: Story = {
play: async (context): Promise<void> => {
await ModelCostDrawer.play?.(context);
await userEvent.click(
await screen.findByRole('combobox', { name: 'Cache mode' }),
);
await screen.findByRole('listbox');
},
};

View File

@@ -25,6 +25,11 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
RESPONSE_STATES,
type ResponseState,
respondWith,
} from '@/storybook/runtime/responseState';
import {
logsSavedViewsResponse,
@@ -44,6 +49,8 @@ import {
logRowsResponse,
QUICK_FILTER_MAX,
logsQuickFiltersResponse,
RECENT_FILTER_MAX,
recentFiltersStorage,
RELATIVE_TIME,
timeRangeState,
} from './__story_mockdata__/logs';
@@ -158,6 +165,20 @@ export const logsMocks = defineStoryMocks({
value: QUICK_FILTER_MAX,
max: QUICK_FILTER_MAX,
}),
filterValues: choiceControl<ResponseState>('Filter values', {
group: FILTERS,
description:
'How `/fields/values` answers once a key and an operator are typed in the filter, apart from the page-wide Data control.',
options: RESPONSE_STATES,
value: 'loaded',
}),
recentFilters: countControl('Recent filters', {
group: FILTERS,
description:
'Filters run before in this browser, which the filter offers above its key suggestions.',
value: 0,
max: RECENT_FILTER_MAX,
}),
savedViews: countControl('Saved views', {
group: FILTERS,
description: 'The views the view picker above the query builder lists.',
@@ -217,7 +238,7 @@ export const logsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
respondWith(values.filterValues, (req) =>
logFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
@@ -264,8 +285,16 @@ export const logsMocks = defineStoryMocks({
}
: {},
}),
effect: ({ frequencyChart, filtersPanel, format, maxLines, fontSize }) => {
effect: ({
frequencyChart,
filtersPanel,
format,
maxLines,
fontSize,
recentFilters,
}) => {
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
setLocalStorage(...recentFiltersStorage(recentFilters));
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, String(filtersPanel));
// The preferences loader reads localStorage ahead of the URL, so this is

View File

@@ -1,4 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
blurFilter,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
findSuggestion,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -209,3 +217,138 @@ export const Tooltips: Story = {
);
},
};
/** The filter focused before anything is typed: every key the logs carry. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'severity_text');
},
};
/** A partial key, with the keys that still match and the typed part marked. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
},
};
/** A string key followed by a space: the operators a string compares with. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
},
};
/** A number key puts the range comparisons first. */
export const FilterNumberOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
},
};
/** `NOT` after a key narrows the list to the operators it can negate. */
export const FilterNegatedOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
},
};
/** A key and an operator: the values the key holds, fetched for it. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};
/** Values still being fetched for the key. */
export const FilterValuesLoading: Story = {
args: { filterValues: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Loading suggestions');
},
};
/** A key the backend holds no values for, such as the free-text body. */
export const FilterNoValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
},
};
/** The values request failed. */
export const FilterValuesError: Story = {
args: { filterValues: 'error' },
// The values request deliberately fails.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Error loading suggestions');
},
};
/** Inside an `IN` list, after the first value: the rest of the values. */
export const FilterInList: Story = {
// The editor logs a TypeError while the list is open, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
},
};
/** A complete condition: the conjunctions that start the next one. */
export const FilterConjunctionSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
},
};
/** Inside an opened group: keys, another group and `NOT`. */
export const FilterNestedGroup: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
},
};
/** A long valid expression mixing operators, left for the next run. */
export const FilterComplete: Story = {
// The editor logs a TypeError while the `IN` list is typed, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
},
};
/** An incomplete expression after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'service.name = ');
},
};
/** Filters run before, offered above the key suggestions. */
export const FilterRecentSearches: Story = {
args: { recentFilters: 3 },
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
},
};

View File

@@ -15,6 +15,9 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import type { OptionsQuery } from 'container/OptionsMenu/types';
import { STORAGE_VERSION } from 'lib/recentQueries/constants';
import type { RecentQueriesStoreShape } from 'lib/recentQueries/types';
import { makeId, storageKeyFor } from 'lib/recentQueries/utils';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import type { AppState } from 'store/reducers';
@@ -470,3 +473,34 @@ const DASHBOARD_NAMES = [
export const dashboardsResponse = (): ListDashboardsForUserV2200 =>
dashboardsForUserResponse(DASHBOARD_NAMES);
const RECENT_FILTERS = [
"service.name = 'checkout' AND severity_text = 'ERROR'",
"k8s.namespace.name = 'observability'",
"body CONTAINS 'timeout'",
'http.status_code >= 500',
"deployment.environment IN ['production', 'staging']",
];
/** The filter lists this many recent entries at most. */
export const RECENT_FILTER_MAX = RECENT_FILTERS.length;
/**
* The explorer's recent filters as the store keeps them in localStorage,
* newest first, five minutes apart. Dated off `new Date()`, which the story
* clock freezes, so the "5 minutes ago" labels hold still.
*/
export const recentFiltersStorage = (count: number): [string, string] => {
const store: RecentQueriesStoreShape = {
version: STORAGE_VERSION,
entries: RECENT_FILTERS.slice(0, count).map((expression, index) => ({
id: makeId('logs', '', expression),
signal: 'logs',
source: '',
filter: { expression },
lastUsedAt: new Date().getTime() - (index + 1) * 5 * 60_000,
})),
};
return [storageKeyFor('logs', ''), JSON.stringify(store)];
};

View File

@@ -1,4 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -114,3 +119,18 @@ export const MetricDetailsDashboardsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The summary's metric search focused: the attributes metrics can be found by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
},
};
/** The metric search on an attribute and an operator: the values it holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};

View File

@@ -15,10 +15,14 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
CREATE_OUTCOMES,
type CreateOutcome,
EXPIRIES,
type Expiry,
ingestionKeyCreateError,
ingestionKeysResponse,
KEYS_PER_PAGE,
legacyIngestionResponse,
@@ -29,6 +33,11 @@ import {
const KEYS = 'Ingestion · keys';
const LIMITS = 'Ingestion · limits';
const rejectCreate: MockResolver = (_req, res, ctx) =>
res(ctx.status(409), ctx.json(ingestionKeyCreateError()));
const holdCreate: MockResolver = (_req, res, ctx) => res(ctx.delay('infinite'));
export const ingestionMocks = defineStoryMocks({
controls: {
gateway: toggleControl('Gateway', {
@@ -50,6 +59,13 @@ export const ingestionMocks = defineStoryMocks({
options: EXPIRIES,
value: 'none',
}),
create: choiceControl<CreateOutcome>('Creating a key', {
group: KEYS,
description:
'What the create behind the new key form answers. `hangs` holds the submit button in its loading state.',
options: CREATE_OUTCOMES,
value: 'succeeds',
}),
limits: multiChoiceControl<LimitSignal>('Signals with a limit', {
group: LIMITS,
description:
@@ -85,10 +101,14 @@ export const ingestionMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v2/gateway/ingestion_keys',
response.json(() => ({
status: 'success',
data: { id: 'ingestion-key-new', value: 'sk_new' },
})),
{
succeeds: response.json(() => ({
status: 'success',
data: { id: 'ingestion-key-new', value: 'sk_new' },
})),
fails: rejectCreate,
hangs: holdCreate,
}[values.create],
),
rest.patch(

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import dayjs from 'dayjs';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -76,16 +77,149 @@ export const KeyLimits: Story = {
},
};
async function openCreateKey(
canvasElement: HTMLElement,
): Promise<ReturnType<typeof within>> {
await userEvent.click(
await within(canvasElement).findByText(
'New Ingestion key',
undefined,
untilLoaded,
),
);
return within(
await screen.findByRole(
'dialog',
{ name: 'Create new ingestion key' },
untilLoaded,
),
);
}
async function addTag(
dialog: ReturnType<typeof within>,
tag: string,
): Promise<void> {
await userEvent.click(await dialog.findByRole('button', { name: /New Tag/ }));
await userEvent.keyboard(`${tag}{Enter}`);
}
async function submitCreateKey(
dialog: ReturnType<typeof within>,
): Promise<void> {
await userEvent.type(dialog.getByLabelText('Name'), 'otel-collectors');
await userEvent.click(dialog.getByLabelText('Expiration'));
await userEvent.click(
await screen.findByTitle(dayjs().add(1, 'day').format('YYYY-MM-DD')),
);
await userEvent.click(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
);
}
/** The form a new key is named and dated in. */
export const CreateKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
'New Ingestion key',
undefined,
untilLoaded,
),
);
await screen.findByText('Create new ingestion key', undefined, untilLoaded);
await openCreateKey(canvasElement);
},
};
/** A tag typed on the new key and not confirmed yet. */
export const CreateKeyAddingTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.click(dialog.getByRole('button', { name: /New Tag/ }));
await userEvent.keyboard('team-payments');
},
};
/** The new key with its tags confirmed, each one removable. */
export const CreateKeyTagsAdded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
for (const tag of ['team-payments', 'env:production']) {
await addTag(dialog, tag);
await dialog.findByText(tag);
}
},
};
/**
* A tag typed again while the key already has it: Enter keeps the input open
* and adds nothing, with no message saying why.
*/
export const CreateKeyDuplicateTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await addTag(dialog, 'team-payments');
await addTag(dialog, 'team-payments');
await dialog.findByDisplayValue('team-payments');
},
};
/** A tag longer than its pill, cut short with an ellipsis. */
export const CreateKeyLongTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
const tag =
'team-payments-platform-observability-production-us-east-1-canary-collectors';
await addTag(dialog, tag);
await dialog.findByText(tag);
},
};
/** A name with a space and no expiration, submitted: each field names its rule. */
export const CreateKeyInvalid: Story = {
// The page logs the rejected validation through `console.error`.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.type(dialog.getByLabelText('Name'), 'otel collectors');
await userEvent.click(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
);
await dialog.findByText(/should only contain letters/);
},
};
/** The expiration calendar, where today and every day before it are disabled. */
export const CreateKeyExpirationOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.click(dialog.getByLabelText('Expiration'));
await screen.findByTitle(dayjs().format('YYYY-MM-DD'));
},
};
/** The new key sent, with the create still in flight. */
export const CreateKeySubmitting: Story = {
args: { create: 'hangs' },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await submitCreateKey(dialog);
await waitFor(() =>
expect(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
).toHaveAttribute('aria-busy', 'true'),
);
},
};
/**
* A create the gateway rejects: the notification names the conflict and the
* form keeps what was typed.
*/
export const CreateKeyFailed: Story = {
args: { create: 'fails' },
// The deliberate 409 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await submitCreateKey(dialog);
await screen.findByText(
'An ingestion key with this name already exists.',
undefined,
untilLoaded,
);
},
};

View File

@@ -7,6 +7,7 @@ import type {
GatewaytypesIngestionKeyDTO,
GatewaytypesLimitDTO,
GetIngestionKeys200,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { IngestionInfo } from 'types/api/settings/ingestion';
@@ -21,6 +22,10 @@ export const EXPIRIES = ['none', 'soon', 'expired'] as const;
export type Expiry = (typeof EXPIRIES)[number];
export const CREATE_OUTCOMES = ['succeeds', 'fails', 'hangs'] as const;
export type CreateOutcome = (typeof CREATE_OUTCOMES)[number];
const KEY_NAMES = [
'production-us-east',
'production-eu-west',
@@ -117,6 +122,18 @@ export const ingestionKeysResponse = (
},
});
export const ingestionKeyCreateError = (): RenderErrorResponseDTO => ({
status: 'error',
error: {
code: 'already_exists',
type: 'already_exists',
message: 'An ingestion key with this name already exists.',
url: '',
errors: [],
suggestions: [],
},
});
/**
* The pre-gateway endpoint, which answers with one record and no limits: the tab
* falls back to it when the gateway feature is off.

View File

@@ -5,6 +5,7 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { userEvent, within } from 'storybook/test';
import { RoleType } from 'types/roles';
import { choiceControl } from '@/storybook/controls/controls';
@@ -84,3 +85,18 @@ export const roleEditorMocks = defineStoryMocks({
],
config: (values) => ({ route: routeFor(values.mode, values.editor) }),
});
/** Opens the Logs card and returns it. */
export const openLogsCard = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const header = await within(canvasElement).findByRole(
'button',
{ name: /^Logs:/ },
{ timeout: 15_000 },
);
await userEvent.click(header);
return header.parentElement as HTMLElement;
};

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { roleEditorMocks } from './RoleEditor.stories.mocks';
import { openLogsCard, roleEditorMocks } from './RoleEditor.stories.mocks';
import SettingsPage from '../../../Settings';
@@ -75,3 +75,27 @@ export const Tooltips: Story = {
export const TooltipsInJsonEditor: Story = {
args: { tooltipsOpen: true, mode: 'edit', editor: 'json' },
};
/** The Logs card's first verb granted over everything instead of nothing. */
export const PermissionEditorScope: Story = {
play: async ({ canvasElement }): Promise<void> => {
const card = await openLogsCard(canvasElement);
const [all] = await within(card).findAllByText('All');
await userEvent.click(all);
},
};
/** The selector wizard, opened from a Logs verb scoped to named objects. */
export const TelemetrySelectorWizard: Story = {
play: async ({ canvasElement }): Promise<void> => {
const card = await openLogsCard(canvasElement);
const [onlySelected] = await within(card).findAllByText('Only selected');
await userEvent.click(onlySelected);
await userEvent.click(
await within(card).findByRole('button', { name: 'Wizard' }),
);
await screen.findByRole('dialog', { name: 'Selector Wizard' });
},
};

View File

@@ -172,3 +172,31 @@ export const TraceOptionsMenu: Story = {
await screen.findByText('Preview fields');
},
};
/** The span panel moved from the right edge to the bottom through its dock toggle. */
export const DockModeSwitched: Story = {
play: async ({ canvasElement }): Promise<void> => {
// The dock options are icon-only and carry no accessible name.
const option = await within(canvasElement).findByTestId(
'dock-mode-docked',
undefined,
untilLoaded,
);
await userEvent.click(option.querySelector('button') ?? option);
},
};
/** The span's percentile badge expanded into the distribution it was ranked in. */
export const SpanPercentileOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /^p\d+/ },
untilLoaded,
),
);
await screen.findByText(/This span duration is/);
},
};

View File

@@ -1,4 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
@@ -142,3 +148,41 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/** The filter focused before anything is typed: span and resource keys together. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'status_code_string');
},
};
/** A context prefix: only the keys that live on the resource. */
export const FilterResourceKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
},
};
/** A key and an operator: the services the spans came from. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};
/** A boolean key offers its two values. */
export const FilterBooleanValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
},
};
/** A dangling conjunction after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'has_error = true AND');
},
};