mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 21:40:34 +01:00
Compare commits
5 Commits
tvats-foll
...
settings-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78ca0642b2 | ||
|
|
57ef60f0e3 | ||
|
|
b96b6918e9 | ||
|
|
9b774bb8d0 | ||
|
|
58b55c922d |
@@ -3565,7 +3565,6 @@ components:
|
||||
errors:
|
||||
items:
|
||||
$ref: '#/components/schemas/ErrorsResponseerroradditional'
|
||||
nullable: true
|
||||
type: array
|
||||
message:
|
||||
type: string
|
||||
@@ -3574,21 +3573,14 @@ components:
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
type:
|
||||
type: string
|
||||
url:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- code
|
||||
- message
|
||||
- url
|
||||
- errors
|
||||
- retry
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseerroradditional:
|
||||
properties:
|
||||
@@ -3597,19 +3589,12 @@ components:
|
||||
suggestions:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- message
|
||||
- suggestions
|
||||
type: object
|
||||
ErrorsResponseretryjson:
|
||||
nullable: true
|
||||
properties:
|
||||
delay:
|
||||
$ref: '#/components/schemas/TimeDuration'
|
||||
required:
|
||||
- delay
|
||||
type: object
|
||||
FactoryResponse:
|
||||
properties:
|
||||
@@ -9019,10 +9004,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9175,10 +9156,6 @@ paths:
|
||||
$ref: '#/components/schemas/DashboardtypesUpdatablePublicDashboard'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -9773,10 +9750,6 @@ paths:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -10961,10 +10934,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11078,10 +11047,6 @@ paths:
|
||||
$ref: '#/components/schemas/AuthtypesPatchableRole'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11228,10 +11193,6 @@ paths:
|
||||
$ref: '#/components/schemas/CoretypesPatchableObjects'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11681,10 +11642,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -11792,10 +11749,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -11977,10 +11930,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12038,10 +11987,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesUpdatableFactorAPIKey'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -12224,10 +12169,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"401":
|
||||
content:
|
||||
@@ -12303,10 +12244,6 @@ paths:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccount'
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"404":
|
||||
content:
|
||||
@@ -13531,10 +13468,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13794,10 +13727,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -13850,10 +13779,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -15584,10 +15509,6 @@ paths:
|
||||
$ref: '#/components/schemas/MetricsexplorertypesUpdateMetricMetadataRequest'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
@@ -20886,10 +20807,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
@@ -20937,10 +20854,6 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
|
||||
@@ -36,55 +36,6 @@ var (
|
||||
|
||||
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
|
||||
|
||||
### Message
|
||||
The primary, human-readable summary of what went wrong, set when the error is created via `errors.New` / `errors.Newf`. Note there are two distinct `message` fields in the response: this top-level one states the overall failure, while each entry under [Additional](#additional) carries its own [message](#message-1) explaining one specific facet of it.
|
||||
|
||||
### Url
|
||||
An optional link to documentation that explains the error in more depth, set with `WithUrl`. It is left empty when the error has no associated doc.
|
||||
|
||||
```go
|
||||
return errors.New(errors.TypeInvalidInput, CodeBadThing, "bad thing").
|
||||
WithUrl("https://signoz.io/docs/...")
|
||||
```
|
||||
|
||||
### Additional
|
||||
`errors` is a list of supplementary details that explain the top-level `message`. Each entry has its own `message` and `suggestions`, so a single error can surface several distinct problems individually. Attach details with `WithAdditional` (message only) or `WithSuggestiveAdditional` (message plus the suggestions that belong to it):
|
||||
|
||||
#### Message
|
||||
A single, self-contained sentence describing one specific facet of the error (e.g. ``field `filed` not found``), distinct from the top-level [Message](#message). Prefer one detail per distinct problem over concatenating several into one message.
|
||||
|
||||
#### Suggestions
|
||||
The suggestions tied to that specific detail — typically a ``did you mean: `x` `` correction for the value the detail is about. These are distinct from the error-wide [Suggestions](#suggestions) below: detail-scoped suggestions never leak into the top-level list.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithAdditional("field `field` not found")
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestiveAdditional("field `filed` not found", "did you mean: `field`")
|
||||
```
|
||||
|
||||
### Retry
|
||||
Carries the `delay` the client should wait before retrying, set with `WithRetryAfter`. It is `null` when the error is not retryable.
|
||||
|
||||
```go
|
||||
return errors.NewTimeoutf(CodeSlow, "upstream timed out").
|
||||
WithRetryAfter(5 * time.Second)
|
||||
```
|
||||
|
||||
### Suggestions
|
||||
`WithSuggestions` sets the error-wide `suggestions` list — hints about the error as a whole (e.g. "narrow the time range window"), as opposed to suggestions tied to a single detail. Prefer the builders in [pkg/errors/suggestions.go](/pkg/errors/suggestions.go) over hand-writing the strings so the phrasing stays consistent:
|
||||
|
||||
- `NewSuggestionsOnLevenshteinDistance(invalidInput, noun, validInputs)` — returns a ``did you mean: `x` `` correction (when a close typo match exists) followed by the valid-references list.
|
||||
- `NewValidReferences(noun, values...)` — formats a capped list as ``valid <noun> are `a`, `b` `` (e.g. `"valid fields are"`, `"valid keys are"`). Returns `""` for an empty set.
|
||||
- `NewSuggestionsFromFunc(produce)` — wraps a caller-computed correction string as a one-element ``did you mean: `x` `` slice (or nil when it returns `""`), for callers with their own matching strategy.
|
||||
|
||||
`noun` names the kind of value being suggested. Use one of the exported `Noun*` constants (`errors.NounFields`, `errors.NounKeys`, `errors.NounServices`, …) so the wording stays uniform across the codebase.
|
||||
|
||||
```go
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown field %q", field).
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, validFields)...)
|
||||
```
|
||||
|
||||
## Show me some examples
|
||||
|
||||
### Using the error
|
||||
|
||||
@@ -63,7 +63,7 @@ export const deletePublicDashboard = (
|
||||
{ id }: DeletePublicDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/dashboards/${id}/public`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -346,7 +346,7 @@ export const updatePublicDashboard = (
|
||||
dashboardtypesUpdatablePublicDashboardDTO?: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/dashboards/${id}/public`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -836,7 +836,7 @@ export const deleteDashboardV2 = (
|
||||
{ id }: DeleteDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboards/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1214,7 +1214,7 @@ export const unlockDashboardV2 = (
|
||||
{ id }: UnlockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1293,7 +1293,7 @@ export const lockDashboardV2 = (
|
||||
{ id }: LockDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/dashboards/${id}/lock`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
@@ -1471,7 +1471,7 @@ export const unpinDashboardV2 = (
|
||||
{ id }: UnpinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/users/me/dashboards/${id}/pins`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1550,7 +1550,7 @@ export const pinDashboardV2 = (
|
||||
{ id }: PinDashboardV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/users/me/dashboards/${id}/pins`,
|
||||
method: 'PUT',
|
||||
signal,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const handleExportRawDataPOST = (
|
||||
params?: HandleExportRawDataPOSTParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/export_raw_data`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -680,7 +680,7 @@ export const updateMetricMetadata = (
|
||||
metricsexplorertypesUpdateMetricMetadataRequestDTO?: BodyType<MetricsexplorertypesUpdateMetricMetadataRequestDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/metrics/${metricName}/metadata`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -203,7 +203,7 @@ export const deleteRole = (
|
||||
{ id }: DeleteRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -372,7 +372,7 @@ export const patchRole = (
|
||||
authtypesPatchableRoleDTO?: BodyType<AuthtypesPatchableRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -572,7 +572,7 @@ export const patchObjects = (
|
||||
coretypesPatchableObjectsDTO?: BodyType<CoretypesPatchableObjectsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/roles/${id}/relations/${relation}/objects`,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -222,7 +222,7 @@ export const deleteServiceAccount = (
|
||||
{ id }: DeleteServiceAccountPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -405,7 +405,7 @@ export const updateServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -707,7 +707,7 @@ export const revokeServiceAccountKey = (
|
||||
{ id, fid }: RevokeServiceAccountKeyPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -788,7 +788,7 @@ export const updateServiceAccountKey = (
|
||||
serviceaccounttypesUpdatableFactorAPIKeyDTO?: BodyType<ServiceaccounttypesUpdatableFactorAPIKeyDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}/keys/${fid}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1090,7 +1090,7 @@ export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
@@ -1254,7 +1254,7 @@ export const updateMyServiceAccount = (
|
||||
serviceaccounttypesPostableServiceAccountDTO?: BodyType<ServiceaccounttypesPostableServiceAccountDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<string>({
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/me`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -2142,21 +2142,16 @@ export interface ErrorsResponseerroradditionalDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
message?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
suggestions: string[] | null;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export type ErrorsResponseretryjsonDTOAnyOf = {
|
||||
delay: TimeDurationDTO;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type ErrorsResponseretryjsonDTO = ErrorsResponseretryjsonDTOAnyOf | null;
|
||||
export interface ErrorsResponseretryjsonDTO {
|
||||
delay?: TimeDurationDTO;
|
||||
}
|
||||
|
||||
export interface ErrorsJSONDTO {
|
||||
/**
|
||||
@@ -2164,26 +2159,26 @@ export interface ErrorsJSONDTO {
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
errors: ErrorsResponseerroradditionalDTO[] | null;
|
||||
errors?: ErrorsResponseerroradditionalDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message: string;
|
||||
retry: ErrorsResponseretryjsonDTO | null;
|
||||
retry?: ErrorsResponseretryjsonDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
suggestions: string[] | null;
|
||||
suggestions?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
type: string;
|
||||
type?: string;
|
||||
/**
|
||||
* @type string,null
|
||||
* @type string
|
||||
*/
|
||||
url: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesOrgSessionContextDTO {
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestWithSuggestiveAdditional(t *testing.T) {
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "field `filed` not found", Suggestions: []string{"did you mean: `field`"}},
|
||||
}, j.Errors)
|
||||
assert.Empty(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
assert.Nil(t, j.Suggestions, "detail-scoped suggestions must not leak into the error-wide list")
|
||||
}
|
||||
|
||||
func TestWithRetryAfter(t *testing.T) {
|
||||
@@ -106,12 +106,7 @@ func TestAsJSONBaseError(t *testing.T) {
|
||||
assert.Equal(t, "bad_input", j.Code)
|
||||
assert.Equal(t, "field foo is bad", j.Message)
|
||||
assert.Equal(t, "https://docs/bad_input", j.Url)
|
||||
// A detail with no suggestions carries a nil slice (the suggestions field is
|
||||
// nullable, so it marshals to null rather than []).
|
||||
assert.Equal(t, []responseerroradditional{
|
||||
{Message: "hint1"},
|
||||
{Message: "hint2"},
|
||||
}, j.Errors)
|
||||
assert.Equal(t, []responseerroradditional{{Message: "hint1"}, {Message: "hint2"}}, j.Errors)
|
||||
|
||||
// InvalidInput auto-applies the after_fix policy via NewInvalidInputf — but
|
||||
// New (bare constructor) does not. The retry block should reflect that.
|
||||
@@ -162,13 +157,9 @@ func TestAsJSONRetryBlock(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAsJSONEmptyWhenNoneSet(t *testing.T) {
|
||||
// errors and suggestions are nullable in the OpenAPI spec, so AsJSON leaves
|
||||
// them empty when the error carries none (they marshal to null / []).
|
||||
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
|
||||
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
|
||||
|
||||
assert.Empty(t, j.Suggestions)
|
||||
assert.Empty(t, j.Errors)
|
||||
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
|
||||
}
|
||||
|
||||
func TestWithStacktrace(t *testing.T) {
|
||||
|
||||
@@ -7,34 +7,31 @@ import (
|
||||
)
|
||||
|
||||
type JSON struct {
|
||||
Type string `json:"type" required:"true"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Code string `json:"code" required:"true"`
|
||||
Message string `json:"message" required:"true"`
|
||||
Url string `json:"url" required:"true" nullable:"true"`
|
||||
Errors []responseerroradditional `json:"errors" required:"true" nullable:"true"`
|
||||
Retry *responseretryjson `json:"retry" required:"true" nullable:"true"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"true"`
|
||||
Url string `json:"url,omitempty"`
|
||||
Errors []responseerroradditional `json:"errors,omitempty"`
|
||||
Retry *responseretryjson `json:"retry,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
type responseretryjson struct {
|
||||
Delay time.Duration `json:"delay" required:"true" nullable:"false"`
|
||||
Delay time.Duration `json:"delay"`
|
||||
}
|
||||
|
||||
type responseerroradditional struct {
|
||||
Message string `json:"message" required:"true"`
|
||||
Suggestions []string `json:"suggestions" required:"true" nullable:"true"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
}
|
||||
|
||||
func AsJSON(cause error) *JSON {
|
||||
// See if this is an instance of the base error or not
|
||||
t, c, m, _, u, a := Unwrapb(cause)
|
||||
|
||||
var rea []responseerroradditional
|
||||
if len(a) > 0 {
|
||||
rea = make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
|
||||
var retry *responseretryjson
|
||||
@@ -57,12 +54,9 @@ func AsURLValues(cause error) url.Values {
|
||||
// See if this is an instance of the base error or not
|
||||
_, c, m, _, u, a := Unwrapb(cause)
|
||||
|
||||
var rea []responseerroradditional
|
||||
if len(a) > 0 {
|
||||
rea = make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
rea := make([]responseerroradditional, len(a))
|
||||
for k, v := range a {
|
||||
rea[k] = responseerroradditional{Message: v.message, Suggestions: v.suggestions}
|
||||
}
|
||||
|
||||
errors, err := json.Marshal(rea)
|
||||
|
||||
@@ -5,18 +5,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Nouns name the kind of value a suggestion refers to. Pass one to
|
||||
// NewValidReferences / NewSuggestionsOnLevenshteinDistance to phrase the
|
||||
// "valid <noun> are ..." list consistently across the codebase.
|
||||
const (
|
||||
NounFields = "fields"
|
||||
NounKeys = "keys"
|
||||
NounServices = "services"
|
||||
NounQueryTypes = "query types"
|
||||
NounSignals = "signals"
|
||||
NounReferences = "references"
|
||||
)
|
||||
|
||||
const (
|
||||
typoSuggestionThreshold = 0.75
|
||||
// maxValidReferences caps how many valid references are listed so
|
||||
@@ -25,18 +13,17 @@ const (
|
||||
maxValidReferences = 20
|
||||
)
|
||||
|
||||
// NewSuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// SuggestionsOnLevenshteinDistance returns a "did you mean" correction (only
|
||||
// when a close match at least typoSuggestionThreshold similar exists) followed
|
||||
// by the valid-references list. noun names the kind of value being suggested
|
||||
// (e.g. "fields", "keys") and is used to phrase the valid-references list.
|
||||
func NewSuggestionsOnLevenshteinDistance(invalidInput string, noun string, validInputs []string) []string {
|
||||
// by the valid-references list.
|
||||
func SuggestionsOnLevenshteinDistance(invalidInput string, validInputs []string) []string {
|
||||
suggestions := make([]string, 0, 2)
|
||||
|
||||
if match, ok := ClosestLevenshteinMatch(invalidInput, validInputs); ok {
|
||||
suggestions = append(suggestions, didYouMean(match))
|
||||
}
|
||||
|
||||
if refs := NewValidReferences(noun, validInputs...); refs != "" {
|
||||
if refs := ValidReferences(validInputs...); refs != "" {
|
||||
suggestions = append(suggestions, refs)
|
||||
}
|
||||
|
||||
@@ -65,10 +52,10 @@ func ClosestLevenshteinMatch(input string, candidates []string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// NewSuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// SuggestionsFromFunc formats the string produce returns as a one-element
|
||||
// "did you mean: `x`" slice, or nil when it returns the empty string (so callers
|
||||
// with their own matching strategy compose into a suggestions list cleanly).
|
||||
func NewSuggestionsFromFunc(produce func() string) []string {
|
||||
func SuggestionsFromFunc(produce func() string) []string {
|
||||
s := produce()
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -77,12 +64,12 @@ func NewSuggestionsFromFunc(produce func() string) []string {
|
||||
return []string{didYouMean(s)}
|
||||
}
|
||||
|
||||
// NewValidReferences formats values as "valid <noun> are `a`, `b`" (e.g. noun
|
||||
// "fields", "functions", "keys"), capped at maxValidReferences with a "(+N more)"
|
||||
// suffix. Each value is rendered as its own string, an Enum() element's
|
||||
// StringValue(), or fmt.Sprint as a fallback. It returns "" when there are no
|
||||
// values, so callers don't surface a bare "valid <noun> are" with nothing after it.
|
||||
func NewValidReferences[T any](noun string, values ...T) string {
|
||||
// ValidReferences formats values as "valid references: `a`, `b`", capped at
|
||||
// maxValidReferences with a "(+N more)" suffix. Each value is rendered as its
|
||||
// own string, an Enum() element's StringValue(), or fmt.Sprint as a fallback.
|
||||
// It returns "" when there are no values, so callers don't surface a bare
|
||||
// "valid references: " with nothing after it.
|
||||
func ValidReferences[T any](values ...T) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -110,7 +97,7 @@ func NewValidReferences[T any](noun string, values ...T) string {
|
||||
quoted[i] = "`" + r + "`"
|
||||
}
|
||||
|
||||
out := "valid " + noun + " are " + strings.Join(quoted, ", ")
|
||||
out := "valid references: " + strings.Join(quoted, ", ")
|
||||
if truncated > 0 {
|
||||
out += fmt.Sprintf(" (+%d more)", truncated)
|
||||
}
|
||||
|
||||
@@ -6,28 +6,26 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid <noun> are".
|
||||
assert.Equal(t, "", NewValidReferences[string](NounFields))
|
||||
func TestValidReferences(t *testing.T) {
|
||||
// An empty set returns "" so callers don't surface a bare "valid references: ".
|
||||
assert.Equal(t, "", ValidReferences[string]())
|
||||
|
||||
// The noun phrases the list, e.g. "valid fields are", "valid keys are".
|
||||
assert.Equal(t, "valid fields are `a`, `b`", NewValidReferences(NounFields, "a", "b"))
|
||||
assert.Equal(t, "valid keys are `a`, `b`", NewValidReferences(NounKeys, "a", "b"))
|
||||
assert.Equal(t, "valid references: `a`, `b`", ValidReferences("a", "b"))
|
||||
}
|
||||
|
||||
func TestNewSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid <noun> are").
|
||||
assert.Empty(t, NewSuggestionsOnLevenshteinDistance("foo", NounFields, nil))
|
||||
func TestSuggestionsOnLevenshteinDistance(t *testing.T) {
|
||||
// No valid inputs => no suggestions at all (no bare "valid references: ").
|
||||
assert.Empty(t, SuggestionsOnLevenshteinDistance("foo", nil))
|
||||
|
||||
// Close match => did-you-mean plus the valid-references list.
|
||||
assert.Equal(t,
|
||||
[]string{"did you mean: `name`", "valid fields are `name`, `color`"},
|
||||
NewSuggestionsOnLevenshteinDistance("nam", NounFields, []string{"name", "color"}),
|
||||
[]string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("nam", []string{"name", "color"}),
|
||||
)
|
||||
|
||||
// No close match => valid-references list only.
|
||||
assert.Equal(t,
|
||||
[]string{"valid fields are `name`, `color`"},
|
||||
NewSuggestionsOnLevenshteinDistance("zzzzz", NounFields, []string{"name", "color"}),
|
||||
[]string{"valid references: `name`, `color`"},
|
||||
SuggestionsOnLevenshteinDistance("zzzzz", []string{"name", "color"}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package binding
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/reflectutil"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -76,7 +76,7 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
|
||||
|
||||
return errors.
|
||||
NewInvalidInputf(errors.CodeInvalidInput, message, field).
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, reflectutil.JSONFieldNames(obj))...)
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, JSONFieldNames(obj))...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,37 @@ func (b *jsonBinding) BindBody(body io.Reader, obj any, opts ...BindBodyOption)
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
|
||||
// skipping fields tagged "-" or without a json tag.
|
||||
func JSONFieldNames(v any) []string {
|
||||
var fields []string
|
||||
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName != "" {
|
||||
fields = append(fields, fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// extractUnknownField pulls fieldname out of a `json: unknown field "fieldname"`
|
||||
// decoder message, or returns "" when the message has no quoted field.
|
||||
func extractUnknownField(errMsg string) string {
|
||||
|
||||
@@ -90,21 +90,21 @@ func TestJSONBinding_BindBody_UnknownFieldSuggestions(t *testing.T) {
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "shape"`,
|
||||
suggestions: []string{"valid fields are `name`, `color`"},
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "WithContext",
|
||||
body: `{"shape":"round"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true), WithUnknownFieldContext("widget spec")},
|
||||
message: `unknown field "shape" in widget spec`,
|
||||
suggestions: []string{"valid fields are `name`, `color`"},
|
||||
suggestions: []string{"valid references: `name`, `color`"},
|
||||
},
|
||||
{
|
||||
name: "NearMatch",
|
||||
body: `{"nam":"x"}`,
|
||||
opts: []BindBodyOption{WithDisallowUnknownFields(true)},
|
||||
message: `unknown field "nam"`,
|
||||
suggestions: []string{"did you mean: `name`", "valid fields are `name`, `color`"},
|
||||
suggestions: []string{"did you mean: `name`", "valid references: `name`, `color`"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -113,9 +113,11 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
|
||||
)
|
||||
} else {
|
||||
// No response body (e.g. 204 No Content): omit the content type so the
|
||||
// spec doesn't declare a body for a bodyless response, which would make
|
||||
// clients try to decode an empty payload.
|
||||
opCtx.AddRespStructure(
|
||||
nil,
|
||||
openapi.WithContentType(handler.openAPIDef.ResponseContentType),
|
||||
openapi.WithHTTPStatus(handler.openAPIDef.SuccessStatusCode),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,13 +99,13 @@ func TestError(t *testing.T) {
|
||||
name: "AlreadyExists",
|
||||
statusCode: http.StatusConflict,
|
||||
err: errors.New(errors.TypeAlreadyExists, errors.MustNewCode("already_exists"), "already exists").WithUrl("https://already_exists"),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists","errors":null,"retry":null,"suggestions":null}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists"}}`),
|
||||
},
|
||||
"/unauthenticated": {
|
||||
name: "Unauthenticated",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
err: errors.New(errors.TypeUnauthenticated, errors.MustNewCode("not_allowed"), "not allowed").WithUrl("https://unauthenticated").WithAdditional("a1", "a2"),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1","suggestions":null},{"message":"a2","suggestions":null}],"retry":null,"suggestions":null}}`),
|
||||
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1"},{"message":"a2"}]}}`),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -177,8 +177,8 @@ func TestErrorRetryAfterHeader(t *testing.T) {
|
||||
name: "BareErrorNoHeaderNoRetryBlock",
|
||||
err: errors.New(errors.TypeInternal, errors.MustNewCode("boom"), "boom"),
|
||||
wantRetryAfter: "",
|
||||
wantBodyContains: `"retry":null`,
|
||||
wantBodyNotContains: `"delay"`,
|
||||
wantBodyContains: `"code":"boom"`,
|
||||
wantBodyNotContains: `"retry"`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func CollisionHandledFinalExpr(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
return "", nil, wrappedErr
|
||||
} else {
|
||||
for _, key := range keysForField {
|
||||
|
||||
@@ -300,7 +300,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
var suggestions []string
|
||||
if len(v.invalid) == 1 {
|
||||
inv := v.invalid[0]
|
||||
suggestions = errors.NewSuggestionsFromFunc(func() string {
|
||||
suggestions = errors.SuggestionsFromFunc(func() string {
|
||||
match, ok := errors.ClosestLevenshteinMatch(inv, validKeys)
|
||||
if !ok || strings.Contains(original, inv+"(") || strings.Contains(match, "(") {
|
||||
return ""
|
||||
@@ -309,7 +309,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
})
|
||||
}
|
||||
|
||||
suggestions = append(suggestions, errors.NewValidReferences(errors.NounReferences, validKeys...))
|
||||
suggestions = append(suggestions, errors.ValidReferences(validKeys...))
|
||||
havingErr := errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"Invalid references in `Having` expression: [%s]",
|
||||
@@ -339,7 +339,7 @@ func (r *HavingExpressionRewriter) rewriteAndValidate(expression string) (string
|
||||
// multiple errors are surfaced as one additional detail each. If the parser
|
||||
// produced no message (rare), the top-level message stands on its own.
|
||||
if len(allSyntaxErrors) == 1 && len(msgs) == 1 {
|
||||
suggestions := errors.NewSuggestionsFromFunc(func() string {
|
||||
suggestions := errors.SuggestionsFromFunc(func() string {
|
||||
return havingSuggestion(allSyntaxErrors[0], original)
|
||||
})
|
||||
|
||||
|
||||
@@ -593,7 +593,7 @@ func TestRewriteForLogsAndTraces_InOperator(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [ghost]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "IN with end bracked missing",
|
||||
@@ -655,7 +655,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [unknown_alias]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "typo in identifier suggests closest match",
|
||||
@@ -666,7 +666,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [totol]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"did you mean: `total > 100`", "valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"did you mean: `total > 100`", "valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "expression not in column map",
|
||||
@@ -677,7 +677,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [sum]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "one valid one invalid reference",
|
||||
@@ -688,7 +688,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [ghost]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count(), total]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`, `total`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`, `total`"},
|
||||
},
|
||||
{
|
||||
name: "__result ambiguous with multiple aggregations",
|
||||
@@ -700,7 +700,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [__result]",
|
||||
wantAdditional: []string{"Valid references are: [__result0, __result1, count(), sum(bytes)]"},
|
||||
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references are `__result0`, `__result1`, `count()`, `sum(bytes)`"},
|
||||
wantSuggestions: []string{"did you mean: `__result0 > 100`", "valid references: `__result0`, `__result1`, `count()`, `sum(bytes)`"},
|
||||
},
|
||||
{
|
||||
name: "out-of-range __result_N index",
|
||||
@@ -711,7 +711,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [__result_9]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "__result_1 out of range for single aggregation",
|
||||
@@ -722,7 +722,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [__result_1]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"did you mean: `__result > 100`", "valid references: `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "cascaded function calls",
|
||||
@@ -733,7 +733,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [sum]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, count()]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `count()`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `count()`"},
|
||||
},
|
||||
{
|
||||
name: "function call with multiple args not in column map",
|
||||
@@ -744,7 +744,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [sum]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(a)]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(a)`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(a)`"},
|
||||
},
|
||||
{
|
||||
name: "unquoted string value treated as unknown identifier",
|
||||
@@ -755,7 +755,7 @@ func TestRewriteForLogsAndTraces_ErrorInvalidReferences(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [xyz]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(bytes)]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(bytes)`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(bytes)`"},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1030,7 +1030,7 @@ func TestRewriteForMetrics(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [wrong_metric]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(cpu_usage)]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
},
|
||||
// --- Error: string literal (not allowed in HAVING) ---
|
||||
{
|
||||
@@ -1077,7 +1077,7 @@ func TestRewriteForMetrics(t *testing.T) {
|
||||
wantErr: true,
|
||||
wantErrMsg: "Invalid references in `Having` expression: [count]",
|
||||
wantAdditional: []string{"Valid references are: [__result, __result0, sum(cpu_usage)]"},
|
||||
wantSuggestions: []string{"valid references are `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
wantSuggestions: []string{"valid references: `__result`, `__result0`, `sum(cpu_usage)`"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Package reflectutil holds small reflection helpers shared across packages.
|
||||
package reflectutil
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSONFieldNames returns the JSON field names of a struct (or pointer to one),
|
||||
// skipping fields tagged "-" or without a json tag.
|
||||
func JSONFieldNames(v any) []string {
|
||||
var fields []string
|
||||
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
if t.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
|
||||
if jsonTag == "" || jsonTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName != "" {
|
||||
fields = append(fields, fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
field.FieldContext = telemetrytypes.FieldContextLog
|
||||
fieldExpression, _ = m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||
} else {
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -263,7 +263,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else if len(keysForField) == 1 {
|
||||
|
||||
@@ -91,7 +91,7 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else if len(keysForField) == 1 {
|
||||
|
||||
@@ -368,7 +368,7 @@ func (m *defaultFieldMapper) ColumnExpressionFor(
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
|
||||
wrappedErr := errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field.Name, maps.Keys(keys))...)
|
||||
return "", wrappedErr
|
||||
}
|
||||
} else if len(keysForField) == 1 {
|
||||
|
||||
@@ -99,5 +99,5 @@ func NewServiceID(provider CloudProviderType, service string) (ServiceID, error)
|
||||
|
||||
return ServiceID{}, errors.NewInvalidInputf(ErrCodeInvalidServiceID,
|
||||
"invalid service id %q for %s cloud provider", service, provider.StringValue()).
|
||||
WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(service, errors.NounServices, validServices)...)
|
||||
WithSuggestions(errors.SuggestionsOnLevenshteinDistance(service, validServices)...)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/govaluate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/reflectutil"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -153,7 +152,7 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
|
||||
shadow.Type,
|
||||
).WithAdditional(
|
||||
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, builder_trace_operator, promql, clickhouse_sql",
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
|
||||
).WithSuggestions(errors.ValidReferences(QueryType{}.Enum()...))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -197,7 +196,7 @@ func UnmarshalBuilderQueryBySignal(data []byte) (any, error) {
|
||||
errors.CodeInvalidInput,
|
||||
"invalid signal %q",
|
||||
header.Signal.StringValue(),
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounSignals, telemetrytypes.Signal{}.Enum()...))
|
||||
).WithSuggestions(errors.ValidReferences(telemetrytypes.Signal{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +229,7 @@ func (c *CompositeQuery) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Valid field names are derived from the struct itself so this stays in
|
||||
// sync with the schema (and the generated OpenAPI spec) automatically.
|
||||
fieldNames := reflectutil.JSONFieldNames((*CompositeQuery)(nil))
|
||||
fieldNames := binding.JSONFieldNames((*CompositeQuery)(nil))
|
||||
validFields := make(map[string]bool, len(fieldNames))
|
||||
for _, f := range fieldNames {
|
||||
validFields[f] = true
|
||||
@@ -244,7 +243,7 @@ func (c *CompositeQuery) UnmarshalJSON(data []byte) error {
|
||||
field,
|
||||
).WithAdditional(
|
||||
"Valid fields are: " + strings.Join(fieldNames, ", "),
|
||||
).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, fieldNames)...)
|
||||
).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, fieldNames)...)
|
||||
return unknownFieldErr
|
||||
}
|
||||
}
|
||||
@@ -557,7 +556,7 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Valid field names are derived from the struct itself so this stays in
|
||||
// sync with the schema (and the generated OpenAPI spec) automatically.
|
||||
fieldNames := reflectutil.JSONFieldNames((*QueryRangeRequest)(nil))
|
||||
fieldNames := binding.JSONFieldNames((*QueryRangeRequest)(nil))
|
||||
validFields := make(map[string]bool, len(fieldNames))
|
||||
for _, f := range fieldNames {
|
||||
validFields[f] = true
|
||||
@@ -571,7 +570,7 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
field,
|
||||
).WithAdditional(
|
||||
"Valid fields are: " + strings.Join(fieldNames, ", "),
|
||||
).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field, errors.NounFields, fieldNames)...)
|
||||
).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(field, fieldNames)...)
|
||||
return unknownFieldErr
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
|
||||
orderId,
|
||||
).WithAdditional(
|
||||
fmt.Sprintf("For aggregation queries, order by can only reference group by keys, aggregation aliases/expressions, or aggregation indices. Valid keys are: %s", strings.Join(validKeys, ", ")),
|
||||
).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(orderKey, errors.NounKeys, validKeys)...)
|
||||
).WithSuggestions(errors.SuggestionsOnLevenshteinDistance(orderKey, validKeys)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,7 +712,7 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
|
||||
envelope.Type,
|
||||
).WithAdditional(
|
||||
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
|
||||
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
|
||||
).WithSuggestions(errors.ValidReferences(QueryType{}.Enum()...))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import {
|
||||
detectPersona,
|
||||
detectSettingsEnv,
|
||||
type Persona,
|
||||
type SettingsEnv,
|
||||
} from '../helpers/persona';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
@@ -20,6 +27,11 @@ export const ADMIN: User = {
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
// Per-worker persona/env caches by user email. Detection is constant for a
|
||||
// given backend + user, so it runs once per worker.
|
||||
const personaByUser = new Map<string, Promise<Persona>>();
|
||||
const envByUser = new Map<string, Promise<SettingsEnv>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
@@ -72,6 +84,10 @@ export const test = base.extend<{
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
|
||||
persona: Persona;
|
||||
|
||||
env: SettingsEnv;
|
||||
}>({
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
@@ -93,6 +109,24 @@ export const test = base.extend<{
|
||||
await use(page);
|
||||
await ctx.close();
|
||||
},
|
||||
|
||||
persona: async ({ authedPage, user }, use) => {
|
||||
let task = personaByUser.get(user.email);
|
||||
if (!task) {
|
||||
task = detectPersona(authedPage);
|
||||
personaByUser.set(user.email, task);
|
||||
}
|
||||
await use(await task);
|
||||
},
|
||||
|
||||
env: async ({ authedPage, user }, use) => {
|
||||
let task = envByUser.get(user.email);
|
||||
if (!task) {
|
||||
task = detectSettingsEnv(authedPage);
|
||||
envByUser.set(user.email, task);
|
||||
}
|
||||
await use(await task);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
124
tests/e2e/helpers/persona.ts
Normal file
124
tests/e2e/helpers/persona.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from './dashboards';
|
||||
|
||||
export type Tier =
|
||||
| 'cloud'
|
||||
| 'enterprise'
|
||||
| 'community'
|
||||
| 'community-enterprise';
|
||||
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER' | 'ANONYMOUS';
|
||||
|
||||
export interface Persona {
|
||||
tier: Tier;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface SettingsEnv {
|
||||
isGatewayEnabled: boolean;
|
||||
}
|
||||
|
||||
interface AuthzCheckItem {
|
||||
authorized?: boolean;
|
||||
object?: { selector?: string };
|
||||
}
|
||||
|
||||
interface FeatureFlag {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const LICENSE_URL = '/api/v3/licenses/active';
|
||||
const AUTHZ_CHECK_URL = '/api/v1/authz/check';
|
||||
const FEATURES_URL = '/api/v1/features';
|
||||
|
||||
// Mirrors IsAdmin/Editor/Viewer in frontend/src/hooks/useAuthZ/legacy.ts:
|
||||
// relation 'assignee' on resource kind/type 'role', selector = preset role id.
|
||||
const ROLE_PROBES: { role: Exclude<Role, 'ANONYMOUS'>; selector: string }[] = [
|
||||
{ role: 'ADMIN', selector: 'signoz-admin' },
|
||||
{ role: 'EDITOR', selector: 'signoz-editor' },
|
||||
{ role: 'VIEWER', selector: 'signoz-viewer' },
|
||||
];
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function parseOverride(): Persona | null {
|
||||
const raw = process.env.SIGNOZ_E2E_PERSONA;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parts = raw.toLowerCase().split('-');
|
||||
const roleRaw = parts.pop();
|
||||
const tier = parts.join('-') as Tier;
|
||||
const role = roleRaw?.toUpperCase() as Role;
|
||||
return { tier, role };
|
||||
}
|
||||
|
||||
async function detectTier(page: Page, token: string): Promise<Tier> {
|
||||
const res = await page.request.get(LICENSE_URL, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (res.status() === 404) {
|
||||
return 'community-enterprise';
|
||||
}
|
||||
if (res.status() === 501) {
|
||||
return 'community';
|
||||
}
|
||||
const body = await res.json();
|
||||
const platform = body?.data?.platform;
|
||||
if (platform === 'CLOUD') {
|
||||
return 'cloud';
|
||||
}
|
||||
return 'enterprise';
|
||||
}
|
||||
|
||||
async function detectRole(page: Page, token: string): Promise<Role> {
|
||||
const payload = ROLE_PROBES.map((p) => ({
|
||||
relation: 'assignee',
|
||||
object: {
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selector: p.selector,
|
||||
},
|
||||
}));
|
||||
const res = await page.request.post(AUTHZ_CHECK_URL, {
|
||||
headers: authHeaders(token),
|
||||
data: payload,
|
||||
});
|
||||
const body = await res.json();
|
||||
const items: AuthzCheckItem[] = body?.data ?? [];
|
||||
const granted = new Set(
|
||||
items.filter((i) => i?.authorized).map((i) => i?.object?.selector),
|
||||
);
|
||||
for (const p of ROLE_PROBES) {
|
||||
if (granted.has(p.selector)) {
|
||||
return p.role;
|
||||
}
|
||||
}
|
||||
return 'ANONYMOUS';
|
||||
}
|
||||
|
||||
export async function detectPersona(page: Page): Promise<Persona> {
|
||||
const override = parseOverride();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const token = await authToken(page);
|
||||
const [tier, role] = await Promise.all([
|
||||
detectTier(page, token),
|
||||
detectRole(page, token),
|
||||
]);
|
||||
return { tier, role };
|
||||
}
|
||||
|
||||
export async function detectSettingsEnv(page: Page): Promise<SettingsEnv> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(FEATURES_URL, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: FeatureFlag[] = body?.data ?? [];
|
||||
const gateway = flags.find((f) => f?.name === 'gateway');
|
||||
return { isGatewayEnabled: !!gateway?.active };
|
||||
}
|
||||
52
tests/e2e/helpers/settings.ts
Normal file
52
tests/e2e/helpers/settings.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect } from '../fixtures/auth';
|
||||
|
||||
// Verbatim from frontend/src/constants/routes.ts
|
||||
export const SETTINGS_ROUTES = {
|
||||
WORKSPACE: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
ALL_CHANNELS: '/settings/channels',
|
||||
INGESTION: '/settings/ingestion-settings',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES: '/settings/roles',
|
||||
MEMBERS: '/settings/members',
|
||||
SERVICE_ACCOUNTS: '/settings/service-accounts',
|
||||
SHORTCUTS: '/settings/shortcuts',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
INTEGRATIONS: '/integrations',
|
||||
} as const;
|
||||
|
||||
export type SettingsRoute =
|
||||
(typeof SETTINGS_ROUTES)[keyof typeof SETTINGS_ROUTES];
|
||||
|
||||
// Sidenav item data-testid == itemKey in menuItems.tsx settingsNavSections.
|
||||
export const NAV_TESTID: Record<string, string> = {
|
||||
[SETTINGS_ROUTES.WORKSPACE]: 'workspace',
|
||||
[SETTINGS_ROUTES.MY_SETTINGS]: 'account',
|
||||
[SETTINGS_ROUTES.ALL_CHANNELS]: 'notification-channels',
|
||||
[SETTINGS_ROUTES.BILLING]: 'billing',
|
||||
[SETTINGS_ROUTES.INTEGRATIONS]: 'integrations',
|
||||
[SETTINGS_ROUTES.MCP_SERVER]: 'mcp-server',
|
||||
[SETTINGS_ROUTES.ROLES]: 'roles',
|
||||
[SETTINGS_ROUTES.MEMBERS]: 'members',
|
||||
[SETTINGS_ROUTES.SERVICE_ACCOUNTS]: 'service-accounts',
|
||||
[SETTINGS_ROUTES.INGESTION]: 'ingestion',
|
||||
[SETTINGS_ROUTES.ORG_SETTINGS]: 'sso',
|
||||
[SETTINGS_ROUTES.SHORTCUTS]: 'keyboard-shortcuts',
|
||||
};
|
||||
|
||||
export async function gotoSettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.WORKSPACE);
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSettingsTab(
|
||||
page: Page,
|
||||
route: SettingsRoute,
|
||||
): Promise<void> {
|
||||
const testid = NAV_TESTID[route];
|
||||
await page.getByTestId('settings-page-sidenav').getByTestId(testid).click();
|
||||
await expect(page).toHaveURL(new RegExp(route.replace(/\//g, '\\/')));
|
||||
}
|
||||
156
tests/e2e/helpers/settingsAccess.ts
Normal file
156
tests/e2e/helpers/settingsAccess.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Persona, SettingsEnv, Tier } from './persona';
|
||||
import { SETTINGS_ROUTES, NAV_TESTID } from './settings';
|
||||
|
||||
// Mirrors the isEnabled effect in frontend/src/pages/Settings/Settings.tsx.
|
||||
// Returns the set of sidenav item testids (itemKeys) that should be visible.
|
||||
export function visibleNavItems(
|
||||
persona: Persona,
|
||||
_env: SettingsEnv,
|
||||
): Set<string> {
|
||||
const { tier, role } = persona;
|
||||
const isAdmin = role === 'ADMIN';
|
||||
const isEditor = role === 'EDITOR';
|
||||
const isViewer = role === 'VIEWER';
|
||||
|
||||
// Defaults that start enabled in menuItems.tsx settingsNavSections.
|
||||
const s = new Set<string>([
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'keyboard-shortcuts',
|
||||
]);
|
||||
|
||||
const enableForAllUsers = (): void => {
|
||||
s.add('roles');
|
||||
s.add('service-accounts');
|
||||
};
|
||||
|
||||
if (tier === 'cloud') {
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
[
|
||||
'billing',
|
||||
'integrations',
|
||||
'ingestion',
|
||||
'sso',
|
||||
'members',
|
||||
'mcp-server',
|
||||
].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isEditor) {
|
||||
['ingestion', 'integrations', 'mcp-server'].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isViewer) {
|
||||
s.add('mcp-server');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
if (tier === 'enterprise') {
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
[
|
||||
'billing',
|
||||
'integrations',
|
||||
'sso',
|
||||
'members',
|
||||
'ingestion',
|
||||
'mcp-server',
|
||||
].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isEditor) {
|
||||
['integrations', 'ingestion', 'mcp-server'].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isViewer) {
|
||||
s.add('mcp-server');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// community / community-enterprise (!cloud && !enterprise)
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
s.add('sso');
|
||||
s.add('members');
|
||||
}
|
||||
// billing & integrations explicitly disabled for non-cloud users.
|
||||
s.delete('billing');
|
||||
s.delete('integrations');
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mirrors getRoutes() in frontend/src/pages/Settings/utils.ts.
|
||||
// Returns the set of /settings route paths that are mounted (navigable).
|
||||
export function registeredRoutes(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
): Set<string> {
|
||||
const { tier, role } = persona;
|
||||
const isAdmin = role === 'ADMIN';
|
||||
const isEditor = role === 'EDITOR';
|
||||
const isCloud = tier === 'cloud';
|
||||
const isEnterprise = tier === 'enterprise';
|
||||
|
||||
const r = new Set<string>([
|
||||
SETTINGS_ROUTES.WORKSPACE, // generalSettings — always
|
||||
SETTINGS_ROUTES.ALL_CHANNELS, // always
|
||||
SETTINGS_ROUTES.SERVICE_ACCOUNTS, // always
|
||||
SETTINGS_ROUTES.ROLES, // always
|
||||
SETTINGS_ROUTES.MY_SETTINGS, // always
|
||||
SETTINGS_ROUTES.SHORTCUTS, // always
|
||||
SETTINGS_ROUTES.MCP_SERVER, // always
|
||||
]);
|
||||
|
||||
// organizationSettings — gated by current_org_settings; mirrored as admin-only.
|
||||
if (isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.ORG_SETTINGS);
|
||||
}
|
||||
// multiIngestionSettings if gateway && (admin||editor); cloud read-only if cloud && !gateway.
|
||||
if (
|
||||
(env.isGatewayEnabled && (isAdmin || isEditor)) ||
|
||||
(isCloud && !env.isGatewayEnabled)
|
||||
) {
|
||||
r.add(SETTINGS_ROUTES.INGESTION);
|
||||
}
|
||||
// membersSettings if admin.
|
||||
if (isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.MEMBERS);
|
||||
}
|
||||
// billing if (cloud||enterprise) && admin.
|
||||
if ((isCloud || isEnterprise) && isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.BILLING);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Skip reason when a route's nav item is hidden for the persona; null when
|
||||
// visible. Centralised so every skip reads identically and is greppable.
|
||||
export function personaSkipReason(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
route: string,
|
||||
): string | null {
|
||||
const visible = visibleNavItems(persona, env);
|
||||
const testid = NAV_TESTID[route];
|
||||
if (testid && visible.has(testid)) {
|
||||
return null;
|
||||
}
|
||||
return `PERSONA_SKIP: ${route} hidden for ${persona.tier}×${persona.role}`;
|
||||
}
|
||||
|
||||
// Second skip axis: a route is visible but renders tier-specific CONTENT (e.g.
|
||||
// /settings shows a cloud support card vs self-hosted retention controls).
|
||||
// Gates a test to the tiers whose content it asserts. Shares the PERSONA_SKIP:
|
||||
// prefix.
|
||||
export function tierSkipReason(
|
||||
persona: Persona,
|
||||
allowedTiers: Tier[],
|
||||
label: string,
|
||||
): string | null {
|
||||
if (allowedTiers.includes(persona.tier)) {
|
||||
return null;
|
||||
}
|
||||
return `PERSONA_SKIP: ${label} not applicable for tier ${persona.tier} (needs ${allowedTiers.join(
|
||||
'|',
|
||||
)})`;
|
||||
}
|
||||
151
tests/e2e/tests/settings/general.spec.ts
Normal file
151
tests/e2e/tests/settings/general.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
personaSkipReason,
|
||||
tierSkipReason,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Workspace (/settings) has two views: cloud (retention inputs disabled, no Save,
|
||||
// GeneralSettingsCloud support card) and self-hosted (interactive inputs, per-row Save).
|
||||
// Retention inputs in compact mode have no data-testid — role/text/CSS fallback.
|
||||
|
||||
async function gotoWorkspace(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.WORKSPACE);
|
||||
// Retention data is fetched server-side; allow margin for the API response.
|
||||
await expect(page.locator('.retention-controls-container')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
function retentionRow(page: Page, signal: string) {
|
||||
return page.locator('.retention-row').filter({ hasText: signal });
|
||||
}
|
||||
|
||||
function retentionInput(page: Page, signal: string) {
|
||||
return retentionRow(page, signal).locator('input[type="number"]').first();
|
||||
}
|
||||
|
||||
function saveButton(page: Page, signal: string) {
|
||||
return retentionRow(page, signal).getByRole('button', { name: /^save$/i });
|
||||
}
|
||||
|
||||
// Tier sets for the two Workspace content variants.
|
||||
const CLOUD_TIERS = ['cloud'] as const;
|
||||
const SELF_HOSTED_TIERS = [
|
||||
'enterprise',
|
||||
'community',
|
||||
'community-enterprise',
|
||||
] as const;
|
||||
|
||||
test.describe('Settings — Workspace / General page', () => {
|
||||
test('TC-01 page renders retention controls and license-key row', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
// Scoped to avoid strict-mode conflict with the sidenav item.
|
||||
await expect(page.locator('.general-settings-title')).toContainText(
|
||||
'Workspace',
|
||||
);
|
||||
await expect(page.locator('.general-settings-subtitle')).toContainText(
|
||||
'Manage your workspace settings.',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Retention Controls')).toBeVisible();
|
||||
|
||||
await expect(retentionRow(page, 'Metrics')).toBeVisible();
|
||||
await expect(retentionRow(page, 'Traces')).toBeVisible();
|
||||
await expect(retentionRow(page, 'Logs')).toBeVisible();
|
||||
|
||||
await expect(retentionInput(page, 'Metrics')).toBeVisible();
|
||||
await expect(retentionInput(page, 'Traces')).toBeVisible();
|
||||
await expect(retentionInput(page, 'Logs')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('license-key-row-copy-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
// RISK MODE: read-only — only asserts disabled state, nothing is mutated.
|
||||
test('TC-02 cloud view — retention inputs are disabled and support card is visible', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view'),
|
||||
tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view') ??
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
await expect(retentionInput(page, 'Metrics')).toBeDisabled();
|
||||
await expect(retentionInput(page, 'Traces')).toBeDisabled();
|
||||
await expect(retentionInput(page, 'Logs')).toBeDisabled();
|
||||
|
||||
await expect(saveButton(page, 'Metrics')).toHaveCount(0);
|
||||
await expect(saveButton(page, 'Traces')).toHaveCount(0);
|
||||
await expect(saveButton(page, 'Logs')).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
page.getByText(/please.*email us.*or connect.*via chat support/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// RISK MODE: never clicks Save — only asserts enable-on-change / disable-on-clear; no PUT/POST.
|
||||
test('TC-03 self-hosted view — retention input enables/disables Save — no save triggered', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(
|
||||
persona,
|
||||
[...SELF_HOSTED_TIERS],
|
||||
'self-hosted retention controls',
|
||||
),
|
||||
tierSkipReason(
|
||||
persona,
|
||||
[...SELF_HOSTED_TIERS],
|
||||
'self-hosted retention controls',
|
||||
) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
const metricsInput = retentionInput(page, 'Metrics');
|
||||
const metricsSaveBtn = saveButton(page, 'Metrics');
|
||||
|
||||
const originalValue = await metricsInput.inputValue();
|
||||
|
||||
try {
|
||||
await metricsInput.fill('9999');
|
||||
await expect(metricsSaveBtn).toBeEnabled();
|
||||
|
||||
await metricsInput.fill('');
|
||||
await expect(metricsSaveBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText(/retention period for .+ is not set yet/i),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
// Restore so unsaved UI state does not leak to other workers sharing this stack.
|
||||
await metricsInput.fill(originalValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
117
tests/e2e/tests/settings/ingestion.spec.ts
Normal file
117
tests/e2e/tests/settings/ingestion.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
personaSkipReason,
|
||||
tierSkipReason,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Ingestion page, two variants gated by env.isGatewayEnabled / tier:
|
||||
// MultiIngestionSettings (gateway ON) vs read-only IngestionSettings (cloud, gateway OFF).
|
||||
// RISK MODE — READ-ONLY: never create/edit/delete keys or rate limits; create
|
||||
// button and copy affordances asserted for presence only, never clicked.
|
||||
// Each TC guards its variant via test.skip so bodies stay branch-free
|
||||
// (playwright/no-conditional-in-test).
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function gotoIngestion(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.INGESTION);
|
||||
// Ingestion keys/settings are fetched server-side; allow margin for the API response.
|
||||
await expect(
|
||||
page
|
||||
.locator('.ingestion-key-container, .ingestion-settings-container')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe('Settings — Ingestion page', () => {
|
||||
test('TC-01 MultiIngestionSettings — page chrome, search, table, and create affordance render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(
|
||||
persona,
|
||||
['cloud', 'enterprise'],
|
||||
'MultiIngestionSettings (gateway)',
|
||||
) || !env.isGatewayEnabled,
|
||||
!env.isGatewayEnabled
|
||||
? 'PERSONA_SKIP: gateway feature flag is OFF — MultiIngestionSettings does not render'
|
||||
: (tierSkipReason(
|
||||
persona,
|
||||
['cloud', 'enterprise'],
|
||||
'MultiIngestionSettings (gateway)',
|
||||
) ?? undefined),
|
||||
);
|
||||
|
||||
await gotoIngestion(page);
|
||||
|
||||
const container = page.locator('.ingestion-key-container');
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
// Exact name match avoids the subtitle partial match.
|
||||
await expect(
|
||||
container.getByRole('heading', { name: 'Ingestion Keys' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
container.getByText(/Create and manage ingestion keys/i),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByPlaceholder('Search for ingestion key...'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByRole('button', { name: /new ingestion key/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(container.locator('.ingestion-keys-table')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.locator('.ingestion-key-url-label', { hasText: 'Ingestion URL' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 IngestionSettings (read-only) — table rows for URL, key, and region render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
|
||||
);
|
||||
// This view only renders on cloud when gateway is disabled
|
||||
test.skip(
|
||||
env.isGatewayEnabled,
|
||||
'PERSONA_SKIP: gateway is ON — MultiIngestionSettings renders instead of read-only table',
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table'),
|
||||
tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table') ??
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gotoIngestion(page);
|
||||
|
||||
const container = page.locator('.ingestion-settings-container');
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByText(/start sending your telemetry data/i),
|
||||
).toBeVisible();
|
||||
|
||||
const table = container.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.getByText('Ingestion URL')).toBeVisible();
|
||||
await expect(table.getByText('Ingestion Key')).toBeVisible();
|
||||
await expect(table.getByText('Ingestion Region')).toBeVisible();
|
||||
});
|
||||
});
|
||||
153
tests/e2e/tests/settings/mcp-server.spec.ts
Normal file
153
tests/e2e/tests/settings/mcp-server.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// MCP Server settings, two variants gated by mcp_url in /api/v1/global/config:
|
||||
// full page (mcp_url present, cloud) vs NotCloudFallback (absent, community/self-hosted).
|
||||
// RISK MODE — READ-ONLY: never create a service account; copy/create/install
|
||||
// buttons asserted for presence only, never clicked.
|
||||
// mcpEndpointPresent is probed in beforeAll (real backend state) so TC-01/TC-02
|
||||
// skip via test.skip rather than branching in bodies (playwright/no-conditional-in-test).
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let mcpEndpointPresent = false;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/global/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const mcpUrl: unknown = body?.data?.mcp_url;
|
||||
mcpEndpointPresent = typeof mcpUrl === 'string' && mcpUrl.length > 0;
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoMcpServer(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MCP_SERVER);
|
||||
// Spinner gone => either full page or fallback has rendered.
|
||||
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe('Settings — MCP Server page', () => {
|
||||
// Locators below use CSS classes / role-text; only mcp-settings has a data-testid.
|
||||
test('TC-01 full page renders: header, client tabs, auth card, use-cases card', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
|
||||
);
|
||||
// Full-page content requires mcp_url to be configured. If not present the
|
||||
// NotCloudFallback renders instead — TC-02 covers that path.
|
||||
test.skip(
|
||||
!mcpEndpointPresent,
|
||||
'PERSONA_SKIP: mcp_url not configured on this stack — NotCloudFallback renders; see TC-02',
|
||||
);
|
||||
|
||||
await gotoMcpServer(page);
|
||||
|
||||
await expect(page.getByTestId('mcp-settings')).toBeVisible();
|
||||
|
||||
await expect(page.locator('.mcp-settings__header-title')).toContainText(
|
||||
'SigNoz MCP Server',
|
||||
);
|
||||
await expect(page.locator('.mcp-settings__header-subtitle')).toContainText(
|
||||
'Model Context Protocol',
|
||||
);
|
||||
|
||||
await expect(page.locator('.mcp-settings__card')).toBeVisible();
|
||||
await expect(page.locator('.mcp-settings__card-title')).toContainText(
|
||||
'Configure your client',
|
||||
);
|
||||
|
||||
const tabsRoot = page.locator('.mcp-client-tabs-root');
|
||||
await expect(tabsRoot).toBeVisible();
|
||||
await expect(tabsRoot.getByRole('tab', { name: /cursor/i })).toBeVisible();
|
||||
await expect(
|
||||
tabsRoot.getByRole('tab', { name: /claude code/i }),
|
||||
).toBeVisible();
|
||||
await expect(tabsRoot.getByRole('tab', { name: /vs code/i })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.mcp-client-tabs__snippet-pre').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /copy cursor config/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const authCard = page.locator('.mcp-auth-card');
|
||||
await expect(authCard).toBeVisible();
|
||||
await expect(authCard.locator('.mcp-auth-card__title')).toContainText(
|
||||
'Authenticate from your client',
|
||||
);
|
||||
|
||||
await expect(
|
||||
authCard.locator('.mcp-auth-card__field-label').first(),
|
||||
).toContainText('SigNoz Instance URL');
|
||||
await expect(
|
||||
authCard.getByRole('button', { name: /copy signoz instance url/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
authCard.locator('.mcp-auth-card__field-label').nth(1),
|
||||
).toContainText('API Key');
|
||||
await expect(
|
||||
authCard.getByRole('button', { name: /create service account/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const useCasesCard = page.locator('.mcp-use-cases-card');
|
||||
await expect(useCasesCard).toBeVisible();
|
||||
await expect(
|
||||
useCasesCard.locator('.mcp-use-cases-card__title'),
|
||||
).toContainText('What you can do with it');
|
||||
await expect(useCasesCard.locator('.mcp-use-cases-card__list')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
useCasesCard.getByRole('button', { name: /see more use cases/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Skipped when the beforeAll probe found mcp_url — full page renders instead.
|
||||
test('TC-02 NotCloudFallback renders when MCP endpoint is not configured', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
mcpEndpointPresent,
|
||||
'PERSONA_SKIP: mcp_url is configured on this stack — NotCloudFallback does not render',
|
||||
);
|
||||
|
||||
await gotoMcpServer(page);
|
||||
|
||||
await expect(page.locator('.not-cloud-fallback')).toBeVisible();
|
||||
await expect(page.locator('.not-cloud-fallback__title')).toContainText(
|
||||
'MCP Server is available on SigNoz',
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /view mcp server docs/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('mcp-settings')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
205
tests/e2e/tests/settings/members.spec.ts
Normal file
205
tests/e2e/tests/settings/members.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// RISK MODE: read-only plus one non-submitting invite-modal check — no member is
|
||||
// created/edited/deleted/role-changed. The fresh bootstrap stack has exactly one
|
||||
// member (seeded admin, active), so filter/search coverage is limited to that row.
|
||||
// No data-testid exists in MembersSettings/Table/InviteModal — role/placeholder/text/CSS fallback.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SIGNOZ_E2E_USERNAME ?? 'admin@integration.test';
|
||||
const SEARCH_PLACEHOLDER = 'Search by name or email...';
|
||||
|
||||
async function gotoMembers(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MEMBERS);
|
||||
// Members list is fetched server-side; allow margin for the API response.
|
||||
await expect(page.locator('.members-table-wrapper')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Settings — Members page', () => {
|
||||
test('TC-01 list renders with columns and the bootstrap admin user row', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Members', level: 1 }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Overview of people added to this workspace.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('.members-filter-trigger')).toBeVisible();
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /invite member/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.locator('.members-table');
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Name / Email' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Status' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Joined On' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
|
||||
const adminRow = page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('.member-email', { hasText: ADMIN_EMAIL }) });
|
||||
await expect(adminRow.getByText('ACTIVE')).toBeVisible();
|
||||
});
|
||||
|
||||
// On the single-member stack, Pending/Deleted both yield the empty state.
|
||||
test('TC-02 filter dropdown — cycles All / Pending / Deleted and updates the list', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByText(/pending invites/i).click();
|
||||
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText(/^deleted/i)
|
||||
.click();
|
||||
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText(/all members/i)
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 search filters by email match and shows empty state on no match', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await searchInput.fill(ADMIN_EMAIL);
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('xyznonexistentuser999@nowhere.invalid');
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('.members-empty-state__text')
|
||||
.getByText('xyznonexistentuser999@nowhere.invalid'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// RISK MODE: submit is never clicked; no invite is sent.
|
||||
test('TC-04 invite modal — renders correctly, submit disabled on untouched rows, Cancel dismisses', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await page.getByRole('button', { name: /invite member/i }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
modal.getByRole('heading', { name: 'Invite Team Members' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Header cells scoped to class selectors to avoid matching input placeholders.
|
||||
await expect(modal.locator('.email-header')).toBeVisible();
|
||||
await expect(modal.locator('.role-header')).toBeVisible();
|
||||
|
||||
// Modal starts with 3 empty rows.
|
||||
const emailInputs = modal.locator('input[type="email"]');
|
||||
await expect(emailInputs.first()).toBeVisible();
|
||||
await expect(emailInputs).toHaveCount(3);
|
||||
|
||||
await expect(
|
||||
modal.getByRole('button', { name: /add another/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Submit is disabled while all rows are untouched.
|
||||
const submitBtn = modal.getByRole('button', { name: 'Invite Team Members' });
|
||||
await expect(submitBtn).toBeVisible();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await modal.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
262
tests/e2e/tests/settings/my-settings.spec.ts
Normal file
262
tests/e2e/tests/settings/my-settings.spec.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// Runtime branching lives in these helpers, not test() bodies — playwright/no-conditional-in-test.
|
||||
|
||||
async function gotoMySettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MY_SETTINGS);
|
||||
await expect(page.getByTestId('theme-selector')).toBeVisible();
|
||||
}
|
||||
|
||||
async function readThemeState(
|
||||
page: Page,
|
||||
): Promise<{ theme: string; autoSwitch: string }> {
|
||||
// globalThis cast: the evaluate callback runs in the browser, but the e2e
|
||||
// tsconfig uses the ES2020 lib (no DOM), so `localStorage` isn't typed here.
|
||||
return page.evaluate(() => ({
|
||||
theme: (globalThis as any).localStorage.getItem('THEME') ?? 'dark',
|
||||
autoSwitch:
|
||||
(globalThis as any).localStorage.getItem('THEME_AUTO_SWITCH') ?? 'false',
|
||||
}));
|
||||
}
|
||||
|
||||
async function restoreTheme(
|
||||
page: Page,
|
||||
theme: string,
|
||||
autoSwitch: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
([t, a]) => {
|
||||
(globalThis as any).localStorage.setItem('THEME', t);
|
||||
(globalThis as any).localStorage.setItem('THEME_AUTO_SWITCH', a);
|
||||
},
|
||||
[theme, autoSwitch],
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSideNavPinned(
|
||||
page: Page,
|
||||
originalChecked: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: originalChecked === 'true' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
function flipAriaChecked(current: string): string {
|
||||
if (current === 'true') {
|
||||
return 'false';
|
||||
}
|
||||
return 'true';
|
||||
}
|
||||
|
||||
test.describe('My Settings — Account page', () => {
|
||||
test('TC-01 page renders with all expected controls', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /update name/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /reset password/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('theme-selector')).toBeVisible();
|
||||
await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible();
|
||||
await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible();
|
||||
|
||||
// License copy button renders because bootstrap issues an enterprise license on cloud.
|
||||
await expect(page.getByTestId('license-key-copy-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 theme toggle cycles dark → light → auto and applies', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const originalTheme = await readThemeState(page);
|
||||
|
||||
try {
|
||||
// Radix ToggleGroup renders items as role="radio" within a radiogroup.
|
||||
const selector = page.getByTestId('theme-selector');
|
||||
const darkRadio = selector.getByRole('radio', { name: /dark/i });
|
||||
const lightRadio = selector.getByRole('radio', { name: /light/i });
|
||||
const systemRadio = selector.getByRole('radio', { name: /system/i });
|
||||
|
||||
await lightRadio.click();
|
||||
await expect(lightRadio).toBeChecked();
|
||||
|
||||
await systemRadio.click();
|
||||
await expect(systemRadio).toBeChecked();
|
||||
|
||||
await darkRadio.click();
|
||||
await expect(darkRadio).toBeChecked();
|
||||
} finally {
|
||||
await restoreTheme(page, originalTheme.theme, originalTheme.autoSwitch);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 sidebar pin toggle flips checked state', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const switchEl = page.getByTestId('side-nav-pinned-switch');
|
||||
const originalChecked =
|
||||
(await switchEl.getAttribute('aria-checked')) ?? 'false';
|
||||
const expectedAfterToggle = flipAriaChecked(originalChecked);
|
||||
|
||||
try {
|
||||
await switchEl.click();
|
||||
// Pin state persists server-side; allow margin for the update under
|
||||
// parallel-worker CPU contention (default 5s expect timeout flakes).
|
||||
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await restoreSideNavPinned(page, originalChecked);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 timezone adaptation toggle flips checked state', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const switchEl = page.getByTestId('timezone-adaptation-switch');
|
||||
const originalChecked =
|
||||
(await switchEl.getAttribute('aria-checked')) ?? 'true';
|
||||
const expectedAfterToggle = flipAriaChecked(originalChecked);
|
||||
|
||||
try {
|
||||
await switchEl.click();
|
||||
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
// isAdaptationEnabled is not persisted — toggle back to restore session state.
|
||||
await switchEl.click();
|
||||
}
|
||||
});
|
||||
|
||||
// note: PUT /api/v2/users/me returns root_user_operation_unsupported for the
|
||||
// bootstrap admin user. Only the modal open/input/submit-button UI is tested
|
||||
// here; the "name reflects in card after save" assertion cannot be verified
|
||||
// against this stack.
|
||||
test('TC-05 update name modal — opens, pre-fills, submit button active', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const currentName = await page.locator('.user-name').first().innerText();
|
||||
|
||||
await page.getByRole('button', { name: /update name/i }).click();
|
||||
|
||||
const nameInput = page.getByPlaceholder('e.g. John Doe');
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await expect(nameInput).toHaveValue(currentName);
|
||||
|
||||
const submitBtn = page.getByTestId('update-name-btn');
|
||||
await expect(submitBtn).toBeVisible();
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
// Close via × button — Ant Modal's Escape handler can race with input focus in headless mode.
|
||||
await page
|
||||
.locator('.update-name-modal')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(nameInput).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 reset-password modal — validation only, never submits', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
// The button that OPENS the modal has no testid; reset-password-btn is the SUBMIT button inside.
|
||||
await page
|
||||
.getByRole('button', { name: /reset password/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const currentPasswordInput = page.getByTestId('current-password-textbox');
|
||||
const newPasswordInput = page.getByTestId('new-password-textbox');
|
||||
const submitBtn = page.getByTestId('reset-password-btn');
|
||||
|
||||
await expect(currentPasswordInput).toBeVisible();
|
||||
await expect(newPasswordInput).toBeVisible();
|
||||
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await currentPasswordInput.fill('somepassword');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Same value → passwords match → validation error + disabled
|
||||
await newPasswordInput.fill('somepassword');
|
||||
await expect(page.getByText(/new password must be different/i)).toBeVisible();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Stop at enabled — clicking would rotate the admin password and break every other worker.
|
||||
await newPasswordInput.fill('differentpassword!1');
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
await page
|
||||
.locator('.reset-password-modal')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(currentPasswordInput).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
106
tests/e2e/tests/settings/org-sso.spec.ts
Normal file
106
tests/e2e/tests/settings/org-sso.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// OrganizationSettings (/settings/org-settings): DisplayName form + AuthDomain section.
|
||||
// Invite coverage lives in members.spec.ts — the #invite-team-members hash is ignored here.
|
||||
//
|
||||
// note: PUT /api/v2/orgs returns root_user_operation_unsupported for the bootstrap
|
||||
// admin user. TC-02 only asserts the field is editable and the Submit button enables;
|
||||
// it does NOT submit the form. The original org name is never mutated.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function gotoOrgSettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.ORG_SETTINGS);
|
||||
await expect(page.getByLabel('Display name')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Organization Settings — SSO & Org page', () => {
|
||||
test('TC-01 page renders display-name field and authenticated-domains section', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
await expect(page.getByLabel('Display name')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Authenticated Domains' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Add Domain' })).toBeVisible();
|
||||
});
|
||||
|
||||
// note: root_user_operation_unsupported on save (see header) — never clicks Submit; value restored in finally.
|
||||
test('TC-02 org display name — field is editable and Submit enables on change', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
const nameInput = page.getByLabel('Display name');
|
||||
const submitBtn = page.getByRole('button', { name: 'Submit' });
|
||||
|
||||
const originalValue = await nameInput.inputValue();
|
||||
|
||||
try {
|
||||
// Submit is disabled when the value equals the current saved name.
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await nameInput.fill('org-sso-spec-temp');
|
||||
await expect(nameInput).toHaveValue('org-sso-spec-temp');
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
await nameInput.fill('');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
} finally {
|
||||
// Restored value equals the saved one, so Submit stays disabled — no API call.
|
||||
await nameInput.fill(originalValue);
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// RISK MODE: never enable SSO/SAML or click Save — that changes auth for the whole stack.
|
||||
test('TC-03 SSO config — Add Domain opens provider-selector modal, close dismisses it', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add Domain' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
modal.getByText('Configure Authentication Method'),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByText('Google Apps Authentication')).toBeVisible();
|
||||
|
||||
// SAML/OIDC visibility depends on the SSO flag — only assert Google Auth, always enabled.
|
||||
|
||||
await modal.getByRole('button', { name: /close/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
172
tests/e2e/tests/settings/roles.spec.ts
Normal file
172
tests/e2e/tests/settings/roles.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Roles page. RISK MODE — READ-ONLY: never create/edit/delete a role; TC-03
|
||||
// only views a managed role's detail page and navigates back.
|
||||
// rolesEnabled probes /api/v1/features for USE_FINE_GRAINED_AUTHZ — real backend
|
||||
// state, not a guess; row navigation is only wired up when it is on, so TC-03 skips otherwise.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let rolesEnabled = false;
|
||||
|
||||
async function gotoRolesList(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.ROLES);
|
||||
await expect(page.getByTestId('roles-settings')).toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/features', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
|
||||
const flag = flags.find((f) => f?.name === 'use_fine_grained_authz');
|
||||
rolesEnabled = !!flag?.active;
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Settings — Roles page', () => {
|
||||
test('TC-01 list renders with container, header, search, and managed-role rows', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
await expect(page.locator('.roles-settings-header-title')).toContainText(
|
||||
'Roles',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.roles-settings-header-description'),
|
||||
).toContainText('Create and manage custom roles for your team.');
|
||||
|
||||
await expect(page.locator('input[type="search"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('input[placeholder="Search for roles..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.locator('.roles-listing-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.locator('.roles-table-header-cell--name')).toContainText(
|
||||
'Name',
|
||||
);
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--description'),
|
||||
).toContainText('Description');
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--updated-at'),
|
||||
).toContainText('Updated At');
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--created-at'),
|
||||
).toContainText('Created At');
|
||||
|
||||
await expect(
|
||||
table.locator('.roles-table-section-header', { hasText: 'Managed roles' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(table.locator('.roles-table-row').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 search filters roles by match and shows empty state on no match', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
const searchInput = page.locator('input[placeholder="Search for roles..."]');
|
||||
const table = page.locator('.roles-listing-table');
|
||||
|
||||
await searchInput.fill('Admin');
|
||||
await expect(
|
||||
table.locator('.roles-table-cell--name', { hasText: /admin/i }).first(),
|
||||
).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('xyznonexistentrole999');
|
||||
await expect(table.locator('.roles-table-empty')).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toContainText(
|
||||
'No roles match your search.',
|
||||
);
|
||||
await expect(table.locator('.roles-table-row')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(table.locator('.roles-table-row').first()).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Read-only: views a managed role, asserts no edit/delete, navigates back.
|
||||
// Skipped when USE_FINE_GRAINED_AUTHZ is off — rows have no click handler.
|
||||
test('TC-03 role detail page — clicking a managed role navigates to its detail view', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!rolesEnabled,
|
||||
'PERSONA_SKIP: USE_FINE_GRAINED_AUTHZ feature flag is off — role rows are not clickable',
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
const table = page.locator('.roles-listing-table');
|
||||
|
||||
const firstRow = table.locator('.roles-table-row').first();
|
||||
await firstRow.scrollIntoViewIfNeeded();
|
||||
await firstRow.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings\/roles\/[^/]+/);
|
||||
|
||||
const detailPage = page.locator('.role-details-page');
|
||||
await expect(detailPage).toBeVisible();
|
||||
await expect(detailPage.locator('.role-details-title')).toBeVisible();
|
||||
await expect(detailPage.locator('.role-details-title')).toContainText(
|
||||
'Role —',
|
||||
);
|
||||
|
||||
await expect(
|
||||
detailPage.getByText(
|
||||
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
detailPage.getByRole('button', { name: 'Edit Role Details' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
detailPage.locator('.role-details-section-label', {
|
||||
hasText: 'Permissions',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(SETTINGS_ROUTES.ROLES);
|
||||
await expect(page.getByTestId('roles-settings')).toBeVisible();
|
||||
});
|
||||
});
|
||||
191
tests/e2e/tests/settings/service-accounts.spec.ts
Normal file
191
tests/e2e/tests/settings/service-accounts.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Service Accounts page. RISK MODE — READ-ONLY: never create/edit/delete an
|
||||
// account or generate a token; the create modal is never opened.
|
||||
// listAccessible probes the real authz/check backend state in beforeAll (when
|
||||
// use_fine_grained_authz is on the admin may lack serviceaccount:list, rendering
|
||||
// PermissionDeniedFullPage); the functional TCs skip when it is false.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let listAccessible = false;
|
||||
|
||||
async function gotoServiceAccounts(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.SERVICE_ACCOUNTS);
|
||||
await expect(page.locator('.sa-settings__title')).toBeVisible();
|
||||
}
|
||||
|
||||
function buildSkipReason(
|
||||
persona: Parameters<typeof personaSkipReason>[0],
|
||||
env: Parameters<typeof personaSkipReason>[1],
|
||||
): string | null {
|
||||
return personaSkipReason(persona, env, SETTINGS_ROUTES.SERVICE_ACCOUNTS);
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/features', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
|
||||
const fgAuthz = flags.find((f) => f?.name === 'use_fine_grained_authz');
|
||||
|
||||
if (!fgAuthz?.active) {
|
||||
// Without fine-grained authz the SA list is always accessible.
|
||||
listAccessible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the authz check endpoint for serviceaccount:list (wildcard).
|
||||
const authzRes = await page.request.post('/api/v1/authz/check', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: [
|
||||
{
|
||||
relation: 'list',
|
||||
object: {
|
||||
resource: { kind: 'serviceaccount', type: 'serviceaccount' },
|
||||
selector: '*',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const authzBody = await authzRes.json();
|
||||
const items: { authorized?: boolean }[] = authzBody?.data ?? [];
|
||||
listAccessible = items.some((i) => i?.authorized);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Settings — Service Accounts page', () => {
|
||||
test('TC-01 page chrome and empty-state render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
await expect(page.locator('.sa-settings__title')).toContainText(
|
||||
'Service Accounts',
|
||||
);
|
||||
await expect(page.locator('.sa-settings__subtitle')).toContainText(
|
||||
'Overview of service accounts added to this workspace.',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.sa-settings__subtitle a[href*="signoz.io/docs"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const controls = page.locator('.sa-settings__controls');
|
||||
await expect(controls).toBeVisible();
|
||||
await expect(
|
||||
controls.getByRole('button', { name: /All accounts/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
controls.locator('input[placeholder="Search by name or email..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
controls.getByRole('button', { name: /New Service Account/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('.sa-table-wrapper')).toBeVisible();
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No service accounts.',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 filter dropdown writes URL param and shows empty-state per mode', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
const filterTrigger = page.getByRole('button', { name: /All accounts/i });
|
||||
|
||||
await filterTrigger.click();
|
||||
await page.getByText(/^Active ⎯/).click();
|
||||
await expect(page).toHaveURL(/[?&]filter=active/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Active ⎯/i }).click();
|
||||
await page.getByText(/^Deleted ⎯/).click();
|
||||
await expect(page).toHaveURL(/[?&]filter=deleted/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Deleted ⎯/i }).click();
|
||||
await page.getByText(/^All accounts ⎯/).click();
|
||||
await expect(page).not.toHaveURL(/[?&]filter=active/);
|
||||
await expect(page).not.toHaveURL(/[?&]filter=deleted/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 search updates URL and empty-state; create button enabled', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
const searchInput = page.locator(
|
||||
'input[placeholder="Search by name or email..."]',
|
||||
);
|
||||
|
||||
await searchInput.fill('xyznonexistent999');
|
||||
await expect(page).toHaveURL(/[?&]search=xyznonexistent999/);
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No results for',
|
||||
);
|
||||
await expect(page.locator('.sa-empty-state__text strong')).toContainText(
|
||||
'xyznonexistent999',
|
||||
);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(page).not.toHaveURL(/[?&]search=xyznonexistent999/);
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No service accounts.',
|
||||
);
|
||||
|
||||
const createBtn = page.getByRole('button', { name: /New Service Account/i });
|
||||
await expect(createBtn).toBeVisible();
|
||||
await expect(createBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
125
tests/e2e/tests/settings/shell.spec.ts
Normal file
125
tests/e2e/tests/settings/shell.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { Persona, SettingsEnv } from '../../helpers/persona';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
registeredRoutes,
|
||||
visibleNavItems,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import {
|
||||
NAV_TESTID,
|
||||
SETTINGS_ROUTES,
|
||||
gotoSettings,
|
||||
} from '../../helpers/settings';
|
||||
|
||||
// Branching lives in module-level helpers, not test bodies — the repo's
|
||||
// playwright/no-conditional-in-test rule forbids `if` inside `test()`.
|
||||
|
||||
function partitionNavTestids(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
): { visible: string[]; hidden: string[] } {
|
||||
const all = Object.values(NAV_TESTID);
|
||||
const expected = visibleNavItems(persona, env);
|
||||
return {
|
||||
visible: all.filter((testid) => expected.has(testid)),
|
||||
hidden: all.filter((testid) => !expected.has(testid)),
|
||||
};
|
||||
}
|
||||
|
||||
// Visible nav items whose /settings route is not registered (mounted).
|
||||
// INTEGRATIONS is excluded — it is a top-level page, not a RouteTab route.
|
||||
function navRouteMismatches(persona: Persona, env: SettingsEnv): string[] {
|
||||
const visible = visibleNavItems(persona, env);
|
||||
const registered = registeredRoutes(persona, env);
|
||||
const routeByTestid = Object.fromEntries(
|
||||
Object.entries(NAV_TESTID).map(([route, testid]) => [testid, route]),
|
||||
);
|
||||
return [...visible]
|
||||
.map((testid) => routeByTestid[testid])
|
||||
.filter((route) => !!route && route !== SETTINGS_ROUTES.INTEGRATIONS)
|
||||
.filter((route) => !registered.has(route))
|
||||
.map((route) => `${route} is nav-visible but route not registered`);
|
||||
}
|
||||
|
||||
test.describe('Settings — shell, gating matrix & integrity', () => {
|
||||
test('TC-01 settings shell chrome renders with no JS pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoSettings(page);
|
||||
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 sidenav shows exactly the matrix-predicted items', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
await gotoSettings(page);
|
||||
const sidenav = page.getByTestId('settings-page-sidenav');
|
||||
const { visible, hidden } = partitionNavTestids(persona, env);
|
||||
|
||||
for (const testid of visible) {
|
||||
await expect(
|
||||
sidenav.getByTestId(testid),
|
||||
`${testid} should be visible`,
|
||||
).toBeVisible();
|
||||
}
|
||||
for (const testid of hidden) {
|
||||
await expect(
|
||||
sidenav.getByTestId(testid),
|
||||
`${testid} should be hidden`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 every registered route deep-links with no JS pageerror', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
const routes = [...registeredRoutes(persona, env)];
|
||||
for (const route of routes) {
|
||||
const errors: Error[] = [];
|
||||
const onError = (err: Error): void => {
|
||||
errors.push(err);
|
||||
};
|
||||
page.on('pageerror', onError);
|
||||
await page.goto(route);
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
page.off('pageerror', onError);
|
||||
expect(
|
||||
errors,
|
||||
`pageerror on ${route}: ${errors.map((e) => e.message).join('\n')}`,
|
||||
).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 every visible nav item resolves to a registered route', async ({
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
const mismatches = navRouteMismatches(persona, env);
|
||||
expect(mismatches, mismatches.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-05 clicking a nav item navigates and marks active', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!visibleNavItems(persona, env).has('account'),
|
||||
'PERSONA_SKIP: account nav hidden',
|
||||
);
|
||||
await gotoSettings(page);
|
||||
const sidenav = page.getByTestId('settings-page-sidenav');
|
||||
await sidenav.getByTestId('account').click();
|
||||
await expect(page).toHaveURL(/\/settings\/my-settings/);
|
||||
});
|
||||
});
|
||||
69
tests/e2e/tests/settings/shortcuts.spec.ts
Normal file
69
tests/e2e/tests/settings/shortcuts.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Keyboard Shortcuts — static read-only page (RISK MODE: nothing mutated).
|
||||
// No testids here, so locators are CSS classes (.keyboard-shortcuts,
|
||||
// .shortcut-section-heading) and role/text.
|
||||
|
||||
const ROUTE = SETTINGS_ROUTES.SHORTCUTS;
|
||||
|
||||
async function gotoShortcuts(page: Page): Promise<void> {
|
||||
await page.goto(ROUTE);
|
||||
await expect(page.locator('.keyboard-shortcuts')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Settings — Keyboard Shortcuts page', () => {
|
||||
test('TC-01 shortcuts page renders all four grouped sections with entries', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, ROUTE),
|
||||
personaSkipReason(persona, env, ROUTE) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoShortcuts(page);
|
||||
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('settings-page-sidenav').getByTestId('keyboard-shortcuts'),
|
||||
).toBeVisible();
|
||||
|
||||
const sections = page.locator('.shortcut-section-heading');
|
||||
await expect(sections).toHaveCount(4);
|
||||
await expect(sections.nth(0)).toHaveText('Global Shortcuts');
|
||||
await expect(sections.nth(1)).toHaveText('Logs Explorer Shortcuts');
|
||||
await expect(sections.nth(2)).toHaveText('Query Builder Shortcuts');
|
||||
await expect(sections.nth(3)).toHaveText('Dashboard Shortcuts');
|
||||
|
||||
await expect(page.locator('.shortcut-section-table')).toHaveCount(4);
|
||||
|
||||
const firstTable = page.locator('.shortcut-section-table').first();
|
||||
await expect(
|
||||
firstTable.getByRole('columnheader', { name: 'Keyboard Shortcut' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
firstTable.getByRole('columnheader', { name: 'Description' }),
|
||||
).toBeVisible();
|
||||
|
||||
// "shift+d" chosen as it is stable across OS variants (no cmd/ctrl).
|
||||
const globalTable = page.locator('.shortcut-section-table').nth(0);
|
||||
await expect(
|
||||
globalTable.getByRole('cell', { name: 'shift+d' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
globalTable.getByRole('cell', { name: 'Navigate to Dashboards List' }),
|
||||
).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const table = page.locator('.shortcut-section-table').nth(i);
|
||||
await expect(table.locator('tbody tr').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user